AI_Menu_Search / scripts /16_eval_with_answer.py
Juhaha
HF Spaces 데모 배포 (Streamlit + Qdrant 임베디드, 색인 빌드타임 생성)
fbd1091
Raw
History Blame Contribute Delete
9.3 kB
"""
Step 16: 현업 정답셋 기반 검색엔진 품질 평가 (Top5)
현업이 검토한 Menusearch_100_answer.xlsx를 읽어
각 쿼리를 검색엔진에 돌리고 Top5 결과를 채워 저장.
실행:
python scripts/16_eval_with_answer.py
출력:
data/generated/Menusearch_100_answer.xlsx (Top1~5 컬럼 + 평가결과)
콘솔: Acc@1, Acc@3, Acc@5
"""
import sys
import re
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
import pandas as pd
from openpyxl import load_workbook
from openpyxl.styles import PatternFill, Font, Alignment
from core.search_engine import MenuSearchEngine
EXCEL_PATH = Path(__file__).parent.parent / "data" / "generated" / "Menusearch_100_answer.xlsx"
# HyDE 활성 여부 (True: GPT 가상 문서 임베딩, False: 기존 쿼리 임베딩)
USE_HYDE = True
# ---------------------------------------------------------------------------
# 정답 비교 유틸
# ---------------------------------------------------------------------------
def normalize_path(path: str) -> str:
"""메뉴 경로 정규화: 공백/괄호 제거, 소문자"""
if not path:
return ""
path = re.sub(r'\s*>\s*', '>', path)
path = re.sub(r'\(.*?\)', '', path)
return path.strip().lower()
def matches(pred: str, ans: str) -> bool:
"""말단 메뉴명 + 상위경로 겹침으로 정답 판단"""
if not pred or not ans:
return False
if pred == ans:
return True
pred_end = pred.split(">")[-1].strip()
ans_end = ans.split(">")[-1].strip()
if pred_end and ans_end and pred_end == ans_end:
pred_parts = pred.split(">")
ans_parts = ans.split(">")
overlap = sum(1 for p in pred_parts if p in ans_parts)
return overlap >= min(2, len(ans_parts))
return False
def evaluate(answer_cell, top_paths: list) -> tuple:
"""
Acc@1, Acc@3, Acc@5 정답 여부 반환.
정답 없는 케이스 → (None, None, None)
"""
if not answer_cell or pd.isna(answer_cell):
return None, None, None
ans_lower = str(answer_cell).lower()
# 메뉴 경로가 아닌 특수 케이스 제외
first_line = ans_lower.split("\n")[0]
if ">" not in first_line and any(
first_line.startswith(m) for m in ["hts", "계좌개설", "마이페이지"]
):
return None, None, None
valid_answers = [normalize_path(a) for a in str(answer_cell).split("\n") if a.strip()]
norm_top = [normalize_path(p) for p in top_paths if p]
def hit_at(k):
return any(matches(p, a) for p in norm_top[:k] for a in valid_answers)
return hit_at(1), hit_at(3), hit_at(5)
# ---------------------------------------------------------------------------
# 메인
# ---------------------------------------------------------------------------
def main():
mode_label = "HyDE 활성" if USE_HYDE else "일반 모드"
print(f"[16_eval] 현업 정답셋 기반 검색엔진 평가 시작 (Top5, {mode_label})")
# ── 1. 원본 Excel 로드 (쿼리/정답 컬럼만 사용) ─────────────────────────
df_src = pd.read_excel(EXCEL_PATH, sheet_name="시트1", usecols=["No", "쿼리", "정답 메뉴"])
# 이전 실행에서 추가된 요약 행 제거 (No가 숫자가 아닌 행)
df_src = df_src[pd.to_numeric(df_src["No"], errors="coerce").notna()].reset_index(drop=True)
print(f"[16_eval] {len(df_src)}개 쿼리 로드 완료")
# ── 2. 검색엔진 초기화 ──────────────────────────────────────────────────
print("[16_eval] 검색엔진 초기화 중...")
engine = MenuSearchEngine.get_instance()
print("[16_eval] 검색엔진 준비 완료\n")
# ── 3. 각 쿼리 검색 (Top5) ──────────────────────────────────────────────
rows = []
acc1_list, acc3_list, acc5_list = [], [], []
for _, row in df_src.iterrows():
query = str(row["쿼리"]).strip()
answer_raw = row["정답 메뉴"] if pd.notna(row.get("정답 메뉴")) else None
hits = engine.search(query, top_n=5, threshold=0.0, use_hyde=USE_HYDE)
top_paths = [h.get("menu_path", "") for h in hits]
# 5개 미만이면 빈 문자열로 패딩
while len(top_paths) < 5:
top_paths.append("")
top_sims = [round(h["similarity"], 4) for h in hits]
while len(top_sims) < 5:
top_sims.append(0.0)
a1, a3, a5 = evaluate(answer_raw, top_paths)
acc1_list.append(a1)
acc3_list.append(a3)
acc5_list.append(a5)
# 평가결과 라벨
if a1 is None:
label = "평가제외"
elif a1:
label = "Top1 정답"
elif a3:
label = "Top3 정답"
elif a5:
label = "Top5 정답"
else:
label = "오답"
rows.append({
"No": row["No"],
"쿼리": query,
"정답 메뉴": answer_raw if answer_raw else "",
"Top1": top_paths[0],
"Top2": top_paths[1],
"Top3": top_paths[2],
"Top4": top_paths[3],
"Top5": top_paths[4],
"평가결과": label,
"Top1 유사도": top_sims[0],
})
status = "[O]" if a1 else ("[ ]" if a1 is None else "[X]")
print(f" {status} [{int(row['No']):3d}] {query[:28]:<28} -> {top_paths[0]}")
# ── 4. 통계 계산 ────────────────────────────────────────────────────────
valid_count = sum(a is not None for a in acc1_list)
skip_count = 100 - valid_count
acc1_n = sum(a for a in acc1_list if a is not None)
acc3_n = sum(a for a in acc3_list if a is not None)
acc5_n = sum(a for a in acc5_list if a is not None)
acc1_rate = acc1_n / valid_count if valid_count else 0
acc3_rate = acc3_n / valid_count if valid_count else 0
acc5_rate = acc5_n / valid_count if valid_count else 0
print("\n" + "="*60)
print("== 평가 결과 요약 ==")
print("="*60)
print(f" 전체 쿼리 : 100개")
print(f" 평가 제외 (빈칸) : {skip_count}개 (챗봇 범위 외)")
print(f" 유효 평가 대상 : {valid_count}개")
print(f" Acc@1 : {acc1_n}/{valid_count} = {acc1_rate:.1%}")
print(f" Acc@3 : {acc3_n}/{valid_count} = {acc3_rate:.1%}")
print(f" Acc@5 : {acc5_n}/{valid_count} = {acc5_rate:.1%}")
print("="*60)
# ── 5. Excel 저장 ────────────────────────────────────────────────────────
df_out = pd.DataFrame(rows)
df_out.to_excel(EXCEL_PATH, sheet_name="시트1", index=False)
wb = load_workbook(EXCEL_PATH)
ws = wb["시트1"]
# 열 너비: A=6, B=38, C=38, D~H=32, I=14, J=12
widths = {"A": 6, "B": 38, "C": 38,
"D": 32, "E": 32, "F": 32, "G": 32, "H": 32,
"I": 14, "J": 12}
for col, w in widths.items():
ws.column_dimensions[col].width = w
# 헤더 스타일
hdr_fill = PatternFill("solid", start_color="1F4E79")
hdr_font = Font(bold=True, color="FFFFFF", name="맑은 고딕", size=10)
for cell in ws[1]:
cell.fill = hdr_fill
cell.font = hdr_font
cell.alignment = Alignment(horizontal="center", vertical="center")
# 행별 색상
fill_map = {
"Top1 정답": PatternFill("solid", start_color="C6EFCE"), # 초록
"Top3 정답": PatternFill("solid", start_color="FFEB9C"), # 노랑
"Top5 정답": PatternFill("solid", start_color="FCE4D6"), # 주황
"오답": PatternFill("solid", start_color="FFC7CE"), # 빨강
"평가제외": PatternFill("solid", start_color="F2F2F2"), # 회색
}
for row in ws.iter_rows(min_row=2, max_row=ws.max_row):
label_val = row[8].value if len(row) > 8 else "" # I열 = 평가결과
fill = fill_map.get(label_val, fill_map["평가제외"])
for cell in row:
cell.fill = fill
cell.font = Font(name="맑은 고딕", size=9)
cell.alignment = Alignment(wrap_text=True, vertical="center")
# 요약 행
ws.append([])
ws.append([
"", f"【요약】 ({mode_label})",
f"유효평가 {valid_count}개 / 제외 {skip_count}개",
f"Acc@1: {acc1_rate:.1%}",
f"Acc@3: {acc3_rate:.1%}",
f"Acc@5: {acc5_rate:.1%}",
"", "", "", "",
])
sum_fill = PatternFill("solid", start_color="D9E1F2")
sum_font = Font(bold=True, name="맑은 고딕", size=10)
for cell in ws[ws.max_row]:
cell.fill = sum_fill
cell.font = sum_font
wb.save(EXCEL_PATH)
print(f"\n[16_eval] 저장 완료: {EXCEL_PATH}")
print(" 초록=Top1정답 노랑=Top3정답 주황=Top5정답 빨강=오답 회색=평가제외")
if __name__ == "__main__":
main()