24차시. Retriever Node 분리
3. 실습 ① — Retriever Node 완성
출처: 원본 PDF 24차시_RetrieverNode분리.pdf 7쪽 · 쪽 그림 7쪽
📝 src/nodes/retriever.py
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 완성」과 이어집니다.
# ==============================================================
# 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 완성」과 이어집니다.
# ==============================================================
# 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
파일 맨 아래 실행 결과 샘플 주석은 실행되지 않습니다. 화면 출력은 다음 절에 다시 적어 두었습니다.