24차시. Retriever Node 분리
7. 실습 ④ — 그래프에 연결
출처: 원본 PDF 24차시_RetrieverNode분리.pdf 15~16쪽 · 쪽 그림 15쪽 16쪽
22차시 graph.py의 노드를 새 모듈로 교체합니다.
■ 변경 전
from nodes.retrieve import retrieve_node
g.add_node("retrieve", retrieve_node)
■ 변경 후
from nodes.retriever import retriever_node
g.add_node("retrieve", retriever_node)실습 코드 · 24/graph.py
실습 코드: 실습/src/24/graph.py · 위의 원본 PDF 24차시 15쪽 「7️⃣ 실습 ④ — 그래프에 연결」과 이어집니다.
# ==============================================================
# 24차시. PPT 24차시_RetrieverNode분리. 코드 폴더는 src/24
# PPT: 24차시 Retriever Node
# 역할: Retriever, Generator, fallback을 LangGraph로 잇습니다. 검색이 되면 답을 만들고, 안 되면 안내합니다.
# 읽는 키: question, retrieval_ok, documents, answer, has_citation, log, fail_reason
# 쓰는 키: ask()가 돌려주는 answer, sources, cited, log, ok, fail_reason
# 그래프:
# START
# |
# v
# retrieve
# |-- ok ----> generate --> END
# +-- empty -> fallback --> END
# --------------------------------------------------------------
# 파이썬만 아는 분을 위한 말
# State는 노드들이 함께 쓰는 딕셔너리입니다. 함수는 state를 읽고, 바꿀 키만 return 합니다.
# return에 적지 않은 키는 이전 값이 남습니다.
# log와 tried_queries는 리스트라서, 새로 return한 항목이 뒤에 이어 붙습니다.
# Retriever: 검색 담당. 질문과 비슷한 문서 조각만 고릅니다. 답 문장은 만들지 않습니다.
# Generator: 고른 문서를 근거로 답 문장을 만듭니다. 문서가 없으면 안내 문장만 돌려줍니다.
# Verifier: 답이 근거와 맞는지 검사하고 grade를 적습니다.
# grade=pass 답을 확정하고 그래프를 끝냅니다.
# grade=retry 같은 문서로 답 문장만 다시 씁니다. retries가 1 늘어납니다.
# grade=research 문서가 부족합니다. 검색어를 바꾼 뒤 다시 찾습니다. rewrites가 늘어납니다.
# grade=giveup 더 진행하지 않고 fallback으로 안내 문장만 돌려줍니다.
# fallback: 실패했을 때 답을 지어내지 않고 안내 문장을 반환하는 노드입니다.
# intent: 질문 종류. greeting 인사, calc 계산, scope 문서 밖, document 문서 검색.
# rewrite: 사용자가 친 question은 유지하고, 검색에 쓰는 query만 바꿉니다.
# safe_node: 노드를 try로 감쌉니다. 오류가 나도 그래프는 멈추지 않고 node_error에 적습니다.
# route_after_retrieve는 노드가 아닙니다. retrieval_ok가 참이면 문자열 ok, 아니면 empty를 반환합니다.
# 그 문자열이 add_conditional_edges의 표에서 다음 노드 이름으로 바뀝니다.
# ask()는 make_initial_state로 질문 딕셔너리를 만든 뒤 app.invoke로 그래프를 한 번 끝까지 돌립니다.
# sources는 documents 안의 filename, page_no만 추린 리스트입니다.
# 이 그래프에는 Verifier와 intent 분기가 아직 없습니다.
# ==============================================================
# 이 파일은 Retriever, Generator, Fallback Node를 하나의 LangGraph로 연결합니다.
# 검색 결과가 있으면 답변을 생성하고, 없으면 안내 메시지를 반환합니다.
import os
import sys
# 현재 파일이 있는 폴더의 경로입니다.
CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
# 18차시와 19차시의 모듈을 사용할 수 있도록 경로를 추가합니다.
sys.path.insert(
0,
os.path.join(CURRENT_DIR, "..", "18")
)
sys.path.insert(
0,
os.path.join(CURRENT_DIR, "..", "19")
)
# LangGraph 그래프를 만들 때 사용하는 클래스와 상수입니다.
from langgraph.graph import StateGraph, START, END
# 그래프에서 사용할 State 구조입니다.
from graph_state import RAGState
# 질문을 초기 State 형태로 바꾸는 함수입니다.
from graph_state2 import make_initial_state
# 24차시에서 만든 검색 노드입니다.
from retriever import retriever_node
# 답변을 생성하는 노드입니다.
from generate import generate_node
# 검색 결과가 없을 때 실행하는 노드입니다.
from fallback import fallback_node
# ==================================================
# 검색 결과에 따라 다음 노드를 결정합니다.
# ==================================================
def route_after_retrieve(state: RAGState) -> str:
# 검색에 성공했으면 generate로 이동합니다.
if state.get("retrieval_ok"):
return "ok"
# 검색에 실패했으면 fallback으로 이동합니다.
return "empty"
# ==================================================
# LangGraph를 조립합니다.
# ==================================================
def build_graph():
# RAGState를 사용하는 그래프를 만듭니다.
graph = StateGraph(RAGState)
# 검색 노드를 등록합니다.
graph.add_node("retrieve", retriever_node)
# 답변 생성 노드를 등록합니다.
graph.add_node("generate", generate_node)
# 검색 실패 처리 노드를 등록합니다.
graph.add_node("fallback", fallback_node)
# 그래프가 시작되면 검색부터 실행합니다.
graph.add_edge(START, "retrieve")
# 검색 결과에 따라 다음 노드를 선택합니다.
graph.add_conditional_edges(
"retrieve",
route_after_retrieve,
{
"ok": "generate",
"empty": "fallback",
}
)
# 답변 생성이 끝나면 그래프를 종료합니다.
graph.add_edge("generate", END)
# fallback 처리가 끝나면 그래프를 종료합니다.
graph.add_edge("fallback", END)
# 나중에 다음 노드를 추가할 수 있습니다.
# 26차시: verify 노드
# 27차시: bump 노드와 재생성 루프
# 30차시: rewrite 노드와 재검색 루프
# 그래프를 실행할 수 있는 형태로 컴파일합니다.
return graph.compile()
# 그래프를 한 번 만들어 앱으로 사용합니다.
app = build_graph()
# ==================================================
# 질문 하나를 그래프에 전달하는 함수입니다.
# ==================================================
def ask(question: str) -> dict:
# 질문을 초기 State로 변환합니다.
initial_state = make_initial_state(question)
# 초기 State를 그래프에 전달합니다.
final_state = app.invoke(
initial_state,
{
"recursion_limit": 20
}
)
# 최종 State에서 필요한 결과만 골라 반환합니다.
return {
"answer": final_state.get("answer", ""),
"sources": [
{
"file": document.metadata.get("filename", ""),
"page": document.metadata.get("page_no", 0),
}
for document in final_state.get("documents", [])
],
"cited": final_state.get("has_citation", False),
"log": final_state.get("log", []),
"ok": final_state.get("retrieval_ok", False),
"fail_reason": final_state.get("fail_reason", ""),
}
# ==================================================
# 이 파일을 직접 실행할 때만 테스트합니다.
# ==================================================
if __name__ == "__main__":
# 테스트할 질문 목록입니다.
questions = [
"환불은 며칠 이내인가요?",
"반품하고 싶은데 언제까지?",
]
# 질문을 하나씩 테스트합니다.
for question in questions:
# ask 함수를 사용해 그래프를 실행합니다.
result = ask(question)
# 질문을 출력합니다.
print(f"\nQ: {question}")
# 검색 성공 여부와 실패 이유를 출력합니다.
print(
f" 검색 성공: {result['ok']} "
f"사유: {result['fail_reason'] or '-'}"
)
# 최종 답변의 앞부분만 출력합니다.
print(
f" 답변: {result['answer'][:60]}..."
)실행 결과 샘플 (교안 기록. API 키가 필요한 코드는 키 없이 맞춘 예시이고, 모델의 답은 실행할 때마다 달라집니다)
===== 실행 결과 샘플 =====
경로: retrieve → generate → END
예시 출력 (API 키 필요, 실행하지 않음)
성공 경로에서는 generate까지 가고, retrieval_ok가 거짓이면 fallback에서 끝납니다.
답 문장 원문은 기록에 없어 적지 않습니다.
아래 숫자는 기록된 로그만 옮긴 것입니다. 다른 점수는 적지 않습니다.
안녕하세요 intent=greeting grade=pass elapsed=0.01
10 + 20 intent=calc grade=pass elapsed=0.0
오늘 날씨 intent=scope grade=pass
환불은 며칠 이내에 신청해야 하나요? intent=document grade=pass retries=0 rewrites=0 elapsed=6.69
다른 실행: elapsed=2.39 n_docs=2 top_score=0.056
대표이사가 누구인가요? intent=document grade=giveup rewrites=2 elapsed=3.41 fail_reason=low_score top_score=-0.219코드 해설
Retriever, Generator, fallback을 LangGraph로 잇습니다. 검색이 되면 답을 만들고, 안 되면 안내합니다.
코드 앞에서 고정해 두는 말입니다.
- 읽는 키: question, retrieval_ok, documents, answer, has_citation, log, fail_reason
- 쓰는 키: ask()가 돌려주는 answer, sources, cited, log, ok, fail_reason
- 그래프:
- START
- |
- v
- retrieve
- |-- ok ----> generate --> END
함수와 클래스로 나누어 읽습니다.
route_after_retrieve: 검색 결과에 따라 다음 노드를 결정합니다.build_graph: LangGraph를 조립합니다.ask: 질문 하나를 그래프에 전달하는 함수입니다.
파일 맨 아래 실행 결과 샘플 주석은 실행되지 않습니다. 화면 출력은 다음 절에 다시 적어 두었습니다.
7-1) State에 키 추가
■ state.py
class RAGState(TypedDict):
...
fail_reason: str # ★ 오늘 추가
top_k: int # ★ 동적 옵션
search_type: str # ★ 동적 옵션
min_score: float # ★ 동적 옵션
def init_state(question):
return {
...
"fail_reason": "",
"top_k": None, # None이면 config 기본값 사용
"search_type": None,
"min_score": None,
}7-2) 동작 확인
from graph import app
from state import init_state
for q in ["환불은 며칠 이내인가요?", "반품하고 싶은데 언제까지?"]:
r = app.invoke(init_state(q))
print(f"\nQ: {q}")
print(f" ok={r['retrieval_ok']} 사유={r['fail_reason'] or '-'}")
print(f" A: {r['answer'][:60]}...")실행 결과 예시 (코드를 읽고 만든 예상 화면입니다. 실제 실행 결과, 특히 모델의 답은 다를 수 있습니다)
Q: 환불은 며칠 이내인가요?
ok=True 사유=-
A: 환불은 상품 수령 후 7일 이내에 신청하실 수 있습니다 [1]. 단순 변심의 경우 왕복 배송비는 고객 부담입...
Q: 반품하고 싶은데 언제까지?
ok=True 사유=-
A: 상품 수령 후 7일 이내에 반품(환불)을 신청하실 수 있습니다 [1]. 상품 불량이라면 30일 이내에 교환이...💡 22차시와 결과가 같아야 정상입니다
오늘은 기능을 추가한 게 아니라 정보를 더 남긴 것입니다.
답변 품질은 그대로여야 합니다.
달라졌다면 옮기는 과정에서 뭔가 빠진 것입니다.
회귀 테스트를 다시 돌려보세요.