지난 글 Askitect를 위한 청사진: RDF로 대화 모델링하기에서 저는 LLM과 지식 그래프를 결합한 뉴로-심볼릭 대화 아키텍처의 설계도를 그려보았습니다. 사용자의 자연어를 RDF 트리플로 변환하고, 심볼릭 엔진이 누락된 정보를 감지하며, 다시 LLM이 자연스러운 질문을 생성하는 순환 루프였습니다.

설계는 제시했지만 구현체는 제시하지 못하고 있었죠. “실제로 동작하는가?”라는 질문에 답하기 위해, 이번 글을 작성하게 되었습니다. Python의 rdflib과 LLM API를 사용하여, 여행 계획 도메인에서 Askitect의 핵심 루프를 실제로 구현한 과정을 공유합니다.

1. 청사진을 코드로 읽기

구현에 들어가기 전에, 청사진에서 그렸던 아키텍처를 다시 꺼내봅니다.

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

이 다이어그램의 각 블록이 하나의 Python 함수에 대응됩니다. LLM이 두 번 등장하지만 역할은 전혀 다릅니다. 왼쪽의 LLM은 의미론적 파서(Semantic Parser), 오른쪽의 LLM은 자연어 생성기(NLG)입니다. 그리고 그 사이에서 SPARQL이 논리적 판단을 담당합니다. 확률과 논리가 톱니바퀴처럼 맞물리는 이 구조를, 이제 코드로 옮겨봅니다.

2. 스키마: 기계가 읽는 여행 계획의 조건

모든 것은 온톨로지에서 시작됩니다. “여행 계획이란 무엇인가?”를 기계가 판단할 수 있으려면, 먼저 그 기준이 코드로 존재해야 합니다.

from rdflib import Graph, Namespace, Literal, RDF, RDFS

EX = Namespace("http://example.org/travel/")

def build_ontology() -> Graph:
    g = Graph()
    g.bind("ex", EX)

    required_properties = [
        (EX.hasDestination,   "여행지"),
        (EX.hasCompanionType, "동행인 유형"),
        (EX.hasPriority,      "여행 우선순위"),
        (EX.hasDuration,      "여행 기간"),
        (EX.hasBudget,        "예산 범위"),
    ]

    for prop, label in required_properties:
        g.add((prop, RDF.type, RDF.Property))
        g.add((prop, RDFS.domain, EX.TripPlan))
        g.add((prop, RDFS.label, Literal(label, lang="ko")))

    return g

required_properties 리스트가 이 온톨로지의 핵심입니다. 청사진 글에서 “TripPlan 클래스는 반드시 hasDurationhasBudget 속성을 가져야 한다”고 서술했던 규칙이, 여기서는 rdflib 트리플로 표현됩니다. 이 리스트에 항목을 추가하거나 제거하는 것만으로도 Askitect가 사용자에게 무엇을 물어야 하는지가 바뀝니다. 규칙이 코드 바깥이 아니라 데이터 안에 살아 있는 것입니다.

3. 의미론적 파서: 모호한 말을 구조로 번역하기

사용자의 자연어를 RDF 트리플로 변환하는 단계입니다. 이 역할은 LLM이 담당합니다. 다만, 자유롭게 생성하도록 두는 것이 아니라 온톨로지의 네임스페이스와 속성 목록을 시스템 프롬프트로 제약합니다.

import anthropic

client = anthropic.Anthropic()

PARSER_SYSTEM_PROMPT = """당신은 자연어를 RDF Turtle 포맷으로 변환하는 의미론적 파서입니다.

사용 가능한 온톨로지:
- Namespace: http://example.org/travel/ (prefix: ex)
- Class: ex:TripPlan
- Properties: ex:hasDestination, ex:hasCompanionType, ex:hasPriority,
              ex:hasDuration, ex:hasBudget

규칙:
1. 사용자 발화에서 추출 가능한 정보만 트리플로 변환하세요.
2. 추측하지 마세요. 언급되지 않은 속성은 생략합니다.
3. 반드시 유효한 Turtle 구문만 출력하세요. 설명 텍스트는 포함하지 마세요.
4. 인스턴스 URI는 ex:Request_001을 사용하세요."""

def parse_to_rdf(user_input: str) -> str:
    message = client.messages.create(
        model="claude-sonnet-4-5-20250929",
        max_tokens=512,
        system=PARSER_SYSTEM_PROMPT,
        messages=[{"role": "user", "content": user_input}],
    )
    return message.content[0].text

이전 글에서 다루었던 여행 계획 예시를 다시 떠올려봅니다. 사용자가 “친구랑 도쿄 가려고. 맛집이 제일 중요해.”라고 말하면, LLM은 다음과 같은 Turtle을 반환합니다.

@prefix ex: <http://example.org/travel/> .

ex:Request_001 a ex:TripPlan ;
    ex:hasDestination "Tokyo" ;
    ex:hasCompanionType "Friend" ;
    ex:hasPriority "Gastronomy" .

여기서 주목할 것은 빠져 있는 것입니다. hasDurationhasBudget이 보이지 않습니다. 사용자가 언급하지 않았으므로 LLM도 생성하지 않은 것입니다. 프롬프트에 “추측하지 마세요”라고 못 박아둔 이유이기도 합니다. 이 빈칸을 발견하는 일은 LLM의 몫이 아닙니다.

4. 완성도 검사: 할루시네이션이 끼어들 수 없는 영역

여기가 Askitect 아키텍처의 심장부입니다. 온톨로지에 정의된 필수 속성과 현재 그래프의 상태를 대조하여, 무엇이 빠졌는지를 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]

FILTER NOT EXISTS가 이 쿼리의 핵심입니다. 온톨로지가 요구하는 속성 중에서 인스턴스에 실제로 존재하지 않는 것만을 걸러냅니다. 위의 여행 예시를 넣으면 [{"property": "...hasDuration", "label": "여행 기간"}, {"property": "...hasBudget", "label": "예산 범위"}]가 반환됩니다.

이 과정에서 LLM은 전혀 관여하지 않습니다. 그래프 패턴 매칭이라는 순수한 논리 연산이므로, 할루시네이션이 개입할 여지가 구조적으로 존재하지 않습니다. 청사진 글에서 “수학적 연산의 영역”이라고 표현했던 것이 바로 이 지점입니다.

5. 질문 생성: 논리가 발견한 빈칸을 언어로 채우기

심볼릭 엔진이 “여행 기간과 예산 범위가 빠져 있다”는 사실을 밝혀냈다면, 이를 사용자에게 자연스럽게 물어보는 일은 다시 LLM의 영역입니다. 다만 이번에는 파서가 아니라 자연어 생성기(NLG) 역할을 수행합니다.

GENERATOR_SYSTEM_PROMPT = """당신은 여행 계획 도우미입니다.
사용자가 제공한 기존 정보를 바탕으로, 아직 빠진 정보를 자연스럽게 물어보세요.
친근하고 간결하게, 한국어로 답변하세요."""

def generate_question(missing: list[dict], context: str) -> str:
    missing_desc = ", ".join(m["label"] for m in missing)

    message = client.messages.create(
        model="claude-sonnet-4-5-20250929",
        max_tokens=256,
        system=GENERATOR_SYSTEM_PROMPT,
        messages=[{
            "role": "user",
            "content": f"기존 대화: {context}\n부족한 정보: {missing_desc}",
        }],
    )
    return message.content[0].text

LLM은 “여행 기간, 예산 범위”라는 구조화된 목표를 받아, 다음과 같은 문장으로 변환합니다.

“도쿄 맛집 여행이라니, 기대되네요! 혹시 며칠 정도 다녀올 계획이세요? 그리고 대략적인 예산 범위도 알려주시면 딱 맞는 코스를 짜드릴 수 있을 것 같아요.”

무엇을 물어야 하는지는 SPARQL이 결정했고, 어떻게 물어야 하는지는 LLM이 결정한 것입니다. 역할의 분리가 여기서 명확하게 드러납니다.

6. 순환 루프: 모든 것을 하나로

이제 각 모듈을 하나의 대화 루프로 연결합니다.

def run_askitect():
    ontology = build_ontology()
    instance = Graph()
    instance.bind("ex", EX)

    print("Askitect: 여행 계획을 도와드릴게요. 어디로 가고 싶으세요?")

    while True:
        user_input = input("User: ")
        if not user_input:
            break

        turtle_str = parse_to_rdf(user_input)
        instance.parse(data=turtle_str, format="turtle")

        missing = check_completeness(ontology, instance)

        if not missing:
            print("Askitect: 필요한 정보가 모두 모였습니다!")
            break

        question = generate_question(missing, user_input)
        print(f"Askitect: {question}")

실행하면 다음과 같은 대화가 이루어집니다.

Askitect: 여행 계획을 도와드릴게요. 어디로 가고 싶으세요?
User: 친구랑 도쿄 가려고. 맛집이 제일 중요해.
Askitect: 도쿄 맛집 여행이라니, 기대되네요! 며칠 정도 다녀오실
          계획이세요? 대략적인 예산 범위도 알려주시면 딱 맞는
          코스를 짜드릴 수 있을 것 같아요.
User: 3박 4일이고 인당 100만원 정도.
Askitect: 필요한 정보가 모두 모였습니다!

두 번의 대화 턴으로 다섯 가지 필수 정보가 모두 채워졌습니다. 첫 번째 턴에서 LLM이 세 개의 트리플을 추출하고, SPARQL이 두 개의 빈칸을 발견했으며, LLM이 그 빈칸을 질문으로 변환했습니다. 두 번째 턴에서 나머지 두 개가 채워지자, SPARQL은 더 이상 누락된 속성이 없음을 확인하고 루프가 종료됩니다.

7. 왜 LLM 단독이 아닌가

“LLM에게 그냥 알아서 물어보라고 하면 되지 않나?”라는 의문이 들 수 있습니다. 물론 동작은 합니다. 하지만 그 구조에서는 얻기 어려운 것들이 있습니다.

첫째, 설명 가능성(Explainability)입니다. 무엇을 물어봐야 하는지의 판단이 SPARQL 쿼리 결과로 남기 때문에, “왜 이 질문을 했는가”를 코드 레벨에서 추적할 수 있습니다. “LLM이 왜 예산을 물어봤지?”가 아니라 “hasBudget이 그래프에 없으므로 물어본 것”이라고 설명되는 것입니다. 이전 글 기호주의(Symbolism) 인공지능: 블랙박스를 여는 열쇠에서 강조했던 바로 그 가치입니다.

둘째, 일관성(Consistency)입니다. LLM은 대화가 길어지면 앞서 받은 정보를 잊거나 같은 질문을 반복하기도 합니다. 하지만 지식 그래프는 명시적인 상태입니다. 이미 채워진 속성을 다시 묻는 일이 구조적으로 발생하지 않습니다.

셋째, 도메인 확장성입니다. 새로운 도메인을 추가하려면 온톨로지만 교체하면 됩니다. 호텔 예약이든, 병원 접수든, 보험 상담이든, 필수 속성을 정의한 스키마만 준비하면 동일한 대화 엔진이 그대로 동작합니다. 대화의 ‘구조’와 ‘내용’이 분리되어 있기 때문입니다.

8. 청사진과 현실 사이

물론 이 프로토타입은 핵심 루프를 검증하기 위해 많은 것을 단순화했습니다. 현실의 시스템으로 발전시키려면 넘어야 할 간극이 존재합니다.

가장 큰 도전은 파싱의 신뢰성입니다. LLM이 항상 유효한 Turtle을 반환하리라는 보장이 없습니다. 구조화된 출력(Structured Output)이나 파싱 실패 시의 재시도 전략이 필요합니다. 또한 현재 구현에서는 매 턴을 독립적으로 파싱하지만, 실제 대화에서는 “거기”, “그 정도”와 같은 대명사가 이전 맥락을 참조합니다. 대화 히스토리를 파서에게 함께 전달하는 구조가 필요한 이유입니다.

온톨로지의 복잡도 역시 과제입니다. 현재는 모든 속성이 동등하게 ‘필수’이지만, 실제로는 속성 간 의존 관계가 존재합니다. 국내 여행이면 비자 정보가 불필요하고, 예산 범위에 따라 물어봐야 할 세부 항목이 달라집니다. 이러한 조건부 검증은 SHACL(Shapes Constraint Language)의 영역으로, 다음 단계에서 다루어볼 주제입니다.

마치며

청사진 위의 화살표 하나하나가 코드로 구체화되는 과정에서 확인한 것은, 결국 역할의 분리라는 원칙의 유효성이었습니다.

LLM은 자연어와 구조화된 데이터 사이를 오가는 번역가이고, 지식 그래프는 대화의 상태를 명시적으로 유지하는 기억이며, SPARQL은 무엇이 빠졌는지를 논리적으로 판단하는 추론 엔진입니다. 확률의 유연함과 논리의 엄격함을 각자의 자리에 배치하는 것. 이것이 뉴로-심볼릭 AI를 설계도가 아닌 동작하는 시스템으로 만드는 첫걸음이며, Askitect가 단순한 챗봇에서 설계자로 진화하는 출발점이 될 것입니다.