Spaces:
Running
Running
| """ | |
| 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() | |