Published on

LangChain RAG 환각 줄이기 - 인용강제+검증체인

Authors

RAG를 붙이면 환각이 사라질 것 같지만, 실제 운영에서는 오히려 “검색은 했는데 답은 틀린” 케이스가 자주 나옵니다. 이유는 단순합니다. LLM은 근거를 찾는 엔진이 아니라, 그럴듯한 문장을 생성하는 엔진이고, RAG는 그 생성이 “근거를 참고하도록” 유도할 뿐 “근거만으로 말하도록” 강제하지 않기 때문입니다.

이 글에서는 LangChain 기반 RAG에서 환각을 줄이기 위해 실무에서 효과가 큰 두 가지 축을 다룹니다.

  • 인용 강제(Citation Enforcement): 답변의 모든 주장에 출처를 붙이게 만들고, 출처가 없으면 모른다고 말하게 강제
  • 검증 체인(Verification Chain): 생성된 답변을 다시 근거 문서와 대조해 “지원 여부”를 판정하고, 실패 시 재작성 또는 거절

검색 품질 자체가 흔들리면 어떤 안전장치도 한계가 있으니, 벡터 검색 튜닝은 별도로 점검하는 것이 좋습니다. 예를 들어 Qdrant를 쓴다면 RAG 검색품질 폭망? Qdrant HNSW 튜닝 체크리스트 같은 체크리스트를 먼저 통과시키는 것을 권합니다.

왜 RAG에서 환각이 계속 생기나

RAG 환각은 보통 아래 패턴으로 발생합니다.

  1. Retrieval은 성공: 관련 문서가 컨텍스트에 들어옴
  2. Generation이 과감: 문서에 없는 디테일을 “상식/추론”으로 채움
  3. 유사 근거 착시: 문서 일부 문장과 답변이 비슷해 보여 검수에서 놓침

또 다른 흔한 원인은 “문서가 컨텍스트에 들어왔는지”와 “답변이 문서에 의해 지지되는지”를 구분하지 않기 때문입니다. 즉, 컨텍스트 존재 여부가 아니라 주장 단위의 근거 연결이 필요합니다.

그래서 접근을 두 단계로 나눕니다.

  • 1단계: 답변이 근거를 반드시 인용하도록 출력 포맷을 강제
  • 2단계: 인용된 근거가 실제로 주장과 일치하는지 검증 체인으로 확인

설계 목표: “그럴듯함”이 아니라 “증거 기반”

실무 목표를 명확히 잡으면 구현이 쉬워집니다.

  • 답변은 문서에 있는 내용만 말한다
  • 각 문장(또는 주장 블록)마다 출처 ID를 붙인다
  • 출처가 부족하면 모른다로 종료한다
  • 검증에서 실패하면 재작성하거나 거절한다

여기서 중요한 포인트는 “모른다”를 실패가 아니라 정상 동작으로 취급하는 것입니다. 운영 서비스에서 환각은 장애에 가깝지만, “모른다”는 UX로 설계하면 됩니다.

인용 강제 1: 문서에 고유 ID를 부여하고, 답변 포맷을 고정

인용 강제의 핵심은 두 가지입니다.

  1. 컨텍스트 문서 조각마다 안정적인 식별자를 부여
  2. 모델 출력 포맷을 구조화(JSON 등) 해서 파싱 가능하게 만들기

문서 포맷 예시

아래처럼 각 chunk에 doc_id, chunk_id를 메타데이터로 넣고, 프롬프트에는 사람이 읽기 쉬운 형태로 노출합니다.

from langchain_core.documents import Document

def format_docs_with_citations(docs: list[Document]) -> str:
    lines = []
    for i, d in enumerate(docs):
        doc_id = d.metadata.get("doc_id", "unknown")
        chunk_id = d.metadata.get("chunk_id", i)
        source = f"{doc_id}#{chunk_id}"
        text = d.page_content.replace("\n", " ")
        lines.append(f"[SOURCE:{source}] {text}")
    return "\n".join(lines)

이제 모델은 [SOURCE:...] 토큰을 인용 키로 사용할 수 있습니다.

답변 스키마: 인용을 강제하는 구조

문장마다 출처를 붙이게 하려면, 자유 텍스트보다 주장 배열 형태가 훨씬 강합니다.

from pydantic import BaseModel, Field
from typing import List

class Claim(BaseModel):
    text: str = Field(description="하나의 주장 문장")
    citations: List[str] = Field(description="예: docA#3 형태의 출처 목록")

class AnswerWithCitations(BaseModel):
    answer: str = Field(description="최종 사용자에게 보여줄 요약 답변")
    claims: List[Claim]
    confidence: str = Field(description="low|medium|high")
    refusal: bool = Field(description="근거 부족으로 답변 거절 여부")

claims[].citations를 비우지 못하게 프롬프트로 못 박으면, 모델이 근거 없는 문장을 쓰기 어렵습니다.

LangChain 프롬프트: “근거 없으면 거절”을 규칙으로 승격

<> 같은 부등호는 MDX에서 문제를 일으킬 수 있어, 아래 예시는 인라인 코드와 일반 텍스트만 사용합니다.

from langchain_core.prompts import ChatPromptTemplate

prompt = ChatPromptTemplate.from_messages([
    ("system", 
     "너는 근거 기반 QA 어시스턴트다. 반드시 제공된 SOURCES에서만 답하라. "
     "각 주장에는 최소 1개 이상의 citations가 있어야 한다. "
     "SOURCES에 없는 내용은 추측하지 말고 refusal=true로 설정하고 answer에 '근거 부족'을 명시하라."),
    ("human", 
     "질문: {question}\n\n"
     "SOURCES:\n{sources}\n\n"
     "출력은 JSON 하나로만 반환하라.")
])

이 단계만으로도 “근거 없는 디테일 덧붙이기”가 크게 줄어듭니다. 하지만 아직 남는 문제가 있습니다.

  • 모델이 엉뚱한 SOURCE를 끼워 넣는 경우
  • SOURCE 문장을 과대 해석하는 경우

그래서 2단계가 필요합니다.

검증 체인: 답변을 다시 근거로 채점하고, 실패 시 재작성

검증 체인은 간단히 말해 “LLM으로 LLM을 감시”하는 구조입니다. 핵심은 검증 LLM이 창작을 하지 않도록 역할을 좁히는 것입니다.

  • 입력: claims, citations, 원문 chunk
  • 출력: 각 claim이 지원됨/부분지원/미지원 인지
  • 정책: 미지원이 하나라도 있으면 재작성 또는 거절

검증 프롬프트

검증 모델은 답을 새로 만들지 않고, 오직 판정만 합니다.

verify_prompt = ChatPromptTemplate.from_messages([
    ("system",
     "너는 엄격한 사실 검증기다. 제공된 EVIDENCE만 보고 CLAIM이 지원되는지 판정하라. "
     "새로운 사실을 생성하지 마라. 출력은 JSON으로만."),
    ("human",
     "CLAIM: {claim}\n\n"
     "CITATIONS: {citations}\n\n"
     "EVIDENCE:\n{evidence}\n\n"
     "JSON 필드: verdict(supported|partial|unsupported), rationale")
])

LangChain 체인 구성 예시

아래 코드는 개념을 보여주는 예시입니다. 실제로는 모델, retriever, 파서 등을 프로젝트에 맞게 교체하면 됩니다.

from langchain_openai import ChatOpenAI
from langchain_core.output_parsers import JsonOutputParser
from langchain_core.runnables import RunnableLambda

llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
parser = JsonOutputParser()

def build_evidence_map(docs):
    m = {}
    for d in docs:
        k = f"{d.metadata.get('doc_id')}#{d.metadata.get('chunk_id')}"
        m[k] = d.page_content
    return m

def verify_claims(answer_json, evidence_map):
    results = []
    for c in answer_json["claims"]:
        citations = c.get("citations", [])
        evidence = "\n\n".join([f"[{k}] {evidence_map.get(k, '')}" for k in citations])
        out = (verify_prompt | llm | parser).invoke({
            "claim": c["text"],
            "citations": ", ".join(citations),
            "evidence": evidence,
        })
        results.append({"claim": c["text"], "check": out})
    return results

verify_chain = RunnableLambda(lambda x: verify_claims(x["answer"], x["evidence_map"]))

이제 전체 흐름은 다음처럼 됩니다.

  1. retriever로 docs 가져오기
  2. 인용 강제 프롬프트로 AnswerWithCitations 생성
  3. evidence map을 만들고 claim별로 검증
  4. 하나라도 unsupported면 재작성 또는 거절

재작성 정책: “다시 쓰기”보다 “범위를 줄이기”가 효과적

검증이 실패했을 때 단순히 “다시 써”라고 하면, 모델이 또 다른 환각으로 메꿀 수 있습니다. 재작성 프롬프트는 범위 축소를 강제해야 합니다.

  • unsupported claim은 제거
  • supported claim만 남겨 요약
  • citations가 부족하면 refusal
rewrite_prompt = ChatPromptTemplate.from_messages([
    ("system",
     "너는 RAG 답변 편집기다. 검증에서 unsupported인 주장은 삭제하라. "
     "남은 주장만으로 답변을 재구성하라. citations 없는 문장은 금지. "
     "남길 내용이 부족하면 refusal=true."),
    ("human",
     "ORIGINAL_JSON: {original}\n\n"
     "VERIFICATION_RESULTS: {verifications}\n\n"
     "SOURCES:\n{sources}\n\n"
     "수정된 JSON만 출력하라.")
])

이 정책은 “맞는 것만 말하기”를 시스템적으로 구현합니다.

운영에서 자주 놓치는 디테일 6가지

1) Top-k를 늘리면 환각이 줄까

대개 반대입니다. 컨텍스트가 길어질수록 모델은 더 많은 문장을 조합해 그럴듯한 결론을 만들고, 인용도 “아무거나” 붙이기 쉬워집니다. 우선은 k를 보수적으로 두고, 질의 확장이나 rerank로 품질을 올리는 편이 낫습니다.

2) chunk가 너무 크면 인용이 무의미해진다

chunk가 크면 [SOURCE:doc#1] 하나가 너무 많은 내용을 포함해 검증이 느슨해집니다. 반대로 너무 작으면 근거가 분절되어 unsupported가 늘어납니다. 도메인마다 다르지만 “한 chunk에 한 주제”가 유지되는 크기를 찾는 게 중요합니다.

3) 인용 문자열은 안정적으로

인용 키는 doc_id#chunk_id처럼 불변이어야 합니다. 검색 결과 순번 1,2,3 같은 것은 요청마다 바뀌어 디버깅이 어려워집니다.

4) 검증 모델은 더 작은 모델로도 가능

검증은 창작이 아니라 판정이므로, 비용을 줄이기 위해 작은 모델을 붙이는 전략이 자주 통합니다. 다만 도메인이 매우 전문적이면 검증 모델도 성능이 필요합니다.

5) 레이트 리밋과 재시도

인용 생성과 검증을 분리하면 호출 수가 늘어납니다. 운영에서 429가 터지면 품질 이전에 장애가 됩니다. 재시도/백오프는 필수로 넣으세요. 관련해서는 OpenAI API 429 RateLimit 재시도·백오프 실무 패턴을 그대로 적용하면 됩니다.

6) 로깅은 “최종 답”보다 “주장-근거 매핑”이 핵심

환각 디버깅은 최종 텍스트만 봐서는 어렵습니다. 아래를 함께 저장하면 재현성이 올라갑니다.

  • 질문
  • retrieval 결과의 doc_id#chunk_id 목록과 원문
  • claims 배열
  • claim별 검증 verdict
  • 재작성 전후 JSON

최소 동작 예제: end-to-end 파이프라인 스케치

아래는 전체를 한 번에 묶은 간단 스케치입니다.

from langchain_core.runnables import RunnablePassthrough
from langchain_core.output_parsers import JsonOutputParser

answer_parser = JsonOutputParser()

def rag_pipeline(retriever):
    def retrieve(question: str):
        docs = retriever.invoke(question)
        return {
            "question": question,
            "docs": docs,
            "sources": format_docs_with_citations(docs),
            "evidence_map": build_evidence_map(docs),
        }

    def generate(state):
        answer = (prompt | llm | answer_parser).invoke({
            "question": state["question"],
            "sources": state["sources"],
        })
        state["answer"] = answer
        return state

    def verify(state):
        verifications = verify_claims(state["answer"], state["evidence_map"])
        state["verifications"] = verifications
        return state

    def maybe_rewrite(state):
        has_unsupported = any(v["check"]["verdict"] == "unsupported" for v in state["verifications"])
        if not has_unsupported:
            return state
        rewritten = (rewrite_prompt | llm | answer_parser).invoke({
            "original": state["answer"],
            "verifications": state["verifications"],
            "sources": state["sources"],
        })
        state["answer"] = rewritten
        return state

    chain = RunnableLambda(retrieve) | RunnableLambda(generate) | RunnableLambda(verify) | RunnableLambda(maybe_rewrite)
    return chain

이 파이프라인의 장점은 “환각을 없애는 마법”이 아니라, 환각이 나왔을 때 잡아내고 차단하는 구조를 만든다는 점입니다.

마무리: 환각을 줄이는 가장 현실적인 방법

LangChain RAG에서 환각을 줄이는 실전 해법은 모델 파라미터를 만지기보다, 다음을 시스템으로 강제하는 것입니다.

  • 답변을 주장 단위로 쪼개고, 각 주장에 인용을 붙인다
  • 인용이 비면 거절한다
  • 생성 결과를 근거와 대조하는 검증 체인을 둔다
  • 실패 시 재작성은 “추가 설명”이 아니라 “범위 축소”로 유도한다

그리고 잊기 쉬운 전제는, retrieval 품질이 바닥이면 어떤 검증도 “거절만 잘하는 시스템”이 되기 쉽다는 것입니다. 검색 품질이 흔들린다면 먼저 RAG 검색품질 폭망? Qdrant HNSW 튜닝 체크리스트 같은 튜닝부터 안정화한 뒤, 인용 강제와 검증 체인을 올리는 순서가 가장 비용 대비 효과가 좋습니다.