Published on

LangChain RAG 환각 줄이는 8단계 평가·가드레일

Authors

서로 다른 모델을 붙이고 문서를 검색해 답을 만드는 RAG는 “모르는 건 모른다”를 강제할 수 있다는 점에서 매력적입니다. 하지만 실제 운영에서는 환각이 완전히 사라지지 않습니다. 이유는 단순합니다. RAG는 검색(retrieval)을 추가했을 뿐, 모델이 근거를 올바르게 선택하고, 근거 범위 안에서만 답하도록 보장하는 시스템이 아니기 때문입니다.

이 글은 LangChain을 기준으로 RAG 환각을 줄이기 위한 8단계 평가·가드레일을 정리합니다. 핵심은 “한 번의 프롬프트 튜닝”이 아니라, 데이터셋 구축 → 검색 평가 → 컨텍스트 품질 → 생성 제약 → 사후 검증 → 모니터링까지 전 파이프라인에 안전장치를 넣는 것입니다.

참고로, 인덱스 품질과 드리프트는 환각의 주요 원인입니다. 벡터 인덱스가 변하면서 검색 분포가 달라지면, 어제까지 맞던 답이 오늘은 틀릴 수 있습니다. 관련해서는 Rust+Qdrant RAG 인덱스 드리프트 잡는 법도 함께 보면 좋습니다.


0. 환각을 “정의”부터 고정하기

환각을 줄이려면 먼저 측정 가능한 정의가 필요합니다. 실무에서는 아래 3가지를 분리하면 디버깅이 쉬워집니다.

  • 검색 실패형 환각: 정답 근거가 문서에 있는데 검색이 못 찾음(Recall 문제)
  • 컨텍스트 오염형 환각: 검색은 했지만 중복/노이즈/상충 문서가 섞여 모델이 잘못 추론
  • 생성 일탈형 환각: 근거가 충분한데도 모델이 컨텍스트 밖 지식을 섞거나 과장

이 3가지가 섞이면 “프롬프트 문제인지, 인덱스 문제인지, 청킹 문제인지”가 끝까지 안 보입니다. 8단계는 이 분해를 전제로 설계합니다.


1단계: 평가용 골든셋 만들기(질문·정답·근거)

RAG 평가는 질문-정답만으로는 부족합니다. 정답이 어디 문서의 어떤 구간에 있는지(근거, evidence) 가 있어야 검색과 생성을 분리해서 평가할 수 있습니다.

권장 스키마는 다음과 같습니다.

  • question: 사용자 질문
  • answer: 정답(짧고 단정적으로)
  • evidence: 근거가 되는 문서 ID, 섹션, 원문 스니펫
  • constraints: “버전 X 기준”, “정책 Y 기준” 같은 조건
  • unanswerable: 답이 문서에 없는 질문인지 여부

unanswerable을 반드시 섞으세요. “모르면 모른다”를 학습시키는 게 아니라, 시스템이 거절하는지를 검증하기 위해서입니다.

간단한 예시(JSONL):

{"question":"퇴사 시 미사용 연차는 어떻게 정산돼?","answer":"퇴사 시 미사용 연차는 평균임금 또는 통상임금을 기준으로 수당으로 정산한다.","evidence":[{"doc_id":"hr_policy_2025","span":"연차휴가 정산","quote":"퇴사 시 미사용 연차는 ... 수당으로 정산한다"}],"unanswerable":false}
{"question":"우리 회사 2026년 연봉 인상률은?","answer":"문서에 없다.","evidence":[],"unanswerable":true}

2단계: 검색(Retrieval)만 따로 평가하기

생성 모델을 붙이기 전에, 검색이 근거를 가져오는지부터 봐야 합니다. 여기서의 목표는 “정답을 포함한 청크가 top_k에 들어오게 하는 것”입니다.

실무 지표:

  • Recall@k: 근거 청크가 상위 k개에 포함되는 비율
  • MRR: 근거가 몇 번째에 등장하는지(순위 품질)
  • Coverage: 질문 유형별(정책/기술/가격)로 Recall이 치우치지 않는지

LangChain에서 retriever를 구성하고, 골든셋으로 Recall@k를 측정하는 예시입니다.

from langchain_openai import OpenAIEmbeddings
from langchain_community.vectorstores import FAISS
from langchain_core.documents import Document

# 예시 문서
texts = [
    "퇴사 시 미사용 연차는 수당으로 정산한다. 기준은 통상임금 또는 평균임금이다.",
    "연차는 1년간 80% 이상 출근 시 15일이 부여된다.",
]

docs = [Document(page_content=t, metadata={"doc_id": f"d{i}"}) for i, t in enumerate(texts)]

emb = OpenAIEmbeddings(model="text-embedding-3-large")
vs = FAISS.from_documents(docs, emb)
retriever = vs.as_retriever(search_kwargs={"k": 3})

query = "퇴사할 때 남은 연차는 어떻게 처리돼?"
results = retriever.invoke(query)

for r in results:
    print(r.metadata, r.page_content)

여기서 Recall@k가 낮다면, 환각의 50%는 이미 결정난 겁니다. 이 단계에서 먼저 손볼 것:

  • 청킹 크기/오버랩
  • 임베딩 모델 교체
  • BM25 하이브리드
  • 메타데이터 필터(버전, 제품군, 국가)

3단계: 청킹·정규화·메타데이터로 “컨텍스트 오염” 줄이기

검색이 되더라도 컨텍스트가 오염되면 모델은 그럴듯하게 틀립니다. 특히 다음이 흔합니다.

  • 같은 내용의 중복 청크가 여러 개 들어와 토큰만 낭비
  • 정책 문서의 “예외 조항”과 “원칙 조항”이 섞여 상충
  • 버전이 다른 문서가 함께 들어와 혼합 답변 생성

가드레일 체크리스트:

  • 청크마다 doc_id, version, updated_at, section 메타데이터 부여
  • retriever 단계에서 version=latest 같은 필터 적용
  • 컨텍스트 조립 시 중복 제거(유사도 기반)
  • 상충 문서 감지(간단히는 키워드, 고급은 NLI/텍스트 분류)

버전 필터 예시(벡터스토어가 메타 필터를 지원한다고 가정):

retriever = vs.as_retriever(
    search_kwargs={
        "k": 6,
        "filter": {"version": "2025.02"}
    }
)

인덱스가 커질수록 “드리프트로 검색 분포가 바뀌는 문제”가 커집니다. 운영에서 자주 겪는 패턴이므로, 인덱스의 변화를 감지·완화하는 방법은 Rust+Qdrant RAG 인덱스 드리프트 잡는 법처럼 별도로 체계화하는 게 좋습니다.


4단계: 프롬프트를 “근거 기반 답변 계약”으로 바꾸기

프롬프트는 문장 예쁘게 만드는 도구가 아니라, 계약(Contract) 입니다. 계약에 반드시 들어가야 할 조항은 아래 4가지입니다.

  1. 답변은 제공된 컨텍스트에서만 작성
  2. 컨텍스트에 없으면 “모른다” 또는 “근거 부족”으로 응답
  3. 근거(인용/문서ID/섹션)를 함께 출력
  4. 질문이 모호하면 먼저 확인 질문

LangChain의 ChatPromptTemplate로 계약을 강제하는 예시입니다.

from langchain_core.prompts import ChatPromptTemplate

prompt = ChatPromptTemplate.from_messages([
    ("system",
     "너는 사내 문서 기반 QA 어시스턴트다. "
     "반드시 CONTEXT에 있는 내용만 사용해 답해라. "
     "CONTEXT에 근거가 없으면 '근거 부족'이라고 말하고, 필요한 추가 정보를 질문하라. "
     "답변에는 근거로 사용한 문서의 doc_id를 각 문장 끝에 대괄호로 표시하라."),
    ("user", "QUESTION: {question}\n\nCONTEXT:\n{context}")
])

주의: “근거를 대괄호로 표시” 같은 형식 제약은 후처리와 결합될 때 특히 강력합니다. 후처리에서 doc_id가 하나도 없으면 실패로 간주하고 재시도할 수 있기 때문입니다.


5단계: 생성 단계 가드레일(디코딩·길이·stop·구조화)

환각은 모델이 “자유롭게 서술”할수록 늘어납니다. 생성 단계에서 제약을 걸 수 있는 실용적인 방법들:

  • 온도 낮추기(temperature=0 또는 낮은 값)
  • 답변 길이 제한
  • 구조화 출력(JSON)으로 강제
  • stop 토큰으로 불필요한 수다 차단

LangChain에서 구조화 출력(예: Pydantic)을 쓰면, “근거 없는 장문 서술”을 줄이는 데 도움됩니다.

from pydantic import BaseModel, Field
from typing import List

class Answer(BaseModel):
    final: str = Field(description="최종 답변")
    citations: List[str] = Field(description="사용한 doc_id 목록")
    confidence: float = Field(description="0~1 사이, 근거 충실도")

# 모델이 구조를 맞추도록 강제(모델/버전에 따라 지원 방식은 다름)

구조화 출력이 어렵다면 최소한 “문장마다 doc_id를 붙여라” 같은 룰을 주고, 후처리에서 검증하세요.


6단계: 답변 후 검증(근거 정합성 체크)으로 2차 차단

RAG의 강점은 “검증 가능한 파이프라인”입니다. 생성된 답이 컨텍스트와 정합한지 사후 검증을 넣으면 환각이 눈에 띄게 줄어듭니다.

가벼운 검증부터 시작하세요.

  • Citation 존재 여부: 인용이 없으면 실패
  • Citation 유효성: doc_id가 실제 컨텍스트에 존재하는지
  • Claim-Context 매칭: 답변 문장별로 컨텍스트에 유사 문장이 있는지(임베딩 유사도)
  • Contradiction 체크: 컨텍스트와 모순되는 표현이 있는지(분류 모델/NLI)

간단한 “인용 검증 + 재시도” 예시(의사코드):

import re

def validate(answer_text: str, allowed_doc_ids: set[str]) -> bool:
    cited = set(re.findall(r"\[([^\]]+)\]", answer_text))
    if not cited:
        return False
    return cited.issubset(allowed_doc_ids)

# 실패 시: 더 보수적인 프롬프트로 재시도하거나, k를 늘려 재검색

이 단계가 중요한 이유는, 모델이 “그럴듯한 인용”을 만들어내는 경우를 잡아내기 때문입니다. 인용을 강제했는데도 환각이 남는다면, 대개 검색 품질 또는 상충 문서가 원인입니다.


7단계: 거절(Refusal)과 에스컬레이션 정책을 제품에 박기

환각을 0으로 만들려는 시도는 비용만 늘리고 실패합니다. 대신 제품 요구사항을 다음처럼 바꾸는 게 현실적입니다.

  • 근거 부족이면 정중히 거절하고, 필요한 추가 정보(기간/버전/대상)를 질문
  • 높은 리스크 도메인(법무/의료/보안)은 사람에게 에스컬레이션
  • 답변 상단에 “문서 기반”과 “추정”을 명확히 라벨링

예시 정책:

  • unanswerable로 판단되면: “관련 문서에서 근거를 찾지 못했습니다” + 검색한 문서 목록 제시
  • confidence가 임계치 미만이면: “확실하지 않음” + 담당팀 링크/티켓 생성

운영 관점에서 이 정책은 “모델 성능”이 아니라 “사고 확률”을 줄입니다.


8단계: 운영 모니터링과 회귀 테스트(인덱스·프롬프트 변경 대비)

RAG는 배포 이후가 더 중요합니다. 다음 변경이 생기면 성능이 쉽게 흔들립니다.

  • 문서 추가/삭제/개정(인덱스 드리프트)
  • 청킹 규칙 변경
  • 임베딩 모델 변경
  • 프롬프트 변경
  • LLM 모델 버전 변경

따라서 최소한 아래를 자동화하세요.

  • 골든셋으로 Recall@k와 “정답 정확도” 회귀 테스트
  • 실패 케이스 자동 수집(질문, top_k 문서, 최종 답, 검증 결과)
  • 인덱스 재빌드 시 샘플 쿼리 분포 비교

인덱스 드리프트는 특히 장기 운영에서 치명적입니다. “검색이 되는 듯 안 되는” 미묘한 문제로 나타나며, 환각이 늘었다고만 관측됩니다. 드리프트를 감지하고 예방하는 접근은 Rust+Qdrant RAG 인덱스 드리프트 잡는 법을 참고해 체계를 잡아두면 좋습니다.

또한 운영 비용이 부담이라면, 추론 최적화도 환각 감소에 간접적으로 기여합니다. 같은 예산에서 더 많은 검증 호출(재검색/재시도)을 돌릴 수 있기 때문입니다. 경량화/최적화 관점은 파이썬 ONNX Runtime로 LLM 10배 경량화 튜닝도 도움이 됩니다.


LangChain 기준 “8단계”를 하나의 파이프라인으로 묶기

정리하면, 환각을 줄이는 가장 실용적인 구조는 아래와 같습니다.

  1. 골든셋(근거 포함) 구축
  2. 검색만 평가(Recall@k, MRR)
  3. 컨텍스트 오염 제거(메타데이터, 중복 제거, 상충 관리)
  4. 근거 기반 답변 계약 프롬프트
  5. 생성 제약(온도/길이/구조화)
  6. 사후 검증(인용·정합성) + 재시도
  7. 거절/에스컬레이션 정책
  8. 운영 모니터링 + 회귀 테스트

이 8단계를 적용하면 “모델이 똑똑해져서” 환각이 줄어드는 게 아니라, 환각이 발생할 수 있는 경로를 시스템적으로 막고, 발생해도 제품 레벨에서 안전하게 처리할 수 있게 됩니다.


부록: 자주 터지는 실패 패턴 5가지

1) top_k를 늘렸더니 환각이 늘었다

문서가 더 많이 들어오면서 상충/노이즈가 증가합니다. k를 늘리기 전에 필터링과 재랭킹을 붙이세요.

2) “정확히 답해” 프롬프트가 오히려 위험하다

정확성 압박은 모델이 빈칸을 상상으로 채우게 만듭니다. 대신 “근거 없으면 거절”을 계약으로 박으세요.

3) 문서 버전이 섞여 답이 흔들린다

메타데이터 필터로 latest를 강제하거나, 질문에서 버전을 먼저 확인하도록 유도하세요.

4) 인용을 달라고 했는데 가짜 인용을 만든다

사후 검증에서 “인용이 컨텍스트에 존재하는지”를 반드시 체크하세요.

5) 운영 중 어느 날부터 갑자기 성능이 나빠졌다

인덱스 드리프트, 문서 개정, 임베딩/모델 버전 변경이 원인일 확률이 큽니다. 회귀 테스트로 바로 감지할 수 있게 만들어야 합니다.


결론

LangChain RAG의 환각을 줄이는 가장 확실한 방법은 “프롬프트 한 방”이 아니라 평가와 가드레일을 8단계로 분리해 병목을 찾고, 각 단계에 실패 안전장치를 넣는 것입니다. 특히 검색 평가(2단계)사후 검증(6단계)를 넣는 순간, 환각은 체감적으로 크게 줄고 디버깅 가능해집니다.

다음 액션으로는 골든셋 50개만이라도 만들어 Recall@5를 측정해 보세요. 그 수치가 지금 시스템이 환각을 만들 수밖에 없는 이유를 가장 정직하게 보여줄 겁니다.