- Published on
RAG 환각 줄이기 - Citation 기반 검증 파이프라인
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서치 기반 RAG(Retrieval-Augmented Generation)는 LLM의 환각을 줄이는 대표적인 접근이지만, 현실에서는 여전히 “그럴듯한데 근거가 없는” 답변이 자주 나옵니다. 이유는 간단합니다. 많은 RAG 구현이 검색 결과를 프롬프트에 붙여 넣는 것에서 끝나고, 모델이 실제로 각 문장을 근거로 뒷받침했는지를 시스템적으로 확인하지 않기 때문입니다.
이 글에서는 RAG의 환각을 줄이기 위해 Citation(인용) 기반 검증 파이프라인을 설계하는 방법을 다룹니다. 핵심은 다음입니다.
- 답변의 모든 핵심 주장(claim)을 근거 문서의 특정 스팬에 연결
- 연결된 근거가 주장에 대해 충분·정확·비모순인지 자동 검증
- 검증 점수에 따라 게이팅(재시도, 축약, 거절, 추가검색) 수행
운영 관점에서 파이프라인을 단단하게 만들려면 오류 전파, 실패 격리, 타임아웃, 관측성도 중요합니다. 배포/운영 이슈를 다룬 글로는 bash set -euo pipefail로 스크립트 폭발 막기, 트래픽 급증과 지연을 다룬 GCP Cloud Run 504와 콜드스타트 지연 해결 가이드도 함께 참고하면 좋습니다.
왜 Citation 기반 검증이 필요한가
전형적인 RAG 파이프라인은 retrieve -> stuff -> generate 형태입니다. 문제는 생성 모델이 컨텍스트를 받았다고 해서 자동으로 “근거를 따라 말하는” 모드로 전환되지 않는다는 점입니다.
환각이 발생하는 주요 패턴은 다음과 같습니다.
- 컨텍스트에 없는 내용을 보완: 문서에 빈 구멍이 있으면 모델이 상식으로 메워 버림
- 부분적 근거를 과장: 문서가 말하는 범위를 넘어 일반화
- 문서 간 모순을 무시: 상충하는 근거가 있는데도 하나로 합쳐 단정
- 근거는 있는데 인용이 엉뚱함: 답변은 맞는데 citation이 다른 문장을 가리킴(검증 실패)
Citation 기반 검증은 “답변 텍스트”가 아니라 답변을 구성하는 주장 단위를 대상으로, 근거와의 정합성을 점검합니다. 즉, 모델이 답을 만들어내는 과정이 아니라, 만들어진 답을 증거 기반으로 통제하는 접근입니다.
목표: 검증 가능한 답변의 정의
먼저 “검증 가능한 답변”을 시스템적으로 정의해야 합니다.
- 답변은 여러 개의 원자적 주장(claim) 으로 분해 가능해야 함
- 각 claim은 최소 1개 이상의 근거 스팬(문서 id, 페이지/섹션, 시작/끝 오프셋 등)과 연결되어야 함
- 근거가 claim을 함의(entail) 하거나 최소한 직접 지지(support) 해야 함
- 근거가 부족하면 “모름/추가 확인 필요”로 후퇴할 수 있어야 함
여기서 중요한 점은 citation이 “문서 링크 1개” 수준이면 검증이 어려워진다는 것입니다. 가능하면 스팬 단위(문단/문장/오프셋)로 좁혀야 자동 평가가 쉬워집니다.
파이프라인 전체 구조
권장하는 Citation 기반 검증 파이프라인은 아래 단계로 구성됩니다.
- Retrieval: 쿼리 확장, 하이브리드 검색, top-k 수집
- Context Packing: 중복 제거, 섹션/문장 단위로 스플릿, 메타데이터 유지
- Draft Answer 생성: 답변 초안 + claim/citation 후보 생성
- Claim Extraction: 답변을 원자적 claim 목록으로 정규화
- Citation Matching: 각 claim에 대해 근거 스팬 후보를 재탐색/정렬
- Verification: claim-근거 쌍을 NLI/LLM judge로 검증
- Gating & Repair: 실패 유형별로 재시도 전략 수행
- Response Formatting: 최종 답변 + 인용 표기 + 불확실성 표시
- Telemetry: 점수, 실패 유형, 재시도 횟수, 문서 커버리지 기록
이 중 4~7단계가 “환각을 줄이는 핵심”입니다.
데이터 모델: claim과 citation을 구조화하기
검증을 자동화하려면 결과를 구조화해야 합니다. 예를 들어 아래처럼 JSON 스키마를 둡니다.
{
"answer": "...",
"claims": [
{
"id": "c1",
"text": "서비스 계정 키를 저장하지 않고 OIDC로 AWS 역할을 Assume할 수 있다.",
"citations": [
{
"doc_id": "doc-12",
"chunk_id": "doc-12#p3",
"span": {"start": 120, "end": 248},
"quote": "..."
}
]
}
]
}
chunk_id는 문서 스플릿 결과의 안정적인 식별자여야 합니다.span은 원문 텍스트 기준 오프셋을 권장합니다(렌더링/하이라이트, 재검증에 유리).quote는 사용자에게 보여줄 “직접 인용”으로, 검증 단계에서 모델이 엉뚱한 근거를 끌어오는 것을 줄입니다.
Retrieval 단계에서의 전제 조건
Citation 검증은 retrieval이 어느 정도 받쳐줘야 합니다. 최소한 아래는 갖추는 게 좋습니다.
- 하이브리드 검색: BM25 + dense embedding
- 문서 스플릿: 너무 크면 근거가 퍼지고, 너무 작으면 의미가 깨짐
- 메타데이터: 제목, 섹션, 날짜, 버전, 출처 신뢰도
실무 팁:
- top-k를 크게 가져오되, 생성 프롬프트에는 전부 넣지 말고 검증 단계에서 재사용하세요.
- 최신성/버전이 중요한 도메인(예: SDK, 정책 문서)은 문서에
effective_date를 붙이고 re-rank에 반영합니다.
Draft Answer를 “인용 우선” 형태로 만들기
초안 생성 프롬프트에서부터 “인용 가능한 문장만 쓰라”고 강제하면 검증 비용이 줄어듭니다. 예시는 다음과 같습니다.
시스템 지시:
- 답변은 짧은 단락으로 작성한다.
- 각 단락의 핵심 주장마다 반드시 근거 인용을 포함한다.
- 근거가 부족하면 추측하지 말고 "문서 근거 부족"이라고 쓴다.
- 인용은 [doc_id:chunk_id] 형태로 표시한다.
사용자 질문: ...
컨텍스트:
[doc-12#p3] ...
[doc-07#p1] ...
여기서 [ 와 ]는 MDX에서 안전하지만, 내부 시스템에 따라 렌더링 규칙이 다를 수 있으니 통일된 포맷을 정하세요.
Claim Extraction: 답변을 검증 가능한 단위로 쪼개기
답변을 그대로 검증하려고 하면 문장이 길어지고 복합 주장(AND/OR, 조건부, 예외)이 섞여 판정이 흔들립니다. 따라서 claim을 분해합니다.
방법은 2가지가 흔합니다.
- 규칙 기반: 문장 분리 + 접속사/조건절 패턴으로 추가 분해
- LLM 기반: “원자적 주장 목록”을 JSON으로 추출
LLM 기반 추출 프롬프트 예시:
다음 답변을 원자적 주장(claim) 목록으로 분해하라.
규칙:
- 각 claim은 하나의 사실 주장만 포함
- 조건이 있으면 "조건" 필드로 분리
- 과도한 요약 금지, 원문 의미 유지
- JSON만 출력
답변:
...
출력 예:
{
"claims": [
{"id": "c1", "text": "OIDC를 사용하면 장기 AWS 키를 저장하지 않아도 된다.", "condition": "GitHub Actions 같은 IDP를 신뢰하도록 설정된 경우"},
{"id": "c2", "text": "AssumeRole 실패 시 신뢰 정책과 audience 설정을 점검해야 한다.", "condition": null}
]
}
Citation Matching: claim별로 근거를 다시 찾기
초안에서 모델이 달아준 citation을 그대로 믿으면 안 됩니다. claim을 기준으로 다시 매칭합니다.
전략:
- claim 텍스트를 쿼리로 하여 top-n chunk를 재검색
- cross-encoder re-rank 또는 LLM re-rank로 “지원 가능성” 정렬
- 최종적으로 claim당 1~3개 근거 스팬을 선택
이 단계에서 중요한 지표는 coverage입니다.
coverage = 인용이 붙은 claim 수 / 전체 claim 수
coverage가 낮으면 검증 이전에 이미 위험 신호입니다.
Verification: NLI 또는 LLM Judge로 정합성 판정
검증은 크게 3분류로 나눌 수 있습니다.
SUPPORTED: 근거가 claim을 지지REFUTED: 근거가 claim과 모순NOT_ENOUGH_INFO: 근거만으로는 결론 불가
전통적으로는 NLI 모델(자연어 추론)을 쓰지만, 도메인 특화 용어/표현이 많으면 LLM judge가 더 안정적일 때가 많습니다. 다만 LLM judge도 환각할 수 있으므로, 입력을 최대한 제한합니다.
LLM judge 입력 템플릿 예시:
역할: 엄격한 검증기
작업: claim이 evidence로부터 논리적으로 따라오는지 판정
출력: SUPPORTED | REFUTED | NOT_ENOUGH_INFO 와 짧은 이유
claim:
...
evidence(직접 인용):
"..."
핵심은 evidence를 “문서 전체”가 아니라 직접 인용 텍스트로 제한하는 것입니다. 그래야 판정이 흔들릴 여지가 줄어듭니다.
스코어링: 답변 신뢰도를 수치화
운영에서는 단일 라벨보다 점수가 필요합니다.
- claim별 점수:
SUPPORTED=1.0,NOT_ENOUGH_INFO=0.3,REFUTED=0.0 - 답변 점수: 가중 평균
- 가중치: 사용자 영향도가 큰 claim(숫자, 보안, 비용, 장애 대응)에 더 큰 가중치
추가로 아래 같은 페널티를 고려합니다.
- citation이 문서와 무관(매칭 점수 낮음)
- 동일 문서에서 반복 인용만 함(다양성 부족)
- 최신 문서가 있는데 오래된 문서를 인용(버전 페널티)
Gating & Repair: 실패 유형별로 다르게 고치기
검증 결과로 파이프라인을 제어하는 것이 핵심입니다.
1) NOT_ENOUGH_INFO가 많을 때
- 전략 A: 추가 검색(쿼리 확장, 키워드 기반 재검색)
- 전략 B: 답변을 축약하고 “근거 있는 부분만” 남김
- 전략 C: 명시적 거절 + 필요한 추가 정보 질문
예: “문서 근거 부족”으로 후퇴하는 템플릿을 표준화하세요.
2) REFUTED가 발생했을 때
- 전략 A: 해당 claim만 제거하고 나머지로 답변 재구성
- 전략 B: 반박 근거를 포함해 “조건부”로 수정
- 전략 C: retrieval을 바꿔서(최신 문서 우선, 신뢰도 높은 출처 우선) 재생성
REFUTED는 단순 누락보다 위험하므로, 보통은 전체 답변을 재작성하거나 최소한 해당 문단을 제거합니다.
3) citation은 있는데 quote가 claim을 직접 지지하지 않을 때
이 경우는 “인용 형식은 갖췄는데 실질은 환각”인 패턴입니다.
- claim을 더 작게 쪼개 재검증
- evidence 스팬을 더 좁게(문장 단위) 재선택
- 그래도 안 되면 claim을 삭제
구현 예시: Python으로 검증 파이프라인 스켈레톤
아래 코드는 구조를 보여주는 예시입니다. 실제로는 벡터DB, 리랭커, LLM 호출부를 교체하면 됩니다.
from dataclasses import dataclass
from typing import List, Literal, Optional
Verdict = Literal["SUPPORTED", "REFUTED", "NOT_ENOUGH_INFO"]
@dataclass
class Citation:
doc_id: str
chunk_id: str
quote: str
@dataclass
class Claim:
id: str
text: str
citations: List[Citation]
verdict: Optional[Verdict] = None
score: float = 0.0
def verify_claim_with_judge(claim: str, quote: str) -> tuple[Verdict, str]:
# TODO: LLM judge 또는 NLI 모델 호출
# 반환: (판정, 이유)
raise NotImplementedError
def verdict_to_score(v: Verdict) -> float:
if v == "SUPPORTED":
return 1.0
if v == "NOT_ENOUGH_INFO":
return 0.3
return 0.0
def verify_claims(claims: List[Claim]) -> List[Claim]:
for c in claims:
if not c.citations:
c.verdict = "NOT_ENOUGH_INFO"
c.score = verdict_to_score(c.verdict)
continue
# 가장 강한 근거 1개로 우선 판정(필요하면 top-3 투표로 확장)
best = c.citations[0]
verdict, _reason = verify_claim_with_judge(c.text, best.quote)
c.verdict = verdict
c.score = verdict_to_score(verdict)
return claims
def gate_answer(claims: List[Claim], min_avg_score: float = 0.75) -> str:
avg = sum(c.score for c in claims) / max(len(claims), 1)
any_refuted = any(c.verdict == "REFUTED" for c in claims)
if any_refuted:
return "RETRY_OR_REMOVE_REFUTED"
if avg < min_avg_score:
return "NEED_MORE_RETRIEVAL_OR_ABSTAIN"
return "OK"
이 스켈레톤에서 중요한 포인트는 “판정 실패를 예외로 던져서 뭉개지 말고, 상태로 들고 가라”입니다. 파이프라인은 외부 API(LLM, 벡터DB)에 의존하므로 실패는 정상입니다. 스크립트/잡에서 실패 전파를 엄격히 하고 싶다면 bash set -euo pipefail로 스크립트 폭발 막기 같은 원칙을 CI와 운영 자동화에도 적용하세요.
프롬프트 설계 체크리스트(환각 억제용)
- “근거 없으면 모른다고 말하라”를 한 번이 아니라 출력 규칙으로 고정
- 답변 형식을 강제: 각 문단 끝에 citation 표기
- 수치/정의/정책 관련 문장은 반드시 quote를 요구
- “내부 지식 사용 금지”를 선언하고, 위반 시 패널티를 주는 judge 프롬프트 사용
주의: “내부 지식 사용 금지”는 완전한 보장이 아닙니다. 그래서 검증 단계가 필요합니다.
운영 관점: 지연, 비용, 신뢰도의 균형
Citation 검증을 붙이면 토큰/호출이 늘어납니다. 그래서 계층화가 필요합니다.
- 경량 모드: claim 추출 없이 문단 단위로 빠른 judge
- 표준 모드: claim 추출 + top-1 evidence 검증
- 엄격 모드: claim 추출 + top-3 evidence 투표 + 모순 탐지
또한 타임아웃과 재시도 정책이 중요합니다. 서버리스/컨테이너 환경에서 지연이 누적되면 사용자 경험이 급격히 나빠지므로, 콜드스타트와 타임아웃 설계는 GCP Cloud Run 504와 콜드스타트 지연 해결 가이드의 관점을 RAG API에도 그대로 적용할 수 있습니다.
평가 방법: “답변 정확도”가 아니라 “근거 정합성”을 측정하라
Citation 기반 파이프라인의 성능을 보려면 오프라인 평가셋이 필요합니다.
추천 지표:
- Claim support rate:
SUPPORTED / 전체 claim - Unsupported rate:
NOT_ENOUGH_INFO / 전체 claim - Refuted rate:
REFUTED / 전체 claim - Citation precision: 인용이 실제로 해당 claim을 지지하는 비율
- Coverage: claim에 citation이 붙은 비율
추가로 사용자에게 중요한 것은 “정답률”보다 “근거가 있는 정답률”입니다. 즉, 정답인데 근거가 틀리면 시스템 신뢰도는 오히려 떨어집니다.
마무리: 환각을 줄이는 가장 현실적인 방법
RAG 환각은 검색 품질만으로는 충분히 줄어들지 않습니다. 생성 모델이 컨텍스트를 받았더라도, 시스템이 주장 단위로 근거를 강제하고 검증하지 않으면 결국 그럴듯한 문장이 새어 나옵니다.
Citation 기반 검증 파이프라인의 요지는 단순합니다.
- 답변을 claim으로 쪼개고
- 각 claim을 근거 스팬에 연결한 뒤
- judge로
SUPPORTED/REFUTED/NOT_ENOUGH_INFO를 판정하고 - 점수에 따라 재검색/수정/거절로 게이팅한다
이 구조를 갖추면 “환각을 완전히 제거”하진 못해도, 최소한 근거 없는 단정을 제품 레벨에서 체계적으로 차단할 수 있습니다.