Joyuno's Dev Blog

실무에서 바로 쓰는 Agentic RAG: “자율적 정보 검색 에이전트”를 LangGraph로 구현하는 설계/코드/함정 총정리

AI Agent

들어가며

전통적 RAG는 보통 retrieve → (rerank) → generate 파이프라인이 고정이라, 질문이 애매하거나(재질문 필요), 문서가 방대하거나(추가 탐색 필요), 근거가 부족한데도 답을 생성하는(환각) 상황에서 취약합니다. 반대로 Agentic RAG는 LLM이 “지금 검색이 필요한가?”, “쿼리를 어떻게 바꿔야 하나?”, “검색 결과가 부실하니 더 파고들까?”를 자율적으로 결정하면서 루프를 돌립니다. LangGraph가 이 패턴(상태/루프/중단조건)을 가장 구현하기 좋은 프레임워크로 많이 쓰이고요. (langchain-ai.lang.chat)

언제 쓰면 좋은가

  • 문서가 크고 이질적(위키+PDF+티켓+코드+로그)이라 한 번의 top-k 검색으로는 정답 근거가 잘 안 모일 때
  • 사용자가 “A와 B 비교해줘”, “조건이 이럴 땐?”, “이 오류의 root cause?”처럼 다단계 탐색이 필요한 질문을 할 때
  • 제품/운영 환경에서 근거 부족 시 재검색/재질문/답변 보류 같은 정책을 넣고 싶을 때(품질, 컴플라이언스)

언제 쓰면 안 되는가

  • FAQ성 Q&A처럼 단발성 검색으로 충분한데도 “에이전트 루프”를 돌리면 비용/지연만 늘어납니다.
  • 관측/통제(Observability, Guardrails)가 없는 상태에서 “자율성”만 키우면 무한 루프·도구 남용·비용 폭발이 발생합니다(Agentic RAG의 가장 흔한 실전 사고). SoK에서도 비용/안정성/감독(oversight)을 핵심 과제로 봅니다. (arxiv.org)

🔧 핵심 개념

1) Agentic RAG의 정의(실무 관점)

Agentic RAG는 “검색을 붙인 챗봇”이 아니라, Retrieval을 ‘툴(tool)’로 만들고 LLM이 호출 여부/반복/전략을 결정하는 구조입니다. LangGraph 튜토리얼도 “retriever tool을 언제 쓸지 에이전트가 결정”하는 흐름을 전제로 합니다. (langchain-ai.lang.chat)

2) 내부 작동 방식(구조/흐름)

실무에서 가장 재사용 가능한 최소 골격은 아래 상태 머신입니다.

  • State
    • user_question: 원 질문
    • working_query: 현재 검색 쿼리(에이전트가 rewrite 가능)
    • retrieved_docs: 누적(또는 최근) 근거
    • draft_answer: 초안
    • critique: “근거 충분?”, “문서와 충돌?”, “추가 검색 필요?”
    • step_count / budget: 루프 상한(비용/안정성)
  • Nodes 1) Plan / Decide: 검색할지, 답을 쓸지, 쿼리를 바꿀지 결정 2) Retrieve: vector + (가능하면) keyword/hybrid 검색 3) Filter/Rerank: 관련도 낮은 문서 제거(여기서 노이즈가 누적되면 이후 모든 step이 망가짐) 4) Generate: 근거 기반 답안 생성(“모르면 모른다” 정책 포함) 5) Reflect / Self-check: 답이 질문을 충족했는지, 근거가 충분한지 평가 → 필요 시 루프

LangChain 블로그는 CRAG/Self-RAG류의 “self-reflective loop(재검색/쿼리 재작성/문서 폐기)”를 LangGraph로 구현하는 방향을 강조합니다. (langchain.com)
최근 SoK(2026)도 agentic RAG를 iterative retrieval + dynamic memory + oversight 관점에서 정리합니다. (arxiv.org)

3) 다른 접근과의 차이점

  • Traditional RAG: 파이프라인이 고정, 실패 시 원인 파악/회복이 어렵지만 단순하고 싸다.
  • Self-RAG(모델 중심): “하나의 모델이 retrieval/critique를 내부적으로 수행”하는 쪽(개념적으로는 좋지만, 실무에선 여전히 외부 툴/정책/예산 제약이 필요). (ibm.com)
  • Agentic RAG(오케스트레이션 중심): 모델은 플래너/결정자 + 툴 호출자. LangGraph/LlamaIndex 워크플로우처럼 상태/루프/도구 호출을 코드로 통제하기 쉬움. (langchain-ai.lang.chat)
  • Hierarchical retrieval interface(A-RAG, 2026): “semantic search만”이 아니라 keyword/semantic/chunk-read처럼 계층 툴을 노출해, 에이전트가 granularity를 바꿔가며 탐색(큰 코퍼스에서 비용/성능 균형에 유리). (arxiv.org)

💻 실전 코드

아래 예제는 “사내 운영 Runbook + 장애 티켓 + 설계 문서”를 합쳐서, 질문이 오면 (1) 쿼리 재작성 → (2) 하이브리드 검색 → (3) 근거 부족 시 추가 탐색 → (4) 최종 답 + 인용까지 도는 Agentic RAG의 최소 실전형입니다.

  • 벡터DB: 로컬 FAISS
  • 검색: BM25(키워드) + Vector(semantic) 결합(간단 가중 합)
  • 오케스트레이션: LangGraph
  • 중단조건: max_steps, no_new_evidence, confidence

주: LangGraph의 “그래프 루프”는 잘못 만들면 무한 재검색으로 빠지기 쉽습니다. 실제로 튜토리얼/예제들에서도 recursion/loop 이슈가 자주 언급됩니다. (langchain-opentutorial.gitbook.io)

0) 설치/환경

python -m venv .venv
source .venv/bin/activate

pip install -U langgraph "langchain[openai]" langchain-community langchain-text-splitters \
  faiss-cpu rank-bm25 pydantic
export OPENAI_API_KEY="..."

(위 설치 조합은 LangGraph agentic RAG 튜토리얼 계열과 호환되는 편입니다.) (langchain-ai.lang.chat)

1) 인덱싱(문서 준비는 “현실적인 형태”로)

# index_build.py
from pathlib import Path
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_community.document_loaders import DirectoryLoader, TextLoader
from langchain_community.vectorstores import FAISS
from langchain_openai import OpenAIEmbeddings

DATA_DIR = Path("./data")  # runbooks/, tickets/, design/
INDEX_DIR = Path("./faiss_index")

def load_docs():
    loader = DirectoryLoader(
        str(DATA_DIR),
        glob="**/*.txt",
        loader_cls=TextLoader,
        show_progress=True,
    )
    return loader.load()

def main():
    docs = load_docs()
    splitter = RecursiveCharacterTextSplitter(chunk_size=900, chunk_overlap=150)
    chunks = splitter.split_documents(docs)

    vs = FAISS.from_documents(
        chunks,
        embedding=OpenAIEmbeddings(model="text-embedding-3-large"),
    )
    vs.save_local(str(INDEX_DIR))
    print(f"Indexed chunks={len(chunks)} into {INDEX_DIR}")

if __name__ == "__main__":
    main()

예상 출력:

Indexed chunks=4823 into faiss_index

2) Agentic RAG 그래프(Plan→Retrieve→Generate→Reflect 루프)

# agentic_rag.py
from __future__ import annotations
from dataclasses import dataclass, field
from typing import List, Dict, Any, Optional, Tuple

from pydantic import BaseModel, Field

from langchain_openai import ChatOpenAI, OpenAIEmbeddings
from langchain_community.vectorstores import FAISS
from langchain_core.documents import Document

from rank_bm25 import BM25Okapi

from langgraph.graph import StateGraph, END

INDEX_DIR = "./faiss_index"

# -----------------------------
# State / Schemas
# -----------------------------
class AgentState(BaseModel):
    user_question: str
    working_query: str
    retrieved: List[Document] = Field(default_factory=list)
    answer: Optional[str] = None
    critique: Optional[str] = None
    step: int = 0
    max_steps: int = 4
    evidence_fingerprint: List[str] = Field(default_factory=list)  # for loop stopping

class Decide(BaseModel):
    action: str = Field(description="one of: retrieve, generate, rewrite, stop")
    rewrite_query: Optional[str] = None

class Critique(BaseModel):
    sufficient: bool
    reason: str
    suggest_rewrite: Optional[str] = None

# -----------------------------
# Retrieval: Hybrid (BM25 + Vector)
# -----------------------------
@dataclass
class HybridRetriever:
    faiss: FAISS
    bm25: BM25Okapi
    bm25_docs: List[Document]
    bm25_tokens: List[List[str]]
    k: int = 6

    @staticmethod
    def _tok(s: str) -> List[str]:
        return [t for t in s.lower().replace("/", " ").replace("_", " ").split() if t]

    @classmethod
    def from_faiss(cls, faiss: FAISS, bm25_source_docs: List[Document], k: int = 6):
        tokens = [cls._tok(d.page_content) for d in bm25_source_docs]
        bm25 = BM25Okapi(tokens)
        return cls(faiss=faiss, bm25=bm25, bm25_docs=bm25_source_docs, bm25_tokens=tokens, k=k)

    def search(self, query: str) -> List[Document]:
        # vector
        vec_hits = self.faiss.similarity_search(query, k=self.k)

        # bm25
        qtok = self._tok(query)
        scores = self.bm25.get_scores(qtok)
        top_idx = sorted(range(len(scores)), key=lambda i: scores[i], reverse=True)[: self.k]
        bm25_hits = [self.bm25_docs[i] for i in top_idx]

        # merge (cheap dedup by metadata+prefix)
        seen = set()
        merged = []
        for d in vec_hits + bm25_hits:
            key = (d.metadata.get("source", ""), d.page_content[:120])
            if key in seen:
                continue
            seen.add(key)
            merged.append(d)
        return merged[: self.k * 2]

# -----------------------------
# Graph nodes
# -----------------------------
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)

def decide_node(state: AgentState) -> Dict[str, Any]:
    prompt = f"""
You are an autonomous retrieval agent for internal engineering docs.
Decide next action for answering the user question.

User question:
{state.user_question}

Current working query:
{state.working_query}

Already have {len(state.retrieved)} retrieved docs.
Step {state.step}/{state.max_steps}.

Rules:
- If evidence is empty -> retrieve.
- If evidence exists but likely insufficient/ambiguous -> rewrite OR retrieve.
- If answer is likely possible with citations -> generate.
- If step reached max_steps -> stop.
Return JSON with action and optional rewrite_query.
"""
    decision = llm.with_structured_output(Decide).invoke(prompt)
    if state.step >= state.max_steps:
        return {"action": "stop"}
    return {"action": decision.action, "rewrite_query": decision.rewrite_query}

def retrieve_node_factory(retriever: HybridRetriever):
    def retrieve_node(state: AgentState) -> Dict[str, Any]:
        docs = retriever.search(state.working_query)
        # fingerprint to detect "no new evidence"
        fps = [f'{d.metadata.get("source","")}::{hash(d.page_content[:300])}' for d in docs]
        return {"retrieved": state.retrieved + docs, "evidence_fingerprint": state.evidence_fingerprint + fps}
    return retrieve_node

def rewrite_node(state: AgentState) -> Dict[str, Any]:
    prompt = f"""
Rewrite the search query to retrieve better evidence.
Constraints:
- Keep it short and keyword-rich.
- Include system/component names, error codes, config keys if relevant.
- Avoid generic wording.

User question:
{state.user_question}

Current working query:
{state.working_query}
"""
    new_q = llm.invoke(prompt).content.strip()
    return {"working_query": new_q}

def generate_node(state: AgentState) -> Dict[str, Any]:
    # keep context bounded: take recent N docs (pragmatic)
    docs = state.retrieved[-10:]
    ctx = "\n\n".join([f"[{i}] source={d.metadata.get('source','')}:\n{d.page_content}" for i, d in enumerate(docs)])
    prompt = f"""
Answer the question using ONLY the provided context. If context is insufficient, say what is missing and propose next retrieval query.

Question:
{state.user_question}

Context:
{ctx}

Output:
- Final answer in Korean
- Bullet list of citations like [0], [2] referencing the context items
"""
    ans = llm.invoke(prompt).content
    return {"answer": ans}

def reflect_node(state: AgentState) -> Dict[str, Any]:
    prompt = f"""
Evaluate if the answer is sufficiently grounded and complete.
If insufficient, suggest a better query to retrieve missing evidence.

User question:
{state.user_question}

Answer:
{state.answer}

Retrieved docs count: {len(state.retrieved)}

Return JSON: sufficient, reason, suggest_rewrite(optional)
"""
    c = llm.with_structured_output(Critique).invoke(prompt)
    return {"critique": c.reason, "sufficient": c.sufficient, "suggest_rewrite": c.suggest_rewrite}

# -----------------------------
# Routing logic
# -----------------------------
def route_after_decide(state: AgentState) -> str:
    if state.step >= state.max_steps:
        return "stop"
    action = state.__dict__.get("action")  # not in pydantic model; we'll pass via updates
    return action or "retrieve"

def route_after_reflect(state: AgentState) -> str:
    # stop if sufficient OR no new evidence pattern
    if getattr(state, "sufficient", False):
        return "stop"
    # if fingerprints are repeating too much, stop (loop breaker)
    if len(state.evidence_fingerprint) > 0:
        recent = state.evidence_fingerprint[-12:]
        if len(set(recent)) <= max(3, len(recent)//4):
            return "stop"
    return "rewrite"

def build_app(retriever: HybridRetriever):
    g = StateGraph(AgentState)

    g.add_node("decide", decide_node)
    g.add_node("retrieve", retrieve_node_factory(retriever))
    g.add_node("rewrite", rewrite_node)
    g.add_node("generate", generate_node)
    g.add_node("reflect", reflect_node)

    # edges
    g.set_entry_point("decide")

    # decide -> (retrieve|rewrite|generate|stop)
    def decide_router(state: AgentState):
        # we pass action via state updates from decide_node
        return state.__dict__.get("action", "retrieve")

    g.add_conditional_edges(
        "decide",
        decide_router,
        {
            "retrieve": "retrieve",
            "rewrite": "rewrite",
            "generate": "generate",
            "stop": END,
        },
    )

    g.add_edge("retrieve", "generate")
    g.add_edge("rewrite", "retrieve")
    g.add_edge("generate", "reflect")

    g.add_conditional_edges(
        "reflect",
        lambda s: "stop" if getattr(s, "sufficient", False) else "rewrite",
        {"stop": END, "rewrite": "rewrite"},
    )

    return g.compile()

def load_retriever() -> HybridRetriever:
    faiss = FAISS.load_local(INDEX_DIR, OpenAIEmbeddings(model="text-embedding-3-large"), allow_dangerous_deserialization=True)
    # BM25 source: FAISS에 들어간 원본 chunk를 재사용(실무에선 별도 코퍼스/필터 권장)
    bm25_docs = list(faiss.docstore._dict.values())
    return HybridRetriever.from_faiss(faiss, bm25_docs, k=6)

if __name__ == "__main__":
    retriever = load_retriever()
    app = build_app(retriever)

    q = "배포 후 특정 AZ에서만 502가 나는데, runbook 기준으로 어떤 점검 순서가 맞아?"
    init = AgentState(user_question=q, working_query=q, max_steps=4)

    out = app.invoke(init.model_dump())
    print(out["answer"])
    print("\nCritique:", out.get("critique"))

예상 출력(형태):

(한국어 답변 …)
- 점검 순서: (1) ALB target health … (2) 해당 AZ 서브넷 라우팅 … (3) 앱 로그에서 upstream timeout …
- 인용: [0], [3], [7]

Critique: 근거는 충분하나 AZ 단위 라우팅 정책 설명이 일부 추상적임…

이 예제의 핵심은 “정답 생성”이 아니라:

  • retrieve를 ‘필요할 때만’ 호출하도록 결정(Decide)
  • 근거 부족 시 rewrite→retrieve 루프
  • 무한 루프 방지(스텝/증거 반복 감지)

이 뼈대가 있어야 실무에서 Agentic RAG가 비용을 태우지 않고도 품질을 올리는 형태가 됩니다. (LangGraph 예제들이 강조하는 지점도 결국 이 그래프 제어입니다.) (langchain-ai.lang.chat)


⚡ 실전 팁 & 함정

Best Practice (2~3개)

1) Retrieval tool을 “계층화”하라

  • A-RAG가 제안하듯 keyword/semantic/chunk-read처럼 granularity가 다른 툴을 주면, 에이전트가 “대충 탐색→정밀 읽기”로 비용을 줄이기 쉽습니다. (arxiv.org)
  • 실무 구현은 간단합니다: keyword_search(), vector_search(), read_chunk(doc_id, span)를 각각 tool로 노출.

2) 중단조건을 품질 메트릭이 아니라 “예산/증거 변화량”으로도 걸어라

  • “충분하다”는 LLM 판정만 믿으면 과하게 낙관적(또는 무한 재시도)입니다.
  • max_steps, no_new_evidence, token/cost budget은 필수. SoK에서도 cost-aware orchestration과 oversight를 중요한 연구/실무 과제로 지적합니다. (arxiv.org)

3) Observability를 먼저 깔아라

  • tool 호출 횟수, 검색 쿼리 변화, top-k 문서, rerank 결과, reflection reason이 남아야 “왜 망했는지” 디버깅이 됩니다.
  • (현실적으로) Langfuse 같은 트레이싱을 붙이는 팀이 많고, 커뮤니티 예제도 관측성을 강하게 강조합니다. (reddit.com)

흔한 함정/안티패턴

  • “검색을 많이 할수록 좋다” 착각: 검색 노이즈가 누적되면 컨텍스트가 오염되어 답 품질이 떨어집니다. 해결: filter/rerank + 컨텍스트 압축/요약(working memory compression). (커뮤니티 프로젝트들도 context compression을 반복적으로 추가합니다.) (reddit.com)
  • 문서 스코프 미정: 멀티 테넌트/멀티 서비스 문서가 섞인 인덱스에서 “비슷한 설정 키”가 충돌하면 답이 그럴듯하게 틀립니다. 해결: retrieval에 service, env, repo, date 메타데이터 필터를 1급으로.
  • Reflection이 ‘비평’이 아니라 ‘추가 요청’만 하는 형태: “부족함”을 감지해도 다음 액션(어떤 쿼리로, 어떤 툴로, 어느 범위를)로 연결되지 않으면 루프가 의미 없습니다. reflection output을 구조화(JSON)하고 라우팅에 사용하세요.

비용/성능/안정성 트레이드오프

  • Agentic RAG는 기본적으로 LLM 호출 수가 늘어 비용/지연이 증가합니다. 대신 “필요할 때만 retrieve”와 “계층 검색”을 잘 설계하면, 단일 거대 모델로 무작정 답을 뽑는 것보다 총비용이 내려갈 수도 있습니다(쉬운 질문에선 retrieve 생략/저가 모델 라우팅).
  • 자율성을 올릴수록 안정성(무한 루프, 툴 남용) 리스크가 커지므로, OpenAI Agents SDK류가 제공하는 guardrails/검증 계층을 함께 고려하는 팀도 많습니다. (openai.com)

🚀 마무리

Agentic RAG를 2026년 실무에서 “도입할 만한 기술”로 만드는 포인트는 화려한 멀티에이전트가 아니라, (1) 루프 제어(중단조건), (2) 계층화된 retrieval tool, (3) 관측/디버깅 가능성, (4) 근거 부족 시 안전한 실패(답변 보류/추가 질문)입니다. (arxiv.org)

도입 판단 기준

  • 코퍼스가 커서 “한 번 검색”이 자주 실패하고, 실패를 복구할 방법(재검색/재질문)이 필요하다 → 도입 가치 큼
  • 질문 난이도가 낮고 top-k만으로 충분하다 → Traditional RAG + rerank가 더 단순/저렴

다음 학습 추천(순서) 1) LangGraph의 agentic RAG 그래프 패턴(상태/루프/중단) (langchain-ai.lang.chat)
2) LlamaIndex의 ReAct + QueryEngine 도구화 방식(툴 인터페이스 설계 참고) (docs.llamaindex.ai)
3) 2026 SoK / A-RAG 논문으로 “계층 retrieval·평가·oversight” 관점 확장 (arxiv.org)

원하시면, 위 코드에 (a) 메타데이터 필터링(서비스/환경), (b) reranker 추가, (c) chunk-read 툴(A-RAG 스타일), (d) 비용 예산 기반 모델 라우팅까지 붙여서 “운영 가능한 형태”로 한 단계 더 확장한 버전도 이어서 작성해드릴게요.