30차시. 질의 재작성 및 재검색
8. 실습 ④ — 14차시 실패 해결 확인
출처: 원본 PDF 30차시_재작성노드실습.pdf 16~17쪽 · 쪽 그림 16쪽 17쪽
드디어 미뤄왔던 문제를 해결했는지 확인합니다.
📝 src/test_rewrite.py
from graph import app
from state import init_state
FAILED_BEFORE = [
"반품하고 싶은데 언제까지 가능해요?",
"물건 바꾸려면 택배비 누가 내나요?",
"돈 돌려받으려면 어떻게 해요?",
]
for q in FAILED_BEFORE:
print(f"\n{'='*58}\nQ: {q}")
for step in app.stream(init_state(q), {"recursion_limit": 30}):
for node, upd in step.items():
for line in upd.get("log", []):
print(f" {line}")
final = app.invoke(init_state(q), {"recursion_limit": 30})
print(f"\n A: {final['answer'][:90]}")
print(f" 재작성 {final.get('rewrites',0)}회 | "
f"판정 {final.get('grade')}")
실행 결과
═════════════════════════════════════════════
═════════════
Q: 반품하고 싶은데 언제까지 가능해요?
분류(규칙): document
검색(similarity,k=5): 0/5건 통과 최고=0.412
재작성 1회: '반품하고 싶은데 언제까...' -> '환불 신청 기한 규정'
검색(similarity,k=5): 3/5건 통과 최고=0.751 ← 성공!
생성: 72자, 인용 2건 정상
검증(LLM): g=True r=True -> pass
A: 반품(환불)은 상품 수령 후 7일 이내에 신청하실 수 있습니다[1].
단순 변심의 경우 왕복 배송비는 고객 부담입니다[1].
재작성 1회 | 판정 pass
═════════════════════════════════════════════
═════════════
Q: 물건 바꾸려면 택배비 누가 내나요?
검색: 0/5건 통과 최고=0.389
재작성 1회: -> '교환 시 배송비 부담 주체'
검색: 2/5건 통과 최고=0.712 ← 성공!
생성 -> 검증 -> pass
A: 상품 불량으로 인한 교환의 경우 배송비는 당사가 부담합니다[1].
재작성 1회 | 판정 pass🎉 12차시부터 18개 차시를 끌어온 문제 해결
· 12차시 — 실패 사례로 수집
· 14차시 — 파라미터로는 못 고친다고 확인
· 24차시 — fail_reason=low_score로 진단
· 29차시 — research 경로 예약
· 30차시 — 해결
답변에 "반품(환불)"이라고 나온 것도 주목하세요.
question을 보존했기 때문에 사용자 표현이 반영됐습니다.
실습 코드 · 30/test_rewrite.py
실습 코드: 실습/src/30/test_rewrite.py · 위의 원본 PDF 30차시 16~17쪽 「8️⃣ 실습 ④ — 14차시 실패 해결 확인」과 이어집니다.
# ==============================================================
# 30차시. PPT 30차시_질의재작성재검색. 코드 폴더는 src/30
# PPT: 30차시 검색어 재작성
# 역할: 일상 표현 질문으로 graph30을 돌려 재작성 횟수, 최종 query, tried_queries, grade를 출력합니다.
# 읽는 키: question으로 만든 초기 State
# 쓰는 키: 화면 출력
# 그래프:
# classify --> retrieve --> (실패 시) rewrite --> retrieve --> generate --> verify
# --------------------------------------------------------------
# 파이썬만 아는 분을 위한 말
# 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에 적습니다.
# QUESTIONS 세 개는 구어체라 첫 검색이 실패하기 쉬운 예시입니다.
# recursion_limit은 30입니다. rewrite 왕복이 있어도 그래프가 무한히 돌지 않게 막는 상한입니다.
# ==============================================================
# ============================================================
# 30차시 - Rewrite 테스트
# 검색에 실패하기 쉬운 질문을 그래프에 전달합니다.
# Rewrite 후 재검색이 정상적으로 이루어지는지 확인합니다.
# ============================================================
import os
import sys
# ------------------------------------------------------------
# 필요한 모듈의 경로를 설정합니다.
# ------------------------------------------------------------
# 현재 파일이 있는 30차시 폴더입니다.
CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
# 공통 파일이 있는 src 폴더입니다.
SRC_DIR = os.path.abspath(os.path.join(CURRENT_DIR, ".."))
# src 폴더를 Python 검색 경로에 추가합니다.
sys.path.insert(0, SRC_DIR)
# 이전 차시에서 만든 모듈을 사용할 수 있게 합니다.
for folder in ["18", "19", "24", "25", "26", "29"]:
path = os.path.join(SRC_DIR, folder)
sys.path.append(path)
# 현재 30차시 모듈을 가장 먼저 찾도록 합니다.
sys.path.insert(0, CURRENT_DIR)
# ------------------------------------------------------------
# 30차시 그래프와 초기 State 생성 함수를 가져옵니다.
# ------------------------------------------------------------
from graph30 import app
from graph_state2 import make_initial_state
# ============================================================
# 테스트 질문
# 일상적인 표현 때문에 검색에 실패하기 쉬운 질문입니다.
# ============================================================
QUESTIONS = [
"반품하고 싶은데 언제까지 가능해요?",
"물건 바꾸려면 택배비 누가 내나요?",
"돈 돌려받으려면 어떻게 해요?",
]
# ============================================================
# 테스트 실행
# 각 질문을 그래프에 전달하고 Rewrite 결과를 확인합니다.
# ============================================================
for question in QUESTIONS:
# 질문 구분선을 출력합니다.
print("\n" + "=" * 60)
print("Q:", question)
print("=" * 60)
# --------------------------------------------------------
# 1. 질문으로 초기 State를 만듭니다.
# --------------------------------------------------------
state = make_initial_state(question)
# --------------------------------------------------------
# 2. LangGraph를 실행합니다.
# --------------------------------------------------------
result = app.invoke(
state,
{
"recursion_limit": 30
}
)
# --------------------------------------------------------
# 3. 최종 답변을 출력합니다.
# --------------------------------------------------------
print(
"A:",
result.get("answer", "")
)
# --------------------------------------------------------
# 4. Rewrite 횟수를 출력합니다.
# --------------------------------------------------------
print(
"재작성:",
result.get("rewrites", 0),
"회"
)
# --------------------------------------------------------
# 5. 마지막으로 사용한 검색어를 출력합니다.
# --------------------------------------------------------
print(
"최종 검색어:",
result.get("query", "")
)
# --------------------------------------------------------
# 6. 지금까지 사용한 검색어를 모두 출력합니다.
# --------------------------------------------------------
print(
"시도한 검색어:",
result.get("tried_queries", [])
)
# --------------------------------------------------------
# 7. 최종 검증 결과를 출력합니다.
# --------------------------------------------------------
print(
"최종 판정:",
result.get("grade", "")
)실행 결과 샘플 (교안 기록. API 키가 필요한 코드는 키 없이 맞춘 예시이고, 모델의 답은 실행할 때마다 달라집니다)
===== 실행 결과 샘플 =====
경로: classify → retrieve → rewrite → retrieve → generate → verify → END
예시 출력 (API 키 필요, 실행하지 않음)
세 질문(반품하고 싶은데 언제까지 가능해요? / 물건 바꾸려면 택배비 누가 내나요? / 돈 돌려받으려면 어떻게 해요?)은 기록된 로그에 없습니다.
재작성 문장, 최종 검색어, 판정은 적지 않습니다.
같은 재작성 그래프에서 기록된 값:
환불은 며칠 이내에 신청해야 하나요? 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코드 해설
일상 표현 질문으로 graph30을 돌려 재작성 횟수, 최종 query, tried_queries, grade를 출력합니다.
코드 앞에서 고정해 두는 말입니다.
- 읽는 키: question으로 만든 초기 State
- 쓰는 키: 화면 출력
- 그래프:
- classify --> retrieve --> (실패 시) rewrite --> retrieve --> generate --> verify
- 파이썬만 아는 분을 위한 말
- State는 노드들이 함께 쓰는 딕셔너리입니다. 함수는 state를 읽고, 바꿀 키만 return 합니다.
- return에 적지 않은 키는 이전 값이 남습니다.
- log와 tried_queries는 리스트라서, 새로 return한 항목이 뒤에 이어 붙습니다.
함수로 나누지 않고 위에서 아래로 실행됩니다.
CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))— 필요한 모듈의 경로를 설정합니다. 현재 파일이 있는 30차시 폴더입니다.SRC_DIR = os.path.abspath(os.path.join(CURRENT_DIR, ".."))— 공통 파일이 있는 src 폴더입니다.sys.path.insert(0, SRC_DIR)— src 폴더를 Python 검색 경로에 추가합니다.for folder in ["18", "19", "24", "25", "26", "29"]:— 이전 차시에서 만든 모듈을 사용할 수 있게 합니다.sys.path.insert(0, CURRENT_DIR)— 현재 30차시 모듈을 가장 먼저 찾도록 합니다.QUESTIONS = [— 테스트 질문 일상적인 표현 때문에 검색에 실패하기 쉬운 질문입니다.for question in QUESTIONS:— 테스트 실행 각 질문을 그래프에 전달하고 Rewrite 결과를 확인합니다.print("\n" + "=" * 60)— 질문 구분선을 출력합니다.state = make_initial_state(question)— 1. 질문으로 초기 State를 만듭니다.result = app.invoke(— 2. LangGraph를 실행합니다.print(— 3. 최종 답변을 출력합니다.print(— 4. Rewrite 횟수를 출력합니다.
파일 맨 아래 실행 결과 샘플 주석은 실행되지 않습니다. 화면 출력은 다음 절에 다시 적어 두었습니다.