- Published on
OpenAI+LangChain RAG 할루시네이션 차단 전략
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서론: RAG를 깔았는데 왜 자꾸 거짓말을 할까
OpenAI와 LangChain으로 RAG를 붙이면 "이제는 할루시네이션이 거의 없겠지"라고 기대하게 됩니다. 하지만 실제로는 다음과 같은 상황을 자주 보게 됩니다.
- 분명 벡터 DB에 없는 정보를 그럴듯하게 만들어낸다.
- 출처 문서에는 A라고 쓰여 있는데, 답변은 B라고 주장한다.
- "모르겠다"라고 말해야 할 때도 끝까지 답을 지어낸다.
RAG는 할루시네이션를 줄이는 구조일 뿐, 자동으로 해결해 주지는 않습니다. 특히 OpenAI API와 LangChain을 조합할 때는, 프롬프트 설계와 체인 구성만으로는 한계가 분명합니다.
이 글에서는:
- RAG에서 할루시네이션이 발생하는 주요 원인
- OpenAI+LangChain 환경에서 출처 검증(Source Grounding Check) 패턴 구현
- LLM이 자기 답변을 스스로 검증하는 Self-Check / Critic 패턴 구현
- 프로덕션에서 쓸 수 있는 Fail-Safe 전략과 모니터링 포인트
까지 코드와 함께 정리합니다.
중간중간, LLM 인프라 및 운영 관점에서 도움이 될만한 다른 글도 함께 참고하시면 좋습니다. 예를 들어 RAG 품질 튜닝 관점에서는 RAG 품질 급락? 벡터DB 하이브리드검색 튜닝, 운영 측면에서는 OpenAI 429·Rate Limit - 백오프·큐잉 실전 글이 연결됩니다.
1. RAG에서 할루시네이션이 생기는 지점 정리
RAG 파이프라인을 크게 나누면 보통 이렇게 구성됩니다.
- 쿼리 이해 / 재작성 (Query Rewriting)
- 검색 (Vector / Hybrid Retrieval)
- 컨텍스트 선택 / 재구성
- 답변 생성 (Answer Generation)
각 단계별로 할루시네이션이 들어갈 수 있는 지점을 짚어보면:
1.1 검색 단계의 할루시네이션
- 관련 없는 문서가 검색되어도, 모델은 그것을 진실로 믿고 답변에 사용합니다.
- 검색이 실패했는데도, 모델은 검색 결과가 없다는 사실을 모른 척하고 기존 파라메터만으로 답을 만들어냅니다.
1.2 컨텍스트-답변 불일치
- 검색된 문서에는 없는 내용을 답변에 끼워넣는다.
- 문서의 수치/날짜/조건을 잘못 조합해서, 부분적으로만 맞는 답을 만든다.
1.3 모델 레벨의 과도한 확신
- 프롬프트에서 "최대한 자세히 답변해" 같은 지시를 할 경우, 모델이 모르는 것도 채우려는 경향이 커집니다.
- "모르면 모른다고 말해"라고 써도, 실제로는 잘 지키지 않는 경우가 많습니다.
이 글에서 다룰 출처 검증 + Self-Check 패턴은 바로 1.2, 1.3을 직접 겨냥하는 전략입니다.
2. 기본 RAG 파이프라인: LangChain+OpenAI 예시
일단 가장 단순한 형태의 RAG 체인을 정리해 보겠습니다. (TypeScript 기반, LangChain 0.3+ 스타일 예시)
import { ChatOpenAI } from "@langchain/openai";
import { MemoryVectorStore } from "langchain/vectorstores/memory";
import { OpenAIEmbeddings } from "@langchain/openai";
import { RunnableSequence } from "@langchain/core/runnables";
const llm = new ChatOpenAI({
model: "gpt-4.1-mini",
temperature: 0,
});
const embeddings = new OpenAIEmbeddings({ model: "text-embedding-3-small" });
const vectorStore = await MemoryVectorStore.fromTexts(
["문서 내용 1", "문서 내용 2"],
[{ id: 1 }, { id: 2 }],
embeddings,
);
const retriever = vectorStore.asRetriever({ k: 4 });
const ragChain = RunnableSequence.from([
{
context: retriever,
question: (input: { question: string }) => input.question,
},
async ({ context, question }) => {
const prompt = `다음 컨텍스트를 기반으로 질문에 답변하세요.
컨텍스트:
${context.map((c: any) => c.pageContent).join("\n\n")}
질문: ${question}
규칙:
- 컨텍스트에 근거하지 않는 내용은 추측하지 마세요.
- 답변할 수 없으면 "제공된 문서만으로는 답할 수 없습니다"라고 말하세요.`;
const res = await llm.invoke(prompt);
return res;
},
]);
const answer = await ragChain.invoke({ question: "질문 내용" });
console.log(answer.content);
이 구조만으로도 어느 정도는 할루시네이션을 줄일 수 있지만, 컨텍스트에 없는 내용을 섞는 문제는 여전히 남습니다. 이제 여기에 출처 검증 단계와 Self-Check 단계를 추가해 보겠습니다.
3. 출처 검증(Source Grounding Check) 패턴
출처 검증의 목표는 단순합니다.
"답변의 각 주장(claim)이 실제로 컨텍스트(검색된 문서)에 의해 뒷받침되는가?"
3.1 설계 패턴 개요
출처 검증을 LangChain 체인으로 표현하면:
- 검색 결과와 질문을 기반으로 초안 답변(draft) 생성
- 초안 답변과 컨텍스트를 함께 LLM에 넘기고, 각 문장의 근거 존재 여부를 평가
- 근거가 부족한 부분이 있다면:
- 해당 부분을 삭제하거나
- "추측일 수 있음"으로 표시하거나
- 아예 전체 답변을 "답변 불가"로 교체
3.2 출처 검증 프롬프트 예시
출처 검증용 프롬프트는 생성 프롬프트보다 훨씬 보수적이어야 합니다.
당신은 사실 검증 어시스턴트입니다.
입력으로 다음 세 가지가 주어집니다.
1) 질문
2) 컨텍스트 문서들
3) 초안 답변
당신의 임무는 초안 답변의 각 문장이 컨텍스트에 의해 뒷받침되는지 확인하는 것입니다.
규칙:
- 컨텍스트에 명시적으로 존재하지 않는 정보는 "추측"으로 간주합니다.
- 파생 가능한 결론이라도, 컨텍스트에 근거가 명확하지 않으면 "불충분"으로 간주합니다.
출력 형식(JSON):
{
"verdict": "ok" | "partial" | "reject",
"issues": [
{
"sentence": "문장 내용",
"reason": "왜 근거가 부족한지 설명",
"severity": "low" | "medium" | "high"
}
],
"fixed_answer": "필요하다면 수정된 답변. reject인 경우에는 질문에 답하지 말고 '제공된 문서만으로는 답할 수 없습니다'라고 작성"
}
이제부터 위 형식의 JSON만 출력하세요.
3.3 LangChain으로 출처 검증 체인 구현
import { z } from "zod";
import { ChatOpenAI } from "@langchain/openai";
import { StructuredOutputParser } from "langchain/output_parsers";
const groundingSchema = z.object({
verdict: z.enum(["ok", "partial", "reject"]),
issues: z
.array(
z.object({
sentence: z.string(),
reason: z.string(),
severity: z.enum(["low", "medium", "high"]),
}),
)
.optional(),
fixed_answer: z.string(),
});
const groundingParser = new StructuredOutputParser(groundingSchema);
const groundingLlm = new ChatOpenAI({
model: "gpt-4.1-mini",
temperature: 0,
});
async function groundingCheck(args: {
question: string;
context: string;
draftAnswer: string;
}) {
const formatInstructions = groundingParser.getFormatInstructions();
const prompt = `질문: ${args.question}
컨텍스트:
${args.context}
초안 답변:
${args.draftAnswer}
${formatInstructions}`;
const res = await groundingLlm.invoke(prompt);
const parsed = await groundingParser.parse(res.content as string);
return parsed;
}
이제 전체 RAG 체인에 출처 검증을 끼워 넣습니다.
const ragWithGrounding = RunnableSequence.from([
async (input: { question: string }) => {
const docs = await retriever.getRelevantDocuments(input.question);
const context = docs.map((d) => d.pageContent).join("\n\n");
const draftPrompt = `다음 컨텍스트를 기반으로 질문에 답변하세요.
컨텍스트:
${context}
질문: ${input.question}
규칙:
- 컨텍스트에 없는 내용은 추측하지 마세요.
- 확실하지 않은 내용은 "제공된 문서만으로는 확실하지 않습니다"라고 말하세요.`;
const draft = await llm.invoke(draftPrompt);
const grounding = await groundingCheck({
question: input.question,
context,
draftAnswer: draft.content as string,
});
return { grounding, context };
},
({ grounding, context }) => {
if (grounding.verdict === "reject") {
return "제공된 문서만으로는 답할 수 없습니다.";
}
// partial인 경우 fixed_answer에 이미 수정된 답변이 들어있다고 가정
return grounding.fixed_answer;
},
]);
const safeAnswer = await ragWithGrounding.invoke({
question: "질문 내용",
});
이렇게 하면 컨텍스트와 불일치하는 주장이 답변에 들어가는 것을 1차적으로 걸러낼 수 있습니다.
4. Self-Check / Critic 패턴: LLM에게 한 번 더 물어보기
출처 검증이 컨텍스트-답변 정합성을 본다면, Self-Check는 답변 자체의 논리적 일관성과 품질에 초점을 맞춥니다.
4.1 Self-Check의 역할
- 질문을 제대로 이해했는지 재확인
- 답변이 질문에 직접적으로 응답하는지 확인
- 모호하거나 과도하게 일반화된 표현을 줄이기
- 중요한 조건/예외를 빠뜨리지 않았는지 점검
Self-Check는 꼭 RAG가 아니어도 쓸 수 있는 패턴이지만, RAG에서는 다음과 같이 결합합니다.
- RAG로 초안 답변 생성
- 출처 검증(grounding check)로 문서와의 정합성 확인
- Self-Check로 논리·구조·질문 적합성 평가 및 수정
4.2 Self-Check 프롬프트 예시
당신은 답변 검토 전문가입니다.
입력으로 질문과 답변이 주어집니다.
1) 질문에 직접적으로 답하고 있는지
2) 답변이 논리적으로 일관적인지
3) 중요한 전제나 예외 조건을 빠뜨리지 않았는지
를 검토한 뒤, 필요하면 답변을 수정하세요.
출력 형식(JSON):
{
"is_good": boolean,
"problems": ["문제점 1", "문제점 2"],
"improved_answer": "개선된 답변 (수정이 필요 없으면 원본과 동일하게 유지)"
}
JSON만 출력하세요.
4.3 Self-Check 체인 구현 예시
const selfCheckSchema = z.object({
is_good: z.boolean(),
problems: z.array(z.string()).optional(),
improved_answer: z.string(),
});
const selfCheckParser = new StructuredOutputParser(selfCheckSchema);
const criticLlm = new ChatOpenAI({
model: "gpt-4.1-mini",
temperature: 0,
});
async function selfCheck(args: { question: string; answer: string }) {
const formatInstructions = selfCheckParser.getFormatInstructions();
const prompt = `질문: ${args.question}
답변:
${args.answer}
${formatInstructions}`;
const res = await criticLlm.invoke(prompt);
return selfCheckParser.parse(res.content as string);
}
출처 검증 체인 뒤에 Self-Check 단계를 추가합니다.
const ragWithGroundingAndSelfCheck = RunnableSequence.from([
async (input: { question: string }) => {
const docs = await retriever.getRelevantDocuments(input.question);
const context = docs.map((d) => d.pageContent).join("\n\n");
const draftPrompt = `다음 컨텍스트를 기반으로 질문에 답변하세요.
컨텍스트:
${context}
질문: ${input.question}`;
const draft = await llm.invoke(draftPrompt);
const grounding = await groundingCheck({
question: input.question,
context,
draftAnswer: draft.content as string,
});
if (grounding.verdict === "reject") {
return {
question: input.question,
answer: "제공된 문서만으로는 답할 수 없습니다.",
};
}
return {
question: input.question,
answer: grounding.fixed_answer,
};
},
async ({ question, answer }) => {
const check = await selfCheck({ question, answer });
return check.improved_answer;
},
]);
const finalAnswer = await ragWithGroundingAndSelfCheck.invoke({
question: "질문 내용",
});
이제 답변은:
- 컨텍스트와의 정합성을 한 번 확인하고,
- 논리·구조 측면에서도 한 번 더 정제된 결과가 됩니다.
5. 성능·비용·지연시간 트레이드오프 설계
출처 검증과 Self-Check를 모두 붙이면 토큰 사용량과 응답 지연이 늘어납니다. 프로덕션에서는 다음과 같은 전략으로 타협점을 찾아야 합니다.
5.1 언제 검증을 켤 것인가
리스크 기반
- 법률, 의료, 금융 등 규제가 강한 도메인: 항상 출처 검증 + Self-Check
- 일반 FAQ, 마케팅 카피: Self-Check만 사용하거나, 둘 다 비활성화
길이 기반
- 답변 길이가 일정 토큰 수 이상일 때만 Self-Check 실행
- 짧은 답변은 오버헤드 대비 이득이 적을 수 있습니다.
불확실성 기반
- 검색된 문서 수가 너무 적거나 (예: k=4인데 실제로 1개만 나옴)
- 유사도 점수가 낮을 때
- 이럴 때만 출처 검증을 강하게 적용
5.2 비용 절감을 위한 모델 선택 전략
- 메인 답변:
gpt-4.1또는gpt-4.1-mini - 출처 검증:
gpt-4.1-mini또는gpt-4o-mini - Self-Check:
gpt-4.1-mini또는 더 작은 모델
출처 검증과 Self-Check는 요약·분류·평가에 가까운 태스크라서, 상대적으로 작은 모델로도 꽤 좋은 결과를 냅니다.
LLM을 로컬에 띄우는 경우라면, 메모리 제약을 고려해 모델과 KV 캐시를 튜닝해야 합니다. 이 부분은 Transformers 로컬 LLM OOM - 4-bit·KV 캐시 튜닝 글을 참고해 보시면 도움이 됩니다.
6. 프로덕션에서 필요한 Fail-Safe와 모니터링
6.1 Fail-Safe 전략
출처 검증과 Self-Check도 결국 LLM이기 때문에, 자체가 실패할 가능성을 항상 염두에 둬야 합니다.
실전에서 쓸 수 있는 패턴 몇 가지:
파서 실패 시 기본 답변 전략
- JSON 파싱 실패, 스키마 불일치 등 오류가 나면:
- 초안 답변(draft)을 그대로 사용하거나
- "현재 시스템 상태로는 안전한 답변을 제공할 수 없습니다" 같은 보수적 메시지 반환
- JSON 파싱 실패, 스키마 불일치 등 오류가 나면:
검증 단계 타임아웃
- 출처 검증/자기 검증 호출에 타임아웃(예: 3초)을 걸고, 초과 시 검증 없이 통과시키거나, 보수적 답변으로 대체
도메인별 규칙 기반 필터링
- 예: 의료 도메인에서는 특정 키워드(진단명, 약물명)가 포함된 답변은 항상 사람이 리뷰하도록 큐에 쌓기
6.2 모니터링 포인트
- 검증 단계에서
verdict = reject비율 - Self-Check에서
is_good = false비율 - 검증 단계 평균 토큰 수, 지연시간
- 질문 카테고리별 할루시네이션 신고(사용자 피드백) 비율
이 지표들을 기반으로:
- 검색 튜닝이 필요한지 (예: 하이브리드 검색 도입)
- 프롬프트를 더 보수적으로 바꿔야 하는지
- 특정 도메인에만 검증을 강하게 적용할지
를 반복적으로 조정할 수 있습니다. 검색 품질 튜닝 측면에서는 RAG 품질 급락? 벡터DB 하이브리드검색 튜닝을 함께 참고하면 좋습니다.
7. 구현 시 자주 나오는 실수와 팁
7.1 검증 프롬프트가 너무 공격적일 때
출처 검증 프롬프트를 너무 엄격하게 쓰면, 거의 모든 답변이 reject로 나오는 문제가 생깁니다.
- 컨텍스트에서 직접 인용된 문장만 허용하는 식의 규칙은 피하세요.
- "상식적 파생" 수준의 추론까지 전부 막으면, 답변이 지나치게 빈약해집니다.
적절한 타협:
- 수치, 날짜, 정책, 법률 조항 등 정확성이 중요한 부분은 엄격하게
- 개념 설명, 맥락 정리 등은 상대적으로 유연하게
7.2 체인 복잡도가 지나치게 높아질 때
LangChain으로 여러 Runnable을 중첩하다 보면, 디버깅이 어려워질 수 있습니다.
- 각 단계(검색, 초안 생성, 출처 검증, Self-Check)를 별도 함수로 분리
- 중간 결과를 로깅/스토리지에 남겨, 나중에 재현 가능하게 만들기
TypeScript를 쓰는 경우에는 스키마와 타입을 잘 맞추는 것이 중요합니다. 복잡한 체인에서 타입 미스매치가 잦다면 TS 5.5+ satisfies로 타입 좁히기 오류 잡기 글에서 소개한 패턴을 활용하면 도움이 됩니다.
7.3 OpenAI Rate Limit와 지연시간 관리
검증 단계가 추가되면 OpenAI 호출 수가 2~3배로 늘어납니다. 프로덕션 환경에서는:
- Rate Limit 초과를 방지하기 위해 큐잉 + 백오프 전략을 적용
- 질문당 허용되는 최대 지연시간을 정하고, 검증을 스킵하는 조건 정의
이 부분은 별도 글인 OpenAI 429·Rate Limit - 백오프·큐잉 실전에서 자세히 다루고 있으니, 실제 운영을 준비 중이라면 꼭 같이 보시길 추천합니다.
8. 마무리: RAG의 진짜 가치는 "안전한 추론"에 있다
RAG를 도입하는 이유는 단순히 더 많은 정보를 쓰기 위해서가 아니라, 더 신뢰할 수 있는 답변을 만들기 위해서입니다. 그 관점에서 보면, 출처 검증과 Self-Check는 선택이 아니라 거의 필수에 가깝습니다.
정리하면:
- 출처 검증(Source Grounding Check)은 컨텍스트와 답변의 정합성을 보장합니다.
- Self-Check / Critic 패턴은 논리적 일관성과 질문 적합성을 보완합니다.
- 두 단계를 모두 붙이되, 비용·지연시간·리스크를 고려해 도메인별·조건별로 온도를 조절해야 합니다.
이 글의 코드 예제를 그대로 가져다가, 먼저 스테이징 환경에서 적용해 보세요. 실제 사용자 질문 로그와 함께 돌려보면, 어떤 도메인과 질문 유형에서 할루시네이션이 많이 나오는지 금방 드러납니다.
그다음 할 일은 단순합니다.
- 검색 튜닝
- 프롬프트 조정
- 출처 검증 규칙 미세 조정
을 반복하면서, **"답을 모를 때는 모른다고 말하는 RAG"**에 가까워지도록 시스템을 다듬어 가는 것입니다.
이제 OpenAI+LangChain 기반 RAG를 운영 중이라면, 오늘부터라도 출처 검증과 Self-Check를 하나씩 붙여 보시길 권합니다. 할루시네이션을 완전히 없앨 수는 없지만, 사용자가 믿고 쓸 수 있는 수준까지는 충분히 줄일 수 있습니다.