""" 검색 품질 정량 평가 스크립트 실행: python scripts/eval_search.py python scripts/eval_search.py --top_n 5 (상위 N개 안에 정답 있으면 pass) python scripts/eval_search.py --verbose (틀린 케이스 상세 출력) 평가 지표: - Acc@1 : 1위가 정답인 비율 (가장 중요) - Acc@3 : 상위 3개 안에 정답 포함 비율 - Acc@5 : 상위 5개 안에 정답 포함 비율 - MRR : Mean Reciprocal Rank (정답이 몇 위인지 평균) - 카테고리별 성적표 """ import sys, json, argparse from pathlib import Path from collections import defaultdict sys.path.insert(0, str(Path(__file__).parent.parent)) sys.stdout.reconfigure(encoding='utf-8') from core.search_engine import MenuSearchEngine # ── 설정 ──────────────────────────────────────────────────────────────────── EVAL_PATH = Path(__file__).parent.parent / "data" / "eval_queries.json" TOP_N = 5 # 검색 시 몇 개 가져올지 def run_eval(verbose: bool = False, top_n_check: int = 1): queries = json.loads(EVAL_PATH.read_text(encoding="utf-8")) engine = MenuSearchEngine.get_instance() results = [] cat_stats = defaultdict(lambda: {"total": 0, "hit1": 0, "hit3": 0, "hit5": 0}) print(f"\n{'='*70}") print(f"{'쿼리':<35} {'정답':^12} {'1위':^12} {'순위':^4} {'결과'}") print(f"{'='*70}") for item in queries: qid = item["id"] query = item["query"] exp_id = item["expected_id"] exp_name = item["expected_name"] category = item["category"] res = engine.search(query, top_n=TOP_N, use_reranker=False, use_hyde=True) # 정답 순위 찾기 rank = None for i, r in enumerate(res, 1): if r["menu_id"] == exp_id: rank = i break hit1 = rank == 1 hit3 = rank is not None and rank <= 3 hit5 = rank is not None and rank <= 5 top1_name = res[0]["menu_name"] if res else "없음" rank_str = f"{rank}위" if rank else "miss" ok_mark = "✅" if hit1 else ("🔶" if hit3 else ("🔸" if hit5 else "❌")) # 전체 출력 또는 틀린 것만 출력 if verbose or not hit1: print( f"{qid:>3}. {query:<33} {exp_name:^12} {top1_name:^12} " f"{rank_str:^6} {ok_mark}" ) results.append({ "id": qid, "query": query, "exp_id": exp_id, "rank": rank, "hit1": hit1, "hit3": hit3, "hit5": hit5, "top1": top1_name, "category": category, }) cat = category.split("-")[0] cat_stats[cat]["total"] += 1 if hit1: cat_stats[cat]["hit1"] += 1 if hit3: cat_stats[cat]["hit3"] += 1 if hit5: cat_stats[cat]["hit5"] += 1 # ── 전체 지표 ── n = len(results) acc1 = sum(r["hit1"] for r in results) / n * 100 acc3 = sum(r["hit3"] for r in results) / n * 100 acc5 = sum(r["hit5"] for r in results) / n * 100 mrr_sum = sum(1 / r["rank"] for r in results if r["rank"]) mrr = mrr_sum / n * 100 print(f"\n{'='*70}") print(f" 총 {n}개 쿼리") print(f" Acc@1 = {acc1:5.1f}% ({sum(r['hit1'] for r in results)}/{n})") print(f" Acc@3 = {acc3:5.1f}% ({sum(r['hit3'] for r in results)}/{n})") print(f" Acc@5 = {acc5:5.1f}% ({sum(r['hit5'] for r in results)}/{n})") print(f" MRR = {mrr:5.1f}%") # ── 카테고리별 ── print(f"\n{'카테고리':<18} {'Acc@1':>7} {'Acc@3':>7} {'Acc@5':>7} {'건수':>5}") print("-" * 50) for cat, s in sorted(cat_stats.items()): t = s["total"] print( f" {cat:<16} " f"{s['hit1']/t*100:5.0f}% " f"{s['hit3']/t*100:5.0f}% " f"{s['hit5']/t*100:5.0f}% " f"{t:4}건" ) # ── 틀린 케이스 요약 (verbose 아닐 때) ── if not verbose: missed = [r for r in results if not r["hit1"]] if missed: print(f"\n❌ 1위 불일치 {len(missed)}건:") for r in missed: print(f" {r['id']:>3}. {r['query'][:30]:<30} → 1위:{r['top1']} (정답:{r['exp_id']} {r['rank']}위)") return acc1, acc3, acc5, mrr if __name__ == "__main__": parser = argparse.ArgumentParser() parser.add_argument("--verbose", action="store_true", help="전체 케이스 출력") args = parser.parse_args() run_eval(verbose=args.verbose)