Merge pull request 'feat(eval): v0.2 graded relevance schema + harness' (#24) from feat/eval-v0-2-graded-relevance into main
Reviewed-on: #24
This commit was merged in pull request #24.
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
# 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`)
|
||||
|
||||
```python
|
||||
# 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 컬럼
|
||||
|
||||
```yaml
|
||||
- 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 사용 예
|
||||
|
||||
```bash
|
||||
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):
|
||||
- [x] v0.2 schema swap (23 case)
|
||||
- [x] run_eval graded 함수 + --eval-version flag
|
||||
- [x] 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 영향 없음
|
||||
+313
-108
@@ -1,257 +1,462 @@
|
||||
# Document Server 검색 평가셋 v0.1
|
||||
# Document Server 검색 평가셋 v0.2 — graded relevance baseline
|
||||
#
|
||||
# Phase 0.2 산출물 — 정량 지표(Recall@10, MRR@10, NDCG@10) 측정용.
|
||||
# 각 쿼리는 실제 코퍼스(2026-04-07 시점, 753 documents)에서 추출한
|
||||
# 정답 doc_id와 함께 정의된다.
|
||||
# Phase 1 산출물 (peppy-hugging-nest.md Phase 1) — Phase 2 모델 점검과 Phase 3
|
||||
# Markdown 전환의 효과를 객관 측정할 도구 마련. graded relevance 1년 미작성
|
||||
# (document-server-2026-05-21.md:24) gap 해소.
|
||||
#
|
||||
# 메타데이터:
|
||||
# - 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 : 의도/배경 (사람용)
|
||||
# v0.2 schema 변경:
|
||||
# - category : 사용자 권장 7 카테고리
|
||||
# (standards / korean_only / english_only / mixed /
|
||||
# exam / ocr_derived / failure_expected)
|
||||
# - legacy_category : v0.1 카테고리 보존 (호환)
|
||||
# - language : ko / en / mixed
|
||||
# - ocr_derived : marker/OCR pipeline 통한 chunk 포함 여부
|
||||
# - graded_relevance : {doc_id: grade}
|
||||
# (0=irrelevant / 1=marginal / 2=relevant / 3=highly)
|
||||
# - failure_expected : 결과 0건 expected (별도 flag)
|
||||
#
|
||||
# 주의:
|
||||
# - 정답은 "현재 코퍼스에 실제 존재하는 문서"만 기재. 코퍼스가 바뀌면 갱신 필요.
|
||||
# - relevant_ids가 빈 리스트인 쿼리는 "결과 없어야 정상" 또는 "low confidence가 정상"인 케이스.
|
||||
# v0.1 호환:
|
||||
# - relevant_ids / top3_ids 는 deprecated 가 아니라 호환용 (--mode v0.1 재현)
|
||||
# - run_eval.py --mode v0.1 에서 옛 점수 ±0.001 재현 가능
|
||||
#
|
||||
# graded 등급 기준:
|
||||
# - 3 : highly relevant (top-3 강제, direct match — 본법/본 chapter/본 article)
|
||||
# - 2 : relevant (정답 후보, 보조 정답, top-10 within OK)
|
||||
# - 1 : marginal (약한 관련, top-10 within = 가점, 밖 = 무감점)
|
||||
# - 0 : irrelevant (검색 결과 잡히면 감점 후보, 현재는 표시만)
|
||||
|
||||
version: "0.1"
|
||||
created_at: "2026-04-07"
|
||||
corpus_size: 753
|
||||
version: "0.2"
|
||||
created_at: "2026-05-23"
|
||||
corpus_size: 753 # v0.1 기준. PR-Eval-V0_2-Baseline-Analysis 에서 갱신
|
||||
notes: |
|
||||
Phase 0.2 초기 평가셋. 22개 쿼리, 6개 카테고리.
|
||||
Phase 1 reranker 통합 후 NDCG@10 비교 baseline으로 사용.
|
||||
search_failure_logs(Phase 0.3)에서 자동 수집된 쿼리로 점진 확장 예정.
|
||||
Phase 1 plan: ~/.claude/plans/phase-1-graded-eval-v0-2.md
|
||||
본 PR (PR-Eval-V0_2-Schema-Harness) = v0.1 23 case → v0.2 23 case schema swap
|
||||
+ run_eval graded 함수 + README. 신규 28 case + baseline 박제는 후속 PR.
|
||||
|
||||
queries:
|
||||
# ─────────────────────────────────────────────────────────
|
||||
# 1. 정확 키워드 검색 (fact_lookup, document)
|
||||
# 1. standards (법령/규칙/기준) — v0.1 exact_keyword 흡수
|
||||
# ─────────────────────────────────────────────────────────
|
||||
- id: kw_001
|
||||
query: "산업안전보건법 제6장"
|
||||
category: exact_keyword
|
||||
category: standards
|
||||
legacy_category: exact_keyword
|
||||
intent: fact_lookup
|
||||
domain_hint: document
|
||||
language: ko
|
||||
ocr_derived: false
|
||||
failure_expected: false
|
||||
graded_relevance:
|
||||
3856: 3
|
||||
3868: 2
|
||||
3879: 2
|
||||
relevant_ids: [3856, 3868, 3879]
|
||||
top3_ids: [3856]
|
||||
notes: |
|
||||
"유해·위험 기계 등에 대한 조치"가 들어있는 6장.
|
||||
Act(3856), Decree(3868), Rule(3879) 모두 정답이지만 본법(Act)이 최우선.
|
||||
Act(3856) = grade 3 (본법, top-3 강제).
|
||||
Decree(3868)/Rule(3879) = grade 2 (보조 정답, 본법 우선).
|
||||
|
||||
- id: kw_002
|
||||
query: "중대재해 처벌 등에 관한 법률 제2장 중대산업재해"
|
||||
category: exact_keyword
|
||||
category: standards
|
||||
legacy_category: exact_keyword
|
||||
intent: fact_lookup
|
||||
domain_hint: document
|
||||
language: ko
|
||||
ocr_derived: false
|
||||
failure_expected: false
|
||||
graded_relevance:
|
||||
3917: 3
|
||||
3921: 2
|
||||
relevant_ids: [3917, 3921]
|
||||
top3_ids: [3917]
|
||||
notes: 본법 제2장(3917)이 정답. 시행령 동일 장(3921)도 허용.
|
||||
notes: |
|
||||
본법 제2장(3917) = grade 3.
|
||||
시행령 동일 장(3921) = grade 2.
|
||||
|
||||
- id: kw_003
|
||||
query: "화학물질관리법 유해화학물질 영업자"
|
||||
category: exact_keyword
|
||||
category: standards
|
||||
legacy_category: exact_keyword
|
||||
intent: fact_lookup
|
||||
domain_hint: document
|
||||
language: ko
|
||||
ocr_derived: false
|
||||
failure_expected: false
|
||||
graded_relevance:
|
||||
3981: 3
|
||||
relevant_ids: [3981]
|
||||
top3_ids: [3981]
|
||||
notes: 화학물질관리법 제4장 = 유해화학물질 영업자.
|
||||
notes: 화학물질관리법 제4장 = 유해화학물질 영업자 (3981, grade 3).
|
||||
|
||||
- id: kw_004
|
||||
query: "근로기준법 안전과 보건"
|
||||
category: exact_keyword
|
||||
category: standards
|
||||
legacy_category: exact_keyword
|
||||
intent: fact_lookup
|
||||
domain_hint: document
|
||||
language: ko
|
||||
ocr_derived: false
|
||||
failure_expected: false
|
||||
graded_relevance:
|
||||
4041: 3
|
||||
relevant_ids: [4041]
|
||||
top3_ids: [4041]
|
||||
notes: 근로기준법 제6장 = 안전과 보건.
|
||||
notes: 근로기준법 제6장 = 안전과 보건 (4041, grade 3).
|
||||
|
||||
- id: kw_005
|
||||
query: "산업안전보건기준에 관한 규칙 보호구"
|
||||
category: exact_keyword
|
||||
category: standards
|
||||
legacy_category: exact_keyword
|
||||
intent: fact_lookup
|
||||
domain_hint: document
|
||||
language: ko
|
||||
ocr_derived: false
|
||||
failure_expected: false
|
||||
graded_relevance:
|
||||
3888: 3
|
||||
relevant_ids: [3888]
|
||||
top3_ids: [3888]
|
||||
notes: 산업안전보건기준 규칙 제4장 = 보호구.
|
||||
notes: 산업안전보건기준 규칙 제4장 = 보호구 (3888, grade 3).
|
||||
|
||||
# ─────────────────────────────────────────────────────────
|
||||
# 2. 한국어 자연어 질의 (semantic_search, document)
|
||||
# 2. korean_only — v0.1 natural_language_ko + news_ko + other_domain 흡수
|
||||
# ─────────────────────────────────────────────────────────
|
||||
- id: nl_001
|
||||
query: "기계로 인한 산업재해 관련 법령"
|
||||
category: natural_language_ko
|
||||
category: korean_only
|
||||
legacy_category: natural_language_ko
|
||||
intent: semantic_search
|
||||
domain_hint: document
|
||||
language: ko
|
||||
ocr_derived: false
|
||||
failure_expected: false
|
||||
graded_relevance:
|
||||
3856: 3
|
||||
3868: 2
|
||||
3879: 2
|
||||
3854: 1
|
||||
relevant_ids: [3856, 3868, 3879, 3854]
|
||||
top3_ids: [3856]
|
||||
notes: |
|
||||
플랜의 대표 예시 쿼리. 기계 안전 = 산안법 6장(3856).
|
||||
4장 유해·위험 방지 조치(3854)도 의미상 관련.
|
||||
플랜의 대표 예시 쿼리. 기계 안전 = 산안법 6장(3856, grade 3).
|
||||
Decree(3868)/Rule(3879) = grade 2.
|
||||
4장 유해·위험 방지 조치(3854) = grade 1 (의미상 약한 관련).
|
||||
|
||||
- id: nl_002
|
||||
query: "사업주가 도급을 줄 때 산업재해를 예방하기 위해 해야 할 일"
|
||||
category: natural_language_ko
|
||||
category: korean_only
|
||||
legacy_category: natural_language_ko
|
||||
intent: semantic_search
|
||||
domain_hint: document
|
||||
language: ko
|
||||
ocr_derived: false
|
||||
failure_expected: false
|
||||
graded_relevance:
|
||||
3855: 3
|
||||
3867: 2
|
||||
3878: 2
|
||||
relevant_ids: [3855, 3867, 3878]
|
||||
top3_ids: [3855]
|
||||
notes: 산안법 제5장 도급 시 산업재해 예방.
|
||||
notes: 산안법 제5장 도급 시 산업재해 예방. Act(3855) = grade 3, Decree(3867)/Rule(3878) = grade 2.
|
||||
|
||||
- id: nl_003
|
||||
query: "유해화학물질을 다루는 회사가 지켜야 할 안전 의무"
|
||||
category: natural_language_ko
|
||||
category: korean_only
|
||||
legacy_category: natural_language_ko
|
||||
intent: semantic_search
|
||||
domain_hint: document
|
||||
language: ko
|
||||
ocr_derived: false
|
||||
failure_expected: false
|
||||
graded_relevance:
|
||||
3980: 2
|
||||
3981: 2
|
||||
3982: 2
|
||||
relevant_ids: [3980, 3981, 3982]
|
||||
notes: 화관법 제3-5장(유해화학물질 관리/영업자/사고 대응).
|
||||
notes: |
|
||||
화관법 제3-5장(유해화학물질 관리/영업자/사고 대응). 셋 다 동등하게 정답 후보.
|
||||
특정 chapter 강제 없음 → grade 2 셋 (top3_ids 없음).
|
||||
|
||||
- id: nl_004
|
||||
query: "중대재해가 발생했을 때 경영책임자가 처벌받는 기준"
|
||||
category: natural_language_ko
|
||||
category: korean_only
|
||||
legacy_category: natural_language_ko
|
||||
intent: semantic_search
|
||||
domain_hint: document
|
||||
language: ko
|
||||
ocr_derived: false
|
||||
failure_expected: false
|
||||
graded_relevance:
|
||||
3917: 3
|
||||
3916: 2
|
||||
3920: 2
|
||||
3921: 2
|
||||
relevant_ids: [3916, 3917, 3920, 3921]
|
||||
top3_ids: [3917]
|
||||
notes: 중대재해처벌법 본법+시행령 제1-2장.
|
||||
notes: 중대재해처벌법 본법+시행령 제1-2장. 제2장(3917) = grade 3, 나머지 = grade 2.
|
||||
|
||||
- id: nl_005
|
||||
query: "안전보건교육은 누가 받아야 하고 어떤 내용을 다루는가"
|
||||
category: natural_language_ko
|
||||
category: korean_only
|
||||
legacy_category: natural_language_ko
|
||||
intent: semantic_search
|
||||
domain_hint: document
|
||||
language: ko
|
||||
ocr_derived: false
|
||||
failure_expected: false
|
||||
graded_relevance:
|
||||
3853: 3
|
||||
3865: 2
|
||||
relevant_ids: [3853, 3865]
|
||||
top3_ids: [3853]
|
||||
notes: 산안법 제3장 안전보건교육 + 시행령 제3장.
|
||||
notes: 산안법 제3장 안전보건교육 (3853, grade 3) + 시행령 제3장 (3865, grade 2).
|
||||
|
||||
- id: news_001
|
||||
query: "이란과 미국의 군사 충돌"
|
||||
category: korean_only
|
||||
legacy_category: news_ko
|
||||
intent: semantic_search
|
||||
domain_hint: news
|
||||
language: ko
|
||||
ocr_derived: false
|
||||
failure_expected: false
|
||||
graded_relevance:
|
||||
4303: 2
|
||||
4304: 2
|
||||
4307: 2
|
||||
4316: 2
|
||||
4322: 2
|
||||
4323: 2
|
||||
4327: 2
|
||||
4335: 2
|
||||
relevant_ids: [4303, 4304, 4307, 4316, 4322, 4323, 4327, 4335]
|
||||
notes: |
|
||||
경향신문의 이란-미국 전쟁 보도. recall 위주 평가, 동등 정답 8건 (grade 2).
|
||||
diversity 제약 적용 후에도 최소 5건은 top-10에 들어와야 함.
|
||||
|
||||
- id: news_002
|
||||
query: "호르무즈 해협 봉쇄"
|
||||
category: korean_only
|
||||
legacy_category: news_ko
|
||||
intent: semantic_search
|
||||
domain_hint: news
|
||||
language: ko
|
||||
ocr_derived: false
|
||||
failure_expected: false
|
||||
graded_relevance:
|
||||
4316: 3
|
||||
4320: 2
|
||||
4322: 2
|
||||
4327: 2
|
||||
relevant_ids: [4316, 4320, 4322, 4327]
|
||||
top3_ids: [4316]
|
||||
notes: 호르무즈 해협 직접 언급 기사. 4316 = grade 3 (direct), 나머지 = grade 2.
|
||||
|
||||
- id: misc_001
|
||||
query: "강체의 평면 운동학"
|
||||
category: korean_only
|
||||
legacy_category: other_domain
|
||||
intent: fact_lookup
|
||||
domain_hint: document
|
||||
language: ko
|
||||
ocr_derived: false
|
||||
failure_expected: false
|
||||
graded_relevance:
|
||||
4063: 3
|
||||
4065: 2
|
||||
relevant_ids: [4063, 4065]
|
||||
top3_ids: [4063]
|
||||
notes: 공업역학 동역학 ch16(4063, grade 3), ch18(4065, grade 2).
|
||||
|
||||
- id: misc_002
|
||||
query: "질점의 운동역학"
|
||||
category: korean_only
|
||||
legacy_category: other_domain
|
||||
intent: semantic_search
|
||||
domain_hint: document
|
||||
language: ko
|
||||
ocr_derived: false
|
||||
failure_expected: false
|
||||
graded_relevance:
|
||||
4060: 2
|
||||
4061: 2
|
||||
4062: 2
|
||||
relevant_ids: [4060, 4061, 4062]
|
||||
notes: 공업역학 동역학 ch13~15 (질점 운동역학). 동등 정답 3건 (grade 2).
|
||||
|
||||
# ─────────────────────────────────────────────────────────
|
||||
# 3. 한국어 → 영어 crosslingual (semantic_search, document)
|
||||
# 3. english_only — v0.1 news_en 흡수
|
||||
# ─────────────────────────────────────────────────────────
|
||||
- id: news_003
|
||||
query: "Trump Iran ultimatum"
|
||||
category: english_only
|
||||
legacy_category: news_en
|
||||
intent: semantic_search
|
||||
domain_hint: news
|
||||
language: en
|
||||
ocr_derived: false
|
||||
failure_expected: false
|
||||
graded_relevance:
|
||||
4258: 2
|
||||
4260: 2
|
||||
4262: 2
|
||||
relevant_ids: [4258, 4260, 4262]
|
||||
notes: Der Spiegel 영어판 Iran 관련 기사. 동등 정답 3건 (grade 2).
|
||||
|
||||
# ─────────────────────────────────────────────────────────
|
||||
# 4. mixed — v0.1 crosslingual_ko_en + news_fr + news_crosslingual 흡수
|
||||
# ─────────────────────────────────────────────────────────
|
||||
- id: cl_001
|
||||
query: "기계 안전 가드 설계 원리"
|
||||
category: crosslingual_ko_en
|
||||
category: mixed
|
||||
legacy_category: crosslingual_ko_en
|
||||
intent: semantic_search
|
||||
domain_hint: document
|
||||
language: mixed
|
||||
ocr_derived: false
|
||||
failure_expected: false
|
||||
graded_relevance:
|
||||
3770: 3
|
||||
3856: 2
|
||||
relevant_ids: [3770, 3856]
|
||||
top3_ids: [3770]
|
||||
notes: |
|
||||
Industrial Safety and Health Management(7-ED) Ch15 Machine Guarding(3770)이
|
||||
한국어 쿼리로 검색되어야 함. 한국 산안법 6장(3856)도 관련.
|
||||
Industrial Safety and Health Management(7-ED) Ch15 Machine Guarding(3770, grade 3)이
|
||||
한국어 쿼리로 검색되어야 함. 한국 산안법 6장(3856) = grade 2.
|
||||
|
||||
- id: cl_002
|
||||
query: "산업 안전 입문서"
|
||||
category: crosslingual_ko_en
|
||||
category: mixed
|
||||
legacy_category: crosslingual_ko_en
|
||||
intent: semantic_search
|
||||
domain_hint: document
|
||||
language: mixed
|
||||
ocr_derived: false
|
||||
failure_expected: false
|
||||
graded_relevance:
|
||||
3755: 2
|
||||
3775: 2
|
||||
3776: 2
|
||||
3777: 2
|
||||
relevant_ids: [3755, 3775, 3776, 3777]
|
||||
notes: |
|
||||
Safety and Health for Engineers / Industrial Safety and Health Management
|
||||
영문 교재 입문 챕터들이 한국어 쿼리로 검색되어야 함.
|
||||
영문 교재 입문 챕터들이 한국어 쿼리로 검색되어야 함. 동등 정답 4건 (grade 2).
|
||||
|
||||
- id: cl_003
|
||||
query: "전기 안전 위험"
|
||||
category: crosslingual_ko_en
|
||||
category: mixed
|
||||
legacy_category: crosslingual_ko_en
|
||||
intent: semantic_search
|
||||
domain_hint: document
|
||||
language: mixed
|
||||
ocr_derived: false
|
||||
failure_expected: false
|
||||
graded_relevance:
|
||||
3772: 2
|
||||
3790: 2
|
||||
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
|
||||
category: mixed
|
||||
legacy_category: news_fr
|
||||
intent: semantic_search
|
||||
domain_hint: news
|
||||
language: mixed # 정확히는 fr 이지만 v0.2 enum (ko/en/mixed) 에서 mixed 매핑
|
||||
ocr_derived: false
|
||||
failure_expected: false
|
||||
graded_relevance:
|
||||
4199: 2
|
||||
4202: 2
|
||||
4210: 2
|
||||
4361: 2
|
||||
4363: 2
|
||||
4507: 2
|
||||
4519: 2
|
||||
4521: 2
|
||||
relevant_ids: [4199, 4202, 4210, 4361, 4363, 4507, 4519, 4521]
|
||||
notes: Le Monde 불어 Iran 전쟁 보도.
|
||||
notes: |
|
||||
Le Monde 불어 Iran 전쟁 보도. 동등 정답 8건 (grade 2).
|
||||
불어 query 라 v0.2 의 ko/en/mixed enum 에서는 mixed 매핑 (정확히는 fr).
|
||||
후속 PR 에서 language enum 확장 검토.
|
||||
|
||||
- id: news_005
|
||||
query: "이란 미국 전쟁 글로벌 반응"
|
||||
category: news_crosslingual
|
||||
category: mixed
|
||||
legacy_category: news_crosslingual
|
||||
intent: semantic_search
|
||||
domain_hint: news
|
||||
language: mixed
|
||||
ocr_derived: false
|
||||
failure_expected: false
|
||||
graded_relevance:
|
||||
4202: 2
|
||||
4258: 2
|
||||
4262: 2
|
||||
4536: 2
|
||||
4303: 2
|
||||
4304: 2
|
||||
4316: 2
|
||||
relevant_ids: [4202, 4258, 4262, 4536, 4303, 4304, 4316]
|
||||
notes: |
|
||||
한국어 쿼리로 한/영/불/독 뉴스가 골고루 검색되어야 함.
|
||||
한국어 쿼리로 한/영/불/독 뉴스가 골고루 검색되어야 함. 동등 정답 7건 (grade 2).
|
||||
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 기대)
|
||||
# 5. failure_expected — 결과 0건 expected
|
||||
# ─────────────────────────────────────────────────────────
|
||||
- id: fail_001
|
||||
query: "Rust async runtime tokio scheduler 내부 구조"
|
||||
category: failure_expected
|
||||
legacy_category: failure_expected
|
||||
intent: semantic_search
|
||||
domain_hint: document
|
||||
language: mixed # 한국어 + 영문 keyword 혼합
|
||||
ocr_derived: false
|
||||
failure_expected: true
|
||||
graded_relevance: {}
|
||||
relevant_ids: []
|
||||
notes: |
|
||||
코퍼스에 Rust/프로그래밍 문서 없음. Phase 0.3 search_failure_logs로 자동 수집되어야 함.
|
||||
코퍼스에 Rust/프로그래밍 문서 없음. 결과 0건 = correct.
|
||||
Phase 0.3 search_failure_logs로 자동 수집되어야 함.
|
||||
Phase 1+에서 confidence 점수 < 0.5로 분류되는지 확인.
|
||||
|
||||
- id: fail_002
|
||||
query: "양자컴퓨터 큐비트 디코히어런스"
|
||||
category: failure_expected
|
||||
legacy_category: failure_expected
|
||||
intent: semantic_search
|
||||
domain_hint: document
|
||||
language: ko
|
||||
ocr_derived: false
|
||||
failure_expected: true
|
||||
graded_relevance: {}
|
||||
relevant_ids: []
|
||||
notes: 코퍼스에 양자물리 문서 없음.
|
||||
notes: 코퍼스에 양자물리 문서 없음. 결과 0건 = correct.
|
||||
|
||||
- id: fail_003
|
||||
query: "재즈 보컬리스트 빌리 홀리데이"
|
||||
category: failure_expected
|
||||
legacy_category: failure_expected
|
||||
intent: semantic_search
|
||||
domain_hint: news
|
||||
language: ko
|
||||
ocr_derived: false
|
||||
failure_expected: true
|
||||
graded_relevance: {}
|
||||
relevant_ids: []
|
||||
notes: 코퍼스에 음악/재즈 문서 없음.
|
||||
notes: 코퍼스에 음악/재즈 문서 없음. 결과 0건 = correct.
|
||||
|
||||
# ─────────────────────────────────────────────────────────
|
||||
# 신규 카테고리 (후속 PR-Eval-V0_2-Baseline-Analysis 에서 batch 작성):
|
||||
# - exam : 가스기사 study 도메인 (study_questions 기반)
|
||||
# - ocr_derived : marker/OCR pipeline 통한 scanned PDF chunk 검색
|
||||
# - english_only : 영어 standards / 자연어 (현재 news_en 1건만)
|
||||
# ─────────────────────────────────────────────────────────
|
||||
|
||||
+233
-13
@@ -68,6 +68,12 @@ class Query:
|
||||
relevant_ids: list[int]
|
||||
top3_ids: list[int] = field(default_factory=list)
|
||||
notes: str = ""
|
||||
# v0.2 schema additions (Phase 1 — graded relevance baseline)
|
||||
legacy_category: str = ""
|
||||
language: str = "ko"
|
||||
ocr_derived: bool = False
|
||||
graded_relevance: dict[int, int] = field(default_factory=dict)
|
||||
failure_expected: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -80,6 +86,10 @@ class QueryResult:
|
||||
mrr_at_10: float
|
||||
ndcg_at_10: float
|
||||
top3_hit: bool
|
||||
# v0.2 graded scores (Phase 1)
|
||||
graded_ndcg_at_10: float = 0.0
|
||||
graded_recall_at_10_t2: float = 0.0
|
||||
graded_recall_at_10_t3: float = 0.0
|
||||
error: str | None = None
|
||||
|
||||
|
||||
@@ -124,6 +134,48 @@ def ndcg_at_k(returned: list[int], relevant: list[int], k: int = 10) -> float:
|
||||
return dcg / idcg if idcg > 0 else 0.0
|
||||
|
||||
|
||||
def graded_ndcg_at_k(returned: list[int], grades: dict[int, int], k: int = 10) -> float:
|
||||
"""graded NDCG@k. grades[doc_id] in {0,1,2,3}. v0.2 산출물.
|
||||
|
||||
gain = 2^grade - 1 (grade=0 → gain=0, grade=3 → gain=7).
|
||||
ideal DCG = grades 를 grade 내림차순으로 top-k 채운 경우.
|
||||
grades 비어 있으면 0.0 (failure_expected 케이스는 별도 처리).
|
||||
"""
|
||||
if not grades:
|
||||
return 0.0
|
||||
dcg = 0.0
|
||||
for rank, doc_id in enumerate(returned[:k], start=1):
|
||||
grade = grades.get(doc_id, 0)
|
||||
if grade > 0:
|
||||
dcg += (2 ** grade - 1) / math.log2(rank + 1)
|
||||
sorted_grades = sorted(grades.values(), reverse=True)[:k]
|
||||
idcg = sum(
|
||||
(2 ** g - 1) / math.log2(r + 1)
|
||||
for r, g in enumerate(sorted_grades, start=1)
|
||||
if g > 0
|
||||
)
|
||||
return dcg / idcg if idcg > 0 else 0.0
|
||||
|
||||
|
||||
def graded_recall_at_k(
|
||||
returned: list[int],
|
||||
grades: dict[int, int],
|
||||
threshold: int = 2,
|
||||
k: int = 10,
|
||||
) -> float:
|
||||
"""grade >= threshold 만 정답으로 본 recall@k. v0.2 산출물.
|
||||
|
||||
threshold=2 → grade 2/3 만 정답 (relevant 이상).
|
||||
threshold=3 → grade 3 만 정답 (highly relevant 만).
|
||||
"""
|
||||
relevant_set = {doc_id for doc_id, g in grades.items() if g >= threshold}
|
||||
if not relevant_set:
|
||||
return 1.0 if not returned else 0.0
|
||||
top_k = set(returned[:k])
|
||||
hits = sum(1 for doc_id in relevant_set if doc_id in top_k)
|
||||
return hits / len(relevant_set)
|
||||
|
||||
|
||||
def top3_hit(returned: list[int], top3_ids: list[int]) -> bool:
|
||||
"""top3_ids가 비어있으면 True (체크 안함). 있으면 그 중 하나라도 top-3에 들어와야 함."""
|
||||
if not top3_ids:
|
||||
@@ -205,6 +257,13 @@ async def evaluate(
|
||||
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),
|
||||
graded_ndcg_at_10=graded_ndcg_at_k(returned_ids, q.graded_relevance, 10),
|
||||
graded_recall_at_10_t2=graded_recall_at_k(
|
||||
returned_ids, q.graded_relevance, threshold=2, k=10
|
||||
),
|
||||
graded_recall_at_10_t3=graded_recall_at_k(
|
||||
returned_ids, q.graded_relevance, threshold=3, k=10
|
||||
),
|
||||
)
|
||||
)
|
||||
except Exception as exc:
|
||||
@@ -241,8 +300,18 @@ def percentile(values: list[float], p: float) -> float:
|
||||
return s[f] + (s[c] - s[f]) * (k - f)
|
||||
|
||||
|
||||
def print_summary(label: str, results: list[QueryResult]) -> dict[str, float]:
|
||||
"""전체 + 카테고리별 요약 출력. 집계 dict 반환."""
|
||||
def print_summary(
|
||||
label: str,
|
||||
results: list[QueryResult],
|
||||
eval_version: str = "both",
|
||||
) -> dict[str, Any]:
|
||||
"""전체 + 카테고리별 요약 출력. 집계 dict 반환.
|
||||
|
||||
eval_version:
|
||||
v0.1 — binary score 만 출력 (옛 점수 회귀 확인용)
|
||||
v0.2 — graded score + language/ocr_derived 별 집계 (Phase 1)
|
||||
both — 둘 다 출력 (default, baseline 박제용)
|
||||
"""
|
||||
n = len(results)
|
||||
if n == 0:
|
||||
return {}
|
||||
@@ -251,11 +320,30 @@ def print_summary(label: str, results: list[QueryResult]) -> dict[str, float]:
|
||||
scored = [r for r in results if r.query.relevant_ids]
|
||||
failure_cases = [r for r in results if not r.query.relevant_ids]
|
||||
|
||||
# v0.1 binary scores
|
||||
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
|
||||
|
||||
# v0.2 graded scores (graded_relevance 있는 케이스만 평균)
|
||||
graded_scored = [r for r in results if r.query.graded_relevance]
|
||||
avg_gndcg = (
|
||||
statistics.mean([r.graded_ndcg_at_10 for r in graded_scored])
|
||||
if graded_scored
|
||||
else 0.0
|
||||
)
|
||||
avg_grecall_t2 = (
|
||||
statistics.mean([r.graded_recall_at_10_t2 for r in graded_scored])
|
||||
if graded_scored
|
||||
else 0.0
|
||||
)
|
||||
avg_grecall_t3 = (
|
||||
statistics.mean([r.graded_recall_at_10_t3 for r in graded_scored])
|
||||
if graded_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)
|
||||
@@ -266,11 +354,23 @@ def print_summary(label: str, results: list[QueryResult]) -> dict[str, float]:
|
||||
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}")
|
||||
show_v01 = eval_version in ("v0.1", "both")
|
||||
show_v02 = eval_version in ("v0.2", "both")
|
||||
|
||||
print(
|
||||
f"\n=== {label} (n={n}, scored={len(scored)}, graded={len(graded_scored)}) ==="
|
||||
)
|
||||
if show_v01:
|
||||
print(" -- v0.1 binary --")
|
||||
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}")
|
||||
if show_v02:
|
||||
print(" -- v0.2 graded --")
|
||||
print(f" NDCG@10 (graded) : {avg_gndcg:.3f}")
|
||||
print(f" Recall@10 (grade>=2) : {avg_grecall_t2:.3f}")
|
||||
print(f" Recall@10 (grade>=3) : {avg_grecall_t3:.3f}")
|
||||
print(f" Latency p50: {p50:.0f} ms")
|
||||
print(f" Latency p95: {p95:.0f} ms")
|
||||
if failure_cases:
|
||||
@@ -283,13 +383,82 @@ def print_summary(label: str, results: list[QueryResult]) -> dict[str, float]:
|
||||
by_cat: dict[str, list[QueryResult]] = {}
|
||||
for r in scored:
|
||||
by_cat.setdefault(r.query.category, []).append(r)
|
||||
by_cat_map: dict[str, dict[str, Any]] = {}
|
||||
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}"
|
||||
graded_items = [r for r in items if r.query.graded_relevance]
|
||||
cat_gndcg = (
|
||||
statistics.mean([r.graded_ndcg_at_10 for r in graded_items])
|
||||
if graded_items
|
||||
else 0.0
|
||||
)
|
||||
by_cat_map[cat] = {
|
||||
"n": len(items),
|
||||
"recall_at_10": cat_recall,
|
||||
"ndcg_at_10": cat_ndcg,
|
||||
"graded_ndcg_at_10": cat_gndcg,
|
||||
}
|
||||
if show_v02:
|
||||
print(
|
||||
f" {cat:<22} n={len(items):>2} recall={cat_recall:.2f} ndcg={cat_ndcg:.2f} gndcg={cat_gndcg:.2f}"
|
||||
)
|
||||
else:
|
||||
print(
|
||||
f" {cat:<22} n={len(items):>2} recall={cat_recall:.2f} ndcg={cat_ndcg:.2f}"
|
||||
)
|
||||
|
||||
# v0.2: language 별
|
||||
by_lang_map: dict[str, dict[str, Any]] = {}
|
||||
if show_v02:
|
||||
by_lang: dict[str, list[QueryResult]] = {}
|
||||
for r in scored:
|
||||
by_lang.setdefault(r.query.language, []).append(r)
|
||||
if by_lang:
|
||||
print(" by language:")
|
||||
for lang, items in sorted(by_lang.items()):
|
||||
lang_recall = statistics.mean([r.recall_at_10 for r in items])
|
||||
graded_items = [r for r in items if r.query.graded_relevance]
|
||||
lang_gndcg = (
|
||||
statistics.mean([r.graded_ndcg_at_10 for r in graded_items])
|
||||
if graded_items
|
||||
else 0.0
|
||||
)
|
||||
by_lang_map[lang] = {
|
||||
"n": len(items),
|
||||
"recall_at_10": lang_recall,
|
||||
"graded_ndcg_at_10": lang_gndcg,
|
||||
}
|
||||
print(
|
||||
f" {lang:<10} n={len(items):>2} recall={lang_recall:.2f} gndcg={lang_gndcg:.2f}"
|
||||
)
|
||||
|
||||
# v0.2: ocr_derived 별
|
||||
by_ocr_map: dict[str, dict[str, Any]] = {}
|
||||
if show_v02:
|
||||
by_ocr: dict[bool, list[QueryResult]] = {}
|
||||
for r in scored:
|
||||
by_ocr.setdefault(r.query.ocr_derived, []).append(r)
|
||||
# OCR-derived 케이스가 1개 이상일 때만 표시
|
||||
if any(flag for flag in by_ocr.keys()):
|
||||
print(" by ocr_derived:")
|
||||
for flag, items in sorted(by_ocr.items()):
|
||||
ocr_recall = statistics.mean([r.recall_at_10 for r in items])
|
||||
graded_items = [r for r in items if r.query.graded_relevance]
|
||||
ocr_gndcg = (
|
||||
statistics.mean([r.graded_ndcg_at_10 for r in graded_items])
|
||||
if graded_items
|
||||
else 0.0
|
||||
)
|
||||
by_ocr_map[str(flag).lower()] = {
|
||||
"n": len(items),
|
||||
"recall_at_10": ocr_recall,
|
||||
"graded_ndcg_at_10": ocr_gndcg,
|
||||
}
|
||||
print(
|
||||
f" {str(flag).lower():<10} n={len(items):>2} recall={ocr_recall:.2f} gndcg={ocr_gndcg:.2f}"
|
||||
)
|
||||
|
||||
# 에러 케이스
|
||||
errors = [r for r in results if r.error]
|
||||
@@ -300,13 +469,21 @@ def print_summary(label: str, results: list[QueryResult]) -> dict[str, float]:
|
||||
|
||||
return {
|
||||
"n": n,
|
||||
"n_scored": len(scored),
|
||||
"n_graded": len(graded_scored),
|
||||
"recall_at_10": avg_recall,
|
||||
"mrr_at_10": avg_mrr,
|
||||
"ndcg_at_10": avg_ndcg,
|
||||
"top3_hit_rate": top3_rate,
|
||||
"graded_ndcg_at_10": avg_gndcg,
|
||||
"graded_recall_at_10_t2": avg_grecall_t2,
|
||||
"graded_recall_at_10_t3": avg_grecall_t3,
|
||||
"latency_p50": p50,
|
||||
"latency_p95": p95,
|
||||
"failure_precision": failure_precision,
|
||||
"by_category": by_cat_map,
|
||||
"by_language": by_lang_map,
|
||||
"by_ocr_derived": by_ocr_map,
|
||||
}
|
||||
|
||||
|
||||
@@ -319,35 +496,54 @@ def write_csv(results: list[QueryResult], output_path: Path) -> None:
|
||||
"label",
|
||||
"id",
|
||||
"category",
|
||||
"legacy_category",
|
||||
"intent",
|
||||
"domain_hint",
|
||||
"language",
|
||||
"ocr_derived",
|
||||
"failure_expected",
|
||||
"query",
|
||||
"relevant_ids",
|
||||
"graded_relevance",
|
||||
"returned_ids_top10",
|
||||
"latency_ms",
|
||||
"recall_at_10",
|
||||
"mrr_at_10",
|
||||
"ndcg_at_10",
|
||||
"top3_hit",
|
||||
"graded_ndcg_at_10",
|
||||
"graded_recall_at_10_t2",
|
||||
"graded_recall_at_10_t3",
|
||||
"error",
|
||||
]
|
||||
)
|
||||
for r in results:
|
||||
graded_str = ";".join(
|
||||
f"{did}:{g}" for did, g in sorted(r.query.graded_relevance.items())
|
||||
)
|
||||
writer.writerow(
|
||||
[
|
||||
r.label,
|
||||
r.query.id,
|
||||
r.query.category,
|
||||
r.query.legacy_category,
|
||||
r.query.intent,
|
||||
r.query.domain_hint,
|
||||
r.query.language,
|
||||
"1" if r.query.ocr_derived else "0",
|
||||
"1" if r.query.failure_expected else "0",
|
||||
r.query.query,
|
||||
";".join(map(str, r.query.relevant_ids)),
|
||||
graded_str,
|
||||
";".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",
|
||||
f"{r.graded_ndcg_at_10:.3f}",
|
||||
f"{r.graded_recall_at_10_t2:.3f}",
|
||||
f"{r.graded_recall_at_10_t3:.3f}",
|
||||
r.error or "",
|
||||
]
|
||||
)
|
||||
@@ -364,6 +560,15 @@ def load_queries(yaml_path: Path) -> list[Query]:
|
||||
data = yaml.safe_load(f)
|
||||
queries: list[Query] = []
|
||||
for q in data["queries"]:
|
||||
relevant_ids = q.get("relevant_ids", []) or []
|
||||
graded_raw = q.get("graded_relevance", {}) or {}
|
||||
graded = {int(k): int(v) for k, v in graded_raw.items()}
|
||||
# v0.1 fallback: if no graded_relevance but has relevant_ids,
|
||||
# treat top3_ids as grade 3 and remaining relevant_ids as grade 2.
|
||||
if not graded and relevant_ids:
|
||||
top3 = set(q.get("top3_ids", []) or [])
|
||||
for rid in relevant_ids:
|
||||
graded[int(rid)] = 3 if int(rid) in top3 else 2
|
||||
queries.append(
|
||||
Query(
|
||||
id=q["id"],
|
||||
@@ -371,9 +576,17 @@ def load_queries(yaml_path: Path) -> list[Query]:
|
||||
category=q["category"],
|
||||
intent=q["intent"],
|
||||
domain_hint=q["domain_hint"],
|
||||
relevant_ids=q.get("relevant_ids", []) or [],
|
||||
relevant_ids=relevant_ids,
|
||||
top3_ids=q.get("top3_ids", []) or [],
|
||||
notes=q.get("notes", "") or "",
|
||||
# v0.2 columns (graceful default for v0.1 yaml)
|
||||
legacy_category=q.get("legacy_category", q.get("category", "")) or "",
|
||||
language=q.get("language", "ko") or "ko",
|
||||
ocr_derived=bool(q.get("ocr_derived", False)),
|
||||
graded_relevance=graded,
|
||||
failure_expected=bool(
|
||||
q.get("failure_expected", not relevant_ids)
|
||||
),
|
||||
)
|
||||
)
|
||||
return queries
|
||||
@@ -1046,6 +1259,13 @@ def main() -> int:
|
||||
action="store_true",
|
||||
help="검색 API debug=true 요청 (발주건 모드에서 응답 검증용)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--eval-version",
|
||||
type=str,
|
||||
default="both",
|
||||
choices=["v0.1", "v0.2", "both"],
|
||||
help="점수 출력 모드 (Phase 1, default both). v0.1=binary only / v0.2=graded only / both=둘 다",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.token:
|
||||
@@ -1100,14 +1320,14 @@ def main() -> int:
|
||||
results = asyncio.run(
|
||||
evaluate(queries, args.base_url, args.token, "single", mode=args.mode, fusion=args.fusion, rerank=args.rerank, analyze=args.analyze)
|
||||
)
|
||||
print_summary("single", results)
|
||||
print_summary("single", results, eval_version=args.eval_version)
|
||||
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, fusion=args.fusion, rerank=args.rerank, analyze=args.analyze)
|
||||
)
|
||||
baseline_summary = print_summary("baseline", baseline_results)
|
||||
baseline_summary = print_summary("baseline", baseline_results, eval_version=args.eval_version)
|
||||
|
||||
print(f"\n>>> candidate: {args.candidate_url}")
|
||||
candidate_results = asyncio.run(
|
||||
@@ -1115,7 +1335,7 @@ def main() -> int:
|
||||
queries, args.candidate_url, args.token, "candidate", mode=args.mode, fusion=args.fusion, rerank=args.rerank, analyze=args.analyze
|
||||
)
|
||||
)
|
||||
candidate_summary = print_summary("candidate", candidate_results)
|
||||
candidate_summary = print_summary("candidate", candidate_results, eval_version=args.eval_version)
|
||||
|
||||
# 델타
|
||||
print("\n=== Δ (candidate - baseline) ===")
|
||||
|
||||
Reference in New Issue
Block a user