diff --git a/README.md b/README.md index d98449b..08bd09b 100644 --- a/README.md +++ b/README.md @@ -84,16 +84,16 @@ kebab doctor | 명령 | 동작 | |------|------| | `kebab init` | XDG 경로에 데이터 디렉토리 + config.toml 생성 | -| `kebab ingest []` | 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 = ""` + `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} "" [--no-cache] [--max-tokens N] [--snippet-chars N] [--cursor ] [--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":""}` 만 필수 (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 []` | 워크스페이스를 스캔해 새/변경 문서를 색인 (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} "" [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 ` / `kebab inspect chunk ` | raw record 보기 | | `kebab fetch chunk [--context N]` / `kebab fetch doc [--max-tokens N]` / `kebab fetch span [--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 "" [--show-citations / --hide-citations] [--session ] [--stream] [--multi-hop]` | RAG 답변 + 근거 인용. 답변 후 `근거:` block 으로 full path / line range / score 한 줄씩 (default ON — `--hide-citations` 로 끄기, pipe 시 유용). 근거 부족 시 거절. Ollama 필요. `--session ` 로 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 "" [flags]` | RAG 답변 + 근거 인용 (근거 부족 시 거절, Ollama 필요). `--hide-citations`, `--session `(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 docs │ ` (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 docs │ `. | | `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 ` 는 같은 의미의 여러 표현(동의어·풀어쓴 문장·한/영) 간 검색 일관성 진단 — `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 ` | 단일 파일 ingest (workspace 외부 가능). 바이트는 `/_external/.` 로 copy. `.kebabignore` 매치 시 stderr warn 후 진행 (explicit ingest 가 bypass intent). | | `kebab ingest-stdin --title [--source-uri ]` | stdin 의 markdown 본문 ingest. frontmatter (title + source_uri) 자동 prepend. v1 markdown only. | diff --git a/crates/kebab-cli/src/main.rs b/crates/kebab-cli/src/main.rs index 1d802d9..3568c32 100644 --- a/crates/kebab-cli/src/main.rs +++ b/crates/kebab-cli/src/main.rs @@ -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, diff --git a/crates/kebab-eval/src/compare.rs b/crates/kebab-eval/src/compare.rs index 6a5986f..3ab8480 100644 --- a/crates/kebab-eval/src/compare.rs +++ b/crates/kebab-eval/src/compare.rs @@ -503,6 +503,7 @@ mod tests { must_contain: vec![], forbidden: vec![], difficulty: None, + group: None, }; let g = Some(&g); // a miss, b hit → Win diff --git a/crates/kebab-eval/src/lib.rs b/crates/kebab-eval/src/lib.rs index c0e0b01..1aac28a 100644 --- a/crates/kebab-eval/src/lib.rs +++ b/crates/kebab-eval/src/lib.rs @@ -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, +}; diff --git a/crates/kebab-eval/src/loader.rs b/crates/kebab-eval/src/loader.rs index d1b2640..42e7836 100644 --- a/crates/kebab-eval/src/loader.rs +++ b/crates/kebab-eval/src/loader.rs @@ -30,6 +30,7 @@ pub fn load_golden_set(path: &Path) -> Result> { let queries: Vec = 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, &str)> = BTreeMap::new(); + // 그룹별 위반 메시지(정렬·dedup 위해 BTreeSet). canonical query id 와 + // divergent query id 를 함께 담아 yaml 수정 시 바로 찾을 수 있게 한다. + let mut offenders: BTreeSet = BTreeSet::new(); + for q in queries { + let Some(group) = q.group.as_deref() else { + continue; + }; + let docs: BTreeSet = 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 = 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 = 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}"); diff --git a/crates/kebab-eval/src/metrics.rs b/crates/kebab-eval/src/metrics.rs index cdf5f0c..bc3e385 100644 --- a/crates/kebab-eval/src/metrics.rs +++ b/crates/kebab-eval/src/metrics.rs @@ -165,7 +165,7 @@ pub(crate) fn resolve_golden_path() -> PathBuf { } } -fn load_golden_for_metrics() -> Result> { +pub(crate) fn load_golden_for_metrics() -> Result> { 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, } } diff --git a/crates/kebab-eval/src/runner.rs b/crates/kebab-eval/src/runner.rs index 45a9652..e1096ef 100644 --- a/crates/kebab-eval/src/runner.rs +++ b/crates/kebab-eval/src/runner.rs @@ -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 { +fn build_config_snapshot(cfg: &kebab_config::Config, eval_k: usize) -> Result { 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, diff --git a/crates/kebab-eval/src/types.rs b/crates/kebab-eval/src/types.rs index db5e15d..0629485 100644 --- a/crates/kebab-eval/src/types.rs +++ b/crates/kebab-eval/src/types.rs @@ -26,6 +26,11 @@ pub struct GoldenQuery { pub forbidden: Vec, #[serde(default)] pub difficulty: Option, + /// 같은 의미의 여러 표현(동의어·다른 어휘·풀어쓴 문장·한/영)을 묶는 + /// 의도 그룹 id. 같은 그룹의 모든 변형은 동일한 `expected_doc_ids`(집합)를 + /// 공유해야 한다(loader가 강제). `None`이면 단독 쿼리(기존 동작 불변). + #[serde(default)] + pub group: Option, } fn default_lang() -> Lang { diff --git a/crates/kebab-eval/src/variant.rs b/crates/kebab-eval/src/variant.rs new file mode 100644 index 0000000..98f13ef --- /dev/null +++ b/crates/kebab-eval/src/variant.rs @@ -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, + pub class: VariantClass, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct VariantGroupReport { + pub group: String, + pub variants: Vec, + /// 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, + pub mis_ranked: u32, + pub missing: u32, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct VariantConsistencyReport { + pub groups: Vec, + 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 { + let golden_by_id: HashMap<&str, &GoldenQuery> = + queries.iter().map(|q| (q.id.as_str(), q)).collect(); + + let mut grouped: BTreeMap> = 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 = 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::() / 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) -> VariantGroupReport { + let measurable: Vec = 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 = 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 { + 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) -> 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 = 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, + 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); + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 90d5df7..6e63013 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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 | diff --git a/docs/superpowers/handoffs/2026-05-29-query-paraphrase-robustness-phase1-handoff.md b/docs/superpowers/handoffs/2026-05-29-query-paraphrase-robustness-phase1-handoff.md new file mode 100644 index 0000000..85fc749 --- /dev/null +++ b/docs/superpowers/handoffs/2026-05-29-query-paraphrase-robustness-phase1-handoff.md @@ -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 ` 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 ` (⚠️ `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 진단으로 충분했음. 필요 시 후속. diff --git a/docs/superpowers/plans/2026-05-29-query-paraphrase-robustness-eval.md b/docs/superpowers/plans/2026-05-29-query-paraphrase-robustness-eval.md new file mode 100644 index 0000000..f203896 --- /dev/null +++ b/docs/superpowers/plans/2026-05-29-query-paraphrase-robustness-eval.md @@ -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` 추가(additive) → loader가 그룹 정합성 검증 → 신규 `variant.rs`가 저장된 run의 per-query 결과를 그룹으로 묶어 recall@narrow(10) vs recall@pool(50) 대비로 변형 일관성 + A/B 분류 산출 → `kebab eval variants ` 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 ` 서브커맨드 | 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, + /// 같은 의미의 여러 표현(동의어·다른 어휘·풀어쓴 문장·한/영)을 묶는 + /// 의도 그룹 id. 같은 그룹의 모든 변형은 동일한 `expected_doc_ids`(집합)를 + /// 공유해야 한다(loader가 강제). `None`이면 단독 쿼리(기존 동작 불변). + #[serde(default)] + pub group: Option, +``` + +- [ ] **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, &str)> = BTreeMap::new(); + let mut offenders: BTreeSet = BTreeSet::new(); + for q in queries { + let Some(group) = q.group.as_deref() else { + continue; + }; + let docs: BTreeSet = 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 = 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, + pub class: VariantClass, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct VariantGroupReport { + pub group: String, + pub variants: Vec, + /// 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, + pub mis_ranked: u32, + pub missing: u32, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct VariantConsistencyReport { + pub groups: Vec, + 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) -> 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 = 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 { + let golden_by_id: HashMap<&str, &GoldenQuery> = + queries.iter().map(|q| (q.id.as_str(), q)).collect(); + + let mut grouped: BTreeMap> = 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 = 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::() / 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) -> VariantGroupReport { + let measurable: Vec = 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 = 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 { + 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 ` 서브커맨드 + +**모델:** 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 — 변형 일관성 진단 리포트" +``` + +--- + +## 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 --config $CFG > /build/cache/tmp/rr_variant_report_hybrid.txt 2>&1; echo "EXIT=$?" +$BIN eval variants --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에 의존 → 순서 준수. diff --git a/docs/superpowers/research/2026-05-30-vocabulary-gap-recall-fix-research.md b/docs/superpowers/research/2026-05-30-vocabulary-gap-recall-fix-research.md new file mode 100644 index 0000000..b51f81c --- /dev/null +++ b/docs/superpowers/research/2026-05-30-vocabulary-gap-recall-fix-research.md @@ -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 diff --git a/docs/superpowers/specs/2026-05-29-query-paraphrase-robustness-eval-design.md b/docs/superpowers/specs/2026-05-29-query-paraphrase-robustness-eval-design.md new file mode 100644 index 0000000..b724d8b --- /dev/null +++ b/docs/superpowers/specs/2026-05-29-query-paraphrase-robustness-eval-design.md @@ -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` 추가 (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{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]] 교훈).