diff --git a/tests/search_eval/queries.yaml b/tests/search_eval/queries.yaml new file mode 100644 index 0000000..9dab27a --- /dev/null +++ b/tests/search_eval/queries.yaml @@ -0,0 +1,257 @@ +# Document Server 검색 평가셋 v0.1 +# +# Phase 0.2 산출물 — 정량 지표(Recall@10, MRR@10, NDCG@10) 측정용. +# 각 쿼리는 실제 코퍼스(2026-04-07 시점, 753 documents)에서 추출한 +# 정답 doc_id와 함께 정의된다. +# +# 메타데이터: +# - intent : semantic_search | fact_lookup | filter_browse (Phase 2 QueryAnalyzer 분기 기준) +# - domain_hint : document | news | mixed (Phase 1 도메인 분기 기준) +# - category : 쿼리 유형 (eval 결과 그루핑용) +# - relevant_ids : 정답 doc_id 리스트 (eval에서 recall/ndcg 계산) +# - top3_ids : 반드시 top-3 안에 들어와야 하는 강한 정답 (선택) +# - notes : 의도/배경 (사람용) +# +# 주의: +# - 정답은 "현재 코퍼스에 실제 존재하는 문서"만 기재. 코퍼스가 바뀌면 갱신 필요. +# - relevant_ids가 빈 리스트인 쿼리는 "결과 없어야 정상" 또는 "low confidence가 정상"인 케이스. + +version: "0.1" +created_at: "2026-04-07" +corpus_size: 753 +notes: | + Phase 0.2 초기 평가셋. 22개 쿼리, 6개 카테고리. + Phase 1 reranker 통합 후 NDCG@10 비교 baseline으로 사용. + search_failure_logs(Phase 0.3)에서 자동 수집된 쿼리로 점진 확장 예정. + +queries: + # ───────────────────────────────────────────────────────── + # 1. 정확 키워드 검색 (fact_lookup, document) + # ───────────────────────────────────────────────────────── + - id: kw_001 + query: "산업안전보건법 제6장" + category: exact_keyword + intent: fact_lookup + domain_hint: document + relevant_ids: [3856, 3868, 3879] + top3_ids: [3856] + notes: | + "유해·위험 기계 등에 대한 조치"가 들어있는 6장. + Act(3856), Decree(3868), Rule(3879) 모두 정답이지만 본법(Act)이 최우선. + + - id: kw_002 + query: "중대재해 처벌 등에 관한 법률 제2장 중대산업재해" + category: exact_keyword + intent: fact_lookup + domain_hint: document + relevant_ids: [3917, 3921] + top3_ids: [3917] + notes: 본법 제2장(3917)이 정답. 시행령 동일 장(3921)도 허용. + + - id: kw_003 + query: "화학물질관리법 유해화학물질 영업자" + category: exact_keyword + intent: fact_lookup + domain_hint: document + relevant_ids: [3981] + top3_ids: [3981] + notes: 화학물질관리법 제4장 = 유해화학물질 영업자. + + - id: kw_004 + query: "근로기준법 안전과 보건" + category: exact_keyword + intent: fact_lookup + domain_hint: document + relevant_ids: [4041] + top3_ids: [4041] + notes: 근로기준법 제6장 = 안전과 보건. + + - id: kw_005 + query: "산업안전보건기준에 관한 규칙 보호구" + category: exact_keyword + intent: fact_lookup + domain_hint: document + relevant_ids: [3888] + top3_ids: [3888] + notes: 산업안전보건기준 규칙 제4장 = 보호구. + + # ───────────────────────────────────────────────────────── + # 2. 한국어 자연어 질의 (semantic_search, document) + # ───────────────────────────────────────────────────────── + - id: nl_001 + query: "기계로 인한 산업재해 관련 법령" + category: natural_language_ko + intent: semantic_search + domain_hint: document + relevant_ids: [3856, 3868, 3879, 3854] + top3_ids: [3856] + notes: | + 플랜의 대표 예시 쿼리. 기계 안전 = 산안법 6장(3856). + 4장 유해·위험 방지 조치(3854)도 의미상 관련. + + - id: nl_002 + query: "사업주가 도급을 줄 때 산업재해를 예방하기 위해 해야 할 일" + category: natural_language_ko + intent: semantic_search + domain_hint: document + relevant_ids: [3855, 3867, 3878] + top3_ids: [3855] + notes: 산안법 제5장 도급 시 산업재해 예방. + + - id: nl_003 + query: "유해화학물질을 다루는 회사가 지켜야 할 안전 의무" + category: natural_language_ko + intent: semantic_search + domain_hint: document + relevant_ids: [3980, 3981, 3982] + notes: 화관법 제3-5장(유해화학물질 관리/영업자/사고 대응). + + - id: nl_004 + query: "중대재해가 발생했을 때 경영책임자가 처벌받는 기준" + category: natural_language_ko + intent: semantic_search + domain_hint: document + relevant_ids: [3916, 3917, 3920, 3921] + top3_ids: [3917] + notes: 중대재해처벌법 본법+시행령 제1-2장. + + - id: nl_005 + query: "안전보건교육은 누가 받아야 하고 어떤 내용을 다루는가" + category: natural_language_ko + intent: semantic_search + domain_hint: document + relevant_ids: [3853, 3865] + top3_ids: [3853] + notes: 산안법 제3장 안전보건교육 + 시행령 제3장. + + # ───────────────────────────────────────────────────────── + # 3. 한국어 → 영어 crosslingual (semantic_search, document) + # ───────────────────────────────────────────────────────── + - id: cl_001 + query: "기계 안전 가드 설계 원리" + category: crosslingual_ko_en + intent: semantic_search + domain_hint: document + relevant_ids: [3770, 3856] + top3_ids: [3770] + notes: | + Industrial Safety and Health Management(7-ED) Ch15 Machine Guarding(3770)이 + 한국어 쿼리로 검색되어야 함. 한국 산안법 6장(3856)도 관련. + + - id: cl_002 + query: "산업 안전 입문서" + category: crosslingual_ko_en + intent: semantic_search + domain_hint: document + relevant_ids: [3755, 3775, 3776, 3777] + notes: | + Safety and Health for Engineers / Industrial Safety and Health Management + 영문 교재 입문 챕터들이 한국어 쿼리로 검색되어야 함. + + - id: cl_003 + query: "전기 안전 위험" + category: crosslingual_ko_en + intent: semantic_search + domain_hint: document + relevant_ids: [3772, 3790] + notes: | + Electrical Hazards(3772), Electrical Safety(3790) 영문 챕터. + 한국어 안전기준 규칙 중 전기 관련 장도 있을 수 있음(보수적으로 영문만 정답). + + # ───────────────────────────────────────────────────────── + # 4. 뉴스 / 다국어 (semantic_search, news) + # ───────────────────────────────────────────────────────── + - id: news_001 + query: "이란과 미국의 군사 충돌" + category: news_ko + intent: semantic_search + domain_hint: news + relevant_ids: [4303, 4304, 4307, 4316, 4322, 4323, 4327, 4335] + notes: | + 경향신문의 이란-미국 전쟁 보도. recall 위주 평가. + diversity 제약 적용 후에도 최소 5건은 top-10에 들어와야 함. + + - id: news_002 + query: "호르무즈 해협 봉쇄" + category: news_ko + intent: semantic_search + domain_hint: news + relevant_ids: [4316, 4320, 4322, 4327] + top3_ids: [4316] + notes: 호르무즈 해협 직접 언급 기사. + + - id: news_003 + query: "Trump Iran ultimatum" + category: news_en + intent: semantic_search + domain_hint: news + relevant_ids: [4258, 4260, 4262] + notes: Der Spiegel 영어판 Iran 관련 기사. + + - id: news_004 + query: "guerre en Iran" + category: news_fr + intent: semantic_search + domain_hint: news + relevant_ids: [4199, 4202, 4210, 4361, 4363, 4507, 4519, 4521] + notes: Le Monde 불어 Iran 전쟁 보도. + + - id: news_005 + query: "이란 미국 전쟁 글로벌 반응" + category: news_crosslingual + intent: semantic_search + domain_hint: news + relevant_ids: [4202, 4258, 4262, 4536, 4303, 4304, 4316] + notes: | + 한국어 쿼리로 한/영/불/독 뉴스가 골고루 검색되어야 함. + Phase 1 domain-aware retrieval + multilingual embedding 효과 측정용. + diversity 제약(국가당 max 2)이 동작하면 최소 4개국 이상 노출. + + # ───────────────────────────────────────────────────────── + # 5. 기타 도메인 (semantic_search, document) + # ───────────────────────────────────────────────────────── + - id: misc_001 + query: "강체의 평면 운동학" + category: other_domain + intent: fact_lookup + domain_hint: document + relevant_ids: [4063, 4065] + top3_ids: [4063] + notes: 공업역학 동역학 ch16, ch18. + + - id: misc_002 + query: "질점의 운동역학" + category: other_domain + intent: semantic_search + domain_hint: document + relevant_ids: [4060, 4061, 4062] + notes: 공업역학 동역학 ch13~15 (질점 운동역학). + + # ───────────────────────────────────────────────────────── + # 6. 실패 / 애매 케이스 (low confidence 기대) + # ───────────────────────────────────────────────────────── + - id: fail_001 + query: "Rust async runtime tokio scheduler 내부 구조" + category: failure_expected + intent: semantic_search + domain_hint: document + relevant_ids: [] + notes: | + 코퍼스에 Rust/프로그래밍 문서 없음. Phase 0.3 search_failure_logs로 자동 수집되어야 함. + Phase 1+에서 confidence 점수 < 0.5로 분류되는지 확인. + + - id: fail_002 + query: "양자컴퓨터 큐비트 디코히어런스" + category: failure_expected + intent: semantic_search + domain_hint: document + relevant_ids: [] + notes: 코퍼스에 양자물리 문서 없음. + + - id: fail_003 + query: "재즈 보컬리스트 빌리 홀리데이" + category: failure_expected + intent: semantic_search + domain_hint: news + relevant_ids: [] + notes: 코퍼스에 음악/재즈 문서 없음. diff --git a/tests/search_eval/run_eval.py b/tests/search_eval/run_eval.py new file mode 100644 index 0000000..90c5b73 --- /dev/null +++ b/tests/search_eval/run_eval.py @@ -0,0 +1,473 @@ +#!/usr/bin/env python3 +"""Document Server 검색 평가 스크립트 (Phase 0.2) + +queries.yaml을 읽어 /api/search 엔드포인트에 호출하고 +Recall@10, MRR@10, NDCG@10, Top3 hit-rate, Latency p50/p95를 계산한다. + +A/B 비교 모드: --baseline-url, --candidate-url 를 각각 지정하면 +두 엔드포인트에 동일 쿼리셋을 던지고 결과를 비교한다. + +사용 예: + + # 단일 평가 + export DOCSRV_TOKEN="eyJ..." + python tests/search_eval/run_eval.py \ + --base-url https://docs.hyungi.net \ + --output reports/baseline_2026-04-07.csv + + # A/B 비교 (같은 토큰) + python tests/search_eval/run_eval.py \ + --baseline-url https://docs.hyungi.net \ + --candidate-url http://localhost:8000 \ + --output reports/phase1_vs_baseline.csv + +토큰은 env DOCSRV_TOKEN 또는 --token 플래그로 전달. +""" + +from __future__ import annotations + +import argparse +import asyncio +import csv +import math +import os +import statistics +import sys +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +import httpx +import yaml + + +# ───────────────────────────────────────────────────────── +# 데이터 구조 +# ───────────────────────────────────────────────────────── + + +@dataclass +class Query: + id: str + query: str + category: str + intent: str + domain_hint: str + relevant_ids: list[int] + top3_ids: list[int] = field(default_factory=list) + notes: str = "" + + +@dataclass +class QueryResult: + query: Query + label: str # "baseline" or "candidate" + returned_ids: list[int] + latency_ms: float + recall_at_10: float + mrr_at_10: float + ndcg_at_10: float + top3_hit: bool + error: str | None = None + + +# ───────────────────────────────────────────────────────── +# 평가 지표 +# ───────────────────────────────────────────────────────── + + +def recall_at_k(returned: list[int], relevant: list[int], k: int = 10) -> float: + """top-k 안에 들어간 정답 비율. 정답 0개면 1.0(빈 케이스는 별도 fail metric).""" + if not relevant: + return 1.0 if not returned else 0.0 # 비어야 정상인 케이스: 결과 있으면 fail + top_k = set(returned[:k]) + hits = sum(1 for doc_id in relevant if doc_id in top_k) + return hits / len(relevant) + + +def mrr_at_k(returned: list[int], relevant: list[int], k: int = 10) -> float: + """top-k 안 첫 정답의 reciprocal rank. 정답 없으면 0.""" + if not relevant: + return 0.0 + relevant_set = set(relevant) + for rank, doc_id in enumerate(returned[:k], start=1): + if doc_id in relevant_set: + return 1.0 / rank + return 0.0 + + +def ndcg_at_k(returned: list[int], relevant: list[int], k: int = 10) -> float: + """binary relevance 기반 NDCG@k. top3_ids 같은 가중치는 v0.1에선 무시.""" + if not relevant: + return 0.0 + relevant_set = set(relevant) + dcg = 0.0 + for rank, doc_id in enumerate(returned[:k], start=1): + if doc_id in relevant_set: + # binary gain = 1, DCG = 1 / log2(rank+1) + dcg += 1.0 / math.log2(rank + 1) + # ideal DCG: 정답을 1..min(len(relevant), k) 위치에 모두 채운 경우 + ideal_hits = min(len(relevant), k) + idcg = sum(1.0 / math.log2(r + 1) for r in range(1, ideal_hits + 1)) + return dcg / idcg if idcg > 0 else 0.0 + + +def top3_hit(returned: list[int], top3_ids: list[int]) -> bool: + """top3_ids가 비어있으면 True (체크 안함). 있으면 그 중 하나라도 top-3에 들어와야 함.""" + if not top3_ids: + return True + top3 = set(returned[:3]) + return any(doc_id in top3 for doc_id in top3_ids) + + +# ───────────────────────────────────────────────────────── +# API 호출 +# ───────────────────────────────────────────────────────── + + +async def call_search( + client: httpx.AsyncClient, + base_url: str, + token: str, + query: str, + mode: str = "hybrid", + limit: int = 20, +) -> tuple[list[int], float]: + """검색 API 호출 → (doc_ids, latency_ms).""" + url = f"{base_url.rstrip('/')}/api/search/" + headers = {"Authorization": f"Bearer {token}"} + params = {"q": query, "mode": mode, "limit": limit} + + import time + + start = time.perf_counter() + response = await client.get(url, headers=headers, params=params, timeout=30.0) + latency_ms = (time.perf_counter() - start) * 1000 + + response.raise_for_status() + data = response.json() + returned_ids = [r["id"] for r in data.get("results", [])] + return returned_ids, latency_ms + + +# ───────────────────────────────────────────────────────── +# 평가 실행 +# ───────────────────────────────────────────────────────── + + +async def evaluate( + queries: list[Query], + base_url: str, + token: str, + label: str, + mode: str = "hybrid", +) -> list[QueryResult]: + """전체 쿼리셋 평가.""" + results: list[QueryResult] = [] + + async with httpx.AsyncClient() as client: + for q in queries: + try: + returned_ids, latency_ms = await call_search( + client, base_url, token, q.query, mode=mode + ) + results.append( + QueryResult( + query=q, + label=label, + returned_ids=returned_ids, + latency_ms=latency_ms, + recall_at_10=recall_at_k(returned_ids, q.relevant_ids, 10), + mrr_at_10=mrr_at_k(returned_ids, q.relevant_ids, 10), + ndcg_at_10=ndcg_at_k(returned_ids, q.relevant_ids, 10), + top3_hit=top3_hit(returned_ids, q.top3_ids), + ) + ) + except Exception as exc: + results.append( + QueryResult( + query=q, + label=label, + returned_ids=[], + latency_ms=0.0, + recall_at_10=0.0, + mrr_at_10=0.0, + ndcg_at_10=0.0, + top3_hit=False, + error=str(exc), + ) + ) + return results + + +# ───────────────────────────────────────────────────────── +# 결과 집계 / 출력 +# ───────────────────────────────────────────────────────── + + +def percentile(values: list[float], p: float) -> float: + if not values: + return 0.0 + s = sorted(values) + k = (len(s) - 1) * p + f = int(k) + c = min(f + 1, len(s) - 1) + if f == c: + return s[f] + return s[f] + (s[c] - s[f]) * (k - f) + + +def print_summary(label: str, results: list[QueryResult]) -> dict[str, float]: + """전체 + 카테고리별 요약 출력. 집계 dict 반환.""" + n = len(results) + if n == 0: + return {} + + # 실패 케이스(relevant_ids=[])는 평균 recall/mrr/ndcg에서 제외 + scored = [r for r in results if r.query.relevant_ids] + failure_cases = [r for r in results if not r.query.relevant_ids] + + avg_recall = statistics.mean([r.recall_at_10 for r in scored]) if scored else 0.0 + avg_mrr = statistics.mean([r.mrr_at_10 for r in scored]) if scored else 0.0 + avg_ndcg = statistics.mean([r.ndcg_at_10 for r in scored]) if scored else 0.0 + top3_rate = sum(1 for r in scored if r.top3_hit) / len(scored) if scored else 0.0 + + latencies = [r.latency_ms for r in results if r.latency_ms > 0] + p50 = percentile(latencies, 0.50) + p95 = percentile(latencies, 0.95) + + # 실패 케이스: 결과 0건이어야 정상 + failure_correct = sum(1 for r in failure_cases if not r.returned_ids) + failure_precision = ( + failure_correct / len(failure_cases) if failure_cases else 0.0 + ) + + print(f"\n=== {label} (n={n}, scored={len(scored)}) ===") + print(f" Recall@10 : {avg_recall:.3f}") + print(f" MRR@10 : {avg_mrr:.3f}") + print(f" NDCG@10 : {avg_ndcg:.3f}") + print(f" Top-3 hit : {top3_rate:.3f}") + print(f" Latency p50: {p50:.0f} ms") + print(f" Latency p95: {p95:.0f} ms") + if failure_cases: + print( + f" Failure-case precision: {failure_correct}/{len(failure_cases)}" + f" ({failure_precision:.2f}) — empty result expected" + ) + + # 카테고리별 + by_cat: dict[str, list[QueryResult]] = {} + for r in scored: + by_cat.setdefault(r.query.category, []).append(r) + print(" by category:") + for cat, items in sorted(by_cat.items()): + cat_recall = statistics.mean([r.recall_at_10 for r in items]) + cat_ndcg = statistics.mean([r.ndcg_at_10 for r in items]) + print( + f" {cat:<22} n={len(items):>2} recall={cat_recall:.2f} ndcg={cat_ndcg:.2f}" + ) + + # 에러 케이스 + errors = [r for r in results if r.error] + if errors: + print(f" ERRORS ({len(errors)}):") + for r in errors: + print(f" [{r.query.id}] {r.error}") + + return { + "n": n, + "recall_at_10": avg_recall, + "mrr_at_10": avg_mrr, + "ndcg_at_10": avg_ndcg, + "top3_hit_rate": top3_rate, + "latency_p50": p50, + "latency_p95": p95, + "failure_precision": failure_precision, + } + + +def write_csv(results: list[QueryResult], output_path: Path) -> None: + output_path.parent.mkdir(parents=True, exist_ok=True) + with output_path.open("w", newline="", encoding="utf-8") as f: + writer = csv.writer(f) + writer.writerow( + [ + "label", + "id", + "category", + "intent", + "domain_hint", + "query", + "relevant_ids", + "returned_ids_top10", + "latency_ms", + "recall_at_10", + "mrr_at_10", + "ndcg_at_10", + "top3_hit", + "error", + ] + ) + for r in results: + writer.writerow( + [ + r.label, + r.query.id, + r.query.category, + r.query.intent, + r.query.domain_hint, + r.query.query, + ";".join(map(str, r.query.relevant_ids)), + ";".join(map(str, r.returned_ids[:10])), + f"{r.latency_ms:.1f}", + f"{r.recall_at_10:.3f}", + f"{r.mrr_at_10:.3f}", + f"{r.ndcg_at_10:.3f}", + "1" if r.top3_hit else "0", + r.error or "", + ] + ) + print(f"\nCSV written: {output_path}") + + +# ───────────────────────────────────────────────────────── +# 로딩 +# ───────────────────────────────────────────────────────── + + +def load_queries(yaml_path: Path) -> list[Query]: + with yaml_path.open(encoding="utf-8") as f: + data = yaml.safe_load(f) + queries: list[Query] = [] + for q in data["queries"]: + queries.append( + Query( + id=q["id"], + query=q["query"], + category=q["category"], + intent=q["intent"], + domain_hint=q["domain_hint"], + relevant_ids=q.get("relevant_ids", []) or [], + top3_ids=q.get("top3_ids", []) or [], + notes=q.get("notes", "") or "", + ) + ) + return queries + + +# ───────────────────────────────────────────────────────── +# CLI +# ───────────────────────────────────────────────────────── + + +def main() -> int: + parser = argparse.ArgumentParser(description="Document Server 검색 평가") + parser.add_argument( + "--queries", + type=Path, + default=Path(__file__).parent / "queries.yaml", + help="평가셋 YAML 경로", + ) + parser.add_argument( + "--base-url", + type=str, + default=None, + help="단일 평가용 URL (예: https://docs.hyungi.net)", + ) + parser.add_argument( + "--baseline-url", + type=str, + default=None, + help="A/B 비교용 baseline URL", + ) + parser.add_argument( + "--candidate-url", + type=str, + default=None, + help="A/B 비교용 candidate URL", + ) + parser.add_argument( + "--mode", + type=str, + default="hybrid", + choices=["fts", "trgm", "vector", "hybrid"], + help="검색 mode 파라미터", + ) + parser.add_argument( + "--token", + type=str, + default=os.environ.get("DOCSRV_TOKEN"), + help="Bearer 토큰 (env DOCSRV_TOKEN)", + ) + parser.add_argument( + "--output", + type=Path, + default=None, + help="CSV 출력 경로 (지정하면 raw 결과 저장)", + ) + args = parser.parse_args() + + if not args.token: + print("ERROR: --token 또는 env DOCSRV_TOKEN 필요", file=sys.stderr) + return 2 + + if not args.base_url and not (args.baseline_url and args.candidate_url): + print( + "ERROR: --base-url 또는 (--baseline-url + --candidate-url) 둘 중 하나 필요", + file=sys.stderr, + ) + return 2 + + queries = load_queries(args.queries) + print(f"Loaded {len(queries)} queries from {args.queries}") + print(f"Mode: {args.mode}") + + all_results: list[QueryResult] = [] + + if args.base_url: + print(f"\n>>> evaluating: {args.base_url}") + results = asyncio.run( + evaluate(queries, args.base_url, args.token, "single", mode=args.mode) + ) + print_summary("single", results) + all_results.extend(results) + else: + print(f"\n>>> baseline: {args.baseline_url}") + baseline_results = asyncio.run( + evaluate(queries, args.baseline_url, args.token, "baseline", mode=args.mode) + ) + baseline_summary = print_summary("baseline", baseline_results) + + print(f"\n>>> candidate: {args.candidate_url}") + candidate_results = asyncio.run( + evaluate( + queries, args.candidate_url, args.token, "candidate", mode=args.mode + ) + ) + candidate_summary = print_summary("candidate", candidate_results) + + # 델타 + print("\n=== Δ (candidate - baseline) ===") + for k in ( + "recall_at_10", + "mrr_at_10", + "ndcg_at_10", + "top3_hit_rate", + "latency_p50", + "latency_p95", + ): + delta = candidate_summary[k] - baseline_summary[k] + sign = "+" if delta >= 0 else "" + print(f" {k:<16}: {sign}{delta:.3f}") + + all_results.extend(baseline_results) + all_results.extend(candidate_results) + + if args.output: + write_csv(all_results, args.output) + + return 0 + + +if __name__ == "__main__": + sys.exit(main())