"환불 기간은 14일입니다 [출처: 환불정책_v2.pdf, p.3]" — RAG가 이렇게 답하면 우리는 안심합니다. 출처도 있고, 페이지도 있고. 그런데 정책서를 직접 열어보면 환불 기간은 30일입니다.
이런 일이 어떻게 가능할까요? Citation은 "이 답변은 이 문서에 근거합니다"라는 약속이 아닌가요?
답은 슬프게도 "아니요"입니다. Citation은 신뢰를 보장하지 않습니다. 출처가 있는데도 답변이 틀릴 수 있는 시나리오는 매우 다양합니다. 그리고 이런 실패는 일반적인 환각보다 훨씬 위험합니다 — 사용자가 "출처가 있으니 맞겠지"라고 더 쉽게 믿어버리기 때문입니다.
이 글에서는 Citation이 있어도 RAG 답변이 틀릴 수 있는 4가지 핵심 패턴을 분석하고, 진정한 의미의 Grounding 검증 패턴을 실전 코드와 함께 다룹니다.
목차
- Citation의 환상 — 출처가 있다고 정답은 아니다
- 4가지 Citation 실패 패턴 개요
- 패턴 1: 검색된 문서 자체가 잘못된 경우
- 패턴 2: 문서는 맞지만 부분 인용으로 왜곡
- 패턴 3: 여러 문서 종합 시 발생하는 추론 오류
- 패턴 4: Citation 자체의 환각
- Grounding 검증 — 답변이 진짜 문서에 근거하는가
- 구현 패턴 — Span 기반 Citation 강제
- UX 측면 — 사용자에게 어떻게 보여줄 것인가
- Citation 신뢰도를 높이는 6가지 원칙
1. Citation의 환상 — 출처가 있다고 정답은 아니다
먼저 Citation이 우리에게 주는 거짓 안전감을 직시해봅시다.
"출처 = 신뢰"라는 직관의 함정
사용자 심리:
"AI가 답하는 거니까 의심스럽긴 한데..."
↓ 출처가 보이면
"출처도 있네! 진짜네."
→ 출처의 존재만으로 신뢰도가 급상승
→ 출처의 정확성을 확인하지 않음
이 심리는 학술 논문에서도 익숙합니다. 인용된 논문이 어떤 내용인지 직접 확인하는 사람은 거의 없습니다. 인용의 존재 자체가 신뢰의 표지로 작동합니다.
이런 심리를 RAG에서 악용하면 — 의도하지 않더라도 — 사용자에게 매우 위험한 시스템이 됩니다.
Citation이 작동하는 4가지 단계
Citation의 정확성을 보장하려면 4단계가 모두 정확해야 합니다.
┌────────────────────────────────────────────────────────┐
│ Citation 정확성의 4단계 │
│ │
│ Step 1: 검색이 정답 문서를 찾았는가? │
│ - 검색 실패 → 잘못된 문서가 출처가 됨 │
│ │
│ Step 2: 문서가 실제로 정확한 정보를 담고 있는가? │
│ - 문서 자체가 오래됨 → 잘못된 출처 │
│ │
│ Step 3: LLM이 문서를 정확히 이해했는가? │
│ - 부분 인용, 맥락 무시 → 왜곡 │
│ │
│ Step 4: Citation 자체가 정확한가? │
│ - LLM이 출처를 환각으로 만들어낼 수 있음 │
│ │
│ → 4단계 중 1단계만 실패해도 Citation은 거짓이 됨 │
└────────────────────────────────────────────────────────┘
대부분의 RAG 시스템은 Step 1만 신경 씁니다. Step 2, 3, 4는 거의 무방비 상태입니다.
2. 4가지 Citation 실패 패턴 개요
┌──────────────────────────────────────────────────────┐
│ Citation 실패 4가지 패턴 │
│ │
│ Pattern 1: Wrong Source (잘못된 출처) │
│ - 검색이 잘못된 문서를 가져옴 │
│ - 답변과 출처는 일관되지만 사실은 틀림 │
│ │
│ Pattern 2: Misquoted (부분 인용 왜곡) │
│ - 검색은 맞지만 LLM이 일부만 인용해서 왜곡 │
│ - "예외 조항"을 무시한 답변 │
│ │
│ Pattern 3: Synthesis Error (종합 추론 오류) │
│ - 여러 문서를 잘못 결합 │
│ - 각 출처는 맞지만 결론은 환각 │
│ │
│ Pattern 4: Hallucinated Citation (출처 환각) │
│ - 존재하지 않는 출처 만들어냄 │
│ - 또는 다른 문서의 위치 잘못 참조 │
└──────────────────────────────────────────────────────┘
각 패턴을 자세히 살펴봅시다.
3. 패턴 1: 검색된 문서 자체가 잘못된 경우
가장 흔한 실패입니다. 검색이 정답 문서를 못 찾고 다른 문서를 가져옵니다.
구체적 시나리오
질문: "GPT-4o의 토큰당 가격은?"
벡터 DB의 문서들:
- doc_A: "GPT-4o 가격은 $2.50/1M 토큰" (정답)
- doc_B: "GPT-4 Turbo 가격은 $30/1M 토큰"
- doc_C: "GPT-4o-mini 가격은 $0.15/1M 토큰"
벡터 검색 결과 (실패 시나리오):
1위: doc_B (GPT-4 Turbo)
2위: doc_A (정답)
3위: doc_C
LLM 답변:
"GPT-4o의 토큰당 가격은 $30/1M입니다 [출처: doc_B]"
→ 출처는 명확히 표시됨
→ 사용자는 "출처도 있으니 맞겠지"
→ 실제로는 GPT-4 Turbo 가격
왜 이런 일이 생기는가
원인 1: 벡터 임베딩의 압축 손실
- "GPT-4o" vs "GPT-4 Turbo"가 의미적으로 너무 비슷
- 임베딩 차이가 0.001 수준
원인 2: Top-K가 작음
- Top-1만 사용 → 운에 따라 잘못된 문서
원인 3: Reranker 미적용
- Bi-encoder 한계로 미세한 차이 못 잡음
대응 — 검색 품질 강화 + 검증
이 패턴은 검색 단계에서 막아야 합니다.
def safe_search_with_validation(query: str, top_k: int = 5) -> list[dict]:
"""검증 단계 포함 검색"""
# 1. Hybrid search
results = hybrid_search(query, top_k=20)
# 2. Reranker
reranked = cohere_rerank(query, results, top_n=top_k)
# 3. 점수 검증
if reranked[0]["score"] < 0.7:
# 신뢰도 낮음
return []
# 4. 답변 전 검증: "이 문서가 질문에 답하나?"
validated = []
for doc in reranked:
is_relevant = llm_check_relevance(query, doc["text"])
if is_relevant:
validated.append(doc)
return validated
def llm_check_relevance(query: str, doc: str) -> bool:
"""LLM으로 문서 관련성 검증"""
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": """이 문서가 주어진 질문에 직접적으로 답할 수 있나요?
'예' 또는 '아니오'로만 답하세요. 부분적이거나 우회적이면 '아니오'."""},
{"role": "user", "content": f"질문: {query}\n문서: {doc[:500]}"}
],
temperature=0,
)
return "예" in response.choices[0].message.content
4. 패턴 2: 문서는 맞지만 부분 인용으로 왜곡
검색은 정확한 문서를 가져왔는데, LLM이 일부만 인용해서 답변이 왜곡되는 경우입니다.
시나리오
원본 문서 (환불 정책):
"환불은 구매 후 14일 이내 가능합니다.
단, 다음의 경우 환불이 불가합니다:
- 사용한 흔적이 있는 제품
- 디지털 콘텐츠 다운로드 후
- 맞춤 제작 상품
- 30일 경과한 식품류"
질문: "환불 기간은?"
LLM 답변:
"환불 기간은 14일입니다 [출처: 환불정책_v2.pdf]"
→ 사실 부분만 떼어보면 정답
→ 그러나 "단, 예외가 있다"는 정보가 사라짐
→ 사용자는 14일 내면 모두 환불 가능하다고 오해
이 패턴은 RAG에서 가장 흔하면서도 발견하기 어려운 실패입니다.
더 위험한 사례 — 의료/법률
원본 (법률 조항):
"본 계약은 일방의 사정으로 해지할 수 있다.
다만, 해지 시 위약금 30%를 지불해야 한다."
질문: "계약 해지 가능한가?"
답변: "네, 일방적으로 해지 가능합니다 [출처: 계약서 제5조]"
→ 위약금 정보 완전 누락
→ 사용자가 손해 볼 수 있음
대응 — Long Context 인용 + 강제 완전성
SYSTEM_PROMPT_FOR_COMPLETENESS = """
당신은 문서 기반 답변 전문가입니다.
## 절대 규칙
1. 답변에 직접 관련된 모든 조건/예외/제한사항을 반드시 포함하세요.
2. "단, ~한 경우" 같은 예외 조항이 있으면 반드시 언급하세요.
3. 부분 인용으로 사용자가 오해할 가능성이 있으면 컨텍스트 전체를 제공하세요.
## 좋은 답변 예시
질문: "환불 기간은?"
답변: "환불은 구매 후 14일 이내 가능합니다.
단, 다음의 경우 환불이 불가합니다:
- 사용한 흔적이 있는 제품
- 디지털 콘텐츠 다운로드 후
- 맞춤 제작 상품
[출처: 환불정책_v2.pdf]"
## 나쁜 답변 (절대 금지)
"환불 기간은 14일입니다." ← 예외 조항 누락은 금지
"""
자동 검증 — 누락 감지
def check_completeness(query: str, answer: str, source_doc: str) -> dict:
"""답변이 문서의 중요 정보를 누락하지 않았는지 검증"""
response = client.chat.completions.create(
model="gpt-4o",
messages=[
{"role": "system", "content": """
사용자가 답변을 보고 잘못된 결정을 내릴 수 있는 누락이 있는지 확인하세요.
특히 다음을 확인:
- 예외 조항 (단, 제외, 다만)
- 조건부 정보 (~인 경우에만)
- 위약금/제한사항
- 시간/기한 제약
- 특수 케이스
JSON: {"has_critical_omission": bool, "omitted_info": "...", "severity": "low/medium/high"}
"""},
{"role": "user", "content": f"질문: {query}\n답변: {answer}\n원본 문서: {source_doc}"}
],
response_format={"type": "json_object"},
temperature=0,
)
return json.loads(response.choices[0].message.content)
Smart Chunk Expansion — 문맥 확장
청크가 분리되어 있으면 예외 조항이 다른 청크에 있을 수 있습니다. Parent Document Retriever 패턴이 도움 됩니다.
# 검색은 작은 청크로 (정확도 ↑)
# 답변은 큰 청크/원문으로 (완전성 ↑)
def parent_doc_retriever(query: str):
# 작은 청크로 검색
small_chunks = vector_search(query, chunk_size=300)
# 부모 문서/큰 청크로 확장
parent_docs = []
for chunk in small_chunks:
parent_id = chunk["metadata"]["parent_id"]
parent = get_parent_doc(parent_id)
if parent not in parent_docs:
parent_docs.append(parent)
return parent_docs
5. 패턴 3: 여러 문서 종합 시 발생하는 추론 오류
RAG는 보통 여러 문서를 동시에 LLM에 줍니다. LLM이 그것들을 결합해서 답하는 과정에서 새로운 오류가 발생할 수 있습니다.
시나리오 — 잘못된 결합
검색된 문서들:
doc_A: "프리미엄 회원은 무료 배송 혜택을 받습니다"
doc_B: "주문 후 24시간 내 출고됩니다"
doc_C: "배송은 영업일 기준 2~3일 소요됩니다"
질문: "프리미엄 회원의 배송 시간은?"
LLM 답변:
"프리미엄 회원은 24시간 내 도착합니다
[출처: doc_A, doc_B]"
→ 각 출처는 정확
→ 그러나 "프리미엄 + 24시간 도착"은 거짓
→ doc_B는 출고 시간이지 도착 시간 아님
→ doc_C(2~3일 소요)와 모순
또 다른 사례 — 시간 무시
검색된 문서들 (다른 날짜):
doc_A (2023): "최저 금리는 3.5%입니다"
doc_B (2024): "최저 금리는 4.5%로 인상되었습니다"
질문: "현재 최저 금리는?"
LLM 답변:
"최저 금리는 3.5%~4.5% 범위입니다 [출처: doc_A, doc_B]"
→ 시간 차이를 인식하지 못함
→ 두 정보를 동시에 유효한 것으로 결합
→ 사실은 4.5%만 현재 정보
대응 — 시간 메타데이터 활용
SYSTEM_PROMPT_TIME_AWARE = """
참고 문서의 작성일/유효일을 반드시 확인하세요.
다음 규칙을 따르세요:
1. 같은 정보가 여러 문서에 있으면 가장 최신 것을 우선
2. 명시적으로 시간/날짜가 있는 정보는 그 시점만 유효
3. "현재" 또는 "최신"을 묻는 질문에는 가장 최근 정보만 사용
4. 확실하지 않으면 "최신 정보를 확인할 수 없다"고 답변
문서 메타데이터를 활용하세요:
- created_at: 문서 작성일
- updated_at: 최종 수정일
- valid_from / valid_until: 유효 기간
"""
Cross-Document 검증
def detect_synthesis_errors(query: str, answer: str, source_docs: list[dict]) -> dict:
"""여러 문서 종합 시 오류 감지"""
response = client.chat.completions.create(
model="gpt-4o",
messages=[
{"role": "system", "content": """
주어진 답변이 여러 출처를 잘못 결합한 부분이 있는지 검증하세요.
확인할 것:
1. 다른 출처의 내용을 잘못 연결한 추론이 있는가?
2. 시간/날짜가 다른 정보를 동시에 유효한 것으로 다뤘는가?
3. 한 출처의 조건/예외가 다른 출처의 결론에 적용되어야 하는데 누락됐는가?
JSON: {
"has_synthesis_error": bool,
"error_type": "wrong_connection/time_mix/missing_condition",
"explanation": "..."
}
"""},
{"role": "user", "content": f"질문: {query}\n답변: {answer}\n출처 문서들: {source_docs}"}
],
response_format={"type": "json_object"},
temperature=0,
)
return json.loads(response.choices[0].message.content)
6. 패턴 4: Citation 자체의 환각
가장 무서운 패턴입니다. LLM이 존재하지 않는 출처를 만들어내거나, 잘못된 위치를 참조합니다.
시나리오 — 가짜 페이지 번호
실제 검색된 문서:
"환불정책_v2.pdf" (총 5페이지)
LLM 답변:
"환불 기간은 14일입니다 [출처: 환불정책_v2.pdf, p.7]"
→ 5페이지 문서인데 "p.7" 참조
→ 사용자가 확인하면 존재하지 않는 페이지
→ 더 큰 문제: "p.3"이라고 했는데 p.3에 다른 내용
시나리오 — 가짜 문서명
실제 검색된 문서:
"FAQ_고객지원.md"
LLM 답변:
"환불 절차는 [출처: 환불처리_매뉴얼.pdf]에 명시되어 있습니다"
→ "환불처리_매뉴얼.pdf"는 검색되지 않은 문서
→ LLM이 그럴듯한 이름을 만들어냄
시나리오 — 모델이 학습 시 본 가짜 출처
질문: "GPT-4o의 컨텍스트 길이는?"
답변: "128,000 토큰입니다 [출처: OpenAI API Reference (2024)]"
→ "OpenAI API Reference"는 실제 검색된 문서 아님
→ 모델이 "권위 있어 보이는" 출처를 만들어냄
→ 사용자는 진짜 출처라고 믿음
대응 — 출처 형식 강제 + 검증
from pydantic import BaseModel
class CitationItem(BaseModel):
doc_id: str # 실제 검색된 문서 ID
quote: str # 직접 인용한 텍스트
class CitedAnswer(BaseModel):
answer: str
citations: list[CitationItem]
def safe_rag_answer(query: str, retrieved_docs: list[dict]) -> dict:
"""검증 가능한 Citation 강제"""
# 검색된 문서 ID와 텍스트만 명시적으로 제공
available_docs = {d["id"]: d["text"] for d in retrieved_docs}
available_ids = list(available_docs.keys())
system_prompt = f"""
참고 문서:
{json.dumps(available_docs, ensure_ascii=False, indent=2)}
# 절대 규칙
1. 답변의 모든 사실 주장에 citation 필수
2. citations[].doc_id는 반드시 다음 중 하나: {available_ids}
3. citations[].quote는 해당 문서에서 정확히 복사한 문장 (한 글자도 변경 금지)
4. 위 목록에 없는 doc_id를 사용하면 안 됨
5. 추론이나 일반 지식으로 답변 보완 금지
"""
response = client.beta.chat.completions.parse(
model="gpt-4o",
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": query}
],
response_format=CitedAnswer,
temperature=0,
)
result = response.choices[0].message.parsed
# 검증
validated_citations = []
for cite in result.citations:
# 1. doc_id가 실제 검색된 것인가?
if cite.doc_id not in available_docs:
print(f"⚠️ 환각 출처: {cite.doc_id}")
continue
# 2. quote가 실제 문서에 있는가?
doc_text = available_docs[cite.doc_id]
if cite.quote not in doc_text:
print(f"⚠️ 환각 인용: {cite.quote}")
continue
validated_citations.append(cite)
return {
"answer": result.answer,
"citations": validated_citations,
"all_validated": len(validated_citations) == len(result.citations)
}
Anthropic Citations 활용
Anthropic Claude는 Citations 기능을 네이티브 지원해서 환각 출처를 거의 막을 수 있습니다.
import anthropic
client = anthropic.Anthropic()
response = client.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=1024,
system="제공된 문서를 기반으로 답하세요.",
messages=[
{
"role": "user",
"content": [
{
"type": "document",
"source": {"type": "text", "data": doc_text},
"title": "환불정책_v2",
"citations": {"enabled": True}
},
{"type": "text", "text": query}
]
}
]
)
# 응답에 자동으로 정확한 인용 위치 포함
for block in response.content:
if block.type == "text":
text = block.text
if hasattr(block, "citations"):
for cite in block.citations:
# cite.cited_text는 실제 문서에서 추출된 텍스트
# 환각이 거의 불가능
print(f"인용: {cite.cited_text}")
print(f"출처: {cite.document_title}")
7. Grounding 검증 — 답변이 진짜 문서에 근거하는가
Grounding은 "답변의 모든 주장이 제공된 문서에 의해 뒷받침되는가"를 의미합니다. 이를 자동으로 검증하는 것이 Citation 신뢰성의 핵심입니다.
Grounding의 3단계
┌──────────────────────────────────────────────────────┐
│ Grounding 검증 3단계 │
│ │
│ Step 1: Claim 분리 │
│ - 답변을 개별 사실 주장(claims)으로 쪼갬 │
│ │
│ Step 2: 각 Claim의 Source 매핑 │
│ - 각 claim이 어느 문서/문장에 근거하는지 확인 │
│ │
│ Step 3: Entailment 검증 │
│ - source가 실제로 claim을 의미적으로 뒷받침하는가 │
│ - NLI 모델 또는 LLM 사용 │
└──────────────────────────────────────────────────────┘
Step 1: Claim 분리
def split_into_claims(answer: str) -> list[str]:
"""답변을 개별 사실 주장으로 분리"""
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": """
주어진 답변을 개별 사실 주장(atomic claims)으로 분리하세요.
각 claim은 하나의 검증 가능한 사실이어야 합니다.
JSON: {"claims": ["주장1", "주장2", ...]}
"""},
{"role": "user", "content": answer}
],
response_format={"type": "json_object"}
)
return json.loads(response.choices[0].message.content)["claims"]
# 사용
answer = "환불 기간은 14일입니다. 단, 디지털 콘텐츠는 환불이 불가합니다."
claims = split_into_claims(answer)
# ["환불 기간은 14일이다",
# "디지털 콘텐츠는 환불이 불가하다"]
Step 2: Source 매핑
def map_claims_to_sources(claims: list[str], docs: list[dict]) -> list[dict]:
"""각 claim이 어느 문서에 근거하는지 매핑"""
mapped = []
for claim in claims:
best_match = None
best_score = 0
for doc in docs:
# NLI 또는 임베딩 유사도로 매칭
score = check_support(doc["text"], claim)
if score > best_score:
best_score = score
best_match = doc
mapped.append({
"claim": claim,
"source": best_match,
"support_score": best_score,
})
return mapped
Step 3: NLI 검증
from transformers import pipeline
# NLI 모델 로드 (한 번만)
nli_pipe = pipeline(
"text-classification",
model="cross-encoder/nli-deberta-v3-base"
)
def check_entailment(premise: str, hypothesis: str) -> dict:
"""premise(문서)가 hypothesis(주장)를 entail 하는가"""
result = nli_pipe(
f"{premise}",
text_pair=hypothesis,
truncation=True,
)
# 결과: ENTAILMENT / NEUTRAL / CONTRADICTION
return {
"label": result[0]["label"],
"score": result[0]["score"],
"is_supported": result[0]["label"] == "ENTAILMENT" and result[0]["score"] > 0.7
}
종합 Grounding 검증
def verify_grounding(query: str, answer: str, docs: list[dict]) -> dict:
"""답변의 Grounding 종합 검증"""
# 1. Claim 분리
claims = split_into_claims(answer)
# 2. 각 claim 검증
verified_claims = []
unsupported = []
for claim in claims:
# 가장 관련 있는 문서 찾기
best_doc = find_most_relevant_doc(claim, docs)
# NLI로 검증
entailment = check_entailment(best_doc["text"], claim)
verified_claims.append({
"claim": claim,
"source_doc": best_doc["id"],
"is_supported": entailment["is_supported"],
"confidence": entailment["score"],
})
if not entailment["is_supported"]:
unsupported.append(claim)
return {
"is_grounded": len(unsupported) == 0,
"claims": verified_claims,
"unsupported_claims": unsupported,
"support_rate": (len(claims) - len(unsupported)) / len(claims),
}
검증 결과 활용
def safe_rag_with_grounding(query: str) -> dict:
"""Grounding 검증 포함 RAG"""
docs = retrieve(query)
answer = generate_answer(query, docs)
grounding = verify_grounding(query, answer, docs)
if not grounding["is_grounded"]:
if grounding["support_rate"] >= 0.8:
# 일부만 문제 → 문제 부분 표시
modified_answer = mark_unsupported_in_answer(answer, grounding["unsupported_claims"])
return {
"answer": modified_answer,
"warning": "일부 정보의 출처를 검증할 수 없습니다."
}
else:
# 대부분 문제 → 거부
return {
"answer": "답변의 신뢰성을 보장할 수 없어 답변을 드리지 못합니다.",
"details": grounding,
}
return {"answer": answer, "grounded": True}
8. 구현 패턴 — Span 기반 Citation 강제
가장 강력한 Citation 패턴은 Span 기반입니다. 답변의 각 부분이 문서의 어느 글자 범위에 근거하는지 명시합니다.
Span 기반 데이터 모델
from pydantic import BaseModel
class TextSpan(BaseModel):
doc_id: str
start: int
end: int
text: str # 검증용
class AnswerSegment(BaseModel):
text: str
sources: list[TextSpan] # 이 부분의 근거가 되는 spans
class GroundedAnswer(BaseModel):
segments: list[AnswerSegment]
def to_html(self) -> str:
"""답변 + 인용 강조 HTML"""
html = ""
for seg in self.segments:
cite_links = ", ".join(
f"<a href='#doc-{s.doc_id}-{s.start}'>📄</a>"
for s in seg.sources
)
html += f"<span>{seg.text} {cite_links}</span> "
return html
Span 검증
def validate_spans(answer: GroundedAnswer, docs: dict[str, str]) -> bool:
"""모든 span이 실제 문서와 일치하는지 검증"""
for seg in answer.segments:
for span in seg.sources:
if span.doc_id not in docs:
return False
actual_text = docs[span.doc_id][span.start:span.end]
if actual_text != span.text:
# Span이 가리키는 위치의 실제 텍스트가 다름
return False
return True
안전한 검색-답변-검증 파이프라인
def production_safe_rag(query: str) -> dict:
"""프로덕션 레벨 Grounding 보장 RAG"""
# 1. 검색
docs = retrieve(query, top_k=5)
docs_dict = {d["id"]: d["text"] for d in docs}
# 2. 답변 생성 (Span 강제)
answer = generate_with_spans(query, docs_dict)
# 3. Span 검증
if not validate_spans(answer, docs_dict):
# 환각 Citation 발견 → 재시도 또는 거부
return {"error": "Citation validation failed", "details": answer}
# 4. NLI 검증 (의미적 entailment)
grounding = verify_grounding(query, answer.to_text(), docs)
if not grounding["is_grounded"]:
return {"error": "Not grounded", "details": grounding}
return {"answer": answer, "verified": True}
9. UX 측면 — 사용자에게 어떻게 보여줄 것인가
기술적 검증만큼 중요한 것이 사용자에게 신뢰도를 솔직하게 표시하는 UX입니다.
신뢰도 표시 방식
좋은 UX:
┌────────────────────────────────────────┐
│ 환불 기간은 14일입니다. (95% 신뢰) │
│ │
│ 📄 출처: 환불정책_v2.pdf, 3페이지 │
│ ✅ 검증된 인용 │
│ "환불은 구매 후 14일 이내 가능" │
│ │
│ ⚠️ 단, 다음 예외 조항도 확인하세요: │
│ - 사용한 흔적 있는 제품 │
│ - 디지털 콘텐츠 │
└────────────────────────────────────────┘
나쁜 UX:
┌────────────────────────────────────────┐
│ 환불 기간은 14일입니다. │
│ [출처: 정책서] │
└────────────────────────────────────────┘
신뢰도 등급 표시
def get_confidence_level(score: float) -> dict:
if score >= 0.9:
return {"label": "매우 높음", "color": "green", "emoji": "✅"}
elif score >= 0.7:
return {"label": "높음", "color": "blue", "emoji": "🔵"}
elif score >= 0.5:
return {"label": "보통", "color": "yellow", "emoji": "⚠️"}
else:
return {"label": "낮음", "color": "red", "emoji": "🚨"}
Citation 인터랙션 — 클릭 가능
사용자가 Citation을 클릭하면 원문을 즉시 볼 수 있어야 합니다.
<div class="answer">
<p>환불 기간은 14일입니다.
<a class="citation"
data-doc="환불정책_v2.pdf"
data-page="3"
data-quote="환불은 구매 후 14일 이내 가능"
onclick="showSource(this)">[1]</a>
</p>
</div>
<div id="source-modal" hidden>
<h3>원문 확인</h3>
<p class="source-quote">...</p>
<button onclick="openFullDoc()">전체 문서 보기</button>
</div>
"검증되지 않은 답변" 명시
자동 검증을 통과하지 못한 답변은 명시적으로 표시합니다.
⚠️ 이 답변은 자동 검증을 완전히 통과하지 못했습니다.
아래 출처를 직접 확인해 주세요:
[출처1] [출처2]
사용자 피드백 수집
# UI에 추가
"이 답변이 도움 됐나요?"
[👍] [👎]
"왜요?" (선택)
[ ] 답변이 잘못됐어요
[ ] 출처가 잘못됐어요 ← 이게 핵심 신호
[ ] 정보가 부족해요
[ ] 잘 모르겠어요
"출처가 잘못됐어요" 클릭이 많으면 Citation 검증을 강화해야 한다는 신호입니다.
10. Citation 신뢰도를 높이는 6가지 원칙
마지막으로 Citation 신뢰도를 높이는 실전 원칙을 정리합니다.
원칙 1: Citation = 인용문 (Quote) 강제
단순히 "doc_id"만 표시하지 말고, 원문에서 직접 발췌한 인용문을 함께 표시합니다.
❌ 약함: "환불 기간은 14일입니다 [출처: 환불정책]"
✅ 강함: "환불 기간은 14일입니다
[출처: 환불정책 - "환불은 구매 후 14일 이내 가능"]"
원칙 2: 가능한 한 자동 검증 적용
- Span/Quote가 실제 문서에 있는지 검증
- NLI로 entailment 검증
- LLM으로 누락 정보 검증
검증을 통과하지 못한 답변은 사용자에게 보내지 마세요.
원칙 3: 시간 메타데이터 활용
- 모든 문서에 created_at, updated_at, valid_until 포함
- LLM에 시간 정보 명시적으로 전달
- "최신" 질문에는 가장 최근 문서만 사용
원칙 4: 부분 인용 위험 인식
답변이 짧을수록 위험합니다. 예외 조항을 놓칠 수 있습니다.
원칙: "Yes/No 답변에는 항상 조건을 함께 표시"
질문: "환불 가능한가요?"
나쁜 답변: "네, 가능합니다."
좋은 답변: "네, 14일 이내라면 가능합니다. 단, 사용 흔적이 있으면 불가능합니다."
원칙 5: 다중 출처 종합은 명시적으로
여러 문서를 결합한 답변은 사용자에게 그 사실을 알려줍니다.
"환불은 14일 [출처1]이고, 처리 시간은 3일입니다 [출처2]"
↑ 두 출처가 명확히 분리됨
❌ "환불은 14일이고 처리 시간은 3일입니다 [출처1, 출처2]"
↑ 어느 출처가 어느 정보인지 모호
원칙 6: 모르면 모른다고 — Citation 없으면 답하지 말라
def strict_rag(query: str) -> str:
docs = retrieve(query)
if not docs or docs[0]["score"] < 0.7:
return "신뢰할 수 있는 출처를 찾지 못해 답변을 드릴 수 없습니다."
answer_with_citations = generate_with_citations(query, docs)
if not answer_with_citations.citations:
return "답변의 출처를 명확히 할 수 없어 답변을 드리지 못합니다."
return answer_with_citations
마무리 — Citation은 약속이지 보장이 아니다
Citation은 "이 답변은 이 문서에 근거합니다"라는 약속입니다. 그 약속이 지켜졌는지는 별도로 검증해야 합니다.
┌──────────────────────────────────────────────────────────┐
│ Citation 신뢰성의 진실 │
│ │
│ "출처가 있다 ≠ 답변이 정확하다" │
│ │
│ Citation의 4가지 실패 패턴: │
│ │
│ ❶ 잘못된 출처 (검색 실패) │
│ ❷ 부분 인용 왜곡 (예외 조항 누락) │
│ ❸ 종합 추론 오류 (다른 출처를 잘못 결합) │
│ ❹ 출처 환각 (가짜 출처 만들어냄) │
│ │
│ 방어 패턴: │
│ - Span 기반 Citation 강제 │
│ - Quote 검증 (실제 문서에 존재하는가) │
│ - NLI 기반 Entailment 검증 │
│ - 시간 메타데이터 활용 │
│ - UX로 신뢰도 솔직히 표시 │
└──────────────────────────────────────────────────────────┘
핵심 원칙:
- Citation은 신뢰의 시작이지 끝이 아니다 — 검증이 본질
- Span/Quote 강제로 환각 출처 차단 — 위치까지 명시
- NLI로 의미 entailment 검증 — 단순 매칭으로는 부족
- 부분 인용 위험을 인식하라 — 예외 조항이 가장 위험
- 모르면 모른다고 — Citation 없으면 답변 거부
가장 좋은 RAG는 "출처를 많이 다는 RAG"가 아니라, "출처 없이는 답하지 않는 RAG"입니다. 신뢰는 화려한 인용 표시가 아니라, 그 인용이 진짜인지 검증할 수 있는 시스템에서 나옵니다.
'프로그래밍 PROGRAMMING > 인공지능 AI' 카테고리의 다른 글
| 왜 PDF를 넣으면 답변 품질이 떨어질까 — 표, 이미지, 페이지 구조를 AI가 못 읽는 문제 (1) | 2026.04.23 |
|---|---|
| Claude Design 시작하기: 말로 만들고, 대화로 다듬는 새로운 디자인 방식 (1) | 2026.04.22 |
| RAG 서버 구축하기 — 벡터DB 세팅부터 Java vs Python 언어 선택까지 (1) | 2026.04.18 |
| AI에서 RAG란 무엇인가? — 환각을 없애고 최신 지식을 주입하는 검색 증강 생성 (3) | 2026.04.17 |
| 클로드 코드 team-onboarding 스킬 - 신규 팀원 온보딩을 자동화하는 최신 기능 (5) | 2026.04.16 |