Askitect의 SPARQL 루프를 Datalog로 바꾸기
by Justin Kim
지난번 Askitect 프로토타입 글에서는 LLM이 사용자의 자연어를 RDF 트리플로 바꾸고, 그 중간 상태를 SPARQL로 검사하는 구조를 구현했습니다. 사용자가 “친구랑 도쿄 가려고. 맛집이 제일 중요해.”라고 말하면 LLM은 hasDestination, hasCompanionType, hasPriority를 추출하고, SPARQL은 hasDuration과 hasBudget이 빠졌다는 사실을 찾아냈습니다.
그 글의 핵심은 LLM을 추론 엔진으로 쓰지 않는다는 점이었습니다. LLM은 자연어와 구조화된 데이터 사이를 오가는 번역가이고, “무엇이 빠졌는가”를 판단하는 일은 심볼릭 엔진이 맡았습니다. 당시에는 그 심볼릭 레이어를 RDF 그래프와 SPARQL로 구현했습니다.
이번 글에서는 같은 Askitect 루프를 pyrewire로 다시 구현해보려 합니다. 결론부터 말하면, 이 예제의 SPARQL은 Datalog로 충분히 대체할 수 있습니다. 더 정확히 말하면, Askitect가 필요로 하는 것은 “그래프 표준” 자체라기보다 필수 조건과 현재 사실을 비교해 새 사실을 도출하는 작은 규칙 엔진입니다.
바꾸려는 부분
기존 구조는 다음과 같았습니다.
graph LR
User(("User")) -->|자연어| A["Semantic Parser<br/>(LLM)"]
A -->|Turtle| B[("Knowledge Graph<br/>(rdflib)")]
B -->|SPARQL| C{"Completeness<br/>Check"}
C -->|Missing| D["Question Generator<br/>(LLM)"]
C -->|Complete| E["Execute"]
D -->|질문| User
이번에는 가운데만 바꿉니다.
graph LR
User(("User")) -->|자연어| A["Semantic Parser<br/>(LLM)"]
A -->|Python facts| B[("Datalog Facts<br/>(pyrewire)")]
B -->|Rules| C{"Completeness<br/>Check"}
C -->|Missing| D["Question Generator<br/>(LLM)"]
C -->|Complete| E["Execute"]
D -->|질문| User
LLM의 역할은 그대로입니다. 자연어에서 구조를 뽑고, 빠진 속성 목록을 자연스러운 질문으로 바꿉니다. 달라지는 것은 상태 표현과 검증 방식입니다. RDF 트리플과 SPARQL 대신, Datalog의 사실(fact)과 규칙(rule)을 씁니다.
SPARQL 쿼리가 하던 일
먼저 기존 글의 완성도 검사를 다시 보겠습니다.
def check_completeness(ontology: Graph, instance: Graph) -> list[dict]:
merged = ontology + instance
query = """
PREFIX ex: <http://example.org/travel/>
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
SELECT ?prop ?label WHERE {
?prop rdfs:domain ex:TripPlan .
?prop rdfs:label ?label .
FILTER(lang(?label) = "ko")
FILTER NOT EXISTS {
ex:Request_001 ?prop ?value .
}
}
"""
results = merged.query(query)
return [{"property": str(row.prop), "label": str(row.label)}
for row in results]
여기서 실제로 필요한 논리는 단순합니다.
- 어떤 속성은 여행 계획에 필수다.
- 현재 요청에는 어떤 속성이 이미 들어 있다.
- 필수인데 현재 요청에 없으면, 그것은 빠진 속성이다.
SPARQL에서는 이 세 번째 문장을 FILTER NOT EXISTS로 표현했습니다. Datalog에서는 같은 생각을 missing이라는 새 사실을 도출하는 규칙으로 표현할 수 있습니다.
스키마를 사실로 바꾸기
RDF 버전에서는 rdfs:domain과 rdfs:label을 온톨로지 그래프에 넣었습니다. Datalog 버전에서는 그것을 더 직접적인 사실로 둡니다.
.decl request(req: symbol)
.decl required(prop: symbol)
.decl property_label(prop: symbol, label: symbol)
.decl present(req: symbol, prop: symbol, value: symbol)
각 관계의 뜻은 이렇습니다.
| 관계 | 의미 |
|---|---|
request(req) |
하나의 여행 계획 요청이 존재한다 |
required(prop) |
모든 여행 계획에 필요한 속성이다 |
property_label(prop, label) |
속성의 사용자 표시 이름이다 |
present(req, prop, value) |
현재 요청에 특정 속성 값이 들어 있다 |
예를 들어 여행지, 동행인, 우선순위, 기간, 예산이 필수라면 Python에서는 이렇게 넣습니다.
REQUIRED = [
("hasDestination", "여행지"),
("hasCompanionType", "동행인 유형"),
("hasPriority", "여행 우선순위"),
("hasDuration", "여행 기간"),
("hasBudget", "예산 범위"),
]
RDF의 URI 대신 짧은 심볼을 썼습니다. 이 예제에서 중요한 것은 RDF 네임스페이스가 아니라, 속성 이름이 안정적인 식별자로 유지된다는 점입니다. 필요하다면 "http://example.org/travel/hasBudget"처럼 전체 URI를 그대로 symbol 값으로 넣어도 됩니다.
Datalog로 누락 정보 찾기
이제 규칙을 작성합니다.
.decl required_for(req: symbol, prop: symbol, label: symbol)
.decl present_prop(req: symbol, prop: symbol)
.decl missing(req: symbol, prop: symbol, label: symbol)
required_for(R, P, L) :- request(R), required(P), property_label(P, L).
present_prop(R, P) :- present(R, P, V).
missing(R, P, L) :- required_for(R, P, L), !present_prop(R, P).
핵심은 마지막 줄입니다.
missing(R, P, L) :- required_for(R, P, L), !present_prop(R, P).
뜻은 거의 그대로 읽힙니다. 어떤 요청 R에 대해 속성 P가 필요하고, 그 속성의 표시 이름이 L인데, 현재 present_prop(R, P)가 없다면 missing(R, P, L)을 도출합니다.
여기서 present_prop를 따로 둔 이유가 있습니다. present(R, P, V)를 직접 부정하면 V가 부정 항에만 등장합니다.
missing(R, P, L) :- required_for(R, P, L), !present(R, P, V).
이런 형태는 Datalog 관점에서 안전하지 않은 규칙이 되기 쉽습니다. 그래서 먼저 “값이 무엇이든 그 속성이 존재한다”는 present_prop(R, P)로 투영한 뒤, 그 관계를 부정합니다. 실무적으로도 이쪽이 더 읽기 쉽습니다. Askitect가 궁금한 것은 값 자체가 아니라, 그 속성이 채워졌는지 여부이기 때문입니다.
참고로 wirelog의 부정 문법은 not rel(...)이 아니라 !rel(...)입니다. 흔히 Datalog 설명에서는 not을 쓰지만, 현재 wirelog 0.51.0의 문법 문서와 실제 파서는 !Rel(x)를 사용합니다.
pyrewire 코드
이제 Python에서 이 규칙을 실행합니다. 여기서는 이전 노트 그래프 글과 마찬가지로 EasySession을 씁니다.
from pyrewire import EasySession
DL_SRC = """
.decl request(req: symbol)
.decl required(prop: symbol)
.decl property_label(prop: symbol, label: symbol)
.decl present(req: symbol, prop: symbol, value: symbol)
.decl required_for(req: symbol, prop: symbol, label: symbol)
.decl present_prop(req: symbol, prop: symbol)
.decl missing(req: symbol, prop: symbol, label: symbol)
required_for(R, P, L) :- request(R), required(P), property_label(P, L).
present_prop(R, P) :- present(R, P, V).
missing(R, P, L) :- required_for(R, P, L), !present_prop(R, P).
"""
REQUIRED = [
("hasDestination", "여행지"),
("hasCompanionType", "동행인 유형"),
("hasPriority", "여행 우선순위"),
("hasDuration", "여행 기간"),
("hasBudget", "예산 범위"),
]
def check_completeness(facts: list[tuple[str, str]]) -> list[dict]:
req = "Request_001"
with EasySession(DL_SRC) as s:
s.insert("request", [req])
for prop, label in REQUIRED:
s.insert("required", [prop])
s.insert("property_label", [prop, label])
for prop, value in facts:
s.insert("present", [req, prop, value])
rows = s.snapshot("missing")
return [
{"request": r, "property": p, "label": label}
for r, p, label in sorted(rows)
]
facts는 LLM 파서가 뽑아낸 현재 요청의 속성 목록입니다. 예를 들어 사용자가 “친구랑 도쿄 가려고. 맛집이 제일 중요해.”라고 말하면, 파서는 다음 정도의 구조를 반환한다고 가정합니다.
facts = [
("hasDestination", "Tokyo"),
("hasCompanionType", "Friend"),
("hasPriority", "Gastronomy"),
]
이 값을 넣으면 Datalog 엔진은 다음 결과를 돌려줍니다.
missing = check_completeness(facts)
print(missing)
출력은 이렇습니다.
[
{'request': 'Request_001', 'property': 'hasBudget', 'label': '예산 범위'},
{'request': 'Request_001', 'property': 'hasDuration', 'label': '여행 기간'}
]
SPARQL 버전에서 FILTER NOT EXISTS가 반환하던 결과와 같은 의미입니다. 다만 이번에는 “쿼리 결과”라기보다, missing이라는 새 사실이 규칙에 의해 도출된 것입니다.
LLM 파서는 꼭 RDF를 만들 필요가 없다
기존 구현에서는 LLM이 Turtle을 생성했습니다.
@prefix ex: <http://example.org/travel/> .
ex:Request_001 a ex:TripPlan ;
ex:hasDestination "Tokyo" ;
ex:hasCompanionType "Friend" ;
ex:hasPriority "Gastronomy" .
Datalog 버전에서는 LLM이 굳이 RDF 문법을 만들 필요가 없습니다. 애플리케이션 내부에서 필요한 것은 결국 속성-값 쌍입니다. 구조화 출력으로 다음과 같은 JSON을 받는 편이 더 단순합니다.
{
"request_id": "Request_001",
"facts": [
{"property": "hasDestination", "value": "Tokyo"},
{"property": "hasCompanionType", "value": "Friend"},
{"property": "hasPriority", "value": "Gastronomy"}
]
}
그러면 Python 쪽에서는 이 JSON을 present 사실로 넣기만 하면 됩니다.
def facts_from_llm(parsed: dict) -> list[tuple[str, str]]:
return [
(item["property"], item["value"])
for item in parsed["facts"]
]
이 변화는 작지만 중요합니다. RDF를 쓰면 표준 도구와 연결하기 쉽고, 외부 지식 그래프와 합치기 좋습니다. 반면 내부 대화 상태를 검증하는 작은 루프라면, RDF 직렬화와 파싱을 매 턴 거칠 필요가 없을 수 있습니다. Datalog는 이 중간 표현을 더 얇게 만들어줍니다.
조건부 질문도 규칙으로 밀어 넣기
SPARQL 버전의 프로토타입은 모든 필수 속성을 동등하게 다뤘습니다. 하지만 실제 대화에서는 조건부 요구사항이 자주 생깁니다.
예를 들어 해외여행이면 여권 여부를 물어봐야 하지만, 국내여행이면 필요하지 않습니다. 이런 규칙은 Datalog에서 자연스럽게 추가할 수 있습니다.
.decl trip_kind(req: symbol, kind: symbol)
.decl requires_for_trip(kind: symbol, prop: symbol)
required_for(R, P, L) :- request(R), required(P), property_label(P, L).
required_for(R, P, L) :-
trip_kind(R, K),
requires_for_trip(K, P),
property_label(P, L).
Python에서는 조건부 요구사항을 사실로 넣습니다.
s.insert("property_label", ["hasPassport", "여권 여부"])
s.insert("trip_kind", ["Request_001", "international"])
s.insert("requires_for_trip", ["international", "hasPassport"])
그러면 hasPassport는 모든 여행에 필요한 속성은 아니지만, international 여행에는 필요한 속성이 됩니다. 이후 missing 규칙은 그대로입니다. 필요한 속성의 집합이 커졌을 뿐, 누락을 찾는 방식은 바뀌지 않습니다.
이 지점이 Datalog가 SPARQL보다 편하게 느껴지는 부분입니다. SPARQL로도 당연히 표현할 수 있습니다. 하지만 규칙이 늘어날수록 “조회 쿼리” 안에 조건을 계속 덧붙이는 느낌이 강해집니다. Datalog에서는 요구사항 자체를 required_for라는 관계로 먼저 도출하고, 그 다음 missing을 도출합니다. 단계가 이름을 갖기 때문에 읽는 사람이 중간 개념을 붙잡기 쉽습니다.
순환 루프에 끼워 넣기
전체 Askitect 루프는 크게 달라지지 않습니다.
def run_askitect():
print("Askitect: 여행 계획을 도와드릴게요. 어디로 가고 싶으세요?")
accumulated: dict[str, str] = {}
while True:
user_input = input("User: ")
if not user_input:
break
parsed = parse_to_structured_facts(user_input)
for item in parsed["facts"]:
accumulated[item["property"]] = item["value"]
facts = list(accumulated.items())
missing = check_completeness(facts)
if not missing:
print("Askitect: 필요한 정보가 모두 모였습니다!")
break
question = generate_question(missing, context=user_input)
print(f"Askitect: {question}")
여기서 parse_to_structured_facts와 generate_question은 여전히 LLM 호출입니다. 하지만 check_completeness는 LLM과 무관합니다. 같은 입력 사실을 넣으면 같은 missing 결과가 나옵니다.
대화 예시는 기존과 거의 같습니다.
Askitect: 여행 계획을 도와드릴게요. 어디로 가고 싶으세요?
User: 친구랑 도쿄 가려고. 맛집이 제일 중요해.
Askitect: 도쿄 맛집 여행이라니 좋네요. 며칠 정도 다녀오실 계획이세요?
그리고 대략적인 예산 범위도 알려주세요.
User: 3박 4일이고 인당 100만원 정도.
Askitect: 필요한 정보가 모두 모였습니다!
차이는 사용자에게 보이지 않습니다. 내부에서 누락 정보를 판단하는 엔진이 SPARQL에서 Datalog로 바뀌었을 뿐입니다.
SPARQL을 버리자는 이야기는 아니다
이 글의 목적은 SPARQL을 대체 불가능한 표준에서 끌어내리자는 것이 아닙니다. RDF 그래프를 외부와 교환해야 하거나, 이미 온톨로지와 시맨틱 웹 도구 체인 위에서 시스템을 만들고 있다면 SPARQL은 여전히 자연스러운 선택입니다.
다만 Askitect 프로토타입에서 SPARQL이 맡았던 역할은 생각보다 좁았습니다. 그것은 거대한 지식 그래프 질의가 아니라, 현재 대화 상태가 스키마의 요구사항을 만족하는지 확인하는 일이었습니다. 이 정도 범위에서는 Datalog가 더 작고 직접적인 표현을 제공합니다.
비교하면 이렇습니다.
| 관점 | SPARQL 버전 | Datalog 버전 |
|---|---|---|
| 상태 표현 | RDF 트리플 그래프 | 관계형 fact |
| 누락 검사 | FILTER NOT EXISTS |
missing 규칙 |
| 중간 개념 | 쿼리 안의 패턴 | 이름 붙은 관계 |
| 외부 표준 연계 | 강함 | 약함 |
| 애플리케이션 임베딩 | RDF 파싱 필요 | Python 값 직접 삽입 |
Askitect를 지식 그래프 플랫폼의 일부로 만들고 싶다면 RDF/SPARQL이 좋은 출발점입니다. 반대로 하나의 애플리케이션 안에서 대화 상태를 빠르게 검증하고 싶다면, pyrewire 같은 임베디드 Datalog 엔진이 더 가벼운 선택지가 될 수 있습니다.
마치며
이번 실험에서 확인한 것은 Askitect의 핵심이 특정 표준 문법에 묶여 있지 않다는 점입니다. 중요한 것은 역할의 분리입니다.
LLM은 자연어를 구조화된 사실로 번역합니다. Datalog는 그 사실과 규칙을 결합해 missing 같은 새 사실을 도출합니다. 다시 LLM은 그 결과를 사용자에게 자연스러운 질문으로 바꿉니다.
지난 글에서는 이 구조를 RDF와 SPARQL로 구현했습니다. 이번에는 pyrewire와 Datalog로 같은 구조를 다시 만들었습니다. 두 구현은 표면 문법은 다르지만, 아키텍처의 원칙은 같습니다. 확률적 모델에게 언어의 유연함을 맡기고, 심볼릭 엔진에게 판단의 일관성을 맡기는 것. Askitect에서 중요한 것은 바로 그 경계입니다.
더 알아보기
- pyrewire:
pip install pyrewire(Python 3.11+) - wirelog: semantic-reasoning/wirelog
- 관련 이슈: Parser accepts unsafe variables in negated body atoms
관련 글
Subscribe via RSS
Comments