- Published on
LangChain RAG 환각 줄이기 - Citations 강제
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서치와 요약이 결합된 RAG는 환각을 줄여주지만, 실제 운영에서는 여전히 "그럴듯한데 근거가 없는" 문장이 섞입니다. 특히 다음 상황에서 환각이 다시 튀어나옵니다.
- 검색 결과가 애매하거나 상충될 때 모델이 "중간 결론"을 만들어냄
- 컨텍스트에 없는 내용을 일반 상식으로 보강함
- 답변이 길어질수록 근거 없는 문장이 증가함
- UI에서 출처를 보여주지 않으니 사용자도 검증하지 못함
해결책은 단순합니다. 답변을 citations(근거)와 함께만 출력하도록 강제하고, 근거가 없으면 "모른다"로 수렴시키는 정책을 넣습니다. 이 글에서는 LangChain에서 이를 구현하는 4단계 패턴을 다룹니다.
- 문장 단위 citations 스키마 설계
- 모델 출력 형식 강제(Structured Output)
- citations 검증 및 자동 재시도(Repair Loop)
- UI/로그로 추적 가능한 형태로 저장
RAG 자체 튜닝(하이브리드 검색, 리랭킹, chunk 전략 등)은 별도 축입니다. 검색 품질을 먼저 끌어올려야 한다면 RAG 품질 급락? 벡터DB 하이브리드검색 튜닝도 함께 보세요.
왜 "Citations 강제"가 환각을 줄이나
일반적인 RAG 프롬프트는 이런 식입니다.
- "아래 문서를 참고해서 답하라"
하지만 LLM은 "참고"를 "반드시 인용"으로 해석하지 않습니다. 그래서 컨텍스트에 없는 문장을 섞어도 스스로는 일관된 답변이라고 믿고 출력합니다.
반면 citations를 강제하면 모델의 목표가 바뀝니다.
- 각 주장(claim)을 문서 조각(evidence)에 매핑해야 함
- 매핑이 실패하면 주장 자체를 포기해야 함
즉 "그럴듯함"보다 "증명 가능성"을 최적화하게 됩니다.
설계 원칙: 문장 단위 claim과 evidence를 분리
운영에서 가장 많이 실패하는 패턴은 "답변 끝에 출처 링크 몇 개"입니다. 이는 다음 문제를 만듭니다.
- 어느 문장이 어느 근거인지 추적 불가
- 근거가 없는 문장을 섞어도 티가 안 남
- QA나 감사(audit)에서 방어 불가
따라서 문장 단위로 citations를 붙이는 구조가 좋습니다.
권장 JSON 스키마 예시(답변 문장 배열 + 각 문장의 citations 배열):
answer: 최종 렌더링 가능한 문장 리스트citations: 각 문장에 대한 근거 목록citation은 최소doc_id,chunk_id,quote(원문 일부),start_char,end_char같은 필드를 포함
이때 MDX에서 부등호가 빌드 에러를 유발할 수 있으므로, 코드 내 -> 같은 표기도 피하거나 백틱으로 감쌉니다.
LangChain 구현 1: 문서에 안정적인 메타데이터 부여
retriever가 반환하는 Document에 메타데이터가 없거나, 매 호출마다 바뀌면 citations가 무의미해집니다. ingest 단계에서 다음을 고정하세요.
doc_id: 소스 문서 식별자(예: URL 해시)chunk_id: chunk 순번source: 사람이 읽을 수 있는 출처(파일명/URL)
from langchain_core.documents import Document
def make_docs(chunks, source: str, doc_id: str):
docs = []
for i, text in enumerate(chunks):
docs.append(
Document(
page_content=text,
metadata={
"doc_id": doc_id,
"chunk_id": i,
"source": source,
},
)
)
return docs
LangChain 구현 2: Structured Output으로 citations 스키마 강제
핵심은 "프롬프트로 부탁"이 아니라 출력 파서를 통해 형식을 강제하는 것입니다. LangChain에서는 모델에 따라 with_structured_output 또는 JSON 스키마 기반 출력을 사용할 수 있습니다.
아래는 Pydantic 모델로 스키마를 정의하고, 모델 출력이 이 스키마를 만족하도록 강제하는 예시입니다.
from typing import List, Optional
from pydantic import BaseModel, Field
class Citation(BaseModel):
doc_id: str
chunk_id: int
source: str
quote: str = Field(..., description="원문에서 그대로 가져온 짧은 인용")
class AnswerSentence(BaseModel):
text: str
citations: List[Citation] = Field(default_factory=list)
class RAGAnswer(BaseModel):
sentences: List[AnswerSentence]
refused: bool = False
refusal_reason: Optional[str] = None
프롬프트는 "각 문장에 최소 1개 이상의 citation"을 요구하고, 인용은 반드시 컨텍스트에 있는 문장 일부를 quote로 복사하도록 지시합니다.
from langchain_core.prompts import ChatPromptTemplate
prompt = ChatPromptTemplate.from_messages(
[
(
"system",
"""
너는 RAG 답변 생성기다.
규칙:
- 출력은 지정된 JSON 스키마를 따른다.
- 각 문장에는 최소 1개의 citations가 있어야 한다.
- citations.quote는 제공된 컨텍스트에서 그대로 복사한 짧은 문장 조각이어야 한다.
- 컨텍스트에 근거가 없으면 refused를 true로 하고 refusal_reason에 이유를 적는다.
""",
),
(
"user",
"""
질문: {question}
컨텍스트:
{context}
""",
),
]
)
모델과 연결:
from langchain_openai import ChatOpenAI
llm = ChatOpenAI(model="gpt-4.1-mini", temperature=0)
structured_llm = llm.with_structured_output(RAGAnswer)
chain = prompt | structured_llm
여기서 temperature=0은 citations 강제와 궁합이 좋습니다. 다양한 표현을 탐색하기보다, 컨텍스트에 "딱 붙는" 출력을 내도록 유도합니다.
LangChain 구현 3: 컨텍스트 구성 시 citations 친화적으로 만들기
컨텍스트를 단순히 이어붙이면 모델이 "어디서 나온 문장인지"를 헷갈립니다. 각 chunk에 헤더를 붙여서 인용을 쉽게 만드세요.
def format_docs_for_context(docs):
parts = []
for d in docs:
header = f"[doc_id={d.metadata['doc_id']} chunk_id={d.metadata['chunk_id']} source={d.metadata['source']}]"
parts.append(header + "\n" + d.page_content)
return "\n\n".join(parts)
이 포맷은 모델이 citations를 만들 때 doc_id와 chunk_id를 정확히 채우는 데 도움이 됩니다.
LangChain 구현 4: citations 검증(Validation)과 재시도(Repair Loop)
Structured Output만으로도 꽤 줄지만, 운영에서는 다음이 남습니다.
quote가 컨텍스트에 실제로 존재하지 않음doc_id/chunk_id가 컨텍스트에 없는 값- 어떤 문장은 citations가 비어 있음
따라서 생성 후 검증을 넣고, 실패하면 "수정 프롬프트"로 짧게 재시도하는 루프를 권장합니다.
검증 로직 예시:
import re
def validate_answer(answer: RAGAnswer, docs) -> list[str]:
errors = []
# 컨텍스트에서 허용되는 (doc_id, chunk_id) 집합
allowed = {(d.metadata["doc_id"], d.metadata["chunk_id"]) for d in docs}
context_text = "\n".join([d.page_content for d in docs])
for i, s in enumerate(answer.sentences):
if not s.citations:
errors.append(f"sentence[{i}] has no citations")
continue
for j, c in enumerate(s.citations):
if (c.doc_id, c.chunk_id) not in allowed:
errors.append(f"sentence[{i}].citations[{j}] invalid doc_id/chunk_id")
# quote가 컨텍스트에 실제 포함되는지(너무 엄격하면 false negative 가능)
q = re.sub(r"\s+", " ", c.quote).strip()
if q and q not in re.sub(r"\s+", " ", context_text):
errors.append(f"sentence[{i}].citations[{j}] quote not found in context")
return errors
재시도 루프 예시(최대 2회):
from langchain_core.messages import SystemMessage, HumanMessage
def run_with_repair(question: str, retriever, max_retries: int = 2):
docs = retriever.get_relevant_documents(question)
context = format_docs_for_context(docs)
result = chain.invoke({"question": question, "context": context})
errors = validate_answer(result, docs)
tries = 0
while errors and tries < max_retries:
tries += 1
repair_prompt = ChatPromptTemplate.from_messages(
[
(
"system",
"""
너는 RAG 답변 JSON을 고치는 역할이다.
규칙:
- 기존 답변을 유지하되, 오류가 난 citations만 수정한다.
- quote는 반드시 컨텍스트에서 그대로 복사한다.
- 근거를 찾을 수 없는 문장은 삭제하거나 refused로 전환한다.
""",
),
(
"user",
"""
질문: {question}
컨텍스트:
{context}
기존 JSON:
{bad_json}
검증 오류 목록:
{errors}
""",
),
]
)
repair_chain = repair_prompt | structured_llm
result = repair_chain.invoke(
{
"question": question,
"context": context,
"bad_json": result.model_dump_json(),
"errors": "\n".join(errors),
}
)
errors = validate_answer(result, docs)
return result, docs, errors
이 패턴의 장점은 "환각을 없애는" 것이 아니라, 환각을 시스템적으로 걸러내고 실패를 안전하게 처리한다는 점입니다.
운영 팁: "모른다"를 제품적으로 허용하기
citations 강제를 하면 거짓말은 줄지만, 대신 refused가 늘 수 있습니다. 이걸 실패로 취급하면 다시 환각을 허용하게 됩니다.
권장 정책:
- refused는 정상 응답 타입으로 취급(HTTP
200+ 상태 필드) - refused 시 다음 액션 제공
- 질문을 좁히기
- 검색 범위 확장(필터 완화)
- 최신 문서 ingest 요청
또한 refused 비율이 급증하면 검색 품질 저하나 색인 문제일 수 있습니다. 이런 "원인 추적" 관점은 인프라 트러블슈팅과 유사합니다. 재현이 어려운 장애를 단계적으로 좁히는 방식은 systemd 서비스 자동 재시작 원인 추적 가이드 같은 글의 접근법과도 통합니다.
UI/로그 설계: 사용자가 출처를 클릭할 수 있어야 한다
citations를 모델 내부에서만 쓰면 효과가 반감됩니다. 최소한 다음을 UI에 노출하세요.
- 문장 끝에
[1]같은 마커 - 클릭 시
source와quote표시 - 가능하면 원문 뷰어에서 해당 chunk로 스크롤
저장 형태는 보통 다음 두 가지가 실용적입니다.
- 원본
RAGAnswerJSON 그대로 저장(감사 및 재현에 유리) - 렌더링용 평문 + citation index를 별도 테이블로 정규화(검색/통계에 유리)
흔한 함정 6가지
1) quote가 너무 길다
quote가 길면 모델이 컨텍스트를 "복사"하는 쪽으로 도망갑니다. quote는 1~2문장, 또는 200자 제한 같은 정책을 두세요.
2) chunk가 너무 작거나 너무 크다
너무 작으면 근거가 분절되어 문장 하나를 뒷받침하기 어렵고, 너무 크면 어디를 인용해야 할지 애매해집니다. citations 강제 환경에서는 "한 chunk가 하나의 주장 단위를 담도록" 조정하는 것이 유리합니다.
3) retriever가 상위 k만 주고 다양성이 없다
동일 문서에서 비슷한 chunk만 오면, 모델이 반대 근거를 못 보고 과감한 결론을 씁니다. MMR 같은 다양성 옵션을 고려하세요.
4) 모델이 doc_id를 지어낸다
컨텍스트 헤더에 doc_id를 명시하고, 검증에서 허용 집합을 강제하세요.
5) citations가 있어도 claim이 과장된다
"인용은 했는데 결론이 과장"되는 경우가 있습니다. 이때는 문장 스타일 가이드를 추가하세요.
- "인용된 문장에서 직접적으로 말하는 범위까지만 서술"
- "추론이 필요하면 '추정'으로 표시"
6) 평가를 안 한다
환각은 체감이 아니라 지표로 관리해야 합니다.
- citation coverage: 문장 중 citations가 있는 비율
- quote validity: quote가 컨텍스트에 존재하는 비율
- refusal rate: refused 비율
- user trust signals: 출처 클릭률, 피드백
최소 동작 예제: LCEL로 RAG + citations 체인
아래는 "검색 - 컨텍스트 포맷 - 구조화 출력"의 최소 예시입니다.
from langchain_core.runnables import RunnablePassthrough
# retriever는 이미 구성되어 있다고 가정
def build_chain(retriever):
def get_context(q):
docs = retriever.get_relevant_documents(q)
return {
"docs": docs,
"context": format_docs_for_context(docs),
}
rag = (
RunnablePassthrough()
.assign(**{"bundle": lambda x: get_context(x["question"])})
.assign(
context=lambda x: x["bundle"]["context"],
)
| chain
)
return rag
운영에서는 여기에 앞서 소개한 validate_answer와 repair loop를 붙여 "항상 citations가 유효한 응답"만 통과시키면 됩니다.
마무리: 환각을 "막는" 게 아니라 "통제"하는 구조
RAG 환각을 줄이는 방법은 많지만, citations 강제는 제품 관점에서 특히 강력합니다.
- 모델이 근거 없는 문장을 쓰기 어렵게 만들고
- 시스템이 근거를 검증하며
- UI가 사용자의 신뢰 검증을 돕습니다.
결국 목표는 "환각 0"이 아니라, 근거가 없으면 말하지 않는 시스템입니다. citations 스키마 강제 + 검증 + 재시도 루프까지 넣으면, LangChain RAG를 운영 가능한 형태로 한 단계 끌어올릴 수 있습니다.