RAG2026-5일35차시 · 평가 데이터셋 구축14 / 18← 이전목차다음 →MetaContents

35차시. 평가 데이터셋 구축

실습 1 — 평가 데이터셋 구축 실습 따라 하기

관련 수업 자료: 원본 PDF 35차시_평가데이터셋구축.pdf · 실습 코드 32/test_coverage.py, 32/test_regression.py

이 실습은 교안 해설(보충_테스트셋.md)의 실습 순서를 옮긴 것입니다. OpenAI 임베딩이나 채팅 모델을 부르는 파일은 .env 에 API 키가 있어야 돌아갑니다. 키가 없으면 실행 결과 샘플로 출력 모양을 확인합니다.

수업 자료와 이어지는 곳

실습 코드원본 PDF이어지는 내용
32/test_coverage.py-원본 PDF 에 짝이 되는 쪽이 없어 이 페이지 아래에 실었습니다
32/test_regression.py-원본 PDF 에 짝이 되는 쪽이 없어 이 페이지 아래에 실었습니다

실행 흐름

출력
질문표 → 유형
질문표 → 키워드
질문표 → 기대 경로
기대 경로 → 모든 노드 1회

실습 순서

notice.txt만 보고 질문 10개를 표로 만듭니다. 각 행에 intent 기대값과 키워드를 적습니다. test_coverage.py의 질문 목록과 비교해 빠진 경로가 있는지 표시합니다.

스스로 확인

  • 표현을 바꾼 질문을 왜 따로 넣는가?
  • 커버리지와 정답률이 다른 지표인 이유를 아는가?
  • 테스트 문장을 본문과 똑같이 쓰면 어떤 착시가 생기는가?

이어지는 실습 코드

실습 코드 · 32/test_coverage.py

실습 코드: 실습/src/32/test_coverage.py · 원본 PDF 에 같은 이름의 쪽이 없어 여기에 실었습니다.

Python
# ==============================================================
# 32차시. PPT 32차시_고도화ModularRAG. 코드 폴더는 src/32
# PPT: 32차시 경로 점검과 회귀 테스트
# 역할: graph32.stream으로 여러 질문을 돌려 노드 방문 횟수를 세고, 한 번도 안 간 노드가 있는지 봅니다.
# 읽는 키: question
# 쓰는 키: visited 딕셔너리와 화면의 경로 문자열
# 그래프:
#   질문마다 stream
#     step의 노드 이름을 visited에 누적
#     마지막에 ALL_NODES와 비교
# --------------------------------------------------------------
# 파이썬만 아는 분을 위한 말
# 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에 적습니다.
# ALL_NODES: classify, greeting, calc, scope, retrieve, generate, verify, bump, rewrite, fallback.
# 한 질문의 경로는 ' -> '.join(nodes)로 출력됩니다.
# 방문 횟수는 실행해야 나오므로 숫자를 만들지 않습니다.
# ==============================================================

# ============================================================
# test_coverage.py
# graph32의 여러 실행 경로를 테스트합니다.
# 모든 노드가 최소 한 번 이상 실행되는지 확인합니다.
# ============================================================

from graph32 import app
from graph_state2 import make_initial_state

# ============================================================
# 테스트 질문
# ============================================================
# 각 질문은 서로 다른 경로를 실행하기 위해 준비합니다.

CASES = [
    ("안녕하세요", "greeting 경로"),
    ("120 * 3 + 45", "calc 경로"),
    ("오늘 날씨 어때요?", "scope 경로"),
    ("환불은 며칠 이내인가요?", "정상 RAG 경로"),
    ("반품하고 싶은데 언제까지?", "재검색 경로"),
    ("환불 방법과 수수료는?", "재생성 경로"),
    ("대표이사가 누구인가요?", "fallback 경로"),
]

# ============================================================
# 전체 노드 목록
# ============================================================

ALL_NODES = [
    "classify",
    "greeting",
    "calc",
    "scope",
    "retrieve",
    "generate",
    "verify",
    "bump",
    "rewrite",
    "fallback",
]

# ============================================================
# 노드별 방문 횟수 초기화
# ============================================================

visited = {}

for node_name in ALL_NODES:
    visited[node_name] = 0

# ============================================================
# 테스트 실행
# ============================================================

for question, description in CASES:

    # 질문으로 초기 State 생성
    state = make_initial_state(question)

    # 이번 질문에서 방문한 노드를 저장
    nodes = []

    # --------------------------------------------------------
    # 그래프를 한 단계씩 실행
    # --------------------------------------------------------

    for step in app.stream(
        state,
        {"recursion_limit": 40}
    ):

        # 현재 단계에서 실행된 노드 확인
        for node_name in step.keys():

            # 실행된 노드 이름 저장
            nodes.append(node_name)

            # 해당 노드의 방문 횟수 증가
            visited[node_name] += 1

    # --------------------------------------------------------
    # 질문별 실행 경로 출력
    # --------------------------------------------------------

    print()
    print(f"[{description}]")
    print(f"질문: {question}")
    print(f"경로: {' -> '.join(nodes)}")

# ============================================================
# 노드별 방문 횟수 출력
# ============================================================

print()
print("=" * 50)
print("노드별 방문 횟수")
print("=" * 50)

for node_name in ALL_NODES:

    # 해당 노드의 방문 횟수
    count = visited[node_name]

    # 한 번 이상 실행되었는지 확인
    if count > 0:
        mark = "✓"

    else:
        mark = "✗ 미방문"

    print(
        f"{node_name:<10} "
        f"{count:>3}회  "
        f"{mark}"
    )

# ============================================================
# 미방문 노드 확인
# ============================================================

missing = []

for node_name in ALL_NODES:

    # 한 번도 실행되지 않은 노드 저장
    if visited[node_name] == 0:
        missing.append(node_name)

# ============================================================
# 최종 결과 출력
# ============================================================

print("=" * 50)

if len(missing) == 0:

    print(
        "✓ 모든 노드를 최소 1회 이상 방문했습니다."
    )

else:

    print(
        "✗ 미방문 노드:",
        ", ".join(missing)
    )

실행 결과 샘플 (교안 기록. 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() — 노드별 방문 횟수 출력

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

실습 코드 · 32/test_regression.py

실습 코드: 실습/src/32/test_regression.py · 원본 PDF 에 같은 이름의 쪽이 없어 여기에 실었습니다.

이 파일은 34차시 「실습 1. RAG 평가 개요 실습 따라 하기」에 주석과 함께 전체가 실려 있습니다. 여기서는 이 차시에서 보는 부분의 해설과 실행 결과만 옮깁니다.

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

출력
===== 실행 결과 샘플 =====
경로: classify → retrieve → generate → verify → END
예시 출력 (API 키 필요, 실행하지 않음)
일곱 질문의 정답률과 건수 집계는 기록에 없습니다.
기록과 맞는 질문만 적습니다.
환불은 며칠 이내에 신청해야 하나요? (테스트 문구는 '환불은 며칠 이내인가요?') grade=pass retries=0 rewrites=0 elapsed=6.69
  다른 실행 elapsed=2.39 n_docs=2 top_score=0.056
대표이사가 누구인가요? grade=giveup rewrites=2 elapsed=3.41 fail_reason=low_score top_score=-0.219

코드 해설

graph32.ask로 기존 질문을 다시 물어 키워드, pass 수, 재생성 수, 재검색 수를 집계합니다.

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

  • 읽는 키: answer, grade, retries, rewrites
  • 쓰는 키: 화면의 질문별 표시와 마지막 집계
  • 그래프:
  • 각 질문 --> ask --> 키워드 포함 여부
  • 문서에 없는 대표이사 질문은 안내 문장 키워드가 있으면 정답
  • 파이썬만 아는 분을 위한 말
  • State는 노드들이 함께 쓰는 딕셔너리입니다. 함수는 state를 읽고, 바꿀 키만 return 합니다.
  • return에 적지 않은 키는 이전 값이 남습니다.

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

  • check_answer: 답변에 기대한 키워드가 하나라도 있는지 확인합니다.

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

코드 설명

  • app.stream(state, {"recursion_limit": 40}) 은 그래프를 끝까지 한 번에 돌리지 않고, 노드 하나가 끝날 때마다 {노드 이름: 그 노드가 돌려준 값} 꼴의 딕셔너리를 하나씩 내줍니다. 그래서 step.keys() 를 보면 방금 실행된 노드 이름을 알 수 있습니다.
  • recursion_limit 은 그래프가 밟을 수 있는 단계 수의 상한입니다. 재검색·재생성 루프가 끝나지 않을 때 무한히 돌지 않고 오류로 멈추게 하는 안전장치입니다.
  • visited 를 ALL_NODES 로 먼저 0으로 채워 두는 까닭이 있습니다. stream 에서 나온 이름만 세면 한 번도 안 간 노드는 표에 아예 나타나지 않아서, 미방문 노드를 찾을 수 없습니다.
  • CASES 의 두 번째 칸("재검색 경로" 등)은 기대일 뿐입니다. 실제 경로는 실행해 봐야 나옵니다. 재검색을 노린 질문이 첫 검색에서 바로 통과하면 rewrite 가 미방문으로 남는데, 그때는 코드가 아니라 질문을 바꿔 넣습니다.
  • 모든 노드에 ✓ 가 붙어도 답이 맞았다는 뜻은 아닙니다. 경로 커버리지는 "테스트 질문이 모든 갈래를 한 번씩 건드렸는가"만 봅니다. 정답률은 질문표와 키워드로 따로 잽니다. 여기서 많이들 헷갈리십니다.
← 이전 절35차시 · 체크리스트와 과제다음 절 →35차시 · 퀴즈 1 — 데이터셋 구성·누락·중복 점검하기