""" 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()