프로그래밍 PROGRAMMING/인공지능 AI

출처가 있어도 답변이 틀리는 이유 — Citation이 신뢰를 보장하지 않는 순간들

매운할라피뇨 2026. 4. 21. 08:50
반응형

"환불 기간은 14일입니다 [출처: 환불정책_v2.pdf, p.3]" — RAG가 이렇게 답하면 우리는 안심합니다. 출처도 있고, 페이지도 있고. 그런데 정책서를 직접 열어보면 환불 기간은 30일입니다.
이런 일이 어떻게 가능할까요? Citation은 "이 답변은 이 문서에 근거합니다"라는 약속이 아닌가요?
답은 슬프게도 "아니요"입니다. Citation은 신뢰를 보장하지 않습니다. 출처가 있는데도 답변이 틀릴 수 있는 시나리오는 매우 다양합니다. 그리고 이런 실패는 일반적인 환각보다 훨씬 위험합니다 — 사용자가 "출처가 있으니 맞겠지"라고 더 쉽게 믿어버리기 때문입니다.
이 글에서는 Citation이 있어도 RAG 답변이 틀릴 수 있는 4가지 핵심 패턴을 분석하고, 진정한 의미의 Grounding 검증 패턴을 실전 코드와 함께 다룹니다.
 

목차

  1. Citation의 환상 — 출처가 있다고 정답은 아니다
  2. 4가지 Citation 실패 패턴 개요
  3. 패턴 1: 검색된 문서 자체가 잘못된 경우
  4. 패턴 2: 문서는 맞지만 부분 인용으로 왜곡
  5. 패턴 3: 여러 문서 종합 시 발생하는 추론 오류
  6. 패턴 4: Citation 자체의 환각
  7. Grounding 검증 — 답변이 진짜 문서에 근거하는가
  8. 구현 패턴 — Span 기반 Citation 강제
  9. UX 측면 — 사용자에게 어떻게 보여줄 것인가
  10. 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: 여러 문서 종합 시 발생하는 추론 오류

320x100

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로 신뢰도 솔직히 표시                                │
└──────────────────────────────────────────────────────────┘

핵심 원칙:

  1. Citation은 신뢰의 시작이지 끝이 아니다 — 검증이 본질
  2. Span/Quote 강제로 환각 출처 차단 — 위치까지 명시
  3. NLI로 의미 entailment 검증 — 단순 매칭으로는 부족
  4. 부분 인용 위험을 인식하라 — 예외 조항이 가장 위험
  5. 모르면 모른다고 — Citation 없으면 답변 거부

가장 좋은 RAG는 "출처를 많이 다는 RAG"가 아니라, "출처 없이는 답하지 않는 RAG"입니다. 신뢰는 화려한 인용 표시가 아니라, 그 인용이 진짜인지 검증할 수 있는 시스템에서 나옵니다.

반응형