당신의 RAG 파이프라인이 환각을 일으키는 이유는 스스로의 결과물을 확인하지 않기 때문입니다
요약
단순한 RAG 시스템의 환각 문제를 해결하기 위해 LangGraph를 활용한 교정형(Corrective) RAG 파이프라인 구축 방법을 소개합니다. 검색 결과의 품질을 평가하고, 필요 시 쿼리를 재작성하는 구조적 개선을 통해 환각된 인용을 획기적으로 낮추는 전략을 다룹니다.
핵심 포인트
- 단순 RAG의 한계인 검색 결과와 질문 간의 불일치 문제 지적
- LangGraph를 이용한 검색 품질 평가 및 쿼리 재작성 루프 구현
- 구조적 개선을 통해 환각된 인용 발생률을 18%에서 3% 미만으로 감소
- 지연 시간(Latency)과 답변 정확도 사이의 트레이드오프 분석
당신의 팀이 문서 챗봇을 출시했습니다. 이 챗봇은 청크(chunks)를 검색하여 프롬프트(prompt)에 집어넣고 답변을 생성합니다. 데모 데이는 성공적으로 끝났습니다. 그러다 한 고객이 "batch API의 rate limit(속도 제한)은 무엇인가요?"라고 묻자, 봇은 완전히 다른 API에 대한 문서를 인용하며 "분당 10,000회 요청"이라고 자신 있게 대답합니다. 답변이 그럴듯하게 들리기 때문에 아무도 이를 잡아내지 못합니다.
이것이 단순한(naive) RAG의 핵심적인 실패 모드입니다: 검색기(retriever)는 무언가를 반환하고, 생성기(generator)는 그것을 사용하며, 검색된 컨텍스트(context)가 실제로 질문에 답하는지 확인하는 사람이 아무도 없다는 것입니다. 해결책은 더 나은 임베딩(embeddings)이나 더 큰 컨텍스트 윈도우(context windows)가 아닙니다. 해결책은 자신의 검색 결과에 점수를 매기고, 결과가 좋지 않으면 쿼리(query)를 재작성하며, 컨텍스트가 답변을 뒷받침하지 않을 때는 생성을 거부하는 파이프라인을 구축하는 것입니다.
이 포스트는 LangGraph를 사용하여 교정형(corrective) RAG 파이프라인을 구축합니다. 검색하고, 점수를 매기고, 필요하면 재작성하며, 인용(citations)과 함께 생성합니다. 이 아키텍처는 재시도(retry) 경로에서 약 1.5초의 지연 시간(latency)을 추가하지만, 우리의 평가(evals)에서 환각된 인용(hallucinated citations)을 약 18%에서 3% 미만으로 낮췄습니다. 이것은 프롬프트 트릭이 아니라 구조적인 개선입니다.
지연 시간 계산 (The Latency Math)
단순한 RAG는 어려운 과정을 건너뛰기 때문에 빠릅니다:
| 단계 | 지연 시간 |
|---|---|
| 쿼리 임베딩 (Embed query) | ~0.1s |
| ... |
교정형 RAG는 점수 매기기와 선택적 재작성을 추가합니다:
| 단계 | 지연 시간 |
|---|---|
| 쿼리 임베딩 (Embed query) | ~0.1s |
| ... |
재시도 경로는 1.5초의 추가 비용이 발생합니다. 하지만 이는 검색 품질이 낮을 때만 실행됩니다. 일반적인 기술 문서 코퍼스(corpus)에서는 쿼리의 약 15-25% 정도입니다. 대안은 그 횟수만큼 100% 확률로 답변을 환각하는 것입니다. 계산은 간단합니다.
교정형 RAG 파이프라인 아키텍처 (The Corrective RAG Pipeline Architecture)
┌──────────────┐
│ Retrieve │
...
핵심 통찰: 평가(grading)는 필터가 아니라 게이트(gate)입니다. 만약 검색된 문서가 질문에 답하지 못한다면, 파이프라인은 더 나쁜 답변을 생성하는 대신 쿼리를 재작성(rewrite)하고 다시 시도합니다. 설정 가능한 재시도 횟수를 초과하면, 환각(hallucination)을 일으키는 대신 "낮은 신뢰도(low confidence)" 플래그를 달고 답변을 생성합니다.
상태(State): 모든 것을 추적하세요
RAG 상태(state)에는 question과 answer 이상의 것이 필요합니다. 검색 품질(retrieval quality), 재작성 횟수(rewrite count), 그리고 문서 자체를 추적해야 합니다. 왜냐하면 당신의 평가(evals) 과정에서 이 모든 정보가 필요하기 때문입니다.
from typing import TypedDict
...
문서 인제스션 (Document Ingestion)
파이프라인이 실행되기 전에, 문서는 분할(split), 임베딩(embedded), 그리고 인덱싱(indexed)되어야 합니다. 이 부분은 대부분의 튜토리얼에서 생략되지만, 대부분의 프로덕션 RAG 시스템이 실패하는 지점이기도 합니다.
from langchain_core.documents import Document
from langchain_core.vectorstores import InMemoryVectorStore
...
청크 크기(Chunk size)는 생각보다 훨씬 중요합니다. 너무 작으면(100자) 문맥(context)을 잃게 됩니다. 검색기(retriever)가 평가자(grader)가 평가할 수 없는 문장 파편들을 반환하게 됩니다. 너무 크면(2000자 이상) 관련성(relevance)이 희석됩니다. 예를 들어 "속도 제한(rate limits) AND 인증(authentication) AND 결제(billing)"에 관한 청크는 모든 것에 매칭되지만 아무것도 제대로 답변하지 못합니다. 기술 문서의 경우 50자의 오버랩(overlap)을 포함한 500자가 합리적인 시작점입니다. 직관이 아닌 평가(evals)를 통해 측정하세요.
노드 1: 검색 (Retrieve)
검색기(retriever)는 쿼리를 임베딩(embedding)으로 변환하고, 벡터 스토어(vector store)를 검색하여 상위 k개의 문서(top-k documents)를 반환합니다. 간단하지만, 대부분의 사람들이 과하게 설계(over-engineer)하는 노드이기도 합니다.
\python
@traceable(name="retrieve", run_type="retriever")
def retrieve_node(state: RAGState) -> dict:
query = state.get("rewritten_query") or state["question"]
results = retriever.invoke(query)
documents = [
{
"content": doc.page_content,
"metadata": doc.metadata,
}
for doc in results
]
return {"documents": documents}
`
참고: rewritten_query가 존재하면 이를 사용하고, 그렇지 않으면 원래 질문으로 되돌아갑니다(fall back). 이것이 재시도 루프(retry loop)가 작동하는 방식입니다. 즉, 재작성 노드(rewrite node)가 rewritten_query를 업데이트하면, 검색기(retriever)가 다음 패스에서 이를 가져와 사용합니다.
노드 2: 관련성 평가 (Grade Relevance)
이 노드는 교정형 RAG(corrective RAG)를 작동하게 만드는 핵심 노드입니다. LLM으로부터 이진 관련성 점수(binary relevance score)를 얻기 위해 구조화된 출력(structured output)을 사용합니다:
from pydantic import BaseModel, Field
...
"예 또는 아니오로 답하세요"라고 말하는 프롬프트 대신 왜 구조화된 출력(structured output)을 사용하나요? 자유 형식 텍스트 파싱(free-text parsing)은 취약하기 때문입니다. LLM이 "네, 대체로 관련이 있습니다" 또는 "어느 정도요" 또는 "문서들이 부분적으로 다루고 있습니다..."라고 답할 수 있으며, 그렇게 되면 정규 표현식(regex)을 작성해야 합니다. with_structured_output은 깔끔한 불리언(boolean) 값을 강제합니다. 파싱이 필요 없고, 모호함이 없으며, LLM이 형식을 창의적으로 바꾸더라도 조용한 실패(silent failures)가 발생하지 않습니다.
노드 3: 쿼리 재작성 (Rewrite Query)
검색 품질이 낮을 때, 쿼리를 더 구체적으로 재작성합니다. LLM은 원래 질문과 실패한 문서들을 모두 확인하므로, 무엇이 누락되었는지 식별할 수 있습니다:
@traceable(name="rewrite_query", run_type="chain")
def rewrite_node(state: RAGState) -> dict:
...
노드 4: 인용을 포함한 생성 (Generate with Citations)
생성기(generator)는 단순히 답변만 생성하는 것이 아니라, 모든 주장(claim)을 소스 문서에 매핑합니다. 이를 통해 환각(hallucination)을 감지할 수 있습니다:
@traceable(name="generate", run_type="chain")
def generate_node(state: RAGState) -> dict:
...
그래프 조립 (Graph Assembly)
라우팅 로직(routing logic)은 교정 패턴(corrective pattern)이 존재하는 곳입니다. 평가(Grade) → 결정(decide) → 재작성(rewrite) 또는 생성(generate):
from langgraph.graph import StateGraph, START, END
from langgraph.types import RetryPolicy
...
재작성(rewrite) → 검색(retrieve) → 평가(grade) 사이클이 교정 루프(corrective loop)입니다. max_rewrites는 기본적으로 2회로 제한합니다. 이는 모호한 쿼리를 정제하기에는 충분하지만, 정말로 답할 수 없는 질문에 대해 API 예산을 낭비할 정도로 많지는 않은 수준입니다.
파이프라인 실행 (Running the Pipeline)
from langsmith import tracing_context
...
운영 환경의 RAG 파이프라인 실패 사례 (Production RAG Pipeline Failures)
이것들은 데모와 운영 시스템(production system)을 구분 짓는 실패 모드들입니다.
1. 검색 드리프트 (Retrieval Drift). 쿼리는 "batch API rate limits"인데, 검색기(retriever)가 실시간(real-time) API rate limits에 관한 문서를 반환합니다. 내용은 rate limits에 관한 것이므로, 단순한(naive) RAG는 잘못된 API에 대해 확신에 찬 답변을 생성합니다. 채점 노드(grading node)는 "Tier 1: 500 RPM"이 배치 처리(batch processing)에 관한 질문에 답하지 못한다는 점을 포착하여 이를 잡아냅니다. 해결책: 관련성 채점기(relevance grader)는 단순히 같은 주제 영역에 있는지가 아니라, 문서가 _이 특정 질문_에 답하는지 여부에 대해 엄격해야 합니다.
2. 환각된 인용 (Hallucinated Citations). 생성기가 "[1]"을 인용했지만, 그 인용이 뒷받침하는 주장은 문서 [1]에 나타나지 않습니다. 인용은 존재하고 출처도 존재하지만, 주장과 출처 사이의 매핑이 조작된 것입니다. 이는 전용 충실도 평가(faithfulness eval) 없이는 잡아내는 것이 거의 불가능합니다. 해결책: 상태(state) 내의 citations 필드를 사용하면 이를 감사(auditable)할 수 있습니다. 귀하의 평가(eval)는 인용된 각 주장이 실제로 인용된 문서에 의해 뒷받침되는지 확인합니다.
3. 컨텍스트 윈도우 오버플로 (Context Window Overflow). k=10으로 10개의 문서를 검색하며, 각 문서는 500자입니다. 질문과 시스템 프롬프트 이전에 5,000자의 컨텍스트가 쌓입니다. 괜찮아 보입니다. 그러다 사용자가 복합적인 질문을 던지고, 재작성기(rewriter)가 이를 확장하면, 두 번째 검색 단계에서 10,000자의 컨텍스트를 밀어 넣게 됩니다. 그러면 생성기는 뒤쪽 문서들을 무시하기 시작합니다. 해결책: k값뿐만 아니라 전체 컨텍스트 길이를 제한(cap)하십시오. 그리고 가장 관련성이 높은 문서를 앞에 배치하십시오. LLM은 컨텍스트 윈도우 내에서도 최신성 편향(recency bias)과 초두 효과(primacy bias)를 가집니다.
4. 재작성 루프 환각 (Rewrite Loop Hallucination). 재작성기 (rewriter)는 쿼리를 더 구체적으로 만들지만, 동시에 정확도는 떨어뜨립니다. "Rate limit이 무엇인가요?"라는 질문이 "엔터프라이즈 스트리밍 엔드포인트의 초당 최대 요청 수는 얼마인가요?"로 변할 수 있습니다. 이는 더 구체적인 질문이지만, 귀하의 문서에는 존재하지 않는 개념을 대상으로 합니다. 두 번째 검색 (retrieval)은 상황이 더 악화됩니다. 해결책: 재작성기가 실패한 문서들을 확인하여 코퍼스 (corpus)에 실제로 무엇이 포함되어 있는지 알 수 있어야 합니다. 저희의 구현체는 바로 이 이유로 failed_docs를 재작성기에 전달합니다.
5. 임베딩 노후화 (Embedding Staleness). 귀하의 문서는 매주 업데이트되지만, 임베딩 (embeddings)은 매달 업데이트됩니다. 4주 중 3주 동안 리트리버 (retriever)는 오래된 인덱스 (index)에서 검색을 수행합니다. 새로운 기능은 결과가 전혀 나오지 않고, 폐기된 기능은 확신에 차 있지만 틀린 답변을 내놓습니다. 해결책: 문서가 업데이트될 때마다 인덱스를 재구성 (re-index)하십시오. 만약 비용이 너무 많이 든다면, 최소한 임베딩 타임스탬프 (timestamp)를 추적하고 결과가 업데이트 임계값보다 오래된 경우 "오래된 인덱스 (stale index)" 경고를 표시하십시오.
관측 가능성 (Observability)
모든 노드에 적용된 @traceable 데코레이터 (decorator)를 통해 LangSmith에서 단계별 가시성을 확보할 수 있습니다. 특히 RAG의 경우, 검색(retrieval) $\rightarrow$ 채점(grading) $\rightarrow$ 생성(generation) 흐름을 하나의 트레이스 (trace)에서 확인해야 합니다.
from langsmith import tracing_context
...
LangSmith 트레이스에서 다음을 확인할 수 있습니다:
- 어떤 문서가 반환되었는지와 그 유사도 점수 (similarity scores)를 보여주는 리트리버 스팬 (retriever span)
- 구조화된 관련성 평가 (structured relevance assessment)를 보여주는 채점 스팬 (grading span)
- 재작성 루프 (rewrite loop)가 실행되었는지 여부 (및 실행 횟수)
- 컨텍스트 (context)가 포함된 최종 프롬프트 (prompt)를 보여주는 생성기 스팬 (generator span)
- 노드당 총 토큰 사용량 및 지연 시간 (latency)
답변 품질이 저하될 때 가장 먼저 확인해야 할 사항은 재작성 (rewrites)이 급증하고 있는가 하는 점입니다. 만약 쿼리 전반에 걸쳐 rewrite_count 평균이 0.5를 넘는다면, 검색 품질이 편향된 것입니다. 아마도 임베딩 노후화나 임베딩 공간을 변화시킨 코퍼스 (corpus)의 변경 때문일 가능성이 높습니다.
검색 증강 생성 (Retrieval Augmented Generation)을 위한 평가 (Evals)
RAG 평가 (evals)에는 세 가지 축이 필요합니다: 검색 품질 (retrieval quality), 답변의 충실도 (answer faithfulness), 그리고 답변의 관련성 (answer relevance)입니다. 이 중 어느 하나라도 생략하면 결국 운영 사고 (production incident)로 이어질 사각지대가 발생하게 됩니다.
from langsmith import Client, evaluate
from openevals.llm import create_llm_as_judge
ls_client = Client()
dataset = ls_client.create_dataset(
dataset_name="corrective-rag-evals",
description="Corrective RAG 파이프라인 평가 데이터셋",
)
ls_client.create_examples(
dataset_id=dataset.id,
inputs=[
{"question": "What are the rate limits for the batch API?"},
{"question": "How do I authenticate API requests?"},
{"question": "What error code means I've been rate limited?"},
{"question": "What is the connection timeout for streaming?"},
],
outputs=[
{"expected_topics": ["batch", "50,000", "asynchronous"], "expected_source": "api-docs/batch-api.md"},
{"expected_topics": ["API key", "Authorization header", "rotate"], "expected_source": "api-docs/authentication.md"},
{"expected_topics": ["429", "rate limit"], "expected_source": "api-docs/errors.md"},
{"expected_topics": ["5 minutes", "SSE", "timeo
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기