19차시. Node 이해
7. 실습 ③ — Fallback 노드 만들기
출처: 원본 PDF 19차시_Node이해.pdf 15쪽 · 쪽 그림 15쪽
검색에 실패했을 때 안전하게 마무리하는 노드입니다.
📝 src/nodes/fallback.py
import config
def fallback_node(state: dict) -> dict:
# 근거를 찾지 못했을 때 안내 메시지를 만든다
tried = state.get("tried_queries", [])
msg = config.MSG_NO_DOC
if tried:
msg += f"\n(시도한 검색어: {', '.join(tried)})"
msg += "\n질문을 조금 더 구체적으로 바꿔 다시 물어봐 주세요."
return {
"answer": msg,
"grade": "giveup",
"log": ["[fallback] 안내 메시지 반환"],
}
if __name__ == "__main__":
print(fallback_node({"question": "대표이사 이름은?"})["answer"])실행 결과 예시 (코드를 읽고 만든 예상 화면입니다. 실제 실행 결과, 특히 모델의 답은 다를 수 있습니다)
관련 자료를 찾지 못했습니다.
질문을 조금 더 구체적으로 바꿔 다시 물어봐 주세요.💡 Fallback도 노드입니다
"실패 처리"라고 해서 특별하지 않습니다.
State를 받아 answer를 채우는 평범한 노드입니다.
이렇게 노드로 만들어두면
31차시에서 더 정교하게 확장하기 쉽습니다.
(예: 유사 질문 추천, 담당자 연결 안내)
7-1) 지금까지 만든 노드 정리
src/
├── nodes/
│ ├── __init__.py
│ ├── retrieve.py → retrieve_node (검색)
│ ├── generate.py → generate_node (생성)
│ └── fallback.py → fallback_node (안내)
├── config.py
├── indexer.py
├── prompts.py
└── graph_state.py → RAGState (18차시)실습 코드 · 19/config.py
실습 코드: 실습/src/19/config.py · 위의 원본 PDF 19차시 15쪽 「7-1) 지금까지 만든 노드 정리」과 이어집니다.
# ============================================================
# 19차시 | Node 이해
# 교안: 19차시_Node이해.pptx
# 역할: 검색·생성 노드가 읽을 상수(경로, 모델, 점수 기준, 안내 문장)를 모아 둔다.
# 입력: 없음
# 출력: 없음. 다른 파일의 노드가 이 이름을 읽는다.
#
# State = 이 파일에는 없다. 노드는 config.TOP_K, MIN_SCORE, MSG_NO_DOC 를 읽는다.
# Node = 이 파일에는 없다.
# 그래프 = 없다.
#
# 흐름
# (그래프 없음)
# DOC_PATH / CHUNK_* → 인덱스 만들 때
# EMBED_MODEL / INDEX_PATH / TOP_K / MIN_SCORE → retrieve 노드
# LLM_MODEL / PROMPT_VER / MSG_* → generate, fallback 노드
# ============================================================
# ?????????????????????????????????????????????
# 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 키가 필요한 코드는 키 없이 맞춘 예시이고, 모델의 답은 실행할 때마다 달라집니다)
===== 실행 결과 샘플 =====
실행: cd 실습/src/19
python config.py
(실제 실행 출력)
(표준 출력 없음. 상수만 정의한다.)코드 해설
검색·생성 노드가 읽을 상수(경로, 모델, 점수 기준, 안내 문장)를 모아 둔다. 입력은 없음이고, 출력은 없음. 다른 파일의 노드가 이 이름을 읽는다.
주석에 적힌 흐름입니다.
흐름
(그래프 없음)
DOC_PATH / CHUNK_* → 인덱스 만들 때
EMBED_MODEL / INDEX_PATH / TOP_K / MIN_SCORE → retrieve 노드
LLM_MODEL / PROMPT_VER / MSG_* → generate, fallback 노드코드 앞에서 고정해 두는 말입니다.
- 19차시 | Node 이해
- 교안: 19차시_Node이해.pptx
- State = 이 파일에는 없다. 노드는 config.TOP_K, MIN_SCORE, MSG_NO_DOC 를 읽는다.
- Node = 이 파일에는 없다.
- 그래프 = 없다.
함수로 나누지 않고 위에서 아래로 실행됩니다.
DOC_PATH = "../../data/manual.pdf"— 19차시 | Node 이해 교안: 19차시_Node이해.pptx 역할: 검색·생성 노드가 읽을 상수(경로, 모델, 점수 기준, 안내 문장)를 모아 둔다. 입력: 없음 출력: 없음. 다른 파일의 노드가 이 이름을 읽는다. State = 이 파일에는 없다. 노드는 config.TOP_K, MIN_SCORE, MSG_NO_DOC 를 읽는다. Node = 이 파일에는 없다. 그래프 = 없다. 흐름 (그래프 없음) DOC_PATH / CHUNK_* → 인덱스 만들 때 EMBED_MODEL / INDEX_PATH / TOP_K / MIN_SCORE → retrieve 노드 LLM_MODEL / PROMPT_VER / MSG_* → generate, fallback 노드 ????????????????????????????????????????????? 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 = "관련 자료를 찾지 못했습니다."— ── 메시지 ──
파일 맨 아래 실행 결과 샘플 주석은 실행되지 않습니다. 화면 출력은 다음 절에 다시 적어 두었습니다.
실습 코드 · 19/retrieve.py
실습 코드: 실습/src/19/retrieve.py · 위의 원본 PDF 19차시 15쪽 「7-1) 지금까지 만든 노드 정리」과 이어집니다.
# ============================================================
# 19차시 | Node 이해
# 교안: 19차시_Node이해.pptx
# 역할: 질문과 비슷한 문서만 골라 State에 넣는 검색 노드.
# 그래프에 넣기 전, 함수만 직접 호출해 본다.
# 입력: State의 question 또는 query, 없으면 top_k 는 config.TOP_K
# 출력: documents, scores, retrieval_ok, log
#
# State = 이 함수가 받는 딕셔너리 한 장. 공유 노트에서 필요한 칸만 읽는다.
# Node = retrieve_node. 고친 칸만 return 한다.
# reducer = log 는 그래프에 넣을 때 리스트를 이어 붙이도록 정의되어 있다.
# 그래서 log 는 문자열 하나가 아니라 한 칸짜리 리스트로 돌려준다.
# 이 파일에는 Edge / compile / invoke 가 없다. __main__ 이 함수를 직접 호출한다.
#
# 흐름 (그래프 없이 함수 한 번)
# state(question) → retrieve_node → documents, scores, retrieval_ok, log
# ============================================================
import os
import sys
import config
import datetime
# ------------------------------------------------------------
# 10차시에 만든 indexer.py를 사용하기 위해 경로를 추가합니다.
# ------------------------------------------------------------
CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
INDEXER_DIR = os.path.join(CURRENT_DIR, "..", "10")
sys.path.insert(0, INDEXER_DIR)
from indexer import get_store
# ------------------------------------------------------------
# 저장된 벡터 저장소를 불러옵니다.
# ------------------------------------------------------------
store = get_store()
# ============================================================
# Retriever Node
# ============================================================
# 노드 retrieve_node
# 읽기: query (없으면 question), top_k (없으면 config.TOP_K)
# 쓰기: documents, scores, retrieval_ok, log
def retrieve_node(state: dict) -> dict:
"""질문과 관련된 문서를 검색합니다."""
# query가 있으면 사용하고, 없으면 원래 question을 사용합니다.
query = state.get("query")
if not query:
query = state["question"]
# top_k가 있으면 사용하고, 없으면 기본값을 사용합니다.
top_k = state.get("top_k", config.TOP_K)
# 질문과 유사한 문서를 검색합니다.
results = store.similarity_search_with_relevance_scores(
query,
k=top_k
)
# 기준 점수 이상인 문서만 저장합니다.
documents = []
for document, score in results:
if score >= config.MIN_SCORE:
documents.append(document)
# 검색된 모든 문서의 점수를 저장합니다.
scores = []
for document, score in results:
scores.append(round(score, 3))
# 기준을 통과한 문서가 있는지 확인합니다.
retrieval_ok = len(documents) > 0
# 실행 내용을 로그로 남깁니다.
log_message = (
f"[retrieve] '{query}' → "
f"{len(documents)}/{len(results)}건 통과"
)
# 변경할 State 값만 반환합니다.
return {
"documents": documents,
"scores": scores,
"retrieval_ok": retrieval_ok,
"log": [log_message],
}
# ============================================================
# Retriever Node 단독 테스트
# ============================================================
if __name__ == "__main__":
# 테스트용 State를 만듭니다.
state = {
"question": "환불은 며칠 이내인가요?"
}
# 그래프 없이 Retriever Node만 직접 실행합니다.
result = retrieve_node(state)
# 검색 결과를 확인합니다.
print("통과 문서:", len(result["documents"]))
print("점수 :", result["scores"])
print("검색 성공 :", result["retrieval_ok"])
print("로그 :", result["log"][0])실행 결과 샘플 (교안 기록. API 키가 필요한 코드는 키 없이 맞춘 예시이고, 모델의 답은 실행할 때마다 달라집니다)
===== 실행 결과 샘플 =====
실행: cd 실습/src/19
python retrieve.py
예시 출력 (API 키 필요, 실행하지 않음)
통과 문서: (건수 미기록)
점수 : (유사도 점수 미기록)
검색 성공 : (미기록)
로그 : (미기록)
질문 '환불은 며칠 이내인가요?' 에 대해 이후 기록은 grade pass, rewrites 0, elapsed 6.69 이다.
그 기록은 이 파일의 print 가 아니다. 점수 숫자는 적지 않는다.코드 해설
질문과 비슷한 문서만 골라 State에 넣는 검색 노드.입니다. 입력은 State의 question 또는 query, 없으면 top_k 는 config.TOP_K이고, 출력은 documents, scores, retrieval_ok, log입니다.
주석에 적힌 흐름입니다.
흐름 (그래프 없이 함수 한 번)
state(question) → retrieve_node → documents, scores, retrieval_ok, log코드 앞에서 고정해 두는 말입니다.
- 19차시 | Node 이해
- 교안: 19차시_Node이해.pptx
- 그래프에 넣기 전, 함수만 직접 호출해 본다.
- State = 이 함수가 받는 딕셔너리 한 장. 공유 노트에서 필요한 칸만 읽는다.
- Node = retrieve_node. 고친 칸만 return 한다.
- reducer = log 는 그래프에 넣을 때 리스트를 이어 붙이도록 정의되어 있다.
- 그래서 log 는 문자열 하나가 아니라 한 칸짜리 리스트로 돌려준다.
- 이 파일에는 Edge / compile / invoke 가 없다. __main__ 이 함수를 직접 호출한다.
함수와 클래스로 나누어 읽습니다.
retrieve_node: Retriever Node 노드 retrieve_node 읽기: query (없으면 question), top_k (없으면 config.TOP_K) 쓰기: documents, scores, retrieval_ok, log
파일 맨 아래 실행 결과 샘플 주석은 실행되지 않습니다. 화면 출력은 다음 절에 다시 적어 두었습니다.
실습 코드 · 19/generate.py
실습 코드: 실습/src/19/generate.py · 위의 원본 PDF 19차시 15쪽 「7-1) 지금까지 만든 노드 정리」과 이어집니다.
# ============================================================
# 19차시 | Node 이해
# 교안: 19차시_Node이해.pptx
# 역할: 검색된 문서를 근거로 답변 문자열을 만드는 생성 노드.
# 입력: documents, question
# 출력: answer, insufficient, has_citation, log
# 문서가 없으면 LLM을 부르지 않고 안내 문장을 answer 에 넣는다.
#
# State = generate_node 가 받는 공유 노트.
# Node = generate_node. build_context 는 노드가 아니라 문서를 문자열로 합치는 도우미.
# 이 파일에는 Edge / compile / invoke(그래프) 가 없다.
# chain.invoke 는 LangGraph 실행이 아니라 LLM 체인 호출이다.
#
# 흐름 (단독 테스트)
# question → retrieve_node → generate_node → answer 출력
# ============================================================
import os
import sys
import re
from dotenv import load_dotenv
from langchain_openai import ChatOpenAI
from langchain_core.output_parsers import StrOutputParser
import config
# ------------------------------------------------------------
# 13차시에 만든 prompts.py를 사용하기 위해 경로를 추가합니다.
# ------------------------------------------------------------
CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
PROMPT_DIR = os.path.join(CURRENT_DIR, "..", "13")
sys.path.insert(0, PROMPT_DIR)
from prompts import PROMPTS
# ------------------------------------------------------------
# .env 파일의 환경 변수를 불러옵니다.
# ------------------------------------------------------------
load_dotenv()
# ------------------------------------------------------------
# 답변 생성에 사용할 LLM 체인을 만듭니다.
# 프롬프트 → LLM → 문자열 변환 순서로 실행됩니다.
# ------------------------------------------------------------
prompt = PROMPTS[config.PROMPT_VER]
llm = ChatOpenAI(
model=config.LLM_MODEL,
temperature=config.TEMPERATURE
)
chain = prompt | llm | StrOutputParser()
# ------------------------------------------------------------
# 자료에 답이 없을 때 사용하는 문구입니다.
# ------------------------------------------------------------
NO_INFO = "자료에서 확인할 수 없습니다"
# ============================================================
# 검색 문서를 LLM에게 전달할 문자열로 변환
# ============================================================
# 노드가 아님. 문서 목록을 프롬프트용 문자열로 합친다.
def build_context(documents):
"""검색된 문서들을 하나의 문자열로 합칩니다."""
parts = []
# 문서를 하나씩 꺼냅니다.
for index in range(len(documents)):
document = documents[index]
# 문서의 metadata를 가져옵니다.
metadata = document.metadata
if not metadata:
metadata = {}
# 파일명을 찾습니다.
filename = metadata.get("filename")
if not filename:
filename = metadata.get("file_name")
if not filename:
filename = metadata.get("source")
if not filename:
filename = "unknown"
# 페이지 번호를 찾습니다.
page_no = metadata.get("page_no")
if page_no is None:
page_no = metadata.get("page")
if page_no is None:
page_no = metadata.get("pageno")
if page_no is None:
page_no = "?"
# 문서 번호는 1번부터 시작합니다.
document_number = index + 1
# LLM에게 전달할 문서 내용을 만듭니다.
text = (
f"[{document_number}] {filename} p.{page_no}\n"
f"{document.page_content}"
)
parts.append(text)
# 여러 문서를 구분선으로 연결합니다.
context = "\n\n---\n\n".join(parts)
return context
# ============================================================
# Generator Node
# ============================================================
# 노드 generate_node
# 읽기: documents, question
# 쓰기: answer, insufficient, has_citation, log
def generate_node(state: dict) -> dict:
"""검색된 문서를 근거로 답변을 생성합니다."""
# Retriever Node가 저장한 문서를 가져옵니다.
documents = state.get("documents")
if not documents:
documents = []
# 검색 문서가 없으면 LLM을 호출하지 않습니다.
if len(documents) == 0:
return {
"answer": config.MSG_NO_DOC,
"insufficient": True,
"has_citation": False,
"log": ["[generate] 근거 없음 - 생성 생략"],
}
# 검색 문서를 LLM에게 전달할 문자열로 만듭니다.
context = build_context(documents)
# 사용자의 원래 질문을 가져옵니다.
question = state["question"]
# 문서와 질문을 LLM에게 전달하여 답변을 생성합니다.
# 여기의 invoke 는 그래프 실행이 아니라 프롬프트|LLM|문자열 체인 호출이다.
answer = chain.invoke({
"context": context,
"question": question,
})
# 답변에서 [1], [2]와 같은 인용 번호를 찾습니다.
found_numbers = re.findall(r"\[(\d+)\]", answer)
# 찾은 인용 번호를 숫자로 변환합니다.
citation_numbers = []
for number in found_numbers:
citation_numbers.append(int(number))
# 인용 번호가 정상인지 검사합니다.
has_citation = False
if len(citation_numbers) > 0:
has_citation = True
# 존재하지 않는 문서 번호가 있으면 비정상입니다.
for number in citation_numbers:
if number < 1 or number > len(documents):
has_citation = False
break
# 자료 부족 문구가 답변에 있는지 확인합니다.
insufficient = NO_INFO in answer
# 로그에 표시할 인용 상태를 정합니다.
if has_citation:
citation_status = "정상"
else:
citation_status = "미확인"
# 생성 결과를 State에 반영할 수 있도록 반환합니다.
return {
"answer": answer,
"insufficient": insufficient,
"has_citation": has_citation,
"log": [
f"[generate] {len(answer)}자 생성 "
f"(인용 {citation_status})"
],
}
# ============================================================
# Generator Node 단독 테스트
# ============================================================
if __name__ == "__main__":
# 앞에서 만든 Retriever Node를 가져옵니다.
from retrieve import retrieve_node
# 테스트용 State를 만듭니다.
state = {
"question": "환불은 며칠 이내인가요?"
}
# 먼저 Retriever Node를 실행합니다.
retrieve_result = retrieve_node(state)
# 검색 결과를 기존 State에 추가합니다.
state.update(retrieve_result)
# 검색 결과를 이용하여 Generator Node를 실행합니다.
generate_result = generate_node(state)
# 생성된 답변을 출력합니다.
print(generate_result["answer"])실행 결과 샘플 (교안 기록. API 키가 필요한 코드는 키 없이 맞춘 예시이고, 모델의 답은 실행할 때마다 달라집니다)
===== 실행 결과 샘플 =====
실행: cd 실습/src/19
python generate.py
예시 출력 (API 키 필요, 실행하지 않음)
(answer 문자열 한 덩어리를 출력한다. 본문은 기록되어 있지 않다.)
질문 '환불은 며칠 이내인가요?' 의 이후 기록은 grade pass, rewrites 0, elapsed 6.69 이다.
그 세 값은 이 파일의 print 가 아니다. 점수와 답변 문장은 적지 않는다.코드 해설
검색된 문서를 근거로 답변 문자열을 만드는 생성 노드.입니다. 입력은 documents, question이고, 출력은 answer, insufficient, has_citation, log입니다.
주석에 적힌 흐름입니다.
흐름 (단독 테스트)
question → retrieve_node → generate_node → answer 출력코드 앞에서 고정해 두는 말입니다.
- 19차시 | Node 이해
- 교안: 19차시_Node이해.pptx
- 문서가 없으면 LLM을 부르지 않고 안내 문장을 answer 에 넣는다.
- State = generate_node 가 받는 공유 노트.
- Node = generate_node. build_context 는 노드가 아니라 문서를 문자열로 합치는 도우미.
- 이 파일에는 Edge / compile / invoke(그래프) 가 없다.
- chain.invoke 는 LangGraph 실행이 아니라 LLM 체인 호출이다.
함수와 클래스로 나누어 읽습니다.
build_context: 검색 문서를 LLM에게 전달할 문자열로 변환 노드가 아님. 문서 목록을 프롬프트용 문자열로 합친다.generate_node: Generator Node 노드 generate_node 읽기: documents, question 쓰기: answer, insufficient, has_citation, log
파일 맨 아래 실행 결과 샘플 주석은 실행되지 않습니다. 화면 출력은 다음 절에 다시 적어 두었습니다.
실습 코드 · 19/fallback.py
실습 코드: 실습/src/19/fallback.py · 위의 원본 PDF 19차시 15쪽 「7-1) 지금까지 만든 노드 정리」과 이어집니다.
# ============================================================
# 19차시 | Node 이해
# 교안: 19차시_Node이해.pptx
# 역할: 검색이 실패했을 때 안내 문장을 answer 에 넣고 grade 를 giveup 으로 둔다.
# 입력: tried_queries (없으면 빈 리스트로 본다)
# 출력: answer, grade, log
#
# State = fallback_node 가 받는 공유 노트.
# Node = fallback_node. 검색이나 LLM을 부르지 않는다.
# 그래프 = 이 파일에는 없다. __main__ 이 함수를 직접 호출한다.
#
# 흐름
# state → fallback_node → answer, grade="giveup", log
# ============================================================
import config
# ============================================================
# Fallback Node
# ============================================================
# 노드 fallback_node
# 읽기: tried_queries
# 쓰기: answer, grade, log
def fallback_node(state: dict) -> dict:
"""검색에 실패했을 때 안내 메시지를 만듭니다."""
# 지금까지 시도한 검색어를 가져옵니다.
tried_queries = state.get("tried_queries", [])
# 기본 안내 메시지를 가져옵니다.
message = config.MSG_NO_DOC
# 시도한 검색어가 있으면 안내 메시지에 추가합니다.
if tried_queries:
tried_text = ", ".join(tried_queries)
message += f"\n(시도한 검색어: {tried_text})"
# 사용자가 다시 질문할 수 있도록 안내합니다.
message += "\n질문을 조금 더 구체적으로 바꿔 다시 물어봐 주세요."
# 변경할 State 값만 반환합니다.
return {
"answer": message,
"grade": "giveup",
"log": ["[fallback] 안내 메시지 반환"],
}
# ============================================================
# Fallback Node 단독 테스트
# ============================================================
if __name__ == "__main__":
# 테스트용 State를 만듭니다.
state = {
"question": "대표이사 이름은?"
}
# 그래프 없이 Fallback Node만 직접 실행합니다.
result = fallback_node(state)
# 만들어진 안내 메시지를 출력합니다.
print(result["answer"])실행 결과 샘플 (교안 기록. API 키가 필요한 코드는 키 없이 맞춘 예시이고, 모델의 답은 실행할 때마다 달라집니다)
===== 실행 결과 샘플 =====
실행: cd 실습/src/19
python fallback.py
(실제 실행 출력)
관련 자료를 찾지 못했습니다.
질문을 조금 더 구체적으로 바꿔 다시 물어봐 주세요.
테스트 State에 tried_queries 가 없어 시도 검색어 줄은 붙지 않는다.
이후 기록에서 '대표이사' 질문은 grade giveup, rewrites 2, elapsed 3.41 이다.
rewrites 와 elapsed 는 이 파일의 print 가 아니다.코드 해설
검색이 실패했을 때 안내 문장을 answer 에 넣고 grade 를 giveup 으로 둔다. 입력은 tried_queries (없으면 빈 리스트로 본다)이고, 출력은 answer, grade, log입니다.
주석에 적힌 흐름입니다.
흐름
state → fallback_node → answer, grade="giveup", log코드 앞에서 고정해 두는 말입니다.
- 19차시 | Node 이해
- 교안: 19차시_Node이해.pptx
- State = fallback_node 가 받는 공유 노트.
- Node = fallback_node. 검색이나 LLM을 부르지 않는다.
- 그래프 = 이 파일에는 없다. __main__ 이 함수를 직접 호출한다.
함수와 클래스로 나누어 읽습니다.
fallback_node: Fallback Node 노드 fallback_node 읽기: tried_queries 쓰기: answer, grade, log
파일 맨 아래 실행 결과 샘플 주석은 실행되지 않습니다. 화면 출력은 다음 절에 다시 적어 두었습니다.