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️⃣ 실습 ④ — 개선 효과 측정」과 이어집니다.
# ==============================================================
# 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: 하나의 그래프에 모든 테스트 질문을 실행합니다.
파일 맨 아래 실행 결과 샘플 주석은 실행되지 않습니다. 화면 출력은 다음 절에 다시 적어 두었습니다.