- Published on
LangChain+OpenAI로 RAG 환각 줄이는 평가 자동화
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
RAG 시스템에서 환각을 줄이려면 검색 품질을 올리는 것만큼이나 평가를 자동화해 회귀를 빠르게 잡는 체계가 중요합니다. 프롬프트를 조금 바꾸거나, 임베딩 모델을 교체하거나, 청크 전략을 수정했을 때 답변 품질이 좋아졌는지 나빠졌는지 감으로 판단하면 결국 운영 단계에서 사고가 납니다.
이 글에서는 LangChain과 OpenAI를 이용해 RAG 답변의 환각(hallucination)을 줄이기 위한 평가 자동화를 설계합니다. 핵심은 다음 3가지를 반복 가능하게 만드는 것입니다.
- 데이터셋(질문, 기대 포인트, 정답 문서 또는 근거) 고정
- RAG 파이프라인 실행 고정(검색, 재정렬, 생성)
- 채점기(LLM-as-a-judge + 규칙 기반) 고정
또한 평가 자동화는 곧 API 호출량이 늘어나는 작업이라 레이트리밋 대응도 같이 설계해야 합니다. 관련해서는 OpenAI Responses API 429 레이트리밋 토큰버킷으로 끝내기 글의 패턴을 그대로 가져오면 안정적으로 운영할 수 있습니다.
왜 RAG 환각은 “평가” 없이는 줄지 않는가
RAG 환각은 단순히 모델이 틀린 말을 하는 문제가 아니라, 다음 유형이 섞여 나타납니다.
- 비근거 환각: 검색된 컨텍스트에 없는 내용을 단정적으로 생성
- 잘못된 근거 매핑: 컨텍스트에 유사 문장이 있지만 다른 개념을 근거로 오인
- 인용-내용 불일치: 인용한 문장과 답변의 주장 사이 논리적 연결이 깨짐
- 부분 정답 + 과잉 일반화: 일부는 맞지만 범위를 넓혀서 결론을 왜곡
- 거절 실패: 근거가 없는데도 “아는 척” 답변
이 중 1, 3, 5는 평가 지표로 비교적 잘 잡힙니다. 문제는 “검색 리콜이 떨어져서 근거가 없어진 것인지”, “생성 프롬프트가 공격적으로 변해서 근거 밖을 말하기 시작했는지”를 분리해 보려면 근거 중심의 평가 체계가 필요합니다.
검색 리콜 자체가 흔들리는 문제는 벡터DB 파라미터 튜닝에서 자주 발생합니다. Milvus를 쓴다면 Milvus RAG 리콜 급락? HNSW 파라미터 튜닝 같은 접근으로 검색 품질을 먼저 안정화한 뒤, 생성 단계 환각을 평가로 관리하는 게 좋습니다.
평가 자동화 아키텍처: 생성과 채점을 분리하라
권장 파이프라인은 아래처럼 생성(Answer)과 채점(Eval)을 분리합니다.
- Step 1: RAG 실행
- 입력: 질문
- 출력: 답변, 사용한 컨텍스트(문서 조각), 메타데이터(문서 id, 페이지, 점수)
- Step 2: 규칙 기반 체크
- 금칙어, 포맷 위반, 인용 누락, 길이 제한, JSON 파싱 여부 등
- Step 3: LLM Judge 평가
- 입력: 질문, 답변, 컨텍스트, 평가 기준
- 출력: 점수(예: 1~5), 판정(예: grounded 또는 ungrounded), 실패 사유
- Step 4: 리포트 및 게이트
- PR 단계에서 평균 점수 하락 또는 특정 치명 항목 실패 시 빌드 실패
여기서 중요한 운영 원칙은 다음입니다.
- 평가 프롬프트는 버전 관리: 코드처럼 변경 이력을 남기고, 점수 분포 변화도 함께 기록
- Judge 모델은 가능한 한 고정: 모델이 바뀌면 점수 기준이 흔들려 회귀 탐지가 어려움
- 컨텍스트를 반드시 평가 입력에 포함: 환각은 “근거 밖 주장”이므로 컨텍스트가 없으면 판정 불가
- CoT 노출 방지: 채점이든 생성이든 내부 추론을 그대로 노출하면 보안·정책·일관성 문제가 생깁니다. 필요하다면 CoT 유출 막기 - Deliberation 없이 성능 유지 방식처럼 “요약된 근거”만 반환하도록 설계하세요.
어떤 지표로 환각을 수치화할까
실무에서 가장 쓸모 있는 지표 조합은 아래 4종입니다.
1) Groundedness(근거 일치성)
- 정의: 답변의 핵심 주장들이 제공된 컨텍스트로부터 직접 지지되는가
- 측정: LLM Judge가 주장 단위로 “컨텍스트에 있음 또는 없음”을 판정
- 결과:
grounded=true/false+score+missing_claims
2) Citation Quality(인용 품질)
- 정의: 답변이 문서 id 또는 출처를 포함하고, 인용이 주장과 연결되는가
- 측정: 규칙 기반(인용 포맷 존재) + LLM Judge(인용-주장 매칭)
3) Refusal Accuracy(거절 정확도)
- 정의: 근거가 부족한 질문에서 적절히 모른다고 말하는가
- 측정: “정답 문서가 없는 케이스”를 데이터셋에 포함하고, 거절 여부를 채점
4) Answer Helpfulness(유용성)
- 정의: 근거 안에서 답했더라도 질문에 충분히 유용한가
- 측정: Judge가 간단한 루브릭으로 평가
환각만 줄이는 게 목표라면 1, 2, 3이 우선이고 4는 보조 지표로 두는 편이 안전합니다. 유용성을 올리려다 근거 밖 확장을 허용하는 프롬프트가 들어가면 환각이 다시 늘어날 수 있습니다.
LangChain + OpenAI로 RAG 실행 코드(생성 단계)
아래 예시는 Python 기준이며, LangChain으로 검색기를 구성하고 OpenAI 모델로 답변을 생성합니다. MDX 빌드 에러 방지를 위해 제네릭이나 부등호가 들어갈 수 있는 표현은 모두 백틱 처리합니다.
import os
from typing import List, Dict, Any
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
# 예시: retriever는 이미 구성되어 있다고 가정
# (Chroma, FAISS, Milvus, Elasticsearch 등)
def run_rag(question: str, retriever, k: int = 6) -> Dict[str, Any]:
docs = retriever.get_relevant_documents(question)
docs = docs[:k]
context = "\n\n".join(
[f"[doc_id={d.metadata.get('doc_id','unknown')}] {d.page_content}" for d in docs]
)
prompt = ChatPromptTemplate.from_messages([
("system",
"""너는 근거 기반 QA 어시스턴트다.
- 제공된 CONTEXT에 근거가 있는 내용만 답한다.
- 근거가 부족하면 '모르겠습니다'라고 말하고, 어떤 정보가 더 필요한지 질문한다.
- 답변 끝에 사용한 doc_id를 목록으로 인용한다.
"""),
("user", "QUESTION: {question}\n\nCONTEXT:\n{context}")
])
llm = ChatOpenAI(
model="gpt-4.1-mini",
temperature=0.2,
)
chain = prompt | llm | StrOutputParser()
answer = chain.invoke({"question": question, "context": context})
return {
"question": question,
"answer": answer,
"docs": [
{
"doc_id": d.metadata.get("doc_id"),
"source": d.metadata.get("source"),
"content": d.page_content,
"score": d.metadata.get("score"),
}
for d in docs
],
}
포인트는 생성 결과에 사용한 컨텍스트와 메타데이터를 같이 저장하는 것입니다. 평가 단계에서 “근거 밖 주장”을 판정하려면 이 정보가 필수입니다.
LLM-as-a-Judge 평가 프롬프트(환각 중심)
Judge는 보통 생성 모델과 분리합니다. 생성은 저렴한 모델로, Judge는 더 안정적인 모델로 고정하는 전략이 흔합니다.
아래는 Judge가 반드시 JSON만 출력하도록 강제하는 예시입니다.
import json
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
JUDGE_SCHEMA_TEXT = """
반드시 JSON 객체만 출력한다. 키는 다음과 같다.
- grounded_score: 1에서 5 정수
- grounded: true 또는 false
- citation_score: 1에서 5 정수
- refusal_ok: true 또는 false
- reasons: 문자열 배열
- missing_claims: 문자열 배열
""".strip()
def judge_rag(question: str, answer: str, docs) -> dict:
context = "\n\n".join(
[f"[doc_id={d['doc_id']}] {d['content']}" for d in docs]
)
prompt = ChatPromptTemplate.from_messages([
("system",
"""너는 RAG 답변 평가자다.
평가 기준:
1) 답변의 핵심 주장들이 CONTEXT로부터 직접 지지되는가
2) 인용(doc_id)이 실제로 사용된 근거와 연결되는가
3) 근거가 부족할 때 거절/유보를 적절히 했는가
""" + JUDGE_SCHEMA_TEXT),
("user", "QUESTION: {question}\n\nANSWER: {answer}\n\nCONTEXT:\n{context}")
])
llm = ChatOpenAI(
model="gpt-4.1",
temperature=0.0,
)
raw = (prompt | llm | StrOutputParser()).invoke(
{"question": question, "answer": answer, "context": context}
)
# 방어적으로 파싱
try:
return json.loads(raw)
except json.JSONDecodeError:
return {
"grounded_score": 1,
"grounded": False,
"citation_score": 1,
"refusal_ok": False,
"reasons": ["judge_output_not_json"],
"missing_claims": ["judge returned non-JSON"],
"raw": raw,
}
여기서 temperature=0.0을 권장합니다. Judge가 창의적으로 변하면 점수 일관성이 깨져서 회귀 테스트로 쓰기 어렵습니다.
데이터셋 설계: “정답”보다 “근거”를 고정하라
RAG 평가는 전통적인 QA처럼 정답 문자열을 고정하는 방식이 잘 안 맞습니다. 같은 의미의 답변이 여러 형태로 나올 수 있기 때문입니다.
대신 아래처럼 구성하면 실전에서 강합니다.
question: 질문must_include: 답변에 반드시 포함되어야 하는 핵심 포인트 목록(선택)must_not_include: 나와서는 안 되는 주장(선택)gold_doc_ids: 정답 근거가 들어있는 문서 id 목록(가능하면)expect_refusal: 근거가 없을 때 거절해야 하는 케이스 여부
예시 JSONL:
{"id":"q1","question":"사내 VPN 설정 변경 절차는?","gold_doc_ids":["it-policy-12"],"expect_refusal":false}
{"id":"q2","question":"우리 회사의 2027년 매출 목표는?","gold_doc_ids":[],"expect_refusal":true}
gold_doc_ids가 있으면 검색 단계 평가도 같이 할 수 있습니다.
- Retrieval hit rate: 상위
k에gold_doc_ids가 포함되는 비율 - MRR: 정답 문서가 몇 번째에 등장하는지
이렇게 하면 “환각이 늘었다”가 아니라 “검색이 실패해서 거절해야 했는데 답했다” 같은 원인 단위 진단이 가능해집니다.
평가 러너: 배치 실행, 동시성, 레이트리밋
평가 자동화는 보통 수십에서 수백 개 질문을 매 PR마다 돌립니다. 이때 병렬 호출을 걸면 OpenAI 429가 쉽게 발생합니다. 토큰 버킷이나 큐잉을 도입해 안정성을 확보하세요. 자세한 설계는 OpenAI Responses API 429 레이트리밋 토큰버킷으로 끝내기 패턴이 그대로 적용됩니다.
아래는 간단한 비동기 배치 러너 예시입니다.
import asyncio
from dataclasses import dataclass
from typing import List, Dict, Any
@dataclass
class EvalCase:
id: str
question: str
expect_refusal: bool = False
def simple_gate(judge: dict, expect_refusal: bool) -> bool:
# 치명 조건을 단순화한 예시
if expect_refusal:
return bool(judge.get("refusal_ok"))
return bool(judge.get("grounded")) and judge.get("grounded_score", 1) >= 4
async def eval_one(case: EvalCase, retriever) -> Dict[str, Any]:
rag = run_rag(case.question, retriever=retriever)
judged = judge_rag(rag["question"], rag["answer"], rag["docs"])
passed = simple_gate(judged, case.expect_refusal)
return {
"id": case.id,
"question": case.question,
"answer": rag["answer"],
"docs": rag["docs"],
"judge": judged,
"passed": passed,
}
async def run_suite(cases: List[EvalCase], retriever, concurrency: int = 3):
sem = asyncio.Semaphore(concurrency)
async def bound(case: EvalCase):
async with sem:
return await eval_one(case, retriever)
results = await asyncio.gather(*[bound(c) for c in cases])
return results
운영에서는 concurrency를 고정하지 말고, 토큰 사용량 기반으로 동적으로 조절하거나, 실패 시 지수 백오프 재시도를 추가하는 편이 안전합니다.
리포트: “평균 점수”보다 “실패 케이스”를 남겨라
평가 결과를 CI에 붙일 때 흔히 평균 점수만 보는데, 환각 대응에는 실패 케이스가 훨씬 중요합니다.
권장 출력:
passed=false케이스의 질문, 답변, 누락 주장, 사용 컨텍스트- 어떤 doc_id를 인용했는지
- Judge의
reasons와missing_claims
이렇게 저장해두면 다음 작업이 빨라집니다.
- 검색 튜닝이 필요한지(정답 문서가 아예 없었는지)
- 청크가 잘려서 근거가 사라졌는지
- 프롬프트가 과감해서 근거 밖 확장을 했는지
- 거절 정책이 약해졌는지
환각을 실제로 줄이는 운영 팁 6가지
평가 자동화는 “측정”이고, 환각을 줄이는 “행동”은 별개입니다. 아래는 평가 결과를 바탕으로 실제 개선으로 이어지는 체크리스트입니다.
- 거절 우선 정책을 명시: 근거 부족 시 답하지 말고 추가 정보를 요청하도록 시스템 프롬프트에 박아넣기
- 인용을 출력 포맷에 포함: 최소
doc_id목록이라도 강제하면 groundedness가 올라가는 경우가 많음 - 컨텍스트 길이보다 품질: 무작정 많이 넣으면 오히려 혼선이 생겨 잘못된 근거 매핑이 늘 수 있음
- retriever 평가를 분리: 생성 점수가 떨어졌을 때 검색 hit rate도 같이 확인
- Judge 프롬프트는 짧고 엄격하게: 평가 기준을 늘리면 일관성이 떨어질 수 있음
- 회귀 테스트 케이스를 계속 추가: 실제 장애 케이스를 데이터셋에 편입하면 시간이 지날수록 강해짐
마무리
LangChain과 OpenAI로 RAG를 만들었다면, 다음 단계는 “더 똑똑한 프롬프트”가 아니라 평가 자동화로 환각을 관리 가능한 리스크로 바꾸는 것입니다.
- 생성 단계는 컨텍스트와 메타데이터를 남기고
- Judge 단계는 groundedness, citation, refusal을 중심으로 엄격하게 채점하며
- CI에서 실패 케이스를 축적해 회귀를 막으면
RAG는 기능 데모를 넘어 운영 가능한 시스템이 됩니다.