24차시. Retriever Node 분리
8. 입출력 명세 문서화
출처: 원본 PDF 24차시_RetrieverNode분리.pdf 17쪽 · 쪽 그림 17쪽
모듈을 만들었으면 계약을 문서로 남깁니다. 팀 작업과 나중의 나를 위해서입니다.
■ nodes/retriever.py 상단 주석
# ===================================================
# Retriever Node
# ===================================================
# 책임: 질의에 맞는 근거를 확보하고 신뢰도를 기록한다.
# 흐름 결정은 하지 않는다.
#
# [입력 - State에서 읽음]
# query str 검색에 쓸 질문 (필수)
# question str query가 없을 때 대체
# top_k int 가져올 개수 (None이면 config.TOP_K)
# search_type str "similarity" 또는 "mmr"
# min_score float 임계값 (None이면 config.MIN_SCORE)
# retries int 재시도 횟수 (점진적 확대에 사용)
#
# [출력 - State에 씀]
# documents list 기준을 통과한 Document 목록
# scores list 전체 후보의 점수 (통과 여부 무관)
# retrieval_ok bool 쓸 만한 결과가 있는가
# fail_reason str "" / empty_query / search_error
# / no_result / low_score
# log list 실행 기록 (누적)
#
# [보장]
# - 어떤 경우에도 예외를 던지지 않는다
# - documents가 비어도 정상 반환한다
# ===================================================📄 명세를 남기면 좋은 점
① 다른 사람이 이 모듈을 쓸 수 있습니다
코드를 안 읽어도 무엇이 오가는지 압니다
② 교체할 때 기준이 됩니다
하이브리드 검색으로 바꿔도 이 명세만 지키면 OK
③ 테스트를 짤 수 있습니다
"fail_reason이 5가지 중 하나인가" 같은 검증
④ 나중의 내가 고마워합니다
석 달 뒤에 이 코드를 다시 볼 때
실습 코드 · 24/retriever.py
실습 코드: 실습/src/24/retriever.py · 위의 원본 PDF 24차시 17쪽 「8️⃣ 입출력 명세 문서화」과 이어집니다.
# ==============================================================
# 24차시. PPT 24차시_RetrieverNode분리. 코드 폴더는 src/24
# PPT: 24차시 Retriever Node
# 역할: Retriever Node. 질문과 비슷한 문서 조각만 골라 State에 넣습니다.
# 읽는 키: query, question, top_k, search_type, min_score
# 쓰는 키: documents, scores, retrieval_ok, fail_reason, log
# 그래프:
# retriever_node(state)
# |-- 질의 비어 있음 --> fail_reason=empty_query
# |-- 검색 예외 -----> fail_reason=search_error
# |-- 결과 0건 -----> fail_reason=no_result
# |-- 점수 미달 -----> fail_reason=low_score, documents=[]
# +-- 점수 통과 -----> retrieval_ok=True, documents=통과 문서
# --------------------------------------------------------------
# 파이썬만 아는 분을 위한 말
# 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에 적습니다.
# query가 있으면 그 문장으로 찾고, 없으면 question으로 찾습니다.
# similarity 검색은 (문서, 점수)를 줍니다. mmr 검색은 점수가 없어 None을 넣습니다.
# 점수는 소수 셋째 자리로 반올림해 scores에 넣습니다. 걸러낸 뒤의 documents와 길이가 다를 수 있습니다.
# 이 노드는 답을 만들지 않습니다. 다음 칸인 Generator가 documents를 읽습니다.
# ==============================================================
# 이 파일은 질문과 관련된 문서를 검색하는 Retriever Node입니다.
# Similarity 검색과 MMR 검색을 지원하며, 검색 점수와 실패 원인을 함께 반환합니다.
import os
import sys
import warnings
from dotenv import load_dotenv
# ==================================================
# 기본 환경 설정
# ==================================================
# 현재 파일의 위치를 기준으로 src 폴더를 찾습니다.
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")
# 프로젝트의 설정값을 불러옵니다.
import config
# 벡터 저장소를 불러오는 함수를 가져옵니다.
from rag_app.indexer import get_store
# ==================================================
# 환경 변수와 벡터 저장소 준비
# ==================================================
# 프로젝트의 .env 파일을 불러옵니다.
load_dotenv()
# 프로그램이 시작될 때 벡터 저장소를 한 번만 준비합니다.
_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에서 검색 조건을 읽고 문서를 검색합니다.
검색 성공 여부와 실패 원인을 함께 반환합니다.
검색 결과가 없어도 예외를 발생시키지 않습니다.
"""
# query가 있으면 query를 사용합니다.
query = state.get("query")
# query가 없으면 question을 검색어로 사용합니다.
if not query:
query = state.get("question", "")
# State에 top_k가 없으면 config의 기본값을 사용합니다.
k = state.get("top_k")
if not k:
k = config.TOP_K
# State에 검색 방식이 없으면 config의 기본값을 사용합니다.
search_type = state.get("search_type")
if not search_type:
search_type = config.SEARCH_TYPE
# State에 최소 점수 기준이 있으면 그 값을 사용합니다.
min_score = state.get("min_score")
# 최소 점수 기준이 없으면 config의 기본값을 사용합니다.
if min_score is None:
min_score = config.MIN_SCORE
# 질문이 비어 있으면 검색하지 않고 결과를 반환합니다.
if not query.strip():
return {
"documents": [],
"scores": [],
"retrieval_ok": False,
"fail_reason": "empty_query",
"log": ["검색: 질의가 비어 있음"],
}
# 검색 중 오류가 발생해도 그래프가 중단되지 않도록 처리합니다.
try:
search_results = search_documents(
query,
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)
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 = ""
# 검색 결과를 그래프의 State에 반환합니다.
return {
"documents": kept_documents,
"scores": scores,
"retrieval_ok": len(kept_documents) > 0,
"fail_reason": fail_reason,
"log": [
f"검색 방식={search_type}, "
f"k={k}, "
f"{len(kept_documents)}/{len(search_results)}건 통과"
],
}
# ==================================================
# Retriever Node 단독 테스트
# ==================================================
if __name__ == "__main__":
# Retriever Node를 테스트할 질문 목록입니다.
test_cases = [
{
"query": "환불은 며칠 이내인가요?",
},
{
"query": "반품하고 싶은데 언제까지?",
},
{
"query": "대표이사가 누구인가요?",
},
{
"query": "",
},
{
"query": "환불 규정",
"top_k": 10,
},
{
"query": "환불과 교환 차이",
"search_type": "mmr",
},
]
# 테스트 질문을 하나씩 실행합니다.
for test_case in test_cases:
# Retriever Node를 실행합니다.
result = retriever_node(test_case)
# 빈 질문이면 화면에 표시할 문구를 바꿉니다.
question = test_case.get("query", "")
if not question:
question = "(빈 질의)"
# 질문을 출력합니다.
print(f"\nQ: {question}")
# 통과한 문서 개수와 검색 성공 여부를 출력합니다.
print(
f" 통과 {len(result['documents'])}건 | "
f"ok={result['retrieval_ok']} | "
f"사유={result['fail_reason'] or '-'}"
)
# 검색 점수를 출력합니다.
print(
f" 점수: {result['scores'][:5]}"
)실행 결과 샘플 (교안 기록. 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
이 파일 __main__ 의 질문 문구는 위 로그와 조금 다릅니다.
빈 질의는 코드가 정한 분기입니다. 점수는 없습니다.
Q: (빈 질의) -> documents=[], retrieval_ok=False, fail_reason=empty_query코드 해설
Retriever Node. 질문과 비슷한 문서 조각만 골라 State에 넣습니다.
코드 앞에서 고정해 두는 말입니다.
- 읽는 키: query, question, top_k, search_type, min_score
- 쓰는 키: documents, scores, retrieval_ok, fail_reason, log
- 그래프:
- retriever_node(state)
- |-- 질의 비어 있음 --> fail_reason=empty_query
- |-- 검색 예외 -----> fail_reason=search_error
- |-- 결과 0건 -----> fail_reason=no_result
- |-- 점수 미달 -----> fail_reason=low_score, documents=[]
함수와 클래스로 나누어 읽습니다.
search_documents: 검색 방식에 따라 문서를 검색합니다.retriever_node: Retriever Node
파일 맨 아래 실행 결과 샘플 주석은 실행되지 않습니다. 화면 출력은 다음 절에 다시 적어 두었습니다.