Compare commits
112 Commits
v0.20.2
...
3d5bb599e3
| Author | SHA1 | Date | |
|---|---|---|---|
| 3d5bb599e3 | |||
| 375a0693e4 | |||
| 8cc4e6d563 | |||
| 901416d8e9 | |||
| b706e3e88c | |||
| 8f8d3a4100 | |||
| 75a543ff69 | |||
| a283e56c5c | |||
| 47ef6532f7 | |||
| 03b0745e9d | |||
| e7cb20990a | |||
| bebf6e4ac7 | |||
| 736d791056 | |||
| 6c9c8df43e | |||
| 0263667684 | |||
| 4918983d9c | |||
| aeaa18a564 | |||
| c91ff909ce | |||
| 8dee610a97 | |||
| d71ed2516b | |||
| 095c9f37a2 | |||
| 16ddb1dfc3 | |||
| 72c99c452c | |||
| cbcae69abf | |||
| 7505645008 | |||
| e2ae9a4589 | |||
| 1dfab6dfc5 | |||
| fc5103642e | |||
| e03d03cb26 | |||
| 16aadea222 | |||
| a48c405826 | |||
| 21e02d8a93 | |||
| a64c31ee94 | |||
| ec96648956 | |||
| ecaf224381 | |||
| b1c5feb3f3 | |||
| ca8c0645fb | |||
| c7af6612b7 | |||
| acb4fa6c65 | |||
| 8bfa4ba76e | |||
| ad0ccf4ccf | |||
| b351523e51 | |||
| a48b055358 | |||
| 581e1d5d55 | |||
| c17d6e67a8 | |||
| af8fd34716 | |||
| 369aeb3d24 | |||
| 99f8cfa691 | |||
| d85d7348a5 | |||
| edac3ae737 | |||
| 6ec4e6809f | |||
| 1011c75fff | |||
| 8f7b6ee538 | |||
| 76841af7d3 | |||
| 980e20fd8d | |||
| cd79ed326c | |||
| 9dbf9d781d | |||
| 9501edd82b | |||
| 4b4a4c0b32 | |||
| f2cc325cf3 | |||
| b7e022a5e3 | |||
| bd7c4fd7ef | |||
| 4dcb4a45d6 | |||
| 6d86214060 | |||
| 6bbb8f854b | |||
| 2a4df4d48d | |||
| 16f3d6eef2 | |||
| fa89c7b561 | |||
| a4c81fed86 | |||
| 5b7c02fe13 | |||
| 88c5b83dea | |||
| 2619b7bff7 | |||
| e9b520216e | |||
| a8fd76499c | |||
| 0282a81c67 | |||
| f3587b7143 | |||
| 483b1ec06b | |||
| d279f343e7 | |||
| b56469f010 | |||
| 6ba8cb2c88 | |||
| afa8af0f88 | |||
| b9d20d23d1 | |||
| 86b4e1ebd0 | |||
| 825543549d | |||
| bcb8b93751 | |||
| 116b3e6377 | |||
| 69b53d1c97 | |||
| a271352e33 | |||
| cde4d75f6b | |||
| bddcd53688 | |||
| 2a207f9868 | |||
| cc31868d24 | |||
| 0df47febf0 | |||
| b12a616ab2 | |||
| 848b75c069 | |||
| 467a974901 | |||
| 098413922b | |||
| 695010ea7a | |||
| 8bb7c276d0 | |||
| 01a03463a6 | |||
| b6ad947378 | |||
| 1529e6d991 | |||
| 5ad1f98227 | |||
| a58cae2ff3 | |||
| 7a1dff1684 | |||
| 0988f66331 | |||
| 82e02aa4fe | |||
| db4af0cc72 | |||
| ab20202241 | |||
| a51e6395c0 | |||
| fe4c854673 | |||
| 1de3f4ffca |
6
.gitattributes
vendored
Normal file
6
.gitattributes
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
# PP-OCRv5 ONNX OCR models (paddle-onnx engine). git-lfs is not installed on
|
||||
# this host, so they are committed as plain binary blobs (treated as binary —
|
||||
# no textual diff/merge). If/when git-lfs becomes available, migrate with
|
||||
# `git lfs migrate import --include='*.onnx'` and restore the filter line:
|
||||
# *.onnx filter=lfs diff=lfs merge=lfs -text
|
||||
*.onnx -text
|
||||
@@ -82,7 +82,10 @@ Release 절차:
|
||||
|
||||
1. `gitea-release v<X.Y.Z>` (gitea-ops skill) 으로 tag + push + release notes.
|
||||
2. release notes 는 사용자 도그푸딩에 영향이 가는 surface 변경을 위주로 — wire schema 추가, CLI flag 신규, TUI 키 변경, V00X migration 등 — 다룬다. 이때 추가된 기능과 변경사항은 유저가 이해할 수 있도록 친절하고 자세하게 풀어서 설명해야 하며, 단순히 commit subject 를 나열하는 형태로 끝내면 안 된다. 필요하다면 도그푸딩이나 테스트 결과도 함께 적어 둔다.
|
||||
3. 프리-1.0 (`0.x.y`) 단계: minor bump 시 wire schema additive / surface 변경 누적, patch bump 시 bug fix only.
|
||||
3. 프리-1.0 (`0.x.y`) 단계 bump 규칙 — **기능(behavior) 또는 인터페이스(interface) 변경 여부**로 판정:
|
||||
- **minor bump** (`0.x.0`): 기능 또는 인터페이스에 *실질적* 변경이 있을 때. 인터페이스 = 신규/변경/삭제된 CLI subcommand·flag, config 키, wire schema 의 **breaking** 변경, 임베딩/검색/RAG 등 사용자가 받는 **결과·동작**의 변화, V00X migration, frozen 설계 변경. 기능 = 새 source 형식·검색 모드·백엔드 등 *할 수 있는 일*의 추가/변경.
|
||||
- **patch bump** (`0.x.y`): 기능·인터페이스 변경이 **없을** 때. bug fix, 내부 refactor, 성능 개선, 로깅/진행표시 등 **관측성(observability) 개선**, **additive-only wire 변경**(backward-compat 신규 필드/이벤트라 기존 소비자 무영향), 문서. ← 즉 "결과가 같고 새 명령/플래그/config 도 없으면 patch".
|
||||
- 경계 예: 진행 로그에 phase/파일명 추가 + additive wire 이벤트(asset_phase) = **patch** (검색·색인 결과 불변, 새 명령/플래그/config 없음). arctic 임베더 provider + 신규 config 키 = **minor** (인터페이스 추가). 별칭 기능 제거 + migration = **minor** (동작·인터페이스 변경).
|
||||
|
||||
**bump 시점 = release 시점 같은 commit**. 즉 commit `chore: bump version 0.x → 0.y` 직후 같은 commit 에 tag. v0.1.0 (`2319206`) 처럼 bump 없이 tag 만 찍는 패턴은 후속 release 가 대상 commit 을 헷갈리게 함 — pre-release snapshot 은 SHA reference 로 충분.
|
||||
|
||||
|
||||
916
Cargo.lock
generated
916
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -11,6 +11,8 @@ members = [
|
||||
"crates/kebab-search",
|
||||
"crates/kebab-embed",
|
||||
"crates/kebab-embed-local",
|
||||
"crates/kebab-embed-candle",
|
||||
"crates/kebab-embed-ollama",
|
||||
"crates/kebab-llm",
|
||||
"crates/kebab-llm-local",
|
||||
"crates/kebab-rag",
|
||||
@@ -30,7 +32,7 @@ edition = "2024"
|
||||
rust-version = "1.85"
|
||||
license = "MIT OR Apache-2.0"
|
||||
repository = "https://github.com/altair823/kebab"
|
||||
version = "0.20.2" # v0.20.2 — Ask 응답언어 rag-v3 + 8 dogfood findings + 검색 품질 eval baseline (golden suite) — CLAUDE.md §Release 도그푸딩 트리거
|
||||
version = "0.27.0" # v0.27.0 — PP-OCRv5 ONNX Rust 네이티브 OCR 엔진: `[image.ocr] engine = "paddle-onnx"` (default 여전히 "ollama-vision") 로 in-process 검출+인식(`ort` =2.0.0-rc.9, Python 런타임 0). DBNet det + CTC rec, 후처리(min-area rect/unclip)는 pure-Rust. e2e CER 0.005(synthetic 한/영, PoC 0.024 대비 우수), 큰 페이지 CPU <4초(Ollama vision ~50초 대비). 신규 config `det_model`/`rec_model`/`dict`/`score_thresh`/`unclip_ratio`/`max_boxes` + `KEBAB_IMAGE_OCR_*` env. ingest 서명 `|ocr:1:{engine}:{engine_version}` 로 engine/모델 변경 시 자동 재색인. 신규 인터페이스(engine 값/config 키) → minor. — CLAUDE.md §Release
|
||||
|
||||
# pre-v0.18 workspace-wide cleanup: enable clippy::pedantic group with
|
||||
# intentional allow-list. The allowed lints are either cosmetic (doc style),
|
||||
|
||||
@@ -30,8 +30,17 @@ P0~P5 직렬. P6~P9 P5 이후 병렬 가능.
|
||||
|
||||
## 머지 후 발견된 버그 / 결정 (요약)
|
||||
|
||||
- **candle 임베딩 백엔드 다변화** (2026-06-01, Track 1, v0.22.0): `provider = "candle"` opt-in 추가 — 같은 `multilingual-e5-large` 모델을 순수 Rust(candle)로 돌려 듀얼소켓 NUMA 서버의 onnxruntime 48-스레드 double-free 를 회피. `[models.embedding].num_threads`(+env `KEBAB_EMBED_THREADS`)로 CPU 스레드 캡. fastembed default 동작·벡터 불변, `embedding_version` 유지(재색인 0). Phase 0 스파이크 패리티 cosine 1.000000. 상세 HOTFIXES 동일 일자.
|
||||
- **config 마이그레이션** (2026-05-31, PR #198): `kebab config migrate` 추가 — 기존 config.toml 에 빠진 섹션을 주석과 함께 채우고 deprecated 정리(멱등·`.bak`·dry-run, 값/주석 보존). `schema_version` 1→2, `init` 도 섹션 주석 포함, doctor 에 `config_migration` 체크. 상세 HOTFIXES 동일 일자.
|
||||
|
||||
머지 후 발견된 모든 deviation / hotfix 의 dated 로그는 [tasks/HOTFIXES.md](tasks/HOTFIXES.md). 본 요약은 \"누군가가 인수받을 때 알아두면 시간을 많이 절약하는\" 항목만:
|
||||
|
||||
- **2026-06-04 PP-OCRv5 ONNX Rust 네이티브 OCR** — v0.27.0. `[image.ocr] engine = "paddle-onnx"` 로 PP-OCRv5(검출+인식) ONNX 를 in-process(`ort` =2.0.0-rc.9) 실행 — Python 런타임/원격 호출 없이 큰 페이지 CPU <4초(Ollama vision ~50초 대비). default 는 여전히 `"ollama-vision"`. 후처리(min-area rect/unclip)는 pure-Rust. **함정**: unclip 은 corner 를 centroid 에서 방사 확장하면 안 되고 edge 별 polygon offset 이어야 함(방사 확장 시 wide/short 텍스트 박스 높이가 안 커져 글자 윗부분 잘림 → ㄷ→ㄴ, e2e CER 0.26). 수정 후 CER 0.005. 모델 ONNX 는 `crates/kebab-parse-image/assets/paddleocr-onnx/`(LFS). 자세한 내용: `tasks/HOTFIXES.md` (2026-06-04 PP-OCRv5 ONNX), spec/plan `docs/superpowers/{specs,plans}/2026-06-04-rust-native-ocr-*.md`.
|
||||
- **2026-06-03 ingest 설정 변경 자동 재색인** — v0.26.2. ingest 산출에 영향 주는 설정(청킹/이미지 OCR·caption/pdf.ocr/`[ingest.code]`)을 변경하면 `--force-reingest` 없이 영향 자산만 자동 재색인. 그 설정들의 결정적 서명(`ingest_config_signature`)을 effective parser_version(skip 비교 + 저장 doc 필드 양쪽)에 폴딩 → 다음 ingest 비교가 mismatch. 비산출 설정(search/rag/ui/log + max_pixels/languages/timeout)은 제외(과도 무효화 회피), doc_id 는 base 로 안정 유지. **업그레이드 후 첫 ingest 는 전 자산 1회 재색인**(저장된 상수 parser_version ≠ 새 composite; embedding 은 V012 캐시 히트). 결과 포맷·CLI·wire 불변(내부 skip 판정 정정). 자세한 내용: `tasks/HOTFIXES.md` (2026-06-03 ingest 설정 변경 자동 재색인), spec/plan `docs/superpowers/{specs,plans}/2026-06-03-*invalidation*.md`.
|
||||
- **2026-06-03 ingest 진행 로그 개선** — v0.26.1. 이미지/PDF + OCR/caption on 볼트 ingest 가 "멈춘 듯" 보이던 문제 해소: TTY 진행바에 현재 파일명 + 느린 phase(ocr/caption/embed)+모델명 + 경과초 `(Ns)` heartbeat, 종료 시 최장 소요 파일 top-5 요약. 신규 wire `asset_phase{idx,total,phase,model}` + `asset_timings.ocr_ms`/`caption_ms`(additive, `ingest_progress.v1` 유지, serde default 0). 이미지·PDF 경로도 `asset_timings` emit(이전 markdown 만). 기본 동작 불변. 자세한 내용: `tasks/HOTFIXES.md` (2026-06-03 ingest 진행 로그), spec/plan `docs/superpowers/{specs,plans}/2026-06-03-ingest-log-improve-*.md`.
|
||||
- **2026-06-03 arctic-embed-l-v2.0 임베더 통합** — v0.26.0. 별칭 제거 후 설명형 query recall 보강(측정 recall@10 130/132, e5 +7). `kebab-embed-candle` 모델 레지스트리화(e5 mean + `snowflake-arctic-embed-l-v2.0` CLS, 모델별 pooling/prefix) + 신규 `kebab-embed-ollama`(`provider="ollama"`, `/api/embed`). config `endpoint: Option<String>` 추가. 기본 e5 유지(opt-in), arctic 전환은 embedding_version cascade → 재색인. candle↔Ollama cosine>0.99 게이트로 pooling/prefix 정확성 고정(`#[ignore]`). 자세한 내용: `tasks/HOTFIXES.md` (2026-06-03 arctic), spec `docs/superpowers/specs/2026-06-03-arctic-embedder-spec.md`.
|
||||
- **2026-06-03 doc-side expansion(별칭) 기능 완전 제거** — v0.25.0. 아래 2026-05-31 항목의 색인-시 청크당 LLM 별칭 생성 + 별칭 검색 채널을 **전부 제거**(ROI 음수: cross-lingual 은 e5-large 단독으로 충분, 기여는 설명형 +2 그룹뿐인데 대가가 청크당 색인-시 LLM). `Chunk.aliases`/`expansion.rs`/`IngestExpansionCfg`/alias lexical arm/`expansion_progress` wire kind 제거, 신규 마이그레이션 **V013** 이 `chunk_aliases_fts`+`chunks.aliases` DROP. 별칭 default-off 였어 사용자 체감 0, 기존 KB 도 재색인 불요(잔존 별칭 벡터는 `strip_alias_suffix` graceful 매핑/`reset` 정리). `AssetTimings.expansion_ms` 는 wire 호환 위해 값 0 으로 유지. 자세한 내용: `tasks/HOTFIXES.md` (2026-06-03), spec `docs/superpowers/specs/2026-06-03-remove-doc-expansion-spec.md`.
|
||||
- **2026-05-31 Phase 2 doc-side expansion 별칭(개별 dense 벡터) + 파생물 캐시(V012)** — v0.21.0 cut. 색인 시 LLM 이 청크별 별칭("같은 의미 다른 표현")을 생성, 줄별 **개별 dense 벡터**(sentinel `{chunk}#alias#N`)로 색인 (묶음 1벡터는 평균화 희석으로 회귀 → 폐기) + boilerplate 청크 skip. `[ingest.expansion]` default off. 측정(나무위키 ~1000 문서 CS corpus): 변형 일관성 14/18 → **16/18**, spread 0.222→0.111, 대조군 false-positive 별칭 무죄. 비용 병목(별칭 18문서 2.5h)은 **파생물 캐시(V012, 청크 내용 해시 키)**로 해소 — 정답 3개 cold 1879s → warm 13s **≈ 145배**, embedding+별칭 LLM 캐싱, version_key cascade 정합. search/ask 가 `kebab.sqlite`+`lancedb` 만으로 동작 → 외부 서버 색인 후 DB 만 복사하는 이식 워크플로 가능. **결정/known limitation**: grounded/refusal 판정이 부분 인용을 grounded 로 오분류(정직한 거부가 false-positive 로 집계) — 별도 개선 후보. stack·svm 설명형 2개 잔존. 자세한 내용: `tasks/HOTFIXES.md` (2026-05-31), 측정: `docs/superpowers/handoffs/2026-05-31-namu-wiki-alias-cache-study.md`.
|
||||
- **2026-05-29 v0.20.2 dogfood findings + 검색 품질 baseline** — 8-finding 라운드 완료. (1) Ask 응답언어: rag-v3 default (질문 언어 = 답변 언어). (2) eval `--config` facade 패치 로 dogfood KB 직접 eval 가능. (3) 검색 품질 baseline — hybrid hit@3=1.0 / MRR=0.833, lexical hit@3=1.0 / MRR=0.7 (golden 10 query). **O-2 known limitation**: 소형 모델(gemma4:e4b) refusal 메시지의 query 언어 불일치 가능 — 판정은 정상, 표시 문구만 해당. 자세한 내용: `tasks/HOTFIXES.md` (2026-05-29).
|
||||
- **v0.20 sub-item 1 (scanned PDF OCR via qwen2.5vl:3b)**: post-extract enrichment pattern (`kebab-app::pdf_ocr_apply`, H-1 resolution), DCTDecode-only v1 scope (FlateDecode/CCITTFax page 는 warning + skip), parser_version `"pdf-text-v1"` 보존 + force-reingest UX 명문 (H-4).
|
||||
- **2026-05-26 kebab-normalize + kebab-parse-types 흡수 (24 → 22 crates, design §3.7b 재작성)** — v0.19.0 cut. 4 parser 중 markdown 한 갈래만 lift 를 경유하는 reality 가 design §3.7b 의 fan-in ≥ 2 가정과 diverge → thin layer (`kebab-parse-types`) + `kebab-normalize` 두 crate 가 `kebab-parse-md` 로 흡수. 5 사용 type + 3 forward-declared struct 모두 `kebab-parse-md::{types,normalize}` module 의 `pub` re-export 로 보존. wire / surface impact = 0 (CLI / TUI / MCP / `--json` / config / XDG / parser_version 모두 unchanged). 자세한 내용: `tasks/HOTFIXES.md` (2026-05-26 design deviation entry).
|
||||
|
||||
369
README.md
369
README.md
@@ -1,151 +1,197 @@
|
||||
# kebab — Local-first Knowledge Base
|
||||
# kebab — Local-first Knowledge Base + RAG
|
||||
|
||||
`kebab` 는 개인용 로컬 knowledge base + RAG 도구다. Markdown / PDF / 이미지를 한 곳에 색인하고, 의미 검색 + page-단위 citation 포함 LLM 답변을 단일 binary 로 제공한다. 모든 추론은 로컬 (Ollama / fastembed) 에서 돌아간다. 대상 하드웨어: M4 48GB MacBook 1대, 사용자 1명.
|
||||
|
||||
## 사전 요구
|
||||
|
||||
- **Rust toolchain** ≥ 1.85 (workspace 가 edition 2024 + resolver 3 사용). [rustup](https://rustup.rs) 권장.
|
||||
- **Ollama** — `kebab ask` 와 이미지 OCR/caption 가 사용. `https://ollama.com/download` 에서 설치 후 `ollama serve` 실행. 기본 LLM 은 gemma4 계열 (`ollama pull gemma4:e4b`) — OCR / caption 도 같은 family 라 모델 하나만 pull 하면 됨. 더 큰 variant 원하면 `gemma4:26b` 등으로 config override. config 의 `[models.llm].endpoint` 에 host:port 명시.
|
||||
- **CPU only / RAM ≤ 16 GB 환경 권장 모델**: gemma4:e4b (8B) 는 CPU 추론에 무거워 RAG 한 답변이 5분을 넘기기 쉽다 — `[models.llm] request_timeout_secs` 의 기본 300 s 한도에 걸려 `error: kb-rag: llm.generate_stream` 으로 떨어진다 (HOTFIXES 2026-05-25). `gemma3:4b` / `qwen2.5:3b` / `phi3:mini` 같은 ≤ 4B Q4 모델로 바꾸면 답변 1-3 분에 안정 동작 (확장 도그푸딩에서 검증). 모델 storage 가 부담이면 `OLLAMA_MODELS=/path` env 로 위치 분리 가능.
|
||||
- **`request_timeout_secs` 노브 (v0.17.0)**: `[models.llm] request_timeout_secs = 1200` (또는 `KEBAB_MODELS_LLM_REQUEST_TIMEOUT_SECS=1200`) 로 한도를 늘려 큰 모델도 시도 가능. 단 응답 동안 RAM 점유가 길어진다. **`= 0` 은 disable 이 아니라 "즉시 timeout"** (reqwest 의 의미상) — "사실상 무제한" 의도면 `u64::MAX` 또는 `86400` 같이 큰 finite 값 사용.
|
||||
- **sudo 없이 설치 (격리 디렉토리 사용)**: `install.sh` 가 `/usr/local/bin/ollama` + `systemd` 유닛까지 건드리는 게 부담이면 binary tarball 만 받아 사용자 디렉토리에 풀고 env 로 모델 위치 분리하면 된다.
|
||||
```bash
|
||||
mkdir -p /opt/ollama/{models,logs}
|
||||
curl -fL https://ollama.com/download/ollama-linux-amd64.tar.zst -o /tmp/ollama.tar.zst
|
||||
zstd -d /tmp/ollama.tar.zst -o /tmp/ollama.tar && tar -xf /tmp/ollama.tar -C /opt/ollama/
|
||||
# bin/ollama + lib/ollama/ 가 풀린다. 모델 디렉토리는 OLLAMA_MODELS 로 분리.
|
||||
OLLAMA_MODELS=/opt/ollama/models OLLAMA_HOST=127.0.0.1:11434 \
|
||||
/opt/ollama/bin/ollama serve > /opt/ollama/logs/serve.log 2>&1 &
|
||||
/opt/ollama/bin/ollama pull gemma3:4b
|
||||
```
|
||||
루트 디스크 부담을 분리하고 싶을 때 (`~/.ollama/models` 가 기본) 그대로 활용. systemd 가 없는 컨테이너 / WSL2 / 회사 머신 등에서 유용.
|
||||
- **`kebab ask --stream` 권장 (fb-33)**: 모델 cold start 가 길 때 (8B+ 또는 첫 호출) `--stream` 으로 토큰을 stderr 에 ndjson 으로 흘려 받으면 5 분 timeout 한도 안에서도 첫 토큰이 빨리 보여 사용자 체감이 개선된다. 동일 inference 시간이라도 wait-and-pray 보다 progressive 가 안정적. CLI: `kebab ask "..." --stream 2> events.ndjson > final.json`. MCP host 도 `streaming_ask` capability flag 가 `true` 면 자동 사용 권장.
|
||||
- **빌드 디스크** — 첫 빌드 시 `target/` 가 6–10 GB (Lance + DataFusion + fastembed). 여유 확인.
|
||||
- **fastembed 모델** — 첫 `kebab ingest` 시 `multilingual-e5-large` (~1.3 GB, fb-39b) 자동 다운로드. `config.toml` 에서 `model = "multilingual-e5-small"` 로 명시하면 이전 모델 사용.
|
||||
|
||||
## 설치
|
||||
|
||||
표준 경로는 `cargo install` — `~/.cargo/bin/kebab` 가 PATH 에 있는지만 확인하면 끝.
|
||||
|
||||
```bash
|
||||
# 1) repo clone
|
||||
git clone https://gitea.altair823.xyz/altair823-org/kebab.git
|
||||
cd kebab
|
||||
|
||||
# 2) binary 빌드 + 설치 (~/.cargo/bin/kebab)
|
||||
cargo install --path crates/kebab-cli --locked
|
||||
|
||||
# 3) PATH 확인 (아직 추가 안 했으면 ~/.bashrc / ~/.zshrc 에 추가)
|
||||
which kebab # → /Users/<you>/.cargo/bin/kebab 같은 경로
|
||||
kebab --version # → kebab 0.1.0
|
||||
```
|
||||
|
||||
git URL 직접 install 도 가능 (clone 없이):
|
||||
|
||||
```bash
|
||||
cargo install --git https://gitea.altair823.xyz/altair823-org/kebab.git --bin kebab --locked
|
||||
```
|
||||
|
||||
업데이트는 `git pull && cargo install --path crates/kebab-cli --locked --force` 또는 git URL 형식의 경우 `cargo install --git ... --force`.
|
||||
|
||||
제거는 `cargo uninstall kebab-cli`. 이 명령은 binary 만 지우고 워크스페이스 데이터는 그대로 남는다. 데이터까지 정리하려면 `kebab reset --all --yes` (config + data + cache + state 4 개 XDG 경로 모두 wipe — **irreversible**, 재시작 시 `kebab init` 다시 실행). 부분 wipe 는 `kebab reset --data-only` (config 보존), `kebab reset --vector-only` (Lance + `embedding_records` 만, 다음 ingest 가 re-embed), **`kebab reset --orphans-only`** (현재 walker scope 밖에 있는 stored doc 만 정리 — `config.workspace.include` 좁히거나 sub-dir 옮긴 후 explicit reconcile; fs 의 file 은 건드리지 않음) 등.
|
||||
`kebab` 는 개인용 로컬 knowledge base + RAG 도구다. Markdown · PDF · 이미지 · 소스코드를 한 곳에 색인하고, 하이브리드 의미 검색과 근거 인용을 포함한 LLM 답변을 **단일 binary** 로 제공한다. 모든 추론은 로컬 (Ollama + fastembed) 에서 돌아간다.
|
||||
|
||||
## Quick start
|
||||
|
||||
사전 요구는 두 가지뿐이다.
|
||||
|
||||
- **Rust toolchain** ≥ 1.85 (workspace 가 edition 2024 사용). [rustup](https://rustup.rs).
|
||||
- **Ollama** — `kebab ask` 와 이미지/PDF OCR 가 사용. [공식 설치 안내](https://ollama.com/download) 참고 후 `ollama serve` 실행. 기본 LLM family 는 gemma4 (`ollama pull gemma4:e4b`) — OCR/caption 도 같은 family 라 모델 하나면 된다. CPU-only 환경이면 소형 모델 (예: `gemma3:4b`) 을 권장.
|
||||
|
||||
```bash
|
||||
# 첫 실행 — XDG 경로에 데이터 디렉토리 + config.toml 생성
|
||||
# 1) 빌드 + 설치 (~/.cargo/bin/kebab)
|
||||
git clone https://gitea.altair823.xyz/altair823-org/kebab.git
|
||||
cd kebab
|
||||
cargo install --path crates/kebab-cli --locked
|
||||
|
||||
# 2) 데이터 디렉토리 + config.toml 생성 (XDG 경로)
|
||||
kebab init
|
||||
|
||||
# config 손보고 — workspace.root, 모델 endpoint 등 설정 (지원 형식: md / png / jpg / pdf / rs / py / ts / js / go)
|
||||
# 3) config 최소 손보기 — workspace.root (색인할 폴더) 와 LLM endpoint
|
||||
${EDITOR:-vi} ~/.config/kebab/config.toml
|
||||
|
||||
# 색인 (Markdown / 이미지 / PDF 모두 한 번에)
|
||||
# 4) 색인 (Markdown · PDF · 이미지 · 소스코드 한 번에)
|
||||
kebab ingest
|
||||
|
||||
# 검색 (citation 의 source_span 이 매체별로 line / region / page)
|
||||
kebab search "Markdown chunking 규칙" --mode hybrid
|
||||
# 5) 검색 (hybrid = lexical + vector RRF, citation 포함)
|
||||
kebab search "Markdown chunking 규칙"
|
||||
|
||||
# 질문 (Ollama 필요, PDF 인용 시 page 번호 surface)
|
||||
# 6) 질문 (RAG 답변 + 근거 인용, Ollama 필요)
|
||||
kebab ask "내 KB 설계에서 저장소 전략은?"
|
||||
|
||||
# Ratatui 셸 (Library + Search + Ask + Inspect 패널, desktop 진행 중)
|
||||
kebab tui
|
||||
|
||||
# 헬스 체크 (config 경로 / 데이터 디렉토리 쓰기 가능 여부)
|
||||
kebab doctor
|
||||
```
|
||||
|
||||
격리된 임시 워크스페이스로 돌려보는 절차는 [docs/SMOKE.md](docs/SMOKE.md) — `--config <path>` 로 분리. 이미지 / PDF fixture 가 필요하면 두 example 바이너리 (`cargo run --release --example gen_smoke_pdf -p kebab-parse-pdf` / `gen_smoke_png -p kebab-parse-image`) 로 시스템 dep 없이 in-tree 생성 가능.
|
||||
clone 없이 git URL 로 바로 설치할 수도 있다: `cargo install --git https://gitea.altair823.xyz/altair823-org/kebab.git --bin kebab --locked`. 업데이트는 동일 명령에 `--force`. 제거는 `cargo uninstall kebab-cli` (데이터는 보존 — 데이터까지 지우려면 `kebab reset --all --yes`).
|
||||
|
||||
설치 없이 dev 흐름으로 돌려볼 때는 `cargo run --release -p kebab-cli -- <subcommand>` 또는 `cargo build --release && ./target/release/kebab <subcommand>`.
|
||||
설치 없이 dev 흐름으로 돌려볼 때는 `cargo run --release -p kebab-cli -- <subcommand>`. 격리된 임시 워크스페이스로 검증하는 절차는 [docs/SMOKE.md](docs/SMOKE.md) (`--config <path>` 로 분리).
|
||||
|
||||
## 핵심 기능
|
||||
|
||||
### 하이브리드 검색 + citation
|
||||
|
||||
lexical (FTS5 BM25) 과 vector (cosine) 두 채널을 **RRF fusion** 으로 합쳐 검색한다. 모든 hit 은 출처 위치를 매체별로 정확히 담는다 — Markdown/코드는 line, 이미지는 region, PDF 는 page. `--tag` · `--media` · `--lang` · `--path-glob` 등 다양한 필터와 `--max-tokens` · `--cursor` 같은 agent budget flag 를 지원한다.
|
||||
|
||||
### 파생물 캐시 (자동)
|
||||
|
||||
embedding 벡터를 청크 **내용 해시** 로 캐싱한다 (`derivation_cache`). 재색인·갱신 시 내용이 같은 청크는 재계산을 건너뛴다. 캐시 키에 모델·차원 버전이 포함돼 버전 변경 시 자동 무효화된다 (cascade 안전). 별도 설정 없이 투명하게 동작한다. (현재 TTL/LRU 자동 정리는 미구현 — 누적된 캐시는 `kebab reset` 으로만 정리.)
|
||||
|
||||
### 외부 계산 + 로컬 검색 워크플로
|
||||
|
||||
search/ask 는 원본 파일 없이 KB 산출물만으로 동작한다 (청크 본문이 SQLite 에 저장되고 문서 경로는 상대경로로 기록됨). 비싼 색인(임베딩·OCR)을 성능 좋은 머신에서 수행한 뒤(예: Apple Silicon 맥에서 candle Metal GPU), **두 산출물만** 다른 머신(예: NUMA 서버)으로 복사하면 그대로 검색·질문할 수 있다.
|
||||
|
||||
**무엇을 복사하나 — `[storage]` 에서 정의된 두 경로:**
|
||||
|
||||
| 복사 대상 | config 키 (`[storage]`) | 기본 경로 | 내용 |
|
||||
|-----------|------------------------|-----------|------|
|
||||
| `kebab.sqlite` | `sqlite = "{data_dir}/kebab.sqlite"` | `{data_dir}/kebab.sqlite` | 문서·청크·본문·FTS5·메타 |
|
||||
| `lancedb/` | `vector_dir = "{data_dir}/lancedb"` | `{data_dir}/lancedb/` | 임베딩 벡터 |
|
||||
|
||||
`{data_dir}` 는 `[storage].data_dir` (예: `~/.local/share/kebab`). `models/`(`model_dir`)·`assets/`(`asset_dir`)는 **복사 불필요** — 모델은 각 머신이 자기 캐시를 받고, asset 원본 바이트는 검색·질문에 쓰이지 않는다 (단일파일/`stdin` 색인의 원본 재읽기·재색인까지 보존하려면 `assets/` 도 함께 복사).
|
||||
|
||||
```bash
|
||||
# ingest 가 끝난(쓰기 없는) 상태에서 복사
|
||||
rsync -a <src-data_dir>/kebab.sqlite user@server:<dst-data_dir>/
|
||||
rsync -a <src-data_dir>/lancedb/ user@server:<dst-data_dir>/lancedb/
|
||||
```
|
||||
|
||||
조건: **양쪽 동일 `kebab` 버전 + 동일 임베딩 모델/차원** (`[models.embedding].model`·`dimensions`). provider 는 달라도 됨 (예: 맥 `candle`/Metal ↔ 서버 `candle`/CPU 또는 `fastembed` — 같은 모델이면 벡터 호환). 복사는 반드시 ingest 가 돌지 않을 때.
|
||||
|
||||
### 멀티미디어 색인
|
||||
|
||||
Markdown · PDF · 이미지(OCR + caption) · 소스코드(Rust/Python/TS/JS/Go/Java/Kotlin/C/C++ AST) · 리소스(YAML/Dockerfile/TOML/JSON/XML 등)를 확장자에 따라 자동으로 적절한 chunker 에 라우팅한다. embedded text 가 없는 scanned PDF 는 `[pdf.ocr]` 로 page-단위 OCR (opt-in). 전체 확장자→chunker 매핑은 [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
|
||||
|
||||
### RAG (근거 인용 + 거절)
|
||||
|
||||
검색 결과를 근거로 LLM 답변을 생성하고 [#번호] 인용을 단다. 근거가 부족하면 답을 지어내지 않고 거절한다. compound 질문은 `--multi-hop` 으로 분해→synthesize. 답변의 groundedness 는 mDeBERTa XNLI 로 검증할 수 있다 (`[rag] nli_threshold`, default off).
|
||||
|
||||
### TUI
|
||||
|
||||
`kebab tui` 는 Ratatui 셸 — Library / Search / Ask / Inspect 패널을 vim-style 모드로 다룬다. 키 매핑은 앱 내 `F1` cheatsheet 가 권위 소스다.
|
||||
|
||||
## 명령
|
||||
|
||||
| 명령 | 동작 |
|
||||
|------|------|
|
||||
| `kebab init` | XDG 경로에 데이터 디렉토리 + config.toml 생성 |
|
||||
| `kebab ingest [<path>]` | Markdown / 이미지 / PDF / Rust 소스코드 색인 (idempotent). TTY 에서는 stderr 진행 바, non-TTY (CI / pipe) 는 stderr 한 줄씩, `--json` 은 stdout 에 `ingest_progress.v1` 라인 streaming 후 마지막에 `ingest_report.v1`. Ctrl-C 한 번이면 현재 asset 마무리 후 abort (부분 commit 보존, idempotent re-run), 두 번째 Ctrl-C 는 hard exit. Markdown title 이 frontmatter 에 없어도 첫 H1 → H2 → 첫 paragraph 80 자 → 파일명 순으로 자동 채움 (parser_version `md-frontmatter-v2`) — 기존 색인된 doc 도 다음 ingest 에서 새 title 로 갱신. **Incremental** (p9-fb-23): 두 번째 이후의 ingest 는 변하지 않은 doc (blake3 + parser/chunker/embedder version 모두 동일) 의 parse/chunk/embed/vector upsert 를 자동 스킵. final summary 에 `N unchanged` 카운트 표시. `--force-reingest` 로 skip 무시 강제 재처리. **지원 형식** (extractor 자동 결정 — config 에 명시 불가): Markdown (`.md`), 이미지 (`.png` / `.jpg` / `.jpeg`, OCR + caption), PDF (`.pdf`), **소스코드** (`.rs` → `code-rust-ast-v1`, `.py` → `code-python-ast-v1`, `.ts`/`.tsx` → `code-ts-ast-v1`, `.js`/`.mjs`/`.cjs`/`.jsx` → `code-js-ast-v1`, `.go` → `code-go-ast-v1`, `.java` → `code-java-ast-v1`, `.kt`/`.kts` → `code-kotlin-ast-v1`, `.c`/`.h` → `code-c-ast-v1`, `.cpp`/`.cc`/`.cxx`/`.hpp`/`.hh`/`.hxx` → `code-cpp-ast-v1` — 모두 tree-sitter AST chunker; **Tier 2 리소스 파일**: `.yaml`/`.yml` → `k8s-manifest-resource-v1` (apiVersion+kind 파싱), `Dockerfile`/`Dockerfile.*`/`*.dockerfile` → `dockerfile-file-v1` (전체 파일), `Cargo.toml`/`pyproject.toml`/`.toml`/`package.json`/`tsconfig.json`/`.json`/`pom.xml`/`.xml`/`build.gradle`/`.gradle`/`go.mod` → `manifest-file-v1` (전체 파일) — yaml (k8s) / dockerfile / toml / json / xml / groovy / go-mod 지원); **Tier 3 paragraph fallback** (`.sh`/`.bash`/`.zsh` → `code-text-paragraph-v1`, blank-line paragraph split + 80-line/20-overlap line-window. Tier 1/2 가 0 chunk 또는 Err 시 자동 fallback — 비-k8s YAML 같은 케이스 picked up. symbol = None, lang 은 원본 보존.). 다른 확장자는 자동 skip — `IngestItem.warnings` 에 사유 (`"unsupported media type: .docx"` 등), `IngestReport.skipped_by_extension` 에 카운트 분류, CLI / TUI summary 에 breakdown 표시. 코드 chunk 는 `citation.kind = "code"` 에 `citation.lang = "<lang>"` + `symbol` + line range 를 담고, SearchHit top-level 에 `code_lang` + `repo` (`.git/` walk-up 의 디렉토리 이름) 가 backfill 됨. `--code-lang rust` / `--code-lang python` / `--code-lang typescript` / `--code-lang javascript` / `--code-lang go` / `--code-lang java` / `--code-lang kotlin` / `--code-lang yaml` / `--code-lang dockerfile` / `--code-lang toml` / `--code-lang json` / `--code-lang xml` / `--code-lang groovy` / `--code-lang go-mod` / `--code-lang shell` / `--code-lang c` / `--code-lang cpp` / `--media code` filter 로 언어별·코드 전용 검색 가능 (p10-1A-1 filter flags). Python symbol 은 workspace 경로 → dotted module path prefix (예: `kebab_eval.metrics.compute_mrr`), TS/JS symbol 은 slash-style module path prefix (예: `src/Foo.Foo.search`), Go symbol 은 `package.Func` / `package.(*Receiver).Method` 형식, Java / Kotlin symbol 은 `com.foo.Foo.bar` 형식 (패키지 + 클래스 + 메서드/필드). |
|
||||
| `kebab search --mode {lexical,vector,hybrid} "<query>" [--no-cache] [--max-tokens N] [--snippet-chars N] [--cursor <opaque>] [--tag T] [--lang L] [--path-glob G] [--trust-min LEVEL] [--media TYPE] [--ingested-after RFC3339] [--doc-id ID] [--trace] [--bulk] [--repo NAME ...] [--code-lang LIST]` | 검색. hybrid는 RRF fusion, citation 포함. 같은 process 안에서 동일 query (NFKC + trim + lowercase 정규화) 반복 시 in-process LRU 캐시 hit (capacity = `[search] cache_capacity`, default 256). `--no-cache` 로 강제 bypass — 디버깅용. ingest commit 발생 시 `kv['corpus_revision']` bump 으로 모든 entry 자동 stale. **`--max-tokens` / `--snippet-chars` / `--cursor` (p9-fb-34)** — agent budget controls. `--json` 출력은 `search_response.v1` wrapper (`{hits, next_cursor, truncated}`) — pre-fb-34 의 bare array 와 호환 안 됨. mismatched cursor → `error.v1.code = stale_cursor`. **filter flags (p9-fb-36):** `--tag` 는 반복 가능 flag (`--tag rust --tag async`) 로 OR 매칭, `--media` 는 `,` 구분 다중 값 OR 매칭, 나머지 flags 간은 AND 조합. `--trust-min` 은 `primary\|secondary\|generated` 중 하나 (해당 level 이상 포함). `--ingested-after` 는 RFC3339 UTC — 파싱 실패 시 `error.v1.code = config_invalid` (exit 2). `--media md` 는 `markdown` alias 로 정규화. 알 수 없는 `--media` 값은 무조건 empty hits (오류 아님). **`--trace` (p9-fb-37)** — `search_response.v1.trace` 에 lexical / vector pre-fusion 후보 + RRF union + per-stage timing (`lexical_ms` / `vector_ms` / `fusion_ms` / `total_ms`) 노출. trace 요청은 캐시 우회 (`--no-cache` 없이도 항상 cold). **`--bulk` (p9-fb-42)** — stdin ndjson 으로 N query 한 번에 실행. `--json` 면 stdout per-query ndjson (`bulk_search_item.v1`) + stderr summary (`bulk_summary: total=N succeeded=S failed=F`). Cap 100. agent 가 query decomposition 후 sub-query 일괄 실행 시 single round-trip — App instance 재사용으로 캐시 / embedder cold-start 비용 한 번만. Per-query failure 는 item 의 `error` (error.v1) 에 격리, 다른 query 계속 진행. 입력은 stdin ndjson — 줄당 한 query object, `{"query":"<text>"}` 만 필수 (string; nested object 아님), `mode`/`k`/`trust_min`/`ingested_after`/`media`/`tag`/`lang` optional (`docs/wire-schema/v1/bulk_search_input.schema.json`). 예: `echo '{"query":"한국","mode":"lexical","k":3}' | kebab search --bulk --json`. **code corpus filters (p10-1A-1):** `--repo` 는 반복 가능 (`--repo kebab --repo other`) OR 매칭. `--code-lang` 는 반복 또는 comma 다중 값 (`--code-lang rust,python`), 알 수 없는 값은 빈 hits. `--media code` 는 Tier 1/2/3 모든 code chunk 포함. 1A-1 시점에서는 indexed 된 code chunk 가 없어 filter 가 항상 빈 결과 — 1A-2 (Rust AST chunker) 머지 이후 실효. **v0.20.1 V009 morphological tokenizer (한국어 + 영어 동작 변경):** `chunks_fts` 가 FTS5 `unicode61` + 한국어 lindera ko-dic 형태소 분석 결과를 별 column 으로 prepend. **한국어 2자 query 지원** — '한국', '서울', '지하철' 같은 2자/3자 단어가 형태소 분해 후 hit. **영어는 whole-token 매칭** — V002 동작으로 회귀 (`tokenizer` query 는 `tokenizer` 토큰만 hit, `token` 같은 substring 은 hit X). substring recall 이 필요하면 vector/hybrid mode 권장. `kebab.sqlite` 파일 크기는 lindera ko-dic embedded dict 와 tokenized_korean_text column 의존성으로 다소 증가. V009 자동 backfill (`App::open_with_config` 의 first-boot hook) — re-ingest 불필요. |
|
||||
| `kebab list docs` | 색인된 문서 목록. human-readable 출력은 `doc_id \t title \t doc_path` (title 은 heading 기반이라 중복 가능 — doc_path 로 구분). `--json` 은 `doc_summary.v1` array (title / doc_path 모두 포함, wire schema 불변). |
|
||||
| `kebab inspect doc <id>` / `kebab inspect chunk <id>` | raw record 보기 |
|
||||
| `kebab fetch chunk <id> [--context N]` / `kebab fetch doc <id> [--max-tokens N]` / `kebab fetch span <doc_id> <ls> <le> [--max-tokens N]` | (p9-fb-35) verbatim text fetch from indexed corpus. wire = `fetch_result.v1` (kind discriminator). chunk: target + ±N ordinal-context chunks. doc: full normalized markdown. span: 1-based line range (PDF/audio rejected as `error.v1.code = span_not_supported`). chars/4 budget on doc/span. |
|
||||
| `kebab ask "<query>" [--show-citations / --hide-citations] [--session <id>] [--stream] [--multi-hop]` | RAG 답변 + 근거 인용. 답변 후 `근거:` block 으로 full path / line range / score 한 줄씩 (default ON — `--hide-citations` 로 끄기, pipe 시 유용). 근거 부족 시 거절. Ollama 필요. `--session <id>` 로 multi-turn — 첫 호출에서 SQLite `chat_sessions` 에 자동 생성, 이후 호출은 prior turns 를 history 로 받아 follow-up. session id 는 사용자 지정 (e.g. `kb-rust-async-2026-05`) — `kebab reset --data-only` 로 모든 session wipe. **`--stream` (p9-fb-33)** 로 ndjson `answer_event.v1` event (retrieval_done → token* → final) 를 stderr 에 흘리고 stdout 마지막 줄에 기존 `answer.v1` — agent 가 token 즉시 소비 가능. **`--multi-hop` (v0.18.0 fb-41)** — single-pass 대신 decompose → decide → synthesize 의 N-hop loop. compound 질문 (cross-doc / prereq chain) 에 효과적. 최종 답변 후 mDeBERTa-v3 XNLI 가 `(packed_chunks, generated_answer)` entailment 검사 — `[rag] nli_threshold > 0` (default 0.0 = disabled, production 권장 0.5) 일 때 활성. entailment < threshold → `refusal_reason = "nli_verification_failed"` (LLM-self-judge ceiling 극복, S7 caffeine hallucination 같은 케이스 catch). 첫 호출 시 ~280 MB ONNX model 자동 다운로드 + RAM peak ~7-8 GB (gemma3:4b 기준). model unavailable 시 `refusal_reason = "nli_model_unavailable"`, 우회는 `[rag] nli_threshold = 0` 임시 disable. |
|
||||
| `kebab doctor` | 설정/모델/DB 헬스 체크 |
|
||||
| `kebab tui` | Ratatui 셸 (Library + Search + Ask + Inspect 패널, desktop 진행 중). Library 에서 `r` 키로 background ingest 시작 — 화면 하단 status bar 가 진행 표시, 완료/abort 시 final 라인 잠시 유지 후 자동 hide. ingest 진행 중 `Esc` / `Ctrl-C` 가 cancel signal (그 외에는 quit). vim-style mode (header 우측 `-- NORMAL --` / `-- INSERT --`) — Library/Inspect 는 자동 NORMAL, Search/Ask 는 자동 INSERT. `i` 로 Normal→Insert (모든 pane — p9-fb-21), `Esc` 로 Insert→Normal 어디서나. mode-authoritative dispatch — Search 의 `j/k/o/g`, Ask 의 `e/j/k` 는 NORMAL 모드에서만 명령으로 동작, INSERT 에서는 입력 문자로 typing. (Search 의 chunk inspect 키는 `i`→`o` 로 rebind — `i` 가 universal Insert toggle.) **`F1` 로 cheatsheet popup** (현재 pane 의 키 매핑 + global 토글 표) — `Esc` / `F1` 로 닫기. Search 패널은 200ms debounce 후 background worker 가 검색 — 키 입력으로 UI freeze 안 됨, 사용자가 계속 타이핑하면 stale 결과 자동 폐기 (generation counter). Ask 패널은 multi-turn — 같은 conversation 안에서 Q1/A1, Q2/A2 transcript 누적, 다음 질문이 이전 턴을 history 로 받아 답변. 답변 본문은 markdown 렌더 (bold/italic/inline code/heading/list/code fence/table/blockquote, raw `**bold**` 가 실제 굵게 표시). `Ctrl-L` 로 새 conversation 시작. Search 의 `g` 키가 `$EDITOR` (기본 `vi`) 로 hit 의 citation 위치 열기 — 종료 후 TUI 화면이 자동으로 깨끗이 redraw. CLI `kebab ask` 는 raw markdown 그대로 (terminal 호환성 위해). Library 의 doc-list 가 한글 / 일본어 / 중국어 (CJK) 제목을 wide-char 정확한 column width 로 truncate — 한글 제목이 한 줄을 넘기지 않음 (CJK 1 자 = 2 col). Search/Ask/Filter 입력의 cursor 가 wide char 위에서 column 단위로 정렬 — 한글 입력 시 caret 이 글자 옆에 정확히 놓임. `← / →` 로 입력 문자열 중간 cursor 이동 (한글 한 글자 = 2 column 이라도 한 번에 이동), `Home / End` 로 양 끝 점프, `Delete` 로 cursor 위치 char 삭제 — 모든 input pane (Ask / Search / Library filter overlay) 동일 (p9-fb-22). Ask 트랜스크립트는 새 답변이 viewport 아래로 누적될 때 자동으로 tail 을 따라감 (auto-scroll); `j` / `k` 로 위로 스크롤하면 freeze, `Shift-G` 로 다시 bottom + auto-tail 재개. 화면 하단 hint line 은 한국어 동사구로 (`"위로"` / `"아래로"` / `"필터"` / `"타이핑 검색어"` / `"Esc 로 NORMAL 모드"` / `"i 입력모드"` 등) + 현재 (pane, mode) 조합에 맞춰 자동 분기, **첫 fragment 가 항상 `F1 도움말`** (cheatsheet 발견성 보장). 모든 모드에서 항상 떠 있는 상태바 — `kebab v<version> │ <pane> │ <docs> docs │ <state>` (state: streaming/searching/indexing/idle, ingest 진행 중에는 progress 가 같은 자리에 흡수됨). Ask 진입 시 conversation id 8 자 prefix 도 함께 표시. Ask 트랜스크립트와 Inspect 양쪽에서 `PgUp / PgDn` 으로 10 줄씩 페이지 스크롤. Library 의 doc list 위에는 `TITLE / TAGS / UPDATED / CHUNKS` 컬럼 헤더 행 표시 (display-width 정렬, Hangul / CJK 안전). |
|
||||
| `kebab 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 schema [--json]` | introspection — wire schemas / capabilities / models / stats 한 번에. `--json` 은 `schema.v1` wire; 사람 모드는 서식 출력. **stats 에 (p9-fb-37) `media_breakdown` (5 keys: markdown / pdf / image / audio / other) + `lang_breakdown` (BCP-47 코드, NULL 은 literal `"null"`) + `index_bytes` (sqlite + lancedb on-disk 합계) + `stale_doc_count` (`config.search.stale_threshold_days` 초과 doc 수) 추가.** **`index_version` 두 곳 주의 (v0.20.2):** `schema.v1.models.index_version` = vector store (LanceDB) version, `search_hit.v1.index_version` = lexical (FTS5) version — 서로 다른 축, cascade 에서 별도 추적. |
|
||||
| `kebab ingest-file <path>` | 단일 파일 ingest (workspace 외부 가능). 바이트는 `<workspace.root>/_external/<hash12>.<ext>` 로 copy. `.kebabignore` 매치 시 stderr warn 후 진행 (explicit ingest 가 bypass intent). |
|
||||
| `kebab ingest-stdin --title <T> [--source-uri <URI>]` | stdin 의 markdown 본문 ingest. frontmatter (title + source_uri) 자동 prepend. v1 markdown only. |
|
||||
| `kebab mcp` | MCP (Model Context Protocol) stdio server. agent host (Claude Code / Cursor / OpenAI Agents) 가 spawn 하여 tool 호출 (`search` / `bulk_search` / `ask` / `fetch` / `schema` / `doctor` / `ingest_file` / `ingest_stdin`). `--config` honor. |
|
||||
| `kebab ingest [<path>]` | 워크스페이스 스캔 후 새/변경 문서 색인 (idempotent · incremental, `--force-reingest` 로 강제 재처리). 미지원 확장자는 자동 skip. 진행바는 현재 **파일명** · 느린 **phase(ocr/caption/embed)+모델명** · **경과초**`(Ns)` · 문서별 청크 수 · phase별 소요시간(parse/chunk/ocr/caption/embed/store)을 표시하고, 종료 시 **최장 소요 파일 top-5** 를 요약한다 (`--json` 은 `asset_phase`/`asset_chunked`/`asset_timings` 이벤트로, 사람용 요약은 미출력) |
|
||||
| `kebab ingest-file <path>` | 단일 파일 ingest (workspace 외부 가능 — `_external/` 로 deterministic copy) |
|
||||
| `kebab ingest-stdin --title <T>` | stdin 의 markdown 본문 ingest |
|
||||
| `kebab search --mode {lexical,vector,hybrid} "<query>" [flags]` | 검색 (default hybrid = RRF fusion, citation 포함). 필터/budget flag 는 `--help` |
|
||||
| `kebab ask "<query>" [flags]` | RAG 답변 + 근거 인용 (Ollama 필요). `--session` (multi-turn) · `--stream` · `--multi-hop` |
|
||||
| `kebab list docs` | 색인된 문서 목록 |
|
||||
| `kebab inspect doc <id>` / `inspect chunk <id>` | raw record 보기 |
|
||||
| `kebab fetch chunk\|doc\|span <id> [flags]` | indexed corpus 에서 verbatim text fetch |
|
||||
| `kebab eval run \| aggregate \| compare \| variants` | golden query 회귀 측정 + 변형 일관성 진단 |
|
||||
| `kebab schema [--json]` | introspection — wire schemas / capabilities / models / stats |
|
||||
| `kebab doctor` | 설정 / 모델 / DB 헬스 체크 |
|
||||
| `kebab tui` | Ratatui 셸 (Library / Search / Ask / Inspect) |
|
||||
| `kebab mcp` | MCP stdio server (`search` / `bulk_search` / `ask` / `fetch` / `schema` / `doctor` / `ingest_file` / `ingest_stdin`) |
|
||||
| `kebab reset [--all \| --data-only \| --vector-only \| --config-only \| --orphans-only] [--yes]` | XDG 데이터 wipe (**irreversible**) |
|
||||
|
||||
모든 명령에 `--json` 플래그. 출력은 frozen wire schema v1 (`schema_version` 항상 포함, 예: `ingest_report.v1`, `ingest_progress.v1`, `search_hit.v1`, `answer.v1`, `doctor.v1`, `reset_report.v1`, `schema.v1`). `--json` 모드에서 fatal error 는 stderr 에 `error.v1` ndjson 으로 emit (exit code 0/1/2/3 unchanged).
|
||||
모든 명령에 `--json` 플래그가 있고, 출력은 frozen **wire schema v1** 을 따른다 (`schema_version` 항상 포함). `--json` 모드에서 fatal error 는 stderr 에 `error.v1` ndjson 으로 emit (exit code 0/1/2/3 불변). 글로벌 flag: `--readonly` (write-path 비활성화), `--quiet` (human stderr 억제), env `KEBAB_PROGRESS=plain`. 전체 flag·wire 의미는 `kebab <cmd> --help` 와 [docs/wire-schema/v1/](docs/wire-schema/v1/). 외부 agent 통합(Claude Code skill / MCP)은 [docs/mcp-usage.md](docs/mcp-usage.md) 와 [integrations/](integrations/).
|
||||
|
||||
글로벌 플래그: `--readonly` (또는 `KEBAB_READONLY=1`) — 모든 write-path 명령 (`ingest` / `ingest-file` / `ingest-stdin` / `reset`) 을 비활성화, exit 1. `--quiet` — 진행 바 / hint 등 human-readable stderr 억제 (exit code / stdout 출력은 그대로). `KEBAB_PROGRESS=plain` — TTY 가 없는 환경에서도 진행 상황을 plain-text 한 줄씩 stderr 로 출력 (spinner 대신).
|
||||
## Configuration
|
||||
|
||||
### `lang` vs `code_lang` (v0.20.2)
|
||||
`~/.config/kebab/config.toml` 은 `kebab init` 가 XDG 경로에 생성한다. 핵심 노브만 정리한다 (전체 절은 생성된 파일 주석 참고, 예시는 [docs/SMOKE.md](docs/SMOKE.md)).
|
||||
|
||||
- `doc.lang` / search hit 의 `lang` 은 **자연어 prose** 의 언어 (lingua 감지 — Markdown / PDF 본문). 감지 불가 / 자연어 아님 → `"und"`.
|
||||
- 소스코드 문서는 자연어 감지를 하지 않으므로 `lang = "und"` 가 정상이다. 소스 언어는 별도 `code_lang` (`rust` / `python` / ...) 에 담긴다.
|
||||
- `schema --json` 의 `lang_breakdown` 에서 `und` 비중이 높은 것은 보통 code 문서 비중 때문 — `code_lang_breakdown` / `code_lang_chunk_breakdown` 로 소스 언어 분포를 확인한다.
|
||||
```toml
|
||||
[workspace]
|
||||
root = "~/KnowledgeBase" # 색인할 폴더. 절대 / tilde / env / 상대 경로 가능.
|
||||
# 상대 경로의 base 는 config.toml 위치 (cwd 무관).
|
||||
|
||||
### Score 해석 (fb-38)
|
||||
|
||||
`search_hit.v1.score` 는 **ranking signal** 이지 confidence 가 아니다. `score_kind` 필드로 의미 선언:
|
||||
|
||||
| `score_kind` | 의미 | 범위 |
|
||||
|--------------|------|------|
|
||||
| `rrf` (hybrid) | RRF normalized | `[0, 1]`, ceiling = 1.0 (양 채널 rank=1) |
|
||||
| `bm25` (lexical) | raw BM25 | unbounded (≥ 0) |
|
||||
| `cosine` (vector) | cosine sim | `[-1, 1]` |
|
||||
|
||||
#### RRF 수식 (hybrid mode)
|
||||
|
||||
```
|
||||
chunk c 의 raw RRF = Σ_m 1 / (k_rrf + rank_m(c))
|
||||
|
||||
여기서 m ∈ {lexical, vector}, k_rrf = config.search.rrf_k (default 60).
|
||||
양 채널 모두 rank=1 일 때 raw RRF = 2 / (k_rrf + 1) ≈ 0.0328.
|
||||
|
||||
normalize: rrf_score = raw_rrf / (2 / (k_rrf + 1))
|
||||
→ rrf_score ∈ [0, 1]. 양쪽 rank=1 → 1.0, 한 쪽만 등장 → ≈ 0.5 천장.
|
||||
[models.embedding]
|
||||
provider = "fastembed" # "fastembed"(기본, onnxruntime) / "candle"(순수 Rust)
|
||||
# / "ollama"(원격 HTTP) / "none"(lexical-only).
|
||||
# candle 는 같은 모델·같은 벡터를 순수 Rust 로 돌려
|
||||
# NUMA 서버의 onnxruntime 48-스레드 double-free 를 피하는
|
||||
# opt-in 백엔드 (e5 는 재색인 불필요).
|
||||
model = "multilingual-e5-large" # 다국어 sentence embedding (1024-dim).
|
||||
# 첫 ingest 시 ONNX (~1.3GB) 자동 다운로드.
|
||||
# candle provider 는 safetensors (~2GB) 다운로드.
|
||||
# candle/ollama 는 "snowflake-arctic-embed-l-v2.0"
|
||||
# (설명형 query 의 recall 보강) 도 지원 — 아래 참고.
|
||||
dimensions = 1024 # config 와 LanceDB stored dim 불일치 시 검색 0건.
|
||||
num_threads = 0 # candle 전용 CPU 스레드 캡 (0=auto=#cores).
|
||||
# env KEBAB_EMBED_THREADS 가 우선. NUMA 노드 바인딩은
|
||||
# numactl 과 조합. fastembed provider 는 무시.
|
||||
# endpoint = "http://127.0.0.1:11434" # provider="ollama" 전용 HTTP endpoint.
|
||||
# 생략 시 [models.llm].endpoint 로 폴백.
|
||||
# fastembed/candle provider 는 무시.
|
||||
```
|
||||
|
||||
`rrf_score = 0.5` 의 의미: chunk 가 한 채널 (lexical 또는 vector) 에서만 rank 1 로 등장. confidence 50% 가 아님 — RRF 수식의 산술적 천장.
|
||||
**arctic-embed-l-v2.0 (설명형 query recall 보강)**: 기본 e5-large 대신
|
||||
Snowflake `arctic-embed-l-v2.0` 임베더를 쓸 수 있다 (1024-dim, opt-in). 측정에서
|
||||
설명형/약어/영문 용어 query 의 recall@10 이 e5 대비 향상됐다. 두 경로:
|
||||
|
||||
agent 가 trust threshold 가 필요하면 top-level `score` 가 아닌 nested `retrieval.lexical_score` (BM25 raw) / `retrieval.vector_score` (cosine raw) 사용.
|
||||
```toml
|
||||
# (A) candle 백엔드 — 순수 Rust, in-process (NUMA 안전, Metal GPU 가능):
|
||||
[models.embedding]
|
||||
provider = "candle"
|
||||
model = "snowflake-arctic-embed-l-v2.0" # CLS pooling, query 에 "query: " 접두어
|
||||
# (문서는 무접두어). safetensors ~2GB 다운로드.
|
||||
|
||||
#### `score` ↔ `retrieval.*` 구조 (v0.20.2 정정)
|
||||
# (B) ollama 백엔드 — 원격/로컬 Ollama 데몬에 위임 (POST /api/embed):
|
||||
[models.embedding]
|
||||
provider = "ollama"
|
||||
model = "snowflake-arctic-embed2" # Ollama 모델 태그 (ollama pull 필요)
|
||||
endpoint = "http://127.0.0.1:11434" # 생략 시 [models.llm].endpoint
|
||||
```
|
||||
|
||||
`fusion_score` / `lexical_score` / `vector_score` / `lexical_rank` / `vector_rank` 는 모두 **`retrieval` object 내부**에 있다 (top-level 아님). top-level `score` 는 canonical ranking score 이며 그 의미는 `score_kind` 가 선언한다.
|
||||
> ⚠️ e5 → arctic 전환은 `embedding_version` cascade 를 트리거한다 (모델이 다르면
|
||||
> 벡터도 다름). 기존 e5 KB 와 혼용 불가 — 전환 시 **재색인** 필요 (`kebab reset`
|
||||
> 후 재 ingest). 기본값은 e5 라 기존 사용자는 영향 없음.
|
||||
|
||||
- **hybrid**: `score == retrieval.fusion_score` (RRF normalized `[0,1]`), `score_kind = "rrf"`.
|
||||
- **lexical-only**: fusion 미실행 → `score == retrieval.fusion_score == retrieval.lexical_score` (raw BM25), `score_kind = "bm25"`.
|
||||
- **vector-only**: `score == retrieval.fusion_score == retrieval.vector_score` (raw cosine), `score_kind = "cosine"`.
|
||||
**Apple Silicon GPU 가속 (candle / macOS)**: M-시리즈 맥에서 candle 임베딩을
|
||||
GPU(Metal)로 돌리면 CPU 대비 대용량 ingest 가 크게 빨라진다. 빌드 또는 설치 시
|
||||
`embed_metal` feature 를 켠다:
|
||||
|
||||
즉 single-mode 에서 `score`/`fusion_score`/(lexical|vector)_score 가 같은 값인 것은 fusion 단계가 없기 때문이며 정상이다 (Finding X).
|
||||
```bash
|
||||
# 빌드만:
|
||||
cargo build --release --features embed_metal
|
||||
# 전역 설치 (~/.cargo/bin/kebab):
|
||||
cargo install --path crates/kebab-cli --features embed_metal --locked
|
||||
```
|
||||
|
||||
## 논리 아키텍처
|
||||
벡터는 CPU candle 과 동일 모델이라 호환되므로, 맥에서 GPU 로 색인한
|
||||
`kebab.sqlite` + `lancedb/` 를 그대로 Linux 서버(CPU candle)로 복사해 질의할 수
|
||||
있다. 색인 로그에 `candle device = Metal (GPU)` 가 보이면 GPU 사용 중. metal
|
||||
feature 는 macOS 전용 (Linux/서버는 기본 CPU 빌드).
|
||||
|
||||
```toml
|
||||
|
||||
[models.llm]
|
||||
endpoint = "http://localhost:11434" # Ollama host:port
|
||||
model = "gemma4:e4b"
|
||||
# request_timeout_secs = 300 # 큰 모델은 늘림. 0 은 disable 이 아니라 "즉시 timeout".
|
||||
|
||||
[search]
|
||||
stale_threshold_days = 30 # search hit / citation 의 stale 플래그 기준 (0 = off).
|
||||
|
||||
[rag]
|
||||
prompt_template_version = "rag-v3" # 답변 언어 = 질문 언어. rag-v1/v2 는 legacy.
|
||||
nli_threshold = 0.0 # >0 (예: 0.5) 면 mDeBERTa XNLI groundedness 검증.
|
||||
```
|
||||
|
||||
- **파생물 캐시** — embedding 결과를 내용 해시로 자동 캐싱한다 (위 「핵심 기능」 참고). 설정 항목 없음.
|
||||
- **`[ingest.code]`** — code ingest 의 skip 정책 (`skip_generated_header`, `max_file_bytes`, `extra_skip_globs`). `.gitignore` 자동 honor, `.kebabignore` 는 추가 layer.
|
||||
- **`[image.ocr]`** — 이미지 OCR (default off / opt-in). `engine` 으로 백엔드 선택: `"ollama-vision"` (default, 원격 vision LM) 또는 `"paddle-onnx"` (v0.27.0 신규 — PP-OCRv5 ONNX 를 in-process 로 실행, Python 런타임 불필요, 큰 페이지 CPU <4초, 오프라인). `paddle-onnx` 는 워크스페이스에 번들된 모델을 쓰며 `det_model`/`rec_model`/`dict` 로 경로 override, `score_thresh`(0.3)/`unclip_ratio`(1.5)/`max_boxes`(1000) 로 검출 튜닝 가능 (`KEBAB_IMAGE_OCR_*` env 동일 지원). engine 또는 모델을 바꾸면 영향 이미지가 자동 재색인된다.
|
||||
- **`[pdf.ocr]`** — scanned PDF 의 page-단위 OCR (default off / opt-in, page 당 ~수십 초 cost). `engine` 은 `[image.ocr]` 과 동일하게 `"ollama-vision"`/`"paddle-onnx"` 선택. 활성화 후 v0.19 시절 색인분은 `kebab ingest --force-reingest` 로 재처리.
|
||||
- **`--config <path>`** — 임시 워크스페이스 / 격리 테스트용 (CLI · TUI 모두 honor).
|
||||
- **`kebab config migrate`** — 새 버전에서 추가된 config 섹션을 기존 `config.toml` 에 설명 주석과 함께 채워 넣는다 (사용자가 손본 값·주석·순서는 보존, 멱등, 변경 시 자동 `.bak` 백업). `--dry-run` 으로 변경 미리보기. `kebab doctor` 가 갱신 필요 시 안내한다. `kebab init` 으로 새로 생성되는 config.toml 도 섹션별 주석을 포함한다.
|
||||
- **`KEBAB_*` env** — 일부 키 override (`KEBAB_RAG_SCORE_GATE`, `KEBAB_EVAL_GOLDEN` 등).
|
||||
- **XDG layout**: `~/.config/kebab/`, `~/.local/share/kebab/`, `~/.cache/kebab/`, `~/.local/state/kebab/`.
|
||||
|
||||
## 아키텍처
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
@@ -162,7 +208,7 @@ flowchart TB
|
||||
|
||||
subgraph Pipeline["도메인 + 파이프라인"]
|
||||
parse["parse-md / parse-pdf / parse-image / parse-code"]
|
||||
chunker["chunker (md-heading-v1, pdf-page-v1, code-{rust,python,ts,js,go,java,kotlin,c,cpp}-ast-v1, k8s-manifest-resource-v1, dockerfile-file-v1, manifest-file-v1, code-text-paragraph-v1)"]
|
||||
chunker["chunker (md / pdf / code-AST / manifest)"]
|
||||
embedder["embedder (fastembed multilingual-e5-large)"]
|
||||
retriever["retriever (lexical / vector / hybrid RRF)"]
|
||||
rag["RAG pipeline"]
|
||||
@@ -204,93 +250,22 @@ flowchart TB
|
||||
rag --> ollama
|
||||
```
|
||||
|
||||
`kebab-app` 가 facade — UI binary 가 store / parse / search / llm / rag 를 직접 참조하지 않는다 (frozen 설계 §8). 자세한 crate-level 의존성 + 디렉토리 + 핵심 기술 결정은 [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
|
||||
v0.21.0 기준 핵심 설계:
|
||||
|
||||
## Configuration
|
||||
- **crate facade** — `kebab-app` 가 유일한 facade다. UI binary (`kebab-cli` / `kebab-tui`) 는 store / parse / search / llm / rag 를 직접 참조하지 않는다 (frozen 설계 §8). 각 user-facing 엔트리는 `*_with_config(cfg, …)` 동반 함수로 explicit config 를 thread 한다.
|
||||
- **chunk_id 는 위치 기반** — chunk 의 정체성은 문서 내 위치(ordinal + span)다. 반면 파생물 캐시 키는 **내용 해시**라, 내용이 같으면 위치·문서가 달라도 동일 캐시를 재사용한다.
|
||||
- **wire schema v1** — 모든 `--json` 출력은 `schema_version` 을 담는 frozen contract다. 깨는 변경은 `*.v2` major bump을 요구한다.
|
||||
- **versioning cascade** — `parser_version` / `chunker_version` / `embedding_version` / `prompt_template_version` / `index_version` 변경은 downstream record(청크·임베딩·캐시·eval)를 무효화한다.
|
||||
|
||||
- `~/.config/kebab/config.toml` — `kebab init` 가 XDG 경로에 생성. `[workspace]` (root, exclude — include 필드는 제거됨, 지원 형식은 자동 결정), `[storage]`, `[chunking]`, `[models.embedding]`, `[models.llm]`, `[image.ocr]`, `[image.caption]`, `[pdf.ocr]`, `[search]`, `[rag]`, `[ui]` 절.
|
||||
- `[models.embedding]` —
|
||||
- `model` (default `"multilingual-e5-large"`, fb-39b) — 다국어 sentence embedding 모델. 1024-dim. ONNX (~1.3 GB) 첫 실행 시 fastembed cache (`config.storage.model_dir/fastembed/`) 에 자동 다운로드. `"multilingual-e5-small"` (384 dim) 는 backwards-compat 으로 사용 가능 — TOML 에 명시.
|
||||
- `dimensions` (default `1024`) — 모델의 embedding 차원. config 와 LanceDB stored dim 불일치 시 검색 결과 0 건 (orphan table). 모델 변경 시 `kebab reset --vector-only && kebab ingest` 로 vector index 재구축 권장.
|
||||
- `[ui] theme = "dark" | "light"` 로 TUI 팔레트 선택 (default `"dark"`, 알 수 없는 값은 dark fallback).
|
||||
- `[search] stale_threshold_days = 30` (p9-fb-32) — search hit / RAG citation 의 `stale` 플래그 기준 (default 30 일, `0` 으로 비활성화). 옛 config 의 `workspace.include = [...]` 은 silently 무시 + 단발 deprecation warning (p9-fb-25).
|
||||
- `[ingest.code]` (p10-1A-1) — code ingest 의 skip 정책 + chunker 기본값.
|
||||
- `skip_generated_header = true` — 첫 ~512 byte 의 generated marker (`@generated` / `DO NOT EDIT` 등) 감지 시 skip.
|
||||
- `max_file_bytes = 262144` (256 KiB) / `max_file_lines = 5000` — 파일당 cap, 초과 시 skip.
|
||||
- `extra_skip_globs = []` — 사용자 추가 skip 패턴 (`.gitignore` 문법).
|
||||
- `.gitignore` honor: 자동 적용. `.kebabignore` 는 추가 layer. 우선순위: built-in safety net (`node_modules/` / `target/` / `__pycache__/` / `.venv/` / `venv/` / `env/`) > `.gitignore` > `.kebabignore`.
|
||||
- `[rag] prompt_template_version` (default `"rag-v3"`) — RAG system prompt version. `"rag-v1"` / `"rag-v2"` 은 legacy backwards-compat (명시 시 유지). v2 강화 규칙: (1) fact 인용 시 [#번호] 앞에 chunk 속 원문 큰따옴표 표기, (2) 학습 지식 동원 금지, (3) 근거 모호 시 "확실하지 않다" 명시. **v3 추가 규칙 (v0.20.2)**: 답변 언어 = 질문 언어 (query 가 영어면 영어로, 한국어면 한국어로). 근거 부족 refusal 문구도 언어중립화. **Known limitation**: gemma4:e4b 같은 소형 모델은 refusal 메시지의 언어가 query 언어와 불일치할 수 있음 — refusal 판정(marker 기반)은 정상, 표시 문구만 해당. v2 고정: `[rag] prompt_template_version = "rag-v2"`.
|
||||
- `--config <path>` flag — 임시 워크스페이스 / 격리 테스트 시 사용. CLI / TUI 모두 honor.
|
||||
- `KEBAB_*` env — 일부 키 override (`KEBAB_RAG_SCORE_GATE`, `KEBAB_EVAL_GOLDEN`, `KEBAB_COMMIT_HASH` 등).
|
||||
- XDG layout: `~/.config/kebab/`, `~/.local/share/kebab/`, `~/.cache/kebab/`, `~/.local/state/kebab/`.
|
||||
- `workspace.root` 경로 형식: 절대 (`/foo/bar`) / tilde (`~/KnowledgeBase`, default) / env (`${XDG_DATA_HOME}/kebab`) / 상대 (`./notes`, `notes`, `../shared/x`) 모두 가능. **상대 경로의 base 는 config.toml 자체가 위치한 디렉토리** — 사용자의 `cwd` 와 무관 (`--config /tmp/cfg.toml` + `root = "kb"` → `/tmp/kb`). p9-fb-05 정책.
|
||||
|
||||
config 예시는 [docs/SMOKE.md](docs/SMOKE.md) 의 `/tmp/kebab-smoke/config.toml` 블록 참조.
|
||||
|
||||
### `[pdf.ocr]` — scanned PDF OCR (v0.20.0+)
|
||||
|
||||
embedded text 가 없는 scanned PDF (책 스캔, 영수증, 카메라 page 등) 의 OCR 활성화. **default off (opt-in)** — OCR 한 page 당 ~45-100s (qwen2.5vl:3b on CPU) 의 cost 때문에 책 / 논문 archive 등 명시적 KB 에만 활성화.
|
||||
|
||||
```toml
|
||||
[pdf.ocr]
|
||||
enabled = false # opt-in: 책 / 논문 archive KB 에서 true
|
||||
always_on = false # true 시 vector PDF page 도 dual-block OCR (confidence boost)
|
||||
engine = "ollama-vision"
|
||||
model = "qwen2.5vl:3b" # PoC alnum 94.79% page1 / 81.56% 받침 (vs gemma4:e4b 의 27%)
|
||||
# endpoint = "http://localhost:11434" # 미명시 시 models.llm.endpoint fallback
|
||||
languages = ["eng", "kor"]
|
||||
max_pixels = 2048
|
||||
request_timeout_secs = 600
|
||||
valid_ratio_threshold = 0.5 # text-detect threshold — mojibake / scanned 판정 boundary
|
||||
min_char_count = 20
|
||||
lang_hint = "kor"
|
||||
```
|
||||
|
||||
env override: `KEBAB_PDF_OCR_*` 11 변수 (예: `KEBAB_PDF_OCR_ENABLED=true kebab ingest`).
|
||||
|
||||
**v0.20 upgrade after**: scanned PDF 가 v0.19 에 빈 block + warning 으로 indexed 된 경우 자동으로 OCR 재실행 안 됨 (parser_version `"pdf-text-v1"` 보존). 명시적 재처리: `kebab ingest --force-reingest`.
|
||||
|
||||
## 외부 AI 통합
|
||||
|
||||
`--json` 출력 + frozen wire schema v1 가 stable contract. 통합 옵션:
|
||||
|
||||
- **Claude Code skill** — repo 의 [`integrations/claude-code/`](integrations/claude-code/) 가 ship-ready skill. `cp -r integrations/claude-code/kebab ~/.claude/skills/` 한 번이면 새 Claude Code 세션부터 자동 trigger (내부 시스템 / 위키 lookup / 사내 runbook 질문). multi-turn 은 `kebab ask --session <id> --json` 으로 영속 — skill 이 conversation id 관리하면 외부 agent 도 `--repl` 없이 stateful 대화 가능 (p9-fb-18).
|
||||
- **Codex / 기타 agent host** — `--json` + frozen wire schema v1 가 stable contract. 동일 패턴으로 ~50줄 wrapper 작성 가능. `integrations/<host>/` 에 추가 PR 환영.
|
||||
- **MCP server** — stdio JSON-RPC 로 `kebab-app` facade 1:1 노출. `kebab mcp` 참조.
|
||||
- **HTTP wrapper** — `kebab serve --bind 127.0.0.1:7711` (P+, local-only 가치 신중).
|
||||
|
||||
## MCP 사용
|
||||
|
||||
`kebab mcp` 가 stdio MCP server. 8 tool: `search` / `bulk_search` (p9-fb-42 — N query 한 번에) / `ask` / `fetch` (p9-fb-35) / `schema` / `doctor` / `ingest_file` / `ingest_stdin`.
|
||||
|
||||
Claude Code 빠른 등록 (`~/.claude/mcp.json` 또는 host 동등 위치):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"kebab": {
|
||||
"command": "kebab",
|
||||
"args": ["mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
자세한 사용법 (Cursor / OpenAI Agents / Copilot CLI config, per-tool 입출력 예시, troubleshooting, multi-turn ask + session 관리, performance / security) — **[docs/mcp-usage.md](docs/mcp-usage.md)** 참조.
|
||||
crate-level 의존성 그래프 · 디렉토리 트리 · 확장자→chunker 전체 매핑 · 핵심 기술 결정은 [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md), 진척도는 [HANDOFF.md](HANDOFF.md).
|
||||
|
||||
## 비-목표
|
||||
|
||||
다중 사용자 SaaS / K8s / 원격 vector DB / enterprise RBAC / 실시간 협업 / 모든 파일 포맷의 완벽한 parsing / agent 임의 파일 수정 / multi-workspace / LLM-as-judge eval / CLIP 시각 embedding / `kebab://` protocol handler — frozen 설계 §11 / §0 참조.
|
||||
다중 사용자 SaaS / K8s / 원격 vector DB / enterprise RBAC / 실시간 협업 / agent 임의 파일 수정 / multi-workspace / LLM-as-judge eval / CLIP 시각 embedding — frozen 설계 §0 / §11 참조.
|
||||
|
||||
## 라이선스
|
||||
## 버전 / 라이선스 / 참고
|
||||
|
||||
`MIT OR Apache-2.0` (workspace `Cargo.toml` 의 `license` 필드).
|
||||
|
||||
## 참고
|
||||
|
||||
- 진척도: [HANDOFF.md](HANDOFF.md)
|
||||
- 아키텍처: [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)
|
||||
- Frozen 설계: [docs/superpowers/specs/2026-04-27-kebab-final-form-design.md](docs/superpowers/specs/2026-04-27-kebab-final-form-design.md)
|
||||
- Task 인덱스: [tasks/INDEX.md](tasks/INDEX.md)
|
||||
- 머지 후 hotfix 로그: [tasks/HOTFIXES.md](tasks/HOTFIXES.md)
|
||||
- Smoke 절차: [docs/SMOKE.md](docs/SMOKE.md)
|
||||
- **버전**: v0.21.0 (`kebab --version` 으로 확인).
|
||||
- **라이선스**: `MIT OR Apache-2.0`.
|
||||
- 진척도: [HANDOFF.md](HANDOFF.md) · 아키텍처: [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) · Frozen 설계: [docs/superpowers/specs/2026-04-27-kebab-final-form-design.md](docs/superpowers/specs/2026-04-27-kebab-final-form-design.md)
|
||||
- Task 인덱스: [tasks/INDEX.md](tasks/INDEX.md) · Hotfix 로그: [tasks/HOTFIXES.md](tasks/HOTFIXES.md) · Smoke 절차: [docs/SMOKE.md](docs/SMOKE.md) · MCP 사용: [docs/mcp-usage.md](docs/mcp-usage.md)
|
||||
|
||||
@@ -18,6 +18,8 @@ kebab-store-vector = { path = "../kebab-store-vector" }
|
||||
kebab-search = { path = "../kebab-search" }
|
||||
kebab-embed = { path = "../kebab-embed" }
|
||||
kebab-embed-local = { path = "../kebab-embed-local" }
|
||||
kebab-embed-candle = { path = "../kebab-embed-candle" }
|
||||
kebab-embed-ollama = { path = "../kebab-embed-ollama" }
|
||||
kebab-llm = { path = "../kebab-llm" }
|
||||
kebab-llm-local = { path = "../kebab-llm-local" }
|
||||
kebab-rag = { path = "../kebab-rag" }
|
||||
@@ -71,6 +73,11 @@ base64 = { workspace = true }
|
||||
rusqlite = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
kebab-config = { path = "../kebab-config" }
|
||||
# doc-side expansion (Phase 2) Task 4: ExpansionGenerator unit tests build
|
||||
# MockLanguageModel (gated behind kebab-llm's `mock` feature, default OFF in
|
||||
# [dependencies]). Enabling it here turns it on for the test build only.
|
||||
kebab-llm = { path = "../kebab-llm", features = ["mock"] }
|
||||
rusqlite = { workspace = true }
|
||||
filetime = "0.2"
|
||||
tempfile = { workspace = true }
|
||||
@@ -94,6 +101,8 @@ reqwest = { version = "0.12", default-features = false, features = ["blocki
|
||||
# disable path 없음; 이 feature 는 spec §6.3 명시를 honor 하는 role 만.
|
||||
default = ["fts_korean_morphological"]
|
||||
fts_korean_morphological = []
|
||||
# opt-in (macOS): candle embedder runs on the Apple Silicon GPU. See kebab-embed-candle.
|
||||
embed_metal = ["kebab-embed-candle/metal"]
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -43,7 +43,9 @@ use kebab_core::{
|
||||
Answer, DocumentStore, Embedder, ExtractContext, Extractor, IndexVersion, LanguageModel,
|
||||
MediaType, Retriever, SearchHit, SearchMode, SearchOpts, SearchQuery, VectorStore,
|
||||
};
|
||||
use kebab_embed_candle::CandleEmbedder;
|
||||
use kebab_embed_local::FastembedEmbedder;
|
||||
use kebab_embed_ollama::OllamaEmbedder;
|
||||
use kebab_llm_local::OllamaLanguageModel;
|
||||
use kebab_parse_code::{
|
||||
CAstExtractor, CppAstExtractor, GoAstExtractor, JavaAstExtractor, JavascriptAstExtractor,
|
||||
@@ -833,9 +835,31 @@ impl App {
|
||||
if let Some(e) = self.embedder.get() {
|
||||
return Ok(Some(e.clone()));
|
||||
}
|
||||
let emb: Arc<dyn Embedder + Send + Sync> = Arc::new(
|
||||
FastembedEmbedder::new(&self.config).context("kb-app: load FastembedEmbedder")?,
|
||||
);
|
||||
// Provider branch (Track 1 spec §3 + arctic-embedder spec). The
|
||||
// `embeddings_disabled()` check above already handled `"none"`; here we
|
||||
// route the live providers. `fastembed`/`onnx`/(empty) keep the default
|
||||
// onnxruntime path (vectors unchanged — `embedding_version` is
|
||||
// preserved); `candle` selects the pure-Rust NUMA-safe backend (e5 or
|
||||
// arctic via its model registry); `ollama` offloads to a remote
|
||||
// `/api/embed` daemon.
|
||||
let provider = self.config.models.embedding.provider.as_str();
|
||||
let emb: Arc<dyn Embedder + Send + Sync> = match provider {
|
||||
"fastembed" | "onnx" | "" => Arc::new(
|
||||
FastembedEmbedder::new(&self.config).context("kb-app: load FastembedEmbedder")?,
|
||||
),
|
||||
"candle" => Arc::new(
|
||||
CandleEmbedder::new(&self.config).context("kb-app: load CandleEmbedder")?,
|
||||
),
|
||||
"ollama" => Arc::new(
|
||||
OllamaEmbedder::new(&self.config).context("kb-app: load OllamaEmbedder")?,
|
||||
),
|
||||
other => {
|
||||
return Err(anyhow!(
|
||||
"kb-app: unknown embedding provider {other:?}; expected one of \
|
||||
`fastembed` (default), `candle`, `ollama`, or `none` (lexical-only)"
|
||||
));
|
||||
}
|
||||
};
|
||||
// `set` returns Err if another thread won the race; in that case
|
||||
// the loser still returns the (now-cached) winner via `get()`.
|
||||
let _ = self.embedder.set(emb.clone());
|
||||
|
||||
61
crates/kebab-app/src/derivation_payload.rs
Normal file
61
crates/kebab-app/src/derivation_payload.rs
Normal file
@@ -0,0 +1,61 @@
|
||||
//! Derivation-cache payload encoding helpers (design 2026-05-31 §3.3).
|
||||
//!
|
||||
//! - embedding: `dimensions × f32` little-endian bytes (1024×4 = 4096 B/chunk).
|
||||
//! - alias / korean_tokens: UTF-8 as-is (handled inline by the caller — no
|
||||
//! helper needed, `String::as_bytes` / `String::from_utf8`).
|
||||
|
||||
/// Encode an embedding vector as a little-endian `f32` byte string (§3.3).
|
||||
pub fn encode_embedding(vector: &[f32]) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(vector.len() * 4);
|
||||
for &v in vector {
|
||||
out.extend_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Decode a little-endian `f32` byte string back into a vector (§3.3).
|
||||
///
|
||||
/// Returns `None` if the payload length is not a multiple of 4 (corrupt
|
||||
/// entry) — the caller treats this as a cache miss and recomputes, so a bad
|
||||
/// payload never produces a wrong vector.
|
||||
pub fn decode_embedding(payload: &[u8]) -> Option<Vec<f32>> {
|
||||
if payload.len() % 4 != 0 {
|
||||
return None;
|
||||
}
|
||||
Some(
|
||||
payload
|
||||
.chunks_exact(4)
|
||||
.map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]]))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn roundtrips_vector() {
|
||||
let v = vec![0.0_f32, 1.5, -2.25, 3.125e10, f32::MIN, f32::MAX];
|
||||
let bytes = encode_embedding(&v);
|
||||
assert_eq!(bytes.len(), v.len() * 4);
|
||||
assert_eq!(decode_embedding(&bytes), Some(v));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_vector_roundtrips() {
|
||||
assert_eq!(encode_embedding(&[]), Vec::<u8>::new());
|
||||
assert_eq!(decode_embedding(&[]), Some(vec![]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn misaligned_payload_is_none() {
|
||||
assert_eq!(decode_embedding(&[1, 2, 3]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn little_endian_layout_is_fixed() {
|
||||
// 1.0_f32 == 0x3F800000, little-endian bytes [0x00,0x00,0x80,0x3F].
|
||||
assert_eq!(encode_embedding(&[1.0]), vec![0x00, 0x00, 0x80, 0x3F]);
|
||||
}
|
||||
}
|
||||
@@ -47,11 +47,19 @@ pub struct AggregateCounts {
|
||||
///
|
||||
/// ```text
|
||||
/// ScanStarted < ScanCompleted
|
||||
/// < (AssetStarted [< (PdfOcrStarted < PdfOcrFinished)*] < AssetFinished)*
|
||||
/// < ( AssetStarted
|
||||
/// [< (PdfOcrStarted < PdfOcrFinished)*]
|
||||
/// [< AssetChunked]
|
||||
/// [< AssetTimings]
|
||||
/// < AssetFinished )*
|
||||
/// < (Completed | Aborted)
|
||||
/// ```
|
||||
///
|
||||
/// `[]` = optional, per-PDF asset only (v0.20.0 sub-item 1).
|
||||
/// `[]` = optional. `PdfOcr*` is per-PDF asset only (v0.20.0 sub-item 1).
|
||||
/// `AssetChunked` / `AssetTimings` are the v0.24.0 asset-internal phase
|
||||
/// events: `AssetChunked` fires once right after chunking (markdown /
|
||||
/// image / PDF); `AssetTimings` reports per-phase wall-clock once
|
||||
/// (markdown only).
|
||||
///
|
||||
/// Embed-batch events (`embed_batch_started` / `embed_batch_finished`
|
||||
/// in §2.4a) are reserved for a future iteration and are not emitted
|
||||
@@ -82,6 +90,52 @@ pub enum IngestEvent {
|
||||
result: IngestItemKind,
|
||||
chunks: u32,
|
||||
},
|
||||
/// v0.24.0 (additive): emitted right after an asset is chunked, before
|
||||
/// expansion / embed / store. Surfaces "this document is N chunks"
|
||||
/// immediately so a single large document no longer looks frozen at
|
||||
/// `idx/total` while its per-chunk phases churn. `chunks` is the chunk
|
||||
/// count for asset `idx`.
|
||||
AssetChunked { idx: u32, total: u32, chunks: u32 },
|
||||
/// v0.26.1 (additive): emitted when an asset enters a *slow* internal
|
||||
/// phase, so the interactive progress bar can show **which** phase
|
||||
/// (and which model) is currently running instead of looking frozen.
|
||||
/// `phase` ∈ {`"ocr"`, `"caption"`, `"embed"`}; short phases
|
||||
/// (parse / chunk / store) are intentionally *not* emitted to avoid
|
||||
/// noise. `model` is the model performing the phase — the vision LLM
|
||||
/// id for `ocr` / `caption`, the embedder `model_id` for `embed`
|
||||
/// (`None` when the phase runs without a configured model, e.g. embed
|
||||
/// with no embedder wired). Emitted once per (asset, phase); no
|
||||
/// throttle needed (low frequency). Wire v1 consumers that predate
|
||||
/// this variant simply ignore the unknown `asset_phase` kind.
|
||||
AssetPhase {
|
||||
idx: u32,
|
||||
total: u32,
|
||||
phase: String,
|
||||
model: Option<String>,
|
||||
},
|
||||
/// v0.24.0 (additive): per-phase wall-clock (milliseconds) for asset
|
||||
/// `idx`, emitted once the asset's pipeline finishes. Lets a user see
|
||||
/// *where* the time went (parse / chunk / ocr / caption / embed /
|
||||
/// store) without parsing logs. The markdown path leaves `ocr_ms` /
|
||||
/// `caption_ms` at 0 (no image analysis); the image / PDF paths fill
|
||||
/// them so the slowest-asset summary attributes vision-model time
|
||||
/// correctly. `expansion_ms` is retained for wire compatibility but is
|
||||
/// always 0 since doc-side expansion was removed (HOTFIXES 2026-06-03).
|
||||
/// `ocr_ms` / `caption_ms` (v0.26.1) are additive with serde default 0
|
||||
/// so pre-v0.26.1 consumers deserialize cleanly.
|
||||
AssetTimings {
|
||||
idx: u32,
|
||||
total: u32,
|
||||
parse_ms: u64,
|
||||
chunk_ms: u64,
|
||||
expansion_ms: u64,
|
||||
embed_ms: u64,
|
||||
store_ms: u64,
|
||||
#[serde(default)]
|
||||
ocr_ms: u64,
|
||||
#[serde(default)]
|
||||
caption_ms: u64,
|
||||
},
|
||||
/// Run finished normally. `counts` is the final aggregate.
|
||||
Completed { counts: AggregateCounts },
|
||||
/// Run finished by user cancellation. `counts` is the partial
|
||||
@@ -199,6 +253,121 @@ mod tests {
|
||||
assert_eq!(v.get("media").and_then(|s| s.as_str()), Some("markdown"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn asset_chunked_serializes_with_discriminator() {
|
||||
// v0.24.0 additive variant — `kind` must be snake_case
|
||||
// `asset_chunked` so wire v1 consumers branch on it cleanly.
|
||||
let ev = IngestEvent::AssetChunked {
|
||||
idx: 3,
|
||||
total: 10,
|
||||
chunks: 142,
|
||||
};
|
||||
let v = serde_json::to_value(&ev).unwrap();
|
||||
assert_eq!(
|
||||
v.get("kind").and_then(|s| s.as_str()),
|
||||
Some("asset_chunked")
|
||||
);
|
||||
assert_eq!(v.get("idx").and_then(serde_json::Value::as_u64), Some(3));
|
||||
assert_eq!(
|
||||
v.get("chunks").and_then(serde_json::Value::as_u64),
|
||||
Some(142)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn asset_timings_serializes_all_phase_fields() {
|
||||
let ev = IngestEvent::AssetTimings {
|
||||
idx: 2,
|
||||
total: 7,
|
||||
parse_ms: 12,
|
||||
chunk_ms: 3,
|
||||
expansion_ms: 45_000,
|
||||
embed_ms: 800,
|
||||
store_ms: 20,
|
||||
ocr_ms: 1_200,
|
||||
caption_ms: 3_400,
|
||||
};
|
||||
let v = serde_json::to_value(&ev).unwrap();
|
||||
assert_eq!(
|
||||
v.get("kind").and_then(|s| s.as_str()),
|
||||
Some("asset_timings")
|
||||
);
|
||||
// All phase fields are present (plain u64, always serialized).
|
||||
for (field, want) in [
|
||||
("parse_ms", 12u64),
|
||||
("chunk_ms", 3),
|
||||
("expansion_ms", 45_000),
|
||||
("embed_ms", 800),
|
||||
("store_ms", 20),
|
||||
("ocr_ms", 1_200),
|
||||
("caption_ms", 3_400),
|
||||
] {
|
||||
assert_eq!(
|
||||
v.get(field).and_then(serde_json::Value::as_u64),
|
||||
Some(want),
|
||||
"field {field}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn asset_timings_ocr_caption_default_to_zero_for_legacy_wire() {
|
||||
// v0.26.1 additive: a pre-v0.26.1 wire payload omits ocr_ms /
|
||||
// caption_ms; serde `default` must fill 0 so old producers stay
|
||||
// compatible.
|
||||
let legacy = serde_json::json!({
|
||||
"kind": "asset_timings",
|
||||
"idx": 1, "total": 1,
|
||||
"parse_ms": 5, "chunk_ms": 2, "expansion_ms": 0,
|
||||
"embed_ms": 10, "store_ms": 3
|
||||
});
|
||||
let ev: IngestEvent = serde_json::from_value(legacy).unwrap();
|
||||
match ev {
|
||||
IngestEvent::AssetTimings {
|
||||
ocr_ms,
|
||||
caption_ms,
|
||||
embed_ms,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(ocr_ms, 0);
|
||||
assert_eq!(caption_ms, 0);
|
||||
assert_eq!(embed_ms, 10);
|
||||
}
|
||||
other => panic!("unexpected event: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn asset_phase_serializes_with_discriminator() {
|
||||
// v0.26.1 additive variant — `kind` must be snake_case
|
||||
// `asset_phase`, `phase` is the slow-phase label, `model` the
|
||||
// model id (nullable).
|
||||
let ev = IngestEvent::AssetPhase {
|
||||
idx: 4,
|
||||
total: 12,
|
||||
phase: "ocr".into(),
|
||||
model: Some("gemma4:e4b".into()),
|
||||
};
|
||||
let v = serde_json::to_value(&ev).unwrap();
|
||||
assert_eq!(v.get("kind").and_then(|s| s.as_str()), Some("asset_phase"));
|
||||
assert_eq!(v.get("idx").and_then(serde_json::Value::as_u64), Some(4));
|
||||
assert_eq!(v.get("phase").and_then(|s| s.as_str()), Some("ocr"));
|
||||
assert_eq!(v.get("model").and_then(|s| s.as_str()), Some("gemma4:e4b"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn asset_phase_model_none_serializes_as_null() {
|
||||
let ev = IngestEvent::AssetPhase {
|
||||
idx: 1,
|
||||
total: 1,
|
||||
phase: "embed".into(),
|
||||
model: None,
|
||||
};
|
||||
let v = serde_json::to_value(&ev).unwrap();
|
||||
assert_eq!(v.get("phase").and_then(|s| s.as_str()), Some("embed"));
|
||||
assert!(v.get("model").is_some_and(serde_json::Value::is_null));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ingest_event_completed_has_counts() {
|
||||
let ev = IngestEvent::Completed {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -108,6 +108,7 @@ const WIRE_SCHEMAS: &[&str] = &[
|
||||
"doc_summary.v1",
|
||||
"chunk_inspection.v1",
|
||||
"doctor.v1",
|
||||
"config_migration.v1",
|
||||
"ingest_report.v1",
|
||||
"ingest_progress.v1",
|
||||
"reset_report.v1",
|
||||
|
||||
@@ -52,7 +52,9 @@ fn rust_file_ingests_and_searches_as_code_citation() {
|
||||
"at least one chunk expected: {code_item:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
code_item.parser_version.as_ref().map(|p| p.0.as_str()),
|
||||
code_item.parser_version
|
||||
.as_ref()
|
||||
.map(|p| p.0.split('|').next().unwrap()),
|
||||
Some("code-rust-v1"),
|
||||
"parser_version must be code-rust-v1"
|
||||
);
|
||||
@@ -185,7 +187,9 @@ fn python_file_ingests_and_searches_as_code_citation() {
|
||||
.find(|i| i.doc_path.0.ends_with("metrics.py"))
|
||||
.expect("metrics.py item");
|
||||
assert_eq!(
|
||||
py_item.parser_version.as_ref().map(|p| p.0.as_str()),
|
||||
py_item.parser_version
|
||||
.as_ref()
|
||||
.map(|p| p.0.split('|').next().unwrap()),
|
||||
Some("code-python-v1"),
|
||||
"parser_version must be code-python-v1"
|
||||
);
|
||||
@@ -261,7 +265,9 @@ fn typescript_file_ingests_and_searches_as_code_citation() {
|
||||
.find(|i| i.doc_path.0.ends_with("Foo.ts"))
|
||||
.expect("Foo.ts item");
|
||||
assert_eq!(
|
||||
ts_item.parser_version.as_ref().map(|p| p.0.as_str()),
|
||||
ts_item.parser_version
|
||||
.as_ref()
|
||||
.map(|p| p.0.split('|').next().unwrap()),
|
||||
Some("code-ts-v1"),
|
||||
"parser_version must be code-ts-v1"
|
||||
);
|
||||
@@ -337,7 +343,9 @@ fn javascript_file_ingests_and_searches_as_code_citation() {
|
||||
.find(|i| i.doc_path.0.ends_with("Bar.js"))
|
||||
.expect("Bar.js item");
|
||||
assert_eq!(
|
||||
js_item.parser_version.as_ref().map(|p| p.0.as_str()),
|
||||
js_item.parser_version
|
||||
.as_ref()
|
||||
.map(|p| p.0.split('|').next().unwrap()),
|
||||
Some("code-js-v1"),
|
||||
"parser_version must be code-js-v1"
|
||||
);
|
||||
@@ -415,7 +423,9 @@ fn go_file_ingests_and_searches_as_code_citation() {
|
||||
.find(|i| i.doc_path.0.ends_with("ast.go"))
|
||||
.expect("ast.go item present");
|
||||
assert_eq!(
|
||||
go_item.parser_version.as_ref().map(|p| p.0.as_str()),
|
||||
go_item.parser_version
|
||||
.as_ref()
|
||||
.map(|p| p.0.split('|').next().unwrap()),
|
||||
Some("code-go-v1"),
|
||||
"parser_version must be code-go-v1"
|
||||
);
|
||||
@@ -486,7 +496,9 @@ fn java_file_ingests_and_searches_as_code_citation() {
|
||||
.find(|i| i.doc_path.0.ends_with("Foo.java"))
|
||||
.expect("Foo.java item present");
|
||||
assert_eq!(
|
||||
java_item.parser_version.as_ref().map(|p| p.0.as_str()),
|
||||
java_item.parser_version
|
||||
.as_ref()
|
||||
.map(|p| p.0.split('|').next().unwrap()),
|
||||
Some("code-java-v1"),
|
||||
"parser_version must be code-java-v1"
|
||||
);
|
||||
@@ -561,7 +573,9 @@ fn kotlin_file_ingests_and_searches_as_code_citation() {
|
||||
.find(|i| i.doc_path.0.ends_with("Foo.kt"))
|
||||
.expect("Foo.kt item present");
|
||||
assert_eq!(
|
||||
kt_item.parser_version.as_ref().map(|p| p.0.as_str()),
|
||||
kt_item.parser_version
|
||||
.as_ref()
|
||||
.map(|p| p.0.split('|').next().unwrap()),
|
||||
Some("code-kotlin-v1"),
|
||||
"parser_version must be code-kotlin-v1"
|
||||
);
|
||||
@@ -634,7 +648,9 @@ fn tier2_k8s_yaml_ingest_searchable() {
|
||||
.find(|i| i.doc_path.0.ends_with("deploy.yaml"))
|
||||
.expect("deploy.yaml item present");
|
||||
assert_eq!(
|
||||
yaml_item.parser_version.as_ref().map(|p| p.0.as_str()),
|
||||
yaml_item.parser_version
|
||||
.as_ref()
|
||||
.map(|p| p.0.split('|').next().unwrap()),
|
||||
Some("none-v1"),
|
||||
"parser_version must be none-v1"
|
||||
);
|
||||
@@ -717,7 +733,9 @@ fn tier2_dockerfile_ingest_searchable() {
|
||||
.find(|i| i.doc_path.0.ends_with("Dockerfile"))
|
||||
.expect("Dockerfile item present");
|
||||
assert_eq!(
|
||||
df_item.parser_version.as_ref().map(|p| p.0.as_str()),
|
||||
df_item.parser_version
|
||||
.as_ref()
|
||||
.map(|p| p.0.split('|').next().unwrap()),
|
||||
Some("none-v1"),
|
||||
"parser_version must be none-v1"
|
||||
);
|
||||
@@ -800,7 +818,9 @@ fn tier2_cargo_toml_ingest_searchable() {
|
||||
.find(|i| i.doc_path.0.ends_with("Cargo.toml"))
|
||||
.expect("Cargo.toml item present");
|
||||
assert_eq!(
|
||||
toml_item.parser_version.as_ref().map(|p| p.0.as_str()),
|
||||
toml_item.parser_version
|
||||
.as_ref()
|
||||
.map(|p| p.0.split('|').next().unwrap()),
|
||||
Some("none-v1"),
|
||||
"parser_version must be none-v1"
|
||||
);
|
||||
@@ -883,7 +903,9 @@ fn tier3_shell_ingest_searchable() {
|
||||
.find(|i| i.doc_path.0.ends_with("deploy.sh"))
|
||||
.expect("deploy.sh item present");
|
||||
assert_eq!(
|
||||
sh_item.parser_version.as_ref().map(|p| p.0.as_str()),
|
||||
sh_item.parser_version
|
||||
.as_ref()
|
||||
.map(|p| p.0.split('|').next().unwrap()),
|
||||
Some("none-v1"),
|
||||
"parser_version must be none-v1 for shell (Tier 3 direct)"
|
||||
);
|
||||
@@ -974,7 +996,9 @@ fn tier3_yaml_fallback_picks_up_non_k8s_yaml() {
|
||||
.find(|i| i.doc_path.0.ends_with("docker-compose.yml"))
|
||||
.expect("docker-compose.yml item present");
|
||||
assert_eq!(
|
||||
yaml_item.parser_version.as_ref().map(|p| p.0.as_str()),
|
||||
yaml_item.parser_version
|
||||
.as_ref()
|
||||
.map(|p| p.0.split('|').next().unwrap()),
|
||||
Some("none-v1"),
|
||||
"parser_version must be none-v1 after Tier 3 fallback"
|
||||
);
|
||||
@@ -1144,7 +1168,9 @@ fn tier1_c_ingest_searchable() {
|
||||
.find(|i| i.doc_path.0.ends_with("parser.c"))
|
||||
.expect("parser.c item present");
|
||||
assert_eq!(
|
||||
c_item.parser_version.as_ref().map(|p| p.0.as_str()),
|
||||
c_item.parser_version
|
||||
.as_ref()
|
||||
.map(|p| p.0.split('|').next().unwrap()),
|
||||
Some("code-c-v2"),
|
||||
"parser_version must be code-c-v2 (v0.17.0 PR-B: typedef-wrapped struct/enum/union 이 typedef alias unit 으로 방출)"
|
||||
);
|
||||
@@ -1228,7 +1254,9 @@ fn tier1_cpp_ingest_searchable() {
|
||||
.find(|i| i.doc_path.0.ends_with("chunker.cpp"))
|
||||
.expect("chunker.cpp item present");
|
||||
assert_eq!(
|
||||
cpp_item.parser_version.as_ref().map(|p| p.0.as_str()),
|
||||
cpp_item.parser_version
|
||||
.as_ref()
|
||||
.map(|p| p.0.split('|').next().unwrap()),
|
||||
Some("code-cpp-v1"),
|
||||
"parser_version must be code-cpp-v1"
|
||||
);
|
||||
|
||||
@@ -39,6 +39,11 @@ impl OcrEngine for MockOcrEngine {
|
||||
"mock-v1".to_string()
|
||||
}
|
||||
|
||||
#[allow(clippy::unnecessary_literal_bound)]
|
||||
fn model(&self) -> &str {
|
||||
"mock-model"
|
||||
}
|
||||
|
||||
fn recognize(&self, _img: &[u8], _hint: Option<&Lang>) -> Result<OcrText> {
|
||||
if self.fail {
|
||||
anyhow::bail!("mock failure");
|
||||
|
||||
148
crates/kebab-app/tests/config_invalidation.rs
Normal file
148
crates/kebab-app/tests/config_invalidation.rs
Normal file
@@ -0,0 +1,148 @@
|
||||
//! v0.26.2: ingest-config invalidation — changing a setting that affects
|
||||
//! ingest output auto-re-indexes the affected assets on the next ingest
|
||||
//! (no `--force-reingest`), while changing an unrelated setting does not.
|
||||
//!
|
||||
//! These end-to-end tests exercise the model-free signals (chunking +
|
||||
//! `[ingest.code]` options vs `search` settings). The exhaustive per-setting
|
||||
//! mapping (image OCR / caption, pdf.ocr, code options, search/rag/ui
|
||||
//! invariance) is unit-tested in
|
||||
//! `kebab-app/src/lib.rs::ingest_config_signature_tests` — those toggles
|
||||
//! (OCR/caption) require a live vision endpoint to ingest, so the wiring is
|
||||
//! verified here via the signature-driven chunking path that shares the same
|
||||
//! `effective_parser_version` plumbing.
|
||||
|
||||
mod common;
|
||||
|
||||
use common::TestEnv;
|
||||
|
||||
use kebab_app::{IngestOpts, ingest_with_config, ingest_with_config_opts};
|
||||
use kebab_core::IngestItemKind;
|
||||
|
||||
/// Seed a workspace with a markdown + a rust file so both the markdown and
|
||||
/// the code ingest paths are exercised. Returns the first-ingest report.
|
||||
fn seed_and_first_ingest(env: &TestEnv) -> kebab_core::IngestReport {
|
||||
std::fs::write(
|
||||
env.workspace_root.join("demo.rs"),
|
||||
"/// adds two integers\npub fn add(a: i32, b: i32) -> i32 {\n a + b\n}\n",
|
||||
)
|
||||
.unwrap();
|
||||
let first = ingest_with_config(env.config.clone(), env.scope(), false).expect("first ingest");
|
||||
assert_eq!(first.errors, 0, "first ingest must not error: {first:?}");
|
||||
assert!(first.new >= 1, "first ingest creates docs: {first:?}");
|
||||
assert_eq!(first.unchanged, 0, "first ingest has no unchanged: {first:?}");
|
||||
first
|
||||
}
|
||||
|
||||
fn reingest(env: &TestEnv) -> kebab_core::IngestReport {
|
||||
ingest_with_config_opts(env.config.clone(), env.scope(), false, IngestOpts::default())
|
||||
.expect("re-ingest")
|
||||
}
|
||||
|
||||
/// Re-running with the identical config skips every asset (no spurious
|
||||
/// re-index). Regression guard for over-invalidation.
|
||||
#[test]
|
||||
fn identical_config_skips_all_assets() {
|
||||
let env = TestEnv::lexical_only();
|
||||
let first = seed_and_first_ingest(&env);
|
||||
let scanned = first.scanned;
|
||||
|
||||
let second = reingest(&env);
|
||||
assert_eq!(second.scanned, scanned);
|
||||
assert_eq!(second.new, 0, "no new docs: {second:?}");
|
||||
assert_eq!(second.updated, 0, "nothing re-indexed: {second:?}");
|
||||
assert_eq!(second.unchanged, scanned, "every doc Unchanged: {second:?}");
|
||||
assert_eq!(second.errors, 0);
|
||||
}
|
||||
|
||||
/// Changing a common chunking parameter re-indexes EVERY media type
|
||||
/// (markdown + code here) without `--force-reingest`.
|
||||
#[test]
|
||||
fn chunking_change_reindexes_all_types() {
|
||||
let mut env = TestEnv::lexical_only();
|
||||
let first = seed_and_first_ingest(&env);
|
||||
let scanned = first.scanned;
|
||||
|
||||
// Bump target_tokens — folds into every type's signature.
|
||||
env.config.chunking.target_tokens += 100;
|
||||
|
||||
let second = reingest(&env);
|
||||
assert_eq!(second.scanned, scanned);
|
||||
assert_eq!(second.new, 0, "no new docs: {second:?}");
|
||||
assert_eq!(
|
||||
second.unchanged, 0,
|
||||
"chunking change must re-index all: {second:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
second.updated, scanned,
|
||||
"every doc re-indexed as Updated: {second:?}"
|
||||
);
|
||||
assert_eq!(second.errors, 0);
|
||||
}
|
||||
|
||||
/// Changing an `[ingest.code]` option re-indexes only the code asset; the
|
||||
/// markdown assets stay Unchanged.
|
||||
#[test]
|
||||
fn code_option_change_reindexes_code_only() {
|
||||
let mut env = TestEnv::lexical_only();
|
||||
let first = seed_and_first_ingest(&env);
|
||||
let scanned = first.scanned;
|
||||
|
||||
// Raise max_file_lines (keeps the tiny demo.rs in-scope; only the code
|
||||
// signature changes).
|
||||
env.config.ingest.code.max_file_lines += 1000;
|
||||
|
||||
let second = reingest(&env);
|
||||
assert_eq!(second.scanned, scanned);
|
||||
assert_eq!(second.new, 0, "no new docs: {second:?}");
|
||||
assert_eq!(second.errors, 0);
|
||||
assert_eq!(
|
||||
second.updated, 1,
|
||||
"exactly the code asset re-indexed: {second:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
second.unchanged,
|
||||
scanned - 1,
|
||||
"all markdown assets stay Unchanged: {second:?}"
|
||||
);
|
||||
|
||||
let items = second.items.as_ref().expect("items present");
|
||||
let code = items
|
||||
.iter()
|
||||
.find(|i| i.doc_path.0.ends_with("demo.rs"))
|
||||
.expect("demo.rs item");
|
||||
assert_eq!(
|
||||
code.kind,
|
||||
IngestItemKind::Updated,
|
||||
"demo.rs must be re-indexed: {code:?}"
|
||||
);
|
||||
for i in items.iter().filter(|i| i.doc_path.0.ends_with(".md")) {
|
||||
assert_eq!(
|
||||
i.kind,
|
||||
IngestItemKind::Unchanged,
|
||||
"markdown must be Unchanged: {i:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Regression guard: changing a non-ingest setting (`search.default_k`) does
|
||||
/// NOT re-index anything.
|
||||
#[test]
|
||||
fn search_setting_change_reindexes_nothing() {
|
||||
let mut env = TestEnv::lexical_only();
|
||||
let first = seed_and_first_ingest(&env);
|
||||
let scanned = first.scanned;
|
||||
|
||||
env.config.search.default_k += 5;
|
||||
env.config.search.snippet_chars += 50;
|
||||
env.config.rag.score_gate = 0.5;
|
||||
|
||||
let second = reingest(&env);
|
||||
assert_eq!(second.scanned, scanned);
|
||||
assert_eq!(
|
||||
second.unchanged, scanned,
|
||||
"search/rag changes must not re-index: {second:?}"
|
||||
);
|
||||
assert_eq!(second.updated, 0, "nothing re-indexed: {second:?}");
|
||||
assert_eq!(second.new, 0);
|
||||
assert_eq!(second.errors, 0);
|
||||
}
|
||||
85
crates/kebab-app/tests/config_migrate.rs
Normal file
85
crates/kebab-app/tests/config_migrate.rs
Normal file
@@ -0,0 +1,85 @@
|
||||
use std::fs;
|
||||
|
||||
#[test]
|
||||
fn migrate_writes_backup_and_atomic_with_dry_run_noop() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let cfg = dir.path().join("config.toml");
|
||||
fs::write(
|
||||
&cfg,
|
||||
"schema_version = 1\n\n[workspace]\nroot = \"/n\"\ninclude = [\"*.md\"]\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// dry-run: 파일·백업 미변경.
|
||||
let report = kebab_app::config_migrate_with_config_path(Some(&cfg), true).unwrap();
|
||||
assert!(report.changed);
|
||||
assert!(report.dry_run);
|
||||
assert!(report.backup_path.is_none());
|
||||
assert!(!dir.path().join("config.toml.bak").exists());
|
||||
assert!(
|
||||
fs::read_to_string(&cfg).unwrap().contains("include"),
|
||||
"dry-run modified file"
|
||||
);
|
||||
|
||||
// 실제 적용: 백업 생성 + 파일 갱신.
|
||||
let report = kebab_app::config_migrate_with_config_path(Some(&cfg), false).unwrap();
|
||||
assert!(report.changed);
|
||||
assert!(!report.dry_run);
|
||||
assert!(report.backup_path.is_some());
|
||||
assert!(dir.path().join("config.toml.bak").exists());
|
||||
let new = fs::read_to_string(&cfg).unwrap();
|
||||
assert!(!new.contains("include"));
|
||||
assert!(new.contains("[ingest.code]"));
|
||||
|
||||
// 멱등: 재실행 changed=false.
|
||||
let report = kebab_app::config_migrate_with_config_path(Some(&cfg), false).unwrap();
|
||||
assert!(!report.changed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrate_missing_file_errors() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let cfg = dir.path().join("nope.toml");
|
||||
assert!(kebab_app::config_migrate_with_config_path(Some(&cfg), false).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn annotated_default_serialization_contains_section_comments() {
|
||||
let doc = kebab_config::migrate::annotated_default_document();
|
||||
let text = doc.to_string();
|
||||
assert!(
|
||||
text.contains("code ingest skip 정책"),
|
||||
"section comment missing:\n{text}"
|
||||
);
|
||||
assert!(text.contains("[ingest.code]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doctor_flags_outdated_config() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let cfg = dir.path().join("config.toml");
|
||||
fs::write(
|
||||
&cfg,
|
||||
"schema_version = 1\n\n[workspace]\nroot = \"/n\"\ninclude=[\"*.md\"]\n",
|
||||
)
|
||||
.unwrap();
|
||||
let report = kebab_app::doctor_with_config_path(Some(&cfg)).unwrap();
|
||||
let check = report
|
||||
.checks
|
||||
.iter()
|
||||
.find(|c| c.name == "config_migration")
|
||||
.unwrap();
|
||||
assert!(!check.ok, "outdated config should fail check");
|
||||
assert!(check.hint.as_deref().unwrap().contains("config migrate"));
|
||||
assert!(!report.ok, "overall doctor should be false");
|
||||
|
||||
// migrate 후엔 통과.
|
||||
kebab_app::config_migrate_with_config_path(Some(&cfg), false).unwrap();
|
||||
let report = kebab_app::doctor_with_config_path(Some(&cfg)).unwrap();
|
||||
let check = report
|
||||
.checks
|
||||
.iter()
|
||||
.find(|c| c.name == "config_migration")
|
||||
.unwrap();
|
||||
assert!(check.ok, "after migrate should pass");
|
||||
}
|
||||
@@ -69,40 +69,74 @@ fn progress_event_sequence_matches_design_section_2_4a() {
|
||||
other => panic!("expected Completed last, got {other:?}"),
|
||||
}
|
||||
|
||||
// Middle: 3 AssetStarted/AssetFinished pairs in monotonic idx order.
|
||||
let asset_events: Vec<&IngestEvent> = events[2..events.len() - 1].iter().collect();
|
||||
assert_eq!(
|
||||
asset_events.len(),
|
||||
6,
|
||||
"expected 3 (Started + Finished) pairs, got {asset_events:?}"
|
||||
);
|
||||
for (chunk_idx, pair) in asset_events.chunks(2).enumerate() {
|
||||
let expected_idx = chunk_idx as u32 + 1;
|
||||
match (pair[0], pair[1]) {
|
||||
(
|
||||
IngestEvent::AssetStarted {
|
||||
idx: si,
|
||||
total: st,
|
||||
media,
|
||||
..
|
||||
},
|
||||
IngestEvent::AssetFinished {
|
||||
idx: fi,
|
||||
total: ft,
|
||||
result,
|
||||
chunks,
|
||||
},
|
||||
) => {
|
||||
assert_eq!(*si, expected_idx, "Started idx mismatch: {pair:?}");
|
||||
assert_eq!(*fi, expected_idx, "Finished idx mismatch: {pair:?}");
|
||||
assert_eq!(*st, 3, "Started total mismatch");
|
||||
assert_eq!(*ft, 3, "Finished total mismatch");
|
||||
assert_eq!(media, "markdown", "fixture is markdown only");
|
||||
assert_eq!(*result, IngestItemKind::New, "first ingest → New");
|
||||
assert!(*chunks >= 1, "chunks: {pair:?}");
|
||||
// Middle (v0.24.0 ordering invariant §2.4a): per asset the stream is
|
||||
// AssetStarted < AssetChunked < [ExpansionProgress*] < AssetTimings
|
||||
// < AssetFinished
|
||||
// Expansion is disabled in the lexical fixture, so no ExpansionProgress
|
||||
// frames appear here — but AssetChunked + AssetTimings are emitted for
|
||||
// every markdown asset.
|
||||
let middle = &events[2..events.len() - 1];
|
||||
|
||||
// 3 AssetStarted events, monotonic idx 1..=3, all markdown, total = 3.
|
||||
let started: Vec<u32> = middle
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
IngestEvent::AssetStarted {
|
||||
idx, total, media, ..
|
||||
} => {
|
||||
assert_eq!(*total, 3, "Started total mismatch: {e:?}");
|
||||
assert_eq!(media, "markdown", "fixture is markdown only: {e:?}");
|
||||
Some(*idx)
|
||||
}
|
||||
other => panic!("expected Started+Finished pair, got {other:?}"),
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(started, vec![1, 2, 3], "AssetStarted idx order: {middle:?}");
|
||||
|
||||
// 3 AssetFinished events, monotonic idx 1..=3, each New with ≥1 chunk.
|
||||
let finished: Vec<u32> = middle
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
IngestEvent::AssetFinished {
|
||||
idx,
|
||||
total,
|
||||
result,
|
||||
chunks,
|
||||
} => {
|
||||
assert_eq!(*total, 3, "Finished total mismatch: {e:?}");
|
||||
assert_eq!(*result, IngestItemKind::New, "first ingest → New: {e:?}");
|
||||
assert!(*chunks >= 1, "chunks: {e:?}");
|
||||
Some(*idx)
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(finished, vec![1, 2, 3], "AssetFinished idx order: {middle:?}");
|
||||
|
||||
// v0.24.0 additive events: exactly one AssetChunked + one AssetTimings
|
||||
// per asset, each strictly bracketed by that asset's Started / Finished.
|
||||
for target in 1u32..=3 {
|
||||
let started_at = middle
|
||||
.iter()
|
||||
.position(|e| matches!(e, IngestEvent::AssetStarted { idx, .. } if *idx == target))
|
||||
.unwrap_or_else(|| panic!("missing AssetStarted for idx {target}: {middle:?}"));
|
||||
let finished_at = middle
|
||||
.iter()
|
||||
.position(|e| matches!(e, IngestEvent::AssetFinished { idx, .. } if *idx == target))
|
||||
.unwrap_or_else(|| panic!("missing AssetFinished for idx {target}: {middle:?}"));
|
||||
let chunked_at = middle
|
||||
.iter()
|
||||
.position(|e| matches!(e, IngestEvent::AssetChunked { idx, chunks, .. } if *idx == target && *chunks >= 1))
|
||||
.unwrap_or_else(|| panic!("missing AssetChunked for idx {target}: {middle:?}"));
|
||||
let timings_at = middle
|
||||
.iter()
|
||||
.position(|e| matches!(e, IngestEvent::AssetTimings { idx, .. } if *idx == target))
|
||||
.unwrap_or_else(|| panic!("missing AssetTimings for idx {target}: {middle:?}"));
|
||||
assert!(
|
||||
started_at < chunked_at && chunked_at < timings_at && timings_at < finished_at,
|
||||
"idx {target} ordering: started={started_at} chunked={chunked_at} \
|
||||
timings={timings_at} finished={finished_at}: {middle:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -162,7 +162,9 @@ fn ingest_3_page_pdf_produces_one_doc_and_per_page_chunks() {
|
||||
"one chunk per non-empty page"
|
||||
);
|
||||
assert_eq!(
|
||||
pdf_item.parser_version.as_ref().map(|p| p.0.as_str()),
|
||||
pdf_item.parser_version
|
||||
.as_ref()
|
||||
.map(|p| p.0.split('|').next().unwrap()),
|
||||
Some("pdf-text-v1")
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -477,7 +479,10 @@ fn inspect_doc_surfaces_page_spans() {
|
||||
.find(|i| i.doc_path.0.ends_with("inspect.pdf"))
|
||||
.unwrap();
|
||||
let doc = kebab_app::inspect_doc_with_config(cfg, pdf_item.doc_id.as_ref().unwrap()).unwrap();
|
||||
assert_eq!(doc.parser_version.0, "pdf-text-v1");
|
||||
// v0.26.2: stored parser_version is now `pdf-text-v1|<ingest-config-sig>`
|
||||
// (the signature folds chunking / pdf.ocr settings for skip detection).
|
||||
// Assert the base identity by taking the prefix before the first '|'.
|
||||
assert_eq!(doc.parser_version.0.split('|').next().unwrap(), "pdf-text-v1");
|
||||
assert_eq!(doc.blocks.len(), 3);
|
||||
for block in &doc.blocks {
|
||||
match block {
|
||||
|
||||
@@ -109,10 +109,12 @@ fn first_ingest_bumps_corpus_revision() {
|
||||
let env = TestEnv::lexical_only();
|
||||
let store_before = kebab_store_sqlite::SqliteStore::open(&env.config).unwrap();
|
||||
store_before.run_migrations().unwrap();
|
||||
// V004 seeds 0; V009 migration bumps to 1 to invalidate any pre-V009
|
||||
// LRU cache (spec §5.2). Baseline before ingest = post-migration value.
|
||||
// V004 seeds 0; V009 + V010 + V011 migrations each bump by 1 to
|
||||
// invalidate stale LRU caches (spec §5.2). Baseline before ingest = 3.
|
||||
// (V012 derivation_cache + V013 drop-chunk-aliases are structural/additive
|
||||
// — neither bumps corpus_revision.)
|
||||
let baseline = store_before.corpus_revision();
|
||||
assert_eq!(baseline, 1, "fresh store post-V009 baseline = 1");
|
||||
assert_eq!(baseline, 3, "fresh store post-V011 baseline = 3");
|
||||
|
||||
let report = kebab_app::ingest_with_config(env.config.clone(), env.scope(), true).unwrap();
|
||||
assert!(
|
||||
|
||||
@@ -51,5 +51,10 @@ tempfile = { workspace = true }
|
||||
rusqlite = { workspace = true }
|
||||
time = { workspace = true }
|
||||
|
||||
[features]
|
||||
# opt-in (macOS): build the `kebab` binary with candle on the Apple Silicon GPU.
|
||||
# cargo build --release --features embed_metal
|
||||
embed_metal = ["kebab-app/embed_metal"]
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -60,6 +60,12 @@ enum Cmd {
|
||||
force: bool,
|
||||
},
|
||||
|
||||
/// config.toml 관리 (스키마 마이그레이션 등).
|
||||
Config {
|
||||
#[command(subcommand)]
|
||||
what: ConfigWhat,
|
||||
},
|
||||
|
||||
/// Scan the workspace and ingest new/updated documents.
|
||||
Ingest {
|
||||
/// Workspace root override.
|
||||
@@ -346,6 +352,16 @@ enum Cmd {
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
enum ConfigWhat {
|
||||
/// 기존 config.toml 을 새 스키마로 마이그레이션(빠진 섹션 추가 + 멱등 + .bak 백업).
|
||||
Migrate {
|
||||
/// 변경만 출력하고 파일은 수정하지 않는다.
|
||||
#[arg(long)]
|
||||
dry_run: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
enum ListWhat {
|
||||
/// List documents currently indexed.
|
||||
@@ -422,6 +438,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 {
|
||||
@@ -608,6 +632,24 @@ fn run(cli: &Cli) -> anyhow::Result<()> {
|
||||
.map(|v| v.eq_ignore_ascii_case("plain"))
|
||||
.unwrap_or(false);
|
||||
let mode = progress::ProgressMode::from_flags(cli.json, cli.quiet, plain_env);
|
||||
|
||||
// Surface the active embedding backend/device on the terminal so the
|
||||
// user sees it without grepping kb.log (the per-device tracing line
|
||||
// only lands in the log file at --verbose). Suppressed under
|
||||
// --json/--quiet. The Metal note reflects the build (`embed_metal`);
|
||||
// the confirmed runtime device is in kb.log (`candle device = ...`).
|
||||
if !cli.json && !cli.quiet {
|
||||
let backend = match cfg.models.embedding.provider.as_str() {
|
||||
"candle" if cfg!(feature = "embed_metal") => "candle (Metal/GPU 빌드)",
|
||||
"candle" => "candle (CPU, 순수 Rust)",
|
||||
"fastembed" | "onnx" | "" => "fastembed (onnxruntime)",
|
||||
"none" => "비활성 (lexical-only)",
|
||||
other => other,
|
||||
};
|
||||
eprintln!("임베딩 백엔드: {backend} · 모델 {} ({}-dim)",
|
||||
cfg.models.embedding.model, cfg.models.embedding.dimensions);
|
||||
}
|
||||
|
||||
let (tx, rx) = std::sync::mpsc::channel::<kebab_app::IngestEvent>();
|
||||
let display_handle =
|
||||
std::thread::spawn(move || progress::ProgressDisplay::new(mode).run(rx));
|
||||
@@ -1302,6 +1344,42 @@ fn run(cli: &Cli) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Cmd::Config { what } => match what {
|
||||
ConfigWhat::Migrate { dry_run } => {
|
||||
let report =
|
||||
kebab_app::config_migrate_with_config_path(cli.config.as_deref(), *dry_run)?;
|
||||
if cli.json {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string(&wire::wire_config_migration(&report))?
|
||||
);
|
||||
} else if !report.changed {
|
||||
println!(
|
||||
"config 이미 최신입니다 (schema v{}).",
|
||||
report.to_schema_version
|
||||
);
|
||||
} else {
|
||||
let verb = if report.dry_run { "변경 예정" } else { "적용됨" };
|
||||
println!(
|
||||
"config 마이그레이션 {verb}: v{} → v{} ({} changes)",
|
||||
report.from_schema_version,
|
||||
report.to_schema_version,
|
||||
report.changes.len()
|
||||
);
|
||||
for c in &report.changes {
|
||||
println!(" - [{:?}] {} — {}", c.kind, c.path, c.detail);
|
||||
}
|
||||
if let Some(bak) = &report.backup_path {
|
||||
println!("백업: {bak}");
|
||||
}
|
||||
if report.dry_run {
|
||||
println!("(--dry-run: 파일 미수정. 적용하려면 --dry-run 없이 재실행)");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
},
|
||||
|
||||
Cmd::Doctor => {
|
||||
let report = kebab_app::doctor_with_config_path(cli.config.as_deref())?;
|
||||
if cli.json {
|
||||
@@ -1392,6 +1470,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,
|
||||
|
||||
@@ -19,16 +19,23 @@
|
||||
//! `Sender` end is dropped (i.e. when `ingest_with_config_progress`
|
||||
//! returns).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::io::{IsTerminal, Write};
|
||||
use std::sync::mpsc::Receiver;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Instant;
|
||||
|
||||
use indicatif::{ProgressBar, ProgressDrawTarget, ProgressStyle};
|
||||
use indicatif::{ProgressBar, ProgressDrawTarget, ProgressState, ProgressStyle};
|
||||
use kebab_app::IngestEvent;
|
||||
use time::OffsetDateTime;
|
||||
use time::format_description::well_known::Rfc3339;
|
||||
|
||||
use crate::wire;
|
||||
|
||||
/// v0.26.1: number of slowest assets surfaced in the end-of-run summary.
|
||||
/// Constant for now (spec defers the config knob).
|
||||
const SLOWEST_TOP_N: usize = 5;
|
||||
|
||||
/// Rendering mode for `ProgressDisplay`. The mode is fixed at
|
||||
/// construction — each `kebab ingest` invocation is a single mode
|
||||
/// (chosen from `--json` plus `IsTerminal` detection).
|
||||
@@ -65,11 +72,33 @@ impl ProgressMode {
|
||||
pub struct ProgressDisplay {
|
||||
mode: ProgressMode,
|
||||
bar: Option<ProgressBar>,
|
||||
/// v0.26.1 heartbeat: start `Instant` of the asset currently in
|
||||
/// flight, shared with the bar's steady-tick custom template key so
|
||||
/// the `(Ns)` elapsed counter advances *between* events (the drain
|
||||
/// loop blocks on `recv()`, so without the ticker the counter would
|
||||
/// freeze). `None` while scanning / between assets / after completion.
|
||||
asset_start: Arc<Mutex<Option<Instant>>>,
|
||||
/// v0.26.1: workspace path of the asset currently in flight — set on
|
||||
/// `AssetStarted`, reused by `AssetPhase` to render `{path} · {phase}…`.
|
||||
current_path: Option<String>,
|
||||
/// v0.26.1 slowest summary: idx → path, captured from `AssetStarted`
|
||||
/// so `AssetTimings` (which only carries `idx`) can name the asset.
|
||||
asset_paths: HashMap<u32, String>,
|
||||
/// v0.26.1 slowest summary: (path, total_ms) per asset that reported
|
||||
/// `AssetTimings`. Sorted + truncated to top-N on `Completed`.
|
||||
timings: Vec<(String, u64)>,
|
||||
}
|
||||
|
||||
impl ProgressDisplay {
|
||||
pub fn new(mode: ProgressMode) -> Self {
|
||||
Self { mode, bar: None }
|
||||
Self {
|
||||
mode,
|
||||
bar: None,
|
||||
asset_start: Arc::new(Mutex::new(None)),
|
||||
current_path: None,
|
||||
asset_paths: HashMap::new(),
|
||||
timings: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Block until `rx` returns `Err` (sender dropped). Renders one
|
||||
@@ -120,15 +149,43 @@ impl ProgressDisplay {
|
||||
}
|
||||
IngestEvent::ScanCompleted { total } => {
|
||||
if let Some(bar) = self.bar.as_mut() {
|
||||
bar.disable_steady_tick();
|
||||
bar.set_length(u64::from(*total));
|
||||
bar.set_position(0);
|
||||
// v0.26.1: a custom `{asset_elapsed}` key reads the shared
|
||||
// per-asset start `Instant` and appends ` (Ns)`. Combined
|
||||
// with the steady tick below, the elapsed counter advances
|
||||
// even while the drain loop is blocked on `recv()` waiting
|
||||
// for the next (possibly very slow) phase event.
|
||||
let asset_start = Arc::clone(&self.asset_start);
|
||||
bar.set_style(
|
||||
ProgressStyle::with_template("ingest [{bar:30}] {pos}/{len} {wide_msg}")
|
||||
.unwrap()
|
||||
.progress_chars("=> "),
|
||||
ProgressStyle::with_template(
|
||||
"ingest [{bar:30}] {pos}/{len} {wide_msg}{asset_elapsed}",
|
||||
)
|
||||
.unwrap()
|
||||
.with_key(
|
||||
"asset_elapsed",
|
||||
move |_: &ProgressState, w: &mut dyn std::fmt::Write| {
|
||||
if let Ok(guard) = asset_start.lock()
|
||||
&& let Some(started) = *guard
|
||||
{
|
||||
let secs = started.elapsed().as_secs();
|
||||
// Only show once the asset has been running
|
||||
// a moment — avoids `(0s)` flicker on fast
|
||||
// assets.
|
||||
if secs >= 1 {
|
||||
let _ = write!(w, " ({secs}s)");
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
.progress_chars("=> "),
|
||||
);
|
||||
bar.set_message("");
|
||||
if tty && !quiet {
|
||||
bar.enable_steady_tick(std::time::Duration::from_secs(1));
|
||||
} else {
|
||||
bar.disable_steady_tick();
|
||||
}
|
||||
}
|
||||
if !tty && !quiet {
|
||||
let mut err = std::io::stderr().lock();
|
||||
@@ -141,11 +198,22 @@ impl ProgressDisplay {
|
||||
path,
|
||||
media,
|
||||
} => {
|
||||
// v0.26.1: remember the path so AssetPhase can render it and
|
||||
// the slowest summary (keyed by idx in AssetTimings) can name
|
||||
// the asset.
|
||||
self.current_path = Some(path.clone());
|
||||
self.asset_paths.insert(*idx, path.clone());
|
||||
// v0.26.1: (re)start the per-asset heartbeat clock.
|
||||
if let Ok(mut guard) = self.asset_start.lock() {
|
||||
*guard = Some(Instant::now());
|
||||
}
|
||||
if let Some(bar) = self.bar.as_ref() {
|
||||
// One draw per file: position only. set_message() would
|
||||
// trigger a second independent draw and pollute TTY scrollback.
|
||||
// Filename is visible in the non-TTY plain-line path below.
|
||||
bar.set_position(u64::from(idx.saturating_sub(1)));
|
||||
// v0.26.1: show the current filename on the bar (TTY).
|
||||
// Previously position-only — the interactive user couldn't
|
||||
// tell which file was in flight. The steady tick redraws
|
||||
// in place, so this no longer pollutes scrollback.
|
||||
bar.set_message(abbreviate_path(path));
|
||||
}
|
||||
if !tty && !quiet {
|
||||
let mut err = std::io::stderr().lock();
|
||||
@@ -154,13 +222,95 @@ impl ProgressDisplay {
|
||||
}
|
||||
IngestEvent::AssetFinished { .. } => {
|
||||
// Position is advanced in AssetStarted; bar.finish_and_clear()
|
||||
// in Completed handles the final state. No per-asset bar update
|
||||
// here avoids the duplicate-frame artifact in TTY scrollback.
|
||||
// in Completed handles the final state. v0.26.1: stop the
|
||||
// heartbeat clock so the bar doesn't show a stale `(Ns)` in the
|
||||
// gap before the next AssetStarted.
|
||||
if let Ok(mut guard) = self.asset_start.lock() {
|
||||
*guard = None;
|
||||
}
|
||||
self.current_path = None;
|
||||
}
|
||||
// v0.26.1: an asset entered a slow internal phase (ocr / caption /
|
||||
// embed). Surface which phase + model is running so a multi-second
|
||||
// vision-model call no longer looks frozen.
|
||||
IngestEvent::AssetPhase {
|
||||
idx,
|
||||
total,
|
||||
phase,
|
||||
model,
|
||||
} => {
|
||||
let label = match model {
|
||||
Some(m) => format!("{phase}({m})"),
|
||||
None => phase.clone(),
|
||||
};
|
||||
if let Some(bar) = self.bar.as_ref() {
|
||||
let path = self.current_path.as_deref().unwrap_or("");
|
||||
bar.set_message(format!("{} · {label}…", abbreviate_path(path)));
|
||||
}
|
||||
if !tty && !quiet {
|
||||
let mut err = std::io::stderr().lock();
|
||||
let _ = writeln!(err, "ingest: {idx}/{total} · {label}…");
|
||||
}
|
||||
}
|
||||
// v0.24.0: asset-internal phase visibility. AssetChunked uses the
|
||||
// bar *message* (live sub-progress for the current asset) —
|
||||
// distinct from the per-file position draw, so a single large
|
||||
// document no longer looks frozen. AssetTimings prints a one-line
|
||||
// breakdown when the asset finishes.
|
||||
IngestEvent::AssetChunked { idx, total, chunks } => {
|
||||
if let Some(bar) = self.bar.as_ref() {
|
||||
bar.set_message(format!("→ {chunks} chunks"));
|
||||
}
|
||||
if !tty && !quiet {
|
||||
let mut err = std::io::stderr().lock();
|
||||
let _ = writeln!(err, "ingest: {idx}/{total} → {chunks} chunks");
|
||||
}
|
||||
}
|
||||
IngestEvent::AssetTimings {
|
||||
idx,
|
||||
parse_ms,
|
||||
chunk_ms,
|
||||
embed_ms,
|
||||
store_ms,
|
||||
ocr_ms,
|
||||
caption_ms,
|
||||
..
|
||||
} => {
|
||||
// v0.26.1: accumulate (path, total_ms) for the slowest summary.
|
||||
// total = every measured phase (expansion_ms is always 0).
|
||||
let total_ms = parse_ms + chunk_ms + embed_ms + store_ms + ocr_ms + caption_ms;
|
||||
if let Some(path) = self.asset_paths.get(idx) {
|
||||
self.timings.push((path.clone(), total_ms));
|
||||
}
|
||||
if let Some(bar) = self.bar.as_ref() {
|
||||
bar.set_message("");
|
||||
}
|
||||
if !quiet {
|
||||
let mut err = std::io::stderr().lock();
|
||||
// v0.26.1: only print ocr / caption when they actually ran
|
||||
// (markdown leaves them 0) so the text path stays uncluttered.
|
||||
let mut parts = vec![
|
||||
format!("parse {}", fmt_ms(*parse_ms)),
|
||||
format!("chunk {}", fmt_ms(*chunk_ms)),
|
||||
];
|
||||
if *ocr_ms > 0 {
|
||||
parts.push(format!("ocr {}", fmt_ms(*ocr_ms)));
|
||||
}
|
||||
if *caption_ms > 0 {
|
||||
parts.push(format!("caption {}", fmt_ms(*caption_ms)));
|
||||
}
|
||||
parts.push(format!("embed {}", fmt_ms(*embed_ms)));
|
||||
parts.push(format!("store {}", fmt_ms(*store_ms)));
|
||||
let _ = writeln!(err, " ⏱ {}", parts.join(" · "));
|
||||
}
|
||||
}
|
||||
IngestEvent::Completed { counts } => {
|
||||
if let Some(bar) = self.bar.take() {
|
||||
bar.finish_and_clear();
|
||||
}
|
||||
if let Ok(mut guard) = self.asset_start.lock() {
|
||||
*guard = None;
|
||||
}
|
||||
// Always emit summary in both TTY and non-TTY (unless quiet).
|
||||
// Bug fix: previously TTY had no summary line after bar.finish_and_clear().
|
||||
if !quiet {
|
||||
@@ -170,6 +320,10 @@ impl ProgressDisplay {
|
||||
"ingest: complete (scanned={} new={} updated={} skipped={} errors={})",
|
||||
counts.scanned, counts.new, counts.updated, counts.skipped, counts.errors,
|
||||
);
|
||||
// v0.26.1: slowest-asset summary. Useful in both TTY and
|
||||
// non-TTY (it pinpoints the bottleneck file), so it prints
|
||||
// unless --quiet. --json mode never reaches here (emit_json).
|
||||
let _ = write_slowest_summary(&mut err, &self.timings, SLOWEST_TOP_N);
|
||||
}
|
||||
}
|
||||
IngestEvent::Aborted { counts } => {
|
||||
@@ -239,6 +393,59 @@ fn emit_json(event: &IngestEvent) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Render a phase duration (milliseconds) compactly for the human-mode
|
||||
/// `AssetTimings` line: `< 1000ms` stays in `ms`, larger spans collapse to
|
||||
/// one-decimal seconds so a 45-second embed reads `45.0s`, not `45000ms`.
|
||||
fn fmt_ms(ms: u64) -> String {
|
||||
if ms >= 1000 {
|
||||
format!("{:.1}s", ms as f64 / 1000.0)
|
||||
} else {
|
||||
format!("{ms}ms")
|
||||
}
|
||||
}
|
||||
|
||||
/// v0.26.1: shorten an over-long workspace path for the progress-bar
|
||||
/// message so the live `(Ns)` heartbeat suffix stays visible on a narrow
|
||||
/// terminal. Keeps the tail (filename + a couple of parents) — that's the
|
||||
/// distinguishing part — and prefixes `…` when truncated. Paths up to the
|
||||
/// budget pass through verbatim.
|
||||
fn abbreviate_path(path: &str) -> String {
|
||||
const MAX: usize = 48;
|
||||
let char_count = path.chars().count();
|
||||
if char_count <= MAX {
|
||||
return path.to_string();
|
||||
}
|
||||
// Keep the last MAX-1 chars (1 reserved for the leading ellipsis).
|
||||
let tail: String = path
|
||||
.chars()
|
||||
.skip(char_count - (MAX - 1))
|
||||
.collect::<String>();
|
||||
format!("…{tail}")
|
||||
}
|
||||
|
||||
/// v0.26.1: render the end-of-run "slowest assets" summary. Sorts
|
||||
/// `(path, total_ms)` descending by time, takes the top `n`, and writes a
|
||||
/// compact table to `w`. No-op (writes nothing) when `timings` is empty so
|
||||
/// a run with no per-asset timing (e.g. all-skipped) prints no stray header.
|
||||
fn write_slowest_summary(
|
||||
w: &mut impl Write,
|
||||
timings: &[(String, u64)],
|
||||
n: usize,
|
||||
) -> std::io::Result<()> {
|
||||
if timings.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut sorted: Vec<&(String, u64)> = timings.iter().collect();
|
||||
// desc by ms; ties broken by path for deterministic output.
|
||||
sorted.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
|
||||
let top = &sorted[..sorted.len().min(n)];
|
||||
writeln!(w, "⏱ 최장 소요 top-{}:", top.len())?;
|
||||
for (rank, (path, ms)) in top.iter().enumerate() {
|
||||
writeln!(w, " {}. {} — {}", rank + 1, path, fmt_ms(*ms))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Format the current wall-clock as RFC 3339 — used by `wire_ingest_progress`
|
||||
/// so every emitted event carries an `ts` field per §2.4a / the wire schema.
|
||||
pub(crate) fn now_rfc3339() -> anyhow::Result<String> {
|
||||
@@ -285,6 +492,15 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fmt_ms_switches_unit_at_one_second() {
|
||||
assert_eq!(fmt_ms(0), "0ms");
|
||||
assert_eq!(fmt_ms(999), "999ms");
|
||||
assert_eq!(fmt_ms(1000), "1.0s");
|
||||
assert_eq!(fmt_ms(45_000), "45.0s");
|
||||
assert_eq!(fmt_ms(1500), "1.5s");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn now_rfc3339_parses_back() {
|
||||
let s = now_rfc3339().unwrap();
|
||||
@@ -292,4 +508,61 @@ mod tests {
|
||||
// well-formed RFC 3339 string.
|
||||
OffsetDateTime::parse(&s, &Rfc3339).expect("RFC 3339 round-trip");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn abbreviate_path_passes_short_paths_through() {
|
||||
assert_eq!(abbreviate_path("notes/foo.md"), "notes/foo.md");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn abbreviate_path_keeps_tail_with_ellipsis() {
|
||||
let long = "a/very/deeply/nested/directory/structure/that/exceeds/the/budget/file.md";
|
||||
let out = abbreviate_path(long);
|
||||
assert!(out.starts_with('…'), "should be prefixed with ellipsis: {out}");
|
||||
assert!(out.ends_with("file.md"), "should keep the filename tail: {out}");
|
||||
// 48-char budget: 1 ellipsis + 47 tail chars.
|
||||
assert_eq!(out.chars().count(), 48);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_slowest_summary_empty_writes_nothing() {
|
||||
let mut buf = Vec::new();
|
||||
write_slowest_summary(&mut buf, &[], 5).unwrap();
|
||||
assert!(buf.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_slowest_summary_sorts_desc_and_truncates() {
|
||||
let timings = vec![
|
||||
("a.md".to_string(), 100),
|
||||
("b.png".to_string(), 5_000),
|
||||
("c.pdf".to_string(), 2_000),
|
||||
("d.md".to_string(), 50),
|
||||
];
|
||||
let mut buf = Vec::new();
|
||||
write_slowest_summary(&mut buf, &timings, 2).unwrap();
|
||||
let out = String::from_utf8(buf).unwrap();
|
||||
assert!(out.contains("top-2:"), "{out}");
|
||||
// b (5s) ranks first, c (2s) second; a/d excluded.
|
||||
let b_pos = out.find("b.png").expect("b.png present");
|
||||
let c_pos = out.find("c.pdf").expect("c.pdf present");
|
||||
assert!(b_pos < c_pos, "b before c: {out}");
|
||||
assert!(!out.contains("a.md"), "a.md excluded by top-2: {out}");
|
||||
assert!(out.contains("5.0s"), "b renders as 5.0s: {out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_slowest_summary_tie_breaks_by_path() {
|
||||
let timings = vec![
|
||||
("z.md".to_string(), 1_000),
|
||||
("a.md".to_string(), 1_000),
|
||||
];
|
||||
let mut buf = Vec::new();
|
||||
write_slowest_summary(&mut buf, &timings, 5).unwrap();
|
||||
let out = String::from_utf8(buf).unwrap();
|
||||
assert!(
|
||||
out.find("a.md").unwrap() < out.find("z.md").unwrap(),
|
||||
"equal ms ties break alphabetically: {out}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,6 +225,12 @@ pub fn wire_bulk_search_item(item: &kebab_core::BulkSearchItem) -> Value {
|
||||
v
|
||||
}
|
||||
|
||||
/// `config_migration.v1` 직렬화. `ConfigMigrationReport` 가 `schema_version`
|
||||
/// 필드를 자체 보유하므로(doctor 와 동일) 그대로 직렬화한다.
|
||||
pub fn wire_config_migration(r: &kebab_app::ConfigMigrationReport) -> Value {
|
||||
serde_json::to_value(r).expect("ConfigMigrationReport serializes")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -15,6 +15,7 @@ serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
toml = "0.8"
|
||||
toml_edit = "0.22"
|
||||
dirs = "5"
|
||||
# p9-fb-05: warn-log when current_dir() fails (chroot, deleted cwd)
|
||||
# during workspace.root resolution.
|
||||
|
||||
@@ -9,6 +9,7 @@ use std::path::{Path, PathBuf};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
mod paths;
|
||||
pub mod migrate;
|
||||
pub use paths::{expand_path, expand_path_with_base};
|
||||
|
||||
/// Signal: `Config::from_file` / `Config::load` failed due to missing path,
|
||||
@@ -154,11 +155,29 @@ impl NliCfg {
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct EmbeddingModelCfg {
|
||||
/// `fastembed` (default, onnxruntime), `candle` (pure-Rust, NUMA-safe),
|
||||
/// or `ollama` (remote HTTP embedding endpoint). `none` disables
|
||||
/// embeddings (lexical-only). Unknown values error at embedder
|
||||
/// construction.
|
||||
pub provider: String,
|
||||
pub model: String,
|
||||
pub version: String,
|
||||
pub dimensions: usize,
|
||||
pub batch_size: usize,
|
||||
/// Cap on the CPU worker threads the `candle` provider spins up
|
||||
/// (sizes the global rayon pool; env `KEBAB_EMBED_THREADS` overrides).
|
||||
/// `0` = auto (rayon default = #cores). Lever to sidestep the
|
||||
/// onnxruntime 48-thread NUMA double-free; ignored by the `fastembed`
|
||||
/// provider. Defaulted on load so pre-0.22 config files still parse.
|
||||
#[serde(default)]
|
||||
pub num_threads: u32,
|
||||
/// HTTP endpoint for the `ollama` embedding provider (e.g.
|
||||
/// `"http://127.0.0.1:11434"`). `None` (or a missing key in TOML) means
|
||||
/// "fall back to `models.llm.endpoint`" — same convention as the OCR /
|
||||
/// vision endpoints. Ignored by the `fastembed` / `candle` providers.
|
||||
/// Defaulted on load so pre-0.26 config files still parse.
|
||||
#[serde(default)]
|
||||
pub endpoint: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
@@ -358,6 +377,36 @@ pub struct OcrCfg {
|
||||
/// `86400`).
|
||||
#[serde(default = "default_ocr_request_timeout_secs")]
|
||||
pub request_timeout_secs: u64,
|
||||
|
||||
// ── paddle-onnx engine overrides (v0.27.0) ──────────────────────────
|
||||
// Only consulted when `engine == "paddle-onnx"`; the ollama-vision
|
||||
// engine ignores them. All `#[serde(default)]` so pre-v0.27 config
|
||||
// files load unchanged.
|
||||
/// Override path to the detection ONNX model. `None` → bundled
|
||||
/// `assets/paddleocr-onnx/ppocrv5_mobile_det.onnx` (or the directory
|
||||
/// named by `KEBAB_IMAGE_OCR_MODEL_DIR`).
|
||||
#[serde(default)]
|
||||
pub det_model: Option<String>,
|
||||
/// Override path to the recognition ONNX model. `None` → bundled
|
||||
/// `assets/paddleocr-onnx/korean_ppocrv5_mobile_rec.onnx`.
|
||||
#[serde(default)]
|
||||
pub rec_model: Option<String>,
|
||||
/// Override path to the character dictionary. `None` → bundled
|
||||
/// `assets/paddleocr-onnx/korean_dict.txt`.
|
||||
#[serde(default)]
|
||||
pub dict: Option<String>,
|
||||
/// DBNet detection box score threshold (0.0..=1.0). Boxes whose mean
|
||||
/// probability is below this are dropped. Default `0.3`.
|
||||
#[serde(default = "default_ocr_score_thresh")]
|
||||
pub score_thresh: f32,
|
||||
/// Polygon unclip ratio applied to each detected box before crop.
|
||||
/// Larger = more padding around the text. Default `1.5`.
|
||||
#[serde(default = "default_ocr_unclip_ratio")]
|
||||
pub unclip_ratio: f32,
|
||||
/// Hard cap on detected boxes per image (runaway guard). Extra boxes
|
||||
/// past this count are truncated with a warning. Default `1000`.
|
||||
#[serde(default = "default_ocr_max_boxes")]
|
||||
pub max_boxes: usize,
|
||||
}
|
||||
|
||||
impl OcrCfg {
|
||||
@@ -370,10 +419,29 @@ impl OcrCfg {
|
||||
languages: vec!["eng".to_string(), "kor".to_string()],
|
||||
max_pixels: 1600,
|
||||
request_timeout_secs: default_ocr_request_timeout_secs(),
|
||||
det_model: None,
|
||||
rec_model: None,
|
||||
dict: None,
|
||||
score_thresh: default_ocr_score_thresh(),
|
||||
unclip_ratio: default_ocr_unclip_ratio(),
|
||||
max_boxes: default_ocr_max_boxes(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// paddle-onnx DBNet box score threshold default. See [`OcrCfg::score_thresh`].
|
||||
fn default_ocr_score_thresh() -> f32 {
|
||||
0.3
|
||||
}
|
||||
/// paddle-onnx unclip ratio default. See [`OcrCfg::unclip_ratio`].
|
||||
fn default_ocr_unclip_ratio() -> f32 {
|
||||
1.5
|
||||
}
|
||||
/// paddle-onnx box-count cap default. See [`OcrCfg::max_boxes`].
|
||||
fn default_ocr_max_boxes() -> usize {
|
||||
1000
|
||||
}
|
||||
|
||||
/// v0.17.2 post-dogfood: matches the legacy hard-coded ceiling so
|
||||
/// existing configs that omit the field keep behaving identically.
|
||||
/// Overridable per config / `KEBAB_IMAGE_OCR_REQUEST_TIMEOUT_SECS`.
|
||||
@@ -639,7 +707,7 @@ impl Config {
|
||||
/// Defaults per design §6.4.
|
||||
pub fn defaults() -> Self {
|
||||
Self {
|
||||
schema_version: 1,
|
||||
schema_version: crate::migrate::CURRENT_SCHEMA_VERSION,
|
||||
workspace: WorkspaceCfg {
|
||||
root: "~/KnowledgeBase".to_string(),
|
||||
exclude: vec![
|
||||
@@ -676,6 +744,8 @@ impl Config {
|
||||
version: "v1".to_string(),
|
||||
dimensions: 1024,
|
||||
batch_size: 64,
|
||||
num_threads: 0,
|
||||
endpoint: None,
|
||||
},
|
||||
llm: LlmCfg {
|
||||
provider: "ollama".to_string(),
|
||||
@@ -933,6 +1003,17 @@ impl Config {
|
||||
self.models.embedding.batch_size = n;
|
||||
}
|
||||
}
|
||||
"KEBAB_MODELS_EMBEDDING_NUM_THREADS" => {
|
||||
if let Ok(n) = v.parse::<u32>() {
|
||||
self.models.embedding.num_threads = n;
|
||||
}
|
||||
}
|
||||
"KEBAB_MODELS_EMBEDDING_ENDPOINT" => {
|
||||
// Empty value → None (= fall back to models.llm.endpoint),
|
||||
// mirroring the OCR endpoint override semantics.
|
||||
self.models.embedding.endpoint =
|
||||
if v.is_empty() { None } else { Some(v.clone()) };
|
||||
}
|
||||
|
||||
// models.llm
|
||||
"KEBAB_MODELS_LLM_PROVIDER" => self.models.llm.provider = v.clone(),
|
||||
@@ -1066,6 +1147,34 @@ impl Config {
|
||||
self.image.ocr.request_timeout_secs = n;
|
||||
}
|
||||
}
|
||||
// paddle-onnx engine overrides (v0.27.0). Empty string → None
|
||||
// (fall back to bundled / KEBAB_IMAGE_OCR_MODEL_DIR).
|
||||
"KEBAB_IMAGE_OCR_DET_MODEL" => {
|
||||
self.image.ocr.det_model =
|
||||
if v.is_empty() { None } else { Some(v.clone()) };
|
||||
}
|
||||
"KEBAB_IMAGE_OCR_REC_MODEL" => {
|
||||
self.image.ocr.rec_model =
|
||||
if v.is_empty() { None } else { Some(v.clone()) };
|
||||
}
|
||||
"KEBAB_IMAGE_OCR_DICT" => {
|
||||
self.image.ocr.dict = if v.is_empty() { None } else { Some(v.clone()) };
|
||||
}
|
||||
"KEBAB_IMAGE_OCR_SCORE_THRESH" => {
|
||||
if let Ok(f) = v.parse::<f32>() {
|
||||
self.image.ocr.score_thresh = f;
|
||||
}
|
||||
}
|
||||
"KEBAB_IMAGE_OCR_UNCLIP_RATIO" => {
|
||||
if let Ok(f) = v.parse::<f32>() {
|
||||
self.image.ocr.unclip_ratio = f;
|
||||
}
|
||||
}
|
||||
"KEBAB_IMAGE_OCR_MAX_BOXES" => {
|
||||
if let Ok(n) = v.parse::<usize>() {
|
||||
self.image.ocr.max_boxes = n;
|
||||
}
|
||||
}
|
||||
|
||||
// image.caption (P6-3)
|
||||
"KEBAB_IMAGE_CAPTION_ENABLED" => {
|
||||
@@ -1846,6 +1955,7 @@ max_context_tokens = 8000
|
||||
let cfg: Config = toml::from_str(&toml_text).unwrap();
|
||||
assert_eq!(cfg.ingest.code.max_file_bytes, 524_288);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
392
crates/kebab-config/src/migrate.rs
Normal file
392
crates/kebab-config/src/migrate.rs
Normal file
@@ -0,0 +1,392 @@
|
||||
//! config.toml 마이그레이션 엔진 (순수 변환, I/O 없음).
|
||||
//!
|
||||
//! 두 메커니즘: (1) reconciliation — default 구조에 있고 사용자 파일에
|
||||
//! 없는 섹션/키를 주석과 함께 추가. (2) step 체인 — schema_version 기반
|
||||
//! non-additive 변환(deprecated 제거 등). 자세한 계약은 spec
|
||||
//! `docs/superpowers/specs/2026-05-31-config-migration-design.md`.
|
||||
|
||||
use toml_edit::DocumentMut;
|
||||
|
||||
/// 현재 바이너리가 이해하는 config 스키마 버전. 마이그레이션 완료 시
|
||||
/// 사용자 파일의 `schema_version` 을 이 값으로 stamp 한다.
|
||||
pub const CURRENT_SCHEMA_VERSION: u32 = 2;
|
||||
|
||||
/// 한 번의 마이그레이션에서 발생한 개별 변경.
|
||||
#[derive(Clone, Debug, PartialEq, serde::Serialize)]
|
||||
pub struct MigrationChange {
|
||||
pub kind: ChangeKind,
|
||||
/// dotted path, 예: `ingest.code`, `workspace.include`.
|
||||
pub path: String,
|
||||
/// 사람·wire 용 한 줄 설명.
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ChangeKind {
|
||||
AddedSection,
|
||||
AddedKey,
|
||||
RemovedDeprecated,
|
||||
}
|
||||
|
||||
/// 마이그레이션 결과 요약(순수 변환 단계 산출). I/O 계층이 backup_path
|
||||
/// 등을 채워 wire 로 내보낸다.
|
||||
#[derive(Clone, Debug, PartialEq, serde::Serialize)]
|
||||
pub struct MigrationOutcome {
|
||||
pub from_schema_version: u32,
|
||||
pub to_schema_version: u32,
|
||||
pub changes: Vec<MigrationChange>,
|
||||
/// 변환 후 직렬화된 새 문서 텍스트(멱등 시 입력과 동일).
|
||||
pub new_text: String,
|
||||
}
|
||||
|
||||
impl MigrationOutcome {
|
||||
pub fn changed(&self) -> bool {
|
||||
!self.changes.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// 문서 최상단 헤더(경로 정책 등). 기존 init 헤더를 이전.
|
||||
const HEADER: &str = "\
|
||||
# kebab config — `~/.config/kebab/config.toml`.
|
||||
#
|
||||
# `workspace.root` accepts: 절대 / tilde(~) / env(${VAR}) / 상대 경로.
|
||||
# 상대 경로의 base 는 cwd 가 아니라 THIS config 파일의 디렉토리.
|
||||
#
|
||||
# 처리 가능한 형식 (extractor 가 자동 결정 — config 에 명시할 수 없음):
|
||||
# • Markdown: .md
|
||||
# • 이미지: .png .jpg .jpeg (OCR + caption)
|
||||
# • PDF: .pdf
|
||||
#
|
||||
# 런타임 override: `KEBAB_*` env (예: KEBAB_WORKSPACE_ROOT=/tmp kebab ingest).
|
||||
#
|
||||
# 이 파일은 `kebab config migrate` 로 새 스키마에 맞춰 갱신할 수 있다
|
||||
# (빠진 섹션 추가 + 손본 값·주석 보존).
|
||||
";
|
||||
|
||||
/// 테이블 헤더(`[section]`) 위에 붙일 주석. dotted path → 한 줄(들).
|
||||
fn section_comment(path: &str) -> Option<&'static str> {
|
||||
Some(match path {
|
||||
"workspace" => "# 색인 대상 워크스페이스.",
|
||||
"storage" => "# XDG 저장 경로(데이터/sqlite/벡터/에셋/모델).",
|
||||
"indexing" => "# 병렬도 + 파일시스템 watch.",
|
||||
"chunking" => "# 청크 크기·오버랩·heading 존중.",
|
||||
"models" => "# embedding / llm / nli 모델.",
|
||||
"models.embedding" => "# 다국어 sentence embedding. dim 불일치 시 검색 0건.",
|
||||
"models.llm" => "# Ollama host:port + 모델.",
|
||||
"models.nli" => "# NLI(groundedness) 모델.",
|
||||
"search" => "# 검색 기본 k·stale 기준·fusion.",
|
||||
"rag" => "# 답변 생성: prompt 템플릿·score gate·NLI.",
|
||||
"image" => "# 이미지 OCR + 캡션(기본 off, asset 당 모델 호출 비용).",
|
||||
"image.ocr" => "# 이미지 OCR(기본 off).",
|
||||
"image.caption" => "# 이미지 캡션(기본 off).",
|
||||
"ui" => "# TUI 팔레트·role 스타일.",
|
||||
"ingest" => "# ingest 정책(code skip 등).",
|
||||
"ingest.code" => "# code ingest skip 정책(.gitignore 자동 honor).",
|
||||
"pdf" => "# PDF ingest. scanned PDF OCR 은 기본 off(page 당 cost).",
|
||||
"pdf.ocr" => "# scanned PDF page-단위 OCR(기본 off).",
|
||||
"logging" => "# ingest 로그(기본 on, ~/.local/state/kebab/logs).",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Config::defaults() 를 직렬화 + 주석 부착한 "완전체" 문서.
|
||||
/// init 과 migrate reconciliation 의 단일 참조 원천.
|
||||
pub fn annotated_default_document() -> DocumentMut {
|
||||
let defaults = crate::Config::defaults();
|
||||
let pretty = toml::to_string_pretty(&defaults).expect("defaults serialize");
|
||||
let mut doc: DocumentMut = pretty.parse().expect("defaults parse as toml_edit");
|
||||
|
||||
// 헤더: 첫 최상위 항목의 prefix 로.
|
||||
if let Some((mut first_key, _)) = doc.as_table_mut().iter_mut().next() {
|
||||
first_key.leaf_decor_mut().set_prefix(format!("{HEADER}\n"));
|
||||
}
|
||||
|
||||
annotate_table(doc.as_table_mut(), "");
|
||||
doc
|
||||
}
|
||||
|
||||
/// 재귀적으로 하위 테이블에 헤더 주석 부착. `prefix_path` 는 dotted 누적 경로.
|
||||
/// annotated_default_document 는 항상 주석 없는 defaults 에서 새로 만들므로
|
||||
/// 무조건 부착해도 중복되지 않는다.
|
||||
fn annotate_table(table: &mut toml_edit::Table, prefix_path: &str) {
|
||||
let keys: Vec<String> = table.iter().map(|(k, _)| k.to_string()).collect();
|
||||
for key in keys {
|
||||
let path = if prefix_path.is_empty() {
|
||||
key.clone()
|
||||
} else {
|
||||
format!("{prefix_path}.{key}")
|
||||
};
|
||||
if let Some(item) = table.get_mut(&key) {
|
||||
if let Some(sub) = item.as_table_mut() {
|
||||
if let Some(c) = section_comment(&path) {
|
||||
sub.decor_mut().set_prefix(format!("\n{c}\n"));
|
||||
}
|
||||
annotate_table(sub, &path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 참조(주석 달린 default) 테이블 `reference` 를 기준으로, 사용자 테이블
|
||||
/// `user` 에 없는 항목을 decor(주석) 포함 통째 복사한다. 이미 있는 키는
|
||||
/// 건드리지 않는다(값 불가침). 양쪽이 테이블이면 하위로 재귀.
|
||||
pub fn reconcile(
|
||||
reference: &toml_edit::Table,
|
||||
user: &mut toml_edit::Table,
|
||||
prefix_path: &str,
|
||||
changes: &mut Vec<MigrationChange>,
|
||||
) {
|
||||
for (key, ref_item) in reference.iter() {
|
||||
let path = if prefix_path.is_empty() {
|
||||
key.to_string()
|
||||
} else {
|
||||
format!("{prefix_path}.{key}")
|
||||
};
|
||||
match user.get_mut(key) {
|
||||
None => {
|
||||
// schema_version 키는 stamp 단계가 다룬다(change 기록 X).
|
||||
if path == "schema_version" {
|
||||
user.insert(key, ref_item.clone());
|
||||
continue;
|
||||
}
|
||||
let kind = if ref_item.is_table() {
|
||||
ChangeKind::AddedSection
|
||||
} else {
|
||||
ChangeKind::AddedKey
|
||||
};
|
||||
user.insert(key, ref_item.clone());
|
||||
changes.push(MigrationChange {
|
||||
kind,
|
||||
path: path.clone(),
|
||||
detail: section_comment(&path).map_or_else(
|
||||
|| format!("{key} 추가"),
|
||||
|c| c.trim_start_matches("# ").to_string(),
|
||||
),
|
||||
});
|
||||
}
|
||||
Some(existing) => {
|
||||
if let (Some(ref_tbl), Some(user_tbl)) =
|
||||
(ref_item.as_table(), existing.as_table_mut())
|
||||
{
|
||||
reconcile(ref_tbl, user_tbl, &path, changes);
|
||||
}
|
||||
// 둘 다 테이블이 아니면(스칼라 등) 값 불가침 → 무시.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// v1 → v2: deprecated `workspace.include` 제거(p9-fb-25). 멱등.
|
||||
pub fn step_1_to_2(doc: &mut DocumentMut, changes: &mut Vec<MigrationChange>) {
|
||||
if let Some(ws) = doc.get_mut("workspace").and_then(|i| i.as_table_mut()) {
|
||||
if ws.remove("include").is_some() {
|
||||
changes.push(MigrationChange {
|
||||
kind: ChangeKind::RemovedDeprecated,
|
||||
path: "workspace.include".to_string(),
|
||||
detail: "p9-fb-25: 처리 형식은 extractor 가 자동 결정 — 더 이상 사용 안 함."
|
||||
.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 파일의 schema_version(없으면 1) 부터 CURRENT 까지 step 적용.
|
||||
fn run_steps(doc: &mut DocumentMut, from: u32, changes: &mut Vec<MigrationChange>) {
|
||||
if from < 2 {
|
||||
step_1_to_2(doc, changes);
|
||||
}
|
||||
// 미래 step: if from < 3 { step_2_to_3(...) } ...
|
||||
}
|
||||
|
||||
/// 사용자 config.toml 텍스트를 받아 step 체인 + reconciliation + version
|
||||
/// stamp 를 적용하고 결과를 반환한다. 순수 함수(I/O 없음). 파싱 실패 시
|
||||
/// from=1, 변경 없음, new_text=입력 그대로(상위에서 파싱 에러를 따로 처리).
|
||||
pub fn migrate_document(text: &str) -> MigrationOutcome {
|
||||
let mut doc: DocumentMut = match text.parse() {
|
||||
Ok(d) => d,
|
||||
Err(_) => {
|
||||
return MigrationOutcome {
|
||||
from_schema_version: 1,
|
||||
to_schema_version: CURRENT_SCHEMA_VERSION,
|
||||
changes: Vec::new(),
|
||||
new_text: text.to_string(),
|
||||
};
|
||||
}
|
||||
};
|
||||
let from = doc
|
||||
.get("schema_version")
|
||||
.and_then(toml_edit::Item::as_integer)
|
||||
.unwrap_or(1) as u32;
|
||||
|
||||
let mut changes = Vec::new();
|
||||
|
||||
// 1. non-additive step 체인.
|
||||
run_steps(&mut doc, from, &mut changes);
|
||||
|
||||
// 2. additive reconciliation(버전 무관).
|
||||
let reference = annotated_default_document();
|
||||
let ref_table = reference.as_table().clone();
|
||||
reconcile(&ref_table, doc.as_table_mut(), "", &mut changes);
|
||||
|
||||
// 3. schema_version stamp.
|
||||
let current_in_file = doc
|
||||
.get("schema_version")
|
||||
.and_then(toml_edit::Item::as_integer)
|
||||
.unwrap_or(0) as u32;
|
||||
if current_in_file != CURRENT_SCHEMA_VERSION {
|
||||
doc["schema_version"] = toml_edit::value(i64::from(CURRENT_SCHEMA_VERSION));
|
||||
}
|
||||
|
||||
MigrationOutcome {
|
||||
from_schema_version: from,
|
||||
to_schema_version: CURRENT_SCHEMA_VERSION,
|
||||
changes,
|
||||
new_text: doc.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn annotated_default_has_all_sections_and_parses_back_to_defaults() {
|
||||
let doc = annotated_default_document();
|
||||
let text = doc.to_string();
|
||||
// PdfCfg/ImageCfg/ModelsCfg/IngestCfg 는 스칼라 필드가 없어 bare
|
||||
// `[pdf]` 등은 안 나오고 `[pdf.ocr]` 같은 하위 테이블만 직렬화된다.
|
||||
for section in [
|
||||
"[workspace]",
|
||||
"[ingest.code]",
|
||||
"[pdf.ocr]",
|
||||
"[logging]",
|
||||
"[ui]",
|
||||
] {
|
||||
assert!(text.contains(section), "missing {section}:\n{text}");
|
||||
}
|
||||
assert!(text.contains("# "), "no comments attached");
|
||||
let back: crate::Config = toml::from_str(&text).expect("parse annotated default");
|
||||
assert_eq!(back, crate::Config::defaults());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconcile_adds_missing_section_preserving_user_values_and_comments() {
|
||||
// ingest 통째 누락(→ ingest.code 추가), logging 통째 누락,
|
||||
// default_k 는 사용자가 바꿈, 주석 보유.
|
||||
let user_text = "\
|
||||
schema_version = 1
|
||||
|
||||
[workspace]
|
||||
root = \"/my/notes\" # 내 워크스페이스
|
||||
|
||||
[search]
|
||||
default_k = 25
|
||||
";
|
||||
let mut user: DocumentMut = user_text.parse().unwrap();
|
||||
let reference = annotated_default_document();
|
||||
let ref_tbl = reference.as_table().clone();
|
||||
let mut changes = Vec::new();
|
||||
reconcile(&ref_tbl, user.as_table_mut(), "", &mut changes);
|
||||
let out = user.to_string();
|
||||
|
||||
// 누락된 [ingest.code] 가 주석과 함께 추가.
|
||||
assert!(out.contains("[ingest.code]"), "ingest.code not added:\n{out}");
|
||||
// 통째 누락된 logging 추가.
|
||||
assert!(out.contains("[logging]"), "logging not added");
|
||||
// 사용자 값/주석/기존 섹션 보존.
|
||||
assert!(out.contains("root = \"/my/notes\""));
|
||||
assert!(out.contains("# 내 워크스페이스"));
|
||||
assert!(out.contains("default_k = 25"));
|
||||
// 새 섹션 주석 부착.
|
||||
assert!(out.contains("code ingest skip 정책"));
|
||||
// 통째 누락 부모는 부모 경로로 한 번 기록.
|
||||
assert!(
|
||||
changes
|
||||
.iter()
|
||||
.any(|c| c.kind == ChangeKind::AddedSection && c.path == "ingest")
|
||||
);
|
||||
assert!(
|
||||
changes
|
||||
.iter()
|
||||
.any(|c| c.kind == ChangeKind::AddedSection && c.path == "logging")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconcile_does_not_overwrite_user_value_differing_from_default() {
|
||||
let user_text = "\
|
||||
schema_version = 2
|
||||
|
||||
[rag]
|
||||
score_gate = 0.8
|
||||
";
|
||||
let mut user: DocumentMut = user_text.parse().unwrap();
|
||||
let reference = annotated_default_document();
|
||||
let ref_tbl = reference.as_table().clone();
|
||||
let mut changes = Vec::new();
|
||||
reconcile(&ref_tbl, user.as_table_mut(), "", &mut changes);
|
||||
let out = user.to_string();
|
||||
assert!(out.contains("score_gate = 0.8"), "user value clobbered:\n{out}");
|
||||
assert!(!changes.iter().any(|c| c.path == "rag.score_gate"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn step_1_to_2_removes_deprecated_workspace_include() {
|
||||
let user_text = "\
|
||||
[workspace]
|
||||
root = \"/n\"
|
||||
include = [\"*.md\"]
|
||||
";
|
||||
let mut user: DocumentMut = user_text.parse().unwrap();
|
||||
let mut changes = Vec::new();
|
||||
step_1_to_2(&mut user, &mut changes);
|
||||
let out = user.to_string();
|
||||
assert!(!out.contains("include"), "include not removed:\n{out}");
|
||||
assert!(
|
||||
changes
|
||||
.iter()
|
||||
.any(|c| c.kind == ChangeKind::RemovedDeprecated && c.path == "workspace.include")
|
||||
);
|
||||
let mut changes2 = Vec::new();
|
||||
step_1_to_2(&mut user, &mut changes2);
|
||||
assert!(changes2.is_empty());
|
||||
}
|
||||
|
||||
fn read_schema_version(text: &str) -> u32 {
|
||||
let doc: DocumentMut = text.parse().unwrap();
|
||||
doc.get("schema_version")
|
||||
.and_then(toml_edit::Item::as_integer)
|
||||
.unwrap_or(1) as u32
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrate_document_stamps_version_and_is_idempotent() {
|
||||
let old = "\
|
||||
schema_version = 1
|
||||
|
||||
[workspace]
|
||||
root = \"/n\"
|
||||
include = [\"*.md\"]
|
||||
";
|
||||
let outcome = migrate_document(old);
|
||||
assert_eq!(outcome.from_schema_version, 1);
|
||||
assert_eq!(outcome.to_schema_version, CURRENT_SCHEMA_VERSION);
|
||||
assert!(outcome.changed());
|
||||
assert!(!outcome.new_text.contains("include"));
|
||||
assert!(outcome.new_text.contains("[ingest.code]"));
|
||||
assert_eq!(read_schema_version(&outcome.new_text), CURRENT_SCHEMA_VERSION);
|
||||
|
||||
let again = migrate_document(&outcome.new_text);
|
||||
assert!(!again.changed(), "not idempotent: {:?}", again.changes);
|
||||
assert_eq!(again.new_text, outcome.new_text);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrate_document_missing_schema_version_treated_as_v1() {
|
||||
let old = "[workspace]\nroot = \"/n\"\n";
|
||||
let outcome = migrate_document(old);
|
||||
assert_eq!(outcome.from_schema_version, 1);
|
||||
assert_eq!(read_schema_version(&outcome.new_text), CURRENT_SCHEMA_VERSION);
|
||||
}
|
||||
}
|
||||
@@ -29,3 +29,26 @@ pub struct Chunk {
|
||||
#[serde(default)]
|
||||
pub tokenized_korean_text: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn tokenized_korean_text_defaults_to_none_on_deserialize() {
|
||||
// tokenized_korean_text 필드가 없는 과거 JSON 도 파싱되어야 한다 (#[serde(default)]).
|
||||
let json = r#"{
|
||||
"chunk_id": "c1",
|
||||
"doc_id": "d1",
|
||||
"block_ids": [],
|
||||
"text": "hello",
|
||||
"heading_path": [],
|
||||
"source_spans": [],
|
||||
"token_estimate": 1,
|
||||
"chunker_version": "md-heading-v1",
|
||||
"policy_hash": "abc"
|
||||
}"#;
|
||||
let c: Chunk = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(c.tokenized_korean_text, None);
|
||||
}
|
||||
}
|
||||
|
||||
110
crates/kebab-core/src/derivation.rs
Normal file
110
crates/kebab-core/src/derivation.rs
Normal file
@@ -0,0 +1,110 @@
|
||||
//! Content-hash derivation cache key (design 2026-05-31 §3.1).
|
||||
//!
|
||||
//! Expensive ingest derivations (embedding vectors, LLM aliases, optional
|
||||
//! Korean morphological tokens) are cached by the *content hash* of the chunk
|
||||
//! text so that re-indexing an updated document skips recomputation for any
|
||||
//! chunk whose text is unchanged — independent of position / `chunk_id`
|
||||
//! (which is position-based, see `ids::id_for_block`).
|
||||
//!
|
||||
//! ```text
|
||||
//! cache_key = blake3_hex( kind || 0x00 || text_blake3 || 0x00 || version_key )[:32]
|
||||
//! ```
|
||||
//! - `text_blake3` = blake3(NFC-normalized UTF-8 bytes of the chunk text).
|
||||
//! - `kind` ∈ { "embedding", "alias", "korean_tokens" }.
|
||||
//! - `version_key` folds every §9 version-cascade input for that kind
|
||||
//! (model / prompt / tokenizer version). A version bump changes the key →
|
||||
//! automatic cache miss → recompute, keeping the cache consistent with the
|
||||
//! cascade contract (§3.5 / §3.6).
|
||||
//!
|
||||
//! Pure: depends only on `blake3` + `unicode-normalization`. No other
|
||||
//! `kebab-*` crate is referenced (deps boundary §5).
|
||||
|
||||
use crate::normalize::nfc;
|
||||
|
||||
/// Derivation-cache key per design §3.1.
|
||||
///
|
||||
/// `text` is NFC-normalized before hashing so the same logical content always
|
||||
/// maps to the same key regardless of Unicode encoding form. `kind` and
|
||||
/// `version_key` are folded in with `0x00` separators (which cannot occur in
|
||||
/// hex digests) so distinct kinds / versions never collide.
|
||||
pub fn derivation_cache_key(kind: &str, text: &str, version_key: &str) -> String {
|
||||
let text_blake3 = blake3::hash(nfc(text).as_bytes()).to_hex().to_string();
|
||||
|
||||
let mut hasher = blake3::Hasher::new();
|
||||
hasher.update(kind.as_bytes());
|
||||
hasher.update(&[0x00]);
|
||||
hasher.update(text_blake3.as_bytes());
|
||||
hasher.update(&[0x00]);
|
||||
hasher.update(version_key.as_bytes());
|
||||
|
||||
hasher.finalize().to_hex().to_string()[..32].to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn key_is_32_hex_chars() {
|
||||
let k = derivation_cache_key("embedding", "hello world", "v1");
|
||||
assert_eq!(k.len(), 32);
|
||||
assert!(k.bytes().all(|b| b.is_ascii_hexdigit()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_inputs_same_key() {
|
||||
let a = derivation_cache_key("embedding", "러스트 소유권", "model|1|1024");
|
||||
let b = derivation_cache_key("embedding", "러스트 소유권", "model|1|1024");
|
||||
assert_eq!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nfc_normalization_collapses_encoding_forms() {
|
||||
// "가" as a precomposed syllable (NFC) vs decomposed jamo (NFD) must
|
||||
// hash to the same key after NFC normalization.
|
||||
let precomposed = "\u{AC00}"; // 가
|
||||
let decomposed = "\u{1100}\u{1161}"; // ᄀ + ᅡ
|
||||
assert_ne!(precomposed, decomposed);
|
||||
let a = derivation_cache_key("embedding", precomposed, "v1");
|
||||
let b = derivation_cache_key("embedding", decomposed, "v1");
|
||||
assert_eq!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_kind_different_key() {
|
||||
let e = derivation_cache_key("embedding", "same text", "v1");
|
||||
let a = derivation_cache_key("alias", "same text", "v1");
|
||||
assert_ne!(e, a);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_version_key_different_key_miss() {
|
||||
// §3.6 correctness guard: a version_key change MUST produce a different
|
||||
// cache_key (so a stale derivation never gets reused after a cascade
|
||||
// bump). This is the most safety-critical invariant of the cache.
|
||||
let v1 = derivation_cache_key("embedding", "same text", "modelA|1|1024");
|
||||
let v2 = derivation_cache_key("embedding", "same text", "modelA|2|1024");
|
||||
assert_ne!(v1, v2);
|
||||
|
||||
// alias prompt_version bump → miss.
|
||||
let p1 = derivation_cache_key("alias", "문단", "expansion-v1|8|");
|
||||
let p2 = derivation_cache_key("alias", "문단", "expansion-v2|8|");
|
||||
assert_ne!(p1, p2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_text_different_key() {
|
||||
let a = derivation_cache_key("embedding", "text one", "v1");
|
||||
let b = derivation_cache_key("embedding", "text two", "v1");
|
||||
assert_ne!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn separator_prevents_field_smearing() {
|
||||
// Without the 0x00 separators, ("ab","","c") and ("a","b","c") shaped
|
||||
// inputs could collide. The kind/version boundaries must be distinct.
|
||||
let a = derivation_cache_key("ab", "x", "c");
|
||||
let b = derivation_cache_key("a", "x", "bc");
|
||||
assert_ne!(a, b);
|
||||
}
|
||||
}
|
||||
@@ -58,6 +58,23 @@ fn validate_hex32(s: &str) -> Result<(), CoreError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Suffix appended to a chunk's vector ID to mark an alias embedding row.
|
||||
pub const ALIAS_SUFFIX: &str = "#alias";
|
||||
|
||||
/// Strip the alias marker from `id`, returning the bare chunk ID.
|
||||
///
|
||||
/// Returns everything before the first occurrence of `ALIAS_SUFFIX`. This
|
||||
/// handles both the suffix form `{orig}#alias` and the per-alias form
|
||||
/// `{orig}#alias#N`. A bare chunk ID is blake3 hex (32 chars, no `#`), so the
|
||||
/// first `#alias` always marks the boundary. If `id` contains no `ALIAS_SUFFIX`,
|
||||
/// returns `id` unchanged.
|
||||
pub fn strip_alias_suffix(id: &str) -> &str {
|
||||
match id.find(ALIAS_SUFFIX) {
|
||||
Some(pos) => &id[..pos],
|
||||
None => id,
|
||||
}
|
||||
}
|
||||
|
||||
/// Canonical-JSON + blake3 + hex prefix 32. Per design §4.2.
|
||||
pub fn id_from<T: Serialize>(tuple: T) -> String {
|
||||
let bytes = serde_json_canonicalizer::to_vec(&tuple)
|
||||
@@ -430,6 +447,20 @@ mod tests {
|
||||
assert_eq!(id.0, "71992c457a5da39880a6d17d646ed0fd");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_alias_suffix_roundtrip() {
|
||||
let bare = "0123456789abcdef0123456789abcdef";
|
||||
let with_suffix = format!("{bare}{ALIAS_SUFFIX}");
|
||||
assert_eq!(strip_alias_suffix(&with_suffix), bare);
|
||||
assert_eq!(strip_alias_suffix(bare), bare);
|
||||
assert_eq!(strip_alias_suffix(""), "");
|
||||
assert_eq!(strip_alias_suffix("#alias"), "");
|
||||
// Per-alias form `{orig}#alias#N` strips to the bare chunk ID.
|
||||
assert_eq!(strip_alias_suffix(&format!("{bare}{ALIAS_SUFFIX}#3")), bare);
|
||||
assert_eq!(strip_alias_suffix(&format!("{bare}{ALIAS_SUFFIX}#0")), bare);
|
||||
assert_eq!(strip_alias_suffix("#alias#3"), "");
|
||||
}
|
||||
|
||||
/// Independent pin for id_for_index.
|
||||
/// inputs:
|
||||
/// collection="default",
|
||||
|
||||
@@ -11,6 +11,7 @@ pub mod answer;
|
||||
pub mod asset;
|
||||
pub mod chunk;
|
||||
pub mod citation;
|
||||
pub mod derivation;
|
||||
pub mod document;
|
||||
pub mod errors;
|
||||
pub mod fetch;
|
||||
@@ -35,6 +36,7 @@ pub use answer::{
|
||||
pub use asset::{AssetStorage, RawAsset, SourceUri, WorkspacePath};
|
||||
pub use chunk::Chunk;
|
||||
pub use citation::Citation;
|
||||
pub use derivation::derivation_cache_key;
|
||||
pub use document::{
|
||||
AudioRefBlock, Block, CanonicalDocument, CodeBlock, CommonBlock, HeadingBlock, ImageRefBlock,
|
||||
Inline, ListBlock, ModelCaption, OcrRegion, OcrText, SourceSpan, TableBlock, TextBlock,
|
||||
@@ -43,8 +45,9 @@ pub use document::{
|
||||
pub use errors::CoreError;
|
||||
pub use fetch::{FetchKind, FetchOpts, FetchQuery, FetchResult};
|
||||
pub use ids::{
|
||||
AssetId, BlockId, ChunkId, DocumentId, EmbeddingId, IndexId, id_for_asset, id_for_block,
|
||||
id_for_chunk, id_for_doc, id_for_embedding, id_for_index, id_from,
|
||||
ALIAS_SUFFIX, AssetId, BlockId, ChunkId, DocumentId, EmbeddingId, IndexId, id_for_asset,
|
||||
id_for_block, id_for_chunk, id_for_doc, id_for_embedding, id_for_index, id_from,
|
||||
strip_alias_suffix,
|
||||
};
|
||||
pub use ingest::{IngestItem, IngestItemKind, IngestReport, SkipExamples};
|
||||
pub use jobs::{JobFilter, JobId, JobKind, JobRow, JobStatus};
|
||||
|
||||
50
crates/kebab-embed-candle/Cargo.toml
Normal file
50
crates/kebab-embed-candle/Cargo.toml
Normal file
@@ -0,0 +1,50 @@
|
||||
[package]
|
||||
name = "kebab-embed-candle"
|
||||
version = { workspace = true }
|
||||
edition = { workspace = true }
|
||||
rust-version = { workspace = true }
|
||||
license = { workspace = true }
|
||||
repository = { workspace = true }
|
||||
description = "Pure-Rust candle adapter implementing kb_core::Embedder (multilingual-e5-large, NUMA-safe thread cap)"
|
||||
|
||||
[dependencies]
|
||||
kebab-core = { path = "../kebab-core" }
|
||||
kebab-config = { path = "../kebab-config" }
|
||||
# candle stack — pinned to the workspace-locked crates.io release (0.10.x),
|
||||
# same versions the Phase 0 spike compiled so build artifacts are reused.
|
||||
candle-core = "0.10.2"
|
||||
candle-nn = "0.10.2"
|
||||
candle-transformers = "0.10.2"
|
||||
tokenizers = "0.21"
|
||||
hf-hub = { version = "0.4", features = ["ureq"] }
|
||||
serde_json = { workspace = true }
|
||||
# Thread cap: a one-shot global rayon pool sizes candle's CPU threads
|
||||
# (the Phase 0 spike proved RAYON_NUM_THREADS caps candle), so a NUMA host
|
||||
# can keep onnxruntime's hard-coded 48-intra-op heap corruption at bay.
|
||||
rayon = "1"
|
||||
anyhow = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
|
||||
[features]
|
||||
# opt-in: run candle on the Apple Silicon GPU (Metal). macOS-only — the build
|
||||
# enables candle's metal backend and `select_device()` picks Metal (CPU fallback
|
||||
# on failure). Lets an M-series Mac ingest e5-large on GPU (10×+ vs CPU); the
|
||||
# resulting vectors are cross-compatible with the CPU path (same model), so the
|
||||
# Linux server can serve queries on CPU candle.
|
||||
metal = ["candle-core/metal", "candle-nn/metal", "candle-transformers/metal"]
|
||||
|
||||
[dev-dependencies]
|
||||
# Integration-test binaries can only see the library's public API + these,
|
||||
# not the library's own (non-dev) dependencies — so rayon/kebab-config/kebab-core
|
||||
# are repeated here for tests/parity.rs and tests/thread_cap.rs.
|
||||
kebab-embed-local = { path = "../kebab-embed-local" }
|
||||
# arctic↔Ollama parity test drives the real Ollama adapter for the reference
|
||||
# vectors (tests/arctic_ollama_parity.rs, `#[ignore]` — live Ollama).
|
||||
kebab-embed-ollama = { path = "../kebab-embed-ollama" }
|
||||
kebab-config = { path = "../kebab-config" }
|
||||
kebab-core = { path = "../kebab-core" }
|
||||
rayon = "1"
|
||||
tempfile = { workspace = true }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
619
crates/kebab-embed-candle/src/lib.rs
Normal file
619
crates/kebab-embed-candle/src/lib.rs
Normal file
@@ -0,0 +1,619 @@
|
||||
//! `kebab-embed-candle` — [`CandleEmbedder`], a pure-Rust (candle)
|
||||
//! implementation of [`Embedder`](kebab_core::Embedder).
|
||||
//!
|
||||
//! Runs an XLM-RoBERTa-large embedding model through `candle`
|
||||
//! (`candle-transformers`' XLM-RoBERTa) instead of onnxruntime. Two models
|
||||
//! are wired through a small **registry** ([`MODEL_REGISTRY`]):
|
||||
//!
|
||||
//! * `multilingual-e5-large` — the same weights the default
|
||||
//! [`FastembedEmbedder`](kebab_embed_local) uses (mean pooling,
|
||||
//! `query: `/`passage: ` prefixes). candle is the NUMA-safe drop-in:
|
||||
//! fastembed 4.9's onnxruntime hard-codes 48 intra-op threads, which
|
||||
//! corrupts the heap (double-free) on dual-socket NUMA hosts. candle's
|
||||
//! CPU backend sizes its threads off the global rayon pool, so a one-shot
|
||||
//! [`rayon::ThreadPoolBuilder`] cap (config `num_threads` / env
|
||||
//! `KEBAB_EMBED_THREADS`) keeps the worker count NUMA-safe.
|
||||
//! * `snowflake-arctic-embed-l-v2.0` — Snowflake's arctic-embed v2.0
|
||||
//! (CLS pooling, `query: ` on queries / no prefix on documents). Same
|
||||
//! XLM-RoBERTa-large architecture, dim 1024, so it rides the exact same
|
||||
//! tokenize → forward → L2 pipeline; only the pooling step and prefixes
|
||||
//! differ (both keyed off the per-model [`EmbedModelSpec`]).
|
||||
//!
|
||||
//! Output parity with the onnxruntime path (for e5) was proven by the
|
||||
//! Phase 0 spike (cosine 1.000000); the arctic path's pooling/prefix
|
||||
//! correctness is pinned by an `#[ignore]`d cosine>0.99 cross-check against
|
||||
//! Ollama's `snowflake-arctic-embed2` (see `tests/arctic_ollama_parity.rs`).
|
||||
//! The shared pipeline:
|
||||
//!
|
||||
//! 1. instruction prefix per [`EmbedModelSpec`] (query/doc);
|
||||
//! 2. tokenize (max_len 512, batch-longest padding, special tokens);
|
||||
//! 3. XLM-RoBERTa forward on the selected [`Device`];
|
||||
//! 4. pooling — mean (attention-mask-weighted) or CLS (first token);
|
||||
//! 5. L2 normalization.
|
||||
//!
|
||||
//! Model files (`config.json`, `tokenizer.json`, `model.safetensors`) are
|
||||
//! fetched via `hf-hub` into `{config.storage.model_dir}/candle/` (hf-hub's
|
||||
//! cache layout namespaces by repo, so e5 and arctic never collide).
|
||||
//!
|
||||
//! This crate is **opt-in** (`config.models.embedding.provider = "candle"`);
|
||||
//! the default provider stays `fastembed`. See
|
||||
//! `docs/superpowers/specs/2026-06-01-embed-candle-track-spec.md` and
|
||||
//! `docs/superpowers/specs/2026-06-03-arctic-embedder-spec.md`.
|
||||
|
||||
use std::sync::Mutex;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use candle_core::{DType, Device, Tensor};
|
||||
use candle_nn::VarBuilder;
|
||||
use candle_transformers::models::xlm_roberta::{Config as XlmConfig, XLMRobertaModel};
|
||||
use kebab_config::{Config, expand_path};
|
||||
use kebab_core::{Embedder, EmbeddingInput, EmbeddingKind, EmbeddingModelId, EmbeddingVersion};
|
||||
use tokenizers::{PaddingParams, PaddingStrategy, Tokenizer, TruncationParams};
|
||||
|
||||
/// Subdirectory under `config.storage.model_dir` where the candle adapter
|
||||
/// caches safetensors + tokenizer. Mirrors `kebab-embed-local`'s
|
||||
/// `fastembed/` subdir so the two backends never collide.
|
||||
const CANDLE_CACHE_SUBDIR: &str = "candle";
|
||||
|
||||
/// Token truncation length (both e5 and arctic-embed-l-v2.0 train at 512).
|
||||
const MAX_LEN: usize = 512;
|
||||
|
||||
/// Env var that overrides `config.models.embedding.num_threads`. Read once in
|
||||
/// [`CandleEmbedder::new`]; `0`/unset/unparseable means "leave rayon default".
|
||||
const ENV_EMBED_THREADS: &str = "KEBAB_EMBED_THREADS";
|
||||
|
||||
/// Pooling strategy over the model's last hidden state. Keyed per-model by
|
||||
/// [`EmbedModelSpec::pooling`] — e5 is mean, arctic is CLS.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Pooling {
|
||||
/// Attention-mask-weighted mean over all tokens (e5 / sentence-transformers
|
||||
/// `pooling_mode_mean_tokens`).
|
||||
Mean,
|
||||
/// First token (`<s>`/`[CLS]`) hidden state (arctic-embed v2.0 —
|
||||
/// `1_Pooling/config.json` has `pooling_mode_cls_token: true`).
|
||||
Cls,
|
||||
}
|
||||
|
||||
/// One supported embedding model: the HF repo candle downloads, the pooling
|
||||
/// strategy, and the e5-style instruction prefixes. [`MODEL_REGISTRY`] maps a
|
||||
/// `config.models.embedding.model` value to one of these.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct EmbedModelSpec {
|
||||
/// The short `config.models.embedding.model` value that selects this spec.
|
||||
pub name: &'static str,
|
||||
/// HuggingFace repo id candle fetches `config.json` / `tokenizer.json` /
|
||||
/// `model.safetensors` from.
|
||||
pub hf_repo: &'static str,
|
||||
/// Pooling over the last hidden state.
|
||||
pub pooling: Pooling,
|
||||
/// Prefix prepended to **query** inputs before tokenization.
|
||||
pub query_prefix: &'static str,
|
||||
/// Prefix prepended to **document** inputs before tokenization (arctic
|
||||
/// uses `""` — documents are embedded raw).
|
||||
pub doc_prefix: &'static str,
|
||||
/// Expected embedding dimension (model hidden size).
|
||||
pub dim: usize,
|
||||
/// Suffix folded into `model_version` so switching **to** this model
|
||||
/// triggers the `embedding_version` cascade even if the operator forgets
|
||||
/// to bump `config.version`. `None` keeps the bare `config.version` — used
|
||||
/// by e5 so candle-e5 and fastembed-e5 report the *same* version and stay
|
||||
/// interchangeable (the NUMA drop-in invariant — Phase 0 cosine 1.0).
|
||||
pub version_tag: Option<&'static str>,
|
||||
}
|
||||
|
||||
/// The models the candle adapter can load. Adding a model = one entry here
|
||||
/// (plus, for a non-XLM-R architecture, a new forward path — both current
|
||||
/// entries are XLM-RoBERTa-large so they share everything but pooling/prefix).
|
||||
static MODEL_REGISTRY: &[EmbedModelSpec] = &[
|
||||
EmbedModelSpec {
|
||||
name: "multilingual-e5-large",
|
||||
hf_repo: "intfloat/multilingual-e5-large",
|
||||
pooling: Pooling::Mean,
|
||||
query_prefix: "query: ",
|
||||
doc_prefix: "passage: ",
|
||||
dim: 1024,
|
||||
version_tag: None,
|
||||
},
|
||||
EmbedModelSpec {
|
||||
name: "snowflake-arctic-embed-l-v2.0",
|
||||
hf_repo: "Snowflake/snowflake-arctic-embed-l-v2.0",
|
||||
pooling: Pooling::Cls,
|
||||
query_prefix: "query: ",
|
||||
doc_prefix: "",
|
||||
dim: 1024,
|
||||
version_tag: Some("arctic-cls"),
|
||||
},
|
||||
];
|
||||
|
||||
/// Look up a model spec by `config.models.embedding.model`. Accepts either the
|
||||
/// short `name` or the full `hf_repo` id (mirrors the old e5 guard, which
|
||||
/// accepted both `multilingual-e5-large` and `intfloat/multilingual-e5-large`).
|
||||
pub(crate) fn lookup_spec(model: &str) -> Option<&'static EmbedModelSpec> {
|
||||
MODEL_REGISTRY
|
||||
.iter()
|
||||
.find(|s| s.name == model || s.hf_repo == model)
|
||||
}
|
||||
|
||||
/// Comma-separated list of supported model names, for the
|
||||
/// unsupported-model error message.
|
||||
fn supported_models() -> String {
|
||||
MODEL_REGISTRY
|
||||
.iter()
|
||||
.map(|s| s.name)
|
||||
.collect::<Vec<_>>()
|
||||
.join("`, `")
|
||||
}
|
||||
|
||||
/// Pure-Rust candle adapter. Construct via [`CandleEmbedder::new`]; the
|
||||
/// constructor downloads the model on first use, so share one instance.
|
||||
pub struct CandleEmbedder {
|
||||
// candle's `forward` is `&self`, but `XLMRobertaModel` is not guaranteed
|
||||
// `Sync`; the `Mutex` both supplies that bound and serializes inference
|
||||
// (callers batch sequentially anyway — same rationale as
|
||||
// `FastembedEmbedder`).
|
||||
model: Mutex<XLMRobertaModel>,
|
||||
tokenizer: Tokenizer,
|
||||
device: Device,
|
||||
/// The resolved model spec (pooling + prefixes) — drives `embed` and
|
||||
/// `embed_batch`.
|
||||
spec: &'static EmbedModelSpec,
|
||||
model_id: EmbeddingModelId,
|
||||
version: EmbeddingVersion,
|
||||
dimensions: usize,
|
||||
batch_size: usize,
|
||||
}
|
||||
|
||||
impl CandleEmbedder {
|
||||
/// Build an embedder from `Config`. Resolves the model spec from
|
||||
/// `config.models.embedding.model`, applies the NUMA thread cap, fetches
|
||||
/// the model into `{model_dir}/candle/`, and validates that the model's
|
||||
/// hidden size matches `config.models.embedding.dimensions` before
|
||||
/// returning.
|
||||
pub fn new(config: &Config) -> Result<Self> {
|
||||
// 1. NUMA thread cap. env `KEBAB_EMBED_THREADS` wins over the config
|
||||
// field; `0`/unset leaves rayon's default. `build_global` errors if
|
||||
// the pool was already initialized — intentionally ignored so a
|
||||
// second embedder (or a prior rayon user) is a no-op, not a failure.
|
||||
let n_threads = std::env::var(ENV_EMBED_THREADS)
|
||||
.ok()
|
||||
.and_then(|v| v.parse::<usize>().ok())
|
||||
.unwrap_or(config.models.embedding.num_threads as usize);
|
||||
if n_threads > 0 {
|
||||
if apply_thread_cap(n_threads) {
|
||||
tracing::info!(
|
||||
target: "kebab-embed-candle",
|
||||
num_threads = n_threads,
|
||||
"capped global rayon pool for candle CPU backend"
|
||||
);
|
||||
} else {
|
||||
tracing::debug!(
|
||||
target: "kebab-embed-candle",
|
||||
requested = n_threads,
|
||||
"global rayon pool already initialized; thread cap not applied"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 1b. Model registry lookup. If the operator configured a model the
|
||||
// candle adapter doesn't know, fail fast (BEFORE the ~2GB
|
||||
// download) — never silently download one model and then label its
|
||||
// vectors with another name via `model_id()`, which would mislabel
|
||||
// `embedding_version` and corrupt a mixed index.
|
||||
let want = config.models.embedding.model.as_str();
|
||||
let spec = lookup_spec(want).ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"candle provider supports the models `{}`, but \
|
||||
config.models.embedding.model = '{want}'. Use provider=fastembed \
|
||||
for other models, or pick a supported one.",
|
||||
supported_models()
|
||||
)
|
||||
})?;
|
||||
|
||||
// 2. Resolve `{data_dir}/models/candle/` exactly like the fastembed
|
||||
// adapter resolves its own subdir.
|
||||
let data_dir = expand_path(&config.storage.data_dir, "");
|
||||
let model_dir = expand_path(&config.storage.model_dir, &data_dir.to_string_lossy());
|
||||
let cache_dir = model_dir.join(CANDLE_CACHE_SUBDIR);
|
||||
std::fs::create_dir_all(&cache_dir)
|
||||
.with_context(|| format!("create candle cache dir {}", cache_dir.display()))?;
|
||||
|
||||
let device = select_device();
|
||||
|
||||
// 3. Fetch model files via hf-hub into the candle cache.
|
||||
tracing::info!(
|
||||
target: "kebab-embed-candle",
|
||||
cache_dir = %cache_dir.display(),
|
||||
model = spec.hf_repo,
|
||||
pooling = ?spec.pooling,
|
||||
"loading candle embedding model (first run downloads ~2GB safetensors)"
|
||||
);
|
||||
let api = hf_hub::api::sync::ApiBuilder::new()
|
||||
.with_cache_dir(cache_dir.clone())
|
||||
.build()
|
||||
.context("kb-embed-candle: build hf-hub api")?;
|
||||
let repo = api.model(spec.hf_repo.to_string());
|
||||
let config_path = repo.get("config.json").context("download config.json")?;
|
||||
let tokenizer_path = repo
|
||||
.get("tokenizer.json")
|
||||
.context("download tokenizer.json")?;
|
||||
let weights_path = repo
|
||||
.get("model.safetensors")
|
||||
.context("download model.safetensors")?;
|
||||
|
||||
// 4. Build the candle XLM-RoBERTa model.
|
||||
let cfg_json = std::fs::read_to_string(&config_path)
|
||||
.with_context(|| format!("read {}", config_path.display()))?;
|
||||
let cfg: XlmConfig =
|
||||
serde_json::from_str(&cfg_json).context("kb-embed-candle: parse XLM-R config")?;
|
||||
|
||||
// Validate dim BEFORE building the model so a misconfigured
|
||||
// `dimensions` fails cheaply (matches FastembedEmbedder's contract).
|
||||
check_dim(cfg.hidden_size, config.models.embedding.dimensions)?;
|
||||
|
||||
let vb = unsafe {
|
||||
VarBuilder::from_mmaped_safetensors(&[weights_path], DType::F32, &device)
|
||||
.context("kb-embed-candle: mmap safetensors")?
|
||||
};
|
||||
let model =
|
||||
XLMRobertaModel::new(&cfg, vb).context("kb-embed-candle: build XLMRobertaModel")?;
|
||||
|
||||
let mut tokenizer = Tokenizer::from_file(&tokenizer_path)
|
||||
.map_err(|e| anyhow::anyhow!("kb-embed-candle: load tokenizer: {e}"))?;
|
||||
tokenizer
|
||||
.with_padding(Some(PaddingParams {
|
||||
strategy: PaddingStrategy::BatchLongest,
|
||||
..Default::default()
|
||||
}))
|
||||
.with_truncation(Some(TruncationParams {
|
||||
max_length: MAX_LEN,
|
||||
..Default::default()
|
||||
}))
|
||||
.map_err(|e| anyhow::anyhow!("kb-embed-candle: set truncation: {e}"))?;
|
||||
|
||||
// model_version: fold the model tag in for non-e5 models so a switch
|
||||
// triggers the embedding_version cascade; e5 keeps the bare
|
||||
// config.version to stay interchangeable with fastembed-e5.
|
||||
let version = match spec.version_tag {
|
||||
Some(tag) => {
|
||||
EmbeddingVersion(format!("{}+{}", config.models.embedding.version, tag))
|
||||
}
|
||||
None => EmbeddingVersion(config.models.embedding.version.clone()),
|
||||
};
|
||||
|
||||
tracing::info!(
|
||||
target: "kebab-embed-candle",
|
||||
dimensions = cfg.hidden_size,
|
||||
layers = cfg.num_hidden_layers,
|
||||
model = spec.name,
|
||||
"candle embedding model loaded"
|
||||
);
|
||||
|
||||
Ok(Self {
|
||||
model: Mutex::new(model),
|
||||
tokenizer,
|
||||
device,
|
||||
spec,
|
||||
model_id: EmbeddingModelId(config.models.embedding.model.clone()),
|
||||
version,
|
||||
dimensions: cfg.hidden_size,
|
||||
batch_size: config.models.embedding.batch_size.max(1),
|
||||
})
|
||||
}
|
||||
|
||||
/// Embed one batch of **already-prefixed** strings (the per-model prefix
|
||||
/// is applied by the caller [`CandleEmbedder::embed`]) through the candle
|
||||
/// pipeline: tokenize → forward → pool (mean|CLS) → L2.
|
||||
fn embed_batch(&self, prefixed: &[String]) -> Result<Vec<Vec<f32>>> {
|
||||
let encodings = self
|
||||
.tokenizer
|
||||
.encode_batch(prefixed.to_vec(), true)
|
||||
.map_err(|e| anyhow::anyhow!("kb-embed-candle: encode_batch: {e}"))?;
|
||||
|
||||
let bsz = encodings.len();
|
||||
// `embed` already returns early on empty input and `.chunks()` never
|
||||
// yields an empty slice, so this is currently unreachable — but guard
|
||||
// the index so a future refactor can't turn it into a panic.
|
||||
let Some(first) = encodings.first() else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let seq = first.get_ids().len();
|
||||
|
||||
let mut ids = Vec::with_capacity(bsz * seq);
|
||||
let mut mask = Vec::with_capacity(bsz * seq);
|
||||
for enc in &encodings {
|
||||
ids.extend(enc.get_ids().iter().copied());
|
||||
mask.extend(enc.get_attention_mask().iter().map(|&m| m as f32));
|
||||
}
|
||||
|
||||
let input_ids = Tensor::from_vec(ids, (bsz, seq), &self.device)?;
|
||||
let attn_f32 = Tensor::from_vec(mask, (bsz, seq), &self.device)?;
|
||||
let token_type_ids = input_ids.zeros_like()?;
|
||||
|
||||
let hidden = {
|
||||
let guard = self
|
||||
.model
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
// forward: (input_ids, attention_mask, token_type_ids, past,
|
||||
// encoder_hidden, encoder_mask)
|
||||
guard.forward(&input_ids, &attn_f32, &token_type_ids, None, None, None)?
|
||||
};
|
||||
|
||||
// Pooling — per the model spec.
|
||||
let pooled = match self.spec.pooling {
|
||||
Pooling::Mean => {
|
||||
// attention-mask-weighted mean pooling
|
||||
let mask3 = attn_f32.unsqueeze(2)?; // (b, seq, 1)
|
||||
let summed = hidden.broadcast_mul(&mask3)?.sum(1)?; // (b, hidden)
|
||||
// counts ≥ 1 always: every input is prefixed AND special
|
||||
// tokens are added (encode_batch(_, true)), so no row has an
|
||||
// all-zero mask. If that invariant ever breaks, broadcast_div
|
||||
// would emit NaN vectors.
|
||||
let counts = mask3.sum(1)?; // (b, 1)
|
||||
summed.broadcast_div(&counts)?
|
||||
}
|
||||
Pooling::Cls => {
|
||||
// CLS pooling: the first token's hidden state. arctic-embed
|
||||
// v2.0 prepends `<s>` (the XLM-R BOS/CLS) at index 0, so
|
||||
// `hidden[:, 0, :]` is the sentence embedding.
|
||||
hidden.narrow(1, 0, 1)?.squeeze(1)? // (b, hidden)
|
||||
}
|
||||
};
|
||||
|
||||
// L2 normalize
|
||||
let norm = pooled.sqr()?.sum_keepdim(1)?.sqrt()?;
|
||||
let normalized = pooled.broadcast_div(&norm)?;
|
||||
|
||||
// `.contiguous()` before host copy: broadcast ops can leave a strided
|
||||
// view, which `to_vec2` rejects on the Metal backend (CPU tolerates it).
|
||||
Ok(normalized.contiguous()?.to_vec2::<f32>()?)
|
||||
}
|
||||
}
|
||||
|
||||
impl Embedder for CandleEmbedder {
|
||||
fn model_id(&self) -> EmbeddingModelId {
|
||||
self.model_id.clone()
|
||||
}
|
||||
|
||||
fn model_version(&self) -> EmbeddingVersion {
|
||||
self.version.clone()
|
||||
}
|
||||
|
||||
fn dimensions(&self) -> usize {
|
||||
self.dimensions
|
||||
}
|
||||
|
||||
fn embed(&self, inputs: &[EmbeddingInput<'_>]) -> Result<Vec<Vec<f32>>> {
|
||||
if inputs.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
// Per-model instruction prefix BEFORE tokenization (same convention as
|
||||
// FastembedEmbedder for e5; arctic uses `query: `/no-prefix).
|
||||
let prefixed: Vec<String> = inputs.iter().map(|i| prefix_input(self.spec, i)).collect();
|
||||
|
||||
let mut out: Vec<Vec<f32>> = Vec::with_capacity(prefixed.len());
|
||||
for chunk in prefixed.chunks(self.batch_size) {
|
||||
let batch = self.embed_batch(chunk)?;
|
||||
for v in &batch {
|
||||
if v.len() != self.dimensions {
|
||||
anyhow::bail!(
|
||||
"candle returned vector of length {} but adapter expects {}",
|
||||
v.len(),
|
||||
self.dimensions
|
||||
);
|
||||
}
|
||||
}
|
||||
out.extend(batch);
|
||||
}
|
||||
|
||||
debug_assert_eq!(out.len(), inputs.len());
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the prefixed string for one [`EmbeddingInput`] using the model spec.
|
||||
/// Free function so a unit test can pin the format without loading the model.
|
||||
/// For e5 this is byte-identical to `kebab-embed-local`'s `prefix_input` — the
|
||||
/// two backends MUST agree there or their vectors diverge.
|
||||
fn prefix_input(spec: &EmbedModelSpec, input: &EmbeddingInput<'_>) -> String {
|
||||
match input.kind {
|
||||
EmbeddingKind::Document => format!("{}{}", spec.doc_prefix, input.text),
|
||||
EmbeddingKind::Query => format!("{}{}", spec.query_prefix, input.text),
|
||||
}
|
||||
}
|
||||
|
||||
/// Select the compute device. Built with the `metal` feature (Apple Silicon
|
||||
/// GPU), try Metal and fall back to CPU on failure; otherwise CPU. Metal only
|
||||
/// compiles/runs on macOS — the Linux server builds the CPU path. Embedding
|
||||
/// vectors are model-defined, so Metal-produced and CPU-produced embeddings
|
||||
/// are cross-compatible (a Mac can ingest on GPU, the server query on CPU).
|
||||
fn select_device() -> Device {
|
||||
#[cfg(feature = "metal")]
|
||||
{
|
||||
match Device::new_metal(0) {
|
||||
Ok(d) => {
|
||||
tracing::info!(target: "kebab-embed-candle", "candle device = Metal (GPU)");
|
||||
return d;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
target: "kebab-embed-candle",
|
||||
error = %e,
|
||||
"Metal device unavailable; falling back to CPU"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
tracing::info!(target: "kebab-embed-candle", "candle device = CPU");
|
||||
Device::Cpu
|
||||
}
|
||||
|
||||
/// Apply a one-shot global rayon thread cap (the NUMA-safety lever). Returns
|
||||
/// `true` if this call set the pool, `false` if it was already initialized
|
||||
/// (cap not applied) or `n_threads == 0`. `#[doc(hidden)] pub` so the
|
||||
/// thread-cap test can drive it without loading the 2GB model.
|
||||
#[doc(hidden)]
|
||||
pub fn apply_thread_cap(n_threads: usize) -> bool {
|
||||
if n_threads == 0 {
|
||||
return false;
|
||||
}
|
||||
rayon::ThreadPoolBuilder::new()
|
||||
.num_threads(n_threads)
|
||||
.build_global()
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
/// Compare model hidden size against the configured dim. Extracted so a unit
|
||||
/// test can exercise the error branch without loading the model.
|
||||
pub(crate) fn check_dim(model_dim: usize, cfg_dim: usize) -> Result<()> {
|
||||
if model_dim != cfg_dim {
|
||||
anyhow::bail!(
|
||||
"dimension mismatch: model={model_dim}, config={cfg_dim}; \
|
||||
update `config.models.embedding.dimensions` to match the model \
|
||||
(or pick a different model)."
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn e5_spec() -> &'static EmbedModelSpec {
|
||||
lookup_spec("multilingual-e5-large").expect("e5 in registry")
|
||||
}
|
||||
|
||||
fn arctic_spec() -> &'static EmbedModelSpec {
|
||||
lookup_spec("snowflake-arctic-embed-l-v2.0").expect("arctic in registry")
|
||||
}
|
||||
|
||||
// ── registry ─────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn registry_resolves_e5_by_name_and_hf_repo() {
|
||||
assert_eq!(
|
||||
lookup_spec("multilingual-e5-large").map(|s| s.name),
|
||||
Some("multilingual-e5-large")
|
||||
);
|
||||
assert_eq!(
|
||||
lookup_spec("intfloat/multilingual-e5-large").map(|s| s.name),
|
||||
Some("multilingual-e5-large")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_resolves_arctic_and_its_pooling_is_cls() {
|
||||
let s = arctic_spec();
|
||||
assert_eq!(s.name, "snowflake-arctic-embed-l-v2.0");
|
||||
assert_eq!(s.hf_repo, "Snowflake/snowflake-arctic-embed-l-v2.0");
|
||||
assert_eq!(s.pooling, Pooling::Cls);
|
||||
assert_eq!(s.dim, 1024);
|
||||
assert_eq!(s.version_tag, Some("arctic-cls"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_e5_is_mean_pooling_no_version_tag() {
|
||||
let s = e5_spec();
|
||||
assert_eq!(s.pooling, Pooling::Mean);
|
||||
assert_eq!(s.version_tag, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_rejects_unknown_model() {
|
||||
assert!(lookup_spec("multilingual-e5-small").is_none());
|
||||
}
|
||||
|
||||
// ── prefix_input ─────────────────────────────────────────────────
|
||||
// e5 prefixes MUST match kebab-embed-local::prefix_input or candle vs
|
||||
// fastembed parity breaks; arctic uses query-only prefixing.
|
||||
|
||||
#[test]
|
||||
fn e5_prefix_document_uses_passage() {
|
||||
let input = EmbeddingInput {
|
||||
text: "hello world",
|
||||
kind: EmbeddingKind::Document,
|
||||
};
|
||||
assert_eq!(prefix_input(e5_spec(), &input), "passage: hello world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn e5_prefix_query_uses_query() {
|
||||
let input = EmbeddingInput {
|
||||
text: "hello world",
|
||||
kind: EmbeddingKind::Query,
|
||||
};
|
||||
assert_eq!(prefix_input(e5_spec(), &input), "query: hello world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arctic_prefix_query_uses_query_doc_is_bare() {
|
||||
let doc = EmbeddingInput {
|
||||
text: "후입선출 자료구조",
|
||||
kind: EmbeddingKind::Document,
|
||||
};
|
||||
let qry = EmbeddingInput {
|
||||
text: "스택 자료구조",
|
||||
kind: EmbeddingKind::Query,
|
||||
};
|
||||
// arctic: documents are embedded raw, queries get `query: `.
|
||||
assert_eq!(prefix_input(arctic_spec(), &doc), "후입선출 자료구조");
|
||||
assert_eq!(prefix_input(arctic_spec(), &qry), "query: 스택 자료구조");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefix_handles_empty_text() {
|
||||
let doc = EmbeddingInput {
|
||||
text: "",
|
||||
kind: EmbeddingKind::Document,
|
||||
};
|
||||
let qry = EmbeddingInput {
|
||||
text: "",
|
||||
kind: EmbeddingKind::Query,
|
||||
};
|
||||
assert_eq!(prefix_input(e5_spec(), &doc), "passage: ");
|
||||
assert_eq!(prefix_input(e5_spec(), &qry), "query: ");
|
||||
assert_eq!(prefix_input(arctic_spec(), &doc), "");
|
||||
assert_eq!(prefix_input(arctic_spec(), &qry), "query: ");
|
||||
}
|
||||
|
||||
// ── check_dim ────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn check_dim_passes_for_1024() {
|
||||
check_dim(1024, 1024).expect("matching dims must pass");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_dim_rejects_384_vs_1024() {
|
||||
let err = check_dim(384, 1024).expect_err("dim mismatch must error");
|
||||
let msg = format!("{err}");
|
||||
assert!(
|
||||
msg.contains("384") && msg.contains("1024"),
|
||||
"error must mention both dims, got: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
// ── model guard ──────────────────────────────────────────────────
|
||||
// A model name not in the registry must fail fast (BEFORE the ~2GB
|
||||
// download), so we never download one model yet label its vectors with
|
||||
// another name via model_id() — which would mislabel embedding_version.
|
||||
|
||||
#[test]
|
||||
fn new_rejects_unsupported_model() {
|
||||
let mut config = kebab_config::Config::defaults();
|
||||
config.models.embedding.model = "multilingual-e5-small".to_string();
|
||||
// num_threads defaults to 0, so no global rayon side effect here.
|
||||
// `.err()` (not `expect_err`) avoids requiring `CandleEmbedder: Debug`
|
||||
// — it holds a Mutex/Tokenizer and intentionally derives no Debug.
|
||||
let err = CandleEmbedder::new(&config)
|
||||
.err()
|
||||
.expect("unsupported model must error");
|
||||
let msg = format!("{err:#}");
|
||||
assert!(
|
||||
msg.contains("candle provider supports the models"),
|
||||
"expected model-registry error, got: {msg}"
|
||||
);
|
||||
}
|
||||
}
|
||||
128
crates/kebab-embed-candle/tests/arctic_ollama_parity.rs
Normal file
128
crates/kebab-embed-candle/tests/arctic_ollama_parity.rs
Normal file
@@ -0,0 +1,128 @@
|
||||
//! arctic-embed-l-v2.0 correctness gate (`#[ignore]` — needs the ~2GB candle
|
||||
//! model + a live Ollama serving `snowflake-arctic-embed2`).
|
||||
//!
|
||||
//! This is the load-bearing pooling/prefix check for the arctic integration.
|
||||
//! The recall measurement that justified adopting arctic (recall@10 130/132)
|
||||
//! went through Ollama's `snowflake-arctic-embed2`. The candle path
|
||||
//! re-implements the model (XLM-RoBERTa-large + **CLS** pooling + `query: ` on
|
||||
//! queries / no prefix on documents). If candle's pooling or prefix is wrong,
|
||||
//! its vectors silently diverge from the measured route and the 130 number
|
||||
//! does NOT carry over. This test pins them together: per-sentence cosine
|
||||
//! between the candle vector and the Ollama vector must be **> 0.99**.
|
||||
//!
|
||||
//! `#[ignore]` because it depends on an external Ollama daemon (CI is
|
||||
//! headless/offline). The leader MUST run it once before merge.
|
||||
//!
|
||||
//! ## Manual run
|
||||
//!
|
||||
//! 1. Confirm Ollama is reachable and has the model:
|
||||
//! ```sh
|
||||
//! curl -s http://192.168.0.47:11434/api/tags # should list snowflake-arctic-embed2
|
||||
//! ```
|
||||
//! 2. Run (downloads the ~2GB candle safetensors on first run):
|
||||
//! ```sh
|
||||
//! CARGO_TARGET_DIR=/build/out/cargo-target \
|
||||
//! KEBAB_ARCTIC_OLLAMA_ENDPOINT=http://192.168.0.47:11434 \
|
||||
//! cargo test -p kebab-embed-candle --test arctic_ollama_parity -- --ignored --nocapture
|
||||
//! ```
|
||||
//! The endpoint defaults to `http://192.168.0.47:11434` if the env is unset.
|
||||
//!
|
||||
//! Record the printed `ARCTIC_PARITY_SUMMARY cosine_min=...` in
|
||||
//! `/tmp/arctic-result.md` + `tasks/HOTFIXES.md`.
|
||||
|
||||
use kebab_config::Config;
|
||||
use kebab_core::{Embedder, EmbeddingInput, EmbeddingKind};
|
||||
use kebab_embed_candle::CandleEmbedder;
|
||||
use kebab_embed_ollama::OllamaEmbedder;
|
||||
|
||||
const DOGFOOD_CONFIG: &str = "/build/dogfood/config.toml";
|
||||
const DEFAULT_OLLAMA_ENDPOINT: &str = "http://192.168.0.47:11434";
|
||||
|
||||
/// Mixed Korean / English + the descriptive-recall shapes arctic was adopted
|
||||
/// for (synonym / abbreviation / English term). Covers both prefix paths.
|
||||
const SENTENCES: &[&str] = &[
|
||||
"스택 자료구조",
|
||||
"후입선출 방식으로 동작하는 자료구조",
|
||||
"큐는 선입선출 자료구조이다",
|
||||
"Rust ownership and the borrow checker",
|
||||
"소유권과 빌림 검사기는 메모리 안전성을 보장한다",
|
||||
"SVM 은 support vector machine 의 약자이다",
|
||||
"정렬 알고리즘의 시간 복잡도",
|
||||
"The capital of France is Paris.",
|
||||
];
|
||||
|
||||
fn cosine(a: &[f32], b: &[f32]) -> f32 {
|
||||
let dot: f32 = a.iter().zip(b).map(|(x, y)| x * y).sum();
|
||||
let na: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
|
||||
let nb: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
|
||||
dot / (na * nb)
|
||||
}
|
||||
|
||||
/// Base config: prefer the canonical dogfood config (for storage/cache roots),
|
||||
/// fall back to `Config::defaults()` so the test still runs on a bare clone.
|
||||
fn base_config() -> Config {
|
||||
Config::load(Some(std::path::Path::new(DOGFOOD_CONFIG))).unwrap_or_else(|_| Config::defaults())
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs ~2GB candle model + live Ollama (snowflake-arctic-embed2); run manually before merge"]
|
||||
fn candle_arctic_matches_ollama_arctic() {
|
||||
let endpoint = std::env::var("KEBAB_ARCTIC_OLLAMA_ENDPOINT")
|
||||
.unwrap_or_else(|_| DEFAULT_OLLAMA_ENDPOINT.to_string());
|
||||
|
||||
// candle side: the in-process arctic model.
|
||||
let mut candle_cfg = base_config();
|
||||
candle_cfg.models.embedding.provider = "candle".to_string();
|
||||
candle_cfg.models.embedding.model = "snowflake-arctic-embed-l-v2.0".to_string();
|
||||
candle_cfg.models.embedding.dimensions = 1024;
|
||||
|
||||
// Ollama side: the reference route the recall numbers came from.
|
||||
let mut ollama_cfg = base_config();
|
||||
ollama_cfg.models.embedding.provider = "ollama".to_string();
|
||||
ollama_cfg.models.embedding.model = "snowflake-arctic-embed2".to_string();
|
||||
ollama_cfg.models.embedding.dimensions = 1024;
|
||||
ollama_cfg.models.embedding.endpoint = Some(endpoint.clone());
|
||||
|
||||
let candle = CandleEmbedder::new(&candle_cfg).expect("build candle arctic embedder");
|
||||
let ollama = OllamaEmbedder::new(&ollama_cfg).expect("build ollama arctic embedder");
|
||||
|
||||
// Exercise BOTH prefix paths so a query-side divergence can't hide.
|
||||
let inputs: Vec<EmbeddingInput> = SENTENCES
|
||||
.iter()
|
||||
.flat_map(|s| {
|
||||
[EmbeddingKind::Document, EmbeddingKind::Query]
|
||||
.into_iter()
|
||||
.map(move |kind| EmbeddingInput { text: s, kind })
|
||||
})
|
||||
.collect();
|
||||
|
||||
let cv = candle.embed(&inputs).expect("candle embed");
|
||||
let ov = ollama
|
||||
.embed(&inputs)
|
||||
.expect("ollama embed (is snowflake-arctic-embed2 pulled @ the endpoint?)");
|
||||
|
||||
assert_eq!(cv.len(), ov.len(), "embedding counts must match");
|
||||
assert_eq!(cv.len(), inputs.len(), "one vector per input");
|
||||
assert_eq!(candle.dimensions(), 1024);
|
||||
|
||||
let mut min_cos = f32::INFINITY;
|
||||
for (i, inp) in inputs.iter().enumerate() {
|
||||
assert_eq!(cv[i].len(), 1024, "candle dim");
|
||||
assert_eq!(ov[i].len(), 1024, "ollama dim");
|
||||
let c = cosine(&cv[i], &ov[i]);
|
||||
min_cos = min_cos.min(c);
|
||||
let kind = match inp.kind {
|
||||
EmbeddingKind::Document => "doc",
|
||||
EmbeddingKind::Query => "qry",
|
||||
};
|
||||
let preview: String = inp.text.chars().take(36).collect();
|
||||
println!("[{i:>2}] {kind} cos={c:.6} {preview}");
|
||||
}
|
||||
|
||||
println!("ARCTIC_PARITY_SUMMARY cosine_min={min_cos:.6} endpoint={endpoint}");
|
||||
assert!(
|
||||
min_cos > 0.99,
|
||||
"candle arctic vs Ollama arctic cosine_min={min_cos:.6} ≤ 0.99 — \
|
||||
pooling/prefix mismatch; the recall=130 measurement will NOT reproduce"
|
||||
);
|
||||
}
|
||||
96
crates/kebab-embed-candle/tests/parity.rs
Normal file
96
crates/kebab-embed-candle/tests/parity.rs
Normal file
@@ -0,0 +1,96 @@
|
||||
//! Parity test (spec §7, `#[ignore]` — needs the ~2GB model + network).
|
||||
//!
|
||||
//! Confirms the candle backend reproduces the onnxruntime `FastembedEmbedder`
|
||||
//! vectors closely enough that no re-index is required (spec D-reindex):
|
||||
//! per-sentence cosine ≥ 0.9999, and reports the dimension-wise max absolute
|
||||
//! difference (the number the re-index decision hangs on).
|
||||
//!
|
||||
//! Run manually:
|
||||
//! CARGO_TARGET_DIR=/build/out/cargo-target/target \
|
||||
//! cargo test -p kebab-embed-candle --release -- --ignored --nocapture
|
||||
//!
|
||||
//! Uses the canonical dogfood config so both backends resolve the same model
|
||||
//! identifiers and cache roots.
|
||||
|
||||
use kebab_config::Config;
|
||||
use kebab_core::{Embedder, EmbeddingInput, EmbeddingKind};
|
||||
use kebab_embed_candle::CandleEmbedder;
|
||||
use kebab_embed_local::FastembedEmbedder;
|
||||
|
||||
const DOGFOOD_CONFIG: &str = "/build/dogfood/config.toml";
|
||||
|
||||
/// Mixed Korean / English parity set (≥ 8 sentences, mirrors the Phase 0 spike).
|
||||
const SENTENCES: &[&str] = &[
|
||||
"The quick brown fox jumps over the lazy dog.",
|
||||
"오늘 날씨가 정말 좋아서 산책을 나가고 싶다.",
|
||||
"Rust is a systems programming language focused on safety and performance.",
|
||||
"벡터 검색은 임베딩 사이의 코사인 유사도를 이용한다.",
|
||||
"Machine learning models require large amounts of training data.",
|
||||
"한국어와 영어가 섞인 문장도 멀티링구얼 모델은 잘 처리한다.",
|
||||
"The capital of France is Paris, a city known for its art and culture.",
|
||||
"이 프로젝트는 로컬 우선 지식 베이스와 검색 증강 생성을 목표로 한다.",
|
||||
"Database indexing dramatically speeds up query performance.",
|
||||
"임베딩 모델을 candle 로 옮기면 NUMA 서버에서 안전하게 돌릴 수 있다.",
|
||||
];
|
||||
|
||||
fn cosine(a: &[f32], b: &[f32]) -> f32 {
|
||||
let dot: f32 = a.iter().zip(b).map(|(x, y)| x * y).sum();
|
||||
let na: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
|
||||
let nb: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
|
||||
dot / (na * nb)
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs ~2GB model + network; run manually for the re-index decision"]
|
||||
fn candle_matches_fastembed() {
|
||||
let config = Config::load(Some(std::path::Path::new(DOGFOOD_CONFIG)))
|
||||
.expect("load dogfood config for parity baseline");
|
||||
|
||||
let candle = CandleEmbedder::new(&config).expect("build CandleEmbedder");
|
||||
let fastembed = FastembedEmbedder::new(&config).expect("build FastembedEmbedder");
|
||||
|
||||
// Cover BOTH prefix paths (`passage:` for Document, `query:` for Query) so
|
||||
// a query-side prefix/pooling divergence can't slip through (reviewer note).
|
||||
let inputs: Vec<EmbeddingInput> = SENTENCES
|
||||
.iter()
|
||||
.flat_map(|s| {
|
||||
[EmbeddingKind::Document, EmbeddingKind::Query]
|
||||
.into_iter()
|
||||
.map(move |kind| EmbeddingInput { text: s, kind })
|
||||
})
|
||||
.collect();
|
||||
|
||||
let cv = candle.embed(&inputs).expect("candle embed");
|
||||
let fv = fastembed.embed(&inputs).expect("fastembed embed");
|
||||
|
||||
assert_eq!(cv.len(), fv.len(), "embedding counts must match");
|
||||
assert_eq!(cv.len(), inputs.len(), "one vector per input");
|
||||
assert_eq!(candle.dimensions(), 1024);
|
||||
|
||||
let mut min_cos = f32::INFINITY;
|
||||
let mut max_abs_diff = 0f32;
|
||||
for (i, inp) in inputs.iter().enumerate() {
|
||||
assert_eq!(cv[i].len(), 1024, "candle dim");
|
||||
assert_eq!(fv[i].len(), 1024, "fastembed dim");
|
||||
let c = cosine(&cv[i], &fv[i]);
|
||||
min_cos = min_cos.min(c);
|
||||
let diff = cv[i]
|
||||
.iter()
|
||||
.zip(&fv[i])
|
||||
.map(|(a, b)| (a - b).abs())
|
||||
.fold(0f32, f32::max);
|
||||
max_abs_diff = max_abs_diff.max(diff);
|
||||
let kind = match inp.kind {
|
||||
EmbeddingKind::Document => "doc",
|
||||
EmbeddingKind::Query => "qry",
|
||||
};
|
||||
let preview: String = inp.text.chars().take(36).collect();
|
||||
println!("[{i:>2}] {kind} cos={c:.6} max_abs_diff={diff:.6e} {preview}");
|
||||
}
|
||||
|
||||
println!("PARITY_SUMMARY cosine_min={min_cos:.6} max_abs_diff={max_abs_diff:.6e}");
|
||||
assert!(
|
||||
min_cos >= 0.9999,
|
||||
"candle vs fastembed cosine_min={min_cos:.6} < 0.9999 — investigate before merge"
|
||||
);
|
||||
}
|
||||
32
crates/kebab-embed-candle/tests/thread_cap.rs
Normal file
32
crates/kebab-embed-candle/tests/thread_cap.rs
Normal file
@@ -0,0 +1,32 @@
|
||||
//! Thread-cap test (spec §7). Own integration binary → clean process, so the
|
||||
//! one-shot global rayon pool is initialized exactly once, by us.
|
||||
//!
|
||||
//! Verifies that `apply_thread_cap(4)` sizes the global rayon pool to 4, which
|
||||
//! is the lever that keeps candle's CPU backend NUMA-safe (vs onnxruntime's
|
||||
//! hard-coded 48 intra-op threads).
|
||||
|
||||
use kebab_embed_candle::apply_thread_cap;
|
||||
|
||||
#[test]
|
||||
fn thread_cap_sizes_global_rayon_pool() {
|
||||
// Must run before any other rayon use in this process. As the only test in
|
||||
// this binary that touches rayon, that holds.
|
||||
let applied = apply_thread_cap(4);
|
||||
assert!(applied, "first build_global call should succeed");
|
||||
assert_eq!(
|
||||
rayon::current_num_threads(),
|
||||
4,
|
||||
"global rayon pool must be capped at the requested 4 threads"
|
||||
);
|
||||
|
||||
// A second cap attempt is a no-op (pool already built), not a panic.
|
||||
assert!(
|
||||
!apply_thread_cap(8),
|
||||
"second build_global must report not-applied"
|
||||
);
|
||||
assert_eq!(
|
||||
rayon::current_num_threads(),
|
||||
4,
|
||||
"thread count must stay at the first cap"
|
||||
);
|
||||
}
|
||||
30
crates/kebab-embed-ollama/Cargo.toml
Normal file
30
crates/kebab-embed-ollama/Cargo.toml
Normal file
@@ -0,0 +1,30 @@
|
||||
[package]
|
||||
name = "kebab-embed-ollama"
|
||||
version = { workspace = true }
|
||||
edition = { workspace = true }
|
||||
rust-version = { workspace = true }
|
||||
license = { workspace = true }
|
||||
repository = { workspace = true }
|
||||
description = "Ollama HTTP adapter implementing kebab_core::Embedder (POST /api/embed, L2-normalized, batched + fail-soft)"
|
||||
|
||||
[dependencies]
|
||||
kebab-core = { path = "../kebab-core" }
|
||||
kebab-config = { path = "../kebab-config" }
|
||||
# `default-features = false` drops native-tls (system OpenSSL); we pin rustls.
|
||||
# reqwest 0.12's `blocking` feature wraps a private current-thread tokio
|
||||
# runtime — this crate exposes NO async surface (no `async`/`await`/`tokio::*`
|
||||
# symbols), matching the kebab-llm-local invariant.
|
||||
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
# wiremock hosts the mock /api/embed server (needs a tokio runtime); tokio is
|
||||
# also pulled transitively at runtime by reqwest's `blocking` feature.
|
||||
wiremock = { workspace = true }
|
||||
tokio = { workspace = true, features = ["macros", "rt"] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
310
crates/kebab-embed-ollama/src/lib.rs
Normal file
310
crates/kebab-embed-ollama/src/lib.rs
Normal file
@@ -0,0 +1,310 @@
|
||||
//! `kebab-embed-ollama` — [`OllamaEmbedder`], a `reqwest::blocking` adapter
|
||||
//! implementing [`Embedder`](kebab_core::Embedder) over Ollama's
|
||||
//! `POST /api/embed` endpoint.
|
||||
//!
|
||||
//! ## Why this exists
|
||||
//!
|
||||
//! The candle backend ([`kebab-embed-candle`]) runs arctic-embed-l-v2.0
|
||||
//! in-process (pure Rust, NUMA-safe). This crate is the **fallback** path:
|
||||
//! it offloads embedding to a local/remote Ollama daemon (`snowflake-arctic-embed2`),
|
||||
//! which is exactly the route the recall measurements used — so it reproduces
|
||||
//! the measured numbers (recall@10 130/132) byte-for-route. Opt-in via
|
||||
//! `config.models.embedding.provider = "ollama"`.
|
||||
//!
|
||||
//! ## Wire shape
|
||||
//!
|
||||
//! Request (`POST {endpoint}/api/embed`):
|
||||
//!
|
||||
//! ```json
|
||||
//! { "model": "snowflake-arctic-embed2", "input": ["query: 스택", "후입선출 ..."] }
|
||||
//! ```
|
||||
//!
|
||||
//! Response:
|
||||
//!
|
||||
//! ```json
|
||||
//! { "model": "...", "embeddings": [[0.01, ...], [0.02, ...]] }
|
||||
//! ```
|
||||
//!
|
||||
//! ## Pipeline
|
||||
//!
|
||||
//! 1. instruction prefix per model ([`prefixes_for`] — arctic: `query: ` on
|
||||
//! queries, no prefix on documents; e5: `query: `/`passage: `);
|
||||
//! 2. batch into `BATCH` (48) inputs per request;
|
||||
//! 3. `POST /api/embed`, with fail-soft retry (`MAX_RETRIES`);
|
||||
//! 4. **L2 normalize** each returned vector — Ollama returns raw (un-normalized)
|
||||
//! embeddings, so we normalize for cosine consistency with the candle path;
|
||||
//! 5. dim check against `config.models.embedding.dimensions`.
|
||||
//!
|
||||
//! ## Send-safety
|
||||
//!
|
||||
//! `reqwest::blocking::Client: Send + Sync`; the adapter holds only the client,
|
||||
//! an endpoint string, and small config scalars, so it is trivially `Send + Sync`
|
||||
//! as the [`Embedder`] trait requires.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use kebab_core::{Embedder, EmbeddingInput, EmbeddingKind, EmbeddingModelId, EmbeddingVersion};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Inputs per `/api/embed` request. Ollama handles arbitrary batch sizes, but
|
||||
/// a cap keeps a single HTTP body bounded and lets a partial failure retry a
|
||||
/// smaller unit.
|
||||
const BATCH: usize = 48;
|
||||
|
||||
/// Fail-soft retry attempts per batch before the error propagates. Cold model
|
||||
/// load on the Ollama side can transiently 500/timeout; a couple of retries
|
||||
/// smooth that over without masking a hard misconfiguration.
|
||||
const MAX_RETRIES: u32 = 3;
|
||||
|
||||
/// Default per-request HTTP timeout (seconds). Cold-loading an embedding model
|
||||
/// on first call can take tens of seconds; this matches the generous default
|
||||
/// used by the LLM adapter.
|
||||
const REQUEST_TIMEOUT_SECS: u64 = 300;
|
||||
|
||||
/// Resolve the (query_prefix, doc_prefix) for an Ollama embedding model tag.
|
||||
///
|
||||
/// Mirrors `kebab-embed-candle`'s `MODEL_REGISTRY`, but keyed on the **Ollama
|
||||
/// model tag** (which differs from the HF id — e.g. `snowflake-arctic-embed2`
|
||||
/// vs `Snowflake/snowflake-arctic-embed-l-v2.0`). Kept here rather than shared
|
||||
/// so this crate does not depend on the candle backend.
|
||||
///
|
||||
/// An unrecognized model gets no prefix (`("", "")`): many embedding models
|
||||
/// are not instruction-tuned, so embedding the raw text is the correct default
|
||||
/// — and a misspelled known model surfaces as a recall regression, not a silent
|
||||
/// wrong-prefix, because the dim check still passes either way.
|
||||
fn prefixes_for(model: &str) -> (&'static str, &'static str) {
|
||||
let m = model.to_ascii_lowercase();
|
||||
if m.contains("arctic-embed") {
|
||||
// arctic-embed v2.0: `query: ` on queries, documents embedded raw.
|
||||
("query: ", "")
|
||||
} else if m.contains("e5") {
|
||||
// multilingual-e5: `query: ` / `passage: `.
|
||||
("query: ", "passage: ")
|
||||
} else {
|
||||
("", "")
|
||||
}
|
||||
}
|
||||
|
||||
/// `reqwest::blocking` adapter implementing [`Embedder`] over Ollama's
|
||||
/// `/api/embed`. Construction is offline; the first network call happens in
|
||||
/// [`Embedder::embed`].
|
||||
pub struct OllamaEmbedder {
|
||||
client: reqwest::blocking::Client,
|
||||
/// Validated endpoint base (e.g. `"http://127.0.0.1:11434"`).
|
||||
endpoint: String,
|
||||
/// Ollama model tag (e.g. `"snowflake-arctic-embed2"`).
|
||||
model: String,
|
||||
query_prefix: &'static str,
|
||||
doc_prefix: &'static str,
|
||||
model_id: EmbeddingModelId,
|
||||
version: EmbeddingVersion,
|
||||
dimensions: usize,
|
||||
}
|
||||
|
||||
impl OllamaEmbedder {
|
||||
/// Build from a workspace [`kebab_config::Config`]. Reads
|
||||
/// `config.models.embedding.{model, dimensions}` and resolves the endpoint
|
||||
/// as `models.embedding.endpoint` → fallback `models.llm.endpoint`.
|
||||
///
|
||||
/// Does NOT touch the network. The caller (app layer) is expected to have
|
||||
/// validated `provider == "ollama"`.
|
||||
pub fn new(config: &kebab_config::Config) -> Result<Self> {
|
||||
let emb = &config.models.embedding;
|
||||
let endpoint = emb
|
||||
.endpoint
|
||||
.clone()
|
||||
.filter(|e| !e.is_empty())
|
||||
.unwrap_or_else(|| config.models.llm.endpoint.clone());
|
||||
if endpoint.is_empty() {
|
||||
anyhow::bail!(
|
||||
"ollama embedding provider needs an endpoint: set \
|
||||
`models.embedding.endpoint` (or `models.llm.endpoint`)"
|
||||
);
|
||||
}
|
||||
let client = reqwest::blocking::Client::builder()
|
||||
.timeout(Duration::from_secs(REQUEST_TIMEOUT_SECS))
|
||||
.build()
|
||||
.context("kb-embed-ollama: build reqwest client")?;
|
||||
let (query_prefix, doc_prefix) = prefixes_for(&emb.model);
|
||||
Ok(Self {
|
||||
client,
|
||||
endpoint,
|
||||
model: emb.model.clone(),
|
||||
query_prefix,
|
||||
doc_prefix,
|
||||
model_id: EmbeddingModelId(emb.model.clone()),
|
||||
// model_version = `ollama:{model}` so a provider/model switch
|
||||
// triggers the embedding_version cascade and never collides with
|
||||
// the candle path's version string for the same model.
|
||||
version: EmbeddingVersion(format!("ollama:{}", emb.model)),
|
||||
dimensions: emb.dimensions,
|
||||
})
|
||||
}
|
||||
|
||||
/// Embed one already-prefixed batch via `/api/embed`, with fail-soft retry.
|
||||
fn embed_batch(&self, prefixed: &[String]) -> Result<Vec<Vec<f32>>> {
|
||||
let url = format!("{}/api/embed", self.endpoint.trim_end_matches('/'));
|
||||
let body = EmbedRequest {
|
||||
model: &self.model,
|
||||
input: prefixed,
|
||||
};
|
||||
|
||||
let mut last_err: Option<anyhow::Error> = None;
|
||||
for attempt in 1..=MAX_RETRIES {
|
||||
match self.try_once(&url, &body) {
|
||||
Ok(resp) => return self.finalize(resp, prefixed.len()),
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
target: "kebab-embed-ollama",
|
||||
attempt,
|
||||
max = MAX_RETRIES,
|
||||
error = %e,
|
||||
"ollama /api/embed attempt failed; retrying"
|
||||
);
|
||||
last_err = Some(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(last_err.unwrap_or_else(|| {
|
||||
anyhow::anyhow!("kb-embed-ollama: all {MAX_RETRIES} attempts failed")
|
||||
}))
|
||||
}
|
||||
|
||||
/// One HTTP round-trip. Network / non-2xx / decode errors all map to
|
||||
/// `Err` so the retry loop can decide.
|
||||
fn try_once(&self, url: &str, body: &EmbedRequest<'_>) -> Result<EmbedResponse> {
|
||||
let resp = self
|
||||
.client
|
||||
.post(url)
|
||||
.json(body)
|
||||
.send()
|
||||
.with_context(|| format!("kb-embed-ollama: POST {url}"))?;
|
||||
let status = resp.status();
|
||||
if !status.is_success() {
|
||||
let text = resp.text().unwrap_or_default();
|
||||
anyhow::bail!("kb-embed-ollama: /api/embed returned {status}: {text}");
|
||||
}
|
||||
resp.json::<EmbedResponse>()
|
||||
.context("kb-embed-ollama: decode /api/embed response")
|
||||
}
|
||||
|
||||
/// Validate count + dim, then L2-normalize each vector.
|
||||
fn finalize(&self, resp: EmbedResponse, expected: usize) -> Result<Vec<Vec<f32>>> {
|
||||
if resp.embeddings.len() != expected {
|
||||
anyhow::bail!(
|
||||
"kb-embed-ollama: expected {expected} embeddings, got {}",
|
||||
resp.embeddings.len()
|
||||
);
|
||||
}
|
||||
let mut out = Vec::with_capacity(resp.embeddings.len());
|
||||
for v in resp.embeddings {
|
||||
if v.len() != self.dimensions {
|
||||
anyhow::bail!(
|
||||
"kb-embed-ollama: model returned dim {} but config expects {} \
|
||||
(check models.embedding.dimensions vs the Ollama model)",
|
||||
v.len(),
|
||||
self.dimensions
|
||||
);
|
||||
}
|
||||
out.push(l2_normalize(v));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
impl Embedder for OllamaEmbedder {
|
||||
fn model_id(&self) -> EmbeddingModelId {
|
||||
self.model_id.clone()
|
||||
}
|
||||
|
||||
fn model_version(&self) -> EmbeddingVersion {
|
||||
self.version.clone()
|
||||
}
|
||||
|
||||
fn dimensions(&self) -> usize {
|
||||
self.dimensions
|
||||
}
|
||||
|
||||
fn embed(&self, inputs: &[EmbeddingInput<'_>]) -> Result<Vec<Vec<f32>>> {
|
||||
if inputs.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let prefixed: Vec<String> = inputs.iter().map(|i| self.prefix(i)).collect();
|
||||
let mut out = Vec::with_capacity(prefixed.len());
|
||||
for chunk in prefixed.chunks(BATCH) {
|
||||
out.extend(self.embed_batch(chunk)?);
|
||||
}
|
||||
debug_assert_eq!(out.len(), inputs.len());
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
impl OllamaEmbedder {
|
||||
/// Prefix one input per the resolved model prefixes.
|
||||
fn prefix(&self, input: &EmbeddingInput<'_>) -> String {
|
||||
match input.kind {
|
||||
EmbeddingKind::Document => format!("{}{}", self.doc_prefix, input.text),
|
||||
EmbeddingKind::Query => format!("{}{}", self.query_prefix, input.text),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// L2-normalize a vector in place-ish (consumes + returns). A zero vector is
|
||||
/// returned unchanged (norm 0 → no division) so a degenerate embedding can
|
||||
/// never produce NaNs.
|
||||
fn l2_normalize(mut v: Vec<f32>) -> Vec<f32> {
|
||||
let norm = v.iter().map(|x| x * x).sum::<f32>().sqrt();
|
||||
if norm > 0.0 {
|
||||
for x in &mut v {
|
||||
*x /= norm;
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
// ── Wire types ──────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct EmbedRequest<'a> {
|
||||
model: &'a str,
|
||||
input: &'a [String],
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct EmbedResponse {
|
||||
embeddings: Vec<Vec<f32>>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn prefixes_for_arctic_is_query_only() {
|
||||
assert_eq!(prefixes_for("snowflake-arctic-embed2"), ("query: ", ""));
|
||||
assert_eq!(prefixes_for("snowflake-arctic-embed2:latest"), ("query: ", ""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefixes_for_e5_is_query_passage() {
|
||||
assert_eq!(prefixes_for("multilingual-e5-large"), ("query: ", "passage: "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefixes_for_unknown_is_bare() {
|
||||
assert_eq!(prefixes_for("nomic-embed-text"), ("", ""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn l2_normalize_unit_length() {
|
||||
let v = l2_normalize(vec![3.0, 4.0]);
|
||||
let norm = (v[0] * v[0] + v[1] * v[1]).sqrt();
|
||||
assert!((norm - 1.0).abs() < 1e-6, "norm = {norm}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn l2_normalize_zero_vector_is_unchanged() {
|
||||
assert_eq!(l2_normalize(vec![0.0, 0.0, 0.0]), vec![0.0, 0.0, 0.0]);
|
||||
}
|
||||
}
|
||||
99
crates/kebab-embed-ollama/tests/embed_mock.rs
Normal file
99
crates/kebab-embed-ollama/tests/embed_mock.rs
Normal file
@@ -0,0 +1,99 @@
|
||||
//! `/api/embed` behavior against a `wiremock`-hosted mock server.
|
||||
//!
|
||||
//! `wiremock` is async, so the tests are `#[tokio::test]`; the sync
|
||||
//! [`OllamaEmbedder`] is driven from `spawn_blocking` to keep `reqwest::blocking`
|
||||
//! off the async runtime (same pattern as `kebab-llm-local`'s streaming tests).
|
||||
//! tokio is a `dev-dependency` only.
|
||||
|
||||
use kebab_config::Config;
|
||||
use kebab_core::{Embedder, EmbeddingInput, EmbeddingKind};
|
||||
use kebab_embed_ollama::OllamaEmbedder;
|
||||
use wiremock::matchers::{method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
/// Config pointing at the mock server, with a small dim so the mock body is
|
||||
/// tiny. `model` is an arctic tag so prefix resolution is exercised.
|
||||
fn cfg_for(endpoint: &str, dim: usize) -> Config {
|
||||
let mut cfg = Config::defaults();
|
||||
cfg.models.embedding.provider = "ollama".to_string();
|
||||
cfg.models.embedding.model = "snowflake-arctic-embed2".to_string();
|
||||
cfg.models.embedding.dimensions = dim;
|
||||
cfg.models.embedding.endpoint = Some(endpoint.to_string());
|
||||
cfg
|
||||
}
|
||||
|
||||
async fn embed_blocking(
|
||||
cfg: Config,
|
||||
inputs: Vec<(String, EmbeddingKind)>,
|
||||
) -> anyhow::Result<Vec<Vec<f32>>> {
|
||||
tokio::task::spawn_blocking(move || -> anyhow::Result<Vec<Vec<f32>>> {
|
||||
let emb = OllamaEmbedder::new(&cfg)?;
|
||||
let refs: Vec<EmbeddingInput<'_>> = inputs
|
||||
.iter()
|
||||
.map(|(t, k)| EmbeddingInput { text: t, kind: *k })
|
||||
.collect();
|
||||
emb.embed(&refs)
|
||||
})
|
||||
.await
|
||||
.expect("blocking task panicked")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn embed_returns_l2_normalized_vectors() {
|
||||
let server = MockServer::start().await;
|
||||
// Two raw (un-normalized) vectors of dim 2; the adapter must L2-normalize.
|
||||
let body = r#"{"model":"snowflake-arctic-embed2","embeddings":[[3.0,4.0],[0.0,5.0]]}"#;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/embed"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(body))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let out = embed_blocking(
|
||||
cfg_for(&server.uri(), 2),
|
||||
vec![
|
||||
("스택 자료구조".to_string(), EmbeddingKind::Query),
|
||||
("후입선출".to_string(), EmbeddingKind::Document),
|
||||
],
|
||||
)
|
||||
.await
|
||||
.expect("embed should succeed");
|
||||
|
||||
assert_eq!(out.len(), 2);
|
||||
for v in &out {
|
||||
let norm = v.iter().map(|x| x * x).sum::<f32>().sqrt();
|
||||
assert!((norm - 1.0).abs() < 1e-5, "expected unit norm, got {norm}");
|
||||
}
|
||||
// [3,4] → [0.6, 0.8].
|
||||
assert!((out[0][0] - 0.6).abs() < 1e-5 && (out[0][1] - 0.8).abs() < 1e-5);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn embed_rejects_dim_mismatch() {
|
||||
let server = MockServer::start().await;
|
||||
// Server returns dim 3, config expects dim 2 → hard error.
|
||||
let body = r#"{"model":"snowflake-arctic-embed2","embeddings":[[1.0,2.0,3.0]]}"#;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/embed"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(body))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let err = embed_blocking(
|
||||
cfg_for(&server.uri(), 2),
|
||||
vec![("q".to_string(), EmbeddingKind::Query)],
|
||||
)
|
||||
.await
|
||||
.expect_err("dim mismatch must error");
|
||||
let msg = format!("{err:#}");
|
||||
assert!(msg.contains("dim"), "expected dim error, got: {msg}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn embed_empty_input_is_noop() {
|
||||
// No mock needed — empty input must never hit the network.
|
||||
let out = embed_blocking(cfg_for("http://127.0.0.1:1", 2), vec![])
|
||||
.await
|
||||
.expect("empty embed should be Ok(empty)");
|
||||
assert!(out.is_empty());
|
||||
}
|
||||
@@ -503,6 +503,7 @@ mod tests {
|
||||
must_contain: vec![],
|
||||
forbidden: vec![],
|
||||
difficulty: None,
|
||||
group: None,
|
||||
};
|
||||
let g = Some(&g);
|
||||
// a miss, b hit → Win
|
||||
|
||||
@@ -25,6 +25,7 @@ mod loader;
|
||||
mod metrics;
|
||||
mod runner;
|
||||
mod types;
|
||||
mod variant;
|
||||
|
||||
pub use compare::{
|
||||
CompareOpts, CompareReport, ComparisonKind, QueryComparison, compare_runs,
|
||||
@@ -37,3 +38,7 @@ pub use metrics::{
|
||||
};
|
||||
pub use runner::{run_eval, run_eval_with_config};
|
||||
pub use types::{EvalRun, EvalRunOpts, GoldenQuery, QueryResult};
|
||||
pub use variant::{
|
||||
VariantClass, VariantConsistencyReport, VariantGroupReport, VariantResult,
|
||||
compute_variant_consistency, compute_variant_consistency_with_config, render_variants_md,
|
||||
};
|
||||
|
||||
@@ -30,6 +30,7 @@ pub fn load_golden_set(path: &Path) -> Result<Vec<GoldenQuery>> {
|
||||
let queries: Vec<GoldenQuery> = serde_yaml::from_slice(&bytes)
|
||||
.with_context(|| format!("parse golden YAML at {}", path.display()))?;
|
||||
check_unique_ids(&queries)?;
|
||||
check_group_integrity(&queries)?;
|
||||
Ok(queries)
|
||||
}
|
||||
|
||||
@@ -54,6 +55,46 @@ pub(crate) fn load_golden_set_validated(
|
||||
Ok(queries)
|
||||
}
|
||||
|
||||
/// 같은 `group`에 속한 모든 쿼리가 동일한 `expected_doc_ids`(집합)를
|
||||
/// 공유하는지 검증. 변형 일관성 메트릭은 "같은 정답을 가진 다른 표현들"을
|
||||
/// 전제하므로, 그룹 내 정답이 갈리면 측정이 무의미해진다 → bail.
|
||||
fn check_group_integrity(queries: &[GoldenQuery]) -> Result<()> {
|
||||
use std::collections::BTreeMap;
|
||||
// group -> (대표 정답 집합, 대표 query id). 첫 멤버를 canonical 로 삼고
|
||||
// 이후 멤버가 다른 expected 를 가지면 offender 로 기록한다.
|
||||
let mut canonical: BTreeMap<&str, (BTreeSet<String>, &str)> = BTreeMap::new();
|
||||
// 그룹별 위반 메시지(정렬·dedup 위해 BTreeSet). canonical query id 와
|
||||
// divergent query id 를 함께 담아 yaml 수정 시 바로 찾을 수 있게 한다.
|
||||
let mut offenders: BTreeSet<String> = BTreeSet::new();
|
||||
for q in queries {
|
||||
let Some(group) = q.group.as_deref() else {
|
||||
continue;
|
||||
};
|
||||
let docs: BTreeSet<String> = q.expected_doc_ids.iter().map(|d| d.0.clone()).collect();
|
||||
match canonical.get(group) {
|
||||
None => {
|
||||
canonical.insert(group, (docs, q.id.as_str()));
|
||||
}
|
||||
Some((expected, first)) if *expected != docs => {
|
||||
offenders.insert(format!(
|
||||
"group '{group}' (query '{}' differs from canonical '{first}')",
|
||||
q.id
|
||||
));
|
||||
}
|
||||
Some(_) => {}
|
||||
}
|
||||
}
|
||||
if offenders.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
let list: Vec<String> = offenders.into_iter().collect();
|
||||
Err(anyhow!(
|
||||
"same group must share one expected_doc_ids set, but found divergence — {}",
|
||||
list.join("; ")
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn check_unique_ids(queries: &[GoldenQuery]) -> Result<()> {
|
||||
let mut seen: HashSet<&str> = HashSet::new();
|
||||
let mut dups: BTreeSet<String> = BTreeSet::new();
|
||||
@@ -149,6 +190,42 @@ mod tests {
|
||||
use std::fs;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn group_integrity_flags_only_divergent_member_in_3plus_group() {
|
||||
// g1(docA) canonical, g2(docB) divergent, g3(docA) matches canonical.
|
||||
// Only g2 is an offender; g3 must pass. Error names g2, not g3.
|
||||
let tmp = tempdir().unwrap();
|
||||
let yaml_path = tmp.path().join("golden.yaml");
|
||||
fs::write(
|
||||
&yaml_path,
|
||||
"- id: g1\n query: a\n group: gr\n expected_doc_ids: [\"docA\"]\n\
|
||||
- id: g2\n query: b\n group: gr\n expected_doc_ids: [\"docB\"]\n\
|
||||
- id: g3\n query: c\n group: gr\n expected_doc_ids: [\"docA\"]\n",
|
||||
)
|
||||
.unwrap();
|
||||
let err = load_golden_set(&yaml_path).unwrap_err();
|
||||
let msg = format!("{err:#}");
|
||||
assert!(msg.contains("'g2'"), "should name the divergent query g2: {msg}");
|
||||
assert!(!msg.contains("'g3'"), "g3 matches canonical, must not be flagged: {msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ungrouped_queries_skip_group_integrity() {
|
||||
// group=None entries mixed with a valid group must not interfere.
|
||||
let tmp = tempdir().unwrap();
|
||||
let yaml_path = tmp.path().join("golden.yaml");
|
||||
fs::write(
|
||||
&yaml_path,
|
||||
"- id: solo1\n query: x\n expected_doc_ids: [\"docA\"]\n\
|
||||
- id: g1\n query: a\n group: gr\n expected_doc_ids: [\"docB\"]\n\
|
||||
- id: solo2\n query: y\n expected_doc_ids: [\"docC\"]\n",
|
||||
)
|
||||
.unwrap();
|
||||
let qs = load_golden_set(&yaml_path).unwrap();
|
||||
assert_eq!(qs.len(), 3);
|
||||
assert!(qs[0].group.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unknown_expected_chunk_id() {
|
||||
let tmp = tempdir().unwrap();
|
||||
@@ -194,6 +271,37 @@ mod tests {
|
||||
assert_eq!(qs.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_group_with_divergent_expected_docs() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let yaml_path = tmp.path().join("golden.yaml");
|
||||
fs::write(
|
||||
&yaml_path,
|
||||
"- id: g1\n query: \"러스트 소유권\"\n group: ownership\n expected_doc_ids: [\"docA\"]\n\
|
||||
- id: g2\n query: \"rust ownership\"\n group: ownership\n expected_doc_ids: [\"docB\"]\n",
|
||||
)
|
||||
.unwrap();
|
||||
let err = load_golden_set(&yaml_path).unwrap_err();
|
||||
let msg = format!("{err:#}");
|
||||
assert!(msg.contains("group"), "msg: {msg}");
|
||||
assert!(msg.contains("ownership"), "msg: {msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_group_with_matching_expected_docs() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let yaml_path = tmp.path().join("golden.yaml");
|
||||
fs::write(
|
||||
&yaml_path,
|
||||
"- id: g1\n query: \"러스트 소유권\"\n group: ownership\n expected_doc_ids: [\"docA\"]\n\
|
||||
- id: g2\n query: \"rust ownership\"\n group: ownership\n expected_doc_ids: [\"docA\"]\n",
|
||||
)
|
||||
.unwrap();
|
||||
let qs = load_golden_set(&yaml_path).unwrap();
|
||||
assert_eq!(qs.len(), 2);
|
||||
assert_eq!(qs[0].group.as_deref(), Some("ownership"));
|
||||
}
|
||||
|
||||
fn seed_one_chunk(store: &SqliteStore, doc_id: &str, chunk_id: &str) {
|
||||
let conn = store.read_conn();
|
||||
let asset_id = format!("a_{doc_id}");
|
||||
|
||||
@@ -165,7 +165,7 @@ pub(crate) fn resolve_golden_path() -> PathBuf {
|
||||
}
|
||||
}
|
||||
|
||||
fn load_golden_for_metrics() -> Result<Vec<GoldenQuery>> {
|
||||
pub(crate) fn load_golden_for_metrics() -> Result<Vec<GoldenQuery>> {
|
||||
let path = resolve_golden_path();
|
||||
load_golden_set(&path).with_context(|| {
|
||||
format!(
|
||||
@@ -456,6 +456,7 @@ mod tests {
|
||||
must_contain: vec![],
|
||||
forbidden: vec![],
|
||||
difficulty: None,
|
||||
group: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@ pub fn run_eval_with_config(cfg: &kebab_config::Config, opts: &EvalRunOpts) -> R
|
||||
.context("run migrations for run_eval")?;
|
||||
|
||||
// ── 3. Build config_snapshot_json ─────────────────────────────────────
|
||||
let config_snapshot_json = build_config_snapshot(cfg)?;
|
||||
let config_snapshot_json = build_config_snapshot(cfg, opts.k)?;
|
||||
let config_snapshot_text =
|
||||
serde_json::to_string(&config_snapshot_json).context("serialize config_snapshot_json")?;
|
||||
|
||||
@@ -215,10 +215,11 @@ fn execute_query(app: &App, gq: &GoldenQuery, opts: &EvalRunOpts) -> QueryResult
|
||||
/// stable run-time property of the config alone. P5-2 may compose it
|
||||
/// from `embedding.{model,version,dimensions}` if it needs the field
|
||||
/// for compare reports.
|
||||
fn build_config_snapshot(cfg: &kebab_config::Config) -> Result<serde_json::Value> {
|
||||
fn build_config_snapshot(cfg: &kebab_config::Config, eval_k: usize) -> Result<serde_json::Value> {
|
||||
let cfg_value = serde_json::to_value(cfg).context("serialize Config")?;
|
||||
Ok(serde_json::json!({
|
||||
"config": cfg_value,
|
||||
"eval_k": eval_k,
|
||||
"chunker_version": cfg.chunking.chunker_version,
|
||||
"embedding": {
|
||||
"model": cfg.models.embedding.model,
|
||||
|
||||
@@ -26,6 +26,11 @@ pub struct GoldenQuery {
|
||||
pub forbidden: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub difficulty: Option<String>,
|
||||
/// 같은 의미의 여러 표현(동의어·다른 어휘·풀어쓴 문장·한/영)을 묶는
|
||||
/// 의도 그룹 id. 같은 그룹의 모든 변형은 동일한 `expected_doc_ids`(집합)를
|
||||
/// 공유해야 한다(loader가 강제). `None`이면 단독 쿼리(기존 동작 불변).
|
||||
#[serde(default)]
|
||||
pub group: Option<String>,
|
||||
}
|
||||
|
||||
fn default_lang() -> Lang {
|
||||
|
||||
530
crates/kebab-eval/src/variant.rs
Normal file
530
crates/kebab-eval/src/variant.rs
Normal file
@@ -0,0 +1,530 @@
|
||||
//! 변형(paraphrase) 일관성 진단 메트릭.
|
||||
//!
|
||||
//! 같은 의도(`GoldenQuery.group`)의 여러 표현이 같은 정답 문서를 공유한다는
|
||||
//! 전제 아래, 표현마다 검색/답변 품질이 얼마나 출렁이는지를 잰다. 핵심은
|
||||
//! `recall@narrow`(사용자가 보는 top-10) vs `recall@pool`(넓은 후보 폭)의 대비:
|
||||
//!
|
||||
//! - (A) 순위 출렁(`MisRanked`): 정답이 pool엔 있는데 top-10 밖 → near-tie 흡수로 해결 후보.
|
||||
//! - (B) 어휘 격차(`Missing`): 정답이 pool에도 없음 → 쿼리 확장/번역 필요.
|
||||
//!
|
||||
//! 진단 전용. 기존 [`crate::metrics::AggregateMetrics`] 경로는 건드리지 않는다.
|
||||
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use kebab_config::Config;
|
||||
use kebab_core::DocumentId;
|
||||
use kebab_store_sqlite::SqliteStore;
|
||||
|
||||
use crate::types::{GoldenQuery, QueryResult};
|
||||
|
||||
/// 사용자가 실제 보는 답변 context 폭.
|
||||
const NARROW_K: u32 = 10;
|
||||
/// 넓은 후보 폭. recall@pool vs recall@narrow 대비로 A/B를 가른다.
|
||||
/// eval run은 `--k`를 이 값 이상으로 줘서 `hits_top_k`가 pool을 담아야 한다.
|
||||
const POOL_K: u32 = 50;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum VariantClass {
|
||||
/// recall@narrow == 1.0 (정답 전부 top-10 안).
|
||||
Ok,
|
||||
/// recall@pool > recall@narrow (정답이 pool엔 있는데 top-10 밖). (A)
|
||||
MisRanked,
|
||||
/// recall@pool == recall@narrow < 1.0 (못 찾은 정답이 pool에도 없음). (B)
|
||||
Missing,
|
||||
/// 정답 문서 미지정(검증 불가).
|
||||
NoExpected,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct VariantResult {
|
||||
pub query_id: String,
|
||||
pub query: String,
|
||||
pub recall_narrow: f32,
|
||||
pub recall_pool: f32,
|
||||
/// must_contain 통과 여부. RAG 답변(`--with-rag`)이 없으면 `None`.
|
||||
pub answer_ok: Option<bool>,
|
||||
pub class: VariantClass,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct VariantGroupReport {
|
||||
pub group: String,
|
||||
pub variants: Vec<VariantResult>,
|
||||
/// max-min recall_narrow (정답 지정 변형들만). 0 = 완전 일관.
|
||||
pub recall_spread_narrow: f32,
|
||||
pub worst_recall_narrow: f32,
|
||||
/// 모든 변형이 must_contain 통과면 Some(true), 하나라도 실패 Some(false),
|
||||
/// RAG 답변이 전혀 없으면 None.
|
||||
pub answer_consistency: Option<bool>,
|
||||
pub mis_ranked: u32,
|
||||
pub missing: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct VariantConsistencyReport {
|
||||
pub groups: Vec<VariantGroupReport>,
|
||||
pub mean_recall_spread_narrow: f32,
|
||||
/// spread==0 && worst_recall_narrow==1.0 인 그룹 수.
|
||||
pub fully_consistent_groups: u32,
|
||||
pub total_groups: u32,
|
||||
/// mis_ranked>0 && mis_ranked>=missing 인 그룹 수 (near-tie 처방 우선).
|
||||
pub a_dominant_groups: u32,
|
||||
/// missing>0 && missing>mis_ranked 인 그룹 수 (쿼리 확장 처방 우선).
|
||||
pub b_dominant_groups: u32,
|
||||
/// 관찰된 최대 rank 가 POOL_K 미만일 때 true — eval run 의 --k 가
|
||||
/// POOL_K 보다 작아 pool 이 절단됐을 수 있음. MisRanked(A) 판정 불가.
|
||||
pub pool_possibly_truncated: bool,
|
||||
}
|
||||
|
||||
/// 저장된 run을 그룹으로 묶어 변형 일관성 리포트를 만든다.
|
||||
/// `rows`는 [`crate::metrics::aggregate_from_rows`]와 동일한 입력
|
||||
/// (저장된 per-query 결과). `group`이 없는 쿼리는 무시한다.
|
||||
pub fn compute_variant_consistency(
|
||||
queries: &[GoldenQuery],
|
||||
rows: &[kebab_store_sqlite::EvalQueryResultRecord],
|
||||
) -> Result<VariantConsistencyReport> {
|
||||
let golden_by_id: HashMap<&str, &GoldenQuery> =
|
||||
queries.iter().map(|q| (q.id.as_str(), q)).collect();
|
||||
|
||||
let mut grouped: BTreeMap<String, Vec<VariantResult>> = BTreeMap::new();
|
||||
let mut observed_max_rank: u32 = 0;
|
||||
let mut has_hits = false;
|
||||
for row in rows {
|
||||
let qr: QueryResult = serde_json::from_str(&row.result_json)
|
||||
.with_context(|| format!("parse result_json for {}", row.query_id))?;
|
||||
for hit in &qr.hits_top_k {
|
||||
has_hits = true;
|
||||
observed_max_rank = observed_max_rank.max(hit.rank);
|
||||
}
|
||||
let Some(gq) = golden_by_id.get(qr.query_id.as_str()) else {
|
||||
continue;
|
||||
};
|
||||
let Some(group) = gq.group.clone() else {
|
||||
continue;
|
||||
};
|
||||
let (recall_narrow, recall_pool) = recall_narrow_pool(&qr, &gq.expected_doc_ids);
|
||||
// Mirrors metrics.rs groundedness guards: skip errored rows and
|
||||
// vacuous-true (no must_contain/forbidden configured).
|
||||
let answer_ok = if qr.error.is_some()
|
||||
|| (gq.must_contain.is_empty() && gq.forbidden.is_empty())
|
||||
{
|
||||
None
|
||||
} else {
|
||||
qr.answer.as_ref().map(|a| {
|
||||
gq.must_contain.iter().all(|s| a.answer.contains(s))
|
||||
&& !gq.forbidden.iter().any(|s| a.answer.contains(s))
|
||||
})
|
||||
};
|
||||
let class = classify(&gq.expected_doc_ids, recall_narrow, recall_pool);
|
||||
grouped.entry(group).or_default().push(VariantResult {
|
||||
query_id: qr.query_id.clone(),
|
||||
query: qr.query.clone(),
|
||||
recall_narrow,
|
||||
recall_pool,
|
||||
answer_ok,
|
||||
class,
|
||||
});
|
||||
}
|
||||
|
||||
let mut groups: Vec<VariantGroupReport> = Vec::with_capacity(grouped.len());
|
||||
for (group, variants) in grouped {
|
||||
groups.push(rollup_group(group, variants));
|
||||
}
|
||||
|
||||
let total_groups = u32::try_from(groups.len()).unwrap_or(u32::MAX);
|
||||
let fully_consistent_groups = groups
|
||||
.iter()
|
||||
.filter(|g| g.recall_spread_narrow == 0.0 && g.worst_recall_narrow == 1.0)
|
||||
.count() as u32;
|
||||
let a_dominant_groups = groups
|
||||
.iter()
|
||||
.filter(|g| g.mis_ranked > 0 && g.mis_ranked >= g.missing)
|
||||
.count() as u32;
|
||||
let b_dominant_groups = groups
|
||||
.iter()
|
||||
.filter(|g| g.missing > 0 && g.missing > g.mis_ranked)
|
||||
.count() as u32;
|
||||
let mean_recall_spread_narrow = if groups.is_empty() {
|
||||
0.0
|
||||
} else {
|
||||
groups.iter().map(|g| g.recall_spread_narrow).sum::<f32>() / groups.len() as f32
|
||||
};
|
||||
|
||||
let pool_possibly_truncated = has_hits && observed_max_rank < POOL_K;
|
||||
Ok(VariantConsistencyReport {
|
||||
groups,
|
||||
mean_recall_spread_narrow,
|
||||
fully_consistent_groups,
|
||||
total_groups,
|
||||
a_dominant_groups,
|
||||
b_dominant_groups,
|
||||
pool_possibly_truncated,
|
||||
})
|
||||
}
|
||||
|
||||
/// 정답 문서 집합에 대한 recall@NARROW_K, recall@POOL_K.
|
||||
/// 정답 미지정이면 (NaN, NaN).
|
||||
fn recall_narrow_pool(qr: &QueryResult, expected: &[DocumentId]) -> (f32, f32) {
|
||||
if expected.is_empty() {
|
||||
return (f32::NAN, f32::NAN);
|
||||
}
|
||||
let exp: HashSet<&DocumentId> = expected.iter().collect();
|
||||
let cover = |k: u32| -> f32 {
|
||||
let topk: HashSet<&DocumentId> = qr
|
||||
.hits_top_k
|
||||
.iter()
|
||||
.filter(|h| h.rank <= k)
|
||||
.map(|h| &h.doc_id)
|
||||
.collect();
|
||||
exp.iter().filter(|d| topk.contains(*d)).count() as f32 / exp.len() as f32
|
||||
};
|
||||
(cover(NARROW_K), cover(POOL_K))
|
||||
}
|
||||
|
||||
// Single label per query: when multiple expected docs produce mixed classes (e.g. one
|
||||
// MisRanked + one Missing), recall_pool > recall_narrow (A: MisRanked) takes priority.
|
||||
fn classify(expected: &[DocumentId], recall_narrow: f32, recall_pool: f32) -> VariantClass {
|
||||
if expected.is_empty() {
|
||||
VariantClass::NoExpected
|
||||
} else if recall_narrow >= 1.0 {
|
||||
VariantClass::Ok
|
||||
} else if recall_pool > recall_narrow {
|
||||
VariantClass::MisRanked
|
||||
} else {
|
||||
VariantClass::Missing
|
||||
}
|
||||
}
|
||||
|
||||
fn rollup_group(group: String, variants: Vec<VariantResult>) -> VariantGroupReport {
|
||||
let measurable: Vec<f32> = variants
|
||||
.iter()
|
||||
.filter(|v| !v.recall_narrow.is_nan())
|
||||
.map(|v| v.recall_narrow)
|
||||
.collect();
|
||||
let (recall_spread_narrow, worst_recall_narrow) = if measurable.is_empty() {
|
||||
// All variants have no expected docs: spread=0/worst=NaN is intentional.
|
||||
// This group won't match fully_consistent (NaN != 1.0) or A/B (both 0) —
|
||||
// it's counted in total_groups but sits in a silent "limbo" bucket.
|
||||
(0.0, f32::NAN)
|
||||
} else {
|
||||
let max = measurable.iter().copied().fold(f32::MIN, f32::max);
|
||||
let min = measurable.iter().copied().fold(f32::MAX, f32::min);
|
||||
(max - min, min)
|
||||
};
|
||||
let answer_flags: Vec<bool> = variants.iter().filter_map(|v| v.answer_ok).collect();
|
||||
let answer_consistency = if answer_flags.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(answer_flags.iter().all(|&ok| ok))
|
||||
};
|
||||
let mis_ranked = variants.iter().filter(|v| v.class == VariantClass::MisRanked).count() as u32;
|
||||
let missing = variants.iter().filter(|v| v.class == VariantClass::Missing).count() as u32;
|
||||
VariantGroupReport {
|
||||
group,
|
||||
variants,
|
||||
recall_spread_narrow,
|
||||
worst_recall_narrow,
|
||||
answer_consistency,
|
||||
mis_ranked,
|
||||
missing,
|
||||
}
|
||||
}
|
||||
|
||||
/// 활성 XDG Config로 저장된 run을 읽어 변형 일관성을 계산
|
||||
/// ([`crate::metrics::compute_aggregate_with_config`]와 동일한 로딩 패턴).
|
||||
pub fn compute_variant_consistency_with_config(
|
||||
cfg: &Config,
|
||||
run_id: &str,
|
||||
) -> Result<VariantConsistencyReport> {
|
||||
let store = SqliteStore::open(cfg).context("open SqliteStore for variant consistency")?;
|
||||
store.run_migrations().context("run migrations")?;
|
||||
let run_record = store
|
||||
.load_eval_run(run_id)
|
||||
.context("load eval_runs row")?
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!("compute_variant_consistency: no eval_runs row for run_id {run_id}")
|
||||
})?;
|
||||
let snapshot: serde_json::Value =
|
||||
serde_json::from_str(&run_record.config_snapshot_json).unwrap_or(serde_json::Value::Null);
|
||||
if let Some(eval_k) = snapshot["eval_k"].as_u64() {
|
||||
let eval_k = eval_k as u32;
|
||||
if eval_k < POOL_K {
|
||||
anyhow::bail!(
|
||||
"variant consistency needs the run to retrieve >= {POOL_K} candidates, \
|
||||
but run used k={eval_k}; re-run `kebab eval run --k {POOL_K}` (or higher)"
|
||||
);
|
||||
}
|
||||
}
|
||||
let rows = store
|
||||
.load_eval_query_results(run_id)
|
||||
.context("load eval_query_results")?;
|
||||
let queries = crate::metrics::load_golden_for_metrics()?;
|
||||
compute_variant_consistency(&queries, &rows)
|
||||
}
|
||||
|
||||
/// 변형 일관성 리포트를 사람이 읽는 마크다운 표로 렌더
|
||||
/// ([`crate::render_report_md`] 스타일).
|
||||
pub fn render_variants_md(rep: &VariantConsistencyReport) -> String {
|
||||
use std::fmt::Write;
|
||||
let mut s = String::new();
|
||||
let _ = writeln!(s, "# Variant consistency\n");
|
||||
let _ = writeln!(
|
||||
s,
|
||||
"groups={} fully_consistent={} A_dominant={} B_dominant={} mean_spread@{}={:.3} pool=top-{}\n",
|
||||
rep.total_groups,
|
||||
rep.fully_consistent_groups,
|
||||
rep.a_dominant_groups,
|
||||
rep.b_dominant_groups,
|
||||
NARROW_K,
|
||||
rep.mean_recall_spread_narrow,
|
||||
POOL_K,
|
||||
);
|
||||
if rep.pool_possibly_truncated {
|
||||
let _ = writeln!(
|
||||
s,
|
||||
"WARNING: max observed rank < {POOL_K} — pool possibly truncated. \
|
||||
MisRanked(A) diagnoses may be suppressed. Re-run `kebab eval run --k {POOL_K}` (or higher).\n"
|
||||
);
|
||||
}
|
||||
for g in &rep.groups {
|
||||
let ac = match g.answer_consistency {
|
||||
Some(true) => "all-ok",
|
||||
Some(false) => "MIXED",
|
||||
None => "n/a",
|
||||
};
|
||||
let _ = writeln!(
|
||||
s,
|
||||
"## {} — spread@{}={:.2} worst={:.2} A={} B={} answers={}",
|
||||
g.group, NARROW_K, g.recall_spread_narrow, g.worst_recall_narrow, g.mis_ranked, g.missing, ac
|
||||
);
|
||||
let _ = writeln!(s, "| variant | recall@{NARROW_K} | recall@{POOL_K} | class | answer |");
|
||||
let _ = writeln!(s, "|---|---|---|---|---|");
|
||||
for v in &g.variants {
|
||||
let ans = match v.answer_ok {
|
||||
Some(true) => "ok",
|
||||
Some(false) => "BAD",
|
||||
None => "-",
|
||||
};
|
||||
let _ = writeln!(
|
||||
s,
|
||||
"| {} | {:.2} | {:.2} | {:?} | {} |",
|
||||
v.query, v.recall_narrow, v.recall_pool, v.class, ans
|
||||
);
|
||||
}
|
||||
let _ = writeln!(s);
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use kebab_core::{
|
||||
ChunkId, ChunkerVersion, Citation, IndexVersion, RetrievalDetail, ScoreKind, SearchMode,
|
||||
WorkspacePath,
|
||||
};
|
||||
use kebab_store_sqlite::EvalQueryResultRecord;
|
||||
|
||||
fn hit(doc: &str, rank: u32) -> kebab_core::SearchHit {
|
||||
let path = WorkspacePath::new(format!("{doc}.md")).unwrap();
|
||||
kebab_core::SearchHit {
|
||||
rank,
|
||||
chunk_id: ChunkId(format!("c-{doc}-{rank}")),
|
||||
doc_id: DocumentId(doc.to_string()),
|
||||
doc_path: path.clone(),
|
||||
heading_path: vec![],
|
||||
section_label: None,
|
||||
snippet: String::new(),
|
||||
citation: Citation::Line { path, start: 1, end: 1, section: None },
|
||||
retrieval: RetrievalDetail {
|
||||
method: SearchMode::Vector,
|
||||
fusion_score: 1.0 / rank as f32,
|
||||
lexical_score: None,
|
||||
vector_score: Some(1.0 / rank as f32),
|
||||
lexical_rank: None,
|
||||
vector_rank: Some(rank),
|
||||
},
|
||||
index_version: IndexVersion("v1".into()),
|
||||
embedding_model: None,
|
||||
chunker_version: ChunkerVersion("v1".into()),
|
||||
indexed_at: time::OffsetDateTime::UNIX_EPOCH,
|
||||
stale: false,
|
||||
score_kind: ScoreKind::Cosine,
|
||||
repo: None,
|
||||
code_lang: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn gq(id: &str, group: &str, expected_doc: &str) -> GoldenQuery {
|
||||
GoldenQuery {
|
||||
id: id.into(),
|
||||
query: id.into(),
|
||||
lang: kebab_core::Lang(String::new()),
|
||||
expected_doc_ids: vec![DocumentId(expected_doc.into())],
|
||||
expected_chunk_ids: vec![],
|
||||
must_contain: vec![],
|
||||
forbidden: vec![],
|
||||
difficulty: None,
|
||||
group: Some(group.into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn row(query_id: &str, hits: Vec<kebab_core::SearchHit>) -> EvalQueryResultRecord {
|
||||
let qr = QueryResult {
|
||||
query_id: query_id.into(),
|
||||
query: query_id.into(),
|
||||
mode: SearchMode::Vector,
|
||||
hits_top_k: hits,
|
||||
answer: None,
|
||||
elapsed_ms: 0,
|
||||
error: None,
|
||||
};
|
||||
EvalQueryResultRecord {
|
||||
query_id: query_id.into(),
|
||||
result_json: serde_json::to_string(&qr).unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_mis_ranked_vs_missing_and_spread() {
|
||||
// group "g": 정답 docX.
|
||||
// v1: docX at rank 3 → narrow=1.0 → Ok
|
||||
// v2: docX at rank 25 → narrow=0.0, pool=1.0 → MisRanked (A)
|
||||
// v3: docX 없음 → narrow=0.0, pool=0.0 → Missing (B)
|
||||
let queries = vec![gq("v1", "g", "docX"), gq("v2", "g", "docX"), gq("v3", "g", "docX")];
|
||||
let rows = vec![
|
||||
row("v1", vec![hit("docX", 3)]),
|
||||
row("v2", vec![hit("docX", 25)]),
|
||||
row("v3", vec![hit("other", 1)]),
|
||||
];
|
||||
let rep = compute_variant_consistency(&queries, &rows).unwrap();
|
||||
assert_eq!(rep.total_groups, 1);
|
||||
let g = &rep.groups[0];
|
||||
assert_eq!(g.group, "g");
|
||||
assert_eq!(g.variants.len(), 3);
|
||||
// spread = max(1.0) - min(0.0) = 1.0
|
||||
assert!((g.recall_spread_narrow - 1.0).abs() < 1e-6);
|
||||
assert!((g.worst_recall_narrow - 0.0).abs() < 1e-6);
|
||||
assert_eq!(g.mis_ranked, 1);
|
||||
assert_eq!(g.missing, 1);
|
||||
let classes: Vec<VariantClass> = g.variants.iter().map(|v| v.class).collect();
|
||||
assert!(classes.contains(&VariantClass::Ok));
|
||||
assert!(classes.contains(&VariantClass::MisRanked));
|
||||
assert!(classes.contains(&VariantClass::Missing));
|
||||
assert_eq!(rep.a_dominant_groups + rep.b_dominant_groups, 1); // tie→정의대로 하나로 분류
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fully_consistent_group_when_all_ok() {
|
||||
let queries = vec![gq("v1", "g", "docX"), gq("v2", "g", "docX")];
|
||||
let rows = vec![row("v1", vec![hit("docX", 1)]), row("v2", vec![hit("docX", 2)])];
|
||||
let rep = compute_variant_consistency(&queries, &rows).unwrap();
|
||||
assert_eq!(rep.fully_consistent_groups, 1);
|
||||
assert!((rep.groups[0].recall_spread_narrow - 0.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ungrouped_queries_are_ignored() {
|
||||
let mut q = gq("solo", "g", "docX");
|
||||
q.group = None;
|
||||
let rep = compute_variant_consistency(&[q], &[row("solo", vec![hit("docX", 1)])]).unwrap();
|
||||
assert_eq!(rep.total_groups, 0);
|
||||
}
|
||||
|
||||
fn row_with_answer(
|
||||
query_id: &str,
|
||||
hits: Vec<kebab_core::SearchHit>,
|
||||
answer_text: &str,
|
||||
error: Option<&str>,
|
||||
) -> EvalQueryResultRecord {
|
||||
let hits_json = serde_json::to_value(&hits).unwrap();
|
||||
let error_json =
|
||||
error.map_or(serde_json::Value::Null, |e| serde_json::Value::String(e.into()));
|
||||
let qr_json = serde_json::json!({
|
||||
"query_id": query_id,
|
||||
"query": query_id,
|
||||
"mode": "vector",
|
||||
"hits_top_k": hits_json,
|
||||
"answer": {
|
||||
"answer": answer_text,
|
||||
"citations": [],
|
||||
"grounded": false,
|
||||
"refusal_reason": null,
|
||||
"model": {"id": "test-model", "provider": "test", "dimensions": null},
|
||||
"embedding": null,
|
||||
"prompt_template_version": "v1",
|
||||
"retrieval": {
|
||||
"trace_id": "t0",
|
||||
"mode": "vector",
|
||||
"k": 10,
|
||||
"score_gate": 0.0,
|
||||
"top_score": 0.0,
|
||||
"chunks_returned": 0,
|
||||
"chunks_used": 0
|
||||
},
|
||||
"usage": {"prompt_tokens": 0, "completion_tokens": 0, "latency_ms": 0},
|
||||
"created_at": "1970-01-01T00:00:00Z"
|
||||
},
|
||||
"elapsed_ms": 0,
|
||||
"error": error_json
|
||||
});
|
||||
EvalQueryResultRecord {
|
||||
query_id: query_id.into(),
|
||||
result_json: serde_json::to_string(&qr_json).unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
/// H1 회귀: eval k=10 으로 실행 시 모든 hit rank ≤ NARROW_K →
|
||||
/// pool_possibly_truncated 플래그로 사용자에게 경고해야 한다.
|
||||
#[test]
|
||||
fn pool_truncation_flag_when_all_hits_within_narrow_k() {
|
||||
let queries = vec![gq("v1", "g", "docX"), gq("v2", "g", "docX")];
|
||||
let rows = vec![
|
||||
row("v1", vec![hit("docX", 1)]),
|
||||
row("v2", vec![hit("other", 7)]), // rank 7 ≤ NARROW_K=10
|
||||
];
|
||||
let rep = compute_variant_consistency(&queries, &rows).unwrap();
|
||||
assert!(rep.pool_possibly_truncated, "all ranks ≤ NARROW_K must set pool_possibly_truncated");
|
||||
// v2 misses docX, pool also has no rank>10 → classified Missing, not MisRanked
|
||||
assert_eq!(rep.a_dominant_groups, 0);
|
||||
assert_eq!(rep.b_dominant_groups, 1);
|
||||
}
|
||||
|
||||
/// M1a: must_contain/forbidden 둘 다 빈 golden → vacuous-true 방지,
|
||||
/// answer_ok = None (answer 있어도).
|
||||
/// M1b: qr.error=Some → answer 있어도 answer_ok = None.
|
||||
#[test]
|
||||
fn answer_ok_vacuous_and_error_guarded() {
|
||||
// M1a: gq() helper already has empty must_contain + forbidden
|
||||
let gq_no_check = gq("v1", "g1", "docX");
|
||||
let row_v1 = row_with_answer("v1", vec![], "any text", None);
|
||||
let rep = compute_variant_consistency(&[gq_no_check], &[row_v1]).unwrap();
|
||||
let v = &rep.groups[0].variants[0];
|
||||
assert_eq!(v.answer_ok, None, "vacuous-true guard: no checks → answer_ok = None");
|
||||
assert_eq!(rep.groups[0].answer_consistency, None);
|
||||
|
||||
// M1b: must_contain present but error is also set
|
||||
let mut gq_check = gq("v2", "g2", "docY");
|
||||
gq_check.must_contain = vec!["expected text".to_string()];
|
||||
let row_v2 = row_with_answer("v2", vec![], "expected text", Some("llm error"));
|
||||
let rep2 = compute_variant_consistency(&[gq_check], &[row_v2]).unwrap();
|
||||
let v2 = &rep2.groups[0].variants[0];
|
||||
assert_eq!(v2.answer_ok, None, "error guard: qr.error present → answer_ok = None");
|
||||
}
|
||||
|
||||
/// N1 순수 B: 두 변형 모두 pool 에서도 정답 없음 → b_dominant=1, a_dominant=0.
|
||||
#[test]
|
||||
fn pure_b_dominant_group() {
|
||||
let queries = vec![gq("v1", "g", "docX"), gq("v2", "g", "docX")];
|
||||
let rows = vec![
|
||||
row("v1", vec![hit("other1", 1)]), // docX 없음 → Missing (B)
|
||||
row("v2", vec![hit("other2", 1)]), // docX 없음 → Missing (B)
|
||||
];
|
||||
let rep = compute_variant_consistency(&queries, &rows).unwrap();
|
||||
assert_eq!(rep.b_dominant_groups, 1);
|
||||
assert_eq!(rep.a_dominant_groups, 0);
|
||||
}
|
||||
}
|
||||
@@ -95,6 +95,24 @@ impl OllamaLanguageModel {
|
||||
default_seed: llm.seed,
|
||||
})
|
||||
}
|
||||
|
||||
/// `new` 와 동일하되 모델 ID 만 override. doc-side expansion(Task 5)이
|
||||
/// `[ingest.expansion].model` 을 쓸 수 있게 한다. 빈 문자열이면 호출측이
|
||||
/// `new` 를 쓰도록 분기(여기선 비어있지 않은 model_id 를 신뢰).
|
||||
pub fn with_model(config: &kebab_config::Config, model_id: &str) -> anyhow::Result<Self> {
|
||||
let llm = &config.models.llm;
|
||||
let client = reqwest::blocking::Client::builder()
|
||||
.timeout(Duration::from_secs(llm.request_timeout_secs))
|
||||
.build()?;
|
||||
Ok(Self {
|
||||
client,
|
||||
endpoint: llm.endpoint.clone(),
|
||||
model_id: model_id.to_string(),
|
||||
context_tokens: llm.context_tokens,
|
||||
default_temperature: llm.temperature,
|
||||
default_seed: llm.seed,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl LanguageModel for OllamaLanguageModel {
|
||||
|
||||
@@ -35,6 +35,24 @@ kamadak-exif = "0.6"
|
||||
# transitive tokio runtime is brought in once.
|
||||
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
|
||||
base64 = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
# paddle-onnx OCR engine (PP-OCRv5, in-process). We reuse the workspace ort
|
||||
# pin (=2.0.0-rc.9) so the ONNX Runtime native lib stays single-versioned with
|
||||
# fastembed / kebab-nli (oar-ocr is intentionally NOT a dep — it would pull
|
||||
# ort rc.12 + ndarray 0.17, splitting the native `links` and threatening the
|
||||
# embedding stack). `download-binaries` extends the pin the same way
|
||||
# `kebab-nli/Cargo.toml:23` does: this crate isn't in fastembed's build graph,
|
||||
# so a standalone `cargo test -p kebab-parse-image` needs it to link onnxruntime.
|
||||
ort = { workspace = true, features = ["ndarray", "download-binaries"] }
|
||||
ndarray = { workspace = true }
|
||||
# blake3: engine_version hash over the bundled det/rec/dict assets (computed
|
||||
# once at OnnxPaddleOcr construction, cached — `ingest_config_signature` calls
|
||||
# engine_version() per asset).
|
||||
blake3 = { workspace = true }
|
||||
# imageproc: connected-components / contours for DBNet det post-processing.
|
||||
# min-area rotated-rect (rotating calipers) and polygon unclip are implemented
|
||||
# in pure Rust (clipper2 is C++ FFI — would break the single-binary guarantee).
|
||||
imageproc = "0.25"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = { workspace = true }
|
||||
|
||||
33
crates/kebab-parse-image/assets/paddleocr-onnx/NOTICE
Normal file
33
crates/kebab-parse-image/assets/paddleocr-onnx/NOTICE
Normal file
@@ -0,0 +1,33 @@
|
||||
PP-OCRv5 mobile ONNX models bundled with kebab (paddle-onnx OCR engine)
|
||||
=======================================================================
|
||||
|
||||
These model weights and the recognition dictionary are derived from
|
||||
PaddleOCR (https://github.com/PaddlePaddle/PaddleOCR), licensed under the
|
||||
Apache License, Version 2.0.
|
||||
|
||||
Copyright (c) PaddlePaddle Authors.
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use these files except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Files
|
||||
-----
|
||||
ppocrv5_mobile_det.onnx PP-OCRv5_mobile detection model (DBNet)
|
||||
korean_ppocrv5_mobile_rec.onnx korean_PP-OCRv5_mobile recognition model (CTC)
|
||||
korean_dict.txt recognition dictionary (11,945 chars: KR + Latin + digits + symbols)
|
||||
|
||||
These were converted from the official PaddlePaddle inference models to ONNX
|
||||
via paddle2onnx for in-process execution with onnxruntime (`ort`). No model
|
||||
architecture or weights were modified; only the serialization format changed.
|
||||
|
||||
The recognition CTC class layout (empirically confirmed, see
|
||||
tests/golden/ctc_rec_golden.json):
|
||||
index 0 = CTC blank
|
||||
index 1..11945 = korean_dict.txt line N -> class N (dict[N-1])
|
||||
index 11946 = space ' '
|
||||
total classes = 11947 (= 11945 dict + blank + space)
|
||||
|
||||
If any post-processing source (min-area-rect / polygon unclip) is later
|
||||
ported verbatim from oar-ocr (Apache-2.0), record the per-file provenance
|
||||
here as required by the Apache-2.0 attribution clause.
|
||||
11945
crates/kebab-parse-image/assets/paddleocr-onnx/korean_dict.txt
Normal file
11945
crates/kebab-parse-image/assets/paddleocr-onnx/korean_dict.txt
Normal file
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
@@ -30,9 +30,11 @@ mod dims;
|
||||
mod exif_extract;
|
||||
mod image_prep;
|
||||
pub mod ocr;
|
||||
pub mod paddle_onnx;
|
||||
|
||||
pub use caption::{apply_caption, caption_image};
|
||||
pub use ocr::{OcrEngine, OllamaVisionOcr, apply_ocr};
|
||||
pub use ocr::{OLLAMA_VISION_ENGINE, OcrEngine, OllamaVisionOcr, apply_ocr};
|
||||
pub use paddle_onnx::{ModelPaths, OnnxPaddleOcr, PADDLE_ONNX_ENGINE, engine_version_for_config};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use kebab_core::{
|
||||
|
||||
@@ -65,6 +65,13 @@ pub trait OcrEngine: Send + Sync {
|
||||
/// through to engines that benefit from it (Tesseract languages,
|
||||
/// LLM prompt steering); ignore otherwise.
|
||||
fn recognize(&self, image_bytes: &[u8], lang_hint: Option<&Lang>) -> Result<OcrText>;
|
||||
|
||||
/// Human-facing model label for the ingest progress display
|
||||
/// (`AssetPhase{phase:"ocr", model}`). Distinct from
|
||||
/// [`engine_version`](Self::engine_version), which is the cache-key
|
||||
/// hash. E.g. `"gemma4:e4b"` (ollama-vision) or `"ppocrv5-mobile-kor"`
|
||||
/// (paddle-onnx).
|
||||
fn model(&self) -> &str;
|
||||
}
|
||||
|
||||
/// Mutate `block.ocr` in place by running `engine` over `image_bytes`,
|
||||
@@ -240,6 +247,10 @@ impl OcrEngine for OllamaVisionOcr {
|
||||
format!("ollama/{}", self.model)
|
||||
}
|
||||
|
||||
fn model(&self) -> &str {
|
||||
&self.model
|
||||
}
|
||||
|
||||
fn recognize(&self, image_bytes: &[u8], lang_hint: Option<&Lang>) -> Result<OcrText> {
|
||||
let (prepared, w, h) = image_prep::downscale_to_png(image_bytes, self.max_pixels)
|
||||
.context("preparing image for OCR")?;
|
||||
|
||||
902
crates/kebab-parse-image/src/paddle_onnx.rs
Normal file
902
crates/kebab-parse-image/src/paddle_onnx.rs
Normal file
@@ -0,0 +1,902 @@
|
||||
//! PP-OCRv5 ONNX OCR engine — in-process detection + recognition on the
|
||||
//! workspace-pinned `ort` (=2.0.0-rc.9), no Python runtime, no oar-ocr
|
||||
//! production dependency (see crate-level rationale + `assets/paddleocr-onnx/NOTICE`).
|
||||
//!
|
||||
//! Pipeline (`recognize`):
|
||||
//! 1. decode (RGB) + downscale long edge to `max_pixels`
|
||||
//! 2. det: ImageNet-normalized NCHW → DBNet prob map `[1,1,H,W]` → threshold
|
||||
//! 0.3 → contours → min-area rect (rotating calipers, pure Rust) →
|
||||
//! unclip(ratio 1.5, pure Rust) → boxes
|
||||
//! 3. crop+rectify: perspective warp each rotated box to a horizontal strip
|
||||
//! 4. rec: 48×W normalized `(x-0.5)/0.5` → `[1,T,11947]` → CTC greedy decode
|
||||
//! 5. assemble reading-order `OcrText`
|
||||
//!
|
||||
//! ## Confirmed CTC facts (empirically derived in T0a, see
|
||||
//! `tests/golden/ctc_rec_golden.json` — do NOT re-derive):
|
||||
//! * rec classes = 11947 = dict(11945) + blank + space
|
||||
//! * index 0 = CTC blank
|
||||
//! * index 1..=11945 = `korean_dict.txt` line N → class N (i.e. `dict[N-1]`)
|
||||
//! * index 11946 = space ' '
|
||||
//!
|
||||
//! ## rc.9 API notes (differ from rc.12):
|
||||
//! * `try_extract_tensor::<f32>()` → `ArrayViewD<f32>` (`.shape()` / indexing).
|
||||
//! * `Session::run` is called through a `Mutex` guard so the engine is
|
||||
//! `Send + Sync` regardless of `Session`'s own auto-trait status (ingest
|
||||
//! is serial today; the lock is uncontended).
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Mutex;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use kebab_core::{Lang, OcrRegion, OcrText};
|
||||
use ndarray::Array4;
|
||||
use ort::session::Session;
|
||||
use ort::value::Value;
|
||||
|
||||
use crate::ocr::OcrEngine;
|
||||
|
||||
/// Engine name written into `OcrText.engine`.
|
||||
pub const PADDLE_ONNX_ENGINE: &str = "paddle-onnx";
|
||||
|
||||
/// CTC blank class index (confirmed in T0a).
|
||||
const CTC_BLANK: usize = 0;
|
||||
/// Space class index (confirmed in T0a). `1..=DICT_LINES` map to dict entries.
|
||||
const CTC_SPACE: usize = 11946;
|
||||
/// `korean_dict.txt` line count (confirmed in T0a).
|
||||
const DICT_LINES: usize = 11945;
|
||||
/// rec output class count = dict + blank + space (confirmed in T0a).
|
||||
const REC_CLASSES: usize = 11947;
|
||||
|
||||
/// det long-edge cap before rounding to a multiple of 32 (PaddleOCR default).
|
||||
const DET_LIMIT_SIDE_LEN: u32 = 960;
|
||||
/// rec input height (PP-OCRv5 mobile).
|
||||
const REC_HEIGHT: u32 = 48;
|
||||
|
||||
/// ImageNet normalization (det preprocessing — RGB).
|
||||
const IMAGENET_MEAN: [f32; 3] = [0.485, 0.456, 0.406];
|
||||
const IMAGENET_STD: [f32; 3] = [0.229, 0.224, 0.225];
|
||||
|
||||
/// PP-OCRv5 ONNX engine. Holds the two ONNX sessions (loaded once) and the
|
||||
/// dict. `engine_version` is computed once at construction (blake3 over the
|
||||
/// three model assets) and cached — `ingest_config_signature` calls
|
||||
/// `engine_version()` per asset, so re-hashing there would be O(assets).
|
||||
pub struct OnnxPaddleOcr {
|
||||
det: Mutex<Session>,
|
||||
rec: Mutex<Session>,
|
||||
det_input_name: String,
|
||||
rec_input_name: String,
|
||||
dict: Vec<String>,
|
||||
engine_version: String,
|
||||
score_thresh: f32,
|
||||
unclip_ratio: f32,
|
||||
max_boxes: usize,
|
||||
max_pixels: u32,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for OnnxPaddleOcr {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("OnnxPaddleOcr")
|
||||
.field("engine_version", &self.engine_version)
|
||||
.field("dict_lines", &self.dict.len())
|
||||
.field("score_thresh", &self.score_thresh)
|
||||
.field("unclip_ratio", &self.unclip_ratio)
|
||||
.field("max_boxes", &self.max_boxes)
|
||||
.field("max_pixels", &self.max_pixels)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolved model-asset paths. Construction is decoupled from `kebab-config`
|
||||
/// (T7 adds the `det_model`/`rec_model`/`dict` overrides) so the engine can be
|
||||
/// built directly in tests.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ModelPaths {
|
||||
pub det: PathBuf,
|
||||
pub rec: PathBuf,
|
||||
pub dict: PathBuf,
|
||||
}
|
||||
|
||||
impl ModelPaths {
|
||||
/// Default bundled-asset directory: `KEBAB_IMAGE_OCR_MODEL_DIR` if set,
|
||||
/// else the crate's `assets/paddleocr-onnx/`.
|
||||
pub fn from_default_dir() -> Self {
|
||||
let dir = std::env::var("KEBAB_IMAGE_OCR_MODEL_DIR").map_or_else(
|
||||
|_| Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/paddleocr-onnx"),
|
||||
PathBuf::from,
|
||||
);
|
||||
Self {
|
||||
det: dir.join("ppocrv5_mobile_det.onnx"),
|
||||
rec: dir.join("korean_ppocrv5_mobile_rec.onnx"),
|
||||
dict: dir.join("korean_dict.txt"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve model paths from the `image.ocr` config (T7). Each of
|
||||
/// `det_model` / `rec_model` / `dict` overrides the corresponding bundled
|
||||
/// path when set; unset fields fall back to [`from_default_dir`], so a
|
||||
/// caller can override just one asset.
|
||||
///
|
||||
/// [`from_default_dir`]: ModelPaths::from_default_dir
|
||||
pub fn from_config(config: &kebab_config::Config) -> Self {
|
||||
let defaults = Self::from_default_dir();
|
||||
let ocr = &config.image.ocr;
|
||||
Self {
|
||||
det: ocr.det_model.as_ref().map(PathBuf::from).unwrap_or(defaults.det),
|
||||
rec: ocr.rec_model.as_ref().map(PathBuf::from).unwrap_or(defaults.rec),
|
||||
dict: ocr.dict.as_ref().map(PathBuf::from).unwrap_or(defaults.dict),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl OnnxPaddleOcr {
|
||||
/// Build from a workspace [`kebab_config::Config`]. Resolves model paths
|
||||
/// from the default bundled directory (T7 will thread config overrides).
|
||||
/// Construction loads both ONNX sessions and hashes the assets — failures
|
||||
/// here are fail-fast (matches the Ollama adapter's construction contract).
|
||||
pub fn new(config: &kebab_config::Config) -> Result<Self> {
|
||||
let paths = ModelPaths::from_config(config);
|
||||
let ocr = &config.image.ocr;
|
||||
Self::from_paths(
|
||||
&paths,
|
||||
ocr.score_thresh,
|
||||
ocr.unclip_ratio,
|
||||
ocr.max_boxes,
|
||||
ocr.max_pixels,
|
||||
)
|
||||
}
|
||||
|
||||
/// Build from explicit asset paths + tuning knobs. Used by tests and by
|
||||
/// `new` after path resolution.
|
||||
pub fn from_paths(
|
||||
paths: &ModelPaths,
|
||||
score_thresh: f32,
|
||||
unclip_ratio: f32,
|
||||
max_boxes: usize,
|
||||
max_pixels: u32,
|
||||
) -> Result<Self> {
|
||||
let dict = load_dict(&paths.dict)
|
||||
.with_context(|| format!("loading OCR dict from {}", paths.dict.display()))?;
|
||||
// bounds-check: dict length must match the rec class layout
|
||||
// (dict + blank + space). A mismatch means a wrong dict file —
|
||||
// fail at construction rather than mis-decoding silently.
|
||||
if dict.len() != DICT_LINES {
|
||||
anyhow::bail!(
|
||||
"OnnxPaddleOcr: dict has {} lines, expected {DICT_LINES} \
|
||||
(rec classes {REC_CLASSES} = dict + blank + space)",
|
||||
dict.len()
|
||||
);
|
||||
}
|
||||
|
||||
let engine_version = compute_engine_version(paths)
|
||||
.context("hashing OCR model assets for engine_version")?;
|
||||
|
||||
let det = Session::builder()
|
||||
.context("ort Session::builder (det)")?
|
||||
.commit_from_file(&paths.det)
|
||||
.with_context(|| format!("loading det model {}", paths.det.display()))?;
|
||||
let rec = Session::builder()
|
||||
.context("ort Session::builder (rec)")?
|
||||
.commit_from_file(&paths.rec)
|
||||
.with_context(|| format!("loading rec model {}", paths.rec.display()))?;
|
||||
|
||||
let det_input_name = det
|
||||
.inputs
|
||||
.first()
|
||||
.map(|i| i.name.clone())
|
||||
.context("det model has no inputs")?;
|
||||
let rec_input_name = rec
|
||||
.inputs
|
||||
.first()
|
||||
.map(|i| i.name.clone())
|
||||
.context("rec model has no inputs")?;
|
||||
|
||||
Ok(Self {
|
||||
det: Mutex::new(det),
|
||||
rec: Mutex::new(rec),
|
||||
det_input_name,
|
||||
rec_input_name,
|
||||
dict,
|
||||
engine_version,
|
||||
score_thresh,
|
||||
unclip_ratio,
|
||||
max_boxes,
|
||||
max_pixels: max_pixels.clamp(256, 4096),
|
||||
})
|
||||
}
|
||||
|
||||
/// Map a CTC class index to its output string. `None` for blank.
|
||||
/// `index 0 = blank`, `1..=11945 = dict[index-1]`, `11946 = space`.
|
||||
fn class_to_str(&self, idx: usize) -> Option<&str> {
|
||||
match idx {
|
||||
CTC_BLANK => None,
|
||||
CTC_SPACE => Some(" "),
|
||||
i if (1..=DICT_LINES).contains(&i) => Some(self.dict[i - 1].as_str()),
|
||||
_ => None, // out-of-range guard (should not happen for 11947 classes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl OcrEngine for OnnxPaddleOcr {
|
||||
fn engine_name(&self) -> &'static str {
|
||||
PADDLE_ONNX_ENGINE
|
||||
}
|
||||
|
||||
fn engine_version(&self) -> String {
|
||||
self.engine_version.clone()
|
||||
}
|
||||
|
||||
// The trait method's elided lifetime ties the return to `&self`; the body
|
||||
// returns a literal, but the signature must match the trait, so allow the
|
||||
// `'static`-narrowing lint here.
|
||||
#[allow(clippy::unnecessary_literal_bound)]
|
||||
fn model(&self) -> &str {
|
||||
// Static label for the progress display; the per-asset hash lives
|
||||
// in `engine_version`.
|
||||
"ppocrv5-mobile-kor"
|
||||
}
|
||||
|
||||
fn recognize(&self, image_bytes: &[u8], _lang_hint: Option<&Lang>) -> Result<OcrText> {
|
||||
let img = image::load_from_memory(image_bytes)
|
||||
.context("decoding image for OCR")?
|
||||
.to_rgb8();
|
||||
let (orig_w, orig_h) = (img.width(), img.height());
|
||||
if orig_w == 0 || orig_h == 0 {
|
||||
return Ok(empty_ocr(self));
|
||||
}
|
||||
|
||||
// ── det ────────────────────────────────────────────────────────
|
||||
let (det_w, det_h) = det_target_dims(orig_w, orig_h, self.max_pixels);
|
||||
let det_img = image::imageops::resize(
|
||||
&img,
|
||||
det_w,
|
||||
det_h,
|
||||
image::imageops::FilterType::Triangle,
|
||||
);
|
||||
let prob = self.run_det(&det_img)?; // (det_h, det_w) prob map
|
||||
let scale_x = orig_w as f32 / det_w as f32;
|
||||
let scale_y = orig_h as f32 / det_h as f32;
|
||||
let mut boxes = det_postprocess(
|
||||
&prob,
|
||||
prob.w,
|
||||
prob.h,
|
||||
self.score_thresh,
|
||||
self.unclip_ratio,
|
||||
);
|
||||
if boxes.len() > self.max_boxes {
|
||||
tracing::warn!(
|
||||
target: "kebab-parse-image",
|
||||
"paddle-onnx: {} boxes exceeds max_boxes {} — truncating",
|
||||
boxes.len(),
|
||||
self.max_boxes
|
||||
);
|
||||
boxes.truncate(self.max_boxes);
|
||||
}
|
||||
// scale box corners back to original image coordinates
|
||||
for b in &mut boxes {
|
||||
for p in &mut b.corners {
|
||||
p.0 *= scale_x;
|
||||
p.1 *= scale_y;
|
||||
}
|
||||
}
|
||||
|
||||
if boxes.is_empty() {
|
||||
return Ok(empty_ocr(self));
|
||||
}
|
||||
|
||||
// ── rec per box (reading order: top→bottom, left→right) ─────────
|
||||
boxes.sort_by(|a, b| {
|
||||
let ay = a.center_y();
|
||||
let by = b.center_y();
|
||||
// group into rough rows by 0.5*box height tolerance via y then x
|
||||
ay.partial_cmp(&by)
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
.then_with(|| {
|
||||
a.center_x()
|
||||
.partial_cmp(&b.center_x())
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
})
|
||||
});
|
||||
|
||||
let mut regions: Vec<OcrRegion> = Vec::with_capacity(boxes.len());
|
||||
for b in &boxes {
|
||||
let crop = rectify_crop(&img, &b.corners);
|
||||
if crop.width() == 0 || crop.height() == 0 {
|
||||
continue;
|
||||
}
|
||||
let (text, conf) = self.run_rec(&crop)?;
|
||||
if text.is_empty() {
|
||||
continue; // rec empty → skip this box, keep the rest
|
||||
}
|
||||
let (x, y, w, h) = b.aabb();
|
||||
regions.push(OcrRegion {
|
||||
bbox: (x, y, w, h),
|
||||
text,
|
||||
confidence: conf,
|
||||
});
|
||||
}
|
||||
|
||||
let joined = regions
|
||||
.iter()
|
||||
.map(|r| r.text.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
Ok(OcrText {
|
||||
joined,
|
||||
regions,
|
||||
engine: PADDLE_ONNX_ENGINE.to_string(),
|
||||
engine_version: self.engine_version.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl OnnxPaddleOcr {
|
||||
/// Run det session → `(det_h, det_w)` probability map as a row-major Vec.
|
||||
fn run_det(&self, det_img: &image::RgbImage) -> Result<ProbMap> {
|
||||
let (w, h) = (det_img.width() as usize, det_img.height() as usize);
|
||||
let mut arr = Array4::<f32>::zeros((1, 3, h, w));
|
||||
for (x, y, px) in det_img.enumerate_pixels() {
|
||||
let (xi, yi) = (x as usize, y as usize);
|
||||
for c in 0..3 {
|
||||
let v = f32::from(px[c]) / 255.0;
|
||||
arr[[0, c, yi, xi]] = (v - IMAGENET_MEAN[c]) / IMAGENET_STD[c];
|
||||
}
|
||||
}
|
||||
let input = Value::from_array(arr).context("det Value::from_array")?;
|
||||
let sess = self.det.lock().expect("det session mutex poisoned");
|
||||
let outputs = sess
|
||||
.run(ort::inputs![self.det_input_name.as_str() => input]?)
|
||||
.context("det session run")?;
|
||||
let out_name = sess.outputs[0].name.clone();
|
||||
let view = outputs[out_name.as_str()]
|
||||
.try_extract_tensor::<f32>()
|
||||
.context("det output extract")?;
|
||||
// shape [1,1,H,W]
|
||||
let shape = view.shape();
|
||||
let (oh, ow) = (shape[shape.len() - 2], shape[shape.len() - 1]);
|
||||
let data: Vec<f32> = view.iter().copied().collect();
|
||||
Ok(ProbMap { w: ow, h: oh, data })
|
||||
}
|
||||
|
||||
/// Run rec session on a rectified crop → (decoded string, mean confidence).
|
||||
fn run_rec(&self, crop: &image::RgbImage) -> Result<(String, f32)> {
|
||||
// resize keep-aspect to height 48, then this single crop is its own batch
|
||||
let (cw, ch) = (crop.width().max(1), crop.height().max(1));
|
||||
let new_w = ((REC_HEIGHT as f32 / ch as f32) * cw as f32).round().max(1.0) as u32;
|
||||
let resized = image::imageops::resize(
|
||||
crop,
|
||||
new_w,
|
||||
REC_HEIGHT,
|
||||
image::imageops::FilterType::Triangle,
|
||||
);
|
||||
let w = new_w as usize;
|
||||
let h = REC_HEIGHT as usize;
|
||||
let mut arr = Array4::<f32>::zeros((1, 3, h, w));
|
||||
for (x, y, px) in resized.enumerate_pixels() {
|
||||
let (xi, yi) = (x as usize, y as usize);
|
||||
for c in 0..3 {
|
||||
let v = f32::from(px[c]) / 255.0;
|
||||
arr[[0, c, yi, xi]] = (v - 0.5) / 0.5; // [-1, 1]
|
||||
}
|
||||
}
|
||||
let input = Value::from_array(arr).context("rec Value::from_array")?;
|
||||
let sess = self.rec.lock().expect("rec session mutex poisoned");
|
||||
let outputs = sess
|
||||
.run(ort::inputs![self.rec_input_name.as_str() => input]?)
|
||||
.context("rec session run")?;
|
||||
let out_name = sess.outputs[0].name.clone();
|
||||
let view = outputs[out_name.as_str()]
|
||||
.try_extract_tensor::<f32>()
|
||||
.context("rec output extract")?;
|
||||
// shape [1, T, C]
|
||||
let shape = view.shape();
|
||||
let (t, c) = (shape[shape.len() - 2], shape[shape.len() - 1]);
|
||||
if c != REC_CLASSES {
|
||||
anyhow::bail!(
|
||||
"rec output has {c} classes, expected {REC_CLASSES} \
|
||||
(dict {DICT_LINES} + blank + space)"
|
||||
);
|
||||
}
|
||||
let data: Vec<f32> = view.iter().copied().collect();
|
||||
Ok(self.ctc_greedy_decode(&data, t, c))
|
||||
}
|
||||
|
||||
/// CTC greedy decode over `[T, C]` logits/probs (row-major). Per timestep
|
||||
/// argmax → collapse consecutive duplicates → drop blank → map class→str.
|
||||
fn ctc_greedy_decode(&self, data: &[f32], t: usize, c: usize) -> (String, f32) {
|
||||
let mut out = String::new();
|
||||
let mut confs: Vec<f32> = Vec::new();
|
||||
let mut prev = usize::MAX;
|
||||
for ti in 0..t {
|
||||
let row = &data[ti * c..(ti + 1) * c];
|
||||
let mut best = 0usize;
|
||||
let mut best_v = f32::MIN;
|
||||
for (i, &v) in row.iter().enumerate() {
|
||||
if v > best_v {
|
||||
best_v = v;
|
||||
best = i;
|
||||
}
|
||||
}
|
||||
if best != prev && best != CTC_BLANK {
|
||||
if let Some(s) = self.class_to_str(best) {
|
||||
out.push_str(s);
|
||||
confs.push(best_v);
|
||||
}
|
||||
}
|
||||
prev = best;
|
||||
}
|
||||
let conf = if confs.is_empty() {
|
||||
0.0
|
||||
} else {
|
||||
confs.iter().sum::<f32>() / confs.len() as f32
|
||||
};
|
||||
(out, conf)
|
||||
}
|
||||
}
|
||||
|
||||
fn empty_ocr(e: &OnnxPaddleOcr) -> OcrText {
|
||||
OcrText {
|
||||
joined: String::new(),
|
||||
regions: Vec::new(),
|
||||
engine: PADDLE_ONNX_ENGINE.to_string(),
|
||||
engine_version: e.engine_version.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Load the dict file: one token per line, trailing newline tolerated.
|
||||
/// Empty lines are preserved as empty tokens (PaddleOCR dicts may carry a
|
||||
/// blank-looking line; index integrity matters more than trimming).
|
||||
fn load_dict(path: &Path) -> Result<Vec<String>> {
|
||||
let raw = std::fs::read_to_string(path)?;
|
||||
// split on '\n'; drop a single trailing empty element from the final newline
|
||||
let mut lines: Vec<String> = raw.split('\n').map(|s| s.trim_end_matches('\r').to_string()).collect();
|
||||
if lines.last().is_some_and(String::is_empty) {
|
||||
lines.pop();
|
||||
}
|
||||
Ok(lines)
|
||||
}
|
||||
|
||||
/// Resolve the paddle-onnx `engine_version` for `config` without loading the
|
||||
/// ONNX sessions (T9). This is the same blake3-over-assets string that a
|
||||
/// constructed [`OnnxPaddleOcr`] exposes via [`OcrEngine::engine_version`], so
|
||||
/// the ingest config signature can include it. Reads ~17 MB of model bytes —
|
||||
/// callers MUST memoize per (det,rec,dict) triple (m3: never re-hash per asset).
|
||||
pub fn engine_version_for_config(config: &kebab_config::Config) -> Result<String> {
|
||||
compute_engine_version(&ModelPaths::from_config(config))
|
||||
}
|
||||
|
||||
/// blake3 over det + rec + dict bytes → stable `engine_version`.
|
||||
fn compute_engine_version(paths: &ModelPaths) -> Result<String> {
|
||||
let mut hasher = blake3::Hasher::new();
|
||||
for p in [&paths.det, &paths.rec, &paths.dict] {
|
||||
let bytes = std::fs::read(p).with_context(|| format!("reading {}", p.display()))?;
|
||||
hasher.update(&bytes);
|
||||
}
|
||||
let hash = hasher.finalize();
|
||||
let hex = hash.to_hex();
|
||||
Ok(format!("ppocrv5-mobile-kor-{}", &hex.as_str()[..12]))
|
||||
}
|
||||
|
||||
/// det resize target: keep aspect, cap long edge at `min(max_pixels, 960)`,
|
||||
/// then round each dim to a multiple of 32 (DBNet stride). Reproduces the T0a
|
||||
/// golden (192×900 → 192×896).
|
||||
fn det_target_dims(w: u32, h: u32, max_pixels: u32) -> (u32, u32) {
|
||||
let limit = DET_LIMIT_SIDE_LEN.min(max_pixels.max(32));
|
||||
let long = w.max(h);
|
||||
let ratio = if long > limit {
|
||||
limit as f32 / long as f32
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
let rw = (w as f32 * ratio).round().max(1.0);
|
||||
let rh = (h as f32 * ratio).round().max(1.0);
|
||||
let round32 = |v: f32| -> u32 {
|
||||
let r = (v / 32.0).round() as u32 * 32;
|
||||
r.max(32)
|
||||
};
|
||||
(round32(rw), round32(rh))
|
||||
}
|
||||
|
||||
// ── det postprocessing ──────────────────────────────────────────────────────
|
||||
|
||||
struct ProbMap {
|
||||
w: usize,
|
||||
h: usize,
|
||||
data: Vec<f32>,
|
||||
}
|
||||
|
||||
impl ProbMap {
|
||||
#[inline]
|
||||
fn at(&self, x: usize, y: usize) -> f32 {
|
||||
self.data[y * self.w + x]
|
||||
}
|
||||
}
|
||||
|
||||
/// A detected text box: 4 corners (clockwise from top-left) in det-image
|
||||
/// coordinates (later scaled to original).
|
||||
#[derive(Clone, Debug)]
|
||||
struct DetBox {
|
||||
corners: [(f32, f32); 4],
|
||||
#[allow(dead_code)]
|
||||
score: f32,
|
||||
}
|
||||
|
||||
impl DetBox {
|
||||
fn center_x(&self) -> f32 {
|
||||
self.corners.iter().map(|p| p.0).sum::<f32>() / 4.0
|
||||
}
|
||||
fn center_y(&self) -> f32 {
|
||||
self.corners.iter().map(|p| p.1).sum::<f32>() / 4.0
|
||||
}
|
||||
/// Axis-aligned bounding box (x, y, w, h) clamped to non-negative.
|
||||
fn aabb(&self) -> (u32, u32, u32, u32) {
|
||||
let xs = self.corners.iter().map(|p| p.0);
|
||||
let ys = self.corners.iter().map(|p| p.1);
|
||||
let minx = xs.clone().fold(f32::MAX, f32::min).max(0.0);
|
||||
let maxx = xs.fold(f32::MIN, f32::max).max(0.0);
|
||||
let miny = ys.clone().fold(f32::MAX, f32::min).max(0.0);
|
||||
let maxy = ys.fold(f32::MIN, f32::max).max(0.0);
|
||||
(
|
||||
minx.round() as u32,
|
||||
miny.round() as u32,
|
||||
(maxx - minx).round().max(0.0) as u32,
|
||||
(maxy - miny).round().max(0.0) as u32,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// DBNet-style postprocess: threshold → connected components → contour →
|
||||
/// min-area rect (rotating calipers) → box-score filter → unclip → boxes.
|
||||
/// Pinned by `tests/golden/det_boxes_clean_paragraph.json` (3 boxes).
|
||||
fn det_postprocess(
|
||||
prob: &ProbMap,
|
||||
w: usize,
|
||||
h: usize,
|
||||
score_thresh: f32,
|
||||
unclip_ratio: f32,
|
||||
) -> Vec<DetBox> {
|
||||
use image::{GrayImage, Luma};
|
||||
|
||||
// binarize at the detection threshold
|
||||
let mut bin = GrayImage::new(w as u32, h as u32);
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let v = if prob.at(x, y) > 0.3 { 255u8 } else { 0u8 };
|
||||
bin.put_pixel(x as u32, y as u32, Luma([v]));
|
||||
}
|
||||
}
|
||||
|
||||
let contours = imageproc::contours::find_contours::<u32>(&bin);
|
||||
let mut boxes = Vec::new();
|
||||
for contour in &contours {
|
||||
if contour.points.len() < 4 {
|
||||
continue;
|
||||
}
|
||||
let pts: Vec<(f32, f32)> = contour
|
||||
.points
|
||||
.iter()
|
||||
.map(|p| (p.x as f32, p.y as f32))
|
||||
.collect();
|
||||
let Some(rect) = min_area_rect(&pts) else {
|
||||
continue;
|
||||
};
|
||||
// mean-prob box score over the AABB of the rotated rect
|
||||
let score = box_score(prob, &rect.corners);
|
||||
if score < score_thresh {
|
||||
continue;
|
||||
}
|
||||
let unclipped = unclip_rect(&rect, unclip_ratio);
|
||||
boxes.push(DetBox {
|
||||
corners: unclipped,
|
||||
score,
|
||||
});
|
||||
}
|
||||
boxes
|
||||
}
|
||||
|
||||
/// Mean probability inside the axis-aligned bbox of the rect — the
|
||||
/// `box_thresh` mean-prob filter used by the golden harness.
|
||||
fn box_score(prob: &ProbMap, corners: &[(f32, f32); 4]) -> f32 {
|
||||
let minx = corners.iter().map(|p| p.0).fold(f32::MAX, f32::min).max(0.0) as usize;
|
||||
let maxx = (corners.iter().map(|p| p.0).fold(f32::MIN, f32::max).max(0.0) as usize)
|
||||
.min(prob.w.saturating_sub(1));
|
||||
let miny = corners.iter().map(|p| p.1).fold(f32::MAX, f32::min).max(0.0) as usize;
|
||||
let maxy = (corners.iter().map(|p| p.1).fold(f32::MIN, f32::max).max(0.0) as usize)
|
||||
.min(prob.h.saturating_sub(1));
|
||||
if maxx <= minx || maxy <= miny {
|
||||
return 0.0;
|
||||
}
|
||||
let mut sum = 0.0f32;
|
||||
let mut n = 0usize;
|
||||
for y in miny..=maxy {
|
||||
for x in minx..=maxx {
|
||||
sum += prob.at(x, y);
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
if n == 0 { 0.0 } else { sum / n as f32 }
|
||||
}
|
||||
|
||||
/// Rotated rect described by its 4 corners + box dims.
|
||||
#[derive(Clone, Debug)]
|
||||
struct RotRect {
|
||||
corners: [(f32, f32); 4],
|
||||
width: f32,
|
||||
height: f32,
|
||||
}
|
||||
|
||||
/// Minimum-area enclosing rectangle of a point set via rotating calipers on
|
||||
/// the convex hull (pure Rust — no OpenCV / clipper2).
|
||||
fn min_area_rect(points: &[(f32, f32)]) -> Option<RotRect> {
|
||||
let hull = convex_hull(points);
|
||||
if hull.len() < 3 {
|
||||
return None;
|
||||
}
|
||||
let n = hull.len();
|
||||
let mut best_area = f32::MAX;
|
||||
let mut best: Option<RotRect> = None;
|
||||
for i in 0..n {
|
||||
let p0 = hull[i];
|
||||
let p1 = hull[(i + 1) % n];
|
||||
let edge = (p1.0 - p0.0, p1.1 - p0.1);
|
||||
let len = (edge.0 * edge.0 + edge.1 * edge.1).sqrt();
|
||||
if len < 1e-6 {
|
||||
continue;
|
||||
}
|
||||
let ux = (edge.0 / len, edge.1 / len); // edge direction
|
||||
let uy = (-ux.1, ux.0); // normal
|
||||
let (mut min_u, mut max_u) = (f32::MAX, f32::MIN);
|
||||
let (mut min_v, mut max_v) = (f32::MAX, f32::MIN);
|
||||
for &p in &hull {
|
||||
let du = p.0 * ux.0 + p.1 * ux.1;
|
||||
let dv = p.0 * uy.0 + p.1 * uy.1;
|
||||
min_u = min_u.min(du);
|
||||
max_u = max_u.max(du);
|
||||
min_v = min_v.min(dv);
|
||||
max_v = max_v.max(dv);
|
||||
}
|
||||
let area = (max_u - min_u) * (max_v - min_v);
|
||||
if area < best_area {
|
||||
best_area = area;
|
||||
// reconstruct corners in (u,v) basis → world
|
||||
let to_world = |u: f32, v: f32| (u * ux.0 + v * uy.0, u * ux.1 + v * uy.1);
|
||||
let corners = [
|
||||
to_world(min_u, min_v),
|
||||
to_world(max_u, min_v),
|
||||
to_world(max_u, max_v),
|
||||
to_world(min_u, max_v),
|
||||
];
|
||||
best = Some(RotRect {
|
||||
corners,
|
||||
width: max_u - min_u,
|
||||
height: max_v - min_v,
|
||||
});
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
/// Andrew's monotone chain convex hull. Returns CCW hull without duplicates.
|
||||
fn convex_hull(points: &[(f32, f32)]) -> Vec<(f32, f32)> {
|
||||
let mut pts: Vec<(f32, f32)> = points.to_vec();
|
||||
pts.sort_by(|a, b| {
|
||||
a.0.partial_cmp(&b.0)
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
.then(a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
|
||||
});
|
||||
pts.dedup();
|
||||
if pts.len() < 3 {
|
||||
return pts;
|
||||
}
|
||||
let cross = |o: (f32, f32), a: (f32, f32), b: (f32, f32)| {
|
||||
(a.0 - o.0) * (b.1 - o.1) - (a.1 - o.1) * (b.0 - o.0)
|
||||
};
|
||||
let mut lower: Vec<(f32, f32)> = Vec::new();
|
||||
for &p in &pts {
|
||||
while lower.len() >= 2 && cross(lower[lower.len() - 2], lower[lower.len() - 1], p) <= 0.0 {
|
||||
lower.pop();
|
||||
}
|
||||
lower.push(p);
|
||||
}
|
||||
let mut upper: Vec<(f32, f32)> = Vec::new();
|
||||
for &p in pts.iter().rev() {
|
||||
while upper.len() >= 2 && cross(upper[upper.len() - 2], upper[upper.len() - 1], p) <= 0.0 {
|
||||
upper.pop();
|
||||
}
|
||||
upper.push(p);
|
||||
}
|
||||
lower.pop();
|
||||
upper.pop();
|
||||
lower.extend(upper);
|
||||
lower
|
||||
}
|
||||
|
||||
/// Unclip a rotated rect by `ratio` (PaddleOCR `distance = area*ratio/perimeter`),
|
||||
/// expanding width + height by `2*distance`. For a rectangle this matches the
|
||||
/// general polygon offset PaddleOCR uses (pyclipper) — pure Rust here.
|
||||
fn unclip_rect(rect: &RotRect, ratio: f32) -> [(f32, f32); 4] {
|
||||
let area = rect.width * rect.height;
|
||||
let perimeter = 2.0 * (rect.width + rect.height);
|
||||
if perimeter < 1e-6 {
|
||||
return rect.corners;
|
||||
}
|
||||
let distance = area * ratio / perimeter;
|
||||
// Offset every EDGE outward by `distance` (PaddleOCR pyclipper polygon
|
||||
// offset): width and height each grow by 2*distance. A naive radial
|
||||
// push-from-centroid is WRONG for text boxes — a wide/short box has an
|
||||
// almost-horizontal diagonal, so radial expansion barely grows the height
|
||||
// and clips character tops/bottoms (ㄷ→ㄴ, ascenders lost). We instead
|
||||
// expand along the rect's own (u, v) axes recovered from its ordered
|
||||
// corners (c0=min_u,min_v; c1=max_u,min_v; c2=max_u,max_v; c3=min_u,max_v).
|
||||
let c = &rect.corners;
|
||||
let unit = |dx: f32, dy: f32| -> (f32, f32) {
|
||||
let len = (dx * dx + dy * dy).sqrt();
|
||||
if len > 1e-6 { (dx / len, dy / len) } else { (0.0, 0.0) }
|
||||
};
|
||||
let u = unit(c[1].0 - c[0].0, c[1].1 - c[0].1); // +u (along width)
|
||||
let v = unit(c[3].0 - c[0].0, c[3].1 - c[0].1); // +v (along height)
|
||||
let off = |p: (f32, f32), su: f32, sv: f32| -> (f32, f32) {
|
||||
(
|
||||
p.0 + su * distance * u.0 + sv * distance * v.0,
|
||||
p.1 + su * distance * u.1 + sv * distance * v.1,
|
||||
)
|
||||
};
|
||||
[
|
||||
off(c[0], -1.0, -1.0),
|
||||
off(c[1], 1.0, -1.0),
|
||||
off(c[2], 1.0, 1.0),
|
||||
off(c[3], -1.0, 1.0),
|
||||
]
|
||||
}
|
||||
|
||||
// ── crop + rectify ───────────────────────────────────────────────────────────
|
||||
|
||||
/// Perspective-warp the quadrilateral `corners` (clockwise from top-left) into
|
||||
/// a horizontal strip. Output size derives from the box edge lengths.
|
||||
fn rectify_crop(img: &image::RgbImage, corners: &[(f32, f32); 4]) -> image::RgbImage {
|
||||
// order corners: top-left, top-right, bottom-right, bottom-left
|
||||
let ordered = order_corners(corners);
|
||||
let dist = |a: (f32, f32), b: (f32, f32)| ((a.0 - b.0).powi(2) + (a.1 - b.1).powi(2)).sqrt();
|
||||
let w = dist(ordered[0], ordered[1]).max(dist(ordered[3], ordered[2]));
|
||||
let h = dist(ordered[0], ordered[3]).max(dist(ordered[1], ordered[2]));
|
||||
let out_w = w.round().max(1.0) as u32;
|
||||
let out_h = h.round().max(1.0) as u32;
|
||||
let mut out = image::RgbImage::new(out_w, out_h);
|
||||
let (iw, ih) = (img.width() as f32, img.height() as f32);
|
||||
// bilinear map from output grid back to the source quad (inverse via
|
||||
// bilinear interpolation of the four corners — adequate for near-affine
|
||||
// text boxes).
|
||||
for oy in 0..out_h {
|
||||
let fy = oy as f32 / (out_h.max(1) as f32 - 1.0).max(1.0);
|
||||
for ox in 0..out_w {
|
||||
let fx = ox as f32 / (out_w.max(1) as f32 - 1.0).max(1.0);
|
||||
// bilinear blend of the four source corners
|
||||
let top = (
|
||||
ordered[0].0 + (ordered[1].0 - ordered[0].0) * fx,
|
||||
ordered[0].1 + (ordered[1].1 - ordered[0].1) * fx,
|
||||
);
|
||||
let bot = (
|
||||
ordered[3].0 + (ordered[2].0 - ordered[3].0) * fx,
|
||||
ordered[3].1 + (ordered[2].1 - ordered[3].1) * fx,
|
||||
);
|
||||
let sx = (top.0 + (bot.0 - top.0) * fy).clamp(0.0, iw - 1.0);
|
||||
let sy = (top.1 + (bot.1 - top.1) * fy).clamp(0.0, ih - 1.0);
|
||||
let px = img.get_pixel(sx.round() as u32, sy.round() as u32);
|
||||
out.put_pixel(ox, oy, *px);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Order 4 corners as [top-left, top-right, bottom-right, bottom-left] using
|
||||
/// coordinate sums/diffs (standard PaddleOCR ordering).
|
||||
fn order_corners(corners: &[(f32, f32); 4]) -> [(f32, f32); 4] {
|
||||
// top-left has smallest x+y, bottom-right largest x+y;
|
||||
// top-right smallest y-x, bottom-left largest y-x.
|
||||
let mut tl = corners[0];
|
||||
let mut br = corners[0];
|
||||
let mut tr = corners[0];
|
||||
let mut bl = corners[0];
|
||||
let (mut min_sum, mut max_sum) = (f32::MAX, f32::MIN);
|
||||
let (mut min_diff, mut max_diff) = (f32::MAX, f32::MIN);
|
||||
for &p in corners {
|
||||
let sum = p.0 + p.1;
|
||||
let diff = p.1 - p.0;
|
||||
if sum < min_sum {
|
||||
min_sum = sum;
|
||||
tl = p;
|
||||
}
|
||||
if sum > max_sum {
|
||||
max_sum = sum;
|
||||
br = p;
|
||||
}
|
||||
if diff < min_diff {
|
||||
min_diff = diff;
|
||||
tr = p;
|
||||
}
|
||||
if diff > max_diff {
|
||||
max_diff = diff;
|
||||
bl = p;
|
||||
}
|
||||
}
|
||||
[tl, tr, br, bl]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn det_target_dims_matches_golden() {
|
||||
// T0a golden: clean_paragraph 192×900 → det input 192×896.
|
||||
assert_eq!(det_target_dims(900, 192, 1600), (896, 192));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convex_hull_square() {
|
||||
let pts = vec![(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0), (5.0, 5.0)];
|
||||
let hull = convex_hull(&pts);
|
||||
assert_eq!(hull.len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn min_area_rect_axis_aligned() {
|
||||
let pts = vec![(0.0, 0.0), (20.0, 0.0), (20.0, 5.0), (0.0, 5.0)];
|
||||
let r = min_area_rect(&pts).expect("rect");
|
||||
let (lo, hi) = (r.width.min(r.height), r.width.max(r.height));
|
||||
assert!((lo - 5.0).abs() < 1e-3, "short side {lo}");
|
||||
assert!((hi - 20.0).abs() < 1e-3, "long side {hi}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dict_length_mismatch_is_construction_error() {
|
||||
// T10: a dict whose line count != DICT_LINES must fail at construction
|
||||
// (before loading the ONNX sessions) rather than mis-decoding silently.
|
||||
use std::io::Write;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let dict_path = dir.path().join("bad_dict.txt");
|
||||
let mut f = std::fs::File::create(&dict_path).unwrap();
|
||||
writeln!(f, "a\nb\nc").unwrap(); // 3 lines, not DICT_LINES
|
||||
let paths = ModelPaths {
|
||||
det: dir.path().join("unused_det.onnx"),
|
||||
rec: dir.path().join("unused_rec.onnx"),
|
||||
dict: dict_path,
|
||||
};
|
||||
let err = OnnxPaddleOcr::from_paths(&paths, 0.3, 1.5, 1000, 1600)
|
||||
.expect_err("dict mismatch must error");
|
||||
let msg = format!("{err:#}");
|
||||
assert!(msg.contains("dict has 3 lines"), "unexpected error: {msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_paths_from_config_uses_overrides() {
|
||||
// T7: unset overrides → bundled default asset paths.
|
||||
let mut cfg = kebab_config::Config::defaults();
|
||||
let def = ModelPaths::from_config(&cfg);
|
||||
assert!(def.det.ends_with("ppocrv5_mobile_det.onnx"), "{:?}", def.det);
|
||||
assert!(def.rec.ends_with("korean_ppocrv5_mobile_rec.onnx"), "{:?}", def.rec);
|
||||
assert!(def.dict.ends_with("korean_dict.txt"), "{:?}", def.dict);
|
||||
|
||||
// Override det + dict; rec stays bundled (partial override allowed).
|
||||
cfg.image.ocr.det_model = Some("/custom/det.onnx".to_string());
|
||||
cfg.image.ocr.dict = Some("/custom/dict.txt".to_string());
|
||||
let ov = ModelPaths::from_config(&cfg);
|
||||
assert_eq!(ov.det, PathBuf::from("/custom/det.onnx"));
|
||||
assert_eq!(ov.dict, PathBuf::from("/custom/dict.txt"));
|
||||
assert!(ov.rec.ends_with("korean_ppocrv5_mobile_rec.onnx"), "{:?}", ov.rec);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unclip_expands_box() {
|
||||
let rect = RotRect {
|
||||
corners: [(0.0, 0.0), (20.0, 0.0), (20.0, 5.0), (0.0, 5.0)],
|
||||
width: 20.0,
|
||||
height: 5.0,
|
||||
};
|
||||
let out = unclip_rect(&rect, 1.5);
|
||||
// unclipped box must be strictly larger than the original
|
||||
let orig_minx = 0.0;
|
||||
let new_minx = out.iter().map(|p| p.0).fold(f32::MAX, f32::min);
|
||||
assert!(new_minx < orig_minx, "expected expansion, got {new_minx}");
|
||||
}
|
||||
}
|
||||
516
crates/kebab-parse-image/tests/golden/ctc_rec_golden.json
Normal file
516
crates/kebab-parse-image/tests/golden/ctc_rec_golden.json
Normal file
@@ -0,0 +1,516 @@
|
||||
{
|
||||
"dict_lines": 11945,
|
||||
"rec_classes": 11947,
|
||||
"blank_index": 0,
|
||||
"space_index": 11946,
|
||||
"mapping": "idx0=blank; idx 1..N=dict[idx-1]; idx N+1=space; classes=dict+2",
|
||||
"rec_norm": "RGB, /255 then (x-0.5)/0.5 => [-1,1], height=48 keep-aspect pad",
|
||||
"det_norm": "RGB, ImageNet mean/std *255 then /std, NCHW",
|
||||
"rec_cases": [
|
||||
{
|
||||
"text": "RAG 시스템 검색 결과",
|
||||
"decoded": "RAG시스템 검색 결과",
|
||||
"cer": 0.0769,
|
||||
"cer_nospace": 0.0,
|
||||
"mapping_ok": true,
|
||||
"T": 40,
|
||||
"C": 11947,
|
||||
"argmax_idx": [
|
||||
0,
|
||||
0,
|
||||
11553,
|
||||
0,
|
||||
11536,
|
||||
0,
|
||||
0,
|
||||
11542,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
6185,
|
||||
0,
|
||||
0,
|
||||
6129,
|
||||
0,
|
||||
0,
|
||||
9897,
|
||||
0,
|
||||
0,
|
||||
11946,
|
||||
0,
|
||||
461,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
5654,
|
||||
0,
|
||||
11946,
|
||||
0,
|
||||
509,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
585,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"collapsed_idx": [
|
||||
11553,
|
||||
11536,
|
||||
11542,
|
||||
6185,
|
||||
6129,
|
||||
9897,
|
||||
11946,
|
||||
461,
|
||||
5654,
|
||||
11946,
|
||||
509,
|
||||
585
|
||||
],
|
||||
"collapsed_conf": [
|
||||
0.0002,
|
||||
0.0002,
|
||||
0.0002,
|
||||
0.0002,
|
||||
0.0002,
|
||||
0.0002,
|
||||
0.0002,
|
||||
0.0002,
|
||||
0.0002,
|
||||
0.0002,
|
||||
0.0002,
|
||||
0.0002
|
||||
],
|
||||
"fired_timesteps": [
|
||||
2,
|
||||
4,
|
||||
7,
|
||||
11,
|
||||
14,
|
||||
17,
|
||||
20,
|
||||
22,
|
||||
26,
|
||||
28,
|
||||
30,
|
||||
34
|
||||
],
|
||||
"fired_logit_top5": [
|
||||
{
|
||||
"t": 2,
|
||||
"top5_idx": [
|
||||
11553,
|
||||
11583,
|
||||
11551,
|
||||
0,
|
||||
11541
|
||||
],
|
||||
"top5_val": [
|
||||
0.9998,
|
||||
0.0001,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0
|
||||
]
|
||||
},
|
||||
{
|
||||
"t": 4,
|
||||
"top5_idx": [
|
||||
11536,
|
||||
11566,
|
||||
0,
|
||||
11748,
|
||||
11551
|
||||
],
|
||||
"top5_val": [
|
||||
0.9998,
|
||||
0.0001,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0
|
||||
]
|
||||
},
|
||||
{
|
||||
"t": 7,
|
||||
"top5_idx": [
|
||||
11542,
|
||||
0,
|
||||
11572,
|
||||
11946,
|
||||
11585
|
||||
],
|
||||
"top5_val": [
|
||||
0.9994,
|
||||
0.0004,
|
||||
0.0001,
|
||||
0.0001,
|
||||
0.0
|
||||
]
|
||||
},
|
||||
{
|
||||
"t": 11,
|
||||
"top5_idx": [
|
||||
6185,
|
||||
0,
|
||||
11946,
|
||||
7949,
|
||||
11518
|
||||
],
|
||||
"top5_val": [
|
||||
0.9993,
|
||||
0.0003,
|
||||
0.0001,
|
||||
0.0001,
|
||||
0.0
|
||||
]
|
||||
},
|
||||
{
|
||||
"t": 14,
|
||||
"top5_idx": [
|
||||
6129,
|
||||
7893,
|
||||
0,
|
||||
9069,
|
||||
11536
|
||||
],
|
||||
"top5_val": [
|
||||
0.9997,
|
||||
0.0002,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0
|
||||
]
|
||||
},
|
||||
{
|
||||
"t": 17,
|
||||
"top5_idx": [
|
||||
9897,
|
||||
9882,
|
||||
9889,
|
||||
9785,
|
||||
3429
|
||||
],
|
||||
"top5_val": [
|
||||
0.9999,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0
|
||||
]
|
||||
},
|
||||
{
|
||||
"t": 20,
|
||||
"top5_idx": [
|
||||
11946,
|
||||
0,
|
||||
11516,
|
||||
11518,
|
||||
11579
|
||||
],
|
||||
"top5_val": [
|
||||
0.9026,
|
||||
0.0971,
|
||||
0.0002,
|
||||
0.0001,
|
||||
0.0
|
||||
]
|
||||
},
|
||||
{
|
||||
"t": 22,
|
||||
"top5_idx": [
|
||||
461,
|
||||
462,
|
||||
9281,
|
||||
349,
|
||||
0
|
||||
],
|
||||
"top5_val": [
|
||||
0.9995,
|
||||
0.0003,
|
||||
0.0001,
|
||||
0.0,
|
||||
0.0
|
||||
]
|
||||
},
|
||||
{
|
||||
"t": 26,
|
||||
"top5_idx": [
|
||||
5654,
|
||||
0,
|
||||
5766,
|
||||
8594,
|
||||
6830
|
||||
],
|
||||
"top5_val": [
|
||||
1.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0
|
||||
]
|
||||
},
|
||||
{
|
||||
"t": 28,
|
||||
"top5_idx": [
|
||||
11946,
|
||||
0,
|
||||
11516,
|
||||
11549,
|
||||
11564
|
||||
],
|
||||
"top5_val": [
|
||||
0.9422,
|
||||
0.0576,
|
||||
0.0001,
|
||||
0.0,
|
||||
0.0
|
||||
]
|
||||
},
|
||||
{
|
||||
"t": 30,
|
||||
"top5_idx": [
|
||||
509,
|
||||
0,
|
||||
453,
|
||||
11946,
|
||||
505
|
||||
],
|
||||
"top5_val": [
|
||||
0.9994,
|
||||
0.0004,
|
||||
0.0001,
|
||||
0.0,
|
||||
0.0
|
||||
]
|
||||
},
|
||||
{
|
||||
"t": 34,
|
||||
"top5_idx": [
|
||||
585,
|
||||
641,
|
||||
0,
|
||||
10329,
|
||||
589
|
||||
],
|
||||
"top5_val": [
|
||||
0.9999,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"text": "Embedding vector 0123",
|
||||
"decoded": "Embedding vector 0123",
|
||||
"cer": 0.0,
|
||||
"cer_nospace": 0.0,
|
||||
"mapping_ok": true,
|
||||
"T": 41,
|
||||
"C": 11947,
|
||||
"argmax_idx": [
|
||||
0,
|
||||
11540,
|
||||
0,
|
||||
0,
|
||||
11578,
|
||||
0,
|
||||
0,
|
||||
11567,
|
||||
0,
|
||||
11570,
|
||||
0,
|
||||
11569,
|
||||
0,
|
||||
11569,
|
||||
0,
|
||||
11574,
|
||||
0,
|
||||
11579,
|
||||
11572,
|
||||
11572,
|
||||
11946,
|
||||
0,
|
||||
11587,
|
||||
11570,
|
||||
0,
|
||||
11568,
|
||||
0,
|
||||
11585,
|
||||
11580,
|
||||
0,
|
||||
11583,
|
||||
11946,
|
||||
11946,
|
||||
11520,
|
||||
0,
|
||||
11521,
|
||||
0,
|
||||
11522,
|
||||
0,
|
||||
11523,
|
||||
0
|
||||
],
|
||||
"collapsed_idx": [
|
||||
11540,
|
||||
11578,
|
||||
11567,
|
||||
11570,
|
||||
11569,
|
||||
11569,
|
||||
11574,
|
||||
11579,
|
||||
11572,
|
||||
11946,
|
||||
11587,
|
||||
11570,
|
||||
11568,
|
||||
11585,
|
||||
11580,
|
||||
11583,
|
||||
11946,
|
||||
11520,
|
||||
11521,
|
||||
11522,
|
||||
11523
|
||||
],
|
||||
"collapsed_conf": [
|
||||
0.0002,
|
||||
0.0002,
|
||||
0.0002,
|
||||
0.0002,
|
||||
0.0002,
|
||||
0.0002,
|
||||
0.0002,
|
||||
0.0002,
|
||||
0.0001,
|
||||
0.0002,
|
||||
0.0002,
|
||||
0.0002,
|
||||
0.0002,
|
||||
0.0002,
|
||||
0.0002,
|
||||
0.0002,
|
||||
0.0002,
|
||||
0.0002,
|
||||
0.0002,
|
||||
0.0002,
|
||||
0.0002
|
||||
]
|
||||
},
|
||||
{
|
||||
"text": "한글 OCR 정확도 테스트",
|
||||
"decoded": "한글 OCR 정확도 테스트",
|
||||
"cer": 0.0,
|
||||
"cer_nospace": 0.0,
|
||||
"mapping_ok": true,
|
||||
"T": 41,
|
||||
"C": 11947,
|
||||
"argmax_idx": [
|
||||
0,
|
||||
0,
|
||||
10921,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
845,
|
||||
0,
|
||||
11946,
|
||||
0,
|
||||
11550,
|
||||
0,
|
||||
0,
|
||||
11538,
|
||||
0,
|
||||
11553,
|
||||
0,
|
||||
11946,
|
||||
0,
|
||||
7522,
|
||||
0,
|
||||
0,
|
||||
11170,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
2321,
|
||||
0,
|
||||
11946,
|
||||
11946,
|
||||
9881,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
6129,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
10245,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"collapsed_idx": [
|
||||
10921,
|
||||
845,
|
||||
11946,
|
||||
11550,
|
||||
11538,
|
||||
11553,
|
||||
11946,
|
||||
7522,
|
||||
11170,
|
||||
2321,
|
||||
11946,
|
||||
9881,
|
||||
6129,
|
||||
10245
|
||||
],
|
||||
"collapsed_conf": [
|
||||
0.0002,
|
||||
0.0002,
|
||||
0.0002,
|
||||
0.0002,
|
||||
0.0002,
|
||||
0.0002,
|
||||
0.0002,
|
||||
0.0002,
|
||||
0.0002,
|
||||
0.0002,
|
||||
0.0002,
|
||||
0.0002,
|
||||
0.0002,
|
||||
0.0002
|
||||
]
|
||||
}
|
||||
],
|
||||
"det_cases": [
|
||||
{
|
||||
"fixture": "clean_paragraph.png",
|
||||
"orig_hw": [
|
||||
192,
|
||||
900
|
||||
],
|
||||
"det_input_hw": [
|
||||
192,
|
||||
896
|
||||
],
|
||||
"prob_shape": [
|
||||
192,
|
||||
896
|
||||
],
|
||||
"prob_max": 1.0,
|
||||
"prob_mean": 0.1139,
|
||||
"positives_at_0.3": 19682,
|
||||
"positive_frac": 0.1144,
|
||||
"box_count": 3,
|
||||
"postproc": "thresh=0.3 -> findContours -> minAreaRect -> unclip(ratio=1.5, area*r/peri); box_thresh=0.5 mean-prob filter; coords scaled back to orig hw"
|
||||
}
|
||||
],
|
||||
"blank_index_confirmed_by_gt": true
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
{
|
||||
"fixture": "clean_paragraph.png",
|
||||
"orig_hw": [
|
||||
192,
|
||||
900
|
||||
],
|
||||
"det_input_hw": [
|
||||
192,
|
||||
896
|
||||
],
|
||||
"thresh": 0.3,
|
||||
"unclip_ratio": 1.5,
|
||||
"boxes": [
|
||||
{
|
||||
"poly": [
|
||||
[
|
||||
29,
|
||||
135
|
||||
],
|
||||
[
|
||||
615,
|
||||
134
|
||||
],
|
||||
[
|
||||
615,
|
||||
149
|
||||
],
|
||||
[
|
||||
29,
|
||||
150
|
||||
]
|
||||
],
|
||||
"score": 0.8724
|
||||
},
|
||||
{
|
||||
"poly": [
|
||||
[
|
||||
30,
|
||||
92
|
||||
],
|
||||
[
|
||||
597,
|
||||
92
|
||||
],
|
||||
[
|
||||
597,
|
||||
105
|
||||
],
|
||||
[
|
||||
30,
|
||||
105
|
||||
]
|
||||
],
|
||||
"score": 0.9627
|
||||
},
|
||||
{
|
||||
"poly": [
|
||||
[
|
||||
30,
|
||||
47
|
||||
],
|
||||
[
|
||||
509,
|
||||
47
|
||||
],
|
||||
[
|
||||
509,
|
||||
60
|
||||
],
|
||||
[
|
||||
30,
|
||||
60
|
||||
]
|
||||
],
|
||||
"score": 0.9304
|
||||
}
|
||||
]
|
||||
}
|
||||
145
crates/kebab-parse-image/tests/paddle_e2e.rs
Normal file
145
crates/kebab-parse-image/tests/paddle_e2e.rs
Normal file
@@ -0,0 +1,145 @@
|
||||
//! T11 e2e accuracy gate for the paddle-onnx OCR engine.
|
||||
//!
|
||||
//! Runs the full `OnnxPaddleOcr` pipeline (det → rectify → rec → CTC) over the
|
||||
//! synthetic OCR benchmark fixtures and asserts the mean character error rate
|
||||
//! (CER) over the clean text set is `<= 0.05`, matching the spec gate.
|
||||
//!
|
||||
//! Model assets come from `KEBAB_TEST_OCR_MODEL_DIR` (default: the crate's
|
||||
//! bundled `assets/paddleocr-onnx/`). Fixtures come from
|
||||
//! `KEBAB_TEST_OCR_FIXTURE_DIR` (default: the dogfood corpus). If either is
|
||||
//! absent the test skips with a warning rather than failing — CI without the
|
||||
//! large models / fixtures stays green (plan T0/M4).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use kebab_parse_image::{ModelPaths, OcrEngine, OnnxPaddleOcr};
|
||||
|
||||
/// Collapse all whitespace runs to a single space + trim — matches the Python
|
||||
/// `score_lib.norm` so the Rust gate and the bench harness agree.
|
||||
fn norm(s: &str) -> String {
|
||||
s.split_whitespace().collect::<Vec<_>>().join(" ")
|
||||
}
|
||||
|
||||
/// Character error rate = Levenshtein(gt, pred) / len(gt), both normalized.
|
||||
fn cer(gt: &str, pred: &str) -> f64 {
|
||||
let g: Vec<char> = norm(gt).chars().collect();
|
||||
let p: Vec<char> = norm(pred).chars().collect();
|
||||
if g.is_empty() {
|
||||
return if p.is_empty() { 0.0 } else { 1.0 };
|
||||
}
|
||||
let (m, n) = (g.len(), p.len());
|
||||
let mut prev: Vec<usize> = (0..=n).collect();
|
||||
for i in 1..=m {
|
||||
let mut cur = vec![i; n + 1];
|
||||
for j in 1..=n {
|
||||
let cost = usize::from(g[i - 1] != p[j - 1]);
|
||||
cur[j] = (prev[j] + 1).min(cur[j - 1] + 1).min(prev[j - 1] + cost);
|
||||
}
|
||||
prev = cur;
|
||||
}
|
||||
prev[n] as f64 / m as f64
|
||||
}
|
||||
|
||||
fn fixture_dir() -> PathBuf {
|
||||
std::env::var("KEBAB_TEST_OCR_FIXTURE_DIR").map_or_else(
|
||||
|_| PathBuf::from("/build/dogfood/corpus/images/synthetic-ocr-bench"),
|
||||
PathBuf::from,
|
||||
)
|
||||
}
|
||||
|
||||
/// T10: undecodable image bytes must surface as an error (the kebab-app caller
|
||||
/// then skips the asset + records provenance), not panic or return garbage.
|
||||
#[test]
|
||||
fn paddle_onnx_decode_failure_is_error() {
|
||||
let paths = ModelPaths::from_default_dir();
|
||||
if !paths.det.exists() || !paths.rec.exists() || !paths.dict.exists() {
|
||||
eprintln!("SKIP paddle_onnx_decode_failure_is_error: model assets not found");
|
||||
return;
|
||||
}
|
||||
let engine = OnnxPaddleOcr::from_paths(&paths, 0.3, 1.5, 1000, 1600).unwrap();
|
||||
let err = engine
|
||||
.recognize(b"not a real image", None)
|
||||
.expect_err("garbage bytes must fail to decode");
|
||||
let msg = format!("{err:#}");
|
||||
assert!(msg.contains("decoding image"), "unexpected error: {msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paddle_onnx_cer_gate() {
|
||||
let paths = ModelPaths::from_default_dir();
|
||||
if !paths.det.exists() || !paths.rec.exists() || !paths.dict.exists() {
|
||||
eprintln!(
|
||||
"SKIP paddle_onnx_cer_gate: model assets not found (det={}). \
|
||||
Set KEBAB_TEST_OCR_MODEL_DIR or place assets/paddleocr-onnx/.",
|
||||
paths.det.display()
|
||||
);
|
||||
return;
|
||||
}
|
||||
let fdir = fixture_dir();
|
||||
let gt_path = fdir.join("gt.json");
|
||||
if !gt_path.exists() {
|
||||
eprintln!(
|
||||
"SKIP paddle_onnx_cer_gate: fixtures not found at {}",
|
||||
fdir.display()
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let gt: HashMap<String, String> =
|
||||
serde_json::from_str(&std::fs::read_to_string(>_path).unwrap()).unwrap();
|
||||
|
||||
let engine = OnnxPaddleOcr::from_paths(&paths, 0.3, 1.5, 1000, 1600)
|
||||
.expect("build OnnxPaddleOcr from bundled assets");
|
||||
|
||||
// "clean" set used for the gate — the standard, well-formed text fixtures.
|
||||
// low_contrast / small_dense are intentionally hard and tracked but not
|
||||
// part of the hard gate.
|
||||
let gate_set = [
|
||||
"clean_paragraph.png",
|
||||
"title_body.png",
|
||||
"tech_terms.png",
|
||||
"korean_heavy.png",
|
||||
"numbers_table.png",
|
||||
];
|
||||
|
||||
let mut gate_cers = Vec::new();
|
||||
let mut names: Vec<&String> = gt.keys().collect();
|
||||
names.sort();
|
||||
println!("\n=== paddle-onnx CER per fixture ===");
|
||||
for name in names {
|
||||
let img_path = fdir.join(name);
|
||||
if !img_path.exists() {
|
||||
continue;
|
||||
}
|
||||
let bytes = std::fs::read(&img_path).unwrap();
|
||||
let t0 = std::time::Instant::now();
|
||||
let out = engine.recognize(&bytes, None).expect("recognize");
|
||||
let dt = t0.elapsed();
|
||||
let c = cer(>[name], &out.joined);
|
||||
if std::env::var("KEBAB_OCR_DUMP").is_ok() {
|
||||
println!(" GT [{name}]: {:?}", norm(>[name]));
|
||||
println!(" OUT [{name}]: {:?}", norm(&out.joined));
|
||||
}
|
||||
let gated = gate_set.contains(&name.as_str());
|
||||
println!(
|
||||
"{:<22} CER={:.4} {} ({} regions, {} ms)",
|
||||
name,
|
||||
c,
|
||||
if gated { "[gate]" } else { " " },
|
||||
out.regions.len(),
|
||||
dt.as_millis()
|
||||
);
|
||||
if gated {
|
||||
gate_cers.push(c);
|
||||
}
|
||||
}
|
||||
|
||||
assert!(!gate_cers.is_empty(), "no gate fixtures were scored");
|
||||
let mean = gate_cers.iter().sum::<f64>() / gate_cers.len() as f64;
|
||||
println!("=== mean gate CER = {mean:.4} (threshold 0.05) ===\n");
|
||||
assert!(
|
||||
mean <= 0.05,
|
||||
"paddle-onnx mean CER {mean:.4} exceeds 0.05 gate"
|
||||
);
|
||||
}
|
||||
@@ -36,9 +36,13 @@ const DEFAULT_K: usize = 10;
|
||||
/// Over-fetch multiplier passed to `VectorStore::search` so that
|
||||
/// SQLite-side filter losses (tags / lang / trust / path_glob) still
|
||||
/// leave at least `k` candidates. The Lance store already applies the
|
||||
/// same filters internally; the extra `* 2` is the spec-mandated
|
||||
/// safety margin for the `Retriever` layer (§7.2 spec line 138).
|
||||
const VECTOR_OVERFETCH_MULTIPLIER: usize = 2;
|
||||
/// same filters internally; the extra margin is the spec-mandated
|
||||
/// safety for the `Retriever` layer (§7.2 spec line 138).
|
||||
///
|
||||
/// `3` (was `2`): dense 별칭 sentinel 벡터(`{orig}#alias`)가 같은 청크의
|
||||
/// body 벡터와 함께 raw_hits 에 들어올 수 있어, strip+dedup 후에도 `k` 개를
|
||||
///확보하도록 여유를 키운다(별칭 미사용 시에도 안전한 상한).
|
||||
const VECTOR_OVERFETCH_MULTIPLIER: usize = 3;
|
||||
|
||||
/// Wraps a vector store + embedder into a [`Retriever`].
|
||||
///
|
||||
@@ -149,23 +153,34 @@ impl Retriever for VectorRetriever {
|
||||
}
|
||||
|
||||
// 3. Hydrate metadata from SQLite for the candidate ids in
|
||||
// one round-trip. Order is preserved by the caller via the
|
||||
// HashMap lookup at hit-construction time.
|
||||
let candidate_ids: Vec<&str> = raw_hits.iter().map(|h| h.chunk_id.0.as_str()).collect();
|
||||
// one round-trip. dense 별칭 벡터는 sentinel chunk_id
|
||||
// (`{orig}#alias`)로 색인되므로, 원본 chunk_id 로 strip 해
|
||||
// hydrate 한다(별칭 벡터는 chunks 테이블에 없음).
|
||||
let candidate_ids: Vec<&str> = raw_hits
|
||||
.iter()
|
||||
.map(|h| kebab_core::strip_alias_suffix(h.chunk_id.0.as_str()))
|
||||
.collect();
|
||||
let hydration = hydrate_chunks(&self.sqlite, &candidate_ids)
|
||||
.context("kb-search vector: hydrate chunk metadata")?;
|
||||
|
||||
// 4. Build `SearchHit` for the first `k` raw hits that pass
|
||||
// hydration (a missing row would be a filter-induced drop —
|
||||
// Lance returned the chunk but SQLite filtered it out, or
|
||||
// the chunk was deleted between Lance's read and ours).
|
||||
// hydration. sentinel 별칭 hit 은 원본 chunk_id 로 strip 하고,
|
||||
// 같은 원본이 body+alias 둘 다 hit 하면 첫(높은 score) 하나만
|
||||
// 남긴다(dedup). raw_hits 는 score 순이라 첫 매칭이 최선.
|
||||
let model_id = self.embed.model_id();
|
||||
let mut hits: Vec<SearchHit> = Vec::with_capacity(k.min(raw_hits.len()));
|
||||
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||
let mut rank: u32 = 0;
|
||||
for hit in raw_hits {
|
||||
let Some(meta) = hydration.get(hit.chunk_id.0.as_str()) else {
|
||||
for mut hit in raw_hits {
|
||||
let orig = kebab_core::strip_alias_suffix(hit.chunk_id.0.as_str()).to_string();
|
||||
if !seen.insert(orig.clone()) {
|
||||
continue; // body+alias 중복 → 첫 hit 유지
|
||||
}
|
||||
let Some(meta) = hydration.get(orig.as_str()) else {
|
||||
continue;
|
||||
};
|
||||
// build_hit 이 원본 chunk_id 를 쓰도록 sentinel 을 strip 본으로 교체.
|
||||
hit.chunk_id = kebab_core::ChunkId(orig);
|
||||
rank = rank.saturating_add(1);
|
||||
hits.push(build_hit(
|
||||
hit,
|
||||
|
||||
@@ -1253,3 +1253,49 @@ fn lexical_raw_mode_can_opt_into_heading_path_filter() {
|
||||
"raw-mode heading_path filter must hit the seeded chunk"
|
||||
);
|
||||
}
|
||||
|
||||
// ── body-only lexical recall (regression-safety) ──────────────────────────
|
||||
|
||||
/// Body `chunks_fts` recall works for a plain term in the chunk text.
|
||||
/// (Was previously the `empty_aliases_table_matches_baseline` regression
|
||||
/// guard; doc-side expansion was removed 2026-06-03 so the body channel is
|
||||
/// the only lexical channel.)
|
||||
#[test]
|
||||
fn body_term_recalls_chunk() {
|
||||
let env = Env::new();
|
||||
let conn = env.raw_conn();
|
||||
insert_document(
|
||||
&conn,
|
||||
&id32("d"),
|
||||
"notes/own.md",
|
||||
"Own",
|
||||
"en",
|
||||
"primary",
|
||||
&[],
|
||||
);
|
||||
insert_chunk(
|
||||
&conn,
|
||||
&id32("c1"),
|
||||
&id32("d"),
|
||||
"rust ownership and borrow checker",
|
||||
&["Own"],
|
||||
None,
|
||||
r#"[{"kind":"line","start":1,"end":1}]"#,
|
||||
"v1",
|
||||
);
|
||||
drop(conn);
|
||||
|
||||
let r = env.retriever();
|
||||
let hits = r
|
||||
.search(&SearchQuery {
|
||||
text: "ownership".to_string(),
|
||||
mode: SearchMode::Lexical,
|
||||
k: 10,
|
||||
filters: SearchFilters::default(),
|
||||
})
|
||||
.unwrap();
|
||||
assert!(
|
||||
hits.iter().any(|h| h.chunk_id.0 == id32("c1")),
|
||||
"본문 매칭 청크가 정상 회수돼야 한다 (회귀 안전)"
|
||||
);
|
||||
}
|
||||
|
||||
192
crates/kebab-store-sqlite/src/derivation_cache.rs
Normal file
192
crates/kebab-store-sqlite/src/derivation_cache.rs
Normal file
@@ -0,0 +1,192 @@
|
||||
//! Content-hash derivation cache store (design 2026-05-31 §3.2 / §3.5).
|
||||
//!
|
||||
//! Backs the `derivation_cache` table (`V012`). The cache stores expensive
|
||||
//! ingest derivations (embedding vectors, LLM aliases, optional Korean
|
||||
//! tokens) keyed by `derivation_cache_key` (§3.1). It is a pure performance
|
||||
//! layer: corruption / deletion only forces recomputation, never wrong
|
||||
//! results (§3.5). Timestamps follow the same RFC3339 `OffsetDateTime`
|
||||
//! formatting the asset / document / embedding writers use.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use rusqlite::{OptionalExtension, params};
|
||||
use time::OffsetDateTime;
|
||||
use time::format_description::well_known::Rfc3339;
|
||||
|
||||
use crate::error::StoreError;
|
||||
use crate::store::SqliteStore;
|
||||
|
||||
impl SqliteStore {
|
||||
/// Look up a cached derivation payload by its content-hash key.
|
||||
///
|
||||
/// Pure read — does **not** bump `last_used_at`. Callers that want LRU
|
||||
/// freshness on a hit collect the hit keys and call [`Self::touch`] once
|
||||
/// per batch (cheaper than a write per `get`).
|
||||
pub fn derivation_cache_get(&self, cache_key: &str) -> Result<Option<Vec<u8>>> {
|
||||
let conn = self.lock_conn();
|
||||
let payload: Option<Vec<u8>> = conn
|
||||
.query_row(
|
||||
"SELECT payload FROM derivation_cache WHERE cache_key = ?",
|
||||
params![cache_key],
|
||||
|row| row.get::<_, Vec<u8>>(0),
|
||||
)
|
||||
.optional()
|
||||
.map_err(StoreError::from)
|
||||
.context("derivation_cache_get")?;
|
||||
Ok(payload)
|
||||
}
|
||||
|
||||
/// Insert (or overwrite) a cached derivation payload.
|
||||
///
|
||||
/// `INSERT OR REPLACE` so a re-computation of the same key (e.g. after a
|
||||
/// manual cache clear, or a non-deterministic LLM regenerating) refreshes
|
||||
/// `created_at` / `last_used_at` to the new attempt. The key already folds
|
||||
/// every version-cascade input (§3.1), so an overwrite is always the same
|
||||
/// logical derivation.
|
||||
pub fn derivation_cache_put(&self, cache_key: &str, kind: &str, payload: &[u8]) -> Result<()> {
|
||||
let now = OffsetDateTime::now_utc()
|
||||
.format(&Rfc3339)
|
||||
.context("format derivation_cache.created_at")?;
|
||||
let conn = self.lock_conn();
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO derivation_cache
|
||||
(cache_key, kind, payload, created_at, last_used_at)
|
||||
VALUES (?, ?, ?, ?, ?)",
|
||||
params![cache_key, kind, payload, now, now],
|
||||
)
|
||||
.map_err(StoreError::from)
|
||||
.context("derivation_cache_put")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Bump `last_used_at` for the given hit keys (LRU freshness, §3.5).
|
||||
///
|
||||
/// Run in a single transaction. Missing keys are a no-op. Called once per
|
||||
/// ingest batch with the keys that hit, so the GC pass keeps live chunks.
|
||||
pub fn derivation_cache_touch(&self, keys: &[String]) -> Result<()> {
|
||||
if keys.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let now = OffsetDateTime::now_utc()
|
||||
.format(&Rfc3339)
|
||||
.context("format derivation_cache.last_used_at")?;
|
||||
let mut conn = self.lock_conn();
|
||||
let tx = conn.transaction().map_err(StoreError::from)?;
|
||||
{
|
||||
let mut stmt = tx
|
||||
.prepare("UPDATE derivation_cache SET last_used_at = ? WHERE cache_key = ?")
|
||||
.map_err(StoreError::from)?;
|
||||
for key in keys {
|
||||
stmt.execute(params![now, key])
|
||||
.map_err(StoreError::from)
|
||||
.context("derivation_cache_touch")?;
|
||||
}
|
||||
}
|
||||
tx.commit().map_err(StoreError::from)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete cache entries whose `last_used_at` is older than `ttl_days`
|
||||
/// (§3.5 lightweight GC). Returns the number of rows removed.
|
||||
///
|
||||
/// `ttl_days <= 0` is a no-op guard (never wipe the whole cache by an
|
||||
/// accidental zero TTL).
|
||||
pub fn derivation_cache_gc(&self, ttl_days: i64) -> Result<usize> {
|
||||
if ttl_days <= 0 {
|
||||
return Ok(0);
|
||||
}
|
||||
let cutoff = (OffsetDateTime::now_utc() - time::Duration::days(ttl_days))
|
||||
.format(&Rfc3339)
|
||||
.context("format derivation_cache gc cutoff")?;
|
||||
let conn = self.lock_conn();
|
||||
let removed = conn
|
||||
.execute(
|
||||
"DELETE FROM derivation_cache WHERE last_used_at < ?",
|
||||
params![cutoff],
|
||||
)
|
||||
.map_err(StoreError::from)
|
||||
.context("derivation_cache_gc")?;
|
||||
Ok(removed)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::store::SqliteStore;
|
||||
|
||||
fn open_store() -> (tempfile::TempDir, SqliteStore) {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut cfg = kebab_config::Config::defaults();
|
||||
cfg.storage.data_dir = dir.path().to_string_lossy().into_owned();
|
||||
let store = SqliteStore::open(&cfg).unwrap();
|
||||
store.run_migrations().unwrap();
|
||||
(dir, store)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn put_then_get_roundtrips() {
|
||||
let (_d, store) = open_store();
|
||||
store
|
||||
.derivation_cache_put("key1", "embedding", &[1, 2, 3, 4])
|
||||
.unwrap();
|
||||
let got = store.derivation_cache_get("key1").unwrap();
|
||||
assert_eq!(got, Some(vec![1, 2, 3, 4]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_miss_returns_none() {
|
||||
let (_d, store) = open_store();
|
||||
assert_eq!(store.derivation_cache_get("absent").unwrap(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn put_replaces_existing() {
|
||||
let (_d, store) = open_store();
|
||||
store.derivation_cache_put("k", "alias", b"old").unwrap();
|
||||
store.derivation_cache_put("k", "alias", b"new").unwrap();
|
||||
assert_eq!(
|
||||
store.derivation_cache_get("k").unwrap(),
|
||||
Some(b"new".to_vec())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn touch_missing_keys_is_noop() {
|
||||
let (_d, store) = open_store();
|
||||
store
|
||||
.derivation_cache_touch(&["nope".to_string()])
|
||||
.unwrap();
|
||||
assert_eq!(store.derivation_cache_get("nope").unwrap(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gc_zero_ttl_is_noop() {
|
||||
let (_d, store) = open_store();
|
||||
store.derivation_cache_put("k", "embedding", b"x").unwrap();
|
||||
assert_eq!(store.derivation_cache_gc(0).unwrap(), 0);
|
||||
assert!(store.derivation_cache_get("k").unwrap().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gc_removes_stale_entries() {
|
||||
let (_d, store) = open_store();
|
||||
store.derivation_cache_put("fresh", "embedding", b"x").unwrap();
|
||||
// Backdate one row by 100 days via a direct UPDATE.
|
||||
let old = (OffsetDateTime::now_utc() - time::Duration::days(100))
|
||||
.format(&Rfc3339)
|
||||
.unwrap();
|
||||
{
|
||||
let conn = store.lock_conn();
|
||||
conn.execute(
|
||||
"INSERT INTO derivation_cache (cache_key, kind, payload, created_at, last_used_at)
|
||||
VALUES ('stale', 'embedding', ?, ?, ?)",
|
||||
params![&b"y"[..], &old, &old],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let removed = store.derivation_cache_gc(30).unwrap();
|
||||
assert_eq!(removed, 1);
|
||||
assert!(store.derivation_cache_get("stale").unwrap().is_none());
|
||||
assert!(store.derivation_cache_get("fresh").unwrap().is_some());
|
||||
}
|
||||
}
|
||||
@@ -98,6 +98,23 @@ impl kebab_core::DocumentStore for SqliteStore {
|
||||
.context("format chunk created_at")?;
|
||||
let mut conn = self.lock_conn();
|
||||
let tx = conn.transaction().map_err(StoreError::from)?;
|
||||
// CASCADE 제거(V011) 대체: 이 doc 의 chunk 임베딩 레코드를 명시 정리.
|
||||
// 원본 + per-alias sentinel({id}#alias#N) 모두. 별칭 dense 벡터는 줄별
|
||||
// sentinel chunk_id(`{orig}#alias#0`, `#alias#1`, …)로 색인되는데 chunks
|
||||
// FK 가 없어 CASCADE 로 자동 정리되지 않으므로 여기서 직접 지운다. 정확
|
||||
// 일치(|| '#alias')는 per-line sentinel 을 놓치므로(PR #195 MAJOR) 본문
|
||||
// chunk_id 와 그 `{id}#alias%` 프리픽스를 LIKE 로 함께 매칭한다. chunks
|
||||
// 행이 살아있는 동안(아래 DELETE FROM chunks 직전) 실행해야 서브쿼리가
|
||||
// chunk_id 를 본다. 설계 spec 2026-05-30-dense-alias-vectors-design.md §3.5-2.
|
||||
tx.execute(
|
||||
"DELETE FROM embedding_records WHERE chunk_id IN \
|
||||
(SELECT chunk_id FROM chunks WHERE doc_id = ?1) \
|
||||
OR EXISTS (SELECT 1 FROM chunks \
|
||||
WHERE chunks.doc_id = ?1 \
|
||||
AND embedding_records.chunk_id LIKE chunks.chunk_id || '#alias%')",
|
||||
params![doc.0],
|
||||
)
|
||||
.map_err(StoreError::from)?;
|
||||
tx.execute("DELETE FROM chunks WHERE doc_id = ?", params![doc.0])
|
||||
.map_err(StoreError::from)?;
|
||||
let mut stmt = tx
|
||||
|
||||
@@ -59,15 +59,25 @@ impl SqliteStore {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
// Deduplicate the IN-list so a pathological caller passing
|
||||
// `[c1, c1, c1]` doesn't blow the SQL placeholder count.
|
||||
// sentinel 별칭 candidate({orig}#alias)는 chunks 에 원본 chunk 가 없어
|
||||
// (chunks JOIN 실패) committed 판정을 못 받는다. 후보를 원본 chunk_id 로
|
||||
// strip 해 IN-list/JOIN 에 넣고(committed 판정은 원본 body chunk 기준),
|
||||
// 통과 여부는 원본 기준으로 매핑하되 반환은 입력 candidate 형태(sentinel
|
||||
// 유지) — VectorRetriever(Task 4)가 그 sentinel 을 받아 strip+dedup 한다.
|
||||
// 설계 spec 2026-05-30-dense-alias-vectors-design.md §3.5-3.
|
||||
//
|
||||
// Deduplicate the IN-list (on the stripped original) so a
|
||||
// pathological caller passing `[c1, c1, c1]` — or a body+alias
|
||||
// pair `[c1, c1#alias]` that strips to the same original —
|
||||
// doesn't blow the SQL placeholder count.
|
||||
let unique_ids: Vec<String> = {
|
||||
let mut seen = HashSet::new();
|
||||
chunk_ids
|
||||
.iter()
|
||||
.filter_map(|c| {
|
||||
if seen.insert(c.0.as_str()) {
|
||||
Some(c.0.clone())
|
||||
let orig = kebab_core::strip_alias_suffix(c.0.as_str());
|
||||
if seen.insert(orig.to_string()) {
|
||||
Some(orig.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
@@ -242,7 +252,11 @@ impl SqliteStore {
|
||||
|
||||
let mut out = Vec::with_capacity(chunk_ids.len());
|
||||
for cand in chunk_ids {
|
||||
let workspace_path = match allowed.get(&cand.0) {
|
||||
// committed 판정은 원본 chunk 기준(allowed 는 원본 chunk_id 로 키됨).
|
||||
// candidate 가 sentinel 이면 strip 한 원본으로 조회하고, 통과 시
|
||||
// 입력 candidate 형태 그대로 반환한다.
|
||||
let orig = kebab_core::strip_alias_suffix(cand.0.as_str());
|
||||
let workspace_path = match allowed.get(orig) {
|
||||
Some(p) => p,
|
||||
None => continue,
|
||||
};
|
||||
@@ -558,6 +572,53 @@ mod tests {
|
||||
assert_eq!(out, vec![cid(c1)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_chunks_sentinel_alias_candidate_passes_via_original() {
|
||||
// 별칭 dense 벡터 sentinel candidate({orig}#alias)는 원본 chunk 가
|
||||
// committed 면 통과해야 한다(strip 해 JOIN). 반환은 입력 candidate
|
||||
// 형태 그대로(sentinel 유지) — VectorRetriever 가 strip+dedup.
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let store = open_store(&tmp);
|
||||
let c1 = "11111111111111111111111111111111";
|
||||
seed_committed(
|
||||
&store,
|
||||
c1,
|
||||
"d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1",
|
||||
"a.md",
|
||||
"en",
|
||||
&[],
|
||||
"primary",
|
||||
);
|
||||
|
||||
// sentinel candidate 단독 → 원본 c1 committed 라 통과, sentinel 형태 유지.
|
||||
let sentinel = format!("{c1}{}", kebab_core::ALIAS_SUFFIX);
|
||||
let out = store
|
||||
.filter_chunks(&[cid(&sentinel)], &SearchFilters::default())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
out,
|
||||
vec![cid(&sentinel)],
|
||||
"sentinel candidate must pass via its committed original and be returned verbatim"
|
||||
);
|
||||
|
||||
// body + sentinel 둘 다 입력 → 둘 다 통과, 입력 순서 보존.
|
||||
let out = store
|
||||
.filter_chunks(&[cid(c1), cid(&sentinel)], &SearchFilters::default())
|
||||
.unwrap();
|
||||
assert_eq!(out, vec![cid(c1), cid(&sentinel)]);
|
||||
|
||||
// 원본이 미존재(uncommitted)면 sentinel 도 탈락.
|
||||
let orphan_sentinel =
|
||||
format!("99999999999999999999999999999999{}", kebab_core::ALIAS_SUFFIX);
|
||||
let out = store
|
||||
.filter_chunks(&[cid(&orphan_sentinel)], &SearchFilters::default())
|
||||
.unwrap();
|
||||
assert!(
|
||||
out.is_empty(),
|
||||
"sentinel whose original is not committed must be dropped"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_chunks_tags_any_lang_trust_path_glob() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
|
||||
mod answers;
|
||||
mod chat_sessions;
|
||||
mod derivation_cache;
|
||||
mod documents;
|
||||
mod embeddings;
|
||||
mod error;
|
||||
|
||||
@@ -570,6 +570,24 @@ impl SqliteStore {
|
||||
keep_doc_id: &str,
|
||||
) -> Result<()> {
|
||||
let conn = self.lock_conn();
|
||||
// CASCADE 제거(V011) 대체: documents→chunks CASCADE 가 chunks 를 지우기 전에
|
||||
// 원본 + per-alias sentinel({id}#alias#N) embedding_records 를 명시 정리.
|
||||
// 별칭 dense 벡터는 줄별 sentinel chunk_id 로 색인되며 chunks FK 가 없어
|
||||
// 자동 정리되지 않으므로 chunks 가 살아있는 동안 직접 지운다(안 하면
|
||||
// tombstone trigger 가 남긴 행이 누적). 정확 일치(|| '#alias')는 per-line
|
||||
// sentinel 을 놓치므로(PR #195 MAJOR) `{id}#alias%` 프리픽스를 LIKE 로 매칭.
|
||||
// 설계 spec 2026-05-30-dense-alias-vectors-design.md §3.5-2. (Task 4.5 리뷰 MAJOR.)
|
||||
conn.execute(
|
||||
"DELETE FROM embedding_records WHERE chunk_id IN \
|
||||
(SELECT chunk_id FROM chunks WHERE doc_id IN \
|
||||
(SELECT doc_id FROM documents WHERE workspace_path = ?1 AND doc_id != ?2)) \
|
||||
OR EXISTS (SELECT 1 FROM chunks \
|
||||
WHERE chunks.doc_id IN \
|
||||
(SELECT doc_id FROM documents WHERE workspace_path = ?1 AND doc_id != ?2) \
|
||||
AND embedding_records.chunk_id LIKE chunks.chunk_id || '#alias%')",
|
||||
params![workspace_path, keep_doc_id],
|
||||
)
|
||||
.map_err(StoreError::from)?;
|
||||
conn.execute(
|
||||
"DELETE FROM documents WHERE workspace_path = ?1 AND doc_id != ?2",
|
||||
params![workspace_path, keep_doc_id],
|
||||
@@ -627,7 +645,24 @@ pub(crate) fn purge_orphan_at_workspace_path(
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
// documents → blocks / chunks / embedding_records via CASCADE.
|
||||
// CASCADE 제거(V011) 대체: 이 asset 의 문서 chunk 임베딩 레코드를 명시 정리.
|
||||
// 원본 + per-alias sentinel({id}#alias#N) 모두. 별칭 dense 벡터는 줄별
|
||||
// sentinel chunk_id 로 색인되며 chunks FK 가 없어 documents→chunks CASCADE 로
|
||||
// 자동 정리되지 않으므로 chunks 가 살아있는 동안 직접 지운다. 정확
|
||||
// 일치(|| '#alias')는 per-line sentinel 을 놓치므로(PR #195 MAJOR) `{id}#alias%`
|
||||
// 프리픽스를 LIKE 로 매칭. 설계 spec 2026-05-30-dense-alias-vectors-design.md §3.5-2.
|
||||
conn.execute(
|
||||
"DELETE FROM embedding_records WHERE chunk_id IN \
|
||||
(SELECT chunk_id FROM chunks WHERE doc_id IN \
|
||||
(SELECT doc_id FROM documents WHERE asset_id = ?1)) \
|
||||
OR EXISTS (SELECT 1 FROM chunks \
|
||||
WHERE chunks.doc_id IN \
|
||||
(SELECT doc_id FROM documents WHERE asset_id = ?1) \
|
||||
AND embedding_records.chunk_id LIKE chunks.chunk_id || '#alias%')",
|
||||
params![stale_asset_id],
|
||||
)
|
||||
.map_err(StoreError::from)?;
|
||||
// documents → blocks / chunks via CASCADE.
|
||||
conn.execute(
|
||||
"DELETE FROM documents WHERE asset_id = ?",
|
||||
params![stale_asset_id],
|
||||
@@ -706,8 +741,24 @@ pub fn purge_deleted_workspace_path(
|
||||
.map_err(StoreError::from)?;
|
||||
drop(stmt);
|
||||
|
||||
// 2. DELETE the document row (CASCADE clears blocks / chunks /
|
||||
// embedding_records via the FK constraints in V001).
|
||||
// 1b. CASCADE 제거(V011) 대체: chunk 임베딩 레코드를 명시 정리(원본 +
|
||||
// per-alias sentinel {id}#alias#N). 별칭 dense 벡터는 줄별 sentinel
|
||||
// chunk_id 로 색인되며 chunks FK 가 없어 documents→chunks CASCADE 로
|
||||
// 자동 정리되지 않는다. 정확 일치(|| '#alias')는 per-line sentinel 을
|
||||
// 놓치므로(PR #195 MAJOR) `{id}#alias%` 프리픽스를 LIKE 로 매칭. chunks 가
|
||||
// 살아있는 동안(2번 DELETE 직전) 실행. spec §3.5-2.
|
||||
conn.execute(
|
||||
"DELETE FROM embedding_records WHERE chunk_id IN \
|
||||
(SELECT chunk_id FROM chunks WHERE doc_id = ?1) \
|
||||
OR EXISTS (SELECT 1 FROM chunks \
|
||||
WHERE chunks.doc_id = ?1 \
|
||||
AND embedding_records.chunk_id LIKE chunks.chunk_id || '#alias%')",
|
||||
rusqlite::params![doc_id],
|
||||
)
|
||||
.map_err(StoreError::from)?;
|
||||
|
||||
// 2. DELETE the document row (CASCADE clears blocks / chunks via the
|
||||
// FK constraints in V001; embedding_records handled above).
|
||||
conn.execute(
|
||||
"DELETE FROM documents WHERE doc_id = ?",
|
||||
rusqlite::params![doc_id],
|
||||
|
||||
@@ -20,26 +20,28 @@ fn open_store(tmp: &TempDir) -> SqliteStore {
|
||||
store
|
||||
}
|
||||
|
||||
/// Fresh store baseline: V004 seeds `corpus_revision = 0`, then V009
|
||||
/// migration bumps it by one to invalidate any pre-V009 LRU cache —
|
||||
/// so a fresh store after `run_migrations()` reads back as `1`.
|
||||
/// Fresh store baseline: V004 seeds `corpus_revision = 0`, then V009,
|
||||
/// V010, and V011 migrations bump it by one each to invalidate any stale
|
||||
/// LRU cache — so a fresh store after `run_migrations()` reads back as `3`.
|
||||
/// (V012 derivation_cache + V013 drop-chunk-aliases are structural/additive
|
||||
/// and do NOT bump corpus_revision.)
|
||||
#[test]
|
||||
fn fresh_store_starts_at_post_migration_baseline() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let store = open_store(&tmp);
|
||||
assert_eq!(store.corpus_revision(), 1);
|
||||
assert_eq!(store.corpus_revision(), 3);
|
||||
}
|
||||
|
||||
/// Each `bump_corpus_revision` returns the new value monotonically
|
||||
/// from the post-migration baseline.
|
||||
/// from the post-migration baseline (V009 + V010 + V011 → 3).
|
||||
#[test]
|
||||
fn bump_increments_monotonically() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let store = open_store(&tmp);
|
||||
assert_eq!(store.bump_corpus_revision().unwrap(), 2);
|
||||
assert_eq!(store.bump_corpus_revision().unwrap(), 3);
|
||||
assert_eq!(store.bump_corpus_revision().unwrap(), 4);
|
||||
assert_eq!(store.corpus_revision(), 4);
|
||||
assert_eq!(store.bump_corpus_revision().unwrap(), 5);
|
||||
assert_eq!(store.bump_corpus_revision().unwrap(), 6);
|
||||
assert_eq!(store.corpus_revision(), 6);
|
||||
}
|
||||
|
||||
/// `corpus_revision` survives a store re-open (persisted in SQLite).
|
||||
@@ -52,6 +54,6 @@ fn revision_persists_across_reopen() {
|
||||
store.bump_corpus_revision().unwrap();
|
||||
} // store dropped — file closed
|
||||
let store = open_store(&tmp);
|
||||
assert_eq!(store.corpus_revision(), 3);
|
||||
assert_eq!(store.bump_corpus_revision().unwrap(), 4);
|
||||
assert_eq!(store.corpus_revision(), 5);
|
||||
assert_eq!(store.bump_corpus_revision().unwrap(), 6);
|
||||
}
|
||||
|
||||
338
crates/kebab-store-sqlite/tests/embedding_records_fk.rs
Normal file
338
crates/kebab-store-sqlite/tests/embedding_records_fk.rs
Normal file
@@ -0,0 +1,338 @@
|
||||
//! V011: `embedding_records.chunk_id` FK 제거 + CASCADE 대체 명시 DELETE.
|
||||
//!
|
||||
//! 별칭 dense 벡터는 sentinel chunk_id(`{orig}#alias`)로 색인되는데, 이 id 는
|
||||
//! `chunks` 에 행이 없다. V001 의 `chunk_id REFERENCES chunks ON DELETE CASCADE`
|
||||
//! FK 가 살아 있으면 sentinel `embedding_records` INSERT 가 SQLite 787 로 실패한다.
|
||||
//! V011 이 FK 를 제거하고, 사라진 CASCADE 는 `put_chunks` / purge 경로의 명시
|
||||
//! DELETE 로 대체한다(설계 spec 2026-05-30-dense-alias-vectors-design.md §3.5).
|
||||
|
||||
use kebab_config::Config;
|
||||
use kebab_core::{
|
||||
Chunk, ChunkId, ChunkerVersion, DocumentId, DocumentStore,
|
||||
};
|
||||
use kebab_store_sqlite::{EmbeddingRecordRow, SqliteStore};
|
||||
use rusqlite::params;
|
||||
use tempfile::TempDir;
|
||||
use time::OffsetDateTime;
|
||||
|
||||
fn open_store(tmp: &TempDir) -> SqliteStore {
|
||||
let mut c = Config::defaults();
|
||||
c.storage.data_dir = tmp.path().to_string_lossy().into_owned();
|
||||
let store = SqliteStore::open(&c).unwrap();
|
||||
store.run_migrations().unwrap();
|
||||
store
|
||||
}
|
||||
|
||||
const DOC_ID: &str = "fedcba9876543210fedcba9876543210";
|
||||
|
||||
/// Seed asset + document + one chunk so the *original* chunk_id has a
|
||||
/// `chunks` row. The sentinel `{chunk_id}#alias` deliberately gets NO
|
||||
/// chunks row — that is the case V011 must allow.
|
||||
fn seed_chunk(store: &SqliteStore, chunk_id: &str) {
|
||||
let conn = store.read_conn();
|
||||
conn.execute(
|
||||
"INSERT INTO assets (
|
||||
asset_id, source_uri, workspace_path, media_type, byte_len,
|
||||
checksum, storage_kind, storage_path, discovered_at
|
||||
) VALUES (?, ?, ?, '{}', 0, 'deadbeefdeadbeefdeadbeefdeadbeef',
|
||||
'reference', '/tmp/x', '1970-01-01T00:00:00Z')",
|
||||
params!["0123456789abcdef0123456789abcdef", "file:///tmp/x", "x.md"],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO documents (
|
||||
doc_id, asset_id, workspace_path, title, lang, source_type,
|
||||
trust_level, parser_version, doc_version, schema_version,
|
||||
metadata_json, provenance_json, created_at, updated_at
|
||||
) VALUES (?, ?, 'x.md', NULL, 'en', 'markdown', 'primary', 'v1', 1, 1,
|
||||
'{}', '{}', '1970-01-01T00:00:00Z', '1970-01-01T00:00:00Z')",
|
||||
params![DOC_ID, "0123456789abcdef0123456789abcdef"],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO chunks (
|
||||
chunk_id, doc_id, text, heading_path_json, section_label,
|
||||
source_spans_json, token_estimate, chunker_version,
|
||||
policy_hash, block_ids_json, created_at
|
||||
) VALUES (?, ?, 'hi', '[]', NULL, '[]', 1, 'v1', 'h', '[]',
|
||||
'1970-01-01T00:00:00Z')",
|
||||
params![chunk_id, DOC_ID],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn embed_row(embedding_id: &str, chunk_id: &str) -> EmbeddingRecordRow {
|
||||
EmbeddingRecordRow {
|
||||
embedding_id: embedding_id.to_string(),
|
||||
chunk_id: chunk_id.to_string(),
|
||||
model_id: "m".to_string(),
|
||||
model_version: "v1".to_string(),
|
||||
dimensions: 4,
|
||||
lance_table: "t".to_string(),
|
||||
created_at: OffsetDateTime::UNIX_EPOCH,
|
||||
}
|
||||
}
|
||||
|
||||
fn embed_count(store: &SqliteStore, chunk_id: &str) -> i64 {
|
||||
let conn = store.read_conn();
|
||||
conn.query_row(
|
||||
"SELECT COUNT(*) FROM embedding_records WHERE chunk_id = ?",
|
||||
params![chunk_id],
|
||||
|r| r.get::<_, i64>(0),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// Count embedding rows whose chunk_id begins with `prefix`. Used to
|
||||
/// assert that *every* per-alias sentinel (`{id}#alias#0`, `#alias#1`, …)
|
||||
/// is gone, not just the legacy single `{id}#alias`.
|
||||
fn embed_count_prefix(store: &SqliteStore, prefix: &str) -> i64 {
|
||||
let conn = store.read_conn();
|
||||
conn.query_row(
|
||||
"SELECT COUNT(*) FROM embedding_records WHERE chunk_id LIKE ? || '%'",
|
||||
params![prefix],
|
||||
|r| r.get::<_, i64>(0),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// V011 후 sentinel chunk_id(`chunks` 에 없는 id)로 `embedding_records` 를
|
||||
/// INSERT 해도 FK 위반 없이 성공해야 한다.
|
||||
#[test]
|
||||
fn sentinel_embedding_record_insert_succeeds_without_fk() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let store = open_store(&tmp);
|
||||
let c1 = "11111111111111111111111111111111";
|
||||
seed_chunk(&store, c1);
|
||||
|
||||
// sentinel: chunks 에 행이 없는 `{c1}#alias`.
|
||||
let sentinel = format!("{c1}{}", kebab_core::ALIAS_SUFFIX);
|
||||
let result =
|
||||
store.put_embedding_records_pending(&[embed_row("e_sentinel_0000000000000000000000", &sentinel)]);
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"sentinel embedding_records insert must not violate a chunks FK after V011: {result:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
embed_count(&store, &sentinel),
|
||||
1,
|
||||
"sentinel embedding row must be persisted"
|
||||
);
|
||||
}
|
||||
|
||||
/// `put_chunks` 재호출(재인제스트) 시, 명시 DELETE 가 그 doc 의 원본 + sentinel
|
||||
/// `embedding_records` 를 모두 정리해 orphan 0 이 되어야 한다(CASCADE 대체).
|
||||
#[test]
|
||||
fn put_chunks_cleans_original_and_sentinel_embeddings() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let store = open_store(&tmp);
|
||||
let c1 = "11111111111111111111111111111111";
|
||||
seed_chunk(&store, c1);
|
||||
let sentinel = format!("{c1}{}", kebab_core::ALIAS_SUFFIX);
|
||||
|
||||
// 원본 + sentinel embedding_records 색인 (committed).
|
||||
store
|
||||
.put_embedding_records_pending(&[
|
||||
embed_row("e_orig_000000000000000000000000000", c1),
|
||||
embed_row("e_sentinel_0000000000000000000000", &sentinel),
|
||||
])
|
||||
.unwrap();
|
||||
store
|
||||
.mark_embedding_records_committed(&[
|
||||
"e_orig_000000000000000000000000000".to_string(),
|
||||
"e_sentinel_0000000000000000000000".to_string(),
|
||||
])
|
||||
.unwrap();
|
||||
assert_eq!(embed_count(&store, c1), 1);
|
||||
assert_eq!(embed_count(&store, &sentinel), 1);
|
||||
|
||||
// 재인제스트: 같은 chunk 를 put_chunks 로 다시 쓴다. 명시 DELETE 가
|
||||
// 원본 + sentinel embedding_records 를 정리한 뒤 chunk 재삽입.
|
||||
let doc_id = DocumentId(DOC_ID.to_string());
|
||||
let chunk = Chunk {
|
||||
chunk_id: ChunkId(c1.to_string()),
|
||||
doc_id: doc_id.clone(),
|
||||
block_ids: Vec::new(),
|
||||
text: "hi".to_string(),
|
||||
heading_path: Vec::new(),
|
||||
source_spans: Vec::new(),
|
||||
token_estimate: 1,
|
||||
chunker_version: ChunkerVersion("v1".to_string()),
|
||||
policy_hash: "h".to_string(),
|
||||
tokenized_korean_text: None,
|
||||
};
|
||||
store.put_chunks(&doc_id, std::slice::from_ref(&chunk)).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
embed_count(&store, c1),
|
||||
0,
|
||||
"original embedding_records must be cleaned on re-ingest (CASCADE replacement)"
|
||||
);
|
||||
assert_eq!(
|
||||
embed_count(&store, &sentinel),
|
||||
0,
|
||||
"sentinel embedding_records must be cleaned on re-ingest (no chunks FK → explicit DELETE)"
|
||||
);
|
||||
}
|
||||
|
||||
/// Task 4.5 리뷰 MAJOR: `purge_document_at_workspace_path_except_doc_id`
|
||||
/// (parser-bump 재인제스트 경로)도 원본 + sentinel embedding_records 를
|
||||
/// 명시 DELETE 로 정리해 orphan 0 이어야 한다. (이 경로 누락 시 tombstone 누적.)
|
||||
#[test]
|
||||
fn purge_except_doc_id_cleans_original_and_sentinel_embeddings() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let store = open_store(&tmp);
|
||||
let c1 = "11111111111111111111111111111111";
|
||||
seed_chunk(&store, c1); // doc DOC_ID @ workspace 'x.md'
|
||||
let sentinel = format!("{c1}{}", kebab_core::ALIAS_SUFFIX);
|
||||
|
||||
store
|
||||
.put_embedding_records_pending(&[
|
||||
embed_row("e_orig_000000000000000000000000000", c1),
|
||||
embed_row("e_sentinel_0000000000000000000000", &sentinel),
|
||||
])
|
||||
.unwrap();
|
||||
store
|
||||
.mark_embedding_records_committed(&[
|
||||
"e_orig_000000000000000000000000000".to_string(),
|
||||
"e_sentinel_0000000000000000000000".to_string(),
|
||||
])
|
||||
.unwrap();
|
||||
assert_eq!(embed_count(&store, c1), 1);
|
||||
assert_eq!(embed_count(&store, &sentinel), 1);
|
||||
|
||||
// workspace 'x.md' 에서 DOC_ID(=현재 문서) 외 문서만 보존 → DOC_ID 가
|
||||
// 삭제 대상(parser-bump: 같은 path 의 옛 doc_id 정리). keep_doc_id 를
|
||||
// DOC_ID 와 다른 값으로 주면 DOC_ID 문서 + 그 chunk embedding 이 정리돼야.
|
||||
store
|
||||
.purge_document_at_workspace_path_except_doc_id("x.md", "0000000000000000000000000000ffff")
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
embed_count(&store, c1),
|
||||
0,
|
||||
"purge_except_doc_id: 원본 embedding_records 정리 (CASCADE 대체)"
|
||||
);
|
||||
assert_eq!(
|
||||
embed_count(&store, &sentinel),
|
||||
0,
|
||||
"purge_except_doc_id: sentinel embedding_records 정리 (chunks FK 없음 → 명시 DELETE)"
|
||||
);
|
||||
}
|
||||
|
||||
/// Seed body chunk + its per-line alias sentinel embedding rows
|
||||
/// (`{c1}#alias#0`, `{c1}#alias#1`) plus the legacy `{c1}#alias`. Returns
|
||||
/// the chunk's bare id. Used by the PR #195 per-alias orphan regressions.
|
||||
fn seed_body_and_alias_sentinels(store: &SqliteStore, c1: &str) {
|
||||
seed_chunk(store, c1);
|
||||
store
|
||||
.put_embedding_records_pending(&[
|
||||
embed_row("e_orig_000000000000000000000000000", c1),
|
||||
embed_row("e_alias0_00000000000000000000000", &format!("{c1}#alias#0")),
|
||||
embed_row("e_alias1_00000000000000000000000", &format!("{c1}#alias#1")),
|
||||
// legacy single sentinel (docs ingested before per-line split).
|
||||
embed_row("e_alias_legacy_00000000000000000", &format!("{c1}#alias")),
|
||||
])
|
||||
.unwrap();
|
||||
store
|
||||
.mark_embedding_records_committed(&[
|
||||
"e_orig_000000000000000000000000000".to_string(),
|
||||
"e_alias0_00000000000000000000000".to_string(),
|
||||
"e_alias1_00000000000000000000000".to_string(),
|
||||
"e_alias_legacy_00000000000000000".to_string(),
|
||||
])
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// PR #195 MAJOR regression: alias dense 벡터가 단일 `{id}#alias` 에서 줄별
|
||||
/// `{id}#alias#0`, `#alias#1`, … 로 바뀐 뒤, `put_chunks` 재인제스트 시 명시
|
||||
/// DELETE 가 본문 + **모든** per-alias sentinel embedding_records 를 정리해야
|
||||
/// 한다. 이전 코드(`|| '#alias'` 정확 일치)는 `#alias#N` 을 놓쳐 누수했다.
|
||||
#[test]
|
||||
fn put_chunks_cleans_per_alias_sentinel_embeddings() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let store = open_store(&tmp);
|
||||
let c1 = "11111111111111111111111111111111";
|
||||
seed_body_and_alias_sentinels(&store, c1);
|
||||
assert_eq!(embed_count(&store, c1), 1);
|
||||
assert_eq!(embed_count_prefix(&store, &format!("{c1}#alias")), 3);
|
||||
|
||||
let doc_id = DocumentId(DOC_ID.to_string());
|
||||
let chunk = Chunk {
|
||||
chunk_id: ChunkId(c1.to_string()),
|
||||
doc_id: doc_id.clone(),
|
||||
block_ids: Vec::new(),
|
||||
text: "hi".to_string(),
|
||||
heading_path: Vec::new(),
|
||||
source_spans: Vec::new(),
|
||||
token_estimate: 1,
|
||||
chunker_version: ChunkerVersion("v1".to_string()),
|
||||
policy_hash: "h".to_string(),
|
||||
tokenized_korean_text: None,
|
||||
};
|
||||
store.put_chunks(&doc_id, std::slice::from_ref(&chunk)).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
embed_count(&store, c1),
|
||||
0,
|
||||
"본문 embedding_records 정리 (CASCADE 대체)"
|
||||
);
|
||||
assert_eq!(
|
||||
embed_count_prefix(&store, &format!("{c1}#alias")),
|
||||
0,
|
||||
"모든 per-alias sentinel embedding_records 정리 (#alias#N + legacy #alias)"
|
||||
);
|
||||
}
|
||||
|
||||
/// PR #195 MAJOR regression: parser-bump 재인제스트 경로
|
||||
/// (`purge_document_at_workspace_path_except_doc_id`)도 본문 + 모든 per-alias
|
||||
/// sentinel embedding_records 를 정리해야 한다.
|
||||
#[test]
|
||||
fn purge_except_doc_id_cleans_per_alias_sentinel_embeddings() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let store = open_store(&tmp);
|
||||
let c1 = "11111111111111111111111111111111";
|
||||
seed_body_and_alias_sentinels(&store, c1); // doc DOC_ID @ 'x.md'
|
||||
assert_eq!(embed_count(&store, c1), 1);
|
||||
assert_eq!(embed_count_prefix(&store, &format!("{c1}#alias")), 3);
|
||||
|
||||
store
|
||||
.purge_document_at_workspace_path_except_doc_id("x.md", "0000000000000000000000000000ffff")
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(embed_count(&store, c1), 0, "본문 정리");
|
||||
assert_eq!(
|
||||
embed_count_prefix(&store, &format!("{c1}#alias")),
|
||||
0,
|
||||
"모든 per-alias sentinel 정리 (#alias#N + legacy #alias)"
|
||||
);
|
||||
}
|
||||
|
||||
/// PR #195 MAJOR regression: 파일 삭제 sweep 경로
|
||||
/// (`purge_deleted_workspace_path`)도 본문 + 모든 per-alias sentinel
|
||||
/// embedding_records 를 정리해야 한다.
|
||||
#[test]
|
||||
fn purge_deleted_workspace_path_cleans_per_alias_sentinel_embeddings() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let store = open_store(&tmp);
|
||||
let c1 = "11111111111111111111111111111111";
|
||||
seed_body_and_alias_sentinels(&store, c1); // doc DOC_ID @ 'x.md'
|
||||
assert_eq!(embed_count(&store, c1), 1);
|
||||
assert_eq!(embed_count_prefix(&store, &format!("{c1}#alias")), 3);
|
||||
|
||||
let returned = kebab_store_sqlite::purge_deleted_workspace_path(
|
||||
&store,
|
||||
&kebab_core::WorkspacePath("x.md".to_string()),
|
||||
)
|
||||
.unwrap();
|
||||
// 반환된 body chunk_ids 는 kebab-app 이 LanceDB 측 별칭 sentinel 까지
|
||||
// 삭제하는 데 쓰인다(`alias_sentinel_ids_to_delete`). 본문 1개.
|
||||
assert_eq!(returned.len(), 1);
|
||||
|
||||
assert_eq!(embed_count(&store, c1), 0, "본문 정리");
|
||||
assert_eq!(
|
||||
embed_count_prefix(&store, &format!("{c1}#alias")),
|
||||
0,
|
||||
"모든 per-alias sentinel 정리 (#alias#N + legacy #alias)"
|
||||
);
|
||||
}
|
||||
@@ -154,7 +154,17 @@ fn apply_event(state: &mut IngestState, event: IngestEvent) {
|
||||
}
|
||||
// v0.20.0 sub-item 1: per-page PDF OCR events — TUI does not
|
||||
// surface per-page OCR progress in v1; no counter to update.
|
||||
IngestEvent::PdfOcrStarted { .. } | IngestEvent::PdfOcrFinished { .. } => {}
|
||||
IngestEvent::PdfOcrStarted { .. }
|
||||
| IngestEvent::PdfOcrFinished { .. }
|
||||
// v0.24.0 asset-internal phase events: the status-bar reducer tracks
|
||||
// per-asset counters, not sub-asset phase progress, so these are
|
||||
// no-ops here (the CLI / --json surfaces render them).
|
||||
| IngestEvent::AssetChunked { .. }
|
||||
| IngestEvent::AssetTimings { .. }
|
||||
// v0.26.1 slow-phase hint (ocr / caption / embed): the CLI bar uses
|
||||
// it for a live phase message; the TUI status-bar reducer tracks only
|
||||
// per-asset counters, so it's a no-op here.
|
||||
| IngestEvent::AssetPhase { .. } => {}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,22 +15,25 @@ 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` |
|
||||
| embedding | `fastembed-rs` (`multilingual-e5-large`, 1024d, v0.18.0부터 default 업그레이드) |
|
||||
| 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 업그레이드). opt-in 대안: candle (e5 또는 `snowflake-arctic-embed-l-v2.0`) / Ollama `/api/embed`. arctic = 설명형 query recall 보강 (v0.26.0, 아래 결정표) |
|
||||
| 한국어 형태소분석 | `lindera-ko-dic` (FTS5 외부 tokenizer, v0.20.1) — 2자 이상 한국어 query 지원 |
|
||||
| LLM | Ollama HTTP (default `gemma4:e4b` ─ OCR / caption 와 family 통일. 사용자가 더 큰 variant `gemma4:26b` 등으로 override 가능) |
|
||||
| 음성 ASR | `whisper.cpp` (via `whisper-rs`) — P8 보류, 시스템 dep brainstorm 후 |
|
||||
| OCR (image) | Ollama vision LM (default `gemma4:e4b`) — `OcrEngine` trait 으로 Tesseract / Apple Vision 등 future swap (HOTFIXES P6-2) |
|
||||
| OCR (image) | `OcrEngine` trait, 2 백엔드: **`ollama-vision`** (default, `gemma4:e4b`) / **`paddle-onnx`** (v0.27.0 — PP-OCRv5 ONNX in-process via `ort` =2.0.0-rc.9, DBNet det + CTC rec, 후처리 min-area rect/unclip pure-Rust, Python 런타임 0). engine 선택은 `[image.ocr] engine`, 팩토리는 `kebab-app::build_image_ocr_engine`. e2e CER 0.005 / 큰 페이지 <4초. (HOTFIXES P6-2, 2026-06-04) |
|
||||
| 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 |
|
||||
| RRF fusion_score | `[0, 1]` 정규화 — `2 / (k_rrf + 1)` 로 나눠 mode 간 비교 가능 (post-merge hotfix) |
|
||||
| ~~doc-side expansion 별칭 (v0.21.0)~~ | **제거됨 (v0.25.0, HOTFIXES 2026-06-03)** — 색인-시 청크당 LLM 별칭 생성 + 별칭 검색 채널을 완전히 제거. 별칭 ROI 음수(cross-lingual 은 e5-large 단독으로 충분, 기여는 설명형 +2 그룹뿐인데 대가가 청크당 색인-시 LLM). V013 마이그레이션이 `chunk_aliases_fts` + `chunks.aliases` DROP. 기존 KB 의 잔존 별칭 벡터는 검색 시 `strip_alias_suffix` 로 본문 chunk 에 매핑(graceful)되거나 `kebab reset` 으로 정리. spec: `docs/superpowers/specs/2026-06-03-remove-doc-expansion-spec.md`. |
|
||||
| 파생물 캐시 `derivation_cache` (V012, v0.21.0) | 비싼 ingest 파생물(embedding 벡터)을 청크 **내용 해시** 키로 SQLite 에 캐싱 → 재색인 시 내용 불변 청크는 재계산 skip. `cache_key = blake3(kind ‖ text_blake3 ‖ version_key)[:32]`; version_key 에 model/dimensions 포함 → §9 cascade 와 정합(버전 bump 시 자동 miss). 위치 기반 `chunk_id` 와 달리 내용이 같으면 문서·위치 무관 동일 키. 순수 가산 — `corpus_revision` bump 안 함, 손상/삭제돼도 정확성 영향 0(miss → 재계산). search/ask 는 `kebab.sqlite`+`lancedb` 만으로 동작하므로 외부 서버 색인 후 DB 만 복사하는 이식 워크플로 가능 (HOTFIXES 2026-05-31). (별칭 LLM 캐싱 kind 는 v0.25.0 에서 제거 — embedding kind 만 남음.) |
|
||||
| layout | XDG (`~/.local/share/kebab/`, `~/.config/kebab/`, …) |
|
||||
|
||||
전체 frozen 설계는 [docs/superpowers/specs/2026-04-27-kebab-final-form-design.md](superpowers/specs/2026-04-27-kebab-final-form-design.md) 12 sections 참조.
|
||||
@@ -63,7 +66,9 @@ flowchart TB
|
||||
end
|
||||
subgraph Adapters ["traits + adapters"]
|
||||
embed["kebab-embed<br/>(trait)"]
|
||||
embedlocal["kebab-embed-local<br/>(fastembed)"]
|
||||
embedlocal["kebab-embed-local<br/>(fastembed, default)"]
|
||||
embedcandle["kebab-embed-candle<br/>(candle, e5+arctic, NUMA-safe opt-in)"]
|
||||
embedollama["kebab-embed-ollama<br/>(Ollama /api/embed, opt-in)"]
|
||||
llm["kebab-llm<br/>(trait)"]
|
||||
llmlocal["kebab-llm-local<br/>(Ollama)"]
|
||||
search["kebab-search"]
|
||||
@@ -89,6 +94,8 @@ flowchart TB
|
||||
app --> sqlite
|
||||
app --> vector
|
||||
app --> embedlocal
|
||||
app --> embedcandle
|
||||
app --> embedollama
|
||||
app --> llmlocal
|
||||
app --> search
|
||||
app --> rag
|
||||
@@ -101,6 +108,10 @@ flowchart TB
|
||||
paud --> core
|
||||
pcode --> core
|
||||
embedlocal --> embed
|
||||
embedcandle --> core
|
||||
embedcandle --> config
|
||||
embedollama --> core
|
||||
embedollama --> config
|
||||
llmlocal --> llm
|
||||
rag --> search
|
||||
rag --> llm
|
||||
@@ -129,6 +140,23 @@ UI → store/llm/parse 직접 의존 금지. 모든 user-facing 진입은 `kebab
|
||||
|
||||
`kebab-parse-code` 의 외부 tree-sitter grammar crate 의존: P10-1A-2 에서 `tree-sitter-rust` 추가, P10-1B 에서 `tree-sitter-python` / `tree-sitter-typescript` / `tree-sitter-javascript` 추가, P10-1C-Go 에서 `tree-sitter-go` 추가, P10-1C-JK 에서 `tree-sitter-java` / `tree-sitter-kotlin-ng` 추가, P10-1D 에서 `tree-sitter-c` / `tree-sitter-cpp` 추가. 모두 `kebab-parse-code` 에만 격리 (facade 룰 — UI crate / chunker 가 직접 import 금지). Kotlin 은 `tree-sitter-kotlin-ng` 사용 (bare `tree-sitter-kotlin` 은 tree-sitter 0.21–0.23 에 고착 — 사용 불가). v0.18.0+ 부터 `kebab-source-fs` 는 자체 `code_meta` 모듈 (lang detect + skip helpers + BUILTIN_BLACKLIST) 을 보유, kebab-parse-code 와 분리 (refactor 2026-05-26). v0.19.0 부터 `kebab-parse-md` 가 `kebab-parse-types` (parser intermediate types) + `kebab-normalize` (CanonicalDocument lift) 두 crate 를 흡수 — 24 → 22 crates, design §3.7b 재작성 (HOTFIXES 2026-05-26). v0.20.1 부터 `kebab-search` 가 `lindera-ko-dic` 를 의존해 한국어 FTS5 형태소 tokenizer 지원 — V009 migration 으로 2자 이상 한국어 query 매칭 (Bug #8 closure).
|
||||
|
||||
### 임베딩 백엔드 결정표 (v0.26.0)
|
||||
|
||||
| provider | 모델 | pooling / prefix | 위치 | 언제 |
|
||||
|---|---|---|---|---|
|
||||
| `fastembed` (기본) | `multilingual-e5-large` | mean / `query:`·`passage:` | in-process (onnxruntime) | 기본. 단일 소켓 호스트 |
|
||||
| `candle` | e5 또는 `snowflake-arctic-embed-l-v2.0` | 모델별 (e5=mean, arctic=CLS) / arctic=`query:`·무접두어 | in-process (pure Rust) | NUMA 서버 (onnxruntime 48-스레드 double-free 회피), Apple Silicon Metal GPU |
|
||||
| `ollama` | `snowflake-arctic-embed2` 등 | 모델 태그로 추론 / arctic=`query:`·무접두어 | 원격 HTTP (`/api/embed`) | candle 폴백, 측정에 쓴 경로 그대로 재현 |
|
||||
|
||||
**arctic-embed-l-v2.0 채택 근거**: 별칭(doc-side expansion) 제거(v0.25.0) 후 설명형
|
||||
query 의 recall 보강책. 측정(`/build/dogfood/logs/2026-06-03-method-measurements.md`)에서
|
||||
arctic = recall@10 130/132 (e5 대비 +7, 색인 1회·per-query 0·LLM 0, 용어 무손실).
|
||||
candle 이 주 백엔드(in-process, NUMA 안전), Ollama 가 폴백(측정 경로 재현). 두 경로의
|
||||
pooling/prefix 정확성은 `kebab-embed-candle/tests/arctic_ollama_parity.rs`
|
||||
(candle arctic vs Ollama arctic 코사인>0.99, `#[ignore]`) 로 고정. e5 → arctic 전환은
|
||||
`embedding_version` cascade (모델별 벡터 상이) → 재색인 필요. 기본값 e5 유지라 기존
|
||||
사용자 무영향. 자세한 내용: [tasks/HOTFIXES.md](../tasks/HOTFIXES.md) 2026-06-03 arctic entry.
|
||||
|
||||
## 디렉토리 구조
|
||||
|
||||
```text
|
||||
@@ -161,7 +189,7 @@ kebab/
|
||||
│ ├── p8/p8-1, p8-2 # (2 — 보류)
|
||||
│ └── p9/p9-1 … p9-5 # (5)
|
||||
├── crates/
|
||||
│ ├── kebab-core/ kebab-config/ # 도메인 + 설정 (P0)
|
||||
│ ├── kebab-core/ kebab-config/ # 도메인 + 설정 (P0). kebab-core/src/derivation.rs = 파생물 캐시 키 순수 함수 (blake3 내용 해시, v0.21.0)
|
||||
│ ├── kebab-source-fs/ # 워크스페이스 walk + checksum (P1-1)
|
||||
│ ├── kebab-parse-md/ # Markdown frontmatter + blocks + types + ParsedBlock → CanonicalDocument lift (P1-2/3/4 — v0.19.0 흡수)
|
||||
│ ├── kebab-chunk/ # heading-aware + pdf-page-v1 + code-*-ast-v1 (Tier 1) + k8s-manifest-resource-v1 + dockerfile-file-v1 + manifest-file-v1 + tier2_shared (P10-2) + code-text-paragraph-v1 (P10-3) chunker (P1-5, P7-2, P10-1A-2, P10-1B, P10-1C-Go, P10-1C-JK, P10-2, P10-3, P10-1D)
|
||||
@@ -174,22 +202,24 @@ kebab/
|
||||
│ │ ├── manifest_file_v1.rs # Tier 2 (p10-2): whole-file Cargo.toml / go.mod / .json / .xml / .groovy
|
||||
│ │ ├── code_text_paragraph_v1.rs # Tier 3 (p10-3): blank-line paragraph + 80/20 line-window fallback
|
||||
│ │ └── tier2_shared.rs # Tier 2 (p10-2): shared oversize fallback + Chunk builder helpers
|
||||
│ ├── kebab-store-sqlite/ # SQLite + FTS5 (V001/V002/V003) (P1-6, P2-1, P3-3)
|
||||
│ ├── kebab-store-sqlite/ # SQLite + FTS5 (V001/V002/V003) (P1-6, P2-1, P3-3). src/derivation_cache.rs = derivation_cache 테이블 저장소 (V012, v0.21.0)
|
||||
│ ├── kebab-search/ # Lexical + Vector + Hybrid retriever (P2-2, P3-4)
|
||||
│ ├── kebab-embed/ kebab-embed-local/ # Embedder trait + fastembed adapter (P3-1, P3-2)
|
||||
│ ├── kebab-embed-candle/ # candle (pure-Rust) Embedder, 모델 레지스트리(e5 mean + arctic CLS), NUMA-safe opt-in provider=candle (Track 1, v0.22.0; arctic v0.26.0)
|
||||
│ ├── kebab-embed-ollama/ # Ollama /api/embed Embedder, opt-in provider=ollama (arctic 폴백 경로, v0.26.0)
|
||||
│ ├── kebab-store-vector/ # LanceDB VectorStore (P3-3, P7-3 follow-up)
|
||||
│ ├── kebab-llm/ kebab-llm-local/ # LanguageModel trait + Ollama adapter (P4-1, P4-2)
|
||||
│ ├── kebab-rag/ # RAG pipeline (P4-3)
|
||||
│ ├── kebab-nli/ # NLI verifier (mDeBERTa-v3 XNLI, fb-41 PR-9a/9b/9c-1)
|
||||
│ ├── kebab-eval/ # golden query runner + metrics (P5-1, P5-2)
|
||||
│ ├── kebab-parse-image/ # ImageExtractor + Ollama OCR + caption (P6)
|
||||
│ ├── kebab-parse-image/ # ImageExtractor + OCR (ollama-vision + paddle-onnx ONNX) + caption (P6)
|
||||
│ ├── kebab-parse-pdf/ # lopdf per-page text extractor (P7-1)
|
||||
│ ├── kebab-parse-code/ # tree-sitter AST extractors: Rust (P10-1A-2), Python + TypeScript + JavaScript (P10-1B), Go (P10-1C-Go), Java + Kotlin (P10-1C-JK — java.rs + kotlin.rs), C + C++ (P10-1D — c.rs + cpp.rs); chunker lives in kebab-chunk
|
||||
│ ├── kebab-app/ # facade (P0 시그니처 + P3-5/P6-4/P7-3 본체)
|
||||
│ ├── kebab-app/ # facade (P0 시그니처 + P3-5/P6-4/P7-3 본체). src/derivation_payload.rs = 캐시 payload 인코딩 (v0.21.0)
|
||||
│ ├── kebab-tui/ # Ratatui shell + Library 패널 (P9-1)
|
||||
│ ├── kebab-mcp/ # stdio MCP server — tools: schema, doctor, search, ask (P9-FB-30)
|
||||
│ └── kebab-cli/ # binary (P0 → 핫픽스로 --config flag wiring 강화)
|
||||
├── migrations/ # SQLite refinery V001/V002/V003
|
||||
├── migrations/ # SQLite refinery V001..V012 (V012 = derivation_cache, v0.21.0)
|
||||
└── fixtures/ # 테스트 fixture 트리
|
||||
```
|
||||
|
||||
|
||||
@@ -683,6 +683,20 @@ ajv-cli validate -s docs/wire-schema/v1/<schema>.schema.json -d <output>
|
||||
|
||||
---
|
||||
|
||||
### config migrate (스키마 마이그레이션, v0.21.1)
|
||||
|
||||
```bash
|
||||
# 옛 스키마 흉내(섹션 누락 + deprecated) 후 migrate.
|
||||
printf 'schema_version = 1\n\n[workspace]\nroot = "~/MyNotes"\ninclude = ["*.md"]\n\n[search]\ndefault_k = 25\n' \
|
||||
> "$DOGFOOD/old.toml"
|
||||
"$RELEASE_BIN" --config "$DOGFOOD/old.toml" config migrate --dry-run # 미리보기, 파일 미수정
|
||||
"$RELEASE_BIN" --config "$DOGFOOD/old.toml" config migrate # .bak + 빠진 섹션 주석과 함께 추가
|
||||
"$RELEASE_BIN" --config "$DOGFOOD/old.toml" config migrate # 멱등
|
||||
"$RELEASE_BIN" --config "$DOGFOOD/old.toml" doctor | grep config_migration # ok 확인
|
||||
```
|
||||
|
||||
기대: dry-run 파일 미수정 → apply 시 `old.toml.bak`(원본 byte-identical) + `[ingest.code]`·`[logging]`·`[pdf.ocr]` 가시화 + 손본 `default_k`/주석 보존 + `workspace.include` 제거 → 재실행 멱등 → doctor `config_migration` ok. v0.21.1 evidence 는 `tasks/HOTFIXES.md` 2026-05-31.
|
||||
|
||||
## §10 Eval (P5)
|
||||
|
||||
### §10.1 Basic eval run
|
||||
|
||||
@@ -107,11 +107,18 @@ respect_markdown_headings = true
|
||||
chunker_version = "md-heading-v1"
|
||||
|
||||
[models.embedding]
|
||||
provider = "fastembed" # "none" 으로 두면 lexical-only — Ollama 불필요
|
||||
model = "multilingual-e5-small"
|
||||
provider = "fastembed" # "fastembed"(기본, onnxruntime) / "candle"(순수 Rust, NUMA-안전)
|
||||
# / "ollama"(원격 HTTP /api/embed) / "none"(lexical-only — Ollama 불필요)
|
||||
# ⚠ provider/model 변경 시 아래 dimensions 도 맞춰야 함.
|
||||
model = "multilingual-e5-small" # candle/ollama 는 "snowflake-arctic-embed-l-v2.0"
|
||||
# (ollama 태그 "snowflake-arctic-embed2", 1024-dim) 도 지원 —
|
||||
# 설명형 query recall 보강. e5↔arctic 전환은
|
||||
# embedding_version cascade (재색인 필요).
|
||||
version = "v1"
|
||||
dimensions = 384
|
||||
dimensions = 384 # arctic / e5-large 는 1024.
|
||||
batch_size = 64
|
||||
num_threads = 0 # candle 전용 CPU 스레드 캡 (0=auto). env KEBAB_EMBED_THREADS 우선.
|
||||
# endpoint = "http://127.0.0.1:11434" # provider="ollama" 전용; 생략 시 [models.llm].endpoint fallback.
|
||||
|
||||
[models.llm]
|
||||
provider = "ollama"
|
||||
@@ -351,6 +358,24 @@ lang_hint = "kor"
|
||||
|
||||
이미지 자산 한 장당 OCR 1 호출 + Caption 1 호출 → ~3-6초 (`gemma4:e4b` 기준). 다이어그램 / 카메라 사진 / 스크린샷 위주 워크스페이스에 권장. 책 / 스캔본은 P7 PDF 라인으로.
|
||||
|
||||
**v0.27.0 — paddle-onnx 엔진 (오프라인, Ollama 불필요).** `[image.ocr] engine = "paddle-onnx"` 로 바꾸면 PP-OCRv5 ONNX 를 in-process 로 실행한다 (원격 vision LM 불필요, 큰 페이지 CPU <4초). embedding 까지 끄려면 `[models.embedding] provider = "none"` (lexical-only) 로 두면 Ollama 없이 OCR→FTS5 검색 전체 경로를 스모크할 수 있다:
|
||||
|
||||
```toml
|
||||
[models.embedding]
|
||||
provider = "none" # lexical-only — Ollama 불필요
|
||||
|
||||
[image.ocr]
|
||||
enabled = true
|
||||
engine = "paddle-onnx" # PP-OCRv5 ONNX in-process (Python/원격 0)
|
||||
model = "ppocrv5-mobile-kor"
|
||||
languages = ["kor", "eng"]
|
||||
max_pixels = 1600
|
||||
# det_model / rec_model / dict 로 번들 모델 경로 override 가능 (생략 시 번들 사용)
|
||||
# score_thresh = 0.3 / unclip_ratio = 1.5 / max_boxes = 1000 으로 검출 튜닝
|
||||
```
|
||||
|
||||
스모크: `kebab ingest --config <cfg>` 후 `kebab search --config <cfg> --mode lexical "<이미지 안 한국어 단어>"` 가 그 image chunk 를 반환하면 OCR→FTS5 wiring 정상. engine 또는 모델을 바꾸면 다음 ingest 가 영향 이미지를 자동 재색인한다.
|
||||
|
||||
## P7-3 PDF ingestion
|
||||
|
||||
`config.toml` 의 `[workspace] include` 에 `**/*.pdf` 를 추가하면 `kebab ingest` 가 텍스트 PDF 자산도 색인합니다. 외부 service 의존 없음 — `kebab-parse-pdf` 가 lopdf 로 페이지 단위 텍스트 추출, `kebab-chunk::PdfPageV1Chunker` 가 페이지 경계를 절대 넘지 않는 chunk 생성.
|
||||
@@ -690,6 +715,20 @@ KB --json schema | jq '.stats.code_lang_breakdown'
|
||||
- OCR / caption 부분 실패는 `errors` 카운터 미증가 — `kebab inspect doc <id>` 의 Provenance Warning 이벤트 또는 `--debug` 로그에서만 확인.
|
||||
- (P7-3) `*.pdf` 자산을 워크스페이스에 두면 `kebab ingest` 출력에 PDF 도 `new` 카운터에 포함. `kebab inspect doc <pdf_doc_id>` 가 `parser_version = "pdf-text-v1"` + 페이지마다 `Block::Paragraph` + `SourceSpan::Page { page, char_start, char_end }`. 본문에 등장하는 단어로 `kebab search --mode hybrid` 시 PDF chunk 가 결과에 포함되고 `source_span.kind = "page"` 면 wiring 정상. 암호화 PDF 는 `errors+=1` 로 분류되며 `error` 필드에 `qpdf --decrypt` 안내 보존. 빈/스캔 페이지 (PDF 가 텍스트를 추출하지 못한 페이지) 는 0 chunk + `Provenance::Warning` ("scanned candidate") 로 표시 — P+ scanned-PDF OCR fallback 까지는 검색 불가.
|
||||
|
||||
## config migrate (마이그레이션)
|
||||
|
||||
```bash
|
||||
# 옛 스키마처럼 섹션이 빠진 config 를 흉내내 migrate 동작 확인.
|
||||
printf 'schema_version = 1\n\n[workspace]\nroot = "/tmp/kb"\ninclude = ["*.md"]\n' \
|
||||
> /tmp/kebab-smoke/old.toml
|
||||
kebab --config /tmp/kebab-smoke/old.toml config migrate --dry-run # 변경 미리보기 (파일 미수정)
|
||||
kebab --config /tmp/kebab-smoke/old.toml config migrate # 적용 (.bak 백업)
|
||||
kebab --config /tmp/kebab-smoke/old.toml config migrate # 멱등: "config 이미 최신입니다"
|
||||
kebab --config /tmp/kebab-smoke/old.toml --json config migrate --dry-run | jq .schema_version
|
||||
```
|
||||
|
||||
기대: dry-run 은 추가될 섹션(`[ingest.code]`·`[logging]` 등)과 제거될 `workspace.include` 를 출력하고 **파일을 수정하지 않는다**. 적용 시 `old.toml.bak`(원본과 동일)이 생기고 빠진 섹션이 주석과 함께 추가되며 사용자가 손본 값·주석은 보존된다. 재실행은 멱등(`config 이미 최신입니다`), `--json` 은 `config_migration.v1`.
|
||||
|
||||
## 정리
|
||||
|
||||
```bash
|
||||
|
||||
97
docs/release-notes/v0.22.0-draft.md
Normal file
97
docs/release-notes/v0.22.0-draft.md
Normal file
@@ -0,0 +1,97 @@
|
||||
---
|
||||
title: kebab v0.22.0 release notes (draft)
|
||||
created: 2026-06-01
|
||||
status: draft
|
||||
release_trigger:
|
||||
- 신규 config surface (provider=candle, num_threads / KEBAB_EMBED_THREADS) — pre-1.0 minor bump
|
||||
- 임베딩 백엔드 다변화 (NUMA-안전 candle provider 추가, opt-in)
|
||||
---
|
||||
|
||||
# kebab v0.22.0 — candle 임베딩 provider (NUMA-안전, opt-in)
|
||||
|
||||
v0.21.1 (config 마이그레이션) 후속 minor release. 듀얼소켓 NUMA 서버에서
|
||||
onnxruntime 의 스레드 하드코딩이 일으키던 ingest 크래시를 피하기 위해, 같은
|
||||
임베딩 모델을 **순수 Rust(candle)** 로 돌리는 opt-in provider 를 추가한다.
|
||||
**기본 동작은 그대로다** — 기존 사용자는 아무것도 바꿀 필요가 없다.
|
||||
|
||||
---
|
||||
|
||||
## 핵심 변경
|
||||
|
||||
### candle 임베딩 provider (`provider = "candle"`)
|
||||
|
||||
**변경 사실.** `[models.embedding].provider` 에 `"candle"` 값이 추가됐다.
|
||||
`"fastembed"`(기본, onnxruntime) / `"candle"`(순수 Rust) / `"none"`(lexical-only)
|
||||
중 하나를 고를 수 있다. candle provider 는 fastembed 와 **완전히 같은 모델**
|
||||
(`intfloat/multilingual-e5-large`, 1024-dim)을 쓰고, e5 prefix → mean pooling
|
||||
→ L2 정규화 파이프라인도 동일하다. 첫 사용 시 safetensors(~2GB)를
|
||||
`{model_dir}/candle/` 아래로 자동 다운로드한다.
|
||||
|
||||
```toml
|
||||
[models.embedding]
|
||||
provider = "candle" # 기본은 "fastembed" — NUMA 서버에서만 candle 권장
|
||||
num_threads = 8 # candle CPU 스레드 캡 (0 = auto = #cores)
|
||||
```
|
||||
|
||||
```bash
|
||||
# env 로도 캡 가능 (config 보다 우선)
|
||||
KEBAB_EMBED_THREADS=8 kebab ingest
|
||||
```
|
||||
|
||||
**Trade-off.** candle 는 순수 Rust 라 onnxruntime 의 네이티브 SIMD 커널보다
|
||||
CPU latency 가 느리다 (Phase 0 스파이크 측정 ~4×). 그래서 **기본값은
|
||||
fastembed 를 유지**하고, candle 은 onnxruntime 가 죽는 NUMA 환경에서만 켜는
|
||||
opt-in 으로 둔다. 단일 워크스테이션 사용자는 fastembed 가 더 빠르다.
|
||||
|
||||
**Mitigation (왜 안전한가).** candle 의 CPU 백엔드는 글로벌 rayon 풀 크기로
|
||||
스레드를 정한다. `num_threads`(또는 env `KEBAB_EMBED_THREADS`)가 그 풀을 한 번
|
||||
캡하므로, onnxruntime 가 하드코딩하던 48 intra-op 스레드 → NUMA 힙 손상 →
|
||||
double-free 경로를 원천 차단한다. NUMA 노드 바인딩이 더 필요하면 `numactl`
|
||||
과 조합한다.
|
||||
|
||||
**Upgrade 절차.** 재색인 **불필요**. candle 과 fastembed 의 벡터는 사실상
|
||||
동일(Phase 0 스파이크 코사인 1.000000)해서 `embedding_version` 을 유지했고,
|
||||
기존 LanceDB 색인을 그대로 재사용한다. provider 를 바꿔도 검색 결과는
|
||||
바뀌지 않는다. 기존 `config.toml` 은 `num_threads` 가 자동으로 `0`(auto)으로
|
||||
채워져 그대로 로드된다 — `kebab config migrate` 도, 수동 편집도 필요 없다.
|
||||
|
||||
---
|
||||
|
||||
## 그 외
|
||||
|
||||
- 신규 crate `kebab-embed-candle` (candle 의존성 트리를 이 crate 에 격리,
|
||||
`kebab-core`/`kebab-config` 외 다른 kebab-* 의존 없음).
|
||||
- Phase 0 feasibility 스파이크(`spike-embed-candle`)는 production 흡수 후 제거.
|
||||
- 문서: README Configuration, `docs/SMOKE.md` config 예시, `docs/ARCHITECTURE.md`
|
||||
crate 그래프/트리에 candle provider 반영.
|
||||
|
||||
## 검증 / 도그푸딩
|
||||
|
||||
- **패리티 (candle vs onnxruntime)**: 동일 e5-large 가중치로 cosine_min =
|
||||
1.000000, 차원별 max 절대오차 = **2.01e-7**. 벡터가 사실상 동일 →
|
||||
`embedding_version` 유지(재색인 0). 재현: `crates/kebab-embed-candle/tests/parity.rs`
|
||||
(`--ignored`).
|
||||
- **전체 도그푸딩 (2026-06-02)**: `provider=candle` 로 도그푸딩 코퍼스 전체
|
||||
재색인 — **997 docs / 23,151 chunks, 에러 0** 완주 (≈9.5 h, 단일소켓 VM).
|
||||
candle 가 23k+ 청크를 메모리 오류 없이 처리함을 확인.
|
||||
- **A1(taskset/numactl) 반증**: NUMA 서버에서 `taskset -c 0-3` 으로 스레드를
|
||||
4개로 묶어도 onnxruntime 은 그대로 죽었다(6/5150 segfault). 스레드 축소는
|
||||
해법이 아니며, **`provider=candle` 만이 실 해법**이다 (candle 은 onnxruntime 을
|
||||
호출하지 않음).
|
||||
- **최종 인수 게이트 (사용자)**: 그 듀얼소켓 NUMA 서버에서 `provider=candle` 로
|
||||
ingest 가 EXIT=0 완주 — 배포·실사용이 이 검증을 겸한다.
|
||||
|
||||
## 성능 노트 (중요)
|
||||
|
||||
candle CPU 임베딩은 onnxruntime 대비 약 **3~4× 느리다** (e5-large/512-tok 의
|
||||
순수-Rust 커널 비용). 측정상 ~1.86 s/chunk, CPU 약 4코어 활용. **이는 의도된
|
||||
트레이드오프** — onnxruntime 이 전 코어를 AVX-512 로 빡빡하게 굴리는 바로 그
|
||||
경로가 NUMA 에서 힙을 손상시켜 죽기 때문이다. "느려도 완주" > "빨라도 크래시".
|
||||
|
||||
- Intel **MKL 가속을 실험했으나 부정 결과**: MKL 은 코어를 더 쓰지만(8~9코어)
|
||||
오히려 38~50% 느렸다(과다구독 + MKL 2020.1 오버헤드). 채택하지 않음.
|
||||
- 더 많은 코어/스레드로는 빨라지지 않는다(병목이 코어 수가 아님). 속도가
|
||||
critical 하면 청크 길이 단축 / 더 작은 모델 / GPU 가 레버다(별도 검토).
|
||||
- 9.5 h 는 **최초 전체 색인 1회 비용**이며, 이후 증분 ingest 는 새/변경 문서만
|
||||
처리해 저렴하다. 단일 워크스테이션(비-NUMA)에서는 기본 `fastembed` 가 더 빠르니
|
||||
candle 은 NUMA 호스트 전용 opt-in 으로 둔다.
|
||||
@@ -0,0 +1,113 @@
|
||||
---
|
||||
title: Query-paraphrase robustness — Phase 1 (변형 일관성 평가) 완료 + (A)/(B) 진단
|
||||
date: 2026-05-29
|
||||
branch: feat/crossscript-rerank
|
||||
status: Phase 1 구현·측정 완료 — Phase 2(처방) 결정 대기
|
||||
related:
|
||||
- docs/superpowers/specs/2026-05-29-query-paraphrase-robustness-eval-design.md
|
||||
- docs/superpowers/plans/2026-05-29-query-paraphrase-robustness-eval.md
|
||||
- docs/superpowers/handoffs/2026-05-29-crossscript-rerank-progress-handoff.md (선행 rerank 실험)
|
||||
- memory: project_paraphrase_robustness, project_rerank_experiment, project_crossscript_diagnosis
|
||||
---
|
||||
|
||||
# Query-paraphrase robustness — Phase 1 완료
|
||||
|
||||
## TL;DR
|
||||
|
||||
같은 의미를 다른 표현(한/영·동의어·풀어쓴 문장)으로 물어도 일관된 품질이 나오는지 **직접 측정**하는
|
||||
프레임워크를 `kebab-eval` 에 구축하고(Phase 1), dogfood KB 에 8개 변형 그룹(32 변형)을 큐레이션해
|
||||
측정했다. 결과: **문제는 한/영이 아니라 "어휘 거리"** 이고, **(B) 어휘격차가 (A) 순위출렁보다 우세**
|
||||
(B_dominant=4 vs A_dominant=2). 즉 선행 rerank 실험(A형 처방)은 소수만 커버 — "측정 먼저" 논제가
|
||||
정량 검증됨. Phase 2 처방(쿼리 확장/번역 vs near-tie 흡수)은 사용자 결정 대기.
|
||||
|
||||
## Phase 1 구현 (branch `feat/crossscript-rerank`, 머지 전)
|
||||
|
||||
| Task | 커밋 | 내용 | 리뷰 |
|
||||
|---|---|---|---|
|
||||
| 1 | `e491a7b`+`48c94de` | `GoldenQuery.group` + loader 그룹 정합성 검증 | sonnet APPROVE-WITH-NITS (반영) |
|
||||
| 2 | `0ff38581`+`67e104f` | `kebab-eval::variant` 메트릭 + (A)/(B) 분류 | opus CHANGES-REQUESTED → H1/M1 수정 |
|
||||
| 3 | `895dcea` | `kebab eval variants <run_id>` CLI | 직접 검증 |
|
||||
|
||||
- **메트릭**: 그룹 내 `recall@narrow(10)` vs `recall@pool(50)` 대비 →
|
||||
`Ok`(top-10 안) / `MisRanked`(A: pool엔 있고 top-10 밖) / `Missing`(B: pool에도 없음).
|
||||
그룹 롤업: recall_spread@10, worst@10, A/B dominant, fully_consistent, `pool_possibly_truncated`.
|
||||
- **리뷰 H1 (실제 버그, 측정 전 차단)**: `POOL_K=50` 인데 `eval run --k` 기본=10 →
|
||||
pool==narrow 항상 → A 영원히 안 나옴, 전부 B 오분류. 수정: `config_snapshot_json` 에 `eval_k`
|
||||
추가 + `eval_k < 50` 이면 `bail` + `pool_possibly_truncated` 플래그. 회귀 테스트 고정.
|
||||
- 전 task `cargo test`+`clippy -D warnings` green. 기존 `AggregateMetrics` 경로 불변(회귀 가드 통과).
|
||||
|
||||
## 측정 (Task 4 큐레이션 + Task 5)
|
||||
|
||||
- golden: `/build/dogfood/golden_queries.yaml` 에 8그룹×4변형(ko/en/동의어/풀어쓴문장) append.
|
||||
정답 문서는 **corpus 의미로 판정**(검색 상위 자동채택 X — ownership 의 rank1 이 garbage-collection.md
|
||||
의 대조 언급이라 정답 아님을 실증). `topics/` 군(1파일=1주제)이라 판정 명확.
|
||||
- run: `kebab eval run --mode hybrid --k 50` (run_id `run_019e74dcae2778f3984df49ee79b725a`).
|
||||
- 리포트: `kebab eval variants <run_id>` (⚠️ `KEBAB_EVAL_GOLDEN=/build/dogfood/golden_queries.yaml`
|
||||
설정 필수 — 미설정 시 default golden → groups=0). 전체:
|
||||
`/build/dogfood/logs/2026-05-29-paraphrase-robustness-variants-hybrid.txt`.
|
||||
|
||||
### 결과 (hybrid, k=50, err=0)
|
||||
|
||||
```
|
||||
groups=8 fully_consistent=2 A_dominant=2 B_dominant=4 mean_spread@10=0.750 pool=top-50
|
||||
```
|
||||
|
||||
| group | A(MisRanked) | B(Missing) | 분류 | 핵심 |
|
||||
|---|---|---|---|---|
|
||||
| ownership | 0 | 0 | 완전 일관 ✅ | 4변형 모두 recall 1.0 |
|
||||
| isolation_levels | 0 | 0 | 완전 일관 ✅ | 4변형 모두 1.0 (한국어 "트랜잭션 격리 수준" 포함) |
|
||||
| cap_theorem | 2 | 0 | (A) near-tie | 풀어쓴 문장(영/한) recall@50=1·@10=0 |
|
||||
| vector_database | 2 | 0 | (A) near-tie | "벡터 데이터베이스"·"근사 최근접…" @50=1·@10=0 |
|
||||
| raft | 0 | 1 | (B) 어휘격차 | 영어 풀어쓴 "how nodes agree…" @50=0 |
|
||||
| mvcc | 0 | 1 | (B) 어휘격차 | 영어 풀어쓴 "how databases serve reads…" @50=0 |
|
||||
| backprop | 0 | 2 | (B) 어휘격차 | 한국어 "역전파 알고리즘"·"연쇄 법칙…" @50=0 |
|
||||
| gradient_descent | 0 | 2 | (B) 어휘격차 | 한국어 "경사 하강법"·"손실 함수…" @50=0 |
|
||||
|
||||
raw search 독립 검증: `kebab search "역전파 알고리즘" --k 50` → backprop doc(54e0ac…) **top-50 부재**
|
||||
(top은 무관한 algorithm.md). eval 파이프라인 artifact 아님 확인.
|
||||
|
||||
### 진단 (Read 검증된 숫자 기반)
|
||||
|
||||
1. **문제는 실재하고 크다**: mean_spread@10=0.750 — 같은 의도의 표현 간 recall 이 평균 0.75 출렁.
|
||||
2. **한/영 문제가 아니라 어휘 거리 문제**: 영어 풀어쓴 문장도 miss(raft/mvcc), 일부 한국어는 잘 됨
|
||||
(러스트 소유권, 트랜잭션 격리 수준, MVCC 동작 원리, 래프트 합의 알고리즘). 사용자 재정의 목표
|
||||
("정확한 단어가 아닌 같은 의미의 다른 단어")와 정확히 일치.
|
||||
3. **(B) 어휘격차 우세 (4 vs 2)**: 못 찾은 정답이 top-50 pool 에도 없음 → 재정렬(rerank)로 해결 불가.
|
||||
특히 ml-training(backprop/gradient_descent) 한국어는 영어 본문 문서를 의미·표층 둘 다 못 매칭.
|
||||
→ **쿼리 확장/번역**(또는 더 나은 다국어 임베딩) 처방 신호.
|
||||
4. **(A) 순위출렁은 소수 (cap_theorem/vector_database)**: 정답이 pool엔 있고 top-10 밖 →
|
||||
near-tie 흡수 / rerank 후보. 선행 rerank 실험이 도움 됐을 그룹.
|
||||
5. **"측정 먼저" 논제 검증**: rerank(A형) 단독은 6개 문제 그룹 중 2개만 커버. 선행 실험이 overlap
|
||||
프록시로 헛돈 이유가 데이터로 드러남.
|
||||
|
||||
## Phase 2 (처방) — 결정 대기
|
||||
|
||||
본 spec §2 의 조건부 게이트대로:
|
||||
- **(B) 우세이므로 쿼리 확장/번역이 1차 후보** (로컬 LLM gemma). cap_theorem/vector_database 의
|
||||
(A) 성분엔 near-tie 흡수가 보조.
|
||||
- 처방 효과는 본 Phase 1 평가셋(`kebab eval variants`)으로 재측정해 검증 (또 프록시 금지).
|
||||
- 미결: 확장/번역의 형태(쿼리→영어 번역 후 retrieve, 양쪽 retrieve 합집합, HyDE 류 등),
|
||||
latency·품질 trade-off, default on/off. → Phase 2 brainstorm/spec 에서.
|
||||
|
||||
## Phase 2 방향 — 딥리서치 + PoC (2026-05-30)
|
||||
|
||||
- **딥리서치** (`docs/superpowers/research/2026-05-30-vocabulary-gap-recall-fix-research.md`, 104 agent,
|
||||
22 confirmed/3 killed): 어휘격차 pool-miss 최선책 = **색인시 doc-side expansion(doc2query)**.
|
||||
pool 자체를 키우고(rerank 아님), per-query 지연 ~0(색인시 1회 → 사용자가 거부한 per-query LLM 아님),
|
||||
정확매칭 보존(별도 필드 append). 단 vanilla mt5 doc2query 는 같은언어라 한/영 갭은 색인시 KO↔EN
|
||||
대체 query 생성 필요. query-side(HyDE=거부된 per-query LLM, Vector-PRF=recall 주장 0-3 기각) 부적합.
|
||||
learned-sparse(SPLADE/MILCO)는 CPU/Rust 경로 없거나 교차언어 약함.
|
||||
- **PoC 확인** (`/build/dogfood/logs/2026-05-30-docexpansion-poc-result.md`): dogfood KB(3940 doc)에
|
||||
backprop/raft 별칭추가판 ingest → recall@50=0 이던 3쿼리 전부 **rank 1~2 로 부활**(hybrid+vector),
|
||||
별칭은 골든쿼리 verbatim 아님(일반화 확인). **딥리서치의 핵심 미검증 고리를 실 corpus 로 정량 확인.**
|
||||
- ⚠️ dogfood KB 현재 3942 doc (PoC 2개 잔존, corpus/_poc 는 삭제). variant 골든은 원본 doc_id
|
||||
타겟이라 baseline eval 무영향. pristine 필요 시 `kebab reset` + reingest.
|
||||
- **Phase 2 권고**: 색인시 doc-side expansion(같은언어 + KO↔EN 번역 별칭, 로컬 gemma 색인시 1회) →
|
||||
별도 FTS5 필드 → RRF. flag off 기본. 효과는 `kebab eval variants` 로 재측정. brainstorm→spec→plan.
|
||||
|
||||
## 다음 세션 첫 작업
|
||||
|
||||
1. 사용자와 Phase 2 방향 확정 (쿼리 확장/번역 설계 brainstorm).
|
||||
2. 또는 Phase 1 코드(group + variant + CLI)를 main 머지할지 결정 (default off, eval 전용·additive,
|
||||
기존 동작 무영향 → 머지 안전. PR 은 gitea-pr + 리뷰 루프).
|
||||
3. `--with-rag` 변형 일관성(답변 품질 직접 측정)은 미실행 — recall 진단으로 충분했음. 필요 시 후속.
|
||||
@@ -0,0 +1,158 @@
|
||||
---
|
||||
title: Phase 2 킥오프 — doc-side expansion (색인시 별칭) + 구현 방법론
|
||||
date: 2026-05-30
|
||||
status: Phase 1 머지 완료(#193), Phase 2 설계 대기
|
||||
audience: 새 세션 (자립적 컨텍스트 — 이 문서 + 아래 참조만으로 이어받기 가능)
|
||||
related:
|
||||
- docs/superpowers/handoffs/2026-05-29-query-paraphrase-robustness-phase1-handoff.md
|
||||
- docs/superpowers/research/2026-05-30-vocabulary-gap-recall-fix-research.md
|
||||
- docs/superpowers/specs/2026-05-29-query-paraphrase-robustness-eval-design.md
|
||||
- docs/superpowers/plans/2026-05-29-query-paraphrase-robustness-eval.md
|
||||
- memory: project_paraphrase_robustness, project_rerank_experiment, project_crossscript_diagnosis,
|
||||
feedback_omc_teams_usage, feedback_teammate_spawn_mode, feedback_teammate_model_routing,
|
||||
feedback_worker_completion_polling, feedback_pr_workflow, feedback_search_quality_dogfood,
|
||||
feedback_serial_build_only, feedback_skip_user_review_gates, feedback_explain_friendly
|
||||
---
|
||||
|
||||
# Phase 2 킥오프 — doc-side expansion
|
||||
|
||||
## 0. TL;DR
|
||||
|
||||
같은 의미를 다른 표현으로 물어도 일관된 검색 품질을 내는 게 목표다. **Phase 1(평가 프레임워크)은
|
||||
main 에 머지됨(#193).** 진단 결과 **어휘격차(B)가 우세** — 같은 뜻 다른 단어(동의어·풀어쓴 문장·
|
||||
한/영)면 정답이 top-50 pool 에도 안 들어와(recall@50=0) rerank 로는 못 고친다. 딥리서치 + 우리
|
||||
corpus PoC 가 처방을 **색인시 doc-side expansion**(문서를 넣을 때 "검색용 별칭"을 1회 생성해 붙이기)
|
||||
으로 확정했다. **Phase 2 = 이 처방을 flag 뒤에 구현하고 `kebab eval variants` 로 효과 재측정.**
|
||||
|
||||
## 1. 여기까지 온 경로 (압축)
|
||||
|
||||
1. 한/영 음차 검색 불안정 → 원인은 **vector near-tie**([[project_crossscript_diagnosis]]).
|
||||
2. 완화책으로 **cross-encoder reranker** 실험(`feat/crossscript-rerank`, default off). full chunk text
|
||||
까지 시도했으나 **회귀 못 없앰 → 가설 반증**([[project_rerank_experiment]]). rerank 는 pool 안의
|
||||
순서만 바꿔서, 정답이 pool 에 없으면 무력.
|
||||
3. 사용자가 목표 재정의: "한/영뿐 아니라 **같은 의미의 다른 단어·표현**에서도 일관된 품질".
|
||||
4. **Phase 1**: `kebab-eval` 에 변형 일관성 평가 추가(group + recall@10 vs recall@50 → A/B 분류).
|
||||
dogfood 8그룹×32변형 측정 → **B(어휘격차) 우세, 문제는 한/영이 아니라 "어휘 거리"**
|
||||
(영어 paraphrase 도 miss, 일부 한국어는 OK). #193 으로 main 머지.
|
||||
5. **딥리서치**(104 agent, 적대검증): 최선책 = 색인시 doc-side expansion. query-side(HyDE=거부된
|
||||
per-query LLM, Vector-PRF=recall 주장 기각) 부적합. learned-sparse(SPLADE/MILCO) CPU/Rust 경로
|
||||
없거나 교차언어 약함. **PoC**(dogfood KB): backprop/raft 별칭추가판 ingest → recall@50=0 이던
|
||||
3쿼리가 **rank 1~2 부활**(hybrid+vector, 골든 verbatim 아님=일반화). 핵심 미검증 고리 정량 확인.
|
||||
|
||||
## 2. Phase 2 설계 방향 (딥리서치 권고 — 합성, 우리 corpus 측정 필수)
|
||||
|
||||
**색인시 doc-side expansion** (`docs/superpowers/research/2026-05-30-vocabulary-gap-recall-fix-research.md`):
|
||||
|
||||
- **무엇**: 문서/청크를 색인할 때 로컬 LLM(gemma, config `models.llm` = `gemma4:e4b`, endpoint 이미
|
||||
설정됨)으로 "이 문서를 찾을 법한 다른 표현/질문"을 **1회** 생성 — 같은언어 paraphrase + **KO↔EN
|
||||
번역 별칭** — 해서 **별도 FTS5 필드**에 저장. RRF 가 {원문 body BM25, 별칭 BM25, e5 dense} 융합.
|
||||
- **왜 우리 제약에 맞나**: (1) 색인시 1회 = 사용자가 거부한 "per-query LLM(밑 빠진 독)" 아님,
|
||||
(2) e5-large dense 유지(bge-m3 dense 는 실측 더 나빴음), (3) 별도 필드라 원문 정확매칭(코드 식별자)
|
||||
보존, (4) per-query 지연 ~0.
|
||||
- **핵심 함정**: vanilla mt5 doc2query 는 *같은 언어* query 만 생성 → 한/영 갭 못 메움. 그래서
|
||||
**색인시 KO↔EN 번역 별칭 생성**이 추가로 필요(이게 "합성/추론" 부분 — 논문 직접 벤치 없음 →
|
||||
우리 corpus 로 반드시 측정).
|
||||
- **(선택) 보조**: BGE-M3 sparse 채널(fastembed-rs `BGEM3Q`, CPU)을 4th RRF 채널로 — 단일언어
|
||||
term-expansion lift, e5 dense 유지. (교차언어는 약하니 선택사항.)
|
||||
|
||||
**딥리서치 openQuestions = Phase 2 가 답할 것:**
|
||||
1. 색인시 KO↔EN 별칭 생성이 *우리 corpus* 에서 recall@50 을 0→양수로 올리나? 생성 예산(별칭 수/문서,
|
||||
모델 크기)의 cost/recall knee 는? → **`/build/dogfood` golden + `kebab eval variants` 로 측정.**
|
||||
2. ONNX/fastembed 호환 교차언어 learned-sparse 체크포인트 있나, 아니면 색인시 expansion 으로만?
|
||||
3. doc2query 가 FTS5 index 를 얼마나 부풀리나. Doc2Query--/++ 필터 가치 있나.
|
||||
4. e5 dense 유지 + BGE-M3 **sparse 만** 추가가 순이득인가, 약한 다국어 sparse 가 노이즈인가.
|
||||
|
||||
**설계 시 고려(brainstorm 에서 확정):** ingest pipeline 의 어디에 hook(chunk 후?), 별도 FTS5 필드
|
||||
스키마 + migration(V0XX), gemma 프롬프트(번역 별칭 품질), versioning cascade(별칭은 새
|
||||
`chunker_version`/별도 version? re-index 정책), flag 이름·default off, 환각·index 팽창 제어(필터).
|
||||
|
||||
## 3. 이미 만든 측정 도구 (Phase 2 검증에 그대로 사용)
|
||||
|
||||
- **`kebab eval variants <run_id> [--json]`** — 변형 그룹 일관성 진단. recall@10 vs recall@50 →
|
||||
`Ok`/`MisRanked`(A)/`Missing`(B) + group rollup + `pool_possibly_truncated`.
|
||||
- **dogfood golden**: `/build/dogfood/golden_queries.yaml` 에 8 변형그룹×4 = 32 (ownership, raft,
|
||||
mvcc, cap_theorem, gradient_descent, backprop, isolation_levels, vector_database). 같은 group =
|
||||
동일 `expected_doc_ids`.
|
||||
- **측정 절차**(⚠️ `KEBAB_EVAL_GOLDEN` 필수 — 미설정 시 default golden → groups=0):
|
||||
```
|
||||
KEBAB_EVAL_GOLDEN=/build/dogfood/golden_queries.yaml \
|
||||
kebab eval run --config /build/dogfood/config.toml --mode hybrid --k 50 # k>=50 필수(아니면 진단 bail)
|
||||
KEBAB_EVAL_GOLDEN=/build/dogfood/golden_queries.yaml \
|
||||
kebab eval variants <run_id> --config /build/dogfood/config.toml
|
||||
```
|
||||
- **Phase 1 baseline**(처방 전): `groups=8 fully_consistent=2 A_dominant=2 B_dominant=4 spread@10=0.750`.
|
||||
Phase 2 목표 = B_dominant↓, fully_consistent↑, spread↓ (처방 on/off 비교).
|
||||
- **PoC 방법**(참고): 별칭 추가판 문서를 corpus 에 넣고 incremental ingest(기존 skip, +N) → 실패
|
||||
쿼리로 새 doc_id 가 top-50 잡히는지. 비파괴적. (`/build/dogfood/logs/2026-05-30-docexpansion-poc-*`)
|
||||
- ⚠️ dogfood KB 현재 3942 doc (PoC 별칭 2개 잔존, corpus/_poc 삭제됨). variant 골든은 원본 doc_id
|
||||
타겟이라 baseline 무영향. pristine 필요 시 `kebab reset` + reingest.
|
||||
|
||||
## 4. 구현 방법론 (지금까지와 동일 — 그대로 따를 것)
|
||||
|
||||
### 4.1 워크플로 (superpowers)
|
||||
**brainstorm → spec → plan → subagent 구현.** 각 단계:
|
||||
- **brainstorm**(`superpowers:brainstorming`): 사용자와 한 번에 하나씩 질문(쉬운 비유·친절히 —
|
||||
사용자는 검색/NLP 지식 적음 [[feedback_explain_friendly]]). 핵심 trade-off 만 AskUserQuestion.
|
||||
- **spec**(`docs/superpowers/specs/YYYY-MM-DD-*.md`): self-review 후 진행. **사용자 컨펌 게이트
|
||||
skip** ([[feedback_skip_user_review_gates]]) — self-review 만 + 바로 다음 단계.
|
||||
- **plan**(`docs/superpowers/plans/*.md`): TDD bite-sized task, 완성 코드(placeholder 금지), self-review.
|
||||
- **구현**: task 별 OMC teammate (아래).
|
||||
|
||||
### 4.2 OMC teammate 실행 ([[feedback_omc_teams_usage]] [[feedback_teammate_spawn_mode]])
|
||||
- **sequential single-team only** (multi-team spawn 실측 fail). 한 팀 끝 → shutdown → 다음 팀.
|
||||
- **spawn**: `OMC_TEAM_ROLE_OVERRIDES='{"<role>":{"model":"claude-opus-4-8|claude-sonnet-4-6"}}'
|
||||
omc team 1:claude:<role> --no-decompose "Task X: read <brief-abs-path> and execute exactly; write
|
||||
result to <result-abs-path>"`. role 예: executor, code-reviewer.
|
||||
- **brief 파일 패턴**: task 내용을 `.omc/reviews/<date>-<id>-brief.md` 에 자립적으로 작성
|
||||
(계획 task 참조 + 빌드/규약 + 결과파일 경로). spawn 의 task 텍스트는 짧게(brief read 지시).
|
||||
- **완료 감지**: spawn 직후 background polling shell(`run_in_background=true`) — `omc team status
|
||||
<slug>` 의 phase=completed/failed 또는 tasks completed>=1 감지 → task notification 자동 알림
|
||||
([[feedback_worker_completion_polling]]). 작은 task sleep 10, 큰 task sleep 20.
|
||||
- **모델 확인**: spawn 후 worker pane 캡처로 `Model: Sonnet/Opus` 검증 (`tmux capture-pane -pt <pane>`).
|
||||
- **shutdown**: `omc team shutdown <slug> --force` (non-force 종종 실패). 다음 팀 전 필수.
|
||||
- ⚠️ `omc team list` 같은 조회 명령 없음 — "list" 를 task 로 해석해 팀이 spawn 됨. 상태는 `omc team
|
||||
status <slug>`. 잘못 뜬 팀은 즉시 `shutdown --force`.
|
||||
|
||||
### 4.3 모델 라우팅 ([[feedback_teammate_model_routing]])
|
||||
- **작은 task → sonnet**, **복잡/핵심 로직 → opus**. 리뷰: 핵심 로직 = opus, 작은 변경 = sonnet.
|
||||
micro-patch/fix 라운드 = sonnet.
|
||||
- (실증: Phase 1 에서 opus 리뷰가 H1 실버그 — pool truncation 으로 진단 무력화 — 를 측정 전 차단.)
|
||||
|
||||
### 4.4 task 별 사이클
|
||||
implement(executor) → **review(code-reviewer, 별도 teammate)** → CHANGES 면 fix 라운드 → **독립
|
||||
검증**. teammate 보고를 신뢰하지 말고 직접 확인: `git show <hash> --stat`, redirect 파일에서 test/
|
||||
clippy EXIT, 신규 심볼 grep. ([[feedback_serial_build_only]] 의 직렬 빌드 규약도.)
|
||||
|
||||
### 4.5 빌드/테스트 규약 (필수 — 어기면 깨진 커밋)
|
||||
- `CARGO_TARGET_DIR=/build/out/cargo-target/target` (XFS 4TB), `-j 4` (fast mode 8, OOM 시 `-j 1`).
|
||||
- **결과를 파일로 redirect + exit code 확인 후에만 커밋.** `cargo ... | grep | tail` **금지**
|
||||
(pipe exit 가 grep 거라 cargo 실패 마스킹). 빌드는 백그라운드(run_in_background) 권장.
|
||||
- cargo clean: /build avail<500G 또는 target>500G 일 때만 ([[feedback_cargo_clean_policy]]).
|
||||
|
||||
### 4.6 측정 규율 ([[feedback_search_quality_dogfood]] [[project_rerank_experiment]] 교훈)
|
||||
- **프록시 금지**: overlap 같은 대리 지표 최적화로 헛돈 전적 있음. 진짜 지표(`kebab eval variants`
|
||||
recall/일관성)로 처방 효과 측정.
|
||||
- **측정값 절대 추측 금지**: grep clean 추출 → Read 로 확인한 값만 기록. (Phase 1 전 세션에서 숫자
|
||||
fabrication 2회 발생·정정.)
|
||||
- 처방은 flag off 기본, on/off 비교 측정 + 회귀(전체 golden) 확인.
|
||||
|
||||
### 4.7 PR ([[feedback_pr_workflow]])
|
||||
- **gitea-pr + 리뷰 루프 모드** (단발/루프 묻지 말 것). 스크립트:
|
||||
`/home/altair823/.claude/.omc-launch/skills/gitea-ops/bin/gitea-pr{,-status,-diff,-review}`.
|
||||
reviewer login `gitea-ops-reviewer` 별도 계정. PR title 정규식 `^(feat|fix|docs|...)(\(scope\))?: .+`,
|
||||
브랜치 `^<type>/<kebab>$`, body `## 요약`+`## 검증` 필수. 회차마다 review 등록, 한국어 본문은
|
||||
손상 점검(다시 fetch). 머지는 사용자가 UI 에서 (Claude 자동 머지 안 함).
|
||||
- **user-facing surface 변경 시 같은 PR 에서 README + HANDOFF + ARCHITECTURE 동기화**
|
||||
([[feedback_readme_sync_rule]]): README 는 좁게(사용법+포인터), 상세는 ARCHITECTURE, flag 망라는
|
||||
`--help`/in-app 권위 소스 위임(stale 방지). #193 에서 이 정리 수행함.
|
||||
- **versioning cascade**: chunker/embedding version 등 변경 시 design §9 cascade — re-process job
|
||||
또는 breaking bump. 별칭 필드가 새 version 축이면 migration(V0XX) + dogfood trigger.
|
||||
|
||||
## 5. 새 세션 첫 작업
|
||||
|
||||
1. 이 문서 + §0~4 참조 + 메모리 로드 확인.
|
||||
2. **brainstorm Phase 2 설계**: doc-side expansion 의 구체(ingest hook 위치, 별도 FTS5 필드 스키마 +
|
||||
migration, gemma 번역-별칭 프롬프트, versioning cascade, flag/config, 환각·팽창 제어). §2 의
|
||||
openQuestions 를 설계로 흡수.
|
||||
3. spec → plan → OMC teammate 구현(§4 방법론) → `kebab eval variants` 로 on/off 측정.
|
||||
4. 효과 확인되면 gitea-pr 리뷰 루프 + README/ARCH sync. flag off 기본.
|
||||
@@ -0,0 +1,77 @@
|
||||
# config 마이그레이션 — 작업 인계 (kickoff)
|
||||
|
||||
> 2026-05-31. config.toml **스키마 진화 시 기존 사용자 파일을 자동 마이그레이션**하는
|
||||
> 기능. 새 세션은 이 문서 + 메모리 [[project_paraphrase_robustness]] 로 이어받는다.
|
||||
> 본격 진행은 brainstorm → spec → plan → 구현 (방법론 §5).
|
||||
|
||||
## 1. 동기
|
||||
|
||||
v0.21.0 에서 `[ingest.expansion]`(별칭) 섹션을 추가했다. 기존 사용자 config.toml 은
|
||||
serde default 로 **동작은 호환**(off 로 로드)되지만, 그 섹션이 **파일에 써지지 않아**
|
||||
사용자가 파일을 열어도 새 기능의 존재·노브를 알 수 없다. DB 는 V00X refinery
|
||||
마이그레이션이 있는데 **config 는 마이그레이션 메커니즘이 없다** — 이걸 만든다.
|
||||
|
||||
## 2. 현황 (코드, 현재 main = v0.21.0)
|
||||
|
||||
- **읽기는 이미 forward-compatible**: `crates/kebab-config/src/lib.rs` 의 모든 새
|
||||
섹션/필드가 `#[serde(default)]` (예: ImageCfg L50, UiCfg L55, ingest.code L60,
|
||||
PdfCfg L65, logging L70, nli L132 …). missing 필드는 default 로 로드돼 **기존
|
||||
config 가 깨지지 않는다**. → 동작 호환성은 확보돼 있고, 만들 것은 *파일 갱신*이다.
|
||||
- **`schema_version: u32`** (lib.rs:38, 현재 `1`) — **검증·마이그레이션에 안 쓰이는
|
||||
장식**. 마이그레이션의 버전 축으로 활용할 자리.
|
||||
- **파일 쓰기는 init 뿐**: `kebab init` 이 `toml::to_string(&Config::defaults())`
|
||||
로 default config 생성(lib.rs:1349 부근). **기존 파일을 갱신하는 경로는 없다.**
|
||||
- **deprecated 선례**: 옛 `workspace.include` 는 로드 시 무시 + 1회 deprecation
|
||||
warning (p9-fb-25). 마이그레이션의 "deprecated 정리" 참고 패턴.
|
||||
|
||||
## 3. 풀어야 할 핵심 — 주석/순서 보존
|
||||
|
||||
`toml::to_string` 으로 통째 재작성하면 **사용자가 손본 주석·정렬·순서가 전부
|
||||
날아간다**. 이게 config 마이그레이션의 본질적 난점. 접근 3안:
|
||||
|
||||
| 방식 | 주석 보존 | 복잡도 | 비고 |
|
||||
|------|-----------|--------|------|
|
||||
| A. 전체 재작성(로드→재직렬화) | ✗ | 낮음 | 사용자 값은 보존되나 주석 손실 |
|
||||
| B. `toml_edit` 로 missing 섹션만 주석과 함께 append/수정 | ✓ | 중간 | 의존성 추가, 가장 사용자 친화적 |
|
||||
| C. 백업(.bak) 후 재생성 + diff 안내 | △ | 낮음 | 안전하나 사용자가 주석 수동 복원 |
|
||||
|
||||
→ **B(`toml_edit`)** 가 사용자 손본 config 보존엔 최선. 의존성·복잡도 trade-off 를
|
||||
brainstorm 에서 결정.
|
||||
|
||||
## 4. 설계 결정 (brainstorm 시작점)
|
||||
|
||||
1. **트리거**: `kebab config migrate` 명시 명령 vs `load` 시 자동(+백업). 자동은
|
||||
편하나 예측 가능성/안전(쓰기 권한·손상)이 걸린다. 명시 명령 + `kebab doctor`
|
||||
에서 "마이그레이션 필요" 안내가 무난할 수 있음.
|
||||
2. **버전 축**: `schema_version` 기반 버전별 변환 함수 체인 (v1→v2→…, DB refinery
|
||||
패턴 차용). 각 step 은 "이 버전에서 추가된 섹션/바뀐 형식/제거된 deprecated".
|
||||
3. **동작**: (a) 새 섹션을 주석과 함께 추가 (b) deprecated 필드 정리/이동
|
||||
(c) 형식 변경 변환. 모두 **멱등**(재실행 안전).
|
||||
4. **안전**: 사용자 손본 config 손상 절대 금지 → **백업(.bak) 필수**, dry-run 옵션,
|
||||
실패 시 원본 보존.
|
||||
|
||||
## 5. 방법론 (v0.21.0 작업과 동일 — PR #195/#196 참고)
|
||||
|
||||
brainstorm(사용자 컨펌 게이트 skip, self-review) → spec(self-review) → plan(TDD,
|
||||
bite-sized) → executor(opus) 또는 OMC teammate 구현 → **gitea-pr 리뷰 루프**
|
||||
(round1 리뷰 opus, closure verify sonnet) → 머지. 빌드는 항상
|
||||
`CARGO_TARGET_DIR=/build/out/cargo-target/target cargo … -j 4 > /tmp/x.log 2>&1; echo EXIT=$?`
|
||||
(절대 `cargo | grep` 금지). PR 은 gitea REST(`~/.netrc`), gh 안 됨.
|
||||
|
||||
## 6. 관련 파일
|
||||
|
||||
- `crates/kebab-config/src/lib.rs` — `Config` struct, `schema_version`, serde default
|
||||
패턴, `load`/`defaults`/`to_string`. 마이그레이션 모듈을 여기 or 신규 `migrate.rs`.
|
||||
- `crates/kebab-cli/src/*` — `init` 명령 옆에 `config migrate`(또는 `config`) 서브커맨드.
|
||||
- `migrations/V0XX__*.sql` — DB 마이그레이션의 버전 체인 패턴 차용 참고.
|
||||
- `toml_edit` 크레이트(주석 보존 편집) — B안 시 의존성 후보.
|
||||
|
||||
## 7. 주의
|
||||
|
||||
- config 마이그레이션은 **user-facing surface** → README(Configuration)/HOTFIXES 동기화
|
||||
(이번 세션 패턴 [[feedback_readme_sync_rule]]). 마이그레이션 *동작 디테일*은 spec 에
|
||||
충실히([[feedback_design_detail_docs]]).
|
||||
- `schema_version` bump 가 release 트리거인지는 별도 판단 — DB schema(V00X)와 달리
|
||||
config 버전은 데이터 무효화가 아니므로, additive 면 release 트리거 아닐 수 있음
|
||||
(CLAUDE.md §Versioning 의 DB/wire 기준과 구분).
|
||||
- 멱등 + 백업 + dry-run 이 안전의 3축.
|
||||
@@ -0,0 +1,114 @@
|
||||
# 나무위키 대규모 측정 — doc-side expansion 별칭 효과 + 파생물 캐시
|
||||
|
||||
> 2026-05-31. Phase 2 doc-side expansion(별칭) 의 효과를 실사용 규모(한국어 나무위키
|
||||
> corpus)로 검증하고, 그 과정에서 드러난 별칭 생성 비용 문제를 "내용 해시 기반 파생물
|
||||
> 캐시"로 해결한 기록. 선행: `2026-05-30-phase2-doc-expansion-kickoff.md`,
|
||||
> 설계: `../specs/2026-05-30-dense-alias-vectors-design.md`,
|
||||
> `../specs/2026-05-31-derivation-cache-design.md`.
|
||||
|
||||
## 1. 출발 질문 (사용자 제기)
|
||||
|
||||
측정을 진행하며 사용자가 던진 질문들이 설계를 단계적으로 교정했다:
|
||||
|
||||
1. **"테스트 모수가 너무 적지 않나? 더 넓게(대규모, 영+한 혼합) 테스트하자."**
|
||||
→ 기존 8~32개 golden 으로는 "변형 일관성 개선"이 우연인지 실재인지 판단 불가.
|
||||
2. **"실사용은 약 2천 개 한국어 위키 문서다."** + 기존 크롤링한 나무위키 parquet
|
||||
(`/build/cache/namu-crawler/pages.parquet`, 119만 문서) 제공.
|
||||
→ 측정 corpus 를 실사용에 맞춤. 노이즈는 크게, 별칭은 정답 문서에만(비용).
|
||||
3. **"정답과 주제가 완전히 다르면(야구·게임) 검색이 너무 쉬워 별칭 효과가 과소평가된다.
|
||||
실사용은 한 개발조직 위키 = 유사 주제 밀집이다."**
|
||||
→ 노이즈를 정답과 같은 분야(CS/IT)로 교체. 진짜 어려운 "유사 경쟁" 환경 구성.
|
||||
4. **"대조군(정답 없는 질문)도 측정하자."** → false-positive(별칭이 노이즈를 grounded
|
||||
answer 로 끌어오는지) 검증.
|
||||
5. **"별칭 벡터 생성이 너무 오래 걸린다(18문서 2.5시간). 캐싱이 절실하다 — 별칭뿐 아니라
|
||||
비용 큰 모든 데이터에."** → 내용 해시 기반 파생물 캐시 설계·구현.
|
||||
6. **"비싼 계산을 외부 CPU ollama 서버에서 하고 결과 DB 파일만 가져오고 싶다. 가능한가?"**
|
||||
→ KB 이식성 검증.
|
||||
|
||||
## 2. corpus 구축
|
||||
|
||||
- 소스: 나무위키 덤프 119만 문서(`pages.parquet`, redirect 제외 완료).
|
||||
- **노이즈 979개**: 본문 3k~30k자 + "분류" 헤더에 CS 키워드(컴퓨터공학·프로그래밍·알고리즘
|
||||
…)가 있는 문서 ~70% 정밀도로 필터 → 무작위 샘플(CCleaner·LLaMA·SQL·멀티스레딩 등).
|
||||
정답과 같은 임베딩 공간(유사 주제 밀집)이라 현실적 난이도.
|
||||
- **정답 18개**: 명확한 CS 개념(경사하강법·TCP·정렬·이진탐색·뮤텍스·정규표현식 …),
|
||||
전부 한국어 문서 → 영어 변형은 자동으로 cross-lingual(영→한) 시나리오.
|
||||
- **변환 핵심 교훈**: nawiki `text_extracted` 는 **개행 0**인 한 덩어리라 md 청커(단락
|
||||
경계 분할)가 거대 청크(4000+토큰)를 만들어 e5 512토큰 한계에서 잘렸다. → `html`
|
||||
컬럼을 pandoc(`-f html -t markdown_strict-raw_html`)으로 변환 + base64/링크 정제 →
|
||||
헤딩·단락 구조 복원 → 청크 중앙값 272토큰으로 정상화.
|
||||
- golden: 변형 18그룹 × 4변형(한국어 용어 / 영어 용어 / 동의어·약어 / 설명형) + 대조군 10
|
||||
(`/build/dogfood/namu_golden.yaml`).
|
||||
|
||||
## 3. 측정 결과
|
||||
|
||||
### 3.1 변형 일관성 (search run, hybrid k=50)
|
||||
|
||||
| 구성 | fully_consistent | A(MisRanked) | B(Missing) | mean_spread@10 |
|
||||
|------|------------------|--------------|------------|----------------|
|
||||
| baseline (별칭 off) | 14/18 | 2 | 2 | 0.222 |
|
||||
| 별도-벡터 (별칭 묶음 1벡터) | 13/18 | 2 | 3 | 0.278 (악화) |
|
||||
| **개선 (별칭 개별 벡터 + boilerplate skip)** | **16/18** | 1 | 1 | **0.111** |
|
||||
|
||||
- baseline 약점은 **전부 "설명형" 변형**(용어·약어·영어는 18그룹 전부 완벽). 자연어 설명이
|
||||
문서 전문용어와 어휘가 멀어 벡터 검색이 못 잡음 = "어휘 격차".
|
||||
- **별도-벡터(묶음)가 오히려 악화**한 원인 진단: ① 청크당 별칭 8개를 줄바꿈으로 묶어 한
|
||||
벡터로 임베딩 → 평균화로 특정 표현 **희석** ② 나무위키 메뉴(boilerplate) 청크에도 별칭
|
||||
생성 → 18문서 공통 노이즈.
|
||||
- **개선판**: 별칭을 줄별 **개별 sentinel 벡터**(`{orig}#alias#N`) + boilerplate 청크 skip.
|
||||
→ linked_list·sorting 회복, tcp 회귀 복구. 남은 약점은 stack·svm 설명형 2개.
|
||||
|
||||
### 3.2 대조군 (RAG run, refusal_correctness)
|
||||
|
||||
- refusal 0.6 (대조군 10개 중 6개 정상 거부, 4개 grounded).
|
||||
- **false-positive 4개(graphql·oauth·react·grpc)의 인용 출처는 전부 노이즈 본문**
|
||||
(GitHub_Mobile·API·Svelte), **별칭 sentinel 인용 0** → 별칭이 false-positive 를
|
||||
유발하지 않음(별칭 무죄). 게다가 answer 는 "근거에서 찾을 수 없다"고 정직히 거부했는데
|
||||
grounded 판정이 "부분 언급 인용 있음"을 grounded 로 오분류 → 실제 refusal 은 0.6 보다 높음.
|
||||
(kebab grounded/refusal 판정의 별도 개선 여지 — HOTFIXES 후보.)
|
||||
|
||||
### 3.3 정답 RAG
|
||||
|
||||
- 변형 72개 중 대부분 grounded=True + 정답 문서 다수 인용(sort 28·linked_list 23 등). 양호.
|
||||
|
||||
## 4. 파생물 캐시 (V012)
|
||||
|
||||
별칭 18문서 재생성 2.5시간이 근본 병목. `chunk_id` 가 `ordinal+span`(위치) 기반이라
|
||||
chunk_id 캐싱은 중간 수정 시 무력 → **청크 text 내용 해시**를 키로 한 범용 캐시 설계.
|
||||
|
||||
- `derivation_cache(cache_key, kind, payload, created_at, last_used_at)` (SQLite, V012).
|
||||
- `cache_key = blake3(kind ‖ text_blake3 ‖ version_key)`. version_key 에 model/prompt/
|
||||
dimensions 포함 → §9 cascade 와 정합(버전 bump 시 자동 miss).
|
||||
- **위치 밀림에도 캐시가 듣는 이유**: chunk_id 는 위치(ordinal+span) 기반이라 문서 중간
|
||||
삽입 시 뒤 청크의 chunk_id 가 바뀌어 row 가 재작성되지만(싼 DB write), cache_key 는
|
||||
*내용 해시*라 내용 불변 청크는 히트 → 비싼 재계산(embedding/LLM) 0. chunk_id 와
|
||||
cache_key 가 별개라는 게 핵심. 설계 근거·동작은 spec §1 / §3.4 참조.
|
||||
- 적용: embedding(본문 + 별칭 벡터 양쪽) + 별칭 LLM. korean_tokens 는 우선순위 낮아 보류.
|
||||
- **측정: 정답 3개 cold 1879초(31분) → warm 13초 ≈ 145배.** 18문서 환산 시 2.5h → ~80s.
|
||||
derivation_cache 1237 엔트리(alias 140 + embedding 1097).
|
||||
- 기존 KB 호환성(본문 재색인 불필요 / V012 가산 / 이전 binary mismatch / 별칭 재생성은
|
||||
선택)은 설계 spec §7 참조 — 이 handoff 는 측정 과정·결과만 담는다.
|
||||
|
||||
## 5. KB 이식성 (외부 계산 워크플로)
|
||||
|
||||
- `storage_path`(asset 절대경로)는 search/ask 경로에서 **사용처 0** — 저장·재처리에서만.
|
||||
- **search/ask 는 `kebab.sqlite` + `lancedb` 만으로 동작**(asset 불필요).
|
||||
- 실증: 원본 KB 와 다른 경로로 복사한 portable KB(asset 제외)의 search 결과가 score·순서·
|
||||
문서까지 **완전 동일**.
|
||||
- 결론 워크플로:
|
||||
```
|
||||
[외부 CPU ollama 서버] 같은 corpus + 같은 e5 모델/버전 + 같은 parser/chunker/embedding 버전
|
||||
kebab ingest → 별칭 LLM + embedding (비싼 계산, 캐시 워밍)
|
||||
↓ kebab.sqlite(+derivation_cache) + lancedb/ 만 복사
|
||||
[로컬] kebab search/ask → 계산 0. 증분 수정 시 외부 캐시가 머신 독립적으로 히트.
|
||||
```
|
||||
|
||||
## 6. 결정 / 후속
|
||||
|
||||
- **채택**: 별칭 개별 sentinel 벡터 + boilerplate skip(효과·안전 입증) + 파생물 캐시(V012).
|
||||
- **보류**: stack·svm 설명형 2그룹 추가 개선, korean_tokens 캐시, 이식용 캐시 export/import
|
||||
명령, 별칭 default-on 여부(현재 off-by-default, 실사용 관찰 후 재결정).
|
||||
- **별도 이슈**: grounded/refusal 판정이 부분 인용을 grounded 로 오분류 — 정직한 거부가
|
||||
false-positive 로 집계됨.
|
||||
- 측정 데이터: corpus `/build/dogfood/corpus/markdown/namu-wiki/`,
|
||||
golden `/build/dogfood/namu_golden.yaml`, 로그 `/build/dogfood/logs/`.
|
||||
@@ -0,0 +1,827 @@
|
||||
# Query-paraphrase Robustness Eval (Phase 1) Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** `kebab-eval`에 "같은 의미의 여러 표현(동의어·다른 어휘·풀어쓴 문장·한/영)"을 묶는 변형 그룹과, 그룹 내 답변/검색 품질 일관성을 재고 (A)순위출렁/(B)어휘격차를 판별하는 진단 메트릭을 추가한다.
|
||||
|
||||
**Architecture:** `GoldenQuery`에 `group: Option<String>` 추가(additive) → loader가 그룹 정합성 검증 → 신규 `variant.rs`가 저장된 run의 per-query 결과를 그룹으로 묶어 recall@narrow(10) vs recall@pool(50) 대비로 변형 일관성 + A/B 분류 산출 → `kebab eval variants <run_id>` CLI로 표/JSON 리포트. 기존 `AggregateMetrics` 경로는 불변(group=None이면 기존 동작).
|
||||
|
||||
**Tech Stack:** Rust 2024, `kebab-eval` 크레이트, serde/serde_yaml, anyhow, rusqlite(간접). 측정은 release `kebab` + dogfood KB.
|
||||
|
||||
**빌드/테스트 규약 (이 환경 필수):** 모든 cargo는 `CARGO_TARGET_DIR=/build/out/cargo-target/target` + `-j 4`, 결과를 **파일 redirect + exit code 확인 후에만** 커밋 (`grep|tail` 금지 — pipe exit가 cargo 실패를 마스킹). 출력 노이즈로 빌드 오독 사례 다수.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| File | 책임 | 변경 |
|
||||
|---|---|---|
|
||||
| `crates/kebab-eval/src/types.rs` | `GoldenQuery`에 `group` 필드 | Modify |
|
||||
| `crates/kebab-eval/src/loader.rs` | 그룹 정합성 검증(`check_group_integrity`) | Modify |
|
||||
| `crates/kebab-eval/src/variant.rs` | 변형 일관성 메트릭 + A/B 분류 + 렌더 | **Create** |
|
||||
| `crates/kebab-eval/src/lib.rs` | `variant` 모듈 등록 + re-export | Modify |
|
||||
| `crates/kebab-cli/src/main.rs` | `kebab eval variants <run_id>` 서브커맨드 | Modify |
|
||||
| `/build/dogfood/golden_queries.yaml` | 변형 그룹 큐레이션 (in-repo 아님) | Modify (data) |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: `group` 필드 + loader 그룹 정합성 검증
|
||||
|
||||
**모델:** sonnet (작은 스키마 + 검증 함수)
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/kebab-eval/src/types.rs:13-29` (GoldenQuery)
|
||||
- Modify: `crates/kebab-eval/src/loader.rs` (`load_golden_set` + 신규 `check_group_integrity`)
|
||||
- Test: `crates/kebab-eval/src/loader.rs` (in-module `#[cfg(test)]`)
|
||||
|
||||
- [ ] **Step 1: `group` 필드 추가**
|
||||
|
||||
`crates/kebab-eval/src/types.rs`의 `GoldenQuery`에 `difficulty` 아래로 추가:
|
||||
|
||||
```rust
|
||||
#[serde(default)]
|
||||
pub difficulty: Option<String>,
|
||||
/// 같은 의미의 여러 표현(동의어·다른 어휘·풀어쓴 문장·한/영)을 묶는
|
||||
/// 의도 그룹 id. 같은 그룹의 모든 변형은 동일한 `expected_doc_ids`(집합)를
|
||||
/// 공유해야 한다(loader가 강제). `None`이면 단독 쿼리(기존 동작 불변).
|
||||
#[serde(default)]
|
||||
pub group: Option<String>,
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 실패하는 테스트 작성**
|
||||
|
||||
`crates/kebab-eval/src/loader.rs`의 `#[cfg(test)] mod tests` 안에 추가:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn rejects_group_with_divergent_expected_docs() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let yaml_path = tmp.path().join("golden.yaml");
|
||||
fs::write(
|
||||
&yaml_path,
|
||||
"- id: g1\n query: \"러스트 소유권\"\n group: ownership\n expected_doc_ids: [\"docA\"]\n\
|
||||
- id: g2\n query: \"rust ownership\"\n group: ownership\n expected_doc_ids: [\"docB\"]\n",
|
||||
)
|
||||
.unwrap();
|
||||
let err = load_golden_set(&yaml_path).unwrap_err();
|
||||
let msg = format!("{err:#}");
|
||||
assert!(msg.contains("group"), "msg: {msg}");
|
||||
assert!(msg.contains("ownership"), "msg: {msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_group_with_matching_expected_docs() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let yaml_path = tmp.path().join("golden.yaml");
|
||||
fs::write(
|
||||
&yaml_path,
|
||||
"- id: g1\n query: \"러스트 소유권\"\n group: ownership\n expected_doc_ids: [\"docA\"]\n\
|
||||
- id: g2\n query: \"rust ownership\"\n group: ownership\n expected_doc_ids: [\"docA\"]\n",
|
||||
)
|
||||
.unwrap();
|
||||
let qs = load_golden_set(&yaml_path).unwrap();
|
||||
assert_eq!(qs.len(), 2);
|
||||
assert_eq!(qs[0].group.as_deref(), Some("ownership"));
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 테스트 실패 확인**
|
||||
|
||||
Run: `CARGO_TARGET_DIR=/build/out/cargo-target/target cargo test -p kebab-eval -j 4 rejects_group_with_divergent > /build/cache/tmp/t1.txt 2>&1; echo "EXIT=$?"`
|
||||
Expected: 컴파일은 되나 `rejects_group_with_divergent_expected_docs` FAIL (현재 정합성 검증 없음 → `load_golden_set`이 Ok 반환).
|
||||
|
||||
- [ ] **Step 4: `check_group_integrity` 구현 + 배선**
|
||||
|
||||
`crates/kebab-eval/src/loader.rs`의 `load_golden_set`에서 `check_unique_ids(&queries)?;` 바로 다음 줄에 `check_group_integrity(&queries)?;` 추가. `check_unique_ids` 함수 아래에 신규 함수:
|
||||
|
||||
```rust
|
||||
/// 같은 `group`에 속한 모든 쿼리가 동일한 `expected_doc_ids`(집합)를
|
||||
/// 공유하는지 검증. 변형 일관성 메트릭은 "같은 정답을 가진 다른 표현들"을
|
||||
/// 전제하므로, 그룹 내 정답이 갈리면 측정이 무의미해진다 → bail.
|
||||
fn check_group_integrity(queries: &[GoldenQuery]) -> Result<()> {
|
||||
use std::collections::BTreeMap;
|
||||
// group -> (대표 정답 집합, 대표 query id)
|
||||
let mut canonical: BTreeMap<&str, (BTreeSet<String>, &str)> = BTreeMap::new();
|
||||
let mut offenders: BTreeSet<String> = BTreeSet::new();
|
||||
for q in queries {
|
||||
let Some(group) = q.group.as_deref() else {
|
||||
continue;
|
||||
};
|
||||
let docs: BTreeSet<String> = q.expected_doc_ids.iter().map(|d| d.0.clone()).collect();
|
||||
match canonical.get(group) {
|
||||
None => {
|
||||
canonical.insert(group, (docs, q.id.as_str()));
|
||||
}
|
||||
Some((expected, _first)) if *expected != docs => {
|
||||
offenders.insert(group.to_string());
|
||||
}
|
||||
Some(_) => {}
|
||||
}
|
||||
}
|
||||
if offenders.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
let list: Vec<String> = offenders.into_iter().collect();
|
||||
Err(anyhow!(
|
||||
"group(s) with divergent expected_doc_ids (same group must share one expected doc set): {}",
|
||||
list.join(", ")
|
||||
))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`BTreeSet`는 파일 상단 `use std::collections::{BTreeSet, HashSet};`에 이미 포함됨(확인). 누락 시 추가.
|
||||
|
||||
- [ ] **Step 5: 테스트 통과 확인**
|
||||
|
||||
Run: `CARGO_TARGET_DIR=/build/out/cargo-target/target cargo test -p kebab-eval -j 4 group > /build/cache/tmp/t1b.txt 2>&1; echo "EXIT=$?"`
|
||||
Expected: `rejects_group_with_divergent_expected_docs` + `accepts_group_with_matching_expected_docs` PASS. EXIT=0.
|
||||
|
||||
- [ ] **Step 6: clippy + 커밋**
|
||||
|
||||
Run: `CARGO_TARGET_DIR=/build/out/cargo-target/target cargo clippy -p kebab-eval --all-targets -j 4 -- -D warnings > /build/cache/tmp/c1.txt 2>&1; echo "EXIT=$?"`
|
||||
Expected: EXIT=0.
|
||||
|
||||
```bash
|
||||
git add crates/kebab-eval/src/types.rs crates/kebab-eval/src/loader.rs
|
||||
git commit -m "feat(eval): GoldenQuery.group + 그룹 정합성 검증 (변형 일관성 기반)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: 변형 일관성 메트릭 모듈 (`variant.rs`)
|
||||
|
||||
**모델:** opus (핵심 로직 — recall@narrow/pool, A/B 분류, 그룹 롤업)
|
||||
|
||||
**Files:**
|
||||
- Create: `crates/kebab-eval/src/variant.rs`
|
||||
- Modify: `crates/kebab-eval/src/lib.rs` (모듈 등록 + re-export)
|
||||
- Test: `crates/kebab-eval/src/variant.rs` (in-module `#[cfg(test)]`)
|
||||
|
||||
- [ ] **Step 1: 모듈 골격 + 타입 작성**
|
||||
|
||||
`crates/kebab-eval/src/variant.rs` 생성:
|
||||
|
||||
```rust
|
||||
//! 변형(paraphrase) 일관성 진단 메트릭.
|
||||
//!
|
||||
//! 같은 의도(`GoldenQuery.group`)의 여러 표현이 같은 정답 문서를 공유한다는
|
||||
//! 전제 아래, 표현마다 검색/답변 품질이 얼마나 출렁이는지를 잰다. 핵심은
|
||||
//! `recall@narrow`(사용자가 보는 top-10) vs `recall@pool`(넓은 후보 폭)의 대비:
|
||||
//!
|
||||
//! - (A) 순위 출렁(`MisRanked`): 정답이 pool엔 있는데 top-10 밖 → near-tie 흡수로 해결 후보.
|
||||
//! - (B) 어휘 격차(`Missing`): 정답이 pool에도 없음 → 쿼리 확장/번역 필요.
|
||||
//!
|
||||
//! 진단 전용. 기존 [`crate::metrics::AggregateMetrics`] 경로는 건드리지 않는다.
|
||||
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use kebab_config::Config;
|
||||
use kebab_core::DocumentId;
|
||||
use kebab_store_sqlite::SqliteStore;
|
||||
|
||||
use crate::types::{GoldenQuery, QueryResult};
|
||||
|
||||
/// 사용자가 실제 보는 답변 context 폭.
|
||||
const NARROW_K: u32 = 10;
|
||||
/// 넓은 후보 폭. recall@pool vs recall@narrow 대비로 A/B를 가른다.
|
||||
/// eval run은 `--k`를 이 값 이상으로 줘서 `hits_top_k`가 pool을 담아야 한다.
|
||||
const POOL_K: u32 = 50;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum VariantClass {
|
||||
/// recall@narrow == 1.0 (정답 전부 top-10 안).
|
||||
Ok,
|
||||
/// recall@pool > recall@narrow (정답이 pool엔 있는데 top-10 밖). (A)
|
||||
MisRanked,
|
||||
/// recall@pool == recall@narrow < 1.0 (못 찾은 정답이 pool에도 없음). (B)
|
||||
Missing,
|
||||
/// 정답 문서 미지정(검증 불가).
|
||||
NoExpected,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct VariantResult {
|
||||
pub query_id: String,
|
||||
pub query: String,
|
||||
pub recall_narrow: f32,
|
||||
pub recall_pool: f32,
|
||||
/// must_contain 통과 여부. RAG 답변(`--with-rag`)이 없으면 `None`.
|
||||
pub answer_ok: Option<bool>,
|
||||
pub class: VariantClass,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct VariantGroupReport {
|
||||
pub group: String,
|
||||
pub variants: Vec<VariantResult>,
|
||||
/// max-min recall_narrow (정답 지정 변형들만). 0 = 완전 일관.
|
||||
pub recall_spread_narrow: f32,
|
||||
pub worst_recall_narrow: f32,
|
||||
/// 모든 변형이 must_contain 통과면 Some(true), 하나라도 실패 Some(false),
|
||||
/// RAG 답변이 전혀 없으면 None.
|
||||
pub answer_consistency: Option<bool>,
|
||||
pub mis_ranked: u32,
|
||||
pub missing: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct VariantConsistencyReport {
|
||||
pub groups: Vec<VariantGroupReport>,
|
||||
pub mean_recall_spread_narrow: f32,
|
||||
/// spread==0 && worst_recall_narrow==1.0 인 그룹 수.
|
||||
pub fully_consistent_groups: u32,
|
||||
pub total_groups: u32,
|
||||
/// mis_ranked>0 && mis_ranked>=missing 인 그룹 수 (near-tie 처방 우선).
|
||||
pub a_dominant_groups: u32,
|
||||
/// missing>0 && missing>mis_ranked 인 그룹 수 (쿼리 확장 처방 우선).
|
||||
pub b_dominant_groups: u32,
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 실패하는 테스트 작성**
|
||||
|
||||
같은 파일 하단에:
|
||||
|
||||
```rust
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use kebab_core::{
|
||||
ChunkId, ChunkerVersion, Citation, IndexVersion, RetrievalDetail, SearchMode, WorkspacePath,
|
||||
ScoreKind,
|
||||
};
|
||||
use kebab_store_sqlite::EvalQueryResultRecord;
|
||||
|
||||
fn hit(doc: &str, rank: u32) -> kebab_core::SearchHit {
|
||||
let path = WorkspacePath::new(format!("{doc}.md")).unwrap();
|
||||
kebab_core::SearchHit {
|
||||
rank,
|
||||
chunk_id: ChunkId(format!("c-{doc}-{rank}")),
|
||||
doc_id: DocumentId(doc.to_string()),
|
||||
doc_path: path.clone(),
|
||||
heading_path: vec![],
|
||||
section_label: None,
|
||||
snippet: String::new(),
|
||||
citation: Citation::Line { path, start: 1, end: 1, section: None },
|
||||
retrieval: RetrievalDetail {
|
||||
method: SearchMode::Vector,
|
||||
fusion_score: 1.0 / rank as f32,
|
||||
lexical_score: None,
|
||||
vector_score: Some(1.0 / rank as f32),
|
||||
lexical_rank: None,
|
||||
vector_rank: Some(rank),
|
||||
},
|
||||
index_version: IndexVersion("v1".into()),
|
||||
embedding_model: None,
|
||||
chunker_version: ChunkerVersion("v1".into()),
|
||||
indexed_at: time::OffsetDateTime::UNIX_EPOCH,
|
||||
stale: false,
|
||||
score_kind: ScoreKind::Cosine,
|
||||
repo: None,
|
||||
code_lang: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn gq(id: &str, group: &str, expected_doc: &str) -> GoldenQuery {
|
||||
GoldenQuery {
|
||||
id: id.into(),
|
||||
query: id.into(),
|
||||
lang: kebab_core::Lang(String::new()),
|
||||
expected_doc_ids: vec![DocumentId(expected_doc.into())],
|
||||
expected_chunk_ids: vec![],
|
||||
must_contain: vec![],
|
||||
forbidden: vec![],
|
||||
difficulty: None,
|
||||
group: Some(group.into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn row(query_id: &str, hits: Vec<kebab_core::SearchHit>) -> EvalQueryResultRecord {
|
||||
let qr = QueryResult {
|
||||
query_id: query_id.into(),
|
||||
query: query_id.into(),
|
||||
mode: SearchMode::Vector,
|
||||
hits_top_k: hits,
|
||||
answer: None,
|
||||
elapsed_ms: 0,
|
||||
error: None,
|
||||
};
|
||||
EvalQueryResultRecord {
|
||||
query_id: query_id.into(),
|
||||
result_json: serde_json::to_string(&qr).unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_mis_ranked_vs_missing_and_spread() {
|
||||
// group "g": 정답 docX.
|
||||
// v1: docX at rank 3 → narrow=1.0 → Ok
|
||||
// v2: docX at rank 25 → narrow=0.0, pool=1.0 → MisRanked (A)
|
||||
// v3: docX 없음 → narrow=0.0, pool=0.0 → Missing (B)
|
||||
let queries = vec![gq("v1", "g", "docX"), gq("v2", "g", "docX"), gq("v3", "g", "docX")];
|
||||
let rows = vec![
|
||||
row("v1", vec![hit("docX", 3)]),
|
||||
row("v2", vec![hit("docX", 25)]),
|
||||
row("v3", vec![hit("other", 1)]),
|
||||
];
|
||||
let rep = compute_variant_consistency(&queries, &rows).unwrap();
|
||||
assert_eq!(rep.total_groups, 1);
|
||||
let g = &rep.groups[0];
|
||||
assert_eq!(g.group, "g");
|
||||
assert_eq!(g.variants.len(), 3);
|
||||
// spread = max(1.0) - min(0.0) = 1.0
|
||||
assert!((g.recall_spread_narrow - 1.0).abs() < 1e-6);
|
||||
assert!((g.worst_recall_narrow - 0.0).abs() < 1e-6);
|
||||
assert_eq!(g.mis_ranked, 1);
|
||||
assert_eq!(g.missing, 1);
|
||||
let classes: Vec<VariantClass> = g.variants.iter().map(|v| v.class).collect();
|
||||
assert!(classes.contains(&VariantClass::Ok));
|
||||
assert!(classes.contains(&VariantClass::MisRanked));
|
||||
assert!(classes.contains(&VariantClass::Missing));
|
||||
assert_eq!(rep.a_dominant_groups + rep.b_dominant_groups, 1); // tie→정의대로 하나로 분류
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fully_consistent_group_when_all_ok() {
|
||||
let queries = vec![gq("v1", "g", "docX"), gq("v2", "g", "docX")];
|
||||
let rows = vec![row("v1", vec![hit("docX", 1)]), row("v2", vec![hit("docX", 2)])];
|
||||
let rep = compute_variant_consistency(&queries, &rows).unwrap();
|
||||
assert_eq!(rep.fully_consistent_groups, 1);
|
||||
assert!((rep.groups[0].recall_spread_narrow - 0.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ungrouped_queries_are_ignored() {
|
||||
let mut q = gq("solo", "g", "docX");
|
||||
q.group = None;
|
||||
let rep = compute_variant_consistency(&[q], &[row("solo", vec![hit("docX", 1)])]).unwrap();
|
||||
assert_eq!(rep.total_groups, 0);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 테스트 실패 확인**
|
||||
|
||||
먼저 `lib.rs`에 모듈 등록(아래 Step 5 일부 선행): `crates/kebab-eval/src/lib.rs`의 모듈 선언부에 `mod variant;` + `pub use variant::{VariantConsistencyReport, VariantGroupReport, VariantResult, VariantClass, compute_variant_consistency, compute_variant_consistency_with_config, render_variants_md};` 추가(아직 함수 미정의 → 다음 스텝에서 채움). 우선 컴파일 통과를 위해 `compute_variant_consistency`만 stub 없이 진행하면 컴파일 에러로 실패함을 확인.
|
||||
|
||||
Run: `CARGO_TARGET_DIR=/build/out/cargo-target/target cargo test -p kebab-eval -j 4 variant > /build/cache/tmp/t2.txt 2>&1; echo "EXIT=$?"`
|
||||
Expected: 컴파일 에러(함수 미정의). 다음 스텝에서 구현.
|
||||
|
||||
- [ ] **Step 4: `compute_variant_consistency` + 헬퍼 구현**
|
||||
|
||||
`variant.rs`의 타입 정의 아래, `#[cfg(test)]` 위에 추가:
|
||||
|
||||
```rust
|
||||
/// 저장된 run을 그룹으로 묶어 변형 일관성 리포트를 만든다.
|
||||
/// `rows`는 [`crate::metrics::aggregate_from_rows`]와 동일한 입력
|
||||
/// (저장된 per-query 결과). `group`이 없는 쿼리는 무시한다.
|
||||
pub fn compute_variant_consistency(
|
||||
queries: &[GoldenQuery],
|
||||
rows: &[kebab_store_sqlite::EvalQueryResultRecord],
|
||||
) -> Result<VariantConsistencyReport> {
|
||||
let golden_by_id: HashMap<&str, &GoldenQuery> =
|
||||
queries.iter().map(|q| (q.id.as_str(), q)).collect();
|
||||
|
||||
let mut grouped: BTreeMap<String, Vec<VariantResult>> = BTreeMap::new();
|
||||
for row in rows {
|
||||
let qr: QueryResult = serde_json::from_str(&row.result_json)
|
||||
.with_context(|| format!("parse result_json for {}", row.query_id))?;
|
||||
let Some(gq) = golden_by_id.get(qr.query_id.as_str()) else {
|
||||
continue;
|
||||
};
|
||||
let Some(group) = gq.group.clone() else {
|
||||
continue;
|
||||
};
|
||||
let (recall_narrow, recall_pool) = recall_narrow_pool(&qr, &gq.expected_doc_ids);
|
||||
let answer_ok = qr.answer.as_ref().map(|a| {
|
||||
gq.must_contain.iter().all(|s| a.answer.contains(s))
|
||||
&& !gq.forbidden.iter().any(|s| a.answer.contains(s))
|
||||
});
|
||||
let class = classify(&gq.expected_doc_ids, recall_narrow, recall_pool);
|
||||
grouped.entry(group).or_default().push(VariantResult {
|
||||
query_id: qr.query_id.clone(),
|
||||
query: qr.query.clone(),
|
||||
recall_narrow,
|
||||
recall_pool,
|
||||
answer_ok,
|
||||
class,
|
||||
});
|
||||
}
|
||||
|
||||
let mut groups: Vec<VariantGroupReport> = Vec::with_capacity(grouped.len());
|
||||
for (group, variants) in grouped {
|
||||
groups.push(rollup_group(group, variants));
|
||||
}
|
||||
|
||||
let total_groups = u32::try_from(groups.len()).unwrap_or(u32::MAX);
|
||||
let fully_consistent_groups = groups
|
||||
.iter()
|
||||
.filter(|g| g.recall_spread_narrow == 0.0 && g.worst_recall_narrow == 1.0)
|
||||
.count() as u32;
|
||||
let a_dominant_groups = groups
|
||||
.iter()
|
||||
.filter(|g| g.mis_ranked > 0 && g.mis_ranked >= g.missing)
|
||||
.count() as u32;
|
||||
let b_dominant_groups = groups
|
||||
.iter()
|
||||
.filter(|g| g.missing > 0 && g.missing > g.mis_ranked)
|
||||
.count() as u32;
|
||||
let mean_recall_spread_narrow = if groups.is_empty() {
|
||||
0.0
|
||||
} else {
|
||||
groups.iter().map(|g| g.recall_spread_narrow).sum::<f32>() / groups.len() as f32
|
||||
};
|
||||
|
||||
Ok(VariantConsistencyReport {
|
||||
groups,
|
||||
mean_recall_spread_narrow,
|
||||
fully_consistent_groups,
|
||||
total_groups,
|
||||
a_dominant_groups,
|
||||
b_dominant_groups,
|
||||
})
|
||||
}
|
||||
|
||||
/// 정답 문서 집합에 대한 recall@NARROW_K, recall@POOL_K.
|
||||
/// 정답 미지정이면 (NaN, NaN).
|
||||
fn recall_narrow_pool(qr: &QueryResult, expected: &[DocumentId]) -> (f32, f32) {
|
||||
if expected.is_empty() {
|
||||
return (f32::NAN, f32::NAN);
|
||||
}
|
||||
let exp: HashSet<&DocumentId> = expected.iter().collect();
|
||||
let cover = |k: u32| -> f32 {
|
||||
let topk: HashSet<&DocumentId> = qr
|
||||
.hits_top_k
|
||||
.iter()
|
||||
.filter(|h| h.rank <= k)
|
||||
.map(|h| &h.doc_id)
|
||||
.collect();
|
||||
exp.iter().filter(|d| topk.contains(*d)).count() as f32 / exp.len() as f32
|
||||
};
|
||||
(cover(NARROW_K), cover(POOL_K))
|
||||
}
|
||||
|
||||
fn classify(expected: &[DocumentId], recall_narrow: f32, recall_pool: f32) -> VariantClass {
|
||||
if expected.is_empty() {
|
||||
VariantClass::NoExpected
|
||||
} else if recall_narrow >= 1.0 {
|
||||
VariantClass::Ok
|
||||
} else if recall_pool > recall_narrow {
|
||||
VariantClass::MisRanked
|
||||
} else {
|
||||
VariantClass::Missing
|
||||
}
|
||||
}
|
||||
|
||||
fn rollup_group(group: String, variants: Vec<VariantResult>) -> VariantGroupReport {
|
||||
let measurable: Vec<f32> = variants
|
||||
.iter()
|
||||
.filter(|v| !v.recall_narrow.is_nan())
|
||||
.map(|v| v.recall_narrow)
|
||||
.collect();
|
||||
let (recall_spread_narrow, worst_recall_narrow) = if measurable.is_empty() {
|
||||
(0.0, f32::NAN)
|
||||
} else {
|
||||
let max = measurable.iter().cloned().fold(f32::MIN, f32::max);
|
||||
let min = measurable.iter().cloned().fold(f32::MAX, f32::min);
|
||||
(max - min, min)
|
||||
};
|
||||
let answer_flags: Vec<bool> = variants.iter().filter_map(|v| v.answer_ok).collect();
|
||||
let answer_consistency = if answer_flags.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(answer_flags.iter().all(|&ok| ok))
|
||||
};
|
||||
let mis_ranked = variants.iter().filter(|v| v.class == VariantClass::MisRanked).count() as u32;
|
||||
let missing = variants.iter().filter(|v| v.class == VariantClass::Missing).count() as u32;
|
||||
VariantGroupReport {
|
||||
group,
|
||||
variants,
|
||||
recall_spread_narrow,
|
||||
worst_recall_narrow,
|
||||
answer_consistency,
|
||||
mis_ranked,
|
||||
missing,
|
||||
}
|
||||
}
|
||||
|
||||
/// 활성 XDG Config로 저장된 run을 읽어 변형 일관성을 계산
|
||||
/// ([`crate::metrics::compute_aggregate_with_config`]와 동일한 로딩 패턴).
|
||||
pub fn compute_variant_consistency_with_config(
|
||||
cfg: &Config,
|
||||
run_id: &str,
|
||||
) -> Result<VariantConsistencyReport> {
|
||||
let store = SqliteStore::open(cfg).context("open SqliteStore for variant consistency")?;
|
||||
store.run_migrations().context("run migrations")?;
|
||||
if store.load_eval_run(run_id).context("load eval_runs row")?.is_none() {
|
||||
anyhow::bail!("compute_variant_consistency: no eval_runs row for run_id {run_id}");
|
||||
}
|
||||
let rows = store
|
||||
.load_eval_query_results(run_id)
|
||||
.context("load eval_query_results")?;
|
||||
let queries = crate::metrics::load_golden_for_metrics_pub()?;
|
||||
compute_variant_consistency(&queries, &rows)
|
||||
}
|
||||
```
|
||||
|
||||
주: `compute_variant_consistency_with_config`는 golden 로드에 `metrics`의 비공개 헬퍼가 필요하다. `crates/kebab-eval/src/metrics.rs`의 `fn load_golden_for_metrics()`를 `pub(crate) fn load_golden_for_metrics_pub()`로 노출하는 얇은 래퍼를 추가하거나, 기존 `load_golden_for_metrics`를 `pub(crate)`로 바꿔 `crate::metrics::load_golden_for_metrics()`로 직접 호출. **후자 채택**: `metrics.rs`의 `fn load_golden_for_metrics`를 `pub(crate) fn load_golden_for_metrics`로 변경하고, 위 호출을 `crate::metrics::load_golden_for_metrics()?`로 수정.
|
||||
|
||||
- [ ] **Step 5: 렌더 함수 + lib.rs 등록**
|
||||
|
||||
`variant.rs`에 사람이 읽는 표 렌더 추가(`#[cfg(test)]` 위):
|
||||
|
||||
```rust
|
||||
/// 변형 일관성 리포트를 사람이 읽는 마크다운 표로 렌더
|
||||
/// ([`crate::render_report_md`] 스타일).
|
||||
pub fn render_variants_md(rep: &VariantConsistencyReport) -> String {
|
||||
use std::fmt::Write;
|
||||
let mut s = String::new();
|
||||
let _ = writeln!(s, "# Variant consistency\n");
|
||||
let _ = writeln!(
|
||||
s,
|
||||
"groups={} fully_consistent={} A_dominant={} B_dominant={} mean_spread@{}={:.3}\n",
|
||||
rep.total_groups,
|
||||
rep.fully_consistent_groups,
|
||||
rep.a_dominant_groups,
|
||||
rep.b_dominant_groups,
|
||||
NARROW_K,
|
||||
rep.mean_recall_spread_narrow,
|
||||
);
|
||||
for g in &rep.groups {
|
||||
let ac = match g.answer_consistency {
|
||||
Some(true) => "all-ok",
|
||||
Some(false) => "MIXED",
|
||||
None => "n/a",
|
||||
};
|
||||
let _ = writeln!(
|
||||
s,
|
||||
"## {} — spread@{}={:.2} worst={:.2} A={} B={} answers={}",
|
||||
g.group, NARROW_K, g.recall_spread_narrow, g.worst_recall_narrow, g.mis_ranked, g.missing, ac
|
||||
);
|
||||
let _ = writeln!(s, "| variant | recall@{NARROW_K} | recall@{POOL_K} | class | answer |");
|
||||
let _ = writeln!(s, "|---|---|---|---|---|");
|
||||
for v in &g.variants {
|
||||
let ans = match v.answer_ok {
|
||||
Some(true) => "ok",
|
||||
Some(false) => "BAD",
|
||||
None => "-",
|
||||
};
|
||||
let _ = writeln!(
|
||||
s,
|
||||
"| {} | {:.2} | {:.2} | {:?} | {} |",
|
||||
v.query, v.recall_narrow, v.recall_pool, v.class, ans
|
||||
);
|
||||
}
|
||||
let _ = writeln!(s);
|
||||
}
|
||||
s
|
||||
}
|
||||
```
|
||||
|
||||
`crates/kebab-eval/src/lib.rs`: 모듈 선언 영역에 `mod variant;` 추가, re-export에 추가:
|
||||
|
||||
```rust
|
||||
pub use variant::{
|
||||
VariantClass, VariantConsistencyReport, VariantGroupReport, VariantResult,
|
||||
compute_variant_consistency, compute_variant_consistency_with_config, render_variants_md,
|
||||
};
|
||||
```
|
||||
|
||||
(기존 `pub use` 패턴은 `lib.rs`에서 `compare`/`metrics` re-export를 보고 맞춤. 정확한 위치/형식은 그 패턴을 따른다.)
|
||||
|
||||
- [ ] **Step 6: 테스트 + clippy 통과 확인**
|
||||
|
||||
Run: `CARGO_TARGET_DIR=/build/out/cargo-target/target cargo test -p kebab-eval -j 4 > /build/cache/tmp/t2b.txt 2>&1; echo "EXIT=$?"`
|
||||
Expected: 3개 신규 variant 테스트 + 기존 테스트 모두 PASS. EXIT=0. (기존 `aggregate` 테스트가 그대로 통과 = group=None 경로 불변 회귀 가드)
|
||||
|
||||
Run: `CARGO_TARGET_DIR=/build/out/cargo-target/target cargo clippy -p kebab-eval --all-targets -j 4 -- -D warnings > /build/cache/tmp/c2.txt 2>&1; echo "EXIT=$?"`
|
||||
Expected: EXIT=0.
|
||||
|
||||
- [ ] **Step 7: 커밋**
|
||||
|
||||
```bash
|
||||
git add crates/kebab-eval/src/variant.rs crates/kebab-eval/src/lib.rs crates/kebab-eval/src/metrics.rs
|
||||
git commit -m "feat(eval): 변형 일관성 메트릭 + A/B(순위출렁/어휘격차) 분류"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: CLI `kebab eval variants <run_id>` 서브커맨드
|
||||
|
||||
**모델:** sonnet (작은 CLI 배선)
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/kebab-cli/src/main.rs` (`EvalWhat` enum ~414 + `Cmd::Eval` 매치 ~1361)
|
||||
- Test: 수동 (Task 5에서 실제 run으로 검증) + 컴파일/clippy
|
||||
|
||||
- [ ] **Step 1: `EvalWhat::Variants` 변형 추가**
|
||||
|
||||
`crates/kebab-cli/src/main.rs`의 `enum EvalWhat`에 `Aggregate` 변형 옆으로 추가 (clap 파생 스타일은 인접 변형을 그대로 따른다):
|
||||
|
||||
```rust
|
||||
/// 변형 그룹 일관성 진단 — 같은 의도의 여러 표현에서 recall@10 vs
|
||||
/// recall@50 대비로 (A)순위출렁/(B)어휘격차를 판별.
|
||||
Variants {
|
||||
/// 진단할 저장된 run_id.
|
||||
run_id: String,
|
||||
/// JSON으로 출력 (기본은 마크다운 표).
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
```
|
||||
|
||||
- [ ] **Step 2: `Cmd::Eval` 매치 암(arm) 추가**
|
||||
|
||||
`Cmd::Eval { what } => { match what { ... } }` 내부, `EvalWhat::Aggregate { .. } => { .. }` 암 다음에:
|
||||
|
||||
```rust
|
||||
EvalWhat::Variants { run_id, json } => {
|
||||
let rep = kebab_eval::compute_variant_consistency_with_config(&cfg, run_id)?;
|
||||
if *json {
|
||||
println!("{}", serde_json::to_string_pretty(&rep)?);
|
||||
} else {
|
||||
print!("{}", kebab_eval::render_variants_md(&rep));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
(`cfg`는 같은 스코프에서 `EvalWhat::Aggregate` 암이 쓰는 것과 동일하게 로드됨 — 그 암의 `cfg` 획득 방식을 그대로 따른다. `run_id`가 `&String`이면 `compute_..._with_config(&cfg, run_id)`로 deref 강제됨; 필요시 `run_id.as_str()`.)
|
||||
|
||||
- [ ] **Step 3: 빌드 + clippy 통과 확인**
|
||||
|
||||
Run: `CARGO_TARGET_DIR=/build/out/cargo-target/target cargo build -p kebab-cli -j 4 > /build/cache/tmp/t3.txt 2>&1; echo "EXIT=$?"`
|
||||
Expected: EXIT=0.
|
||||
|
||||
Run: `CARGO_TARGET_DIR=/build/out/cargo-target/target cargo clippy -p kebab-cli --all-targets -j 4 -- -D warnings > /build/cache/tmp/c3.txt 2>&1; echo "EXIT=$?"`
|
||||
Expected: EXIT=0.
|
||||
|
||||
- [ ] **Step 4: 커밋**
|
||||
|
||||
```bash
|
||||
git add crates/kebab-cli/src/main.rs
|
||||
git commit -m "feat(cli): kebab eval variants <run_id> — 변형 일관성 진단 리포트"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: dogfood golden_queries.yaml 변형 그룹 큐레이션
|
||||
|
||||
**모델:** opus (정답 문서를 corpus 의미로 판정 — 판단 필요)
|
||||
|
||||
**Files:**
|
||||
- Modify: `/build/dogfood/golden_queries.yaml` (in-repo 아님 — dogfood 데이터)
|
||||
|
||||
**큐레이션 원칙 (순환 회피, [[feedback_search_quality_dogfood]]):** 정답 *문서*는 corpus 의미로
|
||||
판정한다. **검색 결과 상위를 정답으로 베끼지 말 것.** 의도에 맞는 문서를 corpus 내용으로 고른 뒤,
|
||||
그 문서의 doc_id/chunk_id를 SQLite에서 조회한다.
|
||||
|
||||
- [ ] **Step 1: 의도(그룹) 6–10개 선정**
|
||||
|
||||
선행 ablation 토픽 재사용 + 동의어/다른어휘/풀어쓴문장 추가. 후보 의도(각 그룹 3–5 표현):
|
||||
|
||||
| group | 표현 예시 (한/영/동의어/풀어쓴문장) |
|
||||
|---|---|
|
||||
| `ownership` | "러스트 소유권" / "rust ownership" / "러스트 메모리 소유권 규칙" / "who owns a value in rust" |
|
||||
| `lifetime` | "러스트 lifetime" / "rust lifetime" / "러스트 수명" / "빌림 검사기 수명" |
|
||||
| `database_index` | "데이터베이스 인덱스" / "database index" / "DB 색인" / "쿼리 빠르게 하는 인덱스" |
|
||||
| `gc` | "가비지 컬렉션" / "garbage collection" / "자동 메모리 회수" |
|
||||
| `async` | "비동기 프로그래밍" / "async programming" / "논블로킹 동시성" |
|
||||
| `kubernetes_deploy` | "쿠버네티스 배포" / "kubernetes deployment" / "k8s 앱 배포" |
|
||||
|
||||
(corpus에 명확한 정답 문서가 없는 의도는 제외. rust류 + 일반 토픽 섞기.)
|
||||
|
||||
- [ ] **Step 2: 각 의도의 정답 문서를 corpus 의미로 판정 + ID 조회**
|
||||
|
||||
dogfood KB(`/build/dogfood/config.toml`)에서, 의도별로 corpus 내용상 그 주제를 다루는 문서를
|
||||
식별한다. doc_id/chunk_id 조회 (release 바이너리):
|
||||
|
||||
```bash
|
||||
BIN=/build/out/cargo-target/target/release/kebab
|
||||
CFG=/build/dogfood/config.toml
|
||||
# 후보 문서를 폭넓게 본 뒤 내용으로 정답 판정 (상위 1개 자동채택 금지):
|
||||
$BIN search "rust ownership" --config $CFG --mode hybrid --k 20 --json --quiet \
|
||||
| python3 -c 'import sys,json; [print(h["doc_id"], h.get("doc_path"), h["chunk_id"]) for h in json.load(sys.stdin)["hits"]]'
|
||||
```
|
||||
|
||||
각 그룹마다: 내용으로 맞는 문서 1–2개의 `doc_id`(+대표 `chunk_id`)를 확정. 같은 그룹의 모든 변형은
|
||||
**동일한 `expected_doc_ids`** 를 갖는다(Task 1의 정합성 검증이 강제).
|
||||
|
||||
- [ ] **Step 3: must_contain 핵심 사실 큐레이션 (그룹 공유)**
|
||||
|
||||
각 그룹에 답변이 반드시 포함해야 할 핵심 substring 1–2개 (정답 문서 내용에서 발췌). 한/영 답변
|
||||
모두에서 성립하는 표현으로 (예: 고유명사·숫자·식별자). 너무 길거나 표현 특정적이면 피한다.
|
||||
|
||||
- [ ] **Step 4: yaml에 그룹 엔트리 추가**
|
||||
|
||||
`/build/dogfood/golden_queries.yaml`에 그룹별로 추가 (기존 dg0xx 엔트리는 유지). 형식:
|
||||
|
||||
```yaml
|
||||
# --- variant groups (paraphrase robustness, 2026-05-29) ---
|
||||
- id: vg_ownership_ko
|
||||
query: "러스트 소유권"
|
||||
lang: ko
|
||||
group: ownership
|
||||
difficulty: medium
|
||||
expected_doc_ids: ["<조회한 doc_id>"]
|
||||
expected_chunk_ids: ["<조회한 chunk_id>"]
|
||||
must_contain: ["<핵심 사실>"]
|
||||
- id: vg_ownership_en
|
||||
query: "rust ownership"
|
||||
lang: en
|
||||
group: ownership
|
||||
difficulty: medium
|
||||
expected_doc_ids: ["<같은 doc_id>"]
|
||||
expected_chunk_ids: ["<같은 chunk_id>"]
|
||||
must_contain: ["<같은 핵심 사실>"]
|
||||
# ... (그룹당 3–5 변형, 그룹 6–10개)
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 로드 검증 (정합성 + ID 실재)**
|
||||
|
||||
release 바이너리로 eval run 시작 직전까지 가서 loader가 통과하는지 확인 (Task 5의 run이 시작 시
|
||||
ID 실재 + 그룹 정합성을 검증 → bail 안 하면 OK). 빠른 단독 검증:
|
||||
|
||||
```bash
|
||||
KEBAB_EVAL_GOLDEN=/build/dogfood/golden_queries.yaml \
|
||||
$BIN eval run --config $CFG --mode hybrid --k 50 --json --quiet > /build/cache/tmp/t4_loadcheck.txt 2>&1
|
||||
echo "EXIT=$?" # 0 또는 run 진행이면 로드 통과; "duplicate"/"divergent"/"missing" 이면 수정
|
||||
```
|
||||
|
||||
(이 run 자체가 Task 5의 측정으로 이어짐 — 여기선 로드 통과만 확인.)
|
||||
|
||||
- [ ] **Step 6: 커밋 불요 (dogfood 데이터)**
|
||||
|
||||
`/build/dogfood/`는 repo 밖. 큐레이션 결과는 Task 5 측정 후 HOTFIXES에 그룹 목록을 요약 기록.
|
||||
|
||||
---
|
||||
|
||||
## Task 5: 측정 실행 + (A)/(B) 진단 리포트
|
||||
|
||||
**모델:** 오케스트레이터(나) 직접 또는 sonnet
|
||||
|
||||
**Files:**
|
||||
- 산출: `/build/cache/tmp/rr_variant_*.txt`, `tasks/HOTFIXES.md`(dated entry), 핸드오프 갱신
|
||||
|
||||
- [ ] **Step 1: release 빌드**
|
||||
|
||||
Run (background): `CARGO_TARGET_DIR=/build/out/cargo-target/target cargo build --release -p kebab-cli -j 4 > /build/cache/tmp/rr_variant_build.txt 2>&1; echo "EXIT=$?"`
|
||||
Expected: EXIT=0. 바이너리 mtime이 갱신됐는지 확인.
|
||||
|
||||
- [ ] **Step 2: eval run (k=50, hybrid + vector, with-rag)**
|
||||
|
||||
```bash
|
||||
BIN=/build/out/cargo-target/target/release/kebab
|
||||
CFG=/build/dogfood/config.toml
|
||||
export KEBAB_EVAL_GOLDEN=/build/dogfood/golden_queries.yaml
|
||||
# 검색 전용(빠름) — recall 진단의 핵심:
|
||||
$BIN eval run --config $CFG --mode hybrid --k 50 > /build/cache/tmp/rr_variant_run_hybrid.txt 2>&1; echo "EXIT=$?"
|
||||
# run_id를 출력에서 추출 (clean grep)
|
||||
```
|
||||
|
||||
`--with-rag`는 answer_consistency가 필요할 때만 (LLM 비용 큼). 1차는 검색 전용으로 recall 기반
|
||||
A/B 진단부터. answer_consistency는 별도 `--with-rag` run으로.
|
||||
|
||||
- [ ] **Step 3: variants 리포트 산출**
|
||||
|
||||
```bash
|
||||
$BIN eval variants <run_id> --config $CFG > /build/cache/tmp/rr_variant_report_hybrid.txt 2>&1; echo "EXIT=$?"
|
||||
$BIN eval variants <run_id> --config $CFG --json > /build/cache/tmp/rr_variant_report_hybrid.json 2>&1; echo "EXIT=$?"
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 결과 Read 검증 + A/B 판정**
|
||||
|
||||
`/build/cache/tmp/rr_variant_report_hybrid.txt`를 Read로 직접 확인 (측정값 추측 절대 금지,
|
||||
[[project_rerank_experiment]] 교훈). 판정:
|
||||
- `a_dominant_groups > b_dominant_groups` → (A) 우세 → Phase 2 처방 = near-tie 흡수.
|
||||
- `b_dominant_groups > a_dominant_groups` → (B) 우세 → Phase 2 처방 = 쿼리 확장/번역.
|
||||
- 혼재면 그룹별로 분리 처방 + 토픽 특성 기록.
|
||||
|
||||
- [ ] **Step 5: HOTFIXES + 핸드오프 기록**
|
||||
|
||||
`tasks/HOTFIXES.md`에 dated entry: 그룹 목록, recall_spread/worst 표, A/B 분류, Phase 2 방향.
|
||||
핸드오프 문서에 측정 결과 + Phase 2 게이트 결정.
|
||||
|
||||
```bash
|
||||
git add tasks/HOTFIXES.md docs/superpowers/handoffs/2026-05-29-crossscript-rerank-progress-handoff.md
|
||||
git commit -m "docs: 변형 일관성 측정 결과 + Phase 2 처방 방향 (A/B 진단)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review (작성자 점검)
|
||||
|
||||
**1. Spec coverage:**
|
||||
- spec §2 Phase 1 "변형 그룹 + 일관성 메트릭 + A/B 판별 + 큐레이션 + 측정" → Task 1(그룹), Task 2(메트릭+A/B), Task 3(surface), Task 4(큐레이션), Task 5(측정). ✓
|
||||
- spec §3 "kebab-eval 단독, AggregateMetrics 불변" → Task 2 Step 6이 기존 테스트 통과로 회귀 가드. ✓
|
||||
- spec §5 "clean 측정 + Read 검증 + baseline이 deliverable" → Task 5 Step 4. ✓
|
||||
- spec §7 미결: group 정합성=bail(Task 1), A/B 임계=classify 정의(Task 2), surface=`eval variants`(Task 3), 큐레이션(Task 4), must_contain(Task 4 Step 3). ✓
|
||||
|
||||
**2. Placeholder scan:** Task 4의 `<조회한 doc_id>` 등은 데이터 큐레이션의 실제 조회 산출물(코드 placeholder 아님). 코드 스텝은 전부 완성 코드. ✓
|
||||
|
||||
**3. Type consistency:** `compute_variant_consistency(queries, rows)` 시그니처가 Task 2 정의 ↔ Task 2 `_with_config` 호출 ↔ Task 3 CLI 호출에서 일치. `VariantConsistencyReport`/`render_variants_md` 이름이 lib.rs re-export(Task 2 Step 5) ↔ CLI(Task 3 Step 2)에서 일치. `EvalQueryResultRecord{query_id, result_json}` 필드가 Task 2 테스트 ↔ 실제 metrics.rs 사용과 일치. ✓
|
||||
|
||||
**의존성 주의:** Task 2가 `metrics::load_golden_for_metrics`를 `pub(crate)`로 승격(Step 4 주석) → 그 변경이 Task 2 커밋에 포함됨(`git add ... metrics.rs`). Task 3는 Task 2의 re-export에 의존 → 순서 준수.
|
||||
397
docs/superpowers/plans/2026-05-30-dense-alias-vectors.md
Normal file
397
docs/superpowers/plans/2026-05-30-dense-alias-vectors.md
Normal file
@@ -0,0 +1,397 @@
|
||||
# 별칭 dense 별도 벡터 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans. Steps use checkbox (`- [ ]`).
|
||||
|
||||
**Goal:** `chunk.aliases`를 별도 dense 벡터(sentinel chunk_id `{orig}#alias`)로 색인해, dense(e5)가 별칭 순수 신호로 설명형 패러프레이즈를 잡게 한다. 본문 벡터 불변(회귀 안전).
|
||||
|
||||
**Architecture:** `ingest.expansion.embed_aliases`(default off) on 이면 별칭을 e5 passage 임베딩 → sentinel chunk_id VectorRecord upsert. VectorRetriever 가 sentinel hit 을 원본 chunk_id 로 strip + dedup(2채널 유지, wire 무변경). purge 가 sentinel 벡터도 정리.
|
||||
|
||||
**Tech Stack:** Rust 2024, fastembed e5, LanceVectorStore(MergeInsert keyed on chunk_id), kebab-core/config/app/search.
|
||||
|
||||
**빌드 규약:** `CARGO_TARGET_DIR=/build/out/cargo-target/target`, `-j 4`. 결과 redirect + `echo "EXIT=$?"` 후 커밋. `cargo|grep` 금지. 브랜치 `feat/doc-side-expansion`(같은 PR).
|
||||
|
||||
**참조 spec:** `docs/superpowers/specs/2026-05-30-dense-alias-vectors-design.md`
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| 파일 | 역할 | Task |
|
||||
|------|------|------|
|
||||
| `crates/kebab-core/src/ids.rs` | `ALIAS_SUFFIX` 상수 + `strip_alias_suffix` 헬퍼 | 1 |
|
||||
| `crates/kebab-config/src/lib.rs` | `IngestExpansionCfg.embed_aliases` + env | 2 |
|
||||
| `crates/kebab-app/src/lib.rs` | ingest 별칭 임베딩 + sentinel VectorRecord + purge sentinel | 3 |
|
||||
| `crates/kebab-search/src/vector.rs` | VectorRetriever sentinel strip + dedup + overfetch↑ | 4 |
|
||||
| `docs/`, dogfood | 측정 + 문서 | 5 |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: `ALIAS_SUFFIX` + `strip_alias_suffix` (kebab-core)
|
||||
|
||||
**Files:** Modify `crates/kebab-core/src/ids.rs` (+ `lib.rs` re-export)
|
||||
|
||||
- [ ] **Step 1: 실패 테스트** — `ids.rs` `#[cfg(test)] mod tests` 에:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn strip_alias_suffix_roundtrip() {
|
||||
assert_eq!(strip_alias_suffix("abc123#alias"), "abc123");
|
||||
assert_eq!(strip_alias_suffix("abc123"), "abc123"); // 접미 없으면 그대로
|
||||
assert_eq!(ALIAS_SUFFIX, "#alias");
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 실패 확인** — `CARGO_TARGET_DIR=/build/out/cargo-target/target cargo test -p kebab-core strip_alias_suffix -j 4 > /tmp/dv-t1.log 2>&1; echo "EXIT=$?"` → 컴파일 실패.
|
||||
|
||||
- [ ] **Step 3: 구현** — `ids.rs` 상단(pub 영역)에:
|
||||
|
||||
```rust
|
||||
/// 별칭 dense 벡터의 sentinel chunk_id 접미. 본문 벡터(원본 chunk_id)와
|
||||
/// 별칭 벡터(`{orig}#alias`)를 LanceDB(chunk_id 키)에서 공존시킨다. ChunkId 는
|
||||
/// blake3 hex(영숫자)라 `#` 미포함 → 충돌 없음. 설계 spec dense-alias-vectors §3.2.
|
||||
pub const ALIAS_SUFFIX: &str = "#alias";
|
||||
|
||||
/// sentinel 별칭 chunk_id 에서 원본 chunk_id 를 복원. 접미 없으면 그대로.
|
||||
pub fn strip_alias_suffix(id: &str) -> &str {
|
||||
id.strip_suffix(ALIAS_SUFFIX).unwrap_or(id)
|
||||
}
|
||||
```
|
||||
|
||||
`crates/kebab-core/src/lib.rs` 의 `ids` re-export 에 `ALIAS_SUFFIX, strip_alias_suffix` 추가
|
||||
(`pub use ids::{... , ALIAS_SUFFIX, strip_alias_suffix};` — 기존 `pub use ids::{...}` 목록에 삽입).
|
||||
|
||||
- [ ] **Step 4: 통과** — `cargo test -p kebab-core strip_alias_suffix -j 4` EXIT=0.
|
||||
|
||||
- [ ] **Step 5: 커밋** — `git add crates/kebab-core && git commit -m "feat(core): ALIAS_SUFFIX + strip_alias_suffix (dense alias vectors)"`
|
||||
|
||||
---
|
||||
|
||||
## Task 2: config `embed_aliases`
|
||||
|
||||
**Files:** Modify `crates/kebab-config/src/lib.rs`
|
||||
|
||||
- [ ] **Step 1: 실패 테스트** — `#[cfg(test)] mod tests` 에:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn embed_aliases_defaults_off() {
|
||||
assert!(!Config::defaults().ingest.expansion.embed_aliases);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embed_aliases_env_override() {
|
||||
let mut cfg = Config::defaults();
|
||||
let env: std::collections::HashMap<String, String> =
|
||||
[("KEBAB_INGEST_EXPANSION_EMBED_ALIASES".to_string(), "true".to_string())]
|
||||
.into_iter().collect();
|
||||
cfg.apply_env(&env);
|
||||
assert!(cfg.ingest.expansion.embed_aliases);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 실패 확인** — `cargo test -p kebab-config embed_aliases -j 4 > /tmp/dv-t2.log 2>&1; echo "EXIT=$?"` → 컴파일 실패.
|
||||
|
||||
- [ ] **Step 3: 구현** — `IngestExpansionCfg` struct 에 필드(기존 `prompt_version` 다음):
|
||||
|
||||
```rust
|
||||
/// 별칭을 dense 벡터로도 색인(별도 sentinel chunk_id). default off.
|
||||
/// `enabled`(별칭 생성)와 별개 축 — 둘 다 on 이어야 dense 별칭. 설계 spec
|
||||
/// dense-alias-vectors §3.3.
|
||||
pub embed_aliases: bool,
|
||||
```
|
||||
|
||||
`impl Default for IngestExpansionCfg` 에 `embed_aliases: false,` 추가. `apply_env` 에:
|
||||
|
||||
```rust
|
||||
"KEBAB_INGEST_EXPANSION_EMBED_ALIASES" => {
|
||||
self.ingest.expansion.embed_aliases = parse_bool(v)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 통과** — `cargo test -p kebab-config -j 4` EXIT=0 (신규 2 + 기존).
|
||||
|
||||
- [ ] **Step 5: 커밋** — `git add crates/kebab-config && git commit -m "feat(config): ingest.expansion.embed_aliases flag (default off)"`
|
||||
|
||||
---
|
||||
|
||||
## Task 3: ingest 별칭 임베딩 + sentinel VectorRecord + purge
|
||||
|
||||
**Files:** Modify `crates/kebab-app/src/lib.rs` (embed 블록 ~1309, purge 함수)
|
||||
|
||||
- [ ] **Step 1: 구현 (embed 블록)** — `if !chunks.is_empty()` 블록(현재 body inputs/records 생성)을 확장. body records 생성 후 별칭 records 를 추가로 만들어 같은 `upsert` 에 합친다:
|
||||
|
||||
기존 body 임베딩(`let inputs = chunks.iter().map(|c| EmbeddingInput{text: c.text.as_str(), ...})` → `vectors` → `records`)은 **그대로**. `vec_store.upsert(&records)` **직전**에 추가:
|
||||
|
||||
```rust
|
||||
// dense 별칭(별도 벡터, sentinel chunk_id). embed_aliases on +
|
||||
// 별칭 있는 청크만. 본문 records 는 위에서 이미 생성됨(불변).
|
||||
let mut all_records = records;
|
||||
if app.config.ingest.expansion.embed_aliases {
|
||||
let alias_chunks: Vec<&kebab_core::Chunk> = chunks
|
||||
.iter()
|
||||
.filter(|c| c.aliases.as_deref().is_some_and(|a| !a.is_empty()))
|
||||
.collect();
|
||||
if !alias_chunks.is_empty() {
|
||||
let alias_inputs: Vec<EmbeddingInput<'_>> = alias_chunks
|
||||
.iter()
|
||||
.map(|c| EmbeddingInput {
|
||||
text: c.aliases.as_deref().unwrap(),
|
||||
kind: EmbeddingKind::Document,
|
||||
})
|
||||
.collect();
|
||||
let alias_vectors = emb
|
||||
.embed(&alias_inputs)
|
||||
.context("Embedder::embed (alias vectors)")?;
|
||||
for (c, v) in alias_chunks.iter().zip(alias_vectors) {
|
||||
let alias_chunk_id = kebab_core::ChunkId(format!(
|
||||
"{}{}",
|
||||
c.chunk_id.0,
|
||||
kebab_core::ALIAS_SUFFIX
|
||||
));
|
||||
all_records.push(VectorRecord {
|
||||
embedding_id: kebab_core::id_for_embedding(
|
||||
&alias_chunk_id, &model_id, &model_version, dimensions,
|
||||
),
|
||||
chunk_id: alias_chunk_id,
|
||||
vector: v,
|
||||
doc_id: canonical.doc_id.clone(),
|
||||
text: c.aliases.clone().unwrap_or_default(),
|
||||
heading_path: c.heading_path.clone(),
|
||||
model_id: model_id.clone(),
|
||||
model_version: model_version.clone(),
|
||||
dimensions,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
vec_store.upsert(&all_records).context("VectorStore::upsert")?;
|
||||
```
|
||||
|
||||
(기존 `vec_store.upsert(&records)` 줄은 위 `upsert(&all_records)` 로 대체 — 중복 upsert 금지.)
|
||||
|
||||
- [ ] **Step 2: 구현 (purge sentinel)** — `purge_vector_orphans_for_workspace_path` 의 `delete_by_chunk_ids(&stale)` 를, stale + sentinel 을 함께 지우도록:
|
||||
|
||||
```rust
|
||||
let mut to_delete = stale.clone();
|
||||
to_delete.extend(stale.iter().map(|id| format!("{}{}", id, kebab_core::ALIAS_SUFFIX)));
|
||||
vec_store
|
||||
.delete_by_chunk_ids(&to_delete)
|
||||
.context("VectorStore::delete_by_chunk_ids (orphan vector cleanup)")?;
|
||||
```
|
||||
|
||||
그리고 `sweep_deleted_files` 의 `purge_deleted_workspace_path` 후 `vec.delete_by_chunk_ids(&chunk_ids)`(있는 곳)도 동일하게 `{id}#alias` 를 포함하도록 확장(해당 위치 `grep -n "delete_by_chunk_ids" crates/kebab-app/src/lib.rs` 로 모두 찾아 sentinel 추가).
|
||||
|
||||
- [ ] **Step 3: 빌드 + 회귀** — `CARGO_TARGET_DIR=/build/out/cargo-target/target cargo build -p kebab-app -j 4 > /tmp/dv-t3.log 2>&1; echo "EXIT=$?"` EXIT=0. `cargo test -p kebab-app -j 4` EXIT=0(embed_aliases off 라 기존 무영향).
|
||||
|
||||
- [ ] **Step 4: 커밋** — `git add crates/kebab-app/src/lib.rs && git commit -m "feat(app): 별칭 dense 별도 벡터 색인 + purge (sentinel)"`
|
||||
|
||||
---
|
||||
|
||||
## Task 4: VectorRetriever sentinel strip + dedup
|
||||
|
||||
**Files:** Modify `crates/kebab-search/src/vector.rs`
|
||||
|
||||
- [ ] **Step 1: 실패 테스트** — `crates/kebab-search/tests/` 의 기존 vector 테스트 패턴 확인(`ls crates/kebab-search/tests/ && grep -rln "VectorRetriever" crates/kebab-search/tests/`). store 에 body + `{orig}#alias` 벡터를 넣고, 별칭 벡터에 가까운 쿼리로 검색 시 결과가 **원본 chunk_id** 1개(중복 없음)인지 검증:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn alias_vector_hit_strips_to_original_and_dedupes() {
|
||||
// store 에 chunk "c1" body 벡터 + "c1#alias" 별칭 벡터. 쿼리가 둘 다 매칭.
|
||||
// 결과: 원본 "c1" 1개 (sentinel strip + dedup).
|
||||
// (기존 vector 테스트 헬퍼로 store fixture 구성 — 벡터/임베딩 mock 패턴 따름.)
|
||||
let hits = retr.search(&q).unwrap();
|
||||
let c1 = hits.iter().filter(|h| h.chunk_id.0 == "c1").count();
|
||||
assert_eq!(c1, 1, "body+alias 둘 다 매칭해도 원본 chunk_id 1개로 dedup");
|
||||
assert!(!hits.iter().any(|h| h.chunk_id.0.ends_with("#alias")),
|
||||
"sentinel chunk_id 가 결과에 노출되면 안 된다");
|
||||
}
|
||||
```
|
||||
|
||||
> 정확한 store fixture(벡터 upsert + embed mock)는 기존 `tests/` 의 VectorRetriever 테스트 패턴을 따른다.
|
||||
|
||||
- [ ] **Step 2: 실패 확인** — `cargo test -p kebab-search alias_vector_hit -j 4 > /tmp/dv-t4.log 2>&1; echo "EXIT=$?"` → 실패(현재 sentinel 노출 + 중복).
|
||||
|
||||
- [ ] **Step 3: 구현** — `vector.rs` `search()`:
|
||||
(a) `VECTOR_OVERFETCH_MULTIPLIER` 를 `2` → `3` (별칭 벡터로 dedup 후 k 미달 방지).
|
||||
(b) raw_hits 순회 루프에서 strip + dedup. 기존:
|
||||
```rust
|
||||
let candidate_ids: Vec<&str> = raw_hits.iter().map(|h| h.chunk_id.0.as_str()).collect();
|
||||
let hydration = hydrate_chunks(&self.sqlite, &candidate_ids)...;
|
||||
...
|
||||
for hit in raw_hits {
|
||||
let Some(meta) = hydration.get(hit.chunk_id.0.as_str()) else { continue; };
|
||||
rank = rank.saturating_add(1);
|
||||
hits.push(build_hit(hit, meta, rank, ...)?);
|
||||
if hits.len() >= k { break; }
|
||||
}
|
||||
```
|
||||
를 다음으로(원본 id 로 hydrate + seen dedup, build_hit 에 strip 된 chunk_id 반영):
|
||||
```rust
|
||||
// sentinel 별칭 hit 을 원본 chunk_id 로 strip 해 hydrate.
|
||||
let candidate_ids: Vec<&str> = raw_hits
|
||||
.iter()
|
||||
.map(|h| kebab_core::strip_alias_suffix(h.chunk_id.0.as_str()))
|
||||
.collect();
|
||||
let hydration = hydrate_chunks(&self.sqlite, &candidate_ids)
|
||||
.context("kb-search vector: hydrate chunk metadata")?;
|
||||
...
|
||||
let model_id = self.embed.model_id();
|
||||
let mut hits: Vec<SearchHit> = Vec::with_capacity(k.min(raw_hits.len()));
|
||||
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||
let mut rank: u32 = 0;
|
||||
for mut hit in raw_hits {
|
||||
let orig = kebab_core::strip_alias_suffix(hit.chunk_id.0.as_str()).to_string();
|
||||
if !seen.insert(orig.clone()) {
|
||||
continue; // 같은 원본이 body+alias 둘 다 → 첫(높은 score) 유지
|
||||
}
|
||||
let Some(meta) = hydration.get(orig.as_str()) else { continue; };
|
||||
// build_hit 이 원본 chunk_id 를 쓰도록 hit 의 chunk_id 를 strip 본으로 교체.
|
||||
hit.chunk_id = kebab_core::ChunkId(orig);
|
||||
rank = rank.saturating_add(1);
|
||||
hits.push(build_hit(hit, meta, rank, &self.index_version, &model_id, self.snippet_chars)?);
|
||||
if hits.len() >= k { break; }
|
||||
}
|
||||
```
|
||||
(`raw_hits` 가 `Vec<VectorHit>` 라 `for mut hit` 가능. `VectorHit.chunk_id` 가 `pub` 인지 확인 — `crates/kebab-core/src/vector.rs:24`. pub 아니면 build_hit 시그니처에 override chunk_id 인자 추가.)
|
||||
|
||||
- [ ] **Step 4: 통과 + 회귀** — `cargo test -p kebab-search -j 4 > /tmp/dv-t4.log 2>&1; echo "EXIT=$?"` EXIT=0 (신규 + 기존 vector/hybrid).
|
||||
|
||||
- [ ] **Step 5: 커밋** — `git add crates/kebab-search/src/vector.rs crates/kebab-search/tests && git commit -m "feat(search): VectorRetriever sentinel 별칭 strip + dedup"`
|
||||
|
||||
---
|
||||
|
||||
## Task 4.5: V0XX — embedding_records FK 제거 (breaking) + CASCADE 대체
|
||||
|
||||
**배경 (spec §3.5)**: sentinel chunk_id 는 chunks 에 없어 `embedding_records.chunk_id REFERENCES
|
||||
chunks(chunk_id) ON DELETE CASCADE`(V001:100) FK 를 위반(SQLite 787) → ingest 에러. SQLite 는 ALTER
|
||||
로 FK 못 지워 테이블 재생성. CASCADE 사라지면 orphan 정리를 명시 DELETE 로 대체.
|
||||
|
||||
**Files:** Create `migrations/V010__drop_embedding_records_fk.sql` (또는 현재 최신 번호+1 확인:
|
||||
`ls migrations/` → 최신이 V010__chunk_aliases.sql 이면 **V011**), Modify `crates/kebab-store-sqlite/src/documents.rs`(put_chunks), `crates/kebab-store-sqlite/src/store.rs`(purge 경로)
|
||||
|
||||
- [ ] **Step 1: 최신 migration 번호 확인** — `ls migrations/`. doc-side expansion 이 V010__chunk_aliases.sql
|
||||
을 추가했으므로 신규는 **V011**. 파일명 `V011__drop_embedding_records_fk.sql`.
|
||||
|
||||
- [ ] **Step 2: migration 작성** — `embedding_records` 를 FK 없이 재생성(V003 의 status/vector_committed
|
||||
컬럼 + 모든 인덱스 보존). FK 외 스키마는 동일:
|
||||
|
||||
```sql
|
||||
-- V011__drop_embedding_records_fk.sql — embedding_records.chunk_id FK 제거.
|
||||
-- sentinel chunk_id({orig}#alias, chunks 에 없는 id) 벡터를 허용하기 위함
|
||||
-- (설계 spec 2026-05-30-dense-alias-vectors-design.md §3.5-1). SQLite 는 ALTER
|
||||
-- 로 FK 제거 불가 → 테이블 재생성. status/vector_committed(V003) + 인덱스 보존.
|
||||
-- CASCADE 제거분은 put_chunks/purge 의 명시 DELETE 로 대체(§3.5-2).
|
||||
PRAGMA foreign_keys=OFF;
|
||||
|
||||
CREATE TABLE embedding_records_new (
|
||||
embedding_id TEXT PRIMARY KEY,
|
||||
chunk_id TEXT NOT NULL, -- FK 제거 (was REFERENCES chunks ON DELETE CASCADE)
|
||||
model_id TEXT NOT NULL,
|
||||
model_version TEXT NOT NULL,
|
||||
dimensions INTEGER NOT NULL,
|
||||
lance_table TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
vector_committed INTEGER NOT NULL DEFAULT 0,
|
||||
UNIQUE(chunk_id, model_id, model_version, dimensions)
|
||||
);
|
||||
INSERT INTO embedding_records_new
|
||||
SELECT embedding_id, chunk_id, model_id, model_version, dimensions,
|
||||
lance_table, created_at, status, vector_committed
|
||||
FROM embedding_records;
|
||||
DROP TABLE embedding_records;
|
||||
ALTER TABLE embedding_records_new RENAME TO embedding_records;
|
||||
CREATE INDEX idx_embed_chunk ON embedding_records(chunk_id);
|
||||
CREATE INDEX idx_embed_model ON embedding_records(model_id, model_version, dimensions);
|
||||
CREATE INDEX idx_embed_status ON embedding_records(status);
|
||||
|
||||
PRAGMA foreign_keys=ON;
|
||||
|
||||
UPDATE kv SET value = CAST(CAST(value AS INTEGER) + 1 AS TEXT) WHERE key = 'corpus_revision';
|
||||
```
|
||||
|
||||
> ⚠️ `chunks_bd_tombstone_embeddings` trigger(V003)는 그대로 유지. FK 제거 후 tombstone 이 실제 보존됨
|
||||
> (CASCADE 가 즉시 안 지움) — 명시 DELETE(Step 3)가 정리를 담당.
|
||||
|
||||
- [ ] **Step 3: CASCADE 대체 — 명시 DELETE** — chunk 삭제 경로에서 embedding_records 를 명시 정리.
|
||||
`crates/kebab-store-sqlite/src/documents.rs` `put_chunks`(DELETE-then-INSERT, 라인 101 `DELETE FROM
|
||||
chunks WHERE doc_id=?` 직전/직후): 해당 doc 의 chunk_id + `{id}#alias` embedding_records 삭제:
|
||||
```rust
|
||||
// CASCADE 제거(V011) 대체: 이 doc 의 chunk 임베딩 레코드를 명시 정리.
|
||||
// 원본 + sentinel({id}#alias) 둘 다. (별칭 벡터는 chunks FK 가 없어 자동 정리 안 됨.)
|
||||
tx.execute(
|
||||
"DELETE FROM embedding_records WHERE chunk_id IN \
|
||||
(SELECT chunk_id FROM chunks WHERE doc_id=?1 \
|
||||
UNION SELECT chunk_id||'#alias' FROM chunks WHERE doc_id=?1)",
|
||||
params![doc.0],
|
||||
).map_err(StoreError::from)?;
|
||||
```
|
||||
(이 DELETE 는 `DELETE FROM chunks` **전에** 실행 — chunks 가 지워지면 서브쿼리가 빈 결과.)
|
||||
`crates/kebab-store-sqlite/src/store.rs` 의 `purge_orphan_at_workspace_path`(라인 ~631 `DELETE FROM
|
||||
documents`)·`purge_deleted_workspace_path` 도 동일하게, chunks 삭제 전 수집한 chunk_id + sentinel 을
|
||||
`DELETE FROM embedding_records WHERE chunk_id IN (...)` 로 정리. (`grep -n "DELETE FROM documents\|DELETE
|
||||
FROM chunks" crates/kebab-store-sqlite/src/store.rs` 로 경로 확인.)
|
||||
|
||||
- [ ] **Step 4: 테스트** — `crates/kebab-store-sqlite/tests/` 에:
|
||||
- sentinel chunk_id embedding_records INSERT 가 FK 위반 없이 성공(V011 후).
|
||||
- put_chunks 재호출 시 기존 embedding_records(원본+sentinel) 정리 → orphan 0.
|
||||
Run: `CARGO_TARGET_DIR=/build/out/cargo-target/target cargo test -p kebab-store-sqlite -j 4 > /tmp/dv-t45.log 2>&1; echo "EXIT=$?"` EXIT=0 + 기존 corpus_revision baseline(V011 bump 로 +1) 갱신 필요 시 갱신.
|
||||
|
||||
- [ ] **Step 5: 커밋** — `git add migrations/V011__drop_embedding_records_fk.sql crates/kebab-store-sqlite && git commit -m "feat(store): V011 embedding_records FK 제거 + CASCADE 대체 명시 DELETE (sentinel 별칭 벡터)"`
|
||||
|
||||
---
|
||||
|
||||
## Task 4.6: filter_chunks sentinel strip
|
||||
|
||||
**배경 (spec §3.5-3)**: `filter_chunks`(filters.rs:81)가 `embedding_records er JOIN chunks c ON
|
||||
c.chunk_id=er.chunk_id WHERE er.status='committed'` 로 LanceDB 후보를 필터. sentinel chunk_id 는 chunks
|
||||
JOIN 에서 버려져 VectorRetriever strip 이전에 탈락. sentinel candidate 를 원본으로 strip 해 JOIN 통과시킴.
|
||||
|
||||
**Files:** Modify `crates/kebab-store-sqlite/src/filters.rs` (`filter_chunks`)
|
||||
|
||||
- [ ] **Step 1: 실패 테스트** — committed 원본 chunk 의 sentinel candidate(`{orig}#alias`)가
|
||||
filter_chunks 결과에 (원본 또는 sentinel 로) 통과하는지. (기존 filters 테스트 패턴 따라.)
|
||||
|
||||
- [ ] **Step 2: 구현** — `filter_chunks(chunk_ids, filters)` 가 candidate `chunk_ids` 중 sentinel
|
||||
(`#alias` 접미)을 **원본으로 strip 해 IN-list/JOIN** 에 넣되, **반환은 입력 candidate 형태(sentinel
|
||||
유지)** 로 — VectorRetriever 가 그 sentinel 을 받아 strip+dedup(Task 4)하기 때문. 즉:
|
||||
- IN-list 바인딩: 각 candidate 를 `strip_alias_suffix` 한 원본 chunk_id 로 JOIN(committed 판정은
|
||||
원본 chunk 기준). 원본이 committed 면 그 candidate(원본 or sentinel) 통과.
|
||||
- 반환: 통과한 **원본 candidate 문자열 그대로**(sentinel 포함) — store.search 가 그대로 VectorRetriever 로.
|
||||
- 구현 주의: 현재 `er.chunk_id IN (?)` 가 candidate 직접 매칭. sentinel 은 embedding_records 에는
|
||||
있으나(V011 후) chunks JOIN 실패. 두 방법 중 택1 — (a) JOIN 을 `c.chunk_id = strip(er.chunk_id)` 로
|
||||
(SQL 에서 `#alias` 제거: `replace(er.chunk_id,'#alias','')` 또는 `rtrim`), 또는 (b) Rust 에서
|
||||
candidate 를 원본으로 strip 해 IN-list 구성 후, 결과를 원본 candidate 와 매핑해 반환. **(b) 권장**
|
||||
(SQL replace 보다 명확). `kebab_core::strip_alias_suffix` 사용.
|
||||
|
||||
- [ ] **Step 3: 테스트 통과 + 회귀** — `cargo test -p kebab-store-sqlite -p kebab-search -j 4 > /tmp/dv-t46.log 2>&1; echo "EXIT=$?"` EXIT=0.
|
||||
|
||||
- [ ] **Step 4: 커밋** — `git add crates/kebab-store-sqlite/src/filters.rs && git commit -m "feat(store): filter_chunks sentinel 별칭 candidate strip (committed 통과)"`
|
||||
|
||||
---
|
||||
|
||||
## Task 5: 측정 + 문서
|
||||
|
||||
- [ ] **Step 1: clippy** — `cargo clippy --workspace --all-targets -j 4 -- -D warnings > /tmp/dv-clippy.log 2>&1; echo "EXIT=$?"` EXIT=0.
|
||||
|
||||
- [ ] **Step 2: 측정** — `.kebabignore`(topics 만) 재작성 → release 빌드 → `KEBAB_INGEST_EXPANSION_ENABLED=true KEBAB_INGEST_EXPANSION_EMBED_ALIASES=true kebab ingest --force-reingest`(topics 재임베딩, 별칭 벡터 생성, ~32분) → `KEBAB_EVAL_GOLDEN=... kebab eval run --mode hybrid --k 50` → `eval variants`. **Read 로 값 확인(추측 금지).**
|
||||
- **효과**: 영어 설명형(mvcc/raft) `recall@50` 0→양수 회복? concat PoC(6/0/2/0.25) 대비 개선?
|
||||
- **회귀**: body 벡터 불변이라 명사형/단일쿼리 회귀 0 확인. 측정 후 `.kebabignore` 삭제.
|
||||
|
||||
- [ ] **Step 3: 문서** — `tasks/HOTFIXES.md` dated entry(lexical 별칭 + dense 별칭 측정 표), README Configuration(`embed_aliases` off 기본), ARCHITECTURE(별칭 dense sentinel 벡터), HANDOFF.
|
||||
|
||||
- [ ] **Step 4: 커밋** — `git add tasks/HOTFIXES.md README.md docs/ARCHITECTURE.md HANDOFF.md && git commit -m "docs: dense 별칭 측정 결과 + 문서 동기화"`
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
- **Spec 커버리지**: §3.2 sentinel→Task1. §3.3 config→Task2, ingest embed→Task3, retriever dedup→Task4, purge→Task3. §5 측정→Task5. §7 테스트→각 Task. ✅
|
||||
- **Placeholder**: Task4 Step1 store fixture 는 "기존 패턴 따름"으로 위임(단언 핵심 명시). VectorHit.chunk_id pub 여부는 "확인 후 분기" 지시. 나머지 완성 코드. ✅
|
||||
- **타입 일관성**: `ALIAS_SUFFIX`/`strip_alias_suffix`(Task1, kebab_core) ↔ ingest(Task3)·retriever(Task4) 사용. `embed_aliases`(Task2 config) ↔ ingest(Task3). VectorRecord 필드(Task3) = 기존 body records 와 동일 구조. ✅
|
||||
|
||||
---
|
||||
|
||||
## Execution Handoff
|
||||
|
||||
OMC teammate(sequential single-team). Task1·2=sonnet(작은), Task3·4=opus(임베딩/retriever 핵심). Task3/4 후 code-reviewer(opus, sentinel dedup·purge 정확성·회귀). Task5 측정은 main 세션 직접.
|
||||
918
docs/superpowers/plans/2026-05-30-doc-side-expansion.md
Normal file
918
docs/superpowers/plans/2026-05-30-doc-side-expansion.md
Normal file
@@ -0,0 +1,918 @@
|
||||
# 색인시 doc-side expansion (검색용 별칭) 구현 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:** 문서 색인 시 각 청크마다 로컬 LLM(gemma)으로 "검색용 별칭"(같은언어 paraphrase + 한↔영 번역)을 1회 생성해 별도 FTS5 테이블에 저장하고, lexical 검색이 본문+별칭을 함께 조회해 어휘격차로 pool 에서 누락되던 정답을 회수한다.
|
||||
|
||||
**Architecture:** 별도 `chunk_aliases_fts` 가상 테이블(기존 `chunks_fts` §5.5 verbatim 블록 무수정) + `chunks.aliases` 컬럼 + 별도 sync trigger. ingest 경로에 flag(`[ingest.expansion]`, default off) 게이트로 `ExpansionGenerator`(LanguageModel trait, mock 가능) hook. 검색은 `LexicalRetriever` 가 본문 쿼리 + 별칭 쿼리 결과를 Rust 에서 병합(body 우선, alias-only append) — `HybridRetriever`/`RetrievalDetail`/wire schema 무변경. 별칭 테이블이 비면 기존과 동일 동작(회귀 안전).
|
||||
|
||||
**Tech Stack:** Rust 2024 workspace, rusqlite + FTS5(unicode61), refinery migrations, `kebab_llm::LanguageModel`(Ollama), `kebab-eval` variants 측정.
|
||||
|
||||
**빌드/테스트 규약 (모든 Run 스텝에 적용):**
|
||||
- `CARGO_TARGET_DIR=/build/out/cargo-target/target`, `-j 4`(OOM 시 `-j 1`).
|
||||
- 결과를 파일로 redirect + exit code 확인 후 커밋. `cargo ... | grep` 금지(pipe exit 마스킹).
|
||||
- 예: `CARGO_TARGET_DIR=/build/out/cargo-target/target cargo test -p kebab-store-sqlite -j 4 > /tmp/t.log 2>&1; echo "EXIT=$?"` → 파일에서 EXIT + 결과 확인.
|
||||
|
||||
**참조 spec:** `docs/superpowers/specs/2026-05-30-doc-side-expansion-design.md`
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| 파일 | 역할 | Task |
|
||||
|------|------|------|
|
||||
| `crates/kebab-core/src/chunk.rs` | `Chunk.aliases: Option<String>` 필드 | 1 |
|
||||
| `migrations/V010__chunk_aliases.sql` | `chunks.aliases` 컬럼 + `chunk_aliases_fts` + trigger 3종 | 2 |
|
||||
| `crates/kebab-store-sqlite/src/documents.rs` | `put_chunks` INSERT 에 `aliases` 컬럼 | 2 |
|
||||
| `crates/kebab-store-sqlite/tests/` | migration + put/get + trigger 동기화 테스트 | 2 |
|
||||
| `crates/kebab-config/src/lib.rs` | `IngestExpansionCfg` + default + env override | 3 |
|
||||
| `crates/kebab-app/src/expansion.rs` (Create) | `ExpansionGenerator` — 프롬프트·파싱·상한·fail-soft | 4 |
|
||||
| `crates/kebab-app/src/lib.rs` | ingest hook (flag 게이트, chunk 직후) | 5 |
|
||||
| `crates/kebab-search/src/lexical.rs` | `run_alias_query` + body/alias 병합 + 컬럼 파라미터화 | 6 |
|
||||
| README / HANDOFF / ARCHITECTURE / HOTFIXES / release-notes | 문서 동기화 + 측정 기록 | 7 |
|
||||
|
||||
각 Task 는 자체로 컴파일·테스트 통과하는 단위다. Task 6 까지 끝나면 flag on 시 end-to-end 동작, Task 7 은 측정/문서.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: `Chunk.aliases` 필드 추가
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/kebab-core/src/chunk.rs:16-31`
|
||||
- Test: 동 파일 인라인(또는 기존 core 테스트) — 직렬화 default 확인
|
||||
|
||||
- [ ] **Step 1: 실패 테스트 작성**
|
||||
|
||||
`crates/kebab-core/src/chunk.rs` 하단에 `#[cfg(test)]` 모듈(없으면 신설):
|
||||
|
||||
```rust
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn aliases_defaults_to_none_on_deserialize() {
|
||||
// aliases 필드가 없는 과거 JSON 도 파싱되어야 한다 (#[serde(default)]).
|
||||
let json = r#"{
|
||||
"chunk_id": "c1",
|
||||
"doc_id": "d1",
|
||||
"block_ids": [],
|
||||
"text": "hello",
|
||||
"heading_path": [],
|
||||
"source_spans": [],
|
||||
"token_estimate": 1,
|
||||
"chunker_version": "md-heading-v1",
|
||||
"policy_hash": "abc"
|
||||
}"#;
|
||||
let c: Chunk = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(c.aliases, None);
|
||||
assert_eq!(c.tokenized_korean_text, None);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 실패 확인**
|
||||
|
||||
Run: `CARGO_TARGET_DIR=/build/out/cargo-target/target cargo test -p kebab-core aliases_defaults -j 4 > /tmp/t1.log 2>&1; echo "EXIT=$?"`
|
||||
Expected: 컴파일 실패 — `Chunk` 에 `aliases` 필드 없음 (`no field 'aliases'`).
|
||||
|
||||
- [ ] **Step 3: 필드 추가**
|
||||
|
||||
`crates/kebab-core/src/chunk.rs` 의 `Chunk` 구조체에서 `tokenized_korean_text` 바로 아래에 추가:
|
||||
|
||||
```rust
|
||||
#[serde(default)]
|
||||
pub tokenized_korean_text: Option<String>,
|
||||
/// 색인시 doc-side expansion (Phase 2) 으로 생성된 "검색용 별칭"
|
||||
/// (같은언어 paraphrase + 한↔영 번역, 개행 join). `[ingest.expansion]`
|
||||
/// flag off 또는 미생성이면 None — 별도 FTS5 테이블 `chunk_aliases_fts`
|
||||
/// 에만 색인되고 본문 매칭/dense 임베딩에는 영향 없음. 설계 spec
|
||||
/// `2026-05-30-doc-side-expansion-design.md` §3.3.
|
||||
#[serde(default)]
|
||||
pub aliases: Option<String>,
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 통과 확인 + 컴파일 영향 점검**
|
||||
|
||||
`Chunk` 를 리터럴로 만드는 곳이 `aliases` 누락으로 깨질 수 있다. 점검:
|
||||
|
||||
Run: `cd /home/altair823/kebab && grep -rn "Chunk {" crates --include=*.rs | grep -v "test" | head -30`
|
||||
|
||||
각 생성 지점에 `aliases: None,` 추가(특히 `crates/kebab-chunk*`/chunker). 그 후:
|
||||
|
||||
Run: `CARGO_TARGET_DIR=/build/out/cargo-target/target cargo test -p kebab-core aliases_defaults -j 4 > /tmp/t1.log 2>&1; echo "EXIT=$?"`
|
||||
Expected: PASS. 이어서 워크스페이스 컴파일 확인:
|
||||
Run: `CARGO_TARGET_DIR=/build/out/cargo-target/target cargo build -p kebab-chunk -p kebab-store-sqlite -j 4 > /tmp/t1b.log 2>&1; echo "EXIT=$?"`
|
||||
Expected: EXIT=0 (chunker 가 `aliases: None` 으로 컴파일).
|
||||
|
||||
- [ ] **Step 5: 커밋**
|
||||
|
||||
```bash
|
||||
git add crates/kebab-core/src/chunk.rs crates
|
||||
git commit -m "feat(core): Chunk.aliases 필드 (doc-side expansion)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: V010 migration + `put_chunks` 별칭 영속화
|
||||
|
||||
**Files:**
|
||||
- Create: `migrations/V010__chunk_aliases.sql`
|
||||
- Modify: `crates/kebab-store-sqlite/src/documents.rs:103-140` (`put_chunks` INSERT)
|
||||
- Test: `crates/kebab-store-sqlite/tests/` (기존 `fts.rs` 패턴 따라 신규 `chunk_aliases.rs` 또는 기존 파일에 추가)
|
||||
|
||||
- [ ] **Step 1: migration 작성**
|
||||
|
||||
`migrations/V010__chunk_aliases.sql` 생성 — 기존 `chunks_fts`/`chunks_ai/ad/au`(§5.5 verbatim CI 대상)는 **건드리지 않는다**:
|
||||
|
||||
```sql
|
||||
-- V010__chunk_aliases.sql — doc-side expansion (Phase 2) 검색용 별칭 채널.
|
||||
--
|
||||
-- 설계 spec docs/superpowers/specs/2026-05-30-doc-side-expansion-design.md §4.
|
||||
-- chunks 에 nullable `aliases` 컬럼 + 별도 FTS5 테이블 chunk_aliases_fts +
|
||||
-- 별도 sync trigger. 기존 chunks_fts / chunks_ai/ad/au (design §5.5 verbatim,
|
||||
-- CI test fts_v009_matches_design_section_5_5_verbatim) 는 무수정.
|
||||
-- aliases 는 additive: 미생성/flag off 이면 NULL → chunk_aliases_fts 빈 채로
|
||||
-- 시작, 검색 UNION 둘째 절 0행 → 기존 동작과 동일. 자동 backfill 없음.
|
||||
|
||||
ALTER TABLE chunks ADD COLUMN aliases TEXT;
|
||||
|
||||
CREATE VIRTUAL TABLE chunk_aliases_fts USING fts5(
|
||||
chunk_id UNINDEXED,
|
||||
doc_id UNINDEXED,
|
||||
aliases,
|
||||
tokenize = 'unicode61'
|
||||
);
|
||||
|
||||
CREATE TRIGGER chunk_aliases_ai AFTER INSERT ON chunks WHEN new.aliases IS NOT NULL BEGIN
|
||||
INSERT INTO chunk_aliases_fts(chunk_id, doc_id, aliases)
|
||||
VALUES (new.chunk_id, new.doc_id, new.aliases);
|
||||
END;
|
||||
CREATE TRIGGER chunk_aliases_ad AFTER DELETE ON chunks BEGIN
|
||||
DELETE FROM chunk_aliases_fts WHERE chunk_id = old.chunk_id;
|
||||
END;
|
||||
CREATE TRIGGER chunk_aliases_au AFTER UPDATE ON chunks BEGIN
|
||||
DELETE FROM chunk_aliases_fts WHERE chunk_id = old.chunk_id;
|
||||
INSERT INTO chunk_aliases_fts(chunk_id, doc_id, aliases)
|
||||
SELECT new.chunk_id, new.doc_id, new.aliases WHERE new.aliases IS NOT NULL;
|
||||
END;
|
||||
|
||||
-- in-process LRU search cache 무효화 (V009 와 동일 패턴).
|
||||
UPDATE kv SET value = CAST(CAST(value AS INTEGER) + 1 AS TEXT) WHERE key = 'corpus_revision';
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 실패 테스트 작성**
|
||||
|
||||
먼저 기존 store 테스트가 임시 SqliteStore 를 어떻게 여는지 확인:
|
||||
Run: `cd /home/altair823/kebab && sed -n '1,60p' crates/kebab-store-sqlite/tests/fts.rs`
|
||||
|
||||
그 헬퍼 패턴(보통 `SqliteStore::open(tempfile)` 가 모든 migration 적용)을 따라 `crates/kebab-store-sqlite/tests/chunk_aliases.rs` 생성. `put_chunks` 로 `aliases=Some(..)` 청크를 저장하면 `chunk_aliases_fts` MATCH 로 회수되고, `aliases=None` 이면 안 들어가는지 검증:
|
||||
|
||||
```rust
|
||||
// 기존 fts.rs 의 store 오픈 + Chunk 생성 헬퍼를 동일하게 재사용/복제할 것.
|
||||
// 아래는 검증 핵심부 — 헬퍼 시그니처는 fts.rs 실제 코드에 맞춘다.
|
||||
use kebab_core::{Chunk, ChunkId, ChunkerVersion, DocumentId};
|
||||
|
||||
#[test]
|
||||
fn aliases_indexed_into_chunk_aliases_fts() {
|
||||
let store = open_temp_store_with_one_document(); // fts.rs 헬퍼 패턴
|
||||
let doc = DocumentId("d1".into());
|
||||
let chunk = Chunk {
|
||||
chunk_id: ChunkId("c1".into()),
|
||||
doc_id: doc.clone(),
|
||||
block_ids: vec![],
|
||||
text: "Rust ownership and borrowing".into(),
|
||||
heading_path: vec![],
|
||||
source_spans: vec![],
|
||||
token_estimate: 5,
|
||||
chunker_version: ChunkerVersion("md-heading-v1".into()),
|
||||
policy_hash: "h".into(),
|
||||
tokenized_korean_text: None,
|
||||
aliases: Some("메모리 안전성\nwho owns the value".into()),
|
||||
};
|
||||
store.put_chunks(&doc, &[chunk]).unwrap();
|
||||
|
||||
let conn = store.read_conn();
|
||||
// 별칭에만 있는 한국어 term 으로 chunk_aliases_fts 검색 → c1 회수.
|
||||
let n: i64 = conn
|
||||
.query_row(
|
||||
"SELECT count(*) FROM chunk_aliases_fts WHERE chunk_aliases_fts MATCH 'aliases : (\"메모리\")'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(n, 1, "aliases 의 한국어 term 이 chunk_aliases_fts 에 색인돼야 한다");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn none_aliases_not_indexed() {
|
||||
let store = open_temp_store_with_one_document();
|
||||
let doc = DocumentId("d1".into());
|
||||
let chunk = Chunk { /* 위와 동일하되 */ aliases: None, ..base_chunk("c1", &doc) };
|
||||
store.put_chunks(&doc, &[chunk]).unwrap();
|
||||
let conn = store.read_conn();
|
||||
let n: i64 = conn
|
||||
.query_row("SELECT count(*) FROM chunk_aliases_fts", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(n, 0, "aliases=None 이면 chunk_aliases_fts 에 행이 없어야 한다");
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 실패 확인**
|
||||
|
||||
Run: `CARGO_TARGET_DIR=/build/out/cargo-target/target cargo test -p kebab-store-sqlite --test chunk_aliases -j 4 > /tmp/t2.log 2>&1; echo "EXIT=$?"`
|
||||
Expected: 실패 — `put_chunks` INSERT 에 `aliases` 컬럼이 없어 `chunk_aliases_fts` 가 비어 있음 (또는 SQL 컬럼 수 불일치). 파일에서 실패 사유 확인.
|
||||
|
||||
- [ ] **Step 4: `put_chunks` 수정**
|
||||
|
||||
`crates/kebab-store-sqlite/src/documents.rs` 의 INSERT 문(라인 103-110)과 `stmt.execute`(126-139) 에 `aliases` 컬럼 추가:
|
||||
|
||||
```rust
|
||||
let mut stmt = tx
|
||||
.prepare(
|
||||
"INSERT INTO chunks (
|
||||
chunk_id, doc_id, text, heading_path_json,
|
||||
section_label, source_spans_json, token_estimate,
|
||||
chunker_version, policy_hash, block_ids_json, created_at,
|
||||
tokenized_korean_text, aliases
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.map_err(StoreError::from)?;
|
||||
```
|
||||
|
||||
`stmt.execute(params![ ... ])` 의 마지막(`chunk.tokenized_korean_text.as_deref(),`) 다음에:
|
||||
|
||||
```rust
|
||||
chunk.tokenized_korean_text.as_deref(),
|
||||
chunk.aliases.as_deref(),
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 통과 확인**
|
||||
|
||||
Run: `CARGO_TARGET_DIR=/build/out/cargo-target/target cargo test -p kebab-store-sqlite -j 4 > /tmp/t2.log 2>&1; echo "EXIT=$?"`
|
||||
Expected: EXIT=0, `aliases_indexed_into_chunk_aliases_fts` + `none_aliases_not_indexed` PASS, 기존 store 테스트 전부 PASS(특히 `fts_v009_matches_design_section_5_5_verbatim` — V010 이 §5.5 블록을 안 건드리므로 그대로 통과해야 함). 파일에서 통과 수 확인.
|
||||
|
||||
- [ ] **Step 6: 커밋**
|
||||
|
||||
```bash
|
||||
git add migrations/V010__chunk_aliases.sql crates/kebab-store-sqlite
|
||||
git commit -m "feat(store): V010 chunk_aliases_fts + put_chunks 별칭 영속화"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: `[ingest.expansion]` config
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/kebab-config/src/lib.rs` (`IngestCfg` 확장 + `IngestExpansionCfg` + `defaults()` + `apply_env`)
|
||||
- Test: 동 crate 인라인 테스트
|
||||
|
||||
- [ ] **Step 1: 실패 테스트 작성**
|
||||
|
||||
`crates/kebab-config/src/lib.rs` 의 기존 `#[cfg(test)] mod tests` 에 추가(없으면 신설):
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn expansion_defaults_off() {
|
||||
let cfg = Config::defaults();
|
||||
assert!(!cfg.ingest.expansion.enabled, "expansion 은 기본 off");
|
||||
assert_eq!(cfg.ingest.expansion.max_aliases_per_chunk, 8);
|
||||
assert_eq!(cfg.ingest.expansion.prompt_version, "expansion-v1");
|
||||
// model 비면 models.llm.model 로 폴백할 수 있게 빈 문자열 default.
|
||||
assert_eq!(cfg.ingest.expansion.model, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expansion_env_override() {
|
||||
let mut cfg = Config::defaults();
|
||||
let env: std::collections::HashMap<String, String> = [
|
||||
("KEBAB_INGEST_EXPANSION_ENABLED".to_string(), "true".to_string()),
|
||||
("KEBAB_INGEST_EXPANSION_MAX_ALIASES".to_string(), "12".to_string()),
|
||||
("KEBAB_INGEST_EXPANSION_MODEL".to_string(), "gemma4:e4b".to_string()),
|
||||
("KEBAB_INGEST_EXPANSION_PROMPT_VERSION".to_string(), "expansion-v2".to_string()),
|
||||
]
|
||||
.into_iter()
|
||||
.collect();
|
||||
cfg.apply_env(&env);
|
||||
assert!(cfg.ingest.expansion.enabled);
|
||||
assert_eq!(cfg.ingest.expansion.max_aliases_per_chunk, 12);
|
||||
assert_eq!(cfg.ingest.expansion.model, "gemma4:e4b");
|
||||
assert_eq!(cfg.ingest.expansion.prompt_version, "expansion-v2");
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 실패 확인**
|
||||
|
||||
Run: `CARGO_TARGET_DIR=/build/out/cargo-target/target cargo test -p kebab-config expansion_ -j 4 > /tmp/t3.log 2>&1; echo "EXIT=$?"`
|
||||
Expected: 컴파일 실패 — `ingest.expansion` 필드 없음.
|
||||
|
||||
- [ ] **Step 3: 구조체 + default + env 추가**
|
||||
|
||||
(3a) `IngestCfg`(라인 ~596) 에 필드 추가:
|
||||
|
||||
```rust
|
||||
pub struct IngestCfg {
|
||||
pub code: IngestCodeCfg,
|
||||
#[serde(default)]
|
||||
pub expansion: IngestExpansionCfg,
|
||||
}
|
||||
```
|
||||
|
||||
(3b) `IngestCodeCfg` 정의 아래에 신규 구조체:
|
||||
|
||||
```rust
|
||||
/// Phase 2 doc-side expansion: 색인시 LLM 으로 청크당 "검색용 별칭"
|
||||
/// (같은언어 paraphrase + 한↔영 번역) 1회 생성. 별도 chunk_aliases_fts
|
||||
/// 채널에 저장, lexical 검색이 본문+별칭 병합. default off (additive).
|
||||
/// 설계 spec 2026-05-30-doc-side-expansion-design.md §3.2.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct IngestExpansionCfg {
|
||||
/// 색인시 별칭 생성 활성화. off 면 chunks.aliases=NULL (기존 동작).
|
||||
pub enabled: bool,
|
||||
/// 별칭 생성에 쓸 LLM 모델. 빈 문자열이면 `models.llm.model` 로 폴백.
|
||||
pub model: String,
|
||||
/// 청크당 별칭 최대 개수(상한). 초과분 drop.
|
||||
pub max_aliases_per_chunk: usize,
|
||||
/// 프롬프트 버전(추적용). 변경 시 재생성 대상 식별.
|
||||
pub prompt_version: String,
|
||||
}
|
||||
|
||||
impl Default for IngestExpansionCfg {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
model: String::new(),
|
||||
max_aliases_per_chunk: 8,
|
||||
prompt_version: "expansion-v1".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
(3c) `Config::defaults()` 의 `ingest: IngestCfg::default(),` 는 이미 `IngestCfg::default()` 를 쓰므로(라인 716) — `IngestCfg` 가 `Default` 파생인지 확인. 만약 `IngestCfg` 가 수동 default 면 `expansion: IngestExpansionCfg::default()` 추가. (확인: `grep -n "impl Default for IngestCfg\|derive.*Default.*\n.*struct IngestCfg" crates/kebab-config/src/lib.rs`)
|
||||
|
||||
(3d) `apply_env`(라인 ~861-1090) 에 env 키 추가. 기존 `parse_bool` 헬퍼 사용:
|
||||
|
||||
```rust
|
||||
"KEBAB_INGEST_EXPANSION_ENABLED" => self.ingest.expansion.enabled = parse_bool(v),
|
||||
"KEBAB_INGEST_EXPANSION_MODEL" => self.ingest.expansion.model = v.clone(),
|
||||
"KEBAB_INGEST_EXPANSION_MAX_ALIASES" => {
|
||||
if let Ok(n) = v.parse::<usize>() {
|
||||
self.ingest.expansion.max_aliases_per_chunk = n;
|
||||
}
|
||||
}
|
||||
"KEBAB_INGEST_EXPANSION_PROMPT_VERSION" => {
|
||||
self.ingest.expansion.prompt_version = v.clone()
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 통과 확인**
|
||||
|
||||
Run: `CARGO_TARGET_DIR=/build/out/cargo-target/target cargo test -p kebab-config -j 4 > /tmp/t3.log 2>&1; echo "EXIT=$?"`
|
||||
Expected: EXIT=0, `expansion_defaults_off` + `expansion_env_override` PASS, 기존 config 테스트 전부 PASS.
|
||||
|
||||
- [ ] **Step 5: 커밋**
|
||||
|
||||
```bash
|
||||
git add crates/kebab-config
|
||||
git commit -m "feat(config): [ingest.expansion] flag (default off)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: `ExpansionGenerator`
|
||||
|
||||
**Files:**
|
||||
- Create: `crates/kebab-app/src/expansion.rs`
|
||||
- Modify: `crates/kebab-app/src/lib.rs` (`mod expansion;` 선언)
|
||||
- Modify: `crates/kebab-app/Cargo.toml` ([dev-dependencies] 에 `kebab-llm` 의 `mock` feature)
|
||||
- Test: `crates/kebab-app/src/expansion.rs` 인라인
|
||||
|
||||
`LanguageModel::generate_stream(req) -> Iterator<Result<TokenChunk>>` 를 모아 문자열로 합치고, 줄 단위 파싱 → trim → 빈 줄/과길이(>120 chars) drop → 상한 N → 개행 join. LLM 호출 실패/빈 결과 시 `None`(fail-soft).
|
||||
|
||||
- [ ] **Step 1: 실패 테스트 작성**
|
||||
|
||||
`crates/kebab-app/src/expansion.rs` 생성:
|
||||
|
||||
```rust
|
||||
//! 색인시 doc-side expansion (Phase 2) — 청크당 "검색용 별칭" 생성.
|
||||
//!
|
||||
//! 설계 spec docs/superpowers/specs/2026-05-30-doc-side-expansion-design.md §3.2 / §5.
|
||||
|
||||
use kebab_core::{Chunk, GenerateRequest, LanguageModel};
|
||||
|
||||
/// 별칭 1줄의 최대 글자 수(이 이상은 문장형/환각으로 보고 drop).
|
||||
const MAX_ALIAS_CHARS: usize = 120;
|
||||
|
||||
/// 청크당 검색용 별칭을 생성한다.
|
||||
///
|
||||
/// 반환: 검증·상한 적용된 별칭들을 개행 join 한 문자열. 생성 0개 / LLM
|
||||
/// 실패 / 빈 출력이면 `None` (호출측은 chunk.aliases 를 None 으로 두고 진행).
|
||||
pub struct ExpansionGenerator<'a> {
|
||||
llm: &'a dyn LanguageModel,
|
||||
max_aliases: usize,
|
||||
}
|
||||
|
||||
impl<'a> ExpansionGenerator<'a> {
|
||||
pub fn new(llm: &'a dyn LanguageModel, max_aliases: usize) -> Self {
|
||||
Self { llm, max_aliases }
|
||||
}
|
||||
|
||||
/// gemma 프롬프트(expansion-v1)를 구성한다.
|
||||
fn build_request(&self, chunk: &Chunk) -> GenerateRequest {
|
||||
let heading = chunk.heading_path.join(" > ");
|
||||
let system = "당신은 검색 색인용 별칭 생성기다. 주어진 문단을 찾을 사용자가 \
|
||||
입력할 법한 짧은 검색어/질문을 생성한다. 동의어·풀어쓴 표현을 포함하라. \
|
||||
문단이 한국어면 영어 표현도, 영어면 한국어 표현도 섞어라. \
|
||||
한 줄에 하나씩, 설명·번호·머리기호 없이 검색어만 출력하라."
|
||||
.to_string();
|
||||
let user = format!(
|
||||
"제목 경로: {heading}\n\n문단:\n{}\n\n검색 별칭(한 줄에 하나):",
|
||||
chunk.text
|
||||
);
|
||||
GenerateRequest {
|
||||
system,
|
||||
user,
|
||||
stop: vec![],
|
||||
max_tokens: 256,
|
||||
temperature: 0.0,
|
||||
seed: Some(0),
|
||||
images: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn generate(&self, chunk: &Chunk) -> Option<String> {
|
||||
let req = self.build_request(chunk);
|
||||
let raw = match self.llm.generate_stream(req) {
|
||||
Ok(iter) => {
|
||||
let mut acc = String::new();
|
||||
for ch in iter {
|
||||
match ch {
|
||||
Ok(kebab_core::TokenChunk::Token(t)) => acc.push_str(&t),
|
||||
Ok(kebab_core::TokenChunk::Done { .. }) => {}
|
||||
Err(_) => return None, // fail-soft
|
||||
}
|
||||
}
|
||||
acc
|
||||
}
|
||||
Err(_) => return None, // fail-soft (connection refused 등)
|
||||
};
|
||||
let aliases = parse_aliases(&raw, self.max_aliases);
|
||||
if aliases.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(aliases.join("\n"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// LLM 출력 문자열 → 검증된 별칭 리스트.
|
||||
/// 줄 단위 split → trim → 번호/머리기호 접두 제거 → 빈 줄·과길이 drop →
|
||||
/// 중복 제거 → 상한 N.
|
||||
fn parse_aliases(raw: &str, max_aliases: usize) -> Vec<String> {
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
for line in raw.lines() {
|
||||
let t = line.trim();
|
||||
// 번호("1." "1)") / 머리기호("- " "* ") 접두 제거.
|
||||
let t = t
|
||||
.trim_start_matches(|c: char| c.is_ascii_digit() || c == '.' || c == ')' || c == '-' || c == '*')
|
||||
.trim();
|
||||
if t.is_empty() || t.chars().count() > MAX_ALIAS_CHARS {
|
||||
continue;
|
||||
}
|
||||
let s = t.to_string();
|
||||
if !out.contains(&s) {
|
||||
out.push(s);
|
||||
}
|
||||
if out.len() >= max_aliases {
|
||||
break;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use kebab_core::{ChunkId, ChunkerVersion, DocumentId, FinishReason, TokenUsage};
|
||||
use kebab_llm::MockLanguageModel;
|
||||
|
||||
fn mk_chunk(text: &str) -> Chunk {
|
||||
Chunk {
|
||||
chunk_id: ChunkId("c1".into()),
|
||||
doc_id: DocumentId("d1".into()),
|
||||
block_ids: vec![],
|
||||
text: text.into(),
|
||||
heading_path: vec!["Guide".into()],
|
||||
source_spans: vec![],
|
||||
token_estimate: 3,
|
||||
chunker_version: ChunkerVersion("md-heading-v1".into()),
|
||||
policy_hash: "h".into(),
|
||||
tokenized_korean_text: None,
|
||||
aliases: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn mock(resp: &str) -> MockLanguageModel {
|
||||
MockLanguageModel {
|
||||
model_id: "gemma4:e4b".into(),
|
||||
provider: "ollama".into(),
|
||||
context_tokens: 32768,
|
||||
canned_response: resp.into(),
|
||||
canned_finish: FinishReason::Stop,
|
||||
canned_usage: TokenUsage { prompt_tokens: 0, completion_tokens: 0 },
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_lines_strips_bullets_and_caps() {
|
||||
let llm = mock("- 메모리 안전성\n1. who owns the value\nborrow checker\n\n* 소유권");
|
||||
let gen = ExpansionGenerator::new(&llm, 2);
|
||||
let out = gen.generate(&mk_chunk("Rust ownership")).unwrap();
|
||||
// 상한 2 → 앞 2개만, 접두 제거됨.
|
||||
assert_eq!(out, "메모리 안전성\nwho owns the value");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drops_overlong_lines() {
|
||||
let long = "x".repeat(200);
|
||||
let llm = mock(&format!("{long}\n짧은 별칭"));
|
||||
let gen = ExpansionGenerator::new(&llm, 8);
|
||||
let out = gen.generate(&mk_chunk("t")).unwrap();
|
||||
assert_eq!(out, "짧은 별칭", "120자 초과 줄은 drop");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_output_returns_none() {
|
||||
let llm = mock(" \n\n");
|
||||
let gen = ExpansionGenerator::new(&llm, 8);
|
||||
assert_eq!(gen.generate(&mk_chunk("t")), None);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 모듈 선언 + dev-dep**
|
||||
|
||||
`crates/kebab-app/src/lib.rs` 상단 모듈 선언부에 `mod expansion;` 추가(필요 시 `pub mod`).
|
||||
`crates/kebab-app/Cargo.toml` 의 `[dev-dependencies]` 에 mock feature 활성화(이미 kebab-llm 의존 시):
|
||||
|
||||
```toml
|
||||
[dev-dependencies]
|
||||
kebab-llm = { workspace = true, features = ["mock"] }
|
||||
```
|
||||
|
||||
(확인: `grep -n "kebab-llm" crates/kebab-app/Cargo.toml`. 이미 `[dependencies]` 에 있으면 dev-dep 에서 features 만 추가하거나, `[dev-dependencies]` 줄 신설.)
|
||||
|
||||
- [ ] **Step 3: 실패 확인**
|
||||
|
||||
Run: `CARGO_TARGET_DIR=/build/out/cargo-target/target cargo test -p kebab-app expansion:: -j 4 > /tmp/t4.log 2>&1; echo "EXIT=$?"`
|
||||
Expected: 위 구현이 이미 들어 있으면 PASS 할 수도 있으나, mock feature/모듈 선언 누락 시 컴파일 실패. 파일에서 사유 확인 후 Step 2 보완.
|
||||
|
||||
- [ ] **Step 4: 통과 확인**
|
||||
|
||||
Run: `CARGO_TARGET_DIR=/build/out/cargo-target/target cargo test -p kebab-app expansion:: -j 4 > /tmp/t4.log 2>&1; echo "EXIT=$?"`
|
||||
Expected: EXIT=0, 3개 테스트 PASS.
|
||||
|
||||
- [ ] **Step 5: 커밋**
|
||||
|
||||
```bash
|
||||
git add crates/kebab-app/src/expansion.rs crates/kebab-app/src/lib.rs crates/kebab-app/Cargo.toml
|
||||
git commit -m "feat(app): ExpansionGenerator — 청크당 별칭 생성 (fail-soft)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: ingest hook (flag 게이트)
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/kebab-app/src/lib.rs` (ingest 진입부에 expansion LLM 빌드 ~388-400 근방; `ingest_one_asset` chunk 직후 ~1253)
|
||||
|
||||
`OllamaLanguageModel` 은 `kebab-llm-local` 의 타입. caption_llm 패턴(라인 394-400) 을 그대로 따른다. expansion LLM 은 `ingest_one_asset` 까지 전달돼야 하므로, caption 처럼 ingest 함수 시그니처/호출 체인을 따라 내려보낸다(`ingest_one_asset` 가 `app` 을 받으므로 `app.config.ingest.expansion` 으로 분기하고 LLM 을 함수 내에서 빌드하는 게 가장 단순 — per-asset 빌드 비용은 무시 가능하지만, 더 깔끔히 하려면 ingest 루프 밖에서 1회 빌드해 `&dyn LanguageModel` 로 전달).
|
||||
|
||||
> **구현 노트(executor 판단):** 우선 가장 단순한 형태 — `ingest_one_asset` 내부에서 `app.config.ingest.expansion.enabled` 이면 LLM 1회 빌드 후 청크 루프. caption_llm 처럼 ingest 루프 밖 1회 빌드가 가능하면 그쪽이 낫다(LLM 핸들 재사용). 단 **테스트 가능성**을 위해 별칭 부여 로직은 Task 4 의 `ExpansionGenerator` 에 이미 격리돼 있으므로, 여기선 "flag 분기 + 청크 루프 + chunk.aliases 세팅"만 한다.
|
||||
|
||||
- [ ] **Step 1: hook 코드 작성**
|
||||
|
||||
`crates/kebab-app/src/lib.rs` 의 `ingest_one_asset` 에서 chunk 생성 직후(라인 1253-1255 의 `let chunks = ...?;` 다음, 버전 스탬핑 전후), `chunks` 를 `mut` 로 바꾸고 추가:
|
||||
|
||||
```rust
|
||||
let mut chunks = MdHeadingV1Chunker
|
||||
.chunk(&canonical, chunk_policy)
|
||||
.context("kb-chunk::MdHeadingV1Chunker::chunk")?;
|
||||
|
||||
// Phase 2 doc-side expansion: flag on 이면 청크당 별칭 생성 (fail-soft).
|
||||
// 설계 spec 2026-05-30-doc-side-expansion-design.md §3.1.
|
||||
if app.config.ingest.expansion.enabled {
|
||||
let exp = &app.config.ingest.expansion;
|
||||
let model = if exp.model.is_empty() {
|
||||
app.config.models.llm.model.clone()
|
||||
} else {
|
||||
exp.model.clone()
|
||||
};
|
||||
match kebab_llm_local::OllamaLanguageModel::with_model(&app.config, &model) {
|
||||
Ok(llm) => {
|
||||
let generator =
|
||||
crate::expansion::ExpansionGenerator::new(&llm, exp.max_aliases_per_chunk);
|
||||
for chunk in &mut chunks {
|
||||
chunk.aliases = generator.generate(chunk);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
// fail-soft: 별칭 없이 색인 진행 (본문 검색은 정상).
|
||||
tracing::warn!(
|
||||
target: "kebab-app",
|
||||
error = %e,
|
||||
"kb-app::ingest: expansion LLM 빌드 실패 — 별칭 없이 진행"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> `OllamaLanguageModel::with_model(&config, &model)` 가 없으면 — `OllamaLanguageModel::new(&config)`(config.models.llm.model 사용) 로 폴백하고, model override 가 필요하면 `kebab-llm-local` 에 `with_model` 생성자를 추가한다. 확인: `grep -n "impl OllamaLanguageModel\|pub fn new\|pub fn with" crates/kebab-llm-local/src/ollama.rs`. override 가 과하면 1차는 `new(&app.config)` 만 쓰고 `exp.model` 은 무시(spec §3.2 의 model 폴백 동작은 Task 7 에서 README 에 "현재 models.llm 사용"으로 명시) — **executor 가 실제 생성자 확인 후 결정**.
|
||||
|
||||
- [ ] **Step 2: 컴파일 + 회귀 확인**
|
||||
|
||||
Run: `CARGO_TARGET_DIR=/build/out/cargo-target/target cargo build -p kebab-app -j 4 > /tmp/t5.log 2>&1; echo "EXIT=$?"`
|
||||
Expected: EXIT=0. 실패 시 생성자 시그니처(위 노트) 보정.
|
||||
|
||||
Run: `CARGO_TARGET_DIR=/build/out/cargo-target/target cargo test -p kebab-app -j 4 > /tmp/t5b.log 2>&1; echo "EXIT=$?"`
|
||||
Expected: EXIT=0 (flag default off 라 기존 ingest 테스트 무영향).
|
||||
|
||||
- [ ] **Step 3: 통합 테스트 (flag on, mock 불가 시 생략 가능)**
|
||||
|
||||
실제 Ollama 가 필요하므로 단위 테스트로는 검증이 어렵다. 대신 flag off 회귀만 단위로 보장하고, flag on end-to-end 는 Task 7 의 dogfood 측정에서 검증한다. (이 Step 은 "flag off 시 chunk.aliases 가 None 으로 유지됨"을 보장하는 기존 테스트로 충분 — 추가 테스트 불필요.)
|
||||
|
||||
- [ ] **Step 4: 커밋**
|
||||
|
||||
```bash
|
||||
git add crates/kebab-app/src/lib.rs
|
||||
git commit -m "feat(app): ingest 별칭 생성 hook (flag off 기본, fail-soft)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: `LexicalRetriever` body+alias 병합 검색
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/kebab-search/src/lexical.rs` (`build_match_string` 컬럼 파라미터화, `run_alias_query` 추가, `search()` 병합)
|
||||
- Test: `crates/kebab-search/tests/` (기존 lexical 통합 테스트 패턴) 또는 lexical.rs 인라인
|
||||
|
||||
핵심: `build_match_string` 은 현재 `text : (...)` 컬럼 필터를 반환. alias 검색은 `aliases : (...)` 가 필요하므로 컬럼명을 파라미터화한다. `search()` 는 body 결과(`run_query`) + alias 결과(`run_alias_query`)를 병합 — **body 우선, alias-only 를 뒤에 append**, `chunk_aliases_fts` 가 비면 alias 결과 0 → 기존과 동일.
|
||||
|
||||
- [ ] **Step 1: 실패 테스트 작성**
|
||||
|
||||
`crates/kebab-search/tests/` 의 기존 lexical 테스트가 store 를 어떻게 채우는지 확인:
|
||||
Run: `cd /home/altair823/kebab && ls crates/kebab-search/tests/ && grep -rln "LexicalRetriever" crates/kebab-search/tests/`
|
||||
|
||||
그 패턴으로, **본문에 없고 별칭에만 있는 term** 으로 검색 시 해당 청크가 회수되는 테스트 작성(핵심 pool-rescue 회귀):
|
||||
|
||||
```rust
|
||||
// 헬퍼(store 오픈 + put_chunks)는 기존 테스트 패턴 재사용.
|
||||
#[test]
|
||||
fn alias_only_term_recalls_chunk() {
|
||||
let store = /* temp store + 1 document */;
|
||||
// 본문엔 "backpropagation" 만, 별칭에 "역전파" 추가.
|
||||
let chunk = Chunk {
|
||||
/* ... */
|
||||
text: "backpropagation computes gradients".into(),
|
||||
aliases: Some("역전파\n신경망 오차 역전달".into()),
|
||||
/* ... */
|
||||
};
|
||||
store.put_chunks(&doc, &[chunk]).unwrap();
|
||||
|
||||
let retr = LexicalRetriever::with_settings(store.clone(), IndexVersion("v1".into()), 220);
|
||||
// 본문에 없는 한국어로 검색 → 별칭 덕에 회수돼야 한다.
|
||||
let q = SearchQuery { text: "역전파".into(), mode: SearchMode::Lexical, k: 10, filters: Default::default() };
|
||||
let hits = retr.search(&q).unwrap();
|
||||
assert!(hits.iter().any(|h| h.chunk_id.0 == "c1"),
|
||||
"별칭에만 있는 term 으로도 청크가 회수돼야 한다 (pool-rescue)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_aliases_table_matches_baseline() {
|
||||
// aliases 전부 None → chunk_aliases_fts 빈 상태 → 본문 검색 결과가
|
||||
// 별칭 도입 전과 동일해야 한다 (회귀 안전).
|
||||
let store = /* temp store, aliases=None 청크들 */;
|
||||
let retr = LexicalRetriever::with_settings(store, IndexVersion("v1".into()), 220);
|
||||
let q = SearchQuery { text: "ownership".into(), mode: SearchMode::Lexical, k: 10, filters: Default::default() };
|
||||
let hits = retr.search(&q).unwrap();
|
||||
// 본문 매칭 청크가 정상 회수 (별칭 경로가 결과를 바꾸지 않음).
|
||||
assert!(hits.iter().any(|h| h.chunk_id.0 == "c1"));
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 실패 확인**
|
||||
|
||||
Run: `CARGO_TARGET_DIR=/build/out/cargo-target/target cargo test -p kebab-search alias_only_term_recalls -j 4 > /tmp/t6.log 2>&1; echo "EXIT=$?"`
|
||||
Expected: 실패 — 현재 `search()` 는 본문(`chunks_fts`)만 보므로 별칭-only term 회수 0.
|
||||
|
||||
- [ ] **Step 3: `build_match_string` 컬럼 파라미터화**
|
||||
|
||||
`build_match_string` 의 마지막 줄 `Some(format!("text : ({expression})"))` 을 컬럼 인자로:
|
||||
|
||||
```rust
|
||||
fn build_match_string(text: &str) -> Option<String> {
|
||||
build_match_string_for_column(text, "text")
|
||||
}
|
||||
|
||||
/// `column` 은 FTS5 컬럼 필터 prefix ("text" 또는 "aliases").
|
||||
fn build_match_string_for_column(text: &str, column: &str) -> Option<String> {
|
||||
// ... 기존 본문 (whole_candidate / token_and_candidate / expression) 그대로 ...
|
||||
Some(format!("{column} : ({expression})"))
|
||||
}
|
||||
```
|
||||
|
||||
(기존 `build_match_string("rust cargo")` 테스트는 `text : (...)` 를 기대하므로 그대로 통과.)
|
||||
|
||||
- [ ] **Step 4: `run_alias_query` 추가**
|
||||
|
||||
`run_query` 아래에 별칭 전용 쿼리. 필터는 1차에선 미적용(별칭 회수가 목적; 측정 후 필요 시 공유)하되, snippet 은 `chunks.text` 앞부분으로 대체:
|
||||
|
||||
```rust
|
||||
/// chunk_aliases_fts 를 검색해 RawRow 를 만든다. snippet 은 별칭이 아닌
|
||||
/// 본문(c.text) 앞부분으로 채워 UI 일관성 유지. chunk_aliases_fts 가 비면
|
||||
/// 0행 반환(회귀 안전). 1차는 filters 미적용 — body 쪽에서 필터가 적용되고,
|
||||
/// 별칭 경로는 pool 진입이 목적(측정 후 필요 시 filters 공유).
|
||||
fn run_alias_query(
|
||||
conn: &Connection,
|
||||
match_str: &str,
|
||||
snippet_chars: usize,
|
||||
fetch_limit: usize,
|
||||
) -> Result<Vec<RawRow>> {
|
||||
let sql = "SELECT \
|
||||
af.chunk_id, af.doc_id, \
|
||||
bm25(chunk_aliases_fts) AS score, \
|
||||
substr(c.text, 1, ?) AS snippet, \
|
||||
c.heading_path_json, c.section_label, c.source_spans_json, \
|
||||
c.chunker_version, \
|
||||
d.workspace_path, d.updated_at \
|
||||
FROM chunk_aliases_fts af \
|
||||
JOIN chunks c ON c.chunk_id = af.chunk_id \
|
||||
JOIN documents d ON d.doc_id = af.doc_id \
|
||||
WHERE chunk_aliases_fts MATCH ? \
|
||||
ORDER BY score, af.chunk_id LIMIT ?";
|
||||
let mut stmt = conn
|
||||
.prepare(sql)
|
||||
.context("kb-search lexical: prepare alias FTS5 statement")?;
|
||||
let rows = stmt
|
||||
.query_map(
|
||||
params_from_iter(vec![
|
||||
Box::new(snippet_chars as i64) as Box<dyn ToSql>,
|
||||
Box::new(match_str.to_owned()),
|
||||
Box::new(i64::try_from(fetch_limit).unwrap_or(i64::MAX)),
|
||||
]
|
||||
.iter()
|
||||
.map(std::convert::AsRef::as_ref)),
|
||||
row_from_sql,
|
||||
)
|
||||
.context("kb-search lexical: execute alias FTS5 query")?;
|
||||
let mut out = Vec::new();
|
||||
for r in rows {
|
||||
out.push(r.context("kb-search lexical: read alias row")?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: `search()` 에서 병합**
|
||||
|
||||
`LexicalRetriever::search` 에서 `run_query` 호출 직후, body+alias 병합. 기존:
|
||||
|
||||
```rust
|
||||
let raw_rows = run_query(&conn, &match_str, self.snippet_words, filters, fetch_limit)?;
|
||||
```
|
||||
|
||||
를 다음으로 교체:
|
||||
|
||||
```rust
|
||||
let body_rows = run_query(&conn, &match_str, self.snippet_words, filters, fetch_limit)?;
|
||||
// 별칭 채널: 같은 query 를 aliases 컬럼 필터로 다시 매칭. 테이블이
|
||||
// 비면 0행 → body_rows 그대로(회귀 안전). body 우선, alias-only append.
|
||||
let alias_rows = match build_match_string_for_column(&query.text, "aliases") {
|
||||
Some(am) => run_alias_query(&conn, &am, self.snippet_chars, fetch_limit)?,
|
||||
None => Vec::new(),
|
||||
};
|
||||
let raw_rows = merge_body_alias(body_rows, alias_rows, fetch_limit);
|
||||
```
|
||||
|
||||
병합 헬퍼 추가(`run_alias_query` 아래):
|
||||
|
||||
```rust
|
||||
/// body 결과 우선, body 에 없는 alias-only 청크를 뒤에 append. fetch_limit
|
||||
/// 로 절단. body_rows 는 이미 bm25 오름차순; alias_rows 도 그러하므로
|
||||
/// alias-only 부분도 별칭 적합도 순으로 들어간다.
|
||||
fn merge_body_alias(body: Vec<RawRow>, alias: Vec<RawRow>, limit: usize) -> Vec<RawRow> {
|
||||
use std::collections::HashSet;
|
||||
let mut seen: HashSet<String> = body.iter().map(|r| r.chunk_id.clone()).collect();
|
||||
let mut out = body;
|
||||
for r in alias {
|
||||
if out.len() >= limit {
|
||||
break;
|
||||
}
|
||||
if seen.insert(r.chunk_id.clone()) {
|
||||
out.push(r);
|
||||
}
|
||||
}
|
||||
out.truncate(limit);
|
||||
out
|
||||
}
|
||||
```
|
||||
|
||||
> `query.text` 가 `search()` 스코프에 있는지 확인(있음 — `match_opt = build_match_string(&query.text)`). `self.snippet_chars` 필드도 존재(LexicalRetriever 구조체).
|
||||
|
||||
- [ ] **Step 6: 통과 + 전체 회귀 확인**
|
||||
|
||||
Run: `CARGO_TARGET_DIR=/build/out/cargo-target/target cargo test -p kebab-search -j 4 > /tmp/t6.log 2>&1; echo "EXIT=$?"`
|
||||
Expected: EXIT=0, `alias_only_term_recalls_chunk` + `empty_aliases_table_matches_baseline` PASS, 기존 lexical/hybrid 테스트 전부 PASS(`build_match_string_default_emits_or_of_phrase_and_and` 포함 — `text : (...)` 유지).
|
||||
|
||||
- [ ] **Step 7: 커밋**
|
||||
|
||||
```bash
|
||||
git add crates/kebab-search/src/lexical.rs crates/kebab-search/tests
|
||||
git commit -m "feat(search): lexical body+alias 병합 검색 (pool-rescue)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: 측정 + 문서 동기화
|
||||
|
||||
**Files:**
|
||||
- 측정: dogfood KB (`/build/dogfood`)
|
||||
- Modify: `README.md`, `HANDOFF.md`, `docs/ARCHITECTURE.md`, `tasks/HOTFIXES.md`, `docs/release-notes/v<X.Y.Z>-draft.md`
|
||||
|
||||
- [ ] **Step 1: 전체 빌드 + clippy 게이트**
|
||||
|
||||
Run: `CARGO_TARGET_DIR=/build/out/cargo-target/target cargo build --release -j 4 > /tmp/t7build.log 2>&1; echo "EXIT=$?"`
|
||||
Run: `CARGO_TARGET_DIR=/build/out/cargo-target/target cargo clippy --workspace --all-targets -j 4 -- -D warnings > /tmp/t7clippy.log 2>&1; echo "EXIT=$?"`
|
||||
Expected: 둘 다 EXIT=0. 파일에서 확인.
|
||||
|
||||
- [ ] **Step 2: baseline (flag off) 측정**
|
||||
|
||||
`/build/dogfood/config.toml` 의 `[ingest.expansion]` 미설정(=off) 상태. dogfood KB 가 V010 migration 을 받도록 한 번 ingest(또는 reset+reingest — pristine 필요 시):
|
||||
|
||||
```
|
||||
KEBAB_EVAL_GOLDEN=/build/dogfood/golden_queries.yaml \
|
||||
/build/out/cargo-target/target/release/kebab eval run --config /build/dogfood/config.toml --mode hybrid --k 50 > /tmp/t7-off-run.log 2>&1; echo "EXIT=$?"
|
||||
# run_id 추출 (Read 로 확인 — 추측 금지)
|
||||
KEBAB_EVAL_GOLDEN=/build/dogfood/golden_queries.yaml \
|
||||
/build/out/cargo-target/target/release/kebab eval variants <run_id> --config /build/dogfood/config.toml > /tmp/t7-off-var.log 2>&1; echo "EXIT=$?"
|
||||
```
|
||||
|
||||
`/tmp/t7-off-var.log` 를 **Read 로 열어** `groups / fully_consistent / A_dominant / B_dominant / spread@10` 값을 그대로 기록. (Phase 1 baseline: `groups=8 fully_consistent=2 A_dominant=2 B_dominant=4 spread@10=0.750` 와 대조.)
|
||||
|
||||
- [ ] **Step 3: 처방 (flag on) 측정**
|
||||
|
||||
`/build/dogfood/config.toml` 에 추가:
|
||||
|
||||
```toml
|
||||
[ingest.expansion]
|
||||
enabled = true
|
||||
max_aliases_per_chunk = 8
|
||||
```
|
||||
|
||||
reset + reingest (별칭 생성 — Ollama gemma 필요, 시간 소요. 진행은 `kebab ingest` ndjson 으로 확인):
|
||||
|
||||
```
|
||||
/build/out/cargo-target/target/release/kebab reset --config /build/dogfood/config.toml --yes > /tmp/t7-reset.log 2>&1; echo "EXIT=$?"
|
||||
/build/out/cargo-target/target/release/kebab ingest --config /build/dogfood/config.toml > /tmp/t7-ingest.log 2>&1; echo "EXIT=$?"
|
||||
KEBAB_EVAL_GOLDEN=/build/dogfood/golden_queries.yaml \
|
||||
/build/out/cargo-target/target/release/kebab eval run --config /build/dogfood/config.toml --mode hybrid --k 50 > /tmp/t7-on-run.log 2>&1; echo "EXIT=$?"
|
||||
KEBAB_EVAL_GOLDEN=/build/dogfood/golden_queries.yaml \
|
||||
/build/out/cargo-target/target/release/kebab eval variants <run_id> --config /build/dogfood/config.toml > /tmp/t7-on-var.log 2>&1; echo "EXIT=$?"
|
||||
```
|
||||
|
||||
`/tmp/t7-on-var.log` 를 Read 로 열어 값 기록. **성공 기준**: B_dominant↓ / fully_consistent↑ / spread@10↓ (off 대비). 회귀: 기존 Ok 그룹이 깨지지 않는지.
|
||||
|
||||
> ⚠️ 측정값 추측 금지([[feedback_search_quality_dogfood]]). grep clean 추출 + Read 확인값만 기록. 효과가 없거나 음수면 — spec §2 의 가설(KO↔EN 별칭이 우리 corpus 에서 recall 회복) 반증으로 보고, HOTFIXES 에 기록 후 사용자와 다음 단계 상의(default off 유지, 또는 프롬프트/단위 조정 재측정).
|
||||
|
||||
- [ ] **Step 4: 문서 동기화**
|
||||
|
||||
- `README.md`: **Configuration** 에 `[ingest.expansion]`(off 기본) 한 줄 + "별칭 생성은 색인 시간을 늘리며 Ollama LLM 필요" 포인터. flag 망라는 config 예제/`--help` 위임.
|
||||
- `docs/ARCHITECTURE.md`: ingest 파이프라인에 expansion hook + `chunk_aliases_fts` 채널 1~2줄. lexical 병합 검색 언급.
|
||||
- `HANDOFF.md`: "머지 후 발견된 버그/결정" 에 Phase 2 doc-side expansion 한 줄(측정 결과 요약).
|
||||
- `tasks/HOTFIXES.md`: dated entry(2026-05-30 이후) — V010, 측정 표(off vs on), known limitation(필터 미적용 등).
|
||||
- `docs/release-notes/v<X.Y.Z>-draft.md`: V010 breaking schema → 4단락(변경/trade-off/mitigation/upgrade). 측정 evidence link.
|
||||
|
||||
- [ ] **Step 5: 커밋**
|
||||
|
||||
```bash
|
||||
git add README.md docs/ARCHITECTURE.md HANDOFF.md tasks/HOTFIXES.md docs/release-notes
|
||||
git commit -m "docs: doc-side expansion 측정 결과 + 문서 동기화 (V010)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review (작성자 체크 — plan 검토)
|
||||
|
||||
- **Spec 커버리지:** §2 결정(D1~D4)→Task 4·5(청크당, 내용)·Task 1·2·5(additive)·Task 4(단순 품질). §3 아키텍처→Task 2(별도 테이블)·Task 6(lexical 병합). §4 스키마→Task 2. §5 프롬프트→Task 4. §6 versioning(try_skip 미변경)→Task 5 가 별칭 부재를 skip 판단에 안 넣음(기존 try_skip_unchanged 무수정). §7 측정→Task 7. §8 YAGNI(3채널/sparse/필터 제외)→plan 에 미포함(의도적). §9 테스트→각 Task TDD. §10 PR/문서→Task 7. ✅
|
||||
- **Placeholder 스캔:** Task 5 의 `OllamaLanguageModel::with_model` / dev-dep 줄은 "executor 가 실제 시그니처 확인 후 결정" 노트로 명시(미정이 아니라 분기 지시). Task 1 Step 4 / Task 6 Step 1 의 `grep` 은 주변 코드 확인 지시(완성 코드 자체는 제시). ✅
|
||||
- **타입 일관성:** `Chunk.aliases`(Task1) ↔ put_chunks(Task2) ↔ ExpansionGenerator.generate→Option<String>(Task4) ↔ ingest hook `chunk.aliases = generator.generate(chunk)`(Task5). `build_match_string_for_column`(Task6 Step3) ↔ search() 호출(Step5). `RawRow`/`row_from_sql`/`build_hit` 재사용(Task6). ✅
|
||||
- **알려진 리스크:** Task 6 의 body/alias bm25 스케일 차이로 lexical 내부 순서가 근사 — hybrid 가 rank 변환하므로 pool 진입(핵심)은 보장, 정밀 순위는 측정 후. Task 5 end-to-end 는 Ollama 필요라 단위 테스트 불가 → Task 7 dogfood 로 검증.
|
||||
|
||||
---
|
||||
|
||||
## Execution Handoff
|
||||
|
||||
이 plan 은 핸드오프 §4.2 의 **OMC teammate(sequential single-team)** 로 task 별 구현 → code-reviewer 리뷰 → 독립 검증한다. Task 1~6 은 코드(executor), Task 7 은 측정+문서. 모델 라우팅(§4.3): Task 2·4·6(핵심 로직)=opus, Task 1·3·5(작은 변경)=sonnet, 리뷰는 핵심=opus.
|
||||
1188
docs/superpowers/plans/2026-05-31-config-migration.md
Normal file
1188
docs/superpowers/plans/2026-05-31-config-migration.md
Normal file
File diff suppressed because it is too large
Load Diff
33
docs/superpowers/plans/2026-06-03-arctic-embedder-plan.md
Normal file
33
docs/superpowers/plans/2026-06-03-arctic-embedder-plan.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# Plan: arctic-embed-l-v2.0 임베더 통합 구현
|
||||
|
||||
spec: `docs/superpowers/specs/2026-06-03-arctic-embedder-spec.md`. 브랜치 `feat/arctic-embedder`. 빌드 `CARGO_TARGET_DIR=/build/out/cargo-target`, `-j 4`(전체 test `-j 1`). cli 통합테스트용 `target` 심링크 필요 후 정리.
|
||||
|
||||
## Task 1 — kebab-embed-candle 모델 레지스트리
|
||||
- e5 하드코딩(`HF_MODEL`/`SUPPORTED_MODEL`/mean pool/`query:`+`passage:`) → 레지스트리 구조체 `EmbedModelSpec { name, hf_repo, pooling: Pooling, query_prefix, doc_prefix, dim }`.
|
||||
- 등록: e5(`intfloat/multilingual-e5-large`, Mean, `query: `/`passage: `, 1024) + arctic(`Snowflake/snowflake-arctic-embed-l-v2.0`, Cls, `query: `/``, 1024). **arctic pooling 은 모델 `1_Pooling/config.json` 로 확인 후 확정(CLS 추정).**
|
||||
- `embed_batch` pooling 분기: Mean=기존 attention-mask-weighted, Cls=hidden_state[:,0,:]. tokenize/forward/L2 공유.
|
||||
- `CandleEmbedder::new` 가 config model 로 spec 조회, 없으면 에러. `model_id`/`model_version` 에 모델명 반영.
|
||||
- 단위테스트: 레지스트리 조회, prefix 적용, (가능하면) CLS vs mean pooling shape.
|
||||
|
||||
## Task 2 — kebab-embed-ollama 신규 크레이트
|
||||
- `Cargo.toml`(workspace member), `Embedder` 구현. `reqwest::blocking` POST `/api/embed`.
|
||||
- 배치(48) + fail-soft 재시도(3). query/doc prefix 모델별. L2 normalize. dim 검증(config 와 일치).
|
||||
- endpoint = config.models.embedding.endpoint ?? models.llm.endpoint. model_version=`ollama:{model}`.
|
||||
- 단위테스트: wiremock 으로 /api/embed mock → dim·정규화·prefix 검증.
|
||||
|
||||
## Task 3 — config + app 배선
|
||||
- `kebab-config`: `EmbeddingCfg.provider` 문서/검증에 `ollama` 추가, `endpoint: Option<String>` 필드(serde default None). migrate.rs 주석.
|
||||
- `kebab-app` `embedder()`(또는 해당 선택부, lib.rs ~836): provider match → fastembed | candle(레지스트리) | ollama. facade 통해 cfg 주입.
|
||||
- config 직렬화/round-trip 테스트 갱신.
|
||||
|
||||
## Task 4 — correctness 검증 테스트 (핵심)
|
||||
- candle arctic vs Ollama arctic 코사인>0.99 테스트: 테스트 문장 임베딩을 candle(arctic spec)로 1개 + Ollama(`snowflake-arctic-embed2` @192.168.0.47)로 1개 → cos>0.99 assert. live Ollama 의존이라 `#[ignore]`(이유: 외부 Ollama), 수동 실행 절차를 테스트 doc + HOTFIXES 에 기록. (CI 무인 환경 회피.)
|
||||
- 단, **리더가 머지 전 이 테스트를 수동 실행해 통과 확인**(pooling/prefix 정확성 게이트).
|
||||
|
||||
## Task 5 — 검증 + 문서
|
||||
- clippy 0 / 전체 test 통과(기존 e5 회귀 0).
|
||||
- provider=candle+arctic, ollama+arctic, fastembed+e5(기본) 각 로드 스모크.
|
||||
- 문서: README Configuration(provider candle/ollama + arctic + endpoint + metal), ARCHITECTURE(백엔드 그래프 + kebab-embed-ollama 크레이트 + 결정표), HANDOFF 1줄, HOTFIXES dated, Cargo.toml members + version minor bump(+Cargo.lock).
|
||||
|
||||
## 리뷰 루프
|
||||
구현 완료 → 리더가 (a) clippy/test 독립 재확인 (b) **candle≈Ollama 코사인>0.99 수동 검증** → `gitea-pr`(title `feat(embed): arctic-embed-l-v2.0 임베더(candle+ollama)`) → 리뷰 루프 → 사용자 머지. 머지 후 Mac Metal 도그푸딩(recall 130 재현).
|
||||
@@ -0,0 +1,37 @@
|
||||
# Plan: ingest 설정 변경 자동 재색인 구현
|
||||
|
||||
spec: `docs/superpowers/specs/2026-06-03-ocr-toggle-invalidation-spec.md`. 브랜치 `fix/ingest-config-invalidation`. 빌드 `CARGO_TARGET_DIR=/build/out/cargo-target`, **테스트 `-j 8`**(절대 `-j 1` 금지), cli 통합테스트용 `target` 심링크 후 정리.
|
||||
|
||||
## Task 1 — ingest_config_signature 헬퍼 (kebab-app)
|
||||
- `fn ingest_config_signature(config: &Config, media: &MediaType) -> String` 추가.
|
||||
- 공통: `[chunking]` target_tokens, overlap_tokens, respect_markdown_headings, chunker_version.
|
||||
- image: + image.ocr.enabled (+model if enabled) + image.caption.enabled (+prompt_template_version if enabled).
|
||||
- pdf: + pdf.ocr.enabled (+model, always_on if enabled).
|
||||
- code: + ingest.code.{skip_generated_header, max_file_bytes, max_file_lines, extra_skip_globs(join), ast_chunk_max_lines, fallback_lines_per_chunk, fallback_lines_overlap}.
|
||||
- markdown: 공통만.
|
||||
- 결정적(필드 순서 고정). 단위테스트: 같은 config→같은 서명, 관련 필드 변경→서명 변경, 무관 필드(search 등)→불변.
|
||||
|
||||
## Task 2 — 4개 ingest 경로에 composite parser_version 적용 (kebab-app/lib.rs)
|
||||
- md(~351), image(~1532), pdf(~2109), code 경로: `*_parser_version` = `ParserVersion(format!("{base}|{}", ingest_config_signature(config, media)))` (base = 각 extractor PARSER_VERSION).
|
||||
- 이 composite 를 (1) `try_skip_unchanged` 의 `current_parser_version` 으로 전달, (2) **persist 전 `canonical.parser_version` override** 로 저장. 두 곳 동일 보장.
|
||||
- doc_id 파생은 손대지 않음(workspace_path 조회).
|
||||
- markdown/code/image/pdf 각 경로에서 동일 패턴 적용 — 누락 없게.
|
||||
|
||||
## Task 3 — 테스트
|
||||
- image.ocr off→on, caption off→on: 재색인(skip 아님). off→off / 동일 설정: skip 유지.
|
||||
- pdf.ocr off→on: 재색인. 동일: skip.
|
||||
- chunking target_tokens 변경: 전 타입 재색인. 무변경: skip.
|
||||
- ingest.code 변경: 코드 자산만 재색인.
|
||||
- **search/rag/ui 변경: 재색인 0** (회귀 가드).
|
||||
- 동일 config 재실행: 전 자산 skip (불필요 재색인 0).
|
||||
- 기존 skip 테스트(markdown unchanged 등) 회귀 0.
|
||||
|
||||
## Task 4 — 검증 + 문서
|
||||
- `cargo clippy --workspace --all-targets -j 8 -- -D warnings` 0.
|
||||
- `cargo test -p kebab-app -p kebab-parse-image -p kebab-parse-pdf -p kebab-parse-code -p kebab-chunk -j 8` 통과(touched 크레이트 타깃; 전체 -j1 금지).
|
||||
- 스모크: 이미지 ocr off 색인 → config ocr on → `kebab ingest`(force 없이) → 그 이미지 재색인 확인.
|
||||
- tasks/HOTFIXES dated entry(일반화 + 업그레이드 1회 재색인 안내), Cargo.toml version **0.26.1 → 0.26.2**(+Cargo.lock), HANDOFF 1줄. README/wire 변화 없음.
|
||||
- 결과 요약 `/tmp/cfginval-result.md`(게이트 + 스모크 캡처).
|
||||
|
||||
## 리뷰 루프
|
||||
완료 → 리더 clippy/타깃테스트(-j8) 독립 재확인 + 토글 스모크 → `gitea-pr`(title `fix(ingest): ingest 설정 변경 시 영향 자산 자동 재색인`) → 리뷰 루프 → 사용자 머지.
|
||||
33
docs/superpowers/plans/2026-06-03-ingest-log-improve-plan.md
Normal file
33
docs/superpowers/plans/2026-06-03-ingest-log-improve-plan.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# Plan: ingest 로그 개선 구현
|
||||
|
||||
spec: `docs/superpowers/specs/2026-06-03-ingest-log-improve-spec.md`. 브랜치 `feat/ingest-log-improve`. 빌드 `CARGO_TARGET_DIR=/build/out/cargo-target -j 4`(전체 test `-j 1`). cli 통합테스트용 `target` 심링크 후 정리.
|
||||
|
||||
## Task 1 — wire 이벤트 (kebab-app/src/ingest_progress.rs)
|
||||
- `IngestEvent` 에 `AssetPhase { idx: u32, total: u32, phase: String, model: Option<String> }` variant 추가(serde tag 규약 기존과 동일, snake `asset_phase`).
|
||||
- `AssetTimings` 에 `ocr_ms: u64`, `caption_ms: u64` 필드 추가(기존 필드 뒤, serde default 0 → 구 소비자 호환).
|
||||
- 직렬화 테스트 추가(asset_phase, 확장 timings).
|
||||
|
||||
## Task 2 — emit 지점 (kebab-app/src/lib.rs)
|
||||
- 이미지 경로: `apply_ocr` 직전 `AssetPhase{phase:"ocr", model: <ocr model>}`, `apply_caption` 직전 `AssetPhase{phase:"caption", model: <llm model>}` emit. 각 호출 시간 측정 → `ocr_ms`/`caption_ms`.
|
||||
- 임베딩 루프 진입 직전 `AssetPhase{phase:"embed", model: embedder.model_id}` emit(텍스트 포함 전 asset).
|
||||
- `AssetTimings` 생성부에 ocr_ms/caption_ms 전달.
|
||||
- 짧은 phase(parse/chunk/store)는 emit 안 함.
|
||||
|
||||
## Task 3 — CLI 렌더 (kebab-cli/src/progress.rs)
|
||||
- **파일명**: AssetStarted TTY 핸들러 `bar.set_message(<path>)` (현재 위치-only 주석/로직 교체; path 길면 말미 축약). 비-TTY 줄 유지.
|
||||
- **phase+모델**: AssetPhase 수신 → `bar.set_message(format!("{path} · {phase}({model})…"))`. 현재 path 를 핸들러 상태로 보관(AssetStarted 에서 저장).
|
||||
- **heartbeat**: AssetStarted 에서 `Instant::now()` 보관 + `bar.enable_steady_tick(1s)` + 메시지 렌더에 경과초 `(Ns)`. AssetFinished/다음 AssetStarted 에서 리셋. (indicatif steady-tick + 커스텀 메시지.)
|
||||
- **slowest 요약**: 핸들러에 `Vec<(path, total_ms)>` 누적 — AssetStarted 로 idx→path, AssetTimings 로 idx→sum(parse+chunk+embed+store+ocr+caption). `Completed` 수신 시 상위 5개 stderr 표 출력(`⏱ 최장 소요:`). `--json` 모드 미출력, quiet 여도 요약은 출력.
|
||||
- `fmt_ms`(기존) 재사용.
|
||||
|
||||
## Task 4 — wire schema + 문서
|
||||
- `docs/wire-schema/v1/ingest_progress.schema.json`: `asset_phase` kind(phase enum, model) + `ocr_ms`/`caption_ms` 필드 추가(additive). verbatim 일치.
|
||||
- README(있으면 진행 표시 한 줄), HANDOFF 1줄, tasks/HOTFIXES dated entry, Cargo.toml version minor bump(+Cargo.lock).
|
||||
|
||||
## Task 5 — 검증
|
||||
- clippy 0, 전체 test 통과(기존 progress 테스트 갱신).
|
||||
- 스모크: 이미지/PDF 포함 임시 폴더 ingest → TTY 파일명+phase+모델+경과, 종료 top-N. 비-TTY 줄+요약. `--json` ndjson(asset_phase/ocr_ms) 확인, 사람텍스트 미혼입.
|
||||
- 결과 요약 `/tmp/ingestlog-result.md`(게이트 + 스모크 캡처).
|
||||
|
||||
## 리뷰 루프
|
||||
완료 → 리더 clippy/test 독립 재확인 → `gitea-pr`(title `feat(ingest): 진행 로그 개선 — 파일명/phase/heartbeat/slowest 요약`) → 리뷰 루프 → 사용자 머지. 머지 후 Obsidian 볼트 도그푸딩.
|
||||
@@ -0,0 +1,49 @@
|
||||
# Plan: doc-side expansion(별칭) 제거 구현
|
||||
|
||||
spec: `docs/superpowers/specs/2026-06-03-remove-doc-expansion-spec.md`. 브랜치 `refactor/remove-doc-expansion`. 빌드 `CARGO_TARGET_DIR=/build/out/cargo-target`, 직렬 `-j 4`(전체 테스트는 `-j 1`).
|
||||
|
||||
원칙: 작은 단위로 컴파일 가능 상태 유지. 각 단계 후 `cargo build -p <crate> -j 4`. 최종 clippy+test.
|
||||
|
||||
## Task 1 — kebab-core: Chunk.aliases 필드 제거
|
||||
- `chunk.rs`: `pub aliases: Option<String>` + serde default + `aliases_defaults_to_none_on_deserialize` 테스트 제거.
|
||||
- **금지**: `metadata.rs` `Metadata.aliases`(Vec) 는 손대지 않음.
|
||||
- 컴파일 깨짐 → Task 2~ 에서 Chunk 리터럴 정리하며 해소.
|
||||
|
||||
## Task 2 — Chunk 리터럴 정리 (kebab-chunk/*, kebab-parse-*/*, store-sqlite, app)
|
||||
- `grep -rn "aliases: None" crates/*/src` 로 Chunk 생성부 전수 → `aliases: None,` 줄 삭제.
|
||||
- store-sqlite `documents.rs`: chunks INSERT 컬럼리스트/바인딩에서 `aliases` 제거(line ~126/156), SELECT 매핑의 aliases 제거, `aliases: None`(271) 제거.
|
||||
|
||||
## Task 3 — kebab-app: expansion 모듈 + 루프 제거
|
||||
- `lib.rs`: `pub mod expansion;` 삭제, `expansion.rs` 파일 삭제.
|
||||
- ingest_one_asset: expansion 블록 전체(`if app.config.ingest.expansion.enabled { … }` + `alias_version_key`/`alias_cache_*`/`alias_touch_keys`/embed_aliases 임베딩/sentinel 벡터 생성) 제거. `expansion_ms` 타이밍은 0 고정 또는 AssetTimings 에서 필드 유지하되 항상 0 — **AssetTimings 의 expansion_ms 필드는 유지(wire 호환)**, 값 0.
|
||||
- alias sentinel 벡터 upsert 경로 제거, purge_vector_orphans 는 본문 벡터 정리로 유지.
|
||||
|
||||
## Task 4 — kebab-config: ExpansionCfg 제거
|
||||
- `lib.rs`: `ExpansionCfg` struct + `IngestCfg.expansion` 필드 + Default 제거.
|
||||
- `migrate.rs`: `[ingest.expansion]` 처리/주석 제거.
|
||||
- config 직렬화 테스트에서 expansion 기대 제거.
|
||||
|
||||
## Task 5 — kebab-search: alias lexical arm 제거
|
||||
- `lexical.rs`: `run_alias_query`, `merge_body_alias`, alias 분기 제거. body_rows 직접 사용으로 단순화. alias 관련 테스트 제거/갱신.
|
||||
|
||||
## Task 6 — wire/progress 정리
|
||||
- `kebab-app/ingest_progress.rs`: `IngestEvent::ExpansionProgress` variant + 직렬화 테스트 제거. AssetChunked/AssetTimings 유지.
|
||||
- `kebab-cli/progress.rs`, `kebab-tui/ingest_progress.rs`: ExpansionProgress 매치/렌더 제거.
|
||||
- `kebab-tui/inspect.rs`: 별칭 표시 제거.
|
||||
- `docs/wire-schema/v1/ingest_progress.schema.json`: expansion_progress kind 제거.
|
||||
|
||||
## Task 7 — sqlite 마이그레이션: DROP chunk_aliases_fts + chunks.aliases
|
||||
- `schema.rs`(refinery 마이그레이션 등록부) 확인 → 신규 forward 마이그레이션 추가: `DROP TABLE IF EXISTS chunk_aliases_fts` (+ 관련 트리거/shadow 테이블 chunk_aliases_fts_*), `ALTER TABLE chunks DROP COLUMN aliases`.
|
||||
- chunk_aliases_fts 를 만들던 기존 마이그레이션은 **수정 금지**(과거 마이그레이션 freeze) — 새 마이그레이션으로 덮어 제거.
|
||||
- `tests/chunk_aliases.rs` 삭제. `tests/migration.rs` 신규 마이그레이션 반영.
|
||||
- 번들 sqlite DROP COLUMN 지원 확인(3.35+); 미지원이면 테이블 재생성 패턴.
|
||||
|
||||
## Task 8 — 검증 + 문서
|
||||
- `cargo clippy --workspace --all-targets -j 4 -- -D warnings`.
|
||||
- `cargo test --workspace --no-fail-fast -j 1`.
|
||||
- fresh KB `.schema` 로 chunk_aliases_fts/aliases 부재 확인. `kebab ingest` 스모크(별칭 config 없이).
|
||||
- grep 잔존 0 (spec Acceptance 의 정규식).
|
||||
- 문서: HOTFIXES dated entry, 2026-05-30 doc-expansion-design spec Risks/notes cross-link, HANDOFF 1줄, wire schema, design 본문 removed 주석, Cargo.toml version bump.
|
||||
|
||||
## 리뷰 루프
|
||||
구현 완료 → `gitea-pr`(title `refactor(app): doc-side expansion(별칭) 제거`, body 요약/검증) → gitea-pr 리뷰 루프(actionable 해소까지) → 사용자 머지.
|
||||
89
docs/superpowers/plans/2026-06-04-rust-native-ocr-plan.md
Normal file
89
docs/superpowers/plans/2026-06-04-rust-native-ocr-plan.md
Normal file
@@ -0,0 +1,89 @@
|
||||
# Plan: Rust 네이티브 OCR 엔진 (PP-OCRv5 ONNX) 구현
|
||||
|
||||
spec: `docs/superpowers/specs/2026-06-04-rust-native-ocr-spec.md`. 브랜치 `feat/rust-native-ocr`.
|
||||
빌드 `CARGO_TARGET_DIR=/build/out/cargo-target`, 테스트 **`-j 8`**(절대 `-j 1` 금지), touched 크레이트 위주(`-p kebab-parse-image -p kebab-app -p kebab-config`).
|
||||
참조 구현: `oar-ocr`(Apache-2.0) 소스 + Python PaddleOCR + 검증된 PoC `/build/cache/ocr-bench/{rust-poc,onnx,rc9-spike}/`(변환 ONNX + rc.9 동작 확인).
|
||||
|
||||
## Task 0a — 레퍼런스 골든 하네스 (C1 — 최우선 선행, executor 차단 제거)
|
||||
**T3/T5 골든은 oar-ocr 로 못 만든다**(중간 텐서 미노출, PoC 는 최종텍스트만). 먼저 Python `onnxruntime` 직접(oar-ocr X)으로 변환 모델을 돌려 fixture 별 중간 산출을 골든으로 덤프:
|
||||
- 입력: `/build/dogfood/corpus/images/synthetic-ocr-bench/` fixtures + 변환 ONNX(`/build/cache/ocr-bench/onnx/`).
|
||||
- 덤프(JSON/npy, repo `crates/kebab-parse-image/tests/golden/`): (a) det 확률맵 슬라이스, (b) threshold 후 박스 폴리곤, (c) **rec 원시 logits `[T,C]`**, (d) 디코드 문자열, (e) 전처리 텐서 일부값.
|
||||
- **M2 해결**: 알려진 텍스트라인 crop 의 logits + argmax 로 **blank 인덱스 + dict 11,945→클래스 11,947 매핑(+2 정체)을 경험적으로 도출**해 plan/주석에 사실로 기록(추정 금지). 경계문자(dict 첫/끝) 포함 골든.
|
||||
- 도구: 기존 venv `/build/cache/ocr-bench/venv`(onnxruntime 직접 설치) 또는 paddleocr API 의 raw 단계. 하네스 스크립트는 `/build/cache/ocr-bench/` 에 보관(런타임 의존 아님, 골든 생성 전용).
|
||||
- 수용: 각 fixture 골든 파일 생성 + blank 인덱스 문서화. 이후 T3~T5 가 이 골든에 핀.
|
||||
|
||||
## Task 0 — 모델 번들 (결정 C-1: include_bytes, release feature 게이트)
|
||||
- 변환 ONNX(이미 존재: `/build/cache/ocr-bench/onnx/{ppocrv5_mobile_det.onnx, korean_ppocrv5_mobile_rec.onnx, korean_dict.txt}`)를 repo `crates/kebab-parse-image/assets/paddleocr-onnx/` 에 배치(+NOTICE, Apache-2.0).
|
||||
- `bundled-ocr-models` cargo feature: on 이면 `include_bytes!` 로 임베드, off(dev 기본)면 config override 경로 필수. release 빌드는 feature on.
|
||||
- 대안 C-2/C-3 는 빌드/링크 부담 측정 후 폴백(spec §모델 배포). 17MB 임베드의 dev 링크 영향 먼저 측정 — 과하면 C-2(repo 벤더 + OUT_DIR) 전환.
|
||||
- **assets 17MB 커밋 방식 결정(M4/packaging)**: git-LFS 권장(clone/`cargo package` 비대 회피). `.gitattributes` 에 `*.onnx filter=lfs`. NOTICE(Apache-2.0) 동반.
|
||||
- **테스트 모델 출처(M4)**: OCR 단위/e2e 테스트는 `bundled-ocr-models` feature 무관하게 `KEBAB_TEST_OCR_MODEL_DIR`(기본 `assets/paddleocr-onnx/`)에서 로드. 모델 없으면 `#[ignore]` 가 아니라 명확 skip+경고(CI 는 assets 존재 가정). dev 빌드 OCR 테스트가 모델 못 찾아 실패하는 모호함 제거.
|
||||
- 수용: feature on 빌드 임베드 확인, off 빌드 정상, 테스트가 assets 에서 모델 로드.
|
||||
|
||||
## Task 1 — 의존성 (kebab-parse-image/Cargo.toml)
|
||||
- `ort = { workspace = true, features = ["ndarray", "download-binaries"] }`(C1: 단독빌드 링크, nli 선례 주석). `ndarray = { workspace = true }`. `imageproc`(연결요소/윤곽).
|
||||
- `ort-sys` caret 으로 rc.12 끌려가지 않게 Cargo.lock 정합 확인(rc.9 고정). unclip 다각형 offset 은 **pure-Rust 직접 구현**(clipper2 C++ FFI 회피 — spec).
|
||||
- 수용: `cargo build -p kebab-parse-image -j 8` 링크 성공(onnxruntime), `cargo tree` 에 ort 단일 rc.9.
|
||||
|
||||
## Task 2 — OnnxPaddleOcr 골격 + 전처리 (kebab-parse-image)
|
||||
- **선행 사실 확인**: rc.9 `ort::Session` 이 `Send+Sync` 인지 먼저 확인(아니면 Mutex 래핑). 결과를 주석에 기록.
|
||||
- 신규 모듈 `paddle_onnx.rs`. `OcrEngine` 구현. **`engine_version`=생성 시 모델+dict blake3 1회 계산해 String 캐시**(m3: per-asset 재해시 금지 — `ingest_config_signature` 가 자산마다 호출). format 고정(후일 변경 시 mass 재색인 주의).
|
||||
- det/rec `ort::Session` 2개 1회 로드 후 보관. **max_pixels 자체 bounds 적용**(spec 의 ocr.rs MIN/MAX clamp 은 Ollama private — paddle 은 자기 clamp 명시).
|
||||
- 전처리: 디코드(image)→긴변 max_pixels 축소→BGR mean/std 정규화→`Array4<f32>`.
|
||||
- 수용: 단위테스트 — 알려진 이미지→입력텐서 일부 값 골든(T0a).
|
||||
|
||||
## Task 3 — det 후처리 (단계 단위, 골든벡터)
|
||||
- det Session 추론(`[1,1,H,W]` 확률맵, rc.9 `try_extract_tensor`→`ArrayViewD`) → threshold 0.3 이진화 → imageproc 연결요소/윤곽 → **min-area rotated-rect(rotating calipers 직접 구현)** → **unclip(pure-Rust 다각형 offset, ratio 1.5)** → 박스 Vec.
|
||||
- 수용: 합성 fixture 기대 박스 개수/대략 좌표 골든. min-area rect·unclip 각각 단위테스트.
|
||||
|
||||
## Task 4 — crop + rectify
|
||||
- 회전 박스 → perspective/affine warp 로 수평 정렬(oar-ocr 가 제공하던 부분 이식).
|
||||
- 수용: 회전 텍스트 fixture → 정렬 crop 골든.
|
||||
|
||||
## Task 5 — rec + CTC decode
|
||||
- crop→48×W 정규화→rec Session(`[1,T,C]`) → CTC greedy(argmax/timestep→연속중복 제거→blank 제거).
|
||||
- **blank 인덱스 + 11,945→11,947 매핑은 T0a 하네스에서 도출한 사실을 사용**(추정 금지). bounds-check(dict 길이≠클래스 시 생성 에러).
|
||||
- 수용: T0a 골든 logit→문자열 일치(blank/중복/**경계문자 dict 첫·끝** 포함).
|
||||
|
||||
## Task 6 — 조립 + OcrText
|
||||
- 박스 reading-order(상→하,좌→우) → `OcrText{joined, regions:[OcrRegion{bbox,text,confidence}], engine, engine_version}`. per-region 실제 confidence(Ollama 상수1.0 대비 값 변화 — release note).
|
||||
- 수용: e2e — 합성 한/영 fixture **CER ≤ 0.05**, bbox>0. PoC 0.976 baseline 대비 회귀 없음.
|
||||
- **CER 게이트 실패 시 폴백 사다리(M3)**: ① T0a 단계 골든과 diff 해 어느 단계 divergence 인지 국소화 → ② det postproc(unclip/min-area rect)가 원인이면 **oar-ocr 의 해당 함수를 verbatim 이식**(Apache-2.0, NOTICE+파일별 출처 표기 — 코드 파생물) → ③ time-box(예 반나절) 초과 시 리더 escalate. 손수 재유도에 매몰 금지.
|
||||
|
||||
## Task 7 — config (kebab-config)
|
||||
- `OcrCfg`: `engine` 값에 "paddle-onnx" 문서화(기본 "ollama-vision" 유지). 신규 override `det_model`/`rec_model`/`dict`(Option), `score_thresh`(0.3)/`unclip_ratio`(1.5)/`max_boxes`(1000). `KEBAB_IMAGE_OCR_*` env. serde default(forward-compat) + init 템플릿 노출.
|
||||
- 수용: override 미지정→번들 모델, 지정→그 경로 사용 테스트. config migrate(#198) 무수정 로드 회귀.
|
||||
|
||||
## Task 8 — 엔진 팩토리 (kebab-app/lib.rs) — **4개 site 전부(M1)**
|
||||
구체타입 `OllamaVisionOcr` 가 박힌 곳이 4군데 — 누락 시 타입에러로 막힘:
|
||||
- `:360` image 엔진 생성 → `Box<dyn OcrEngine>` 팩토리(`match engine`: ollama-vision|paddle-onnx|err).
|
||||
- `:379` pdf 엔진 생성 → 동일 팩토리.
|
||||
- `:839` `ImagePipeline.ocr_engine` 필드 → `Option<&dyn OcrEngine>`.
|
||||
- `:1113`, `:2096` `pdf_ocr_engine: Option<&OllamaVisionOcr>` 함수 시그니처 2곳 → `Option<&dyn OcrEngine>`.
|
||||
- `apply_ocr_to_pdf_pages`(`pdf_ocr_apply.rs:93`)는 이미 `&dyn OcrEngine` — 스레딩만 변경, 헬퍼 불변. `--config` facade 스레딩(`OnnxPaddleOcr::new(cfg,…)`).
|
||||
- 수용: 팩토리 단위테스트(선택/미지값 에러). **ollama-vision 경로 출력 동일** 회귀 테스트(구체→dyn 전환 무영향).
|
||||
|
||||
## Task 9 — 서명 cascade (C3, kebab-app)
|
||||
- `ingest_config_signature` image/pdf 브랜치 `|ocr:1:{model}` → `|ocr:1:{engine}:{engine_version}`(engine + 모델/dict blake3).
|
||||
- 수용: (a)ollama↔paddle 동일model→서명다름 (b)engine_version 다름→다름 (c)search 등 무관→불변. → 엔진/모델 변경 시 v0.26.2 자동 재색인.
|
||||
|
||||
## Task 10 — 에러 매트릭스 (spec §에러 처리)
|
||||
- 다운로드/blake3 실패→fail-fast, 디코드불가→skip+provenance, det 0박스→`OcrText{"",[]}` 성공, rec 빈→박스skip, 박스폭증→max_boxes 절단+로그, dict 불일치→생성에러.
|
||||
- 수용: 각 케이스 단위/통합 테스트.
|
||||
|
||||
## Task 11 — 검증 게이트
|
||||
- `cargo clippy --workspace --all-targets -j 8 -- -D warnings` 0.
|
||||
- `cargo test -p kebab-parse-image -p kebab-app -p kebab-config -j 8` 통과(+ `-p kebab-parse-image` 단독 링크 확인).
|
||||
- 스모크: `engine="paddle-onnx"` 이미지 ingest→FTS5 hit, 큰 페이지 CPU <5초.
|
||||
|
||||
## Task 12 — 문서 + 버전 + 도그푸딩
|
||||
- README(Configuration: `image.ocr.engine`+모델 번들), docs/SMOKE(config 예시), HANDOFF 1줄, docs/ARCHITECTURE(OCR 백엔드/그래프), HOTFIXES dated entry.
|
||||
- Cargo.toml workspace version **minor bump**(+Cargo.lock). release notes(엔진 추가/per-region confidence/오프라인).
|
||||
- 도그푸딩: 사용자 실제 이미지·책 스캔 정확도·속도 → HOTFIXES + release notes evidence.
|
||||
- 결과 요약 `/tmp/rust-ocr-result.md`(게이트 + 스모크 + 도그푸딩 캡처).
|
||||
|
||||
## 리뷰 루프
|
||||
완료 → 리더 clippy/타깃테스트(-j8) 독립 재확인 + paddle-onnx 스모크 → `gitea-pr`(title `feat(ocr): PP-OCRv5 ONNX Rust 네이티브 OCR 엔진`) → 리뷰 루프 → 사용자 머지. 모델 ONNX 는 release feature/asset 로 동반.
|
||||
|
||||
## 단계 의존
|
||||
**T0a(레퍼런스 골든+blank 도출) 최우선 선행** → T0(번들),T1(deps) → T2→T3→T4→T5→T6(파이프라인 순차, 각 T0a 골든에 핀) ∥ T7(config) → T8(팩토리 4site)→T9(서명)→T10(에러) → T11 게이트 → T12 문서. T3~T5 가 핵심 난도(직접 이식), T0a 골든+T6 폴백사다리로 회귀·매몰 차단. T8 의 정확한 라인(:1113/:2096 등)은 구현 시점 grep 으로 재확인(코드 이동 가능).
|
||||
@@ -0,0 +1,102 @@
|
||||
---
|
||||
title: 어휘격차(vocabulary-gap) pool-miss 해결 — 딥리서치 레퍼런스
|
||||
date: 2026-05-30
|
||||
type: research-reference
|
||||
provenance:
|
||||
- "deep-research 워크플로 (wf_e76011c6-de8): 5 angle, 22 sources fetch, 103 claims 추출, 25 claim 3-vote 적대적 검증, 22 confirmed / 3 killed. 104 agent, ~3.5M subagent tokens."
|
||||
related:
|
||||
- docs/superpowers/handoffs/2026-05-29-query-paraphrase-robustness-phase1-handoff.md (B 어휘격차 우세 진단)
|
||||
- docs/superpowers/research/2026-05-29-crossscript-synonym-retrieval-research.md (선행 — rerank 결정 중심)
|
||||
- memory: project_paraphrase_robustness, project_crossscript_diagnosis
|
||||
---
|
||||
|
||||
# 어휘격차 pool-miss 해결 — 딥리서치 레퍼런스
|
||||
|
||||
> Phase 1 진단(변형 일관성 측정)에서 **B(어휘격차) 우세** 확인 — 같은 의미를 다른 단어로 물으면
|
||||
> 정답이 top-50 pool 에도 안 들어옴(recall@50=0), rerank 불가. 그 실패 모드 전용 처방을 조사.
|
||||
|
||||
## 0. 질문 (요약)
|
||||
|
||||
CPU-only 로컬 RAG(FTS5/BM25 + LanceDB dense e5-large + RRF, Rust/fastembed-rs/LanceDB) 에서
|
||||
"같은 의미 다른 표현(동의어·풀어쓴 문장·한/영)이 top-50 pool 에도 못 들어오는" 실패를 가장
|
||||
좋은 비용대비로 고치는 법. 제약: per-query LLM 확장 거부("밑 빠진 독"), e5-large 유지(bge-m3
|
||||
dense 는 실측 더 나빴음), 코드 식별자 정확매칭 보존.
|
||||
|
||||
## 1. 적대적 검증 통과 결론 (confirmed)
|
||||
|
||||
### 1.1 색인시 doc-side expansion(doc2query/docTTTTTquery)이 pool-miss 의 최선책 (3-0)
|
||||
- **유일하게 lexical pool 자체를 키운다**(rerank 아님): docTTTTTquery README — MS MARCO doc
|
||||
Recall@1000 0.9180→0.9490(rerank 없는 1차 BM25 지표 → pool 진입 증가 실증). passage MRR@10
|
||||
18.6→27.2, **per-query +9ms 만**(55→64ms, T5 추론은 색인시 1회·query 무영향).
|
||||
- **메커니즘이 어휘격차 정조준**: SIGIR 2024 — N개 예측 query append 가 없던 term 주입(TF↑),
|
||||
Doc2Query model card "generated queries contain synonyms → close the lexical gap".
|
||||
- **Doc2Query++(2510.09557, 2025-10)**: 5개 BEIR 에서 sparse·dense 둘 다 **Recall@100** 개선
|
||||
(SCIDOCS 0.3323→0.3749, FiQA 0.5864→0.6197). Recall@100=pool-membership → pool 확장 확인.
|
||||
- 출처: github.com/castorini/docTTTTTquery, doc2query/msmarco-14langs-mt5-base-v1, mzzm24-sigir, 2510.09557.
|
||||
|
||||
### 1.2 ⚠️ vanilla 다국어 doc2query(mt5)는 한/영을 못 잇는다 (3-0)
|
||||
- model card: "input passage 의 **같은 언어**로 query 생성". mMARCO 14개 언어에 **한국어 없음**.
|
||||
- 귀결: doc2query 단독은 영어 paraphrase·동의어 pool-miss(raft "how nodes agree…")는 고치나,
|
||||
**KO↔EN 갭(역전파→영어 backprop doc)은 못 고침** → 색인시 *교차언어* 대체 query 생성이 추가로 필요.
|
||||
|
||||
### 1.3 MILCO(교차언어 learned-sparse)가 한/영 갭 직접 해결, 단 배포 경로 없음 (3-0)
|
||||
- 2510.00671(2025-10): query·doc 를 "공유 English lexical space"로 매핑. MKQA(한국어 포함)
|
||||
zero-shot R@100 **76.6**(BGE-M3-Sparse +69%, BM25 +92%). **그러나 560M 연구모델, ONNX/
|
||||
fastembed-rs 체크포인트 미확인** → research signal, turnkey 아님. (0.61ms 는 index lookup 만, 인코딩 제외.)
|
||||
|
||||
### 1.4 BGE-M3 sparse 채널은 CPU-native 추가 가능, 단 한/영은 못 고침 (3-0)
|
||||
- 1 forward 로 dense+sparse+ColBERT 산출, fastembed-rs `BGEM3Q`(CPU 양자화, CUDA 주면 오히려 실패).
|
||||
- 단일언어 향상: MIRACL Dense+Sparse 68.9 > Dense 67.8 → **3rd RRF 채널 후보**.
|
||||
- **교차언어 약함**: BGE-M3 논문도 sparse cross-lingual MKQA 45.3 vs dense 67.8 "다른 언어라 공존 term
|
||||
거의 없음". → KO↔EN 갭엔 무용. ("sparse 가 모든 언어서 BM25 압도" 주장은 **0-3 기각**.)
|
||||
|
||||
### 1.5 turnkey SPLADE 는 새 corpus 에서 자동 해결 못 함 (3-0)
|
||||
- LSR 은 term expansion 으로 어휘격차 겨냥하나, SOTA(Echo-Mistral-SPLADE BEIR 55.07)는 **Mistral-7B
|
||||
로 학습**(무겁다). AACL 2022: "SPLADE 는 저빈도 단어 exact match 에 약함 + 어휘/빈도 domain shift
|
||||
시 성능 저하". → 개인 혼합 KO/EN corpus 에 drop-in 기대 금물, 코드 식별자 정확매칭도 약점.
|
||||
|
||||
### 1.6 query-side(HyDE, Vector-PRF)는 이 제약에 부적합 (confirmed)
|
||||
- **HyDE**: query 마다 LLM 이 가설답변 생성(1~5s/query) = 사용자가 거부한 "밑 빠진 독" 바로 그것.
|
||||
- **Vector-PRF**: per-query 생성은 피하나 2-pass 필요 + **recall 개선 주장 0-3 기각**(1.6/6.2/7.7%
|
||||
Recall@100 gain 전부 refute). → 이 실패 모드 해결 증거 없음.
|
||||
|
||||
## 2. 기각 (killed — 믿지 말 것)
|
||||
- "BGE-M3 sparse 가 모든 언어서 BM25 압도" (0-3).
|
||||
- "Vector-PRF 가 Recall@100 을 1.6~7.7% 올린다(pool 확장)" (0-3).
|
||||
- "Vector-PRF 가 여러 데이터셋서 dense 효과 개선" (0-3).
|
||||
|
||||
## 3. 권고 (minimal combination, medium conf — 합성/추론)
|
||||
|
||||
**(1) 색인시 doc2query-style 확장 → 별도 FTS5 lexical 필드** (원문 body 필드는 그대로 verbatim
|
||||
index → 코드 식별자 정확매칭 보존, append-not-replace). RRF 가 {body-BM25, expansion-BM25, e5-dense} 융합.
|
||||
**(2) 문서당 같은언어 query + 소수의 교차언어(KO↔EN) 대체 표현/번역**을 색인시 1회 생성(로컬 LLM
|
||||
= gemma, **per-query 아님 → 사용자 제약 충족**). 역전파→backprop doc 의 직접 해법.
|
||||
**(3) (선택) BGE-M3 sparse(fastembed-rs BGEM3Q)를 4th RRF 채널**로 단일언어 lift, e5-large dense 유지.
|
||||
필터(Doc2Query--/++ topic-coverage)로 환각·index 팽창 제어.
|
||||
|
||||
- 사용자 제약 충족: 색인시 1회(per-query LLM 아님) + e5-large 유지 + 정확매칭 보존.
|
||||
- 엄격 no-LLM 원하면 (2) 대신 seq2seq mt5 doc2query 로 폴백(단 한국어 미커버 → 한/영 갭 부분만 해결).
|
||||
|
||||
## 4. 미해결 질문 (= Phase 2 실험 설계, **기존 variant eval 로 측정 가능**)
|
||||
1. **색인시 KO↔EN 대체 query 생성이 우리 corpus 에서 recall@50 을 0→양수로 올리나?** — 핵심 미검증
|
||||
고리. `/build/dogfood` golden + `kebab eval variants` 로 직접 측정(또 프록시 금지).
|
||||
2. ONNX/fastembed 호환 교차언어 learned-sparse(MILCO 또는 distill) 체크포인트가 있나, 아니면
|
||||
교차언어는 전적으로 색인시 doc expansion 으로만 풀어야 하나.
|
||||
3. doc2query 가 개인 KB(수천 doc/수만 chunk)의 FTS5 index 를 얼마나 부풀리나. Doc2Query--/++ 필터
|
||||
가치 있나 vs plain mt5.
|
||||
4. e5 dense 유지하고 BGE-M3 **sparse 만** 추가 시 paraphrase/동의어 recall@50 순이득인가, 약한
|
||||
다국어 sparse 가 RRF 에 노이즈만 더하나.
|
||||
|
||||
## 5. 핵심 caveat (시점 민감)
|
||||
- 최강 교차언어 근거(MILCO, Doc2Query++)는 2025-10 단일 논문·저자 보고 벤치 — research signal.
|
||||
- **교차언어 권고(색인시 KO↔EN 생성)는 합성/추론** — "index-time LLM translation 이 한/영 recall@50
|
||||
갭을 닫는다"를 직접 벤치한 논문 없음. confirmed fact 들의 논리적 조합. → **우리 corpus 측정 필수**.
|
||||
- docTTTTTquery 의 MS MARCO recall 증가는 modest(+3.4% rel) — 순수 pool-rescue 크기는 우리 corpus 미검증.
|
||||
- 정확매칭 보존은 architectural 논증(별도 필드), 코드 corpus 직접 측정 아님.
|
||||
|
||||
## 6. 출처
|
||||
docTTTTTquery — github.com/castorini/docTTTTTquery · Doc2Query++ — arxiv 2510.09557 ·
|
||||
mt5 14-lang doc2query — hf.co/doc2query/msmarco-14langs-mt5-base-v1 · SIGIR2024 doc-exp — jmmackenzie.io/pdf/mzzm24-sigir.pdf ·
|
||||
MILCO — arxiv 2510.00671 · BGE-M3 — arxiv 2402.03216 · bge-m3-onnx — github.com/yuniko-software/bge-m3-onnx ·
|
||||
Mistral-SPLADE — arxiv 2408.11119 · SPLADE domain-shift — arxiv 2211.03988 · HyDE/PRF — arxiv 2511.19349, 2504.01448, 2108.11044 ·
|
||||
fastembed-rs — github.com/Anush008/fastembed-rs · KURE — github.com/nlpai-lab/KURE · arctic-embed-ko — hf.co/dragonkue/snowflake-arctic-embed-l-v2.0-ko
|
||||
@@ -0,0 +1,163 @@
|
||||
# Expansion 비용 재고 — 별칭(doc-side LLM expansion)을 대체할 방법 조사
|
||||
|
||||
**날짜**: 2026-06-03
|
||||
**상태**: 조사 완료, 검증(측정) 대기
|
||||
**선행**: [[2026-05-30-vocabulary-gap-recall-fix-research]] (당시 결론 = doc-side expansion), v0.21.0 별칭 구현(#195/#196)
|
||||
**계기**: 도그푸딩에서 expansion 이 ingest 임계경로의 압도적 병목으로 확정(청크당 gemma4:e4b ~1.3s, 5150 청크 ≈ 1.9h). 동시성(A)·모델스왑(D) 실측 소진. 사용자가 두 구조적 반론 제기.
|
||||
|
||||
---
|
||||
|
||||
## 1. 문제 재정의 — 왜 "반창고"가 다 실패하는가
|
||||
|
||||
별칭은 **청크마다 LLM 1회 호출**(`kebab-app/src/lib.rs` expansion 루프). 비용이 **코퍼스 크기에 비례**하고, KB 가 살아있으므로(문서 수정·추가) **갱신 청크를 영원히 따라가야 함**.
|
||||
|
||||
소진된 레버 (전부 *같은 총량을 언제/어떻게 나눌지*만 바꿈, 총량 불변):
|
||||
|
||||
| 레버 | 실측(2026-06-02~03, Mac M4 Pro Metal) | 판정 |
|
||||
|------|------|------|
|
||||
| A. `OLLAMA_NUM_PARALLEL` + 클라 동시요청 | 슬롯 2/4, 동시 2/4/8 → **최대 1.28×** (GPU compute 포화) | ✖ 불충분 |
|
||||
| D. 모델 스왑 | gemma4:e4b 1.22s/건(품질 합격선) · qwen2.5vl:3b 더 느림+무한반복 · **qwen3.5:2b-mlx 0.24s(~5배)지만 중국어(所有权系统)+47줄 degeneration** · 0.8b 입력에코 | ✖ gemma 품질 못 이김 |
|
||||
| B. 백그라운드/별도명령 | 총량·팬·리소스 불변, 유지보수 treadmill 잔존 | ✖ 사용자 반론으로 기각 |
|
||||
|
||||
**사용자 반론(정확)**: ① 별도 명령이어도 맥 팬·리소스 총량 동일 ② 청크당 이렇게 비싸면 갱신을 못 따라감. → "청크마다 미리 LLM" 구조 자체가 부적합. **아키텍처를 의심해야 하는 지점.**
|
||||
|
||||
---
|
||||
|
||||
## 2. 학계/웹 조사 핵심 발견
|
||||
|
||||
### 2.1 결정타 — Expansion 은 강한 검색기에 오히려 해롭다
|
||||
*"When do Generative Query and Document Expansions Fail?"* (arXiv 2309.08541): **검색기 성능과 expansion 이득 사이 강한 음의 상관**. 11개 expansion 기법 × 12개 데이터셋 × 24개 검색 모델에서 일관. 약한 모델엔 도움, **강한 모델엔 손해**(추가 noise 가 relevance 신호를 흐림, false positive 유발). 권고: *"target 이 학습 코퍼스와 크게 다르거나 약한 모델일 때만 expansion, 아니면 피하라."*
|
||||
|
||||
→ 함의: 별칭의 v0.21.0 이득이 **14/18→16/18(미미)** 였던 건 우연이 아님. e5-large 는 이미 준수한 다국어 검색기 → 별칭은 *목발*에 가깝고 ROI 가 0~음수 구간일 수 있음. **측정으로 즉시 확인 가능**(별칭 on/off 골든 비교).
|
||||
|
||||
### 2.2 어휘·교차언어 격차는 본질적으로 *임베딩* 문제
|
||||
별칭은 "역전파↔backpropagation 이 벡터공간에서 안 가깝다"를 색인-시 텍스트로 우회한 것. 정공법 = **교차언어가 강한 임베더로 벡터공간 자체에서 정렬**. 비용 = LLM 0, **색인 1회 재계산**(살아있는 KB 에서도 신규/수정 청크 임베딩은 어차피 하는 일 — treadmill 없음).
|
||||
|
||||
### 2.3 임베더 후보 (로컬·오픈, 2026)
|
||||
- **BGE-M3** (사용자 Mac 에 이미 pull 됨): XLM-RoBERTa-large 기반(= `kebab-embed-candle` 의 `XLMRobertaModel` 과 **동일 아키텍처**), dense **1024-dim**(= e5-large, 벡터스토어 그대로), **prefix 불필요**(e5 의 `query:`/`passage:` 와 달리), 단 **CLS pooling**(e5 는 mean pooling — 통합 시 분기 필요). **dense+sparse(lexical)+multi-vector(ColBERT)** 3-헤드.
|
||||
- 한↔영 실측(Belebele, 2507.08480): base bge-m3 ≈ base e5-large (EN→KO 는 e5 92.0 > m3 90.4, KO→EN 은 m3 88.4 > e5 86.5). **dense 단독 교체만으론 대박 아님**. 차별점은 sparse/multi-vector 헤드.
|
||||
- **Qwen3-Embedding** (2026 초, MTEB v2 오픈웨이트 1위; 8B 는 무거움, 0.6B/4B 변형 존재): 다국어 최상위. 소형 변형이 로컬 가용하면 dense 업그레이드 후보.
|
||||
- 다국어 일반 권고(2026 가이드들): "BGE-M3 또는 Nomic". e5-large 도 여전히 경쟁력.
|
||||
|
||||
### 2.4 Multi-vector(ColBERT)는 색인-전체가 아니라 *질의-시 rerank* 로
|
||||
ColBERT/multi-vector 는 토큰당 벡터 1개 → 저장 폭증(10M doc ≈ 6TB vs bi-encoder 30GB). **전체 코퍼스 색인 금지.** 실용 패턴 = dense 1차 검색 → **top-50/100 만 multi-vector late-interaction rerank(질의-시, O(질의))**. 진단된 "near-tie 벡터 불안정"([[project_crossscript_diagnosis]])을 정조준하면서 색인 비용 0.
|
||||
|
||||
### 2.5 굳이 expansion 한다면 — query-side, single-pass
|
||||
CTQE(2509.02377): LLM 한 번의 decoding 패스에서 candidate token 재활용 → **추가 inference 없이** query expansion. 비용 O(질의), 캐시 가능. doc-side 의 O(코퍼스)·treadmill 과 정반대.
|
||||
|
||||
---
|
||||
|
||||
## 3. 권고 아키텍처 — 청크당 LLM 0, 측정-우선 단계별
|
||||
|
||||
원칙: 비용을 **O(코퍼스 LLM)** 에서 **O(코퍼스 임베딩, 이미 수용중) + O(질의)** 로 이동. 각 단계는 기존 골든/variant eval 로 검증 후 다음 진행(사용자 "측정 먼저" 방법론).
|
||||
|
||||
- **Step 0 — 별칭 ROI 실측 (LLM 0, 코드 0)**: 현재 e5-large 에서 별칭 **on vs off** 골든/variant 비교. 2.1 예측대로 차이 미미/음수면 → **별칭 기능 통째 제거**(즉시 최대 승리: 청크당 LLM 영구 소멸). 차이가 유의미할 때만 Step 1+.
|
||||
- **Step 1 — 강한 dense 임베더 (LLM 0, 색인 1회)**: BGE-M3 를 `kebab-embed-candle` 로 dense 임베더 교체 검증(같은 XLM-R, CLS pooling + prefix 제거, 1024-dim 동일). 소형 Qwen3-Embedding 가용 시 병행. `embedding_version` cascade = 전체 1회 재임베딩(0.48s/asset 관측, 수용 범위, treadmill 아님).
|
||||
- **Step 2 — BGE-M3 sparse 헤드를 lexical arm 으로 (LLM 0)**: 학습된 sparse lexical 이 FTS5 보다 교차언어 우수. 같은 임베드 패스 산출물 → 추가 색인 비용 ≈ 0. RRF 의 lexical 항 보강/대체.
|
||||
- **Step 3 — (선택) 질의-시 multi-vector rerank**: 잔존 near-tie 순위 출렁이면 top-50 만 BGE-M3 multi-vector late-interaction rerank(O(질의), 색인 bloat 0).
|
||||
|
||||
**통합 이점**: 사용자가 이미 NUMA 대응으로 만든 `kebab-embed-candle`(XLM-RoBERTa)가 BGE-M3 와 동일 아키텍처 → 가중치/풀링/헤드 추가 위주로 재사용. fastembed 도 bge-m3 지원(단 NUMA double-free 회피 위해 candle 경로 선호).
|
||||
|
||||
**리스크/주의**: ① dense 단독 교체 이득은 한↔영 데이터상 작을 수 있음 → sparse/multi-vector 가 실질 차별점, Step 1 단독 성패로 판단 말 것. ② CLS vs mean pooling, prefix 차이 → 정확히 구현 안 하면 품질 급락(검증 필수). ③ `embedding_version` bump = breaking, 재임베딩 필요(versioning cascade). ④ Mono-IR 소폭 저하 가능(2507.08480) — 골든의 한국어-단일 케이스도 같이 측정.
|
||||
|
||||
---
|
||||
|
||||
## 4. Step 0 측정 결과 (2026-06-03, v0.24.0 fresh)
|
||||
|
||||
namu corpus(997 docs / 23151 chunks, e5-large) + `namu_golden_step0.yaml`(doc_id 재매핑, 18그룹×4변형+10대조) hybrid k=50.
|
||||
|
||||
| arm | fully_consistent | recall@10 | recall@50 | mean_spread@10 | 색인 LLM 비용 |
|
||||
|------|------|------|------|------|------|
|
||||
| **OFF (별칭 없음, fresh v0.24.0)** | **14/18** | **68/72 (0.944)** | 70/72 (0.972) | **0.222** | **0** |
|
||||
| ON (별칭, v0.21.0 prior, 동일 corpus/golden) | 16/18 | ~70/72 | ~72/72 | 0.111 | 별칭 LLM (정답 18문서만 **2.5h**, 전 corpus 수 시간) |
|
||||
|
||||
fresh OFF 가 이전 baseline(14/18, A2/B2, spread 0.222)을 **정확히 재현** → 이전 ON(16/18, A1/B1, spread 0.111, handoff 2026-05-31)과 직접 비교 유효. ON 재측정은 시드 캐시의 alias 행이 7개뿐이라 전 corpus cold 별칭생성=수 시간(= 사용자가 못 견디는 그 비용) → 비실시.
|
||||
|
||||
**변형 종류별 OFF recall@10 (별칭 0):**
|
||||
`en 18/18 · ko 18/18 · syn 11/11 · abbr 7/7 · para 14/18` — **교차언어(en↔ko)·동의어·약어는 별칭 없이 이미 완벽.** 유일한 약점 = 설명형(paraphrase) 4쿼리.
|
||||
|
||||
**결론 (Step 0)**:
|
||||
- 별칭이 정조준한 **cross-lingual 격차는 e5-large 단독으로 이미 top-10 완벽 해결**(역전파↔backprop 우려는 기우였음). 별칭의 실제 기여 = **paraphrase 그룹 +2(14→16)** 뿐, 그것도 stack/svm 설명형 잔존.
|
||||
- 그 +2 를 위해 **색인-시 수 시간 LLM + 살아있는 KB treadmill**(사용자 2대 반론) 을 지불 = ROI 음수 구간. §2.1 "강한 검색기엔 expansion 이 해롭다" 와 정합.
|
||||
- **권고**: 별칭 default-off 유지하다 **제거 후보**로 격하. 단 paraphrase 잔존(4쿼리)을 §3 Step 1(BGE-M3 dense, LLM 0/색인 1회)이 닫는지 먼저 측정 → 닫으면 별칭 완전 삭제, 못 닫으면 query-side single-pass(§2.5) 소폭 보강. **어느 쪽도 청크당 LLM 0.**
|
||||
|
||||
산출물: `/build/dogfood/_archive/step0/`(config-off/on, kb-off, namu_golden_step0.yaml, fill_docids_step0.py), OFF run `run_019e89c524ca76a1befae126f0c77336`, `/tmp/step0_off_variants.json`.
|
||||
|
||||
## 5. Step 1 측정 결과 (2026-06-03) — bge-m3 dense = lateral, 업그레이드 아님
|
||||
|
||||
kebab(fastembed 4.9.1)은 bge-m3 dense 미지원(reranker V2M3 / BGE EN·ZH v1.5 / e5 만). candle 은 e5 전용(mean pool+prefix). → **standalone 측정**: kb-off 청크 23151개 + 변형 72쿼리를 Ollama `bge-m3:latest`(Mac GPU, /api/embed, prefix 없음)로 임베딩, exact cosine top-k recall. e5 baseline = kebab `--mode vector`(run_019e89d0...). 청크 임베딩 911s(~25/s), npz 캐시 `bge_m3_chunks.npz`.
|
||||
|
||||
| 변형 | e5-large dense | bge-m3 dense | Δ |
|
||||
|------|------|------|------|
|
||||
| en (영→한 cross-lingual) | 18/18 | 17/18 | −1 |
|
||||
| ko | 18/18 | 18/18 | = |
|
||||
| syn (동의어) | 11/11 | 10/11 | −1 |
|
||||
| abbr (약어) | 7/7 | 6/7 | −1 |
|
||||
| **para (설명형)** | 14/18 | **17/18** | **+3** |
|
||||
| **recall@10 합계** | **68/72 (0.944)** | **68/72 (0.944)** | **0** |
|
||||
| recall@50 합계 | 70/72 | 71/72 | +1 |
|
||||
|
||||
bge-m3 미스(recall@10): nn_syn(뉴럴 네트워크 모델), dp_abbr(DP 알고리즘), **stk_para**(stack 설명형 — 양쪽 공통 잔존), re_en(regular expression).
|
||||
|
||||
**결론(Step 1)**: bge-m3 dense 는 **맞교환** — 설명형 +3, 용어/약어/영어 −3, 합계 동률. §2.3 KO-EN 연구("base bge-m3 ≈ base e5-large, 케이스별 한쪽씩")의 정량 재현. **dense 단독 임베더 교체는 정당화 안 됨**(이득 0, embedding_version cascade 재임베딩 비용만 발생). bge-m3 의 미검증 레버 = sparse+multivector hybrid(용어 손실을 sparse 가 회복하며 para 이득 유지 가능) — 단 별도/대형 작업(kebab 에 bge-m3 3-head 통합 필요).
|
||||
|
||||
## 6. 종합 결론 & 권고
|
||||
- **별칭(doc-side expansion) = 제거 확정 후보.** Step 0: cross-lingual 은 e5 단독으로 이미 완벽, 별칭 기여는 para +2 뿐, 대가는 청크당 LLM(살아있는 KB 에 지속 불가). §2.1 문헌과 정합. **권고 = 별칭 기능 제거(또는 영구 default-off + 문서화), e5-large 유지.** → 사용자 2대 반론(총량·treadmill) 즉시 해소.
|
||||
- **임베더 교체(e5→bge-m3 dense) = 보류.** Step 1: 이득 0(lateral). 추진 시에도 dense 단독 말고 **bge-m3 hybrid(sparse 포함)** 를 먼저 측정해야 의미 — 별도 조사 트랙.
|
||||
- **잔존 약점(설명형 ~4쿼리, 특히 stack)**: 별칭으로도 bge-m3 로도 안 닫힘 → 별도 소형 과제(query-side single-pass §2.5 또는 bge-m3 sparse)로 분리, 우선순위 낮음.
|
||||
|
||||
**다음 행동**: 별칭 제거 spec → plan → 구현(gitea-pr 리뷰루프). bge-m3 hybrid 는 후속 조사 항목으로 파킹. 관련 메모리 [[project_expansion_perf]] · [[project_paraphrase_robustness]] · [[project_embedding_numa_backends]].
|
||||
|
||||
## 7. 딥리서치 — 별칭 대체 방법 (2026-06-03, 4-agent 병렬)
|
||||
|
||||
별칭이 정조준했던 목적(설명형/풀어쓴 질의 recall)을 사용자 제약(로컬, 비용∝질의 또는 색인-1회, 청크당 LLM 금지)에서 달성하는 방법을 4갈래 병렬 조사. 출처는 각 절 말미.
|
||||
|
||||
### 7.1 핵심 재프레이밍 (agent 1·4 수렴)
|
||||
잔존 실패("마지막에 넣은 것을 먼저 꺼내는 자료구조"→스택)는 **reverse-dictionary / describe-to-term** 과제 — 설명에서 이름(용어)을 찾는, 본질적으로 **생성·추론** 문제. dense cosine(e5든 bge-m3든) 단독이 약한 이유. **함의: 빠진 표면 용어("스택/stack/LIFO")를 materialize 하는 방법 > 벡터만 평활화하는 방법(dense PRF 등).** 측정된 실패 분해(OFF): recall@10 68/72, recall@50 70/72 → **MisRanked ~2(top-50 안, top-10 밖) + Missing ~2(top-50 밖)**.
|
||||
|
||||
### 7.2 후보 shortlist (제약 적합)
|
||||
| # | 방법 | 비용모델 | 설명형 효과 | 로컬 경로 | 통합 난이도 | 한계 |
|
||||
|---|------|---------|-----------|----------|-----------|------|
|
||||
| **A** | **heading/title chunk enrichment** (제목+가장 가까운 heading 을 청크 임베드 텍스트/FTS5 필드에 주입) | **per-doc, LLM 0**(heading 추출만, kebab `heading_path` 이미 존재) | terse doc 에 "손잡이" 부여 → Missing 완화. MC-indexing +16~43% recall(무학습) | 색인 1회 재임베딩 | 낮음 | 순수 paraphrase(용어가 doc 본문에도 없음)엔 부분적 |
|
||||
| **B** | **임베더 교체 → `dragonkue/snowflake-arctic-embed-l-v2.0-ko`** | 색인 1회 재임베딩, LLM 0 | **e5 대비 Korean 전 벤치 우위**(Ko-StrategyQA·AutoRAGRetrieval·Belebele 설명형 + XPQA 용어 둘 다) — bge-m3 와 달리 **회귀 없는 업그레이드** | XLM-R-large·1024-dim·`query:` prefix = candle crate 거의 드롭인 | 낮음 | chunk >~1300토큰서 품질↓(긴 청크면 KURE-v1 고려) |
|
||||
| **C** | **query-time rerank `bge-reranker-v2-m3`** (RRF top-50 재정렬) | **per-query**(O질의) | MisRanked 설명형의 정석 해결(cross-encoder 토큰 상호작용); 긴/설명형서 이득 최대 | **fastembed 4.9.1 `BGERerankerV2M3` 이미 보유**(ONNX int8 CPU/ M4 GPU) | 가장 낮음(신규 인덱스 0) | **Missing 못 고침**(top-50 밖). CPU FP32 느림→int8 필수. `dragonkue/...-ko` 파인튠 +3.5% |
|
||||
| **D** | **term-style query expansion**(설명→핵심용어 ≤32토큰, 캐시, RRF 별 리스트 융합) | per-query, **캐시→amortized ~0** | reverse-dictionary 직접 공략(용어 materialize). pseudo-doc(HyDE) 아님 | 기존 Ollama 재사용 | 중간 | 드리프트 위험→원쿼리 융합 유지·하드질의만 게이트 |
|
||||
| **E** | **bge-m3 sparse 헤드**를 RRF lexical 항으로 | 색인 1회(dense+sparse 1패스), LLM 0 | dense 의 용어/약어 손실 회복 가능(MLDR서 sparse>dense +10NDCG) | fastembed `SparseTextEmbedding`/ bge-m3 ONNX | 높음(3rd 인덱스+가중치) | FTS5 와 중복, dense 항은 Korean서 arctic-ko 보다 약함 |
|
||||
|
||||
### 7.3 수렴한 경고 (피할 것)
|
||||
- **always-on HyDE / pseudo-document LLM**: 로컬 1~4B 에서 13s+/질의, 살아있는(long-tail) KB 서 환각·baseline 하회. → 쓰면 **term 변형 + 하드질의 게이트만**.
|
||||
- **dense PRF 를 주해법으로**: 이미 recalled facet 만 강화, Missing 못 살림, 드리프트. (싼 add-on 이상 금물.)
|
||||
- **학습형 QPP 를 트리거로**: 비신뢰·미일반화(2504.01101). → 대신 사용자가 이미 측정한 **near-tie Δcosine(0.003~0.005, [[project_crossscript_diagnosis]])** 를 corpus-보정 게이트로 사용.
|
||||
- **SPLADE 전면 도입 / ColBERT 전체 색인**: Korean 약함·저장 폭증. (multi-vector 는 top-k rerank 로만.)
|
||||
- **reranking 으로 Missing 기대**: 불가(1차에 없으면 못 살림).
|
||||
|
||||
### 7.4 권고 — 측정-우선 계층 (싼 것부터)
|
||||
0. **무료 점검**: e5 `query:`/`passage:` prefix 정확 적용 확인(불일치 시 verbose↔terse 격차 악화). 코드 0.
|
||||
1. **Layer A (heading enrichment)** — 가장 싸고 제약 완벽 적합, kebab 이 `heading_path` 보유. 재임베딩 1회 후 골든 측정. Missing 완화 기대.
|
||||
2. **Layer B (arctic-ko 임베더)** — bge-m3 와 달리 회귀 없는 Korean 업그레이드 가설. candle 드롭인. A 와 직교·중첩 가능. 측정.
|
||||
3. **Layer C (bge-reranker-v2-m3 top-50)** — MisRanked 해결 + **MisRanked:Missing 비율 진단 도구**(한 실험으로 둘 다). 이미 보유.
|
||||
4. **Layer D (near-tie 게이트 term-expansion/triggered HyDE)** — A~C 후에도 순수 paraphrase 잔존 시에만, 하드질의에만.
|
||||
|
||||
각 계층은 기존 golden/variant eval 로 검증, 회귀(잘 되던 질의) 감시.
|
||||
|
||||
### 7.5 반대 의견 (agent 4, 정직히 기록)
|
||||
단일 사용자 KB(본인이 쓴 corpus, 존재를 기억)에선 놓친 paraphrase 질의는 **용어로 재입력하면 초 단위 복구** — 멀티테넌트엔 없는 무료 fallback. 공격적 expansion 의 false-positive 비용 > 가끔의 miss 비용일 수 있음. reverse-dictionary 는 본질적으로 어려워(생성 추론) 무리한 추격은 16/18 잘 되는 걸 회귀시킬 위험([[project_ranking_deferred]]). **현실적 결론: 싼 A(+B) 로 80% 잡고, 잔존 paraphrase 꼬리는 "알려진 한계"로 문서화** — eval 이 그게 실사용의 큰 반복 비중임을 보이지 않는 한.
|
||||
|
||||
### 7.6 출처
|
||||
- reverse-dictionary: [GEAR 2412.06654](https://arxiv.org/pdf/2412.06654) · [unified RD 2205.04602](https://arxiv.org/abs/2205.04602) · [RD probe 2402.14404](https://arxiv.org/pdf/2402.14404)
|
||||
- query expansion: [CTQE 2509.02377](https://arxiv.org/abs/2509.02377) · [QE survey 2509.07794](https://arxiv.org/html/2509.07794) · [knowledge-leakage 2504.14175](https://arxiv.org/html/2504.14175v1) · [HyDE on local 1B/4B 2506.21568](https://arxiv.org/html/2506.21568v1)
|
||||
- PRF: [LLM-VPRF 2504.01448](https://arxiv.org/abs/2504.01448) · [PRF pitfalls TOIS 3570724](https://dl.acm.org/doi/10.1145/3570724)
|
||||
- rerank: [bge-reranker-v2-m3](https://huggingface.co/BAAI/bge-reranker-v2-m3) · [dragonkue ko 파인튠](https://huggingface.co/dragonkue/bge-reranker-v2-m3-ko) · [onnx-community ONNX](https://huggingface.co/onnx-community/bge-reranker-v2-m3-ONNX) · [FlashRank](https://github.com/PrithivirajDamodaran/FlashRank) · [Scaling Laws for Reranking 2603.04816](https://arxiv.org/pdf/2603.04816) · [SlideGar 2501.09186](https://arxiv.org/html/2501.09186v1)
|
||||
- 임베더: [arctic-embed-l-v2.0-ko](https://huggingface.co/dragonkue/snowflake-arctic-embed-l-v2.0-ko) · [Arctic-Embed 2.0 2412.04506](https://arxiv.org/html/2412.04506v1) · [ko-embedding-leaderboard](https://github.com/OnAnd0n/ko-embedding-leaderboard) · [KURE](https://github.com/nlpai-lab/KURE) · [bge-m3 2402.03216](https://arxiv.org/html/2402.03216v3) · [bge-m3-onnx dense+sparse](https://github.com/yuniko-software/bge-m3-onnx)
|
||||
- verbose-query/asymmetry: [Key Concepts in Verbose Queries (SIGIR'08)](https://dl.acm.org/doi/abs/10.1145/1390334.1390419) · [Collapse of Dense Retrievers 2503.05037](https://arxiv.org/pdf/2503.05037) · [MC-indexing 2404.15103](https://arxiv.org/pdf/2404.15103) · [Elastic title-into-chunk](https://www.elastic.co/search-labs/blog/multi-vector-documents) · [Adaptive-RAG 2403.14403](https://arxiv.org/pdf/2403.14403) · [QPP limits 2504.01101](https://arxiv.org/abs/2504.01101)
|
||||
|
||||
## 출처
|
||||
- [When do Generative Query and Document Expansions Fail? (2309.08541)](https://arxiv.org/pdf/2309.08541)
|
||||
- [Korean-English Cross-Lingual Retrieval data-centric study (2507.08480)](https://arxiv.org/html/2507.08480)
|
||||
- [BGE M3-Embedding (2402.03216)](https://arxiv.org/html/2402.03216v3) · [HF BAAI/bge-m3](https://huggingface.co/BAAI/bge-m3) · [Ollama bge-m3](https://ollama.com/library/bge-m3)
|
||||
- [Doc2Query++ (2510.09557)](https://arxiv.org/abs/2510.09557) · [Doc2Query-- When Less is More](https://www.semanticscholar.org/paper/7b2e78d4e7986914ae633fa6b30e73bad8a2b2c1)
|
||||
- [CTQE — Upcycling Candidate Tokens for Query Expansion (2509.02377)](https://arxiv.org/pdf/2509.02377)
|
||||
- [Query Expansion in the Age of LLMs: A Survey (2509.07794)](https://arxiv.org/abs/2509.07794)
|
||||
- [Best Open-Source Embedding Models 2026 (BentoML)](https://www.bentoml.com/blog/a-guide-to-open-source-embedding-models)
|
||||
- [ColBERT / late interaction storage tradeoff (Weaviate)](https://weaviate.io/blog/late-interaction-overview) · [PLAID (2205.09707)](https://arxiv.org/pdf/2205.09707)
|
||||
- [MILCO — multilingual learned sparse (2510.00671)](https://www.arxiv.org/pdf/2510.00671)
|
||||
@@ -0,0 +1,128 @@
|
||||
---
|
||||
title: Query-paraphrase robustness — 변형 일관성 평가 프레임워크 (측정 먼저)
|
||||
date: 2026-05-29
|
||||
status: design (approved-to-plan)
|
||||
related:
|
||||
- docs/superpowers/handoffs/2026-05-29-crossscript-rerank-progress-handoff.md
|
||||
- docs/superpowers/specs/2026-05-29-crossscript-rerank-experiment-design.md (선행 실험 — overlap 프록시의 한계)
|
||||
- memory: project_crossscript_diagnosis, project_rerank_experiment, project_ranking_deferred, feedback_search_quality_dogfood
|
||||
goal_reframe: "한/영 cross-script overlap → 같은 의미의 다양한 표현(동의어·다른 어휘·풀어쓴 문장·한/영)에서 일관되게 좋은 답"
|
||||
---
|
||||
|
||||
# Query-paraphrase robustness — 변형 일관성 평가 프레임워크
|
||||
|
||||
## 0. 한 문단 요약
|
||||
|
||||
같은 의미를 다른 표현(동의어, 다른 어휘, 풀어쓴 문장, 한국어/영어)으로 물어도 **답변 품질이
|
||||
일관되게 좋아야 한다**는 것이 목표다. 지난 cross-encoder reranker 실험은 "한/영 top-k 겹침
|
||||
(overlap)"이라는 **프록시 지표**를 최적화하다 헛돌았다 (full-chunk-text 까지 시도했으나 회귀가
|
||||
1:1 재현 — 가설 반증, 핸드오프 참조). 이번엔 처방을 만들기 전에 **진짜 지표(변형 간 답변 품질
|
||||
일관성)를 직접 재는 평가 프레임워크**를 먼저 만든다. 이 평가가 (A) "핵심 문서는 후보 풀에
|
||||
들어왔는데 순위만 출렁" 인지 (B) "다른 단어로 물으니 핵심 문서가 아예 후보에서 빠짐" 인지를
|
||||
숫자로 판별하고, 그 결과에 따라 처방(near-tie 흡수 vs 쿼리 확장)을 별도 spec 으로 확정한다.
|
||||
**본 spec 의 구현 범위는 Phase 1 (평가 프레임워크) 까지.** Phase 2 (처방) 는 측정 결과 게이트
|
||||
뒤의 조건부 설계다.
|
||||
|
||||
## 1. 진단 근거 (왜 측정 먼저인가)
|
||||
|
||||
- **확정된 근본 원인** ([[project_crossscript_diagnosis]]): vector near-tie 불안정 — 상위 후보들의
|
||||
cosine 점수가 Δ0.003~0.005 로 다닥다닥 붙어, "top-k" 라는 칼같은 cutoff 가 near-tie 뭉치
|
||||
한가운데를 지나면 표현 차이(동의어/한영)가 핵심 문서를 9등↔11등으로 흔든다.
|
||||
- **사용자 실제 불편** (2026-05-29 brainstorm): "한쪽 답이 나쁘다" — 겹침이 아니라 **답변 품질
|
||||
비대칭**이 핵심. 어느 쪽이 나쁠지는 **쿼리마다 다름** → 특정 언어의 구조적 결함이 아니라
|
||||
near-tie 불안정이 표현마다 다르게 발현.
|
||||
- **선행 실험의 교훈** ([[project_rerank_experiment]]): overlap 프록시 최적화 → cross-encoder 가
|
||||
한/영 query 로 pool 을 독립 재정렬해 토픽별 수렴/발산. full-chunk-text 로도 database −4 등
|
||||
회귀가 그대로. **원인(near-tie)을 모르고 프록시를 최적화하면 또 헛돈다.** → 측정 선결.
|
||||
- **(A) vs (B) 미해결**: 표현이 바뀔 때 핵심 문서가 ① 후보 풀엔 있는데 순위만 밀린 건지(A,
|
||||
near-tie), ② 후보 풀에서 아예 빠진 건지(B, 어휘 격차) 모른다. 처방이 완전히 다르므로 먼저 측정.
|
||||
|
||||
## 2. 범위 (scope)
|
||||
|
||||
**Phase 1 (본 spec 구현 대상):**
|
||||
- `kebab-eval` 의 golden suite 에 **변형 그룹(intent group)** 개념 추가 — 같은 의도의 여러
|
||||
표현이 같은 정답(expected_doc_ids / expected_chunk_ids / must_contain)을 공유.
|
||||
- **변형 일관성 메트릭** 산출: 그룹 내 recall/답변정답 의 분산(spread)·최악값, 그리고
|
||||
recall@pool vs recall@k 대비로 (A)/(B) 자동 분류.
|
||||
- dogfood KB 에 큐레이션된 변형 그룹 ~6–10 개 (각 3–5 표현). 정답 문서는 **corpus 의미로 판정**
|
||||
(순환 회피, [[feedback_search_quality_dogfood]]).
|
||||
- 측정 실행 → (A)/(B) 진단 리포트 → Phase 2 결정 게이트.
|
||||
|
||||
**Phase 2 (조건부, 별도 spec — 본 구현 제외):**
|
||||
- (A) 우세 → near-tie 밴드 흡수 (cutoff 를 near-tie band 까지 확장; 검색 순서 불변, 저위험).
|
||||
- (B) 우세 → 쿼리 확장/번역 (로컬 LLM).
|
||||
- Phase 1 평가셋으로 처방 효과를 진짜 지표로 검증.
|
||||
|
||||
**비범위 (YAGNI):**
|
||||
- LLM-judge 기반 답변 채점. `must_contain`/`forbidden` substring groundedness 가 이미 있고
|
||||
Phase 1 진단엔 충분. 필요성은 Phase 1 결과가 정한다.
|
||||
- 임베딩 모델 교체(③). 전체 재임베딩 cascade 비용 + 효과 불확실 → 측정 후 최후 옵션.
|
||||
- ranking 파라미터 자동 조정 ([[project_ranking_deferred]] 와 충돌 — 처방은 명시적 flag/설정).
|
||||
|
||||
## 3. 구조 (크레이트 경계 — design §8 준수)
|
||||
|
||||
- **`kebab-eval` 단독 변경.** retrieval/embedding/LLM 크레이트 직접 import 금지 규칙 유지
|
||||
(runner 만 `kebab-app` facade 사용 — P5-1 상속).
|
||||
- `types.rs`: `GoldenQuery` 에 `group: Option<String>` 추가 (backward-compat — 기존 쿼리는
|
||||
`None`, 단독 그룹 취급). yaml 역직렬화 optional.
|
||||
- `metrics.rs`: 기존 per-query 집계는 불변. per_query 결과를 `group` 으로 묶는 **변형 일관성
|
||||
집계** 함수 신규 (`compute_variant_consistency` 류). 기존 `AggregateMetrics` 는 안 건드림.
|
||||
- `loader.rs`: 그룹 필드 로드 + 그룹 내 expected 정합성 검증(같은 그룹은 같은 expected 공유
|
||||
권장 — 경고/허용 정책은 plan 에서 확정).
|
||||
- 측정 실행/리포트: 기존 `eval` 경로 재사용 (`--with-rag` 로 답변까지). 신규 metric 은 JSON
|
||||
리포트 + 사람이 읽는 요약 (CLI surface 확정은 plan).
|
||||
|
||||
## 4. 데이터 흐름
|
||||
|
||||
```
|
||||
golden_queries.yaml (변형 그룹 포함)
|
||||
└─ loader → Vec<GoldenQuery>{group}
|
||||
└─ runner (eval --with-rag, mode=hybrid/vector) → per_query: {recall@k, answer.must_contain pass}
|
||||
├─ 기존 AggregateMetrics (전체 hit@k/MRR/recall) — 불변
|
||||
└─ NEW: group 으로 묶어 변형 일관성:
|
||||
· recall_spread@k = max−min recall@k (그룹 내) → 0 이면 완전 일관
|
||||
· worst_recall@k = min recall@k (약한 표현)
|
||||
· answer_consistency = 모든 변형이 must_contain 통과한 그룹 비율
|
||||
· A/B 분류: 변형별 (recall@pool_k high & recall@k low) → A(순위), recall@pool_k low → B(어휘)
|
||||
```
|
||||
|
||||
`pool_k` = 진단용 넓은 후보 폭 (예: 50). near-tie 가설 검증을 위해 좁은 k(=답변 context 폭)와
|
||||
넓은 pool 을 둘 다 측정.
|
||||
|
||||
## 5. 측정 / 수용 기준
|
||||
|
||||
- 변형 그룹 ≥6 개 (한/영 쌍 + 동의어 + 다른 어휘 + 풀어쓴 문장 골고루), 각 ≥3 표현.
|
||||
- 평가 실행이 **clean**(err=0) 하고 결과가 파일로 추출 후 Read 검증됨 ([[project_rerank_experiment]]
|
||||
교훈 — 측정값 추측 금지, grep clean 추출 후에만 기록).
|
||||
- 산출물: 그룹별 recall_spread@k, worst_recall, answer_consistency + A/B 분류 표.
|
||||
- **수용 기준은 "처방이 좋아지는지" 가 아니라 "진단이 나오는지"** — Phase 1 은 측정 프레임워크
|
||||
완성 + (A)/(B) 판별이 목표. baseline 숫자 자체가 deliverable.
|
||||
- 회귀 가드: 기존 golden suite 21쿼리의 AggregateMetrics 가 변형 그룹 추가 후에도 동일하게
|
||||
계산되어야 함 (group=None 경로 불변 — 기존 테스트 green).
|
||||
|
||||
## 6. 롤백 / 버전
|
||||
|
||||
- `group` 필드는 additive — 기존 yaml/스키마 backward-compat, 버전 cascade 트리거 아님.
|
||||
- 평가 전용 변경이라 wire schema (`search_hit.v1`/`answer.v1`) 불변. 바이너리 surface 변경 없음
|
||||
(eval CLI 리포트 항목 추가 가능 — additive).
|
||||
- golden_queries.yaml 의 변형 그룹은 dogfood KB 스냅샷(docs=3940 / chunks=34896, 2026-05-28)에
|
||||
큐레이션 — reset/re-ingest 시 chunk_id stale → runner bail. 재큐레이션 정책은 기존과 동일.
|
||||
|
||||
## 7. 미결 (구현 계획에서 확정)
|
||||
|
||||
- `group` 정합성: 같은 그룹이 서로 다른 expected 를 가질 때 — 에러 bail vs 경고+합집합. (권장:
|
||||
같은 그룹 = 같은 expected 강제, 위반 시 loader bail.)
|
||||
- A/B 분류 임계값: "recall@pool_k high" 의 high 기준, near-tie band Δ 정의 (진단 리포트용).
|
||||
- 변형 일관성 metric 의 CLI/JSON surface 형태 (기존 `eval` 출력에 합칠지 별도 서브커맨드일지).
|
||||
- 변형 그룹 큐레이션: 어떤 의도 ~6–10 개를 고를지 — dogfood corpus 에서 한/영 양쪽으로 명확한
|
||||
정답 문서가 있는 토픽 선정 (rust 류 + 일반 토픽 섞기, 선행 ablation 토픽 재사용 가능).
|
||||
- 답변 정답 신호: `must_contain` 큐레이션 방식 (핵심 사실 substring) — 그룹 내 공유.
|
||||
|
||||
## 8. 실행 방식 (사용자 지정, 2026-05-29)
|
||||
|
||||
- spec → plan → subagent 구현. 각 작업·리뷰는 **OMC teammate** (tmux pane spawn,
|
||||
[[feedback_teammate_spawn_mode]] / [[feedback_omc_teams_usage]]).
|
||||
- 작은 작업은 sonnet, 복잡 작업은 opus ([[feedback_teammate_model_routing]] 조정).
|
||||
- 테스트용 데이터는 dogfood 데이터셋(`/build/dogfood/corpus`, `/build/dogfood/golden_queries.yaml`)
|
||||
에서 가져올 수 있음.
|
||||
- 빌드/테스트는 파일 redirect + exit code 확인 후에만 커밋 ([[project_rerank_experiment]] 교훈).
|
||||
151
docs/superpowers/specs/2026-05-30-dense-alias-vectors-design.md
Normal file
151
docs/superpowers/specs/2026-05-30-dense-alias-vectors-design.md
Normal file
@@ -0,0 +1,151 @@
|
||||
---
|
||||
title: 별칭 dense 별도 벡터 — 설계 spec
|
||||
date: 2026-05-30
|
||||
status: 설계 확정 (brainstorm + PoC 측정 완료) — plan 대기
|
||||
phase: Phase 2 (query-paraphrase robustness 처방 — dense 활용)
|
||||
related:
|
||||
- docs/superpowers/specs/2026-05-30-doc-side-expansion-design.md
|
||||
- memory: project_paraphrase_robustness, project_ranking_deferred, feedback_search_quality_dogfood
|
||||
contract_sections:
|
||||
- "design §6 (retrieval / vector store + hybrid)"
|
||||
- "design §9 (versioning cascade)"
|
||||
---
|
||||
|
||||
# 별칭 dense 별도 벡터
|
||||
|
||||
## 0. 한 줄 요약
|
||||
|
||||
doc-side expansion 의 별칭(`chunk.aliases`)은 현재 lexical FTS 채널(`chunk_aliases_fts`)에만 색인돼
|
||||
dense(e5)가 활용하지 못한다. 설명형 패러프레이즈는 dense 의 영역인데(단어 안 겹쳐도 의미 매칭), dense 가
|
||||
별칭 덕을 못 봐 `recall@50=0` 으로 남았다. **별칭을 별도 dense 벡터로 색인**(sentinel chunk_id, 본문
|
||||
벡터 불변)해 dense 가 별칭 순수 신호로 설명형을 잡게 한다. **flag off 기본**, variants + 전체 golden 회귀로 측정.
|
||||
|
||||
## 1. 진단 (PoC 측정 근거, 2026-05-30)
|
||||
|
||||
별칭을 **본문에 concat 해 한 벡터**로 임베딩한 PoC(dogfood topics 7 doc):
|
||||
- 종합 `fully_consistent 2→6, A_dominant 2→0, B_dominant 4→2, spread@10 0.75→0.25` — **명사형·한국어
|
||||
설명형·일부 영어 설명형 회복, 명사형 회귀 0**. dense 가 설명형의 본령임을 실증.
|
||||
- 남은 미회복: mvcc/raft **영어 설명형**(`how databases serve reads without locking rows`,
|
||||
`how nodes agree on a single ordered log`) — vector/hybrid 모두 top-50 밖.
|
||||
- 질문형 프롬프트 강화(`max_tokens` 384 + "질문 형태 생성") 시도 → 동일 `6/0/2/0.25`, 영어 설명형 미회복.
|
||||
- **가설**: concat 은 긴 본문 + 짧은 별칭을 한 벡터로 합쳐 **본문 의미가 별칭 신호를 희석**. 한국어
|
||||
설명형은 한국어 별칭이 풍부해 회복됐으나, 영어 설명형은 별칭 신호가 약함. → 별칭을 **별도 순수 벡터**로
|
||||
색인하면 본문 희석 없이 dense 매칭 가능(미검증 — 본 작업이 검증).
|
||||
|
||||
## 2. 설계 결정
|
||||
|
||||
| # | 결정 | 선택 | 근거 |
|
||||
|---|------|------|------|
|
||||
| D1 | 별칭 dense 색인 방식 | **별도 벡터(sentinel chunk_id)** | concat 은 본문 벡터 변경(전체 corpus 회귀 부담) + 본문 희석. 별도 벡터는 본문 벡터 불변(회귀 안전) + 별칭 순수 신호. lexical `chunk_aliases_fts` 와 대칭. |
|
||||
| D2 | flag | **`ingest.expansion.embed_aliases` default false** | `expansion.enabled`(별칭 생성)와 별개 축. 독립 on/off 측정([[feedback_search_quality_dogfood]]). |
|
||||
| D3 | RRF 통합 | VectorRetriever 내부 dedup (2채널 유지) | lexical 의 body+alias merge 와 대칭. `RetrievalDetail`/wire schema `search_hit.v1` 무변경. |
|
||||
|
||||
## 3. 아키텍처
|
||||
|
||||
### 3.1 데이터 흐름
|
||||
|
||||
```
|
||||
ingest_one_asset (embed + upsert):
|
||||
body : emb.embed(chunk.text) → VectorRecord{chunk_id: orig} (변경 없음)
|
||||
alias : if embed_aliases && aliases → emb.embed(aliases) [NEW]
|
||||
→ VectorRecord{chunk_id: "{orig}#alias", text: aliases, doc_id: 동일}
|
||||
vec_store.upsert([body, alias]) # LanceDB MergeInsert keyed on chunk_id → 별도 row 공존
|
||||
|
||||
검색 (VectorRetriever.search):
|
||||
store.search(query_vec) → raw_hits (orig + "{orig}#alias" 섞임)
|
||||
각 hit: chunk_id 가 "#alias" 로 끝나면 → 원본 strip
|
||||
seen(원본 chunk_id) dedup: 같은 원본이 body+alias 둘 다 → 첫(높은 score) 유지
|
||||
hydrate(원본 chunk_id) → SearchHit (원본 chunk_id, body 메타)
|
||||
→ 단일 vector 결과. HybridRetriever.fuse(lexical, vector) 2채널 그대로.
|
||||
```
|
||||
|
||||
### 3.2 sentinel chunk_id
|
||||
|
||||
- `ALIAS_SUFFIX = "#alias"`. ChunkId 는 blake3 hex(32 영숫자)라 `#` 미포함 → 충돌 없음.
|
||||
- alias VectorRecord: `chunk_id = format!("{orig}{ALIAS_SUFFIX}")`, `embedding_id =
|
||||
id_for_embedding(&alias_chunk_id, ...)`, `text = aliases`(별칭 원문), `doc_id`/`heading_path` 동일.
|
||||
- strip 헬퍼: `fn strip_alias_suffix(id: &str) -> &str { id.strip_suffix(ALIAS_SUFFIX).unwrap_or(id) }`.
|
||||
|
||||
### 3.3 컴포넌트
|
||||
|
||||
- **ingest (kebab-app/src/lib.rs)**: embed 블록 확장. `embed_aliases` on 이고 별칭 있는 청크는 별칭도
|
||||
임베딩 → alias VectorRecord 생성. body VectorRecord 는 그대로(chunk.text). 한 `upsert` 에 body+alias 함께.
|
||||
- **VectorRetriever.search (kebab-search/src/vector.rs)**: raw_hits 순회 시 chunk_id strip + seen
|
||||
dedup. candidate_ids/hydrate 는 strip 된 원본 사용. build_hit 도 원본 chunk_id. overfetch
|
||||
multiplier 상향(별칭 벡터로 dedup 후 k 미달 방지 — `VECTOR_OVERFETCH_MULTIPLIER` 2→3).
|
||||
- **purge**: `purge_vector_orphans_for_workspace_path`(stale_chunk_ids_at 기반) + `sweep_deleted_files`
|
||||
가 stale/삭제 chunk_id 의 `{id}#alias` 도 함께 `delete_by_chunk_ids`. (별칭 벡터는 SQLite chunks 에
|
||||
없어 stale 목록에 안 잡히므로 명시 추가 — 안 하면 orphan 별칭 벡터 누적.)
|
||||
- **config**: `IngestExpansionCfg.embed_aliases: bool`(default false) + `KEBAB_INGEST_EXPANSION_EMBED_ALIASES`.
|
||||
|
||||
### 3.5 인프라 제약 — embedding_records FK + filter_chunks (구현 중 발견, 2026-05-30)
|
||||
|
||||
sentinel chunk_id 는 chunks 테이블에 **없는** id 라, 다음 두 인프라가 sentinel 벡터를 막는다. 둘 다
|
||||
수정해야 별도 벡터가 동작한다(PoC 측정으로 확인된 차단 요인).
|
||||
|
||||
1. **embedding_records FK (breaking schema, V0XX)** — `embedding_records.chunk_id TEXT NOT NULL
|
||||
REFERENCES chunks(chunk_id) ON DELETE CASCADE`(V001__init.sql:100). LanceVectorStore.upsert 의
|
||||
phase 1(`put_embedding_records_pending`)이 sentinel chunk_id 를 INSERT 하면 **FK 위반(SQLite 787)**
|
||||
→ ingest 전체 에러. SQLite 는 ALTER 로 FK 제거 불가 → `embedding_records` **테이블 재생성**
|
||||
(rename + recreate without FK + data copy + index 재생성). V003 의 `status`/`vector_committed`
|
||||
컬럼 + `idx_embed_*` 인덱스 보존. **breaking → 버전 bump + dogfood**. (V003 주석이 "GC 스케줄러
|
||||
구현 시 이 CASCADE 제거 예정"을 이미 예고 — 프로젝트 로드맵과 정합.)
|
||||
|
||||
2. **CASCADE 대체 (orphan 정리)** — FK 의 `ON DELETE CASCADE` 가 사라지면 chunk DELETE 시
|
||||
embedding_records 가 자동 정리 안 됨. `put_chunks`(DELETE-then-INSERT) + purge 경로
|
||||
(`purge_orphan_at_workspace_path` / `purge_deleted_workspace_path`)에 **명시
|
||||
`DELETE FROM embedding_records WHERE chunk_id IN (...)`**(원본 + `{id}#alias`) 추가. V003 의
|
||||
`chunks_bd_tombstone_embeddings` BEFORE-DELETE trigger 는 FK 제거 후 오히려 tombstone 을 보존하므로,
|
||||
명시 DELETE 와 함께 정책 일관성 확인(tombstone 누적 시 GC 는 P+ 로드맵).
|
||||
|
||||
3. **filter_chunks sentinel strip (검색 차단)** — `filter_chunks`(filters.rs:81)가 LanceDB 후보를
|
||||
`embedding_records er JOIN chunks c ON c.chunk_id = er.chunk_id WHERE er.status='committed'` 로
|
||||
필터한다. sentinel chunk_id 는 chunks 에 JOIN 안 돼 **버려짐** → VectorRetriever 의 strip(§3.3)
|
||||
이전에 이미 탈락. 따라서 filter_chunks 도 candidate 의 sentinel 을 **원본으로 strip 해 JOIN**
|
||||
(committed 통과)하도록 수정. 원본 chunk 가 committed 면 sentinel 후보도 통과시킴.
|
||||
|
||||
> **PoC 근거**: 별칭-문서(별칭 순수 벡터 근사)로 영어 설명형이 rank 7~30 으로 잡힘(concat 은 본문
|
||||
> 희석으로 미회복). golden 의 특정 영어 표현은 무관 영어 코드 문서 경쟁으로 경계선 — 별도 벡터 정식
|
||||
> 구현 후 golden variants 로 회복 정도 측정. (한국어 설명형은 concat·별도 둘 다 회복.)
|
||||
|
||||
### 3.4 격리 / 회귀 안전
|
||||
|
||||
- body 벡터(chunk.text 임베딩) **불변** → 기존 명사형/본문 dense 매칭 회귀 0(concat 과 달리).
|
||||
- 별칭 벡터는 sentinel row 라 본문 벡터와 독립. flag off 면 별칭 벡터 미생성 → 기존과 동일.
|
||||
|
||||
## 4. versioning (design §9)
|
||||
|
||||
- 별칭 dense 는 additive(별도 벡터). `try_skip_unchanged` 의 기존 5버전 판단 무변경(별칭 부재가 자동
|
||||
재색인 트리거 안 함). 재생성은 `--force-reingest`.
|
||||
- embed_aliases flag 토글은 임베딩 정책 변경이나 별도 벡터라 body 임베딩 version 불변. flag off 면 wire 무변경.
|
||||
- **§3.5-1 의 embedding_records FK 제거(V0XX)는 breaking schema** → CLAUDE.md §Release 트리거: 워크스페이스
|
||||
`version` bump + 새 release cut + dogfood evidence. 기존 release binary 는 새 embedding_records 스키마와
|
||||
호환되나(FK 만 제거, 컬럼 동일), migration 자동 적용. wire schema 자체는 불변(search_hit.v1 그대로).
|
||||
|
||||
## 5. 측정 (§4.6)
|
||||
|
||||
- dogfood topics 7 doc, embed_aliases on 재임베딩 → `kebab eval variants`.
|
||||
- **효과**: 영어 설명형(mvcc/raft) `recall@50` 0→양수 회복되는지(concat 미회복분). 종합 B_dominant↓.
|
||||
- **회귀**: body 벡터 불변이라 명사형/단일쿼리 회귀 0 기대 — 전체 golden 로 확인.
|
||||
- concat PoC(6/0/2/0.25) 대비 별도 벡터가 영어 설명형까지 잡으면 추가 개선, 못 잡으면 e5 한계로 기록.
|
||||
|
||||
## 6. 범위 밖 (YAGNI)
|
||||
|
||||
- dense 모델 교체(e5 유지 — research 권고).
|
||||
- 별칭별 다중 벡터(별칭 전체를 1벡터로).
|
||||
- lexical 긴 쿼리 완화(content-OR) — dense 가 설명형 본령이라 폐기(2026-05-30 brainstorm).
|
||||
|
||||
## 7. 테스트 (TDD)
|
||||
|
||||
- `strip_alias_suffix`: `"abc#alias"`→`"abc"`, `"abc"`→`"abc"`.
|
||||
- ingest: embed_aliases on + 별칭 청크 → vector store 에 `{orig}#alias` row 존재. off → 없음.
|
||||
- VectorRetriever dedup: 같은 원본이 body+alias 둘 다 hit → 결과에 1개(원본 chunk_id), 높은 score 유지.
|
||||
- VectorRetriever strip: alias-only hit → 원본 chunk_id 로 hydrate(원본 chunk 메타).
|
||||
- purge: 청크 재처리 시 `{orig}#alias` 벡터도 삭제(orphan 잔존 0).
|
||||
- 회귀: embed_aliases off → vector 결과가 기존과 동일.
|
||||
|
||||
## 8. PR / 문서
|
||||
|
||||
- doc-side expansion 과 같은 PR. README Configuration 에 `embed_aliases`(off 기본) 명시.
|
||||
ARCHITECTURE 에 별칭 dense 별도 벡터(sentinel) 1~2줄. HOTFIXES dated entry(lexical 별칭 + dense 별칭 측정 표).
|
||||
- versioning cascade 없음(body 임베딩 불변). flag off 라 wire 무변경.
|
||||
226
docs/superpowers/specs/2026-05-30-doc-side-expansion-design.md
Normal file
226
docs/superpowers/specs/2026-05-30-doc-side-expansion-design.md
Normal file
@@ -0,0 +1,226 @@
|
||||
---
|
||||
title: 색인시 doc-side expansion (검색용 별칭 생성) — 설계 spec
|
||||
date: 2026-05-30
|
||||
status: 설계 확정 (brainstorm 완료) — plan 대기
|
||||
phase: Phase 2 (query-paraphrase robustness 처방)
|
||||
related:
|
||||
- docs/superpowers/handoffs/2026-05-30-phase2-doc-expansion-kickoff.md
|
||||
- docs/superpowers/research/2026-05-30-vocabulary-gap-recall-fix-research.md
|
||||
- docs/superpowers/specs/2026-05-29-query-paraphrase-robustness-eval-design.md
|
||||
- memory: project_paraphrase_robustness, project_crossscript_diagnosis, feedback_search_quality_dogfood
|
||||
contract_sections:
|
||||
- "design §6 (retrieval / hybrid fusion)"
|
||||
- "design §9 (versioning cascade)"
|
||||
---
|
||||
|
||||
# 색인시 doc-side expansion — 설계 spec
|
||||
|
||||
> **⚠️ 제거됨 (2026-06-03).** 본 spec 이 도입한 doc-side expansion(별칭) 기능은
|
||||
> 2026-06-03 완전히 제거되었다. 근거: 별칭 ROI 음수(cross-lingual 은 e5-large
|
||||
> 단독으로 충분, 기여는 설명형 +2 그룹뿐인데 대가가 살아있는 KB 에 지속 불가한
|
||||
> 청크당 색인-시 LLM). 제거 spec: `docs/superpowers/specs/2026-06-03-remove-doc-expansion-spec.md`,
|
||||
> HOTFIXES dated entry 2026-06-03. 이 문서는 역사적 contract 로 freeze 유지.
|
||||
|
||||
## 0. 한 줄 요약
|
||||
|
||||
문서를 색인할 때(ingest) 각 청크마다 로컬 LLM(gemma)에게 "이 내용을 찾을 사람이 던질 법한 다른
|
||||
표현·질문"(같은언어 paraphrase + 한↔영 번역 별칭)을 **1회** 생성하게 해, **별도 FTS5 채널**에
|
||||
저장한다. 검색 시 RRF 가 `{body-BM25, aliases-BM25, e5-dense}` 3채널을 융합한다. 어휘격차(B)로
|
||||
정답이 top-50 pool 에도 안 들어오던 실패(`recall@50=0`)를 lexical pool 자체를 키워 해결하는 게 목표.
|
||||
**flag off 기본**, on/off 를 `kebab eval variants` 로 정량 비교한다.
|
||||
|
||||
## 1. 배경 / 문제 (압축)
|
||||
|
||||
- Phase 1 진단: 같은 의미를 다른 단어로 물으면 정답이 top-50 pool 에도 안 들어옴(`recall@50=0`).
|
||||
rerank 는 pool 안 순서만 바꿔 무력(`[[project_rerank_experiment]]` 가설 반증).
|
||||
- 딥리서치(104 agent, 적대검증): pool-miss 의 최선책 = **색인시 doc-side expansion**. query-side
|
||||
(HyDE=거부된 per-query LLM, Vector-PRF=recall 주장 기각) 부적합. learned-sparse(SPLADE/MILCO)
|
||||
CPU/Rust turnkey 경로 없음.
|
||||
- 핵심 함정: vanilla mt5 doc2query 는 *같은 언어* query 만 생성 → 한/영 갭 못 메움. 따라서 색인시
|
||||
**KO↔EN 번역 별칭**을 함께 생성해야 함 (research §1.2). 이 교차언어 부분은 직접 벤치 논문 없는
|
||||
**합성 권고** → 우리 corpus 측정 필수.
|
||||
|
||||
## 2. 설계 결정 (brainstorm 확정)
|
||||
|
||||
| # | 결정 | 선택 | 근거 |
|
||||
|---|------|------|------|
|
||||
| D1 | 별칭 생성 단위 | **청크당 1회** | 각 조각의 세부 내용에 맞는 정밀 별칭. ingest 느려지나 효과 측정이 1순위(§4.6 측정 규율). |
|
||||
| D2 | 별칭 내용 | **같은언어 paraphrase + 한↔영 번역**, 1 LLM 호출 | 진단상 영어 paraphrase 도 miss(어휘 거리), 한/영 갭은 번역 별칭으로만 메움. 한 호출로 둘 다 → 추가 호출비용 0. |
|
||||
| D3 | 기존 문서 처리 | **additive + 수동 재색인** | 별칭은 "있으면 쓰고 없으면 본문만". flag on 이 전체 자동 재색인을 트리거하지 않음. `--force` 로 원할 때 재생성. 측정은 dogfood reset→reingest 로 통제. |
|
||||
| D4 | 품질 제어 (1차) | **단순**: 개수 상한 + 형식 검증만 | 정교한 환각 필터(임베딩 유사도, Doc2Query--)는 research openQuestion 3 = 측정 대상. 1차는 단순히 만들고 환각·팽창이 실제 문제인지 측정 후 결정. |
|
||||
|
||||
## 3. 아키텍처
|
||||
|
||||
### 3.1 데이터 흐름
|
||||
|
||||
```
|
||||
ingest_one_asset (kebab-app/src/lib.rs:~1253)
|
||||
chunks = MdHeadingV1Chunker.chunk(&canonical, policy)?
|
||||
│
|
||||
├─ [NEW] if config.ingest.expansion.enabled:
|
||||
│ for chunk in &mut chunks:
|
||||
│ aliases = ExpansionGenerator.generate(chunk)? # gemma 1회/청크
|
||||
│ chunk.aliases = Some(aliases) # 상한·형식검증 적용
|
||||
│
|
||||
app.sqlite.put_chunks(doc_id, &chunks)? # chunks.aliases 컬럼 저장
|
||||
│
|
||||
(V010 chunk_aliases_au/ai trigger) → chunk_aliases_fts 별도 테이블 색인
|
||||
│
|
||||
embedder.embed(...) → vec_store.upsert(...) # dense는 body text 기준 (변경 없음)
|
||||
|
||||
검색 (kebab-search/src/lexical.rs — body·alias 두 쿼리 + Rust merge):
|
||||
body = run_query(chunks_fts MATCH 'text : (..)') (bm25 asc) ┐ merge_body_alias:
|
||||
alias = run_alias_query(chunk_aliases_fts MATCH 'aliases : (..)') ┘ body 우선 + alias-only append
|
||||
│ → 단일 lexical 결과 (rank 부여)
|
||||
HybridRetriever.fuse: RRF(lexical, vector) # 2채널 그대로 — RetrievalDetail/wire 무변경
|
||||
```
|
||||
|
||||
**왜 lexical 내부 병합인가 (3채널 RRF 대신):** `RetrievalDetail` 은 `lexical_score`/
|
||||
`vector_score`/`*_rank` 만 보유하고 wire schema `search_hit.v1` 가 이를 그대로 노출한다. 정통
|
||||
3채널 RRF 는 `RetrievalDetail` + wire schema + `HybridRetriever` 시그니처 + 다수 테스트를 침습
|
||||
변경한다. alias-only 청크가 lexical 결과(→ hybrid pool)에 진입하기만 하면 pool-rescue 목적은
|
||||
동일하게 달성되므로, **`LexicalRetriever` 내부에서 body+alias 를 병합**해 단일 lexical 결과로
|
||||
내보낸다. `chunk_aliases_fts` 가 비면(flag off / 미생성) alias 쿼리가 0행 → merge no-op → 기존
|
||||
동작과 동일 → **search-side 는 flag 게이트 불필요, ingest-side 만 게이트**.
|
||||
|
||||
> **구현 메커니즘 (shipped):** 단일 `UNION ALL + GROUP BY` SQL 이 아니라 **두 쿼리(`run_query` +
|
||||
> `run_alias_query`) + Rust `merge_body_alias`(body 우선, body 에 없는 alias-only 만 append,
|
||||
> `fetch_limit` 절단)**. 서로 다른 FTS 테이블의 bm25 절대값을 `GROUP BY MIN` 으로 비교하는 것은
|
||||
> 무의미하므로 body-우선 Rust 병합이 의미상 더 깨끗하다(§3.3 body 보존과도 일치). raw 모드
|
||||
> (작은따옴표 식)는 body-only 컬럼 참조 가능성 때문에 alias 채널에서 제외한다(방어 가드).
|
||||
|
||||
### 3.2 컴포넌트 (단위별 책임)
|
||||
|
||||
- **`ExpansionGenerator`** (kebab-app, `kebab_llm::LanguageModel` trait 경계로 mock 가능)
|
||||
- 입력: 청크(본문 + heading_path 컨텍스트), config(model, max_aliases, prompt_version).
|
||||
- 출력: 검증된 별칭 문자열(개행 join). 빈 출력·과길이 drop, 개수 상한 적용.
|
||||
- 의존: `LanguageModel::generate_stream`(스트림을 모아 문자열). 기존 `OllamaLanguageModel`
|
||||
재사용. LLM 호출 실패/빈 결과 시 해당 청크는 별칭 없이 진행(ingest 비중단 — **fail-soft**).
|
||||
- **V010 migration** — `chunks.aliases TEXT` 컬럼 + **별도 `chunk_aliases_fts` virtual table**
|
||||
+ 별도 trigger 3종(`chunk_aliases_ai/ad/au`). 기존 `chunks_fts` / `chunks_ai/ad/au`(§5.5
|
||||
verbatim CI 대상)는 **무수정**. tokenizer `unicode61`(V009 동일).
|
||||
- **`LexicalRetriever` body+alias 병합** (kebab-search/lexical.rs) — 기존 `run_query`(body) +
|
||||
신규 `run_alias_query`(`chunk_aliases_fts` MATCH, `chunks`/`documents` JOIN, snippet 은 본문
|
||||
`substr(c.text,1,?)`) 를 각각 실행하고 `merge_body_alias`(body 우선, body 에 없는 alias-only 만
|
||||
append, `fetch_limit` 절단)로 합친다. `build_match_string` 은 컬럼 파라미터화(`text :` / `aliases :`).
|
||||
alias-only 청크가 결과에 진입. `chunk_aliases_fts` 가 비면 alias 쿼리 0행 → 기존과 동일(회귀 안전).
|
||||
- **config `[ingest.expansion]`** — `IngestExpansionCfg`:
|
||||
- `enabled: bool` (default **false**)
|
||||
- `model: String` (default = `models.llm.model`)
|
||||
- `max_aliases_per_chunk: usize` (default 8)
|
||||
- `prompt_version: String` (default `expansion-v1`)
|
||||
- env override: `KEBAB_INGEST_EXPANSION_ENABLED`, `KEBAB_INGEST_EXPANSION_MODEL`,
|
||||
`KEBAB_INGEST_EXPANSION_MAX_ALIASES`, `KEBAB_INGEST_EXPANSION_PROMPT_VERSION`.
|
||||
|
||||
### 3.3 격리 / 코드 식별자 보존 (load-bearing)
|
||||
|
||||
- `chunks_fts.text`(body) 는 **verbatim 유지**, 별칭은 **별도 테이블** `chunk_aliases_fts`.
|
||||
body-우선 merge 라 body 매칭이 항상 alias-only 보다 앞서 보존되어, 코드 식별자(`Vec::with_capacity`)
|
||||
정확매칭이 별칭 노이즈에 희석되지 않음.
|
||||
- dense(e5) 임베딩은 **body text 기준 그대로** — 별칭을 임베딩에 넣지 않음(research: e5 dense
|
||||
유지, bge-m3 dense 는 실측 더 나빴음). 별칭은 lexical 채널에만 기여.
|
||||
|
||||
## 4. 스키마 / migration (V010)
|
||||
|
||||
현재 최신 = V009. 신규 = **V010__chunk_aliases.sql**. 기존 `chunks_fts` / `chunks_ai/ad/au`
|
||||
(§5.5 verbatim CI `fts_v009_matches_design_section_5_5_verbatim` 대상)는 **건드리지 않는다.**
|
||||
|
||||
```sql
|
||||
-- 1) chunks 테이블에 별칭 컬럼 (nullable; 미생성/flag off = NULL)
|
||||
ALTER TABLE chunks ADD COLUMN aliases TEXT;
|
||||
|
||||
-- 2) 별도 FTS5 가상 테이블 (body 와 분리된 lexical 채널)
|
||||
CREATE VIRTUAL TABLE chunk_aliases_fts USING fts5(
|
||||
chunk_id UNINDEXED,
|
||||
doc_id UNINDEXED,
|
||||
aliases,
|
||||
tokenize = 'unicode61' -- V009 본문과 동일 tokenizer
|
||||
);
|
||||
|
||||
-- 3) 별도 sync trigger 3종 (aliases NULL 이면 색인 안 함)
|
||||
CREATE TRIGGER chunk_aliases_ai AFTER INSERT ON chunks WHEN new.aliases IS NOT NULL BEGIN
|
||||
INSERT INTO chunk_aliases_fts(chunk_id, doc_id, aliases)
|
||||
VALUES (new.chunk_id, new.doc_id, new.aliases);
|
||||
END;
|
||||
CREATE TRIGGER chunk_aliases_ad AFTER DELETE ON chunks BEGIN
|
||||
DELETE FROM chunk_aliases_fts WHERE chunk_id = old.chunk_id;
|
||||
END;
|
||||
CREATE TRIGGER chunk_aliases_au AFTER UPDATE ON chunks BEGIN
|
||||
DELETE FROM chunk_aliases_fts WHERE chunk_id = old.chunk_id;
|
||||
INSERT INTO chunk_aliases_fts(chunk_id, doc_id, aliases)
|
||||
SELECT new.chunk_id, new.doc_id, new.aliases WHERE new.aliases IS NOT NULL;
|
||||
END;
|
||||
|
||||
-- 4) backfill 불필요: 기존 행 aliases 전부 NULL → chunk_aliases_fts 빈 채로 시작.
|
||||
|
||||
-- 5) corpus_revision bump (in-process search cache 무효화; V009 와 동일 패턴)
|
||||
UPDATE kv SET value = CAST(CAST(value AS INTEGER) + 1 AS TEXT) WHERE key = 'corpus_revision';
|
||||
```
|
||||
|
||||
- `put_chunks` 의 DELETE-then-INSERT(documents.rs:101) 는 `chunk_aliases_ad`(DELETE) +
|
||||
`chunk_aliases_ai`(INSERT) 를 발화 → 별칭 동기화 자동. INSERT 문에 `aliases` 컬럼만 추가.
|
||||
- migration 은 refinery 자동 embed/apply. **migration = breaking schema change** → CLAUDE.md
|
||||
§Release / Dogfood trigger 발동(V010, dogfood + release notes).
|
||||
- `kebab_core::Chunk` 에 `aliases: Option<String>` 필드 추가(`#[serde(default)]`).
|
||||
|
||||
## 5. gemma 프롬프트 (expansion-v1)
|
||||
|
||||
청크 본문 + heading_path 를 주고, **검색 별칭만** 줄 단위로 출력하게 한다(설명·번호 금지).
|
||||
같은언어 표현 + 반대언어(한↔영) 번역을 섞어 최대 `max_aliases_per_chunk` 개.
|
||||
|
||||
요지(plan 단계에서 정확한 문구·few-shot 확정):
|
||||
- "다음 문단을 검색할 사용자가 쓸 법한 짧은 질의/표현을 생성하라. 동의어·풀어쓴 표현 포함.
|
||||
문단이 한국어면 영어 표현도, 영어면 한국어 표현도 섞어라. 한 줄에 하나, 설명 없이."
|
||||
- 출력 파싱: 줄 단위 split → trim → 빈 줄/번호접두/과길이(예: >120자) drop → 상한 N개.
|
||||
- 결정성: `temperature` 낮게, `seed` 고정(config 의 llm seed 재사용) → 재색인 재현성.
|
||||
|
||||
## 6. versioning cascade (design §9)
|
||||
|
||||
- 별칭은 **additive** → `try_skip_unchanged`(kebab-app:~886) 의 기존 5버전(parser/chunker/
|
||||
embedding…) 판단에 **넣지 않는다**. 즉 flag 토글이 전체 문서를 stale 로 만들지 않음(D3).
|
||||
- `expansion_version`(= `prompt_version`)을 documents 레코드에 기록(추적용). 프롬프트가 바뀌면
|
||||
추후 재생성 대상 식별 가능. 단 자동 cascade 는 걸지 않음(수동 `--force`).
|
||||
- 측정/실사용에서 별칭을 새로 입히려면: `kebab ingest --force`(전체 재처리) 또는 dogfood
|
||||
`kebab reset` + reingest.
|
||||
|
||||
## 7. 측정 (§4.6 측정 규율 — 프록시 금지, 추측 금지)
|
||||
|
||||
```
|
||||
# baseline (flag off, 또는 Phase 1 기록): groups=8 fully_consistent=2 A=2 B=4 spread@10=0.750
|
||||
KEBAB_EVAL_GOLDEN=/build/dogfood/golden_queries.yaml \
|
||||
kebab eval run --config /build/dogfood/config.toml --mode hybrid --k 50
|
||||
KEBAB_EVAL_GOLDEN=/build/dogfood/golden_queries.yaml \
|
||||
kebab eval variants <run_id> --config /build/dogfood/config.toml
|
||||
|
||||
# 처방 on: expansion enabled 로 reset+reingest 후 동일 측정
|
||||
```
|
||||
|
||||
- 성공 기준: **B_dominant↓, fully_consistent↑, spread@10↓** (on vs off). 전체 golden 회귀 확인
|
||||
(기존 Ok 그룹이 깨지지 않는지).
|
||||
- 측정값은 grep clean 추출 → Read 확인값만 기록(추측 금지). HOTFIXES + release notes-draft 에 cascade.
|
||||
|
||||
## 8. 범위 밖 (YAGNI)
|
||||
|
||||
- **BGE-M3 sparse 4th RRF 채널** — research §1.4: 교차언어 약함(우리 핵심은 KO↔EN 갭). 측정 후
|
||||
단일언어 lift 가 필요하다 판단되면 별도 작업.
|
||||
- **임베딩 유사도 환각 필터 / Doc2Query--/++** — D4. 측정에서 환각·팽창이 실제 문제일 때.
|
||||
- **문서/혼합 단위 생성** — D1 에서 청크당으로 확정.
|
||||
- **별칭의 dense 임베딩** — body 기준 유지(§3.3).
|
||||
|
||||
## 9. 테스트 전략 (TDD — plan 에서 task 분해)
|
||||
|
||||
- migration: V010 적용 후 `chunks.aliases` + `chunks_fts.aliases` 존재, 기존 행 본문 색인 동일.
|
||||
- `put_chunks`/`get` round-trip: `aliases=Some(..)` 저장·조회.
|
||||
- FTS5 alias 검색: `chunk_aliases_fts` 의 term 으로 MATCH 시 해당 chunk 회수.
|
||||
- lexical UNION: body 에 없고 alias 에만 있는 term 으로 검색 시 alias-only 청크가 `LexicalRetriever`
|
||||
결과(→ hybrid pool)에 진입(pool-rescue 핵심 회귀). 양쪽 매칭 청크는 중복 없이 1개.
|
||||
- `ExpansionGenerator`(LLM mock): 프롬프트→파싱, 상한 N 적용, 빈/과길이 drop, LLM 실패 시 fail-soft.
|
||||
- 회귀: `chunk_aliases_fts` 빈 상태에서 lexical 결과가 V009 와 동일(alias 쿼리 0행 → merge no-op).
|
||||
|
||||
## 10. PR / 문서 동기화
|
||||
|
||||
- gitea-pr 리뷰 루프(`[[feedback_pr_workflow]]`). flag off 기본.
|
||||
- user-facing surface(신규 config `[ingest.expansion]`, `KEBAB_INGEST_EXPANSION_*` env, V010
|
||||
migration) → 같은 PR 에서 README(좁게: flag 존재+포인터) + HANDOFF + ARCHITECTURE 동기화
|
||||
(`[[feedback_readme_sync_rule]]`). flag 망라는 `--help`/config 예제에 위임.
|
||||
- V010 = breaking schema → dogfood evidence(HOTFIXES dated entry) + release notes-draft 4단락.
|
||||
268
docs/superpowers/specs/2026-05-31-config-migration-design.md
Normal file
268
docs/superpowers/specs/2026-05-31-config-migration-design.md
Normal file
@@ -0,0 +1,268 @@
|
||||
# config 마이그레이션 — 설계 (spec)
|
||||
|
||||
> 2026-05-31. config.toml **스키마 진화 시 기존 사용자 파일을 자동 갱신**하는 기능의
|
||||
> 설계 계약. kickoff 인계 문서
|
||||
> [`2026-05-31-config-migration-kickoff.md`](../handoffs/2026-05-31-config-migration-kickoff.md)
|
||||
> 의 brainstorm 결과를 확정한 spec 이다. 본 문서를 기준으로 plan → 구현.
|
||||
|
||||
## 0. 결정 요약 (brainstorm 게이트)
|
||||
|
||||
| 축 | 결정 | 근거 |
|
||||
|----|------|------|
|
||||
| **트리거** | 명시 명령 `kebab config migrate` + `kebab doctor` 안내 | 예측 가능성·안전. load 시 자동 쓰기는 쓰기 권한/동시 실행/손상 위험. |
|
||||
| **주석 보존** | `toml_edit` 부분 편집 | 사용자가 손본 값·주석·순서·정렬 100% 보존. 빠진 것만 추가. |
|
||||
| **버전 메커니즘** | reconciliation(additive) + step 체인(non-additive) 하이브리드 | kebab config 는 `schema_version` 이 줄곧 `1` 인 채로 섹션이 누적돼 버전 번호만으로 "무엇이 빠졌는지" 구분 불가 → 구조 비교가 본질. |
|
||||
|
||||
## 1. 동기 (kickoff §1 재확인)
|
||||
|
||||
v0.21.0 에서 `[ingest.expansion]` 등 섹션이 늘었지만, 기존 사용자 config.toml 은
|
||||
serde default 로 **동작은 호환**(off 로 로드)되나 그 섹션이 **파일에 써지지 않아**
|
||||
사용자가 파일을 열어도 새 기능의 존재·노브를 알 수 없다. DB 는 V00X refinery
|
||||
마이그레이션이 있는데 config 는 없다 — 이걸 만든다.
|
||||
|
||||
핵심: **데이터 무효화가 아니라 *파일 가시성* 문제**. 읽기 호환성은 이미 확보돼 있으므로
|
||||
(`#[serde(default)]`), 만들 것은 *사용자 파일을 새 스키마에 맞춰 갱신*하는 것이다.
|
||||
|
||||
## 2. 비목표 (YAGNI)
|
||||
|
||||
- config 값의 **의미적 검증**(예: score_gate 범위 체크) — 별개 기능. 본 작업 범위 아님.
|
||||
- **load 시 자동 마이그레이션** — 명시적으로 제외(트리거 결정). 추후 필요 시 별 작업.
|
||||
- **다운그레이드**(새 → 옛 스키마) — 단방향만.
|
||||
- 기존 사용자 **값의 재조정**(default 가 바뀌었다고 사용자 값 덮어쓰기) — 절대 안 함.
|
||||
마이그레이션은 *없는 것 추가* + *deprecated 정리*만. 사용자가 명시한 값은 불가침.
|
||||
|
||||
## 3. 아키텍처 — 두 메커니즘
|
||||
|
||||
마이그레이션은 사용자 파일(`toml_edit::DocumentMut`)에 다음 순서로 적용한다.
|
||||
|
||||
```
|
||||
원본 파일 → [1. step 체인(non-additive)] → [2. reconciliation(additive)] → [3. schema_version stamp] → 결과
|
||||
```
|
||||
|
||||
### 3.1 Reconciliation (additive — 핵심 메커니즘)
|
||||
|
||||
**정의**: "default Config 구조에는 있지만 사용자 파일에 없는 테이블/키를, 설명 주석과
|
||||
함께 사용자 파일에 추가한다." 버전과 무관하게 동작하며 멱등이다.
|
||||
|
||||
**참조 문서 = 주석 달린 default**: `annotated_default_document()` 가 단일 진실 원천이다.
|
||||
|
||||
```
|
||||
fn annotated_default_document() -> toml_edit::DocumentMut
|
||||
// Config::defaults() 를 toml_edit Document 로 직렬화한 뒤,
|
||||
// 주석 카탈로그(§3.3)의 설명을 각 테이블/키의 decor(prefix)에 부착.
|
||||
// → 이 문서가 "완전체 config.toml" 의 정의.
|
||||
```
|
||||
|
||||
`kebab init` 도 이 함수의 출력을 그대로 파일로 쓴다(§5.2). 즉 **init 과 migrate 가
|
||||
동일한 참조 문서를 공유** → 주석·구조의 단일 원천.
|
||||
|
||||
**reconcile 알고리즘** (참조 문서 `ref` → 사용자 문서 `user`, 재귀):
|
||||
|
||||
```
|
||||
for each (key, ref_item) in ref (문서 순서 유지):
|
||||
if key 가 user 에 없음:
|
||||
user 에 ref_item 을 통째 복사 (decor=주석 포함). → change: added_section / added_key
|
||||
else if ref_item 과 user[key] 가 둘 다 테이블:
|
||||
recurse(ref_item, user[key]) # 하위만 비교
|
||||
else:
|
||||
# 키가 이미 존재(값이 default 와 달라도) → 건드리지 않음. (값 불가침)
|
||||
```
|
||||
|
||||
- **삽입 위치**: 누락 키는 해당 테이블 **끝에 append**(결정적·단순). 사용자가 짜둔 기존
|
||||
순서는 보존되고 새 항목만 뒤에 붙는다.
|
||||
- **중첩 테이블**: `[ingest]` 는 있는데 `[ingest.expansion]` 이 없으면 `expansion`
|
||||
하위 테이블만 추가. `[ingest]` 자체가 없으면 `[ingest]` + 그 안의 모든 하위를 추가.
|
||||
- **값 불가침 예시**: 사용자가 `score_gate = 0.8` 로 바꿔뒀고 default 가 0.6 이어도,
|
||||
키가 존재하므로 **0.8 유지**. 마이그레이션은 0.6 으로 되돌리지 않는다.
|
||||
|
||||
### 3.2 Step 체인 (non-additive)
|
||||
|
||||
`schema_version` 기반 버전별 변환 함수. additive 가 아닌 변경(deprecated 제거, rename,
|
||||
형식 변환)을 담당한다. DB refinery 패턴 차용.
|
||||
|
||||
```
|
||||
const CURRENT_SCHEMA_VERSION: u32 = 2; // 이번 작업에서 1 → 2
|
||||
|
||||
fn step_1_to_2(doc: &mut DocumentMut, changes: &mut Vec<MigrationChange>)
|
||||
// v1 → v2 변환: 옛 `workspace.include` 키 제거 (p9-fb-25 deprecated).
|
||||
// - doc["workspace"]["include"] 존재 시 remove → change: removed_deprecated.
|
||||
// - 없으면 noop (멱등).
|
||||
```
|
||||
|
||||
- **실행 범위**: 파일의 `schema_version`(없으면 1 로 간주) 부터 `CURRENT` 까지 순차 적용.
|
||||
이미 `CURRENT` 이상이면 step 없음.
|
||||
- 각 step 은 **개별적으로 멱등**(이미 적용된 상태에서 재실행해도 noop).
|
||||
- 이번 작업의 유일한 step 은 `1→2`(workspace.include 제거). 누적된 섹션 추가
|
||||
(image/ui/ingest/pdf/logging/expansion)는 **전부 reconciliation 이 처리**하므로
|
||||
step 으로 만들지 않는다. step 체인은 "구조로 표현 못 하는 변환"만 담는다.
|
||||
|
||||
### 3.3 주석 카탈로그
|
||||
|
||||
"섹션/키 → 한국어 설명 주석" 매핑을 kebab-config 의 마이그레이션 모듈 한 곳에 정적
|
||||
정의한다. 단일 원천 — README/SMOKE 와 중복하지 않고 여기를 정본으로.
|
||||
|
||||
- 기존 `init_workspace` 의 헤더(경로 정책 설명, `kebab-app/src/lib.rs:147~`)는
|
||||
**문서 레벨 prefix** 로 이전한다(`annotated_default_document` 가 부착).
|
||||
- 섹션별 주석은 README Configuration §의 노브 설명을 차용해 **간결**하게(1~2줄).
|
||||
예: `[ingest.expansion]` → `# doc-side 별칭 확장 (기본 off). 검색 패러프레이즈 강건성↑.`
|
||||
- 주석 문구는 짧게, 과하지 않게. 전체 문서는 생성된 파일·README·SMOKE 참고로 유도.
|
||||
|
||||
### 3.4 멱등성 보장 (안전 1축)
|
||||
|
||||
- reconciliation: 이미 있는 키는 skip → 두 번째 실행 시 changes 비어 있음.
|
||||
- step: 각 step 이 noop-safe.
|
||||
- 결과: **마이그레이션 후 재실행하면 `changed=false`, 파일 미변경.** 이것이 doctor
|
||||
체크(§5.3)와 멱등 테스트의 핵심 단언.
|
||||
|
||||
## 4. 안전 3축 (kickoff §4.4)
|
||||
|
||||
1. **멱등** — §3.4.
|
||||
2. **백업** — 파일 수정 직전 `<config>.bak` 생성(원본 복사). 기존 `.bak` 있으면 덮어씀
|
||||
(단순화; 변경 내용은 dry-run 으로 사전 확인 가능). dry-run 시 백업도 안 만듦.
|
||||
3. **dry-run** — `--dry-run` 은 changes 만 계산·출력하고 **파일·백업 모두 미수정**.
|
||||
|
||||
**실패 시 원본 보존(atomic write)**: 편집 결과는 `<config>.tmp` 에 먼저 쓰고
|
||||
`rename(tmp, config)` 로 교체. rename 이전 어느 단계에서 실패해도 원본 불변. 순서:
|
||||
`백업 생성 → tmp 쓰기 → tmp 검증(재파싱 round-trip) → atomic rename`.
|
||||
|
||||
## 5. 표면 (surface)
|
||||
|
||||
### 5.1 CLI — `kebab config migrate`
|
||||
|
||||
신규 top-level `Config` 서브커맨드 그룹(clap nested, `Inspect`/`List` 패턴 차용):
|
||||
|
||||
```
|
||||
kebab config migrate [--dry-run] [--json]
|
||||
```
|
||||
|
||||
- 전역 `--config <path>` 존중 (facade rule). 미지정 시 XDG 기본 경로.
|
||||
- 대상 파일이 없으면 에러: `config 파일이 없습니다. 먼저 kebab init 을 실행하세요.`
|
||||
(`--json` 시 `error.v1`, code `config_not_found`).
|
||||
- 사람용 출력: 변경 목록(추가된 섹션/키, 제거된 deprecated) + 백업 경로 + "N changes
|
||||
applied" 또는 "already up to date".
|
||||
- `--json`: `config_migration.v1` (§5.4).
|
||||
|
||||
**facade**: kebab-cli 는 kebab-app 의
|
||||
`config_migrate_with_config_path(config_path: Option<&Path>, dry_run: bool)
|
||||
-> anyhow::Result<ConfigMigrationReport>` 를 호출(파일 read/백업/atomic write
|
||||
오케스트레이션은 app 계층, 순수 변환은 config 계층 — §6).
|
||||
|
||||
### 5.2 `kebab init` 영향 (user-visible)
|
||||
|
||||
`init_workspace` 가 `annotated_default_document()` 출력을 쓰도록 변경. 결과: init 이
|
||||
생성하는 config.toml 이 **섹션별 주석을 포함**(기존엔 헤더만). 이는 user-visible surface
|
||||
변경이므로 README Configuration §·docs/SMOKE.md 의 config 예시 블록 동기화 필요.
|
||||
|
||||
### 5.3 `kebab doctor` 체크 추가 (additive)
|
||||
|
||||
config load 체크 직후 `config_migration` 체크 1개 추가:
|
||||
|
||||
- 내부적으로 dry-run 마이그레이션 실행 → changes 비었으면 `ok=true`,
|
||||
detail `config up to date (schema v2)`, hint=None.
|
||||
- changes 있으면 `ok=false`, detail `N pending changes (added M sections, removed K
|
||||
deprecated)`, hint `run kebab config migrate to update your config.toml`.
|
||||
- **trade-off (확정)**: `DoctorCheck` 는 `ok: bool` 뿐이고 hint 는 `ok==false` 일 때
|
||||
표시되는 규약이므로, "마이그레이션 필요"는 `ok=false` 로 신호한다. 이는 전체
|
||||
`DoctorReport.ok`(모든 체크의 AND)를 false 로 만든다 — 즉 *완전히 동작하지만
|
||||
config 가 옛 스키마인* 환경에서 `kebab doctor` 가 "비정상"으로 보고된다. 이를
|
||||
의도된 동작으로 받아들인다(doctor = "정리할 것이 있는가"의 점검이고, hint 가 정확한
|
||||
교정 명령을 제시). 새 키만 추가하는 additive 변경을 "건강 실패"로 과하게 보는 면이
|
||||
있으나, 별도 warn 상태를 도입하는 것(스키마·표면 변경)보다 단순함을 택한다.
|
||||
- `doctor.v1` 스키마는 변경 없음(checks 배열에 행 1개 추가 — additive, backward-compat).
|
||||
|
||||
### 5.4 wire schema `config_migration.v1` (신규)
|
||||
|
||||
`docs/wire-schema/v1/config_migration.schema.json` 신설. `--json` 출력:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": "config_migration.v1",
|
||||
"dry_run": true,
|
||||
"config_path": "/home/me/.config/kebab/config.toml",
|
||||
"from_schema_version": 1,
|
||||
"to_schema_version": 2,
|
||||
"changed": true,
|
||||
"backup_path": null,
|
||||
"changes": [
|
||||
{ "kind": "added_section", "path": "ingest.expansion", "detail": "doc-side 별칭 확장 (기본 off)" },
|
||||
{ "kind": "added_key", "path": "logging.enabled", "detail": "ingest 로그 활성화" },
|
||||
{ "kind": "removed_deprecated","path": "workspace.include","detail": "p9-fb-25: extractor 자동 결정" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- `changed`: 실제(또는 dry-run 시 가정) 변경 발생 여부. false 면 changes=[].
|
||||
- `backup_path`: 실제 적용 시 `.bak` 경로, dry-run 시 `null`.
|
||||
- `kind` enum: `added_section | added_key | removed_deprecated`. (향후 `renamed`,
|
||||
`reformatted` 확장 여지 — 본 작업은 3종.)
|
||||
- additive 신규 스키마 → 기존 통합 영향 없음. wire major bump 아님(v1 추가).
|
||||
|
||||
## 6. 코드 배치 (crate 경계)
|
||||
|
||||
| 위치 | 책임 | 비고 |
|
||||
|------|------|------|
|
||||
| `crates/kebab-config/src/migrate.rs` (신규) | **순수 변환**: `annotated_default_document`, `reconcile`, step 체인, `CURRENT_SCHEMA_VERSION`, 주석 카탈로그, `MigrationChange`/`ConfigMigrationReport` 타입, `migrate_document(doc) -> Vec<MigrationChange>` | I/O 없음. 문자열 in → 문자열 out 로 테스트 가능. |
|
||||
| `crates/kebab-config/Cargo.toml` | `toml_edit = "0.22"` 의존성 추가 | 주석 보존 편집 핵심. |
|
||||
| `crates/kebab-app/src/lib.rs` | **I/O 오케스트레이션**: `config_migrate_with_config_path`(read → migrate_document → 백업 → tmp write → atomic rename), `init_workspace` 가 `annotated_default_document` 사용하도록 수정, doctor 에 체크 추가 | facade. fs 부작용은 app 계층. |
|
||||
| `crates/kebab-cli/src/main.rs` | `Config { Migrate { dry_run } }` 서브커맨드, 사람용 출력 | kebab-app facade 만 호출. |
|
||||
| `crates/kebab-cli/src/wire.rs` | `wire_config_migration(report) -> Value` | `config_migration.v1` 직렬화. |
|
||||
| `docs/wire-schema/v1/config_migration.schema.json` (신규) | wire 계약 | |
|
||||
|
||||
**경계 근거**: kebab-config 는 이미 파일 *읽기*(`from_file`)를 하지만, *쓰기*는
|
||||
`init_workspace`(app)에 있다. 일관성·테스트성 위해 순수 변환은 config, 부작용(백업·쓰기)
|
||||
은 app. doctor(app)·cli 모두 동일 순수 변환을 재사용.
|
||||
|
||||
## 7. schema_version 의 새 의미
|
||||
|
||||
- 기존: 항상 `1`, 검증·로직에 안 쓰이는 장식.
|
||||
- 신규: "이 파일이 sync 된 스키마 버전" 마커 + step 체인의 축.
|
||||
- `Config::defaults().schema_version` 및 `CURRENT_SCHEMA_VERSION` 을 **2** 로 bump.
|
||||
마이그레이션 완료 시 사용자 파일의 `schema_version` 을 `CURRENT` 로 stamp.
|
||||
- 읽기 경로(`from_file`)는 여전히 `schema_version` 으로 **거부하지 않음**(forward-compat
|
||||
유지). 즉 옛 바이너리로 새 파일을, 새 바이너리로 옛 파일을 읽어도 동작.
|
||||
|
||||
## 8. 문서 동기화 (user-facing surface)
|
||||
|
||||
- **README.md Configuration §**: `kebab config migrate` 한 줄 + init config 가 섹션
|
||||
주석을 갖는다는 설명. config 예시 블록을 `annotated_default_document` 산출과 일치.
|
||||
- **docs/SMOKE.md**: config 예시 블록 동기화. migrate dry-run smoke 단계 추가.
|
||||
- **docs/DOGFOOD.md**: config 관련 section 에 migrate 시나리오(옛 파일 → migrate →
|
||||
섹션 가시성 확인) 추가.
|
||||
- **tasks/HOTFIXES.md**: 머지 후 dated entry(`## YYYY-MM-DD — config 마이그레이션`),
|
||||
도그푸딩 evidence(옛 config 에 빠진 섹션 N개 추가 + workspace.include 제거 멱등 확인).
|
||||
- **HANDOFF.md**: 해당되면 한 줄.
|
||||
|
||||
## 9. 릴리스 트리거 판단
|
||||
|
||||
- 신규 CLI 서브커맨드(`config migrate`) + doctor 체크 + init 출력 변경 = **user-visible
|
||||
surface 변경** → 도그푸딩 필수, README 동기화 필수.
|
||||
- `schema_version` bump(1→2)는 **additive**(데이터 무효화 아님, 읽기 호환 유지) →
|
||||
CLAUDE.md §Versioning 의 DB/wire breaking 기준엔 해당 안 됨. 다만 surface 누적이
|
||||
있으므로 **minor bump** 대상일 수 있음. 실제 bump/release 컷 시점은 사용자 판단.
|
||||
|
||||
## 10. 테스트 전략 (plan 의 TDD 근거)
|
||||
|
||||
순수 변환(kebab-config)이 테스트의 중심 — 문자열 in/out, fs 불필요:
|
||||
|
||||
1. **reconciliation 추가**: 옛 config 문자열(섹션 누락) → migrate → 누락 섹션이 주석과
|
||||
함께 추가됐고, 기존 키·주석·순서는 보존.
|
||||
2. **값 불가침**: 사용자가 바꾼 값(예: `score_gate = 0.8`)이 migrate 후에도 유지.
|
||||
3. **멱등**: migrate 출력을 다시 migrate → `changed=false`, 동일 문자열.
|
||||
4. **step (workspace.include 제거)**: 옛 키 있는 문자열 → 제거됨 + change 기록. 없으면 noop.
|
||||
5. **schema_version stamp**: 결과의 `schema_version = 2`. 없던 파일엔 추가됨.
|
||||
6. **주석 보존**: 사용자가 임의 키에 단 주석이 migrate 후에도 그대로.
|
||||
7. (app) **백업·atomic·실패 보존**: 백업 파일 생성, tmp rename, 손상 입력 시 원본 불변.
|
||||
8. (app) **dry-run**: 파일·백업 미생성, report.changed 정확.
|
||||
9. (cli/wire) `config_migration.v1` 직렬화 형태.
|
||||
|
||||
## 11. Risks / notes
|
||||
|
||||
- `toml_edit` 신규 의존성 — kebab-config 에 추가. `toml`(0.8)과 공존(serde 경로는
|
||||
여전히 `toml`, 편집 경로만 `toml_edit`). 버전은 구현 시 최신 0.22.x 확인.
|
||||
- reconciliation 의 "끝에 append" 는 사용자가 짠 미적 순서를 흩뜨릴 수 있으나(새 섹션이
|
||||
뒤로 몰림), 값·주석·기존 순서 보존이 우선이며 단순·결정적이라 채택.
|
||||
- 첫 step(`1→2`)은 사실상 이미 무시되는 `workspace.include` 청소뿐 — step 체인은 주로
|
||||
*프레임워크*로서 미래 non-additive 변경을 위해 깔아둔다.
|
||||
- kickoff 인계 문서와의 차이: kickoff §4.2 는 "버전별 변환 함수 체인"만 제안했으나,
|
||||
kebab 의 serde-default 특성상 additive 변경은 step 으로 표현하기 부적절(버전 무관) →
|
||||
**reconciliation 을 1급 메커니즘으로 승격**하고 step 은 non-additive 전용으로 한정.
|
||||
262
docs/superpowers/specs/2026-05-31-derivation-cache-design.md
Normal file
262
docs/superpowers/specs/2026-05-31-derivation-cache-design.md
Normal file
@@ -0,0 +1,262 @@
|
||||
# 내용 해시 기반 파생물 캐시 (Derivation Cache)
|
||||
|
||||
> 작성 2026-05-31. 비용 큰 ingest 파생물(embedding 벡터 / LLM 별칭 / 한국어 형태소)을
|
||||
> 청크 **내용 해시** 키로 캐싱해, 문서 갱신·재색인 시 변경되지 않은 청크의 재계산을 없앤다.
|
||||
|
||||
## 1. 문제
|
||||
|
||||
현재 kebab ingest 는 **doc 단위 skip**(`try_skip_unchanged`, lib.rs:894)만 한다. 변경된
|
||||
문서는 모든 청크를 재파싱·재청킹·재임베딩·재별칭한다(`put_chunks` 가 doc 의 청크를
|
||||
통째 DELETE 후 재INSERT — documents.rs:113, embedding/alias/tokens 무조건 재계산).
|
||||
|
||||
측정 증거: 정답 18개 문서의 별칭 재생성에 **2.5시간**(gemma LLM, doc 당 ~39청크).
|
||||
embedding 도 전체 재계산. 문서 한 줄만 고쳐도 동일 비용이 든다. 실사용(나무위키
|
||||
~2천 문서) 시 재색인이 비현실적으로 느리다.
|
||||
|
||||
`chunk_id` 는 `id_for_block` 의 `ordinal + span`(ids.rs) 때문에 **위치 기반**이라,
|
||||
chunk_id 를 캐시 키로 쓰면 중간 수정 시 뒤 청크가 전부 무효화된다 → 캐시 키는
|
||||
**청크 text 의 내용 해시**여야 위치와 무관하게 재사용된다.
|
||||
|
||||
> **`chunk_id` vs `cache_key` — 둘은 완전히 별개다(가장 혼동하는 지점).**
|
||||
> - **`chunk_id`** 는 LanceDB 벡터 / SQLite chunk row 의 **식별자**다. `id_for_block`
|
||||
> 이 `ordinal + source_span`(ids.rs) 을 canonical-JSON+blake3 한 **위치 기반** 해시라,
|
||||
> 문서 중간이 밀리면 뒤 청크의 chunk_id 가 바뀐다. 이 작업은 **chunk_id 생성 방식을
|
||||
> 전혀 바꾸지 않는다**(frozen 동작 — §2 비목표).
|
||||
> - **`cache_key`** 는 `derivation_cache` 테이블의 **조회 키**다. `chunk.text` 의 NFC
|
||||
> 정규화 **내용 해시** + kind + version_key 로만 만든다(위치·chunk_id·문서 무관).
|
||||
> - 즉 위치가 밀려 chunk_id 가 바뀌어도, 내용이 같은 청크는 같은 cache_key 로 캐시
|
||||
> 히트한다. chunk_id 는 "이 벡터가 어디에 속하나", cache_key 는 "이 내용을 전에
|
||||
> 계산했나" — 묻는 질문이 다르다. 별칭 sentinel chunk_id(`{orig}#alias#N`) 역시
|
||||
> 벡터 식별자일 뿐 cache_key 와 무관하며, 별칭 dense 벡터의 cache_key 는 **별칭
|
||||
> 문자열 자체**의 embedding 내용 해시다(§3.4).
|
||||
|
||||
구체 예: 문서 중간에 헤딩/내용이 삽입되면 뒤 청크들의 ordinal/span 이 밀려
|
||||
chunk_id 가 바뀌고 `put_chunks` 가 그 문서의 row 를 **전부 재작성**한다(싼 DB
|
||||
write — chunk row + LanceDB 벡터 재기록). 그러나 내용이 변하지 않은 청크는
|
||||
내용 해시 cache_key 가 동일하므로 embedding·별칭 캐시가 **히트**한다 → 비싼
|
||||
재계산(e5 forward / LLM)은 **0**, 새로 삽입된 청크만 실제로 계산된다. 즉
|
||||
"row 재작성(싸다)"과 "compute 재실행(비싸다)"을 분리해, 위치가 밀려도 compute
|
||||
는 변경분에만 든다. 이것이 chunk_id 를 위치 기반으로 두면서도(diff 불필요)
|
||||
재색인 비용을 없애는 핵심이다.
|
||||
|
||||
## 2. 목표 / 비목표
|
||||
|
||||
**목표**
|
||||
- ingest 시 청크별로 (embedding, alias, korean_tokens) 를 내용 해시로 캐싱.
|
||||
- 캐시 히트 시 비싼 계산(embedder.embed / LLM.generate / lindera tokenize)을 건너뜀.
|
||||
- 모델/프롬프트/토크나이저 버전을 캐시 키에 포함 → §9 version cascade 와 정합
|
||||
(버전 변경 시 자동 cache miss → 재계산).
|
||||
- 별칭뿐 아니라 비용 큰 파생물 전반에 동일 메커니즘.
|
||||
|
||||
**비목표**
|
||||
- 청크 단위 diff (put_chunks 의 전체 DELETE/INSERT 는 그대로 둔다 — chunks 행 재생성은
|
||||
싸다). 캐시는 *계산*만 절감한다.
|
||||
- chunk_id 생성 방식 변경 (위치 기반 유지 — frozen 동작).
|
||||
- doc 단위 skip(`try_skip_unchanged`) 변경 (그대로, 캐시와 독립).
|
||||
|
||||
## 3. 설계
|
||||
|
||||
### 3.1 캐시 키
|
||||
|
||||
```
|
||||
cache_key = blake3_hex( kind || 0x00 || text_blake3 || 0x00 || version_key )[:32]
|
||||
```
|
||||
- `text_blake3` = blake3(chunk.text 의 NFC 정규화 UTF-8 bytes).
|
||||
- `kind` ∈ { "embedding", "alias", "korean_tokens" }.
|
||||
- `version_key` (kind 별, 버전 변경 시 캐시 무효화) — **구현 기준(e9b5202, lib.rs)**:
|
||||
- embedding: `doc|{model_id}|{model_version}|{dimensions}` — 맨 앞의 **kind 토큰
|
||||
`doc`** 은 PR #195 리뷰 반영. 임베더는 호출 kind 별 프리픽스(Document=`passage:`,
|
||||
Query=`query:`)를 붙여 *같은 text* 라도 다른 벡터를 만든다. 현재 ingest 는 Document
|
||||
고정이라 live 버그는 없지만, 미래에 query 임베딩이 같은 캐시를 타도 충돌하지 않도록
|
||||
방어적으로 분리한다(현재 토큰은 `doc` 상수).
|
||||
- alias: `{prompt_version}|{max_aliases_per_chunk}|{model}` (model="" 면 LLM 기본).
|
||||
구현은 `expansion::PROMPT_VERSION`(현재 `"expansion-v1"`) + `max_aliases_per_chunk`
|
||||
+ `exp.model` 을 `|` 로 join.
|
||||
- korean_tokens: `{tokenizer_version}` (현재 lindera 고정 → 상수 "lindera-v1";
|
||||
추후 토크나이저 교체 시 bump). **미구현(보류)** — embedding/LLM 이 주 비용이라 미적용.
|
||||
|
||||
text 내용이 같고 버전이 같으면 문서·위치·chunk_id 와 무관하게 동일 cache_key.
|
||||
실제 키 함수는 `kebab-core::derivation_cache_key(kind, text, version_key)`
|
||||
(derivation.rs): `blake3(kind ‖ 0x00 ‖ blake3(NFC(text)) ‖ 0x00 ‖ version_key)` 의
|
||||
hex 앞 32자. `0x00` 구분자는 hex 다이제스트에 못 나오므로 kind/version 경계가 절대
|
||||
섞이지 않는다.
|
||||
|
||||
### 3.2 저장소 — SQLite `derivation_cache` 테이블
|
||||
|
||||
신규 마이그레이션 `V012__derivation_cache.sql`:
|
||||
```sql
|
||||
CREATE TABLE derivation_cache (
|
||||
cache_key TEXT PRIMARY KEY, -- §3.1
|
||||
kind TEXT NOT NULL, -- 'embedding' | 'alias' | 'korean_tokens'
|
||||
payload BLOB NOT NULL, -- kind 별 인코딩 (§3.3)
|
||||
created_at TEXT NOT NULL,
|
||||
last_used_at TEXT NOT NULL -- LRU 정리용
|
||||
);
|
||||
CREATE INDEX idx_dcache_kind ON derivation_cache(kind);
|
||||
CREATE INDEX idx_dcache_last_used ON derivation_cache(last_used_at);
|
||||
```
|
||||
- `corpus_revision` 은 bump 하지 않는다 — 캐시 테이블 추가는 기존 데이터 무효화가
|
||||
아니다(순수 가산). 단 V012 자체는 schema migration 이라 release bump 트리거(§Versioning).
|
||||
|
||||
### 3.3 payload 인코딩
|
||||
- embedding: `dimensions × f32` little-endian 바이트열 (1024×4 = 4096 B/청크).
|
||||
`derivation_payload::{encode,decode}_embedding`(kebab-app). 디코드는 길이가 4의
|
||||
배수가 아니면(손상) `None` → 미스 강등.
|
||||
- alias: 별칭 **묶음** 문자열의 UTF-8 (현행 `chunk.aliases` 와 동일 형식 — 줄바꿈 join).
|
||||
즉 캐시 payload 는 LLM 이 청크당 생성한 별칭 *전체 묶음*이다. 이후 임베딩 단계에서
|
||||
이 묶음을 줄 단위로 쪼개 개별 벡터로 색인하는 것(§3.4)과는 별개 — alias kind 캐시는
|
||||
"이 청크 text 의 별칭 묶음을 LLM 으로 이미 뽑았나"만 기억한다.
|
||||
- korean_tokens: 토큰 문자열 UTF-8. (미구현 — §3.1 참고.)
|
||||
|
||||
### 3.4 ingest 흐름 변경 (kebab-app lib.rs)
|
||||
|
||||
각 파생물 생성 직전에 캐시를 조회한다. 의사코드(e9b5202 lib.rs 기준):
|
||||
```rust
|
||||
// --- 별칭 (lib.rs ~1346) ---
|
||||
if expansion.enabled {
|
||||
for chunk in &mut chunks {
|
||||
let key = cache_key("alias", &chunk.text, &alias_version_key);
|
||||
if let Some(p) = cache.get(&key)? { // 히트 (비-UTF8 이면 None → 미스 강등)
|
||||
chunk.aliases = Some(String::from_utf8(p)?);
|
||||
} else if is_nav_boilerplate(chunk) { // (기존 skip 규칙 유지)
|
||||
chunk.aliases = None; // 캐시에 넣지 않음(None 표현 불가)
|
||||
} else { // 미스 → LLM
|
||||
chunk.aliases = generator.generate(chunk);
|
||||
if let Some(a) = &chunk.aliases { cache.put(&key, "alias", a.as_bytes())?; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- embedding (lib.rs ~1434, fn embed_with_cache) ---
|
||||
// 1) 각 청크 cache_key 계산 → 히트/미스 분리 (out: Vec<Option<Vec<f32>>>, 입력당 1슬롯)
|
||||
// 2) 미스 청크만 emb.embed(&miss_inputs) (배치 축소)
|
||||
// 3) 미스 결과를 캐시에 put
|
||||
// 4) 히트 vector(슬롯)와 미스 vector(miss_indices 의 슬롯)를 각자 제자리에 채운 뒤,
|
||||
// 슬롯 순서대로 collect → **입력 texts 순서와 1:1 보존**(off-by-one 없음).
|
||||
// 이후 chunks.iter().zip(vectors) 로 VectorRecord 를 만들므로 순서 보존이
|
||||
// 정확성에 직결된다.
|
||||
```
|
||||
|
||||
순서 보존(§3.4 핵심 불변): `embed_with_cache` 는 히트/미스를 분리 계산하되 결과를
|
||||
입력 인덱스 슬롯(`out[i]`)에 되돌려 채우고 그 순서대로 반환한다. 따라서 히트·미스가
|
||||
섞여도 반환 벡터의 i번째는 항상 입력 text 의 i번째에 대응한다 — 호출부의
|
||||
`chunks.iter().zip(vectors)` 가 잘못된 청크에 벡터를 붙이는 off-by-one 이 발생하지 않는다.
|
||||
|
||||
핵심: **embedding 캐시는 청크 본문 + 별칭 문자열 양쪽에 적용**된다(같은 `embed_with_cache`
|
||||
+ 같은 `emb_version_key` 재사용). 같은 text 면 본문이든 별칭이든 같은 cache_key 로 적중하므로,
|
||||
별칭과 동일한 문자열이 본문에도 있으면 한쪽 계산이 다른 쪽을 워밍한다(별칭 LLM 캐시 +
|
||||
별칭 임베딩 캐시 2중 절감).
|
||||
|
||||
별칭은 **묶음 1벡터가 아니라 줄별 개별 sentinel 벡터**로 색인한다(`{orig}#alias#0`,
|
||||
`#alias#1`, …). 근거: 측정(handoff §3.1)에서 청크당 별칭 8개를 줄바꿈으로 묶어 한 벡터로
|
||||
임베딩하면 평균화로 특정 표현이 **희석**되어 오히려 변형 일관성이 악화했다(13/18). 줄별
|
||||
개별 벡터로 바꾸자 16/18 로 회복. 구현은 `chunk.aliases`(묶음)를 `\n` 으로 split·trim 한
|
||||
뒤 빈 줄을 거르고, 각 줄을 같은 청크 안에서 0부터 인덱싱해 `{chunk_id}#alias#{i}` 의
|
||||
VectorRecord 를 만든다. 별칭 dense 벡터의 cache_key 는 **별칭 줄 문자열 자체**의 embedding
|
||||
내용 해시이므로(본문 chunk text 가 아님), 같은 별칭 문자열이 재등장하면 캐시 히트한다.
|
||||
|
||||
// korean_tokens: tokenize 직전 cache 조회 + 미스만 lindera 호출 — **미구현(보류)**.
|
||||
|
||||
### 3.5 무효화 / 정리
|
||||
- **버전 무효화**: version_key 가 cache_key 에 포함 → model/prompt/tokenizer 버전이 bump
|
||||
되면 새 키가 되어 자동 miss(옛 엔트리는 고아). §9 cascade 와 자동 정합.
|
||||
- **캐시 엔트리 고아 정리(GC)**: `derivation_cache_gc(ttl_days)` 가 `last_used_at` 이
|
||||
N일(설계 기본 30) 지난 엔트리를 삭제한다(`ttl_days <= 0` 은 통째 wipe 방지 no-op).
|
||||
히트 키는 `derivation_cache_touch` 로 `last_used_at` 을 갱신해 GC 가 live 청크를 유지.
|
||||
**구현 상태(e9b5202)**: `touch` 는 ingest 종료 시 호출되어 wired 되어 있으나, `gc` 는
|
||||
store 메서드로 **존재만 하고 아직 어느 호출부(ingest/doctor)에도 연결되지 않았다**.
|
||||
즉 현재 캐시는 무한 누적이며, TTL/LRU 자동 정리는 후속 작업이다. 행수 임계(기본 50만)
|
||||
LRU 삭제도 미구현. 당장은 `kebab reset`(같은 sqlite 라 같이 비워짐)이 유일한 정리 경로.
|
||||
- **stale 별칭 sentinel cleanup**(별개 — 캐시 GC 아니라 *벡터 스토어* 정리, PR #195 MAJOR):
|
||||
별칭 dense 벡터는 본문 청크가 아니라 줄별 sentinel `{orig}#alias#N` 로 LanceDB·
|
||||
embedding_records 에 색인된다. 이 sentinel chunk_id 는 SQLite `chunks` 에 **존재하지
|
||||
않아** 재색인/문서삭제 시 stale-set SELECT 에 안 잡힌다. 정리 안 하면 옛 별칭 벡터가
|
||||
남아 검색에 hit 하는 누수(리뷰 MAJOR). 따라서 재색인·삭제 경로가 본문 chunk_id 와 함께
|
||||
별칭 sentinel 을 양쪽에서 명시 삭제한다:
|
||||
- **LanceDB**: `alias_sentinel_ids_to_delete(body_ids, max_aliases_per_chunk)`
|
||||
(lib.rs) 가 본문 id + legacy `{orig}#alias` + `{orig}#alias#0..max-1` 를 모두
|
||||
생성해 `delete_by_chunk_ids` 의 exact-match `IN (...)` 로 삭제. `max` 는
|
||||
`expansion.max_aliases_per_chunk`(parse_aliases 가 강제하는 상한)라 index ≥ max 는
|
||||
절대 안 나오고, 안 쓰인 index 는 무해한 no-op.
|
||||
- **SQLite** `embedding_records`: `chunk_id LIKE chunks.chunk_id || '#alias%'`
|
||||
프리픽스 매칭(store.rs / documents.rs)으로 본문 chunk_id 의 모든 별칭 sentinel 행을
|
||||
함께 정리. 정확 일치 `|| '#alias'` 는 per-line sentinel 을 놓치므로 `%` 프리픽스 필수.
|
||||
|
||||
이 두 정리는 **별칭 expansion 을 켰던 KB** 에만 해당하고, derivation_cache GC 와는
|
||||
독립적이다(캐시는 계산 결과 보관, sentinel 정리는 벡터 식별자 누수 방지).
|
||||
- 캐시는 **순수 성능 레이어** — 손상/삭제되어도 정확성 영향 없음(miss → 재계산).
|
||||
`embed_with_cache` 는 길이 misalign payload 를, 별칭 경로는 비-UTF8 payload 를
|
||||
**미스로 강등**해 재계산한다(잘못된 결과 대신 재계산, §3.6 정확성 우선).
|
||||
`kebab reset` 시 함께 비워진다(같은 sqlite).
|
||||
|
||||
### 3.6 정확성 보장
|
||||
- 캐시 히트가 재계산과 **동일 결과**임을 보장하는 근거: embedding/LLM/tokenize 는 같은
|
||||
입력(text) + 같은 버전에서 결정적이어야 한다. embedding(e5, temperature 무관) ✓.
|
||||
LLM 별칭은 `temperature=0.0, seed=0`(config) 라 사실상 결정적 — 단 LLM 비결정성은
|
||||
"캐시가 첫 생성 결과를 고정"하는 것이라 오히려 일관성↑(허용).
|
||||
- 버전 키 누락이 가장 위험한 실패 모드(옛 모델 벡터 재사용). version_key 에 모든
|
||||
cascade 인자를 넣고, 테스트로 "버전 변경 → cache miss" 를 고정한다.
|
||||
|
||||
## 4. 컴포넌트 / 파일
|
||||
|
||||
- `migrations/V012__derivation_cache.sql` — 신규 테이블.
|
||||
- `kebab-core` — `derivation_cache_key(kind, text, version_key) -> String` 순수 함수
|
||||
(도메인, 다른 crate 의존 없음). text NFC 정규화 + blake3.
|
||||
- `kebab-store-sqlite` — `SqliteStore` 의 inherent 메서드(derivation_cache.rs):
|
||||
`derivation_cache_get(key) -> Option<Vec<u8>>`, `derivation_cache_put(key, kind,
|
||||
payload)`(INSERT OR REPLACE), `derivation_cache_touch(keys)`(last_used 갱신, 1tx),
|
||||
`derivation_cache_gc(ttl_days)`(존재하나 미 wiring — §3.5). 별도 trait 안 만들고
|
||||
store 에 직접 단다.
|
||||
- `kebab-app` — `embed_with_cache`(lib.rs, 히트/미스 분리 + 순서 보존 §3.4) +
|
||||
`derivation_payload`(embedding f32↔LE bytes encode/decode) + ingest hook(별칭/embedding
|
||||
캐시 조회·저장, hit/miss 카운트 로깅, touch 호출).
|
||||
- `kebab-chunk` — korean_tokens 캐시(선택, 우선순위 낮음 — embedding/LLM 이 주 비용).
|
||||
**미구현(보류)**.
|
||||
|
||||
## 5. Allowed / forbidden deps
|
||||
- `kebab-core` 의 키 함수는 순수(blake3 + unicode-normalization 만). 다른 kebab-* 금지.
|
||||
- 캐시 저장소는 `kebab-store-sqlite`. UI crate 직접 접근 금지(facade 경유).
|
||||
- `kebab-app` 만 캐시를 오케스트레이션(ingest 경로).
|
||||
|
||||
## 6. 측정 / 검증
|
||||
- 동일 corpus 2회 ingest: 1회차(cold) vs 2회차(warm, 전부 캐시 히트) 시간 비교.
|
||||
warm 재색인이 별칭 LLM 0회·embedding 0회여야(로그로 hit/miss 카운트 노출).
|
||||
- 정답 18 문서 별칭: cold 2.5h → warm ~수십초(캐시 히트) 목표.
|
||||
- golden eval: warm 재색인 후 variant 16/18 + refusal 동일(결과 불변 = 캐시 정확성).
|
||||
- 버전 bump 시뮬: prompt_version 변경 → 별칭 전부 miss(재계산) 확인.
|
||||
|
||||
## 7. 호환성 / 마이그레이션 (기존 KB 영향)
|
||||
|
||||
이 작업이 기존 KB 를 어떻게 건드리는지 — 무엇이 재색인 필요하고 무엇이 그대로인지.
|
||||
|
||||
- **본문 청크 재색인 불필요.** chunk_id 생성 방식(위치 기반 `id_for_block`)을 안 바꿨고
|
||||
본문 dense 벡터 색인 경로도 안 바꿨다. 같은 corpus 를 같은 parser/chunker/embedding
|
||||
버전으로 다시 ingest 하면 본문 chunk_id·벡터가 그대로다. 캐시는 *계산*만 절감할 뿐
|
||||
결과(벡터 값)는 동일하므로 기존 본문 데이터는 손대지 않아도 된다.
|
||||
- **V012 는 순수 가산 — 자동 적용, 기존 데이터 불변.** 새 테이블 `derivation_cache` 만
|
||||
추가하고 `corpus_revision` 을 bump 하지 않는다(§3.2). 기존 SQLite 를 새 binary 로 열면
|
||||
refinery 가 V012 를 자동 적용하며 기존 행은 건드리지 않는다. **단 binary 교체는 필수**:
|
||||
V012 가 적용된 DB 를 **이전 release binary** 로 열면 refinery 마이그레이션 상태가
|
||||
mismatch 한다(이전 binary 는 V012 를 모름) → 새 binary 로만 열 것. 이 schema 변경은
|
||||
CLAUDE.md §Versioning 의 release bump 트리거다.
|
||||
- **별칭 dense 벡터 — expansion 을 켰던 KB 만 해당.** 별칭 색인 단위가 묶음 단일 sentinel
|
||||
`{orig}#alias`(1벡터) → 줄별 개별 sentinel `{orig}#alias#N`(N벡터)로 바뀌었다.
|
||||
- expansion 을 한 번도 안 켠 KB: 별칭 sentinel 자체가 없으므로 영향 0.
|
||||
- 기존 단일 sentinel 이 남아 있어도 **검색은 그대로 동작**한다: candidate strip 이
|
||||
`strip_alias_suffix`(ids.rs)의 `find("#alias")` 기반이라 legacy `{orig}#alias` 와
|
||||
신형 `{orig}#alias#N` 를 똑같이 원본 chunk_id 로 환원한다.
|
||||
- 개별 벡터의 검색 품질 이점(희석 회피, §3.4)을 원하면 **별칭만 재생성**하면 된다
|
||||
(본문은 그대로). 강제 사항은 아니다.
|
||||
- stale 별칭 sentinel 누수 방지는 §3.5 의 cleanup(LanceDB exact-match + SQLite
|
||||
`#alias%` LIKE)이 재색인·삭제 시 자동 처리한다.
|
||||
- **KB 이식성(외부 계산 워크플로).** `derivation_cache` 는 SQLite 안에 있고 cache_key 가
|
||||
머신 독립적인 내용 해시라, 외부 서버에서 워밍한 `kebab.sqlite`(+`lancedb/`)를 그대로
|
||||
복사해 오면 로컬 증분 수정 시에도 캐시가 히트한다(측정: handoff §5).
|
||||
|
||||
## 8. Risks / notes
|
||||
- LLM 별칭의 미세한 비결정성: 캐시가 첫 결과를 고정하므로 재현성은 오히려 향상.
|
||||
단 "더 나은 별칭" 재생성을 원하면 prompt_version bump 로 무효화.
|
||||
- payload BLOB 크기: embedding 4KB/청크 × 캐시 엔트리. 50만 엔트리 ≈ 2GB. TTL/LRU 로 관리.
|
||||
- V012 는 schema migration → release version bump 트리거(CLAUDE.md §Versioning).
|
||||
- 본 설계는 frozen design contract(§9 versioning)의 *의미*를 바꾸지 않는다(캐시는 그
|
||||
위의 성능 레이어). design 문서 수정 불필요; cascade 안전성만 version_key 로 보장.
|
||||
78
docs/superpowers/specs/2026-06-01-embed-candle-track-spec.md
Normal file
78
docs/superpowers/specs/2026-06-01-embed-candle-track-spec.md
Normal file
@@ -0,0 +1,78 @@
|
||||
# Track 1 Spec — candle e5-large 임베딩 provider (NUMA-안전)
|
||||
|
||||
- 날짜: 2026-06-01
|
||||
- 우산: [meta-spec](./2026-06-01-embedding-numa-backends-meta-spec.md) / [meta-plan](./2026-06-01-embedding-numa-backends-meta-plan.md)
|
||||
- 선행: Phase 0 스파이크 PASS+독립검증 (cosine 1.000000, 스레드 캡 가능, latency ~4×). 커밋 76841af.
|
||||
- 브랜치: `feat/embed-candle`
|
||||
|
||||
## 1. 목표
|
||||
|
||||
fastembed(onnxruntime) 의 "intra-op 스레드 48 하드코딩 → NUMA 힙 손상" 을 회피하기 위해, 동일 모델 `multilingual-e5-large` 를 **candle(순수 Rust)** 로 돌리는 임베딩 provider 를 추가한다. opt-in, 품질 중립, NUMA 스레드 캡 가능.
|
||||
|
||||
## 2. 확정 결정 (사용자 승인 2026-06-01)
|
||||
|
||||
- **D-reindex**: `embedding_version` **유지(재색인 0)** 를 목표. 구현 중 candle vs onnxruntime 벡터의 **차원별 max 절대오차**를 측정해 사실상 동일(예: max abs diff < 1e-5)함을 확인하고, 골든 스위트로 회귀 0 을 실측해 확정. 유의미한 차이가 나오면만 version bump + 재색인.
|
||||
- **D-default**: 글로벌 default provider 는 **onnxruntime 유지**, candle 은 **opt-in** (`models.embedding.provider = "candle"`).
|
||||
- **조기 종료**: candle 이 골든 baseline 충족 시 ollama/A2 트랙 생략 (A1 stopgap 문서만 별도).
|
||||
|
||||
## 3. 아키텍처
|
||||
|
||||
- **신규 crate `kebab-embed-candle`** — `kebab_core::Embedder` 구현. candle 의 큰 의존성 트리를 이 crate 에 격리.
|
||||
- 허용 deps: `candle-core`/`candle-nn`/`candle-transformers` (0.10.x), `tokenizers`, `hf-hub`, `kebab-core`, `kebab-config`, `anyhow`, `tracing`. **다른 `kebab-*` 의존 금지**(core/config 외) — design §8 경계.
|
||||
- **주입 분기**: `kebab-app/src/app.rs` 의 `embedder()` (현 :829-837, `FastembedEmbedder::new` 무조건 생성) 를 `config.models.embedding.provider` 로 분기:
|
||||
- `"fastembed"` | `"onnx"` | (빈값/기존) → `FastembedEmbedder` (default, 기존 동작 유지).
|
||||
- `"candle"` → `CandleEmbedder`.
|
||||
- 알 수 없는 값 → 명확한 에러.
|
||||
- **facade 규칙 준수**: UI crate 는 `kebab-app` 만. `kebab-app` 이 `kebab-embed-candle` 의존 추가.
|
||||
|
||||
## 4. CandleEmbedder 동작 (스파이크에서 검증된 파이프라인)
|
||||
|
||||
- 모델: `intfloat/multilingual-e5-large` 의 `model.safetensors` + `config.json` + `tokenizer.json` 을 `hf-hub` 으로 `{model_dir}/candle/` (config `storage.model_dir`) 아래에 캐시.
|
||||
- `candle_transformers::models::xlm_roberta::{Config, XLMRobertaModel}` 로 로드 (CPU `Device::Cpu`).
|
||||
- `embed()`: e5 프리픽스(`query: `/`passage: `, `EmbeddingInput` kind 기준 — `kebab-embed-local` 의 `prefix_input` 규약과 동일) → 토크나이즈(max_len 512, batch-longest 패딩, special tokens) → forward → **attention-mask 가중 mean pooling** → **L2 정규화**.
|
||||
- `dimensions()` = 1024, `model_id`/`model_version` = config 값(기존과 동일 식별자 유지).
|
||||
- **스레드 캡**: config 신규 필드 `models.embedding.num_threads`(u32, 0=auto) + env `KEBAB_EMBED_THREADS`. `CandleEmbedder::new` 에서 `rayon::ThreadPoolBuilder::new().num_threads(n).build_global()` 1회 적용(이미 초기화 시 무시). 0/auto 면 미설정(rayon 기본). NUMA 노드 바인딩은 `numactl`(A1) 과 조합 — 문서화.
|
||||
- `Mutex<XLMRobertaModel>` 또는 forward 가 `&self` 면 불필요 — candle forward 는 `&self` 가능, 단 내부 가변 없으면 `Send+Sync` 보장 확인.
|
||||
|
||||
## 5. config 변경
|
||||
|
||||
- `EmbeddingModelCfg` 에 `num_threads: u32`(default 0) 추가. env `KEBAB_EMBED_THREADS`.
|
||||
- `provider` 허용값 문서화: `fastembed`(default)/`candle`.
|
||||
- default toml + `Config::default()` 갱신, 기존 테스트 영향 확인.
|
||||
|
||||
## 6. 버전/캐스케이드
|
||||
|
||||
- D-reindex 에 따라 `embedding_version` 유지 (벡터 동일). cascade(design §9) 트리거 안 함 — 기존 색인 재사용. (max abs diff 확인 실패 시에만 bump.)
|
||||
- wire schema 변경 없음.
|
||||
|
||||
## 7. 테스트 (산출물)
|
||||
|
||||
- **단위**(`kebab-embed-candle`): `dimensions()==1024`; `embed()` 출력 L2≈1; 빈 입력 빈 출력; 프리픽스 적용 확인.
|
||||
- **패리티 테스트**(`#[ignore]`, 모델 2GB+네트워크 필요): candle vs `FastembedEmbedder` 동일 문장 cosine ≥ 0.9999 + max abs diff 보고. CI 기본 제외, 수동/도그푸딩에서 실행.
|
||||
- **통합**(`kebab-cli` 또는 `kebab-app`): `provider="candle"` 로 소량 fixture ingest → 청크/임베딩 카운트 > 0, 검색 1건 성공. (모델 필요 → `#[ignore]` 또는 feature.)
|
||||
- **스레드 캡**: `num_threads=4` 설정 시 `rayon::current_num_threads()==4` 확인.
|
||||
- **회귀**: 기존 fastembed 경로 default 동작 불변(provider 미지정 시).
|
||||
- clippy `-D warnings`, 빌드 직렬 `-j 4`.
|
||||
|
||||
## 8. 품질 게이트 (머지 전)
|
||||
|
||||
- `kebab-eval` 골든 스위트(`/build/dogfood/golden_queries.yaml`) 를 provider=candle 로 실행 → MRR/hit@k ≥ 현 baseline (회귀 0). [[feedback_search_quality_dogfood]]
|
||||
- 패러프레이즈 robustness(#195/#196) 스폿 확인.
|
||||
|
||||
## 9. 문서/릴리스 (머지 시 동일 PR)
|
||||
|
||||
- README: Configuration 에 `provider=candle` + `num_threads`/`KEBAB_EMBED_THREADS` 추가. SMOKE config 예시 동기화. [[feedback_readme_sync_rule]]
|
||||
- ARCHITECTURE: crate 그래프 + 디렉터리에 `kebab-embed-candle` 추가.
|
||||
- HANDOFF: 머지 후 한 줄(임베딩 백엔드 다변화).
|
||||
- HOTFIXES: 본 날짜 dated entry (NUMA double-free 진단 + candle provider 도입 + 스파이크 패리티 증거).
|
||||
- 버전 bump: 신규 config surface(provider=candle, num_threads) = pre-1.0 minor bump (0.21.1 → 0.22.0), release notes.
|
||||
|
||||
## 10. 범위 밖 / 후속
|
||||
|
||||
- candle crate feature-gate 로 빌드 비용 격리 (후속).
|
||||
- NUMA 노드 자동 바인딩(현재는 numactl 외부 조합).
|
||||
- ollama/A2/A1 트랙 (candle 게이트 통과 시 생략).
|
||||
|
||||
## 11. 잔여 게이트 (사용자 실행, Claude 불가)
|
||||
|
||||
- 그 듀얼소켓 NUMA 서버에서 `provider=candle` 로 5150-doc ingest **double-free 없이 EXIT=0 완주**. PR 머지 전/후 검증 예약. (meta-spec §4.3)
|
||||
@@ -0,0 +1,77 @@
|
||||
# Meta-Plan — NUMA-안전 임베딩 백엔드 실행 계획
|
||||
|
||||
- 날짜: 2026-06-01
|
||||
- 우산 스펙: [2026-06-01-embedding-numa-backends-meta-spec.md](./2026-06-01-embedding-numa-backends-meta-spec.md)
|
||||
- 실행 모델: 트랙별 worktree 격리 + omc teammate (omc-teams, sequential single-team). 트랙 내 단계는 spec → plan → 구현 → 테스트 → PR.
|
||||
|
||||
## 0. 즉시 (본 계획과 병행, 무코드)
|
||||
|
||||
- **A1 stopgap 문서화 + 사용자 제공**: `numactl --cpunodebind=0 --membind=0 kebab ingest` (또는 `taskset -c 0-11`). 현재 불통 해소용. 이건 트랙 4의 산출물 일부지만 지금 바로 안내.
|
||||
- 사용자 NUMA 서버에서 A1 로 5150-doc 완주되는지 1회 확인 → "스레드/NUMA 가 원인" 인과 확정(메타스펙 §1 보강).
|
||||
|
||||
## 1. 트랙 실행 순서 & 게이트
|
||||
|
||||
`candle → ollama → A2 → A1(정식 문서화)`. 한 트랙의 PR open + NUMA 검증 예약 전까지 다음 트랙 미착수.
|
||||
|
||||
**조기 종료 (D1 확정)**: candle 또는 ollama 가 허용 품질(골든 ≥ baseline 무회귀) + NUMA 안전을 만족하면 **거기서 종료**, 이후 트랙 미진행. 둘 다 품질 미달 시에만 A2 → A1 진행. candle 은 동일 e5-large 라 패리티 통과 시 종착 유력.
|
||||
|
||||
### 트랙 1 — candle (`feat/embed-candle`)
|
||||
|
||||
- **Phase 0 — 타당성 스파이크 (게이트, 최우선)**
|
||||
- worktree 에서 candle + candle-transformers 의존성 추가, `xlm_roberta::XLMRobertaModel` 로 `intfloat/multilingual-e5-large` safetensors 로드 (CPU).
|
||||
- 몇 개 문장 임베딩 → (a) onnxruntime e5-large 벡터와 cosine 패리티, (b) CPU latency, (c) `RAYON_NUM_THREADS` 로 스레드 캡 동작, (d) padding_idx 위치 임베딩 정확성.
|
||||
- 산출: 스파이크 리포트(패리티 수치 + latency + 스레드 제어 확인). **통과해야 Phase 1 진행.**
|
||||
- **Phase 1 — spec**: 트랙 spec 작성 (Embedder 구현, config provider="candle", embedding_version, 재색인 절차, 테스트 매트릭스).
|
||||
- **Phase 2 — plan**: 구현 plan.
|
||||
- **Phase 3 — 구현**: `kebab-embed-candle`(신규 crate) 또는 `kebab-embed-local` 내 provider 분기. Embedder 구현 + app.rs 주입 분기 + config.
|
||||
- **Phase 4 — 테스트**: 단위/통합 + 패리티 + 골든. 빌드는 직렬 `-j 4`.
|
||||
- **Phase 5 — PR + 검증**: gitea PR. 사용자 NUMA 서버 5150-doc 완주 + 골든 baseline 확인.
|
||||
|
||||
### 트랙 2 — ollama (`feat/embed-ollama`)
|
||||
|
||||
- spec → plan → 구현(`OllamaEmbedder`: `/api/embed` 호출, provider="ollama", 모델 선택[e5 GGUF 또는 bge-m3]) → 테스트(패리티/골든, 프로세스 격리로 double-free 부재) → PR + NUMA 검증.
|
||||
|
||||
### 트랙 3 — A2 (`feat/embed-ort-direct`)
|
||||
|
||||
- spec → plan → 구현(fastembed 우회, `ort` 세션 직접 + `with_intra_threads(N)` + NUMA affinity, 토크나이즈/mean-pool/L2 재현, provider="onnx" 기본 유지) → 테스트(기존 e5 벡터와 cosine≈1.0, 재색인 0) → PR + NUMA 검증.
|
||||
- **품질-중립 안전망**: 재색인 없이 즉시 default 가능.
|
||||
|
||||
### 트랙 4 — A1 정식화 (`docs/embed-numa-affinity`)
|
||||
|
||||
- 런처 래핑/문서 + (선택) config 노브로 affinity 힌트. README/SMOKE/HOTFIXES 동기화.
|
||||
|
||||
## 2. omc teammate 운용 (메모리 규약 준수)
|
||||
|
||||
- spawn: omc-teams tmux pane + brief 파일. **sequential single-team** (multi-team 동시 spawn 금지).
|
||||
- 모델 라우팅: executor + initial draft + round-1 review = **opus**; closure verify / micro-patch round = **sonnet**. (`OMC_TEAM_ROLE_OVERRIDES` env)
|
||||
- worker spawn 직후 completion polling shell `run_in_background=true` (phase=completed/failed 감지 → main session 자동 알림).
|
||||
- 빌드/테스트 직렬, `-j 4` 기본. `CARGO_TARGET_DIR=/build` 사용 (routinely clean 금지).
|
||||
|
||||
## 3. 워크트리 / 브랜치
|
||||
|
||||
| 트랙 | 브랜치 | worktree |
|
||||
|---|---|---|
|
||||
| 1 candle | `feat/embed-candle` | 신규 |
|
||||
| 2 ollama | `feat/embed-ollama` | 신규 |
|
||||
| 3 A2 | `feat/embed-ort-direct` | 신규 |
|
||||
| 4 A1 | `docs/embed-numa-affinity` | 신규 |
|
||||
|
||||
각 트랙 머지 후 다음 트랙 rebase. 트랙 간 공유 상태 없음(독립 provider).
|
||||
|
||||
## 4. 리스크 레지스터
|
||||
|
||||
- candle Phase 0 패리티 실패 → 트랙 1 강등, ollama 우선.
|
||||
- candle CPU latency 가 onnxruntime 대비 과도 → opt-in provider 로만.
|
||||
- ollama 모델이 e5 아님 → 골든 회귀 가능 → default 승격 보류.
|
||||
- NUMA 검증이 사용자 가용성에 의존 → 각 PR 은 검증 전까지 "merge-pending".
|
||||
- ort rc.9 자체 버그가 A2 에서도 재현 가능성 → A2 스레드 캡으로도 안 죽는지 NUMA 검증 필수.
|
||||
|
||||
## 5. 진행 상태 (라이브)
|
||||
|
||||
- [x] candle 타당성 desk-research (xlm_roberta 모듈 존재 + cembedd 선례) — 2026-06-01
|
||||
- [ ] A1 stopgap 사용자 NUMA 서버 확인
|
||||
- [x] 트랙 1 Phase 0 스파이크 — **VERDICT=PASS** (2026-06-01). cosine min=mean=1.000000(onnxruntime 동일), RAYON 스레드 캡 가능, latency ~4×(67.5 vs 16.8 ms/문장, 4 vs 12 스레드). 커밋 76841af. → **조기 종료 유력**: candle 이 품질 baseline 자동 충족 → ollama/A2/A1 불필요 전망. 잔여 게이트=골든 실측 + NUMA 서버 5150-doc 완주.
|
||||
- [ ] 트랙 1 spec/plan/impl/test/PR (진행)
|
||||
- [ ] 트랙 2 …
|
||||
- [ ] 트랙 3 …
|
||||
- [ ] 트랙 4 …
|
||||
@@ -0,0 +1,102 @@
|
||||
# Meta-Spec — NUMA-안전 임베딩 백엔드 (다중 트랙)
|
||||
|
||||
- 날짜: 2026-06-01
|
||||
- 상태: DRAFT (umbrella)
|
||||
- 범위: `kebab-embed-local` 및 임베더 주입 경로. 4개 트랙의 우산 스펙.
|
||||
- 하위 산출물: 각 트랙은 본 메타스펙을 참조하는 자체 spec(`tasks/` 또는 `docs/superpowers/specs/`)과 plan을 가진다.
|
||||
|
||||
## 1. 문제
|
||||
|
||||
CPU-only Ollama 서버(Intel Xeon Silver 4214 ×2 소켓 = 48 logical, NUMA 2노드)에서 `kebab ingest` 가 매 실행 힙 손상으로 죽는다:
|
||||
|
||||
```
|
||||
ingest [> ] 3/5150 double free or corruption (!prev)
|
||||
중지됨 (core dumped)
|
||||
```
|
||||
|
||||
근본 원인(코드로 확정): fastembed 4.9.1 (`text_embedding/impl.rs:52,80`) 이 ONNX intra-op 스레드를 `available_parallelism()`(=48) 로 **하드코딩**하고 `InitOptions` 에 이를 덮어쓸 API 가 없다. 듀얼소켓 NUMA 에서 onnxruntime(`ort 2.0.0-rc.9`) 스레드풀이 힙을 손상시킨다. 진단 근거: `tasks/HOTFIXES.md` 의 본 날짜 entry + 대화 로그.
|
||||
|
||||
- 모델/디스크/AVX/데이터 문제 아님 (모델 2.08GB 정상, AVX-512 완비). 순수 스레드/NUMA × 네이티브 런타임 버그.
|
||||
- onnxruntime 공식 문서도 듀얼소켓 NUMA 는 intra-op 스레드를 한 노드로 묶으라고 권고.
|
||||
|
||||
## 2. 목표 / 비목표
|
||||
|
||||
목표:
|
||||
- 그 NUMA 서버에서 5150-doc 코퍼스를 **double-free 없이 완주**하는 임베딩 경로 확보.
|
||||
- 검색 품질을 골든 스위트(MRR/hit@k) baseline 이상으로 유지.
|
||||
- `models.embedding.provider` 로 선택 가능한 백엔드들로 구현 (기존 provider 필드 활용).
|
||||
|
||||
비목표:
|
||||
- 랭킹 자동 조정 (별도 보류 결정, `[[project_ranking_deferred]]`).
|
||||
- 임베딩 모델 품질 개선 자체 (NUMA 안정성이 본 과제의 초점).
|
||||
- GPU 경로.
|
||||
|
||||
## 3. 공유 아키텍처
|
||||
|
||||
- 교체 지점은 **단일**: `crates/kebab-app/src/app.rs:836` 의 `FastembedEmbedder::new(&config)`.
|
||||
- 트레이트 표면이 작다: `kebab_core::Embedder` (`traits.rs:127`) — `model_id / model_version / dimensions / embed`. 새 백엔드는 이 4개만 구현.
|
||||
- 설정: `models.embedding.provider` (이미 존재), `model`, `version`, `dimensions`, `batch_size`. 신규로 트랙별 스레드/affinity 노브 추가 가능.
|
||||
|
||||
## 4. 횡단 정책 (모든 트랙 공통)
|
||||
|
||||
### 4.1 embedding_version & 재색인
|
||||
- 벡터가 바뀌면(=candle, ollama) **`embedding_version` bump → 전체 재색인** (design §9 cascade). A2/A1 은 동일 onnxruntime e5-large 라 벡터 불변 → 재색인 불필요.
|
||||
- 재색인 비용/절차를 각 트랙 spec 에 명시.
|
||||
|
||||
### 4.2 품질 검증 (필수 게이트)
|
||||
- 벡터가 바뀌는 트랙은 머지 전 `kebab-eval` 골든 스위트(`/build/dogfood/golden_queries.yaml`) 로 MRR/hit@k 측정, **baseline 이상**이어야 default 승격. baseline 미달이면 opt-in provider 로만 유지.
|
||||
- 패러프레이즈 robustness(#195/#196) 회귀 확인.
|
||||
|
||||
### 4.3 NUMA 서버 검증 (필수 게이트, 사용자 실행)
|
||||
- **결정적 증거는 그 서버에서만 난다 (Claude 접근 불가).** 각 트랙은 사용자가 그 서버에서 5150-doc 코퍼스 ingest 를 **double-free 없이 완주(EXIT=0)** 함을 확인해야 "검증 완료".
|
||||
- 각 트랙 spec 에 사용자-실행 검증 절차(명령 + 기대 출력)를 문서화.
|
||||
|
||||
### 4.4 스레드/NUMA 제어
|
||||
- 각 백엔드가 intra-op/worker 스레드를 캡하고 한 NUMA 노드로 묶을 수 있어야 함. 캡 못 하면 트랙 실패.
|
||||
|
||||
## 5. 트랙
|
||||
|
||||
선호/구현 순서: **candle → ollama → A2 → A1**. (단 A1 은 무코드 stopgap 이라 즉시 문서화해 당장의 불통을 해소; 구현 순서와 별개.)
|
||||
|
||||
| # | 트랙 | 백엔드 | 벡터 변경(재색인) | 핵심 리스크 | 격리 브랜치 |
|
||||
|---|------|--------|----|------|------|
|
||||
| 1 | candle | 순수 Rust (candle `xlm_roberta`) | 예 | XLM-R padding_idx/패리티/CPU 성능 | `feat/embed-candle` |
|
||||
| 2 | ollama | 별 프로세스 (Ollama `/api/embed`) | 예 | 모델이 e5 아님→품질, ingest 가 Ollama 의존 | `feat/embed-ollama` |
|
||||
| 3 | A2 | onnxruntime 직접(`ort` 세션) | 아니오 | fastembed 우회 후 토크나이즈/풀링 재현 정확도 | `feat/embed-ort-direct` |
|
||||
| 4 | A1 | onnxruntime + 실행 래핑(taskset/numactl) | 아니오 | 코드 변경 거의 없음, 문서/런처만 | `docs/embed-numa-affinity` |
|
||||
|
||||
### 5.1 트랙별 테스트 매트릭스 (각 트랙 spec 에서 구체화)
|
||||
|
||||
모든 트랙:
|
||||
- 단위: `embed()` 가 올바른 dim/정규화(L2≈1) 벡터 반환.
|
||||
- 통합: `kebab ingest` 소량 fixture → 청크/임베딩 카운트.
|
||||
- **NUMA 서버 검증**(§4.3): 5150-doc 완주.
|
||||
|
||||
벡터-변경 트랙(candle/ollama) 추가:
|
||||
- 패리티: onnxruntime e5-large 대비 동일 입력 cosine 유사도(가능 시) 또는 골든 스위트 동등성.
|
||||
- 골든: MRR/hit@k ≥ baseline (§4.2).
|
||||
- 재색인 절차 검증.
|
||||
|
||||
벡터-불변 트랙(A2/A1) 추가:
|
||||
- 회귀: 기존 e5-large 벡터와 cosine ≈ 1.0 (A2 는 같은 런타임이라 사실상 동일해야).
|
||||
|
||||
## 6. 결정사항 (확정 2026-06-01)
|
||||
|
||||
- **D1 조기 종료 (사용자 확정)**: 트랙을 선호 순서로 진행하되, candle 또는 ollama 가 **허용 품질 기준 + NUMA 안전**을 만족하면 **거기서 멈춘다** (이후 트랙 미진행). 둘 다 품질이 너무 낮으면 A2 → A1 까지 계속.
|
||||
- **허용 품질 기준**: 골든 스위트 MRR/hit@k 가 현 e5-large(onnxruntime) baseline 대비 유의미한 회귀 없음. candle 은 동일 e5-large 가중치라 패리티 통과 시 이 기준을 거의 자동 충족 → candle 이 종착 가능성 높음. ollama 는 모델이 달라 경계선이면 사용자 판단.
|
||||
- A2/A1 은 candle·ollama 둘 다 실패 시의 **fallback** (A2 는 재색인 0 품질-중립).
|
||||
- **D2 즉시 완화**: A1(taskset/numactl) 은 무코드라 본 작업과 무관하게 지금 바로 사용자에게 워크어라운드로 제공.
|
||||
- **D3 메타 산출물 위치**: 본 메타스펙 + 메타플랜은 `docs/superpowers/specs/`. 트랙별 spec 은 도달 시 작성.
|
||||
- **D4 frozen design 영향**: 임베딩 백엔드 다변화는 design §(임베딩) 갱신 가능 — 트랙 머지 시 동기화.
|
||||
|
||||
## 7. 성공 기준
|
||||
|
||||
- 그 NUMA 서버에서 최소 1개 트랙이 5150-doc 완주(EXIT=0).
|
||||
- default 로 승격되는 백엔드는 골든 baseline 이상.
|
||||
- 각 트랙이 자체 브랜치/워크트리 + 문서화된 테스트로 독립 검증.
|
||||
|
||||
## 8. 시퀀싱 게이트
|
||||
|
||||
1. candle **스파이크**(Phase 0) 가 패리티+CPU 성능+스레드 제어를 입증해야 candle 본 구현 진행. 실패 시 candle 트랙 강등/스킵 후 ollama 로.
|
||||
2. 각 트랙은 PR open + NUMA 서버 검증 예약 후 다음 트랙 시작 (omc-teams sequential single-team 제약).
|
||||
3. 벡터-변경 트랙은 골든 게이트 통과 전 default 승격 금지.
|
||||
66
docs/superpowers/specs/2026-06-03-arctic-embedder-spec.md
Normal file
66
docs/superpowers/specs/2026-06-03-arctic-embedder-spec.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# Spec: arctic-embed-l-v2.0 임베더 통합 (candle 우선 + Ollama provider)
|
||||
|
||||
**날짜**: 2026-06-03
|
||||
**유형**: feature (신규 임베딩 백엔드/모델)
|
||||
**근거**: `docs/superpowers/research/2026-06-03-expansion-cost-rethink-research.md` + `/build/dogfood/logs/2026-06-03-method-measurements.md`. 별칭 제거(v0.25.0) 후 설명형 recall 보강의 최선책. 측정: arctic-embed2 = recall@10 **130/132**, recall@50 **132/132**, **용어 무손실**(bge-m3 와 달리 syn/abbr/en 유지). e5 대비 +7, 색인 1회·per-query 0·LLM 0 = 살아있는 KB 최적합.
|
||||
**사용자 결정**: candle 우선 + Ollama embed provider 폴백 둘 다.
|
||||
|
||||
## 목표
|
||||
`models.embedding` 에서 arctic-embed-l-v2.0 을 선택 가능하게 한다. 두 백엔드:
|
||||
1. **candle** (주): `kebab-embed-candle` 를 e5 전용 → 다중 모델로 일반화, arctic 추가. in-process pure-Rust, NUMA 안전.
|
||||
2. **ollama** (폴백): 신규 Ollama embedding provider. 측정에 쓴 경로(`/api/embed`) 그대로 → 130 보장.
|
||||
기본 동작 불변(기본 provider=fastembed e5). arctic 은 opt-in.
|
||||
|
||||
## 모델 사실 (구현 기준)
|
||||
- 아키텍처: **XLM-RoBERTa-large** (candle `XLMRobertaModel` 로드 가능, e5 와 동일 계열).
|
||||
- dim: **1024** (e5 와 동일 → 벡터스토어/lancedb 테이블 차원 불변, 단 테이블명은 모델명 포함).
|
||||
- pooling: arctic-embed-l-v2.0 의 sentence-transformers `1_Pooling/config.json` 기준(**CLS 토큰 추정 — 반드시 config 로 확인**). e5 는 mean pooling → pooling 을 모델별 분기.
|
||||
- prefix: **query 에 `query: ` 접두어, 문서는 무접두어**(e5 의 `query:`/`passage:` 와 다름). 모델별 분기.
|
||||
- 정규화: L2 normalize (코사인 일관성, 기존 e5 경로와 동일).
|
||||
- HF repo: `Snowflake/snowflake-arctic-embed-l-v2.0` (candle 다운로드). Ollama: `snowflake-arctic-embed2`.
|
||||
|
||||
## 작업 A — kebab-embed-candle 다중 모델화
|
||||
- 현재 `HF_MODEL`/`SUPPORTED_MODEL` 상수(e5 하드코딩) → **모델 레지스트리**로: `{ name, hf_repo, pooling: Mean|Cls, query_prefix, doc_prefix, dim }`. e5(mean, `query: `/`passage: `) + arctic(cls, `query: `/``).
|
||||
- `embed_batch` 의 pooling 단계를 모델별 분기(mean=attention-mask-weighted mean / cls=first token). 나머지(tokenize→forward→L2)는 공유.
|
||||
- `model_id()` / `model_version()` 가 모델명+pooling 반영(전환 시 embedding_version cascade 트리거).
|
||||
- config `models.embedding.model` 이 레지스트리에 없으면 기존처럼 명확한 에러.
|
||||
- `[features] metal`/`mkl` 유지(arctic 도 동일 XLM-R 경로라 그대로 동작).
|
||||
|
||||
## 작업 B — Ollama embedding provider (신규)
|
||||
- 신규 크레이트 `kebab-embed-ollama` (또는 kebab-embed-local 내 모듈 — **새 크레이트 권장**, 의존 분리). `Embedder` trait 구현.
|
||||
- `reqwest::blocking` 으로 `POST {endpoint}/api/embed` `{model, input:[...]}` → `embeddings`. 배치(예: 48/req), fail-soft 재시도.
|
||||
- query/doc prefix 모델별(arctic: query 에 `query: `). 결과 **L2 normalize**(Ollama raw 반환 → 일관성 위해 정규화).
|
||||
- endpoint: `models.embedding.endpoint`(신규, 미설정 시 models.llm.endpoint fallback). model_version = `ollama:{model}`.
|
||||
|
||||
## 작업 C — config + app 배선
|
||||
- `kebab-config`: `EmbeddingCfg.provider` 에 `"ollama"` 허용. 신규 `endpoint: Option<String>`(ollama 용). serde forward-compat 유지.
|
||||
- `kebab-app`: embedder 선택 분기(`embedder()`)에 candle 다중모델 + ollama provider 추가. facade(`*_with_config`) 통해 config 주입(facade rule 준수).
|
||||
- UI 크레이트는 kebab-app 만 touch(불변).
|
||||
|
||||
## 결정 사항
|
||||
- **차원 1024 동일** → lancedb 테이블은 모델명 포함(`chunk_embeddings_{model}_{dim}`)이라 모델 전환 시 새 테이블, 충돌 없음.
|
||||
- **embedding_version cascade**: arctic 으로 전환 = embedding_version 변경 → 전체 재임베딩 필요(breaking). 기존 e5 KB 와 혼용 불가(명확). 기본값 e5 유지라 기존 사용자 무영향.
|
||||
- arctic **ko 파인튠(dragonkue)** 은 base(130) 로 충분 → 본 작업은 base. ko 는 후속 옵션(레지스트리에 추가만 하면 됨).
|
||||
- A(heading enrichment) 는 측정상 arctic 에서 악화 → **미적용**.
|
||||
|
||||
## 검증 기준 (Acceptance)
|
||||
- `cargo clippy --workspace --all-targets -j 4 -- -D warnings` 통과.
|
||||
- `cargo test --workspace --no-fail-fast -j 1` 통과 — 기존 e5-candle/fastembed 테스트 회귀 0.
|
||||
- **correctness 핵심**: candle arctic 으로 임베딩한 테스트 문장(예: `query: 스택 자료구조` + 문서 `후입선출 자료구조`)이 **Ollama `snowflake-arctic-embed2` 임베딩과 코사인 > 0.99 일치**(Ollama 192.168.0.47 도달 가능 — pooling/prefix 정확성 정밀 검증, 130 재현 위험 차단). live Ollama 없으면 `#[ignore]` + 수동 절차 문서화.
|
||||
- ollama provider: mock 또는 live 로 dim 1024 정규화 벡터 반환 smoke.
|
||||
- config provider=`candle`+arctic / `ollama`+arctic 각각 올바른 embedder 로드.
|
||||
- 기본 provider=fastembed e5 동작 불변(스모크).
|
||||
|
||||
## 도그푸딩 (별도, Mac Metal — 본 PR acceptance 아님)
|
||||
arctic 으로 namu 재임베딩 → `namu_golden_expanded.yaml` 로 recall@10 ≈ 130 재현 확인. CLAUDE.md §Dogfood trigger(embedder 모델 변경) 충족. 결과 HOTFIXES + release notes.
|
||||
|
||||
## 문서 동기화 (같은 PR)
|
||||
- README Configuration: provider=candle/ollama + arctic 모델 + endpoint + Apple Silicon(metal) 안내.
|
||||
- docs/ARCHITECTURE: 임베딩 백엔드 그래프 + 신규 크레이트(kebab-embed-ollama) + 결정 표(arctic 채택 근거 측정 링크).
|
||||
- HANDOFF 1줄. tasks/HOTFIXES dated entry(측정 근거 + cascade).
|
||||
- Cargo.toml workspace members += kebab-embed-ollama, version minor bump.
|
||||
|
||||
## 비범위
|
||||
- e5 KB 자동 마이그레이션(전환 = 수동 재임베딩, cascade 규칙대로).
|
||||
- dragonkue ko 파인튠(후속).
|
||||
- D(query-side)·C(reranker) 통합(별도 후속, 본 PR 은 임베더만).
|
||||
60
docs/superpowers/specs/2026-06-03-ingest-log-improve-spec.md
Normal file
60
docs/superpowers/specs/2026-06-03-ingest-log-improve-spec.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# Spec: ingest 로그 개선 (파일명·phase·heartbeat·slowest 요약)
|
||||
|
||||
**날짜**: 2026-06-03
|
||||
**유형**: feature (관측성/UX, additive wire)
|
||||
**근거**: arctic 도그푸딩 중 Obsidian 볼트(이미지/PDF 혼재 + OCR/caption on)에서 ingest 가 중간부터 느려졌는데, **TTY 진행바가 파일명·현재 phase·모델·경과시간을 안 보여줘** "멈춘 것처럼" 보였다. 원인(비전 모델 스와핑)을 로그만으로 파악 불가. v0.24.0 상세 진행 로깅의 후속 — 느린 phase(특히 이미지 OCR/caption)와 병목 파일을 가시화한다.
|
||||
|
||||
## 현재 한계 (코드 근거)
|
||||
- `kebab-cli/src/progress.rs:145` — TTY 에서 AssetStarted 는 **위치만 갱신, 파일명 메시지 미설정**(의도적; 비-TTY 줄에만 파일명). → 인터랙티브 실행 시 현재 파일 안 보임.
|
||||
- 이미지 **OCR/caption 진행 이벤트 없음** — `PdfOcrStarted/Finished`(PDF 페이지)만 존재. 이미지 OCR/caption(gemma 비전, 느림)은 무이벤트 → 진행바 정지처럼 보임(`lib.rs` apply_ocr/apply_caption 호출 주변).
|
||||
- 한 asset 이 오래 걸려도 **경과시간 heartbeat 없음**(완료 후 `AssetTimings` ⏱ 한 번).
|
||||
- 병목 파일을 **사후 파악할 요약 없음**.
|
||||
|
||||
## 목표 (사용자 결정: 1+2+3+4)
|
||||
1. **파일명**을 TTY 진행바 메시지에 표시.
|
||||
2. 느린 **phase(OCR/caption/embed) + 모델명** 실시간 표시.
|
||||
3. 현재 asset **경과시간 heartbeat**.
|
||||
4. 종료 시 **가장 오래 걸린 파일 top-N 요약**.
|
||||
|
||||
## 작업
|
||||
|
||||
### A. wire 이벤트 (additive, ingest_progress.v1)
|
||||
- **신규 `AssetPhase { idx, total, phase, model }`** — asset 이 느린 phase 진입 시 emit. `phase: &str` ∈ {`"ocr"`,`"caption"`,`"embed"`}; `model: Option<String>`(그 phase 를 수행하는 모델 — OCR/caption=비전 LLM 모델 id, embed=임베더 model_id). 짧은 phase(parse/chunk/store)는 emit 안 함(노이즈 방지).
|
||||
- **`AssetTimings` 확장**: `ocr_ms`, `caption_ms` 필드 추가(additive, 기본 0). 기존 parse/chunk/embed/store/expansion_ms 유지. → top-N 요약의 정확한 per-asset 총시간 계산 근거.
|
||||
- `PdfOcrStarted/Finished`(기존) 유지 — PDF 페이지 단위 진행은 이미 있음.
|
||||
- wire schema `docs/wire-schema/v1/ingest_progress.schema.json`: `asset_phase` kind + `phase`/`model` + `ocr_ms`/`caption_ms` 필드 문서화(additive, v1 유지).
|
||||
|
||||
### B. emit 지점 (kebab-app)
|
||||
- `ingest_one_asset` / 이미지·미디어 경로(`apply_ocr`/`apply_caption` 호출 직전, `lib.rs:~1568/1586`): 각각 `AssetPhase{phase:"ocr"|"caption", model}` emit. 임베딩 루프 진입 시 `AssetPhase{phase:"embed", model:embedder.model_id}` emit(텍스트 asset 도 적용).
|
||||
- OCR/caption 소요를 측정해 `AssetTimings.ocr_ms`/`caption_ms` 채움.
|
||||
|
||||
### C. CLI 렌더 (kebab-cli/src/progress.rs)
|
||||
1. **파일명**: AssetStarted TTY 핸들러에서 `bar.set_message(<path 축약>)`(현재 위치-only 주석 제거). 비-TTY 줄은 그대로.
|
||||
2. **phase+모델**: AssetPhase 수신 시 `bar.set_message("{path} · {phase}({model})…")`.
|
||||
3. **heartbeat**: AssetStarted 에서 현재 asset 시작 시각 기록 + steady-tick(예: 1s)으로 메시지 끝에 `(Ns)` 경과 갱신. asset 전환/완료 시 리셋.
|
||||
4. **slowest 요약**: AssetStarted(idx→path) + AssetTimings(idx→총ms=parse+chunk+embed+store+ocr+caption) 를 누적, `Completed` 수신 시 stderr 에 `⏱ 최장 소요 top-N`(기본 N=5) 표 출력. 비-TTY/quiet 에서도 요약은 출력(유용), `--json` 모드는 미출력(ndjson 오염 방지).
|
||||
|
||||
### 결정 사항
|
||||
- 모두 **additive wire** → `ingest_progress.v1` 유지(major bump 없음). 신규 소비자는 `asset_phase` 부재 허용.
|
||||
- AssetPhase 는 **emit 스로틀 불필요**(asset·phase 당 1회, 빈도 낮음). PDF 페이지 OCR 은 기존 PdfOcrStarted 가 담당(페이지 많으면 그쪽 스로틀은 별도 — 본 spec 비범위).
|
||||
- top-N 의 N: 상수 5(후속에 config 화 가능, 본 spec 비범위).
|
||||
- `--quiet` 시 진행바·phase 메시지는 억제하되 **slowest 요약은 출력**(짧고 유용). `--json` 은 전부 ndjson 으로만.
|
||||
|
||||
## 검증 기준
|
||||
- clippy 0 / 전체 test 통과(기존 진행 렌더 테스트 갱신 + 신규 이벤트 직렬화 테스트).
|
||||
- TTY 스모크: 이미지/PDF 포함 폴더 ingest 시 진행바에 **파일명 + OCR/caption/embed phase + 모델 + 경과초** 표시, 종료 시 **top-N 요약**.
|
||||
- 비-TTY: 기존 줄 로그 유지 + 종료 요약.
|
||||
- `--json`: `asset_phase`/확장 `asset_timings` ndjson 출력, 사람용 텍스트 미혼입.
|
||||
- wire schema 문서 동기화 + verbatim 일치(CI diff-check 있으면).
|
||||
|
||||
## 도그푸딩 (별도)
|
||||
사용자 Obsidian 볼트(이미지/PDF + OCR on)로 재현 — 느린 구간에서 어떤 파일·phase·모델인지 즉시 보이는지, 종료 요약이 병목 파일을 짚는지 확인. HOTFIXES + release notes.
|
||||
|
||||
## 문서 동기화 (같은 PR)
|
||||
- `docs/wire-schema/v1/ingest_progress.schema.json` (asset_phase, ocr_ms/caption_ms).
|
||||
- README(진행 표시 설명 있으면 갱신, 명령표 영향 없음), HANDOFF 1줄, tasks/HOTFIXES dated entry, Cargo.toml version minor bump.
|
||||
|
||||
## 비범위
|
||||
- PDF 페이지 OCR 진행 스로틀/요약(기존 이벤트 유지).
|
||||
- 모델 스와핑 자체 해결(그건 Ollama 설정/OCR off — 본 작업은 가시화만).
|
||||
- top-N 의 config 화.
|
||||
@@ -0,0 +1,55 @@
|
||||
# Spec: ingest 출력에 영향 주는 모든 설정 변경 시 자동 재색인 (skip 무효화 일반화)
|
||||
|
||||
**날짜**: 2026-06-03
|
||||
**유형**: bug fix (patch)
|
||||
**근거**: `[image.ocr]`/`[image.caption]` 를 off→색인→on 으로 바꿔도 증분 skip 이 이미지를 "Unchanged" 로 건너뛴다. 더 일반적으로, `try_skip_unchanged` 가 자산 내용(blake3)+`parser_version`+`chunker_version`+`embedding_version` 만 비교하는데, **ingest 산출물을 바꾸는 다른 설정들**(청킹 파라미터, OCR/caption, pdf.ocr, 코드 ingest 옵션)이 이 셋 중 어디에도 반영되지 않아 변경해도 재색인이 안 된다. 사용자 요구: **OCR/caption 뿐 아니라 ingest 출력에 영향 주는 모든 설정**이 같은 방식으로 동작(변경→영향 자산 자동 재색인). 결과 포맷·인터페이스·새 플래그 변화 없음(내부 skip 판정 정정) → **patch**.
|
||||
|
||||
## 동작 사실 (코드 근거)
|
||||
- `try_skip_unchanged`(lib.rs:866)는 `get_document_by_workspace_path` 로 기존 doc 조회 후 `existing_doc.parser_version != current_parser_version`(line 959) 면 재색인(cascade). **조회는 workspace_path** 이므로 doc_id 파생과 무관 — 비교는 저장된 `parser_version` 필드 대 현재값.
|
||||
- 각 경로가 상수 parser_version 을 넘김: md `md-heading-v1`(351), image `image-meta-v1`(1532), pdf `pdf-text-v1`(2109), code 등. 청킹 파라미터(`target_tokens`/`overlap_tokens`/`respect_markdown_headings`)는 `chunker_version` 상수에 안 들어가 변경해도 재청킹 안 됨(동일 갭).
|
||||
|
||||
## 설계: per-asset-type "ingest config signature" 를 effective parser_version 에 폴딩
|
||||
|
||||
`try_skip_unchanged` 에 넘기는 `current_parser_version` 과 **persist 되는 doc 의 `parser_version` 필드**를, 그 자산 타입의 **ingest 산출물에 영향을 주는 설정 전체의 결정적 서명**을 포함한 composite 로 만든다. 두 값이 같은 함수에서 나오므로, 관련 설정이 바뀌면 다음 run 비교가 mismatch → **영향 받는 자산만** 자동 재색인. doc_id 는 path 조회라 기존대로(안정, orphan churn 회피).
|
||||
|
||||
### 어떤 설정이 어느 자산에 영향 (서명 구성)
|
||||
공통 헬퍼 `ingest_config_signature(config, media_type) -> String`. **ingest 산출물에 영향 주는 것만** 포함(아래 외 search/rag/nli/ui/logging/storage/workspace 는 **제외** — 바뀌어도 재색인 안 함):
|
||||
|
||||
- **공통(모든 타입)**: `[chunking]` target_tokens, overlap_tokens, respect_markdown_headings, chunker_version. (embedding model/dim 은 이미 `embedding_version` cascade 가 담당 — 서명에 중복 포함 불필요, 단 일관성 위해 포함해도 무방.)
|
||||
- **image**: + `[image.ocr]` enabled (+enabled 면 model), `[image.caption]` enabled (+enabled 면 prompt_template_version).
|
||||
- **pdf**: + `[pdf.ocr]` enabled (+enabled 면 model, always_on).
|
||||
- **code**: + `[ingest.code]` skip_generated_header, max_file_bytes, max_file_lines, extra_skip_globs, ast_chunk_max_lines, fallback_lines_per_chunk, fallback_lines_overlap.
|
||||
- **markdown**: 공통만.
|
||||
|
||||
서명 형식: 결정적 문자열 또는 그 blake3-12. 예 `image-meta-v1|chunk:500:80:true|ocr:1:qwen2.5vl:3b|cap:1:caption-v1`. off/미적용 항목은 안정적 표현(빈값)으로 — 동일 설정 재실행은 서명 동일 → **불필요 재색인 0**.
|
||||
|
||||
## 작업 (kebab-app)
|
||||
1. `ingest_config_signature(config, media_type)` 헬퍼 추가(위 매핑). 출력 결정적(필드 순서 고정, Vec 는 join).
|
||||
2. 각 ingest 경로에서 effective parser_version = `format!("{base}|{signature}")` 또는 base 를 서명으로 감싼 값으로:
|
||||
- md(351), image(1532), pdf(2109), code 경로의 `*_parser_version` 계산을 composite 로.
|
||||
- **persist 전 `canonical.parser_version` 을 동일 composite 로 override**(extractor 가 박은 상수 대신). skip-check 와 저장값이 같아야 함.
|
||||
3. doc_id: 변경 불필요(workspace_path 조회). composite 는 비교 필드에만.
|
||||
|
||||
## 동작 / 호환
|
||||
- ingest 영향 설정(청킹/OCR/caption/pdf.ocr/code) 변경 또는 모델·prompt 변경 → effective parser_version 변화 → **영향 자산만** `--force-reingest` 없이 자동 재색인(+UPSERT/purge). 비영향 설정(search/rag/ui/log) 변경 → 재색인 0.
|
||||
- **업그레이드 1회 효과**: 기존 doc 의 저장 parser_version(상수)이 새 composite 와 달라 → 업그레이드 후 첫 ingest 에서 전 자산 1회 재색인(현재 설정대로). 마크다운/코드도 1회 재청킹되나 embedding 은 V012 캐시 히트라 재임베딩 비용 작음. (HOTFIXES/release notes 에 1회 재색인 명시.)
|
||||
- `--force-reingest` 는 전체 강제용으로 그대로 유지.
|
||||
|
||||
## 검증 기준
|
||||
- clippy 0. `cargo test -p kebab-app -p kebab-parse-image -p kebab-parse-pdf -p kebab-parse-code -p kebab-chunk -j 8` 통과 (**전체 워크스페이스 `-j 1` 금지 — `-j 8`**).
|
||||
- 신규 테스트(자산 타입별):
|
||||
- image.ocr off→on / caption off→on → 해당 이미지 재색인(skip 아님). off→off, on→on(동일) → skip 유지.
|
||||
- pdf.ocr off→on → PDF 재색인. 동일 설정 → skip.
|
||||
- chunking target_tokens 변경 → md/code/image/pdf 전부 재색인. 변경 없으면 skip.
|
||||
- ingest.code 옵션 변경 → 코드 자산 재색인, 이미지/md 는 영향 받되 **공통(chunking) 변경 아니면 코드만** (code 전용 설정은 code 서명에만).
|
||||
- search/rag/ui 설정 변경 → 재색인 0 (회귀 가드, 중요).
|
||||
- 동일 config 재실행 → 전 자산 skip(불필요 재색인 0) — 회귀 가드.
|
||||
- 스모크: 이미지 ocr off 색인 → config ocr on → `kebab ingest`(force 없이) → 그 이미지만 재색인 확인.
|
||||
|
||||
## 비범위
|
||||
- 새 config 키/CLI 플래그/wire(없음).
|
||||
- 서명에 max_pixels/languages/timeout 같은 *런타임 비-산출* 파라미터는 **제외**(산출물 불변 → 과도 무효화 회피). 포함 기준 = "그 값이 바뀌면 색인되는 chunk/embedding 내용이 달라지는가".
|
||||
- search/rag/nli/ui/logging/storage/workspace 설정(ingest 산출 무관) 제외.
|
||||
|
||||
## 문서/버전
|
||||
- tasks/HOTFIXES dated entry(일반화 + 1회 재색인 안내). Cargo.toml **patch bump (0.26.1 → 0.26.2)**(+Cargo.lock). README/wire 변화 없음. HANDOFF 1줄(선택).
|
||||
@@ -0,0 +1,55 @@
|
||||
# Spec: doc-side expansion(별칭) 기능 제거
|
||||
|
||||
**날짜**: 2026-06-03
|
||||
**유형**: 기능 제거 (refactor/removal)
|
||||
**근거**: `docs/superpowers/research/2026-06-03-expansion-cost-rethink-research.md` (Step 0/1 측정 + 딥리서치). 별칭 ROI 음수: cross-lingual 은 e5-large 단독으로 이미 완벽, 별칭 기여는 설명형 +2 그룹뿐인데 대가가 청크당 색인-시 LLM(살아있는 KB 에 지속 불가). 문헌(arXiv 2309.08541)도 "강한 검색기엔 expansion 해롭다" 확인.
|
||||
**design contract 영향**: design §(Phase 2 doc-side expansion) 에서 도입된 기능 제거 → `tasks/HOTFIXES.md` dated entry + 원 spec(`docs/superpowers/specs/2026-05-30-doc-side-expansion-design.md`)의 Risks/notes 에 제거 cross-link 1줄. design 본문은 별도 spec PR 이 아닌 본 PR 에서 "deprecated/removed" 주석만.
|
||||
|
||||
## 목표
|
||||
색인-시 청크당 LLM 별칭 생성 + 별칭 검색 경로를 **완전히 제거**한다. 기본 동작 불변(별칭은 이미 default-off)이라 일반 사용자 체감 0. 코드/스키마/wire 표면을 정리해 유지보수 부담을 없앤다.
|
||||
|
||||
## 제거 대상 (REMOVE)
|
||||
- `crates/kebab-app/src/expansion.rs` — 모듈 전체 (ExpansionGenerator, is_nav_boilerplate, parse_aliases, strip_list_marker).
|
||||
- `crates/kebab-app/src/lib.rs` — `pub mod expansion;`, ingest_one_asset 의 expansion 루프(별칭 생성·캐시 조회/저장·`alias_version_key`·`embed_aliases` 임베딩·alias sentinel 벡터 `{orig}#alias#N`), 관련 카운터(`alias_cache_hit/miss`, `alias_touch_keys`).
|
||||
- `crates/kebab-config/src/lib.rs` — `ExpansionCfg` 구조체 + `IngestCfg.expansion` 필드 + 기본값.
|
||||
- `crates/kebab-config/src/migrate.rs` — `[ingest.expansion]` 섹션 주석/마이그레이션 처리.
|
||||
- `crates/kebab-core/src/chunk.rs` — `Chunk.aliases: Option<String>` 필드 (+ 관련 serde default 테스트). **주의: `crates/kebab-core/src/metadata.rs` 의 `Metadata.aliases: Vec<String>` 는 문서 메타데이터(§3.6)로 무관 — 유지.**
|
||||
- `crates/kebab-search/src/lexical.rs` — `run_alias_query`, `merge_body_alias`, alias FTS 분기(`build_match_string_for_column(.., "aliases")`).
|
||||
- `crates/kebab-store-sqlite` — `chunk_aliases_fts` 테이블 + 트리거 + `chunks.aliases` 컬럼: **신규 forward 마이그레이션(V0XX)으로 DROP**. INSERT/SELECT 경로(`documents.rs` 의 aliases 컬럼 쓰기/읽기) 제거.
|
||||
- `crates/kebab-app/src/ingest_progress.rs` — `IngestEvent::ExpansionProgress` variant (+ 직렬화 테스트). **`AssetChunked`/`AssetTimings` 는 유지**(별칭과 무관, 청킹/타이밍 가시성).
|
||||
- `crates/kebab-cli/src/progress.rs` + `crates/kebab-tui/src/ingest_progress.rs` — ExpansionProgress 렌더(`별칭 확장 N/chunks`).
|
||||
- `crates/kebab-tui/src/inspect.rs` — chunk 별칭 표시(있으면).
|
||||
- derivation_cache 의 `"alias"` kind: 쓰기 경로 제거. 기존 행은 무해(읽지 않음), `kebab reset` 시 정리. kind enum 에서 alias 제거는 선택(read 호환 위해 남겨도 무방).
|
||||
|
||||
## 유지 (KEEP — 제거 금지)
|
||||
- `Metadata.aliases` (문서 메타데이터, metadata.rs).
|
||||
- `AssetChunked`, `AssetTimings` wire 이벤트 + 렌더.
|
||||
- derivation_cache 의 `embedding` kind (V012 임베딩 캐시 — 별칭과 독립, 성능 핵심).
|
||||
- `chunks_fts`(본문 FTS) 전부.
|
||||
- `Chunk` 구조체를 생성하는 모든 곳(kebab-chunk/*, kebab-parse-*/*): `aliases: None` 리터럴은 필드 제거에 맞춰 **삭제만**(기능 변경 아님).
|
||||
|
||||
## 결정 사항
|
||||
- **마이그레이션**: 신규 forward-only 마이그레이션으로 `chunk_aliases_fts`(+ 트리거)와 `chunks.aliases` 컬럼 DROP. SQLite 3.35+ `DROP COLUMN` 사용(번들 sqlite 확인). down 마이그레이션 불필요(refinery forward-only 관행 따름). 기존 KB: 별칭 default-off 라 대부분 빈 데이터 → 손실 없음. corpus_revision cascade 불필요(별칭은 검색 보조였을 뿐, 본문/임베딩 불변).
|
||||
- **wire schema**: `ingest_progress.v1` 에서 `expansion_progress` kind 제거. v0.24.0 에서 막 추가된 additive variant 라 소비자(agent/CLI)는 부재 허용 → major bump 불요. `docs/wire-schema/v1/ingest_progress.schema.json` 에서 해당 kind 정의 삭제 + 주석.
|
||||
- **버전**: workspace `version` patch/minor bump(별칭 제거 = surface 정리, breaking schema 아님 — 단 chunk_aliases_fts DROP 마이그레이션 포함이라 이전 binary 가 새 DB 열 때 영향 없음(컬럼 제거는 구 binary 의 SELECT 깨뜨릴 수 있으나 단일 사용자·forward-only 전제). minor bump 권장.
|
||||
- **config**: `[ingest.expansion]` 제거 후 기존 사용자 config.toml 에 해당 섹션이 있어도 serde forward-compat(unknown field ignore)로 무해. `kebab config migrate` 가 섹션 제거하도록 갱신(선택).
|
||||
|
||||
## 문서 동기화 (같은 PR)
|
||||
- `tasks/HOTFIXES.md`: dated entry — 제거 근거(연구 링크) + 마이그레이션 + wire 변경.
|
||||
- `docs/superpowers/specs/2026-05-30-doc-side-expansion-design.md`: Risks/notes 에 "2026-06-03 제거됨, 본 spec 참조" 1줄.
|
||||
- `README.md` / `HANDOFF.md`: 별칭이 README 에 노출돼 있으면 제거(default-off 라 노출 없을 가능성). HANDOFF 한 줄.
|
||||
- `docs/wire-schema/v1/ingest_progress.schema.json`: expansion_progress 제거.
|
||||
- design 본문(frozen contract)에 Phase 2 별칭 기술이 있으면 "removed (HOTFIXES 2026-06-03)" 주석.
|
||||
|
||||
## 검증 기준 (Acceptance)
|
||||
- `cargo clippy --workspace --all-targets -j 4 -- -D warnings` 통과.
|
||||
- `cargo test --workspace --no-fail-fast -j 1` 통과 — 별칭 전용 테스트(`tests/chunk_aliases.rs`, expansion.rs 테스트, lexical alias 테스트)는 삭제, 그 외 회귀 0.
|
||||
- 신규 마이그레이션 적용된 fresh KB 에 `chunk_aliases_fts`/`chunks.aliases` 부재 확인(`.schema`).
|
||||
- `kebab ingest`(별칭 config 없이) 정상 — AssetChunked/AssetTimings 진행 표시 유지, expansion_progress 미출력.
|
||||
- 기존 별칭 데이터가 있던 KB 도 마이그레이션 후 search/ask 정상(별칭 벡터는 무시/정리).
|
||||
- grep 잔존 0: `expansion::|ExpansionCfg|chunk_aliases|run_alias_query|merge_body_alias|ExpansionProgress|embed_aliases|is_nav_boilerplate|Chunk.*aliases`.
|
||||
|
||||
## 비범위 (out of scope)
|
||||
- 별칭 대체 방법(heading enrichment / arctic-ko 임베더 / reranker / query-side) — 후속 별 작업(연구문서 §7 Layer A~D).
|
||||
- `Metadata.aliases`(문서 메타) 변경.
|
||||
- derivation_cache GC wiring.
|
||||
192
docs/superpowers/specs/2026-06-04-rust-native-ocr-spec.md
Normal file
192
docs/superpowers/specs/2026-06-04-rust-native-ocr-spec.md
Normal file
@@ -0,0 +1,192 @@
|
||||
# Spec: Rust 네이티브 OCR 엔진 (PP-OCRv5 ONNX, in-process)
|
||||
|
||||
**날짜**: 2026-06-04
|
||||
**유형**: feature (minor) — 신규 OCR 엔진 + config 키 + 동작 변화
|
||||
**상태**: draft (self-review 대기)
|
||||
**contract_sections**: design §6 (parse/extract), §8 (deps), §9 (versioning cascade)
|
||||
|
||||
## 동기
|
||||
|
||||
현재 이미지/PDF OCR 은 Ollama Vision LLM(`gemma4:e4b` 8B) 1콜(`crates/kebab-parse-image/src/ocr.rs`, `OllamaVisionOcr`). 사용자 실측 문제:
|
||||
|
||||
- 실제 이미지 한 장당 **~50초**(VLM 은 글자를 토큰 단위로 생성 → 조밀 페이지는 본질적으로 느림). 모델을 바꿔도(qwen2.5vl:3b GPU 20~28초) 사용자 허용치 미달.
|
||||
- 사용자 결정: **배치 ingest 용도 + Python 의존 불가 + Rust 내장**.
|
||||
|
||||
### 근거 벤치 (2026-06-04, `/build/dogfood/logs/2026-06-04-ocr-model-bench.md`)
|
||||
|
||||
| 방식 | 작은 이미지 | 초대형 1757×2644 | 정확도 | 비고 |
|
||||
|---|---|---|---|---|
|
||||
| gemma4:e4b 8B VLM (GPU) | 11초 | 43초 | 0.65~0.82 | 현재 |
|
||||
| qwen2.5vl:3b VLM (GPU) | 3.6초 | 20초 | 0.93 | 속도 미달 |
|
||||
| **PP-OCRv5 mobile ONNX, Rust (CPU)** | **0.05초** | **2.75초** | **0.976** | **PoC 검증됨** |
|
||||
|
||||
VLM 은 생성 병목으로 탈락. **검출+인식형 전용 엔진(PP-OCRv5)을 ONNX 로 Rust in-process 실행**이 속도·정확도·한국어·단일바이너리 모두 만족. PoC: `oar-ocr` 0.6.3 + `ort` 로 위 수치 확인(오류는 띄어쓰기뿐, 한국어 오인식 0). PoC 코드/모델: `/build/cache/ocr-bench/{rust-poc,onnx}/`.
|
||||
|
||||
## 핵심 설계 결정: oar-ocr 미채택, 핀 ort 위 직접 구현
|
||||
|
||||
PoC 는 `oar-ocr` 0.6.3 으로 검증했으나 **프로덕션 의존성으로는 쓰지 않는다**. 이유(load-bearing):
|
||||
|
||||
- kebab 은 `ort = "=2.0.0-rc.9"` 를 **의도적 핀**(workspace `Cargo.toml:195-204`): fastembed 4.9 의 ONNX Runtime+tokenizer 스택을 워크스페이스 단일 버전으로 유지. `ndarray = "0.16"` 도 동일.
|
||||
- `oar-ocr` 0.6.3 은 `ort 2.0.0-rc.12` + `ndarray 0.17` 요구. `ort` 는 `ort-sys` 가 onnxruntime 네이티브 라이브러리를 `links` 하므로 **두 버전 공존 불가** → oar-ocr 채택 시 ort/ndarray 를 bump 해야 하고, 이는 fastembed/kebab-nli/kebab-embed-candle 의 임베딩·NLI 스택을 흔든다(사용자 우선순위인 검색 품질 직결, [[search-quality-dogfood]]).
|
||||
|
||||
**→ PaddleOCR 의 전/후처리(검출 DBNet postproc + 인식 CTC decode)를 kebab 의 기존 핀 `ort`(rc.9) 위에 직접 구현.** oar-ocr(Apache-2.0) 소스 + Python PaddleOCR 을 레퍼런스로. 공유 ort 라 새 네이티브 의존성 0, 임베딩 스택 무영향.
|
||||
|
||||
### C2 검증 완료 (rc.9 스파이크, 2026-06-04)
|
||||
|
||||
PoC 는 oar-ocr 경유 ort **rc.12** 로 돌았으므로, 핀 **rc.9** 가 paddle2onnx 산출 모델을 실제 로드/추론하는지 별도 검증함(`/build/cache/ocr-bench/rc9-spike/`). 결과 **PASS**:
|
||||
- `ort = "=2.0.0-rc.9"` + `ort-sys = "=2.0.0-rc.9"`(caret 으로 rc.12 끌려가는 것 방지 — kebab Cargo.lock 과 동일) + `ndarray 0.16` + feature `["ndarray","download-binaries"]` 로 빌드/링크/onnxruntime 다운로드 성공.
|
||||
- det: 입력 `"x"` → 출력 `[1,1,640,640]`(DBNet 확률맵). rec: 출력 `[1,40,11947]`(timestep×클래스; dict 11,945 + blank/특수 = 11,947, CTC 정합 확인).
|
||||
- `try_extract_tensor::<f32>()` 는 rc.9 에서 `ArrayViewD<f32>` 반환(rc.12 의 `(shape,&[T])` 와 다름) — 구현 시 유의.
|
||||
- **함의**: 핀 ort 유지(ort/ndarray bump 불필요)로 임베딩 스택 무영향 확정. opset 호환 OK. 출력 형태가 후처리 설계(det threshold→박스 / rec CTC)와 일치.
|
||||
|
||||
### 추가 의존성
|
||||
|
||||
- `image`(이미 허용), `ndarray`(workspace `=0.16`), `ort`(workspace `=2.0.0-rc.9`, **features `["ndarray","download-binaries"]`**).
|
||||
- **download-binaries 필수**: `kebab-parse-image` 는 fastembed 빌드그래프에 없어, 단독 빌드(`cargo test -p kebab-parse-image`)시 onnxruntime 링크 위해 명시 필요. `kebab-nli/Cargo.toml:23` 의 동일 선례 주석 그대로 따름.
|
||||
- `ort-sys` 가 caret 으로 rc.12 로 끌려가지 않도록 workspace 핀과 Cargo.lock 정합 확인.
|
||||
- `imageproc` — det 확률맵 연결요소/윤곽 추출. **단 min-area rotated-rect 는 imageproc 미제공 → rotating-calipers 직접 구현**.
|
||||
- DBNet unclip(다각형 확장): **`clipper2` 는 C++ FFI 가능성 → single-binary/pure-Rust 위배 위험. 우선 pure-Rust 다각형 offset 직접 구현 또는 검증된 pure-Rust crate.** (plan 에서 clipper2 가 C++ 링크인지 확인 후 택일.)
|
||||
|
||||
## 파이프라인 (OnnxPaddleOcr)
|
||||
|
||||
`crates/kebab-parse-image/src/` 에 신규 모듈. `OcrEngine` trait(`ocr.rs:54`) 구현:
|
||||
|
||||
```rust
|
||||
pub trait OcrEngine: Send + Sync {
|
||||
fn engine_name(&self) -> &'static str; // "paddle-onnx"
|
||||
fn engine_version(&self) -> String; // "ppocrv5-mobile-kor-v1" (+model hash)
|
||||
fn recognize(&self, image_bytes: &[u8], lang_hint: Option<&Lang>) -> Result<OcrText>;
|
||||
}
|
||||
```
|
||||
|
||||
`recognize` 단계 (PoC 와 동일 알고리즘):
|
||||
|
||||
1. **디코드+다운스케일**: `image` 로 디코드 → 긴변 `max_pixels`(기본 1600) 로 축소(기존 `OcrCfg.max_pixels` 재사용, qwen 과 달리 PP-OCRv5 는 원본도 안전하나 속도 위해 유지).
|
||||
2. **검출(det)**: BGR 정규화 → det ONNX(`PP-OCRv5_mobile_det`) → 확률맵 → threshold(0.3) 이진화 → 윤곽(imageproc) → min-area rect → unclip(ratio 1.5) → 텍스트 박스 N개.
|
||||
3. **인식(rec)**: 각 박스 crop+회전보정 → 48×W 리사이즈/정규화 → rec ONNX(`korean_PP-OCRv5_mobile_rec`) → CTC greedy decode(dict 11,945자, blank 처리) → 텍스트+score.
|
||||
4. **조립**: 박스를 reading-order(상→하, 좌→우) 정렬 → `OcrText { joined, regions: Vec<OcrRegion{bbox,text,confidence}>, engine, engine_version }`. **Ollama 와 달리 per-line bbox/confidence 제공**(`OcrRegion` 풍부화).
|
||||
|
||||
배치: PoC 는 박스별 순차 rec. 성능 충분(초대형 2.75초)하나, rec 를 ort 배치 입력으로 묶으면 추가 향상 가능(plan 에서 측정 후 결정).
|
||||
|
||||
### 단계별 분해 (M1 — 각 단계 골든벡터 단위테스트)
|
||||
|
||||
후처리가 실제 난도. "쉽다"로 뭉뚱그리지 않고 **각 단계를 독립 테스트 가능 단위**로 쪼갠다. 각 단위는 oar-ocr/Python PaddleOCR 이 **같은 fixture** 에 내는 출력을 골든벡터로 박아 단계별 회귀(0.976 baseline 대비)를 잡는다:
|
||||
|
||||
1. **전처리**(resize/pad/normalize): det 입력 정규화(mean/std, /255). 골든: 알려진 이미지→텐서 일부 값.
|
||||
2. **det 후처리**: 확률맵(`[1,1,H,W]`)→threshold(0.3)→연결요소(imageproc)→**min-area rotated-rect(rotating calipers 직접 구현)**→**unclip(다각형 offset, ratio 1.5)**→박스. 골든: 합성 이미지의 기대 박스 개수/대략 좌표.
|
||||
3. **crop+rectify**: 회전 박스→perspective/affine warp 로 수평 정렬(oar-ocr 가 공짜 제공하던 부분; 직접 구현 필요). 골든: 회전 텍스트 fixture.
|
||||
4. **rec 전처리+추론**: crop→48×W 정규화→rec ONNX→`[1,T,C]` logits.
|
||||
5. **CTC greedy decode**: argmax per timestep→연속중복 제거→blank(인덱스 0 또는 dict 길이 위치, **PaddleOCR 규약 정확 매칭**) 제거→dict 인덱스→char. dict 길이(11,945) vs rec 출력 클래스(11,947) 정합 + **인덱스 bounds-check**(잘못된 dict 길이/빈 줄 방어). 골든: 알려진 logit→문자열.
|
||||
6. **box reading-order**: 상→하, 좌→우 정렬(가로쓰기 전제; 세로/회전 페이지는 비범위).
|
||||
|
||||
각 단계 divergence 를 end-to-end 가 아니라 단위에서 잡는다(M1 권고).
|
||||
|
||||
## Config
|
||||
|
||||
`OcrCfg`(`kebab-config/src/lib.rs:343`)에 `engine` 필드 **이미 존재**(기본 `"ollama-vision"`). 변경:
|
||||
|
||||
- `engine` 값에 `"paddle-onnx"` 추가(문서화). 기본값은 **당장 바꾸지 않음**(default 변경은 별도 결정 — 아래 "기본 엔진" 참조).
|
||||
- 신규(선택) 필드: `det_model` / `rec_model` / `dict` 경로 override(미지정 시 자동 다운로드 캐시 경로). `score_thresh`(기본 0.3), `unclip_ratio`(기본 1.5) 는 고급 튜닝용(기본값 고정, 노출 최소).
|
||||
- `pdf.ocr` 도 동일 `engine` 분기 적용(같은 trait).
|
||||
|
||||
### 모델 배포 — 결정 C: kebab 와 함께 번들 (HF 미사용, 사용자 확정 2026-06-04)
|
||||
|
||||
제3자(HF) 호스팅 의존 제거. 변환본(det 4.7MB + korean rec 13MB + dict ≈ **17MB**)을 kebab 자체에 번들. **구체 기법은 plan 에서 택1**(모두 HF/외부 네트워크 0):
|
||||
|
||||
- **C-1 바이너리 임베드(`include_bytes!`)**: 모델을 바이너리에 박음. 진정한 single-binary·완전 오프라인·재현성 100%. 비용: 릴리스 바이너리 +17MB, 그리고 dev/test 빌드마다 17MB 링크 부담 → **release feature(`bundled-ocr-models`) 게이트**로 dev 빌드 제외 가능. 로컬-first 철학 최적합.
|
||||
- **C-2 repo 벤더링**: `assets/paddleocr-onnx/`(git 또는 git-LFS) 에 두고 빌드 시 `OUT_DIR` 복사 또는 런타임 상대경로. 바이너리 비대 회피하나 배포 시 파일 동반 필요.
|
||||
- **C-3 gitea 릴리스 에셋 + 첫 실행 다운로드**: `gitea-release --asset` 로 첨부, 첫 실행 시 릴리스 URL 에서 `model_dir/paddleocr-onnx/` 로 받음. 바이너리 lean 하나 첫 실행 시 gitea 네트워크 필요(에어갭 불가) — 로컬-first 와 약간 상충.
|
||||
|
||||
**권장 = C-1(release feature 게이트)**: 오프라인·재현성·single-binary 가 kebab 정체성과 가장 정합. plan 에서 빌드/링크 영향 측정 후 확정.
|
||||
|
||||
- **무결성**: 임베드(C-1)면 빌드 시점 고정이라 별도 해시 불요(바이너리=정본). C-2/C-3 면 blake3 pin 필수.
|
||||
- **라이선스**: PP-OCRv5 가중치 Apache-2.0 — 재배포 가능. 번들에 NOTICE 동반.
|
||||
- **오프라인**: C-1 완전 오프라인. config override(`det_model`/`rec_model`/`dict`)로 로컬 모델 교체 항상 가능.
|
||||
|
||||
## 엔진 선택 (kebab-app 팩토리)
|
||||
|
||||
현재 `OllamaVisionOcr` 하드코딩(`kebab-app/src/lib.rs:360`(image), `379`(pdf)). 변경:
|
||||
|
||||
```rust
|
||||
let ocr_engine: Option<Box<dyn OcrEngine>> = if cfg.image.ocr.enabled {
|
||||
match cfg.image.ocr.engine.as_str() {
|
||||
"ollama-vision" => Some(Box::new(OllamaVisionOcr::new(cfg)?)),
|
||||
"paddle-onnx" => Some(Box::new(OnnxPaddleOcr::new(cfg)?)),
|
||||
other => bail!("unknown image.ocr.engine: {other}"),
|
||||
}
|
||||
} else { None };
|
||||
```
|
||||
|
||||
- `ImagePipeline.ocr_engine` 를 `Option<&'a dyn OcrEngine>` 로(현재 구체타입 `&OllamaVisionOcr`).
|
||||
- pdf 경로 동일. `apply_ocr`/`apply_ocr_to_pdf_pages` 는 이미 `&dyn OcrEngine` 받음 → 변경 불필요.
|
||||
- `OnnxPaddleOcr` 는 한 번 생성(모델 1회 로드) 후 ingest 전체에서 재사용(PoC 모델로드 58ms, 무시 가능).
|
||||
|
||||
## 버전/재색인 cascade
|
||||
|
||||
OCR 엔진 변경 시 **영향 자산 자동 재색인**되어야 함(v0.26.2 메커니즘). 현재 `ingest_config_signature`(`kebab-app/src/lib.rs:3036` 부근)의 image/pdf 브랜치는 `|ocr:1:{ocr.model}` 만 서명.
|
||||
|
||||
**C3 (필수, 권장 아님)**: paddle-onnx 브랜치에서 `model`("gemma4:e4b" 기본) 은 **미사용** — 실제 모델 정체성은 det/rec/dict + engine_version 에 있음. 따라서:
|
||||
- 서명을 `|ocr:1:{engine}:{engine_version}` 로(엔진 + 모델/dict 식별자). `engine_version()`(spec 의 model+dict blake3 해시 포함, 라인 47)을 **반드시** 서명에 사용.
|
||||
- 이유: ① `engine="ollama-vision"→"paddle-onnx"` 전환 시 model 이 기본값 그대로면 `{model}` 만으론 서명 불변 → **재색인 안 됨**(silent stale index, v0.26.2 가 없애려던 바로 그 버그). ② 모델 재변환/dict 수정 시 engine_version 변화로 재색인 트리거.
|
||||
- 단위테스트(필수): (a) `ollama-vision`↔`paddle-onnx` 동일 model → 서명 다름. (b) 동일 engine, engine_version 다름 → 서명 다름. (c) 무관 설정(search 등) → 서명 불변.
|
||||
|
||||
## 기본 엔진 (default) — 별도 결정
|
||||
|
||||
본 spec 은 `paddle-onnx` 를 **선택 가능**하게만 한다. kebab 의 `image.ocr.engine` **기본값을 `paddle-onnx` 로 바꿀지**는 후속 결정:
|
||||
- 바꾸면: 신규 사용자/기본 동작 변화 + 모델 다운로드 기본화. 강력하나 영향 큼.
|
||||
- v1 은 기본 `ollama-vision` 유지, opt-in `paddle-onnx`. 도그푸딩 후 기본 전환을 별 PR 로. (사용자 본인 config 는 즉시 `paddle-onnx`.)
|
||||
|
||||
## 에러 처리 (M3 — 명시 매트릭스)
|
||||
|
||||
배치 ingest 가 미지의 사용자 스캔을 돈다. 각 케이스 동작 확정:
|
||||
|
||||
| 케이스 | 동작 | 근거 |
|
||||
|---|---|---|
|
||||
| 모델 다운로드 실패 | 엔진 생성 시 **fail-fast**(Ollama 와 동일, `lib.rs:360`) | 색인 시작 전 차단 |
|
||||
| blake3 불일치 | fail-fast + 사유 | 무결성 |
|
||||
| 디코드 불가 이미지 | **자산 skip + provenance 노트**(ingest 중단 X) | 기존 `apply_ocr` "skip vs surface" 계약(`ocr.rs:75`) |
|
||||
| det 0 박스(빈 이미지 등) | **성공, `OcrText{joined:"", regions:[]}`**(에러 아님) | Ollama 빈줄 동작(`ocr.rs:290`) 미러 |
|
||||
| rec 빈 출력(한 박스) | 그 박스 skip, 나머지 진행 | |
|
||||
| 박스 폭증(노이즈 스캔) | **`max_boxes` 상한**(기본 예: 1000) 초과분 절단 + 로그 | 메모리/지연 cliff 방지 |
|
||||
| dict 길이 ≠ rec 클래스 | 생성 시 에러(정합 검증) | bounds-check |
|
||||
|
||||
ort `Session` 은 생성 후 1회 로드·재사용. ingest 는 현재 직렬(`lib.rs:460`, rayon 없음)이라 동시접근 없음 — 단 `OcrEngine: Send+Sync` 유지(미래 병렬화 대비, rc.9 Session Send/Sync 확인은 plan).
|
||||
|
||||
## 검증 기준
|
||||
|
||||
- `cargo clippy --workspace --all-targets -j 8 -- -D warnings` 0.
|
||||
- `cargo test -p kebab-parse-image -p kebab-app -j 8` 통과(touched 크레이트; `kebab-parse-image` 단독 빌드가 download-binaries 로 링크되는지 포함).
|
||||
- 신규 단위테스트:
|
||||
- 단계별 골든벡터(전처리/det후처리/CTC/박스정렬) — baseline 0.976 대비 단계 회귀 감지.
|
||||
- OnnxPaddleOcr e2e: 합성 한/영 fixture → **CER ≤ 0.05**(=문자정확도 ≥95%), bbox>0. (단 합성 fixture 는 실코퍼스 회귀 미보장 → 도그푸딩 병행.)
|
||||
- CTC decode: 알려진 logit→문자열(blank/중복 제거, bounds-check).
|
||||
- 엔진 팩토리: `engine="paddle-onnx"`→OnnxPaddleOcr, 미지 값 에러.
|
||||
- 서명(C3): 위 (a)(b)(c) 케이스.
|
||||
- config override(`det_model`/`rec_model`/`dict`) 가 실제 사용됨 + **`--config` facade 스레딩**(CLAUDE.md facade rule, P3-5/P4-3 회귀 전례) — `OnnxPaddleOcr::new(cfg, …)` 가 explicit Config 받음.
|
||||
- 회귀 가드: `engine="ollama-vision"`(기본) 경로 — 팩토리 리팩터(구체타입→`&dyn`) 후에도 **출력 동일** 핀하는 테스트.
|
||||
- 스모크: `engine="paddle-onnx"` 이미지 ingest → OCR 텍스트 FTS5 hit. 큰 페이지 CPU <5초.
|
||||
- 도그푸딩: 사용자 실제 이미지/책 스캔 정확도·속도(HOTFIXES + release notes).
|
||||
|
||||
## 의존성 규칙 (design §8)
|
||||
|
||||
`kebab-parse-image` allowed: kebab-core, kebab-config, serde, image, tracing, thiserror(task p6-2). 추가: `ort`(workspace, features `["ndarray","download-binaries"]`), `ndarray`(workspace), `imageproc`. **clipper2 미추가**(C++ FFI 회피 — unclip pure-Rust 직접). **hf-hub 미추가**(결정 C: 모델 번들, 외부 다운로드 0). **금지 유지**: kebab-store-*/embed-*/llm-* 미import. UI 크레이트 영향 없음.
|
||||
|
||||
## 비범위
|
||||
|
||||
- **OCR 텍스트→임베딩 갭**(현재 OCR 은 FTS5 lexical 전용, 벡터 미포함). 사용자 "OCR 모델만 먼저" → 별도 작업.
|
||||
- **caption** 은 gemma 유지([[project_llm_default]]).
|
||||
- **GPU provider**(ort CUDA/CoreML): CPU 로 충분(2.75초). 후속 옵션.
|
||||
- **기본 엔진 전환**(default `paddle-onnx`): 도그푸딩 후 별 PR.
|
||||
- 다국어 dict 동적 전환(현재 korean dict = 한+영+숫자+기호 11,945자로 한/영 충분).
|
||||
|
||||
## 잔여 노트 (critic minors)
|
||||
|
||||
- **max_pixels(m1)**: 기존 `[256,4096]` clamp 은 VLM 프롬프트 비용 기준. det/rec 엔진은 비용이 latency 라 trade-off 다름. v1 은 기본 1600 **유지(의도적)** — PoC 에서 1600 대 원본 정확도 차 미미, 속도 이점. plan 에서 paddle-onnx 전용 기본 재검토 가능.
|
||||
- **config 마이그레이션(m3)**: 신규 키(`det_model` 등)는 serde default 로 forward-compat(기존 파일 무수정 로드). `kebab config migrate`(#198) 가 주석/순서 보존하며 신규 키 추가 — migration 핸들링 불필요(serde default), 단 init 템플릿에 신규 키 노출.
|
||||
- **per-region confidence(open q)**: Ollama 는 region confidence 상수 1.0, paddle-onnx 는 실제 score. `OcrRegion` 형태 불변이라 wire 호환(값만 의미있어짐) — release note 1줄.
|
||||
- **세로/회전 페이지**: 비범위(가로쓰기 reading-order 전제). 회전 박스 rectify 는 지원하나 페이지 전체 세로조판은 미지원 명시.
|
||||
|
||||
## 버전/문서
|
||||
|
||||
- feature(신규 engine 값 + 동작) → **minor bump**.
|
||||
- README(Configuration: `image.ocr.engine`, 모델 첫 다운로드 안내), docs/SMOKE(config 예시), HANDOFF 1줄, docs/ARCHITECTURE(새 OCR 백엔드 추가 시 그래프/결정), HOTFIXES dated entry(도그푸딩 evidence). wire schema 불변(OcrText 내부, `--json` 표면 동일).
|
||||
38
docs/wire-schema/v1/config_migration.v1.schema.json
Normal file
38
docs/wire-schema/v1/config_migration.v1.schema.json
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "config_migration.v1",
|
||||
"description": "Result of `kebab config migrate` — schema reconciliation of a user's config.toml.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"schema_version",
|
||||
"config_path",
|
||||
"dry_run",
|
||||
"from_schema_version",
|
||||
"to_schema_version",
|
||||
"changed",
|
||||
"changes"
|
||||
],
|
||||
"properties": {
|
||||
"schema_version": { "const": "config_migration.v1" },
|
||||
"config_path": { "type": "string" },
|
||||
"dry_run": { "type": "boolean" },
|
||||
"from_schema_version": { "type": "integer" },
|
||||
"to_schema_version": { "type": "integer" },
|
||||
"changed": { "type": "boolean" },
|
||||
"backup_path": { "type": ["string", "null"] },
|
||||
"changes": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["kind", "path", "detail"],
|
||||
"properties": {
|
||||
"kind": {
|
||||
"enum": ["added_section", "added_key", "removed_deprecated"]
|
||||
},
|
||||
"path": { "type": "string" },
|
||||
"detail": { "type": "string" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user