- Published on
LangChain RAG 환각 줄이기 - 출처검증·재랭킹
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
RAG(Retrieval-Augmented Generation)는 LLM이 모르는 내용을 문서에서 찾아 답하게 만들어 환각을 줄이는 접근입니다. 하지만 “RAG를 붙였는데도” 환각이 남는 경우가 많습니다. 원인은 대개 두 가지입니다.
- 검색 단계에서 관련 없는 문서가 섞이거나, 정말 중요한 문서가 상위에 못 올라오는 문제
- 생성 단계에서 모델이 문서에 없는 내용을 그럴듯하게 덧붙이거나, 인용을 임의로 만들어내는 문제
이 글은 LangChain을 기준으로, 환각을 체계적으로 줄이는 핵심 레버인 출처 검증(source verification) 과 재랭킹(reranking) 을 중심으로 설계와 구현 포인트를 정리합니다. 목표는 “답변이 틀리면 침묵하거나 추가 질문을 하게 만들고”, “답변을 하더라도 근거를 강제”하는 것입니다.
환각이 발생하는 지점부터 분해하기
RAG 파이프라인을 단순화하면 아래 흐름입니다.
- 쿼리 전처리(의도 파악, 멀티쿼리 등)
- 벡터 검색(Top
k) 및 필터링 - 재랭킹(Top
n을 더 정확히 정렬) - 컨텍스트 구성(문서 조각 합치기, 중복 제거)
- 답변 생성(인용 포함)
- 사후 검증(근거 기반인지 체크, 부족하면 거절)
환각을 줄이려면 2~6번에 “가드레일”을 넣어야 합니다. 특히 실무에서 효과가 큰 조합은 다음입니다.
- 재랭킹으로 검색 품질을 끌어올린 다음
- 답변에 인용을 강제하고
- 인용된 근거가 실제로 답변을 지지하는지 검증
이 3단계가 맞물리면, 모델이 문서 밖으로 튀는 순간을 빨리 잡아낼 수 있습니다.
1) 재랭킹: Top k 검색의 한계를 보정하기
벡터 검색은 빠르지만, “유사한 문장”을 찾는 데 최적화되어 있어서 다음에 약합니다.
- 질문이 길거나 복합 조건이 있을 때
- 문서 조각이 너무 짧아 문맥이 부족할 때
- 용어가 비슷하지만 결론이 다른 문서가 섞일 때
이때 재랭킹은 “질문-문서의 관련성”을 더 정교한 모델(크로스 인코더, LLM 기반 스코어링)로 다시 평가해 상위 문서를 교체합니다.
LangChain에서 재랭킹 구성(개념 코드)
아래 코드는 “벡터 검색으로 많이 가져오고(k=20) → 재랭킹으로 줄이기(top_n=5)” 패턴입니다. 사용하는 재랭커는 환경에 따라 다르지만, 구조는 동일합니다.
from langchain_openai import OpenAIEmbeddings
from langchain_community.vectorstores import FAISS
from langchain_core.documents import Document
# 1) 벡터스토어 준비 (예: FAISS)
emb = OpenAIEmbeddings(model="text-embedding-3-large")
vectorstore = FAISS.from_documents(
documents=[Document(page_content="...", metadata={"source": "doc-a"})],
embedding=emb,
)
retriever = vectorstore.as_retriever(search_kwargs={"k": 20})
# 2) 재랭킹 함수(자리표시)
# 실제로는 cross-encoder, Cohere rerank, LLM scoring 등으로 구현
def rerank(query: str, docs: list[Document], top_n: int = 5) -> list[Document]:
scored = []
for d in docs:
# TODO: 더 정교한 관련성 점수로 교체
score = len(set(query.lower().split()) & set(d.page_content.lower().split()))
scored.append((score, d))
scored.sort(key=lambda x: x[0], reverse=True)
return [d for _, d in scored[:top_n]]
query = "RAG에서 환각을 줄이기 위한 출처 검증 방법"
raw_docs = retriever.get_relevant_documents(query)
final_docs = rerank(query, raw_docs, top_n=5)
위 예시는 단순 점수지만, 핵심은 “벡터 검색 결과를 그대로 믿지 말고” 재랭킹으로 상위 컨텍스트를 교체하는 것입니다. 실제 운영에서는 재랭킹이 비용을 늘리므로, 다음 최적화가 중요합니다.
- 재랭킹 대상은 Top
k중에서도 중복 제거 후 적용 - 질문이 짧고 명확하면 재랭킹 생략(휴리스틱)
- 재랭킹 모델은 작은 크로스 인코더 또는 ONNX 최적화로 비용 절감
모델을 경량화해 재랭킹을 빠르게 돌리고 싶다면, BERT 계열을 ONNX로 튜닝하는 접근도 유효합니다. 관련해서는 파이썬 ONNX Runtime로 BERT 4배 경량·2배 가속 튜닝을 함께 참고하면 좋습니다.
2) 출처 검증: “인용을 했는가”가 아니라 “근거가 맞는가”
많은 RAG 시스템이 “답변 끝에 출처 링크를 붙이기” 수준에서 멈춥니다. 하지만 환각을 줄이려면 다음을 분리해야 합니다.
- Citation presence: 출처가 붙어 있는가
- Citation correctness: 그 출처가 해당 문장을 실제로 뒷받침하는가
LLM은 출처를 “그럴듯하게” 생성할 수 있습니다. 따라서 출처를 텍스트로만 받지 말고, 컨텍스트로 제공한 문서 조각의 metadata(예: source, chunk_id, page)를 기반으로 인용 후보를 제한해야 합니다.
인용을 강제하는 프롬프트 패턴
다음 패턴은 효과가 좋습니다.
- 답변은 문장 단위로 작성
- 각 문장 끝에
[source:...#chunk:...]형태로 인용 강제 - 컨텍스트에 없는 내용은 “모름” 또는 추가 질문
MDX 빌드 에러를 피하기 위해 대괄호 표기는 안전하지만, 부등호가 들어가는 토큰은 반드시 백틱 처리해야 합니다.
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
prompt = ChatPromptTemplate.from_messages([
("system",
"""너는 근거 기반 QA 시스템이다.
- 제공된 CONTEXT에 있는 내용만 사용한다.
- 각 문장 끝에 반드시 인용을 붙인다. 형식: [source:문서명#chunk:번호]
- CONTEXT에 근거가 없으면 '근거 부족'이라고 말하고, 필요한 추가 정보를 질문한다.
"""),
("human",
"""QUESTION:
{question}
CONTEXT:
{context}
답변:""")
])
여기서 중요한 건 “인용 형식”이 아니라, 인용 가능한 source 목록을 컨텍스트에서만 가져오게 만드는 것입니다.
3) 사후 출처 검증(Verifier): 답변-근거 정합성 체크
인용을 강제해도, 모델이 잘못된 문서 조각을 끼워 넣어 “정답처럼” 보이게 만들 수 있습니다. 그래서 마지막에 검증기를 둡니다.
검증기의 역할은 단순합니다.
- 입력:
(question, answer_with_citations, retrieved_chunks) - 출력:
PASS또는FAIL+ 실패 이유
실무에서는 다음 기준이 특히 유용합니다.
- 답변의 각 문장이 인용된 chunk에 의해 직접적으로 지지되는가
- 인용이 없는 문장이 존재하는가
- 인용된 chunk가 컨텍스트에 실제로 존재하는가(조작 방지)
LangChain으로 검증 체인 구성(간단 예시)
from langchain_core.output_parsers import StrOutputParser
verifier = ChatPromptTemplate.from_messages([
("system",
"""너는 엄격한 검증기다.
입력의 ANSWER가 CONTEXT에 의해 지지되는지만 판단한다.
- 지지되면 PASS
- 하나라도 근거가 없거나 과장/추론이 섞이면 FAIL
- FAIL이면 어떤 문장이 어떤 이유로 문제인지 한 줄로 요약
"""),
("human",
"""QUESTION:
{question}
ANSWER:
{answer}
CONTEXT:
{context}
판정:""")
]) | llm | StrOutputParser()
result = verifier.invoke({
"question": query,
"answer": "...",
"context": "..."
})
운영에서는 FAIL일 때의 정책이 핵심입니다.
- 재검색 후 재시도(쿼리 확장, 필터 변경)
- 재랭킹 강도 올리기(Top
k확대) - 그래도 실패하면 “근거 부족”으로 거절
이렇게 하면 “틀린 답을 자신 있게 말하는” 상황을 “답변 보류 또는 추가 질문”으로 바꿀 수 있습니다.
4) 컨텍스트 구성 팁: 환각을 부르는 나쁜 컨텍스트 제거
재랭킹과 검증이 있어도, 컨텍스트가 지저분하면 환각이 늘어납니다.
(1) 중복 chunk 제거
동일 문장이 여러 chunk에 반복되면 모델이 “다수결”로 오해해 과신합니다. chunk_id나 해시로 중복 제거하세요.
(2) 서로 모순되는 문서 동시 제공 방지
정책 문서가 버전별로 섞이면 모델이 임의로 합성합니다. 메타데이터로 version, updated_at을 넣고 최신만 남기는 필터가 필요합니다.
(3) Top k를 무작정 늘리지 말기
Top k를 늘리면 리콜은 오르지만 노이즈도 오릅니다. 일반적으로 “많이 가져온 뒤 재랭킹으로 줄이기”가 더 안정적입니다.
5) 실패를 설계하기: 모르면 모른다고 말하게 만들기
환각을 완전히 0으로 만드는 건 어렵습니다. 대신 시스템이 안전하게 실패하도록 만들어야 합니다.
- 근거가 부족하면 답변을 하지 말고, 필요한 정보를 질문
- “추론”과 “근거”를 분리해 표기
- 검증 실패 시 자동 재시도 횟수 제한
외부 API 호출이 들어가는 경우(재랭커, LLM)에는 레이트리밋으로 인해 재시도가 폭주할 수 있습니다. 재시도 정책은 지수 백오프와 상한을 두는 게 안전하며, 자세한 설계는 Anthropic Claude 429 레이트리밋 재시도 설계법을 참고할 수 있습니다.
6) 실전 조합 예시: Retrive k=30 → Rerank n=6 → Answer → Verify
아래는 전체 흐름을 한 함수로 묶은 예시입니다. 실제 프로젝트에서는 관측(로그, 트레이싱)과 캐시를 추가하세요.
from typing import Tuple
from langchain_core.documents import Document
def build_context(docs: list[Document]) -> str:
lines = []
for i, d in enumerate(docs):
src = d.metadata.get("source", "unknown")
chunk = d.metadata.get("chunk_id", i)
lines.append(f"[source:{src}#chunk:{chunk}]\n{d.page_content}")
return "\n\n".join(lines)
def rag_answer(question: str) -> Tuple[str, str]:
raw_docs = retriever.get_relevant_documents(question)
docs = rerank(question, raw_docs, top_n=6)
context = build_context(docs)
answer = (prompt | llm | StrOutputParser()).invoke({
"question": question,
"context": context,
})
verdict = verifier.invoke({
"question": question,
"answer": answer,
"context": context,
})
# 정책: FAIL이면 답변 대신 보류
if "FAIL" in verdict:
safe = "근거 부족으로 확답할 수 없습니다. 관련 문서/정책의 정확한 조항을 제공해 주세요."
return safe, verdict
return answer, verdict
이 구조의 장점은 명확합니다.
- 재랭킹으로 “좋은 컨텍스트” 확률을 올리고
- 인용 강제로 “문서 밖 발언”을 줄이며
- 검증기로 “남은 환각”을 마지막에 차단
7) 관측과 디버깅 포인트
환각을 줄이는 작업은 모델 탓만 하면 진전이 없습니다. 아래 지표를 남기면 원인을 빠르게 분리할 수 있습니다.
- 검색 단계: Top
k문서의source분포, 중복률, 필터 적용 여부 - 재랭킹 단계: 재랭킹 전후 상위 문서가 얼마나 바뀌는지
- 생성 단계: 인용 누락 문장 비율, 문장 수 대비 인용 수
- 검증 단계:
FAIL사유 유형(근거 없음, 과장, 잘못된 인용 등)
운영 환경에서 장애가 나면 “모델이 이상하다”가 아니라, 네트워크/권한/리소스 문제로 검색이 비거나 느려지는 경우도 많습니다. 쿠버네티스에서 리소스 이슈를 추적하는 방법은 K8s CrashLoopBackOff와 OOMKilled 원인 추적 같은 글이 도움이 됩니다.
마무리: 환각을 줄이는 건 ‘기능’이 아니라 ‘파이프라인 설계’
LangChain으로 RAG를 만들 때 환각을 줄이려면, 단순히 벡터 검색을 붙이는 수준을 넘어 다음을 기본값으로 두는 게 좋습니다.
- 검색 결과를 그대로 쓰지 말고 재랭킹으로 상위 컨텍스트를 정제
- 답변에 인용을 강제하고, 인용 가능한 출처를 컨텍스트로 제한
- 마지막에 Verifier로 정합성 검증 후 실패 정책(거절 또는 재검색) 적용
이 3가지만 제대로 묶어도 “그럴듯한 오답”이 “근거 기반 답변 또는 보류”로 바뀌면서, 제품 신뢰도가 눈에 띄게 올라갑니다.