39차시. 미니프로젝트 ② 구현과 개선
실습 1 — 미니프로젝트 ② 구현과 개선 실습 따라 하기
관련 수업 자료: 원본 PDF 39차시_미니프로젝트구현.pdf · 실습 코드 rag_app_prj/prompts.py, rag_app_prj/rag.py, rag_app_prj/app.py, rag_app_prj/web.py, 32/graph32.py, 32/test_coverage.py
이 실습은 교안 해설(보충_미니프로젝트구현.md, 보충_미니프로젝트심화.md)의 실습 순서를 옮긴 것입니다. OpenAI 임베딩이나 채팅 모델을 부르는 파일은 .env 에 API 키가 있어야 돌아갑니다. 키가 없으면 실행 결과 샘플로 출력 모양을 확인합니다.
수업 자료와 이어지는 곳
| 실습 코드 | 원본 PDF | 이어지는 내용 |
|---|---|---|
rag_app_prj/prompts.py | - | 원본 PDF 에 짝이 되는 쪽이 없어 이 페이지 아래에 실었습니다 |
rag_app_prj/rag.py | - | 원본 PDF 에 짝이 되는 쪽이 없어 이 페이지 아래에 실었습니다 |
rag_app_prj/app.py | - | 원본 PDF 에 짝이 되는 쪽이 없어 이 페이지 아래에 실었습니다 |
rag_app_prj/web.py | - | 원본 PDF 에 짝이 되는 쪽이 없어 이 페이지 아래에 실었습니다 |
32/graph32.py | - | 원본 PDF 에 짝이 되는 쪽이 없어 이 페이지 아래에 실었습니다 |
32/test_coverage.py | - | 원본 PDF 에 짝이 되는 쪽이 없어 이 페이지 아래에 실었습니다 |
보충 미니프로젝트구현
구현은 15차시 네 모듈을 부산 문서로 연결하는 일입니다. rag.py의 ask가 검색과 생성을 하고, app.py는 콘솔, web.py는 사진을 곁들인 웹 화면입니다. 그래프 버전이 필요하면 22차시 ask와 반환 키를 같게 유지합니다.
성공 조건은 16차시 미니 과제와 같습니다. 인덱스는 두 번째 실행부터 불러오고, 출처가 보이고, 문서에 없는 질문에는 거절하고, 예외가 나도 프로세스가 죽지 않습니다. API 샘플은 예시입니다. 앱과 웹은 실행하지 않았습니다.
실행 흐름
질문 → rag.ask
rag.ask → answer
rag.ask → sources
answer → 콘솔 또는 웹
sources → 콘솔 또는 웹실습 순서
키가 있으면 실습/src/rag_app_prj에서 python app.py로 설계서의 10개 질문을 넣고 키워드가 있는지 표시합니다. 웹은 streamlit run web.py입니다. 첫 실행은 인덱스를 만들고 둘째 실행은 불러오는지 시간을 적습니다.
스스로 확인
- 설계서의 없는 질문이 거절로 끝나는가?
- 출처에 파일 또는 페이지가 보이는가?
- 청크를 바꿀 때 config 외의 파일을 열지 않아도 되는가?
보충 미니프로젝트심화
구현은 38차시 표와 같은 연결을 코드로 만드는 시간입니다. 참고 구현은 graph32.py입니다. 분류, 검색, 생성, 검증, 재생성, 재작성, fallback이 한 그래프에 있습니다. 31차시와 달리 이 파일은 safe_node를 붙이지 않습니다. 예외 처리까지 넣으면 graph31.py의 감싸개를 합치면 됩니다.
기본 주제를 고른 사람은 rag_app_prj의 ask가 질문 10개에 답하는지만 확인해도 됩니다. 확장 주제는 인사, 계산, 문서 질문, 없는 질문이 서로 다른 경로로 가는지 test_coverage.py 질문으로 확인합니다.
샘플은 API 예시입니다. 환불 질문 기록은 pass, 재작성 0입니다. 대표이사 질문 기록은 giveup, 재작성 2, fail_reason low_score, top_score 약 -0.219입니다. 이 점수는 거리형 점수일 수 있으니 클수록 좋다고 단정하지 않습니다.
실행 흐름
classify → retrieve
retrieve → generate (ok)
generate → verify
verify → bump (regenerate)
bump → generate
verify → rewrite (research)
rewrite → retrieve
verify → fallback (giveup)실습 순서
키가 있으면 자기 주제 질문 10개를 돌리고, 기대 경로와 실제 log를 나란히 적습니다. 키가 없으면 graph32.py를 따라 가며 38차시 표의 모든 분기가 코드에 있는지 체크합니다.
스스로 확인
- 표에 있는 경로가 코드의 경로 맵에 전부 있는가?
- 없는 질문이 지어낸 사실로 끝나지 않는가?
- 재작성 상한과 재생성 상한이 각각 있는가?
이어지는 실습 코드
실습 코드 · rag_app_prj/prompts.py
실습 코드: 실습/src/rag_app_prj/prompts.py · 원본 PDF 에 같은 이름의 쪽이 없어 여기에 실었습니다.
# ==============================================================
# 보충 미니프로젝트. 코드 폴더는 rag_app_prj
# PPT: 미니프로젝트 부산 2박 3일 여행 가이드
# 역할: 여행 가이드 말투의 프롬프트 v1, v2, v3. 칸 이름은 context, question 입니다.
# 읽는 키: 없음
# 쓰는 키: PROMPTS
# 그래프:
# 가이드 청크 문자열 + 사용자 질문 --> 선택한 버전 프롬프트 --> LLM
# --------------------------------------------------------------
# v2와 v3의 역할 문장은 '부산 2박 3일 여행 가이드' 입니다.
# 규칙은 자료에 있는 내용만 답하고, 문장 끝에 [1] 형식의 근거 번호를 다는 것입니다.
# v3는 일부만 자료에 있을 때 있는 부분만 답하라고 적혀 있습니다.
# ==============================================================
# ChatPromptTemplate 은 {context}, {question} 같은 빈칸이 있는 문장 틀입니다. invoke 할 때 빈칸이 채워집니다.
from langchain_core.prompts import ChatPromptTemplate
# ── V1: 최소 버전 (비교 대조군) ──
# 규칙이 없는 가장 단순한 틀입니다. v2, v3 가 얼마나 나아졌는지 견주는 기준으로 둡니다.
# 괄호 안의 문자열 여러 개는 파이썬이 이어 붙여 한 문장으로 만듭니다. 쉼표가 없다는 점을 보세요.
RAG_PROMPT_V1 = ChatPromptTemplate.from_template(
"아래 자료를 참고해서 질문에 답해주세요.\n\n"
"[자료]\n{context}\n\n"
"[질문] {question}"
)
# ── V2: 규칙 강화 버전 (권장) ──
# 역할을 정하고 규칙 다섯 줄을 줍니다. 2번 규칙이 문서에 없는 질문을 거절하게 만듭니다.
# 4번 규칙의 [1] 번호는 rag.py 가 답변 속 인용 번호를 검사할 때 씁니다.
RAG_PROMPT_V2 = ChatPromptTemplate.from_template(
"당신은 부산 2박 3일 여행 가이드입니다.\n\n"
"[규칙]\n"
"1. 아래 [자료]에 있는 내용만 근거로 답하십시오.\n"
"2. 자료에 없는 내용은 '자료에서 확인할 수 없습니다'라고 "
"명시하십시오.\n"
"3. 추측하거나 일반 상식으로 보충하지 마십시오.\n"
"4. 각 문장 끝에 근거 번호를 [1] 형식으로 표기하십시오.\n"
"5. 3~5문장으로 간결하게 답하십시오.\n\n"
"[자료]\n{context}\n\n"
"[질문]\n{question}"
)
# ── V3: 부분 답변 허용 버전 (과잉 거부 방지) ──
# v2 와 다른 곳은 2번 규칙 하나입니다. 질문 일부만 자료에 있을 때 통째로 거절하지 않게 합니다.
RAG_PROMPT_V3 = ChatPromptTemplate.from_template(
"당신은 부산 2박 3일 여행 가이드입니다.\n\n"
"[규칙]\n"
"1. 아래 [자료]에 있는 내용만 근거로 답하십시오.\n"
"2. 질문의 일부만 자료에 있다면, 있는 부분은 답하고 "
"없는 부분만 '자료에서 확인할 수 없습니다'라고 하십시오.\n"
"3. 추측하거나 일반 상식으로 보충하지 마십시오.\n"
"4. 각 문장 끝에 근거 번호를 [1] 형식으로 표기하십시오.\n"
"5. 3~5문장으로 간결하게 답하십시오.\n\n"
"[자료]\n{context}\n\n"
"[질문]\n{question}"
)
# config.PROMPT_VER 에 적은 이름("v3")으로 틀을 고릅니다. 버전을 바꿀 때 이 파일은 고치지 않습니다.
PROMPTS = {
"v1": RAG_PROMPT_V1,
"v2": RAG_PROMPT_V2,
"v3": RAG_PROMPT_V3,
}실행 결과 샘플 (교안 기록. API 키가 필요한 코드는 키 없이 맞춘 예시이고, 모델의 답은 실행할 때마다 달라집니다)
===== 실행 결과 샘플 =====
직접 실행 시 출력 없음
직접 실행 시 출력 없음.코드 해설
여행 가이드 말투의 프롬프트 v1, v2, v3. 칸 이름은 context, question 입니다.
코드 앞에서 고정해 두는 말입니다.
- PPT: 미니프로젝트 부산 2박 3일 여행 가이드
- 읽는 키: 없음
- 쓰는 키: PROMPTS
- 그래프:
- 가이드 청크 문자열 + 사용자 질문 --> 선택한 버전 프롬프트 --> LLM
- v2와 v3의 역할 문장은 '부산 2박 3일 여행 가이드' 입니다.
- 규칙은 자료에 있는 내용만 답하고, 문장 끝에 [1] 형식의 근거 번호를 다는 것입니다.
- v3는 일부만 자료에 있을 때 있는 부분만 답하라고 적혀 있습니다.
함수로 나누지 않고 위에서 아래로 실행됩니다.
RAG_PROMPT_V1 = ChatPromptTemplate.from_template(— ── V1: 최소 버전 (비교 대조군) ──RAG_PROMPT_V2 = ChatPromptTemplate.from_template(— ── V2: 규칙 강화 버전 (권장) ──RAG_PROMPT_V3 = ChatPromptTemplate.from_template(— ── V3: 부분 답변 허용 버전 (과잉 거부 방지) ──
파일 맨 아래 실행 결과 샘플 주석은 실행되지 않습니다. 화면 출력은 다음 절에 다시 적어 두었습니다.
실습 코드 · rag_app_prj/rag.py
실습 코드: 실습/src/rag_app_prj/rag.py · 원본 PDF 에 같은 이름의 쪽이 없어 여기에 실었습니다.
# ==============================================================
# 보충 미니프로젝트. 코드 폴더는 rag_app_prj
# PPT: 미니프로젝트 부산 2박 3일 여행 가이드
# 역할: 가이드 PDF 인덱스에서 관련 청크를 찾고, 그 청크로만 답을 만든 뒤 인용 번호를 검사합니다.
# 읽는 키: question, k
# 쓰는 키: answer, sources, cited, ok
# 그래프:
# 질문 --> _search --> 청크가 있으면 _build_context --> LLM --> 인용 검사
# 청크가 없으면 MSG_NO_DOC
# --------------------------------------------------------------
# 파이썬만 아는 분을 위한 말
# 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에 적습니다.
# 구조는 rag_app/rag.py와 같습니다. 문서가 사내 매뉴얼이 아니라 부산 가이드입니다.
# import 시점에 .env를 읽고 인덱스와 LLM을 준비하므로 실행하지 않았습니다.
# LangGraph의 intent, grade, retries는 이 반환 dict에 없습니다.
# ==============================================================
# ============================================================
# 사용자의 질문과 관련된 가이드를 FAISS에서 검색하고,
# 검색된 내용을 근거로 OpenAI LLM이 최종 답변을 생성합니다.
# 답변의 출처와 인용 번호가 올바른지도 함께 확인합니다.
# ============================================================
import re
from dotenv import load_dotenv
from langchain_openai import ChatOpenAI
from langchain_core.output_parsers import StrOutputParser
import config
from indexer import get_store
from prompts import PROMPTS
# ============================================================
# 1. 기본 환경 준비
# ============================================================
# .env 파일의 OPENAI_API_KEY를 환경변수로 불러옵니다.
load_dotenv()
# FAISS 벡터 저장소를 준비합니다.
store = get_store()
# OpenAI LLM을 준비합니다.
llm = ChatOpenAI(
model=config.LLM_MODEL,
temperature=config.TEMPERATURE
)
# 프롬프트 → LLM → 문자열 변환 순서로 실행 체인을 만듭니다.
chain = (
PROMPTS[config.PROMPT_VER]
| llm
| StrOutputParser()
)
# ============================================================
# 2. 검색 함수
# ============================================================
# 질문과 관련된 문서를 벡터 저장소에서 검색합니다.
# MIN_SCORE 이상의 문서만 최종 검색 결과로 사용합니다.
# ============================================================
def _search(question, k=None):
# 검색 개수가 지정되지 않으면 기본값을 사용합니다.
if k is None:
k = config.TOP_K
# 질문과 유사한 문서를 검색합니다.
results = store.similarity_search_with_relevance_scores(
question,
k=k
)
# 기준 점수를 통과한 문서만 저장합니다.
docs = []
for doc, score in results:
if score >= config.MIN_SCORE:
docs.append(doc)
return docs
# ============================================================
# 3. 컨텍스트 생성 함수
# ============================================================
# 검색된 문서들을 LLM에게 전달하기 좋은 문자열로 만듭니다.
# 각 문서 앞에 번호, 파일명, 페이지 번호를 표시합니다.
# ============================================================
def _build_context(docs):
# 문서별 문자열을 저장할 리스트입니다.
parts = []
# 검색된 문서를 하나씩 처리합니다.
for number, doc in enumerate(docs, 1):
# 원본 파일 이름을 가져옵니다.
filename = doc.metadata.get(
"filename",
"unknown"
)
# 페이지 번호를 가져옵니다.
page_no = doc.metadata.get("page_no")
# page_no가 없으면 기본 page 값을 사용합니다.
if page_no is None:
page = doc.metadata.get("page", 0)
page_no = page + 1
# 출처 정보와 문서 내용을 하나의 문자열로 만듭니다.
part = (
f"[{number}] {filename} p.{page_no}\n"
f"{doc.page_content}"
)
parts.append(part)
# 문서 사이에 구분선을 넣어 하나의 문자열로 만듭니다.
context = "\n\n---\n\n".join(parts)
return context
# ============================================================
# 4. 질문 처리 함수
# ============================================================
# 질문을 받아 관련 문서를 검색하고,
# 검색 결과를 근거로 LLM에게 답변을 생성시킵니다.
# ============================================================
def ask(question: str, k: int = None) -> dict:
# --------------------------------------------------------
# 질문 입력 확인
# --------------------------------------------------------
# 질문이 없거나 공백만 있으면 종료합니다.
if not question or not question.strip():
return {
"answer": "질문을 입력해주세요.",
"sources": [],
"ok": False
}
# --------------------------------------------------------
# 관련 문서 검색
# --------------------------------------------------------
try:
docs = _search(question, k)
except Exception as error:
print("[검색 오류]", error)
return {
"answer": config.MSG_ERROR,
"sources": [],
"ok": False
}
# --------------------------------------------------------
# 검색 결과 확인
# --------------------------------------------------------
# 관련 문서를 찾지 못했다면 LLM을 호출하지 않습니다.
if not docs:
return {
"answer": config.MSG_NO_DOC,
"sources": [],
"ok": True
}
# --------------------------------------------------------
# LLM에게 전달할 근거 문서 만들기
# --------------------------------------------------------
context = _build_context(docs)
# --------------------------------------------------------
# LLM 답변 생성
# --------------------------------------------------------
try:
answer = chain.invoke({
"context": context,
"question": question
})
except Exception as error:
print("[생성 오류]", error)
return {
"answer": config.MSG_ERROR,
"sources": [],
"ok": False
}
# --------------------------------------------------------
# 답변의 인용 번호 확인
# --------------------------------------------------------
# 답변에서 [1], [2] 같은 인용 번호를 찾습니다.
found = re.findall(
r"\[(\d+)\]",
answer
)
# 찾은 문자열 번호를 정수로 변환합니다.
citation_numbers = []
for number in found:
citation_numbers.append(int(number))
# 인용 번호가 하나라도 있으면 True로 시작합니다.
cited = False
if len(citation_numbers) > 0:
cited = True
# 실제 문서 범위를 벗어난 인용이 있는지 확인합니다.
for number in citation_numbers:
if number < 1 or number > len(docs):
cited = False
# --------------------------------------------------------
# 출처 정보 정리
# --------------------------------------------------------
sources = []
# 검색에 사용된 문서의 출처를 하나씩 저장합니다.
for doc in docs:
# 파일 이름을 가져옵니다.
filename = doc.metadata.get(
"filename",
"unknown"
)
# 페이지 번호를 가져옵니다.
page_no = doc.metadata.get("page_no")
# page_no가 없으면 기본 page 값을 사용합니다.
if page_no is None:
page = doc.metadata.get("page", 0)
page_no = page + 1
# 출처 정보를 저장합니다.
source = {
"file": filename,
"page": page_no
}
sources.append(source)
# --------------------------------------------------------
# 최종 결과 반환
# --------------------------------------------------------
return {
"answer": answer,
"sources": sources,
"cited": cited,
"ok": True
}실행 결과 샘플 (교안 기록. API 키가 필요한 코드는 키 없이 맞춘 예시이고, 모델의 답은 실행할 때마다 달라집니다)
===== 실행 결과 샘플 =====
예시 출력 (API 키 필요, 실행하지 않음)
빈 질문이면 answer='질문을 입력해주세요.', ok=False.
검색 결과가 없으면 answer는 '가이드에서 관련 내용을 찾지 못했습니다.', ok=True.
답이 나올 때의 문장과 페이지 번호는 기록에 없습니다.
사내 매뉴얼 로그의 top_score는 이 가이드 앱의 결과가 아니므로 여기 붙이지 않습니다.코드 해설
가이드 PDF 인덱스에서 관련 청크를 찾고, 그 청크로만 답을 만든 뒤 인용 번호를 검사합니다.
코드 앞에서 고정해 두는 말입니다.
- PPT: 미니프로젝트 부산 2박 3일 여행 가이드
- 읽는 키: question, k
- 쓰는 키: answer, sources, cited, ok
- 그래프:
- 질문 --> _search --> 청크가 있으면 _build_context --> LLM --> 인용 검사
- 청크가 없으면 MSG_NO_DOC
- 파이썬만 아는 분을 위한 말
- State는 노드들이 함께 쓰는 딕셔너리입니다. 함수는 state를 읽고, 바꿀 키만 return 합니다.
함수와 클래스로 나누어 읽습니다.
_search: 이름 그대로 한 가지 일만 합니다. 본문 주석을 같이 읽습니다._build_context: 이름 그대로 한 가지 일만 합니다. 본문 주석을 같이 읽습니다.ask: 이름 그대로 한 가지 일만 합니다. 본문 주석을 같이 읽습니다.
파일 맨 아래 실행 결과 샘플 주석은 실행되지 않습니다. 화면 출력은 다음 절에 다시 적어 두었습니다.
실습 코드 · rag_app_prj/app.py
실습 코드: 실습/src/rag_app_prj/app.py · 원본 PDF 에 같은 이름의 쪽이 없어 여기에 실었습니다.
# ==============================================================
# 보충 미니프로젝트. 코드 폴더는 rag_app_prj
# PPT: 미니프로젝트 부산 2박 3일 여행 가이드
# 역할: 콘솔에서 부산 가이드에게 질문하고 답과 참고 페이지를 출력합니다.
# 읽는 키: ask()의 answer, sources, cited
# 쓰는 키: 화면 출력
# 그래프:
# 배너: 부산 2박 3일 여행 가이드
# |
# 질문 > --> rag.ask --> 답변, 참고 자료, 인용 경고
# q / exit / quit / 종료 --> 끝
# --------------------------------------------------------------
# rag_app/app.py와 같은 루프이고 배너 문구만 여행 가이드입니다.
# input()과 API가 필요해 실행하지 않았습니다.
# ==============================================================
# ============================================================
# 콘솔에서 사용자의 질문을 반복해서 입력받고,
# 여행 가이드 RAG에 질문을 전달한 뒤 답변과 출처를 출력합니다.
# q, exit, quit, 종료를 입력하면 프로그램을 종료합니다.
# ============================================================
import warnings
from rag import ask
# 불필요한 경고 메시지를 화면에 표시하지 않습니다.
warnings.filterwarnings("ignore")
# ============================================================
# 프로그램 시작 시 보여줄 안내 문구입니다.
# ============================================================
BANNER = (
"==========================================\n"
" 부산 2박 3일 여행 가이드\n"
" 종료하려면 'q' 또는 'exit' 입력\n"
"=========================================="
)
# ============================================================
# 사용자에게 질문을 입력받고 답변을 출력합니다.
# ============================================================
def main():
# 프로그램 시작 안내문을 출력합니다.
print(BANNER)
# 사용자가 종료할 때까지 계속 질문을 받습니다.
while True:
# ----------------------------------------------------
# 질문 입력
# ----------------------------------------------------
try:
question = input("\n질문 > ").strip()
# Ctrl+C 또는 Ctrl+Z 등이 입력되면 종료합니다.
except (KeyboardInterrupt, EOFError):
print("\n종료합니다.")
break
# ----------------------------------------------------
# 종료 명령 확인
# ----------------------------------------------------
# 종료 명령이면 반복문을 끝냅니다.
if question.lower() in ("q", "exit", "quit", "종료"):
print("종료합니다.")
break
# ----------------------------------------------------
# 빈 질문 확인
# ----------------------------------------------------
# 아무것도 입력하지 않았다면 다시 질문을 받습니다.
if not question:
continue
# ----------------------------------------------------
# RAG 시스템에 질문 전달
# ----------------------------------------------------
result = ask(question)
# ----------------------------------------------------
# 답변 출력
# ----------------------------------------------------
print("\n" + "─" * 50)
print(result["answer"])
# ----------------------------------------------------
# 참고 자료 출력
# ----------------------------------------------------
sources = result["sources"]
# 검색된 출처가 있다면 출력합니다.
if sources:
print("\n참고 자료")
# 이미 출력한 출처를 저장합니다.
seen = set()
# 출처를 하나씩 확인합니다.
for source in sources:
filename = source["file"]
page = source["page"]
# 파일명과 페이지 번호를 하나의 값으로 묶습니다.
key = (filename, page)
# 아직 출력하지 않은 출처만 출력합니다.
if key not in seen:
seen.add(key)
print(
f" · {filename} {page}페이지"
)
# ----------------------------------------------------
# 인용 여부 확인
# ----------------------------------------------------
# 답변에 올바른 인용 표시가 없으면 경고합니다.
if result.get("cited") is False:
print("\n⚠ 인용 표시가 확인되지 않았습니다.")
# 한 번의 질문 처리가 끝났음을 표시합니다.
print("─" * 50)
# ============================================================
# 이 파일을 직접 실행했을 때만 main()을 실행합니다.
# ============================================================
if __name__ == "__main__":
main()실행 결과 샘플 (교안 기록. API 키가 필요한 코드는 키 없이 맞춘 예시이고, 모델의 답은 실행할 때마다 달라집니다)
===== 실행 결과 샘플 =====
예시 콘솔 (API 키 필요, 실행하지 않음)
==========================================
부산 2박 3일 여행 가이드
종료하려면 'q' 또는 'exit' 입력
==========================================
질문 > 태종대 입장료는 얼마인가요?
──────────────────────────────────────────────────
(답과 페이지는 API와 인덱스가 있어야 나옵니다. 실행하지 않았습니다.)
참고 자료
· busan_guide.pdf 와 페이지 번호
──────────────────────────────────────────────────코드 해설
콘솔에서 부산 가이드에게 질문하고 답과 참고 페이지를 출력합니다.
코드 앞에서 고정해 두는 말입니다.
- PPT: 미니프로젝트 부산 2박 3일 여행 가이드
- 읽는 키: ask()의 answer, sources, cited
- 쓰는 키: 화면 출력
- 그래프:
- 배너: 부산 2박 3일 여행 가이드
- |
- 질문 > --> rag.ask --> 답변, 참고 자료, 인용 경고
- q / exit / quit / 종료 --> 끝
함수와 클래스로 나누어 읽습니다.
main: 이름 그대로 한 가지 일만 합니다. 본문 주석을 같이 읽습니다.
파일 맨 아래 실행 결과 샘플 주석은 실행되지 않습니다. 화면 출력은 다음 절에 다시 적어 두었습니다.
실습 코드 · rag_app_prj/web.py
실습 코드: 실습/src/rag_app_prj/web.py · 원본 PDF 에 같은 이름의 쪽이 없어 여기에 실었습니다.
# ==============================================================
# 보충 미니프로젝트. 코드 폴더는 rag_app_prj
# PPT: 미니프로젝트 부산 2박 3일 여행 가이드
# 역할: 부산 가이드 Streamlit 화면. 사이드바 일정·예시 질문, 히어로 이미지, 채팅으로 rag.ask를 호출합니다.
# 읽는 키: session_state.history, 예시 버튼이 고른 질문, chat_input
# 쓰는 키: 브라우저에 대화 history 추가
# 그래프:
# 사이드바 예시 버튼 또는 하단 입력
# |
# v
# remember --> ask(question) --> history
# |
# v
# 말풍선 + 가이드 N페이지 표시 + 인용 경고
# --------------------------------------------------------------
# 시작 시 st.secrets에 OPENAI_API_KEY가 있으면 환경 변수로 복사한 뒤 rag를 import합니다.
# 로컬에 secrets가 없어도 except로 빈 문자열을 쓰고 화면은 뜨게 되어 있습니다.
# 히어로 이미지는 assets/gamcheon.jpg 를 읽어 화면에 넣습니다. 이미지 파일은 이미 복사되어 있습니다.
# 예시 버튼: 2일차 일정, 태종대 입장료, 공항에서 남포동, 환불 규정.
# 일정 카드: 1일차 남포·자갈치, 2일차 해운대·광안, 3일차 감천·태종대.
# 서버는 실행하지 않았습니다.
# ==============================================================
# base64 는 사진 파일을 글자로 바꿔 HTML 안에 바로 넣을 때 씁니다.
import os
import base64
from pathlib import Path
# Streamlit 은 파이썬 파일 하나를 웹 화면으로 띄웁니다. 버튼을 누르거나 입력할 때마다 이 파일이 위에서부터 다시 실행됩니다.
import streamlit as st
# rag.py calls load_dotenv() and then builds OpenAI clients at import time.
# On Streamlit Cloud there is no .env; copy the secret into the environment
# before that import. A local run with no secrets.toml must still start.
# secrets 파일이 없으면 오류가 나므로 try 로 감싸고, 그때는 빈 문자열로 둡니다.
try:
_openai_api_key = st.secrets["OPENAI_API_KEY"]
except Exception:
_openai_api_key = ""
if isinstance(_openai_api_key, str) and _openai_api_key.strip():
os.environ["OPENAI_API_KEY"] = _openai_api_key.strip()
# 키를 환경 변수에 넣은 뒤에 rag 를 import 해야 합니다. rag.py 는 import 되는 순간 인덱스와 LLM 을 준비하기 때문입니다.
from rag import ask
# 화면 맨 위에 깔 감천문화마을 사진입니다. 사진은 답의 근거가 아니라 화면 장식입니다.
HERO_IMAGE = Path(__file__).resolve().parent / "assets" / "gamcheon.jpg"
# 사이드바 예시 버튼: (버튼 글자, 실제로 보낼 질문) 쌍입니다.
# 마지막 "환불 규정" 은 가이드에 없는 내용이라, 거절 문장이 나오는지 보는 질문입니다.
EXAMPLES = [
("2일차 일정", "2일차에는 어디에 가나요?"),
("태종대 입장료", "태종대 입장료는 얼마인가요?"),
("공항에서 남포동", "김해공항에서 남포동까지 어떻게 가나요?"),
("환불 규정", "환불 규정은 어떻게 되나요?"),
]
# 일정 카드에 쓸 (일차, 지역, 들르는 곳) 입니다. 사이드바와 본문 카드가 같은 목록을 씁니다.
DAYS = [
("1일차", "남포 · 자갈치", "국제시장, 자갈치 저녁, 부산타워"),
("2일차", "해운대 · 광안", "동백섬, 밀면, 광안대교 야경"),
("3일차", "감천 · 태종대", "감천문화마을, 태종대, 출발"),
]
# 탭 제목, 아이콘, 넓은 화면 배치를 정합니다. 다른 st 명령보다 먼저 불러야 합니다.
st.set_page_config(
page_title="부산 2박 3일",
page_icon="🌊",
layout="wide",
initial_sidebar_state="expanded",
)
# 아래 긴 문자열은 화면 색과 모양을 정하는 CSS 입니다. 동작에는 영향이 없으니 처음 읽을 때는 건너뛰어도 됩니다.
st.markdown(
"""
<style>
.stApp {
background: linear-gradient(180deg, #e4eef2 0%, #f6f3ee 240px);
color: #1c2a33;
font-family: "Malgun Gothic", sans-serif;
}
[data-testid="stHeader"] {
background: transparent;
}
[data-testid="stSidebar"] {
background: #0e4d6c;
}
[data-testid="stSidebar"] * {
color: #f6f3ee !important;
}
[data-testid="stSidebar"] .stButton button {
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.22);
border-radius: 12px;
text-align: left;
height: auto !important;
min-height: 2.5rem;
padding: 0.65rem 0.85rem;
white-space: normal;
line-height: 1.35;
}
[data-testid="stSidebar"] .stButton button p {
white-space: normal;
text-align: left;
}
[data-testid="stSidebar"] .stButton button:hover {
border-color: #e07a3d;
background: rgba(224, 122, 61, 0.22);
}
[data-testid="stMain"],
[data-testid="stMainBlockContainer"],
[data-testid="stMain"] [data-testid="stVerticalBlock"],
[data-testid="stMain"] [data-testid="stVerticalBlockBorderWrapper"] {
overflow: visible !important;
}
[data-testid="stAppViewContainer"] {
overflow-y: auto !important;
height: 100vh;
}
[data-testid="stElementContainer"]:has(.hero-pin) {
position: sticky;
top: 0.4rem;
z-index: 40;
background: #f6f3ee;
padding-bottom: 0.75rem;
}
.hero {
border-radius: 22px 22px 0 0;
min-height: 250px;
overflow: hidden;
background-color: #d7e4ea;
background-size: cover;
background-position: center center;
box-shadow: 0 12px 28px rgba(14, 77, 108, 0.18);
}
.hero-copy {
background: #ffffff;
border-radius: 0 0 22px 22px;
padding: 0.9rem 1.2rem 1rem;
box-shadow: 0 12px 28px rgba(14, 77, 108, 0.08);
}
.hero-kicker {
letter-spacing: 0.16em;
font-size: 0.78rem;
color: #e07a3d;
margin-bottom: 0.25rem;
}
.hero-title {
font-size: 1.7rem;
font-weight: 700;
line-height: 1.2;
color: #0e4d6c;
margin-bottom: 0.25rem;
}
.hero-sub {
font-size: 0.98rem;
line-height: 1.5;
color: #3d5160;
max-width: 40rem;
}
.day-card, .welcome-card {
background: #ffffff;
border-radius: 16px;
padding: 0.95rem 1rem 0.85rem;
box-shadow: 0 8px 20px rgba(28, 42, 51, 0.06);
border-top: 4px solid #1f8a70;
min-height: 7.2rem;
}
.day-card .label, .welcome-card .label {
color: #e07a3d;
font-size: 0.82rem;
font-weight: 700;
margin-bottom: 0.2rem;
}
.day-card .name, .welcome-card .name {
color: #0e4d6c;
font-size: 1.12rem;
font-weight: 700;
margin-bottom: 0.25rem;
}
.day-card .detail, .welcome-card .detail {
color: #3d5160;
font-size: 0.92rem;
line-height: 1.45;
}
[data-testid="stChatMessage"] {
background: transparent;
margin-bottom: 0.55rem;
}
[data-testid="stChatMessage"]:has([aria-label="Chat message from user"]) {
background: #0e4d6c;
border-radius: 16px;
padding: 0.25rem 0.45rem;
}
[data-testid="stChatMessage"]:has([aria-label="Chat message from user"]) [data-testid="stChatMessageContent"] p {
color: #f6f3ee !important;
}
[data-testid="stChatMessage"]:has([aria-label="Chat message from assistant"]) {
background: #ffffff;
border-radius: 16px;
box-shadow: 0 8px 20px rgba(28, 42, 51, 0.06);
padding: 0.25rem 0.45rem;
}
.source-pill {
display: inline-block;
margin-top: 0.35rem;
background: #e7f5f1;
color: #1f8a70;
border-radius: 999px;
padding: 0.2rem 0.7rem;
font-size: 0.85rem;
}
.cite-warn {
color: #a14b24;
font-size: 0.85rem;
margin-top: 0.35rem;
}
</style>
""",
unsafe_allow_html=True,
)
# sources 의 쪽 번호를 겹치지 않게 모아 "가이드 3, 7페이지" 같은 한 줄로 만듭니다.
def source_label(sources):
# 같은 쪽에서 조각이 여러 개 나오면 쪽 번호가 겹치므로, 처음 본 것만 담습니다.
pages = []
for source in sources:
page = source["page"]
if page not in pages:
pages.append(page)
pages.sort()
page_text = ", ".join(str(page) for page in pages)
return f"가이드 {page_text}페이지"
# 질문 하나를 처리해 대화 기록에 붙입니다. 답을 기다리는 동안 "찾는 중" 표시를 띄웁니다.
def remember(question):
with st.spinner("가이드에서 찾는 중..."):
result = ask(question)
st.session_state.history.append((question, result))
# session_state 는 화면이 다시 실행돼도 지워지지 않는 저장 칸입니다. 처음 한 번만 빈 기록을 만듭니다.
if "history" not in st.session_state:
st.session_state.history = []
# with st.sidebar 안에서 그린 것은 왼쪽 사이드바에 나옵니다.
with st.sidebar:
st.markdown("### 일정")
st.caption("숙소는 남포동, 체크인 15:00")
for day, title, detail in DAYS:
st.markdown(f"**{day}** {title}")
st.caption(detail)
st.markdown("---")
st.markdown("### 예시 질문")
# 이번 실행에서 누른 예시 버튼의 질문을 담습니다. 누르지 않았으면 None 그대로입니다.
picked = None
for label, question in EXAMPLES:
# st.button 은 그 버튼을 누른 실행에서만 True 입니다. key 는 버튼마다 달라야 합니다.
if st.button(label, use_container_width=True, key=f"ex-{label}"):
picked = question
# 사진을 base64 글자로 바꿔 CSS 배경 그림으로 넣습니다. f-string 안이라 CSS 중괄호를 {{ }} 로 두 번 씁니다.
hero_bytes = base64.b64encode(HERO_IMAGE.read_bytes()).decode("ascii")
st.markdown(
f"""
<style>
.hero {{
background-image: url("data:image/jpeg;base64,{hero_bytes}");
}}
</style>
<div class="hero-pin">
<div class="hero"></div>
<div class="hero-copy">
<div class="hero-kicker">BUSAN · 2 NIGHTS 3 DAYS</div>
<div class="hero-title">부산 2박 3일 여행 가이드</div>
<div class="hero-sub">일정, 교통, 입장료, 식사를 가이드에 있는 내용만 안내합니다.</div>
</div>
</div>
""",
unsafe_allow_html=True,
)
# 본문에 일정 카드 세 장을 나란히 놓습니다. zip 으로 칸 하나에 DAYS 한 줄씩 짝지읍니다.
columns = st.columns(3)
for column, (day, title, detail) in zip(columns, DAYS):
with column:
st.markdown(
f"""
<div class="day-card">
<div class="label">{day}</div>
<div class="name">{title}</div>
<div class="detail">{detail}</div>
</div>
""",
unsafe_allow_html=True,
)
st.write("")
# 아래 입력창에 친 질문입니다. 예시 버튼을 눌렀으면 그 질문이 먼저입니다.
typed = st.chat_input("일정, 교통, 입장료, 식사를 물어보세요")
question = picked or typed
if question:
remember(question)
# 아직 대화가 없을 때만 안내 카드를 보여 줍니다.
if not st.session_state.history:
st.markdown(
"""
<div class="welcome-card">
<div class="label">가이드에게 물어보세요</div>
<div class="name">요금과 시간은 문서에 적힌 숫자만 답합니다.</div>
<div class="detail">왼쪽 예시 질문을 누르거나, 아래에 직접 입력하세요. 가이드에 없는 내용은 없다고 말합니다.</div>
</div>
""",
unsafe_allow_html=True,
)
# 지금까지의 대화를 처음부터 다시 그립니다. 화면이 매번 새로 그려지므로 기록을 모두 다시 찍어야 합니다.
for question, result in st.session_state.history:
with st.chat_message("user", avatar="🧳"):
st.write(question)
with st.chat_message("assistant", avatar="🌊"):
st.write(result["answer"])
# 출처가 있으면 쪽 번호 알약을, 인용 번호가 맞지 않으면 경고 한 줄을 붙입니다.
if result["sources"]:
st.markdown(
f'<span class="source-pill">{source_label(result["sources"])}</span>',
unsafe_allow_html=True,
)
if result.get("cited") is False:
st.markdown(
'<div class="cite-warn">인용 표시가 확인되지 않았습니다.</div>',
unsafe_allow_html=True,
)
# streamlit run web.py실행 결과 샘플 (교안 기록. API 키가 필요한 코드는 키 없이 맞춘 예시이고, 모델의 답은 실행할 때마다 달라집니다)
===== 실행 결과 샘플 =====
예시 Streamlit 화면 (API 키 필요, 실행하지 않음)
실행 명령: streamlit run web.py
탭 제목: 부산 2박 3일
사이드바: 일정, 예시 질문 버튼
입력 안내: 일정, 교통, 입장료, 식사를 물어보세요
검색 중 문구: 가이드에서 찾는 중...
대화가 없으면 환영 카드 '요금과 시간은 문서에 적힌 숫자만 답합니다.' 가 보입니다.
출처가 있으면 '가이드 N페이지' 알약이 붙습니다.
API 키와 FAISS 인덱스가 있어야 답이 생성됩니다. 실행하지 않았습니다.코드 해설
부산 가이드 Streamlit 화면. 사이드바 일정·예시 질문, 히어로 이미지, 채팅으로 rag.ask를 호출합니다.
코드 앞에서 고정해 두는 말입니다.
- PPT: 미니프로젝트 부산 2박 3일 여행 가이드
- 읽는 키: session_state.history, 예시 버튼이 고른 질문, chat_input
- 쓰는 키: 브라우저에 대화 history 추가
- 그래프:
- 사이드바 예시 버튼 또는 하단 입력
- |
- v
- remember --> ask(question) --> history
함수와 클래스로 나누어 읽습니다.
source_label: 이름 그대로 한 가지 일만 합니다. 본문 주석을 같이 읽습니다.remember: 이름 그대로 한 가지 일만 합니다. 본문 주석을 같이 읽습니다.
파일 맨 아래 실행 결과 샘플 주석은 실행되지 않습니다. 화면 출력은 다음 절에 다시 적어 두었습니다.
실습 코드 · 32/graph32.py
실습 코드: 실습/src/32/graph32.py · 원본 PDF 에 같은 이름의 쪽이 없어 여기에 실었습니다.
이 파일은 32차시 「2. 실습 ① — 통합 그래프 완성」에 주석과 함께 전체가 실려 있습니다. 여기서는 이 차시에서 보는 부분의 해설과 실행 결과만 옮깁니다.
실행 결과 샘플 (교안 기록. API 키가 필요한 코드는 키 없이 맞춘 예시이고, 모델의 답은 실행할 때마다 달라집니다)
===== 실행 결과 샘플 =====
경로: classify → retrieve → generate → verify → END
예시 출력 (API 키 필요, 실행하지 않음)
__main__ 질문 '환불은 며칠 이내에 신청하고 대표이사 이름은?'은 기록된 로그에 없습니다.
그 질문의 판정과 점수는 적지 않습니다.
기록된 질문:
안녕하세요 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코드 해설
분류, 검색, 생성, 검증, 재생성, 재작성, fallback을 한 그래프로 모은 최종 연결입니다.
코드 앞에서 고정해 두는 말입니다.
- 읽는 키: intent, retrieval_ok, fail_reason, rewrites, grade, retries, query
- 쓰는 키: retries, log, ask()는 최종 State
- 그래프:
- START --> classify
- |-- greeting|calc|scope --> END
- +-- document --> retrieve
- |-- ok --> generate --> verify
- | |-- done --> END
함수와 클래스로 나누어 읽습니다.
bump_node: 답변 재생성 횟수를 1 증가시킵니다.route_by_intent: 분류된 질문 종류에 따라 다음 경로를 결정합니다.route_after_retrieve: 검색 성공 여부에 따라 생성, 재검색, 포기를 결정합니다.route_after_verify: 검증 결과에 따라 종료, 재생성, 재검색을 결정합니다.route_after_rewrite: 새 검색어가 만들어졌으면 다시 검색합니다.build_graph: 모든 노드를 등록하고 실행 순서를 연결합니다.ask: 질문을 받아 초기 State를 만들고 그래프를 실행합니다.
파일 맨 아래 실행 결과 샘플 주석은 실행되지 않습니다. 화면 출력은 다음 절에 다시 적어 두었습니다.
실습 코드 · 32/test_coverage.py
실습 코드: 실습/src/32/test_coverage.py · 원본 PDF 에 같은 이름의 쪽이 없어 여기에 실었습니다.
이 파일은 35차시 「실습 1. 평가 데이터셋 구축 실습 따라 하기」에 주석과 함께 전체가 실려 있습니다. 여기서는 이 차시에서 보는 부분의 해설과 실행 결과만 옮깁니다.
실행 결과 샘플 (교안 기록. API 키가 필요한 코드는 키 없이 맞춘 예시이고, 모델의 답은 실행할 때마다 달라집니다)
===== 실행 결과 샘플 =====
경로: classify → retrieve → generate → verify → END
예시 출력 (API 키 필요, 실행하지 않음)
케이스와 코드에 적힌 경로 이름:
안녕하세요 -> greeting 경로
120 * 3 + 45 -> calc 경로
오늘 날씨 어때요? -> scope 경로
환불은 며칠 이내인가요? -> 정상 RAG 경로
반품하고 싶은데 언제까지? -> 재검색 경로
환불 방법과 수수료는? -> 재생성 경로
대표이사가 누구인가요? -> fallback 경로
방문 횟수와 미방문 목록은 실행하지 않아 적지 않습니다.
기록:
안녕하세요 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코드 해설
graph32.stream으로 여러 질문을 돌려 노드 방문 횟수를 세고, 한 번도 안 간 노드가 있는지 봅니다.
코드 앞에서 고정해 두는 말입니다.
- 읽는 키: question
- 쓰는 키: visited 딕셔너리와 화면의 경로 문자열
- 그래프:
- 질문마다 stream
- step의 노드 이름을 visited에 누적
- 마지막에 ALL_NODES와 비교
- 파이썬만 아는 분을 위한 말
- State는 노드들이 함께 쓰는 딕셔너리입니다. 함수는 state를 읽고, 바꿀 키만 return 합니다.
함수로 나누지 않고 위에서 아래로 실행됩니다.
CASES = [— 테스트 질문 각 질문은 서로 다른 경로를 실행하기 위해 준비합니다.ALL_NODES = [— 전체 노드 목록visited = {}— 노드별 방문 횟수 초기화for question, description in CASES:— 테스트 실행state = make_initial_state(question)— 질문으로 초기 State 생성nodes = []— 이번 질문에서 방문한 노드를 저장for step in app.stream(— 그래프를 한 단계씩 실행for node_name in step.keys():— 현재 단계에서 실행된 노드 확인nodes.append(node_name)— 실행된 노드 이름 저장visited[node_name] += 1— 해당 노드의 방문 횟수 증가print()— 질문별 실행 경로 출력print()— 노드별 방문 횟수 출력
파일 맨 아래 실행 결과 샘플 주석은 실행되지 않습니다. 화면 출력은 다음 절에 다시 적어 두었습니다.
코드 설명
PROMPTS[config.PROMPT_VER]는 config 에 적은 이름("v3")으로 프롬프트 틀을 고릅니다. 버전을 견줄 때 prompts.py 나 rag.py 를 열지 않고 config 한 줄만 바꾸면 됩니다.store.similarity_search_with_relevance_scores(question, k=k)는 조각과 점수를 함께 돌려줍니다. 그다음score >= config.MIN_SCORE로 거르므로, MIN_SCORE 가 새 문서에 비해 높으면 모든 질문이 MSG_NO_DOC 으로 끝납니다. 1-2절 재측정이 필요한 까닭입니다.if not docs:에서 돌려주는 딕셔너리는"ok": True입니다. 문서에 없어서 거절한 것은 정상 동작이고, 오류일 때만ok가 False 입니다. 여기서 많이들 헷갈리십니다.re.findall(r"\[(\d+)\]", answer)는 답변 속 [1], [2] 같은 번호를 뽑습니다. 번호가 없거나 검색한 조각 수를 넘으면cited가 False 가 되고, app.py 와 web.py 가 인용 경고를 띄웁니다.- web.py 의
question = picked or typed는 예시 버튼을 눌렀으면 그 질문을, 아니면 입력창 글을 씁니다. Streamlit 은 누를 때마다 파일 전체를 다시 실행하므로, 대화는st.session_state.history에 모아 두고 매번 처음부터 다시 그립니다.