RAG·Agent

ReAct 루프를 버리고 상태 그래프로: LangGraph 법령 RAG 에이전트 설계

법령 RAG 에이전트를 LangGraph StateGraph로 만들면서 내린 설계 결정들을 정리합니다. 에이전트 아키텍처의 선택지들, ReAct 툴콜 루프 대신 결정적 라우팅을 택한 이유, 상태 스키마와 재시도 설계, 그리고 실측으로 확인한 트레이드오프까지.

선형 파이프라인은 어디서 부러지는가

교과서적인 RAG는 검색 → 프롬프트 조립 → 생성의 한 방향 파이프라인입니다. 이 구조는 검색이 성공했을 때만 작동합니다. 검색된 조문이 질문과 무관하면 모델은 무관한 근거로 답을 지어내고, 답이 코퍼스에 없으면 거절 대신 환각을 냅니다. 법령 QA에서는 이 실패가 특히 아픕니다. 틀린 조문 번호를 인용한 답변은 맞는 답보다 해롭기 때문입니다.

그래서 파이프라인이 아니라 루프가 필요했습니다. 검색 결과의 근거 충분성을 판정하고, 부족하면 질의를 재작성해 다시 검색하고, 생성된 답변의 인용을 검증해 실패하면 재생성하고, 끝까지 안 되면 거절하는 흐름입니다. 이런 조건 분기와 재시도를 담는 도구로 LangGraph의 StateGraph를 썼습니다.

잠깐 용어를 정리하면, LangGraph의 StateGraph는 타입 지정된 공유 상태를 중심에 두고, 노드(함수)들이 상태를 읽고 갱신하며, 엣지(고정 또는 조건부)가 다음 노드를 정하는 상태 기계입니다. LLM 호출은 노드 안의 구현 디테일일 뿐이고, 흐름 제어는 그래프 정의가 가집니다. 이 분리가 아래 모든 설계 결정의 바탕입니다.

첫 번째 결정: ReAct 루프를 쓰지 않았다

LangGraph 예제의 다수는 ReAct 스타일입니다. LLM에게 검색 도구를 쥐여 주고, 매 스텝 어떤 도구를 부를지 스스로 결정하게 하는 구조입니다. 이 방식은 경로가 미리 정해지지 않은 개방형 작업에서 값어치를 하지만, 대신 비용이 스텝 수에 선형으로 늘고(매 스텝이 LLM 호출이고 컨텍스트가 누적됩니다), 한 스텝 앞만 보므로 전역 계획에 약하다는 트레이드오프가 일반적으로 보고됩니다(비교 정리).

법령 QA의 행동 공간은 그보다 훨씬 좁습니다. 검색 전략은 벡터·하이브리드·그래프 세 가지뿐이고, 질문 유형(단일 조문·정의·멀티홉·답변 불가)과 전략의 대응도 사전에 정할 수 있습니다. 그래서 LLM에게 도구 선택을 맡기는 대신, 분류 결과를 결정적 매핑으로 라우팅했습니다.

# 질문 유형 → 검색 전략. LLM이 아니라 dict가 라우팅한다.
_STRATEGY = {
    "single": "hybrid",
    "definition": "vector",
    "multihop": "graph",
}

여기에 한 가지 우회로를 얹었습니다. 질문이 “「해양환경관리법」 제63조”처럼 조문을 직접 지목하면 분류를 건너뛰고 정규식 파싱으로 해당 조문을 잡아 1순위에 고정합니다. LLM 분류가 개입할수록 좋은 지점과 개입하지 않아야 하는 지점을 나눈 셈입니다.

이 구조에서 LLM 호출은 정확히 세 지점입니다. 질문 유형 분류, 근거 충분성 판정(+질의 재작성), 답변 생성. 전부 structured output으로 받아 파싱 실패를 없앴습니다. 질의당 비용은 실측 평균 입력 5,635 토큰, 출력 202 토큰, 약 $0.001로 예측 가능한 범위에 묶였습니다.

상태 스키마가 설계의 절반이다

StateGraph는 모든 노드가 읽고 쓰는 타입 지정 공유 상태를 중심에 둡니다. 어떤 필드를 상태에 둘 것인가가 곧 에이전트가 무엇을 기억하고 무엇으로 분기하는가를 정의합니다. 이 프로젝트의 상태에서 중요한 필드는 세 부류였습니다.

class _State(TypedDict, total=False):
    # 질의 계보: 원 질문과, 재작성을 거친 현재 검색어를 분리 유지
    query: str
    active_query: str
    rewritten_query: str
    # 분기 신호: 다음 노드를 정하는 판정 결과
    query_type: str
    strategy: str
    evidence_sufficient: bool
    refused: bool
    low_confidence: bool
    # 안전장치: 무한 루프를 막는 카운터
    retrieval_attempts: int
    generation_attempts: int

원 질문(query)과 현재 검색어(active_query)를 분리한 것이 재검색 루프의 뼈대입니다. 재작성된 질의로 다시 검색하더라도 생성 단계는 항상 원 질문에 답해야 하기 때문입니다. 재시도 카운터를 상태에 명시적으로 두면 “최대 2회”라는 정책이 그래프 정의에서 바로 읽힙니다. 여러 브랜치가 같은 필드를 누적 갱신하는 설계라면 reducer를 붙여 조용한 덮어쓰기를 막으라는 것이 일반적인 권고인데(LangGraph 상태 설계), 이 그래프는 병렬 브랜치가 없어 단순 대입으로 충분했습니다.

노드 7개, 거절 경로 2개

최종 그래프는 노드 7개입니다. 분류(classify) → 검색(retrieve) → 근거 판정(grade_evidence) → 생성(generate) → 검증(verify), 그리고 거절 노드 2개(refuse, refuse_low_confidence)입니다.

법령 RAG 에이전트의 StateGraph 흐름 — classify에서 verify까지의 본선과 거절 경로 2개, 재검색·재생성 루프 2개

거절 경로를 둘로 나눈 것은 의도적입니다. 분류 단계에서 답변 불가로 판정되면 검색 없이 바로 거절하고(비용 절약), 검색까지 했는데 근거가 부족하면 “확신이 낮다”는 다른 종류의 거절을 냅니다. 골드셋의 거절 문항 26개에 대해 거절 정확도 1.000이 나온 데에는 이 이중 경로가 기여했다고 봅니다. 답할 수 없는 질문에 그럴듯한 답을 만드는 것이 법령 도메인에서 최악의 실패 모드이기 때문에, 거절을 일급 경로로 설계했습니다.

재시도는 두 곳에 있습니다. 근거 판정이 부족 판정을 내리면 재작성된 질의로 재검색(최대 2회), 인용 검증이 실패하면 재생성(최대 2회)합니다. 재검색 조건에는 “재작성된 질의가 현재 검색어와 실제로 다를 것”이라는 조항을 넣어, 같은 질의로 같은 검색을 반복하는 낭비를 막았습니다.

스트리밍은 UX가 아니라 신뢰의 문제였다

LangGraph의 stream_mode="updates"는 각 노드가 상태에 만든 변경분을 순서대로 내보냅니다. 이것을 FastAPI에서 SSE로 감싸면, 사용자 화면에 “질문을 멀티홉으로 분류 → 그래프 전략으로 검색 → 근거 충분 판정 → 생성” 같은 진행 과정이 실시간으로 흐릅니다. 법령 답변처럼 검증이 중요한 도메인에서는 이 과정 노출이 답변 텍스트만큼 중요했습니다. 어떤 전략으로 어떤 조문을 찾았는지가 보여야 사용자가 인용을 따라가며 검증할 수 있기 때문입니다.

한편 체크포인팅(checkpointer)은 붙이지 않았습니다. 멀티턴 대화나 장기 실행 워크플로에는 상태 스냅샷 영속화가 사실상 필수지만, 이 시스템은 단발 질의응답이라 스냅샷을 저장할 이유가 없었습니다. 기능을 아는 것과 기본으로 켜는 것은 다른 문제입니다.

실측이 알려준 뜻밖의 사실: 오라클 라우팅이 지지 않는다, 이긴다

이 설계에서 가장 흥미로운 실측은 라우팅 평가였습니다. 골드셋의 정답 질문 유형대로 라우팅한 오라클의 hit@1은 0.357, LLM 분류(정확도 0.672)를 거친 실제 라우팅은 0.526이었습니다. 분류가 자주 틀리는데 결과는 더 좋았습니다.

원인은 라우팅 테이블에 있습니다. 오라클은 멀티홉 117문항을 전부 그래프 전략(hit@1 0.239)으로 보내는데, LLM은 멀티홉 상당수를 단일·정의형으로 오분류해 하이브리드·벡터로 보냈고, 그쪽 1순위 정답률이 더 높았던 것입니다. “분류를 더 정확하게”가 아니라 “멀티홉→그래프라는 매핑 자체가 1순위 정확도에는 손해”라는 결론이 나옵니다. 라우팅 테이블은 직관이 아니라 전략별 실측 위에서 정해야 한다는, 당연하지만 자주 건너뛰는 교훈을 데이터로 확인했습니다.

LangGraph가 정당화되는 조건

이 프로젝트를 기준으로 판단 기준을 정리하면 이렇습니다.

  • 조건 분기·재시도·다중 종료(거절)가 있으면 StateGraph가 값어치를 합니다. 같은 로직을 if문과 while문으로 짜면 상태 전이가 코드 곳곳에 흩어지는데, 그래프 정의는 흐름 전체를 한 파일에서 읽게 해 주고 스트리밍·관측(Langfuse 콜백 전파)도 공짜로 따라옵니다.
  • 경로가 사전에 정해지는 작업이면 ReAct식 툴콜 루프는 오버엔지니어링입니다. 행동 공간이 유한하면 라우팅은 dict로 충분하고, LLM은 분류·판정·생성처럼 정말 언어 이해가 필요한 지점에만 씁니다.
  • 단발 질의면 체크포인터는 생략합니다. 멀티턴, 사람 개입 대기, 장기 실행이 생길 때 붙여도 늦지 않습니다.

실무 과제(해양경찰청 CDX)에서 검색·답변 흐름을 구성할 때도 같은 원칙으로 접근했습니다. 도구 선택의 자유도가 필요한 문제인지, 유한한 분기의 문제인지를 먼저 가르는 것이 LangGraph를 쓸지 말지보다 앞에 오는 질문입니다.

수치의 출처는 maritime-law-rag-agent 레포의 reports/(agent_eval, embedder_ablation, cost)이고, 그래프 구조는 src/korean_maritime_law_rag/agent/graph.py에 있습니다.

LangGraphRAG에이전트 설계
관련 프로젝트
해양 법령 도메인 RAG 개발

해양 법령을 조문 단위로 검색하고 답변마다 근거 조문을 인용하는 RAG 시스템. BM25·벡터·조문 관계 그래프 검색을 병합해 단순 키워드 검색보다 정확한 인용을 만드는 흐름에 초점.

© 2026 Minsu Kang. All rights reserved.