RAG2026-5일24차시 · Retriever Node 분리6 / 18← 이전목차다음 →MetaContents

24차시. Retriever Node 분리

3. 실습 ① — Retriever Node 완성

출처: 원본 PDF 24차시_RetrieverNode분리.pdf 7쪽 · 쪽 그림 7쪽

📝 src/nodes/retriever.py

Python
import config
from indexer import get_store
_store = get_store()          # 모듈 로드 시 한 번만
def _search(query, k, search_type):
    # 검색 방식에 따라 다른 API를 호출한다
    if search_type == "mmr":
        docs = _store.max_marginal_relevance_search(
            query, k=k, fetch_k=k * 4, lambda_mult=0.5)
        return [(d, None) for d in docs]        # MMR은 점수 미제공
    return _store.similarity_search_with_relevance_scores(query, k=k)
def retriever_node(state) -> dict:
    query = state.get("query") or state.get("question", "")
    k     = state.get("top_k") or config.TOP_K
    stype = state.get("search_type") or config.SEARCH_TYPE
    floor = state.get("min_score")
    if floor is None:
        floor = config.MIN_SCORE
    if not query.strip():
        return {"documents": [], "scores": [], "retrieval_ok": False,
                "fail_reason": "empty_query",
                "log": ["검색: 질의가 비어 있음"]}
    try:
        pairs = _search(query, k, stype)
    except Exception as e:
        return {"documents": [], "scores": [], "retrieval_ok": False,
                "fail_reason": "search_error",
                "log": [f"검색 오류: {type(e).__name__}"]}
    if not pairs:
        return {"documents": [], "scores": [], "retrieval_ok": False,
                "fail_reason": "no_result",
                "log": ["검색: 결과 0건"]}
    scores = [round(s, 3) if s is not None else None for _, s in pairs]
    # 점수가 있는 경우에만 임계값 필터 적용
    if scores[0] is not None:
        kept = []
        for d, s in pairs:
            if s >= floor:
                kept.append(d)
        reason = "" if kept else "low_score"
    else:
        kept, reason = [d for d, _ in pairs], ""

    return {
        "documents":    kept,
        "scores":       scores,
        "retrieval_ok": len(kept) > 0,
        "fail_reason":  reason,
        "log": [f"검색({stype},k={k}): {len(kept)}/{len(pairs)}건 "
                f"통과 최고={scores[0]}"],
    }

실습 코드 · 24/config.py

실습 코드: 실습/src/24/config.py · 위의 원본 PDF 24차시 7쪽 「3️⃣ 실습 ① — Retriever Node 완성」과 이어집니다.

Python
# ==============================================================
# 24차시. PPT 24차시_RetrieverNode분리. 코드 폴더는 src/24
# PPT: 24차시 Retriever Node
# 역할: 검색과 생성에 쓰는 숫자·모델 이름 상수 모음. State를 읽거나 쓰지 않습니다.
# 읽는 키: 없음
# 쓰는 키: 없음
# 그래프:
#   이 파일은 그래프의 한 칸이 아닙니다.
#   다른 노드가 import config 로 이 값을 읽습니다.
# --------------------------------------------------------------
# DOC_PATH: 자를 PDF 위치. CHUNK_SIZE 500, CHUNK_OVERLAP 50.
# EMBED_MODEL: 문장을 숫자 벡터로 바꾸는 모델 이름. 바꾸면 인덱스를 다시 만들어야 합니다.
# INDEX_PATH: FAISS 인덱스 폴더 이름.
# TOP_K: 한 번에 가져올 문서 개수. MIN_SCORE: 이 점수 미만 문서는 버립니다.
# SEARCH_TYPE: similarity 이면 유사도 검색. mmr 이면 다양하게 고르는 검색.
# LLM_MODEL, TEMPERATURE, PROMPT_VER: Generator가 답을 만들 때 사용.
# MAX_RETRY: 답 재생성 상한. USE_LLM_CLASSIFY: 규칙으로 못 가르면 LLM 분류를 쓸지.
# MSG_NO_DOC, MSG_ERROR: 문서가 없거나 오류일 때 사용자에게 보여줄 문장.
# ==============================================================

# ═════════════════════════════════════════════
#  RAG 설정
#  최종 결정 : 2026-08-03
#  근거      : 14차시 벤치마크 (질문 20개 기준)
#              기준선 60% → 최종 90% (+30%p)
# ═════════════════════════════════════════════
# ── 문서 처리 ──
DOC_PATH      = "../../data/manual.pdf"
CHUNK_SIZE    = 500      # 500→300: +10%p, 토큰 33% 절감
CHUNK_OVERLAP = 50       # chunk_size의 10%
# ── 임베딩 · 저장소 ──
EMBED_MODEL   = "text-embedding-3-small"   # ⚠ 변경 시 인덱스 재생성 
INDEX_PATH    = "faiss_index"
# ── 검색 ──
TOP_K         = 5        # 3→5: +10%p (k=8은 노이즈로 하락) 
MIN_SCORE     = 0.1     # 9차시 실측값
SEARCH_TYPE   = "similarity"
# ── 생성 ──
LLM_MODEL = "gpt-4o-mini"
TEMPERATURE = 0
PROMPT_VER = "v3"
MAX_RETRY = 3
USE_LLM_CLASSIFY = True

# ── 메시지 ──
MSG_NO_DOC = "관련 자료를 찾지 못했습니다."
MSG_ERROR = "일시적인 오류가 발생했습니다. 잠시 후 다시 시도해주세요."

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

출력
===== 실행 결과 샘플 =====
직접 실행 시 출력 없음
이 파일을 직접 실행해도 print가 없어 화면에는 아무것도 나오지 않습니다.

코드 해설

검색과 생성에 쓰는 숫자·모델 이름 상수 모음. State를 읽거나 쓰지 않습니다.

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

  • 읽는 키: 없음
  • 쓰는 키: 없음
  • 그래프:
  • 이 파일은 그래프의 한 칸이 아닙니다.
  • 다른 노드가 import config 로 이 값을 읽습니다.
  • DOC_PATH: 자를 PDF 위치. CHUNK_SIZE 500, CHUNK_OVERLAP 50.
  • EMBED_MODEL: 문장을 숫자 벡터로 바꾸는 모델 이름. 바꾸면 인덱스를 다시 만들어야 합니다.
  • INDEX_PATH: FAISS 인덱스 폴더 이름.

함수로 나누지 않고 위에서 아래로 실행됩니다.

  • DOC_PATH = "../../data/manual.pdf" — 24차시 Retriever Node 역할: 검색과 생성에 쓰는 숫자·모델 이름 상수 모음. State를 읽거나 쓰지 않습니다. 읽는 키: 없음 쓰는 키: 없음 그래프: 이 파일은 그래프의 한 칸이 아닙니다. 다른 노드가 import config 로 이 값을 읽습니다. DOC_PATH: 자를 PDF 위치. CHUNK_SIZE 500, CHUNK_OVERLAP 50. EMBED_MODEL: 문장을 숫자 벡터로 바꾸는 모델 이름. 바꾸면 인덱스를 다시 만들어야 합니다. INDEX_PATH: FAISS 인덱스 폴더 이름. TOP_K: 한 번에 가져올 문서 개수. MIN_SCORE: 이 점수 미만 문서는 버립니다. SEARCH_TYPE: similarity 이면 유사도 검색. mmr 이면 다양하게 고르는 검색. LLM_MODEL, TEMPERATURE, PROMPT_VER: Generator가 답을 만들 때 사용. MAX_RETRY: 답 재생성 상한. USE_LLM_CLASSIFY: 규칙으로 못 가르면 LLM 분류를 쓸지. MSG_NO_DOC, MSG_ERROR: 문서가 없거나 오류일 때 사용자에게 보여줄 문장. ═════════════════════════════════════════════ RAG 설정 최종 결정 : 2026-08-03 근거 : 14차시 벤치마크 (질문 20개 기준) 기준선 60% → 최종 90% (+30%p) ═════════════════════════════════════════════ ── 문서 처리 ──
  • EMBED_MODEL = "text-embedding-3-small" # ⚠ 변경 시 인덱스 재생성 — ── 임베딩 · 저장소 ──
  • TOP_K = 5 # 3→5: +10%p (k=8은 노이즈로 하락) — ── 검색 ──
  • LLM_MODEL = "gpt-4o-mini" — ── 생성 ──
  • MSG_NO_DOC = "관련 자료를 찾지 못했습니다." — ── 메시지 ──

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

실습 코드 · 24/retriever_retries.py

실습 코드: 실습/src/24/retriever_retries.py · 위의 원본 PDF 24차시 7쪽 「3️⃣ 실습 ① — Retriever Node 완성」과 이어집니다.

Python
# ==============================================================
# 24차시. PPT 24차시_RetrieverNode분리. 코드 폴더는 src/24
# PPT: 24차시 Retriever Node
# 역할: 재시도를 반영하는 Retriever. retries가 늘면 더 많이 찾고 점수 기준을 낮춥니다.
# 읽는 키: retries, query, question, top_k, search_type, min_score
# 쓰는 키: documents, scores, retrieval_ok, fail_reason, log
# 그래프:
#   retries=0,1,2 ...
#     |
#     v
#   retriever_node
#     top_k 없으면 TOP_K + retries*3
#     min_score 없으면 MIN_SCORE - retries*0.05 (0 미만이면 0)
# --------------------------------------------------------------
# 파이썬만 아는 분을 위한 말
# 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에 적습니다.
# State에 top_k나 min_score를 직접 넣으면 그 값을 쓰고, 재시도 공식은 쓰지 않습니다.
# 로그 한 줄에 검색 방식, 재시도 횟수, k, 기준 점수, 통과 건수가 들어갑니다.
# 재시도해도 문서 점수가 기준보다 낮으면 fail_reason은 여전히 low_score 입니다.
# ==============================================================

# 이 파일은 질문과 관련된 문서를 검색하는 Retriever Node입니다.
# 재시도할수록 검색 문서 수를 늘리고, 점수 기준을 조금씩 완화합니다.

import os
import sys
import warnings

from dotenv import load_dotenv

# ==================================================
# 기본 경로와 환경 설정
# ==================================================

# 현재 파일이 있는 폴더의 절대 경로입니다.
CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))

# 현재 파일의 상위 폴더인 src의 경로입니다.
SRC_DIR = os.path.abspath(
    os.path.join(CURRENT_DIR, "..")
)

# src 폴더에서 config와 rag_app을 찾을 수 있도록 등록합니다.
sys.path.insert(0, SRC_DIR)

# 불필요한 경고 메시지를 숨깁니다.
warnings.filterwarnings("ignore")

# 환경 변수 파일을 불러옵니다.
load_dotenv()

# 프로젝트 설정을 불러옵니다.
import config

# 벡터 저장소를 가져오는 함수를 불러옵니다.
from rag_app.indexer import get_store

# ==================================================
# 벡터 저장소 준비
# ==================================================

# 모듈이 처음 실행될 때 벡터 저장소를 한 번만 준비합니다.
_store = get_store()

# ==================================================
# 검색 방식에 따라 문서를 검색합니다.
# ==================================================
def search_documents(query: str, k: int, search_type: str):
    """
    질문에 맞는 문서를 검색합니다.

    search_type이 "mmr"이면 MMR 검색을 사용하고,
    그 외의 경우에는 Similarity 검색을 사용합니다.
    """

    # MMR 검색을 사용하는 경우입니다.
    if search_type == "mmr":
        documents = _store.max_marginal_relevance_search(
            query=query,
            k=k,
            fetch_k=k * 4,
            lambda_mult=0.5,
        )

        # MMR은 점수를 제공하지 않으므로 None을 넣습니다.
        return [
            (document, None)
            for document in documents
        ]

    # 기본 검색 방식인 Similarity 검색입니다.
    return _store.similarity_search_with_relevance_scores(
        query,
        k=k,
    )

# ==================================================
# 재시도 가능한 Retriever Node
# ==================================================
def retriever_node(state: dict) -> dict:
    """
    State에서 검색 조건을 읽고 문서를 검색합니다.

    재시도 횟수에 따라 검색 범위와 점수 기준을 조정합니다.
    """

    # 현재까지 재시도한 횟수를 가져옵니다.
    retries = state.get("retries", 0)

    # query가 있으면 query를 사용합니다.
    query = state.get("query")

    # query가 없으면 question을 검색어로 사용합니다.
    if not query:
        query = state.get("question", "")

    # State에 top_k가 있으면 그 값을 사용합니다.
    top_k = state.get("top_k")

    # top_k가 없으면 재시도 횟수에 따라 검색 개수를 늘립니다.
    if not top_k:
        top_k = config.TOP_K + retries * 3

    # State에 search_type이 있으면 그 값을 사용합니다.
    search_type = state.get("search_type")

    # search_type이 없으면 config의 기본값을 사용합니다.
    if not search_type:
        search_type = config.SEARCH_TYPE

    # State에 min_score가 있으면 그 값을 사용합니다.
    min_score = state.get("min_score")

    # min_score가 없으면 재시도 횟수에 따라 기준을 완화합니다.
    if min_score is None:
        min_score = config.MIN_SCORE - retries * 0.05

        # 점수 기준이 0.0보다 낮아지지 않도록 제한합니다.
        if min_score < 0.0:
            min_score = 0.0

    # 질문이 비어 있으면 검색하지 않습니다.
    if not query.strip():
        return {
            "documents": [],
            "scores": [],
            "retrieval_ok": False,
            "fail_reason": "empty_query",
            "log": ["검색: 질의가 비어 있음"],
        }

    # 검색 중 오류가 발생해도 그래프가 중단되지 않도록 처리합니다.
    try:
        search_results = search_documents(
            query,
            top_k,
            search_type,
        )

    except Exception as error:
        return {
            "documents": [],
            "scores": [],
            "retrieval_ok": False,
            "fail_reason": "search_error",
            "log": [
                f"검색 오류: {type(error).__name__}"
            ],
        }

    # 검색 결과가 없으면 정상적인 실패 상태를 반환합니다.
    if not search_results:
        return {
            "documents": [],
            "scores": [],
            "retrieval_ok": False,
            "fail_reason": "no_result",
            "log": ["검색: 결과 0건"],
        }

    # 검색 결과의 점수를 저장합니다.
    scores = []

    for document, score in search_results:
        # MMR은 점수가 없으므로 None을 저장합니다.
        if score is None:
            scores.append(None)

        # Similarity 검색 점수는 소수 셋째 자리까지 저장합니다.
        else:
            scores.append(round(float(score), 3))

    # 최소 점수를 통과한 문서를 저장합니다.
    kept_documents = []

    # Similarity 검색은 점수 기준으로 문서를 필터링합니다.
    if search_results[0][1] is not None:

        for document, score in search_results:
            # 최소 점수 이상인 문서만 보관합니다.
            if float(score) >= float(min_score):
                kept_documents.append(document)

        # 통과한 문서가 없으면 실패 이유를 기록합니다.
        if kept_documents:
            fail_reason = ""
        else:
            fail_reason = "low_score"

    # MMR 검색은 점수가 없으므로 결과를 그대로 사용합니다.
    else:
        for document, score in search_results:
            kept_documents.append(document)

        # MMR은 점수 기준으로 실패 여부를 판단하지 않습니다.
        fail_reason = ""

    # 최종 검색 결과를 반환합니다.
    return {
        "documents": kept_documents,
        "scores": scores,
        "retrieval_ok": len(kept_documents) > 0,
        "fail_reason": fail_reason,
        "log": [
            f"검색 방식={search_type}, "
            f"재시도={retries}, "
            f"k={top_k}, "
            f"기준={min_score}, "
            f"{len(kept_documents)}/{len(search_results)}건 통과"
        ],
    }

# ==================================================
# 재시도 횟수에 따른 동작 테스트
# ==================================================
if __name__ == "__main__":

    # 반복해서 검색할 질문입니다.
    query = "환불은 며칠 이내인가요?"

    # 재시도 횟수를 0회, 1회, 2회로 바꾸어 테스트합니다.
    for retries in range(3):

        # Retriever Node에 전달할 State입니다.
        state = {
            "query": query,
            "retries": retries,
        }

        # Retriever Node를 실행합니다.
        result = retriever_node(state)

        # 검색 결과를 출력합니다.
        print(
            f"retries={retries} | "
            f"ok={result['retrieval_ok']} | "
            f"사유={result['fail_reason'] or '-'}"
        )

        # 실제 적용된 검색 과정을 출력합니다.
        print(
            f"   {result['log'][0]}"
        )

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

출력
===== 실행 결과 샘플 =====
예시 출력 (API 키 필요, 실행하지 않음)
아래 숫자는 기록된 로그만 옮긴 것입니다. 다른 점수는 적지 않습니다.
안녕하세요 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

retries를 0, 1, 2로 바꿔 같은 질문을 검색하는 파일입니다.
재시도마다의 새 점수는 기록에 없어 적지 않습니다.
대표이사 질문의 기록된 최종 값은 rewrites=2, fail_reason=low_score, top_score=-0.219 입니다.

코드 해설

재시도를 반영하는 Retriever. retries가 늘면 더 많이 찾고 점수 기준을 낮춥니다.

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

  • 읽는 키: retries, query, question, top_k, search_type, min_score
  • 쓰는 키: documents, scores, retrieval_ok, fail_reason, log
  • 그래프:
  • retries=0,1,2 ...
  • |
  • v
  • retriever_node
  • top_k 없으면 TOP_K + retries*3

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

  • search_documents: 검색 방식에 따라 문서를 검색합니다.
  • retriever_node: 재시도 가능한 Retriever Node

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

← 이전 절24차시 · 2. 좋은 Retriever Node의 4가지 설계 원칙다음 절 →24차시 · 4. 실패 사유를 구분하는 이유