티스토리 뷰

AI 기반 챗봇의 보편화로 사용자는 자연어로 묻는게 당연해졌다. 하지만 실제 뒤에서 데이터를 꺼내올때는 그래프는 SPARQL로 답한다. 그 사이를 LLM이 채우기 위해  text2SPARQL(LPG라면 text2Cypher)을 사용한다.

다만, text2sparql은 생각보다 자주 틀리고, 문법이 틀리면 차라리 실행이 실패하니까 틀린 걸 알 수 있다.

진짜 문제는 문법은 멀쩡한데 의미가 틀린 쿼리다. 실행되고, 결과도 나오고, 그 결과가 답변에 들어가기 때문에 검증이 어렵다.

1. 어떻게 틀리는가

직접 겪거나 관찰한 실패를 유형별로 나눠보면 대략 이렇다.

  • 존재하지 않는 속성을 지어낸다. 스키마에는 :worksIn뿐인데 :employedBy, :memberOf를 만들어 쓴다. 그래프에 그런 술어가 없으니 결과는 나오지 않는다. 그리고 LLM은 빈 결과를 받으면 "해당 정보가 없습니다"라고 답하거나, 할루시네이션으로 메꾼다
  • domain/range를 무시한다. :manages의 주어는 Person이어야 하는데 Project를 주어로 놓는다. 역시 빈 결과이거나, 스키마가 느슨한 그래프라면 잘못 들어간 데이터와 결합해 그럴듯한 오답이 나온다
  • 방향을 뒤집는다. ?a :supervises ?b와 ?b :supervises ?a는 정반대인데, 자연어에는 이 방향이 흐릿하게 담겨 있어 LLM이 자주 헷갈린다
  • 레이블과 URI를 혼동한다. "삼성전자"라는 문자열로 FILTER를 거는데 그래프에는 URI와 다국어 레이블로 저장되어 있어 못 찾는다

 

결국 스키마를 몰라서 생기는 문제이다. 그래서 처음 드는 생각은 당연히 "스키마를 프롬프트에 넣어주면 되잖아?"이고, 실제로 어느정도는 해결이 된다.

 

2. 1단계: 스키마 컨텍스트 주입

하지만, 온톨로지 전체를 프롬프트에 넣는 건 조금만 규모가 커져도 불가능하다(수백 클래스, 수천 속성). 그래서 요즘 연구들이 수렴하는 패턴은 대략 이런 순서다.

  1. 질문을 임베딩해서 관련 클래스부터 식별한다
  2. 식별된 클래스에 붙은 속성들을 가져온다 (domain/range 시그니처 포함)
  3. 거기서 1~2홉 이웃 클래스·속성까지 후보를 확장한다
  4. 이렇게 추린 "스키마 조각"만 프롬프트에 넣고 SPARQL을 생성시킨다

클래스를 먼저 잡고 → 속성을 찾고 → 몇 홉을 넓혀가는 이 흐름은 사람이 낯선 스키마를 파악하는 순서와도 비슷하다. 이것만으로도 지어낸 속성 문제는 크게 줄어든다.

 

 

그런데 주입만으로는 부족하다. 스키마를 줘도 LLM은 여전히 방향을 뒤집고 타입을 어긴다. LLM 자체에 대한 한계점이 있어서 "잘 생성하게 만드는 것"과 별개로 "틀리게 생성한 걸 잡아내는 장치"가 필요하다.

3. 2단계: 실행 전 검사하기

여기서 온톨로지의 장점이 나온다. 온톨로지에는 domain/range가 기계가 읽을 수 있는 형태로 선언되어 있다. 그 말은, 생성된 SPARQL을 실행하기 전에 정적으로 검사할 수 있다는 뜻이다. 컴파일러의 타입 체커가 하는 일과 같다.

검사 로직 자체는 복잡하지 않다. rdflib로 뼈대만 그려보면:

from rdflib import Graph, RDFS
from rdflib.plugins.sparql import prepareQuery

onto = Graph().parse("company.ttl")

def check_triple_pattern(subj_type, pred, obj_type):
    """생성된 쿼리의 트리플 패턴 (?s :pred ?o)에 대해
    ?s의 추정 타입이 pred의 domain과 호환되는지 검사"""
    domains = set(onto.objects(pred, RDFS.domain))
    if domains and not any(is_subclass(subj_type, d) for d in domains):
        return f"위반: {pred}의 domain은 {domains}인데 주어 타입이 {subj_type}"
    # range 검사도 동일한 구조
    return None

실제로는 쿼리를 파싱해 트리플 패턴별로 변수의 타입을 추론하는 과정이 붙지만, 핵심은 하나다. domain이 Person인 속성에 맞지 않는 변수가 주어로 오면, 실행하기 전에 거부한다.

거부하고 끝이 아니라, 위반 내용을 LLM에게 피드백으로 돌려주도록 추가 작업을 할 수도 있다.

[검사기] 오류: :manages의 domain은 :Person입니다.
현재 쿼리에서 주어 ?p는 :Project로 추론됩니다.
방향이 뒤집혔을 가능성이 있습니다. 쿼리를 수정하세요.

이 self-correction 루프를 2~3회 돌리면 쿼리가 실패하고 스스로 고치는 쿼리가 된다. 

 

4. 최근 연구들은 어디까지 왔나

지금까지 정리한 "스키마를 선별해서 넣고, 실행 전에 검증하고, 위반을 피드백한다"는 생각이 나 혼자만의 생각인지 확인하려고 최근 연구를 좀 찾아봤다. 결론부터 말하면, 이 구조는 이미 여러 팀이 수렴하고 있는 패턴에 가깝다. 다만 세부에서 배울 점이 많았다.

 

SPARQL-LLM (2025). 스위스 생물정보학연구소(SIB)에서 나온 최근 논문인데, 가설을 실제 프로덕션(expasy.org/chat)에 올려서 체계적으로 평가했다. 흐름을 요약하면 질문을 하위 질문으로 분해 → 관련 예시 쿼리와 스키마 클래스를 임베딩 검색으로 회수 → 프롬프트 구성 → SPARQL 생성 → 스키마 기반 검증·수정을 최대 3회 반복 → 실행 → 결과 해석이다.

 

- 논문 리뷰: https://chaeeunsong.tistory.com/115

 

생성된 쿼리를 파싱해 트리플 패턴을 뽑고, 각 패턴이 스키마와 맞는지 확인해서, 안 맞으면 어떤 클래스·속성이 틀렸는지와 가능한 대안 목록까지 담은 사람이 읽을 수 있는 오류 메시지를 만들어 LLM에 되먹인다.

 

배운 점이 세 가지 있었다.

  • 스키마 전체보다 자주 쓰이는 일부가 낫다. DBpedia에서는 빈도 상위 25%의 스키마 조각만 넣었을 때 성능이 가장 좋았다. 이유가 재미있는데, 같은 이름의 클래스가 여러 네임스페이스(dbo:Person, schema:Person, foaf:Person)에 흩어져 있으면 LLM이 어느 걸 쓸지 헷갈리기 때문이다. 스키마를 많이 준다고 좋은 게 아니라, 회수를 잘 해서 적게 주는 게 중요하다는 이야기다
  • 예시 쿼리가 스키마보다 효과가 컸다. 이 시스템의 구성요소를 하나씩 떼보는 절제 실험에서 중요도 순서는 예시 쿼리 > 스키마 클래스 정보 > 검증기였다. 특히 사람이 직접 만든 실제 예시가 LLM이 생성한 예시보다 효과적이었다. 3절에서 나는 스키마 주입만 이야기했는데, 여기에 좋은 예시 쿼리 몇 개(5~10개면 충분)를 얹는 게 실전에서는 더 큰 지렛대인 셈이다
  • 검증기를 떼면 성능이 떨어진다. 절제 실험에서 검증 컴포넌트를 제거했을 때 성능 하락이 확인됐다. 실행 전 검증이 장식이 아니라 실제로 기여한다는 방증이다

흥미로운 건 이들이 domain/range를 무거운 OWL 추론으로 다루지 않고, 각 클래스마다 "이 클래스에는 이런 속성들이 있고 그 속성은 이런 클래스/데이터타입을 가리킨다"를 요약한 경량 스키마(ShEx 형태)로 표현했다는 점이다.

 

엔드포인트에서 VoID 통계를 뽑아 자동 생성하기 때문에, 변화가 잦은 데이터에도 스키마 기술을 최신으로 유지할 수 있다. 

 

 

text2Cypher 연구

LPG 연구도 비슷하다. Neo4j가 공개한 Text2Cypher 데이터셋과 후속 연구들(Cypher-Bench, Mind the Query 등)이 공통적으로 강조하는 건 스키마 그라운딩, 실행 기반 검증, 복잡도를 고려한 평가다. Neo4j의 GraphRAG 문서조차 text2Cypher 패턴을 두고 "가장 유연하지만 가장 불안정하다"고 솔직하게 적어두고 있고, 개선책으로 강화된 스키마 제공과 풀텍스트 인덱스 검색을 든다. 방향이 SPARQL 쪽과 유사하다.

 

즉, 스키마를 잘 주고, 실행으로 검증하라는 것. 최근에는 추론 비용을 줄이려고 프롬프트에 넣을 스키마를 미리 걸러내는 스키마 필터링 같은 최적화까지 나오고 있다.

 

쿼리를 만드는 접근은 크게 두 갈래다.

하나는 LLM이 search_entity, search_examples 같은 도구를 반복 호출하며 그래프를 탐색해 쿼리를 조립하는 에이전트형(예: GRASP, MetaboT)이고, 다른 하나는 필요한 맥락을 미리 임베딩으로 회수해 한두 번의 LLM 호출로 끝내는 방식(SPARQL-LLM)이다.

 

5. 그래서 text2SQL과 뭐가 다른가

여기까지 보면 "text2SQL에 스키마 넣어주는 거랑 뭐가 달라?"라는 질문이 나온다. 나도 이 답을 계속 고민했는데, 온톨로지의 특성을 상속과 전이라는 개념 너무 당연해져서, 이게 장점이라고 설명해야 한다는 사실 자체를 잊게 되는 것 같다.

 

정리하자면

첫째, 검사의 근거가 데이터 정의에 내장되어 있다. SQL 스키마의 타입은 INT, VARCHAR 수준이다. "manages의 주어는 사람이어야 한다"는 의미 수준의 제약은 스키마 바깥(문서나 코드)에 있어서 기계 검사가 안 된다. 만약 제약조건을 주더라도 결국 헤비해지는 순간 관리가 필요해진다. 이와 다르게, 온톨로지는 그 제약이 domain/range 공리로 데이터 정의 안에 들어 있다.

 

둘째, 상속이 쿼리를 줄인다. :Employee ⊑ :Person이 선언되어 있으면 Person을 묻는 쿼리가 Employee를 자동으로 포함한다. SQL이라면 UNION이나 뷰로 일일이 풀어야 하는 일이다.

 

셋째, 전이 속성과 역속성이 홉을 줄인다. :partOf가 전이 속성으로 선언되어 있으면 "이 부품이 속한 최상위 조립품"을 재귀 CTE 없이 :partOf+ 한 줄로 묻는다. :manages의 역속성 :managedBy가 선언되어 있으면 방향 뒤집기 실수 자체를 추론기가 흡수해준다.

 

넷째, 스키마가 표준 어휘로 공유된다. 조직마다 다른 테이블명 대신 공용 온톨로지 어휘를 쓰면, 한 번 만든 쿼리 생성·검사 로직이 도메인을 넘어 재사용된다.

 

RDF/OWL에서는 스키마 자체의 표현력으로 상당 부분 해결되는 것이 있지만, 표현력 있는 스키마를 만드는 비용과 직접 도메인 전문가가 고민해야한다는 점은 고스란히 남아 있다.

6. 한계

타입 검사가 잡아주는 건 타입 위반까지다. 타입은 맞는데 의도와 다른 쿼리(예를 들어 "작년 매출"을 물었는데 올해 조건으로 필터하는 쿼리)는 통과시킨다. 값 수준의 오류는 이 단계에서 못 잡는다.

 

앞서 본 SPARQL-LLM의 오류 분석에서도 남은 오류의 상당수가 잘못된 술어·클래스 선택(같은 개념의 여러 네임스페이스 중 엉뚱한 걸 고르는 것)이나 잘못된 문자 인코딩처럼, 타입은 맞지만 값이 어긋나는 종류였다.

결국 스키마의 품질이 중요한 것이라 domain/range가 부실하게 선언된 온톨로지에서는 검사기가 걸러낼 수 있는 것도 줄어든다. 

 

그리고 지금까지는 "쿼리 한 방"으로 답이 나오는 경우만 다뤘다. 실제 질문은 다중 홉 탐색이 필요한 경우가 많다..

탐색 과정 자체를 온톨로지가 통제할 수 있도록 SHACL을 적용하는 것이 다음 편이다.

반응형
반응형
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2026/07   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30 31
글 보관함