Files
hyungi_document_server/tests/search_eval
hyungi 3b753f18d6 fix(search): Phase 2Q result dedup — apply_diversity unlimited path doc_id inflation 차단
PR-2Q-Search-Result-Dedup. measurement chain 의 마지막 cleanup. plan inline.

root cause: apply_diversity 의 top_score ≥ 0.90 → unlimited path (diversity 제약 해제)
→ 같은 doc 의 N chunks 가 results 에 박제 → returned_ids 에 doc.id 중복 → 모든 graded
metric inflation. multi-query 의 reranker score 가 자주 0.90+ → 다수 case 영향.

변경 (baseline path 영향 0, multi-query 전용 invariant):
- app/services/search/search_pipeline.py:
  · _dedup_results_by_doc_id() helper 신규 (doc.id first-only, top score 보존)
  · search_with_rewrite() 의 rerank path 에 apply_diversity(top_score_threshold=2.0)
    강제 + 후속 _dedup_results_by_doc_id 적용
  · rerank=False path 도 _dedup_results_by_doc_id(unified_docs) 적용
- tests/test_query_rewriter.py — 신규 4 test (55/55 PASS)

🎯 진짜 측정값 (모든 dedup layer 적용, 51 case gemma):
  cold: NDCG 0.663 / Recall t≥2 0.729 / Recall t≥3 0.761 / p50 3692ms / p95 9992ms
  warm: NDCG 0.659 / Recall t≥2 0.721 / Recall t≥3 0.739 / p50 1588ms / p95 3514ms
  baseline (rewrite_backend=null): NDCG 0.644 / Recall t≥2 0.699 / Recall t≥3 0.761 / p50 378ms
  Dedup audit: gemma 0/51 ✓ 정상 (fix 작동, eval-dedup 42/51 → 0/51 회복)

Δ vs baseline (진짜 multi-query 효과):
  NDCG +0.019 (cold) / +0.015 (warm) — sub-noise level
  Recall t≥2 +0.030 (cold) / +0.022 (warm) — 소량 개선
  Recall t≥3 0.000 / -0.022 — 동등~약간 회귀
  latency p50 +876% (cold) / +320% (warm) — major cost
  category: english/standards/mixed 약간 우세 / exam/korean 약간 회귀

measurement chain 정정 history:
  Phase 3 (a41adb6) 0.927 — chunk_id 중복 inflation
  Rerank-Fix (b734fc5) 0.876 — doc_id 중복 잔재
  Eval-Dedup (3553573) 0.641 — eval layer 만 dedup
  Result-Dedup (본 PR) 0.663 — production + eval 둘 다 dedup ← 정확값

사용자 결정 필요 (3 path, json 박제):
  (a) rollback — marginal 개선이 latency cost 정당화 X
  (b) opt-in 유지 + PR-2Q-Cache-Prewarm 진입 (warm path 만 노출)
  (c) 1주 관찰 종료 후 (2026-05-31) 재결정 (현 상태 유지)

산출물:
  reports/v0_2_phase2q_result_dedup_gemma_{cold,warm}_2026-05-24.csv
  tests/search_eval/baselines/v0_2_phase2q_result_dedup_2026-05-24.json (요약 + 사용자 결정 옵션)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 04:48:50 +00:00
..

Document Server 검색 평가셋 (search_eval)

Phase 1 산출물 — graded relevance baseline. peppy-hugging-nest.md Phase 1. Phase 2 모델 점검 (embedding / reranker / OCR-marker pipeline / STT) 의 객관 측정 도구.

개요

tests/search_eval/queries.yaml 은 Document Server 의 검색 품질을 정량 측정하기 위한 평가셋이다. 두 버전 존재:

  • v0.1 (2026-04-07) — binary relevance (relevant_ids set + top3_ids 보조). 23 case, 9 카테고리. corpus 753 documents 기준.
  • v0.2 (2026-05-23) — graded relevance (0~3 등급) + 사용자 권장 7 카테고리 + language / ocr_derived metadata. v0.1 호환 유지 (legacy_category + relevant_ids + top3_ids 컬럼 보존).

본 PR (PR-Eval-V0_2-Schema-Harness) = v0.1 23 case 의 v0.2 schema swap + run_eval graded harness 만. 신규 28 case (50+ 목표) + baseline 박제 + 약점 분석은 후속 PR (PR-Eval-V0_2-Baseline-Analysis).

Graded relevance 등급 (4단계)

Grade 의미 기준
3 highly relevant (top-3 강제) direct match — 본법/본 chapter/본 article. 검색 결과 top-3 에 반드시 포함돼야 함.
2 relevant (정답 후보) 보조 정답 — 시행령/규칙/관련 chapter. top-10 안 = OK.
1 marginal 약한 관련 — 같은 법령군이지만 다른 chapter, 같은 주제지만 다른 문서 유형. top-10 안 = 가점, 밖 = 무감점.
0 irrelevant 무관 — 검색 결과 잡히면 감점 후보 (현재는 표시만, 감점 미구현).

failure_expected 케이스는 graded_relevance 비움 ({}). 검색 결과 0건 = correct.

Graded score 계산식 (run_eval.py)

# graded NDCG@k — gain = 2^grade - 1
dcg = sum((2 ** grade - 1) / log2(rank + 1) for rank, doc_id ...)
idcg = sum((2 ** g - 1) / log2(rank + 1) for rank, g in sorted(grades, desc)[:k] ...)
ndcg = dcg / idcg

# graded recall@k — grade >= threshold 만 정답
recall_t2 = |{returned[:k]}  {grade>=2}| / |{grade>=2}|
recall_t3 = |{returned[:k]}  {grade>=3}| / |{grade>=3}|

카테고리 (7개)

Category 의미 측정 목적
standards 법령/규칙/기준 (산업안전보건법, ASME, KGS, NIST 등) exact_keyword + structured doc lookup
korean_only 한국어 자연어 query → 한국어 doc base semantic_search 품질
english_only 영어 자연어 query → 영어 doc base 영어 자료 검색
mixed 한국어+영어 혼합 OR ko query → en doc OR vice versa crosslingual 능력 (현재 0.53 미달 root cause 검증)
exam 가스기사 study 도메인 (study_questions 기반) 시험 문제 → 학습 자료 매칭
ocr_derived marker/OCR pipeline 통한 scanned PDF chunk 검색 Phase 2C 입력 — OCR 추출 품질이 검색에 미치는 영향
failure_expected 결과 0건 expected precision 측정 (no-result correctness)

legacy_category 컬럼으로 v0.1 9 카테고리 호환 보존 (분석 시 옛 분포 재현 가능).

v0.2 schema 컬럼

- id: kw_001
  query: "산업안전보건법 제6장"
  category: standards               # v0.2 7 카테고리 중 하나
  legacy_category: exact_keyword    # v0.1 카테고리 보존
  intent: fact_lookup               # semantic_search / fact_lookup / filter_browse
  domain_hint: document             # document / news / mixed
  language: ko                      # ko / en / mixed
  ocr_derived: false                # marker/OCR chunk 검색 케이스 여부
  failure_expected: false           # 결과 0건 expected
  graded_relevance:                 # {doc_id: grade} (0~3)
    3856: 3
    3868: 2
    3879: 2
  relevant_ids: [3856, 3868, 3879]  # v0.1 호환 (--eval-version v0.1 용)
  top3_ids: [3856]                  # v0.1 호환
  notes: |
    Act(3856) = grade 3 (본법, top-3 강제).
    Decree(3868)/Rule(3879) = grade 2 (보조 정답, 본법 우선).

신규 case 작성 가이드

  1. id 명명: 카테고리 prefix + 3-digit (예: std_006, ko_006, en_001, mix_006, exam_001, ocr_001, fail_004)
  2. graded_relevance 채점 사유 notes 필수 — 왜 그 등급 부여했는지 1~3줄 사람용 audit trail
  3. failure_expected = true 인 case 는 graded_relevance: {} (빈 dict)
  4. language 결정:
    • 순수 한국어 query + 한국어 doc → ko
    • 순수 영어 query + 영어 doc → en
    • 혼합 (한국어+영어 keyword OR ko query → en doc) → mixed
    • 다른 언어 (불어 등) 도 일단 mixed (PR-2 에서 enum 확장 검토)
  5. ocr_derived 결정: 정답 doc 중 documents.source_type='scanned' 또는 marker 처리 chunk 가 1개 이상이면 true
  6. 정답 doc_id 식별: GPU DB 의 documents 테이블에서 title ILIKE '%...%' 또는 metadata grep 으로 식별. graded 등급은 사람 판단.

Self-check (PR review)

  • notes 에 채점 사유 명시?
  • graded grade 분포 합리적 (모든 정답이 grade 3 = 의심)?
  • failure_expected case 의 graded_relevance 가 {} 인가?
  • legacy_category 보존 (v0.1 호환)?
  • language / ocr_derived 일관성?

v0.1 호환성

--eval-version v0.1 으로 옛 점수 (Recall@10 / MRR@10 / NDCG@10 / Top-3 hit-rate) 재현 가능. swap 후 옛 baseline 대비 ±0.001 이내여야 함.

load_queries() 의 v0.1 fallback:

  • yaml 에 graded_relevance 없고 relevant_ids 만 있으면, top3_ids 는 grade 3 / 나머지 relevant_ids 는 grade 2 로 자동 매핑.
  • 이 fallback 으로 v0.1 yaml 도 v0.2 mode 에서 graded score 산출 가능.

CLI 사용 예

export DOCSRV_TOKEN="eyJ..."

# 단일 평가, default = 두 mode 다 출력
.venv/bin/python tests/search_eval/run_eval.py \
    --base-url https://docs.hyungi.net \
    --output reports/baseline_v0_2_2026-05-23.csv

# v0.2 graded only
.venv/bin/python tests/search_eval/run_eval.py \
    --base-url https://docs.hyungi.net \
    --eval-version v0.2 \
    --output reports/v0_2_only.csv

# v0.1 mode (옛 점수 회귀 확인)
.venv/bin/python tests/search_eval/run_eval.py \
    --base-url https://docs.hyungi.net \
    --eval-version v0.1

# A/B 비교 (baseline vs candidate)
.venv/bin/python tests/search_eval/run_eval.py \
    --baseline-url https://docs.hyungi.net \
    --candidate-url http://localhost:8000 \
    --output reports/phase2a_vs_baseline.csv

Output (v0.2 mode 예)

=== single (n=23, scored=20, graded=20) ===
  -- v0.2 graded --
    NDCG@10 (graded)        : 0.812
    Recall@10 (grade>=2)    : 0.756
    Recall@10 (grade>=3)    : 0.900
  Latency p50: 240 ms
  Latency p95: 412 ms
  Failure-case precision: 3/3 (1.00) — empty result expected
  by category:
    failure_expected      n= 3  recall=0.00  ndcg=0.00  gndcg=0.00
    korean_only            n= 9  recall=0.73  ndcg=0.78  gndcg=0.81
    mixed                  n= 5  recall=0.53  ndcg=0.61  gndcg=0.65
    standards              n= 5  recall=0.90  ndcg=0.95  gndcg=0.95
    english_only           n= 1  recall=0.67  ndcg=0.75  gndcg=0.78
  by language:
    en         n= 1  recall=0.67  gndcg=0.78
    ko         n=15  recall=0.81  gndcg=0.85
    mixed      n= 4  recall=0.55  gndcg=0.63

(위는 예시 — 실제 baseline 박제는 후속 PR)

Baseline 박제 정책

위치: tests/search_eval/baselines/v{version}_{date}.{json,md}

각 baseline = 2 파일:

  • .json — 점수 raw (overall + by_category + by_language + by_ocr_derived)
  • .md — 약점 카테고리 분석 보고서 (사람용 audit)

박제 시점:

  • 평가셋 schema 변경 시 (v0.1 → v0.2 swap)
  • 검색 시스템 주요 변경 commit 직후 (모델 swap / chunks 재생성 / reranker 교체)
  • Phase 2A~D Diagnose PR 의 비교 baseline

박제 명명 컨벤션: v0_2_baseline_YYYY-MM-DD[_label].{json,md}. label 은 선택 (예: _after_embedding_swap, _chunks_md_content).

Phase 1 closure 조건 (feedback_quant_expectation_not_hard_gate)

본 PR (PR-Eval-V0_2-Schema-Harness):

  • v0.2 schema swap (23 case)
  • run_eval graded 함수 + --eval-version flag
  • README.md (본 파일)

후속 PR (PR-Eval-V0_2-Baseline-Analysis):

  • 50+ graded case 확정 (신규 28 case 작성)
  • baseline json + analysis md 박제
  • 약점 카테고리 보고서 — embedding-sensitive failure pattern 4 카테고리 식별:
    • candidate recall@k (embedding 후보 selection)
    • crosslingual miss (ko↔en mismatch)
    • Korean-English mismatch (한자/영문 fallback)
    • OCR-derived chunk miss

closure gate: 0.70 미달 OK — 원인 분해 보고서 (analysis md) 가 있으면 Phase 1 closure.

관련

  • Phase 1 plan: ~/.claude/plans/phase-1-graded-eval-v0-2.md
  • Parent plan: ~/.claude/plans/peppy-hugging-nest.md § Phase 1
  • 건강도 평가 (graded relevance 1년 미작성 gap): ~/.claude/projects/-Users-hyungi/memory/project_document_server_health_assessment.md:24
  • 발주건 단위 baseline (별 트랙, Phase 0 / merry-yawning-owl): queries_order_baseline.yaml + order_groups.yaml — 본 PR 영향 없음