12차시. RAG 품질 문제
5. 실습 ① — 진단 함수 만들기
출처: 원본 PDF 12차시_RAG디버깅.pdf 9~10쪽 · 쪽 그림 9쪽 10쪽
답변과 검색 결과를 함께 보여주는 도구를 만듭니다.
📝 src/diagnose.py
from indexer import get_store
from retriever import build_context, MIN_SCORE
from langchain_openai import ChatOpenAI
from dotenv import load_dotenv
load_dotenv()
store = get_store()
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
def diagnose(question, k=3):
print("=" * 60)
print("Q:", question)
pairs = store.similarity_search_with_relevance_scores(
question, k=k)
# ── 검색 단계 진단 ──
if not pairs:
print("→ 【검색 실패】 결과가 0건입니다")
return
# 기준(MIN_SCORE)을 넘는 것만 골라 담기
passed = []
for d, s in pairs:
if s >= MIN_SCORE:
passed.append((d, s))
print(f"검색 {len(pairs)}개 / 기준 통과 {len(passed)}개")
print("-" * 60)
for i, (d, s) in enumerate(pairs, 1):
mark = "✓" if s >= MIN_SCORE else "✗"
text = d.page_content[:70].replace("\n", " ")
print(f"{mark} [{i}] {s:.3f} p.{d.metadata['page_no']} {text}...")
if not passed:
print("\n→ 【검색 실패】 기준을 넘는 조각이 없습니다")
return
# ── 생성 단계 ──
docs = []
for d, s in passed:
docs.append(d)
prompt = ("아래 자료만 근거로 답하세요.\n"
"자료에 없으면 '자료에서 확인할 수 없습니다'라고 답하세요.\n\n"
f"[자료]\n{build_context(docs)}\n\n[질문] {question}")
answer = llm.invoke(prompt).content
print("\nA:", answer)
print("\n👉 판단: 위 조각들 안에 정답이 있었는가?")
print(" 있는데 답이 틀렸다면 → 생성 문제 (13차시)")
print(" 없다면 → 검색 문제 (7·11차시)")
print("=" * 60)실습 코드 · 12/diagnose.py
실습 코드: 실습/src/12/diagnose.py · 위의 원본 PDF 12차시 9~10쪽 「5️⃣ 실습 ① — 진단 함수 만들기」과 이어집니다.
# ============================================================
# 12차시 | PPT: 12차시_RAG품질문제.pptx
# 역할: 질문이 검색에서 막히는지, 답 생성에서 막히는지 나눠 보는 진단
# 입력: FAISS 인덱스, 유형별 질문 8개
# 출력: 질문, 검색 개수, 조각별 통과 표시와 점수, 생성된 답, 판단 안내
# 흐름:
# 질문 -> similarity_search_with_relevance_scores
# -> MIN_SCORE 이상만 남김
# -> build_context -> prompt -> ChatOpenAI -> print
# -> input()으로 다음 질문 대기
# ============================================================
# retriever 쪽 검색: 질문과 가까운 chunk(문서 조각)를 찾는 단계
# embedding: 질문을 숫자 목록(vector)으로 바꾸는 과정. 검색 때 네트워크를 씁니다.
# FAISS: 그 벡터를 저장해 둔 인덱스
# prompt: 통과한 조각만 모아 모델에게 주는 지시문
# metadata: page_no처럼 조각에 붙은 쪽 번호
import os
import sys
from dotenv import load_dotenv
from langchain_openai import ChatOpenAI
# 10차시(indexer)와 11차시(retriever) 폴더를 모듈 검색 경로에 넣어 두 파일을 불러옵니다.
CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
CH10_DIR = os.path.join(CURRENT_DIR, "..", "10")
CH11_DIR = os.path.join(CURRENT_DIR, "..", "11")
sys.path.insert(0, CH10_DIR)
sys.path.insert(0, CH11_DIR)
from indexer import get_store
# MIN_SCORE는 11차시 retriever.py에 정해 둔 점수 기준입니다.
from retriever import build_context, MIN_SCORE
# .env의 OPENAI_API_KEY를 환경 변수로 읽습니다.
load_dotenv()
# 저장해 둔 FAISS 인덱스를 엽니다.
store = get_store()
# temperature=0으로 두면 같은 질문에 답이 덜 흔들립니다. 진단할 때는 이 값이 고정돼야 비교가 됩니다.
llm = ChatOpenAI(
model="gpt-4o-mini",
temperature=0
)
def diagnose(question, k=3):
# 질문 하나마다 구분선, 검색 점수, 답, 원인 안내를 출력합니다.
print("=" * 60)
print("Q:", question)
# (문서, 점수) 쌍을 k개 받습니다. 기준으로 거르기 전의 전체 검색 결과입니다.
pairs = store.similarity_search_with_relevance_scores(
question,
k=k
)
# 검색 결과가 하나도 없으면 생성 단계로 가지 않습니다.
if not pairs:
print("→ 【검색 실패】 결과가 0건입니다")
return
# 기준 점수 이상인 조각만 따로 모읍니다.
passed = []
for document, score in pairs:
if score >= MIN_SCORE:
passed.append((document, score))
# 검색된 개수와 통과한 개수를 나란히 보여 줘야 어디서 막혔는지 보입니다.
print(
f"검색 {len(pairs)}개 / "
f"기준 통과 {len(passed)}개"
)
print("-" * 60)
# 통과하지 못한 조각도 함께 찍습니다. 정답 조각이 기준 아래에 있었는지 눈으로 확인하려는 것입니다.
for index, (document, score) in enumerate(pairs, start=1):
# 기준 이상이면 체크, 아니면 엑스입니다.
if score >= MIN_SCORE:
mark = "✓"
else:
mark = "✗"
# 본문 앞 70자만 한 줄로 보여 줍니다.
text = document.page_content[:70]
text = text.replace("\n", " ")
page_no = document.metadata["page_no"]
print(
f"{mark} [{index}] "
f"{score:.3f} "
f"p.{page_no} "
f"{text}..."
)
# 점수는 빼고 문서만 남겨 build_context에 넘깁니다.
docs = []
for document, score in passed:
docs.append(document)
# 통과한 조각만 자료로 만듭니다. 없으면 빈 자료로 답을 요청합니다.
context = build_context(docs)
# 자료와 질문을 넣은 지시문입니다. 자료에 없으면 모른다고 답하라는 규칙이 들어 있습니다.
prompt = (
"아래 자료만 근거로 답하세요.\n"
"자료에 없으면 "
"'자료에서 확인할 수 없습니다'라고 답하세요.\n\n"
f"[자료]\n{context}\n\n"
f"[질문] {question}"
)
# 모델에게 보내 답을 받습니다. 답 문장은 .content에 들어 있습니다.
response = llm.invoke(prompt)
answer = response.content
print("\nA:", answer)
# 마지막 판단은 사람이 합니다. 위 조각과 답을 견주어 검색 문제인지 생성 문제인지 고릅니다.
print("\n 판단: 위 조각들 안에 정답이 있었는가?")
print(" 있는데 답이 틀렸다면 → 생성 문제 (13차시)")
print(" 없다면 → 검색 문제 (7·11차시)")
print("=" * 60)
if __name__ == "__main__":
# diagnose("환불은 몇일인가요?")
# 네 유형의 공격 질문입니다. 유형 이름이 키, 질문 목록이 값입니다.
TESTS = {
"① 정상 (문서에 명확히 있음)": [
"환불은 며칠 이내에 신청해야 하나요?",
"고객센터 운영 시간은 언제인가요?",
],
"② 표현 변경 (같은 뜻, 다른 단어)": [
"반품하고 싶은데 언제까지 가능해요?",
"상담원이랑 통화되는 시간 알려주세요",
],
"③ 없는 내용 (문서에 없음)": [
"대표이사 성함이 어떻게 되나요?",
"작년 매출이 얼마인가요?",
],
"④ 복합 질문 (여러 조항에 걸침)": [
"환불과 교환의 조건 차이를 설명해주세요",
"배송비는 어떤 경우에 누가 부담하나요?",
],
}
# 유형별로 질문을 하나씩 진단합니다.
for group, questions in TESTS.items():
# 현재 테스트 유형을 출력합니다.
print(f"\n\n########## {group} ##########")
# 해당 그룹의 질문을 하나씩 실행합니다.
for question in questions:
diagnose(question)
# 결과를 확인한 후 Enter를 누르면 다음 질문으로 넘어갑니다.
input("\n[Enter 키로 다음 질문 진행]")실행 결과 샘플 (교안 기록. API 키가 필요한 코드는 키 없이 맞춘 예시이고, 모델의 답은 실행할 때마다 달라집니다)
===== 실행 결과 샘플 =====
실행: cd 실습/src/12
python diagnose.py
예시 출력 (API 키 필요, 실행하지 않음)
3쪽 로딩 완료 (총 1529 자)
########## ① 정상 (문서에 명확히 있음) ##########
============================================================
Q: 환불은 며칠 이내에 신청해야 하나요?
검색 3개 / 기준 통과 (유사도 미기록)
------------------------------------------------------------
(점수 줄 형식) ✓ [1] (유사도 미기록) p.2 상품 수령 후 7 일 이내에 환불을 신청하실 수 있습니다....
A: 상품 수령 후 7일 이내에 환불을 신청하실 수 있습니다.
판단: 위 조각들 안에 정답이 있었는가?
있는데 답이 틀렸다면 → 생성 문제 (13차시)
없다면 → 검색 문제 (7·11차시)
============================================================
[Enter 키로 다음 질문 진행]
============================================================
Q: 고객센터 운영 시간은 언제인가요?
검색 3개 / 기준 통과 (유사도 미기록)
------------------------------------------------------------
(점수 줄 형식) ✓ [1] (유사도 미기록) p.3 운영 시간은 평일 09:00 ~ 18:00 입니다....
A: 운영 시간은 평일 09:00 ~ 18:00 입니다.
판단: 위 조각들 안에 정답이 있었는가?
있는데 답이 틀렸다면 → 생성 문제 (13차시)
없다면 → 검색 문제 (7·11차시)
============================================================
[Enter 키로 다음 질문 진행]
########## ③ 없는 내용 (문서에 없음) ##########
============================================================
Q: 대표이사 성함이 어떻게 되나요?
검색 3개 / 기준 통과 (유사도 미기록)
------------------------------------------------------------
A: 자료에서 확인할 수 없습니다
판단: 위 조각들 안에 정답이 있었는가?
있는데 답이 틀렸다면 → 생성 문제 (13차시)
없다면 → 검색 문제 (7·11차시)
============================================================
(나머지 질문도 같은 형식입니다. 유사도 점수는 기록되지 않아 숫자를 넣지 않았습니다.
환불 7일, 교환 30일, 운영 09:00~18:00은 manual.pdf와 notice.txt에 있는 문장입니다.
대표이사 이름과 매출은 문서에 없습니다. Enter 입력 때문에 질문 사이에 실행이 멈춥니다.)코드 해설
질문이 검색에서 막히는지, 답 생성에서 막히는지 나눠 보는 진단입니다. 입력은 FAISS 인덱스, 유형별 질문 8개이고, 출력은 질문, 검색 개수, 조각별 통과 표시와 점수, 생성된 답, 판단 안내입니다.
주석에 적힌 흐름입니다.
질문 -> similarity_search_with_relevance_scores
-> MIN_SCORE 이상만 남김
-> build_context -> prompt -> ChatOpenAI -> print
-> input()으로 다음 질문 대기코드 앞에서 고정해 두는 말입니다.
- retriever 쪽 검색: 질문과 가까운 chunk(문서 조각)를 찾는 단계
- embedding: 질문을 숫자 목록(vector)으로 바꾸는 과정. 검색 때 네트워크를 씁니다.
- FAISS: 그 벡터를 저장해 둔 인덱스
- prompt: 통과한 조각만 모아 모델에게 주는 지시문
- metadata: page_no처럼 조각에 붙은 쪽 번호
함수와 클래스로 나누어 읽습니다.
diagnose: 이름 그대로 한 가지 일만 합니다. 본문 주석을 같이 읽습니다.
파일 맨 아래 실행 결과 샘플 주석은 실행되지 않습니다. 화면 출력은 다음 절에 다시 적어 두었습니다.