RAG2026-5일12차시 · RAG 품질 문제8 / 18← 이전목차다음 →MetaContents

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️⃣ 실습 ① — 진단 함수 만들기」과 이어집니다.

Python
# ============================================================
# 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: 이름 그대로 한 가지 일만 합니다. 본문 주석을 같이 읽습니다.

파일 맨 아래 실행 결과 샘플 주석은 실행되지 않습니다. 화면 출력은 다음 절에 다시 적어 두었습니다.

← 이전 절12차시 · 4. 증상 → 원인 진단표다음 절 →12차시 · 6. 실습 ② — 일부러 무너뜨리기