RAG(검색 증강 생성)를 붙이면 환각(hallucination)이 저절로 사라진다는 기대는 대체로 배신당합니다. 검색으로 관련 문서를 넣어 줘도 모델이 그 문서만 근거로 답한다는 보장은 없기 때문입니다. 모델은 검색된 근거와 사전 지식을 뒤섞고, 근거에 없는 수치나 조건을 그럴듯하게 지어냅니다. 검색 품질이 아무리 좋아도 생성 단계에서 근거를 강제하지 않으면 환각은 남습니다.
이 글은 RAG 환각을 생성 파이프라인의 문제로 다룹니다. 핵심은 두 가지입니다. 하나는 모든 주장에 인용(citation)을 강제하도록 출력을 설계하는 것이고, 다른 하나는 생성된 답을 근거 문서와 대조해 검증(grounding verification)하는 후처리입니다. 예시는 Anthropic Claude API 기준이지만, 인용 강제와 근거 검증이라는 골격은 다른 프로바이더에도 그대로 적용됩니다.
환각의 진짜 원인: 근거 없는 자유 서술
RAG 환각의 대부분은 “검색이 틀려서”가 아니라 “생성이 근거를 벗어나서” 발생합니다. 검색이 정답을 넣어 줘도 나타나는 전형적 유형은 세 가지입니다.
- 근거 이탈: 검색 문서에 없는 내용을 사전 지식으로 채움.
- 근거 혼합: 여러 문서의 값을 뒤섞어 존재하지 않는 조합을 만듦.
- 과잉 일반화: 특정 조건에서만 참인 문장을 무조건 참인 것처럼 서술.
자유 서술 형식은 이런 이탈을 구조적으로 막지 못합니다. 대응 전략은 답의 모든 문장을 특정 근거 조각에 묶는 것입니다. 각 주장이 어느 문서에서 나왔는지 명시하게 만들면 근거 없이 말하기 어려워집니다. 인용은 장식이 아니라 환각 억제 장치입니다.
1단계: 검색 청크에 안정적 ID 부여
인용을 강제하려면 먼저 인용 대상이 있어야 합니다. 각 청크에 안정적 ID를 붙여 프롬프트에 넣으면, 모델은 이 ID로만 인용하고 우리는 답의 ID를 원문 청크로 되돌려 검증할 수 있습니다.
# 청크에 안정적 ID를 부여해 프롬프트 컨텍스트 구성
def build_context(chunks: list[dict]) -> str:
# chunks: [{"doc_id": "policy-2026", "chunk_id": 3, "text": "..."}]
lines = []
for c in chunks:
tag = f"S{c['chunk_id']}" # 짧고 유일한 인용 앵커
lines.append(f"[{tag}] (문서:{c['doc_id']})n{c['text'].strip()}")
return "nn".join(lines)
# 태그 → 원본 청크 역참조 (검증 단계에서 사용)
def build_source_index(chunks: list[dict]) -> dict:
return {f"S{c['chunk_id']}": c for c in chunks}
태그는 짧고 유일해야 파싱이 안정적입니다. S1 같은 정수 접미 태그가 안전합니다.
2단계: 인용을 강제하는 프롬프트
프롬프트에서 규칙을 명시적으로 못 박아야 합니다. “근거가 있으면 인용하라”는 약한 요청이 아니라 “모든 주장에 인용을 달고, 근거가 없으면 답하지 말라”는 강한 계약입니다. 특히 모른다고 말할 자유를 허용해야 빈칸을 지어내지 않습니다.
당신은 제공된 [출처] 조각만을 근거로 답하는 어시스턴트다. 규칙:
1. 모든 사실 문장 끝에 근거 태그를 [S3] 형식으로 붙인다. 여러 근거는 [S3][S7].
2. 제공된 출처에 없는 내용은 절대 말하지 않는다. 사전 지식으로 빈칸을 채우지 않는다.
3. 출처로 답할 수 없으면 "제공된 자료로는 확인할 수 없습니다"라고만 답한다.
4. 출처끼리 내용이 충돌하면, 충돌을 명시하고 각 주장에 해당 태그를 붙인다.
5. 태그는 반드시 제공된 출처 목록에 실재하는 것만 사용한다.
3번과 5번이 핵심입니다. 3번은 답을 만들어 내는 압력을 제거하고, 5번은 존재하지 않는 출처를 지어내는 가짜 인용을 막습니다. 규칙만으로 완벽하진 않지만 뒤의 검증 단계와 결합하면 실효성이 큽니다.
3단계: 구조화된 출력으로 인용을 파싱 가능하게
텍스트에 [S3] 태그를 심어도 되지만, 검증 자동화에는 기계가 파싱하기 쉬운 구조가 낫습니다. 주장(claim)과 근거 태그를 분리한 JSON으로 받으면 각 주장을 개별 검증합니다. Claude API는 도구(tool) 입력 스키마를 강제해 구조화 출력을 안정적으로 받습니다.
import anthropic
client = anthropic.Anthropic()
# 답을 "주장 배열"로 강제하는 도구 스키마
answer_tool = {
"name": "submit_answer",
"description": "출처에 근거한 답을 주장 단위로 제출한다",
"input_schema": {
"type": "object",
"properties": {
"claims": { # 주장 배열
"type": "array",
"items": {
"type": "object",
"properties": {
"text": {"type": "string"}, # 주장 문장
"sources": {"type": "array", # 근거 태그
"items": {"type": "string"}},
},
"required": ["text", "sources"],
},
},
"answerable": {"type": "boolean"}, # 답 가능 여부
},
"required": ["claims", "answerable"],
},
}
resp = client.messages.create(
model="claude-opus-4-8", max_tokens=1500,
tools=[answer_tool],
tool_choice={"type": "tool", "name": "submit_answer"}, # 도구 사용 강제
system=[{"type": "text", "text": CITATION_RULES,
"cache_control": {"type": "ephemeral"}}], # 규칙은 캐싱
messages=[{"role": "user",
"content": f"[출처]n{context}nn[질문]n{question}"}],
)
answer = resp.content[0].input # {"claims": [...], "answerable": true}
주장 단위 분리의 이점은 검증의 입도(granularity)입니다. 문장 하나하나를 근거와 대조하므로 어느 주장이 환각인지 정확히 짚어냅니다. tool_choice로 도구를 강제하면 자유 서술로 새는 것도 막힙니다.
4단계: 인용 검증과 근거 대조
구조화된 답을 받았으면 먼저 LLM 없이 할 수 있는 검증부터 합니다. 빠르고 결정론적이며 비용이 0입니다. 가짜 인용(존재하지 않는 태그)과 무인용 주장을 여기서 걸러냅니다.
def verify_citations(answer: dict, source_index: dict) -> list[str]:
problems = []
for i, claim in enumerate(answer["claims"]):
srcs = claim["sources"]
if not srcs: # (a) 근거 없는 사실 주장
problems.append(f"claim[{i}]: 인용 없음")
for tag in srcs:
if tag not in source_index: # (b) 가짜 인용 = 환각 신호
problems.append(f"claim[{i}]: 허위 태그 {tag}")
return problems
problems = verify_citations(answer, source_index)
if problems:
handle_ungrounded(problems) # 그대로 내보내지 않는다
이 검사만으로 가짜 인용이라는 가장 위험한 유형을 잡습니다. [S9]를 인용했는데 출처에 S9가 없다면 명백히 지어낸 것이므로, 내보내기 전에 차단하거나 재생성합니다.
그러나 태그가 실재해도 그 주장이 실제로 그 출처에서 도출되는지는 보장되지 않습니다. [S3]을 인용하고도 S3에 없는 내용을 말할 수 있습니다. 이를 잡으려면 각 주장을 인용된 출처와 대조해 함의(entailment)를 판정합니다. 좁고 결정적인 작업이라 작은 모델로도 안정적입니다.
CHECK_PROMPT = """다음 [주장]이 [근거] 텍스트만으로 도출되는지 판정하라.
- 근거가 주장을 명확히 뒷받침하면: SUPPORTED
- 근거가 주장과 모순되면: CONTRADICTED
- 근거만으로 판단 불가(정보 부족)면: NOT_ENOUGH
세 값 중 하나의 단어만 출력한다."""
def check_entailment(claim: dict, source_index: dict) -> str:
# 인용된 출처의 원문만 모아 근거로 제시 (전체 컨텍스트 X)
evidence = "nn".join(source_index[t]["text"]
for t in claim["sources"] if t in source_index)
r = client.messages.create(
model="claude-haiku-4-8", max_tokens=8, # 좁은 작업 → 작은 모델
system=[{"type": "text", "text": CHECK_PROMPT}],
messages=[{"role": "user",
"content": f"[근거]n{evidence}nn[주장]n{claim['text']}"}],
)
return r.content[0].text.strip()
# SUPPORTED 아닌 주장 = 근거 이탈 환각
for i, c in enumerate(answer["claims"]):
if check_entailment(c, source_index) != "SUPPORTED":
problems.append(f"claim[{i}]: 근거 미달")
판정 대상을 인용된 출처로만 좁히는 것이 핵심입니다. 전체 컨텍스트가 아니라 인용한 조각만 근거로 주면, 검증기가 “다른 곳에 있을지도”라며 관대해지는 것을 막습니다.
5단계: 재시도와 안전한 폴백
검증에서 문제가 발견됐을 때의 행동을 미리 정해야 합니다. 잡아 놓고 그대로 내보내면 검증이 무의미합니다. 전략은 한정된 재생성 후 안전한 폴백입니다. 재시도 시 실패 이유를 알려 주어 모델이 문제 주장을 제거하거나 근거를 보강하게 합니다.
def answer_with_verification(question, chunks, max_retries=2):
context = build_context(chunks)
source_index = build_source_index(chunks)
feedback = ""
for _ in range(max_retries + 1):
answer = generate(question, context, feedback) # 3단계 호출
if not answer["answerable"]:
return SAFE_UNKNOWN # 모델이 스스로 못 한다고 함
problems = verify_citations(answer, source_index)
problems += entailment_problems(answer, source_index)
if not problems:
return render(answer) # 모든 주장 근거 통과
# 실패 정보를 피드백으로 넣어 재생성
feedback = "검증 실패 주장을 제거하거나 근거를 보강하라:n"
+ "n".join(problems)
return SAFE_UNKNOWN # 재시도 소진 → 안전 폴백
폴백을 “확인할 수 없습니다”로 두는 것이 핵심입니다. 틀린 답보다 답을 안 하는 편이 낫습니다. 보통 2회 재시도면 충분하고, 반복 실패는 대개 검색이 정답 문서를 못 가져온 것이므로 검증 실패율을 검색 품질 지표로도 쓸 수 있습니다.
지표로 검증 파이프라인을 운영하기
검증을 다 붙이면 요청당 LLM 호출이 늘어 지연·비용이 커집니다. 결정론 검사를 앞세워 entailment 호출을 줄이고, 판정을 한 번에 배칭하며, 함의 판정은 저가 모델로 돌리고, 고정 규칙은 cache_control로 캐싱하는 것이 기본 절감입니다. 그리고 실제로 환각이 줄고 있는지 측정해야 합니다. 검증 단계 자체가 훌륭한 관측 지점이라, 아래 지표로 파이프라인 건강도와 검색 품질을 함께 봅니다.
- 근거 미달률: SUPPORTED가 아닌 비율. 높으면 검색·프롬프트를 손본다.
- 가짜 인용률: 허위 태그를 인용한 비율. 0에 수렴해야 하며, 튀면 규칙 5번을 강화한다.
- 폴백률: “확인 불가”로 끝난 비율. 높으면 검색 커버리지 부족 신호다.
- 재시도 분포: 2회로도 안 되는 질문 유형을 찾아 분석한다.
미묘한 점은 폴백률과 정확성이 트레이드오프라는 것입니다. 검증 임계를 엄격히 하면 환각은 줄지만 “확인 불가”가 늘고, 느슨하게 하면 답변율은 오르지만 환각이 샙니다. 의료·법률·금융처럼 오답 비용이 큰 영역은 엄격하게, 일반 검색은 느슨하게 두어 도메인 위험도에 맞춥니다.
마무리
RAG 환각은 검색만 잘한다고 사라지지 않습니다. 근본 해법은 생성 단계에서 근거를 강제하고 답을 근거와 대조해 검증하는 것입니다. 청크에 안정적 ID를 붙이고, 인용 규칙과 구조화 출력으로 주장을 근거에 묶고, 결정론 검사로 가짜 인용을 거른 뒤 함의 판정으로 근거 이탈을 잡고, 실패하면 “확인 불가”로 폴백하는 흐름입니다.
이 파이프라인의 진짜 가치는 환각을 0으로 만드는 것이 아니라 사용자에게 도달하기 전에 관측하고 차단하는 데 있습니다. 정확성이 최우선인 도메인일수록 “그럴듯한 답”보다 “근거로 검증된 답 또는 정직한 모름”이 결국 신뢰를 만듭니다.