LLM으로 비정형 텍스트에서 구조화된 데이터를 뽑아내다 보면 결국 한 가지 문제가 남습니다. 바로 환각(hallucination)입니다. 모델은 입력에 없는 관계를 만들어내거나, 스스로 새로운 클래스를 정의하거나, 원문에 없는 속성값을 그럴듯하게 채워 넣습니다.

이번 글에서는 이 문제를 프롬프트만으로 억제하는 대신, TBox와 Datalog facts를 이용해 구조적으로 제약하는 방법을 정리해 보았습니다. 완전히 새로운 기법이라기보다는, 온톨로지와 논리 프로그래밍 쪽에서 이미 익숙한 구분을 LLM 추출 파이프라인에 적용해 보는 시도에 가깝습니다.

문제: LLM은 스키마도 데이터도 동시에 생성한다

LLM에게 “이 텍스트에서 지식 그래프를 뽑아줘”라고 요청하면, 모델은 단순히 사실(fact)만 추출하지 않습니다. 어떤 개념을 클래스로 잡을지, 어떤 관계를 속성(property)으로 모델링할지, 심지어 어떤 계층 구조를 둘지까지 즉석에서 결정합니다. 스키마 설계와 데이터 채움을 한 번에 처리하는 셈입니다.

문제는 여기서부터 시작됩니다. LLM은 입력 텍스트에 실제로 존재하는 정보와, 문맥상 있을 법하다고 판단한 정보를 늘 엄격하게 구분하지 않습니다. 예를 들어 Person이라는 클래스를 만들고 나면, 이어서 hasJob이나 livesIn 같은 속성도 자연스럽게 붙일 수 있다고 생각합니다. 원문에 그런 정보가 없어도 말입니다.

입력: "홍길동은 2024년 한국에서 태어났다."

LLM 출력 (환각 포함):
Person(홍길동)
bornIn(홍길동, 한국)
bornYear(홍길동, 2024)       # OK
hasNationality(홍길동, 한국)  # 애매함
hasJob(홍길동, ???)          # 원문에 없음
age(홍길동, 1)               # 계산 결과이지 추출된 사실은 아님

스키마와 데이터를 동시에 생성하게 두면 이런 식의 오염이 쉽게 섞입니다.

TBox와 ABox의 구분

Description Logic(DL)이나 OWL에서 온톨로지는 보통 두 레이어로 나누어 설명합니다.

  • TBox (Terminological Box): 클래스, 속성, 계층 관계, 제약을 정의하는 스키마 레이어입니다. “Person은 Agent의 하위 클래스다”, “hasAge의 치역(range)은 xsd:integer다” 같은 내용이 여기에 들어갑니다.
  • ABox (Assertional Box): 실제 개별 인스턴스에 대한 사실 레이어입니다. “홍길동은 Person이다”, “홍길동의 나이는 30이다” 같은 주장이 여기에 해당합니다.

Datalog 관점으로 옮겨 보면 TBox는 주로 규칙(rule)과 제약에 가깝고, ABox는 facts에 해당합니다. 규칙은 새로운 사실을 추론하는 데 쓰이고, facts는 추론의 출발점이 되는 기저 데이터입니다.

TBox를 추출 제약으로 제공하기

실제로 프롬프트에 스키마를 명시하는 방식은 이미 많이 쓰입니다. 예를 들어 “Person, Organization, Event 클래스만 사용하고, 속성은 hasName, worksFor, occursAt만 허용한다”는 식으로 모델에게 작업 범위를 알려주는 방식입니다.

이 방식은 어느 정도 효과가 있습니다. 허용된 클래스와 속성을 명시하면, 모델이 임의의 클래스를 새로 만들어내는 빈도는 줄어듭니다. 다만 이것만으로 충분하다고 보기는 어렵습니다.

왜 부분적으로만 효과가 있는가

문제는 LLM이 프롬프트 지시를 항상 완벽하게 따르지는 않는다는 점입니다. 특히 다음과 같은 상황에서 스키마 밖의 출력이 섞입니다.

1. 스키마가 커질수록 준수율이 떨어집니다

TBox가 50개, 100개의 클래스와 속성을 포함하기 시작하면 모델이 컨텍스트 안에서 이를 모두 안정적으로 따라가기가 어려워집니다. 주의가 분산되고, 어느 순간 스키마에 없는 표현이 섞여 들어옵니다.

2. 모호한 입력에서 스키마를 스스로 확장합니다

텍스트에 기존 스키마로 매핑하기 어려운 개념이 등장하면, LLM은 가장 가까워 보이는 표현을 새로 만들어내려는 경향을 보입니다. TBox에 없는 속성을 추가하거나, 기존 클래스를 임의로 세분화하는 식입니다.

3. TBox 자체의 해석에서도 드리프트가 생깁니다

“TBox를 줬으니 그대로 따르겠지”라고 기대하지만, 모델은 그 TBox를 자기 방식으로 해석합니다. hasJob이 없으면 worksAt을 쓰거나, occupation을 만들어내기도 합니다.

결국 TBox를 프롬프트로 제공하는 것만으로는 스키마 일탈을 안정적으로 막기 어렵습니다.

핵심 아이디어: TBox는 사람이 만들고, LLM은 ABox만 채운다

여기서는 역할을 나누는 편이 낫습니다.

TBox와 ABox 역할 분리

이 구성에서 LLM은 TBox를 생성하지 않습니다. 이미 만들어진 TBox를 참조 스키마로 받아 보고, 그 범위 안에서만 facts를 추출합니다. 작은 차이처럼 보이지만 실제 파이프라인에서는 큰 차이를 만듭니다.

모델에게 주어지는 작업도 단순해집니다.

  • “이 텍스트에서 온톨로지를 설계하고 인스턴스를 추출해” (스키마 설계 + 데이터 추출)
  • “이 TBox에 정의된 클래스와 속성만 써서, 텍스트에 실제로 나오는 사실만 Datalog facts로 뽑아줘” (데이터 추출만)

작업 범위가 ABox 채우기로 좁혀지면, 모델이 임의로 결정할 수 있는 영역도 같이 줄어듭니다.

Datalog Facts 수준에서의 추출

이 접근에서는 LLM의 출력 형식을 Datalog facts로 제한하는 것이 중요합니다.

% TBox (사전에 정의됨, LLM이 수정 불가)
subClassOf(Person, Agent).
subClassOf(Organization, Agent).
domain(bornIn, Person).
range(bornIn, Place).
domain(hasName, Agent).
range(hasName, xsd_string).

% LLM이 추출하는 ABox (facts만)
Person(홍길동).
Place(한국).
bornIn(홍길동, 한국).
hasName(홍길동, "홍길동").

여기서 LLM이 hasJob(홍길동, 의사)를 만들어내려 해도, TBox에 hasJob이 없으면 출력 검증 단계에서 걸러낼 수 있습니다. 단순히 프롬프트로 “이것만 써라”라고 지시하는 것이 아니라, 파싱과 검증 레벨에서 TBox 밖의 심볼을 차단하는 방식입니다.

Datalog facts 형식은 이런 검증을 구현하기에 적합합니다. 각 fact가 predicate(arg1, arg2, ...) 형태이므로, predicate name이 TBox에 등록되어 있는지 확인하는 과정은 단순한 집합 조회(set lookup)로 처리할 수 있습니다.

allowed_predicates = {p for p in tbox.get_all_predicates()}

def validate_fact(fact: str) -> bool:
    predicate = parse_predicate_name(fact)
    return predicate in allowed_predicates

TBox에 없는 predicate는 이 단계에서 바로 거부됩니다. 모델이 아무리 그럴듯한 표현을 만들어내도, 스키마 밖이면 파이프라인 안으로 들어오지 못합니다.

실제로 어떻게 생겼나

프롬프트 구조는 대략 다음과 같이 잡을 수 있습니다.

당신은 텍스트에서 Datalog facts를 추출하는 시스템입니다.

[스키마 (TBox)]
허용된 클래스: Person, Organization, Place, Event
허용된 속성:
  - hasName(Agent, xsd:string)
  - bornIn(Person, Place)
  - worksFor(Person, Organization)
  - occursAt(Event, Place)
  - occursOn(Event, xsd:date)

[규칙]
1. 위 클래스와 속성 외에는 어떤 것도 사용하지 마세요.
2. 텍스트에 명시적으로 언급된 사실만 추출하세요.
3. 추론이나 상식적 보완은 하지 마세요.
4. 확실하지 않으면 출력하지 마세요.

[입력 텍스트]
...

[출력 형식]
Datalog facts만 출력:
Person(개체명).
hasName(개체명, "이름").
...

이때 모델은 TBox를 참고해 허용된 클래스와 속성 안에서만 facts를 만들어야 합니다. 물론 이 판단을 모델에게만 맡기면 다시 같은 문제가 생기므로, 출력 이후에는 반드시 검증 단계를 둡니다.

Fallback으로 unmapped를 둔다

실제로는 TBox에 딱 맞지 않지만 바로 버리기 어려운 정보가 자주 나옵니다. 이때 LLM에게 신규 predicate를 만들 권한을 주면 다시 원래 문제로 돌아갑니다. 대신 어디에도 맞지 않는 후보를 임시로 담아두는 fallback 슬롯을 둘 수 있습니다.

% === TBox: 허용된 vocabulary ===

% 엔티티 타입
.type Paper
.type Concept
.type Model <: Concept
.type Person
.type Dataset

% 관계 시그니처
.decl authored_by(paper: Paper, person: Person)
.decl introduced_in(concept: Concept, paper: Paper)
.decl published_in(paper: Paper, year: number)
.decl trained_on(model: Model, dataset: Dataset)
.decl extends(child: Model, parent: Model)
.decl cites(citing: Paper, cited: Paper)

% fallback buffer
.decl unmapped(subject: symbol, raw_predicate: symbol, object: symbol, source: symbol)

프롬프트에서는 다음과 같이 지시합니다.

추출은 위 타입과 관계만 사용한다.
다른 술어가 필요해 보이면 새 술어를 만들지 말고 unmapped에 넣는다.
unmapped의 source에는 근거가 된 원문 일부를 그대로 둔다.
TBox를 수정하거나 확장할 권한은 없다.

예를 들어 논문 요약에서 “Transformer was pretrained on WMT 2014 English-German”이라는 문장이 나왔는데, 현재 TBox에 evaluated_on이나 pretrained_on이 없다면 모델은 새 predicate를 만들지 않습니다. 대신 다음과 같이 남깁니다.

unmapped(model_transformer, pretrained_on, dataset_wmt_2014_en_de,
         "Transformer was pretrained on WMT 2014 English-German").

이렇게 하면 LLM은 ABox 추출자로만 일하고, 나머지는 사람이 통제합니다. unmapped에 비슷한 패턴이 계속 쌓이면 사람이 보고 판단할 수 있습니다. “이건 자주 등장하는 관계이니 TBox에 추가하자”라고 결정할 수도 있고, “이건 문맥상 노이즈에 가깝다”라고 버릴 수도 있습니다. 중요한 점은 LLM이 직접 TBox를 건드리지 않는다는 것입니다.

엔티티 ID에도 타입 prefix를 강제하면 검증이 조금 더 쉬워집니다.

paper_attention_2017      (Paper)
concept_self_attention    (Concept)
model_transformer         (Model)
person_vaswani            (Person)
dataset_wmt_2014_en_de    (Dataset)

모델이 새 엔티티를 만들 때 prefix를 고르게 하면 자연스럽게 타입 분류를 한 번 더 하게 됩니다. 잘못된 타입 추론도 나중에 grep이나 간단한 validator로 잡아낼 수 있습니다.

이 구성의 장점은 에러가 조용히 사라지지 않는다는 데 있습니다. TBox가 없으면 LLM은 적당해 보이는 관계를 만들어 fact를 계속 채워 넣습니다. 어떤 것이 원문에 근거한 추출이고 어떤 것이 환각인지 나중에 구분하기 어렵습니다. 반면 TBox와 unmapped fallback을 같이 쓰면, 추출하지 못한 후보가 한곳에 모입니다. 그 목록은 단순한 실패 로그가 아니라, 현재 위키나 온톨로지의 빈 곳이 어디인지 보여주는 지도에 가깝습니다.

결국 TBox는 LLM이 모르는 것을 모른다고 표시하게 만드는 장치입니다. 환각을 완전히 막는다기보다는, 환각으로 번질 수 있는 지점을 드러나게 만드는 쪽에 가깝습니다. provenance를 함께 남기는 이유도 같은 맥락입니다.

한계와 남은 과제

물론 이 접근이 모든 문제를 해결하지는 못합니다.

TBox가 커버하지 못하는 정보는 정식 fact가 되지 못합니다. unmapped를 두면 완전히 잃어버리지는 않지만, 쿼리 가능한 지식으로 바로 들어오지는 않습니다. 도메인을 미리 잘 정의해 두지 않으면 recall이 떨어지고, 결국 TBox 설계와 갱신 주기가 병목이 됩니다.

같은 클래스 안에서의 환각은 여전히 가능합니다. 예를 들어 Person이 허용되어 있을 때, 텍스트에 언급되지 않은 사람 이름을 만들어내는 것은 TBox만으로 막기 어렵습니다. 개체 자체를 조작하는 ABox 수준의 환각에는 별도의 NER 기반 검증이나 텍스트 어라인먼트가 필요합니다.

TBox 설계 자체도 쉽지 않습니다. 도메인 전문가가 스키마를 미리 설계해야 하므로 초기 비용이 듭니다. 다만 한 번 잘 만들어 두면 여러 추출 작업에서 재사용할 수 있다는 장점도 있습니다.

왜 Datalog인가

OWL Full처럼 표현력이 높은 온톨로지 언어 대신 Datalog를 택하는 데는 몇 가지 이유가 있습니다.

  • LLM 출력이 파싱하기 쉽습니다. Datalog facts는 문법이 단순해서, 모델이 생성한 텍스트를 파싱하고 검증하기 쉽습니다.
  • 표현력과 decidability 사이의 균형이 좋습니다. Datalog는 재귀를 허용하면서도 decidable합니다. 추출된 facts 위에 규칙 기반 추론을 적용할 수 있습니다.
  • 기존 Datalog 엔진과 바로 연동됩니다. 추출된 facts를 Soufflé나 DLV 같은 엔진에 바로 넣으면 TBox의 규칙으로 추론을 돌릴 수 있습니다.

이렇게 구성하면 파이프라인도 비교적 단순해집니다.

LLM과 Datalog 추출 파이프라인

LLM은 facts 추출만 담당하고, 추론은 Datalog 엔진이 담당합니다. 각 컴포넌트의 책임이 분리되기 때문에 디버깅도 쉬워집니다. 문제가 생겼을 때 모델이 잘못 추출한 것인지, TBox 규칙이 잘못된 것인지, 검증기가 느슨한 것인지 따로 확인할 수 있습니다.

마치며

이 아이디어의 본질은 LLM이 할 수 있는 결정의 자유도를 줄이는 것입니다. 스키마 설계는 모델 밖에서 이루어지고, 모델은 “이 텍스트에 이 사실이 실제로 존재하는가?”만 판단합니다. 환각을 없애는 방법이라기보다는, 환각이 발생할 수 있는 공간을 구조적으로 좁히는 접근에 가깝습니다.

완벽한 해결책은 아닙니다. 개체 수준의 환각을 막으려면 추가 검증이 필요하고, TBox를 미리 잘 만들어야 한다는 전제도 있습니다. 다만 unmapped 같은 fallback을 함께 두면 실패가 조용히 사라지지 않고 검토 가능한 형태로 남습니다. LLM에게 스키마와 데이터를 동시에 만들게 하는 방식보다는 훨씬 안정적인 파이프라인을 구성할 수 있다고 생각합니다.

특히 법률, 의학, 금융처럼 도메인 스키마가 비교적 잘 정의되어 있고 환각의 비용이 큰 분야에서는 이런 접근이 실용적인 대안이 될 수 있습니다. TBox 설계에 초기 비용을 투자하고, 그 스키마 안에서 LLM을 facts 추출기로 사용하는 방식입니다.


관련 키워드

  • Datalog: 재귀적 쿼리를 지원하는 논리 프로그래밍 언어. 지식 표현과 추론에 자주 사용됩니다.
  • TBox (T-Box): Description Logic에서 개념(클래스)과 역할(속성)의 계층 구조 및 제약을 정의하는 스키마 레이어입니다.
  • ABox (A-Box): 개별 인스턴스에 대한 사실 주장(assertion)을 담는 데이터 레이어입니다.
  • Hallucination: LLM이 입력 근거 없이 사실처럼 보이는 정보를 생성하는 현상입니다.