Published on

OpenAI+LangChain RAG 할루시네이션 차단 전략

Authors

서론: 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 파이프라인을 크게 나누면 보통 이렇게 구성됩니다.

  1. 쿼리 이해 / 재작성 (Query Rewriting)
  2. 검색 (Vector / Hybrid Retrieval)
  3. 컨텍스트 선택 / 재구성
  4. 답변 생성 (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 체인으로 표현하면:

  1. 검색 결과와 질문을 기반으로 초안 답변(draft) 생성
  2. 초안 답변과 컨텍스트를 함께 LLM에 넘기고, 각 문장의 근거 존재 여부를 평가
  3. 근거가 부족한 부분이 있다면:
    • 해당 부분을 삭제하거나
    • "추측일 수 있음"으로 표시하거나
    • 아예 전체 답변을 "답변 불가"로 교체

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에서는 다음과 같이 결합합니다.

  1. RAG로 초안 답변 생성
  2. 출처 검증(grounding check)로 문서와의 정합성 확인
  3. 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 언제 검증을 켤 것인가

  1. 리스크 기반

    • 법률, 의료, 금융 등 규제가 강한 도메인: 항상 출처 검증 + Self-Check
    • 일반 FAQ, 마케팅 카피: Self-Check만 사용하거나, 둘 다 비활성화
  2. 길이 기반

    • 답변 길이가 일정 토큰 수 이상일 때만 Self-Check 실행
    • 짧은 답변은 오버헤드 대비 이득이 적을 수 있습니다.
  3. 불확실성 기반

    • 검색된 문서 수가 너무 적거나 (예: 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이기 때문에, 자체가 실패할 가능성을 항상 염두에 둬야 합니다.

실전에서 쓸 수 있는 패턴 몇 가지:

  1. 파서 실패 시 기본 답변 전략

    • JSON 파싱 실패, 스키마 불일치 등 오류가 나면:
      • 초안 답변(draft)을 그대로 사용하거나
      • "현재 시스템 상태로는 안전한 답변을 제공할 수 없습니다" 같은 보수적 메시지 반환
  2. 검증 단계 타임아웃

    • 출처 검증/자기 검증 호출에 타임아웃(예: 3초)을 걸고, 초과 시 검증 없이 통과시키거나, 보수적 답변으로 대체
  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를 하나씩 붙여 보시길 권합니다. 할루시네이션을 완전히 없앨 수는 없지만, 사용자가 믿고 쓸 수 있는 수준까지는 충분히 줄일 수 있습니다.