Compare commits
58 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
45
Cargo.lock
generated
45
Cargo.lock
generated
@@ -4276,7 +4276,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-app"
|
||||
version = "0.20.2"
|
||||
version = "0.21.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64 0.22.1",
|
||||
@@ -4322,7 +4322,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-chunk"
|
||||
version = "0.20.2"
|
||||
version = "0.21.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"blake3",
|
||||
@@ -4340,7 +4340,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-cli"
|
||||
version = "0.20.2"
|
||||
version = "0.21.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
@@ -4361,7 +4361,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-config"
|
||||
version = "0.20.2"
|
||||
version = "0.21.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"dirs 5.0.1",
|
||||
@@ -4371,12 +4371,13 @@ dependencies = [
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"toml",
|
||||
"toml_edit 0.22.27",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kebab-core"
|
||||
version = "0.20.2"
|
||||
version = "0.21.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"blake3",
|
||||
@@ -4390,7 +4391,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-embed"
|
||||
version = "0.20.2"
|
||||
version = "0.21.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"blake3",
|
||||
@@ -4404,7 +4405,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-embed-local"
|
||||
version = "0.20.2"
|
||||
version = "0.21.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"fastembed",
|
||||
@@ -4417,7 +4418,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-eval"
|
||||
version = "0.20.2"
|
||||
version = "0.21.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"kebab-app",
|
||||
@@ -4436,7 +4437,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-llm"
|
||||
version = "0.20.2"
|
||||
version = "0.21.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"kebab-core",
|
||||
@@ -4445,7 +4446,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-llm-local"
|
||||
version = "0.20.2"
|
||||
version = "0.21.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"kebab-config",
|
||||
@@ -4462,7 +4463,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-mcp"
|
||||
version = "0.20.2"
|
||||
version = "0.21.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"kebab-app",
|
||||
@@ -4480,7 +4481,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-nli"
|
||||
version = "0.20.2"
|
||||
version = "0.21.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"hf-hub",
|
||||
@@ -4495,7 +4496,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-parse-code"
|
||||
version = "0.20.2"
|
||||
version = "0.21.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"gix",
|
||||
@@ -4518,7 +4519,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-parse-image"
|
||||
version = "0.20.2"
|
||||
version = "0.21.1"
|
||||
dependencies = [
|
||||
"ab_glyph",
|
||||
"anyhow",
|
||||
@@ -4542,7 +4543,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-parse-md"
|
||||
version = "0.20.2"
|
||||
version = "0.21.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"kebab-core",
|
||||
@@ -4559,7 +4560,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-parse-pdf"
|
||||
version = "0.20.2"
|
||||
version = "0.21.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"blake3",
|
||||
@@ -4574,7 +4575,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-rag"
|
||||
version = "0.20.2"
|
||||
version = "0.21.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"blake3",
|
||||
@@ -4596,7 +4597,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-search"
|
||||
version = "0.20.2"
|
||||
version = "0.21.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"globset",
|
||||
@@ -4615,7 +4616,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-source-fs"
|
||||
version = "0.20.2"
|
||||
version = "0.21.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"blake3",
|
||||
@@ -4633,7 +4634,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-store-sqlite"
|
||||
version = "0.20.2"
|
||||
version = "0.21.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"blake3",
|
||||
@@ -4653,7 +4654,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-store-vector"
|
||||
version = "0.20.2"
|
||||
version = "0.21.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arrow",
|
||||
@@ -4677,7 +4678,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-tui"
|
||||
version = "0.20.2"
|
||||
version = "0.21.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"crossterm",
|
||||
|
||||
@@ -30,7 +30,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.21.1" # v0.21.1 — config 마이그레이션(kebab config migrate): 기존 config.toml 에 빠진 섹션 주석과 함께 추가 + deprecated 정리 + schema_version 1→2 — 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,11 @@ P0~P5 직렬. P6~P9 P5 이후 병렬 가능.
|
||||
|
||||
## 머지 후 발견된 버그 / 결정 (요약)
|
||||
|
||||
- **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-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).
|
||||
|
||||
307
README.md
307
README.md
@@ -1,151 +1,133 @@
|
||||
# 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 를 지원한다.
|
||||
|
||||
### doc-side expansion 별칭 (opt-in)
|
||||
|
||||
색인 시 각 청크에 대해 "같은 의미의 다른 표현"(동의어 · 약어 · 한↔영 번역 · 풀어쓴 설명) 별칭을 LLM 으로 생성해 별도 dense 벡터로 색인한다. 설명형 query 나 cross-lingual query 의 검색 일관성을 높인다 (나무위키 ~1000 문서 CS corpus 측정: 변형 일관성 14/18 → 16/18, 대조군 false-positive 미유발). 청크당 LLM 호출이 들어 비용이 크므로 **default off** — `[ingest.expansion] enabled = true` 로 opt-in.
|
||||
|
||||
### 파생물 캐시 (자동)
|
||||
|
||||
embedding 벡터와 별칭 LLM 결과를 청크 **내용 해시** 로 캐싱한다 (`derivation_cache`). 재색인·갱신 시 내용이 같은 청크는 재계산을 건너뛴다 (측정: cold 1879s → warm 13s ≈ 145배). 캐시 키에 모델·프롬프트·차원 버전이 포함돼 버전 변경 시 자동 무효화된다 (cascade 안전). 별도 설정 없이 투명하게 동작한다. (현재 TTL/LRU 자동 정리는 미구현 — 누적된 캐시는 `kebab reset` 으로만 정리.)
|
||||
|
||||
### 외부 계산 + 로컬 검색 워크플로
|
||||
|
||||
search/ask 는 asset 파일 없이 `kebab.sqlite` + `lancedb` 만으로 동작한다. 비싼 색인(임베딩·OCR·별칭 생성)을 성능 좋은 서버에서 수행한 뒤, 이 두 산출물만 로컬로 복사하면 그대로 검색·질문할 수 있다.
|
||||
|
||||
### 멀티미디어 색인
|
||||
|
||||
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 |
|
||||
| `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)
|
||||
[models.embedding]
|
||||
model = "multilingual-e5-large" # 다국어 sentence embedding (1024-dim).
|
||||
# 첫 ingest 시 ONNX (~1.3GB) 자동 다운로드.
|
||||
dimensions = 1024 # config 와 LanceDB stored dim 불일치 시 검색 0건.
|
||||
|
||||
`search_hit.v1.score` 는 **ranking signal** 이지 confidence 가 아니다. `score_kind` 필드로 의미 선언:
|
||||
[models.llm]
|
||||
endpoint = "http://localhost:11434" # Ollama host:port
|
||||
model = "gemma4:e4b"
|
||||
# request_timeout_secs = 300 # 큰 모델은 늘림. 0 은 disable 이 아니라 "즉시 timeout".
|
||||
|
||||
| `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]` |
|
||||
[ingest.expansion] # doc-side expansion 별칭 (opt-in)
|
||||
enabled = false # true 면 청크당 LLM 호출로 별칭 생성 — 비용 큼.
|
||||
embed_aliases = true # 별칭을 줄별 개별 dense 벡터로 색인.
|
||||
max_aliases_per_chunk = 8
|
||||
|
||||
#### RRF 수식 (hybrid mode)
|
||||
[search]
|
||||
stale_threshold_days = 30 # search hit / citation 의 stale 플래그 기준 (0 = off).
|
||||
|
||||
```
|
||||
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 천장.
|
||||
[rag]
|
||||
prompt_template_version = "rag-v3" # 답변 언어 = 질문 언어. rag-v1/v2 는 legacy.
|
||||
nli_threshold = 0.0 # >0 (예: 0.5) 면 mDeBERTa XNLI groundedness 검증.
|
||||
```
|
||||
|
||||
`rrf_score = 0.5` 의 의미: chunk 가 한 채널 (lexical 또는 vector) 에서만 rank 1 로 등장. confidence 50% 가 아님 — RRF 수식의 산술적 천장.
|
||||
- **파생물 캐시** — embedding·별칭 결과를 내용 해시로 자동 캐싱한다 (위 「핵심 기능」 참고). 설정 항목 없음.
|
||||
- **`[ingest.code]`** — code ingest 의 skip 정책 (`skip_generated_header`, `max_file_bytes`, `extra_skip_globs`). `.gitignore` 자동 honor, `.kebabignore` 는 추가 layer.
|
||||
- **`[pdf.ocr]`** — scanned PDF 의 page-단위 OCR (default off / opt-in, page 당 ~수십 초 cost). 활성화 후 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/`.
|
||||
|
||||
agent 가 trust threshold 가 필요하면 top-level `score` 가 아닌 nested `retrieval.lexical_score` (BM25 raw) / `retrieval.vector_score` (cosine raw) 사용.
|
||||
|
||||
#### `score` ↔ `retrieval.*` 구조 (v0.20.2 정정)
|
||||
|
||||
`fusion_score` / `lexical_score` / `vector_score` / `lexical_rank` / `vector_rank` 는 모두 **`retrieval` object 내부**에 있다 (top-level 아님). top-level `score` 는 canonical ranking score 이며 그 의미는 `score_kind` 가 선언한다.
|
||||
|
||||
- **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"`.
|
||||
|
||||
즉 single-mode 에서 `score`/`fusion_score`/(lexical|vector)_score 가 같은 값인 것은 fusion 단계가 없기 때문이며 정상이다 (Finding X).
|
||||
|
||||
## 논리 아키텍처
|
||||
## 아키텍처
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
@@ -162,7 +144,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 +186,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)
|
||||
|
||||
@@ -71,6 +71,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 }
|
||||
|
||||
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]);
|
||||
}
|
||||
}
|
||||
274
crates/kebab-app/src/expansion.rs
Normal file
274
crates/kebab-app/src/expansion.rs
Normal file
@@ -0,0 +1,274 @@
|
||||
//! 색인시 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;
|
||||
|
||||
/// 별칭 프롬프트 템플릿 버전. derivation cache 의 alias version_key 에 포함되어
|
||||
/// (§3.1), 프롬프트를 바꾸면 bump 해 캐시를 무효화한다(전부 miss → 재생성).
|
||||
/// `build_request` 의 gemma 프롬프트와 한 쌍 — 프롬프트 수정 시 함께 bump.
|
||||
pub const PROMPT_VERSION: &str = "expansion-v1";
|
||||
|
||||
/// 청크당 검색용 별칭을 생성한다.
|
||||
///
|
||||
/// 반환: 검증·상한 적용된 별칭들을 개행 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)를 구성한다. (self 미사용 — associated fn.)
|
||||
fn build_request(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> {
|
||||
// 나무위키 네비게이션 boilerplate 청크는 LLM 호출 없이 skip — 별칭
|
||||
// 생성 가치가 없고 노이즈 sentinel 벡터만 만든다.
|
||||
if is_nav_boilerplate(chunk) {
|
||||
return None;
|
||||
}
|
||||
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"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 나무위키 네비게이션 boilerplate 청크 판정.
|
||||
///
|
||||
/// heading_path 가 비어 있고(문서 본문 섹션이 아닌 머리/꼬리 nav), text 앞부분에
|
||||
/// nav 키워드("최근 변경" 등)가 하나라도 있으면 boilerplate 로 본다. 둘 다
|
||||
/// 만족할 때만 true — 정상 본문(heading 있음, 또는 nav 키워드 없음)은 false.
|
||||
pub fn is_nav_boilerplate(chunk: &Chunk) -> bool {
|
||||
const NAV_KEYWORDS: [&str; 5] = [
|
||||
"최근 변경",
|
||||
"Recent changes",
|
||||
"최근 토론",
|
||||
"특수 기능",
|
||||
"편집 토론 역사",
|
||||
];
|
||||
if !chunk.heading_path.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let head: String = chunk.text.chars().take(200).collect();
|
||||
NAV_KEYWORDS.iter().any(|kw| head.contains(kw))
|
||||
}
|
||||
|
||||
/// 줄 선두의 목록 마커만 1회 제거한다. **마커 뒤 공백이 필수** — 별칭 내용이
|
||||
/// 숫자/하이픈/별표로 시작하는 경우(예: "3D 렌더링", "-fast", "2단계")는 보존한다.
|
||||
/// (Task 4 리뷰 MAJOR-1: 탐욕적 `trim_start_matches` 가 정당한 별칭을 손상시키던 버그 수정.)
|
||||
fn strip_list_marker(s: &str) -> &str {
|
||||
// 1) 머리기호 + 공백 ("- " / "* " / "• ").
|
||||
for marker in ["- ", "* ", "• "] {
|
||||
if let Some(rest) = s.strip_prefix(marker) {
|
||||
return rest.trim_start();
|
||||
}
|
||||
}
|
||||
// 2) 번호 + ('.' | ')') + 공백 ("1. " / "2) "). 마커 뒤 공백이 없으면
|
||||
// ("3D", "2단계") 번호가 아니라 내용으로 보고 보존.
|
||||
let digit_end = s.find(|c: char| !c.is_ascii_digit()).unwrap_or(s.len());
|
||||
if digit_end > 0 {
|
||||
let after = &s[digit_end..];
|
||||
if let Some(rest) = after.strip_prefix(". ").or_else(|| after.strip_prefix(") ")) {
|
||||
return rest.trim_start();
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// LLM 출력 문자열 → 검증된 별칭 리스트.
|
||||
/// 줄 단위 split → trim → 목록 마커 1회 제거 → 빈 줄·과길이 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 = strip_list_marker(line.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,
|
||||
latency_ms: 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_lines_strips_bullets_and_caps() {
|
||||
let llm = mock("- 메모리 안전성\n1. who owns the value\nborrow checker\n\n* 소유권");
|
||||
let generator = ExpansionGenerator::new(&llm, 2);
|
||||
let out = generator.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 generator = ExpansionGenerator::new(&llm, 8);
|
||||
let out = generator.generate(&mk_chunk("t")).unwrap();
|
||||
assert_eq!(out, "짧은 별칭", "120자 초과 줄은 drop");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_output_returns_none() {
|
||||
let llm = mock(" \n\n");
|
||||
let generator = ExpansionGenerator::new(&llm, 8);
|
||||
assert_eq!(generator.generate(&mk_chunk("t")), None);
|
||||
}
|
||||
|
||||
/// Task 4 리뷰 MAJOR-1 회귀: 숫자/하이픈/별표로 시작하는 정당한 별칭은
|
||||
/// 손상 없이 보존돼야 한다(목록 마커는 마커 뒤 공백이 있을 때만 제거).
|
||||
#[test]
|
||||
fn preserves_numeric_and_dash_leading_aliases() {
|
||||
let llm = mock("3D 렌더링\n2단계 커밋\n-fast 플래그\n- 메모리 안전성\n1. 첫 항목");
|
||||
let generator = ExpansionGenerator::new(&llm, 8);
|
||||
let out = generator.generate(&mk_chunk("graphics")).unwrap();
|
||||
// 마커 없는 선두 숫자/하이픈은 보존; "- "/"1. " 만 마커로 제거.
|
||||
assert_eq!(out, "3D 렌더링\n2단계 커밋\n-fast 플래그\n메모리 안전성\n첫 항목");
|
||||
}
|
||||
|
||||
fn mk_chunk_nav(text: &str, heading: Vec<String>) -> Chunk {
|
||||
let mut c = mk_chunk(text);
|
||||
c.heading_path = heading;
|
||||
c
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nav_boilerplate_skips_alias_generation() {
|
||||
// heading 없음 + nav 키워드 → boilerplate → LLM 호출 전에 None.
|
||||
let llm = mock("별칭1\n별칭2");
|
||||
let generator = ExpansionGenerator::new(&llm, 8);
|
||||
let chunk = mk_chunk_nav("최근 변경 최근 토론 특수 기능", vec![]);
|
||||
assert_eq!(generator.generate(&chunk), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normal_body_chunk_generates_aliases() {
|
||||
// heading 없지만 nav 키워드도 없음 → 정상 본문 → 별칭 생성.
|
||||
let llm = mock("별칭1\n별칭2");
|
||||
let generator = ExpansionGenerator::new(&llm, 8);
|
||||
let chunk = mk_chunk_nav("러스트의 소유권과 빌림 검사기 개요", vec![]);
|
||||
assert_eq!(generator.generate(&chunk).unwrap(), "별칭1\n별칭2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nav_keyword_with_heading_is_not_boilerplate() {
|
||||
// nav 키워드가 있어도 heading 이 있으면 본문 섹션 → 생성.
|
||||
let llm = mock("별칭1");
|
||||
let generator = ExpansionGenerator::new(&llm, 8);
|
||||
let chunk = mk_chunk_nav("최근 변경 내역 설명", vec!["문서 변경사항".into()]);
|
||||
assert_eq!(generator.generate(&chunk).unwrap(), "별칭1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_nav_boilerplate_unit() {
|
||||
assert!(is_nav_boilerplate(&mk_chunk_nav("Recent changes list", vec![])));
|
||||
assert!(is_nav_boilerplate(&mk_chunk_nav("편집 토론 역사", vec![])));
|
||||
assert!(!is_nav_boilerplate(&mk_chunk_nav("일반 본문 텍스트", vec![])));
|
||||
assert!(!is_nav_boilerplate(&mk_chunk_nav(
|
||||
"최근 변경",
|
||||
vec!["섹션".into()]
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_list_marker_unit() {
|
||||
assert_eq!(strip_list_marker("- 메모리"), "메모리");
|
||||
assert_eq!(strip_list_marker("* 소유권"), "소유권");
|
||||
assert_eq!(strip_list_marker("1. who owns"), "who owns");
|
||||
assert_eq!(strip_list_marker("2) 항목"), "항목");
|
||||
// 마커 뒤 공백 없음 → 보존.
|
||||
assert_eq!(strip_list_marker("3D 렌더링"), "3D 렌더링");
|
||||
assert_eq!(strip_list_marker("-fast"), "-fast");
|
||||
assert_eq!(strip_list_marker("2단계"), "2단계");
|
||||
assert_eq!(strip_list_marker("2.0 릴리스"), "2.0 릴리스");
|
||||
}
|
||||
}
|
||||
@@ -59,9 +59,11 @@ use kebab_source_fs::FsSourceConnector;
|
||||
mod app;
|
||||
mod bulk;
|
||||
pub mod cursor;
|
||||
pub mod derivation_payload;
|
||||
pub mod doctor_signal;
|
||||
pub mod error_signal;
|
||||
pub mod error_wire;
|
||||
pub mod expansion;
|
||||
pub mod external;
|
||||
pub mod fetch;
|
||||
pub mod ingest_log;
|
||||
@@ -141,40 +143,10 @@ pub fn init_workspace(force: bool) -> anyhow::Result<()> {
|
||||
std::fs::create_dir_all(&workspace_root)?;
|
||||
|
||||
if !cfg_path.exists() || force {
|
||||
let cfg = kebab_config::Config::defaults();
|
||||
let toml_text = toml::to_string_pretty(&cfg)?;
|
||||
// p9-fb-05: prepend a header comment documenting the path
|
||||
// policy so a user editing this file knows what's allowed
|
||||
// for `workspace.root` (and how relative paths resolve).
|
||||
// The actual key lives inside `[workspace]` further down;
|
||||
// we keep the explanation up top because users skim header
|
||||
// comments first.
|
||||
let header = "\
|
||||
# kebab config — `~/.config/kebab/config.toml`.
|
||||
#
|
||||
# `workspace.root` accepts:
|
||||
# • absolute paths (`/home/me/KnowledgeBase`)
|
||||
# • tilde (`~/KnowledgeBase`) ← default
|
||||
# • env vars (`${XDG_DATA_HOME}/kebab`)
|
||||
# • relative paths (`./notes`, `notes`, `../shared/x`)
|
||||
# — relative paths resolve against the directory of THIS
|
||||
# config file, NOT the user's `cwd` at invocation time.
|
||||
#
|
||||
# 처리 가능한 형식 (extractor 가 자동 결정 — config 에 명시할 수 없음):
|
||||
# • Markdown: .md
|
||||
# • 이미지: .png .jpg .jpeg (OCR + caption)
|
||||
# • PDF: .pdf
|
||||
# 다른 확장자는 ingest 시 자동 skip + warning. 처리 대상 폴더의
|
||||
# 일부만 ingest 하고 싶으면 `kebab ingest <path>` 로 root 명시
|
||||
# 또는 `.kebabignore` 파일 / 본 `workspace.exclude` 로 denylist.
|
||||
#
|
||||
# Override individual keys at runtime with `KEBAB_*` env vars
|
||||
# (e.g. `KEBAB_WORKSPACE_ROOT=/tmp/test kebab ingest`).
|
||||
\n";
|
||||
let mut combined = String::with_capacity(header.len() + toml_text.len());
|
||||
combined.push_str(header);
|
||||
combined.push_str(&toml_text);
|
||||
std::fs::write(&cfg_path, combined)?;
|
||||
// init 과 migrate 가 동일한 "주석 달린 default" 문서를 공유한다
|
||||
// (주석 카탈로그·헤더의 단일 원천 = kebab_config::migrate).
|
||||
let doc = kebab_config::migrate::annotated_default_document();
|
||||
std::fs::write(&cfg_path, doc.to_string())?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -1056,6 +1028,70 @@ fn unsupported_media_warning(path: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Embed `texts` with the derivation cache (design 2026-05-31 §3.4).
|
||||
///
|
||||
/// 1) 각 text 의 embedding cache_key 계산 → 히트/미스 분리.
|
||||
/// 2) 미스 text 만 `emb.embed`(축소 배치) 호출.
|
||||
/// 3) 미스 결과를 `Vec<f32>` little-endian 으로 캐시 put.
|
||||
/// 4) 히트(bytes→Vec<f32>) + 미스 벡터를 **원래 순서대로** 합쳐 반환.
|
||||
///
|
||||
/// 손상된 payload(길이 misalign)는 미스로 강등 → 재계산(정확성 우선, §3.5).
|
||||
/// 히트 키는 `touch_keys` 에 누적(호출측이 배치로 last_used_at 갱신).
|
||||
fn embed_with_cache(
|
||||
emb: &dyn Embedder,
|
||||
sqlite: &kebab_store_sqlite::SqliteStore,
|
||||
texts: &[&str],
|
||||
version_key: &str,
|
||||
hit: &mut usize,
|
||||
miss: &mut usize,
|
||||
touch_keys: &mut Vec<String>,
|
||||
) -> anyhow::Result<Vec<Vec<f32>>> {
|
||||
let mut out: Vec<Option<Vec<f32>>> = Vec::with_capacity(texts.len());
|
||||
let mut miss_indices: Vec<usize> = Vec::new();
|
||||
let mut miss_inputs: Vec<EmbeddingInput<'_>> = Vec::new();
|
||||
let mut keys: Vec<String> = Vec::with_capacity(texts.len());
|
||||
|
||||
for (i, text) in texts.iter().enumerate() {
|
||||
let key = kebab_core::derivation_cache_key("embedding", text, version_key);
|
||||
// 히트 = 캐시에 있고 payload 가 정상 디코드되는 경우. 손상 payload 는
|
||||
// 미스로 강등(재계산, 정확성 우선 §3.5).
|
||||
let cached = sqlite
|
||||
.derivation_cache_get(&key)?
|
||||
.and_then(|p| crate::derivation_payload::decode_embedding(&p));
|
||||
if let Some(v) = cached {
|
||||
*hit += 1;
|
||||
touch_keys.push(key.clone());
|
||||
out.push(Some(v));
|
||||
} else {
|
||||
*miss += 1;
|
||||
miss_indices.push(i);
|
||||
miss_inputs.push(EmbeddingInput {
|
||||
text,
|
||||
kind: EmbeddingKind::Document,
|
||||
});
|
||||
out.push(None);
|
||||
}
|
||||
keys.push(key);
|
||||
}
|
||||
|
||||
if !miss_inputs.is_empty() {
|
||||
let miss_vectors = emb.embed(&miss_inputs)?;
|
||||
for (slot, v) in miss_indices.iter().zip(miss_vectors) {
|
||||
sqlite.derivation_cache_put(
|
||||
&keys[*slot],
|
||||
"embedding",
|
||||
&crate::derivation_payload::encode_embedding(&v),
|
||||
)?;
|
||||
out[*slot] = Some(v);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(out
|
||||
.into_iter()
|
||||
.map(|v| v.expect("every slot filled by hit or miss"))
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Process a single asset: read bytes, parse, normalize, chunk,
|
||||
/// persist, embed. Per-asset failures bubble up to the caller for
|
||||
/// labelling as `IngestItemKind::Error` — they do NOT abort the
|
||||
@@ -1250,10 +1286,76 @@ fn ingest_one_asset(
|
||||
build_canonical_document(asset, metadata, parsed_blocks, parser_version, all_warnings)
|
||||
.context("kb-parse-md::build_canonical_document")?;
|
||||
|
||||
let chunks = MdHeadingV1Chunker
|
||||
let mut chunks = MdHeadingV1Chunker
|
||||
.chunk(&canonical, chunk_policy)
|
||||
.context("kb-chunk::MdHeadingV1Chunker::chunk")?;
|
||||
|
||||
// Phase 2 doc-side expansion: flag on 이면 청크당 별칭 생성 (fail-soft).
|
||||
// derivation cache(§3.4): 같은 청크 text + 같은 alias version_key 면 LLM
|
||||
// 호출 없이 캐시된 별칭 재사용. version_key = {prompt_version}|{max}|{model}.
|
||||
let mut alias_cache_hit = 0_usize;
|
||||
let mut alias_cache_miss = 0_usize;
|
||||
let mut alias_touch_keys: Vec<String> = Vec::new();
|
||||
if app.config.ingest.expansion.enabled {
|
||||
let exp = &app.config.ingest.expansion;
|
||||
let alias_version_key = format!(
|
||||
"{}|{}|{}",
|
||||
crate::expansion::PROMPT_VERSION,
|
||||
exp.max_aliases_per_chunk,
|
||||
exp.model
|
||||
);
|
||||
let llm_built = if exp.model.is_empty() {
|
||||
OllamaLanguageModel::new(&app.config)
|
||||
} else {
|
||||
OllamaLanguageModel::with_model(&app.config, &exp.model)
|
||||
};
|
||||
match llm_built {
|
||||
Ok(llm) => {
|
||||
let generator =
|
||||
crate::expansion::ExpansionGenerator::new(&llm, exp.max_aliases_per_chunk);
|
||||
for chunk in &mut chunks {
|
||||
let key = kebab_core::derivation_cache_key(
|
||||
"alias",
|
||||
&chunk.text,
|
||||
&alias_version_key,
|
||||
);
|
||||
// 히트 = 캐시에 있고 payload 가 정상 UTF-8 로 디코드되는
|
||||
// 경우만. 손상(비-UTF8) payload 는 미스로 강등해 재생성
|
||||
// 분기로 보낸다(embedding 경로의 decode-실패→미스 강등과
|
||||
// 동작 일치, 정확성 우선 §3.5).
|
||||
let cached_aliases = app
|
||||
.sqlite
|
||||
.derivation_cache_get(&key)?
|
||||
.and_then(|payload| String::from_utf8(payload).ok());
|
||||
if let Some(aliases) = cached_aliases {
|
||||
// 히트: 저장된 별칭(UTF-8) 재사용. LLM 호출 없음.
|
||||
chunk.aliases = Some(aliases);
|
||||
alias_cache_hit += 1;
|
||||
alias_touch_keys.push(key);
|
||||
} else if crate::expansion::is_nav_boilerplate(chunk) {
|
||||
// 미스지만 nav boilerplate → 생성 가치 없음(기존 skip 규칙).
|
||||
// 캐시에 넣지 않음(None 은 payload 로 표현 불가, 다음 run 도 동일 판정).
|
||||
chunk.aliases = None;
|
||||
} else {
|
||||
// 미스 → LLM 생성 후 캐시 저장.
|
||||
chunk.aliases = generator.generate(chunk);
|
||||
alias_cache_miss += 1;
|
||||
if let Some(a) = &chunk.aliases {
|
||||
app.sqlite
|
||||
.derivation_cache_put(&key, "alias", a.as_bytes())?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
target: "kebab-app", error = %e,
|
||||
"kb-app::ingest: expansion LLM 빌드 실패 — 별칭 없이 진행"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stamp chunker + embedding versions so Task 7's skip detection has
|
||||
// data on the second run.
|
||||
canonical.last_chunker_version = Some(MdHeadingV1Chunker.chunker_version());
|
||||
@@ -1280,21 +1382,35 @@ fn ingest_one_asset(
|
||||
.context("DocumentStore::put_chunks")?;
|
||||
|
||||
// Embed + vector upsert (only when both sides are configured).
|
||||
let mut emb_cache_hit = 0_usize;
|
||||
let mut emb_cache_miss = 0_usize;
|
||||
if let (Some(emb), Some(vec_store)) = (embedder, vector_store) {
|
||||
if !chunks.is_empty() {
|
||||
let inputs: Vec<EmbeddingInput<'_>> = chunks
|
||||
.iter()
|
||||
.map(|c| EmbeddingInput {
|
||||
text: c.text.as_str(),
|
||||
kind: EmbeddingKind::Document,
|
||||
})
|
||||
.collect();
|
||||
let vectors = emb
|
||||
.embed(&inputs)
|
||||
.context("Embedder::embed (document chunks)")?;
|
||||
let model_id = emb.model_id();
|
||||
let model_version = emb.model_version();
|
||||
let dimensions = emb.dimensions();
|
||||
// derivation cache(§3.4): embedding version_key =
|
||||
// {kind}|{model_id}|{model_version}|{dimensions}.
|
||||
// 본문 청크 + 별칭 문자열 양쪽이 같은 메커니즘(같은 text → 같은 캐시).
|
||||
// kind 토큰("doc") 을 맨 앞에 둔다: 임베더가 kind 별 프리픽스
|
||||
// (Document=`passage:`, Query=`query:`)를 붙여 같은 text 라도 벡터가
|
||||
// 달라지므로, 미래에 query 임베딩이 같은 캐시를 타도 충돌하지 않도록
|
||||
// 방어적으로 분리(현재 ingest 는 Document 고정이라 live 버그 없음).
|
||||
let emb_version_key =
|
||||
format!("doc|{}|{}|{}", model_id.0, model_version.0, dimensions);
|
||||
let mut emb_touch_keys: Vec<String> = Vec::new();
|
||||
// 본문 청크 text 로 캐시 조회 → 미스만 embed → 원래 순서로 합침.
|
||||
let body_texts: Vec<&str> = chunks.iter().map(|c| c.text.as_str()).collect();
|
||||
let vectors = embed_with_cache(
|
||||
&**emb,
|
||||
&app.sqlite,
|
||||
&body_texts,
|
||||
&emb_version_key,
|
||||
&mut emb_cache_hit,
|
||||
&mut emb_cache_miss,
|
||||
&mut emb_touch_keys,
|
||||
)
|
||||
.context("Embedder::embed (document chunks)")?;
|
||||
let records: Vec<VectorRecord> = chunks
|
||||
.iter()
|
||||
.zip(vectors)
|
||||
@@ -1315,10 +1431,100 @@ fn ingest_one_asset(
|
||||
dimensions,
|
||||
})
|
||||
.collect();
|
||||
vec_store.upsert(&records).context("VectorStore::upsert")?;
|
||||
// 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() {
|
||||
// 각 별칭을 줄 단위로 분리해 개별 sentinel 벡터로 임베딩한다.
|
||||
// 묶음 1벡터는 벡터를 희석시켜 효과가 없으므로(측정), 별칭 i
|
||||
// 마다 chunk_id `{orig}#alias#{i}` 의 VectorRecord 를 만든다.
|
||||
// `(청크 참조, 별칭 문자열)` 쌍을 평탄화한 뒤 한 번에 임베딩.
|
||||
let alias_lines: Vec<(&kebab_core::Chunk, &str)> = alias_chunks
|
||||
.iter()
|
||||
.flat_map(|c| {
|
||||
c.aliases
|
||||
.as_deref()
|
||||
.unwrap()
|
||||
.split('\n')
|
||||
.map(str::trim)
|
||||
.filter(|line| !line.is_empty())
|
||||
.map(move |line| (*c, line))
|
||||
})
|
||||
.collect();
|
||||
if !alias_lines.is_empty() {
|
||||
// 별칭 dense 벡터도 본문과 동일한 embedding 캐시 재사용:
|
||||
// 같은 별칭 문자열이면 본문 embedding 캐시와 같은 키로 적중(§3.4).
|
||||
let alias_texts: Vec<&str> =
|
||||
alias_lines.iter().map(|(_, line)| *line).collect();
|
||||
let alias_vectors = embed_with_cache(
|
||||
&**emb,
|
||||
&app.sqlite,
|
||||
&alias_texts,
|
||||
&emb_version_key,
|
||||
&mut emb_cache_hit,
|
||||
&mut emb_cache_miss,
|
||||
&mut emb_touch_keys,
|
||||
)
|
||||
.context("Embedder::embed (alias vectors)")?;
|
||||
// 같은 청크 안에서 별칭 인덱스를 0부터 매긴다.
|
||||
let mut per_chunk_idx: std::collections::HashMap<String, usize> =
|
||||
std::collections::HashMap::new();
|
||||
for ((c, line), v) in alias_lines.iter().zip(alias_vectors) {
|
||||
let i = per_chunk_idx.entry(c.chunk_id.0.clone()).or_insert(0);
|
||||
let alias_chunk_id = kebab_core::ChunkId(format!(
|
||||
"{}{}#{}",
|
||||
c.chunk_id.0,
|
||||
kebab_core::ALIAS_SUFFIX,
|
||||
*i
|
||||
));
|
||||
*i += 1;
|
||||
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: (*line).to_string(),
|
||||
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")?;
|
||||
// 히트한 embedding 키들의 last_used_at 갱신(LRU 보존, §3.5).
|
||||
app.sqlite.derivation_cache_touch(&emb_touch_keys)?;
|
||||
}
|
||||
}
|
||||
|
||||
// 히트한 alias 키들의 last_used_at 갱신(LRU 보존, §3.5).
|
||||
app.sqlite.derivation_cache_touch(&alias_touch_keys)?;
|
||||
|
||||
// 검증용 hit/miss 카운트 노출(§3.4 / §6): warm 재색인이 LLM·embed 0회임을
|
||||
// 로그로 확인. tracing target 은 stderr 로 흐른다.
|
||||
if alias_cache_hit + alias_cache_miss + emb_cache_hit + emb_cache_miss > 0 {
|
||||
tracing::info!(
|
||||
target: "kebab-app",
|
||||
doc = %canonical.doc_id.0,
|
||||
"derivation cache: embedding hit={emb_cache_hit} miss={emb_cache_miss}, \
|
||||
alias hit={alias_cache_hit} miss={alias_cache_miss}"
|
||||
);
|
||||
}
|
||||
|
||||
let kind = if existing_doc_ids.contains(&canonical.doc_id.0) {
|
||||
kebab_core::IngestItemKind::Updated
|
||||
} else {
|
||||
@@ -1634,6 +1840,49 @@ fn record_image_analysis_failure(
|
||||
warning_notes.push(note);
|
||||
}
|
||||
|
||||
/// Expand a set of body `chunk_id`s into every per-alias sentinel
|
||||
/// `chunk_id` that orphan cleanup must also delete.
|
||||
///
|
||||
/// PR #195 review (MAJOR): alias dense vectors moved from a single
|
||||
/// legacy sentinel `{orig}#alias` to per-line sentinels
|
||||
/// `{orig}#alias#0`, `{orig}#alias#1`, … (one VectorRecord per alias
|
||||
/// line). These sentinel chunk_ids never appear in SQLite `chunks`, so
|
||||
/// they are absent from the stale-set the cleanup paths SELECT. Because
|
||||
/// `delete_by_chunk_ids` matches on exact `chunk_id IN (...)` (not a
|
||||
/// prefix), deleting only `{orig}#alias` leaked `{orig}#alias#N` rows
|
||||
/// into LanceDB — stale aliases could still hit search.
|
||||
///
|
||||
/// We reuse the existing exact-match delete infra (approach A): for each
|
||||
/// body id emit `{id}#alias` (legacy, backward-compat) plus
|
||||
/// `{id}#alias#0` .. `{id}#alias#{max-1}`. `max` is
|
||||
/// `expansion.max_aliases_per_chunk`, which is the hard cap
|
||||
/// `parse_aliases` enforces (it `break`s once `out.len() >= max`), so no
|
||||
/// index ≥ max is ever produced at ingest time. Indices that were never
|
||||
/// written are harmless no-ops in an `IN (...)` delete.
|
||||
fn alias_sentinel_ids_to_delete(
|
||||
body_ids: &[kebab_core::ChunkId],
|
||||
max_aliases_per_chunk: usize,
|
||||
) -> Vec<kebab_core::ChunkId> {
|
||||
let mut out = body_ids.to_vec();
|
||||
for id in body_ids {
|
||||
// Legacy single sentinel (docs ingested before per-line split).
|
||||
out.push(kebab_core::ChunkId(format!(
|
||||
"{}{}",
|
||||
id.0,
|
||||
kebab_core::ALIAS_SUFFIX
|
||||
)));
|
||||
for i in 0..max_aliases_per_chunk {
|
||||
out.push(kebab_core::ChunkId(format!(
|
||||
"{}{}#{}",
|
||||
id.0,
|
||||
kebab_core::ALIAS_SUFFIX,
|
||||
i
|
||||
)));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// v0.17.0 PR-B: parser-bump cascade. When a code extractor ships a
|
||||
/// new `PARSER_VERSION` (e.g. `code-c-v1` → `code-c-v2`), the same
|
||||
/// (workspace_path, asset_id) pair re-emerges with a fresh `doc_id`.
|
||||
@@ -1661,8 +1910,15 @@ fn purge_workspace_path_for_parser_bump(app: &App, asset: &RawAsset) -> anyhow::
|
||||
if !stale.is_empty() {
|
||||
if let Some(vec_store) = app.vector().context("App::vector")? {
|
||||
use kebab_core::VectorStore as _;
|
||||
// per-alias sentinel 벡터(`{id}#alias#N`)는 SQLite chunks 에 없어
|
||||
// stale 에 안 잡힌다 → 본문 + 모든 별칭 sentinel 을 명시적으로 함께
|
||||
// 삭제(orphan 누적 방지, PR #195 MAJOR).
|
||||
let to_delete = alias_sentinel_ids_to_delete(
|
||||
&stale,
|
||||
app.config.ingest.expansion.max_aliases_per_chunk,
|
||||
);
|
||||
vec_store
|
||||
.delete_by_chunk_ids(&stale)
|
||||
.delete_by_chunk_ids(&to_delete)
|
||||
.context("VectorStore::delete_by_chunk_ids (parser-bump orphans)")?;
|
||||
}
|
||||
}
|
||||
@@ -1706,8 +1962,15 @@ fn purge_vector_orphans_for_workspace_path(
|
||||
return Ok(());
|
||||
}
|
||||
use kebab_core::VectorStore as _;
|
||||
// per-alias sentinel 벡터(`{id}#alias#N`)는 SQLite chunks 에 없어 stale 에
|
||||
// 안 잡힌다 → 본문 + 모든 별칭 sentinel 을 명시적으로 함께 삭제(orphan
|
||||
// 누적 방지, PR #195 MAJOR).
|
||||
let to_delete = alias_sentinel_ids_to_delete(
|
||||
&stale,
|
||||
app.config.ingest.expansion.max_aliases_per_chunk,
|
||||
);
|
||||
vec_store
|
||||
.delete_by_chunk_ids(&stale)
|
||||
.delete_by_chunk_ids(&to_delete)
|
||||
.context("VectorStore::delete_by_chunk_ids (orphan vector cleanup)")?;
|
||||
tracing::debug!(
|
||||
target: "kebab-app",
|
||||
@@ -1807,7 +2070,14 @@ fn sweep_deleted_files(
|
||||
if let Some(vec) = vector_store {
|
||||
if !chunk_ids.is_empty() {
|
||||
use kebab_core::VectorStore as _;
|
||||
if let Err(e) = vec.delete_by_chunk_ids(&chunk_ids) {
|
||||
// per-alias sentinel 벡터(`{id}#alias#N`)는 SQLite chunks 에 없어
|
||||
// chunk_ids 에 안 잡힌다 → 본문 + 모든 별칭 sentinel 을 명시적으로
|
||||
// 함께 삭제(orphan 누적 방지, PR #195 MAJOR).
|
||||
let to_delete = alias_sentinel_ids_to_delete(
|
||||
&chunk_ids,
|
||||
app.config.ingest.expansion.max_aliases_per_chunk,
|
||||
);
|
||||
if let Err(e) = vec.delete_by_chunk_ids(&to_delete) {
|
||||
tracing::warn!(
|
||||
target: "kebab-app",
|
||||
path = %stored_path.0,
|
||||
@@ -2911,6 +3181,48 @@ pub fn doctor_with_config_path(
|
||||
hint: data_hint,
|
||||
});
|
||||
|
||||
// config_migration — 사용자 파일이 새 스키마와 동기인지(dry-run 마이그레이션).
|
||||
// 파일이 존재할 때만 점검(없으면 defaults 사용 중이라 마이그레이션 무의미).
|
||||
if cfg_path.exists() {
|
||||
if let Ok(text) = std::fs::read_to_string(&cfg_path) {
|
||||
let outcome = kebab_config::migrate::migrate_document(&text);
|
||||
let (mok, detail, hint) = if outcome.changed() {
|
||||
let added = outcome
|
||||
.changes
|
||||
.iter()
|
||||
.filter(|c| {
|
||||
matches!(
|
||||
c.kind,
|
||||
kebab_config::migrate::ChangeKind::AddedSection
|
||||
| kebab_config::migrate::ChangeKind::AddedKey
|
||||
)
|
||||
})
|
||||
.count();
|
||||
let removed = outcome.changes.len() - added;
|
||||
(
|
||||
false,
|
||||
format!(
|
||||
"{} pending changes (added {added}, removed {removed} deprecated)",
|
||||
outcome.changes.len()
|
||||
),
|
||||
Some("run `kebab config migrate` to update your config.toml".to_string()),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
true,
|
||||
format!("config up to date (schema v{})", outcome.to_schema_version),
|
||||
None,
|
||||
)
|
||||
};
|
||||
checks.push(DoctorCheck {
|
||||
name: "config_migration".to_string(),
|
||||
ok: mok,
|
||||
detail,
|
||||
hint,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let ok = checks.iter().all(|c| c.ok);
|
||||
Ok(DoctorReport {
|
||||
schema_version: "doctor.v1".to_string(),
|
||||
@@ -2927,6 +3239,66 @@ pub fn doctor() -> anyhow::Result<DoctorReport> {
|
||||
doctor_with_config_path(None)
|
||||
}
|
||||
|
||||
/// `kebab config migrate` 의 결과(wire `config_migration.v1` 소스).
|
||||
#[derive(Clone, Debug, PartialEq, serde::Serialize)]
|
||||
pub struct ConfigMigrationReport {
|
||||
/// 항상 `"config_migration.v1"`.
|
||||
pub schema_version: String,
|
||||
pub config_path: String,
|
||||
pub dry_run: bool,
|
||||
pub from_schema_version: u32,
|
||||
pub to_schema_version: u32,
|
||||
pub changed: bool,
|
||||
pub backup_path: Option<String>,
|
||||
pub changes: Vec<kebab_config::migrate::MigrationChange>,
|
||||
}
|
||||
|
||||
/// 사용자 config.toml 을 새 스키마로 마이그레이션한다(facade).
|
||||
/// `config_path` 미지정 시 XDG 기본. `dry_run=true` 면 파일·백업 미변경.
|
||||
/// 안전: 변경 시 `.bak` 백업 후 tmp 에 쓰고 round-trip 검증 → atomic rename.
|
||||
pub fn config_migrate_with_config_path(
|
||||
config_path: Option<&std::path::Path>,
|
||||
dry_run: bool,
|
||||
) -> anyhow::Result<ConfigMigrationReport> {
|
||||
let path: PathBuf = match config_path {
|
||||
Some(p) => p.to_path_buf(),
|
||||
None => kebab_config::Config::xdg_config_path(),
|
||||
};
|
||||
if !path.exists() {
|
||||
anyhow::bail!(
|
||||
"config 파일이 없습니다: {} — 먼저 `kebab init` 을 실행하세요.",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
let text = std::fs::read_to_string(&path)?;
|
||||
let outcome = kebab_config::migrate::migrate_document(&text);
|
||||
|
||||
let mut backup_path = None;
|
||||
if !dry_run && outcome.changed() {
|
||||
let bak = path.with_extension("toml.bak");
|
||||
std::fs::copy(&path, &bak)?;
|
||||
backup_path = Some(bak.display().to_string());
|
||||
let tmp = path.with_extension("toml.tmp");
|
||||
std::fs::write(&tmp, &outcome.new_text)?;
|
||||
if kebab_config::Config::from_file(&tmp).is_err() {
|
||||
std::fs::remove_file(&tmp).ok();
|
||||
anyhow::bail!("마이그레이션 결과가 유효하지 않아 원본을 보존합니다.");
|
||||
}
|
||||
std::fs::rename(&tmp, &path)?;
|
||||
}
|
||||
|
||||
Ok(ConfigMigrationReport {
|
||||
schema_version: "config_migration.v1".to_string(),
|
||||
config_path: path.display().to_string(),
|
||||
dry_run,
|
||||
from_schema_version: outcome.from_schema_version,
|
||||
to_schema_version: outcome.to_schema_version,
|
||||
changed: outcome.changed(),
|
||||
backup_path,
|
||||
changes: outcome.changes,
|
||||
})
|
||||
}
|
||||
|
||||
/// Single-file ingest (p9-fb-31). Copies the file to
|
||||
/// `<workspace.root>/_external/<blake3-12>.<ext>` and runs the
|
||||
/// per-medium ingest pipeline on that single asset. Returns an
|
||||
@@ -3068,3 +3440,49 @@ fn check_kebabignore_match(
|
||||
.matched(source_path, source_path.is_dir())
|
||||
.is_ignore()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod orphan_cleanup_tests {
|
||||
use super::alias_sentinel_ids_to_delete;
|
||||
use kebab_core::ChunkId;
|
||||
|
||||
/// PR #195 MAJOR: alias dense 벡터가 줄별 `{id}#alias#N` sentinel 로 색인되므로
|
||||
/// orphan cleanup 의 LanceDB delete-set 은 본문 + legacy `{id}#alias` +
|
||||
/// `{id}#alias#0` .. `{id}#alias#{max-1}` 를 모두 포함해야 한다. 이전 코드는
|
||||
/// 단일 `{id}#alias` 만 넣어 per-line sentinel 을 LanceDB 에 누수시켰다.
|
||||
#[test]
|
||||
fn expands_body_legacy_and_per_alias_sentinels() {
|
||||
let body = ChunkId("aabbccddeeff00112233445566778899".to_string());
|
||||
let max = 3;
|
||||
let out = alias_sentinel_ids_to_delete(std::slice::from_ref(&body), max);
|
||||
let ids: Vec<&str> = out.iter().map(|c| c.0.as_str()).collect();
|
||||
|
||||
assert!(ids.contains(&body.0.as_str()), "본문 chunk_id 포함");
|
||||
assert!(
|
||||
ids.contains(&"aabbccddeeff00112233445566778899#alias"),
|
||||
"하위호환 legacy 단일 sentinel 포함"
|
||||
);
|
||||
for i in 0..max {
|
||||
let expected = format!("aabbccddeeff00112233445566778899#alias#{i}");
|
||||
assert!(
|
||||
ids.contains(&expected.as_str()),
|
||||
"per-alias sentinel #{i} 포함 (max={max})"
|
||||
);
|
||||
}
|
||||
// body(1) + legacy(1) + per-alias(max) = max + 2.
|
||||
assert_eq!(out.len(), max + 2, "정확히 max+2 개 id");
|
||||
// max 상한과 일치: #alias#{max} 는 절대 생성 안 함(parse_aliases 가 cap).
|
||||
assert!(
|
||||
!ids.contains(&"aabbccddeeff00112233445566778899#alias#3"),
|
||||
"상한(max) 이상 인덱스는 생성하지 않음"
|
||||
);
|
||||
}
|
||||
|
||||
/// max=0 (확장 비활성 동등) 이면 per-alias sentinel 없이 본문 + legacy 만.
|
||||
#[test]
|
||||
fn zero_max_emits_body_and_legacy_only() {
|
||||
let body = ChunkId("00000000000000000000000000000000".to_string());
|
||||
let out = alias_sentinel_ids_to_delete(std::slice::from_ref(&body), 0);
|
||||
assert_eq!(out.len(), 2, "본문 + legacy sentinel 만");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
82
crates/kebab-app/tests/config_migrate.rs
Normal file
82
crates/kebab-app/tests/config_migrate.rs
Normal file
@@ -0,0 +1,82 @@
|
||||
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.expansion]"));
|
||||
|
||||
// 멱등: 재실행 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("doc-side 별칭"), "section comment missing:\n{text}");
|
||||
assert!(text.contains("[ingest.expansion]"));
|
||||
}
|
||||
|
||||
#[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");
|
||||
}
|
||||
@@ -109,10 +109,11 @@ 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 is purely additive — does NOT bump.)
|
||||
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!(
|
||||
|
||||
@@ -152,6 +152,7 @@ fn make_chunk(
|
||||
token_estimate,
|
||||
chunker_version: chunker_version.clone(),
|
||||
policy_hash: base_policy_hash.to_string(),
|
||||
aliases: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -154,6 +154,7 @@ fn make_chunk(
|
||||
token_estimate,
|
||||
chunker_version: chunker_version.clone(),
|
||||
policy_hash: base_policy_hash.to_string(),
|
||||
aliases: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -154,6 +154,7 @@ fn make_chunk(
|
||||
token_estimate,
|
||||
chunker_version: chunker_version.clone(),
|
||||
policy_hash: base_policy_hash.to_string(),
|
||||
aliases: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -154,6 +154,7 @@ fn make_chunk(
|
||||
token_estimate,
|
||||
chunker_version: chunker_version.clone(),
|
||||
policy_hash: base_policy_hash.to_string(),
|
||||
aliases: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -154,6 +154,7 @@ fn make_chunk(
|
||||
token_estimate,
|
||||
chunker_version: chunker_version.clone(),
|
||||
policy_hash: base_policy_hash.to_string(),
|
||||
aliases: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -154,6 +154,7 @@ fn make_chunk(
|
||||
token_estimate,
|
||||
chunker_version: chunker_version.clone(),
|
||||
policy_hash: base_policy_hash.to_string(),
|
||||
aliases: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -154,6 +154,7 @@ fn make_chunk(
|
||||
token_estimate,
|
||||
chunker_version: chunker_version.clone(),
|
||||
policy_hash: base_policy_hash.to_string(),
|
||||
aliases: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -154,6 +154,7 @@ fn make_chunk(
|
||||
token_estimate,
|
||||
chunker_version: chunker_version.clone(),
|
||||
policy_hash: base_policy_hash.to_string(),
|
||||
aliases: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -154,6 +154,7 @@ fn make_chunk(
|
||||
token_estimate,
|
||||
chunker_version: chunker_version.clone(),
|
||||
policy_hash: base_policy_hash.to_string(),
|
||||
aliases: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -339,6 +339,7 @@ fn build_chunk(
|
||||
token_estimate,
|
||||
chunker_version: chunker_version.clone(),
|
||||
policy_hash: policy_hash.to_string(),
|
||||
aliases: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -177,6 +177,7 @@ impl Chunker for PdfPageV1Chunker {
|
||||
token_estimate,
|
||||
chunker_version: chunker_version.clone(),
|
||||
policy_hash: base_policy_hash.clone(),
|
||||
aliases: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,5 +196,6 @@ fn build_chunk_from_span(
|
||||
token_estimate,
|
||||
chunker_version: chunker_version.clone(),
|
||||
policy_hash: base_policy_hash.to_string(),
|
||||
aliases: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
[
|
||||
{
|
||||
"aliases": null,
|
||||
"block_ids": [
|
||||
"8149e12ca002489acb4a0f74c97a061a"
|
||||
],
|
||||
@@ -22,6 +23,7 @@
|
||||
"tokenized_korean_text": "# include < stdio . h > # include < stdlib . h > # define MAX _ BUF 4096 typedef enum { OK = 0 , ERR _ PARSE , ERR _ IO , } status _ t ; typedef struct { int id ; char name [ 64 ]; status _ t status ; } record _ t ; static int counter = 0 ;"
|
||||
},
|
||||
{
|
||||
"aliases": null,
|
||||
"block_ids": [
|
||||
"1baaa89f21a47b2f32d6396a24a85454"
|
||||
],
|
||||
@@ -44,6 +46,7 @@
|
||||
"tokenized_korean_text": "int parse _ record ( const char * line , record _ t * out ) { if ( line == NULL || out == NULL ) return ERR _ PARSE ; return OK ; }"
|
||||
},
|
||||
{
|
||||
"aliases": null,
|
||||
"block_ids": [
|
||||
"8d0e14cbcc6d1e92d7878ab796ea68b8"
|
||||
],
|
||||
@@ -66,6 +69,7 @@
|
||||
"tokenized_korean_text": "void print _ record ( const record _ t * r ) { printf (\"[% d ] % s ( status =% d )\\ n \", r -> id , r -> name , r -> status ); }"
|
||||
},
|
||||
{
|
||||
"aliases": null,
|
||||
"block_ids": [
|
||||
"9c2ede84423871b615d48c38fefb1853"
|
||||
],
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,5 +1,6 @@
|
||||
[
|
||||
{
|
||||
"aliases": null,
|
||||
"block_ids": [
|
||||
"53292605459065d170cd36c118e20546"
|
||||
],
|
||||
@@ -22,6 +23,7 @@
|
||||
"tokenized_korean_text": "# include < string > # include < vector > namespace kebab {"
|
||||
},
|
||||
{
|
||||
"aliases": null,
|
||||
"block_ids": [
|
||||
"f349acad94c9fa4cf9ad1c0a93e83610"
|
||||
],
|
||||
@@ -44,6 +46,7 @@
|
||||
"tokenized_korean_text": "class MdHeadingV 1 Chunker { public : MdHeadingV 1 Chunker ( ) = default ; ~ MdHeadingV 1 Chunker ( ) = default ; std : : string chunk _ doc ( const std : : string & doc ) { return doc ; } int operator ( ) ( int x ) const { return x * 2 ; } private : int counter _ = 0 ; };"
|
||||
},
|
||||
{
|
||||
"aliases": null,
|
||||
"block_ids": [
|
||||
"8b9811387717d0bd4abf84abcc35b8b1"
|
||||
],
|
||||
@@ -66,6 +69,7 @@
|
||||
"tokenized_korean_text": "template < typename T > T identity ( T value ) { return value ; }"
|
||||
},
|
||||
{
|
||||
"aliases": null,
|
||||
"block_ids": [
|
||||
"1754cb6b971f6a4cb292f144a4f0570b"
|
||||
],
|
||||
@@ -88,6 +92,7 @@
|
||||
"tokenized_korean_text": "void global _ helper ( ) { / / free function in kebab namespace }"
|
||||
},
|
||||
{
|
||||
"aliases": null,
|
||||
"block_ids": [
|
||||
"14b5f3393d6d25f822f5b70763d24acd"
|
||||
],
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
[
|
||||
{
|
||||
"aliases": null,
|
||||
"block_ids": [
|
||||
"c182bf37e32c7fc1b868bd617f8eaf66"
|
||||
],
|
||||
@@ -22,6 +23,7 @@
|
||||
"tokenized_korean_text": "import ( \" fmt \" \" os \" \" strings \" )"
|
||||
},
|
||||
{
|
||||
"aliases": null,
|
||||
"block_ids": [
|
||||
"c9992cdcfdf3c2a7700a4abc4782a8a4"
|
||||
],
|
||||
@@ -44,6 +46,7 @@
|
||||
"tokenized_korean_text": "func ComputeMRR ( scores [ ] float 64 ) float 64 { if len ( scores ) == 0 { return 0 . 0 } _ = fmt . Sprintf (\"% v \", scores ) return 1 . 0 / float 64 ( len ( scores ) ) }"
|
||||
},
|
||||
{
|
||||
"aliases": null,
|
||||
"block_ids": [
|
||||
"5f18dc3e79fe946ba05d32c3bfc00684"
|
||||
],
|
||||
@@ -66,6 +69,7 @@
|
||||
"tokenized_korean_text": "type MetricsCollector struct { Scores [ ] float 64 Labels [ ] string Counts map [ string ] int Totals map [ string ] float 64 Tags [ ] string }"
|
||||
},
|
||||
{
|
||||
"aliases": null,
|
||||
"block_ids": [
|
||||
"3009cc022ca832c323393e4f9bcdb388"
|
||||
],
|
||||
@@ -88,6 +92,7 @@
|
||||
"tokenized_korean_text": "type BaseEvaluator struct { Name string } func ( e * BaseEvaluator ) Evaluate ( data [ ] string ) error { _ = os . Stderr _ = strings . Join ( data , \",\") return nil }"
|
||||
},
|
||||
{
|
||||
"aliases": null,
|
||||
"block_ids": [
|
||||
"e0e83d1d7f9327a1902ae9a8f67c1f1c"
|
||||
],
|
||||
@@ -110,6 +115,7 @@
|
||||
"tokenized_korean_text": "func ( m * MetricsCollector ) Run ( inputs [ ] float 64 ) { for _, inp := range inputs { m . Scores = append ( m . Scores , inp , ) } }"
|
||||
},
|
||||
{
|
||||
"aliases": null,
|
||||
"block_ids": [
|
||||
"0e6a572bc3fe2bd6d173fe614bd1b763"
|
||||
],
|
||||
@@ -132,6 +138,7 @@
|
||||
"tokenized_korean_text": "func ( m * MetricsCollector ) Report ( ) map [ string ] interface {} { return map [ string ] interface {}{ \" mean \": 0 . 0 , \" count \": len ( m . Scores ) , \" tags \": m . Tags , } }"
|
||||
},
|
||||
{
|
||||
"aliases": null,
|
||||
"block_ids": [
|
||||
"5d269745b2e5dbdcbef0c09ba54b0bd6"
|
||||
],
|
||||
@@ -154,6 +161,7 @@
|
||||
"tokenized_korean_text": "func BigCompute ( data [ ] int ) int { v 0 := 0 if 0 < len ( data ) { v 0 = data [ 0 ] } v 1 := 0 if 1 < len ( data ) { v 1 = data [ 1 ] } v 2 := 0 if 2 < len ( data ) { v 2 = data [ 2 ] } v 3 := 0 if 3 < len ( data ) { v 3 = data [ 3 ] } v 4 := 0 if 4 < len ( data ) { v 4 = data [ 4 ] } v 5 := 0 if 5 < len ( data ) { v 5 = data [ 5 ] } v 6 := 0 if 6 < len ( data ) { v 6 = data [ 6 ] } v 7 := 0 if 7 < len ( data ) { v 7 = data [ 7 ] } v 8 := 0 if 8 < len ( data ) { v 8 = data [ 8 ] } v 9 := 0 if 9 < len ( data ) { v 9 = data [ 9 ] } v 10 := 0 if 10 < len ( data ) { v 10 = data [ 10 ] } v 11 := 0 if 11 < len ( data ) { v 11 = data [ 11 ] } v 12 := 0 if 12 < len ( data ) { v 12 = data [ 12 ] } v 13 := 0 if 13 < len ( data ) { v 13 = data [ 13 ] } v 14 := 0 if 14 < len ( data ) { v 14 = data [ 14 ] } v 15 := 0 if 15 < len ( data ) { v 15 = data [ 15 ] } v 16 := 0 if 16 < len ( data ) { v 16 = data [ 16 ] } v 17 := 0 if 17 < len ( data ) { v 17 = data [ 17 ] } v 18 := 0 if 18 < len ( data ) { v 18 = data [ 18 ] } v 19 := 0 if 19 < len ( data ) { v 19 = data [ 19 ] } v 20 := 0 if 20 < len ( data ) { v 20 = data [ 20 ] } v 21 := 0 if 21 < len ( data ) { v 21 = data [ 21 ] } v 22 := 0 if 22 < len ( data ) { v 22 = data [ 22 ] } v 23 := 0 if 23 < len ( data ) { v 23 = data [ 23 ] } v 24 := 0 if 24 < len ( data ) { v 24 = data [ 24 ] } v 25 := 0 if 25 < len ( data ) { v 25 = data [ 25 ] } v 26 := 0 if 26 < len ( data ) { v 26 = data [ 26 ] } v 27 := 0 if 27 < len ( data ) { v 27 = data [ 27 ] } v 28 := 0 if 28 < len ( data ) { v 28 = data [ 28 ] } v 29 := 0 if 29 < len ( data ) { v 29 = data [ 29 ] } v 30 := 0 if 30 < len ( data ) { v 30 = data [ 30 ] } v 31 := 0 if 31 < len ( data ) { v 31 = data [ 31 ] } v 32 := 0 if 32 < len ( data ) { v 32 = data [ 32 ] } v 33 := 0 if 33 < len ( data ) { v 33 = data [ 33 ] } v 34 := 0 if 34 < len ( data ) { v 34 = data [ 34 ] } v 35 := 0 if 35 < len ( data ) { v 35 = data [ 35 ] } v 36 := 0 if 36 < len ( data ) { v 36 = data [ 36 ] } v 37 := 0 if 37 < len ( data ) { v 37 = data [ 37 ] } v 38 := 0 if 38 < len ( data ) { v 38 = data [ 38 ] } v 39 := 0 if 39 < len ( data ) { v 39 = data [ 39 ] } v 40 := 0 if 40 < len ( data ) { v 40 = data [ 40 ] } v 41 := 0 if 41 < len ( data ) { v 41 = data [ 41 ] } v 42 := 0 if 42 < len ( data ) { v 42 = data [ 42 ] } v 43 := 0 if 43 < len ( data ) { v 43 = data [ 43 ] } v 44 := 0 if 44 < len ( data ) { v 44 = data [ 44 ] } v 45 := 0 if 45 < len ( data ) { v 45 = data [ 45 ] } v 46 := 0 if 46 < len ( data ) { v 46 = data [ 46 ] } v 47 := 0 if 47 < len ( data ) { v 47 = data [ 47 ] } v 48 := 0 if 48 < len ( data ) { v 48 = data [ 48 ] } v 49 := 0 if 49 < len ( data ) { v 49 = data [ 49 ]"
|
||||
},
|
||||
{
|
||||
"aliases": null,
|
||||
"block_ids": [
|
||||
"5d269745b2e5dbdcbef0c09ba54b0bd6"
|
||||
],
|
||||
@@ -176,6 +184,7 @@
|
||||
"tokenized_korean_text": "} v 50 := 0 if 50 < len ( data ) { v 50 = data [ 50 ] } v 51 := 0 if 51 < len ( data ) { v 51 = data [ 51 ] } v 52 := 0 if 52 < len ( data ) { v 52 = data [ 52 ] } v 53 := 0 if 53 < len ( data ) { v 53 = data [ 53 ] } v 54 := 0 if 54 < len ( data ) { v 54 = data [ 54 ] } v 55 := 0 if 55 < len ( data ) { v 55 = data [ 55 ] } v 56 := 0 if 56 < len ( data ) { v 56 = data [ 56 ] } v 57 := 0 if 57 < len ( data ) { v 57 = data [ 57 ] } v 58 := 0 if 58 < len ( data ) { v 58 = data [ 58 ] } v 59 := 0 if 59 < len ( data ) { v 59 = data [ 59 ] } v 60 := 0 if 60 < len ( data ) { v 60 = data [ 60 ] } v 61 := 0 if 61 < len ( data ) { v 61 = data [ 61 ] } v 62 := 0 if 62 < len ( data ) { v 62 = data [ 62 ] } v 63 := 0 if 63 < len ( data ) { v 63 = data [ 63 ] } v 64 := 0 if 64 < len ( data ) { v 64 = data [ 64 ] } v 65 := 0 if 65 < len ( data ) { v 65 = data [ 65 ] } v 66 := 0 if 66 < len ( data ) { v 66 = data [ 66 ] } v 67 := 0 if 67 < len ( data ) { v 67 = data [ 67 ] } v 68 := 0 if 68 < len ( data ) { v 68 = data [ 68 ] } v 69 := 0 if 69 < len ( data ) { v 69 = data [ 69 ] } v 70 := 0 if 70 < len ( data ) { v 70 = data [ 70 ] } v 71 := 0 if 71 < len ( data ) { v 71 = data [ 71 ] } v 72 := 0 if 72 < len ( data ) { v 72 = data [ 72 ] } v 73 := 0 if 73 < len ( data ) { v 73 = data [ 73 ] } v 74 := 0 if 74 < len ( data ) { v 74 = data [ 74 ] } v 75 := 0 if 75 < len ( data ) { v 75 = data [ 75 ] } v 76 := 0 if 76 < len ( data ) { v 76 = data [ 76 ] } v 77 := 0 if 77 < len ( data ) { v 77 = data [ 77 ] } v 78 := 0 if 78 < len ( data ) { v 78 = data [ 78 ] } v 79 := 0 if 79 < len ( data ) { v 79 = data [ 79 ] } v 80 := 0 if 80 < len ( data ) { v 80 = data [ 80 ] } v 81 := 0 if 81 < len ( data ) { v 81 = data [ 81 ] } v 82 := 0 if 82 < len ( data ) { v 82 = data [ 82 ] } v 83 := 0 if 83 < len ( data ) { v 83 = data [ 83 ] } v 84 := 0 if 84 < len ( data ) { v 84 = data [ 84 ] } v 85 := 0 if 85 < len ( data ) { v 85 = data [ 85 ] } v 86 := 0 if 86 < len ( data ) { v 86 = data [ 86 ] } v 87 := 0 if 87 < len ( data ) { v 87 = data [ 87 ] } v 88 := 0 if 88 < len ( data ) { v 88 = data [ 88 ] } v 89 := 0 if 89 < len ( data ) { v 89 = data [ 89 ] } v 90 := 0 if 90 < len ( data ) { v 90 = data [ 90 ] } v 91 := 0 if 91 < len ( data ) { v 91 = data [ 91 ] } v 92 := 0 if 92 < len ( data ) { v 92 = data [ 92 ] } v 93 := 0 if 93 < len ( data ) { v 93 = data [ 93 ] } v 94 := 0 if 94 < len ( data ) { v 94 = data [ 94 ] } v 95 := 0 if 95 < len ( data ) { v 95 = data [ 95 ] } v 96 := 0 if 96 < len ( data ) { v 96 = data [ 96 ] } v 97 := 0 if 97 < len ( data ) { v 97 = data [ 97 ] } v 98 := 0 if 98 < len ( data ) { v 98 = data [ 98 ] } v 99 := 0 if 99 < len ( data ) { v 99 = data [ 99 ]"
|
||||
},
|
||||
{
|
||||
"aliases": null,
|
||||
"block_ids": [
|
||||
"5d269745b2e5dbdcbef0c09ba54b0bd6"
|
||||
],
|
||||
@@ -198,6 +207,7 @@
|
||||
"tokenized_korean_text": "} v 100 := 0 if 100 < len ( data ) { v 100 = data [ 100 ] } v 101 := 0 if 101 < len ( data ) { v 101 = data [ 101 ] } v 102 := 0 if 102 < len ( data ) { v 102 = data [ 102 ] } v 103 := 0 if 103 < len ( data ) { v 103 = data [ 103 ] } v 104 := 0 if 104 < len ( data ) { v 104 = data [ 104 ] } v 105 := 0 if 105 < len ( data ) { v 105 = data [ 105 ] } v 106 := 0 if 106 < len ( data ) { v 106 = data [ 106 ] } v 107 := 0 if 107 < len ( data ) { v 107 = data [ 107 ] } v 108 := 0 if 108 < len ( data ) { v 108 = data [ 108 ] } v 109 := 0 if 109 < len ( data ) { v 109 = data [ 109 ] } v 110 := 0 if 110 < len ( data ) { v 110 = data [ 110 ] } v 111 := 0 if 111 < len ( data ) { v 111 = data [ 111 ] } v 112 := 0 if 112 < len ( data ) { v 112 = data [ 112 ] } v 113 := 0 if 113 < len ( data ) { v 113 = data [ 113 ] } v 114 := 0 if 114 < len ( data ) { v 114 = data [ 114 ] } v 115 := 0 if 115 < len ( data ) { v 115 = data [ 115 ] } v 116 := 0 if 116 < len ( data ) { v 116 = data [ 116 ] } v 117 := 0 if 117 < len ( data ) { v 117 = data [ 117 ] } v 118 := 0 if 118 < len ( data ) { v 118 = data [ 118 ] } v 119 := 0 if 119 < len ( data ) { v 119 = data [ 119 ] } v 120 := 0 if 120 < len ( data ) { v 120 = data [ 120 ] } v 121 := 0 if 121 < len ( data ) { v 121 = data [ 121 ] } v 122 := 0 if 122 < len ( data ) { v 122 = data [ 122 ] } v 123 := 0 if 123 < len ( data ) { v 123 = data [ 123 ] } v 124 := 0 if 124 < len ( data ) { v 124 = data [ 124 ] } v 125 := 0 if 125 < len ( data ) { v 125 = data [ 125 ] } v 126 := 0 if 126 < len ( data ) { v 126 = data [ 126 ] } v 127 := 0 if 127 < len ( data ) { v 127 = data [ 127 ] } v 128 := 0 if 128 < len ( data ) { v 128 = data [ 128 ] } v 129 := 0 if 129 < len ( data ) { v 129 = data [ 129 ] } v 130 := 0 if 130 < len ( data ) { v 130 = data [ 130 ] } v 131 := 0 if 131 < len ( data ) { v 131 = data [ 131 ] } v 132 := 0 if 132 < len ( data ) { v 132 = data [ 132 ] } v 133 := 0 if 133 < len ( data ) { v 133 = data [ 133 ] } v 134 := 0 if 134 < len ( data ) { v 134 = data [ 134 ] } v 135 := 0 if 135 < len ( data ) { v 135 = data [ 135 ] } v 136 := 0 if 136 < len ( data ) { v 136 = data [ 136 ] } v 137 := 0 if 137 < len ( data ) { v 137 = data [ 137 ] } v 138 := 0 if 138 < len ( data ) { v 138 = data [ 138 ] } v 139 := 0 if 139 < len ( data ) { v 139 = data [ 139 ] } v 140 := 0 if 140 < len ( data ) { v 140 = data [ 140 ] } v 141 := 0 if 141 < len ( data ) { v 141 = data [ 141 ] } v 142 := 0 if 142 < len ( data ) { v 142 = data [ 142 ] } v 143 := 0 if 143 < len ( data ) { v 143 = data [ 143 ] } v 144 := 0 if 144 < len ( data ) { v 144 = data [ 144 ] } v 145 := 0 if 145 < len ( data ) { v 145 = data [ 145 ] } v 146 := 0 if 146 < len ( data ) { v 146 = data [ 146 ] } v 147 := 0 if 147 < len ( data ) { v 147 = data [ 147 ] } v 148 := 0 if 148 < len ( data ) { v 148 = data [ 148 ] } v 149 := 0 if 149 < len ( data ) { v 149 = data [ 149 ]"
|
||||
},
|
||||
{
|
||||
"aliases": null,
|
||||
"block_ids": [
|
||||
"5d269745b2e5dbdcbef0c09ba54b0bd6"
|
||||
],
|
||||
@@ -220,6 +230,7 @@
|
||||
"tokenized_korean_text": "} v 150 := 0 if 150 < len ( data ) { v 150 = data [ 150 ] } v 151 := 0 if 151 < len ( data ) { v 151 = data [ 151 ] } v 152 := 0 if 152 < len ( data ) { v 152 = data [ 152 ] } v 153 := 0 if 153 < len ( data ) { v 153 = data [ 153 ] } v 154 := 0 if 154 < len ( data ) { v 154 = data [ 154 ] } v 155 := 0 if 155 < len ( data ) { v 155 = data [ 155 ] } v 156 := 0 if 156 < len ( data ) { v 156 = data [ 156 ] } v 157 := 0 if 157 < len ( data ) { v 157 = data [ 157 ] } v 158 := 0 if 158 < len ( data ) { v 158 = data [ 158 ] } v 159 := 0 if 159 < len ( data ) { v 159 = data [ 159 ] } v 160 := 0 if 160 < len ( data ) { v 160 = data [ 160 ] } v 161 := 0 if 161 < len ( data ) { v 161 = data [ 161 ] } v 162 := 0 if 162 < len ( data ) { v 162 = data [ 162 ] } v 163 := 0 if 163 < len ( data ) { v 163 = data [ 163 ] } v 164 := 0 if 164 < len ( data ) { v 164 = data [ 164 ] } v 165 := 0 if 165 < len ( data ) { v 165 = data [ 165 ] } v 166 := 0 if 166 < len ( data ) { v 166 = data [ 166 ] } v 167 := 0 if 167 < len ( data ) { v 167 = data [ 167 ] } v 168 := 0 if 168 < len ( data ) { v 168 = data [ 168 ] } v 169 := 0 if 169 < len ( data ) { v 169 = data [ 169 ] } v 170 := 0 if 170 < len ( data ) { v 170 = data [ 170 ] } v 171 := 0 if 171 < len ( data ) { v 171 = data [ 171 ] } v 172 := 0 if 172 < len ( data ) { v 172 = data [ 172 ] } v 173 := 0 if 173 < len ( data ) { v 173 = data [ 173 ] } v 174 := 0 if 174 < len ( data ) { v 174 = data [ 174 ] } v 175 := 0 if 175 < len ( data ) { v 175 = data [ 175 ] } v 176 := 0 if 176 < len ( data ) { v 176 = data [ 176 ] } v 177 := 0 if 177 < len ( data ) { v 177 = data [ 177 ] } v 178 := 0 if 178 < len ( data ) { v 178 = data [ 178 ] } v 179 := 0 if 179 < len ( data ) { v 179 = data [ 179 ] } v 180 := 0 if 180 < len ( data ) { v 180 = data [ 180 ] } v 181 := 0 if 181 < len ( data ) { v 181 = data [ 181 ] } v 182 := 0 if 182 < len ( data ) { v 182 = data [ 182 ] } v 183 := 0 if 183 < len ( data ) { v 183 = data [ 183 ] } v 184 := 0 if 184 < len ( data ) { v 184 = data [ 184 ] } v 185 := 0 if 185 < len ( data ) { v 185 = data [ 185 ] } v 186 := 0 if 186 < len ( data ) { v 186 = data [ 186 ] } v 187 := 0 if 187 < len ( data ) { v 187 = data [ 187 ] } v 188 := 0 if 188 < len ( data ) { v 188 = data [ 188 ] } v 189 := 0 if 189 < len ( data ) { v 189 = data [ 189 ] } v 190 := 0 if 190 < len ( data ) { v 190 = data [ 190 ] } v 191 := 0 if 191 < len ( data ) { v 191 = data [ 191 ] } v 192 := 0 if 192 < len ( data ) { v 192 = data [ 192 ] } v 193 := 0 if 193 < len ( data ) { v 193 = data [ 193 ] } v 194 := 0 if 194 < len ( data ) { v 194 = data [ 194 ] } v 195 := 0 if 195 < len ( data ) { v 195 = data [ 195 ] } v 196 := 0 if 196 < len ( data ) { v 196 = data [ 196 ] } v 197 := 0 if 197 < len ( data ) { v 197 = data [ 197 ] } v 198 := 0 if 198 < len ( data ) { v 198 = data [ 198 ] } v 199 := 0 if 199 < len ( data ) { v 199 = data [ 199 ]"
|
||||
},
|
||||
{
|
||||
"aliases": null,
|
||||
"block_ids": [
|
||||
"5d269745b2e5dbdcbef0c09ba54b0bd6"
|
||||
],
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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 {
|
||||
@@ -1302,6 +1326,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 +1452,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,
|
||||
|
||||
@@ -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,
|
||||
@@ -595,6 +596,8 @@ impl UiCfg {
|
||||
#[serde(default)]
|
||||
pub struct IngestCfg {
|
||||
pub code: IngestCodeCfg,
|
||||
#[serde(default)]
|
||||
pub expansion: IngestExpansionCfg,
|
||||
}
|
||||
|
||||
/// p10-1A-1: settings for the code ingest pipeline. All fields have
|
||||
@@ -635,11 +638,39 @@ impl Default for IngestCodeCfg {
|
||||
}
|
||||
}
|
||||
|
||||
/// doc-side expansion config. Default: disabled (requires explicit opt-in).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct IngestExpansionCfg {
|
||||
/// Whether doc-side alias expansion is enabled during ingest.
|
||||
pub enabled: bool,
|
||||
/// Ollama model used for alias generation (empty = use LLM default).
|
||||
pub model: String,
|
||||
/// Maximum aliases generated per chunk.
|
||||
pub max_aliases_per_chunk: usize,
|
||||
/// Prompt template version tag.
|
||||
pub prompt_version: String,
|
||||
/// Whether alias embeddings are stored as separate dense vectors.
|
||||
pub embed_aliases: bool,
|
||||
}
|
||||
|
||||
impl Default for IngestExpansionCfg {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
model: String::new(),
|
||||
max_aliases_per_chunk: 8,
|
||||
prompt_version: "expansion-v1".to_string(),
|
||||
embed_aliases: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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![
|
||||
@@ -1119,6 +1150,25 @@ impl Config {
|
||||
self.pdf.ocr.lang_hint = if v.is_empty() { None } else { Some(v.clone()) };
|
||||
}
|
||||
|
||||
// ingest.expansion
|
||||
"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();
|
||||
}
|
||||
"KEBAB_INGEST_EXPANSION_EMBED_ALIASES" => {
|
||||
self.ingest.expansion.embed_aliases = parse_bool(v);
|
||||
}
|
||||
|
||||
// Unknown KEBAB_* keys are silently ignored — see
|
||||
// `env_unknown_key_is_ignored` test.
|
||||
_ => {}
|
||||
@@ -1846,6 +1896,42 @@ max_context_tokens = 8000
|
||||
let cfg: Config = toml::from_str(&toml_text).unwrap();
|
||||
assert_eq!(cfg.ingest.code.max_file_bytes, 524_288);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expansion_defaults_off() {
|
||||
let cfg = Config::defaults();
|
||||
assert!(!cfg.ingest.expansion.enabled);
|
||||
assert_eq!(cfg.ingest.expansion.max_aliases_per_chunk, 8);
|
||||
assert_eq!(cfg.ingest.expansion.prompt_version, "expansion-v1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expansion_env_override() {
|
||||
let mut env = HashMap::new();
|
||||
env.insert("KEBAB_INGEST_EXPANSION_ENABLED".into(), "true".into());
|
||||
env.insert("KEBAB_INGEST_EXPANSION_MODEL".into(), "gemma3:4b".into());
|
||||
env.insert("KEBAB_INGEST_EXPANSION_MAX_ALIASES".into(), "12".into());
|
||||
env.insert("KEBAB_INGEST_EXPANSION_PROMPT_VERSION".into(), "expansion-v2".into());
|
||||
let c = Config::defaults().apply_env(&env);
|
||||
assert!(c.ingest.expansion.enabled);
|
||||
assert_eq!(c.ingest.expansion.model, "gemma3:4b");
|
||||
assert_eq!(c.ingest.expansion.max_aliases_per_chunk, 12);
|
||||
assert_eq!(c.ingest.expansion.prompt_version, "expansion-v2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embed_aliases_defaults_off() {
|
||||
let cfg = Config::defaults();
|
||||
assert!(!cfg.ingest.expansion.embed_aliases);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embed_aliases_env_override() {
|
||||
let mut env = HashMap::new();
|
||||
env.insert("KEBAB_INGEST_EXPANSION_EMBED_ALIASES".into(), "true".into());
|
||||
let c = Config::defaults().apply_env(&env);
|
||||
assert!(c.ingest.expansion.embed_aliases);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
399
crates/kebab-config/src/migrate.rs
Normal file
399
crates/kebab-config/src/migrate.rs
Normal file
@@ -0,0 +1,399 @@
|
||||
//! 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.expansion`, `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).",
|
||||
"ingest.expansion" => "# doc-side 별칭 확장(기본 off). 패러프레이즈 강건성↑, LLM 비용 큼.",
|
||||
"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.expansion]",
|
||||
"[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 는 code 만 있고 expansion 누락(v0.21.0 동기 시나리오),
|
||||
// logging 통째 누락, score 는 사용자가 바꿈, 주석 보유.
|
||||
let user_text = "\
|
||||
schema_version = 1
|
||||
|
||||
[workspace]
|
||||
root = \"/my/notes\" # 내 워크스페이스
|
||||
|
||||
[search]
|
||||
default_k = 25
|
||||
|
||||
[ingest.code]
|
||||
skip_generated_header = true
|
||||
";
|
||||
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] 에 expansion 만 주석과 함께 추가.
|
||||
assert!(out.contains("[ingest.expansion]"), "expansion 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("skip_generated_header = true"));
|
||||
// 새 섹션 주석 부착.
|
||||
assert!(out.contains("doc-side 별칭"));
|
||||
// 부분 존재 부모로 재귀해 leaf 경로를 기록.
|
||||
assert!(
|
||||
changes
|
||||
.iter()
|
||||
.any(|c| c.kind == ChangeKind::AddedSection && c.path == "ingest.expansion"),
|
||||
"changes: {changes:?}"
|
||||
);
|
||||
// 통째 누락 부모는 부모 경로로 한 번 기록.
|
||||
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.expansion]"));
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -28,4 +28,35 @@ pub struct Chunk {
|
||||
/// Bug #8 (한국어 2자 query) 해결을 위한 V009 cascade.
|
||||
#[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>,
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
|
||||
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};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -123,7 +123,29 @@ impl Retriever for LexicalRetriever {
|
||||
};
|
||||
|
||||
let conn = self.store.read_conn();
|
||||
let raw_rows = run_query(&conn, &match_str, self.snippet_words, filters, fetch_limit)?;
|
||||
let body_rows = run_query(&conn, &match_str, self.snippet_words, filters, fetch_limit)?;
|
||||
// doc-side expansion (V010): re-run the same query against the
|
||||
// `aliases` column of `chunk_aliases_fts`. Empty table → 0 rows →
|
||||
// `body_rows` unchanged (regression-safe). body wins; alias-only
|
||||
// chunks are appended so a term present only in a chunk's aliases
|
||||
// still enters the pool.
|
||||
//
|
||||
// Raw mode (`'...'`) is a body-FTS5 escape hatch and may reference
|
||||
// body-only columns (e.g. `heading_path : ...`) that don't exist on
|
||||
// `chunk_aliases_fts`. Running such an expression against the alias
|
||||
// table is a hard FTS5 error, so we skip the alias channel for raw
|
||||
// queries — they target the body intentionally.
|
||||
let alias_rows = if strip_single_quotes(query.text.trim()).is_some() {
|
||||
Vec::new()
|
||||
} else {
|
||||
match build_match_string_for_column(&query.text, "aliases") {
|
||||
Some(alias_match) => {
|
||||
run_alias_query(&conn, &alias_match, self.snippet_chars, fetch_limit)?
|
||||
}
|
||||
None => Vec::new(),
|
||||
}
|
||||
};
|
||||
let raw_rows = merge_body_alias(body_rows, alias_rows, fetch_limit);
|
||||
|
||||
let mut hits: Vec<SearchHit> = Vec::with_capacity(raw_rows.len().min(k));
|
||||
let mut rank: u32 = 0;
|
||||
@@ -206,6 +228,16 @@ impl Retriever for LexicalRetriever {
|
||||
/// match is scoped to the body column. FTS5's column-filter syntax
|
||||
/// accepts an arbitrary OR/AND sub-expression inside the parens.
|
||||
fn build_match_string(text: &str) -> Option<String> {
|
||||
build_match_string_for_column(text, "text")
|
||||
}
|
||||
|
||||
/// Column-parameterized variant of [`build_match_string`]. `column` is the
|
||||
/// FTS5 column-filter prefix the combined expression is scoped to — `"text"`
|
||||
/// for the body channel (`chunks_fts`) or `"aliases"` for the doc-side
|
||||
/// expansion channel (`chunk_aliases_fts`, V010). Raw mode (`'...'`) is still
|
||||
/// passed through verbatim without any column scoping, so an explicit
|
||||
/// user-supplied column filter is honored unchanged.
|
||||
fn build_match_string_for_column(text: &str, column: &str) -> Option<String> {
|
||||
let trimmed = text.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
@@ -242,7 +274,7 @@ fn build_match_string(text: &str) -> Option<String> {
|
||||
(Some(w), Some(a)) if w == a => w,
|
||||
(Some(w), Some(a)) => format!("({w}) OR ({a})"),
|
||||
};
|
||||
Some(format!("text : ({expression})"))
|
||||
Some(format!("{column} : ({expression})"))
|
||||
}
|
||||
|
||||
/// Return `Some(inner)` if `s` is wrapped in a matching pair of single
|
||||
@@ -480,6 +512,77 @@ fn row_from_sql(row: &Row<'_>) -> rusqlite::Result<RawRow> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Search the doc-side expansion channel (`chunk_aliases_fts`, V010) and
|
||||
/// build [`RawRow`]s with the **same 10-column shape** as [`run_query`] so
|
||||
/// `row_from_sql` / `build_hit` can be reused verbatim. The snippet is taken
|
||||
/// from the body (`substr(c.text, 1, ?)`) rather than the alias text so the
|
||||
/// rendered hit stays consistent with the body channel. When
|
||||
/// `chunk_aliases_fts` is empty (no chunk carries aliases) this returns 0
|
||||
/// rows, making the merge a no-op (regression-safe).
|
||||
///
|
||||
/// 1차는 filters 미적용 — body 채널이 필터를 적용하고, 별칭 경로는 pool 진입
|
||||
/// (회수)이 목적이다(측정 후 필요 시 filters 공유). `bm25(chunk_aliases_fts)`
|
||||
/// 오름차순 + `af.chunk_id` tie-break 로 결정적 순서.
|
||||
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 params: Vec<Box<dyn ToSql>> = vec![
|
||||
Box::new(snippet_chars as i64),
|
||||
Box::new(match_str.to_owned()),
|
||||
Box::new(i64::try_from(fetch_limit).unwrap_or(i64::MAX)),
|
||||
];
|
||||
let mut stmt = conn
|
||||
.prepare(sql)
|
||||
.context("kb-search lexical: prepare alias FTS5 statement")?;
|
||||
let rows = stmt
|
||||
.query_map(
|
||||
params_from_iter(params.iter().map(std::convert::AsRef::as_ref)),
|
||||
row_from_sql,
|
||||
)
|
||||
.context("kb-search lexical: execute alias FTS5 query")?;
|
||||
let mut out: Vec<RawRow> = Vec::new();
|
||||
for r in rows {
|
||||
out.push(r.context("kb-search lexical: read alias row")?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Merge body + alias rows: body rows first (already bm25-ordered), then
|
||||
/// any alias-only chunk (not already present in the body result) appended in
|
||||
/// alias-relevance order. Capped at `limit`. An empty `alias` slice leaves
|
||||
/// `body` unchanged, so an empty `chunk_aliases_fts` reproduces the
|
||||
/// pre-expansion behavior exactly.
|
||||
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
|
||||
}
|
||||
|
||||
// ── Hit construction ─────────────────────────────────────────────────────
|
||||
|
||||
fn build_hit(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -144,6 +144,42 @@ fn insert_chunk(
|
||||
.expect("insert chunk");
|
||||
}
|
||||
|
||||
/// Like [`insert_chunk`] but also writes the `chunks.aliases` column so the
|
||||
/// `chunk_aliases_ai` trigger (V010) mirrors the row into `chunk_aliases_fts`.
|
||||
/// `aliases=None` leaves the column NULL (trigger skips → no alias row).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn insert_chunk_with_aliases(
|
||||
conn: &Connection,
|
||||
chunk_id: &str,
|
||||
doc_id: &str,
|
||||
text: &str,
|
||||
heading_path: &[&str],
|
||||
section_label: Option<&str>,
|
||||
source_spans_json: &str,
|
||||
chunker_version: &str,
|
||||
aliases: Option<&str>,
|
||||
) {
|
||||
let heading_json = serde_json::to_string(heading_path).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, aliases
|
||||
) VALUES (?, ?, ?, ?, ?, ?, 0, ?, 'h', '[]', '2024-01-01T00:00:00Z', ?)",
|
||||
rusqlite::params![
|
||||
chunk_id,
|
||||
doc_id,
|
||||
text,
|
||||
heading_json,
|
||||
section_label,
|
||||
source_spans_json,
|
||||
chunker_version,
|
||||
aliases,
|
||||
],
|
||||
)
|
||||
.expect("insert chunk with aliases");
|
||||
}
|
||||
|
||||
/// Pad a short ID to the 32-hex shape kebab_core newtypes expect.
|
||||
fn id32(prefix: &str) -> String {
|
||||
let mut s = prefix.to_string();
|
||||
@@ -1253,3 +1289,87 @@ fn lexical_raw_mode_can_opt_into_heading_path_filter() {
|
||||
"raw-mode heading_path filter must hit the seeded chunk"
|
||||
);
|
||||
}
|
||||
|
||||
// ── doc-side expansion (V010) — body+alias merged search ──────────────────
|
||||
|
||||
/// pool-rescue core: a term present ONLY in `chunks.aliases` (not in the
|
||||
/// body) must still recall the chunk via the `chunk_aliases_fts` channel.
|
||||
/// Body is English ("backpropagation…"); the Korean term "역전파" lives only
|
||||
/// in the alias text, so the body `chunks_fts` MATCH alone would miss it.
|
||||
#[test]
|
||||
fn alias_only_term_recalls_chunk() {
|
||||
let env = Env::new();
|
||||
let conn = env.raw_conn();
|
||||
insert_document(&conn, &id32("d"), "notes/nn.md", "NN", "en", "primary", &[]);
|
||||
insert_chunk_with_aliases(
|
||||
&conn,
|
||||
&id32("c1"),
|
||||
&id32("d"),
|
||||
"backpropagation computes gradients",
|
||||
&["NN"],
|
||||
None,
|
||||
r#"[{"kind":"line","start":1,"end":1}]"#,
|
||||
"v1",
|
||||
Some("역전파\n신경망 오차 역전달"),
|
||||
);
|
||||
drop(conn);
|
||||
|
||||
let r = env.retriever();
|
||||
let hits = r
|
||||
.search(&SearchQuery {
|
||||
text: "역전파".to_string(),
|
||||
mode: SearchMode::Lexical,
|
||||
k: 10,
|
||||
filters: SearchFilters::default(),
|
||||
})
|
||||
.unwrap();
|
||||
assert!(
|
||||
hits.iter().any(|h| h.chunk_id.0 == id32("c1")),
|
||||
"별칭에만 있는 term 으로도 청크가 회수돼야 한다 (pool-rescue); got {:?}",
|
||||
hits.iter().map(|h| h.chunk_id.0.clone()).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression-safety: with every chunk's `aliases=NULL` the
|
||||
/// `chunk_aliases_fts` table is empty, so the alias channel yields 0 rows
|
||||
/// and the body search result is identical to the pre-expansion behavior.
|
||||
#[test]
|
||||
fn empty_aliases_table_matches_baseline() {
|
||||
let env = Env::new();
|
||||
let conn = env.raw_conn();
|
||||
insert_document(
|
||||
&conn,
|
||||
&id32("d"),
|
||||
"notes/own.md",
|
||||
"Own",
|
||||
"en",
|
||||
"primary",
|
||||
&[],
|
||||
);
|
||||
// aliases=None → no chunk_aliases_fts row; body channel only.
|
||||
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")),
|
||||
"aliases 빈 상태에서 본문 매칭 청크가 정상 회수돼야 한다 (회귀 안전)"
|
||||
);
|
||||
}
|
||||
|
||||
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
|
||||
@@ -106,8 +123,8 @@ impl kebab_core::DocumentStore for SqliteStore {
|
||||
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
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
tokenized_korean_text, aliases
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.map_err(StoreError::from)?;
|
||||
for chunk in chunks {
|
||||
@@ -136,6 +153,7 @@ impl kebab_core::DocumentStore for SqliteStore {
|
||||
block_ids,
|
||||
now,
|
||||
chunk.tokenized_korean_text.as_deref(),
|
||||
chunk.aliases.as_deref(),
|
||||
])
|
||||
.map_err(StoreError::from)?;
|
||||
}
|
||||
@@ -250,6 +268,7 @@ impl kebab_core::DocumentStore for SqliteStore {
|
||||
chunker_version: kebab_core::ChunkerVersion(row.chunker_version),
|
||||
policy_hash: row.policy_hash,
|
||||
tokenized_korean_text: row.tokenized_korean_text,
|
||||
aliases: None,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -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],
|
||||
|
||||
220
crates/kebab-store-sqlite/tests/chunk_aliases.rs
Normal file
220
crates/kebab-store-sqlite/tests/chunk_aliases.rs
Normal file
@@ -0,0 +1,220 @@
|
||||
//! V010 doc-side expansion: `put_chunks` 가 `chunk.aliases` 를 chunks.aliases
|
||||
//! 컬럼에 영속화하고, chunk_aliases_ai trigger 가 별도 `chunk_aliases_fts`
|
||||
//! 가상 테이블로 mirror 하는지 검증.
|
||||
//!
|
||||
//! `put_chunks` 는 store-owned conn(FK ON)에서 도므로 chunks 의
|
||||
//! `doc_id REFERENCES documents(doc_id)` FK 를 만족시키려면 asset +
|
||||
//! document 그래프가 먼저 있어야 한다. 헬퍼는 `idempotency.rs` 패턴 복제.
|
||||
//! 인덱싱 검증은 side-channel `env.with_conn` 으로 chunk_aliases_fts 를 직접
|
||||
//! MATCH 한다(같은 established 패턴).
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use kebab_core::{
|
||||
AssetId, AssetStorage, Block, CanonicalDocument, Checksum, Chunk, ChunkerVersion, CommonBlock,
|
||||
DocumentId, DocumentStore, HeadingBlock, Lang, MediaType, Metadata, ParserVersion, Provenance,
|
||||
SourceSpan, SourceType, SourceUri, TextBlock, TrustLevel, WorkspacePath,
|
||||
};
|
||||
use kebab_store_sqlite::SqliteStore;
|
||||
use time::OffsetDateTime;
|
||||
|
||||
mod common;
|
||||
|
||||
fn make_asset() -> kebab_core::RawAsset {
|
||||
let bytes = b"dummy";
|
||||
kebab_core::RawAsset {
|
||||
asset_id: AssetId("a".repeat(32)),
|
||||
source_uri: SourceUri::File(PathBuf::from("/tmp/foo.md")),
|
||||
workspace_path: WorkspacePath::new("notes/foo.md".into()).unwrap(),
|
||||
media_type: MediaType::Markdown,
|
||||
byte_len: bytes.len() as u64,
|
||||
checksum: Checksum(blake3::hash(bytes).to_hex().to_string()),
|
||||
discovered_at: OffsetDateTime::from_unix_timestamp(1_700_000_000).unwrap(),
|
||||
stored: AssetStorage::Reference {
|
||||
path: PathBuf::from("/tmp/foo.md"),
|
||||
sha: Checksum(blake3::hash(bytes).to_hex().to_string()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn make_metadata() -> Metadata {
|
||||
Metadata {
|
||||
aliases: vec![],
|
||||
tags: vec![],
|
||||
created_at: OffsetDateTime::from_unix_timestamp(1_700_000_000).unwrap(),
|
||||
updated_at: OffsetDateTime::from_unix_timestamp(1_700_000_000).unwrap(),
|
||||
source_type: SourceType::Markdown,
|
||||
trust_level: TrustLevel::Primary,
|
||||
user_id_alias: None,
|
||||
user: Default::default(),
|
||||
repo: None,
|
||||
git_branch: None,
|
||||
git_commit: None,
|
||||
code_lang: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn make_doc() -> CanonicalDocument {
|
||||
let doc_id = DocumentId("d".repeat(32));
|
||||
let span = SourceSpan::Line { start: 1, end: 1 };
|
||||
let block = Block::Heading(HeadingBlock {
|
||||
common: CommonBlock {
|
||||
block_id: kebab_core::BlockId("b".repeat(32)),
|
||||
heading_path: vec![],
|
||||
source_span: span.clone(),
|
||||
},
|
||||
level: 1,
|
||||
text: "Title".into(),
|
||||
});
|
||||
let para = Block::Paragraph(TextBlock {
|
||||
common: CommonBlock {
|
||||
block_id: kebab_core::BlockId("c".repeat(32)),
|
||||
heading_path: vec!["Title".into()],
|
||||
source_span: span,
|
||||
},
|
||||
text: "body".into(),
|
||||
inlines: vec![],
|
||||
});
|
||||
CanonicalDocument {
|
||||
doc_id,
|
||||
source_asset_id: AssetId("a".repeat(32)),
|
||||
workspace_path: WorkspacePath::new("notes/foo.md".into()).unwrap(),
|
||||
title: "Title".into(),
|
||||
lang: Lang("en".into()),
|
||||
blocks: vec![block, para],
|
||||
metadata: make_metadata(),
|
||||
provenance: Provenance { events: vec![] },
|
||||
parser_version: ParserVersion("test-parser".into()),
|
||||
schema_version: 1,
|
||||
doc_version: 1,
|
||||
last_chunker_version: None,
|
||||
last_embedding_version: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 단일 청크 생성. `aliases` 만 호출측이 지정.
|
||||
fn base_chunk(chunk_id: &str, doc_id: &DocumentId, aliases: Option<String>) -> Chunk {
|
||||
Chunk {
|
||||
chunk_id: kebab_core::ChunkId(chunk_id.into()),
|
||||
doc_id: doc_id.clone(),
|
||||
block_ids: vec![kebab_core::BlockId("b".repeat(32))],
|
||||
text: "Rust ownership and borrowing".into(),
|
||||
heading_path: vec!["Title".into()],
|
||||
source_spans: vec![SourceSpan::Line { start: 1, end: 1 }],
|
||||
token_estimate: 5,
|
||||
chunker_version: ChunkerVersion("md-heading-v1".into()),
|
||||
policy_hash: "h".into(),
|
||||
tokenized_korean_text: None,
|
||||
aliases,
|
||||
}
|
||||
}
|
||||
|
||||
/// asset + document 그래프를 깔고 마이그레이션된 store 를 돌려준다.
|
||||
fn open_store_with_document(env: &common::TestEnv) -> SqliteStore {
|
||||
let store = SqliteStore::open(&env.config()).unwrap();
|
||||
store.run_migrations().unwrap();
|
||||
store.put_asset(&make_asset()).expect("put_asset");
|
||||
store.put_document(&make_doc()).expect("put_document");
|
||||
store
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aliases_indexed_into_chunk_aliases_fts() {
|
||||
let env = common::TestEnv::new();
|
||||
let store = open_store_with_document(&env);
|
||||
let doc = DocumentId("d".repeat(32));
|
||||
let chunk = base_chunk(
|
||||
&"e".repeat(32),
|
||||
&doc,
|
||||
Some("메모리 안전성\nwho owns the value".into()),
|
||||
);
|
||||
store.put_chunks(&doc, &[chunk]).unwrap();
|
||||
|
||||
// 별칭에만 있는 한국어 term 으로 chunk_aliases_fts 검색 → 청크 회수.
|
||||
let n: i64 = env.with_conn(|c| {
|
||||
c.query_row(
|
||||
"SELECT count(*) FROM chunk_aliases_fts \
|
||||
WHERE chunk_aliases_fts MATCH 'aliases : (\"메모리\")'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
});
|
||||
assert_eq!(
|
||||
n, 1,
|
||||
"aliases 의 한국어 term 이 chunk_aliases_fts 에 색인돼야 한다"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn none_aliases_not_indexed() {
|
||||
let env = common::TestEnv::new();
|
||||
let store = open_store_with_document(&env);
|
||||
let doc = DocumentId("d".repeat(32));
|
||||
let chunk = base_chunk(&"e".repeat(32), &doc, None);
|
||||
store.put_chunks(&doc, &[chunk]).unwrap();
|
||||
|
||||
let n: i64 = env.with_conn(|c| {
|
||||
c.query_row("SELECT count(*) FROM chunk_aliases_fts", [], |r| r.get(0))
|
||||
});
|
||||
assert_eq!(
|
||||
n, 0,
|
||||
"aliases=None 이면 chunk_aliases_fts 에 행이 없어야 한다"
|
||||
);
|
||||
}
|
||||
|
||||
/// Task 2 리뷰 M2: 같은 doc 을 두 번 `put_chunks` 해도 `chunk_aliases_fts`
|
||||
/// 행이 중복되지 않아야 한다. put_chunks 의 DELETE-then-INSERT 가
|
||||
/// chunk_aliases_ad → chunk_aliases_ai 를 발화해 멱등 재동기화하는지 검증.
|
||||
#[test]
|
||||
fn reput_keeps_single_alias_row() {
|
||||
let env = common::TestEnv::new();
|
||||
let store = open_store_with_document(&env);
|
||||
let doc = DocumentId("d".repeat(32));
|
||||
let mk = || base_chunk(&"e".repeat(32), &doc, Some("메모리 안전성".into()));
|
||||
|
||||
store.put_chunks(&doc, &[mk()]).unwrap();
|
||||
store.put_chunks(&doc, &[mk()]).unwrap(); // 같은 doc 재-put
|
||||
|
||||
let n: i64 = env.with_conn(|c| {
|
||||
c.query_row("SELECT count(*) FROM chunk_aliases_fts", [], |r| r.get(0))
|
||||
});
|
||||
assert_eq!(n, 1, "재색인 후에도 별칭 행은 1개여야 한다 (중복/누락 없음)");
|
||||
}
|
||||
|
||||
/// Task 2 리뷰 N1: 별칭 term 이 본문 `chunks_fts` 로 새지 않아야 한다(§3.3 격리).
|
||||
/// 본문엔 없고 별칭에만 있는 한국어 term 으로 chunks_fts 를 MATCH 하면 0행.
|
||||
#[test]
|
||||
fn aliases_dont_leak_into_body_fts() {
|
||||
let env = common::TestEnv::new();
|
||||
let store = open_store_with_document(&env);
|
||||
let doc = DocumentId("d".repeat(32));
|
||||
// 본문 "Rust ownership and borrowing" 에 "메모리" 없음, 별칭에만 있음.
|
||||
let chunk = base_chunk(&"e".repeat(32), &doc, Some("메모리 안전성".into()));
|
||||
store.put_chunks(&doc, &[chunk]).unwrap();
|
||||
|
||||
let body_hits: i64 = env.with_conn(|c| {
|
||||
c.query_row(
|
||||
"SELECT count(*) FROM chunks_fts WHERE chunks_fts MATCH 'text : (\"메모리\")'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
});
|
||||
assert_eq!(body_hits, 0, "별칭 term 이 본문 chunks_fts 로 누출되면 안 된다");
|
||||
}
|
||||
|
||||
/// Task 2 리뷰 M1: 빈 문자열 별칭은 색인하지 않는다(trigger 가드
|
||||
/// `AND new.aliases <> ''`). producer 가 Some("") 를 넘겨도 무용한 행이
|
||||
/// chunk_aliases_fts 에 쌓이지 않아야 한다.
|
||||
#[test]
|
||||
fn empty_string_alias_not_indexed() {
|
||||
let env = common::TestEnv::new();
|
||||
let store = open_store_with_document(&env);
|
||||
let doc = DocumentId("d".repeat(32));
|
||||
let chunk = base_chunk(&"e".repeat(32), &doc, Some(String::new()));
|
||||
store.put_chunks(&doc, &[chunk]).unwrap();
|
||||
|
||||
let n: i64 = env.with_conn(|c| {
|
||||
c.query_row("SELECT count(*) FROM chunk_aliases_fts", [], |r| r.get(0))
|
||||
});
|
||||
assert_eq!(n, 0, "빈 문자열 별칭은 chunk_aliases_fts 에 색인되면 안 된다");
|
||||
}
|
||||
@@ -20,26 +20,26 @@ 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`.
|
||||
#[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 +52,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);
|
||||
}
|
||||
|
||||
340
crates/kebab-store-sqlite/tests/embedding_records_fk.rs
Normal file
340
crates/kebab-store-sqlite/tests/embedding_records_fk.rs
Normal file
@@ -0,0 +1,340 @@
|
||||
//! 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,
|
||||
aliases: 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,
|
||||
aliases: 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)"
|
||||
);
|
||||
}
|
||||
@@ -98,6 +98,7 @@ fn make_chunks(doc_id: &DocumentId) -> Vec<Chunk> {
|
||||
chunker_version: ChunkerVersion("md-heading-v1".into()),
|
||||
policy_hash: "deadbeefdeadbeef".into(),
|
||||
tokenized_korean_text: None,
|
||||
aliases: None,
|
||||
}]
|
||||
}
|
||||
|
||||
|
||||
@@ -114,6 +114,7 @@ fn make_chunk() -> Chunk {
|
||||
chunker_version: ChunkerVersion("md-heading-v1".into()),
|
||||
policy_hash: "deadbeefdeadbeef".into(),
|
||||
tokenized_korean_text: None,
|
||||
aliases: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ Cargo workspace, 함수 호출 기반 모듈러 모놀리스. UI binary (`kebab-
|
||||
| 원본 저장 | filesystem + blake3 content-addressable copy (대용량은 reference + checksum) |
|
||||
| metadata | SQLite + FTS5 (lexical search + v0.20.1 한국어 형태소 tokenizer via lindera-ko-dic) |
|
||||
| vector | LanceDB (embedded, model 별 분리 table) |
|
||||
| Markdown parser | `pulldown-cmark` |
|
||||
| Markdown parser | `pulldown-cmark`. frontmatter 에 title 없으면 첫 H1 → H2 → 첫 paragraph 80 자 → 파일명 순으로 자동 채움 (`parser_version = md-frontmatter-v2`, 기존 doc 도 다음 ingest 에서 갱신) |
|
||||
| embedding | `fastembed-rs` (`multilingual-e5-large`, 1024d, v0.18.0부터 default 업그레이드) |
|
||||
| 한국어 형태소분석 | `lindera-ko-dic` (FTS5 외부 tokenizer, v0.20.1) — 2자 이상 한국어 query 지원 |
|
||||
| LLM | Ollama HTTP (default `gemma4:e4b` ─ OCR / caption 와 family 통일. 사용자가 더 큰 variant `gemma4:26b` 등으로 override 가능) |
|
||||
@@ -23,14 +23,17 @@ Cargo workspace, 함수 호출 기반 모듈러 모놀리스. UI binary (`kebab-
|
||||
| OCR (image) | Ollama vision LM (default `gemma4:e4b`) — `OcrEngine` trait 으로 Tesseract / Apple Vision 등 future swap (HOTFIXES P6-2) |
|
||||
| OCR (PDF, v0.20.0+) | Ollama vision LM (default `qwen2.5vl:3b`) — post-extract enrichment via `kebab-app::pdf_ocr_apply` (H-1 resolution). DCTDecode-only v1 (FlateDecode/CCITTFax skip + warning). family asymmetry vs image OCR: PoC alnum 94.79% (qwen2.5vl) >> 27% (gemma4:e4b 받침), 본 단계에서 PDF OCR 만 qwen2.5vl. |
|
||||
| Image caption | Ollama vision LM, runtime gate `image.caption.enabled` (default OFF) |
|
||||
| RAG groundedness 검증 | `kebab-nli` 의 mDeBERTa-v3 XNLI 가 `(packed_chunks, generated_answer)` entailment 검사 (fb-41). `[rag] nli_threshold > 0` (default 0 = disabled, production 권장 0.5) 일 때 활성 — 미달 시 `refusal_reason = nli_verification_failed` (LLM self-judge ceiling 보완). 첫 호출 시 ~280 MB ONNX 자동 다운로드 |
|
||||
| PDF parser | `lopdf` per-page 텍스트 + scanned-page image extract (`page_image::extract_dctdecode_page_image`, v0.20.0). `chunker_version = "pdf-page-v1"` 하드코딩 (HOTFIXES P7-3). `parser_version = "pdf-text-v1"` 보존 (v0.20 OCR 후에도) — provenance event 로 OCR 사용 차별화. force-reingest 가 v0.19 indexed scanned PDF 의 재처리에 필요. |
|
||||
| code parser | `tree-sitter` + `tree-sitter-rust` / `tree-sitter-python` / `tree-sitter-typescript` / `tree-sitter-javascript` / `tree-sitter-go` / `tree-sitter-java` / `tree-sitter-kotlin-ng` — **parser-side** (`kebab-parse-code`), chunker-side 아님 (design §6.3). chunker versions: Rust = `code-rust-ast-v1`, Python = `code-python-ast-v1`, TypeScript = `code-ts-ast-v1`, JavaScript = `code-js-ast-v1`, Go = `code-go-ast-v1`, Java = `code-java-ast-v1`, Kotlin = `code-kotlin-ast-v1`. `ast_chunk_max_lines = 200` 상수 고정 (HOTFIXES 2026-05-19 — Chunker trait 이 per-medium config 미노출). Kotlin grammar 은 `tree-sitter-kotlin-ng` 사용 — bare `tree-sitter-kotlin` 은 tree-sitter 0.21–0.23 에 고착되어 있어 사용 불가. **Tier 2 (p10-2)**: YAML/k8s → `serde_yaml` + `k8s-manifest-resource-v1` (apiVersion+kind per resource), Dockerfile → `dockerfile-file-v1` (whole-file), Cargo.toml/go.mod/.json/.xml/.groovy → `manifest-file-v1` (whole-file). Tier 2 chunkers live in `kebab-chunk`; no tree-sitter grammar needed (structure from file type, not AST). **Tier 3 (p10-3)**: shell scripts (`.sh`/`.bash`/`.zsh`) direct → `code-text-paragraph-v1` (blank-line paragraph segmentation + 80-line / 20-overlap line-window for oversize). Same chunker also serves as fallback when Tier 1/2 emit 0 chunks or Err — non-k8s YAML / invalid YAML / AST extractor failures all picked up. symbol = None; lang preserved from input doc. **Tier 1 family complete (p10-1D)**: C (`tree-sitter-c`, `code-c-ast-v1`, `.c`/`.h`) + C++ (`tree-sitter-cpp`, `code-cpp-ast-v1`, `.cpp`/`.cc`/`.cxx`/`.hpp`/`.hh`/`.hxx`). C symbol = function name only; C++ symbol = `namespace::Class::method` (recursive nesting). `.h` 가 C++ syntax 만나면 tree-sitter-c parse 실패 → Tier 3 fallback. |
|
||||
| 1B symbol path | workspace path → module path: Python = dotted prefix (`kebab_eval.metrics.compute_mrr`), TypeScript/JavaScript = slash-style prefix (`src/Foo.Foo.search`). Rust 1A-2 는 file-scope nesting 만 (workspace prefix 없음, 비일관 수용 — HOTFIXES 2026-05-20). |
|
||||
| TUI | Ratatui + crossterm — P9-1 Library 패널, P9-2/3/4 진행 예정 |
|
||||
| symbol path 형식 | workspace path → module path: Python = dotted prefix (`kebab_eval.metrics.compute_mrr`), TypeScript/JavaScript = slash-style prefix (`src/Foo.Foo.search`), Go = `package.Func` / `package.(*Receiver).Method`, Java/Kotlin = `com.foo.Foo.bar` (패키지+클래스+메서드/필드), C = 함수명, C++ = `namespace::Class::method`. Rust 1A-2 는 file-scope nesting 만 (workspace prefix 없음, 비일관 수용 — HOTFIXES 2026-05-20). code chunk 은 `citation.kind = "code"` + `citation.lang` + `symbol` + line range, SearchHit 에 `code_lang` + `repo`(`.git` walk-up 디렉토리명) backfill. |
|
||||
| TUI | Ratatui + crossterm — Library / Search / Ask / Inspect 패널 (P9-1~4 완료), vim-style NORMAL/INSERT 모드 + `F1` cheatsheet (런타임 키 매핑 권위 소스) |
|
||||
| Desktop | Tauri 2 + `pdfjs-dist` (native PDF render backend 금지) — P9-5 |
|
||||
| citation 형식 | URI fragment (`path#L12-L34` / `path#p=12` / `path#xywh=0,0,100,50`, W3C Media Fragments) |
|
||||
| ID 생성 | `blake3(canonical_json(tuple))[..32]` hex |
|
||||
| RRF fusion_score | `[0, 1]` 정규화 — `2 / (k_rrf + 1)` 로 나눠 mode 간 비교 가능 (post-merge hotfix) |
|
||||
| doc-side expansion 별칭 (v0.21.0) | 색인 시 LLM 이 청크별 "같은 의미 다른 표현" 별칭 생성. 별칭은 줄별 **개별 dense 벡터**(sentinel `{chunk}#alias#N`)로 색인하고 본문 벡터는 그대로 둠 (묶음 1벡터는 평균화로 희석 → 회귀, HOTFIXES 2026-05-31). boilerplate 청크는 별칭 skip. 검색 시 별칭 hit 는 `kebab-core::strip_alias_suffix` 로 원본 chunk_id 에 매핑. `[ingest.expansion]` default off (opt-in, 청크당 LLM 비용). |
|
||||
| 파생물 캐시 `derivation_cache` (V012, v0.21.0) | 비싼 ingest 파생물(embedding 벡터 / 별칭 LLM 결과)을 청크 **내용 해시** 키로 SQLite 에 캐싱 → 재색인 시 내용 불변 청크는 재계산 skip. `cache_key = blake3(kind ‖ text_blake3 ‖ version_key)[:32]`; version_key 에 model/prompt/dimensions 포함 → §9 cascade 와 정합(버전 bump 시 자동 miss). 위치 기반 `chunk_id` 와 달리 내용이 같으면 문서·위치 무관 동일 키. 순수 가산 — `corpus_revision` bump 안 함, 손상/삭제돼도 정확성 영향 0(miss → 재계산). search/ask 는 `kebab.sqlite`+`lancedb` 만으로 동작하므로 외부 서버 색인 후 DB 만 복사하는 이식 워크플로 가능 (HOTFIXES 2026-05-31). |
|
||||
| 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 참조.
|
||||
@@ -161,7 +164,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,7 +177,7 @@ 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-store-vector/ # LanceDB VectorStore (P3-3, P7-3 follow-up)
|
||||
@@ -185,11 +188,11 @@ kebab/
|
||||
│ ├── kebab-parse-image/ # ImageExtractor + Ollama OCR + 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/expansion.rs = 별칭 생성, 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.expansion]`·`[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
|
||||
|
||||
@@ -690,6 +690,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.expansion]`·`[logging]` 등)과 제거될 `workspace.include` 를 출력하고 **파일을 수정하지 않는다**. 적용 시 `old.toml.bak`(원본과 동일)이 생기고 빠진 섹션이 주석과 함께 추가되며 사용자가 손본 값·주석은 보존된다. 재실행은 멱등(`config 이미 최신입니다`), `--json` 은 `config_migration.v1`.
|
||||
|
||||
## 정리
|
||||
|
||||
```bash
|
||||
|
||||
@@ -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
@@ -0,0 +1,102 @@
|
||||
---
|
||||
title: 어휘격차(vocabulary-gap) pool-miss 해결 — 딥리서치 레퍼런스
|
||||
date: 2026-05-30
|
||||
type: research-reference
|
||||
provenance:
|
||||
- "deep-research 워크플로 (wf_e76011c6-de8): 5 angle, 22 sources fetch, 103 claims 추출, 25 claim 3-vote 적대적 검증, 22 confirmed / 3 killed. 104 agent, ~3.5M subagent tokens."
|
||||
related:
|
||||
- docs/superpowers/handoffs/2026-05-29-query-paraphrase-robustness-phase1-handoff.md (B 어휘격차 우세 진단)
|
||||
- docs/superpowers/research/2026-05-29-crossscript-synonym-retrieval-research.md (선행 — rerank 결정 중심)
|
||||
- memory: project_paraphrase_robustness, project_crossscript_diagnosis
|
||||
---
|
||||
|
||||
# 어휘격차 pool-miss 해결 — 딥리서치 레퍼런스
|
||||
|
||||
> Phase 1 진단(변형 일관성 측정)에서 **B(어휘격차) 우세** 확인 — 같은 의미를 다른 단어로 물으면
|
||||
> 정답이 top-50 pool 에도 안 들어옴(recall@50=0), rerank 불가. 그 실패 모드 전용 처방을 조사.
|
||||
|
||||
## 0. 질문 (요약)
|
||||
|
||||
CPU-only 로컬 RAG(FTS5/BM25 + LanceDB dense e5-large + RRF, Rust/fastembed-rs/LanceDB) 에서
|
||||
"같은 의미 다른 표현(동의어·풀어쓴 문장·한/영)이 top-50 pool 에도 못 들어오는" 실패를 가장
|
||||
좋은 비용대비로 고치는 법. 제약: per-query LLM 확장 거부("밑 빠진 독"), e5-large 유지(bge-m3
|
||||
dense 는 실측 더 나빴음), 코드 식별자 정확매칭 보존.
|
||||
|
||||
## 1. 적대적 검증 통과 결론 (confirmed)
|
||||
|
||||
### 1.1 색인시 doc-side expansion(doc2query/docTTTTTquery)이 pool-miss 의 최선책 (3-0)
|
||||
- **유일하게 lexical pool 자체를 키운다**(rerank 아님): docTTTTTquery README — MS MARCO doc
|
||||
Recall@1000 0.9180→0.9490(rerank 없는 1차 BM25 지표 → pool 진입 증가 실증). passage MRR@10
|
||||
18.6→27.2, **per-query +9ms 만**(55→64ms, T5 추론은 색인시 1회·query 무영향).
|
||||
- **메커니즘이 어휘격차 정조준**: SIGIR 2024 — N개 예측 query append 가 없던 term 주입(TF↑),
|
||||
Doc2Query model card "generated queries contain synonyms → close the lexical gap".
|
||||
- **Doc2Query++(2510.09557, 2025-10)**: 5개 BEIR 에서 sparse·dense 둘 다 **Recall@100** 개선
|
||||
(SCIDOCS 0.3323→0.3749, FiQA 0.5864→0.6197). Recall@100=pool-membership → pool 확장 확인.
|
||||
- 출처: github.com/castorini/docTTTTTquery, doc2query/msmarco-14langs-mt5-base-v1, mzzm24-sigir, 2510.09557.
|
||||
|
||||
### 1.2 ⚠️ vanilla 다국어 doc2query(mt5)는 한/영을 못 잇는다 (3-0)
|
||||
- model card: "input passage 의 **같은 언어**로 query 생성". mMARCO 14개 언어에 **한국어 없음**.
|
||||
- 귀결: doc2query 단독은 영어 paraphrase·동의어 pool-miss(raft "how nodes agree…")는 고치나,
|
||||
**KO↔EN 갭(역전파→영어 backprop doc)은 못 고침** → 색인시 *교차언어* 대체 query 생성이 추가로 필요.
|
||||
|
||||
### 1.3 MILCO(교차언어 learned-sparse)가 한/영 갭 직접 해결, 단 배포 경로 없음 (3-0)
|
||||
- 2510.00671(2025-10): query·doc 를 "공유 English lexical space"로 매핑. MKQA(한국어 포함)
|
||||
zero-shot R@100 **76.6**(BGE-M3-Sparse +69%, BM25 +92%). **그러나 560M 연구모델, ONNX/
|
||||
fastembed-rs 체크포인트 미확인** → research signal, turnkey 아님. (0.61ms 는 index lookup 만, 인코딩 제외.)
|
||||
|
||||
### 1.4 BGE-M3 sparse 채널은 CPU-native 추가 가능, 단 한/영은 못 고침 (3-0)
|
||||
- 1 forward 로 dense+sparse+ColBERT 산출, fastembed-rs `BGEM3Q`(CPU 양자화, CUDA 주면 오히려 실패).
|
||||
- 단일언어 향상: MIRACL Dense+Sparse 68.9 > Dense 67.8 → **3rd RRF 채널 후보**.
|
||||
- **교차언어 약함**: BGE-M3 논문도 sparse cross-lingual MKQA 45.3 vs dense 67.8 "다른 언어라 공존 term
|
||||
거의 없음". → KO↔EN 갭엔 무용. ("sparse 가 모든 언어서 BM25 압도" 주장은 **0-3 기각**.)
|
||||
|
||||
### 1.5 turnkey SPLADE 는 새 corpus 에서 자동 해결 못 함 (3-0)
|
||||
- LSR 은 term expansion 으로 어휘격차 겨냥하나, SOTA(Echo-Mistral-SPLADE BEIR 55.07)는 **Mistral-7B
|
||||
로 학습**(무겁다). AACL 2022: "SPLADE 는 저빈도 단어 exact match 에 약함 + 어휘/빈도 domain shift
|
||||
시 성능 저하". → 개인 혼합 KO/EN corpus 에 drop-in 기대 금물, 코드 식별자 정확매칭도 약점.
|
||||
|
||||
### 1.6 query-side(HyDE, Vector-PRF)는 이 제약에 부적합 (confirmed)
|
||||
- **HyDE**: query 마다 LLM 이 가설답변 생성(1~5s/query) = 사용자가 거부한 "밑 빠진 독" 바로 그것.
|
||||
- **Vector-PRF**: per-query 생성은 피하나 2-pass 필요 + **recall 개선 주장 0-3 기각**(1.6/6.2/7.7%
|
||||
Recall@100 gain 전부 refute). → 이 실패 모드 해결 증거 없음.
|
||||
|
||||
## 2. 기각 (killed — 믿지 말 것)
|
||||
- "BGE-M3 sparse 가 모든 언어서 BM25 압도" (0-3).
|
||||
- "Vector-PRF 가 Recall@100 을 1.6~7.7% 올린다(pool 확장)" (0-3).
|
||||
- "Vector-PRF 가 여러 데이터셋서 dense 효과 개선" (0-3).
|
||||
|
||||
## 3. 권고 (minimal combination, medium conf — 합성/추론)
|
||||
|
||||
**(1) 색인시 doc2query-style 확장 → 별도 FTS5 lexical 필드** (원문 body 필드는 그대로 verbatim
|
||||
index → 코드 식별자 정확매칭 보존, append-not-replace). RRF 가 {body-BM25, expansion-BM25, e5-dense} 융합.
|
||||
**(2) 문서당 같은언어 query + 소수의 교차언어(KO↔EN) 대체 표현/번역**을 색인시 1회 생성(로컬 LLM
|
||||
= gemma, **per-query 아님 → 사용자 제약 충족**). 역전파→backprop doc 의 직접 해법.
|
||||
**(3) (선택) BGE-M3 sparse(fastembed-rs BGEM3Q)를 4th RRF 채널**로 단일언어 lift, e5-large dense 유지.
|
||||
필터(Doc2Query--/++ topic-coverage)로 환각·index 팽창 제어.
|
||||
|
||||
- 사용자 제약 충족: 색인시 1회(per-query LLM 아님) + e5-large 유지 + 정확매칭 보존.
|
||||
- 엄격 no-LLM 원하면 (2) 대신 seq2seq mt5 doc2query 로 폴백(단 한국어 미커버 → 한/영 갭 부분만 해결).
|
||||
|
||||
## 4. 미해결 질문 (= Phase 2 실험 설계, **기존 variant eval 로 측정 가능**)
|
||||
1. **색인시 KO↔EN 대체 query 생성이 우리 corpus 에서 recall@50 을 0→양수로 올리나?** — 핵심 미검증
|
||||
고리. `/build/dogfood` golden + `kebab eval variants` 로 직접 측정(또 프록시 금지).
|
||||
2. ONNX/fastembed 호환 교차언어 learned-sparse(MILCO 또는 distill) 체크포인트가 있나, 아니면
|
||||
교차언어는 전적으로 색인시 doc expansion 으로만 풀어야 하나.
|
||||
3. doc2query 가 개인 KB(수천 doc/수만 chunk)의 FTS5 index 를 얼마나 부풀리나. Doc2Query--/++ 필터
|
||||
가치 있나 vs plain mt5.
|
||||
4. e5 dense 유지하고 BGE-M3 **sparse 만** 추가 시 paraphrase/동의어 recall@50 순이득인가, 약한
|
||||
다국어 sparse 가 RRF 에 노이즈만 더하나.
|
||||
|
||||
## 5. 핵심 caveat (시점 민감)
|
||||
- 최강 교차언어 근거(MILCO, Doc2Query++)는 2025-10 단일 논문·저자 보고 벤치 — research signal.
|
||||
- **교차언어 권고(색인시 KO↔EN 생성)는 합성/추론** — "index-time LLM translation 이 한/영 recall@50
|
||||
갭을 닫는다"를 직접 벤치한 논문 없음. confirmed fact 들의 논리적 조합. → **우리 corpus 측정 필수**.
|
||||
- docTTTTTquery 의 MS MARCO recall 증가는 modest(+3.4% rel) — 순수 pool-rescue 크기는 우리 corpus 미검증.
|
||||
- 정확매칭 보존은 architectural 논증(별도 필드), 코드 corpus 직접 측정 아님.
|
||||
|
||||
## 6. 출처
|
||||
docTTTTTquery — github.com/castorini/docTTTTTquery · Doc2Query++ — arxiv 2510.09557 ·
|
||||
mt5 14-lang doc2query — hf.co/doc2query/msmarco-14langs-mt5-base-v1 · SIGIR2024 doc-exp — jmmackenzie.io/pdf/mzzm24-sigir.pdf ·
|
||||
MILCO — arxiv 2510.00671 · BGE-M3 — arxiv 2402.03216 · bge-m3-onnx — github.com/yuniko-software/bge-m3-onnx ·
|
||||
Mistral-SPLADE — arxiv 2408.11119 · SPLADE domain-shift — arxiv 2211.03988 · HyDE/PRF — arxiv 2511.19349, 2504.01448, 2108.11044 ·
|
||||
fastembed-rs — github.com/Anush008/fastembed-rs · KURE — github.com/nlpai-lab/KURE · arctic-embed-ko — hf.co/dragonkue/snowflake-arctic-embed-l-v2.0-ko
|
||||
@@ -0,0 +1,128 @@
|
||||
---
|
||||
title: Query-paraphrase robustness — 변형 일관성 평가 프레임워크 (측정 먼저)
|
||||
date: 2026-05-29
|
||||
status: design (approved-to-plan)
|
||||
related:
|
||||
- docs/superpowers/handoffs/2026-05-29-crossscript-rerank-progress-handoff.md
|
||||
- docs/superpowers/specs/2026-05-29-crossscript-rerank-experiment-design.md (선행 실험 — overlap 프록시의 한계)
|
||||
- memory: project_crossscript_diagnosis, project_rerank_experiment, project_ranking_deferred, feedback_search_quality_dogfood
|
||||
goal_reframe: "한/영 cross-script overlap → 같은 의미의 다양한 표현(동의어·다른 어휘·풀어쓴 문장·한/영)에서 일관되게 좋은 답"
|
||||
---
|
||||
|
||||
# Query-paraphrase robustness — 변형 일관성 평가 프레임워크
|
||||
|
||||
## 0. 한 문단 요약
|
||||
|
||||
같은 의미를 다른 표현(동의어, 다른 어휘, 풀어쓴 문장, 한국어/영어)으로 물어도 **답변 품질이
|
||||
일관되게 좋아야 한다**는 것이 목표다. 지난 cross-encoder reranker 실험은 "한/영 top-k 겹침
|
||||
(overlap)"이라는 **프록시 지표**를 최적화하다 헛돌았다 (full-chunk-text 까지 시도했으나 회귀가
|
||||
1:1 재현 — 가설 반증, 핸드오프 참조). 이번엔 처방을 만들기 전에 **진짜 지표(변형 간 답변 품질
|
||||
일관성)를 직접 재는 평가 프레임워크**를 먼저 만든다. 이 평가가 (A) "핵심 문서는 후보 풀에
|
||||
들어왔는데 순위만 출렁" 인지 (B) "다른 단어로 물으니 핵심 문서가 아예 후보에서 빠짐" 인지를
|
||||
숫자로 판별하고, 그 결과에 따라 처방(near-tie 흡수 vs 쿼리 확장)을 별도 spec 으로 확정한다.
|
||||
**본 spec 의 구현 범위는 Phase 1 (평가 프레임워크) 까지.** Phase 2 (처방) 는 측정 결과 게이트
|
||||
뒤의 조건부 설계다.
|
||||
|
||||
## 1. 진단 근거 (왜 측정 먼저인가)
|
||||
|
||||
- **확정된 근본 원인** ([[project_crossscript_diagnosis]]): vector near-tie 불안정 — 상위 후보들의
|
||||
cosine 점수가 Δ0.003~0.005 로 다닥다닥 붙어, "top-k" 라는 칼같은 cutoff 가 near-tie 뭉치
|
||||
한가운데를 지나면 표현 차이(동의어/한영)가 핵심 문서를 9등↔11등으로 흔든다.
|
||||
- **사용자 실제 불편** (2026-05-29 brainstorm): "한쪽 답이 나쁘다" — 겹침이 아니라 **답변 품질
|
||||
비대칭**이 핵심. 어느 쪽이 나쁠지는 **쿼리마다 다름** → 특정 언어의 구조적 결함이 아니라
|
||||
near-tie 불안정이 표현마다 다르게 발현.
|
||||
- **선행 실험의 교훈** ([[project_rerank_experiment]]): overlap 프록시 최적화 → cross-encoder 가
|
||||
한/영 query 로 pool 을 독립 재정렬해 토픽별 수렴/발산. full-chunk-text 로도 database −4 등
|
||||
회귀가 그대로. **원인(near-tie)을 모르고 프록시를 최적화하면 또 헛돈다.** → 측정 선결.
|
||||
- **(A) vs (B) 미해결**: 표현이 바뀔 때 핵심 문서가 ① 후보 풀엔 있는데 순위만 밀린 건지(A,
|
||||
near-tie), ② 후보 풀에서 아예 빠진 건지(B, 어휘 격차) 모른다. 처방이 완전히 다르므로 먼저 측정.
|
||||
|
||||
## 2. 범위 (scope)
|
||||
|
||||
**Phase 1 (본 spec 구현 대상):**
|
||||
- `kebab-eval` 의 golden suite 에 **변형 그룹(intent group)** 개념 추가 — 같은 의도의 여러
|
||||
표현이 같은 정답(expected_doc_ids / expected_chunk_ids / must_contain)을 공유.
|
||||
- **변형 일관성 메트릭** 산출: 그룹 내 recall/답변정답 의 분산(spread)·최악값, 그리고
|
||||
recall@pool vs recall@k 대비로 (A)/(B) 자동 분류.
|
||||
- dogfood KB 에 큐레이션된 변형 그룹 ~6–10 개 (각 3–5 표현). 정답 문서는 **corpus 의미로 판정**
|
||||
(순환 회피, [[feedback_search_quality_dogfood]]).
|
||||
- 측정 실행 → (A)/(B) 진단 리포트 → Phase 2 결정 게이트.
|
||||
|
||||
**Phase 2 (조건부, 별도 spec — 본 구현 제외):**
|
||||
- (A) 우세 → near-tie 밴드 흡수 (cutoff 를 near-tie band 까지 확장; 검색 순서 불변, 저위험).
|
||||
- (B) 우세 → 쿼리 확장/번역 (로컬 LLM).
|
||||
- Phase 1 평가셋으로 처방 효과를 진짜 지표로 검증.
|
||||
|
||||
**비범위 (YAGNI):**
|
||||
- LLM-judge 기반 답변 채점. `must_contain`/`forbidden` substring groundedness 가 이미 있고
|
||||
Phase 1 진단엔 충분. 필요성은 Phase 1 결과가 정한다.
|
||||
- 임베딩 모델 교체(③). 전체 재임베딩 cascade 비용 + 효과 불확실 → 측정 후 최후 옵션.
|
||||
- ranking 파라미터 자동 조정 ([[project_ranking_deferred]] 와 충돌 — 처방은 명시적 flag/설정).
|
||||
|
||||
## 3. 구조 (크레이트 경계 — design §8 준수)
|
||||
|
||||
- **`kebab-eval` 단독 변경.** retrieval/embedding/LLM 크레이트 직접 import 금지 규칙 유지
|
||||
(runner 만 `kebab-app` facade 사용 — P5-1 상속).
|
||||
- `types.rs`: `GoldenQuery` 에 `group: Option<String>` 추가 (backward-compat — 기존 쿼리는
|
||||
`None`, 단독 그룹 취급). yaml 역직렬화 optional.
|
||||
- `metrics.rs`: 기존 per-query 집계는 불변. per_query 결과를 `group` 으로 묶는 **변형 일관성
|
||||
집계** 함수 신규 (`compute_variant_consistency` 류). 기존 `AggregateMetrics` 는 안 건드림.
|
||||
- `loader.rs`: 그룹 필드 로드 + 그룹 내 expected 정합성 검증(같은 그룹은 같은 expected 공유
|
||||
권장 — 경고/허용 정책은 plan 에서 확정).
|
||||
- 측정 실행/리포트: 기존 `eval` 경로 재사용 (`--with-rag` 로 답변까지). 신규 metric 은 JSON
|
||||
리포트 + 사람이 읽는 요약 (CLI surface 확정은 plan).
|
||||
|
||||
## 4. 데이터 흐름
|
||||
|
||||
```
|
||||
golden_queries.yaml (변형 그룹 포함)
|
||||
└─ loader → Vec<GoldenQuery>{group}
|
||||
└─ runner (eval --with-rag, mode=hybrid/vector) → per_query: {recall@k, answer.must_contain pass}
|
||||
├─ 기존 AggregateMetrics (전체 hit@k/MRR/recall) — 불변
|
||||
└─ NEW: group 으로 묶어 변형 일관성:
|
||||
· recall_spread@k = max−min recall@k (그룹 내) → 0 이면 완전 일관
|
||||
· worst_recall@k = min recall@k (약한 표현)
|
||||
· answer_consistency = 모든 변형이 must_contain 통과한 그룹 비율
|
||||
· A/B 분류: 변형별 (recall@pool_k high & recall@k low) → A(순위), recall@pool_k low → B(어휘)
|
||||
```
|
||||
|
||||
`pool_k` = 진단용 넓은 후보 폭 (예: 50). near-tie 가설 검증을 위해 좁은 k(=답변 context 폭)와
|
||||
넓은 pool 을 둘 다 측정.
|
||||
|
||||
## 5. 측정 / 수용 기준
|
||||
|
||||
- 변형 그룹 ≥6 개 (한/영 쌍 + 동의어 + 다른 어휘 + 풀어쓴 문장 골고루), 각 ≥3 표현.
|
||||
- 평가 실행이 **clean**(err=0) 하고 결과가 파일로 추출 후 Read 검증됨 ([[project_rerank_experiment]]
|
||||
교훈 — 측정값 추측 금지, grep clean 추출 후에만 기록).
|
||||
- 산출물: 그룹별 recall_spread@k, worst_recall, answer_consistency + A/B 분류 표.
|
||||
- **수용 기준은 "처방이 좋아지는지" 가 아니라 "진단이 나오는지"** — Phase 1 은 측정 프레임워크
|
||||
완성 + (A)/(B) 판별이 목표. baseline 숫자 자체가 deliverable.
|
||||
- 회귀 가드: 기존 golden suite 21쿼리의 AggregateMetrics 가 변형 그룹 추가 후에도 동일하게
|
||||
계산되어야 함 (group=None 경로 불변 — 기존 테스트 green).
|
||||
|
||||
## 6. 롤백 / 버전
|
||||
|
||||
- `group` 필드는 additive — 기존 yaml/스키마 backward-compat, 버전 cascade 트리거 아님.
|
||||
- 평가 전용 변경이라 wire schema (`search_hit.v1`/`answer.v1`) 불변. 바이너리 surface 변경 없음
|
||||
(eval CLI 리포트 항목 추가 가능 — additive).
|
||||
- golden_queries.yaml 의 변형 그룹은 dogfood KB 스냅샷(docs=3940 / chunks=34896, 2026-05-28)에
|
||||
큐레이션 — reset/re-ingest 시 chunk_id stale → runner bail. 재큐레이션 정책은 기존과 동일.
|
||||
|
||||
## 7. 미결 (구현 계획에서 확정)
|
||||
|
||||
- `group` 정합성: 같은 그룹이 서로 다른 expected 를 가질 때 — 에러 bail vs 경고+합집합. (권장:
|
||||
같은 그룹 = 같은 expected 강제, 위반 시 loader bail.)
|
||||
- A/B 분류 임계값: "recall@pool_k high" 의 high 기준, near-tie band Δ 정의 (진단 리포트용).
|
||||
- 변형 일관성 metric 의 CLI/JSON surface 형태 (기존 `eval` 출력에 합칠지 별도 서브커맨드일지).
|
||||
- 변형 그룹 큐레이션: 어떤 의도 ~6–10 개를 고를지 — dogfood corpus 에서 한/영 양쪽으로 명확한
|
||||
정답 문서가 있는 토픽 선정 (rust 류 + 일반 토픽 섞기, 선행 ablation 토픽 재사용 가능).
|
||||
- 답변 정답 신호: `must_contain` 큐레이션 방식 (핵심 사실 substring) — 그룹 내 공유.
|
||||
|
||||
## 8. 실행 방식 (사용자 지정, 2026-05-29)
|
||||
|
||||
- spec → plan → subagent 구현. 각 작업·리뷰는 **OMC teammate** (tmux pane spawn,
|
||||
[[feedback_teammate_spawn_mode]] / [[feedback_omc_teams_usage]]).
|
||||
- 작은 작업은 sonnet, 복잡 작업은 opus ([[feedback_teammate_model_routing]] 조정).
|
||||
- 테스트용 데이터는 dogfood 데이터셋(`/build/dogfood/corpus`, `/build/dogfood/golden_queries.yaml`)
|
||||
에서 가져올 수 있음.
|
||||
- 빌드/테스트는 파일 redirect + exit code 확인 후에만 커밋 ([[project_rerank_experiment]] 교훈).
|
||||
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 무변경.
|
||||
220
docs/superpowers/specs/2026-05-30-doc-side-expansion-design.md
Normal file
220
docs/superpowers/specs/2026-05-30-doc-side-expansion-design.md
Normal file
@@ -0,0 +1,220 @@
|
||||
---
|
||||
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
|
||||
|
||||
## 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 로 보장.
|
||||
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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
37
migrations/V010__chunk_aliases.sql
Normal file
37
migrations/V010__chunk_aliases.sql
Normal file
@@ -0,0 +1,37 @@
|
||||
-- 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'
|
||||
);
|
||||
|
||||
-- 가드 `IS NOT NULL AND <> ''`: producer 가 Some("") 를 넘겨도 내용 없는
|
||||
-- 행이 chunk_aliases_fts 에 쌓이지 않게 한다(Task 2 리뷰 M1).
|
||||
CREATE TRIGGER chunk_aliases_ai AFTER INSERT ON chunks
|
||||
WHEN new.aliases IS NOT NULL AND new.aliases <> '' 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 AND new.aliases <> '';
|
||||
END;
|
||||
|
||||
-- in-process LRU search cache 무효화 (V009 와 동일 패턴).
|
||||
UPDATE kv SET value = CAST(CAST(value AS INTEGER) + 1 AS TEXT) WHERE key = 'corpus_revision';
|
||||
46
migrations/V011__drop_embedding_records_fk.sql
Normal file
46
migrations/V011__drop_embedding_records_fk.sql
Normal file
@@ -0,0 +1,46 @@
|
||||
-- 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).
|
||||
-- NOTE: PRAGMA foreign_keys 는 refinery 가 마이그레이션을 트랜잭션으로 감싸므로
|
||||
-- 트랜잭션 내에서 no-op(SQLite: "FK enforcement may only be changed when no
|
||||
-- transaction is pending"). 실제 안전장치는 아래 legacy_alter_table — trigger
|
||||
-- 재파싱 회피가 본 마이그레이션의 핵심 보호다. (Task 4.5 리뷰 NIT.)
|
||||
PRAGMA foreign_keys=OFF;
|
||||
-- legacy_alter_table=ON: DROP embedding_records 직후 V003 의
|
||||
-- chunks_bd_tombstone_embeddings trigger 가 (아직 존재하는 chunks 위에서)
|
||||
-- 사라진 embedding_records 를 참조하는 dangling 상태가 된다. 이후 RENAME 이
|
||||
-- 기본(legacy off) 모드면 스키마 전체를 재파싱하며 그 trigger 에서
|
||||
-- "no such table: embedding_records" 로 실패한다. legacy 모드는 RENAME 시
|
||||
-- trigger/view 본문 재파싱을 생략하므로 trigger 를 건드리지 않고 통과한다
|
||||
-- (SQLite ALTER TABLE 문서의 권장 table-redefinition 절차).
|
||||
PRAGMA legacy_alter_table=ON;
|
||||
|
||||
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'
|
||||
CHECK (status IN ('pending','committed','tombstone')), -- V003 와 동일 무결성 가드 보존
|
||||
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 legacy_alter_table=OFF;
|
||||
PRAGMA foreign_keys=ON;
|
||||
|
||||
UPDATE kv SET value = CAST(CAST(value AS INTEGER) + 1 AS TEXT) WHERE key = 'corpus_revision';
|
||||
22
migrations/V012__derivation_cache.sql
Normal file
22
migrations/V012__derivation_cache.sql
Normal file
@@ -0,0 +1,22 @@
|
||||
-- V012__derivation_cache.sql — 내용 해시 기반 파생물 캐시 (Derivation Cache).
|
||||
--
|
||||
-- 설계 spec docs/superpowers/specs/2026-05-31-derivation-cache-design.md §3.2.
|
||||
-- 비용 큰 ingest 파생물(embedding 벡터 / LLM 별칭 / 선택적 한국어 형태소)을
|
||||
-- 청크 text 의 *내용 해시* 키로 캐싱해, 문서 갱신·재색인 시 변경되지 않은
|
||||
-- 청크의 재계산을 없앤다. cache_key = blake3(kind ‖ text_blake3 ‖ version_key)[:32]
|
||||
-- (§3.1) — 위치 기반 chunk_id 와 달리 내용이 같으면 문서·위치 무관 동일 키.
|
||||
--
|
||||
-- 순수 가산(additive): 기존 데이터를 무효화하지 않으므로 corpus_revision 을
|
||||
-- bump 하지 않는다(§3.2). 캐시는 순수 성능 레이어 — 손상/삭제되어도 정확성
|
||||
-- 영향 없음(miss → 재계산). `kebab reset` 시 같은 sqlite 라 함께 비워진다.
|
||||
|
||||
CREATE TABLE derivation_cache (
|
||||
cache_key TEXT PRIMARY KEY, -- §3.1 blake3 32-hex
|
||||
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/TTL 정리용 (§3.5)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_dcache_kind ON derivation_cache(kind);
|
||||
CREATE INDEX idx_dcache_last_used ON derivation_cache(last_used_at);
|
||||
@@ -14,6 +14,68 @@ historical contract that was implemented; this file accumulates the
|
||||
deltas so phase 5+ readers can find the live behavior without diffing
|
||||
git history.
|
||||
|
||||
## 2026-05-31 — config 마이그레이션 (`kebab config migrate`)
|
||||
|
||||
**Trigger**: config.toml 스키마가 진화해도(v0.21.0 의 `[ingest.expansion]` 등) 기존 사용자 파일은 serde default 로 *동작*만 호환될 뿐 새 섹션이 파일에 안 써져 사용자가 노브의 존재를 알 수 없었다. DB 의 V00X refinery 와 달리 config 엔 마이그레이션 메커니즘이 없어 추가. 설계 `docs/superpowers/specs/2026-05-31-config-migration-design.md`, 계획 `docs/superpowers/plans/2026-05-31-config-migration.md`, PR #198.
|
||||
|
||||
### 메커니즘
|
||||
|
||||
`kebab config migrate` 가 (1) **reconciliation** — `Config::defaults()` 구조에 있고 사용자 파일에 없는 섹션/키를 주석과 함께 `toml_edit` 으로 추가(버전 무관·멱등) + (2) **step 체인** — `schema_version` 기반 non-additive 변환(첫 step v1→v2 = `workspace.include` 제거, p9-fb-25). `init` 과 migrate 가 `annotated_default_document()` 로 주석·헤더 단일 원천 공유 → init config 도 섹션 주석 보유. `schema_version` default 1→2(sync 마커+step 축). 안전 3축=멱등·백업(`.bak`, 원본 byte-identical)·dry-run + tmp atomic rename(round-trip 검증). 순수변환=`kebab-config/migrate.rs`, I/O facade=`kebab-app`.
|
||||
|
||||
### 도그푸딩 evidence (v0.21.0 release 바이너리)
|
||||
|
||||
옛 스키마 흉내(`schema_version=1`, `[workspace]`+`[search]`+`[rag]`, `workspace.include` 보유, 사용자가 `default_k=25`/`score_gate=0.8`+인라인 주석 손봄):
|
||||
|
||||
| 시나리오 | 결과 |
|
||||
|----------|------|
|
||||
| `migrate --dry-run` | 22 changes 나열, **파일 미수정** |
|
||||
| `migrate` | 적용 v1→v2, `.bak` **원본과 byte-identical**(diff 0) |
|
||||
| 값·주석 보존 | `root="~/MyNotes" # 내가 직접 바꾼…`, `default_k=25`, `score_gate=0.8` 유지 |
|
||||
| deprecated 정리 | `workspace.include` 제거(grep 0) |
|
||||
| 가시화 | `[ingest.expansion]`·`[logging]`·`[pdf.ocr]` 등장 |
|
||||
| 멱등 | 재실행 → `config 이미 최신입니다 (schema v2)` |
|
||||
| doctor | `✓ config_migration config up to date (schema v2)` |
|
||||
| `--json` | `config_migration.v1` (kind=added_section/removed_deprecated) |
|
||||
|
||||
### 알려진 한계 / 결정
|
||||
|
||||
- 누락 섹션은 테이블 끝 append(순서 미보존, 값·주석·기존순서는 보존).
|
||||
- 통째 누락 부모는 부모 경로 1건 기록, 부분 존재 부모는 leaf 경로 기록(재귀 깊이 차이).
|
||||
- doctor 의 `config_migration` ok=false 가 전체 `DoctorReport.ok` 를 false 로 만듦(의도; hint 가 교정 명령 제시, warn 상태 미도입).
|
||||
- `schema_version` bump(1→2)은 additive(데이터 무효화 아님, 읽기 호환 유지) → DB/wire breaking release 트리거 아님. 신규 CLI 서브커맨드+doctor 체크+init 출력 변경은 user-visible surface.
|
||||
|
||||
## 2026-05-31 — doc-side expansion 별칭 개선 + 파생물 캐시(V012)
|
||||
|
||||
**Trigger**: Phase 2 doc-side expansion(별칭) 효과를 실사용 규모(한국어 나무위키 ~1000 문서 CS corpus)로 검증하고, 그 과정에서 드러난 별칭 생성 비용을 "내용 해시 기반 파생물 캐시"로 해소. v0.21.0 cut. 측정 상세: `docs/superpowers/handoffs/2026-05-31-namu-wiki-alias-cache-study.md`, 설계: `docs/superpowers/specs/2026-05-31-derivation-cache-design.md`.
|
||||
|
||||
### (a) 별칭 개별 dense 벡터 + boilerplate skip
|
||||
|
||||
초기 별도-벡터(청크당 별칭 8개를 줄바꿈으로 묶어 한 벡터로 임베딩) 방식은 평균화로 특정 표현이 **희석**되고 나무위키 메뉴(boilerplate) 청크에도 별칭이 생성돼 **오히려 회귀**(13/18). 개선판은 별칭을 줄별 **개별 sentinel 벡터**(`{chunk}#alias#N`)로 색인하고 본문 벡터는 그대로 두며, boilerplate 청크는 별칭 생성을 skip 한다. `kebab-core::strip_alias_suffix` 가 suffix 형(`{orig}#alias`)과 per-alias 형(`{orig}#alias#N`) 둘 다 처리(bare chunk_id 는 `#` 없는 blake3 32-hex 라 첫 `#alias` 가 경계).
|
||||
|
||||
| 구성 | fully_consistent | mean_spread@10 |
|
||||
|------|------------------|----------------|
|
||||
| baseline (별칭 off) | 14/18 | 0.222 |
|
||||
| 별도-벡터 (별칭 묶음 1벡터) | 13/18 | 0.278 (악화) |
|
||||
| **개선 (별칭 개별 벡터 + boilerplate skip)** | **16/18** | **0.111** |
|
||||
|
||||
baseline 약점은 전부 "설명형" 변형(용어·약어·영어는 18그룹 완벽) = 자연어 설명과 문서 전문용어의 "어휘 격차". 개선판이 linked_list·sorting 회복 + tcp 회귀 복구. 파일: `crates/kebab-core/src/ids.rs` (`strip_alias_suffix` find 기반), `crates/kebab-app/src/lib.rs`, `crates/kebab-app/src/expansion.rs`. `[ingest.expansion]` default off (opt-in).
|
||||
|
||||
### (b) 대조군 false-positive — 별칭 무죄
|
||||
|
||||
대조군(정답 없는 질문) 10개 RAG run 에서 refusal 0.6 (4개 grounded). false-positive 4개(graphql·oauth·react·grpc)의 인용 출처는 **전부 노이즈 본문**(GitHub_Mobile·API·Svelte 등), **별칭 sentinel 인용 0** → 별칭이 false-positive 를 유발하지 않음(별칭 무죄, default-on 안전성 근거).
|
||||
|
||||
### (c) 파생물 캐시 145배 + 외부 계산 이식 워크플로
|
||||
|
||||
별칭 18문서 재생성 2.5시간이 근본 병목. `chunk_id` 가 위치(`ordinal+span`) 기반이라 chunk_id 캐싱은 중간 수정 시 무력 → 청크 text **내용 해시**를 키로 한 범용 캐시(V012). `cache_key = blake3(kind ‖ text_blake3 ‖ version_key)[:32]`, version_key 에 model/prompt/dimensions 포함 → §9 cascade 와 자동 정합(버전 bump 시 자동 miss). embedding(본문 + 별칭 벡터 양쪽) + 별칭 LLM 결과 캐싱. **측정: 정답 3개 cold 1879s → warm 13s ≈ 145배**(18문서 환산 2.5h → ~80s). `corpus_revision` 은 bump 안 함(순수 가산). 파일: `migrations/V012__derivation_cache.sql`, `crates/kebab-core/src/derivation.rs`, `crates/kebab-store-sqlite/src/derivation_cache.rs`, `crates/kebab-app/src/derivation_payload.rs`.
|
||||
|
||||
**이식**: search/ask 는 `kebab.sqlite` + `lancedb` 만으로 동작(`storage_path` asset 은 search/ask 경로에서 사용처 0). 비싼 색인(별칭 LLM + embedding)을 외부 CPU ollama 서버에서 돌린 뒤 sqlite(+derivation_cache) + lancedb 만 로컬로 복사하면 동일 동작 + 증분 캐시 히트가 머신 독립적으로 적용.
|
||||
|
||||
### Known limitation
|
||||
|
||||
- **stack·svm 설명형 잔존**: 개선 후에도 2개 설명형 변형은 별칭으로 못 메움(추가 개선 보류).
|
||||
- **grounded/refusal 오분류**: answer 가 "근거에서 찾을 수 없다"고 정직히 거부했는데도 부분 언급 인용이 있으면 grounded 로 오분류 → 실제 refusal 은 0.6 보다 높음. kebab grounded/refusal 판정의 별도 개선 여지(후속 후보).
|
||||
- **korean_tokens 캐시 / export-import 명령 / 별칭 default-on**: 보류.
|
||||
|
||||
## 2026-05-29 — v0.20.2 dogfood findings + 검색 품질 baseline
|
||||
|
||||
**Trigger**: v0.20.2 release 준비 8-finding dogfood 라운드 (2026-05-29). 구현 + eval + 도그푸딩 전부 완료.
|
||||
|
||||
Reference in New Issue
Block a user