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:
@@ -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()
|
||||
Reference in New Issue
Block a user