RAG 입문

LangChain + LangGraph + Langfuse로 만드는 가장 작은 RAG

2026. 06. 27.
AIRAGLangGraphLangChainLangfuse

지난 두 글에서 RAG의 개념과 임베딩의 직관을 잡았다. 이제 진짜로 돌아가는 코드를 만들어볼 차례다.

이번 글에서 만들 건 단순하다.

작긴 하지만 실무 RAG의 모양이 그대로 들어가 있다. 끝나면 Langfuse 대시보드에서 한 요청이 어떻게 흘렀는지 한눈에 볼 수 있다.


등장 인물: LangChain, LangGraph, Langfuse

세 라이브러리를 같이 쓰는 이유부터 짚자.

LangChain

LLM, Prompt, Tool, Retriever를 추상화해서 연결해주는 프레임워크

LLM 호출을 ChatOpenAI() 한 줄로, 프롬프트를 ChatPromptTemplate 객체로 다룰 수 있게 해준다. "부품 모음집" 정도로 생각하면 된다.

LangGraph

Agent의 상태와 흐름을 graph로 제어하는 프레임워크

LangChain만 쓰면 "체인" 하나로 일렬로 흐르는 구조밖에 못 만든다. RAG는 보통 여러 단계가 분기하고 반복하기 때문에, 이걸 그래프로 그릴 수 있는 LangGraph가 필요하다.

LangChain : 부품을 연결하는 와이어
LangGraph : 그 와이어들의 흐름도(state machine)

Langfuse

LLM app의 trace / prompt / cost / latency / tool call을 관찰하는 도구

LLM 앱은 블랙박스가 되기 쉽다. "왜 답이 이상하지?"를 추적하려면 모든 단계가 기록되어야 한다. Langfuse가 그걸 해준다.


설치와 환경 변수

먼저 의존성을 깐다.

python -m venv .venv
source .venv/bin/activate
 
pip install \
  langchain \
  langchain-openai \
  langgraph \
  langfuse \
  python-dotenv

.env.example을 만들어 키 자리를 만들어 둔다. 실제 키는 .env에 넣고 .gitignore에 추가한다.

# .env.example
OPENAI_API_KEY=sk-...
LANGFUSE_PUBLIC_KEY=pk-lf-...
LANGFUSE_SECRET_KEY=sk-lf-...
LANGFUSE_HOST=https://cloud.langfuse.com

Langfuse는 cloud.langfuse.com에서 무료 프로젝트를 만들면 키 두 개가 발급된다.


State 설계

LangGraph는 State라는 dict-like 객체를 노드마다 주고받는다. 이번 예제의 State는 이렇게 잡는다.

from typing import TypedDict
 
class RAGState(TypedDict):
    question: str            # 원래 사용자 질문
    rewritten_query: str     # 검색용으로 다듬은 쿼리
    retrieved_docs: list[str]  # 검색된 문서 조각
    final_answer: str        # 최종 답변

각 노드는 이 state의 일부를 채워서 다음 노드로 넘긴다.

[입력]  question
     ↓
[query_rewrite_node]
     ↓ rewritten_query 채움
[retrieve_node]
     ↓ retrieved_docs 채움
[answer_node]
     ↓ final_answer 채움
[출력]  final_answer

전체 코드

app.py 하나에 다 담는다.

# app.py
import os
import sys
from typing import TypedDict
 
from dotenv import load_dotenv
from langchain_core.messages import HumanMessage, SystemMessage
from langchain_openai import ChatOpenAI
from langfuse.callback import CallbackHandler
from langgraph.graph import END, StateGraph
 
load_dotenv()
 
# 1) State 정의
class RAGState(TypedDict):
    question: str
    rewritten_query: str
    retrieved_docs: list[str]
    final_answer: str
 
# 2) 더미 코퍼스 (실무에선 벡터 DB)
CORPUS = [
    "회사 휴가는 연 15일이며 분기당 5일까지 사용 가능하다.",
    "결제 모듈 에러 코드 4023은 한도 초과를 의미한다.",
    "주간 회의는 매주 월요일 10시에 진행한다.",
    "사내 위키 접근은 SSO 로그인 후 가능하다.",
    "재택근무는 주 2회까지 자율적으로 사용할 수 있다.",
]
 
# 3) LLM
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
 
# 4) 노드 함수
def query_rewrite_node(state: RAGState) -> RAGState:
    """사용자 질문을 검색용 키워드 중심 쿼리로 다듬는다."""
    msgs = [
        SystemMessage(
            content=(
                "사용자 질문을 한국어 검색용 키워드 쿼리로 다듬어라. "
                "한 줄, 부연 설명 없이 쿼리만 출력."
            )
        ),
        HumanMessage(content=state["question"]),
    ]
    rewritten = llm.invoke(msgs).content.strip()
    return {**state, "rewritten_query": rewritten}
 
def retrieve_node(state: RAGState) -> RAGState:
    """더미 키워드 매칭으로 top-3 문서를 가져온다."""
    query = state["rewritten_query"]
    scored = [
        (sum(1 for w in query.split() if w in doc), doc)
        for doc in CORPUS
    ]
    scored.sort(key=lambda x: x[0], reverse=True)
    top_docs = [doc for score, doc in scored[:3] if score > 0]
    return {**state, "retrieved_docs": top_docs}
 
def answer_node(state: RAGState) -> RAGState:
    """검색된 문서를 컨텍스트로 LLM이 답변한다."""
    context = "\n".join(f"- {d}" for d in state["retrieved_docs"]) or "(관련 문서 없음)"
    msgs = [
        SystemMessage(
            content=(
                "너는 사내 문서를 기반으로 답하는 도우미다. "
                "주어진 문서에 답이 없으면 '문서에 없습니다'라고만 답해라."
            )
        ),
        HumanMessage(
            content=f"[문서]\n{context}\n\n[질문]\n{state['question']}"
        ),
    ]
    answer = llm.invoke(msgs).content.strip()
    return {**state, "final_answer": answer}
 
# 5) Graph 빌드
graph = StateGraph(RAGState)
graph.add_node("query_rewrite", query_rewrite_node)
graph.add_node("retrieve", retrieve_node)
graph.add_node("answer", answer_node)
graph.set_entry_point("query_rewrite")
graph.add_edge("query_rewrite", "retrieve")
graph.add_edge("retrieve", "answer")
graph.add_edge("answer", END)
app = graph.compile()
 
# 6) Langfuse Callback
langfuse_handler = CallbackHandler(
    public_key=os.environ["LANGFUSE_PUBLIC_KEY"],
    secret_key=os.environ["LANGFUSE_SECRET_KEY"],
    host=os.environ.get("LANGFUSE_HOST", "https://cloud.langfuse.com"),
)
 
# 7) 실행
if __name__ == "__main__":
    question = sys.argv[1] if len(sys.argv) > 1 else "휴가 며칠이야?"
    result = app.invoke(
        {
            "question": question,
            "rewritten_query": "",
            "retrieved_docs": [],
            "final_answer": "",
        },
        config={"callbacks": [langfuse_handler]},
    )
    print("---")
    print("Rewritten:", result["rewritten_query"])
    print("Docs:", result["retrieved_docs"])
    print("Answer:", result["final_answer"])

실행

python app.py "휴가 며칠이야?"

출력 예시:

---
Rewritten: 휴가 일수 회사
Docs: ['회사 휴가는 연 15일이며 분기당 5일까지 사용 가능하다.']
Answer: 회사 휴가는 연 15일이며 분기당 5일까지 사용 가능합니다.

Langfuse 대시보드에서 확인하기

cloud.langfuse.com에 로그인해서 프로젝트로 들어가면, 방금 실행한 요청이 Trace 한 건으로 잡혀 있다.

펼쳐 보면 이런 구조가 그대로 보인다.

Trace
├── query_rewrite (Span)
│   └── ChatOpenAI invoke
│         input  : "휴가 며칠이야?"
│         output : "휴가 일수 회사"
│         tokens : 12 in / 5 out
│         latency: 320ms
├── retrieve (Span)
│   └── 함수 실행 (LLM 호출 없음)
└── answer (Span)
    └── ChatOpenAI invoke
          input  : "[문서]..., [질문]..."
          output : "회사 휴가는 연 15일이며..."
          tokens : 180 in / 28 out
          latency: 560ms

이게 RAG 개발자가 가장 자주 들여다보는 화면이다. "왜 답이 이상하지?"를 디버깅할 때 보는 곳이고, 비용/속도 최적화를 할 때도 여길 본다.


이 예제가 실무 RAG와 다른 점

이 예제는 일부러 단순하게 만들었다. 실무 RAG는 여기서 다음을 더한다.

CORPUS (인메모리 리스트)
   ↓ 진짜 벡터 DB + 키워드 인덱스 (Elasticsearch, pgvector 등)

retrieve_node (단순 단어 매칭)
   ↓ Dense + Sparse + Score Fusion
   ↓ Reranker로 top-k 재정렬
   ↓ Metadata 필터 (권한, 날짜, 문서 타입)

answer_node (단일 호출)
   ↓ 인용(citation) 생성
   ↓ Tool call (검색을 한 번 더 / 계산기 / API 호출)

Langfuse
   ↓ 사용자 피드백 점수 수집
   ↓ Evaluation 자동 실행

이걸 차례로 풀어가는 게 다음 시리즈인 RAG Retriever 실무다.


정리

  • RAG 에이전트의 최소 구성: State + 3개 노드(rewrite, retrieve, answer) + LangGraph + Langfuse
  • State 설계가 곧 흐름 설계다 — 어떤 필드를 두느냐가 노드 책임을 결정한다
  • Langfuse CallbackHandler 하나만 붙이면 그래프 전체 trace가 잡힌다
  • 이 구조를 그대로 두고 retrieve_node만 진짜 검색기로 갈아끼우는 게 실무 RAG

다음 시리즈에서는 retrieve_node를 진짜로 만들어본다. Dense / Sparse / Hybrid / Reranker / Metadata Filter를 차례로 다룰 거다.


학습 로드맵 (보너스)

이 시리즈가 끝나고 다음을 순서대로 보면 좋다.

1. LLM message 구조 이해 (system / user / assistant / tool)
2. Prompt cache / KV cache 이해              ← Prompt Cache 시리즈
3. LangChain Runnable 이해 (.invoke, .batch, .stream)
4. LangGraph StateGraph 이해 (조건 분기, 루프)
5. Langfuse trace 붙이기                     ← LLM Observability 시리즈
6. 기본 Retriever 만들기                     ← RAG Retriever 시리즈 01
7. Hybrid Retriever 만들기                   ← RAG Retriever 시리즈 01
8. Reranker 붙이기                           ← RAG Retriever 시리즈 02
9. RAG Evaluation 만들기                     ← RAG Retriever 시리즈 03
10. FastAPI / Spring Boot 백엔드와 연결하기