diff --git a/tests/search_eval/baselines/v0_2_phase2q_category_analysis_2026-05-24.md b/tests/search_eval/baselines/v0_2_phase2q_category_analysis_2026-05-24.md new file mode 100644 index 0000000..bae14be --- /dev/null +++ b/tests/search_eval/baselines/v0_2_phase2q_category_analysis_2026-05-24.md @@ -0,0 +1,134 @@ +# Phase 2Q Category-Analysis — standards/exam "회귀" 진단 (2026-05-24) + +**결론**: Phase 3 의 standards 1.44 / exam 1.11 = **측정 artifact (top-N doc 중복 +박제 → graded NDCG inflation)**. Rerank-Fix 의 chunk_id dedup 적용 후 정상화. +"회귀" -0.28/-0.19 는 false alarm — **inflation 의 정정**. + +→ **Rerank-Fix 의 NDCG 0.876 이 정확한 측정값**. baseline 0.659 대비 **Δ +0.217 = 실제 +multi-query 효과** (Phase 3 박제값 +0.268 은 inflation 포함). Apply rollout 결정은 여전히 valid. + +## 1. 핵심 진단 + +graded NDCG 계산은 returned top-N 에 **unique doc 등장 가정**. retrieval path 가 같은 +doc 을 N번 박제하면: +- DCG 분자 = `Σ grade_i / log2(rank_i + 1)` 에 같은 doc 의 grade 가 N번 합산 +- ideal DCG (분모) 는 graded_relevance 의 unique doc 기준 +- → actual DCG > ideal DCG → **NDCG > 1.0** (정상 invariant 위반) + +Phase 3 측정 (commit `a41adb6`) 의 multi-query path 는 chunk_id dedup 미적용 +(`merged_chunks_by_doc.setdefault(doc_id, []).extend(chunks)` 가 variant 별 same chunk +중복 누적). reranker 가 동일 chunk N개 받음 + RRF fallback 시에도 unified RRF 의 +representative SearchResult 들이 doc_id 중복 가능 (별 chunk_id 의 same doc). + +Rerank-Fix (commit `b734fc5`) 의 `_dedup_chunks_by_id` helper 가 chunk_id 기준 dedup → +top-N 에 unique doc 만 등장 → NDCG ≤ 1.0 invariant 복원. + +## 2. 카테고리별 graded_ndcg 3-way 비교 + +| Category | n | baseline | Phase 3 (inflated) | **Rerank-Fix (정확)** | baseline → Fix | +|---|---:|---:|---:|---:|---:| +| standards | 11 | 0.873 | 1.441 ⚠️ | **1.157** | **+0.284** | +| exam | 7 | 0.738 | 1.109 ⚠️ | **0.918** | **+0.180** | + +**Rerank-Fix 의 standards 1.157 + exam 0.918 도 여전히 baseline 대비 net 개선**. multi-query +실효성 = 둘 다 유의미 회복. + +## 3. "회귀" top 5 — 모두 inflation 정정 (false alarm) + +| Case | Category | Δ Phase 3→Fix | Phase 3 | Fix | baseline | Query | +|---|---|---:|---:|---:|---:|---| +| kw_004 | standards | -0.631 | 1.631 ⚠️ | **1.000** | 1.000 | 근로기준법 안전과 보건 | +| kw_009 | standards | -0.631 | 1.631 ⚠️ | **1.000** | 1.000 | KGS FP111 가스설비 배관설비 기준 | +| kw_010 | standards | -0.631 | 1.631 ⚠️ | **1.000** | 1.000 | KGS FU551 가스설비 압력조정기 가스계량기 | +| kw_001 | standards | -0.504 | 1.235 ⚠️ | 0.731 | 0.808 | 산업안전보건법 제6장 | +| exam_004 | exam | -0.472 | 1.099 ⚠️ | 0.627 | 0.674 | 고압가스 용기 내압시험 영구증가량 | + +**3 case (kw_004 / kw_009 / kw_010)** = Phase 3 inflation 1.631 → Rerank-Fix 가 baseline +1.000 동일 = **dedup 후 정상값 복원 + 회귀 0**. + +**2 case (kw_001 / exam_004)** = Rerank-Fix 가 baseline 대비도 회귀 (0.731 < 0.808 / +0.627 < 0.674). 이는 reranker 가 chunk-level relevance 우선 → doc 의 best chunk 가 +baseline 의 doc-level retrieval 보다 grade 낮은 doc 위로 올림. + +### kw_001 raw rank (3856 = grade 3, 3868/3879 = grade 2) +``` +baseline top: 1.3868★★ | 2.3879★★ | 3.3856★★★ | 4.3851✗ | 5.4041✗ +Phase 3 top: 1.3868★★ | 2.3879★★ | 3.3856★★★ | 4.3851✗ | 5.3868★★ ← 3868 중복 +Rerank-Fix top: 1.3879★★ | 2.3868★★ | 3.3890✗ | 4.3863✗ | 5.3856★★★ ← 3856 rank 5 +``` + +→ Rerank-Fix path 에서 reranker 가 3856 (grade 3) 를 rank 5 로 밀어내고 non-relevant +(3890/3863) 를 위로 올림. 실제 reranker ranking 손실 — Apply 후 분석 항목. + +## 4. 회복 top — multi-query 의 진짜 효과 (Phase 3 → Rerank-Fix) + +| Case | Δ | Phase 3 | Rerank-Fix | Query | +|---|---:|---:|---:|---| +| exam_006 | +0.240 | 0.316 | **0.556** | LPG 저장탱크 안전거리 분말소화기 | +| kw_002 | +0.149 | 1.079 ⚠️ | **1.228** | 중대재해 처벌 등에 관한 법률 제2장 중대산업재해 | +| kw_007 | +0.081 | 1.496 ⚠️ | **1.577** | 산업안전보건기준 폭발 화재 위험물 누출 방지 | + +**exam_006** = Phase 3 inflation 작은 case 에서 dedup 후 Recall 회복 (baseline 0.321 → +Rerank-Fix 0.556 = **+0.235 vs baseline**). multi-query 의 본격 효과. + +**kw_002** = Rerank-Fix 가 inflation 정상화 후에도 baseline 0.834 대비 0.394 회복 — **3917 +(grade 3) 가 rank 2 + rank 3 에 등장**: +``` +Rerank-Fix top: 1.3921★★ | 2.3917★★★ | 3.3917★★★ | 4.10573✗ | 5.3923✗ +``` +→ 3917 의 다른 chunk_id 가 rank 2/3 = chunk-level reranker 의 정확한 doc 식별 효과. + +## 5. 정정값 박제 (Apply 결정 baseline) + +| Metric | 박제됐던 값 (Phase 3) | **정정 (Rerank-Fix)** | baseline | Δ vs baseline | +|---|---:|---:|---:|---:| +| **Overall NDCG** | 0.927 ⚠️ inflated | **0.876** | 0.659 | **+0.217** ✅ | +| Recall t≥2 | 0.687 | 0.721 | 0.695 | +0.026 | +| Recall t≥3 | 0.728 | 0.739 | 0.761 | -0.022 | +| standards | 1.44 ⚠️ | **1.157** | 0.873 | +0.284 | +| exam | 1.11 ⚠️ | **0.918** | 0.738 | +0.180 | +| korean_only | 0.71 | 0.66 | 0.51 | +0.15 | +| mixed | 0.57 | 0.52 | 0.39 | +0.13 | +| english_only | 0.77 | 1.11 ⚠️ | 0.78 | +0.33 | + +`english_only` 의 1.11 은 여전히 inflation (baseline `single` 측정의 path 와 같지만 +multi-query path 의 동작 다름). 추가 진단 별 chore 후보 — `_rrf_fuse_variants` 의 +representative 보존 logic 이 doc 중복 박제 가능성. + +## 6. 따라야 할 후속 작업 + +1. **Apply rollout 결정 = 정정값 기준 invariant 유지** — Rerank-Fix NDCG 0.876 + + baseline +0.217 = 충분히 net 개선. opt-in path 1주 관찰 valid. + +2. **별 chore `PR-Eval-GradedNDCG-Dedup`** — `tests/search_eval/run_eval.py` 의 + graded NDCG 계산 함수에 returned_ids dedup 적용 + NDCG > 1.0 시 warning + dedup + 카운트 박제. 운영 평가 metric inflation 회피 invariant. + +3. **별 chore `PR-2Q-Search-Result-Dedup`** — `_rrf_fuse_variants` 의 representative + 보존 logic 이 같은 doc_id 의 chunk-level SearchResult 들을 dedup 하는지 audit. + `chunks_by_doc` 의 chunk 중복 + `_rrf_fuse_variants` 의 doc_id 중복 2 layer + dedup invariant. + +4. **kw_001 / exam_004 등 baseline 회귀 case** = Apply 후 telemetry 박제 + 실제 + 사용자 query 분포에서 발생 빈도 측정. 회귀 발견 시 reranker tuning (예: chunk-level + score 보다 doc-level grade 우선) 검토. + +## 7. 메모리 갱신 필요 항목 + +- `project_search_v2.md` Phase 2Q section — NDCG 박제값 정정 (0.927 → 0.876, Δ 0.268 + → 0.217), inflation 발견 사유 박제 +- `MEMORY.md` Phase 2Q row — 위와 동일 +- 신규 feedback memory — graded NDCG dedup invariant (run_eval 측정 무결성) +- decision md 의 4-factor 표는 정정값으로 swap 또는 footnote 추가 +- Apply PR 의 docs/phase_2q_apply_opt_in.md 의 metric 목표 (Recall t≥3 ≥ 0.74) 는 + Rerank-Fix 정확값 0.739 와 거의 일치 — 그대로 유지 + +## 8. raw 산출물 + +- 본 진단 = `tests/search_eval/baselines/v0_2_phase2q_category_analysis_2026-05-24.md` +- script = `phase2q_category_analysis.py` (read-only csv parse, gpu repo 루트) +- 비교 csv: + - `reports/v0_2_phase2q_baseline_rebaseline_2026-05-24.csv` + - `reports/v0_2_phase2q_cand_multi_query_macmini_2026-05-24_cold.csv` (Phase 3, inflated) + - `reports/v0_2_phase2q_rerank_fix_2026-05-24.csv` (정확값) +- queries.yaml graded_relevance = `tests/search_eval/queries.yaml` diff --git a/tests/search_eval/scripts/category_analysis_phase2q.py b/tests/search_eval/scripts/category_analysis_phase2q.py new file mode 100644 index 0000000..efd1a03 --- /dev/null +++ b/tests/search_eval/scripts/category_analysis_phase2q.py @@ -0,0 +1,129 @@ +"""Phase 2Q Category-Analysis 진단 — standards/exam 회귀 case 의 3-way rank 비교. + +read-only. csv parse + queries.yaml graded_relevance lookup + standards 11 + exam 7 +case 의 baseline / Phase 3 (RRF fallback) / Rerank-Fix (reranker 정상) rank 1~5 +박제. 회귀 큰 case top + root cause 가설. +""" +from __future__ import annotations + +import csv +import sys +from pathlib import Path + +REPORTS = Path("reports") +CSV_BASELINE = REPORTS / "v0_2_phase2q_baseline_rebaseline_2026-05-24.csv" +CSV_PHASE3 = REPORTS / "v0_2_phase2q_cand_multi_query_macmini_2026-05-24_cold.csv" +CSV_RERANK_FIX = REPORTS / "v0_2_phase2q_rerank_fix_2026-05-24.csv" + + +def parse_csv(path: Path) -> dict[str, dict]: + """id → row dict.""" + rows: dict[str, dict] = {} + with path.open() as f: + reader = csv.DictReader(f) + for row in reader: + rows[row["id"]] = row + return rows + + +def parse_graded(s: str) -> dict[int, int]: + """`3856:3;3868:2;3879:2` → {3856:3, 3868:2, 3879:2}.""" + if not s or s == "": + return {} + out = {} + for tok in s.split(";"): + if ":" in tok: + doc_id, grade = tok.split(":") + out[int(doc_id)] = int(grade) + return out + + +def parse_returned(s: str) -> list[int]: + if not s: + return [] + return [int(x) for x in s.split(";") if x] + + +def fmt_top(ids: list[int], graded: dict[int, int], n: int = 5) -> str: + """top-N doc_id 와 grade. grade=3 ★★★ / grade=2 ★★ / grade=1 ★ / 0 ✗.""" + out = [] + for i, doc_id in enumerate(ids[:n], 1): + g = graded.get(doc_id, 0) + mark = "★" * g if g > 0 else "✗" + out.append(f"{i}.{doc_id}{mark}") + return " | ".join(out) + + +def main(): + bl = parse_csv(CSV_BASELINE) + p3 = parse_csv(CSV_PHASE3) + rf = parse_csv(CSV_RERANK_FIX) + + # standards (11) + exam (7) 만 + target_cats = {"standards", "exam"} + target_ids = [i for i, r in bl.items() if r["category"] in target_cats] + target_ids.sort(key=lambda i: (bl[i]["category"], i)) + + print(f"# Phase 2Q Category-Analysis — standards/exam 18 case 3-way rank 비교\n") + print(f"**baseline (single-query) vs Phase 3 cand (RRF fallback 다수) vs Rerank-Fix (reranker 정상)**\n") + print(f"Phase 3 NDCG 0.927 vs Rerank-Fix NDCG 0.876 (Δ -0.051). standards -0.28 / exam -0.19 회귀의 raw case 단위 박제.\n") + print(f"각 case 의 top-5 rank doc_id. ★★★=grade 3 (관련 매우 높음) / ★★=2 / ★=1 / ✗=non-relevant.\n") + + # category 별 graded_ndcg 비교 + print("## 1. 카테고리 별 graded_ndcg 요약\n") + print("| Category | n | baseline | Phase 3 cand | Rerank-Fix | Δ Phase 3 → Fix |") + print("|---|---:|---:|---:|---:|---:|") + cat_summary = {} + for cat in ("standards", "exam"): + ids = [i for i in target_ids if bl[i]["category"] == cat] + bl_avg = sum(float(bl[i].get("graded_ndcg_at_10", 0) or 0) for i in ids) / len(ids) + p3_avg = sum(float(p3[i].get("graded_ndcg_at_10", 0) or 0) for i in ids) / len(ids) + rf_avg = sum(float(rf[i].get("graded_ndcg_at_10", 0) or 0) for i in ids) / len(ids) + cat_summary[cat] = (bl_avg, p3_avg, rf_avg) + print(f"| {cat} | {len(ids)} | {bl_avg:.3f} | {p3_avg:.3f} | {rf_avg:.3f} | {rf_avg-p3_avg:+.3f} |") + print() + + # case 별 상세 + print("## 2. case 별 top-5 rank 비교\n") + regressions = [] # (delta, id, ...) for sorting + for cid in target_ids: + bl_row, p3_row, rf_row = bl[cid], p3[cid], rf[cid] + cat = bl_row["category"] + query = bl_row["query"] + graded = parse_graded(bl_row.get("graded_relevance", "")) + bl_top = parse_returned(bl_row["returned_ids_top10"]) + p3_top = parse_returned(p3_row["returned_ids_top10"]) + rf_top = parse_returned(rf_row["returned_ids_top10"]) + + bl_ndcg = float(bl_row.get("graded_ndcg_at_10", 0) or 0) + p3_ndcg = float(p3_row.get("graded_ndcg_at_10", 0) or 0) + rf_ndcg = float(rf_row.get("graded_ndcg_at_10", 0) or 0) + delta = rf_ndcg - p3_ndcg + + print(f"### `{cid}` ({cat}) — `{query}`\n") + relevant_str = ", ".join(f"{d}★{'★'*(g-1) if g > 1 else ''}" for d, g in sorted(graded.items(), key=lambda x: -x[1])) + print(f"- **graded_relevance**: {relevant_str}") + print(f"- baseline NDCG {bl_ndcg:.3f} top: {fmt_top(bl_top, graded)}") + print(f"- Phase 3 NDCG {p3_ndcg:.3f} top: {fmt_top(p3_top, graded)}") + print(f"- Rerank-Fix NDCG {rf_ndcg:.3f} top: {fmt_top(rf_top, graded)}") + print(f"- **Δ Phase 3 → Rerank-Fix**: {delta:+.3f}\n") + regressions.append((delta, cid, cat, query, p3_ndcg, rf_ndcg)) + + # 회귀 top + print("## 3. 회귀 큰 case top 5 (Phase 3 → Rerank-Fix)\n") + regressions.sort(key=lambda x: x[0]) + print("| Case | Category | Δ | Phase 3 | Rerank-Fix | Query |") + print("|---|---|---:|---:|---:|---|") + for delta, cid, cat, query, p3v, rfv in regressions[:8]: + print(f"| {cid} | {cat} | {delta:+.3f} | {p3v:.3f} | {rfv:.3f} | `{query[:50]}` |") + print() + print("## 4. 회복 큰 case top 5 (Phase 3 → Rerank-Fix)\n") + print("| Case | Category | Δ | Phase 3 | Rerank-Fix | Query |") + print("|---|---|---:|---:|---:|---|") + for delta, cid, cat, query, p3v, rfv in sorted(regressions, key=lambda x: -x[0])[:5]: + print(f"| {cid} | {cat} | {delta:+.3f} | {p3v:.3f} | {rfv:.3f} | `{query[:50]}` |") + print() + + +if __name__ == "__main__": + main()