RAG2026-5일27차시 · 기본 Modular RAG 구현10 / 18← 이전목차다음 →MetaContents

27차시. 기본 Modular RAG 구현

7. 실습 ④ — 개선 효과 측정

출처: 원본 PDF 27차시_기본ModularRAG구현.pdf 15~16쪽 · 쪽 그림 15쪽 16쪽

22차시 버전과 비교해 검증이 실제로 도움이 됐는지 확인합니다.

📝 src/compare_v22_v27.py

출력
import graph_v22          # 22차시 버전 (백업해둔 것)
import graph              # 27차시 버전
TESTS = [ ... 12차시 질문 20개 ... ]
def evaluate(mod, name):
    hit, halluc = 0, 0
    for t in TESTS:
        r = mod.ask(t["q"])
        ans = r["answer"]
        # 정답 키워드 중 하나라도 들어있으면 정답
        ok = False
        for k in t["keys"]:
            if k in ans:
                ok = True
                break
        if ok:
            hit += 1
        # 문서에 없는 질문인데 구체적 답을 했으면 환각 의심
        if t.get("no_answer") and "확인할 수 없" not in ans \
           and "찾지 못" not in ans:
            halluc += 1
    print(f"[{name}] 정답 {hit}/{len(TESTS)} "
          f"({hit/len(TESTS)*100:.0f}%) | 환각 의심 {halluc}건")
evaluate(graph_v22, "22차시")
evaluate(graph,     "27차시")

실행 결과 예시
─────────────────────────────────────────────
─────
[22차시] 정답 18/20 (90%) | 환각 의심 2건
[27차시] 정답 19/20 (95%) | 환각 의심 0건
지표22차시27차시해석
정답률90%95%재생성으로 일부 개선
환각 의심2건0건검증이 잡아냄 (핵심 성과)
평균 응답2.1초4.3초검증·재시도로 느려짐
평균 비용1.0배1.8배LLM 호출 증가

⚠️ 성능만 좋아진 게 아닙니다
정답률은 5%p 올랐지만
속도는 2배 느려지고 비용은 1.8배가 됐습니다.
그래도 도입할 가치가 있을까요?
환각 2건 → 0건이 답입니다.
정답률 5%p보다 거짓말을 하지 않는 것이
실무에서 훨씬 중요한 경우가 많습니다.
반대로 속도가 생명인 서비스라면
검증을 빼는 선택도 합리적입니다. 용도에 따라 판단하세요.

실습 코드 · 27/compare_v22_v27.py

실습 코드: 실습/src/27/compare_v22_v27.py · 위의 원본 PDF 27차시 15~16쪽 「7️⃣ 실습 ④ — 개선 효과 측정」과 이어집니다.

Python
# ==============================================================
# 27차시. PPT 27차시_기본ModularRAG구현. 코드 폴더는 src/27
# PPT: 27차시 재생성 루프
# 역할: 22차시 그래프와 27차시 그래프에 같은 질문을 넣어 키워드 정답과 환각 의심 건수를 비교합니다.
# 읽는 키: 각 ask()가 돌려주는 answer, grade, retries
# 쓰는 키: 화면 출력만
# 그래프:
#   각 질문
#     |-- graph22.ask
#     +-- graph27.ask --> 키워드 포함 여부, 문서 밖 질문이면 모른다는 안내인지
# --------------------------------------------------------------
# 파이썬만 아는 분을 위한 말
# 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에 적습니다.
# 문서에 있는 질문은 keys 단어가 답에 있으면 정답으로 셉니다.
# no_answer 질문은 '확인할 수 없', '찾지 못' 같은 안내가 있어야 정답입니다.
# 안내 없이 구체적 답을 하면 hallucination_count가 늘어납니다.
# 정답률과 환각 건수는 실행 결과라서, 기록에 없는 숫자는 적지 않습니다.
# ==============================================================

# 이 파일은 22차시 그래프와 27차시 그래프의 답변 품질을 비교합니다.
# 정답 키워드 포함 여부와 문서에 없는 질문에 대한 환각 여부를 확인합니다.

import os
import sys

# ============================================================
# 필요한 폴더 등록
# ============================================================

# 현재 파일이 있는 폴더의 경로입니다.
BASE_DIR = os.path.dirname(__file__)

# 각 차시 폴더를 Python 모듈 검색 경로에 추가합니다.
sys.path.insert(0, os.path.join(BASE_DIR, "..", "18"))
sys.path.insert(0, os.path.join(BASE_DIR, "..", "22"))
sys.path.insert(0, os.path.join(BASE_DIR, "..", "24"))
sys.path.insert(0, os.path.join(BASE_DIR, "..", "25"))
sys.path.insert(0, os.path.join(BASE_DIR, "..", "26"))

# 비교할 두 그래프를 가져옵니다.
import graph22
import graph27

# ============================================================
# 테스트 질문
# ============================================================

TESTS = [
       {
        "q": "환불 방법을 알려주세요.",
        "keys": ["환불"],
    },
    {
        "q": "반품은 언제까지 가능한가요?",
        "keys": ["7일", "반품"],
    },
    {
        "q": "상품을 교환할 수 있나요?",
        "keys": ["교환"],
    },
    {
        "q": "환불 방법과 대표이사를 알려주세요",
        "keys": ["환불"],
    },
    

    # 문서에 없는 질문입니다.
    {
        "q": "대표이사는 누구인가요?",
        "no_answer": True,
    },
    {
        "q": "회사 창립일은 언제인가요?",
        "no_answer": True,
    },

]

# ============================================================
# 답변할 근거가 없다는 내용인지 확인
# ============================================================

def is_no_answer(answer):
    """답변이 '알 수 없다'는 안내인지 확인합니다."""

    # 근거가 없을 때 사용할 수 있는 표현들입니다.
    no_answer_words = [
        "확인할 수 없",
        "찾지 못",
        "관련 자료가 없",
        "알 수 없",
        "제공된 자료",
    ]

    # 안내 문구가 하나라도 답변에 있으면 True를 반환합니다.
    for word in no_answer_words:
        if word in answer:
            return True

    # 안내 문구가 없으면 False를 반환합니다.
    return False

# ============================================================
# 정답 키워드 포함 여부 확인
# ============================================================

def contains_answer_keyword(answer, test):
    """답변에 정답 키워드가 포함되어 있는지 확인합니다."""

    # 답변할 근거가 없다는 안내가 나오면 정답으로 보지 않습니다.
    if is_no_answer(answer):
        return False

    # 테스트 질문에 등록된 정답 키워드를 하나씩 확인합니다.
    for key in test.get("keys", []):

        # 키워드가 답변에 있으면 정답으로 판단합니다.
        if key in answer:
            return True

    # 어떤 키워드도 없으면 오답으로 판단합니다.
    return False

# ============================================================
# 하나의 그래프 평가
# ============================================================

def evaluate(graph_module, graph_name):
    """하나의 그래프에 모든 테스트 질문을 실행합니다."""

    # 정답으로 판단된 질문 수입니다.
    hit_count = 0

    # 근거 없이 답변한 것으로 의심되는 질문 수입니다.
    hallucination_count = 0

    # 그래프 이름을 먼저 출력합니다.
    print()
    print("=" * 50)
    print(f"{graph_name} 평가 시작")
    print("=" * 50)

    # 모든 테스트 질문을 하나씩 실행합니다.
    for test in TESTS:

        # 현재 질문을 그래프에 전달합니다.
        result = graph_module.ask(test["q"])

        # 결과에서 답변을 가져옵니다.
        answer = result.get("answer", "")

        # 현재 질문이 문서에 없는 질문인지 확인합니다.
        no_answer_question = test.get("no_answer", False)

        # 현재 질문의 정답 여부를 저장합니다.
        is_correct = False

        # --------------------------------------------
        # 문서에 없는 질문 처리
        # --------------------------------------------

        if no_answer_question:

            # 모른다고 안내하면 올바른 대응입니다.
            if is_no_answer(answer):
                is_correct = True

            # 모른다고 하지 않고 구체적인 답변을 하면 환각 의심입니다.
            else:
                hallucination_count += 1

        # --------------------------------------------
        # 문서에 있는 질문 처리
        # --------------------------------------------

        else:

            # 정답 키워드가 포함되었는지 확인합니다.
            is_correct = contains_answer_keyword(answer, test)

        # 정답이면 전체 정답 수를 증가시킵니다.
        if is_correct:
            hit_count += 1

        # 질문별 실행 결과를 출력합니다.
        print()
        print("질문:", test["q"])
        print("답변:", answer[:100])
        print("판정:", result.get("grade", ""))
        print("재시도:", result.get("retries", 0))
        print("정답 여부:", is_correct)

    # 전체 질문 수를 계산합니다.
    total_count = len(TESTS)

    # 정답률을 계산합니다.
    accuracy = hit_count / total_count * 100

    # 최종 평가 결과를 출력합니다.
    print()
    print("=" * 50)
    print(f"[{graph_name}]")
    print(f"정답: {hit_count}/{total_count}")
    print(f"정답률: {accuracy:.0f}%")
    print(f"환각 의심: {hallucination_count}건")
    print("=" * 50)

# ============================================================
# 프로그램 실행
# ============================================================

if __name__ == "__main__":

    # 22차시 검증 전 그래프를 평가합니다.
    evaluate(graph22, "22차시")

    # 27차시 검증 및 재생성 그래프를 평가합니다.
    evaluate(graph27, "27차시")

실행 결과 샘플 (교안 기록. API 키가 필요한 코드는 키 없이 맞춘 예시이고, 모델의 답은 실행할 때마다 달라집니다)

출력
===== 실행 결과 샘플 =====
예시 출력 (API 키 필요, 실행하지 않음)
여섯 질문을 두 그래프에 넣도록 되어 있습니다.
정답률, 환각 건수, 답 문장은 기록에 없습니다.
목록 안의 대표이사 질문과 맞닿은 기록만 적습니다.
대표이사가 누구인가요? intent=document grade=giveup rewrites=2 elapsed=3.41 fail_reason=low_score top_score=-0.219
환불은 며칠 이내에 신청해야 하나요? grade=pass retries=0 rewrites=0 elapsed=6.69
  다른 실행 elapsed=2.39 n_docs=2 top_score=0.056

코드 해설

22차시 그래프와 27차시 그래프에 같은 질문을 넣어 키워드 정답과 환각 의심 건수를 비교합니다.

코드 앞에서 고정해 두는 말입니다.

  • 읽는 키: 각 ask()가 돌려주는 answer, grade, retries
  • 쓰는 키: 화면 출력만
  • 그래프:
  • 각 질문
  • |-- graph22.ask
  • +-- graph27.ask --> 키워드 포함 여부, 문서 밖 질문이면 모른다는 안내인지
  • 파이썬만 아는 분을 위한 말
  • State는 노드들이 함께 쓰는 딕셔너리입니다. 함수는 state를 읽고, 바꿀 키만 return 합니다.

함수와 클래스로 나누어 읽습니다.

  • is_no_answer: 답변이 '알 수 없다'는 안내인지 확인합니다.
  • contains_answer_keyword: 답변에 정답 키워드가 포함되어 있는지 확인합니다.
  • evaluate: 하나의 그래프에 모든 테스트 질문을 실행합니다.

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

← 이전 절27차시 · 6. 실습 ③ — 루프가 도는 것 확인다음 절 →27차시 · 8. 자주 겪는 문제