검색해서 넣으면 끝? 당신의 RAG가 자신 있게 틀리는 4가지 순간 — LangGraph로 스스로 검색을 고치는 에이전틱 RAG(CRAG), 노드 6개로 끝내기

검색해서 넣으면 끝? 단순 RAG가 틀리는 4가지 순간 — LangGraph 에이전틱 RAG

사내 문서로 RAG 챗봇을 만들어 돌려 보면 꼭 이런 순간이 옵니다. "작년 AKS 업그레이드 때 뭐가 문제였지?"라고 물었는데, 검색기가 엉뚱한 GKE 문서를 끌어오고, 모델은 그 문서를 근거로 자신 있게 틀린 답을 합니다. 검색이 틀렸다는 걸 아무도 확인하지 않았기 때문입니다. 지난 글에서 LangChain으로 30줄짜리 RAG를 만들었는데, 그건 "검색 → 프롬프트 → 생성"이 한 방향으로 흐르는 체인(Chain)이었습니다. 이 구조는 한 번 틀리면 되돌릴 방법이 없습니다.

오늘은 그 한계를 LangGraph로 푸는 이야기입니다. 검색 결과를 스스로 평가하고, 엉터리면 질문을 고쳐 다시 검색하고, 답변에 근거가 없으면 다시 쓰는 RAG. 요즘 에이전틱 RAG(Agentic RAG)라고 부르는 그것을, 논문 이름으로는 CRAG(Corrective RAG)라고 부르는 패턴을 중심으로, 노드 6개짜리 그래프로 직접 만들어 봅니다.

단순 RAG(체인) vs 에이전틱 RAG(그래프) 비교

단순 RAG가 틀리는 4가지 순간

체인 RAG의 문제는 성능이 아니라 구조에 있습니다. 첫째, 검색 결과가 엉터리여도 그대로 답변합니다. 벡터 유사도 상위 4개를 가져왔을 뿐, 그게 질문과 정말 관련 있는지는 아무도 보지 않습니다. 둘째, 질문이 모호해도 다시 묻거나 고쳐 쓰지 않습니다. "그거 설정 어떻게 해?" 같은 질문은 검색 자체가 실패합니다. 셋째, 답변이 검색된 문서에 근거하고 있는지 확인하지 않습니다. 모델이 문서를 무시하고 자기 지식으로 답해도 막을 수 없습니다. 넷째, 그래서 한 번 틀리면 끝입니다. 재시도라는 개념이 없습니다.

지난 글에서 "청킹 튜닝, 하이브리드 검색, 리랭커를 붙이면 품질이 오른다"고 했는데, 그건 모두 검색을 더 잘하게 만드는 방법입니다. 그래도 검색은 실패합니다. 에이전틱 RAG는 접근이 다릅니다. 검색이 실패할 것을 전제하고, 실패를 감지해서 고치는 루프를 넣습니다.

LangChain은 체인, LangGraph는 그래프: 왜 둘이 따로 있나

LangChain의 LCEL(prompt | llm | parser)은 DAG, 즉 방향이 있고 순환이 없는 그래프입니다. 앞에서 뒤로만 흐릅니다. "평가 결과가 나쁘면 두 단계 전으로 돌아가라"는 표현 자체가 불가능합니다. LangGraph는 이 제약을 풀기 위해 나온 하위 런타임으로, 세 가지 개념만 알면 됩니다.

State는 그래프 전체가 공유하는 딕셔너리입니다. 질문, 검색된 문서, 생성된 답변, 재시도 횟수 같은 것이 들어갑니다. Node는 State를 받아 일부를 갱신해 돌려주는 평범한 파이썬 함수입니다. Edge는 어느 노드 다음에 어디로 갈지 정하며, 조건부 엣지(conditional edge)는 State를 보고 분기합니다. 순환이 허용되므로 "평가 → 재작성 → 재검색 → 평가"가 자연스럽게 표현됩니다.

참고로 2025년 10월 LangChain 1.0과 LangGraph 1.0이 함께 나오면서 역할이 정리됐습니다. LangChain은 create_agent 한 줄로 에이전트를 만드는 상위 API, LangGraph는 그 밑에서 도는 런타임이자 세밀한 제어가 필요할 때 직접 쓰는 하위 프레임워크입니다. 기존의 create_react_agent는 deprecated 됐고, 2.0까지 API를 깨지 않겠다는 약속이 붙었습니다. 오늘 만드는 CRAG처럼 분기와 루프를 내 손으로 설계해야 하는 경우가 바로 LangGraph를 직접 쓰는 경우입니다.

CRAG 그래프 노드 구성: retrieve, grade_documents, decide_to_generate, transform_query, web_search, generate

CRAG 그래프, 노드 6개로 끝난다

흐름은 위 그림이 전부입니다. retrieve가 벡터 검색을 하고, grade_documents가 문서 하나하나를 "질문과 관련 있는가"로 판정해 관련 없는 것을 버립니다. decide_to_generate는 조건부 엣지로, 살아남은 문서가 있으면 generate로, 하나도 없으면 transform_query로 보냅니다. 재작성된 질문으로 web_search를 돌려 문서를 보강한 뒤 다시 평가로 돌아갑니다. 코드로 옮기면 이렇습니다.

from typing import TypedDict, List
from langgraph.graph import StateGraph, START, END

class State(TypedDict):
    question: str
    documents: List[str]
    generation: str
    retries: int

def retrieve(s):
    return {"documents": retriever.invoke(s["question"])}

def grade_documents(s):
    keep = [d for d in s["documents"]
            if grader.invoke({"q": s["question"], "doc": d}).score == "yes"]
    return {"documents": keep}

def decide_to_generate(s):
    if s["documents"] or s["retries"] >= 2:   # 루프 상한
        return "generate"
    return "transform_query"

def transform_query(s):
    better = rewriter.invoke({"q": s["question"]})
    return {"question": better, "retries": s["retries"] + 1}

def web_search(s):
    hits = tavily.invoke(s["question"])
    return {"documents": s["documents"] + [h["content"] for h in hits]}

def generate(s):
    return {"generation": rag_chain.invoke(
        {"context": s["documents"], "question": s["question"]})}

g = StateGraph(State)
for name, fn in [("retrieve", retrieve), ("grade_documents", grade_documents),
                 ("transform_query", transform_query), ("web_search", web_search),
                 ("generate", generate)]:
    g.add_node(name, fn)

g.add_edge(START, "retrieve")
g.add_edge("retrieve", "grade_documents")
g.add_conditional_edges("grade_documents", decide_to_generate,
                        {"generate": "generate", "transform_query": "transform_query"})
g.add_edge("transform_query", "web_search")
g.add_edge("web_search", "grade_documents")
g.add_edge("generate", END)

app = g.compile()
app.invoke({"question": "작년 AKS 업그레이드 때 뭐가 문제였지?", "retries": 0})

세 가지만 짚겠습니다. 첫째, grader는 "yes/no"만 뱉는 작은 모델이면 충분합니다. 문서 4개를 판정하느라 큰 모델을 네 번 부르면 비용이 생성보다 커집니다. 저는 Haiku급 모델에 구조화 출력(with_structured_output)을 붙여 씁니다. 둘째, retries 상한을 반드시 둬야 합니다. 상한이 없는 그래프는 검색이 계속 실패하는 질문에서 무한 루프를 돌며 과금만 쌓입니다. 셋째, 노드는 전부 평범한 함수라서 단위 테스트가 됩니다. 체인 전체를 돌리지 않고 grade_documents만 따로 검증할 수 있다는 건 운영에서 꽤 큰 차이입니다.

여기에 "답변이 문서에 근거하는가, 질문에 답하는가"를 검사하는 grade_generation 노드를 generate 뒤에 하나 더 붙이면, 환각까지 잡는 완전한 Self-Corrective RAG가 됩니다. 근거가 부족하면 다시 생성하고, 질문에 답하지 못했으면 transform_query로 돌려보냅니다.

CRAG, Self-RAG, Adaptive RAG 비교

CRAG, Self-RAG, Adaptive RAG: 고치는 지점이 다르다

에이전틱 RAG 논문이 여럿이라 헷갈리는데, "어느 지점을 고치느냐"로 보면 정리됩니다. CRAG는 검색 결과를 고칩니다. 평가기가 문서를 판정하고, 틀렸으면 질문을 고쳐 웹 검색으로 보강합니다. 기존 파이프라인의 검색기와 생성기를 바꾸지 않고 덧붙일 수 있어 실무 진입 장벽이 가장 낮습니다. Self-RAG는 생성 결과를 고칩니다. 원 논문은 모델이 "검색 필요한가, 문서가 관련 있나, 답이 근거 있나, 유용한가"를 뜻하는 반성 토큰을 직접 뱉도록 학습시켰는데, 실무에서는 그 모델을 쓰는 대신 같은 역할을 프롬프트 기반 평가기로 흉내 내는 경우가 대부분입니다. 위에서 말한 grade_generation이 그것입니다. Adaptive RAG는 질문 자체를 라우팅합니다. 작은 분류기가 질문 난이도를 보고 "검색 없이 답변 / 한 번 검색 / 반복 검색" 중 하나로 보냅니다. 인사말이나 단순 질문에 검색 비용을 쓰지 않게 해 줍니다.

제 추천 순서는 이렇습니다. CRAG로 시작해서 검색 실패를 잡고, 환각 검사를 추가하고, 트래픽이 늘어 비용이 보이기 시작하면 Adaptive 라우팅을 맨 앞에 붙입니다. 셋 다 LangGraph에서는 노드 하나와 조건부 엣지 하나를 추가하는 일이라, 한 번 그래프로 옮겨 두면 확장이 쉽습니다.

에이전틱 RAG 운영 체크리스트

운영에서 터지지 않으려면

그래프는 똑똑해지는 만큼 느려지고 비싸집니다. 단순 RAG가 LLM 호출 1회로 끝난다면, CRAG는 최소 "판정 N회 + 생성 1회"이고 루프가 돌면 곱절이 됩니다. 그래서 몇 가지 원칙을 지킵니다. 루프 상한은 2~3회로 State에 카운터를 둡니다. 판정은 작은 모델, 생성만 큰 모델로 나눕니다. 그래프로 가기 전에 반드시 단순 체인으로 베이스라인 정확도를 재 둡니다. 그래야 "그래프로 바꿔서 얼마나 좋아졌는지"를 말할 수 있습니다. LangSmith든 다른 트레이싱이든 어느 노드에서 몇 번 돌았는지 보이는 도구는 필수입니다. 보이지 않는 루프는 디버깅이 안 됩니다. 사내 문서 RAG에 웹 검색을 섞을 때는 검색 도메인을 화이트리스트로 제한합니다. 긴 루프는 체크포인터(개발은 MemorySaver, 운영은 Postgres)를 붙여 중단 지점부터 재개할 수 있게 합니다.

정리

지난 글의 결론이 "LangChain은 LLM 앱의 부품 상자"였다면, 이번 글의 결론은 "RAG의 품질은 검색기가 아니라 실패를 다루는 구조에서 갈린다"입니다. 체인은 실패를 모르고, 그래프는 실패를 감지하고 고칩니다. 그 차이를 만드는 데 필요한 건 노드 6개와 조건부 엣지 하나입니다. 이미 돌아가는 RAG가 있다면, 검색기를 바꾸기 전에 grade_documents 노드 하나부터 붙여 보시길 권합니다. 틀린 검색이 얼마나 자주 그대로 답변으로 나가고 있었는지 눈으로 보게 됩니다.

참고: LangChain 블로그 "LangChain and LangGraph 1.0"(2025.10), LangGraph 공식 CRAG/Self-RAG 튜토리얼, Yan et al. "Corrective Retrieval Augmented Generation"(2024), Asai et al. "Self-RAG"(ICLR 2024), Jeong et al. "Adaptive-RAG"(2024), dataaspirant.com의 세 논문 비교 글. 코드는 설명을 위해 단순화한 것으로, retriever·grader·rewriter·rag_chain은 각자 환경에 맞게 정의해야 합니다.

댓글

이 블로그의 인기 게시물

금융권 망분리 환경에서 클로드 코드 도입할 때 실제로 걸리는 5가지 — 보안팀 질문과 대응 설정

맥 스튜디오 M5 Ultra 512GB, 3,000만원을 어떻게 결제할까 — 카드 한도 상향·18개월 무이자·잔존가치 금융리스 총정리

Kubernetes Pod Pending 해결법: FailedScheduling 원인별 실전 점검 가이드