feat(eval): 변형 일관성(query-paraphrase robustness) 평가 프레임워크 #193
10
README.md
10
README.md
@@ -84,16 +84,16 @@ kebab doctor
|
||||
| 명령 | 동작 |
|
||||
|------|------|
|
||||
| `kebab init` | XDG 경로에 데이터 디렉토리 + config.toml 생성 |
|
||||
| `kebab ingest [<path>]` | Markdown / 이미지 / PDF / Rust 소스코드 색인 (idempotent). TTY 에서는 stderr 진행 바, non-TTY (CI / pipe) 는 stderr 한 줄씩, `--json` 은 stdout 에 `ingest_progress.v1` 라인 streaming 후 마지막에 `ingest_report.v1`. Ctrl-C 한 번이면 현재 asset 마무리 후 abort (부분 commit 보존, idempotent re-run), 두 번째 Ctrl-C 는 hard exit. Markdown title 이 frontmatter 에 없어도 첫 H1 → H2 → 첫 paragraph 80 자 → 파일명 순으로 자동 채움 (parser_version `md-frontmatter-v2`) — 기존 색인된 doc 도 다음 ingest 에서 새 title 로 갱신. **Incremental** (p9-fb-23): 두 번째 이후의 ingest 는 변하지 않은 doc (blake3 + parser/chunker/embedder version 모두 동일) 의 parse/chunk/embed/vector upsert 를 자동 스킵. final summary 에 `N unchanged` 카운트 표시. `--force-reingest` 로 skip 무시 강제 재처리. **지원 형식** (extractor 자동 결정 — config 에 명시 불가): Markdown (`.md`), 이미지 (`.png` / `.jpg` / `.jpeg`, OCR + caption), PDF (`.pdf`), **소스코드** (`.rs` → `code-rust-ast-v1`, `.py` → `code-python-ast-v1`, `.ts`/`.tsx` → `code-ts-ast-v1`, `.js`/`.mjs`/`.cjs`/`.jsx` → `code-js-ast-v1`, `.go` → `code-go-ast-v1`, `.java` → `code-java-ast-v1`, `.kt`/`.kts` → `code-kotlin-ast-v1`, `.c`/`.h` → `code-c-ast-v1`, `.cpp`/`.cc`/`.cxx`/`.hpp`/`.hh`/`.hxx` → `code-cpp-ast-v1` — 모두 tree-sitter AST chunker; **Tier 2 리소스 파일**: `.yaml`/`.yml` → `k8s-manifest-resource-v1` (apiVersion+kind 파싱), `Dockerfile`/`Dockerfile.*`/`*.dockerfile` → `dockerfile-file-v1` (전체 파일), `Cargo.toml`/`pyproject.toml`/`.toml`/`package.json`/`tsconfig.json`/`.json`/`pom.xml`/`.xml`/`build.gradle`/`.gradle`/`go.mod` → `manifest-file-v1` (전체 파일) — yaml (k8s) / dockerfile / toml / json / xml / groovy / go-mod 지원); **Tier 3 paragraph fallback** (`.sh`/`.bash`/`.zsh` → `code-text-paragraph-v1`, blank-line paragraph split + 80-line/20-overlap line-window. Tier 1/2 가 0 chunk 또는 Err 시 자동 fallback — 비-k8s YAML 같은 케이스 picked up. symbol = None, lang 은 원본 보존.). 다른 확장자는 자동 skip — `IngestItem.warnings` 에 사유 (`"unsupported media type: .docx"` 등), `IngestReport.skipped_by_extension` 에 카운트 분류, CLI / TUI summary 에 breakdown 표시. 코드 chunk 는 `citation.kind = "code"` 에 `citation.lang = "<lang>"` + `symbol` + line range 를 담고, SearchHit top-level 에 `code_lang` + `repo` (`.git/` walk-up 의 디렉토리 이름) 가 backfill 됨. `--code-lang rust` / `--code-lang python` / `--code-lang typescript` / `--code-lang javascript` / `--code-lang go` / `--code-lang java` / `--code-lang kotlin` / `--code-lang yaml` / `--code-lang dockerfile` / `--code-lang toml` / `--code-lang json` / `--code-lang xml` / `--code-lang groovy` / `--code-lang go-mod` / `--code-lang shell` / `--code-lang c` / `--code-lang cpp` / `--media code` filter 로 언어별·코드 전용 검색 가능 (p10-1A-1 filter flags). Python symbol 은 workspace 경로 → dotted module path prefix (예: `kebab_eval.metrics.compute_mrr`), TS/JS symbol 은 slash-style module path prefix (예: `src/Foo.Foo.search`), Go symbol 은 `package.Func` / `package.(*Receiver).Method` 형식, Java / Kotlin symbol 은 `com.foo.Foo.bar` 형식 (패키지 + 클래스 + 메서드/필드). |
|
||||
| `kebab search --mode {lexical,vector,hybrid} "<query>" [--no-cache] [--max-tokens N] [--snippet-chars N] [--cursor <opaque>] [--tag T] [--lang L] [--path-glob G] [--trust-min LEVEL] [--media TYPE] [--ingested-after RFC3339] [--doc-id ID] [--trace] [--bulk] [--repo NAME ...] [--code-lang LIST]` | 검색. hybrid는 RRF fusion, citation 포함. 같은 process 안에서 동일 query (NFKC + trim + lowercase 정규화) 반복 시 in-process LRU 캐시 hit (capacity = `[search] cache_capacity`, default 256). `--no-cache` 로 강제 bypass — 디버깅용. ingest commit 발생 시 `kv['corpus_revision']` bump 으로 모든 entry 자동 stale. **`--max-tokens` / `--snippet-chars` / `--cursor` (p9-fb-34)** — agent budget controls. `--json` 출력은 `search_response.v1` wrapper (`{hits, next_cursor, truncated}`) — pre-fb-34 의 bare array 와 호환 안 됨. mismatched cursor → `error.v1.code = stale_cursor`. **filter flags (p9-fb-36):** `--tag` 는 반복 가능 flag (`--tag rust --tag async`) 로 OR 매칭, `--media` 는 `,` 구분 다중 값 OR 매칭, 나머지 flags 간은 AND 조합. `--trust-min` 은 `primary\|secondary\|generated` 중 하나 (해당 level 이상 포함). `--ingested-after` 는 RFC3339 UTC — 파싱 실패 시 `error.v1.code = config_invalid` (exit 2). `--media md` 는 `markdown` alias 로 정규화. 알 수 없는 `--media` 값은 무조건 empty hits (오류 아님). **`--trace` (p9-fb-37)** — `search_response.v1.trace` 에 lexical / vector pre-fusion 후보 + RRF union + per-stage timing (`lexical_ms` / `vector_ms` / `fusion_ms` / `total_ms`) 노출. trace 요청은 캐시 우회 (`--no-cache` 없이도 항상 cold). **`--bulk` (p9-fb-42)** — stdin ndjson 으로 N query 한 번에 실행. `--json` 면 stdout per-query ndjson (`bulk_search_item.v1`) + stderr summary (`bulk_summary: total=N succeeded=S failed=F`). Cap 100. agent 가 query decomposition 후 sub-query 일괄 실행 시 single round-trip — App instance 재사용으로 캐시 / embedder cold-start 비용 한 번만. Per-query failure 는 item 의 `error` (error.v1) 에 격리, 다른 query 계속 진행. 입력은 stdin ndjson — 줄당 한 query object, `{"query":"<text>"}` 만 필수 (string; nested object 아님), `mode`/`k`/`trust_min`/`ingested_after`/`media`/`tag`/`lang` optional (`docs/wire-schema/v1/bulk_search_input.schema.json`). 예: `echo '{"query":"한국","mode":"lexical","k":3}' | kebab search --bulk --json`. **code corpus filters (p10-1A-1):** `--repo` 는 반복 가능 (`--repo kebab --repo other`) OR 매칭. `--code-lang` 는 반복 또는 comma 다중 값 (`--code-lang rust,python`), 알 수 없는 값은 빈 hits. `--media code` 는 Tier 1/2/3 모든 code chunk 포함. 1A-1 시점에서는 indexed 된 code chunk 가 없어 filter 가 항상 빈 결과 — 1A-2 (Rust AST chunker) 머지 이후 실효. **v0.20.1 V009 morphological tokenizer (한국어 + 영어 동작 변경):** `chunks_fts` 가 FTS5 `unicode61` + 한국어 lindera ko-dic 형태소 분석 결과를 별 column 으로 prepend. **한국어 2자 query 지원** — '한국', '서울', '지하철' 같은 2자/3자 단어가 형태소 분해 후 hit. **영어는 whole-token 매칭** — V002 동작으로 회귀 (`tokenizer` query 는 `tokenizer` 토큰만 hit, `token` 같은 substring 은 hit X). substring recall 이 필요하면 vector/hybrid mode 권장. `kebab.sqlite` 파일 크기는 lindera ko-dic embedded dict 와 tokenized_korean_text column 의존성으로 다소 증가. V009 자동 backfill (`App::open_with_config` 의 first-boot hook) — re-ingest 불필요. |
|
||||
| `kebab ingest [<path>]` | 워크스페이스를 스캔해 새/변경 문서를 색인 (idempotent, **incremental** — 변하지 않은 doc 의 parse/chunk/embed/upsert 자동 skip, `--force-reingest` 로 강제 재처리). 지원 형식: Markdown · 이미지(OCR+caption) · PDF · 소스코드(Rust/Python/TS/JS/Go/Java/Kotlin/C/C++ AST) · 리소스(YAML·Dockerfile·TOML·JSON·XML 등) — 확장자→chunker 전체 매핑·symbol 형식은 [ARCHITECTURE](docs/ARCHITECTURE.md) 「핵심 기술 결정」 표. TTY 면 진행 바, `--json` 은 `ingest_progress.v1` 스트리밍 후 `ingest_report.v1`. Ctrl-C 한 번은 graceful abort (부분 commit 보존). 미지원 확장자는 자동 skip (`IngestReport.skipped_by_extension`). |
|
||||
| `kebab search --mode {lexical,vector,hybrid} "<query>" [flags]` | 검색 (hybrid = RRF fusion, citation 포함). 주요 flag: 필터 `--tag`/`--media`/`--lang`/`--path-glob`/`--trust-min`/`--doc-id`/`--repo`/`--code-lang`/`--ingested-after` (flag 간 AND, 반복·comma 는 OR), agent budget `--max-tokens`/`--snippet-chars`/`--cursor`(`search_response.v1` 페이징), `--trace`(pre-fusion 후보 + per-stage timing), `--bulk`(stdin ndjson 다중 query, cap 100). 같은 query 는 in-process LRU 캐시 (`--no-cache` 로 bypass, ingest 시 자동 stale). **V009 (v0.20.1):** 한국어 2자/형태소 query 지원, 영어는 whole-token 매칭 (substring recall 은 vector/hybrid 권장). 전체 flag·wire 의미는 `kebab search --help` 와 [docs/wire-schema/v1/](docs/wire-schema/v1/). |
|
||||
| `kebab list docs` | 색인된 문서 목록. human-readable 출력은 `doc_id \t title \t doc_path` (title 은 heading 기반이라 중복 가능 — doc_path 로 구분). `--json` 은 `doc_summary.v1` array (title / doc_path 모두 포함, wire schema 불변). |
|
||||
| `kebab inspect doc <id>` / `kebab inspect chunk <id>` | raw record 보기 |
|
||||
| `kebab fetch chunk <id> [--context N]` / `kebab fetch doc <id> [--max-tokens N]` / `kebab fetch span <doc_id> <ls> <le> [--max-tokens N]` | (p9-fb-35) verbatim text fetch from indexed corpus. wire = `fetch_result.v1` (kind discriminator). chunk: target + ±N ordinal-context chunks. doc: full normalized markdown. span: 1-based line range (PDF/audio rejected as `error.v1.code = span_not_supported`). chars/4 budget on doc/span. |
|
||||
| `kebab ask "<query>" [--show-citations / --hide-citations] [--session <id>] [--stream] [--multi-hop]` | RAG 답변 + 근거 인용. 답변 후 `근거:` block 으로 full path / line range / score 한 줄씩 (default ON — `--hide-citations` 로 끄기, pipe 시 유용). 근거 부족 시 거절. Ollama 필요. `--session <id>` 로 multi-turn — 첫 호출에서 SQLite `chat_sessions` 에 자동 생성, 이후 호출은 prior turns 를 history 로 받아 follow-up. session id 는 사용자 지정 (e.g. `kb-rust-async-2026-05`) — `kebab reset --data-only` 로 모든 session wipe. **`--stream` (p9-fb-33)** 로 ndjson `answer_event.v1` event (retrieval_done → token* → final) 를 stderr 에 흘리고 stdout 마지막 줄에 기존 `answer.v1` — agent 가 token 즉시 소비 가능. **`--multi-hop` (v0.18.0 fb-41)** — single-pass 대신 decompose → decide → synthesize 의 N-hop loop. compound 질문 (cross-doc / prereq chain) 에 효과적. 최종 답변 후 mDeBERTa-v3 XNLI 가 `(packed_chunks, generated_answer)` entailment 검사 — `[rag] nli_threshold > 0` (default 0.0 = disabled, production 권장 0.5) 일 때 활성. entailment < threshold → `refusal_reason = "nli_verification_failed"` (LLM-self-judge ceiling 극복, S7 caffeine hallucination 같은 케이스 catch). 첫 호출 시 ~280 MB ONNX model 자동 다운로드 + RAM peak ~7-8 GB (gemma3:4b 기준). model unavailable 시 `refusal_reason = "nli_model_unavailable"`, 우회는 `[rag] nli_threshold = 0` 임시 disable. |
|
||||
| `kebab ask "<query>" [flags]` | RAG 답변 + 근거 인용 (근거 부족 시 거절, Ollama 필요). `--hide-citations`, `--session <id>`(multi-turn — prior turns 를 history 로), `--stream`(ndjson `answer_event.v1`), `--multi-hop`(decompose→decide→synthesize N-hop, compound 질문용). 답변 groundedness 는 mDeBERTa XNLI 가 검증 — `[rag] nli_threshold > 0` (default 0 = off, production 권장 0.5) 일 때 활성, 첫 호출 시 ~280MB ONNX 다운로드. 전체 옵션은 `kebab ask --help`. |
|
||||
| `kebab doctor` | 설정/모델/DB 헬스 체크 |
|
||||
| `kebab tui` | Ratatui 셸 (Library + Search + Ask + Inspect 패널, desktop 진행 중). Library 에서 `r` 키로 background ingest 시작 — 화면 하단 status bar 가 진행 표시, 완료/abort 시 final 라인 잠시 유지 후 자동 hide. ingest 진행 중 `Esc` / `Ctrl-C` 가 cancel signal (그 외에는 quit). vim-style mode (header 우측 `-- NORMAL --` / `-- INSERT --`) — Library/Inspect 는 자동 NORMAL, Search/Ask 는 자동 INSERT. `i` 로 Normal→Insert (모든 pane — p9-fb-21), `Esc` 로 Insert→Normal 어디서나. mode-authoritative dispatch — Search 의 `j/k/o/g`, Ask 의 `e/j/k` 는 NORMAL 모드에서만 명령으로 동작, INSERT 에서는 입력 문자로 typing. (Search 의 chunk inspect 키는 `i`→`o` 로 rebind — `i` 가 universal Insert toggle.) **`F1` 로 cheatsheet popup** (현재 pane 의 키 매핑 + global 토글 표) — `Esc` / `F1` 로 닫기. Search 패널은 200ms debounce 후 background worker 가 검색 — 키 입력으로 UI freeze 안 됨, 사용자가 계속 타이핑하면 stale 결과 자동 폐기 (generation counter). Ask 패널은 multi-turn — 같은 conversation 안에서 Q1/A1, Q2/A2 transcript 누적, 다음 질문이 이전 턴을 history 로 받아 답변. 답변 본문은 markdown 렌더 (bold/italic/inline code/heading/list/code fence/table/blockquote, raw `**bold**` 가 실제 굵게 표시). `Ctrl-L` 로 새 conversation 시작. Search 의 `g` 키가 `$EDITOR` (기본 `vi`) 로 hit 의 citation 위치 열기 — 종료 후 TUI 화면이 자동으로 깨끗이 redraw. CLI `kebab ask` 는 raw markdown 그대로 (terminal 호환성 위해). Library 의 doc-list 가 한글 / 일본어 / 중국어 (CJK) 제목을 wide-char 정확한 column width 로 truncate — 한글 제목이 한 줄을 넘기지 않음 (CJK 1 자 = 2 col). Search/Ask/Filter 입력의 cursor 가 wide char 위에서 column 단위로 정렬 — 한글 입력 시 caret 이 글자 옆에 정확히 놓임. `← / →` 로 입력 문자열 중간 cursor 이동 (한글 한 글자 = 2 column 이라도 한 번에 이동), `Home / End` 로 양 끝 점프, `Delete` 로 cursor 위치 char 삭제 — 모든 input pane (Ask / Search / Library filter overlay) 동일 (p9-fb-22). Ask 트랜스크립트는 새 답변이 viewport 아래로 누적될 때 자동으로 tail 을 따라감 (auto-scroll); `j` / `k` 로 위로 스크롤하면 freeze, `Shift-G` 로 다시 bottom + auto-tail 재개. 화면 하단 hint line 은 한국어 동사구로 (`"위로"` / `"아래로"` / `"필터"` / `"타이핑 검색어"` / `"Esc 로 NORMAL 모드"` / `"i 입력모드"` 등) + 현재 (pane, mode) 조합에 맞춰 자동 분기, **첫 fragment 가 항상 `F1 도움말`** (cheatsheet 발견성 보장). 모든 모드에서 항상 떠 있는 상태바 — `kebab v<version> │ <pane> │ <docs> docs │ <state>` (state: streaming/searching/indexing/idle, ingest 진행 중에는 progress 가 같은 자리에 흡수됨). Ask 진입 시 conversation id 8 자 prefix 도 함께 표시. Ask 트랜스크립트와 Inspect 양쪽에서 `PgUp / PgDn` 으로 10 줄씩 페이지 스크롤. Library 의 doc list 위에는 `TITLE / TAGS / UPDATED / CHUNKS` 컬럼 헤더 행 표시 (display-width 정렬, Hangul / CJK 안전). |
|
||||
| `kebab tui` | Ratatui 셸 — Library / Search / Ask / Inspect 패널 (vim-style NORMAL/INSERT 모드, header 우측에 현재 모드 표시). **키 매핑은 앱 내 `F1` cheatsheet 가 권위 소스** (pane 별 키 + global 토글). Library `r` 로 background ingest, Search 는 200ms debounce 후 background 검색 (입력 중 freeze 없음), Ask 는 multi-turn + markdown 렌더 (`Ctrl-L` 로 새 conversation), Search `g` 로 `$EDITOR` 에서 citation 위치 열기. CJK 제목/입력 caret 폭 정렬, 하단 상태바 `kebab v<version> │ <pane> │ <docs> docs │ <state>`. |
|
||||
| `kebab reset [--all / --data-only / --vector-only / --config-only] [--yes]` | XDG 데이터 wipe. **Irreversible.** TTY 면 confirm prompt, 아니면 `--yes` 필수. `--vector-only` 는 SQLite `embedding_records` 도 함께 truncate (orphan 방지) |
|
||||
| `kebab eval run / compare` | golden query 회귀 측정 |
|
||||
| `kebab eval run / aggregate / compare / variants` | golden query 회귀 측정 (`run` 실행 → `aggregate` 집계 → `compare` run 비교) + `variants <run_id>` 는 같은 의미의 여러 표현(동의어·풀어쓴 문장·한/영) 간 검색 일관성 진단 — `recall@10` vs `recall@50` 대비로 순위출렁(A)/어휘격차(B) 분류, `--json` 지원 |
|
||||
| `kebab schema [--json]` | introspection — wire schemas / capabilities / models / stats 한 번에. `--json` 은 `schema.v1` wire; 사람 모드는 서식 출력. **stats 에 (p9-fb-37) `media_breakdown` (5 keys: markdown / pdf / image / audio / other) + `lang_breakdown` (BCP-47 코드, NULL 은 literal `"null"`) + `index_bytes` (sqlite + lancedb on-disk 합계) + `stale_doc_count` (`config.search.stale_threshold_days` 초과 doc 수) 추가.** **`index_version` 두 곳 주의 (v0.20.2):** `schema.v1.models.index_version` = vector store (LanceDB) version, `search_hit.v1.index_version` = lexical (FTS5) version — 서로 다른 축, cascade 에서 별도 추적. |
|
||||
| `kebab ingest-file <path>` | 단일 파일 ingest (workspace 외부 가능). 바이트는 `<workspace.root>/_external/<hash12>.<ext>` 로 copy. `.kebabignore` 매치 시 stderr warn 후 진행 (explicit ingest 가 bypass intent). |
|
||||
| `kebab ingest-stdin --title <T> [--source-uri <URI>]` | stdin 의 markdown 본문 ingest. frontmatter (title + source_uri) 자동 prepend. v1 markdown only. |
|
||||
|
||||
@@ -422,6 +422,14 @@ enum EvalWhat {
|
||||
/// into `eval_runs.aggregate_json` (P5-2).
|
||||
Aggregate { run_id: String },
|
||||
|
||||
/// Compute variant-consistency metrics for a stored run and print
|
||||
/// a Markdown report (or JSON with `--json`).
|
||||
Variants {
|
||||
run_id: String,
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
|
||||
/// Diff two stored runs (P5-2). Default output is a Markdown
|
||||
/// summary; use `--json` (top-level flag) for the raw report.
|
||||
Compare {
|
||||
@@ -1392,6 +1400,16 @@ fn run(cli: &Cli) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
EvalWhat::Variants { run_id, json } => {
|
||||
let rep = kebab_eval::compute_variant_consistency_with_config(&cfg, run_id)?;
|
||||
if *json {
|
||||
println!("{}", serde_json::to_string_pretty(&rep)?);
|
||||
} else {
|
||||
print!("{}", kebab_eval::render_variants_md(&rep));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
EvalWhat::Compare {
|
||||
run_a,
|
||||
run_b,
|
||||
|
||||
@@ -503,6 +503,7 @@ mod tests {
|
||||
must_contain: vec![],
|
||||
forbidden: vec![],
|
||||
difficulty: None,
|
||||
group: None,
|
||||
};
|
||||
let g = Some(&g);
|
||||
// a miss, b hit → Win
|
||||
|
||||
@@ -25,6 +25,7 @@ mod loader;
|
||||
mod metrics;
|
||||
mod runner;
|
||||
mod types;
|
||||
mod variant;
|
||||
|
||||
pub use compare::{
|
||||
CompareOpts, CompareReport, ComparisonKind, QueryComparison, compare_runs,
|
||||
@@ -37,3 +38,7 @@ pub use metrics::{
|
||||
};
|
||||
pub use runner::{run_eval, run_eval_with_config};
|
||||
pub use types::{EvalRun, EvalRunOpts, GoldenQuery, QueryResult};
|
||||
pub use variant::{
|
||||
VariantClass, VariantConsistencyReport, VariantGroupReport, VariantResult,
|
||||
compute_variant_consistency, compute_variant_consistency_with_config, render_variants_md,
|
||||
};
|
||||
|
||||
@@ -30,6 +30,7 @@ pub fn load_golden_set(path: &Path) -> Result<Vec<GoldenQuery>> {
|
||||
let queries: Vec<GoldenQuery> = serde_yaml::from_slice(&bytes)
|
||||
.with_context(|| format!("parse golden YAML at {}", path.display()))?;
|
||||
check_unique_ids(&queries)?;
|
||||
check_group_integrity(&queries)?;
|
||||
Ok(queries)
|
||||
}
|
||||
|
||||
@@ -54,6 +55,46 @@ pub(crate) fn load_golden_set_validated(
|
||||
Ok(queries)
|
||||
}
|
||||
|
||||
/// 같은 `group`에 속한 모든 쿼리가 동일한 `expected_doc_ids`(집합)를
|
||||
/// 공유하는지 검증. 변형 일관성 메트릭은 "같은 정답을 가진 다른 표현들"을
|
||||
/// 전제하므로, 그룹 내 정답이 갈리면 측정이 무의미해진다 → bail.
|
||||
fn check_group_integrity(queries: &[GoldenQuery]) -> Result<()> {
|
||||
use std::collections::BTreeMap;
|
||||
// group -> (대표 정답 집합, 대표 query id). 첫 멤버를 canonical 로 삼고
|
||||
// 이후 멤버가 다른 expected 를 가지면 offender 로 기록한다.
|
||||
let mut canonical: BTreeMap<&str, (BTreeSet<String>, &str)> = BTreeMap::new();
|
||||
// 그룹별 위반 메시지(정렬·dedup 위해 BTreeSet). canonical query id 와
|
||||
// divergent query id 를 함께 담아 yaml 수정 시 바로 찾을 수 있게 한다.
|
||||
let mut offenders: BTreeSet<String> = BTreeSet::new();
|
||||
for q in queries {
|
||||
let Some(group) = q.group.as_deref() else {
|
||||
continue;
|
||||
};
|
||||
let docs: BTreeSet<String> = q.expected_doc_ids.iter().map(|d| d.0.clone()).collect();
|
||||
match canonical.get(group) {
|
||||
None => {
|
||||
canonical.insert(group, (docs, q.id.as_str()));
|
||||
}
|
||||
Some((expected, first)) if *expected != docs => {
|
||||
offenders.insert(format!(
|
||||
"group '{group}' (query '{}' differs from canonical '{first}')",
|
||||
q.id
|
||||
));
|
||||
}
|
||||
Some(_) => {}
|
||||
}
|
||||
}
|
||||
if offenders.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
let list: Vec<String> = offenders.into_iter().collect();
|
||||
Err(anyhow!(
|
||||
"same group must share one expected_doc_ids set, but found divergence — {}",
|
||||
list.join("; ")
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn check_unique_ids(queries: &[GoldenQuery]) -> Result<()> {
|
||||
let mut seen: HashSet<&str> = HashSet::new();
|
||||
let mut dups: BTreeSet<String> = BTreeSet::new();
|
||||
@@ -149,6 +190,42 @@ mod tests {
|
||||
use std::fs;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn group_integrity_flags_only_divergent_member_in_3plus_group() {
|
||||
// g1(docA) canonical, g2(docB) divergent, g3(docA) matches canonical.
|
||||
// Only g2 is an offender; g3 must pass. Error names g2, not g3.
|
||||
let tmp = tempdir().unwrap();
|
||||
let yaml_path = tmp.path().join("golden.yaml");
|
||||
fs::write(
|
||||
&yaml_path,
|
||||
"- id: g1\n query: a\n group: gr\n expected_doc_ids: [\"docA\"]\n\
|
||||
- id: g2\n query: b\n group: gr\n expected_doc_ids: [\"docB\"]\n\
|
||||
- id: g3\n query: c\n group: gr\n expected_doc_ids: [\"docA\"]\n",
|
||||
)
|
||||
.unwrap();
|
||||
let err = load_golden_set(&yaml_path).unwrap_err();
|
||||
let msg = format!("{err:#}");
|
||||
assert!(msg.contains("'g2'"), "should name the divergent query g2: {msg}");
|
||||
assert!(!msg.contains("'g3'"), "g3 matches canonical, must not be flagged: {msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ungrouped_queries_skip_group_integrity() {
|
||||
// group=None entries mixed with a valid group must not interfere.
|
||||
let tmp = tempdir().unwrap();
|
||||
let yaml_path = tmp.path().join("golden.yaml");
|
||||
fs::write(
|
||||
&yaml_path,
|
||||
"- id: solo1\n query: x\n expected_doc_ids: [\"docA\"]\n\
|
||||
- id: g1\n query: a\n group: gr\n expected_doc_ids: [\"docB\"]\n\
|
||||
- id: solo2\n query: y\n expected_doc_ids: [\"docC\"]\n",
|
||||
)
|
||||
.unwrap();
|
||||
let qs = load_golden_set(&yaml_path).unwrap();
|
||||
assert_eq!(qs.len(), 3);
|
||||
assert!(qs[0].group.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unknown_expected_chunk_id() {
|
||||
let tmp = tempdir().unwrap();
|
||||
@@ -194,6 +271,37 @@ mod tests {
|
||||
assert_eq!(qs.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_group_with_divergent_expected_docs() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let yaml_path = tmp.path().join("golden.yaml");
|
||||
fs::write(
|
||||
&yaml_path,
|
||||
"- id: g1\n query: \"러스트 소유권\"\n group: ownership\n expected_doc_ids: [\"docA\"]\n\
|
||||
- id: g2\n query: \"rust ownership\"\n group: ownership\n expected_doc_ids: [\"docB\"]\n",
|
||||
)
|
||||
.unwrap();
|
||||
let err = load_golden_set(&yaml_path).unwrap_err();
|
||||
let msg = format!("{err:#}");
|
||||
assert!(msg.contains("group"), "msg: {msg}");
|
||||
assert!(msg.contains("ownership"), "msg: {msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_group_with_matching_expected_docs() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let yaml_path = tmp.path().join("golden.yaml");
|
||||
fs::write(
|
||||
&yaml_path,
|
||||
"- id: g1\n query: \"러스트 소유권\"\n group: ownership\n expected_doc_ids: [\"docA\"]\n\
|
||||
- id: g2\n query: \"rust ownership\"\n group: ownership\n expected_doc_ids: [\"docA\"]\n",
|
||||
)
|
||||
.unwrap();
|
||||
let qs = load_golden_set(&yaml_path).unwrap();
|
||||
assert_eq!(qs.len(), 2);
|
||||
assert_eq!(qs[0].group.as_deref(), Some("ownership"));
|
||||
}
|
||||
|
||||
fn seed_one_chunk(store: &SqliteStore, doc_id: &str, chunk_id: &str) {
|
||||
let conn = store.read_conn();
|
||||
let asset_id = format!("a_{doc_id}");
|
||||
|
||||
@@ -165,7 +165,7 @@ pub(crate) fn resolve_golden_path() -> PathBuf {
|
||||
}
|
||||
}
|
||||
|
||||
fn load_golden_for_metrics() -> Result<Vec<GoldenQuery>> {
|
||||
pub(crate) fn load_golden_for_metrics() -> Result<Vec<GoldenQuery>> {
|
||||
let path = resolve_golden_path();
|
||||
load_golden_set(&path).with_context(|| {
|
||||
format!(
|
||||
@@ -456,6 +456,7 @@ mod tests {
|
||||
must_contain: vec![],
|
||||
forbidden: vec![],
|
||||
difficulty: None,
|
||||
group: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@ pub fn run_eval_with_config(cfg: &kebab_config::Config, opts: &EvalRunOpts) -> R
|
||||
.context("run migrations for run_eval")?;
|
||||
|
||||
// ── 3. Build config_snapshot_json ─────────────────────────────────────
|
||||
let config_snapshot_json = build_config_snapshot(cfg)?;
|
||||
let config_snapshot_json = build_config_snapshot(cfg, opts.k)?;
|
||||
let config_snapshot_text =
|
||||
serde_json::to_string(&config_snapshot_json).context("serialize config_snapshot_json")?;
|
||||
|
||||
@@ -215,10 +215,11 @@ fn execute_query(app: &App, gq: &GoldenQuery, opts: &EvalRunOpts) -> QueryResult
|
||||
/// stable run-time property of the config alone. P5-2 may compose it
|
||||
/// from `embedding.{model,version,dimensions}` if it needs the field
|
||||
/// for compare reports.
|
||||
fn build_config_snapshot(cfg: &kebab_config::Config) -> Result<serde_json::Value> {
|
||||
fn build_config_snapshot(cfg: &kebab_config::Config, eval_k: usize) -> Result<serde_json::Value> {
|
||||
let cfg_value = serde_json::to_value(cfg).context("serialize Config")?;
|
||||
Ok(serde_json::json!({
|
||||
"config": cfg_value,
|
||||
"eval_k": eval_k,
|
||||
"chunker_version": cfg.chunking.chunker_version,
|
||||
"embedding": {
|
||||
"model": cfg.models.embedding.model,
|
||||
|
||||
@@ -26,6 +26,11 @@ pub struct GoldenQuery {
|
||||
pub forbidden: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub difficulty: Option<String>,
|
||||
/// 같은 의미의 여러 표현(동의어·다른 어휘·풀어쓴 문장·한/영)을 묶는
|
||||
/// 의도 그룹 id. 같은 그룹의 모든 변형은 동일한 `expected_doc_ids`(집합)를
|
||||
/// 공유해야 한다(loader가 강제). `None`이면 단독 쿼리(기존 동작 불변).
|
||||
#[serde(default)]
|
||||
pub group: Option<String>,
|
||||
}
|
||||
|
||||
fn default_lang() -> Lang {
|
||||
|
||||
530
crates/kebab-eval/src/variant.rs
Normal file
530
crates/kebab-eval/src/variant.rs
Normal file
@@ -0,0 +1,530 @@
|
||||
//! 변형(paraphrase) 일관성 진단 메트릭.
|
||||
//!
|
||||
//! 같은 의도(`GoldenQuery.group`)의 여러 표현이 같은 정답 문서를 공유한다는
|
||||
//! 전제 아래, 표현마다 검색/답변 품질이 얼마나 출렁이는지를 잰다. 핵심은
|
||||
//! `recall@narrow`(사용자가 보는 top-10) vs `recall@pool`(넓은 후보 폭)의 대비:
|
||||
//!
|
||||
//! - (A) 순위 출렁(`MisRanked`): 정답이 pool엔 있는데 top-10 밖 → near-tie 흡수로 해결 후보.
|
||||
//! - (B) 어휘 격차(`Missing`): 정답이 pool에도 없음 → 쿼리 확장/번역 필요.
|
||||
//!
|
||||
//! 진단 전용. 기존 [`crate::metrics::AggregateMetrics`] 경로는 건드리지 않는다.
|
||||
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use kebab_config::Config;
|
||||
use kebab_core::DocumentId;
|
||||
use kebab_store_sqlite::SqliteStore;
|
||||
|
||||
use crate::types::{GoldenQuery, QueryResult};
|
||||
|
||||
/// 사용자가 실제 보는 답변 context 폭.
|
||||
const NARROW_K: u32 = 10;
|
||||
/// 넓은 후보 폭. recall@pool vs recall@narrow 대비로 A/B를 가른다.
|
||||
/// eval run은 `--k`를 이 값 이상으로 줘서 `hits_top_k`가 pool을 담아야 한다.
|
||||
const POOL_K: u32 = 50;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum VariantClass {
|
||||
/// recall@narrow == 1.0 (정답 전부 top-10 안).
|
||||
Ok,
|
||||
/// recall@pool > recall@narrow (정답이 pool엔 있는데 top-10 밖). (A)
|
||||
MisRanked,
|
||||
/// recall@pool == recall@narrow < 1.0 (못 찾은 정답이 pool에도 없음). (B)
|
||||
Missing,
|
||||
/// 정답 문서 미지정(검증 불가).
|
||||
NoExpected,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct VariantResult {
|
||||
pub query_id: String,
|
||||
pub query: String,
|
||||
pub recall_narrow: f32,
|
||||
pub recall_pool: f32,
|
||||
/// must_contain 통과 여부. RAG 답변(`--with-rag`)이 없으면 `None`.
|
||||
pub answer_ok: Option<bool>,
|
||||
pub class: VariantClass,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct VariantGroupReport {
|
||||
pub group: String,
|
||||
pub variants: Vec<VariantResult>,
|
||||
/// max-min recall_narrow (정답 지정 변형들만). 0 = 완전 일관.
|
||||
pub recall_spread_narrow: f32,
|
||||
pub worst_recall_narrow: f32,
|
||||
/// 모든 변형이 must_contain 통과면 Some(true), 하나라도 실패 Some(false),
|
||||
/// RAG 답변이 전혀 없으면 None.
|
||||
pub answer_consistency: Option<bool>,
|
||||
pub mis_ranked: u32,
|
||||
pub missing: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct VariantConsistencyReport {
|
||||
pub groups: Vec<VariantGroupReport>,
|
||||
pub mean_recall_spread_narrow: f32,
|
||||
/// spread==0 && worst_recall_narrow==1.0 인 그룹 수.
|
||||
pub fully_consistent_groups: u32,
|
||||
pub total_groups: u32,
|
||||
/// mis_ranked>0 && mis_ranked>=missing 인 그룹 수 (near-tie 처방 우선).
|
||||
pub a_dominant_groups: u32,
|
||||
/// missing>0 && missing>mis_ranked 인 그룹 수 (쿼리 확장 처방 우선).
|
||||
pub b_dominant_groups: u32,
|
||||
/// 관찰된 최대 rank 가 POOL_K 미만일 때 true — eval run 의 --k 가
|
||||
/// POOL_K 보다 작아 pool 이 절단됐을 수 있음. MisRanked(A) 판정 불가.
|
||||
pub pool_possibly_truncated: bool,
|
||||
}
|
||||
|
||||
/// 저장된 run을 그룹으로 묶어 변형 일관성 리포트를 만든다.
|
||||
/// `rows`는 [`crate::metrics::aggregate_from_rows`]와 동일한 입력
|
||||
/// (저장된 per-query 결과). `group`이 없는 쿼리는 무시한다.
|
||||
pub fn compute_variant_consistency(
|
||||
queries: &[GoldenQuery],
|
||||
rows: &[kebab_store_sqlite::EvalQueryResultRecord],
|
||||
) -> Result<VariantConsistencyReport> {
|
||||
let golden_by_id: HashMap<&str, &GoldenQuery> =
|
||||
queries.iter().map(|q| (q.id.as_str(), q)).collect();
|
||||
|
||||
let mut grouped: BTreeMap<String, Vec<VariantResult>> = BTreeMap::new();
|
||||
let mut observed_max_rank: u32 = 0;
|
||||
let mut has_hits = false;
|
||||
for row in rows {
|
||||
let qr: QueryResult = serde_json::from_str(&row.result_json)
|
||||
.with_context(|| format!("parse result_json for {}", row.query_id))?;
|
||||
for hit in &qr.hits_top_k {
|
||||
has_hits = true;
|
||||
observed_max_rank = observed_max_rank.max(hit.rank);
|
||||
}
|
||||
let Some(gq) = golden_by_id.get(qr.query_id.as_str()) else {
|
||||
continue;
|
||||
};
|
||||
let Some(group) = gq.group.clone() else {
|
||||
continue;
|
||||
};
|
||||
let (recall_narrow, recall_pool) = recall_narrow_pool(&qr, &gq.expected_doc_ids);
|
||||
// Mirrors metrics.rs groundedness guards: skip errored rows and
|
||||
// vacuous-true (no must_contain/forbidden configured).
|
||||
let answer_ok = if qr.error.is_some()
|
||||
|| (gq.must_contain.is_empty() && gq.forbidden.is_empty())
|
||||
{
|
||||
None
|
||||
} else {
|
||||
qr.answer.as_ref().map(|a| {
|
||||
gq.must_contain.iter().all(|s| a.answer.contains(s))
|
||||
&& !gq.forbidden.iter().any(|s| a.answer.contains(s))
|
||||
})
|
||||
};
|
||||
let class = classify(&gq.expected_doc_ids, recall_narrow, recall_pool);
|
||||
grouped.entry(group).or_default().push(VariantResult {
|
||||
query_id: qr.query_id.clone(),
|
||||
query: qr.query.clone(),
|
||||
recall_narrow,
|
||||
recall_pool,
|
||||
answer_ok,
|
||||
class,
|
||||
});
|
||||
}
|
||||
|
||||
let mut groups: Vec<VariantGroupReport> = Vec::with_capacity(grouped.len());
|
||||
for (group, variants) in grouped {
|
||||
groups.push(rollup_group(group, variants));
|
||||
}
|
||||
|
||||
let total_groups = u32::try_from(groups.len()).unwrap_or(u32::MAX);
|
||||
let fully_consistent_groups = groups
|
||||
.iter()
|
||||
.filter(|g| g.recall_spread_narrow == 0.0 && g.worst_recall_narrow == 1.0)
|
||||
.count() as u32;
|
||||
let a_dominant_groups = groups
|
||||
.iter()
|
||||
.filter(|g| g.mis_ranked > 0 && g.mis_ranked >= g.missing)
|
||||
.count() as u32;
|
||||
let b_dominant_groups = groups
|
||||
.iter()
|
||||
.filter(|g| g.missing > 0 && g.missing > g.mis_ranked)
|
||||
.count() as u32;
|
||||
let mean_recall_spread_narrow = if groups.is_empty() {
|
||||
0.0
|
||||
} else {
|
||||
groups.iter().map(|g| g.recall_spread_narrow).sum::<f32>() / groups.len() as f32
|
||||
};
|
||||
|
||||
let pool_possibly_truncated = has_hits && observed_max_rank < POOL_K;
|
||||
Ok(VariantConsistencyReport {
|
||||
groups,
|
||||
mean_recall_spread_narrow,
|
||||
fully_consistent_groups,
|
||||
total_groups,
|
||||
a_dominant_groups,
|
||||
b_dominant_groups,
|
||||
pool_possibly_truncated,
|
||||
})
|
||||
}
|
||||
|
||||
/// 정답 문서 집합에 대한 recall@NARROW_K, recall@POOL_K.
|
||||
/// 정답 미지정이면 (NaN, NaN).
|
||||
fn recall_narrow_pool(qr: &QueryResult, expected: &[DocumentId]) -> (f32, f32) {
|
||||
if expected.is_empty() {
|
||||
return (f32::NAN, f32::NAN);
|
||||
}
|
||||
let exp: HashSet<&DocumentId> = expected.iter().collect();
|
||||
let cover = |k: u32| -> f32 {
|
||||
let topk: HashSet<&DocumentId> = qr
|
||||
.hits_top_k
|
||||
.iter()
|
||||
.filter(|h| h.rank <= k)
|
||||
.map(|h| &h.doc_id)
|
||||
.collect();
|
||||
exp.iter().filter(|d| topk.contains(*d)).count() as f32 / exp.len() as f32
|
||||
};
|
||||
(cover(NARROW_K), cover(POOL_K))
|
||||
}
|
||||
|
||||
// Single label per query: when multiple expected docs produce mixed classes (e.g. one
|
||||
// MisRanked + one Missing), recall_pool > recall_narrow (A: MisRanked) takes priority.
|
||||
fn classify(expected: &[DocumentId], recall_narrow: f32, recall_pool: f32) -> VariantClass {
|
||||
if expected.is_empty() {
|
||||
VariantClass::NoExpected
|
||||
} else if recall_narrow >= 1.0 {
|
||||
VariantClass::Ok
|
||||
} else if recall_pool > recall_narrow {
|
||||
VariantClass::MisRanked
|
||||
} else {
|
||||
VariantClass::Missing
|
||||
}
|
||||
}
|
||||
|
||||
fn rollup_group(group: String, variants: Vec<VariantResult>) -> VariantGroupReport {
|
||||
let measurable: Vec<f32> = variants
|
||||
.iter()
|
||||
.filter(|v| !v.recall_narrow.is_nan())
|
||||
.map(|v| v.recall_narrow)
|
||||
.collect();
|
||||
let (recall_spread_narrow, worst_recall_narrow) = if measurable.is_empty() {
|
||||
// All variants have no expected docs: spread=0/worst=NaN is intentional.
|
||||
// This group won't match fully_consistent (NaN != 1.0) or A/B (both 0) —
|
||||
// it's counted in total_groups but sits in a silent "limbo" bucket.
|
||||
(0.0, f32::NAN)
|
||||
} else {
|
||||
let max = measurable.iter().copied().fold(f32::MIN, f32::max);
|
||||
let min = measurable.iter().copied().fold(f32::MAX, f32::min);
|
||||
(max - min, min)
|
||||
};
|
||||
let answer_flags: Vec<bool> = variants.iter().filter_map(|v| v.answer_ok).collect();
|
||||
let answer_consistency = if answer_flags.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(answer_flags.iter().all(|&ok| ok))
|
||||
};
|
||||
let mis_ranked = variants.iter().filter(|v| v.class == VariantClass::MisRanked).count() as u32;
|
||||
let missing = variants.iter().filter(|v| v.class == VariantClass::Missing).count() as u32;
|
||||
VariantGroupReport {
|
||||
group,
|
||||
variants,
|
||||
recall_spread_narrow,
|
||||
worst_recall_narrow,
|
||||
answer_consistency,
|
||||
mis_ranked,
|
||||
missing,
|
||||
}
|
||||
}
|
||||
|
||||
/// 활성 XDG Config로 저장된 run을 읽어 변형 일관성을 계산
|
||||
/// ([`crate::metrics::compute_aggregate_with_config`]와 동일한 로딩 패턴).
|
||||
pub fn compute_variant_consistency_with_config(
|
||||
cfg: &Config,
|
||||
run_id: &str,
|
||||
) -> Result<VariantConsistencyReport> {
|
||||
let store = SqliteStore::open(cfg).context("open SqliteStore for variant consistency")?;
|
||||
store.run_migrations().context("run migrations")?;
|
||||
let run_record = store
|
||||
.load_eval_run(run_id)
|
||||
.context("load eval_runs row")?
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!("compute_variant_consistency: no eval_runs row for run_id {run_id}")
|
||||
})?;
|
||||
let snapshot: serde_json::Value =
|
||||
serde_json::from_str(&run_record.config_snapshot_json).unwrap_or(serde_json::Value::Null);
|
||||
if let Some(eval_k) = snapshot["eval_k"].as_u64() {
|
||||
let eval_k = eval_k as u32;
|
||||
if eval_k < POOL_K {
|
||||
anyhow::bail!(
|
||||
"variant consistency needs the run to retrieve >= {POOL_K} candidates, \
|
||||
but run used k={eval_k}; re-run `kebab eval run --k {POOL_K}` (or higher)"
|
||||
);
|
||||
}
|
||||
}
|
||||
let rows = store
|
||||
.load_eval_query_results(run_id)
|
||||
.context("load eval_query_results")?;
|
||||
let queries = crate::metrics::load_golden_for_metrics()?;
|
||||
compute_variant_consistency(&queries, &rows)
|
||||
}
|
||||
|
||||
/// 변형 일관성 리포트를 사람이 읽는 마크다운 표로 렌더
|
||||
/// ([`crate::render_report_md`] 스타일).
|
||||
pub fn render_variants_md(rep: &VariantConsistencyReport) -> String {
|
||||
use std::fmt::Write;
|
||||
let mut s = String::new();
|
||||
let _ = writeln!(s, "# Variant consistency\n");
|
||||
let _ = writeln!(
|
||||
s,
|
||||
"groups={} fully_consistent={} A_dominant={} B_dominant={} mean_spread@{}={:.3} pool=top-{}\n",
|
||||
rep.total_groups,
|
||||
rep.fully_consistent_groups,
|
||||
rep.a_dominant_groups,
|
||||
rep.b_dominant_groups,
|
||||
NARROW_K,
|
||||
rep.mean_recall_spread_narrow,
|
||||
POOL_K,
|
||||
);
|
||||
if rep.pool_possibly_truncated {
|
||||
let _ = writeln!(
|
||||
s,
|
||||
"WARNING: max observed rank < {POOL_K} — pool possibly truncated. \
|
||||
MisRanked(A) diagnoses may be suppressed. Re-run `kebab eval run --k {POOL_K}` (or higher).\n"
|
||||
);
|
||||
}
|
||||
for g in &rep.groups {
|
||||
let ac = match g.answer_consistency {
|
||||
Some(true) => "all-ok",
|
||||
Some(false) => "MIXED",
|
||||
None => "n/a",
|
||||
};
|
||||
let _ = writeln!(
|
||||
s,
|
||||
"## {} — spread@{}={:.2} worst={:.2} A={} B={} answers={}",
|
||||
g.group, NARROW_K, g.recall_spread_narrow, g.worst_recall_narrow, g.mis_ranked, g.missing, ac
|
||||
);
|
||||
let _ = writeln!(s, "| variant | recall@{NARROW_K} | recall@{POOL_K} | class | answer |");
|
||||
let _ = writeln!(s, "|---|---|---|---|---|");
|
||||
for v in &g.variants {
|
||||
let ans = match v.answer_ok {
|
||||
Some(true) => "ok",
|
||||
Some(false) => "BAD",
|
||||
None => "-",
|
||||
};
|
||||
let _ = writeln!(
|
||||
s,
|
||||
"| {} | {:.2} | {:.2} | {:?} | {} |",
|
||||
v.query, v.recall_narrow, v.recall_pool, v.class, ans
|
||||
);
|
||||
}
|
||||
let _ = writeln!(s);
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use kebab_core::{
|
||||
ChunkId, ChunkerVersion, Citation, IndexVersion, RetrievalDetail, ScoreKind, SearchMode,
|
||||
WorkspacePath,
|
||||
};
|
||||
use kebab_store_sqlite::EvalQueryResultRecord;
|
||||
|
||||
fn hit(doc: &str, rank: u32) -> kebab_core::SearchHit {
|
||||
let path = WorkspacePath::new(format!("{doc}.md")).unwrap();
|
||||
kebab_core::SearchHit {
|
||||
rank,
|
||||
chunk_id: ChunkId(format!("c-{doc}-{rank}")),
|
||||
doc_id: DocumentId(doc.to_string()),
|
||||
doc_path: path.clone(),
|
||||
heading_path: vec![],
|
||||
section_label: None,
|
||||
snippet: String::new(),
|
||||
citation: Citation::Line { path, start: 1, end: 1, section: None },
|
||||
retrieval: RetrievalDetail {
|
||||
method: SearchMode::Vector,
|
||||
fusion_score: 1.0 / rank as f32,
|
||||
lexical_score: None,
|
||||
vector_score: Some(1.0 / rank as f32),
|
||||
lexical_rank: None,
|
||||
vector_rank: Some(rank),
|
||||
},
|
||||
index_version: IndexVersion("v1".into()),
|
||||
embedding_model: None,
|
||||
chunker_version: ChunkerVersion("v1".into()),
|
||||
indexed_at: time::OffsetDateTime::UNIX_EPOCH,
|
||||
stale: false,
|
||||
score_kind: ScoreKind::Cosine,
|
||||
repo: None,
|
||||
code_lang: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn gq(id: &str, group: &str, expected_doc: &str) -> GoldenQuery {
|
||||
GoldenQuery {
|
||||
id: id.into(),
|
||||
query: id.into(),
|
||||
lang: kebab_core::Lang(String::new()),
|
||||
expected_doc_ids: vec![DocumentId(expected_doc.into())],
|
||||
expected_chunk_ids: vec![],
|
||||
must_contain: vec![],
|
||||
forbidden: vec![],
|
||||
difficulty: None,
|
||||
group: Some(group.into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn row(query_id: &str, hits: Vec<kebab_core::SearchHit>) -> EvalQueryResultRecord {
|
||||
let qr = QueryResult {
|
||||
query_id: query_id.into(),
|
||||
query: query_id.into(),
|
||||
mode: SearchMode::Vector,
|
||||
hits_top_k: hits,
|
||||
answer: None,
|
||||
elapsed_ms: 0,
|
||||
error: None,
|
||||
};
|
||||
EvalQueryResultRecord {
|
||||
query_id: query_id.into(),
|
||||
result_json: serde_json::to_string(&qr).unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_mis_ranked_vs_missing_and_spread() {
|
||||
// group "g": 정답 docX.
|
||||
// v1: docX at rank 3 → narrow=1.0 → Ok
|
||||
// v2: docX at rank 25 → narrow=0.0, pool=1.0 → MisRanked (A)
|
||||
// v3: docX 없음 → narrow=0.0, pool=0.0 → Missing (B)
|
||||
let queries = vec![gq("v1", "g", "docX"), gq("v2", "g", "docX"), gq("v3", "g", "docX")];
|
||||
let rows = vec![
|
||||
row("v1", vec![hit("docX", 3)]),
|
||||
row("v2", vec![hit("docX", 25)]),
|
||||
row("v3", vec![hit("other", 1)]),
|
||||
];
|
||||
let rep = compute_variant_consistency(&queries, &rows).unwrap();
|
||||
assert_eq!(rep.total_groups, 1);
|
||||
let g = &rep.groups[0];
|
||||
assert_eq!(g.group, "g");
|
||||
assert_eq!(g.variants.len(), 3);
|
||||
// spread = max(1.0) - min(0.0) = 1.0
|
||||
assert!((g.recall_spread_narrow - 1.0).abs() < 1e-6);
|
||||
assert!((g.worst_recall_narrow - 0.0).abs() < 1e-6);
|
||||
assert_eq!(g.mis_ranked, 1);
|
||||
assert_eq!(g.missing, 1);
|
||||
let classes: Vec<VariantClass> = g.variants.iter().map(|v| v.class).collect();
|
||||
assert!(classes.contains(&VariantClass::Ok));
|
||||
assert!(classes.contains(&VariantClass::MisRanked));
|
||||
assert!(classes.contains(&VariantClass::Missing));
|
||||
assert_eq!(rep.a_dominant_groups + rep.b_dominant_groups, 1); // tie→정의대로 하나로 분류
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fully_consistent_group_when_all_ok() {
|
||||
let queries = vec![gq("v1", "g", "docX"), gq("v2", "g", "docX")];
|
||||
let rows = vec![row("v1", vec![hit("docX", 1)]), row("v2", vec![hit("docX", 2)])];
|
||||
let rep = compute_variant_consistency(&queries, &rows).unwrap();
|
||||
assert_eq!(rep.fully_consistent_groups, 1);
|
||||
assert!((rep.groups[0].recall_spread_narrow - 0.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ungrouped_queries_are_ignored() {
|
||||
let mut q = gq("solo", "g", "docX");
|
||||
q.group = None;
|
||||
let rep = compute_variant_consistency(&[q], &[row("solo", vec![hit("docX", 1)])]).unwrap();
|
||||
assert_eq!(rep.total_groups, 0);
|
||||
}
|
||||
|
||||
fn row_with_answer(
|
||||
query_id: &str,
|
||||
hits: Vec<kebab_core::SearchHit>,
|
||||
answer_text: &str,
|
||||
error: Option<&str>,
|
||||
) -> EvalQueryResultRecord {
|
||||
let hits_json = serde_json::to_value(&hits).unwrap();
|
||||
let error_json =
|
||||
error.map_or(serde_json::Value::Null, |e| serde_json::Value::String(e.into()));
|
||||
let qr_json = serde_json::json!({
|
||||
"query_id": query_id,
|
||||
"query": query_id,
|
||||
"mode": "vector",
|
||||
"hits_top_k": hits_json,
|
||||
"answer": {
|
||||
"answer": answer_text,
|
||||
"citations": [],
|
||||
"grounded": false,
|
||||
"refusal_reason": null,
|
||||
"model": {"id": "test-model", "provider": "test", "dimensions": null},
|
||||
"embedding": null,
|
||||
"prompt_template_version": "v1",
|
||||
"retrieval": {
|
||||
"trace_id": "t0",
|
||||
"mode": "vector",
|
||||
"k": 10,
|
||||
"score_gate": 0.0,
|
||||
"top_score": 0.0,
|
||||
"chunks_returned": 0,
|
||||
"chunks_used": 0
|
||||
},
|
||||
"usage": {"prompt_tokens": 0, "completion_tokens": 0, "latency_ms": 0},
|
||||
"created_at": "1970-01-01T00:00:00Z"
|
||||
},
|
||||
"elapsed_ms": 0,
|
||||
"error": error_json
|
||||
});
|
||||
EvalQueryResultRecord {
|
||||
query_id: query_id.into(),
|
||||
result_json: serde_json::to_string(&qr_json).unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
/// H1 회귀: eval k=10 으로 실행 시 모든 hit rank ≤ NARROW_K →
|
||||
/// pool_possibly_truncated 플래그로 사용자에게 경고해야 한다.
|
||||
#[test]
|
||||
fn pool_truncation_flag_when_all_hits_within_narrow_k() {
|
||||
let queries = vec![gq("v1", "g", "docX"), gq("v2", "g", "docX")];
|
||||
let rows = vec![
|
||||
row("v1", vec![hit("docX", 1)]),
|
||||
row("v2", vec![hit("other", 7)]), // rank 7 ≤ NARROW_K=10
|
||||
];
|
||||
let rep = compute_variant_consistency(&queries, &rows).unwrap();
|
||||
assert!(rep.pool_possibly_truncated, "all ranks ≤ NARROW_K must set pool_possibly_truncated");
|
||||
// v2 misses docX, pool also has no rank>10 → classified Missing, not MisRanked
|
||||
assert_eq!(rep.a_dominant_groups, 0);
|
||||
assert_eq!(rep.b_dominant_groups, 1);
|
||||
}
|
||||
|
||||
/// M1a: must_contain/forbidden 둘 다 빈 golden → vacuous-true 방지,
|
||||
/// answer_ok = None (answer 있어도).
|
||||
/// M1b: qr.error=Some → answer 있어도 answer_ok = None.
|
||||
#[test]
|
||||
fn answer_ok_vacuous_and_error_guarded() {
|
||||
// M1a: gq() helper already has empty must_contain + forbidden
|
||||
let gq_no_check = gq("v1", "g1", "docX");
|
||||
let row_v1 = row_with_answer("v1", vec![], "any text", None);
|
||||
let rep = compute_variant_consistency(&[gq_no_check], &[row_v1]).unwrap();
|
||||
let v = &rep.groups[0].variants[0];
|
||||
assert_eq!(v.answer_ok, None, "vacuous-true guard: no checks → answer_ok = None");
|
||||
assert_eq!(rep.groups[0].answer_consistency, None);
|
||||
|
||||
// M1b: must_contain present but error is also set
|
||||
let mut gq_check = gq("v2", "g2", "docY");
|
||||
gq_check.must_contain = vec!["expected text".to_string()];
|
||||
let row_v2 = row_with_answer("v2", vec![], "expected text", Some("llm error"));
|
||||
let rep2 = compute_variant_consistency(&[gq_check], &[row_v2]).unwrap();
|
||||
let v2 = &rep2.groups[0].variants[0];
|
||||
assert_eq!(v2.answer_ok, None, "error guard: qr.error present → answer_ok = None");
|
||||
}
|
||||
|
||||
/// N1 순수 B: 두 변형 모두 pool 에서도 정답 없음 → b_dominant=1, a_dominant=0.
|
||||
#[test]
|
||||
fn pure_b_dominant_group() {
|
||||
let queries = vec![gq("v1", "g", "docX"), gq("v2", "g", "docX")];
|
||||
let rows = vec![
|
||||
row("v1", vec![hit("other1", 1)]), // docX 없음 → Missing (B)
|
||||
row("v2", vec![hit("other2", 1)]), // docX 없음 → Missing (B)
|
||||
];
|
||||
let rep = compute_variant_consistency(&queries, &rows).unwrap();
|
||||
assert_eq!(rep.b_dominant_groups, 1);
|
||||
assert_eq!(rep.a_dominant_groups, 0);
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ Cargo workspace, 함수 호출 기반 모듈러 모놀리스. UI binary (`kebab-
|
||||
| 원본 저장 | filesystem + blake3 content-addressable copy (대용량은 reference + checksum) |
|
||||
| metadata | SQLite + FTS5 (lexical search + v0.20.1 한국어 형태소 tokenizer via lindera-ko-dic) |
|
||||
| vector | LanceDB (embedded, model 별 분리 table) |
|
||||
| Markdown parser | `pulldown-cmark` |
|
||||
| Markdown parser | `pulldown-cmark`. frontmatter 에 title 없으면 첫 H1 → H2 → 첫 paragraph 80 자 → 파일명 순으로 자동 채움 (`parser_version = md-frontmatter-v2`, 기존 doc 도 다음 ingest 에서 갱신) |
|
||||
| embedding | `fastembed-rs` (`multilingual-e5-large`, 1024d, v0.18.0부터 default 업그레이드) |
|
||||
| 한국어 형태소분석 | `lindera-ko-dic` (FTS5 외부 tokenizer, v0.20.1) — 2자 이상 한국어 query 지원 |
|
||||
| LLM | Ollama HTTP (default `gemma4:e4b` ─ OCR / caption 와 family 통일. 사용자가 더 큰 variant `gemma4:26b` 등으로 override 가능) |
|
||||
@@ -23,10 +23,11 @@ Cargo workspace, 함수 호출 기반 모듈러 모놀리스. UI binary (`kebab-
|
||||
| OCR (image) | Ollama vision LM (default `gemma4:e4b`) — `OcrEngine` trait 으로 Tesseract / Apple Vision 등 future swap (HOTFIXES P6-2) |
|
||||
| OCR (PDF, v0.20.0+) | Ollama vision LM (default `qwen2.5vl:3b`) — post-extract enrichment via `kebab-app::pdf_ocr_apply` (H-1 resolution). DCTDecode-only v1 (FlateDecode/CCITTFax skip + warning). family asymmetry vs image OCR: PoC alnum 94.79% (qwen2.5vl) >> 27% (gemma4:e4b 받침), 본 단계에서 PDF OCR 만 qwen2.5vl. |
|
||||
| Image caption | Ollama vision LM, runtime gate `image.caption.enabled` (default OFF) |
|
||||
| RAG groundedness 검증 | `kebab-nli` 의 mDeBERTa-v3 XNLI 가 `(packed_chunks, generated_answer)` entailment 검사 (fb-41). `[rag] nli_threshold > 0` (default 0 = disabled, production 권장 0.5) 일 때 활성 — 미달 시 `refusal_reason = nli_verification_failed` (LLM self-judge ceiling 보완). 첫 호출 시 ~280 MB ONNX 자동 다운로드 |
|
||||
| PDF parser | `lopdf` per-page 텍스트 + scanned-page image extract (`page_image::extract_dctdecode_page_image`, v0.20.0). `chunker_version = "pdf-page-v1"` 하드코딩 (HOTFIXES P7-3). `parser_version = "pdf-text-v1"` 보존 (v0.20 OCR 후에도) — provenance event 로 OCR 사용 차별화. force-reingest 가 v0.19 indexed scanned PDF 의 재처리에 필요. |
|
||||
| code parser | `tree-sitter` + `tree-sitter-rust` / `tree-sitter-python` / `tree-sitter-typescript` / `tree-sitter-javascript` / `tree-sitter-go` / `tree-sitter-java` / `tree-sitter-kotlin-ng` — **parser-side** (`kebab-parse-code`), chunker-side 아님 (design §6.3). chunker versions: Rust = `code-rust-ast-v1`, Python = `code-python-ast-v1`, TypeScript = `code-ts-ast-v1`, JavaScript = `code-js-ast-v1`, Go = `code-go-ast-v1`, Java = `code-java-ast-v1`, Kotlin = `code-kotlin-ast-v1`. `ast_chunk_max_lines = 200` 상수 고정 (HOTFIXES 2026-05-19 — Chunker trait 이 per-medium config 미노출). Kotlin grammar 은 `tree-sitter-kotlin-ng` 사용 — bare `tree-sitter-kotlin` 은 tree-sitter 0.21–0.23 에 고착되어 있어 사용 불가. **Tier 2 (p10-2)**: YAML/k8s → `serde_yaml` + `k8s-manifest-resource-v1` (apiVersion+kind per resource), Dockerfile → `dockerfile-file-v1` (whole-file), Cargo.toml/go.mod/.json/.xml/.groovy → `manifest-file-v1` (whole-file). Tier 2 chunkers live in `kebab-chunk`; no tree-sitter grammar needed (structure from file type, not AST). **Tier 3 (p10-3)**: shell scripts (`.sh`/`.bash`/`.zsh`) direct → `code-text-paragraph-v1` (blank-line paragraph segmentation + 80-line / 20-overlap line-window for oversize). Same chunker also serves as fallback when Tier 1/2 emit 0 chunks or Err — non-k8s YAML / invalid YAML / AST extractor failures all picked up. symbol = None; lang preserved from input doc. **Tier 1 family complete (p10-1D)**: C (`tree-sitter-c`, `code-c-ast-v1`, `.c`/`.h`) + C++ (`tree-sitter-cpp`, `code-cpp-ast-v1`, `.cpp`/`.cc`/`.cxx`/`.hpp`/`.hh`/`.hxx`). C symbol = function name only; C++ symbol = `namespace::Class::method` (recursive nesting). `.h` 가 C++ syntax 만나면 tree-sitter-c parse 실패 → Tier 3 fallback. |
|
||||
| 1B symbol path | workspace path → module path: Python = dotted prefix (`kebab_eval.metrics.compute_mrr`), TypeScript/JavaScript = slash-style prefix (`src/Foo.Foo.search`). Rust 1A-2 는 file-scope nesting 만 (workspace prefix 없음, 비일관 수용 — HOTFIXES 2026-05-20). |
|
||||
| TUI | Ratatui + crossterm — P9-1 Library 패널, P9-2/3/4 진행 예정 |
|
||||
| symbol path 형식 | workspace path → module path: Python = dotted prefix (`kebab_eval.metrics.compute_mrr`), TypeScript/JavaScript = slash-style prefix (`src/Foo.Foo.search`), Go = `package.Func` / `package.(*Receiver).Method`, Java/Kotlin = `com.foo.Foo.bar` (패키지+클래스+메서드/필드), C = 함수명, C++ = `namespace::Class::method`. Rust 1A-2 는 file-scope nesting 만 (workspace prefix 없음, 비일관 수용 — HOTFIXES 2026-05-20). code chunk 은 `citation.kind = "code"` + `citation.lang` + `symbol` + line range, SearchHit 에 `code_lang` + `repo`(`.git` walk-up 디렉토리명) backfill. |
|
||||
| TUI | Ratatui + crossterm — Library / Search / Ask / Inspect 패널 (P9-1~4 완료), vim-style NORMAL/INSERT 모드 + `F1` cheatsheet (런타임 키 매핑 권위 소스) |
|
||||
| Desktop | Tauri 2 + `pdfjs-dist` (native PDF render backend 금지) — P9-5 |
|
||||
| citation 형식 | URI fragment (`path#L12-L34` / `path#p=12` / `path#xywh=0,0,100,50`, W3C Media Fragments) |
|
||||
| ID 생성 | `blake3(canonical_json(tuple))[..32]` hex |
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
---
|
||||
title: Query-paraphrase robustness — Phase 1 (변형 일관성 평가) 완료 + (A)/(B) 진단
|
||||
date: 2026-05-29
|
||||
branch: feat/crossscript-rerank
|
||||
status: Phase 1 구현·측정 완료 — Phase 2(처방) 결정 대기
|
||||
related:
|
||||
- docs/superpowers/specs/2026-05-29-query-paraphrase-robustness-eval-design.md
|
||||
- docs/superpowers/plans/2026-05-29-query-paraphrase-robustness-eval.md
|
||||
- docs/superpowers/handoffs/2026-05-29-crossscript-rerank-progress-handoff.md (선행 rerank 실험)
|
||||
- memory: project_paraphrase_robustness, project_rerank_experiment, project_crossscript_diagnosis
|
||||
---
|
||||
|
||||
# Query-paraphrase robustness — Phase 1 완료
|
||||
|
||||
## TL;DR
|
||||
|
||||
같은 의미를 다른 표현(한/영·동의어·풀어쓴 문장)으로 물어도 일관된 품질이 나오는지 **직접 측정**하는
|
||||
프레임워크를 `kebab-eval` 에 구축하고(Phase 1), dogfood KB 에 8개 변형 그룹(32 변형)을 큐레이션해
|
||||
측정했다. 결과: **문제는 한/영이 아니라 "어휘 거리"** 이고, **(B) 어휘격차가 (A) 순위출렁보다 우세**
|
||||
(B_dominant=4 vs A_dominant=2). 즉 선행 rerank 실험(A형 처방)은 소수만 커버 — "측정 먼저" 논제가
|
||||
정량 검증됨. Phase 2 처방(쿼리 확장/번역 vs near-tie 흡수)은 사용자 결정 대기.
|
||||
|
||||
## Phase 1 구현 (branch `feat/crossscript-rerank`, 머지 전)
|
||||
|
||||
| Task | 커밋 | 내용 | 리뷰 |
|
||||
|---|---|---|---|
|
||||
| 1 | `e491a7b`+`48c94de` | `GoldenQuery.group` + loader 그룹 정합성 검증 | sonnet APPROVE-WITH-NITS (반영) |
|
||||
| 2 | `0ff38581`+`67e104f` | `kebab-eval::variant` 메트릭 + (A)/(B) 분류 | opus CHANGES-REQUESTED → H1/M1 수정 |
|
||||
| 3 | `895dcea` | `kebab eval variants <run_id>` CLI | 직접 검증 |
|
||||
|
||||
- **메트릭**: 그룹 내 `recall@narrow(10)` vs `recall@pool(50)` 대비 →
|
||||
`Ok`(top-10 안) / `MisRanked`(A: pool엔 있고 top-10 밖) / `Missing`(B: pool에도 없음).
|
||||
그룹 롤업: recall_spread@10, worst@10, A/B dominant, fully_consistent, `pool_possibly_truncated`.
|
||||
- **리뷰 H1 (실제 버그, 측정 전 차단)**: `POOL_K=50` 인데 `eval run --k` 기본=10 →
|
||||
pool==narrow 항상 → A 영원히 안 나옴, 전부 B 오분류. 수정: `config_snapshot_json` 에 `eval_k`
|
||||
추가 + `eval_k < 50` 이면 `bail` + `pool_possibly_truncated` 플래그. 회귀 테스트 고정.
|
||||
- 전 task `cargo test`+`clippy -D warnings` green. 기존 `AggregateMetrics` 경로 불변(회귀 가드 통과).
|
||||
|
||||
## 측정 (Task 4 큐레이션 + Task 5)
|
||||
|
||||
- golden: `/build/dogfood/golden_queries.yaml` 에 8그룹×4변형(ko/en/동의어/풀어쓴문장) append.
|
||||
정답 문서는 **corpus 의미로 판정**(검색 상위 자동채택 X — ownership 의 rank1 이 garbage-collection.md
|
||||
의 대조 언급이라 정답 아님을 실증). `topics/` 군(1파일=1주제)이라 판정 명확.
|
||||
- run: `kebab eval run --mode hybrid --k 50` (run_id `run_019e74dcae2778f3984df49ee79b725a`).
|
||||
- 리포트: `kebab eval variants <run_id>` (⚠️ `KEBAB_EVAL_GOLDEN=/build/dogfood/golden_queries.yaml`
|
||||
설정 필수 — 미설정 시 default golden → groups=0). 전체:
|
||||
`/build/dogfood/logs/2026-05-29-paraphrase-robustness-variants-hybrid.txt`.
|
||||
|
||||
### 결과 (hybrid, k=50, err=0)
|
||||
|
||||
```
|
||||
groups=8 fully_consistent=2 A_dominant=2 B_dominant=4 mean_spread@10=0.750 pool=top-50
|
||||
```
|
||||
|
||||
| group | A(MisRanked) | B(Missing) | 분류 | 핵심 |
|
||||
|---|---|---|---|---|
|
||||
| ownership | 0 | 0 | 완전 일관 ✅ | 4변형 모두 recall 1.0 |
|
||||
| isolation_levels | 0 | 0 | 완전 일관 ✅ | 4변형 모두 1.0 (한국어 "트랜잭션 격리 수준" 포함) |
|
||||
| cap_theorem | 2 | 0 | (A) near-tie | 풀어쓴 문장(영/한) recall@50=1·@10=0 |
|
||||
| vector_database | 2 | 0 | (A) near-tie | "벡터 데이터베이스"·"근사 최근접…" @50=1·@10=0 |
|
||||
| raft | 0 | 1 | (B) 어휘격차 | 영어 풀어쓴 "how nodes agree…" @50=0 |
|
||||
| mvcc | 0 | 1 | (B) 어휘격차 | 영어 풀어쓴 "how databases serve reads…" @50=0 |
|
||||
| backprop | 0 | 2 | (B) 어휘격차 | 한국어 "역전파 알고리즘"·"연쇄 법칙…" @50=0 |
|
||||
| gradient_descent | 0 | 2 | (B) 어휘격차 | 한국어 "경사 하강법"·"손실 함수…" @50=0 |
|
||||
|
||||
raw search 독립 검증: `kebab search "역전파 알고리즘" --k 50` → backprop doc(54e0ac…) **top-50 부재**
|
||||
(top은 무관한 algorithm.md). eval 파이프라인 artifact 아님 확인.
|
||||
|
||||
### 진단 (Read 검증된 숫자 기반)
|
||||
|
||||
1. **문제는 실재하고 크다**: mean_spread@10=0.750 — 같은 의도의 표현 간 recall 이 평균 0.75 출렁.
|
||||
2. **한/영 문제가 아니라 어휘 거리 문제**: 영어 풀어쓴 문장도 miss(raft/mvcc), 일부 한국어는 잘 됨
|
||||
(러스트 소유권, 트랜잭션 격리 수준, MVCC 동작 원리, 래프트 합의 알고리즘). 사용자 재정의 목표
|
||||
("정확한 단어가 아닌 같은 의미의 다른 단어")와 정확히 일치.
|
||||
3. **(B) 어휘격차 우세 (4 vs 2)**: 못 찾은 정답이 top-50 pool 에도 없음 → 재정렬(rerank)로 해결 불가.
|
||||
특히 ml-training(backprop/gradient_descent) 한국어는 영어 본문 문서를 의미·표층 둘 다 못 매칭.
|
||||
→ **쿼리 확장/번역**(또는 더 나은 다국어 임베딩) 처방 신호.
|
||||
4. **(A) 순위출렁은 소수 (cap_theorem/vector_database)**: 정답이 pool엔 있고 top-10 밖 →
|
||||
near-tie 흡수 / rerank 후보. 선행 rerank 실험이 도움 됐을 그룹.
|
||||
5. **"측정 먼저" 논제 검증**: rerank(A형) 단독은 6개 문제 그룹 중 2개만 커버. 선행 실험이 overlap
|
||||
프록시로 헛돈 이유가 데이터로 드러남.
|
||||
|
||||
## Phase 2 (처방) — 결정 대기
|
||||
|
||||
본 spec §2 의 조건부 게이트대로:
|
||||
- **(B) 우세이므로 쿼리 확장/번역이 1차 후보** (로컬 LLM gemma). cap_theorem/vector_database 의
|
||||
(A) 성분엔 near-tie 흡수가 보조.
|
||||
- 처방 효과는 본 Phase 1 평가셋(`kebab eval variants`)으로 재측정해 검증 (또 프록시 금지).
|
||||
- 미결: 확장/번역의 형태(쿼리→영어 번역 후 retrieve, 양쪽 retrieve 합집합, HyDE 류 등),
|
||||
latency·품질 trade-off, default on/off. → Phase 2 brainstorm/spec 에서.
|
||||
|
||||
## Phase 2 방향 — 딥리서치 + PoC (2026-05-30)
|
||||
|
||||
- **딥리서치** (`docs/superpowers/research/2026-05-30-vocabulary-gap-recall-fix-research.md`, 104 agent,
|
||||
22 confirmed/3 killed): 어휘격차 pool-miss 최선책 = **색인시 doc-side expansion(doc2query)**.
|
||||
pool 자체를 키우고(rerank 아님), per-query 지연 ~0(색인시 1회 → 사용자가 거부한 per-query LLM 아님),
|
||||
정확매칭 보존(별도 필드 append). 단 vanilla mt5 doc2query 는 같은언어라 한/영 갭은 색인시 KO↔EN
|
||||
대체 query 생성 필요. query-side(HyDE=거부된 per-query LLM, Vector-PRF=recall 주장 0-3 기각) 부적합.
|
||||
learned-sparse(SPLADE/MILCO)는 CPU/Rust 경로 없거나 교차언어 약함.
|
||||
- **PoC 확인** (`/build/dogfood/logs/2026-05-30-docexpansion-poc-result.md`): dogfood KB(3940 doc)에
|
||||
backprop/raft 별칭추가판 ingest → recall@50=0 이던 3쿼리 전부 **rank 1~2 로 부활**(hybrid+vector),
|
||||
별칭은 골든쿼리 verbatim 아님(일반화 확인). **딥리서치의 핵심 미검증 고리를 실 corpus 로 정량 확인.**
|
||||
- ⚠️ dogfood KB 현재 3942 doc (PoC 2개 잔존, corpus/_poc 는 삭제). variant 골든은 원본 doc_id
|
||||
타겟이라 baseline eval 무영향. pristine 필요 시 `kebab reset` + reingest.
|
||||
- **Phase 2 권고**: 색인시 doc-side expansion(같은언어 + KO↔EN 번역 별칭, 로컬 gemma 색인시 1회) →
|
||||
별도 FTS5 필드 → RRF. flag off 기본. 효과는 `kebab eval variants` 로 재측정. brainstorm→spec→plan.
|
||||
|
||||
## 다음 세션 첫 작업
|
||||
|
||||
1. 사용자와 Phase 2 방향 확정 (쿼리 확장/번역 설계 brainstorm).
|
||||
2. 또는 Phase 1 코드(group + variant + CLI)를 main 머지할지 결정 (default off, eval 전용·additive,
|
||||
기존 동작 무영향 → 머지 안전. PR 은 gitea-pr + 리뷰 루프).
|
||||
3. `--with-rag` 변형 일관성(답변 품질 직접 측정)은 미실행 — recall 진단으로 충분했음. 필요 시 후속.
|
||||
@@ -0,0 +1,827 @@
|
||||
# Query-paraphrase Robustness Eval (Phase 1) Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** `kebab-eval`에 "같은 의미의 여러 표현(동의어·다른 어휘·풀어쓴 문장·한/영)"을 묶는 변형 그룹과, 그룹 내 답변/검색 품질 일관성을 재고 (A)순위출렁/(B)어휘격차를 판별하는 진단 메트릭을 추가한다.
|
||||
|
||||
**Architecture:** `GoldenQuery`에 `group: Option<String>` 추가(additive) → loader가 그룹 정합성 검증 → 신규 `variant.rs`가 저장된 run의 per-query 결과를 그룹으로 묶어 recall@narrow(10) vs recall@pool(50) 대비로 변형 일관성 + A/B 분류 산출 → `kebab eval variants <run_id>` CLI로 표/JSON 리포트. 기존 `AggregateMetrics` 경로는 불변(group=None이면 기존 동작).
|
||||
|
||||
**Tech Stack:** Rust 2024, `kebab-eval` 크레이트, serde/serde_yaml, anyhow, rusqlite(간접). 측정은 release `kebab` + dogfood KB.
|
||||
|
||||
**빌드/테스트 규약 (이 환경 필수):** 모든 cargo는 `CARGO_TARGET_DIR=/build/out/cargo-target/target` + `-j 4`, 결과를 **파일 redirect + exit code 확인 후에만** 커밋 (`grep|tail` 금지 — pipe exit가 cargo 실패를 마스킹). 출력 노이즈로 빌드 오독 사례 다수.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| File | 책임 | 변경 |
|
||||
|---|---|---|
|
||||
| `crates/kebab-eval/src/types.rs` | `GoldenQuery`에 `group` 필드 | Modify |
|
||||
| `crates/kebab-eval/src/loader.rs` | 그룹 정합성 검증(`check_group_integrity`) | Modify |
|
||||
| `crates/kebab-eval/src/variant.rs` | 변형 일관성 메트릭 + A/B 분류 + 렌더 | **Create** |
|
||||
| `crates/kebab-eval/src/lib.rs` | `variant` 모듈 등록 + re-export | Modify |
|
||||
| `crates/kebab-cli/src/main.rs` | `kebab eval variants <run_id>` 서브커맨드 | Modify |
|
||||
| `/build/dogfood/golden_queries.yaml` | 변형 그룹 큐레이션 (in-repo 아님) | Modify (data) |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: `group` 필드 + loader 그룹 정합성 검증
|
||||
|
||||
**모델:** sonnet (작은 스키마 + 검증 함수)
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/kebab-eval/src/types.rs:13-29` (GoldenQuery)
|
||||
- Modify: `crates/kebab-eval/src/loader.rs` (`load_golden_set` + 신규 `check_group_integrity`)
|
||||
- Test: `crates/kebab-eval/src/loader.rs` (in-module `#[cfg(test)]`)
|
||||
|
||||
- [ ] **Step 1: `group` 필드 추가**
|
||||
|
||||
`crates/kebab-eval/src/types.rs`의 `GoldenQuery`에 `difficulty` 아래로 추가:
|
||||
|
||||
```rust
|
||||
#[serde(default)]
|
||||
pub difficulty: Option<String>,
|
||||
/// 같은 의미의 여러 표현(동의어·다른 어휘·풀어쓴 문장·한/영)을 묶는
|
||||
/// 의도 그룹 id. 같은 그룹의 모든 변형은 동일한 `expected_doc_ids`(집합)를
|
||||
/// 공유해야 한다(loader가 강제). `None`이면 단독 쿼리(기존 동작 불변).
|
||||
#[serde(default)]
|
||||
pub group: Option<String>,
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 실패하는 테스트 작성**
|
||||
|
||||
`crates/kebab-eval/src/loader.rs`의 `#[cfg(test)] mod tests` 안에 추가:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn rejects_group_with_divergent_expected_docs() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let yaml_path = tmp.path().join("golden.yaml");
|
||||
fs::write(
|
||||
&yaml_path,
|
||||
"- id: g1\n query: \"러스트 소유권\"\n group: ownership\n expected_doc_ids: [\"docA\"]\n\
|
||||
- id: g2\n query: \"rust ownership\"\n group: ownership\n expected_doc_ids: [\"docB\"]\n",
|
||||
)
|
||||
.unwrap();
|
||||
let err = load_golden_set(&yaml_path).unwrap_err();
|
||||
let msg = format!("{err:#}");
|
||||
assert!(msg.contains("group"), "msg: {msg}");
|
||||
assert!(msg.contains("ownership"), "msg: {msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_group_with_matching_expected_docs() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let yaml_path = tmp.path().join("golden.yaml");
|
||||
fs::write(
|
||||
&yaml_path,
|
||||
"- id: g1\n query: \"러스트 소유권\"\n group: ownership\n expected_doc_ids: [\"docA\"]\n\
|
||||
- id: g2\n query: \"rust ownership\"\n group: ownership\n expected_doc_ids: [\"docA\"]\n",
|
||||
)
|
||||
.unwrap();
|
||||
let qs = load_golden_set(&yaml_path).unwrap();
|
||||
assert_eq!(qs.len(), 2);
|
||||
assert_eq!(qs[0].group.as_deref(), Some("ownership"));
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 테스트 실패 확인**
|
||||
|
||||
Run: `CARGO_TARGET_DIR=/build/out/cargo-target/target cargo test -p kebab-eval -j 4 rejects_group_with_divergent > /build/cache/tmp/t1.txt 2>&1; echo "EXIT=$?"`
|
||||
Expected: 컴파일은 되나 `rejects_group_with_divergent_expected_docs` FAIL (현재 정합성 검증 없음 → `load_golden_set`이 Ok 반환).
|
||||
|
||||
- [ ] **Step 4: `check_group_integrity` 구현 + 배선**
|
||||
|
||||
`crates/kebab-eval/src/loader.rs`의 `load_golden_set`에서 `check_unique_ids(&queries)?;` 바로 다음 줄에 `check_group_integrity(&queries)?;` 추가. `check_unique_ids` 함수 아래에 신규 함수:
|
||||
|
||||
```rust
|
||||
/// 같은 `group`에 속한 모든 쿼리가 동일한 `expected_doc_ids`(집합)를
|
||||
/// 공유하는지 검증. 변형 일관성 메트릭은 "같은 정답을 가진 다른 표현들"을
|
||||
/// 전제하므로, 그룹 내 정답이 갈리면 측정이 무의미해진다 → bail.
|
||||
fn check_group_integrity(queries: &[GoldenQuery]) -> Result<()> {
|
||||
use std::collections::BTreeMap;
|
||||
// group -> (대표 정답 집합, 대표 query id)
|
||||
let mut canonical: BTreeMap<&str, (BTreeSet<String>, &str)> = BTreeMap::new();
|
||||
let mut offenders: BTreeSet<String> = BTreeSet::new();
|
||||
for q in queries {
|
||||
let Some(group) = q.group.as_deref() else {
|
||||
continue;
|
||||
};
|
||||
let docs: BTreeSet<String> = q.expected_doc_ids.iter().map(|d| d.0.clone()).collect();
|
||||
match canonical.get(group) {
|
||||
None => {
|
||||
canonical.insert(group, (docs, q.id.as_str()));
|
||||
}
|
||||
Some((expected, _first)) if *expected != docs => {
|
||||
offenders.insert(group.to_string());
|
||||
}
|
||||
Some(_) => {}
|
||||
}
|
||||
}
|
||||
if offenders.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
let list: Vec<String> = offenders.into_iter().collect();
|
||||
Err(anyhow!(
|
||||
"group(s) with divergent expected_doc_ids (same group must share one expected doc set): {}",
|
||||
list.join(", ")
|
||||
))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`BTreeSet`는 파일 상단 `use std::collections::{BTreeSet, HashSet};`에 이미 포함됨(확인). 누락 시 추가.
|
||||
|
||||
- [ ] **Step 5: 테스트 통과 확인**
|
||||
|
||||
Run: `CARGO_TARGET_DIR=/build/out/cargo-target/target cargo test -p kebab-eval -j 4 group > /build/cache/tmp/t1b.txt 2>&1; echo "EXIT=$?"`
|
||||
Expected: `rejects_group_with_divergent_expected_docs` + `accepts_group_with_matching_expected_docs` PASS. EXIT=0.
|
||||
|
||||
- [ ] **Step 6: clippy + 커밋**
|
||||
|
||||
Run: `CARGO_TARGET_DIR=/build/out/cargo-target/target cargo clippy -p kebab-eval --all-targets -j 4 -- -D warnings > /build/cache/tmp/c1.txt 2>&1; echo "EXIT=$?"`
|
||||
Expected: EXIT=0.
|
||||
|
||||
```bash
|
||||
git add crates/kebab-eval/src/types.rs crates/kebab-eval/src/loader.rs
|
||||
git commit -m "feat(eval): GoldenQuery.group + 그룹 정합성 검증 (변형 일관성 기반)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: 변형 일관성 메트릭 모듈 (`variant.rs`)
|
||||
|
||||
**모델:** opus (핵심 로직 — recall@narrow/pool, A/B 분류, 그룹 롤업)
|
||||
|
||||
**Files:**
|
||||
- Create: `crates/kebab-eval/src/variant.rs`
|
||||
- Modify: `crates/kebab-eval/src/lib.rs` (모듈 등록 + re-export)
|
||||
- Test: `crates/kebab-eval/src/variant.rs` (in-module `#[cfg(test)]`)
|
||||
|
||||
- [ ] **Step 1: 모듈 골격 + 타입 작성**
|
||||
|
||||
`crates/kebab-eval/src/variant.rs` 생성:
|
||||
|
||||
```rust
|
||||
//! 변형(paraphrase) 일관성 진단 메트릭.
|
||||
//!
|
||||
//! 같은 의도(`GoldenQuery.group`)의 여러 표현이 같은 정답 문서를 공유한다는
|
||||
//! 전제 아래, 표현마다 검색/답변 품질이 얼마나 출렁이는지를 잰다. 핵심은
|
||||
//! `recall@narrow`(사용자가 보는 top-10) vs `recall@pool`(넓은 후보 폭)의 대비:
|
||||
//!
|
||||
//! - (A) 순위 출렁(`MisRanked`): 정답이 pool엔 있는데 top-10 밖 → near-tie 흡수로 해결 후보.
|
||||
//! - (B) 어휘 격차(`Missing`): 정답이 pool에도 없음 → 쿼리 확장/번역 필요.
|
||||
//!
|
||||
//! 진단 전용. 기존 [`crate::metrics::AggregateMetrics`] 경로는 건드리지 않는다.
|
||||
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use kebab_config::Config;
|
||||
use kebab_core::DocumentId;
|
||||
use kebab_store_sqlite::SqliteStore;
|
||||
|
||||
use crate::types::{GoldenQuery, QueryResult};
|
||||
|
||||
/// 사용자가 실제 보는 답변 context 폭.
|
||||
const NARROW_K: u32 = 10;
|
||||
/// 넓은 후보 폭. recall@pool vs recall@narrow 대비로 A/B를 가른다.
|
||||
/// eval run은 `--k`를 이 값 이상으로 줘서 `hits_top_k`가 pool을 담아야 한다.
|
||||
const POOL_K: u32 = 50;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum VariantClass {
|
||||
/// recall@narrow == 1.0 (정답 전부 top-10 안).
|
||||
Ok,
|
||||
/// recall@pool > recall@narrow (정답이 pool엔 있는데 top-10 밖). (A)
|
||||
MisRanked,
|
||||
/// recall@pool == recall@narrow < 1.0 (못 찾은 정답이 pool에도 없음). (B)
|
||||
Missing,
|
||||
/// 정답 문서 미지정(검증 불가).
|
||||
NoExpected,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct VariantResult {
|
||||
pub query_id: String,
|
||||
pub query: String,
|
||||
pub recall_narrow: f32,
|
||||
pub recall_pool: f32,
|
||||
/// must_contain 통과 여부. RAG 답변(`--with-rag`)이 없으면 `None`.
|
||||
pub answer_ok: Option<bool>,
|
||||
pub class: VariantClass,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct VariantGroupReport {
|
||||
pub group: String,
|
||||
pub variants: Vec<VariantResult>,
|
||||
/// max-min recall_narrow (정답 지정 변형들만). 0 = 완전 일관.
|
||||
pub recall_spread_narrow: f32,
|
||||
pub worst_recall_narrow: f32,
|
||||
/// 모든 변형이 must_contain 통과면 Some(true), 하나라도 실패 Some(false),
|
||||
/// RAG 답변이 전혀 없으면 None.
|
||||
pub answer_consistency: Option<bool>,
|
||||
pub mis_ranked: u32,
|
||||
pub missing: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct VariantConsistencyReport {
|
||||
pub groups: Vec<VariantGroupReport>,
|
||||
pub mean_recall_spread_narrow: f32,
|
||||
/// spread==0 && worst_recall_narrow==1.0 인 그룹 수.
|
||||
pub fully_consistent_groups: u32,
|
||||
pub total_groups: u32,
|
||||
/// mis_ranked>0 && mis_ranked>=missing 인 그룹 수 (near-tie 처방 우선).
|
||||
pub a_dominant_groups: u32,
|
||||
/// missing>0 && missing>mis_ranked 인 그룹 수 (쿼리 확장 처방 우선).
|
||||
pub b_dominant_groups: u32,
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 실패하는 테스트 작성**
|
||||
|
||||
같은 파일 하단에:
|
||||
|
||||
```rust
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use kebab_core::{
|
||||
ChunkId, ChunkerVersion, Citation, IndexVersion, RetrievalDetail, SearchMode, WorkspacePath,
|
||||
ScoreKind,
|
||||
};
|
||||
use kebab_store_sqlite::EvalQueryResultRecord;
|
||||
|
||||
fn hit(doc: &str, rank: u32) -> kebab_core::SearchHit {
|
||||
let path = WorkspacePath::new(format!("{doc}.md")).unwrap();
|
||||
kebab_core::SearchHit {
|
||||
rank,
|
||||
chunk_id: ChunkId(format!("c-{doc}-{rank}")),
|
||||
doc_id: DocumentId(doc.to_string()),
|
||||
doc_path: path.clone(),
|
||||
heading_path: vec![],
|
||||
section_label: None,
|
||||
snippet: String::new(),
|
||||
citation: Citation::Line { path, start: 1, end: 1, section: None },
|
||||
retrieval: RetrievalDetail {
|
||||
method: SearchMode::Vector,
|
||||
fusion_score: 1.0 / rank as f32,
|
||||
lexical_score: None,
|
||||
vector_score: Some(1.0 / rank as f32),
|
||||
lexical_rank: None,
|
||||
vector_rank: Some(rank),
|
||||
},
|
||||
index_version: IndexVersion("v1".into()),
|
||||
embedding_model: None,
|
||||
chunker_version: ChunkerVersion("v1".into()),
|
||||
indexed_at: time::OffsetDateTime::UNIX_EPOCH,
|
||||
stale: false,
|
||||
score_kind: ScoreKind::Cosine,
|
||||
repo: None,
|
||||
code_lang: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn gq(id: &str, group: &str, expected_doc: &str) -> GoldenQuery {
|
||||
GoldenQuery {
|
||||
id: id.into(),
|
||||
query: id.into(),
|
||||
lang: kebab_core::Lang(String::new()),
|
||||
expected_doc_ids: vec![DocumentId(expected_doc.into())],
|
||||
expected_chunk_ids: vec![],
|
||||
must_contain: vec![],
|
||||
forbidden: vec![],
|
||||
difficulty: None,
|
||||
group: Some(group.into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn row(query_id: &str, hits: Vec<kebab_core::SearchHit>) -> EvalQueryResultRecord {
|
||||
let qr = QueryResult {
|
||||
query_id: query_id.into(),
|
||||
query: query_id.into(),
|
||||
mode: SearchMode::Vector,
|
||||
hits_top_k: hits,
|
||||
answer: None,
|
||||
elapsed_ms: 0,
|
||||
error: None,
|
||||
};
|
||||
EvalQueryResultRecord {
|
||||
query_id: query_id.into(),
|
||||
result_json: serde_json::to_string(&qr).unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_mis_ranked_vs_missing_and_spread() {
|
||||
// group "g": 정답 docX.
|
||||
// v1: docX at rank 3 → narrow=1.0 → Ok
|
||||
// v2: docX at rank 25 → narrow=0.0, pool=1.0 → MisRanked (A)
|
||||
// v3: docX 없음 → narrow=0.0, pool=0.0 → Missing (B)
|
||||
let queries = vec![gq("v1", "g", "docX"), gq("v2", "g", "docX"), gq("v3", "g", "docX")];
|
||||
let rows = vec![
|
||||
row("v1", vec![hit("docX", 3)]),
|
||||
row("v2", vec![hit("docX", 25)]),
|
||||
row("v3", vec![hit("other", 1)]),
|
||||
];
|
||||
let rep = compute_variant_consistency(&queries, &rows).unwrap();
|
||||
assert_eq!(rep.total_groups, 1);
|
||||
let g = &rep.groups[0];
|
||||
assert_eq!(g.group, "g");
|
||||
assert_eq!(g.variants.len(), 3);
|
||||
// spread = max(1.0) - min(0.0) = 1.0
|
||||
assert!((g.recall_spread_narrow - 1.0).abs() < 1e-6);
|
||||
assert!((g.worst_recall_narrow - 0.0).abs() < 1e-6);
|
||||
assert_eq!(g.mis_ranked, 1);
|
||||
assert_eq!(g.missing, 1);
|
||||
let classes: Vec<VariantClass> = g.variants.iter().map(|v| v.class).collect();
|
||||
assert!(classes.contains(&VariantClass::Ok));
|
||||
assert!(classes.contains(&VariantClass::MisRanked));
|
||||
assert!(classes.contains(&VariantClass::Missing));
|
||||
assert_eq!(rep.a_dominant_groups + rep.b_dominant_groups, 1); // tie→정의대로 하나로 분류
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fully_consistent_group_when_all_ok() {
|
||||
let queries = vec![gq("v1", "g", "docX"), gq("v2", "g", "docX")];
|
||||
let rows = vec![row("v1", vec![hit("docX", 1)]), row("v2", vec![hit("docX", 2)])];
|
||||
let rep = compute_variant_consistency(&queries, &rows).unwrap();
|
||||
assert_eq!(rep.fully_consistent_groups, 1);
|
||||
assert!((rep.groups[0].recall_spread_narrow - 0.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ungrouped_queries_are_ignored() {
|
||||
let mut q = gq("solo", "g", "docX");
|
||||
q.group = None;
|
||||
let rep = compute_variant_consistency(&[q], &[row("solo", vec![hit("docX", 1)])]).unwrap();
|
||||
assert_eq!(rep.total_groups, 0);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 테스트 실패 확인**
|
||||
|
||||
먼저 `lib.rs`에 모듈 등록(아래 Step 5 일부 선행): `crates/kebab-eval/src/lib.rs`의 모듈 선언부에 `mod variant;` + `pub use variant::{VariantConsistencyReport, VariantGroupReport, VariantResult, VariantClass, compute_variant_consistency, compute_variant_consistency_with_config, render_variants_md};` 추가(아직 함수 미정의 → 다음 스텝에서 채움). 우선 컴파일 통과를 위해 `compute_variant_consistency`만 stub 없이 진행하면 컴파일 에러로 실패함을 확인.
|
||||
|
||||
Run: `CARGO_TARGET_DIR=/build/out/cargo-target/target cargo test -p kebab-eval -j 4 variant > /build/cache/tmp/t2.txt 2>&1; echo "EXIT=$?"`
|
||||
Expected: 컴파일 에러(함수 미정의). 다음 스텝에서 구현.
|
||||
|
||||
- [ ] **Step 4: `compute_variant_consistency` + 헬퍼 구현**
|
||||
|
||||
`variant.rs`의 타입 정의 아래, `#[cfg(test)]` 위에 추가:
|
||||
|
||||
```rust
|
||||
/// 저장된 run을 그룹으로 묶어 변형 일관성 리포트를 만든다.
|
||||
/// `rows`는 [`crate::metrics::aggregate_from_rows`]와 동일한 입력
|
||||
/// (저장된 per-query 결과). `group`이 없는 쿼리는 무시한다.
|
||||
pub fn compute_variant_consistency(
|
||||
queries: &[GoldenQuery],
|
||||
rows: &[kebab_store_sqlite::EvalQueryResultRecord],
|
||||
) -> Result<VariantConsistencyReport> {
|
||||
let golden_by_id: HashMap<&str, &GoldenQuery> =
|
||||
queries.iter().map(|q| (q.id.as_str(), q)).collect();
|
||||
|
||||
let mut grouped: BTreeMap<String, Vec<VariantResult>> = BTreeMap::new();
|
||||
for row in rows {
|
||||
let qr: QueryResult = serde_json::from_str(&row.result_json)
|
||||
.with_context(|| format!("parse result_json for {}", row.query_id))?;
|
||||
let Some(gq) = golden_by_id.get(qr.query_id.as_str()) else {
|
||||
continue;
|
||||
};
|
||||
let Some(group) = gq.group.clone() else {
|
||||
continue;
|
||||
};
|
||||
let (recall_narrow, recall_pool) = recall_narrow_pool(&qr, &gq.expected_doc_ids);
|
||||
let answer_ok = qr.answer.as_ref().map(|a| {
|
||||
gq.must_contain.iter().all(|s| a.answer.contains(s))
|
||||
&& !gq.forbidden.iter().any(|s| a.answer.contains(s))
|
||||
});
|
||||
let class = classify(&gq.expected_doc_ids, recall_narrow, recall_pool);
|
||||
grouped.entry(group).or_default().push(VariantResult {
|
||||
query_id: qr.query_id.clone(),
|
||||
query: qr.query.clone(),
|
||||
recall_narrow,
|
||||
recall_pool,
|
||||
answer_ok,
|
||||
class,
|
||||
});
|
||||
}
|
||||
|
||||
let mut groups: Vec<VariantGroupReport> = Vec::with_capacity(grouped.len());
|
||||
for (group, variants) in grouped {
|
||||
groups.push(rollup_group(group, variants));
|
||||
}
|
||||
|
||||
let total_groups = u32::try_from(groups.len()).unwrap_or(u32::MAX);
|
||||
let fully_consistent_groups = groups
|
||||
.iter()
|
||||
.filter(|g| g.recall_spread_narrow == 0.0 && g.worst_recall_narrow == 1.0)
|
||||
.count() as u32;
|
||||
let a_dominant_groups = groups
|
||||
.iter()
|
||||
.filter(|g| g.mis_ranked > 0 && g.mis_ranked >= g.missing)
|
||||
.count() as u32;
|
||||
let b_dominant_groups = groups
|
||||
.iter()
|
||||
.filter(|g| g.missing > 0 && g.missing > g.mis_ranked)
|
||||
.count() as u32;
|
||||
let mean_recall_spread_narrow = if groups.is_empty() {
|
||||
0.0
|
||||
} else {
|
||||
groups.iter().map(|g| g.recall_spread_narrow).sum::<f32>() / groups.len() as f32
|
||||
};
|
||||
|
||||
Ok(VariantConsistencyReport {
|
||||
groups,
|
||||
mean_recall_spread_narrow,
|
||||
fully_consistent_groups,
|
||||
total_groups,
|
||||
a_dominant_groups,
|
||||
b_dominant_groups,
|
||||
})
|
||||
}
|
||||
|
||||
/// 정답 문서 집합에 대한 recall@NARROW_K, recall@POOL_K.
|
||||
/// 정답 미지정이면 (NaN, NaN).
|
||||
fn recall_narrow_pool(qr: &QueryResult, expected: &[DocumentId]) -> (f32, f32) {
|
||||
if expected.is_empty() {
|
||||
return (f32::NAN, f32::NAN);
|
||||
}
|
||||
let exp: HashSet<&DocumentId> = expected.iter().collect();
|
||||
let cover = |k: u32| -> f32 {
|
||||
let topk: HashSet<&DocumentId> = qr
|
||||
.hits_top_k
|
||||
.iter()
|
||||
.filter(|h| h.rank <= k)
|
||||
.map(|h| &h.doc_id)
|
||||
.collect();
|
||||
exp.iter().filter(|d| topk.contains(*d)).count() as f32 / exp.len() as f32
|
||||
};
|
||||
(cover(NARROW_K), cover(POOL_K))
|
||||
}
|
||||
|
||||
fn classify(expected: &[DocumentId], recall_narrow: f32, recall_pool: f32) -> VariantClass {
|
||||
if expected.is_empty() {
|
||||
VariantClass::NoExpected
|
||||
} else if recall_narrow >= 1.0 {
|
||||
VariantClass::Ok
|
||||
} else if recall_pool > recall_narrow {
|
||||
VariantClass::MisRanked
|
||||
} else {
|
||||
VariantClass::Missing
|
||||
}
|
||||
}
|
||||
|
||||
fn rollup_group(group: String, variants: Vec<VariantResult>) -> VariantGroupReport {
|
||||
let measurable: Vec<f32> = variants
|
||||
.iter()
|
||||
.filter(|v| !v.recall_narrow.is_nan())
|
||||
.map(|v| v.recall_narrow)
|
||||
.collect();
|
||||
let (recall_spread_narrow, worst_recall_narrow) = if measurable.is_empty() {
|
||||
(0.0, f32::NAN)
|
||||
} else {
|
||||
let max = measurable.iter().cloned().fold(f32::MIN, f32::max);
|
||||
let min = measurable.iter().cloned().fold(f32::MAX, f32::min);
|
||||
(max - min, min)
|
||||
};
|
||||
let answer_flags: Vec<bool> = variants.iter().filter_map(|v| v.answer_ok).collect();
|
||||
let answer_consistency = if answer_flags.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(answer_flags.iter().all(|&ok| ok))
|
||||
};
|
||||
let mis_ranked = variants.iter().filter(|v| v.class == VariantClass::MisRanked).count() as u32;
|
||||
let missing = variants.iter().filter(|v| v.class == VariantClass::Missing).count() as u32;
|
||||
VariantGroupReport {
|
||||
group,
|
||||
variants,
|
||||
recall_spread_narrow,
|
||||
worst_recall_narrow,
|
||||
answer_consistency,
|
||||
mis_ranked,
|
||||
missing,
|
||||
}
|
||||
}
|
||||
|
||||
/// 활성 XDG Config로 저장된 run을 읽어 변형 일관성을 계산
|
||||
/// ([`crate::metrics::compute_aggregate_with_config`]와 동일한 로딩 패턴).
|
||||
pub fn compute_variant_consistency_with_config(
|
||||
cfg: &Config,
|
||||
run_id: &str,
|
||||
) -> Result<VariantConsistencyReport> {
|
||||
let store = SqliteStore::open(cfg).context("open SqliteStore for variant consistency")?;
|
||||
store.run_migrations().context("run migrations")?;
|
||||
if store.load_eval_run(run_id).context("load eval_runs row")?.is_none() {
|
||||
anyhow::bail!("compute_variant_consistency: no eval_runs row for run_id {run_id}");
|
||||
}
|
||||
let rows = store
|
||||
.load_eval_query_results(run_id)
|
||||
.context("load eval_query_results")?;
|
||||
let queries = crate::metrics::load_golden_for_metrics_pub()?;
|
||||
compute_variant_consistency(&queries, &rows)
|
||||
}
|
||||
```
|
||||
|
||||
주: `compute_variant_consistency_with_config`는 golden 로드에 `metrics`의 비공개 헬퍼가 필요하다. `crates/kebab-eval/src/metrics.rs`의 `fn load_golden_for_metrics()`를 `pub(crate) fn load_golden_for_metrics_pub()`로 노출하는 얇은 래퍼를 추가하거나, 기존 `load_golden_for_metrics`를 `pub(crate)`로 바꿔 `crate::metrics::load_golden_for_metrics()`로 직접 호출. **후자 채택**: `metrics.rs`의 `fn load_golden_for_metrics`를 `pub(crate) fn load_golden_for_metrics`로 변경하고, 위 호출을 `crate::metrics::load_golden_for_metrics()?`로 수정.
|
||||
|
||||
- [ ] **Step 5: 렌더 함수 + lib.rs 등록**
|
||||
|
||||
`variant.rs`에 사람이 읽는 표 렌더 추가(`#[cfg(test)]` 위):
|
||||
|
||||
```rust
|
||||
/// 변형 일관성 리포트를 사람이 읽는 마크다운 표로 렌더
|
||||
/// ([`crate::render_report_md`] 스타일).
|
||||
pub fn render_variants_md(rep: &VariantConsistencyReport) -> String {
|
||||
use std::fmt::Write;
|
||||
let mut s = String::new();
|
||||
let _ = writeln!(s, "# Variant consistency\n");
|
||||
let _ = writeln!(
|
||||
s,
|
||||
"groups={} fully_consistent={} A_dominant={} B_dominant={} mean_spread@{}={:.3}\n",
|
||||
rep.total_groups,
|
||||
rep.fully_consistent_groups,
|
||||
rep.a_dominant_groups,
|
||||
rep.b_dominant_groups,
|
||||
NARROW_K,
|
||||
rep.mean_recall_spread_narrow,
|
||||
);
|
||||
for g in &rep.groups {
|
||||
let ac = match g.answer_consistency {
|
||||
Some(true) => "all-ok",
|
||||
Some(false) => "MIXED",
|
||||
None => "n/a",
|
||||
};
|
||||
let _ = writeln!(
|
||||
s,
|
||||
"## {} — spread@{}={:.2} worst={:.2} A={} B={} answers={}",
|
||||
g.group, NARROW_K, g.recall_spread_narrow, g.worst_recall_narrow, g.mis_ranked, g.missing, ac
|
||||
);
|
||||
let _ = writeln!(s, "| variant | recall@{NARROW_K} | recall@{POOL_K} | class | answer |");
|
||||
let _ = writeln!(s, "|---|---|---|---|---|");
|
||||
for v in &g.variants {
|
||||
let ans = match v.answer_ok {
|
||||
Some(true) => "ok",
|
||||
Some(false) => "BAD",
|
||||
None => "-",
|
||||
};
|
||||
let _ = writeln!(
|
||||
s,
|
||||
"| {} | {:.2} | {:.2} | {:?} | {} |",
|
||||
v.query, v.recall_narrow, v.recall_pool, v.class, ans
|
||||
);
|
||||
}
|
||||
let _ = writeln!(s);
|
||||
}
|
||||
s
|
||||
}
|
||||
```
|
||||
|
||||
`crates/kebab-eval/src/lib.rs`: 모듈 선언 영역에 `mod variant;` 추가, re-export에 추가:
|
||||
|
||||
```rust
|
||||
pub use variant::{
|
||||
VariantClass, VariantConsistencyReport, VariantGroupReport, VariantResult,
|
||||
compute_variant_consistency, compute_variant_consistency_with_config, render_variants_md,
|
||||
};
|
||||
```
|
||||
|
||||
(기존 `pub use` 패턴은 `lib.rs`에서 `compare`/`metrics` re-export를 보고 맞춤. 정확한 위치/형식은 그 패턴을 따른다.)
|
||||
|
||||
- [ ] **Step 6: 테스트 + clippy 통과 확인**
|
||||
|
||||
Run: `CARGO_TARGET_DIR=/build/out/cargo-target/target cargo test -p kebab-eval -j 4 > /build/cache/tmp/t2b.txt 2>&1; echo "EXIT=$?"`
|
||||
Expected: 3개 신규 variant 테스트 + 기존 테스트 모두 PASS. EXIT=0. (기존 `aggregate` 테스트가 그대로 통과 = group=None 경로 불변 회귀 가드)
|
||||
|
||||
Run: `CARGO_TARGET_DIR=/build/out/cargo-target/target cargo clippy -p kebab-eval --all-targets -j 4 -- -D warnings > /build/cache/tmp/c2.txt 2>&1; echo "EXIT=$?"`
|
||||
Expected: EXIT=0.
|
||||
|
||||
- [ ] **Step 7: 커밋**
|
||||
|
||||
```bash
|
||||
git add crates/kebab-eval/src/variant.rs crates/kebab-eval/src/lib.rs crates/kebab-eval/src/metrics.rs
|
||||
git commit -m "feat(eval): 변형 일관성 메트릭 + A/B(순위출렁/어휘격차) 분류"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: CLI `kebab eval variants <run_id>` 서브커맨드
|
||||
|
||||
**모델:** sonnet (작은 CLI 배선)
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/kebab-cli/src/main.rs` (`EvalWhat` enum ~414 + `Cmd::Eval` 매치 ~1361)
|
||||
- Test: 수동 (Task 5에서 실제 run으로 검증) + 컴파일/clippy
|
||||
|
||||
- [ ] **Step 1: `EvalWhat::Variants` 변형 추가**
|
||||
|
||||
`crates/kebab-cli/src/main.rs`의 `enum EvalWhat`에 `Aggregate` 변형 옆으로 추가 (clap 파생 스타일은 인접 변형을 그대로 따른다):
|
||||
|
||||
```rust
|
||||
/// 변형 그룹 일관성 진단 — 같은 의도의 여러 표현에서 recall@10 vs
|
||||
/// recall@50 대비로 (A)순위출렁/(B)어휘격차를 판별.
|
||||
Variants {
|
||||
/// 진단할 저장된 run_id.
|
||||
run_id: String,
|
||||
/// JSON으로 출력 (기본은 마크다운 표).
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
```
|
||||
|
||||
- [ ] **Step 2: `Cmd::Eval` 매치 암(arm) 추가**
|
||||
|
||||
`Cmd::Eval { what } => { match what { ... } }` 내부, `EvalWhat::Aggregate { .. } => { .. }` 암 다음에:
|
||||
|
||||
```rust
|
||||
EvalWhat::Variants { run_id, json } => {
|
||||
let rep = kebab_eval::compute_variant_consistency_with_config(&cfg, run_id)?;
|
||||
if *json {
|
||||
println!("{}", serde_json::to_string_pretty(&rep)?);
|
||||
} else {
|
||||
print!("{}", kebab_eval::render_variants_md(&rep));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
(`cfg`는 같은 스코프에서 `EvalWhat::Aggregate` 암이 쓰는 것과 동일하게 로드됨 — 그 암의 `cfg` 획득 방식을 그대로 따른다. `run_id`가 `&String`이면 `compute_..._with_config(&cfg, run_id)`로 deref 강제됨; 필요시 `run_id.as_str()`.)
|
||||
|
||||
- [ ] **Step 3: 빌드 + clippy 통과 확인**
|
||||
|
||||
Run: `CARGO_TARGET_DIR=/build/out/cargo-target/target cargo build -p kebab-cli -j 4 > /build/cache/tmp/t3.txt 2>&1; echo "EXIT=$?"`
|
||||
Expected: EXIT=0.
|
||||
|
||||
Run: `CARGO_TARGET_DIR=/build/out/cargo-target/target cargo clippy -p kebab-cli --all-targets -j 4 -- -D warnings > /build/cache/tmp/c3.txt 2>&1; echo "EXIT=$?"`
|
||||
Expected: EXIT=0.
|
||||
|
||||
- [ ] **Step 4: 커밋**
|
||||
|
||||
```bash
|
||||
git add crates/kebab-cli/src/main.rs
|
||||
git commit -m "feat(cli): kebab eval variants <run_id> — 변형 일관성 진단 리포트"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: dogfood golden_queries.yaml 변형 그룹 큐레이션
|
||||
|
||||
**모델:** opus (정답 문서를 corpus 의미로 판정 — 판단 필요)
|
||||
|
||||
**Files:**
|
||||
- Modify: `/build/dogfood/golden_queries.yaml` (in-repo 아님 — dogfood 데이터)
|
||||
|
||||
**큐레이션 원칙 (순환 회피, [[feedback_search_quality_dogfood]]):** 정답 *문서*는 corpus 의미로
|
||||
판정한다. **검색 결과 상위를 정답으로 베끼지 말 것.** 의도에 맞는 문서를 corpus 내용으로 고른 뒤,
|
||||
그 문서의 doc_id/chunk_id를 SQLite에서 조회한다.
|
||||
|
||||
- [ ] **Step 1: 의도(그룹) 6–10개 선정**
|
||||
|
||||
선행 ablation 토픽 재사용 + 동의어/다른어휘/풀어쓴문장 추가. 후보 의도(각 그룹 3–5 표현):
|
||||
|
||||
| group | 표현 예시 (한/영/동의어/풀어쓴문장) |
|
||||
|---|---|
|
||||
| `ownership` | "러스트 소유권" / "rust ownership" / "러스트 메모리 소유권 규칙" / "who owns a value in rust" |
|
||||
| `lifetime` | "러스트 lifetime" / "rust lifetime" / "러스트 수명" / "빌림 검사기 수명" |
|
||||
| `database_index` | "데이터베이스 인덱스" / "database index" / "DB 색인" / "쿼리 빠르게 하는 인덱스" |
|
||||
| `gc` | "가비지 컬렉션" / "garbage collection" / "자동 메모리 회수" |
|
||||
| `async` | "비동기 프로그래밍" / "async programming" / "논블로킹 동시성" |
|
||||
| `kubernetes_deploy` | "쿠버네티스 배포" / "kubernetes deployment" / "k8s 앱 배포" |
|
||||
|
||||
(corpus에 명확한 정답 문서가 없는 의도는 제외. rust류 + 일반 토픽 섞기.)
|
||||
|
||||
- [ ] **Step 2: 각 의도의 정답 문서를 corpus 의미로 판정 + ID 조회**
|
||||
|
||||
dogfood KB(`/build/dogfood/config.toml`)에서, 의도별로 corpus 내용상 그 주제를 다루는 문서를
|
||||
식별한다. doc_id/chunk_id 조회 (release 바이너리):
|
||||
|
||||
```bash
|
||||
BIN=/build/out/cargo-target/target/release/kebab
|
||||
CFG=/build/dogfood/config.toml
|
||||
# 후보 문서를 폭넓게 본 뒤 내용으로 정답 판정 (상위 1개 자동채택 금지):
|
||||
$BIN search "rust ownership" --config $CFG --mode hybrid --k 20 --json --quiet \
|
||||
| python3 -c 'import sys,json; [print(h["doc_id"], h.get("doc_path"), h["chunk_id"]) for h in json.load(sys.stdin)["hits"]]'
|
||||
```
|
||||
|
||||
각 그룹마다: 내용으로 맞는 문서 1–2개의 `doc_id`(+대표 `chunk_id`)를 확정. 같은 그룹의 모든 변형은
|
||||
**동일한 `expected_doc_ids`** 를 갖는다(Task 1의 정합성 검증이 강제).
|
||||
|
||||
- [ ] **Step 3: must_contain 핵심 사실 큐레이션 (그룹 공유)**
|
||||
|
||||
각 그룹에 답변이 반드시 포함해야 할 핵심 substring 1–2개 (정답 문서 내용에서 발췌). 한/영 답변
|
||||
모두에서 성립하는 표현으로 (예: 고유명사·숫자·식별자). 너무 길거나 표현 특정적이면 피한다.
|
||||
|
||||
- [ ] **Step 4: yaml에 그룹 엔트리 추가**
|
||||
|
||||
`/build/dogfood/golden_queries.yaml`에 그룹별로 추가 (기존 dg0xx 엔트리는 유지). 형식:
|
||||
|
||||
```yaml
|
||||
# --- variant groups (paraphrase robustness, 2026-05-29) ---
|
||||
- id: vg_ownership_ko
|
||||
query: "러스트 소유권"
|
||||
lang: ko
|
||||
group: ownership
|
||||
difficulty: medium
|
||||
expected_doc_ids: ["<조회한 doc_id>"]
|
||||
expected_chunk_ids: ["<조회한 chunk_id>"]
|
||||
must_contain: ["<핵심 사실>"]
|
||||
- id: vg_ownership_en
|
||||
query: "rust ownership"
|
||||
lang: en
|
||||
group: ownership
|
||||
difficulty: medium
|
||||
expected_doc_ids: ["<같은 doc_id>"]
|
||||
expected_chunk_ids: ["<같은 chunk_id>"]
|
||||
must_contain: ["<같은 핵심 사실>"]
|
||||
# ... (그룹당 3–5 변형, 그룹 6–10개)
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 로드 검증 (정합성 + ID 실재)**
|
||||
|
||||
release 바이너리로 eval run 시작 직전까지 가서 loader가 통과하는지 확인 (Task 5의 run이 시작 시
|
||||
ID 실재 + 그룹 정합성을 검증 → bail 안 하면 OK). 빠른 단독 검증:
|
||||
|
||||
```bash
|
||||
KEBAB_EVAL_GOLDEN=/build/dogfood/golden_queries.yaml \
|
||||
$BIN eval run --config $CFG --mode hybrid --k 50 --json --quiet > /build/cache/tmp/t4_loadcheck.txt 2>&1
|
||||
echo "EXIT=$?" # 0 또는 run 진행이면 로드 통과; "duplicate"/"divergent"/"missing" 이면 수정
|
||||
```
|
||||
|
||||
(이 run 자체가 Task 5의 측정으로 이어짐 — 여기선 로드 통과만 확인.)
|
||||
|
||||
- [ ] **Step 6: 커밋 불요 (dogfood 데이터)**
|
||||
|
||||
`/build/dogfood/`는 repo 밖. 큐레이션 결과는 Task 5 측정 후 HOTFIXES에 그룹 목록을 요약 기록.
|
||||
|
||||
---
|
||||
|
||||
## Task 5: 측정 실행 + (A)/(B) 진단 리포트
|
||||
|
||||
**모델:** 오케스트레이터(나) 직접 또는 sonnet
|
||||
|
||||
**Files:**
|
||||
- 산출: `/build/cache/tmp/rr_variant_*.txt`, `tasks/HOTFIXES.md`(dated entry), 핸드오프 갱신
|
||||
|
||||
- [ ] **Step 1: release 빌드**
|
||||
|
||||
Run (background): `CARGO_TARGET_DIR=/build/out/cargo-target/target cargo build --release -p kebab-cli -j 4 > /build/cache/tmp/rr_variant_build.txt 2>&1; echo "EXIT=$?"`
|
||||
Expected: EXIT=0. 바이너리 mtime이 갱신됐는지 확인.
|
||||
|
||||
- [ ] **Step 2: eval run (k=50, hybrid + vector, with-rag)**
|
||||
|
||||
```bash
|
||||
BIN=/build/out/cargo-target/target/release/kebab
|
||||
CFG=/build/dogfood/config.toml
|
||||
export KEBAB_EVAL_GOLDEN=/build/dogfood/golden_queries.yaml
|
||||
# 검색 전용(빠름) — recall 진단의 핵심:
|
||||
$BIN eval run --config $CFG --mode hybrid --k 50 > /build/cache/tmp/rr_variant_run_hybrid.txt 2>&1; echo "EXIT=$?"
|
||||
# run_id를 출력에서 추출 (clean grep)
|
||||
```
|
||||
|
||||
`--with-rag`는 answer_consistency가 필요할 때만 (LLM 비용 큼). 1차는 검색 전용으로 recall 기반
|
||||
A/B 진단부터. answer_consistency는 별도 `--with-rag` run으로.
|
||||
|
||||
- [ ] **Step 3: variants 리포트 산출**
|
||||
|
||||
```bash
|
||||
$BIN eval variants <run_id> --config $CFG > /build/cache/tmp/rr_variant_report_hybrid.txt 2>&1; echo "EXIT=$?"
|
||||
$BIN eval variants <run_id> --config $CFG --json > /build/cache/tmp/rr_variant_report_hybrid.json 2>&1; echo "EXIT=$?"
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 결과 Read 검증 + A/B 판정**
|
||||
|
||||
`/build/cache/tmp/rr_variant_report_hybrid.txt`를 Read로 직접 확인 (측정값 추측 절대 금지,
|
||||
[[project_rerank_experiment]] 교훈). 판정:
|
||||
- `a_dominant_groups > b_dominant_groups` → (A) 우세 → Phase 2 처방 = near-tie 흡수.
|
||||
- `b_dominant_groups > a_dominant_groups` → (B) 우세 → Phase 2 처방 = 쿼리 확장/번역.
|
||||
- 혼재면 그룹별로 분리 처방 + 토픽 특성 기록.
|
||||
|
||||
- [ ] **Step 5: HOTFIXES + 핸드오프 기록**
|
||||
|
||||
`tasks/HOTFIXES.md`에 dated entry: 그룹 목록, recall_spread/worst 표, A/B 분류, Phase 2 방향.
|
||||
핸드오프 문서에 측정 결과 + Phase 2 게이트 결정.
|
||||
|
||||
```bash
|
||||
git add tasks/HOTFIXES.md docs/superpowers/handoffs/2026-05-29-crossscript-rerank-progress-handoff.md
|
||||
git commit -m "docs: 변형 일관성 측정 결과 + Phase 2 처방 방향 (A/B 진단)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review (작성자 점검)
|
||||
|
||||
**1. Spec coverage:**
|
||||
- spec §2 Phase 1 "변형 그룹 + 일관성 메트릭 + A/B 판별 + 큐레이션 + 측정" → Task 1(그룹), Task 2(메트릭+A/B), Task 3(surface), Task 4(큐레이션), Task 5(측정). ✓
|
||||
- spec §3 "kebab-eval 단독, AggregateMetrics 불변" → Task 2 Step 6이 기존 테스트 통과로 회귀 가드. ✓
|
||||
- spec §5 "clean 측정 + Read 검증 + baseline이 deliverable" → Task 5 Step 4. ✓
|
||||
- spec §7 미결: group 정합성=bail(Task 1), A/B 임계=classify 정의(Task 2), surface=`eval variants`(Task 3), 큐레이션(Task 4), must_contain(Task 4 Step 3). ✓
|
||||
|
||||
**2. Placeholder scan:** Task 4의 `<조회한 doc_id>` 등은 데이터 큐레이션의 실제 조회 산출물(코드 placeholder 아님). 코드 스텝은 전부 완성 코드. ✓
|
||||
|
||||
**3. Type consistency:** `compute_variant_consistency(queries, rows)` 시그니처가 Task 2 정의 ↔ Task 2 `_with_config` 호출 ↔ Task 3 CLI 호출에서 일치. `VariantConsistencyReport`/`render_variants_md` 이름이 lib.rs re-export(Task 2 Step 5) ↔ CLI(Task 3 Step 2)에서 일치. `EvalQueryResultRecord{query_id, result_json}` 필드가 Task 2 테스트 ↔ 실제 metrics.rs 사용과 일치. ✓
|
||||
|
||||
**의존성 주의:** Task 2가 `metrics::load_golden_for_metrics`를 `pub(crate)`로 승격(Step 4 주석) → 그 변경이 Task 2 커밋에 포함됨(`git add ... metrics.rs`). Task 3는 Task 2의 re-export에 의존 → 순서 준수.
|
||||
@@ -0,0 +1,102 @@
|
||||
---
|
||||
title: 어휘격차(vocabulary-gap) pool-miss 해결 — 딥리서치 레퍼런스
|
||||
date: 2026-05-30
|
||||
type: research-reference
|
||||
provenance:
|
||||
- "deep-research 워크플로 (wf_e76011c6-de8): 5 angle, 22 sources fetch, 103 claims 추출, 25 claim 3-vote 적대적 검증, 22 confirmed / 3 killed. 104 agent, ~3.5M subagent tokens."
|
||||
related:
|
||||
- docs/superpowers/handoffs/2026-05-29-query-paraphrase-robustness-phase1-handoff.md (B 어휘격차 우세 진단)
|
||||
- docs/superpowers/research/2026-05-29-crossscript-synonym-retrieval-research.md (선행 — rerank 결정 중심)
|
||||
- memory: project_paraphrase_robustness, project_crossscript_diagnosis
|
||||
---
|
||||
|
||||
# 어휘격차 pool-miss 해결 — 딥리서치 레퍼런스
|
||||
|
||||
> Phase 1 진단(변형 일관성 측정)에서 **B(어휘격차) 우세** 확인 — 같은 의미를 다른 단어로 물으면
|
||||
> 정답이 top-50 pool 에도 안 들어옴(recall@50=0), rerank 불가. 그 실패 모드 전용 처방을 조사.
|
||||
|
||||
## 0. 질문 (요약)
|
||||
|
||||
CPU-only 로컬 RAG(FTS5/BM25 + LanceDB dense e5-large + RRF, Rust/fastembed-rs/LanceDB) 에서
|
||||
"같은 의미 다른 표현(동의어·풀어쓴 문장·한/영)이 top-50 pool 에도 못 들어오는" 실패를 가장
|
||||
좋은 비용대비로 고치는 법. 제약: per-query LLM 확장 거부("밑 빠진 독"), e5-large 유지(bge-m3
|
||||
dense 는 실측 더 나빴음), 코드 식별자 정확매칭 보존.
|
||||
|
||||
## 1. 적대적 검증 통과 결론 (confirmed)
|
||||
|
||||
### 1.1 색인시 doc-side expansion(doc2query/docTTTTTquery)이 pool-miss 의 최선책 (3-0)
|
||||
- **유일하게 lexical pool 자체를 키운다**(rerank 아님): docTTTTTquery README — MS MARCO doc
|
||||
Recall@1000 0.9180→0.9490(rerank 없는 1차 BM25 지표 → pool 진입 증가 실증). passage MRR@10
|
||||
18.6→27.2, **per-query +9ms 만**(55→64ms, T5 추론은 색인시 1회·query 무영향).
|
||||
- **메커니즘이 어휘격차 정조준**: SIGIR 2024 — N개 예측 query append 가 없던 term 주입(TF↑),
|
||||
Doc2Query model card "generated queries contain synonyms → close the lexical gap".
|
||||
- **Doc2Query++(2510.09557, 2025-10)**: 5개 BEIR 에서 sparse·dense 둘 다 **Recall@100** 개선
|
||||
(SCIDOCS 0.3323→0.3749, FiQA 0.5864→0.6197). Recall@100=pool-membership → pool 확장 확인.
|
||||
- 출처: github.com/castorini/docTTTTTquery, doc2query/msmarco-14langs-mt5-base-v1, mzzm24-sigir, 2510.09557.
|
||||
|
||||
### 1.2 ⚠️ vanilla 다국어 doc2query(mt5)는 한/영을 못 잇는다 (3-0)
|
||||
- model card: "input passage 의 **같은 언어**로 query 생성". mMARCO 14개 언어에 **한국어 없음**.
|
||||
- 귀결: doc2query 단독은 영어 paraphrase·동의어 pool-miss(raft "how nodes agree…")는 고치나,
|
||||
**KO↔EN 갭(역전파→영어 backprop doc)은 못 고침** → 색인시 *교차언어* 대체 query 생성이 추가로 필요.
|
||||
|
||||
### 1.3 MILCO(교차언어 learned-sparse)가 한/영 갭 직접 해결, 단 배포 경로 없음 (3-0)
|
||||
- 2510.00671(2025-10): query·doc 를 "공유 English lexical space"로 매핑. MKQA(한국어 포함)
|
||||
zero-shot R@100 **76.6**(BGE-M3-Sparse +69%, BM25 +92%). **그러나 560M 연구모델, ONNX/
|
||||
fastembed-rs 체크포인트 미확인** → research signal, turnkey 아님. (0.61ms 는 index lookup 만, 인코딩 제외.)
|
||||
|
||||
### 1.4 BGE-M3 sparse 채널은 CPU-native 추가 가능, 단 한/영은 못 고침 (3-0)
|
||||
- 1 forward 로 dense+sparse+ColBERT 산출, fastembed-rs `BGEM3Q`(CPU 양자화, CUDA 주면 오히려 실패).
|
||||
- 단일언어 향상: MIRACL Dense+Sparse 68.9 > Dense 67.8 → **3rd RRF 채널 후보**.
|
||||
- **교차언어 약함**: BGE-M3 논문도 sparse cross-lingual MKQA 45.3 vs dense 67.8 "다른 언어라 공존 term
|
||||
거의 없음". → KO↔EN 갭엔 무용. ("sparse 가 모든 언어서 BM25 압도" 주장은 **0-3 기각**.)
|
||||
|
||||
### 1.5 turnkey SPLADE 는 새 corpus 에서 자동 해결 못 함 (3-0)
|
||||
- LSR 은 term expansion 으로 어휘격차 겨냥하나, SOTA(Echo-Mistral-SPLADE BEIR 55.07)는 **Mistral-7B
|
||||
로 학습**(무겁다). AACL 2022: "SPLADE 는 저빈도 단어 exact match 에 약함 + 어휘/빈도 domain shift
|
||||
시 성능 저하". → 개인 혼합 KO/EN corpus 에 drop-in 기대 금물, 코드 식별자 정확매칭도 약점.
|
||||
|
||||
### 1.6 query-side(HyDE, Vector-PRF)는 이 제약에 부적합 (confirmed)
|
||||
- **HyDE**: query 마다 LLM 이 가설답변 생성(1~5s/query) = 사용자가 거부한 "밑 빠진 독" 바로 그것.
|
||||
- **Vector-PRF**: per-query 생성은 피하나 2-pass 필요 + **recall 개선 주장 0-3 기각**(1.6/6.2/7.7%
|
||||
Recall@100 gain 전부 refute). → 이 실패 모드 해결 증거 없음.
|
||||
|
||||
## 2. 기각 (killed — 믿지 말 것)
|
||||
- "BGE-M3 sparse 가 모든 언어서 BM25 압도" (0-3).
|
||||
- "Vector-PRF 가 Recall@100 을 1.6~7.7% 올린다(pool 확장)" (0-3).
|
||||
- "Vector-PRF 가 여러 데이터셋서 dense 효과 개선" (0-3).
|
||||
|
||||
## 3. 권고 (minimal combination, medium conf — 합성/추론)
|
||||
|
||||
**(1) 색인시 doc2query-style 확장 → 별도 FTS5 lexical 필드** (원문 body 필드는 그대로 verbatim
|
||||
index → 코드 식별자 정확매칭 보존, append-not-replace). RRF 가 {body-BM25, expansion-BM25, e5-dense} 융합.
|
||||
**(2) 문서당 같은언어 query + 소수의 교차언어(KO↔EN) 대체 표현/번역**을 색인시 1회 생성(로컬 LLM
|
||||
= gemma, **per-query 아님 → 사용자 제약 충족**). 역전파→backprop doc 의 직접 해법.
|
||||
**(3) (선택) BGE-M3 sparse(fastembed-rs BGEM3Q)를 4th RRF 채널**로 단일언어 lift, e5-large dense 유지.
|
||||
필터(Doc2Query--/++ topic-coverage)로 환각·index 팽창 제어.
|
||||
|
||||
- 사용자 제약 충족: 색인시 1회(per-query LLM 아님) + e5-large 유지 + 정확매칭 보존.
|
||||
- 엄격 no-LLM 원하면 (2) 대신 seq2seq mt5 doc2query 로 폴백(단 한국어 미커버 → 한/영 갭 부분만 해결).
|
||||
|
||||
## 4. 미해결 질문 (= Phase 2 실험 설계, **기존 variant eval 로 측정 가능**)
|
||||
1. **색인시 KO↔EN 대체 query 생성이 우리 corpus 에서 recall@50 을 0→양수로 올리나?** — 핵심 미검증
|
||||
고리. `/build/dogfood` golden + `kebab eval variants` 로 직접 측정(또 프록시 금지).
|
||||
2. ONNX/fastembed 호환 교차언어 learned-sparse(MILCO 또는 distill) 체크포인트가 있나, 아니면
|
||||
교차언어는 전적으로 색인시 doc expansion 으로만 풀어야 하나.
|
||||
3. doc2query 가 개인 KB(수천 doc/수만 chunk)의 FTS5 index 를 얼마나 부풀리나. Doc2Query--/++ 필터
|
||||
가치 있나 vs plain mt5.
|
||||
4. e5 dense 유지하고 BGE-M3 **sparse 만** 추가 시 paraphrase/동의어 recall@50 순이득인가, 약한
|
||||
다국어 sparse 가 RRF 에 노이즈만 더하나.
|
||||
|
||||
## 5. 핵심 caveat (시점 민감)
|
||||
- 최강 교차언어 근거(MILCO, Doc2Query++)는 2025-10 단일 논문·저자 보고 벤치 — research signal.
|
||||
- **교차언어 권고(색인시 KO↔EN 생성)는 합성/추론** — "index-time LLM translation 이 한/영 recall@50
|
||||
갭을 닫는다"를 직접 벤치한 논문 없음. confirmed fact 들의 논리적 조합. → **우리 corpus 측정 필수**.
|
||||
- docTTTTTquery 의 MS MARCO recall 증가는 modest(+3.4% rel) — 순수 pool-rescue 크기는 우리 corpus 미검증.
|
||||
- 정확매칭 보존은 architectural 논증(별도 필드), 코드 corpus 직접 측정 아님.
|
||||
|
||||
## 6. 출처
|
||||
docTTTTTquery — github.com/castorini/docTTTTTquery · Doc2Query++ — arxiv 2510.09557 ·
|
||||
mt5 14-lang doc2query — hf.co/doc2query/msmarco-14langs-mt5-base-v1 · SIGIR2024 doc-exp — jmmackenzie.io/pdf/mzzm24-sigir.pdf ·
|
||||
MILCO — arxiv 2510.00671 · BGE-M3 — arxiv 2402.03216 · bge-m3-onnx — github.com/yuniko-software/bge-m3-onnx ·
|
||||
Mistral-SPLADE — arxiv 2408.11119 · SPLADE domain-shift — arxiv 2211.03988 · HyDE/PRF — arxiv 2511.19349, 2504.01448, 2108.11044 ·
|
||||
fastembed-rs — github.com/Anush008/fastembed-rs · KURE — github.com/nlpai-lab/KURE · arctic-embed-ko — hf.co/dragonkue/snowflake-arctic-embed-l-v2.0-ko
|
||||
@@ -0,0 +1,128 @@
|
||||
---
|
||||
title: Query-paraphrase robustness — 변형 일관성 평가 프레임워크 (측정 먼저)
|
||||
date: 2026-05-29
|
||||
status: design (approved-to-plan)
|
||||
related:
|
||||
- docs/superpowers/handoffs/2026-05-29-crossscript-rerank-progress-handoff.md
|
||||
- docs/superpowers/specs/2026-05-29-crossscript-rerank-experiment-design.md (선행 실험 — overlap 프록시의 한계)
|
||||
- memory: project_crossscript_diagnosis, project_rerank_experiment, project_ranking_deferred, feedback_search_quality_dogfood
|
||||
goal_reframe: "한/영 cross-script overlap → 같은 의미의 다양한 표현(동의어·다른 어휘·풀어쓴 문장·한/영)에서 일관되게 좋은 답"
|
||||
---
|
||||
|
||||
# Query-paraphrase robustness — 변형 일관성 평가 프레임워크
|
||||
|
||||
## 0. 한 문단 요약
|
||||
|
||||
같은 의미를 다른 표현(동의어, 다른 어휘, 풀어쓴 문장, 한국어/영어)으로 물어도 **답변 품질이
|
||||
일관되게 좋아야 한다**는 것이 목표다. 지난 cross-encoder reranker 실험은 "한/영 top-k 겹침
|
||||
(overlap)"이라는 **프록시 지표**를 최적화하다 헛돌았다 (full-chunk-text 까지 시도했으나 회귀가
|
||||
1:1 재현 — 가설 반증, 핸드오프 참조). 이번엔 처방을 만들기 전에 **진짜 지표(변형 간 답변 품질
|
||||
일관성)를 직접 재는 평가 프레임워크**를 먼저 만든다. 이 평가가 (A) "핵심 문서는 후보 풀에
|
||||
들어왔는데 순위만 출렁" 인지 (B) "다른 단어로 물으니 핵심 문서가 아예 후보에서 빠짐" 인지를
|
||||
숫자로 판별하고, 그 결과에 따라 처방(near-tie 흡수 vs 쿼리 확장)을 별도 spec 으로 확정한다.
|
||||
**본 spec 의 구현 범위는 Phase 1 (평가 프레임워크) 까지.** Phase 2 (처방) 는 측정 결과 게이트
|
||||
뒤의 조건부 설계다.
|
||||
|
||||
## 1. 진단 근거 (왜 측정 먼저인가)
|
||||
|
||||
- **확정된 근본 원인** ([[project_crossscript_diagnosis]]): vector near-tie 불안정 — 상위 후보들의
|
||||
cosine 점수가 Δ0.003~0.005 로 다닥다닥 붙어, "top-k" 라는 칼같은 cutoff 가 near-tie 뭉치
|
||||
한가운데를 지나면 표현 차이(동의어/한영)가 핵심 문서를 9등↔11등으로 흔든다.
|
||||
- **사용자 실제 불편** (2026-05-29 brainstorm): "한쪽 답이 나쁘다" — 겹침이 아니라 **답변 품질
|
||||
비대칭**이 핵심. 어느 쪽이 나쁠지는 **쿼리마다 다름** → 특정 언어의 구조적 결함이 아니라
|
||||
near-tie 불안정이 표현마다 다르게 발현.
|
||||
- **선행 실험의 교훈** ([[project_rerank_experiment]]): overlap 프록시 최적화 → cross-encoder 가
|
||||
한/영 query 로 pool 을 독립 재정렬해 토픽별 수렴/발산. full-chunk-text 로도 database −4 등
|
||||
회귀가 그대로. **원인(near-tie)을 모르고 프록시를 최적화하면 또 헛돈다.** → 측정 선결.
|
||||
- **(A) vs (B) 미해결**: 표현이 바뀔 때 핵심 문서가 ① 후보 풀엔 있는데 순위만 밀린 건지(A,
|
||||
near-tie), ② 후보 풀에서 아예 빠진 건지(B, 어휘 격차) 모른다. 처방이 완전히 다르므로 먼저 측정.
|
||||
|
||||
## 2. 범위 (scope)
|
||||
|
||||
**Phase 1 (본 spec 구현 대상):**
|
||||
- `kebab-eval` 의 golden suite 에 **변형 그룹(intent group)** 개념 추가 — 같은 의도의 여러
|
||||
표현이 같은 정답(expected_doc_ids / expected_chunk_ids / must_contain)을 공유.
|
||||
- **변형 일관성 메트릭** 산출: 그룹 내 recall/답변정답 의 분산(spread)·최악값, 그리고
|
||||
recall@pool vs recall@k 대비로 (A)/(B) 자동 분류.
|
||||
- dogfood KB 에 큐레이션된 변형 그룹 ~6–10 개 (각 3–5 표현). 정답 문서는 **corpus 의미로 판정**
|
||||
(순환 회피, [[feedback_search_quality_dogfood]]).
|
||||
- 측정 실행 → (A)/(B) 진단 리포트 → Phase 2 결정 게이트.
|
||||
|
||||
**Phase 2 (조건부, 별도 spec — 본 구현 제외):**
|
||||
- (A) 우세 → near-tie 밴드 흡수 (cutoff 를 near-tie band 까지 확장; 검색 순서 불변, 저위험).
|
||||
- (B) 우세 → 쿼리 확장/번역 (로컬 LLM).
|
||||
- Phase 1 평가셋으로 처방 효과를 진짜 지표로 검증.
|
||||
|
||||
**비범위 (YAGNI):**
|
||||
- LLM-judge 기반 답변 채점. `must_contain`/`forbidden` substring groundedness 가 이미 있고
|
||||
Phase 1 진단엔 충분. 필요성은 Phase 1 결과가 정한다.
|
||||
- 임베딩 모델 교체(③). 전체 재임베딩 cascade 비용 + 효과 불확실 → 측정 후 최후 옵션.
|
||||
- ranking 파라미터 자동 조정 ([[project_ranking_deferred]] 와 충돌 — 처방은 명시적 flag/설정).
|
||||
|
||||
## 3. 구조 (크레이트 경계 — design §8 준수)
|
||||
|
||||
- **`kebab-eval` 단독 변경.** retrieval/embedding/LLM 크레이트 직접 import 금지 규칙 유지
|
||||
(runner 만 `kebab-app` facade 사용 — P5-1 상속).
|
||||
- `types.rs`: `GoldenQuery` 에 `group: Option<String>` 추가 (backward-compat — 기존 쿼리는
|
||||
`None`, 단독 그룹 취급). yaml 역직렬화 optional.
|
||||
- `metrics.rs`: 기존 per-query 집계는 불변. per_query 결과를 `group` 으로 묶는 **변형 일관성
|
||||
집계** 함수 신규 (`compute_variant_consistency` 류). 기존 `AggregateMetrics` 는 안 건드림.
|
||||
- `loader.rs`: 그룹 필드 로드 + 그룹 내 expected 정합성 검증(같은 그룹은 같은 expected 공유
|
||||
권장 — 경고/허용 정책은 plan 에서 확정).
|
||||
- 측정 실행/리포트: 기존 `eval` 경로 재사용 (`--with-rag` 로 답변까지). 신규 metric 은 JSON
|
||||
리포트 + 사람이 읽는 요약 (CLI surface 확정은 plan).
|
||||
|
||||
## 4. 데이터 흐름
|
||||
|
||||
```
|
||||
golden_queries.yaml (변형 그룹 포함)
|
||||
└─ loader → Vec<GoldenQuery>{group}
|
||||
└─ runner (eval --with-rag, mode=hybrid/vector) → per_query: {recall@k, answer.must_contain pass}
|
||||
├─ 기존 AggregateMetrics (전체 hit@k/MRR/recall) — 불변
|
||||
└─ NEW: group 으로 묶어 변형 일관성:
|
||||
· recall_spread@k = max−min recall@k (그룹 내) → 0 이면 완전 일관
|
||||
· worst_recall@k = min recall@k (약한 표현)
|
||||
· answer_consistency = 모든 변형이 must_contain 통과한 그룹 비율
|
||||
· A/B 분류: 변형별 (recall@pool_k high & recall@k low) → A(순위), recall@pool_k low → B(어휘)
|
||||
```
|
||||
|
||||
`pool_k` = 진단용 넓은 후보 폭 (예: 50). near-tie 가설 검증을 위해 좁은 k(=답변 context 폭)와
|
||||
넓은 pool 을 둘 다 측정.
|
||||
|
||||
## 5. 측정 / 수용 기준
|
||||
|
||||
- 변형 그룹 ≥6 개 (한/영 쌍 + 동의어 + 다른 어휘 + 풀어쓴 문장 골고루), 각 ≥3 표현.
|
||||
- 평가 실행이 **clean**(err=0) 하고 결과가 파일로 추출 후 Read 검증됨 ([[project_rerank_experiment]]
|
||||
교훈 — 측정값 추측 금지, grep clean 추출 후에만 기록).
|
||||
- 산출물: 그룹별 recall_spread@k, worst_recall, answer_consistency + A/B 분류 표.
|
||||
- **수용 기준은 "처방이 좋아지는지" 가 아니라 "진단이 나오는지"** — Phase 1 은 측정 프레임워크
|
||||
완성 + (A)/(B) 판별이 목표. baseline 숫자 자체가 deliverable.
|
||||
- 회귀 가드: 기존 golden suite 21쿼리의 AggregateMetrics 가 변형 그룹 추가 후에도 동일하게
|
||||
계산되어야 함 (group=None 경로 불변 — 기존 테스트 green).
|
||||
|
||||
## 6. 롤백 / 버전
|
||||
|
||||
- `group` 필드는 additive — 기존 yaml/스키마 backward-compat, 버전 cascade 트리거 아님.
|
||||
- 평가 전용 변경이라 wire schema (`search_hit.v1`/`answer.v1`) 불변. 바이너리 surface 변경 없음
|
||||
(eval CLI 리포트 항목 추가 가능 — additive).
|
||||
- golden_queries.yaml 의 변형 그룹은 dogfood KB 스냅샷(docs=3940 / chunks=34896, 2026-05-28)에
|
||||
큐레이션 — reset/re-ingest 시 chunk_id stale → runner bail. 재큐레이션 정책은 기존과 동일.
|
||||
|
||||
## 7. 미결 (구현 계획에서 확정)
|
||||
|
||||
- `group` 정합성: 같은 그룹이 서로 다른 expected 를 가질 때 — 에러 bail vs 경고+합집합. (권장:
|
||||
같은 그룹 = 같은 expected 강제, 위반 시 loader bail.)
|
||||
- A/B 분류 임계값: "recall@pool_k high" 의 high 기준, near-tie band Δ 정의 (진단 리포트용).
|
||||
- 변형 일관성 metric 의 CLI/JSON surface 형태 (기존 `eval` 출력에 합칠지 별도 서브커맨드일지).
|
||||
- 변형 그룹 큐레이션: 어떤 의도 ~6–10 개를 고를지 — dogfood corpus 에서 한/영 양쪽으로 명확한
|
||||
정답 문서가 있는 토픽 선정 (rust 류 + 일반 토픽 섞기, 선행 ablation 토픽 재사용 가능).
|
||||
- 답변 정답 신호: `must_contain` 큐레이션 방식 (핵심 사실 substring) — 그룹 내 공유.
|
||||
|
||||
## 8. 실행 방식 (사용자 지정, 2026-05-29)
|
||||
|
||||
- spec → plan → subagent 구현. 각 작업·리뷰는 **OMC teammate** (tmux pane spawn,
|
||||
[[feedback_teammate_spawn_mode]] / [[feedback_omc_teams_usage]]).
|
||||
- 작은 작업은 sonnet, 복잡 작업은 opus ([[feedback_teammate_model_routing]] 조정).
|
||||
- 테스트용 데이터는 dogfood 데이터셋(`/build/dogfood/corpus`, `/build/dogfood/golden_queries.yaml`)
|
||||
에서 가져올 수 있음.
|
||||
- 빌드/테스트는 파일 redirect + exit code 확인 후에만 커밋 ([[project_rerank_experiment]] 교훈).
|
||||
Reference in New Issue
Block a user