RAG2026-5일30차시 · 질의 재작성 및 재검색8 / 19← 이전목차다음 →MetaContents

30차시. 질의 재작성 및 재검색

5. 실습 ② — Rewrite Node 구현

출처: 원본 PDF 30차시_재작성노드실습.pdf 10~11쪽 · 쪽 그림 10쪽 11쪽

📝 src/nodes/rewriter.py

Python
import config
from prompts import REWRITE_PROMPT
from nodes.generator import _llm
from langchain_core.output_parsers import StrOutputParser

_rewrite = REWRITE_PROMPT | _llm | StrOutputParser()

MAX_REWRITE = getattr(config, "MAX_REWRITE", 2)

def rewrite_node(state) -> dict:
    question = state.get("question", "")
    tried    = state.get("tried_queries", [])
    reason   = state.get("reason") or state.get("fail_reason", "")
    n        = state.get("rewrites", 0)

    # 상한 도달 시 재작성하지 않는다
    if n >= MAX_REWRITE:
        return {"rewrites": n,
                "log": [f"재작성 상한({MAX_REWRITE}) 도달"]}

    try:
        new_query = _rewrite.invoke({
            "question": question,
            "tried":    ", ".join(tried) if tried else "(없음)",
            "reason":   reason or "관련 자료를 찾지 못함",
        }).strip().strip('"').strip("'")
    except Exception as e:
        return {"rewrites": n + 1,
                "log": [f"재작성 오류({type(e).__name__})"]}

    # 빈 결과나 중복이면 재작성 실패로 처리
    if not new_query or new_query in tried:
        return {"rewrites": n + 1,
                "log": [f"재작성 실패(중복/빈값): {new_query}"]}

    return {
        "query":         new_query,
        "rewrites":      n + 1,
        "tried_queries": [new_query],      # Reducer로 누적
        "retrieval_ok":  False,            # 재검색 전 초기화
        "log": [f"재작성 {n+1}회: '{question[:20]}...' "
                f"-> '{new_query}'"],
    }

실습 코드 · 30/rewriter.py

실습 코드: 실습/src/30/rewriter.py · 위의 원본 PDF 30차시 10쪽 「5️⃣ 실습 ② — Rewrite Node 구현」과 이어집니다.

Python
# ==============================================================
# 30차시. PPT 30차시_질의재작성재검색. 코드 폴더는 src/30
# PPT: 30차시 검색어 재작성
# 역할: Rewrite Node. 검색에 실패한 질문을 검색용 문장으로 바꿉니다. 검색 자체는 하지 않습니다.
# 읽는 키: question, tried_queries, rewrites, reason, fail_reason
# 쓰는 키: 성공 시 query, rewrites, tried_queries, retrieval_ok=False, rewrite_ok, log
# 그래프:
#   rewrites >= 상한 --> rewrite_ok=False
#   LLM 오류·빈 문장·중복 문장 --> rewrite_ok=False, rewrites+1
#   새 문장 성공 --> query=새 검색어, rewrites+1, tried_queries에 추가
# --------------------------------------------------------------
# 파이썬만 아는 분을 위한 말
# 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에 적습니다.
# question은 그대로 두고 query만 바꿉니다. 다음 Retriever가 query를 우선 사용합니다.
# retrieval_ok를 False로 되돌려, 이어지는 검색이 새 query를 쓰게 합니다.
# MAX_REWRITE 기본값은 config에 없으면 2입니다.
# reason이 없으면 fail_reason을 쓰고, 둘 다 없으면 '관련 자료를 찾지 못함'을 넣습니다.
# ==============================================================

# ============================================================
# 30차시 - Rewrite Node
# 검색에 실패한 질문을 검색하기 좋은 표현으로 다시 작성합니다.
# 원본 question은 유지하고, 검색용 query만 변경합니다.
# ============================================================

import os
import sys
import warnings

# ------------------------------------------------------------
# 필요한 모듈의 경로를 설정합니다.
# ------------------------------------------------------------

CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
SRC_DIR = os.path.abspath(os.path.join(CURRENT_DIR, ".."))

warnings.filterwarnings("ignore")

# 공통 모듈이 있는 src 폴더
sys.path.insert(0, SRC_DIR)

# 이전 차시에서 만든 모듈
sys.path.append(os.path.join(SRC_DIR, "13"))
sys.path.append(os.path.join(SRC_DIR, "18"))
sys.path.append(os.path.join(SRC_DIR, "24"))
sys.path.append(os.path.join(SRC_DIR, "25"))

# 현재 30차시 모듈을 가장 먼저 찾도록 합니다.
sys.path.insert(0, CURRENT_DIR)

# ------------------------------------------------------------
# 필요한 모듈을 가져옵니다.
# ------------------------------------------------------------

import config

from prompts import REWRITE_PROMPT
from generator import _llm
from langchain_core.output_parsers import StrOutputParser

# ------------------------------------------------------------
# Rewrite Chain을 만듭니다.
#
# Prompt → LLM → 문자열
# ------------------------------------------------------------

rewrite_chain = REWRITE_PROMPT | _llm | StrOutputParser()

# 최대 재작성 횟수입니다.
MAX_REWRITE = getattr(config, "MAX_REWRITE", 2)

# ============================================================
# Rewrite Node
# 검색에 실패한 질문을 새로운 검색어로 바꿉니다.
# 직접 검색하지 않고 query만 새로 만듭니다.
# ============================================================

def rewrite_node(state) -> dict:

    # 원본 사용자 질문을 가져옵니다.
    question = state.get("question", "")

    # 지금까지 사용한 검색어를 가져옵니다.
    tried = state.get("tried_queries", [])

    # 현재까지 재작성한 횟수를 가져옵니다.
    rewrites = state.get("rewrites", 0)

    # 검색 또는 검증 단계의 실패 이유를 가져옵니다.
    reason = state.get("reason")

    if not reason:
        reason = state.get("fail_reason")

    if not reason:
        reason = "관련 자료를 찾지 못함"

    # --------------------------------------------------------
    # 최대 재작성 횟수에 도달하면 중단합니다.
    # --------------------------------------------------------

    if rewrites >= MAX_REWRITE:

        return {
            "rewrite_ok": False,
            "log": [f"재작성 상한({MAX_REWRITE}) 도달"],
        }

    # --------------------------------------------------------
    # 이미 사용한 검색어 목록을 문자열로 만듭니다.
    # --------------------------------------------------------

    if tried:
        tried_text = ", ".join(tried)
    else:
        tried_text = "(없음)"

    # --------------------------------------------------------
    # LLM에게 새로운 검색어를 요청합니다.
    # --------------------------------------------------------

    try:

        new_query = rewrite_chain.invoke({
            "question": question,
            "tried": tried_text,
            "reason": reason,
        })

        # 앞뒤 공백과 따옴표를 제거합니다.
        new_query = new_query.strip()
        new_query = new_query.strip('"')
        new_query = new_query.strip("'")

    # LLM 호출 중 오류가 발생한 경우입니다.
    except Exception as e:

        return {
            "rewrite_ok": False,
            "rewrites": rewrites + 1,
            "log": [f"재작성 오류({type(e).__name__})"],
        }

    # --------------------------------------------------------
    # 새로운 검색어가 비어 있으면 실패입니다.
    # --------------------------------------------------------

    if not new_query:

        return {
            "rewrite_ok": False,
            "rewrites": rewrites + 1,
            "log": ["재작성 실패(빈값)"],
        }

    # --------------------------------------------------------
    # 이미 사용했던 검색어라면 다시 검색하지 않습니다.
    # --------------------------------------------------------

    if new_query in tried:

        return {
            "rewrite_ok": False,
            "rewrites": rewrites + 1,
            "log": [f"재작성 실패(중복): {new_query}"],
        }

    # --------------------------------------------------------
    # 새로운 검색어 생성에 성공했습니다.
    # --------------------------------------------------------

    return {
        # Retriever가 다음 검색에서 사용할 검색어입니다.
        "query": new_query,

        # 재작성 횟수를 1 증가시킵니다.
        "rewrites": rewrites + 1,

        # 새로운 검색어를 검색 이력에 추가합니다.
        "tried_queries": [new_query],

        # 새로운 query는 아직 검색하지 않은 상태입니다.
        "retrieval_ok": False,

        # Rewrite 성공 여부입니다.
        "rewrite_ok": True,

        # 실행 기록을 남깁니다.
        "log": [
            f"재작성 {rewrites + 1}회: "
            f"'{question[:20]}...' -> '{new_query}'"
        ],
    }

실행 결과 샘플 (교안 기록. API 키가 필요한 코드는 키 없이 맞춘 예시이고, 모델의 답은 실행할 때마다 달라집니다)

출력
===== 실행 결과 샘플 =====
예시 출력 (API 키 필요, 실행하지 않음)
이 파일에는 __main__ 테스트가 없습니다.
재작성으로 나온 새 문장 원문은 기록에 없습니다.
환불은 며칠 이내에 신청해야 하나요? rewrites=0 grade=pass elapsed=6.69
  다른 실행 elapsed=2.39 n_docs=2 top_score=0.056
대표이사가 누구인가요? rewrites=2 grade=giveup elapsed=3.41 fail_reason=low_score top_score=-0.219

코드 해설

Rewrite Node. 검색에 실패한 질문을 검색용 문장으로 바꿉니다. 검색 자체는 하지 않습니다.

코드 앞에서 고정해 두는 말입니다.

  • 읽는 키: question, tried_queries, rewrites, reason, fail_reason
  • 쓰는 키: 성공 시 query, rewrites, tried_queries, retrieval_ok=False, rewrite_ok, log
  • 그래프:
  • rewrites >= 상한 --> rewrite_ok=False
  • LLM 오류·빈 문장·중복 문장 --> rewrite_ok=False, rewrites+1
  • 새 문장 성공 --> query=새 검색어, rewrites+1, tried_queries에 추가
  • 파이썬만 아는 분을 위한 말
  • State는 노드들이 함께 쓰는 딕셔너리입니다. 함수는 state를 읽고, 바꿀 키만 return 합니다.

함수와 클래스로 나누어 읽습니다.

  • rewrite_node: 이름 그대로 한 가지 일만 합니다. 본문 주석을 같이 읽습니다.

파일 맨 아래 실행 결과 샘플 주석은 실행되지 않습니다. 화면 출력은 다음 절에 다시 적어 두었습니다.

5-1) State에 키 추가

출처: 원본 PDF 11쪽

출력
■ state.py

class RAGState(TypedDict):
    ...
    rewrites: int                                    # ★ 추가
    tried_queries: Annotated[List[str], operator.add]  # ★ 누적

def init_state(question):
    return {
        ...
        "rewrites": 0,
        "tried_queries": [question],    # 원본도 시도 목록에
    }

💡 retries와 rewrites를 나눈 이유
retries — 재생성 횟수 (같은 근거로 다시 씀)
rewrites — 재검색 횟수 (질의를 바꿔 다시 찾음)
비용이 다릅니다.
재생성 = LLM 2회 (생성 + 검증)
재검색 = LLM 3회 + 임베딩 (재작성 + 검색 + 생성 + 검증)
그래서 상한을 따로 관리해야 합니다.

← 이전 절30차시 · 4. 실습 ① — Rewrite 프롬프트다음 절 →30차시 · 6. 실습 ③ — 재검색 루프 연결