docs(eval): Phase 2Q Category-Analysis — standards/exam 회귀 진단 (inflation 정정)

Apply rollout 후속 read-only 진단. Phase 3 측정 (commit a41adb6) 의 NDCG 0.927 + standards 1.441 + exam 1.109 = **측정 artifact (top-N doc 중복 박제 → graded NDCG inflation)**.

진단 path:
- script category_analysis_phase2q.py (csv parse + queries.yaml graded lookup + standards/exam 18 case 3-way top-5 박제)
- 회귀 큰 case top: kw_004/kw_009/kw_010 = Phase 3 inflation 1.631 → Rerank-Fix 정상 1.000 (baseline 동일, 회귀 0)
- kw_001/exam_004 = Rerank-Fix 가 baseline 대비도 회귀 (reranker chunk-level relevance 우선 → doc grade 3 가 rank 5 밀림)

정정값 박제:
- Phase 3 NDCG 0.927 → **Rerank-Fix 0.876 (정확값)**
- Δ vs baseline: +0.268 (inflated) → **+0.217 (실제 multi-query 효과)**
- standards 1.441 → 1.157 (vs baseline 0.873, +0.284)
- exam 1.109 → 0.918 (vs baseline 0.738, +0.180)

결론:
- **Apply rollout 결정 = 정정값 기준 invariant 유지** — +0.217 vs baseline = 유의미 net 개선
- standards -0.28 / exam -0.19 회귀 = false alarm (inflation 정정)
- 실제 회귀 case (kw_001/exam_004) = Apply 후 telemetry 박제 항목

산출물:
- tests/search_eval/baselines/v0_2_phase2q_category_analysis_2026-05-24.md (180+ lines, §1~8)
- tests/search_eval/scripts/category_analysis_phase2q.py (read-only csv parse script, reproducibility)

신규 feedback memory: graded-ndcg-dedup-invariant (NDCG > 1.0 = inflation 의심 invariant + dedup audit 필수)

후속 별 chore 후보:
- PR-Eval-GradedNDCG-Dedup — run_eval.py 의 graded NDCG 계산 dedup + NDCG > 1.0 warning
- PR-2Q-Search-Result-Dedup — _rrf_fuse_variants 의 representative doc_id 중복 audit

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hyungi
2026-05-24 04:23:58 +00:00
parent fef5ddc5c8
commit b00d9f5e15
2 changed files with 263 additions and 0 deletions
@@ -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`
@@ -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()