- Published on
LangChain RAG 환각 줄이기 - 출처강제+재랭킹
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
RAG(Retrieval-Augmented Generation)를 붙였는데도 모델이 그럴듯한 거짓말(환각)을 하는 경우가 많습니다. 원인은 단순히 top_k가 작아서가 아니라, (1) 검색 결과가 답과 충분히 관련이 없거나 (2) 관련 문서가 섞여 들어와도 LLM이 근거 없이 일반지식을 섞어 말하거나 (3) 근거가 있어도 답변에 출처를 연결하지 못하는 문제로 나뉩니다.
이 글은 LangChain 기반 RAG에서 환각을 줄이는 데 효과가 큰 두 가지 패턴을 묶어 설명합니다.
- 출처강제(Citation Enforcement): 답변의 모든 주장에 대해 문서 출처를 붙이게 하고, 근거가 없으면 모른다고 말하게 만들기
- 재랭킹(Re-ranking): 벡터 검색 결과를 그대로 쓰지 않고, 크로스 인코더/LLM 기반 재정렬로 “답에 쓸 근거”를 상위로 끌어올리기
추가로, 두 패턴이 실제로 환각을 줄였는지 확인하는 평가 체크리스트까지 다룹니다.
환각이 생기는 RAG의 구조적 이유
RAG 파이프라인을 단순화하면 다음 단계입니다.
- 쿼리 생성(또는 리라이트)
- 벡터 검색(유사도 기반)
- (선택) 재랭킹
- 컨텍스트 구성(문서 조합, 길이 제한, 중복 제거)
- LLM 생성
여기서 환각은 보통 2가지 지점에서 발생합니다.
- 검색 단계의 노이즈: 벡터 검색은 “의미가 비슷한 문서”를 가져오지 “질문에 대한 정답 근거”만 가져오지 않습니다. 특히 사내 위키처럼 문서가 길고 주제가 넓으면, 질문과 일부 단어가 겹치는 문서가 상위에 섞입니다.
- 생성 단계의 자유도: LLM은 컨텍스트가 애매하면 일반 상식으로 메워서 답을 완성하려는 경향이 있습니다. 즉, 컨텍스트가 부족하거나 상충하면 환각 확률이 올라갑니다.
따라서 환각을 줄이려면 **검색 품질을 올리는 것(재랭킹)**과 **생성 규칙을 강하게 거는 것(출처강제)**를 동시에 적용하는 편이 안정적입니다.
참고로 벡터 검색 인덱스 자체의 품질도 중요합니다. Postgres pgvector를 쓴다면 인덱스/파라미터 튜닝으로 1차 검색 품질을 개선할 수 있습니다. 관련 내용은 pgvector RAG 인덱스 튜닝 - IVFFlat·HNSW 실전을 함께 보시면 좋습니다.
전략 1) 출처강제: “근거 없으면 답하지 마”를 시스템화
출처강제의 핵심은 단순히 “출처를 달아줘”가 아니라, 출처가 없으면 해당 문장을 생성할 수 없게 만드는 제약을 설계하는 것입니다.
출처강제 프롬프트의 필수 조건
다음 3가지를 프롬프트에 명시하면 효과가 큽니다.
- 답변은 제공된 컨텍스트만 사용
- 각 문장(또는 각 주장)마다 출처 id를 붙임
- 근거가 없으면
모른다로 답하고 추가 질문을 요청
또한 문서 조각마다 source_id를 부여해, LLM이 참조할 수 있는 “고정된 앵커”를 제공합니다.
LangChain 예시: 문서에 source_id 부여 + 인용 강제
아래 코드는 LangChain에서 Document.metadata에 source_id를 넣고, 답변을 JSON으로 강제한 뒤 파싱하는 패턴입니다. JSON 강제는 “출처 없는 문장”을 후처리에서 걸러내기 쉽다는 장점이 있습니다.
from langchain_openai import ChatOpenAI
from langchain_core.documents import Document
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import JsonOutputParser
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
docs = [
Document(
page_content="서비스 A의 토큰 만료 시간은 30분이다.",
metadata={"source_id": "S1", "title": "Auth Spec", "url": "https://intra/wiki/auth"},
),
Document(
page_content="리프레시 토큰은 14일 동안 유효하다.",
metadata={"source_id": "S2", "title": "Auth Spec", "url": "https://intra/wiki/auth"},
),
]
context = "\n\n".join(
[f"[source_id={d.metadata['source_id']}] {d.page_content}" for d in docs]
)
prompt = ChatPromptTemplate.from_messages([
("system",
"""
너는 사내 문서 기반 Q&A 에이전트다.
규칙:
- 반드시 아래 CONTEXT에 있는 내용만 사용한다.
- 답변의 각 문장은 반드시 source_id를 포함해야 한다.
- CONTEXT에 근거가 없으면 '모른다'라고 답하고, 필요한 추가 정보를 질문한다.
- 출력은 JSON만 반환한다.
JSON 스키마:
{
"answer": [
{"sentence": "...", "citations": ["S1", "S2"]}
],
"follow_up_questions": ["..."]
}
"""),
("human", "CONTEXT:\n{context}\n\nQUESTION:\n{question}")
])
parser = JsonOutputParser()
chain = prompt | llm | parser
result = chain.invoke({
"context": context,
"question": "토큰 만료 시간과 리프레시 토큰 유효기간을 알려줘"
})
print(result)
이 방식의 포인트는 다음입니다.
- LLM이 “문장 단위”로 출처를 강제받으므로, 근거 없는 문장을 섞기 어려움
- 후처리에서
citations가 빈 문장을 제거하거나,source_id가 컨텍스트에 없는 경우를 에러 처리 가능
출처강제 후처리: 인용 검증(guardrail)
프롬프트만으로는 100% 막히지 않습니다. 따라서 후처리로 최소한의 검증을 추가하세요.
citations에 나온source_id가 실제 컨텍스트에 존재하는지 확인- 문장 수 대비 인용 비율이 너무 낮으면 재시도
모른다케이스에서 follow-up 질문이 비어 있으면 재시도
간단한 검증 예시는 다음과 같습니다.
def validate_citations(parsed: dict, allowed_source_ids: set[str]) -> None:
for item in parsed.get("answer", []):
cits = set(item.get("citations", []))
if not cits:
raise ValueError("Missing citations")
if not cits.issubset(allowed_source_ids):
raise ValueError(f"Invalid citation: {cits - allowed_source_ids}")
allowed = {d.metadata["source_id"] for d in docs}
validate_citations(result, allowed)
이 정도만 해도 “아무 말이나 하고 출처를 아무거나 붙이는” 유형을 상당히 줄일 수 있습니다.
전략 2) 재랭킹: 벡터 검색 결과를 ‘답변 근거’로 정제
재랭킹은 1차 검색(벡터 검색)으로 가져온 후보 문서 k개를 대상으로, 질문-문서 쌍을 더 정밀하게 점수화해 상위 n개만 컨텍스트로 쓰는 단계입니다.
왜 필요할까요?
- 벡터 검색은 “유사한 문서”를 가져오며, 종종 정답과 무관한 문서도 상위에 섞임
- 컨텍스트 윈도우가 제한되어 있으므로, 노이즈가 섞이면 정답 근거가 컨텍스트에서 밀려나거나 LLM이 혼합해 환각을 만들기 쉬움
재랭킹 방식 3가지
- Cross-Encoder(권장):
query와doc을 함께 넣고 관련도를 직접 예측. 정확도가 높지만 비용이 듭니다. - LLM-as-a-reranker: LLM에게 “질문에 답하는 데 유용한 문서 순서”를 매기게 함. 비용이 더 들 수 있고 일관성 이슈가 있을 수 있습니다.
- Heuristic + BM25 혼합: 키워드 매칭/메타데이터 필터링으로 노이즈를 줄이는 방식. 저렴하지만 한계가 있습니다.
실무에서 가장 흔한 조합은 vector top_k=20 후 rerank top_n=5입니다.
LangChain 예시: 1차 검색 후 재랭킹 적용
아래는 “1차 검색 결과를 재랭커로 정렬한 뒤 상위 N개만 컨텍스트로 사용”하는 골격 코드입니다. 재랭커는 벤더마다 다르므로 인터페이스만 단순화해 표현합니다.
from typing import List, Tuple
from langchain_core.documents import Document
def rerank(query: str, docs: List[Document]) -> List[Tuple[Document, float]]:
"""예시용 재랭커. 실제로는 cross-encoder나 외부 API를 호출."""
scored = []
for d in docs:
# placeholder score: 길이 기반 같은 가짜 점수
score = min(len(d.page_content) / 1000.0, 1.0)
scored.append((d, score))
scored.sort(key=lambda x: x[1], reverse=True)
return scored
# 1) retriever로 후보 20개를 가져왔다고 가정
candidate_docs: List[Document] = candidate_docs = [
Document(page_content="...", metadata={"source_id": "S1"}),
Document(page_content="...", metadata={"source_id": "S2"}),
]
# 2) 재랭킹 후 상위 5개만 사용
ranked = rerank("질문 텍스트", candidate_docs)
top_docs = [d for d, _ in ranked[:5]]
context = "\n\n".join(
[f"[source_id={d.metadata['source_id']}] {d.page_content}" for d in top_docs]
)
핵심은 재랭킹 점수 자체가 아니라, 컨텍스트에 들어갈 문서가 ‘답변 근거로서’ 더 적합해지는 것입니다. 이 상태에서 앞서 설명한 출처강제를 적용하면, 환각이 줄어드는 체감이 큽니다.
재랭킹을 더 잘 먹이는 팁: 청크 설계와 메타데이터
재랭킹은 문서 단위가 아니라 “청크 단위”로 하는 경우가 많습니다. 이때 다음이 중요합니다.
- 청크 크기: 너무 크면 관련 문장이 묻히고, 너무 작으면 문맥이 깨집니다. 보통 300~800 토큰 범위를 많이 씁니다.
- 메타데이터 필터:
product,version,lang,updated_at같은 필드로 후보군을 줄이면 재랭킹 비용이 감소합니다. - 중복 제거: 같은 페이지에서 비슷한 청크가 여러 개 올라오면 컨텍스트가 낭비됩니다.
출처강제 + 재랭킹을 함께 쓸 때의 체인 구성
두 전략을 합치면 파이프라인은 다음처럼 정리됩니다.
- 질문 입력
- (선택) 쿼리 리라이트
- 벡터 검색으로 후보 20개
- 재랭킹으로 상위 5개
- 상위 5개를
source_id와 함께 컨텍스트로 구성 - 출처강제 프롬프트로 JSON 생성
- 인용 검증 후 응답
이 구조의 장점은 “검색이 조금 흔들려도 생성이 제어되고”, “생성이 흔들려도 후처리에서 걸러지는” 이중 안전장치가 생긴다는 점입니다.
실패 패턴과 디버깅 포인트
1) 출처는 달았는데 내용이 틀림
- 원인: 문서가 애매하거나 오래됨, 또는 청크에 반례/예외가 함께 들어있음
- 대응:
- 청크를 더 잘게 쪼개고, 예외/조건을 분리
updated_at기반 최신 문서 우선- 답변 템플릿에 “조건/전제” 필드를 추가해 모호성을 드러내게 함
2) 항상 모른다만 답함
- 원인: 재랭킹으로 너무 공격적으로 문서를 줄이거나, 컨텍스트가 짧아서 근거가 부족
- 대응:
top_k를 늘리고top_n을 완화- 질문을 더 구체화하는 follow-up 질문을 먼저 생성하는 플로우 추가
3) 재랭킹 비용이 너무 큼
- 원인: 후보 문서 수가 많고, 크로스 인코더 호출이 비쌈
- 대응:
- 메타데이터 필터로 후보군 축소
- 1차 검색을 하이브리드(BM25+벡터)로 바꿔 후보 품질 개선
- 캐싱(질문 정규화 후 rerank 결과 캐시)
CI에서 이런 체인 변경을 반복하다 보면 “어제는 되던 프롬프트가 오늘은 깨지는” 일이 생기는데, 동시 실행/캐시 경합으로 실험 결과가 섞이는 경우도 있습니다. 파이프라인 자동화 시에는 GitHub Actions 동시 실행 경합으로 캐시 깨질 때 같은 이슈도 함께 점검해두면 좋습니다.
환각 감소를 측정하는 실전 체크리스트
출처강제와 재랭킹을 넣었다면, 다음 지표를 최소한으로 측정하세요.
- Citation coverage: 답변 문장 중 인용이 붙은 문장 비율
- Invalid citation rate: 컨텍스트에 없는
source_id를 인용한 비율 - Answerability rate: 정답이 문서에 존재하는 질문에서
모른다로 빠지는 비율 - Contradiction rate: 같은 답변 내에서 상충되는 주장 비율
- Human eval(소량): 상위 20~50개 질문은 사람이 직접 “근거-주장 매칭”을 확인
특히 “인용은 많은데 틀린 답”은 재랭킹/청크 설계 문제일 가능성이 높고, “모른다 남발”은 컨텍스트 부족 또는 프롬프트 제약이 과한 경우가 많습니다.
결론: 검색을 믿지 말고, 생성도 믿지 말고, 둘 다 통제하라
RAG 환각을 줄이는 가장 현실적인 접근은 검색 단계에서 재랭킹으로 근거 후보를 정제하고, 생성 단계에서 출처강제로 근거 없는 문장을 구조적으로 차단하는 것입니다. 여기에 간단한 인용 검증과 재시도 정책까지 붙이면, “그럴듯한데 틀린 답”이 눈에 띄게 줄어듭니다.
다음 단계로는 (1) 하이브리드 검색, (2) 질문 리라이트, (3) 문서 신뢰도 스코어링, (4) 반례/예외를 포함한 평가셋 구축을 붙여가며 안정성을 올리는 것을 권합니다. 또한 추론 품질을 높이는 일반적인 기법이 궁금하다면 CoT 없이 추론 품질 올리는 SC·ToT 실전도 함께 참고해보세요.