Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 736d791056 | |||
| 6c9c8df43e | |||
| 0263667684 | |||
| 4918983d9c | |||
| aeaa18a564 | |||
| c91ff909ce | |||
| 8dee610a97 | |||
| d71ed2516b | |||
| 095c9f37a2 | |||
| 16ddb1dfc3 | |||
| 72c99c452c | |||
| cbcae69abf | |||
| 7505645008 | |||
| e2ae9a4589 | |||
| 1dfab6dfc5 | |||
| fc5103642e | |||
| e03d03cb26 | |||
| 16aadea222 | |||
| a48c405826 | |||
| 21e02d8a93 | |||
| a64c31ee94 | |||
| ec96648956 | |||
| ecaf224381 | |||
| b1c5feb3f3 | |||
| ca8c0645fb | |||
| c7af6612b7 | |||
| acb4fa6c65 | |||
| 8bfa4ba76e | |||
| ad0ccf4ccf | |||
| b351523e51 | |||
| a48b055358 | |||
| 581e1d5d55 | |||
| c17d6e67a8 | |||
| af8fd34716 | |||
| 369aeb3d24 |
@@ -82,7 +82,10 @@ Release 절차:
|
||||
|
||||
1. `gitea-release v<X.Y.Z>` (gitea-ops skill) 으로 tag + push + release notes.
|
||||
2. release notes 는 사용자 도그푸딩에 영향이 가는 surface 변경을 위주로 — wire schema 추가, CLI flag 신규, TUI 키 변경, V00X migration 등 — 다룬다. 이때 추가된 기능과 변경사항은 유저가 이해할 수 있도록 친절하고 자세하게 풀어서 설명해야 하며, 단순히 commit subject 를 나열하는 형태로 끝내면 안 된다. 필요하다면 도그푸딩이나 테스트 결과도 함께 적어 둔다.
|
||||
3. 프리-1.0 (`0.x.y`) 단계: minor bump 시 wire schema additive / surface 변경 누적, patch bump 시 bug fix only.
|
||||
3. 프리-1.0 (`0.x.y`) 단계 bump 규칙 — **기능(behavior) 또는 인터페이스(interface) 변경 여부**로 판정:
|
||||
- **minor bump** (`0.x.0`): 기능 또는 인터페이스에 *실질적* 변경이 있을 때. 인터페이스 = 신규/변경/삭제된 CLI subcommand·flag, config 키, wire schema 의 **breaking** 변경, 임베딩/검색/RAG 등 사용자가 받는 **결과·동작**의 변화, V00X migration, frozen 설계 변경. 기능 = 새 source 형식·검색 모드·백엔드 등 *할 수 있는 일*의 추가/변경.
|
||||
- **patch bump** (`0.x.y`): 기능·인터페이스 변경이 **없을** 때. bug fix, 내부 refactor, 성능 개선, 로깅/진행표시 등 **관측성(observability) 개선**, **additive-only wire 변경**(backward-compat 신규 필드/이벤트라 기존 소비자 무영향), 문서. ← 즉 "결과가 같고 새 명령/플래그/config 도 없으면 patch".
|
||||
- 경계 예: 진행 로그에 phase/파일명 추가 + additive wire 이벤트(asset_phase) = **patch** (검색·색인 결과 불변, 새 명령/플래그/config 없음). arctic 임베더 provider + 신규 config 키 = **minor** (인터페이스 추가). 별칭 기능 제거 + migration = **minor** (동작·인터페이스 변경).
|
||||
|
||||
**bump 시점 = release 시점 같은 commit**. 즉 commit `chore: bump version 0.x → 0.y` 직후 같은 commit 에 tag. v0.1.0 (`2319206`) 처럼 bump 없이 tag 만 찍는 패턴은 후속 release 가 대상 commit 을 헷갈리게 함 — pre-release snapshot 은 SHA reference 로 충분.
|
||||
|
||||
|
||||
469
Cargo.lock
generated
469
Cargo.lock
generated
@@ -712,6 +712,12 @@ dependencies = [
|
||||
"cpufeatures 0.3.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a"
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
@@ -895,23 +901,42 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6bd9895436c1ba5dc1037a19935d084b838db066ff4e15ef7dded020b7c12a4a"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"candle-metal-kernels",
|
||||
"candle-ug",
|
||||
"float8",
|
||||
"gemm",
|
||||
"gemm 0.19.0",
|
||||
"half",
|
||||
"libm",
|
||||
"memmap2",
|
||||
"num-traits",
|
||||
"num_cpus",
|
||||
"objc2-foundation",
|
||||
"objc2-metal",
|
||||
"rand 0.9.4",
|
||||
"rand_distr 0.5.1",
|
||||
"rayon",
|
||||
"safetensors",
|
||||
"safetensors 0.7.0",
|
||||
"thiserror 2.0.18",
|
||||
"tokenizers 0.22.2",
|
||||
"yoke",
|
||||
"yoke 0.8.2",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "candle-metal-kernels"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4b6b5a4cae6b4e1ab0efcee4dc05272d11b374a3d1ba121b3a961e36be54ab60"
|
||||
dependencies = [
|
||||
"half",
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
"objc2-metal",
|
||||
"once_cell",
|
||||
"thiserror 2.0.18",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "candle-nn"
|
||||
version = "0.10.2"
|
||||
@@ -919,11 +944,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a9317a09d6530b758990ed7f625ac69ff43653bc9ee28b0464644ad1169ada87"
|
||||
dependencies = [
|
||||
"candle-core",
|
||||
"candle-metal-kernels",
|
||||
"half",
|
||||
"libc",
|
||||
"num-traits",
|
||||
"objc2-metal",
|
||||
"rayon",
|
||||
"safetensors",
|
||||
"safetensors 0.7.0",
|
||||
"serde",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
@@ -947,6 +974,16 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "candle-ug"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ca0fc3167cbc99c8ec1be618cb620aa21dca95038f118c3579a79370e3dc5f77"
|
||||
dependencies = [
|
||||
"ug",
|
||||
"ug-metal",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cassowary"
|
||||
version = "0.3.0"
|
||||
@@ -1210,6 +1247,17 @@ version = "0.8.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||
|
||||
[[package]]
|
||||
name = "core-graphics-types"
|
||||
version = "0.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"core-foundation 0.9.4",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "counter"
|
||||
version = "0.7.1"
|
||||
@@ -2637,7 +2685,28 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
|
||||
dependencies = [
|
||||
"foreign-types-shared",
|
||||
"foreign-types-shared 0.1.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
|
||||
dependencies = [
|
||||
"foreign-types-macros",
|
||||
"foreign-types-shared 0.3.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types-macros"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2646,6 +2715,12 @@ version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types-shared"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b"
|
||||
|
||||
[[package]]
|
||||
name = "form_urlencoded"
|
||||
version = "1.2.2"
|
||||
@@ -2784,6 +2859,26 @@ dependencies = [
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gemm"
|
||||
version = "0.18.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ab96b703d31950f1aeddded248bc95543c9efc7ac9c4a21fda8703a83ee35451"
|
||||
dependencies = [
|
||||
"dyn-stack",
|
||||
"gemm-c32 0.18.2",
|
||||
"gemm-c64 0.18.2",
|
||||
"gemm-common 0.18.2",
|
||||
"gemm-f16 0.18.2",
|
||||
"gemm-f32 0.18.2",
|
||||
"gemm-f64 0.18.2",
|
||||
"num-complex",
|
||||
"num-traits",
|
||||
"paste",
|
||||
"raw-cpuid",
|
||||
"seq-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gemm"
|
||||
version = "0.19.0"
|
||||
@@ -2791,12 +2886,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aa0673db364b12263d103b68337a68fbecc541d6f6b61ba72fe438654709eacb"
|
||||
dependencies = [
|
||||
"dyn-stack",
|
||||
"gemm-c32",
|
||||
"gemm-c64",
|
||||
"gemm-common",
|
||||
"gemm-f16",
|
||||
"gemm-f32",
|
||||
"gemm-f64",
|
||||
"gemm-c32 0.19.0",
|
||||
"gemm-c64 0.19.0",
|
||||
"gemm-common 0.19.0",
|
||||
"gemm-f16 0.19.0",
|
||||
"gemm-f32 0.19.0",
|
||||
"gemm-f64 0.19.0",
|
||||
"num-complex",
|
||||
"num-traits",
|
||||
"paste",
|
||||
"raw-cpuid",
|
||||
"seq-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gemm-c32"
|
||||
version = "0.18.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6db9fd9f40421d00eea9dd0770045a5603b8d684654816637732463f4073847"
|
||||
dependencies = [
|
||||
"dyn-stack",
|
||||
"gemm-common 0.18.2",
|
||||
"num-complex",
|
||||
"num-traits",
|
||||
"paste",
|
||||
@@ -2811,7 +2921,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "086936dbdcb99e37aad81d320f98f670e53c1e55a98bee70573e83f95beb128c"
|
||||
dependencies = [
|
||||
"dyn-stack",
|
||||
"gemm-common",
|
||||
"gemm-common 0.19.0",
|
||||
"num-complex",
|
||||
"num-traits",
|
||||
"paste",
|
||||
"raw-cpuid",
|
||||
"seq-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gemm-c64"
|
||||
version = "0.18.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dfcad8a3d35a43758330b635d02edad980c1e143dc2f21e6fd25f9e4eada8edf"
|
||||
dependencies = [
|
||||
"dyn-stack",
|
||||
"gemm-common 0.18.2",
|
||||
"num-complex",
|
||||
"num-traits",
|
||||
"paste",
|
||||
@@ -2826,7 +2951,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "20c8aeeeec425959bda4d9827664029ba1501a90a0d1e6228e48bef741db3a3f"
|
||||
dependencies = [
|
||||
"dyn-stack",
|
||||
"gemm-common",
|
||||
"gemm-common 0.19.0",
|
||||
"num-complex",
|
||||
"num-traits",
|
||||
"paste",
|
||||
@@ -2834,6 +2959,27 @@ dependencies = [
|
||||
"seq-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gemm-common"
|
||||
version = "0.18.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a352d4a69cbe938b9e2a9cb7a3a63b7e72f9349174a2752a558a8a563510d0f3"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"dyn-stack",
|
||||
"half",
|
||||
"libm",
|
||||
"num-complex",
|
||||
"num-traits",
|
||||
"once_cell",
|
||||
"paste",
|
||||
"pulp 0.21.5",
|
||||
"raw-cpuid",
|
||||
"rayon",
|
||||
"seq-macro",
|
||||
"sysctl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gemm-common"
|
||||
version = "0.19.0"
|
||||
@@ -2848,13 +2994,31 @@ dependencies = [
|
||||
"num-traits",
|
||||
"once_cell",
|
||||
"paste",
|
||||
"pulp",
|
||||
"pulp 0.22.2",
|
||||
"raw-cpuid",
|
||||
"rayon",
|
||||
"seq-macro",
|
||||
"sysctl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gemm-f16"
|
||||
version = "0.18.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cff95ae3259432f3c3410eaa919033cd03791d81cebd18018393dc147952e109"
|
||||
dependencies = [
|
||||
"dyn-stack",
|
||||
"gemm-common 0.18.2",
|
||||
"gemm-f32 0.18.2",
|
||||
"half",
|
||||
"num-complex",
|
||||
"num-traits",
|
||||
"paste",
|
||||
"raw-cpuid",
|
||||
"rayon",
|
||||
"seq-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gemm-f16"
|
||||
version = "0.19.0"
|
||||
@@ -2862,8 +3026,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3df7a55202e6cd6739d82ae3399c8e0c7e1402859b30e4cb780e61525d9486e"
|
||||
dependencies = [
|
||||
"dyn-stack",
|
||||
"gemm-common",
|
||||
"gemm-f32",
|
||||
"gemm-common 0.19.0",
|
||||
"gemm-f32 0.19.0",
|
||||
"half",
|
||||
"num-complex",
|
||||
"num-traits",
|
||||
@@ -2873,6 +3037,21 @@ dependencies = [
|
||||
"seq-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gemm-f32"
|
||||
version = "0.18.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bc8d3d4385393304f407392f754cd2dc4b315d05063f62cf09f47b58de276864"
|
||||
dependencies = [
|
||||
"dyn-stack",
|
||||
"gemm-common 0.18.2",
|
||||
"num-complex",
|
||||
"num-traits",
|
||||
"paste",
|
||||
"raw-cpuid",
|
||||
"seq-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gemm-f32"
|
||||
version = "0.19.0"
|
||||
@@ -2880,7 +3059,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "02e0b8c9da1fbec6e3e3ab2ce6bc259ef18eb5f6f0d3e4edf54b75f9fd41a81c"
|
||||
dependencies = [
|
||||
"dyn-stack",
|
||||
"gemm-common",
|
||||
"gemm-common 0.19.0",
|
||||
"num-complex",
|
||||
"num-traits",
|
||||
"paste",
|
||||
"raw-cpuid",
|
||||
"seq-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gemm-f64"
|
||||
version = "0.18.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "35b2a4f76ce4b8b16eadc11ccf2e083252d8237c1b589558a49b0183545015bd"
|
||||
dependencies = [
|
||||
"dyn-stack",
|
||||
"gemm-common 0.18.2",
|
||||
"num-complex",
|
||||
"num-traits",
|
||||
"paste",
|
||||
@@ -2895,7 +3089,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "056131e8f2a521bfab322f804ccd652520c79700d81209e9d9275bbdecaadc6a"
|
||||
dependencies = [
|
||||
"dyn-stack",
|
||||
"gemm-common",
|
||||
"gemm-common 0.19.0",
|
||||
"num-complex",
|
||||
"num-traits",
|
||||
"paste",
|
||||
@@ -4067,7 +4261,7 @@ checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"potential_utf",
|
||||
"yoke",
|
||||
"yoke 0.8.2",
|
||||
"zerofrom",
|
||||
"zerovec",
|
||||
]
|
||||
@@ -4134,7 +4328,7 @@ dependencies = [
|
||||
"displaydoc",
|
||||
"icu_locale_core",
|
||||
"writeable",
|
||||
"yoke",
|
||||
"yoke 0.8.2",
|
||||
"zerofrom",
|
||||
"zerotrie",
|
||||
"zerovec",
|
||||
@@ -4530,7 +4724,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-app"
|
||||
version = "0.22.0"
|
||||
version = "0.26.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64 0.22.1",
|
||||
@@ -4545,6 +4739,7 @@ dependencies = [
|
||||
"kebab-embed",
|
||||
"kebab-embed-candle",
|
||||
"kebab-embed-local",
|
||||
"kebab-embed-ollama",
|
||||
"kebab-llm",
|
||||
"kebab-llm-local",
|
||||
"kebab-nli",
|
||||
@@ -4577,7 +4772,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-chunk"
|
||||
version = "0.22.0"
|
||||
version = "0.26.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"blake3",
|
||||
@@ -4595,7 +4790,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-cli"
|
||||
version = "0.22.0"
|
||||
version = "0.26.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
@@ -4616,7 +4811,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-config"
|
||||
version = "0.22.0"
|
||||
version = "0.26.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"dirs 5.0.1",
|
||||
@@ -4632,7 +4827,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-core"
|
||||
version = "0.22.0"
|
||||
version = "0.26.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"blake3",
|
||||
@@ -4646,7 +4841,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-embed"
|
||||
version = "0.22.0"
|
||||
version = "0.26.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"blake3",
|
||||
@@ -4660,7 +4855,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-embed-candle"
|
||||
version = "0.22.0"
|
||||
version = "0.26.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"candle-core",
|
||||
@@ -4670,6 +4865,7 @@ dependencies = [
|
||||
"kebab-config",
|
||||
"kebab-core",
|
||||
"kebab-embed-local",
|
||||
"kebab-embed-ollama",
|
||||
"rayon",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
@@ -4679,7 +4875,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-embed-local"
|
||||
version = "0.22.0"
|
||||
version = "0.26.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"fastembed",
|
||||
@@ -4690,9 +4886,24 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kebab-embed-ollama"
|
||||
version = "0.26.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"kebab-config",
|
||||
"kebab-core",
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"wiremock",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kebab-eval"
|
||||
version = "0.22.0"
|
||||
version = "0.26.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"kebab-app",
|
||||
@@ -4711,7 +4922,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-llm"
|
||||
version = "0.22.0"
|
||||
version = "0.26.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"kebab-core",
|
||||
@@ -4720,7 +4931,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-llm-local"
|
||||
version = "0.22.0"
|
||||
version = "0.26.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"kebab-config",
|
||||
@@ -4737,7 +4948,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-mcp"
|
||||
version = "0.22.0"
|
||||
version = "0.26.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"kebab-app",
|
||||
@@ -4755,7 +4966,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-nli"
|
||||
version = "0.22.0"
|
||||
version = "0.26.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"hf-hub",
|
||||
@@ -4770,7 +4981,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-parse-code"
|
||||
version = "0.22.0"
|
||||
version = "0.26.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"gix",
|
||||
@@ -4793,7 +5004,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-parse-image"
|
||||
version = "0.22.0"
|
||||
version = "0.26.1"
|
||||
dependencies = [
|
||||
"ab_glyph",
|
||||
"anyhow",
|
||||
@@ -4817,7 +5028,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-parse-md"
|
||||
version = "0.22.0"
|
||||
version = "0.26.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"kebab-core",
|
||||
@@ -4834,7 +5045,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-parse-pdf"
|
||||
version = "0.22.0"
|
||||
version = "0.26.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"blake3",
|
||||
@@ -4849,7 +5060,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-rag"
|
||||
version = "0.22.0"
|
||||
version = "0.26.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"blake3",
|
||||
@@ -4871,7 +5082,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-search"
|
||||
version = "0.22.0"
|
||||
version = "0.26.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"globset",
|
||||
@@ -4890,7 +5101,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-source-fs"
|
||||
version = "0.22.0"
|
||||
version = "0.26.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"blake3",
|
||||
@@ -4908,7 +5119,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-store-sqlite"
|
||||
version = "0.22.0"
|
||||
version = "0.26.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"blake3",
|
||||
@@ -4928,7 +5139,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-store-vector"
|
||||
version = "0.22.0"
|
||||
version = "0.26.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arrow",
|
||||
@@ -4952,7 +5163,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-tui"
|
||||
version = "0.22.0"
|
||||
version = "0.26.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"crossterm",
|
||||
@@ -5626,6 +5837,16 @@ dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libloading"
|
||||
version = "0.8.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libm"
|
||||
version = "0.2.16"
|
||||
@@ -5942,6 +6163,15 @@ version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "670fdfda89751bc4a84ac13eaa63e205cf0fd22b4c9a5fbfa085b63c1f1d3a30"
|
||||
|
||||
[[package]]
|
||||
name = "malloc_buf"
|
||||
version = "0.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "maplit"
|
||||
version = "1.0.2"
|
||||
@@ -6038,6 +6268,21 @@ dependencies = [
|
||||
"stable_deref_trait",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "metal"
|
||||
version = "0.29.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7ecfd3296f8c56b7c1f6fbac3c71cefa9d78ce009850c45000015f206dc7fa21"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"block",
|
||||
"core-graphics-types",
|
||||
"foreign-types 0.5.0",
|
||||
"log",
|
||||
"objc",
|
||||
"paste",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mime"
|
||||
version = "0.3.17"
|
||||
@@ -6401,6 +6646,15 @@ version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3"
|
||||
|
||||
[[package]]
|
||||
name = "objc"
|
||||
version = "0.2.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1"
|
||||
dependencies = [
|
||||
"malloc_buf",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2"
|
||||
version = "0.6.4"
|
||||
@@ -6410,12 +6664,50 @@ dependencies = [
|
||||
"objc2-encode",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-foundation"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"dispatch2",
|
||||
"objc2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-encode"
|
||||
version = "4.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33"
|
||||
|
||||
[[package]]
|
||||
name = "objc2-foundation"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"block2",
|
||||
"libc",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-metal"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"block2",
|
||||
"dispatch2",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "object"
|
||||
version = "0.37.3"
|
||||
@@ -6497,7 +6789,7 @@ checksum = "f38c4372413cdaaf3cc79dd92d29d7d9f5ab09b51b10dded508fb90bb70b9222"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"cfg-if",
|
||||
"foreign-types",
|
||||
"foreign-types 0.3.2",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"openssl-macros",
|
||||
@@ -7006,6 +7298,20 @@ dependencies = [
|
||||
"unicase",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pulp"
|
||||
version = "0.21.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "96b86df24f0a7ddd5e4b95c94fc9ed8a98f1ca94d3b01bdce2824097e7835907"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"cfg-if",
|
||||
"libm",
|
||||
"num-complex",
|
||||
"reborrow",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pulp"
|
||||
version = "0.22.2"
|
||||
@@ -7932,6 +8238,16 @@ version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dd29631678d6fb0903b69223673e122c32e9ae559d0960a38d574695ebc0ea15"
|
||||
|
||||
[[package]]
|
||||
name = "safetensors"
|
||||
version = "0.4.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44560c11236a6130a46ce36c836a62936dc81ebf8c36a37947423571be0e55b6"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "safetensors"
|
||||
version = "0.7.0"
|
||||
@@ -9470,6 +9786,41 @@ version = "1.20.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"
|
||||
|
||||
[[package]]
|
||||
name = "ug"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76b761acf8af3494640d826a8609e2265e19778fb43306c7f15379c78c9b05b0"
|
||||
dependencies = [
|
||||
"gemm 0.18.2",
|
||||
"half",
|
||||
"libloading",
|
||||
"memmap2",
|
||||
"num",
|
||||
"num-traits",
|
||||
"num_cpus",
|
||||
"rayon",
|
||||
"safetensors 0.4.5",
|
||||
"serde",
|
||||
"thiserror 1.0.69",
|
||||
"tracing",
|
||||
"yoke 0.7.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ug-metal"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7adf545a99a086d362efc739e7cf4317c18cbeda22706000fd434d70ea3d95"
|
||||
dependencies = [
|
||||
"half",
|
||||
"metal",
|
||||
"objc",
|
||||
"serde",
|
||||
"thiserror 1.0.69",
|
||||
"ug",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unarray"
|
||||
version = "0.1.4"
|
||||
@@ -10416,6 +10767,18 @@ version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448"
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
version = "0.7.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "120e6aef9aa629e3d4f52dc8cc43a015c7724194c97dfaf45180d2daf2b77f40"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"stable_deref_trait",
|
||||
"yoke-derive 0.7.5",
|
||||
"zerofrom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
version = "0.8.2"
|
||||
@@ -10423,10 +10786,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca"
|
||||
dependencies = [
|
||||
"stable_deref_trait",
|
||||
"yoke-derive",
|
||||
"yoke-derive 0.8.2",
|
||||
"zerofrom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yoke-derive"
|
||||
version = "0.7.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yoke-derive"
|
||||
version = "0.8.2"
|
||||
@@ -10493,7 +10868,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"yoke",
|
||||
"yoke 0.8.2",
|
||||
"zerofrom",
|
||||
]
|
||||
|
||||
@@ -10503,7 +10878,7 @@ version = "0.11.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"
|
||||
dependencies = [
|
||||
"yoke",
|
||||
"yoke 0.8.2",
|
||||
"zerofrom",
|
||||
"zerovec-derive",
|
||||
]
|
||||
|
||||
@@ -12,6 +12,7 @@ members = [
|
||||
"crates/kebab-embed",
|
||||
"crates/kebab-embed-local",
|
||||
"crates/kebab-embed-candle",
|
||||
"crates/kebab-embed-ollama",
|
||||
"crates/kebab-llm",
|
||||
"crates/kebab-llm-local",
|
||||
"crates/kebab-rag",
|
||||
@@ -31,7 +32,7 @@ edition = "2024"
|
||||
rust-version = "1.85"
|
||||
license = "MIT OR Apache-2.0"
|
||||
repository = "https://github.com/altair823/kebab"
|
||||
version = "0.22.0" # v0.22.0 — candle 임베딩 provider (NUMA-안전, opt-in `provider=candle` + `num_threads`/KEBAB_EMBED_THREADS). fastembed default 불변, embedding_version 유지(재색인 0). — CLAUDE.md §Release 도그푸딩 트리거
|
||||
version = "0.26.1" # v0.26.1 — ingest 진행 로그 개선: TTY 진행바에 현재 파일명 + 느린 phase(ocr/caption/embed)+모델명 실시간 + 경과초 heartbeat `(Ns)`, 종료 시 최장 소요 파일 top-5 요약. 신규 wire 이벤트 `asset_phase{idx,total,phase,model}` + `asset_timings.ocr_ms`/`caption_ms` 추가(additive, ingest_progress.v1 유지, serde default 0). 기본 동작 불변. — 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),
|
||||
|
||||
@@ -35,6 +35,9 @@ P0~P5 직렬. P6~P9 P5 이후 병렬 가능.
|
||||
|
||||
머지 후 발견된 모든 deviation / hotfix 의 dated 로그는 [tasks/HOTFIXES.md](tasks/HOTFIXES.md). 본 요약은 \"누군가가 인수받을 때 알아두면 시간을 많이 절약하는\" 항목만:
|
||||
|
||||
- **2026-06-03 ingest 진행 로그 개선** — v0.26.1. 이미지/PDF + OCR/caption on 볼트 ingest 가 "멈춘 듯" 보이던 문제 해소: TTY 진행바에 현재 파일명 + 느린 phase(ocr/caption/embed)+모델명 + 경과초 `(Ns)` heartbeat, 종료 시 최장 소요 파일 top-5 요약. 신규 wire `asset_phase{idx,total,phase,model}` + `asset_timings.ocr_ms`/`caption_ms`(additive, `ingest_progress.v1` 유지, serde default 0). 이미지·PDF 경로도 `asset_timings` emit(이전 markdown 만). 기본 동작 불변. 자세한 내용: `tasks/HOTFIXES.md` (2026-06-03 ingest 진행 로그), spec/plan `docs/superpowers/{specs,plans}/2026-06-03-ingest-log-improve-*.md`.
|
||||
- **2026-06-03 arctic-embed-l-v2.0 임베더 통합** — v0.26.0. 별칭 제거 후 설명형 query recall 보강(측정 recall@10 130/132, e5 +7). `kebab-embed-candle` 모델 레지스트리화(e5 mean + `snowflake-arctic-embed-l-v2.0` CLS, 모델별 pooling/prefix) + 신규 `kebab-embed-ollama`(`provider="ollama"`, `/api/embed`). config `endpoint: Option<String>` 추가. 기본 e5 유지(opt-in), arctic 전환은 embedding_version cascade → 재색인. candle↔Ollama cosine>0.99 게이트로 pooling/prefix 정확성 고정(`#[ignore]`). 자세한 내용: `tasks/HOTFIXES.md` (2026-06-03 arctic), spec `docs/superpowers/specs/2026-06-03-arctic-embedder-spec.md`.
|
||||
- **2026-06-03 doc-side expansion(별칭) 기능 완전 제거** — v0.25.0. 아래 2026-05-31 항목의 색인-시 청크당 LLM 별칭 생성 + 별칭 검색 채널을 **전부 제거**(ROI 음수: cross-lingual 은 e5-large 단독으로 충분, 기여는 설명형 +2 그룹뿐인데 대가가 청크당 색인-시 LLM). `Chunk.aliases`/`expansion.rs`/`IngestExpansionCfg`/alias lexical arm/`expansion_progress` wire kind 제거, 신규 마이그레이션 **V013** 이 `chunk_aliases_fts`+`chunks.aliases` DROP. 별칭 default-off 였어 사용자 체감 0, 기존 KB 도 재색인 불요(잔존 별칭 벡터는 `strip_alias_suffix` graceful 매핑/`reset` 정리). `AssetTimings.expansion_ms` 는 wire 호환 위해 값 0 으로 유지. 자세한 내용: `tasks/HOTFIXES.md` (2026-06-03), spec `docs/superpowers/specs/2026-06-03-remove-doc-expansion-spec.md`.
|
||||
- **2026-05-31 Phase 2 doc-side expansion 별칭(개별 dense 벡터) + 파생물 캐시(V012)** — v0.21.0 cut. 색인 시 LLM 이 청크별 별칭("같은 의미 다른 표현")을 생성, 줄별 **개별 dense 벡터**(sentinel `{chunk}#alias#N`)로 색인 (묶음 1벡터는 평균화 희석으로 회귀 → 폐기) + boilerplate 청크 skip. `[ingest.expansion]` default off. 측정(나무위키 ~1000 문서 CS corpus): 변형 일관성 14/18 → **16/18**, spread 0.222→0.111, 대조군 false-positive 별칭 무죄. 비용 병목(별칭 18문서 2.5h)은 **파생물 캐시(V012, 청크 내용 해시 키)**로 해소 — 정답 3개 cold 1879s → warm 13s **≈ 145배**, embedding+별칭 LLM 캐싱, version_key cascade 정합. search/ask 가 `kebab.sqlite`+`lancedb` 만으로 동작 → 외부 서버 색인 후 DB 만 복사하는 이식 워크플로 가능. **결정/known limitation**: grounded/refusal 판정이 부분 인용을 grounded 로 오분류(정직한 거부가 false-positive 로 집계) — 별도 개선 후보. stack·svm 설명형 2개 잔존. 자세한 내용: `tasks/HOTFIXES.md` (2026-05-31), 측정: `docs/superpowers/handoffs/2026-05-31-namu-wiki-alias-cache-study.md`.
|
||||
- **2026-05-29 v0.20.2 dogfood findings + 검색 품질 baseline** — 8-finding 라운드 완료. (1) Ask 응답언어: rag-v3 default (질문 언어 = 답변 언어). (2) eval `--config` facade 패치 로 dogfood KB 직접 eval 가능. (3) 검색 품질 baseline — hybrid hit@3=1.0 / MRR=0.833, lexical hit@3=1.0 / MRR=0.7 (golden 10 query). **O-2 known limitation**: 소형 모델(gemma4:e4b) refusal 메시지의 query 언어 불일치 가능 — 판정은 정상, 표시 문구만 해당. 자세한 내용: `tasks/HOTFIXES.md` (2026-05-29).
|
||||
- **v0.20 sub-item 1 (scanned PDF OCR via qwen2.5vl:3b)**: post-extract enrichment pattern (`kebab-app::pdf_ocr_apply`, H-1 resolution), DCTDecode-only v1 scope (FlateDecode/CCITTFax page 는 warning + skip), parser_version `"pdf-text-v1"` 보존 + force-reingest UX 명문 (H-4).
|
||||
|
||||
87
README.md
87
README.md
@@ -41,17 +41,30 @@ clone 없이 git URL 로 바로 설치할 수도 있다: `cargo install --git ht
|
||||
|
||||
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` 으로만 정리.)
|
||||
embedding 벡터를 청크 **내용 해시** 로 캐싱한다 (`derivation_cache`). 재색인·갱신 시 내용이 같은 청크는 재계산을 건너뛴다. 캐시 키에 모델·차원 버전이 포함돼 버전 변경 시 자동 무효화된다 (cascade 안전). 별도 설정 없이 투명하게 동작한다. (현재 TTL/LRU 자동 정리는 미구현 — 누적된 캐시는 `kebab reset` 으로만 정리.)
|
||||
|
||||
### 외부 계산 + 로컬 검색 워크플로
|
||||
|
||||
search/ask 는 asset 파일 없이 `kebab.sqlite` + `lancedb` 만으로 동작한다. 비싼 색인(임베딩·OCR·별칭 생성)을 성능 좋은 서버에서 수행한 뒤, 이 두 산출물만 로컬로 복사하면 그대로 검색·질문할 수 있다.
|
||||
search/ask 는 원본 파일 없이 KB 산출물만으로 동작한다 (청크 본문이 SQLite 에 저장되고 문서 경로는 상대경로로 기록됨). 비싼 색인(임베딩·OCR)을 성능 좋은 머신에서 수행한 뒤(예: Apple Silicon 맥에서 candle Metal GPU), **두 산출물만** 다른 머신(예: NUMA 서버)으로 복사하면 그대로 검색·질문할 수 있다.
|
||||
|
||||
**무엇을 복사하나 — `[storage]` 에서 정의된 두 경로:**
|
||||
|
||||
| 복사 대상 | config 키 (`[storage]`) | 기본 경로 | 내용 |
|
||||
|-----------|------------------------|-----------|------|
|
||||
| `kebab.sqlite` | `sqlite = "{data_dir}/kebab.sqlite"` | `{data_dir}/kebab.sqlite` | 문서·청크·본문·FTS5·메타 |
|
||||
| `lancedb/` | `vector_dir = "{data_dir}/lancedb"` | `{data_dir}/lancedb/` | 임베딩 벡터 |
|
||||
|
||||
`{data_dir}` 는 `[storage].data_dir` (예: `~/.local/share/kebab`). `models/`(`model_dir`)·`assets/`(`asset_dir`)는 **복사 불필요** — 모델은 각 머신이 자기 캐시를 받고, asset 원본 바이트는 검색·질문에 쓰이지 않는다 (단일파일/`stdin` 색인의 원본 재읽기·재색인까지 보존하려면 `assets/` 도 함께 복사).
|
||||
|
||||
```bash
|
||||
# ingest 가 끝난(쓰기 없는) 상태에서 복사
|
||||
rsync -a <src-data_dir>/kebab.sqlite user@server:<dst-data_dir>/
|
||||
rsync -a <src-data_dir>/lancedb/ user@server:<dst-data_dir>/lancedb/
|
||||
```
|
||||
|
||||
조건: **양쪽 동일 `kebab` 버전 + 동일 임베딩 모델/차원** (`[models.embedding].model`·`dimensions`). provider 는 달라도 됨 (예: 맥 `candle`/Metal ↔ 서버 `candle`/CPU 또는 `fastembed` — 같은 모델이면 벡터 호환). 복사는 반드시 ingest 가 돌지 않을 때.
|
||||
|
||||
### 멀티미디어 색인
|
||||
|
||||
@@ -70,7 +83,7 @@ Markdown · PDF · 이미지(OCR + caption) · 소스코드(Rust/Python/TS/JS/Go
|
||||
| 명령 | 동작 |
|
||||
|------|------|
|
||||
| `kebab init` | XDG 경로에 데이터 디렉토리 + config.toml 생성 |
|
||||
| `kebab ingest [<path>]` | 워크스페이스 스캔 후 새/변경 문서 색인 (idempotent · incremental, `--force-reingest` 로 강제 재처리). 미지원 확장자는 자동 skip |
|
||||
| `kebab ingest [<path>]` | 워크스페이스 스캔 후 새/변경 문서 색인 (idempotent · incremental, `--force-reingest` 로 강제 재처리). 미지원 확장자는 자동 skip. 진행바는 현재 **파일명** · 느린 **phase(ocr/caption/embed)+모델명** · **경과초**`(Ns)` · 문서별 청크 수 · phase별 소요시간(parse/chunk/ocr/caption/embed/store)을 표시하고, 종료 시 **최장 소요 파일 top-5** 를 요약한다 (`--json` 은 `asset_phase`/`asset_chunked`/`asset_timings` 이벤트로, 사람용 요약은 미출력) |
|
||||
| `kebab ingest-file <path>` | 단일 파일 ingest (workspace 외부 가능 — `_external/` 로 deterministic copy) |
|
||||
| `kebab ingest-stdin --title <T>` | stdin 의 markdown 본문 ingest |
|
||||
| `kebab search --mode {lexical,vector,hybrid} "<query>" [flags]` | 검색 (default hybrid = RRF fusion, citation 포함). 필터/budget flag 는 `--help` |
|
||||
@@ -98,27 +111,69 @@ root = "~/KnowledgeBase" # 색인할 폴더. 절대 / tilde / env / 상대 경
|
||||
|
||||
[models.embedding]
|
||||
provider = "fastembed" # "fastembed"(기본, onnxruntime) / "candle"(순수 Rust)
|
||||
# / "none"(lexical-only). candle 는 같은 모델·같은 벡터를
|
||||
# 순수 Rust 로 돌려 NUMA 서버의 onnxruntime 48-스레드
|
||||
# double-free 를 피하는 opt-in 백엔드 (재색인 불필요).
|
||||
# / "ollama"(원격 HTTP) / "none"(lexical-only).
|
||||
# candle 는 같은 모델·같은 벡터를 순수 Rust 로 돌려
|
||||
# NUMA 서버의 onnxruntime 48-스레드 double-free 를 피하는
|
||||
# opt-in 백엔드 (e5 는 재색인 불필요).
|
||||
model = "multilingual-e5-large" # 다국어 sentence embedding (1024-dim).
|
||||
# 첫 ingest 시 ONNX (~1.3GB) 자동 다운로드.
|
||||
# candle provider 는 safetensors (~2GB) 다운로드.
|
||||
# candle/ollama 는 "snowflake-arctic-embed-l-v2.0"
|
||||
# (설명형 query 의 recall 보강) 도 지원 — 아래 참고.
|
||||
dimensions = 1024 # config 와 LanceDB stored dim 불일치 시 검색 0건.
|
||||
num_threads = 0 # candle 전용 CPU 스레드 캡 (0=auto=#cores).
|
||||
# env KEBAB_EMBED_THREADS 가 우선. NUMA 노드 바인딩은
|
||||
# numactl 과 조합. fastembed provider 는 무시.
|
||||
# endpoint = "http://127.0.0.1:11434" # provider="ollama" 전용 HTTP endpoint.
|
||||
# 생략 시 [models.llm].endpoint 로 폴백.
|
||||
# fastembed/candle provider 는 무시.
|
||||
```
|
||||
|
||||
**arctic-embed-l-v2.0 (설명형 query recall 보강)**: 기본 e5-large 대신
|
||||
Snowflake `arctic-embed-l-v2.0` 임베더를 쓸 수 있다 (1024-dim, opt-in). 측정에서
|
||||
설명형/약어/영문 용어 query 의 recall@10 이 e5 대비 향상됐다. 두 경로:
|
||||
|
||||
```toml
|
||||
# (A) candle 백엔드 — 순수 Rust, in-process (NUMA 안전, Metal GPU 가능):
|
||||
[models.embedding]
|
||||
provider = "candle"
|
||||
model = "snowflake-arctic-embed-l-v2.0" # CLS pooling, query 에 "query: " 접두어
|
||||
# (문서는 무접두어). safetensors ~2GB 다운로드.
|
||||
|
||||
# (B) ollama 백엔드 — 원격/로컬 Ollama 데몬에 위임 (POST /api/embed):
|
||||
[models.embedding]
|
||||
provider = "ollama"
|
||||
model = "snowflake-arctic-embed2" # Ollama 모델 태그 (ollama pull 필요)
|
||||
endpoint = "http://127.0.0.1:11434" # 생략 시 [models.llm].endpoint
|
||||
```
|
||||
|
||||
> ⚠️ e5 → arctic 전환은 `embedding_version` cascade 를 트리거한다 (모델이 다르면
|
||||
> 벡터도 다름). 기존 e5 KB 와 혼용 불가 — 전환 시 **재색인** 필요 (`kebab reset`
|
||||
> 후 재 ingest). 기본값은 e5 라 기존 사용자는 영향 없음.
|
||||
|
||||
**Apple Silicon GPU 가속 (candle / macOS)**: M-시리즈 맥에서 candle 임베딩을
|
||||
GPU(Metal)로 돌리면 CPU 대비 대용량 ingest 가 크게 빨라진다. 빌드 또는 설치 시
|
||||
`embed_metal` feature 를 켠다:
|
||||
|
||||
```bash
|
||||
# 빌드만:
|
||||
cargo build --release --features embed_metal
|
||||
# 전역 설치 (~/.cargo/bin/kebab):
|
||||
cargo install --path crates/kebab-cli --features embed_metal --locked
|
||||
```
|
||||
|
||||
벡터는 CPU candle 과 동일 모델이라 호환되므로, 맥에서 GPU 로 색인한
|
||||
`kebab.sqlite` + `lancedb/` 를 그대로 Linux 서버(CPU candle)로 복사해 질의할 수
|
||||
있다. 색인 로그에 `candle device = Metal (GPU)` 가 보이면 GPU 사용 중. metal
|
||||
feature 는 macOS 전용 (Linux/서버는 기본 CPU 빌드).
|
||||
|
||||
```toml
|
||||
|
||||
[models.llm]
|
||||
endpoint = "http://localhost:11434" # Ollama host:port
|
||||
model = "gemma4:e4b"
|
||||
# request_timeout_secs = 300 # 큰 모델은 늘림. 0 은 disable 이 아니라 "즉시 timeout".
|
||||
|
||||
[ingest.expansion] # doc-side expansion 별칭 (opt-in)
|
||||
enabled = false # true 면 청크당 LLM 호출로 별칭 생성 — 비용 큼.
|
||||
embed_aliases = true # 별칭을 줄별 개별 dense 벡터로 색인.
|
||||
max_aliases_per_chunk = 8
|
||||
|
||||
[search]
|
||||
stale_threshold_days = 30 # search hit / citation 의 stale 플래그 기준 (0 = off).
|
||||
|
||||
@@ -127,7 +182,7 @@ prompt_template_version = "rag-v3" # 답변 언어 = 질문 언어. rag-v1/v2
|
||||
nli_threshold = 0.0 # >0 (예: 0.5) 면 mDeBERTa XNLI groundedness 검증.
|
||||
```
|
||||
|
||||
- **파생물 캐시** — embedding·별칭 결과를 내용 해시로 자동 캐싱한다 (위 「핵심 기능」 참고). 설정 항목 없음.
|
||||
- **파생물 캐시** — 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).
|
||||
|
||||
@@ -19,6 +19,7 @@ kebab-search = { path = "../kebab-search" }
|
||||
kebab-embed = { path = "../kebab-embed" }
|
||||
kebab-embed-local = { path = "../kebab-embed-local" }
|
||||
kebab-embed-candle = { path = "../kebab-embed-candle" }
|
||||
kebab-embed-ollama = { path = "../kebab-embed-ollama" }
|
||||
kebab-llm = { path = "../kebab-llm" }
|
||||
kebab-llm-local = { path = "../kebab-llm-local" }
|
||||
kebab-rag = { path = "../kebab-rag" }
|
||||
@@ -100,6 +101,8 @@ reqwest = { version = "0.12", default-features = false, features = ["blocki
|
||||
# disable path 없음; 이 feature 는 spec §6.3 명시를 honor 하는 role 만.
|
||||
default = ["fts_korean_morphological"]
|
||||
fts_korean_morphological = []
|
||||
# opt-in (macOS): candle embedder runs on the Apple Silicon GPU. See kebab-embed-candle.
|
||||
embed_metal = ["kebab-embed-candle/metal"]
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -45,6 +45,7 @@ use kebab_core::{
|
||||
};
|
||||
use kebab_embed_candle::CandleEmbedder;
|
||||
use kebab_embed_local::FastembedEmbedder;
|
||||
use kebab_embed_ollama::OllamaEmbedder;
|
||||
use kebab_llm_local::OllamaLanguageModel;
|
||||
use kebab_parse_code::{
|
||||
CAstExtractor, CppAstExtractor, GoAstExtractor, JavaAstExtractor, JavascriptAstExtractor,
|
||||
@@ -834,11 +835,13 @@ impl App {
|
||||
if let Some(e) = self.embedder.get() {
|
||||
return Ok(Some(e.clone()));
|
||||
}
|
||||
// Provider branch (Track 1 spec §3). `embeddings_disabled()` above
|
||||
// already handled `"none"`; here we route the live providers.
|
||||
// `fastembed`/`onnx`/(empty) keep the default onnxruntime path
|
||||
// (vectors unchanged — `embedding_version` is preserved); `candle`
|
||||
// selects the pure-Rust NUMA-safe backend.
|
||||
// Provider branch (Track 1 spec §3 + arctic-embedder spec). The
|
||||
// `embeddings_disabled()` check above already handled `"none"`; here we
|
||||
// route the live providers. `fastembed`/`onnx`/(empty) keep the default
|
||||
// onnxruntime path (vectors unchanged — `embedding_version` is
|
||||
// preserved); `candle` selects the pure-Rust NUMA-safe backend (e5 or
|
||||
// arctic via its model registry); `ollama` offloads to a remote
|
||||
// `/api/embed` daemon.
|
||||
let provider = self.config.models.embedding.provider.as_str();
|
||||
let emb: Arc<dyn Embedder + Send + Sync> = match provider {
|
||||
"fastembed" | "onnx" | "" => Arc::new(
|
||||
@@ -847,10 +850,13 @@ impl App {
|
||||
"candle" => Arc::new(
|
||||
CandleEmbedder::new(&self.config).context("kb-app: load CandleEmbedder")?,
|
||||
),
|
||||
"ollama" => Arc::new(
|
||||
OllamaEmbedder::new(&self.config).context("kb-app: load OllamaEmbedder")?,
|
||||
),
|
||||
other => {
|
||||
return Err(anyhow!(
|
||||
"kb-app: unknown embedding provider {other:?}; expected one of \
|
||||
`fastembed` (default), `candle`, or `none` (lexical-only)"
|
||||
`fastembed` (default), `candle`, `ollama`, or `none` (lexical-only)"
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,274 +0,0 @@
|
||||
//! 색인시 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 릴리스");
|
||||
}
|
||||
}
|
||||
@@ -47,11 +47,19 @@ pub struct AggregateCounts {
|
||||
///
|
||||
/// ```text
|
||||
/// ScanStarted < ScanCompleted
|
||||
/// < (AssetStarted [< (PdfOcrStarted < PdfOcrFinished)*] < AssetFinished)*
|
||||
/// < ( AssetStarted
|
||||
/// [< (PdfOcrStarted < PdfOcrFinished)*]
|
||||
/// [< AssetChunked]
|
||||
/// [< AssetTimings]
|
||||
/// < AssetFinished )*
|
||||
/// < (Completed | Aborted)
|
||||
/// ```
|
||||
///
|
||||
/// `[]` = optional, per-PDF asset only (v0.20.0 sub-item 1).
|
||||
/// `[]` = optional. `PdfOcr*` is per-PDF asset only (v0.20.0 sub-item 1).
|
||||
/// `AssetChunked` / `AssetTimings` are the v0.24.0 asset-internal phase
|
||||
/// events: `AssetChunked` fires once right after chunking (markdown /
|
||||
/// image / PDF); `AssetTimings` reports per-phase wall-clock once
|
||||
/// (markdown only).
|
||||
///
|
||||
/// Embed-batch events (`embed_batch_started` / `embed_batch_finished`
|
||||
/// in §2.4a) are reserved for a future iteration and are not emitted
|
||||
@@ -82,6 +90,52 @@ pub enum IngestEvent {
|
||||
result: IngestItemKind,
|
||||
chunks: u32,
|
||||
},
|
||||
/// v0.24.0 (additive): emitted right after an asset is chunked, before
|
||||
/// expansion / embed / store. Surfaces "this document is N chunks"
|
||||
/// immediately so a single large document no longer looks frozen at
|
||||
/// `idx/total` while its per-chunk phases churn. `chunks` is the chunk
|
||||
/// count for asset `idx`.
|
||||
AssetChunked { idx: u32, total: u32, chunks: u32 },
|
||||
/// v0.26.1 (additive): emitted when an asset enters a *slow* internal
|
||||
/// phase, so the interactive progress bar can show **which** phase
|
||||
/// (and which model) is currently running instead of looking frozen.
|
||||
/// `phase` ∈ {`"ocr"`, `"caption"`, `"embed"`}; short phases
|
||||
/// (parse / chunk / store) are intentionally *not* emitted to avoid
|
||||
/// noise. `model` is the model performing the phase — the vision LLM
|
||||
/// id for `ocr` / `caption`, the embedder `model_id` for `embed`
|
||||
/// (`None` when the phase runs without a configured model, e.g. embed
|
||||
/// with no embedder wired). Emitted once per (asset, phase); no
|
||||
/// throttle needed (low frequency). Wire v1 consumers that predate
|
||||
/// this variant simply ignore the unknown `asset_phase` kind.
|
||||
AssetPhase {
|
||||
idx: u32,
|
||||
total: u32,
|
||||
phase: String,
|
||||
model: Option<String>,
|
||||
},
|
||||
/// v0.24.0 (additive): per-phase wall-clock (milliseconds) for asset
|
||||
/// `idx`, emitted once the asset's pipeline finishes. Lets a user see
|
||||
/// *where* the time went (parse / chunk / ocr / caption / embed /
|
||||
/// store) without parsing logs. The markdown path leaves `ocr_ms` /
|
||||
/// `caption_ms` at 0 (no image analysis); the image / PDF paths fill
|
||||
/// them so the slowest-asset summary attributes vision-model time
|
||||
/// correctly. `expansion_ms` is retained for wire compatibility but is
|
||||
/// always 0 since doc-side expansion was removed (HOTFIXES 2026-06-03).
|
||||
/// `ocr_ms` / `caption_ms` (v0.26.1) are additive with serde default 0
|
||||
/// so pre-v0.26.1 consumers deserialize cleanly.
|
||||
AssetTimings {
|
||||
idx: u32,
|
||||
total: u32,
|
||||
parse_ms: u64,
|
||||
chunk_ms: u64,
|
||||
expansion_ms: u64,
|
||||
embed_ms: u64,
|
||||
store_ms: u64,
|
||||
#[serde(default)]
|
||||
ocr_ms: u64,
|
||||
#[serde(default)]
|
||||
caption_ms: u64,
|
||||
},
|
||||
/// Run finished normally. `counts` is the final aggregate.
|
||||
Completed { counts: AggregateCounts },
|
||||
/// Run finished by user cancellation. `counts` is the partial
|
||||
@@ -199,6 +253,121 @@ mod tests {
|
||||
assert_eq!(v.get("media").and_then(|s| s.as_str()), Some("markdown"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn asset_chunked_serializes_with_discriminator() {
|
||||
// v0.24.0 additive variant — `kind` must be snake_case
|
||||
// `asset_chunked` so wire v1 consumers branch on it cleanly.
|
||||
let ev = IngestEvent::AssetChunked {
|
||||
idx: 3,
|
||||
total: 10,
|
||||
chunks: 142,
|
||||
};
|
||||
let v = serde_json::to_value(&ev).unwrap();
|
||||
assert_eq!(
|
||||
v.get("kind").and_then(|s| s.as_str()),
|
||||
Some("asset_chunked")
|
||||
);
|
||||
assert_eq!(v.get("idx").and_then(serde_json::Value::as_u64), Some(3));
|
||||
assert_eq!(
|
||||
v.get("chunks").and_then(serde_json::Value::as_u64),
|
||||
Some(142)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn asset_timings_serializes_all_phase_fields() {
|
||||
let ev = IngestEvent::AssetTimings {
|
||||
idx: 2,
|
||||
total: 7,
|
||||
parse_ms: 12,
|
||||
chunk_ms: 3,
|
||||
expansion_ms: 45_000,
|
||||
embed_ms: 800,
|
||||
store_ms: 20,
|
||||
ocr_ms: 1_200,
|
||||
caption_ms: 3_400,
|
||||
};
|
||||
let v = serde_json::to_value(&ev).unwrap();
|
||||
assert_eq!(
|
||||
v.get("kind").and_then(|s| s.as_str()),
|
||||
Some("asset_timings")
|
||||
);
|
||||
// All phase fields are present (plain u64, always serialized).
|
||||
for (field, want) in [
|
||||
("parse_ms", 12u64),
|
||||
("chunk_ms", 3),
|
||||
("expansion_ms", 45_000),
|
||||
("embed_ms", 800),
|
||||
("store_ms", 20),
|
||||
("ocr_ms", 1_200),
|
||||
("caption_ms", 3_400),
|
||||
] {
|
||||
assert_eq!(
|
||||
v.get(field).and_then(serde_json::Value::as_u64),
|
||||
Some(want),
|
||||
"field {field}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn asset_timings_ocr_caption_default_to_zero_for_legacy_wire() {
|
||||
// v0.26.1 additive: a pre-v0.26.1 wire payload omits ocr_ms /
|
||||
// caption_ms; serde `default` must fill 0 so old producers stay
|
||||
// compatible.
|
||||
let legacy = serde_json::json!({
|
||||
"kind": "asset_timings",
|
||||
"idx": 1, "total": 1,
|
||||
"parse_ms": 5, "chunk_ms": 2, "expansion_ms": 0,
|
||||
"embed_ms": 10, "store_ms": 3
|
||||
});
|
||||
let ev: IngestEvent = serde_json::from_value(legacy).unwrap();
|
||||
match ev {
|
||||
IngestEvent::AssetTimings {
|
||||
ocr_ms,
|
||||
caption_ms,
|
||||
embed_ms,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(ocr_ms, 0);
|
||||
assert_eq!(caption_ms, 0);
|
||||
assert_eq!(embed_ms, 10);
|
||||
}
|
||||
other => panic!("unexpected event: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn asset_phase_serializes_with_discriminator() {
|
||||
// v0.26.1 additive variant — `kind` must be snake_case
|
||||
// `asset_phase`, `phase` is the slow-phase label, `model` the
|
||||
// model id (nullable).
|
||||
let ev = IngestEvent::AssetPhase {
|
||||
idx: 4,
|
||||
total: 12,
|
||||
phase: "ocr".into(),
|
||||
model: Some("gemma4:e4b".into()),
|
||||
};
|
||||
let v = serde_json::to_value(&ev).unwrap();
|
||||
assert_eq!(v.get("kind").and_then(|s| s.as_str()), Some("asset_phase"));
|
||||
assert_eq!(v.get("idx").and_then(serde_json::Value::as_u64), Some(4));
|
||||
assert_eq!(v.get("phase").and_then(|s| s.as_str()), Some("ocr"));
|
||||
assert_eq!(v.get("model").and_then(|s| s.as_str()), Some("gemma4:e4b"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn asset_phase_model_none_serializes_as_null() {
|
||||
let ev = IngestEvent::AssetPhase {
|
||||
idx: 1,
|
||||
total: 1,
|
||||
phase: "embed".into(),
|
||||
model: None,
|
||||
};
|
||||
let v = serde_json::to_value(&ev).unwrap();
|
||||
assert_eq!(v.get("phase").and_then(|s| s.as_str()), Some("embed"));
|
||||
assert!(v.get("model").is_some_and(serde_json::Value::is_null));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ingest_event_completed_has_counts() {
|
||||
let ev = IngestEvent::Completed {
|
||||
|
||||
@@ -63,7 +63,6 @@ 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;
|
||||
@@ -480,6 +479,8 @@ pub fn ingest_with_config_opts(
|
||||
let item = ingest_one_asset(
|
||||
&app,
|
||||
&asset,
|
||||
idx,
|
||||
scanned_count,
|
||||
&parser_version,
|
||||
&chunk_policy,
|
||||
embedder.as_ref(),
|
||||
@@ -1100,6 +1101,8 @@ fn embed_with_cache(
|
||||
fn ingest_one_asset(
|
||||
app: &App,
|
||||
asset: &RawAsset,
|
||||
idx: u32,
|
||||
total: u32,
|
||||
parser_version: &ParserVersion,
|
||||
chunk_policy: &ChunkPolicy,
|
||||
embedder: Option<&Arc<dyn Embedder + Send + Sync>>,
|
||||
@@ -1132,18 +1135,23 @@ fn ingest_one_asset(
|
||||
return ingest_one_image_asset(
|
||||
app,
|
||||
asset,
|
||||
idx,
|
||||
total,
|
||||
chunk_policy,
|
||||
embedder,
|
||||
vector_store,
|
||||
existing_doc_ids,
|
||||
image_pipeline,
|
||||
force_reingest,
|
||||
progress,
|
||||
);
|
||||
}
|
||||
MediaType::Pdf => {
|
||||
return ingest_one_pdf_asset(
|
||||
app,
|
||||
asset,
|
||||
idx,
|
||||
total,
|
||||
chunk_policy,
|
||||
embedder,
|
||||
vector_store,
|
||||
@@ -1252,6 +1260,10 @@ fn ingest_one_asset(
|
||||
return Ok(item);
|
||||
}
|
||||
|
||||
// v0.24.0 phase timing: parse spans from here (byte read) through
|
||||
// `build_canonical_document`, i.e. everything before the chunker runs.
|
||||
let t_parse = std::time::Instant::now();
|
||||
|
||||
let bytes = std::fs::read(&path)
|
||||
.with_context(|| format!("read asset bytes from {}", path.display()))?;
|
||||
|
||||
@@ -1286,75 +1298,30 @@ fn ingest_one_asset(
|
||||
build_canonical_document(asset, metadata, parsed_blocks, parser_version, all_warnings)
|
||||
.context("kb-parse-md::build_canonical_document")?;
|
||||
|
||||
let mut chunks = MdHeadingV1Chunker
|
||||
let parse_ms = u64::try_from(t_parse.elapsed().as_millis()).unwrap_or(u64::MAX);
|
||||
|
||||
let t_chunk = std::time::Instant::now();
|
||||
let chunks = MdHeadingV1Chunker
|
||||
.chunk(&canonical, chunk_policy)
|
||||
.context("kb-chunk::MdHeadingV1Chunker::chunk")?;
|
||||
let chunk_ms = u64::try_from(t_chunk.elapsed().as_millis()).unwrap_or(u64::MAX);
|
||||
|
||||
// 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 빌드 실패 — 별칭 없이 진행"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
// v0.24.0: surface the chunk count immediately, before the (potentially
|
||||
// very slow) expansion / embed phases — so a single large document no
|
||||
// longer looks frozen at `idx/total` while its chunks churn.
|
||||
let total_chunks = u32::try_from(chunks.len()).unwrap_or(u32::MAX);
|
||||
crate::ingest_progress::emit(
|
||||
progress,
|
||||
crate::ingest_progress::IngestEvent::AssetChunked {
|
||||
idx,
|
||||
total,
|
||||
chunks: total_chunks,
|
||||
},
|
||||
);
|
||||
|
||||
// doc-side expansion(별칭) 제거됨 (HOTFIXES 2026-06-03). `expansion_ms`
|
||||
// 는 wire 호환을 위해 AssetTimings 에 남기되 항상 0.
|
||||
let expansion_ms = 0_u64;
|
||||
|
||||
// Stamp chunker + embedding versions so Task 7's skip detection has
|
||||
// data on the second run.
|
||||
@@ -1367,7 +1334,7 @@ fn ingest_one_asset(
|
||||
// (per-document tx semantics per design §5.8); composing them is
|
||||
// the kb-app job. A failure mid-way leaves the DB in a state the
|
||||
// next ingest run can re-converge (UPSERT + DELETE-then-INSERT).
|
||||
purge_vector_orphans_for_workspace_path(app, asset, vector_store)?;
|
||||
let t_store = std::time::Instant::now();
|
||||
app.sqlite
|
||||
.put_asset_with_bytes(asset, &bytes)
|
||||
.context("DocumentStore::put_asset_with_bytes")?;
|
||||
@@ -1380,8 +1347,27 @@ fn ingest_one_asset(
|
||||
app.sqlite
|
||||
.put_chunks(&canonical.doc_id, &chunks)
|
||||
.context("DocumentStore::put_chunks")?;
|
||||
let store_ms = u64::try_from(t_store.elapsed().as_millis()).unwrap_or(u64::MAX);
|
||||
|
||||
// Embed + vector upsert (only when both sides are configured).
|
||||
// v0.26.1: surface the embed phase + model so a long embed run reads as
|
||||
// "embedding(<model>)…" rather than a frozen bar (markdown path too).
|
||||
crate::ingest_progress::emit(
|
||||
progress,
|
||||
crate::ingest_progress::IngestEvent::AssetPhase {
|
||||
idx,
|
||||
total,
|
||||
phase: "embed".to_string(),
|
||||
model: embedder.map(|e| e.model_id().0),
|
||||
},
|
||||
);
|
||||
let t_embed = std::time::Instant::now();
|
||||
// Stale-vector purge is LanceDB I/O, so it belongs to the embed/vector
|
||||
// phase — not the SQLite `store` phase. Keeping it here makes `store_ms`
|
||||
// mean "SQLite persist only" and `embed_ms` cover all vector-store work
|
||||
// (purge + upsert), so per-phase timings attribute the bottleneck
|
||||
// correctly (review fix). Runs before any new upsert, as before.
|
||||
purge_vector_orphans_for_workspace_path(app, asset, vector_store)?;
|
||||
let mut emb_cache_hit = 0_usize;
|
||||
let mut emb_cache_miss = 0_usize;
|
||||
if let (Some(emb), Some(vec_store)) = (embedder, vector_store) {
|
||||
@@ -1431,97 +1417,38 @@ fn ingest_one_asset(
|
||||
dimensions,
|
||||
})
|
||||
.collect();
|
||||
// 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")?;
|
||||
vec_store.upsert(&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)?;
|
||||
let embed_ms = u64::try_from(t_embed.elapsed().as_millis()).unwrap_or(u64::MAX);
|
||||
|
||||
// 검증용 hit/miss 카운트 노출(§3.4 / §6): warm 재색인이 LLM·embed 0회임을
|
||||
// v0.24.0: phase-timing breakdown for this asset (markdown path).
|
||||
// ocr_ms / caption_ms are 0 — markdown has no image-analysis phases.
|
||||
crate::ingest_progress::emit(
|
||||
progress,
|
||||
crate::ingest_progress::IngestEvent::AssetTimings {
|
||||
idx,
|
||||
total,
|
||||
parse_ms,
|
||||
chunk_ms,
|
||||
expansion_ms,
|
||||
embed_ms,
|
||||
store_ms,
|
||||
ocr_ms: 0,
|
||||
caption_ms: 0,
|
||||
},
|
||||
);
|
||||
|
||||
// 검증용 hit/miss 카운트 노출(§3.4 / §6): warm 재색인이 embed 0회임을
|
||||
// 로그로 확인. tracing target 은 stderr 로 흐른다.
|
||||
if alias_cache_hit + alias_cache_miss + emb_cache_hit + emb_cache_miss > 0 {
|
||||
if 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}"
|
||||
"derivation cache: embedding hit={emb_cache_hit} miss={emb_cache_miss}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1564,12 +1491,15 @@ fn ingest_one_asset(
|
||||
fn ingest_one_image_asset(
|
||||
app: &App,
|
||||
asset: &RawAsset,
|
||||
idx: u32,
|
||||
total: u32,
|
||||
chunk_policy: &ChunkPolicy,
|
||||
embedder: Option<&Arc<dyn Embedder + Send + Sync>>,
|
||||
vector_store: Option<&Arc<kebab_store_vector::LanceVectorStore>>,
|
||||
existing_doc_ids: &std::collections::HashSet<String>,
|
||||
image_pipeline: &ImagePipeline<'_>,
|
||||
force_reingest: bool,
|
||||
progress: Option<&std::sync::mpsc::Sender<crate::ingest_progress::IngestEvent>>,
|
||||
) -> anyhow::Result<kebab_core::IngestItem> {
|
||||
let ocr_engine = image_pipeline.ocr_engine;
|
||||
let caption_llm = image_pipeline.caption_llm;
|
||||
@@ -1629,9 +1559,11 @@ fn ingest_one_image_asset(
|
||||
workspace_root: &workspace_root,
|
||||
config: &extract_config,
|
||||
};
|
||||
let t_parse = std::time::Instant::now();
|
||||
let mut canonical = app
|
||||
.extract_for(&asset.media_type, &ctx, &bytes)
|
||||
.context("kb-app::extract_for (image)")?;
|
||||
let parse_ms = u64::try_from(t_parse.elapsed().as_millis()).unwrap_or(u64::MAX);
|
||||
|
||||
// 2 + 3. Apply OCR / caption when their adapters exist. Both are
|
||||
// Lenient — failure is captured into Provenance Warning,
|
||||
@@ -1646,44 +1578,74 @@ fn ingest_one_image_asset(
|
||||
let lang_hint = lang_hint_from_doc(&canonical);
|
||||
let now = time::OffsetDateTime::now_utc();
|
||||
let mut warning_notes: Vec<String> = Vec::new();
|
||||
// v0.26.1: vision phases (OCR / caption) are the usual bottleneck on an
|
||||
// image-heavy vault and emitted no progress before — so the bar looked
|
||||
// frozen. Surface each as an `AssetPhase` and measure its wall-clock for
|
||||
// the slowest-asset summary.
|
||||
let mut ocr_ms = 0_u64;
|
||||
let mut caption_ms = 0_u64;
|
||||
match canonical.blocks.first_mut() {
|
||||
Some(Block::ImageRef(block)) => {
|
||||
if let Some(engine) = ocr_engine
|
||||
&& let Err(e) = apply_ocr(
|
||||
if let Some(engine) = ocr_engine {
|
||||
crate::ingest_progress::emit(
|
||||
progress,
|
||||
crate::ingest_progress::IngestEvent::AssetPhase {
|
||||
idx,
|
||||
total,
|
||||
phase: "ocr".to_string(),
|
||||
model: Some(engine.model().to_string()),
|
||||
},
|
||||
);
|
||||
let t_ocr = std::time::Instant::now();
|
||||
let res = apply_ocr(
|
||||
engine,
|
||||
&bytes,
|
||||
block,
|
||||
lang_hint.as_ref(),
|
||||
&mut canonical.provenance.events,
|
||||
)
|
||||
{
|
||||
record_image_analysis_failure(
|
||||
asset,
|
||||
&mut canonical.provenance.events,
|
||||
&mut warning_notes,
|
||||
"OcrFailed",
|
||||
e,
|
||||
now,
|
||||
);
|
||||
ocr_ms = u64::try_from(t_ocr.elapsed().as_millis()).unwrap_or(u64::MAX);
|
||||
if let Err(e) = res {
|
||||
record_image_analysis_failure(
|
||||
asset,
|
||||
&mut canonical.provenance.events,
|
||||
&mut warning_notes,
|
||||
"OcrFailed",
|
||||
e,
|
||||
now,
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(llm) = caption_llm
|
||||
&& let Err(e) = apply_caption(
|
||||
if let Some(llm) = caption_llm {
|
||||
crate::ingest_progress::emit(
|
||||
progress,
|
||||
crate::ingest_progress::IngestEvent::AssetPhase {
|
||||
idx,
|
||||
total,
|
||||
phase: "caption".to_string(),
|
||||
model: Some(llm.model_ref().id),
|
||||
},
|
||||
);
|
||||
let t_caption = std::time::Instant::now();
|
||||
let res = apply_caption(
|
||||
llm,
|
||||
&bytes,
|
||||
block,
|
||||
lang_hint.as_ref(),
|
||||
&app.config,
|
||||
&mut canonical.provenance.events,
|
||||
)
|
||||
{
|
||||
record_image_analysis_failure(
|
||||
asset,
|
||||
&mut canonical.provenance.events,
|
||||
&mut warning_notes,
|
||||
"CaptionFailed",
|
||||
e,
|
||||
now,
|
||||
);
|
||||
caption_ms = u64::try_from(t_caption.elapsed().as_millis()).unwrap_or(u64::MAX);
|
||||
if let Err(e) = res {
|
||||
record_image_analysis_failure(
|
||||
asset,
|
||||
&mut canonical.provenance.events,
|
||||
&mut warning_notes,
|
||||
"CaptionFailed",
|
||||
e,
|
||||
now,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
// P6-1 contract: image documents always have exactly one
|
||||
@@ -1718,9 +1680,21 @@ fn ingest_one_image_asset(
|
||||
// `Block::ImageRef` arm already produces a single chunk per
|
||||
// image (P1-5). The chunk text now follows the (β) plain-concat
|
||||
// contract per the kebab-chunk render_block_text update.
|
||||
let t_chunk = std::time::Instant::now();
|
||||
let chunks = MdHeadingV1Chunker
|
||||
.chunk(&canonical, chunk_policy)
|
||||
.context("kb-chunk::MdHeadingV1Chunker::chunk (image)")?;
|
||||
let chunk_ms = u64::try_from(t_chunk.elapsed().as_millis()).unwrap_or(u64::MAX);
|
||||
|
||||
// v0.24.0: surface chunk count for the image path too.
|
||||
crate::ingest_progress::emit(
|
||||
progress,
|
||||
crate::ingest_progress::IngestEvent::AssetChunked {
|
||||
idx,
|
||||
total,
|
||||
chunks: u32::try_from(chunks.len()).unwrap_or(u32::MAX),
|
||||
},
|
||||
);
|
||||
|
||||
// 5. Persist + embed — identical sequence to markdown.
|
||||
// Stamp chunker + embedding versions (image uses MdHeadingV1Chunker
|
||||
@@ -1729,6 +1703,7 @@ fn ingest_one_image_asset(
|
||||
if let Some(emb) = embedder {
|
||||
canonical.last_embedding_version = Some(emb.model_version());
|
||||
}
|
||||
let t_store = std::time::Instant::now();
|
||||
purge_vector_orphans_for_workspace_path(app, asset, vector_store)?;
|
||||
app.sqlite
|
||||
.put_asset_with_bytes(asset, &bytes)
|
||||
@@ -1742,7 +1717,18 @@ fn ingest_one_image_asset(
|
||||
app.sqlite
|
||||
.put_chunks(&canonical.doc_id, &chunks)
|
||||
.context("DocumentStore::put_chunks (image)")?;
|
||||
let store_ms = u64::try_from(t_store.elapsed().as_millis()).unwrap_or(u64::MAX);
|
||||
|
||||
crate::ingest_progress::emit(
|
||||
progress,
|
||||
crate::ingest_progress::IngestEvent::AssetPhase {
|
||||
idx,
|
||||
total,
|
||||
phase: "embed".to_string(),
|
||||
model: embedder.map(|e| e.model_id().0),
|
||||
},
|
||||
);
|
||||
let t_embed = std::time::Instant::now();
|
||||
if let (Some(emb), Some(vec_store)) = (embedder, vector_store)
|
||||
&& !chunks.is_empty()
|
||||
{
|
||||
@@ -1783,6 +1769,25 @@ fn ingest_one_image_asset(
|
||||
.upsert(&records)
|
||||
.context("VectorStore::upsert (image)")?;
|
||||
}
|
||||
let embed_ms = u64::try_from(t_embed.elapsed().as_millis()).unwrap_or(u64::MAX);
|
||||
|
||||
// v0.26.1: per-phase timing for the image path — ocr_ms / caption_ms
|
||||
// carry the vision-model cost so the slowest-asset summary attributes
|
||||
// an image-heavy run's bottleneck correctly.
|
||||
crate::ingest_progress::emit(
|
||||
progress,
|
||||
crate::ingest_progress::IngestEvent::AssetTimings {
|
||||
idx,
|
||||
total,
|
||||
parse_ms,
|
||||
chunk_ms,
|
||||
expansion_ms: 0,
|
||||
embed_ms,
|
||||
store_ms,
|
||||
ocr_ms,
|
||||
caption_ms,
|
||||
},
|
||||
);
|
||||
|
||||
let kind = if existing_doc_ids.contains(&canonical.doc_id.0) {
|
||||
kebab_core::IngestItemKind::Updated
|
||||
@@ -1840,49 +1845,6 @@ 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`.
|
||||
@@ -1910,15 +1872,8 @@ 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(&to_delete)
|
||||
.delete_by_chunk_ids(&stale)
|
||||
.context("VectorStore::delete_by_chunk_ids (parser-bump orphans)")?;
|
||||
}
|
||||
}
|
||||
@@ -1962,15 +1917,8 @@ 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(&to_delete)
|
||||
.delete_by_chunk_ids(&stale)
|
||||
.context("VectorStore::delete_by_chunk_ids (orphan vector cleanup)")?;
|
||||
tracing::debug!(
|
||||
target: "kebab-app",
|
||||
@@ -2070,14 +2018,7 @@ fn sweep_deleted_files(
|
||||
if let Some(vec) = vector_store {
|
||||
if !chunk_ids.is_empty() {
|
||||
use kebab_core::VectorStore as _;
|
||||
// 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) {
|
||||
if let Err(e) = vec.delete_by_chunk_ids(&chunk_ids) {
|
||||
tracing::warn!(
|
||||
target: "kebab-app",
|
||||
path = %stored_path.0,
|
||||
@@ -2127,6 +2068,8 @@ fn sweep_deleted_files(
|
||||
fn ingest_one_pdf_asset(
|
||||
app: &App,
|
||||
asset: &RawAsset,
|
||||
idx: u32,
|
||||
total: u32,
|
||||
chunk_policy: &ChunkPolicy,
|
||||
embedder: Option<&Arc<dyn Embedder + Send + Sync>>,
|
||||
vector_store: Option<&Arc<kebab_store_vector::LanceVectorStore>>,
|
||||
@@ -2188,9 +2131,11 @@ fn ingest_one_pdf_asset(
|
||||
workspace_root: &workspace_root,
|
||||
config: &extract_config,
|
||||
};
|
||||
let t_parse = std::time::Instant::now();
|
||||
let mut canonical = app
|
||||
.extract_for(&asset.media_type, &ctx, &bytes)
|
||||
.context("kb-app::extract_for (pdf)")?;
|
||||
let parse_ms = u64::try_from(t_parse.elapsed().as_millis()).unwrap_or(u64::MAX);
|
||||
|
||||
// v0.20 sub-item 1: post-extract OCR enrichment (PR #187 registry
|
||||
// dispatch invariant 보존 — extract_for 가 normal entry).
|
||||
@@ -2326,9 +2271,21 @@ fn ingest_one_pdf_asset(
|
||||
// validates every block carries `SourceSpan::Page`; failure here
|
||||
// means the parser drifted from its contract.
|
||||
let chunker = PdfPageV1Chunker;
|
||||
let t_chunk = std::time::Instant::now();
|
||||
let chunks = chunker
|
||||
.chunk(&canonical, chunk_policy)
|
||||
.context("kb-chunk::PdfPageV1Chunker::chunk")?;
|
||||
let chunk_ms = u64::try_from(t_chunk.elapsed().as_millis()).unwrap_or(u64::MAX);
|
||||
|
||||
// v0.24.0: surface chunk count for the PDF path too.
|
||||
crate::ingest_progress::emit(
|
||||
progress,
|
||||
crate::ingest_progress::IngestEvent::AssetChunked {
|
||||
idx,
|
||||
total,
|
||||
chunks: u32::try_from(chunks.len()).unwrap_or(u32::MAX),
|
||||
},
|
||||
);
|
||||
|
||||
// Stamp chunker + embedding versions so Task 7's skip detection has
|
||||
// data on the second run.
|
||||
@@ -2337,6 +2294,7 @@ fn ingest_one_pdf_asset(
|
||||
canonical.last_embedding_version = Some(emb.model_version());
|
||||
}
|
||||
|
||||
let t_store = std::time::Instant::now();
|
||||
purge_vector_orphans_for_workspace_path(app, asset, vector_store)?;
|
||||
app.sqlite
|
||||
.put_asset_with_bytes(asset, &bytes)
|
||||
@@ -2350,7 +2308,18 @@ fn ingest_one_pdf_asset(
|
||||
app.sqlite
|
||||
.put_chunks(&canonical.doc_id, &chunks)
|
||||
.context("DocumentStore::put_chunks (pdf)")?;
|
||||
let store_ms = u64::try_from(t_store.elapsed().as_millis()).unwrap_or(u64::MAX);
|
||||
|
||||
crate::ingest_progress::emit(
|
||||
progress,
|
||||
crate::ingest_progress::IngestEvent::AssetPhase {
|
||||
idx,
|
||||
total,
|
||||
phase: "embed".to_string(),
|
||||
model: embedder.map(|e| e.model_id().0),
|
||||
},
|
||||
);
|
||||
let t_embed = std::time::Instant::now();
|
||||
if let (Some(emb), Some(vec_store)) = (embedder, vector_store)
|
||||
&& !chunks.is_empty()
|
||||
{
|
||||
@@ -2389,6 +2358,25 @@ fn ingest_one_pdf_asset(
|
||||
.upsert(&records)
|
||||
.context("VectorStore::upsert (pdf)")?;
|
||||
}
|
||||
let embed_ms = u64::try_from(t_embed.elapsed().as_millis()).unwrap_or(u64::MAX);
|
||||
|
||||
// v0.26.1: per-phase timing for the PDF path. `ocr_ms` reuses the
|
||||
// page-OCR total already computed above so a scanned-PDF run's OCR cost
|
||||
// shows up in the slowest-asset summary; caption is markdown/image-only.
|
||||
crate::ingest_progress::emit(
|
||||
progress,
|
||||
crate::ingest_progress::IngestEvent::AssetTimings {
|
||||
idx,
|
||||
total,
|
||||
parse_ms,
|
||||
chunk_ms,
|
||||
expansion_ms: 0,
|
||||
embed_ms,
|
||||
store_ms,
|
||||
ocr_ms: pdf_ocr_ms_total.unwrap_or(0),
|
||||
caption_ms: 0,
|
||||
},
|
||||
);
|
||||
|
||||
let kind = if existing_doc_ids.contains(&canonical.doc_id.0) {
|
||||
kebab_core::IngestItemKind::Updated
|
||||
@@ -3441,48 +3429,3 @@ fn check_kebabignore_match(
|
||||
.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 만");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ fn migrate_writes_backup_and_atomic_with_dry_run_noop() {
|
||||
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]"));
|
||||
assert!(new.contains("[ingest.code]"));
|
||||
|
||||
// 멱등: 재실행 changed=false.
|
||||
let report = kebab_app::config_migrate_with_config_path(Some(&cfg), false).unwrap();
|
||||
@@ -47,8 +47,11 @@ fn migrate_missing_file_errors() {
|
||||
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]"));
|
||||
assert!(
|
||||
text.contains("code ingest skip 정책"),
|
||||
"section comment missing:\n{text}"
|
||||
);
|
||||
assert!(text.contains("[ingest.code]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -69,40 +69,74 @@ fn progress_event_sequence_matches_design_section_2_4a() {
|
||||
other => panic!("expected Completed last, got {other:?}"),
|
||||
}
|
||||
|
||||
// Middle: 3 AssetStarted/AssetFinished pairs in monotonic idx order.
|
||||
let asset_events: Vec<&IngestEvent> = events[2..events.len() - 1].iter().collect();
|
||||
assert_eq!(
|
||||
asset_events.len(),
|
||||
6,
|
||||
"expected 3 (Started + Finished) pairs, got {asset_events:?}"
|
||||
);
|
||||
for (chunk_idx, pair) in asset_events.chunks(2).enumerate() {
|
||||
let expected_idx = chunk_idx as u32 + 1;
|
||||
match (pair[0], pair[1]) {
|
||||
(
|
||||
IngestEvent::AssetStarted {
|
||||
idx: si,
|
||||
total: st,
|
||||
media,
|
||||
..
|
||||
},
|
||||
IngestEvent::AssetFinished {
|
||||
idx: fi,
|
||||
total: ft,
|
||||
result,
|
||||
chunks,
|
||||
},
|
||||
) => {
|
||||
assert_eq!(*si, expected_idx, "Started idx mismatch: {pair:?}");
|
||||
assert_eq!(*fi, expected_idx, "Finished idx mismatch: {pair:?}");
|
||||
assert_eq!(*st, 3, "Started total mismatch");
|
||||
assert_eq!(*ft, 3, "Finished total mismatch");
|
||||
assert_eq!(media, "markdown", "fixture is markdown only");
|
||||
assert_eq!(*result, IngestItemKind::New, "first ingest → New");
|
||||
assert!(*chunks >= 1, "chunks: {pair:?}");
|
||||
// Middle (v0.24.0 ordering invariant §2.4a): per asset the stream is
|
||||
// AssetStarted < AssetChunked < [ExpansionProgress*] < AssetTimings
|
||||
// < AssetFinished
|
||||
// Expansion is disabled in the lexical fixture, so no ExpansionProgress
|
||||
// frames appear here — but AssetChunked + AssetTimings are emitted for
|
||||
// every markdown asset.
|
||||
let middle = &events[2..events.len() - 1];
|
||||
|
||||
// 3 AssetStarted events, monotonic idx 1..=3, all markdown, total = 3.
|
||||
let started: Vec<u32> = middle
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
IngestEvent::AssetStarted {
|
||||
idx, total, media, ..
|
||||
} => {
|
||||
assert_eq!(*total, 3, "Started total mismatch: {e:?}");
|
||||
assert_eq!(media, "markdown", "fixture is markdown only: {e:?}");
|
||||
Some(*idx)
|
||||
}
|
||||
other => panic!("expected Started+Finished pair, got {other:?}"),
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(started, vec![1, 2, 3], "AssetStarted idx order: {middle:?}");
|
||||
|
||||
// 3 AssetFinished events, monotonic idx 1..=3, each New with ≥1 chunk.
|
||||
let finished: Vec<u32> = middle
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
IngestEvent::AssetFinished {
|
||||
idx,
|
||||
total,
|
||||
result,
|
||||
chunks,
|
||||
} => {
|
||||
assert_eq!(*total, 3, "Finished total mismatch: {e:?}");
|
||||
assert_eq!(*result, IngestItemKind::New, "first ingest → New: {e:?}");
|
||||
assert!(*chunks >= 1, "chunks: {e:?}");
|
||||
Some(*idx)
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(finished, vec![1, 2, 3], "AssetFinished idx order: {middle:?}");
|
||||
|
||||
// v0.24.0 additive events: exactly one AssetChunked + one AssetTimings
|
||||
// per asset, each strictly bracketed by that asset's Started / Finished.
|
||||
for target in 1u32..=3 {
|
||||
let started_at = middle
|
||||
.iter()
|
||||
.position(|e| matches!(e, IngestEvent::AssetStarted { idx, .. } if *idx == target))
|
||||
.unwrap_or_else(|| panic!("missing AssetStarted for idx {target}: {middle:?}"));
|
||||
let finished_at = middle
|
||||
.iter()
|
||||
.position(|e| matches!(e, IngestEvent::AssetFinished { idx, .. } if *idx == target))
|
||||
.unwrap_or_else(|| panic!("missing AssetFinished for idx {target}: {middle:?}"));
|
||||
let chunked_at = middle
|
||||
.iter()
|
||||
.position(|e| matches!(e, IngestEvent::AssetChunked { idx, chunks, .. } if *idx == target && *chunks >= 1))
|
||||
.unwrap_or_else(|| panic!("missing AssetChunked for idx {target}: {middle:?}"));
|
||||
let timings_at = middle
|
||||
.iter()
|
||||
.position(|e| matches!(e, IngestEvent::AssetTimings { idx, .. } if *idx == target))
|
||||
.unwrap_or_else(|| panic!("missing AssetTimings for idx {target}: {middle:?}"));
|
||||
assert!(
|
||||
started_at < chunked_at && chunked_at < timings_at && timings_at < finished_at,
|
||||
"idx {target} ordering: started={started_at} chunked={chunked_at} \
|
||||
timings={timings_at} finished={finished_at}: {middle:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -111,7 +111,8 @@ fn first_ingest_bumps_corpus_revision() {
|
||||
store_before.run_migrations().unwrap();
|
||||
// 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.)
|
||||
// (V012 derivation_cache + V013 drop-chunk-aliases are structural/additive
|
||||
// — neither bumps corpus_revision.)
|
||||
let baseline = store_before.corpus_revision();
|
||||
assert_eq!(baseline, 3, "fresh store post-V011 baseline = 3");
|
||||
|
||||
|
||||
@@ -152,7 +152,6 @@ fn make_chunk(
|
||||
token_estimate,
|
||||
chunker_version: chunker_version.clone(),
|
||||
policy_hash: base_policy_hash.to_string(),
|
||||
aliases: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -154,7 +154,6 @@ fn make_chunk(
|
||||
token_estimate,
|
||||
chunker_version: chunker_version.clone(),
|
||||
policy_hash: base_policy_hash.to_string(),
|
||||
aliases: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -154,7 +154,6 @@ fn make_chunk(
|
||||
token_estimate,
|
||||
chunker_version: chunker_version.clone(),
|
||||
policy_hash: base_policy_hash.to_string(),
|
||||
aliases: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -154,7 +154,6 @@ fn make_chunk(
|
||||
token_estimate,
|
||||
chunker_version: chunker_version.clone(),
|
||||
policy_hash: base_policy_hash.to_string(),
|
||||
aliases: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -154,7 +154,6 @@ fn make_chunk(
|
||||
token_estimate,
|
||||
chunker_version: chunker_version.clone(),
|
||||
policy_hash: base_policy_hash.to_string(),
|
||||
aliases: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -154,7 +154,6 @@ fn make_chunk(
|
||||
token_estimate,
|
||||
chunker_version: chunker_version.clone(),
|
||||
policy_hash: base_policy_hash.to_string(),
|
||||
aliases: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -154,7 +154,6 @@ fn make_chunk(
|
||||
token_estimate,
|
||||
chunker_version: chunker_version.clone(),
|
||||
policy_hash: base_policy_hash.to_string(),
|
||||
aliases: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -154,7 +154,6 @@ fn make_chunk(
|
||||
token_estimate,
|
||||
chunker_version: chunker_version.clone(),
|
||||
policy_hash: base_policy_hash.to_string(),
|
||||
aliases: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -154,7 +154,6 @@ fn make_chunk(
|
||||
token_estimate,
|
||||
chunker_version: chunker_version.clone(),
|
||||
policy_hash: base_policy_hash.to_string(),
|
||||
aliases: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -339,7 +339,6 @@ fn build_chunk(
|
||||
token_estimate,
|
||||
chunker_version: chunker_version.clone(),
|
||||
policy_hash: policy_hash.to_string(),
|
||||
aliases: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -177,7 +177,6 @@ impl Chunker for PdfPageV1Chunker {
|
||||
token_estimate,
|
||||
chunker_version: chunker_version.clone(),
|
||||
policy_hash: base_policy_hash.clone(),
|
||||
aliases: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,6 +196,5 @@ fn build_chunk_from_span(
|
||||
token_estimate,
|
||||
chunker_version: chunker_version.clone(),
|
||||
policy_hash: base_policy_hash.to_string(),
|
||||
aliases: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
[
|
||||
{
|
||||
"aliases": null,
|
||||
"block_ids": [
|
||||
"8149e12ca002489acb4a0f74c97a061a"
|
||||
],
|
||||
@@ -23,7 +22,6 @@
|
||||
"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"
|
||||
],
|
||||
@@ -46,7 +44,6 @@
|
||||
"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"
|
||||
],
|
||||
@@ -69,7 +66,6 @@
|
||||
"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,6 +1,5 @@
|
||||
[
|
||||
{
|
||||
"aliases": null,
|
||||
"block_ids": [
|
||||
"53292605459065d170cd36c118e20546"
|
||||
],
|
||||
@@ -23,7 +22,6 @@
|
||||
"tokenized_korean_text": "# include < string > # include < vector > namespace kebab {"
|
||||
},
|
||||
{
|
||||
"aliases": null,
|
||||
"block_ids": [
|
||||
"f349acad94c9fa4cf9ad1c0a93e83610"
|
||||
],
|
||||
@@ -46,7 +44,6 @@
|
||||
"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"
|
||||
],
|
||||
@@ -69,7 +66,6 @@
|
||||
"tokenized_korean_text": "template < typename T > T identity ( T value ) { return value ; }"
|
||||
},
|
||||
{
|
||||
"aliases": null,
|
||||
"block_ids": [
|
||||
"1754cb6b971f6a4cb292f144a4f0570b"
|
||||
],
|
||||
@@ -92,7 +88,6 @@
|
||||
"tokenized_korean_text": "void global _ helper ( ) { / / free function in kebab namespace }"
|
||||
},
|
||||
{
|
||||
"aliases": null,
|
||||
"block_ids": [
|
||||
"14b5f3393d6d25f822f5b70763d24acd"
|
||||
],
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
[
|
||||
{
|
||||
"aliases": null,
|
||||
"block_ids": [
|
||||
"c182bf37e32c7fc1b868bd617f8eaf66"
|
||||
],
|
||||
@@ -23,7 +22,6 @@
|
||||
"tokenized_korean_text": "import ( \" fmt \" \" os \" \" strings \" )"
|
||||
},
|
||||
{
|
||||
"aliases": null,
|
||||
"block_ids": [
|
||||
"c9992cdcfdf3c2a7700a4abc4782a8a4"
|
||||
],
|
||||
@@ -46,7 +44,6 @@
|
||||
"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"
|
||||
],
|
||||
@@ -69,7 +66,6 @@
|
||||
"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"
|
||||
],
|
||||
@@ -92,7 +88,6 @@
|
||||
"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"
|
||||
],
|
||||
@@ -115,7 +110,6 @@
|
||||
"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"
|
||||
],
|
||||
@@ -138,7 +132,6 @@
|
||||
"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"
|
||||
],
|
||||
@@ -161,7 +154,6 @@
|
||||
"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"
|
||||
],
|
||||
@@ -184,7 +176,6 @@
|
||||
"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"
|
||||
],
|
||||
@@ -207,7 +198,6 @@
|
||||
"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"
|
||||
],
|
||||
@@ -230,7 +220,6 @@
|
||||
"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
@@ -51,5 +51,10 @@ tempfile = { workspace = true }
|
||||
rusqlite = { workspace = true }
|
||||
time = { workspace = true }
|
||||
|
||||
[features]
|
||||
# opt-in (macOS): build the `kebab` binary with candle on the Apple Silicon GPU.
|
||||
# cargo build --release --features embed_metal
|
||||
embed_metal = ["kebab-app/embed_metal"]
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -632,6 +632,24 @@ fn run(cli: &Cli) -> anyhow::Result<()> {
|
||||
.map(|v| v.eq_ignore_ascii_case("plain"))
|
||||
.unwrap_or(false);
|
||||
let mode = progress::ProgressMode::from_flags(cli.json, cli.quiet, plain_env);
|
||||
|
||||
// Surface the active embedding backend/device on the terminal so the
|
||||
// user sees it without grepping kb.log (the per-device tracing line
|
||||
// only lands in the log file at --verbose). Suppressed under
|
||||
// --json/--quiet. The Metal note reflects the build (`embed_metal`);
|
||||
// the confirmed runtime device is in kb.log (`candle device = ...`).
|
||||
if !cli.json && !cli.quiet {
|
||||
let backend = match cfg.models.embedding.provider.as_str() {
|
||||
"candle" if cfg!(feature = "embed_metal") => "candle (Metal/GPU 빌드)",
|
||||
"candle" => "candle (CPU, 순수 Rust)",
|
||||
"fastembed" | "onnx" | "" => "fastembed (onnxruntime)",
|
||||
"none" => "비활성 (lexical-only)",
|
||||
other => other,
|
||||
};
|
||||
eprintln!("임베딩 백엔드: {backend} · 모델 {} ({}-dim)",
|
||||
cfg.models.embedding.model, cfg.models.embedding.dimensions);
|
||||
}
|
||||
|
||||
let (tx, rx) = std::sync::mpsc::channel::<kebab_app::IngestEvent>();
|
||||
let display_handle =
|
||||
std::thread::spawn(move || progress::ProgressDisplay::new(mode).run(rx));
|
||||
|
||||
@@ -19,16 +19,23 @@
|
||||
//! `Sender` end is dropped (i.e. when `ingest_with_config_progress`
|
||||
//! returns).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::io::{IsTerminal, Write};
|
||||
use std::sync::mpsc::Receiver;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Instant;
|
||||
|
||||
use indicatif::{ProgressBar, ProgressDrawTarget, ProgressStyle};
|
||||
use indicatif::{ProgressBar, ProgressDrawTarget, ProgressState, ProgressStyle};
|
||||
use kebab_app::IngestEvent;
|
||||
use time::OffsetDateTime;
|
||||
use time::format_description::well_known::Rfc3339;
|
||||
|
||||
use crate::wire;
|
||||
|
||||
/// v0.26.1: number of slowest assets surfaced in the end-of-run summary.
|
||||
/// Constant for now (spec defers the config knob).
|
||||
const SLOWEST_TOP_N: usize = 5;
|
||||
|
||||
/// Rendering mode for `ProgressDisplay`. The mode is fixed at
|
||||
/// construction — each `kebab ingest` invocation is a single mode
|
||||
/// (chosen from `--json` plus `IsTerminal` detection).
|
||||
@@ -65,11 +72,33 @@ impl ProgressMode {
|
||||
pub struct ProgressDisplay {
|
||||
mode: ProgressMode,
|
||||
bar: Option<ProgressBar>,
|
||||
/// v0.26.1 heartbeat: start `Instant` of the asset currently in
|
||||
/// flight, shared with the bar's steady-tick custom template key so
|
||||
/// the `(Ns)` elapsed counter advances *between* events (the drain
|
||||
/// loop blocks on `recv()`, so without the ticker the counter would
|
||||
/// freeze). `None` while scanning / between assets / after completion.
|
||||
asset_start: Arc<Mutex<Option<Instant>>>,
|
||||
/// v0.26.1: workspace path of the asset currently in flight — set on
|
||||
/// `AssetStarted`, reused by `AssetPhase` to render `{path} · {phase}…`.
|
||||
current_path: Option<String>,
|
||||
/// v0.26.1 slowest summary: idx → path, captured from `AssetStarted`
|
||||
/// so `AssetTimings` (which only carries `idx`) can name the asset.
|
||||
asset_paths: HashMap<u32, String>,
|
||||
/// v0.26.1 slowest summary: (path, total_ms) per asset that reported
|
||||
/// `AssetTimings`. Sorted + truncated to top-N on `Completed`.
|
||||
timings: Vec<(String, u64)>,
|
||||
}
|
||||
|
||||
impl ProgressDisplay {
|
||||
pub fn new(mode: ProgressMode) -> Self {
|
||||
Self { mode, bar: None }
|
||||
Self {
|
||||
mode,
|
||||
bar: None,
|
||||
asset_start: Arc::new(Mutex::new(None)),
|
||||
current_path: None,
|
||||
asset_paths: HashMap::new(),
|
||||
timings: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Block until `rx` returns `Err` (sender dropped). Renders one
|
||||
@@ -120,15 +149,43 @@ impl ProgressDisplay {
|
||||
}
|
||||
IngestEvent::ScanCompleted { total } => {
|
||||
if let Some(bar) = self.bar.as_mut() {
|
||||
bar.disable_steady_tick();
|
||||
bar.set_length(u64::from(*total));
|
||||
bar.set_position(0);
|
||||
// v0.26.1: a custom `{asset_elapsed}` key reads the shared
|
||||
// per-asset start `Instant` and appends ` (Ns)`. Combined
|
||||
// with the steady tick below, the elapsed counter advances
|
||||
// even while the drain loop is blocked on `recv()` waiting
|
||||
// for the next (possibly very slow) phase event.
|
||||
let asset_start = Arc::clone(&self.asset_start);
|
||||
bar.set_style(
|
||||
ProgressStyle::with_template("ingest [{bar:30}] {pos}/{len} {wide_msg}")
|
||||
.unwrap()
|
||||
.progress_chars("=> "),
|
||||
ProgressStyle::with_template(
|
||||
"ingest [{bar:30}] {pos}/{len} {wide_msg}{asset_elapsed}",
|
||||
)
|
||||
.unwrap()
|
||||
.with_key(
|
||||
"asset_elapsed",
|
||||
move |_: &ProgressState, w: &mut dyn std::fmt::Write| {
|
||||
if let Ok(guard) = asset_start.lock()
|
||||
&& let Some(started) = *guard
|
||||
{
|
||||
let secs = started.elapsed().as_secs();
|
||||
// Only show once the asset has been running
|
||||
// a moment — avoids `(0s)` flicker on fast
|
||||
// assets.
|
||||
if secs >= 1 {
|
||||
let _ = write!(w, " ({secs}s)");
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
.progress_chars("=> "),
|
||||
);
|
||||
bar.set_message("");
|
||||
if tty && !quiet {
|
||||
bar.enable_steady_tick(std::time::Duration::from_secs(1));
|
||||
} else {
|
||||
bar.disable_steady_tick();
|
||||
}
|
||||
}
|
||||
if !tty && !quiet {
|
||||
let mut err = std::io::stderr().lock();
|
||||
@@ -141,11 +198,22 @@ impl ProgressDisplay {
|
||||
path,
|
||||
media,
|
||||
} => {
|
||||
// v0.26.1: remember the path so AssetPhase can render it and
|
||||
// the slowest summary (keyed by idx in AssetTimings) can name
|
||||
// the asset.
|
||||
self.current_path = Some(path.clone());
|
||||
self.asset_paths.insert(*idx, path.clone());
|
||||
// v0.26.1: (re)start the per-asset heartbeat clock.
|
||||
if let Ok(mut guard) = self.asset_start.lock() {
|
||||
*guard = Some(Instant::now());
|
||||
}
|
||||
if let Some(bar) = self.bar.as_ref() {
|
||||
// One draw per file: position only. set_message() would
|
||||
// trigger a second independent draw and pollute TTY scrollback.
|
||||
// Filename is visible in the non-TTY plain-line path below.
|
||||
bar.set_position(u64::from(idx.saturating_sub(1)));
|
||||
// v0.26.1: show the current filename on the bar (TTY).
|
||||
// Previously position-only — the interactive user couldn't
|
||||
// tell which file was in flight. The steady tick redraws
|
||||
// in place, so this no longer pollutes scrollback.
|
||||
bar.set_message(abbreviate_path(path));
|
||||
}
|
||||
if !tty && !quiet {
|
||||
let mut err = std::io::stderr().lock();
|
||||
@@ -154,13 +222,95 @@ impl ProgressDisplay {
|
||||
}
|
||||
IngestEvent::AssetFinished { .. } => {
|
||||
// Position is advanced in AssetStarted; bar.finish_and_clear()
|
||||
// in Completed handles the final state. No per-asset bar update
|
||||
// here avoids the duplicate-frame artifact in TTY scrollback.
|
||||
// in Completed handles the final state. v0.26.1: stop the
|
||||
// heartbeat clock so the bar doesn't show a stale `(Ns)` in the
|
||||
// gap before the next AssetStarted.
|
||||
if let Ok(mut guard) = self.asset_start.lock() {
|
||||
*guard = None;
|
||||
}
|
||||
self.current_path = None;
|
||||
}
|
||||
// v0.26.1: an asset entered a slow internal phase (ocr / caption /
|
||||
// embed). Surface which phase + model is running so a multi-second
|
||||
// vision-model call no longer looks frozen.
|
||||
IngestEvent::AssetPhase {
|
||||
idx,
|
||||
total,
|
||||
phase,
|
||||
model,
|
||||
} => {
|
||||
let label = match model {
|
||||
Some(m) => format!("{phase}({m})"),
|
||||
None => phase.clone(),
|
||||
};
|
||||
if let Some(bar) = self.bar.as_ref() {
|
||||
let path = self.current_path.as_deref().unwrap_or("");
|
||||
bar.set_message(format!("{} · {label}…", abbreviate_path(path)));
|
||||
}
|
||||
if !tty && !quiet {
|
||||
let mut err = std::io::stderr().lock();
|
||||
let _ = writeln!(err, "ingest: {idx}/{total} · {label}…");
|
||||
}
|
||||
}
|
||||
// v0.24.0: asset-internal phase visibility. AssetChunked uses the
|
||||
// bar *message* (live sub-progress for the current asset) —
|
||||
// distinct from the per-file position draw, so a single large
|
||||
// document no longer looks frozen. AssetTimings prints a one-line
|
||||
// breakdown when the asset finishes.
|
||||
IngestEvent::AssetChunked { idx, total, chunks } => {
|
||||
if let Some(bar) = self.bar.as_ref() {
|
||||
bar.set_message(format!("→ {chunks} chunks"));
|
||||
}
|
||||
if !tty && !quiet {
|
||||
let mut err = std::io::stderr().lock();
|
||||
let _ = writeln!(err, "ingest: {idx}/{total} → {chunks} chunks");
|
||||
}
|
||||
}
|
||||
IngestEvent::AssetTimings {
|
||||
idx,
|
||||
parse_ms,
|
||||
chunk_ms,
|
||||
embed_ms,
|
||||
store_ms,
|
||||
ocr_ms,
|
||||
caption_ms,
|
||||
..
|
||||
} => {
|
||||
// v0.26.1: accumulate (path, total_ms) for the slowest summary.
|
||||
// total = every measured phase (expansion_ms is always 0).
|
||||
let total_ms = parse_ms + chunk_ms + embed_ms + store_ms + ocr_ms + caption_ms;
|
||||
if let Some(path) = self.asset_paths.get(idx) {
|
||||
self.timings.push((path.clone(), total_ms));
|
||||
}
|
||||
if let Some(bar) = self.bar.as_ref() {
|
||||
bar.set_message("");
|
||||
}
|
||||
if !quiet {
|
||||
let mut err = std::io::stderr().lock();
|
||||
// v0.26.1: only print ocr / caption when they actually ran
|
||||
// (markdown leaves them 0) so the text path stays uncluttered.
|
||||
let mut parts = vec![
|
||||
format!("parse {}", fmt_ms(*parse_ms)),
|
||||
format!("chunk {}", fmt_ms(*chunk_ms)),
|
||||
];
|
||||
if *ocr_ms > 0 {
|
||||
parts.push(format!("ocr {}", fmt_ms(*ocr_ms)));
|
||||
}
|
||||
if *caption_ms > 0 {
|
||||
parts.push(format!("caption {}", fmt_ms(*caption_ms)));
|
||||
}
|
||||
parts.push(format!("embed {}", fmt_ms(*embed_ms)));
|
||||
parts.push(format!("store {}", fmt_ms(*store_ms)));
|
||||
let _ = writeln!(err, " ⏱ {}", parts.join(" · "));
|
||||
}
|
||||
}
|
||||
IngestEvent::Completed { counts } => {
|
||||
if let Some(bar) = self.bar.take() {
|
||||
bar.finish_and_clear();
|
||||
}
|
||||
if let Ok(mut guard) = self.asset_start.lock() {
|
||||
*guard = None;
|
||||
}
|
||||
// Always emit summary in both TTY and non-TTY (unless quiet).
|
||||
// Bug fix: previously TTY had no summary line after bar.finish_and_clear().
|
||||
if !quiet {
|
||||
@@ -170,6 +320,10 @@ impl ProgressDisplay {
|
||||
"ingest: complete (scanned={} new={} updated={} skipped={} errors={})",
|
||||
counts.scanned, counts.new, counts.updated, counts.skipped, counts.errors,
|
||||
);
|
||||
// v0.26.1: slowest-asset summary. Useful in both TTY and
|
||||
// non-TTY (it pinpoints the bottleneck file), so it prints
|
||||
// unless --quiet. --json mode never reaches here (emit_json).
|
||||
let _ = write_slowest_summary(&mut err, &self.timings, SLOWEST_TOP_N);
|
||||
}
|
||||
}
|
||||
IngestEvent::Aborted { counts } => {
|
||||
@@ -239,6 +393,59 @@ fn emit_json(event: &IngestEvent) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Render a phase duration (milliseconds) compactly for the human-mode
|
||||
/// `AssetTimings` line: `< 1000ms` stays in `ms`, larger spans collapse to
|
||||
/// one-decimal seconds so a 45-second embed reads `45.0s`, not `45000ms`.
|
||||
fn fmt_ms(ms: u64) -> String {
|
||||
if ms >= 1000 {
|
||||
format!("{:.1}s", ms as f64 / 1000.0)
|
||||
} else {
|
||||
format!("{ms}ms")
|
||||
}
|
||||
}
|
||||
|
||||
/// v0.26.1: shorten an over-long workspace path for the progress-bar
|
||||
/// message so the live `(Ns)` heartbeat suffix stays visible on a narrow
|
||||
/// terminal. Keeps the tail (filename + a couple of parents) — that's the
|
||||
/// distinguishing part — and prefixes `…` when truncated. Paths up to the
|
||||
/// budget pass through verbatim.
|
||||
fn abbreviate_path(path: &str) -> String {
|
||||
const MAX: usize = 48;
|
||||
let char_count = path.chars().count();
|
||||
if char_count <= MAX {
|
||||
return path.to_string();
|
||||
}
|
||||
// Keep the last MAX-1 chars (1 reserved for the leading ellipsis).
|
||||
let tail: String = path
|
||||
.chars()
|
||||
.skip(char_count - (MAX - 1))
|
||||
.collect::<String>();
|
||||
format!("…{tail}")
|
||||
}
|
||||
|
||||
/// v0.26.1: render the end-of-run "slowest assets" summary. Sorts
|
||||
/// `(path, total_ms)` descending by time, takes the top `n`, and writes a
|
||||
/// compact table to `w`. No-op (writes nothing) when `timings` is empty so
|
||||
/// a run with no per-asset timing (e.g. all-skipped) prints no stray header.
|
||||
fn write_slowest_summary(
|
||||
w: &mut impl Write,
|
||||
timings: &[(String, u64)],
|
||||
n: usize,
|
||||
) -> std::io::Result<()> {
|
||||
if timings.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut sorted: Vec<&(String, u64)> = timings.iter().collect();
|
||||
// desc by ms; ties broken by path for deterministic output.
|
||||
sorted.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
|
||||
let top = &sorted[..sorted.len().min(n)];
|
||||
writeln!(w, "⏱ 최장 소요 top-{}:", top.len())?;
|
||||
for (rank, (path, ms)) in top.iter().enumerate() {
|
||||
writeln!(w, " {}. {} — {}", rank + 1, path, fmt_ms(*ms))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Format the current wall-clock as RFC 3339 — used by `wire_ingest_progress`
|
||||
/// so every emitted event carries an `ts` field per §2.4a / the wire schema.
|
||||
pub(crate) fn now_rfc3339() -> anyhow::Result<String> {
|
||||
@@ -285,6 +492,15 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fmt_ms_switches_unit_at_one_second() {
|
||||
assert_eq!(fmt_ms(0), "0ms");
|
||||
assert_eq!(fmt_ms(999), "999ms");
|
||||
assert_eq!(fmt_ms(1000), "1.0s");
|
||||
assert_eq!(fmt_ms(45_000), "45.0s");
|
||||
assert_eq!(fmt_ms(1500), "1.5s");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn now_rfc3339_parses_back() {
|
||||
let s = now_rfc3339().unwrap();
|
||||
@@ -292,4 +508,61 @@ mod tests {
|
||||
// well-formed RFC 3339 string.
|
||||
OffsetDateTime::parse(&s, &Rfc3339).expect("RFC 3339 round-trip");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn abbreviate_path_passes_short_paths_through() {
|
||||
assert_eq!(abbreviate_path("notes/foo.md"), "notes/foo.md");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn abbreviate_path_keeps_tail_with_ellipsis() {
|
||||
let long = "a/very/deeply/nested/directory/structure/that/exceeds/the/budget/file.md";
|
||||
let out = abbreviate_path(long);
|
||||
assert!(out.starts_with('…'), "should be prefixed with ellipsis: {out}");
|
||||
assert!(out.ends_with("file.md"), "should keep the filename tail: {out}");
|
||||
// 48-char budget: 1 ellipsis + 47 tail chars.
|
||||
assert_eq!(out.chars().count(), 48);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_slowest_summary_empty_writes_nothing() {
|
||||
let mut buf = Vec::new();
|
||||
write_slowest_summary(&mut buf, &[], 5).unwrap();
|
||||
assert!(buf.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_slowest_summary_sorts_desc_and_truncates() {
|
||||
let timings = vec![
|
||||
("a.md".to_string(), 100),
|
||||
("b.png".to_string(), 5_000),
|
||||
("c.pdf".to_string(), 2_000),
|
||||
("d.md".to_string(), 50),
|
||||
];
|
||||
let mut buf = Vec::new();
|
||||
write_slowest_summary(&mut buf, &timings, 2).unwrap();
|
||||
let out = String::from_utf8(buf).unwrap();
|
||||
assert!(out.contains("top-2:"), "{out}");
|
||||
// b (5s) ranks first, c (2s) second; a/d excluded.
|
||||
let b_pos = out.find("b.png").expect("b.png present");
|
||||
let c_pos = out.find("c.pdf").expect("c.pdf present");
|
||||
assert!(b_pos < c_pos, "b before c: {out}");
|
||||
assert!(!out.contains("a.md"), "a.md excluded by top-2: {out}");
|
||||
assert!(out.contains("5.0s"), "b renders as 5.0s: {out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_slowest_summary_tie_breaks_by_path() {
|
||||
let timings = vec![
|
||||
("z.md".to_string(), 1_000),
|
||||
("a.md".to_string(), 1_000),
|
||||
];
|
||||
let mut buf = Vec::new();
|
||||
write_slowest_summary(&mut buf, &timings, 5).unwrap();
|
||||
let out = String::from_utf8(buf).unwrap();
|
||||
assert!(
|
||||
out.find("a.md").unwrap() < out.find("z.md").unwrap(),
|
||||
"equal ms ties break alphabetically: {out}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,9 +155,10 @@ impl NliCfg {
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct EmbeddingModelCfg {
|
||||
/// `fastembed` (default, onnxruntime) or `candle` (pure-Rust,
|
||||
/// NUMA-safe). `none` disables embeddings (lexical-only). Unknown
|
||||
/// values error at embedder construction.
|
||||
/// `fastembed` (default, onnxruntime), `candle` (pure-Rust, NUMA-safe),
|
||||
/// or `ollama` (remote HTTP embedding endpoint). `none` disables
|
||||
/// embeddings (lexical-only). Unknown values error at embedder
|
||||
/// construction.
|
||||
pub provider: String,
|
||||
pub model: String,
|
||||
pub version: String,
|
||||
@@ -170,6 +171,13 @@ pub struct EmbeddingModelCfg {
|
||||
/// provider. Defaulted on load so pre-0.22 config files still parse.
|
||||
#[serde(default)]
|
||||
pub num_threads: u32,
|
||||
/// HTTP endpoint for the `ollama` embedding provider (e.g.
|
||||
/// `"http://127.0.0.1:11434"`). `None` (or a missing key in TOML) means
|
||||
/// "fall back to `models.llm.endpoint`" — same convention as the OCR /
|
||||
/// vision endpoints. Ignored by the `fastembed` / `candle` providers.
|
||||
/// Defaulted on load so pre-0.26 config files still parse.
|
||||
#[serde(default)]
|
||||
pub endpoint: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
@@ -606,8 +614,6 @@ 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
|
||||
@@ -648,34 +654,6 @@ 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 {
|
||||
@@ -718,6 +696,7 @@ impl Config {
|
||||
dimensions: 1024,
|
||||
batch_size: 64,
|
||||
num_threads: 0,
|
||||
endpoint: None,
|
||||
},
|
||||
llm: LlmCfg {
|
||||
provider: "ollama".to_string(),
|
||||
@@ -980,6 +959,12 @@ impl Config {
|
||||
self.models.embedding.num_threads = n;
|
||||
}
|
||||
}
|
||||
"KEBAB_MODELS_EMBEDDING_ENDPOINT" => {
|
||||
// Empty value → None (= fall back to models.llm.endpoint),
|
||||
// mirroring the OCR endpoint override semantics.
|
||||
self.models.embedding.endpoint =
|
||||
if v.is_empty() { None } else { Some(v.clone()) };
|
||||
}
|
||||
|
||||
// models.llm
|
||||
"KEBAB_MODELS_LLM_PROVIDER" => self.models.llm.provider = v.clone(),
|
||||
@@ -1166,25 +1151,6 @@ 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.
|
||||
_ => {}
|
||||
@@ -1913,41 +1879,6 @@ max_context_tokens = 8000
|
||||
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)]
|
||||
|
||||
@@ -15,7 +15,7 @@ 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`.
|
||||
/// dotted path, 예: `ingest.code`, `workspace.include`.
|
||||
pub path: String,
|
||||
/// 사람·wire 용 한 줄 설명.
|
||||
pub detail: String,
|
||||
@@ -83,7 +83,6 @@ fn section_comment(path: &str) -> Option<&'static str> {
|
||||
"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).",
|
||||
@@ -259,7 +258,7 @@ mod tests {
|
||||
// `[pdf]` 등은 안 나오고 `[pdf.ocr]` 같은 하위 테이블만 직렬화된다.
|
||||
for section in [
|
||||
"[workspace]",
|
||||
"[ingest.expansion]",
|
||||
"[ingest.code]",
|
||||
"[pdf.ocr]",
|
||||
"[logging]",
|
||||
"[ui]",
|
||||
@@ -273,8 +272,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn reconcile_adds_missing_section_preserving_user_values_and_comments() {
|
||||
// ingest 는 code 만 있고 expansion 누락(v0.21.0 동기 시나리오),
|
||||
// logging 통째 누락, score 는 사용자가 바꿈, 주석 보유.
|
||||
// ingest 통째 누락(→ ingest.code 추가), logging 통째 누락,
|
||||
// default_k 는 사용자가 바꿈, 주석 보유.
|
||||
let user_text = "\
|
||||
schema_version = 1
|
||||
|
||||
@@ -283,9 +282,6 @@ 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();
|
||||
@@ -294,25 +290,22 @@ skip_generated_header = true
|
||||
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}");
|
||||
// 누락된 [ingest.code] 가 주석과 함께 추가.
|
||||
assert!(out.contains("[ingest.code]"), "ingest.code not added:\n{out}");
|
||||
// 통째 누락된 logging 추가.
|
||||
assert!(out.contains("[logging]"), "logging not added");
|
||||
// 사용자 값/주석/기존 섹션 보존.
|
||||
assert!(out.contains("root = \"/my/notes\""));
|
||||
assert!(out.contains("# 내 워크스페이스"));
|
||||
assert!(out.contains("default_k = 25"));
|
||||
assert!(out.contains("skip_generated_header = true"));
|
||||
// 새 섹션 주석 부착.
|
||||
assert!(out.contains("doc-side 별칭"));
|
||||
// 부분 존재 부모로 재귀해 leaf 경로를 기록.
|
||||
assert!(out.contains("code ingest skip 정책"));
|
||||
// 통째 누락 부모는 부모 경로로 한 번 기록.
|
||||
assert!(
|
||||
changes
|
||||
.iter()
|
||||
.any(|c| c.kind == ChangeKind::AddedSection && c.path == "ingest.expansion"),
|
||||
"changes: {changes:?}"
|
||||
.any(|c| c.kind == ChangeKind::AddedSection && c.path == "ingest")
|
||||
);
|
||||
// 통째 누락 부모는 부모 경로로 한 번 기록.
|
||||
assert!(
|
||||
changes
|
||||
.iter()
|
||||
@@ -381,7 +374,7 @@ include = [\"*.md\"]
|
||||
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!(outcome.new_text.contains("[ingest.code]"));
|
||||
assert_eq!(read_schema_version(&outcome.new_text), CURRENT_SCHEMA_VERSION);
|
||||
|
||||
let again = migrate_document(&outcome.new_text);
|
||||
|
||||
@@ -28,13 +28,6 @@ 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)]
|
||||
@@ -42,8 +35,8 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn aliases_defaults_to_none_on_deserialize() {
|
||||
// aliases 필드가 없는 과거 JSON 도 파싱되어야 한다 (#[serde(default)]).
|
||||
fn tokenized_korean_text_defaults_to_none_on_deserialize() {
|
||||
// tokenized_korean_text 필드가 없는 과거 JSON 도 파싱되어야 한다 (#[serde(default)]).
|
||||
let json = r#"{
|
||||
"chunk_id": "c1",
|
||||
"doc_id": "d1",
|
||||
@@ -56,7 +49,6 @@ mod tests {
|
||||
"policy_hash": "abc"
|
||||
}"#;
|
||||
let c: Chunk = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(c.aliases, None);
|
||||
assert_eq!(c.tokenized_korean_text, None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,11 +25,22 @@ rayon = "1"
|
||||
anyhow = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
|
||||
[features]
|
||||
# opt-in: run candle on the Apple Silicon GPU (Metal). macOS-only — the build
|
||||
# enables candle's metal backend and `select_device()` picks Metal (CPU fallback
|
||||
# on failure). Lets an M-series Mac ingest e5-large on GPU (10×+ vs CPU); the
|
||||
# resulting vectors are cross-compatible with the CPU path (same model), so the
|
||||
# Linux server can serve queries on CPU candle.
|
||||
metal = ["candle-core/metal", "candle-nn/metal", "candle-transformers/metal"]
|
||||
|
||||
[dev-dependencies]
|
||||
# Integration-test binaries can only see the library's public API + these,
|
||||
# not the library's own (non-dev) dependencies — so rayon/kebab-config/kebab-core
|
||||
# are repeated here for tests/parity.rs and tests/thread_cap.rs.
|
||||
kebab-embed-local = { path = "../kebab-embed-local" }
|
||||
# arctic↔Ollama parity test drives the real Ollama adapter for the reference
|
||||
# vectors (tests/arctic_ollama_parity.rs, `#[ignore]` — live Ollama).
|
||||
kebab-embed-ollama = { path = "../kebab-embed-ollama" }
|
||||
kebab-config = { path = "../kebab-config" }
|
||||
kebab-core = { path = "../kebab-core" }
|
||||
rayon = "1"
|
||||
|
||||
@@ -1,31 +1,44 @@
|
||||
//! `kebab-embed-candle` — [`CandleEmbedder`], a pure-Rust (candle)
|
||||
//! implementation of [`Embedder`](kebab_core::Embedder).
|
||||
//!
|
||||
//! Runs the same `intfloat/multilingual-e5-large` model as the default
|
||||
//! [`FastembedEmbedder`](kebab_embed_local) but through `candle`
|
||||
//! (`candle-transformers`' XLM-RoBERTa) instead of onnxruntime. Motivation:
|
||||
//! fastembed 4.9's onnxruntime hard-codes 48 intra-op threads, which corrupts
|
||||
//! the heap (double-free) on dual-socket NUMA hosts. candle's CPU backend
|
||||
//! sizes its threads off the global rayon pool, so a one-shot
|
||||
//! [`rayon::ThreadPoolBuilder`] cap (config `num_threads` / env
|
||||
//! `KEBAB_EMBED_THREADS`) keeps the worker count NUMA-safe.
|
||||
//! Runs an XLM-RoBERTa-large embedding model through `candle`
|
||||
//! (`candle-transformers`' XLM-RoBERTa) instead of onnxruntime. Two models
|
||||
//! are wired through a small **registry** ([`MODEL_REGISTRY`]):
|
||||
//!
|
||||
//! Output parity with the onnxruntime path was proven by the Phase 0 spike
|
||||
//! (cosine 1.000000); this crate absorbs that pipeline verbatim:
|
||||
//! * `multilingual-e5-large` — the same weights the default
|
||||
//! [`FastembedEmbedder`](kebab_embed_local) uses (mean pooling,
|
||||
//! `query: `/`passage: ` prefixes). candle is the NUMA-safe drop-in:
|
||||
//! fastembed 4.9's onnxruntime hard-codes 48 intra-op threads, which
|
||||
//! corrupts the heap (double-free) on dual-socket NUMA hosts. candle's
|
||||
//! CPU backend sizes its threads off the global rayon pool, so a one-shot
|
||||
//! [`rayon::ThreadPoolBuilder`] cap (config `num_threads` / env
|
||||
//! `KEBAB_EMBED_THREADS`) keeps the worker count NUMA-safe.
|
||||
//! * `snowflake-arctic-embed-l-v2.0` — Snowflake's arctic-embed v2.0
|
||||
//! (CLS pooling, `query: ` on queries / no prefix on documents). Same
|
||||
//! XLM-RoBERTa-large architecture, dim 1024, so it rides the exact same
|
||||
//! tokenize → forward → L2 pipeline; only the pooling step and prefixes
|
||||
//! differ (both keyed off the per-model [`EmbedModelSpec`]).
|
||||
//!
|
||||
//! 1. e5 prefix (`passage: ` for documents, `query: ` for queries — the same
|
||||
//! convention as `kebab-embed-local`'s `prefix_input`);
|
||||
//! Output parity with the onnxruntime path (for e5) was proven by the
|
||||
//! Phase 0 spike (cosine 1.000000); the arctic path's pooling/prefix
|
||||
//! correctness is pinned by an `#[ignore]`d cosine>0.99 cross-check against
|
||||
//! Ollama's `snowflake-arctic-embed2` (see `tests/arctic_ollama_parity.rs`).
|
||||
//! The shared pipeline:
|
||||
//!
|
||||
//! 1. instruction prefix per [`EmbedModelSpec`] (query/doc);
|
||||
//! 2. tokenize (max_len 512, batch-longest padding, special tokens);
|
||||
//! 3. XLM-RoBERTa forward on `Device::Cpu`;
|
||||
//! 4. attention-mask-weighted mean pooling;
|
||||
//! 3. XLM-RoBERTa forward on the selected [`Device`];
|
||||
//! 4. pooling — mean (attention-mask-weighted) or CLS (first token);
|
||||
//! 5. L2 normalization.
|
||||
//!
|
||||
//! Model files (`config.json`, `tokenizer.json`, `model.safetensors`) are
|
||||
//! fetched via `hf-hub` into `{config.storage.model_dir}/candle/`.
|
||||
//! fetched via `hf-hub` into `{config.storage.model_dir}/candle/` (hf-hub's
|
||||
//! cache layout namespaces by repo, so e5 and arctic never collide).
|
||||
//!
|
||||
//! This crate is **opt-in** (`config.models.embedding.provider = "candle"`);
|
||||
//! the default provider stays `fastembed`. See
|
||||
//! `docs/superpowers/specs/2026-06-01-embed-candle-track-spec.md`.
|
||||
//! `docs/superpowers/specs/2026-06-01-embed-candle-track-spec.md` and
|
||||
//! `docs/superpowers/specs/2026-06-03-arctic-embedder-spec.md`.
|
||||
|
||||
use std::sync::Mutex;
|
||||
|
||||
@@ -42,22 +55,95 @@ use tokenizers::{PaddingParams, PaddingStrategy, Tokenizer, TruncationParams};
|
||||
/// `fastembed/` subdir so the two backends never collide.
|
||||
const CANDLE_CACHE_SUBDIR: &str = "candle";
|
||||
|
||||
/// HuggingFace repo id for the multilingual e5 large model. Same weights the
|
||||
/// onnxruntime path uses, just the safetensors variant candle can read.
|
||||
const HF_MODEL: &str = "intfloat/multilingual-e5-large";
|
||||
|
||||
/// The only `config.models.embedding.model` value the candle adapter accepts
|
||||
/// (the e5-large weights `HF_MODEL` resolves to). Guards against silently
|
||||
/// downloading e5-large while `model_id()` reports a different name.
|
||||
const SUPPORTED_MODEL: &str = "multilingual-e5-large";
|
||||
|
||||
/// Token truncation length (e5 was trained at 512).
|
||||
/// Token truncation length (both e5 and arctic-embed-l-v2.0 train at 512).
|
||||
const MAX_LEN: usize = 512;
|
||||
|
||||
/// Env var that overrides `config.models.embedding.num_threads`. Read once in
|
||||
/// [`CandleEmbedder::new`]; `0`/unset/unparseable means "leave rayon default".
|
||||
const ENV_EMBED_THREADS: &str = "KEBAB_EMBED_THREADS";
|
||||
|
||||
/// Pooling strategy over the model's last hidden state. Keyed per-model by
|
||||
/// [`EmbedModelSpec::pooling`] — e5 is mean, arctic is CLS.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Pooling {
|
||||
/// Attention-mask-weighted mean over all tokens (e5 / sentence-transformers
|
||||
/// `pooling_mode_mean_tokens`).
|
||||
Mean,
|
||||
/// First token (`<s>`/`[CLS]`) hidden state (arctic-embed v2.0 —
|
||||
/// `1_Pooling/config.json` has `pooling_mode_cls_token: true`).
|
||||
Cls,
|
||||
}
|
||||
|
||||
/// One supported embedding model: the HF repo candle downloads, the pooling
|
||||
/// strategy, and the e5-style instruction prefixes. [`MODEL_REGISTRY`] maps a
|
||||
/// `config.models.embedding.model` value to one of these.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct EmbedModelSpec {
|
||||
/// The short `config.models.embedding.model` value that selects this spec.
|
||||
pub name: &'static str,
|
||||
/// HuggingFace repo id candle fetches `config.json` / `tokenizer.json` /
|
||||
/// `model.safetensors` from.
|
||||
pub hf_repo: &'static str,
|
||||
/// Pooling over the last hidden state.
|
||||
pub pooling: Pooling,
|
||||
/// Prefix prepended to **query** inputs before tokenization.
|
||||
pub query_prefix: &'static str,
|
||||
/// Prefix prepended to **document** inputs before tokenization (arctic
|
||||
/// uses `""` — documents are embedded raw).
|
||||
pub doc_prefix: &'static str,
|
||||
/// Expected embedding dimension (model hidden size).
|
||||
pub dim: usize,
|
||||
/// Suffix folded into `model_version` so switching **to** this model
|
||||
/// triggers the `embedding_version` cascade even if the operator forgets
|
||||
/// to bump `config.version`. `None` keeps the bare `config.version` — used
|
||||
/// by e5 so candle-e5 and fastembed-e5 report the *same* version and stay
|
||||
/// interchangeable (the NUMA drop-in invariant — Phase 0 cosine 1.0).
|
||||
pub version_tag: Option<&'static str>,
|
||||
}
|
||||
|
||||
/// The models the candle adapter can load. Adding a model = one entry here
|
||||
/// (plus, for a non-XLM-R architecture, a new forward path — both current
|
||||
/// entries are XLM-RoBERTa-large so they share everything but pooling/prefix).
|
||||
static MODEL_REGISTRY: &[EmbedModelSpec] = &[
|
||||
EmbedModelSpec {
|
||||
name: "multilingual-e5-large",
|
||||
hf_repo: "intfloat/multilingual-e5-large",
|
||||
pooling: Pooling::Mean,
|
||||
query_prefix: "query: ",
|
||||
doc_prefix: "passage: ",
|
||||
dim: 1024,
|
||||
version_tag: None,
|
||||
},
|
||||
EmbedModelSpec {
|
||||
name: "snowflake-arctic-embed-l-v2.0",
|
||||
hf_repo: "Snowflake/snowflake-arctic-embed-l-v2.0",
|
||||
pooling: Pooling::Cls,
|
||||
query_prefix: "query: ",
|
||||
doc_prefix: "",
|
||||
dim: 1024,
|
||||
version_tag: Some("arctic-cls"),
|
||||
},
|
||||
];
|
||||
|
||||
/// Look up a model spec by `config.models.embedding.model`. Accepts either the
|
||||
/// short `name` or the full `hf_repo` id (mirrors the old e5 guard, which
|
||||
/// accepted both `multilingual-e5-large` and `intfloat/multilingual-e5-large`).
|
||||
pub(crate) fn lookup_spec(model: &str) -> Option<&'static EmbedModelSpec> {
|
||||
MODEL_REGISTRY
|
||||
.iter()
|
||||
.find(|s| s.name == model || s.hf_repo == model)
|
||||
}
|
||||
|
||||
/// Comma-separated list of supported model names, for the
|
||||
/// unsupported-model error message.
|
||||
fn supported_models() -> String {
|
||||
MODEL_REGISTRY
|
||||
.iter()
|
||||
.map(|s| s.name)
|
||||
.collect::<Vec<_>>()
|
||||
.join("`, `")
|
||||
}
|
||||
|
||||
/// Pure-Rust candle adapter. Construct via [`CandleEmbedder::new`]; the
|
||||
/// constructor downloads the model on first use, so share one instance.
|
||||
pub struct CandleEmbedder {
|
||||
@@ -68,6 +154,9 @@ pub struct CandleEmbedder {
|
||||
model: Mutex<XLMRobertaModel>,
|
||||
tokenizer: Tokenizer,
|
||||
device: Device,
|
||||
/// The resolved model spec (pooling + prefixes) — drives `embed` and
|
||||
/// `embed_batch`.
|
||||
spec: &'static EmbedModelSpec,
|
||||
model_id: EmbeddingModelId,
|
||||
version: EmbeddingVersion,
|
||||
dimensions: usize,
|
||||
@@ -75,7 +164,8 @@ pub struct CandleEmbedder {
|
||||
}
|
||||
|
||||
impl CandleEmbedder {
|
||||
/// Build an embedder from `Config`. Applies the NUMA thread cap, fetches
|
||||
/// Build an embedder from `Config`. Resolves the model spec from
|
||||
/// `config.models.embedding.model`, applies the NUMA thread cap, fetches
|
||||
/// the model into `{model_dir}/candle/`, and validates that the model's
|
||||
/// hidden size matches `config.models.embedding.dimensions` before
|
||||
/// returning.
|
||||
@@ -104,21 +194,20 @@ impl CandleEmbedder {
|
||||
}
|
||||
}
|
||||
|
||||
// 1b. Model guard. `HF_MODEL` is hard-coded (candle currently only wires
|
||||
// e5-large), so if the operator configured a *different* model name
|
||||
// we must NOT silently download e5-large and then label its vectors
|
||||
// with the configured name via `model_id()` — that would mislabel
|
||||
// `embedding_version` and corrupt a mixed index. Fail fast, before
|
||||
// the ~2GB download.
|
||||
// 1b. Model registry lookup. If the operator configured a model the
|
||||
// candle adapter doesn't know, fail fast (BEFORE the ~2GB
|
||||
// download) — never silently download one model and then label its
|
||||
// vectors with another name via `model_id()`, which would mislabel
|
||||
// `embedding_version` and corrupt a mixed index.
|
||||
let want = config.models.embedding.model.as_str();
|
||||
if want != SUPPORTED_MODEL && want != HF_MODEL {
|
||||
anyhow::bail!(
|
||||
"candle provider currently supports only '{SUPPORTED_MODEL}' (or \
|
||||
the HF id '{HF_MODEL}'), but config.models.embedding.model = \
|
||||
'{want}'. Use provider=fastembed for other models, or set \
|
||||
model = \"{SUPPORTED_MODEL}\"."
|
||||
);
|
||||
}
|
||||
let spec = lookup_spec(want).ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"candle provider supports the models `{}`, but \
|
||||
config.models.embedding.model = '{want}'. Use provider=fastembed \
|
||||
for other models, or pick a supported one.",
|
||||
supported_models()
|
||||
)
|
||||
})?;
|
||||
|
||||
// 2. Resolve `{data_dir}/models/candle/` exactly like the fastembed
|
||||
// adapter resolves its own subdir.
|
||||
@@ -128,20 +217,21 @@ impl CandleEmbedder {
|
||||
std::fs::create_dir_all(&cache_dir)
|
||||
.with_context(|| format!("create candle cache dir {}", cache_dir.display()))?;
|
||||
|
||||
let device = Device::Cpu;
|
||||
let device = select_device();
|
||||
|
||||
// 3. Fetch model files via hf-hub into the candle cache.
|
||||
tracing::info!(
|
||||
target: "kebab-embed-candle",
|
||||
cache_dir = %cache_dir.display(),
|
||||
model = HF_MODEL,
|
||||
model = spec.hf_repo,
|
||||
pooling = ?spec.pooling,
|
||||
"loading candle embedding model (first run downloads ~2GB safetensors)"
|
||||
);
|
||||
let api = hf_hub::api::sync::ApiBuilder::new()
|
||||
.with_cache_dir(cache_dir.clone())
|
||||
.build()
|
||||
.context("kb-embed-candle: build hf-hub api")?;
|
||||
let repo = api.model(HF_MODEL.to_string());
|
||||
let repo = api.model(spec.hf_repo.to_string());
|
||||
let config_path = repo.get("config.json").context("download config.json")?;
|
||||
let tokenizer_path = repo
|
||||
.get("tokenizer.json")
|
||||
@@ -180,10 +270,21 @@ impl CandleEmbedder {
|
||||
}))
|
||||
.map_err(|e| anyhow::anyhow!("kb-embed-candle: set truncation: {e}"))?;
|
||||
|
||||
// model_version: fold the model tag in for non-e5 models so a switch
|
||||
// triggers the embedding_version cascade; e5 keeps the bare
|
||||
// config.version to stay interchangeable with fastembed-e5.
|
||||
let version = match spec.version_tag {
|
||||
Some(tag) => {
|
||||
EmbeddingVersion(format!("{}+{}", config.models.embedding.version, tag))
|
||||
}
|
||||
None => EmbeddingVersion(config.models.embedding.version.clone()),
|
||||
};
|
||||
|
||||
tracing::info!(
|
||||
target: "kebab-embed-candle",
|
||||
dimensions = cfg.hidden_size,
|
||||
layers = cfg.num_hidden_layers,
|
||||
model = spec.name,
|
||||
"candle embedding model loaded"
|
||||
);
|
||||
|
||||
@@ -191,16 +292,17 @@ impl CandleEmbedder {
|
||||
model: Mutex::new(model),
|
||||
tokenizer,
|
||||
device,
|
||||
spec,
|
||||
model_id: EmbeddingModelId(config.models.embedding.model.clone()),
|
||||
version: EmbeddingVersion(config.models.embedding.version.clone()),
|
||||
version,
|
||||
dimensions: cfg.hidden_size,
|
||||
batch_size: config.models.embedding.batch_size.max(1),
|
||||
})
|
||||
}
|
||||
|
||||
/// Embed one batch of **already-prefixed** strings (the e5 `query:`/
|
||||
/// `passage:` prefix is applied by the caller [`CandleEmbedder::embed`])
|
||||
/// through the candle pipeline: tokenize → forward → masked mean pool → L2.
|
||||
/// Embed one batch of **already-prefixed** strings (the per-model prefix
|
||||
/// is applied by the caller [`CandleEmbedder::embed`]) through the candle
|
||||
/// pipeline: tokenize → forward → pool (mean|CLS) → L2.
|
||||
fn embed_batch(&self, prefixed: &[String]) -> Result<Vec<Vec<f32>>> {
|
||||
let encodings = self
|
||||
.tokenizer
|
||||
@@ -237,20 +339,34 @@ impl CandleEmbedder {
|
||||
guard.forward(&input_ids, &attn_f32, &token_type_ids, None, None, None)?
|
||||
};
|
||||
|
||||
// attention-mask-weighted mean pooling
|
||||
let mask3 = attn_f32.unsqueeze(2)?; // (b, seq, 1)
|
||||
let summed = hidden.broadcast_mul(&mask3)?.sum(1)?; // (b, hidden)
|
||||
// counts ≥ 1 always: every input is e5-prefixed AND special tokens are
|
||||
// added (encode_batch(_, true)), so no row has an all-zero mask. If that
|
||||
// invariant ever breaks, broadcast_div would emit NaN vectors.
|
||||
let counts = mask3.sum(1)?; // (b, 1)
|
||||
let mean = summed.broadcast_div(&counts)?;
|
||||
// Pooling — per the model spec.
|
||||
let pooled = match self.spec.pooling {
|
||||
Pooling::Mean => {
|
||||
// attention-mask-weighted mean pooling
|
||||
let mask3 = attn_f32.unsqueeze(2)?; // (b, seq, 1)
|
||||
let summed = hidden.broadcast_mul(&mask3)?.sum(1)?; // (b, hidden)
|
||||
// counts ≥ 1 always: every input is prefixed AND special
|
||||
// tokens are added (encode_batch(_, true)), so no row has an
|
||||
// all-zero mask. If that invariant ever breaks, broadcast_div
|
||||
// would emit NaN vectors.
|
||||
let counts = mask3.sum(1)?; // (b, 1)
|
||||
summed.broadcast_div(&counts)?
|
||||
}
|
||||
Pooling::Cls => {
|
||||
// CLS pooling: the first token's hidden state. arctic-embed
|
||||
// v2.0 prepends `<s>` (the XLM-R BOS/CLS) at index 0, so
|
||||
// `hidden[:, 0, :]` is the sentence embedding.
|
||||
hidden.narrow(1, 0, 1)?.squeeze(1)? // (b, hidden)
|
||||
}
|
||||
};
|
||||
|
||||
// L2 normalize
|
||||
let norm = mean.sqr()?.sum_keepdim(1)?.sqrt()?;
|
||||
let normalized = mean.broadcast_div(&norm)?;
|
||||
let norm = pooled.sqr()?.sum_keepdim(1)?.sqrt()?;
|
||||
let normalized = pooled.broadcast_div(&norm)?;
|
||||
|
||||
Ok(normalized.to_vec2::<f32>()?)
|
||||
// `.contiguous()` before host copy: broadcast ops can leave a strided
|
||||
// view, which `to_vec2` rejects on the Metal backend (CPU tolerates it).
|
||||
Ok(normalized.contiguous()?.to_vec2::<f32>()?)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -272,9 +388,9 @@ impl Embedder for CandleEmbedder {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
// e5 prefix per §11.3 BEFORE tokenization (same convention as
|
||||
// FastembedEmbedder so the two backends produce comparable vectors).
|
||||
let prefixed: Vec<String> = inputs.iter().map(prefix_input).collect();
|
||||
// Per-model instruction prefix BEFORE tokenization (same convention as
|
||||
// FastembedEmbedder for e5; arctic uses `query: `/no-prefix).
|
||||
let prefixed: Vec<String> = inputs.iter().map(|i| prefix_input(self.spec, i)).collect();
|
||||
|
||||
let mut out: Vec<Vec<f32>> = Vec::with_capacity(prefixed.len());
|
||||
for chunk in prefixed.chunks(self.batch_size) {
|
||||
@@ -296,17 +412,43 @@ impl Embedder for CandleEmbedder {
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the e5-prefixed string for one [`EmbeddingInput`]. Free function so
|
||||
/// a unit test can pin the format without loading the model. Byte-identical to
|
||||
/// `kebab-embed-local`'s `prefix_input` — the two backends MUST agree here or
|
||||
/// their vectors diverge.
|
||||
fn prefix_input(input: &EmbeddingInput<'_>) -> String {
|
||||
/// Build the prefixed string for one [`EmbeddingInput`] using the model spec.
|
||||
/// Free function so a unit test can pin the format without loading the model.
|
||||
/// For e5 this is byte-identical to `kebab-embed-local`'s `prefix_input` — the
|
||||
/// two backends MUST agree there or their vectors diverge.
|
||||
fn prefix_input(spec: &EmbedModelSpec, input: &EmbeddingInput<'_>) -> String {
|
||||
match input.kind {
|
||||
EmbeddingKind::Document => format!("passage: {}", input.text),
|
||||
EmbeddingKind::Query => format!("query: {}", input.text),
|
||||
EmbeddingKind::Document => format!("{}{}", spec.doc_prefix, input.text),
|
||||
EmbeddingKind::Query => format!("{}{}", spec.query_prefix, input.text),
|
||||
}
|
||||
}
|
||||
|
||||
/// Select the compute device. Built with the `metal` feature (Apple Silicon
|
||||
/// GPU), try Metal and fall back to CPU on failure; otherwise CPU. Metal only
|
||||
/// compiles/runs on macOS — the Linux server builds the CPU path. Embedding
|
||||
/// vectors are model-defined, so Metal-produced and CPU-produced embeddings
|
||||
/// are cross-compatible (a Mac can ingest on GPU, the server query on CPU).
|
||||
fn select_device() -> Device {
|
||||
#[cfg(feature = "metal")]
|
||||
{
|
||||
match Device::new_metal(0) {
|
||||
Ok(d) => {
|
||||
tracing::info!(target: "kebab-embed-candle", "candle device = Metal (GPU)");
|
||||
return d;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
target: "kebab-embed-candle",
|
||||
error = %e,
|
||||
"Metal device unavailable; falling back to CPU"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
tracing::info!(target: "kebab-embed-candle", "candle device = CPU");
|
||||
Device::Cpu
|
||||
}
|
||||
|
||||
/// Apply a one-shot global rayon thread cap (the NUMA-safety lever). Returns
|
||||
/// `true` if this call set the pool, `false` if it was already initialized
|
||||
/// (cap not applied) or `n_threads == 0`. `#[doc(hidden)] pub` so the
|
||||
@@ -339,26 +481,85 @@ pub(crate) fn check_dim(model_dim: usize, cfg_dim: usize) -> Result<()> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ── prefix_input ─────────────────────────────────────────────────
|
||||
// Pin the exact e5 prefix strings; these MUST match
|
||||
// kebab-embed-local::prefix_input or candle vs fastembed parity breaks.
|
||||
fn e5_spec() -> &'static EmbedModelSpec {
|
||||
lookup_spec("multilingual-e5-large").expect("e5 in registry")
|
||||
}
|
||||
|
||||
fn arctic_spec() -> &'static EmbedModelSpec {
|
||||
lookup_spec("snowflake-arctic-embed-l-v2.0").expect("arctic in registry")
|
||||
}
|
||||
|
||||
// ── registry ─────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn prefix_document_uses_passage() {
|
||||
fn registry_resolves_e5_by_name_and_hf_repo() {
|
||||
assert_eq!(
|
||||
lookup_spec("multilingual-e5-large").map(|s| s.name),
|
||||
Some("multilingual-e5-large")
|
||||
);
|
||||
assert_eq!(
|
||||
lookup_spec("intfloat/multilingual-e5-large").map(|s| s.name),
|
||||
Some("multilingual-e5-large")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_resolves_arctic_and_its_pooling_is_cls() {
|
||||
let s = arctic_spec();
|
||||
assert_eq!(s.name, "snowflake-arctic-embed-l-v2.0");
|
||||
assert_eq!(s.hf_repo, "Snowflake/snowflake-arctic-embed-l-v2.0");
|
||||
assert_eq!(s.pooling, Pooling::Cls);
|
||||
assert_eq!(s.dim, 1024);
|
||||
assert_eq!(s.version_tag, Some("arctic-cls"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_e5_is_mean_pooling_no_version_tag() {
|
||||
let s = e5_spec();
|
||||
assert_eq!(s.pooling, Pooling::Mean);
|
||||
assert_eq!(s.version_tag, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_rejects_unknown_model() {
|
||||
assert!(lookup_spec("multilingual-e5-small").is_none());
|
||||
}
|
||||
|
||||
// ── prefix_input ─────────────────────────────────────────────────
|
||||
// e5 prefixes MUST match kebab-embed-local::prefix_input or candle vs
|
||||
// fastembed parity breaks; arctic uses query-only prefixing.
|
||||
|
||||
#[test]
|
||||
fn e5_prefix_document_uses_passage() {
|
||||
let input = EmbeddingInput {
|
||||
text: "hello world",
|
||||
kind: EmbeddingKind::Document,
|
||||
};
|
||||
assert_eq!(prefix_input(&input), "passage: hello world");
|
||||
assert_eq!(prefix_input(e5_spec(), &input), "passage: hello world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefix_query_uses_query() {
|
||||
fn e5_prefix_query_uses_query() {
|
||||
let input = EmbeddingInput {
|
||||
text: "hello world",
|
||||
kind: EmbeddingKind::Query,
|
||||
};
|
||||
assert_eq!(prefix_input(&input), "query: hello world");
|
||||
assert_eq!(prefix_input(e5_spec(), &input), "query: hello world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arctic_prefix_query_uses_query_doc_is_bare() {
|
||||
let doc = EmbeddingInput {
|
||||
text: "후입선출 자료구조",
|
||||
kind: EmbeddingKind::Document,
|
||||
};
|
||||
let qry = EmbeddingInput {
|
||||
text: "스택 자료구조",
|
||||
kind: EmbeddingKind::Query,
|
||||
};
|
||||
// arctic: documents are embedded raw, queries get `query: `.
|
||||
assert_eq!(prefix_input(arctic_spec(), &doc), "후입선출 자료구조");
|
||||
assert_eq!(prefix_input(arctic_spec(), &qry), "query: 스택 자료구조");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -371,8 +572,10 @@ mod tests {
|
||||
text: "",
|
||||
kind: EmbeddingKind::Query,
|
||||
};
|
||||
assert_eq!(prefix_input(&doc), "passage: ");
|
||||
assert_eq!(prefix_input(&qry), "query: ");
|
||||
assert_eq!(prefix_input(e5_spec(), &doc), "passage: ");
|
||||
assert_eq!(prefix_input(e5_spec(), &qry), "query: ");
|
||||
assert_eq!(prefix_input(arctic_spec(), &doc), "");
|
||||
assert_eq!(prefix_input(arctic_spec(), &qry), "query: ");
|
||||
}
|
||||
|
||||
// ── check_dim ────────────────────────────────────────────────────
|
||||
@@ -393,9 +596,9 @@ mod tests {
|
||||
}
|
||||
|
||||
// ── model guard ──────────────────────────────────────────────────
|
||||
// A non-e5-large model name must fail fast (BEFORE the ~2GB download),
|
||||
// so we never download e5-large yet label its vectors with another name
|
||||
// via model_id() — which would mislabel embedding_version.
|
||||
// A model name not in the registry must fail fast (BEFORE the ~2GB
|
||||
// download), so we never download one model yet label its vectors with
|
||||
// another name via model_id() — which would mislabel embedding_version.
|
||||
|
||||
#[test]
|
||||
fn new_rejects_unsupported_model() {
|
||||
@@ -409,8 +612,8 @@ mod tests {
|
||||
.expect("unsupported model must error");
|
||||
let msg = format!("{err:#}");
|
||||
assert!(
|
||||
msg.contains("candle provider currently supports only"),
|
||||
"expected model-guard error, got: {msg}"
|
||||
msg.contains("candle provider supports the models"),
|
||||
"expected model-registry error, got: {msg}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
128
crates/kebab-embed-candle/tests/arctic_ollama_parity.rs
Normal file
128
crates/kebab-embed-candle/tests/arctic_ollama_parity.rs
Normal file
@@ -0,0 +1,128 @@
|
||||
//! arctic-embed-l-v2.0 correctness gate (`#[ignore]` — needs the ~2GB candle
|
||||
//! model + a live Ollama serving `snowflake-arctic-embed2`).
|
||||
//!
|
||||
//! This is the load-bearing pooling/prefix check for the arctic integration.
|
||||
//! The recall measurement that justified adopting arctic (recall@10 130/132)
|
||||
//! went through Ollama's `snowflake-arctic-embed2`. The candle path
|
||||
//! re-implements the model (XLM-RoBERTa-large + **CLS** pooling + `query: ` on
|
||||
//! queries / no prefix on documents). If candle's pooling or prefix is wrong,
|
||||
//! its vectors silently diverge from the measured route and the 130 number
|
||||
//! does NOT carry over. This test pins them together: per-sentence cosine
|
||||
//! between the candle vector and the Ollama vector must be **> 0.99**.
|
||||
//!
|
||||
//! `#[ignore]` because it depends on an external Ollama daemon (CI is
|
||||
//! headless/offline). The leader MUST run it once before merge.
|
||||
//!
|
||||
//! ## Manual run
|
||||
//!
|
||||
//! 1. Confirm Ollama is reachable and has the model:
|
||||
//! ```sh
|
||||
//! curl -s http://192.168.0.47:11434/api/tags # should list snowflake-arctic-embed2
|
||||
//! ```
|
||||
//! 2. Run (downloads the ~2GB candle safetensors on first run):
|
||||
//! ```sh
|
||||
//! CARGO_TARGET_DIR=/build/out/cargo-target \
|
||||
//! KEBAB_ARCTIC_OLLAMA_ENDPOINT=http://192.168.0.47:11434 \
|
||||
//! cargo test -p kebab-embed-candle --test arctic_ollama_parity -- --ignored --nocapture
|
||||
//! ```
|
||||
//! The endpoint defaults to `http://192.168.0.47:11434` if the env is unset.
|
||||
//!
|
||||
//! Record the printed `ARCTIC_PARITY_SUMMARY cosine_min=...` in
|
||||
//! `/tmp/arctic-result.md` + `tasks/HOTFIXES.md`.
|
||||
|
||||
use kebab_config::Config;
|
||||
use kebab_core::{Embedder, EmbeddingInput, EmbeddingKind};
|
||||
use kebab_embed_candle::CandleEmbedder;
|
||||
use kebab_embed_ollama::OllamaEmbedder;
|
||||
|
||||
const DOGFOOD_CONFIG: &str = "/build/dogfood/config.toml";
|
||||
const DEFAULT_OLLAMA_ENDPOINT: &str = "http://192.168.0.47:11434";
|
||||
|
||||
/// Mixed Korean / English + the descriptive-recall shapes arctic was adopted
|
||||
/// for (synonym / abbreviation / English term). Covers both prefix paths.
|
||||
const SENTENCES: &[&str] = &[
|
||||
"스택 자료구조",
|
||||
"후입선출 방식으로 동작하는 자료구조",
|
||||
"큐는 선입선출 자료구조이다",
|
||||
"Rust ownership and the borrow checker",
|
||||
"소유권과 빌림 검사기는 메모리 안전성을 보장한다",
|
||||
"SVM 은 support vector machine 의 약자이다",
|
||||
"정렬 알고리즘의 시간 복잡도",
|
||||
"The capital of France is Paris.",
|
||||
];
|
||||
|
||||
fn cosine(a: &[f32], b: &[f32]) -> f32 {
|
||||
let dot: f32 = a.iter().zip(b).map(|(x, y)| x * y).sum();
|
||||
let na: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
|
||||
let nb: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
|
||||
dot / (na * nb)
|
||||
}
|
||||
|
||||
/// Base config: prefer the canonical dogfood config (for storage/cache roots),
|
||||
/// fall back to `Config::defaults()` so the test still runs on a bare clone.
|
||||
fn base_config() -> Config {
|
||||
Config::load(Some(std::path::Path::new(DOGFOOD_CONFIG))).unwrap_or_else(|_| Config::defaults())
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs ~2GB candle model + live Ollama (snowflake-arctic-embed2); run manually before merge"]
|
||||
fn candle_arctic_matches_ollama_arctic() {
|
||||
let endpoint = std::env::var("KEBAB_ARCTIC_OLLAMA_ENDPOINT")
|
||||
.unwrap_or_else(|_| DEFAULT_OLLAMA_ENDPOINT.to_string());
|
||||
|
||||
// candle side: the in-process arctic model.
|
||||
let mut candle_cfg = base_config();
|
||||
candle_cfg.models.embedding.provider = "candle".to_string();
|
||||
candle_cfg.models.embedding.model = "snowflake-arctic-embed-l-v2.0".to_string();
|
||||
candle_cfg.models.embedding.dimensions = 1024;
|
||||
|
||||
// Ollama side: the reference route the recall numbers came from.
|
||||
let mut ollama_cfg = base_config();
|
||||
ollama_cfg.models.embedding.provider = "ollama".to_string();
|
||||
ollama_cfg.models.embedding.model = "snowflake-arctic-embed2".to_string();
|
||||
ollama_cfg.models.embedding.dimensions = 1024;
|
||||
ollama_cfg.models.embedding.endpoint = Some(endpoint.clone());
|
||||
|
||||
let candle = CandleEmbedder::new(&candle_cfg).expect("build candle arctic embedder");
|
||||
let ollama = OllamaEmbedder::new(&ollama_cfg).expect("build ollama arctic embedder");
|
||||
|
||||
// Exercise BOTH prefix paths so a query-side divergence can't hide.
|
||||
let inputs: Vec<EmbeddingInput> = SENTENCES
|
||||
.iter()
|
||||
.flat_map(|s| {
|
||||
[EmbeddingKind::Document, EmbeddingKind::Query]
|
||||
.into_iter()
|
||||
.map(move |kind| EmbeddingInput { text: s, kind })
|
||||
})
|
||||
.collect();
|
||||
|
||||
let cv = candle.embed(&inputs).expect("candle embed");
|
||||
let ov = ollama
|
||||
.embed(&inputs)
|
||||
.expect("ollama embed (is snowflake-arctic-embed2 pulled @ the endpoint?)");
|
||||
|
||||
assert_eq!(cv.len(), ov.len(), "embedding counts must match");
|
||||
assert_eq!(cv.len(), inputs.len(), "one vector per input");
|
||||
assert_eq!(candle.dimensions(), 1024);
|
||||
|
||||
let mut min_cos = f32::INFINITY;
|
||||
for (i, inp) in inputs.iter().enumerate() {
|
||||
assert_eq!(cv[i].len(), 1024, "candle dim");
|
||||
assert_eq!(ov[i].len(), 1024, "ollama dim");
|
||||
let c = cosine(&cv[i], &ov[i]);
|
||||
min_cos = min_cos.min(c);
|
||||
let kind = match inp.kind {
|
||||
EmbeddingKind::Document => "doc",
|
||||
EmbeddingKind::Query => "qry",
|
||||
};
|
||||
let preview: String = inp.text.chars().take(36).collect();
|
||||
println!("[{i:>2}] {kind} cos={c:.6} {preview}");
|
||||
}
|
||||
|
||||
println!("ARCTIC_PARITY_SUMMARY cosine_min={min_cos:.6} endpoint={endpoint}");
|
||||
assert!(
|
||||
min_cos > 0.99,
|
||||
"candle arctic vs Ollama arctic cosine_min={min_cos:.6} ≤ 0.99 — \
|
||||
pooling/prefix mismatch; the recall=130 measurement will NOT reproduce"
|
||||
);
|
||||
}
|
||||
30
crates/kebab-embed-ollama/Cargo.toml
Normal file
30
crates/kebab-embed-ollama/Cargo.toml
Normal file
@@ -0,0 +1,30 @@
|
||||
[package]
|
||||
name = "kebab-embed-ollama"
|
||||
version = { workspace = true }
|
||||
edition = { workspace = true }
|
||||
rust-version = { workspace = true }
|
||||
license = { workspace = true }
|
||||
repository = { workspace = true }
|
||||
description = "Ollama HTTP adapter implementing kebab_core::Embedder (POST /api/embed, L2-normalized, batched + fail-soft)"
|
||||
|
||||
[dependencies]
|
||||
kebab-core = { path = "../kebab-core" }
|
||||
kebab-config = { path = "../kebab-config" }
|
||||
# `default-features = false` drops native-tls (system OpenSSL); we pin rustls.
|
||||
# reqwest 0.12's `blocking` feature wraps a private current-thread tokio
|
||||
# runtime — this crate exposes NO async surface (no `async`/`await`/`tokio::*`
|
||||
# symbols), matching the kebab-llm-local invariant.
|
||||
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
# wiremock hosts the mock /api/embed server (needs a tokio runtime); tokio is
|
||||
# also pulled transitively at runtime by reqwest's `blocking` feature.
|
||||
wiremock = { workspace = true }
|
||||
tokio = { workspace = true, features = ["macros", "rt"] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
310
crates/kebab-embed-ollama/src/lib.rs
Normal file
310
crates/kebab-embed-ollama/src/lib.rs
Normal file
@@ -0,0 +1,310 @@
|
||||
//! `kebab-embed-ollama` — [`OllamaEmbedder`], a `reqwest::blocking` adapter
|
||||
//! implementing [`Embedder`](kebab_core::Embedder) over Ollama's
|
||||
//! `POST /api/embed` endpoint.
|
||||
//!
|
||||
//! ## Why this exists
|
||||
//!
|
||||
//! The candle backend ([`kebab-embed-candle`]) runs arctic-embed-l-v2.0
|
||||
//! in-process (pure Rust, NUMA-safe). This crate is the **fallback** path:
|
||||
//! it offloads embedding to a local/remote Ollama daemon (`snowflake-arctic-embed2`),
|
||||
//! which is exactly the route the recall measurements used — so it reproduces
|
||||
//! the measured numbers (recall@10 130/132) byte-for-route. Opt-in via
|
||||
//! `config.models.embedding.provider = "ollama"`.
|
||||
//!
|
||||
//! ## Wire shape
|
||||
//!
|
||||
//! Request (`POST {endpoint}/api/embed`):
|
||||
//!
|
||||
//! ```json
|
||||
//! { "model": "snowflake-arctic-embed2", "input": ["query: 스택", "후입선출 ..."] }
|
||||
//! ```
|
||||
//!
|
||||
//! Response:
|
||||
//!
|
||||
//! ```json
|
||||
//! { "model": "...", "embeddings": [[0.01, ...], [0.02, ...]] }
|
||||
//! ```
|
||||
//!
|
||||
//! ## Pipeline
|
||||
//!
|
||||
//! 1. instruction prefix per model ([`prefixes_for`] — arctic: `query: ` on
|
||||
//! queries, no prefix on documents; e5: `query: `/`passage: `);
|
||||
//! 2. batch into `BATCH` (48) inputs per request;
|
||||
//! 3. `POST /api/embed`, with fail-soft retry (`MAX_RETRIES`);
|
||||
//! 4. **L2 normalize** each returned vector — Ollama returns raw (un-normalized)
|
||||
//! embeddings, so we normalize for cosine consistency with the candle path;
|
||||
//! 5. dim check against `config.models.embedding.dimensions`.
|
||||
//!
|
||||
//! ## Send-safety
|
||||
//!
|
||||
//! `reqwest::blocking::Client: Send + Sync`; the adapter holds only the client,
|
||||
//! an endpoint string, and small config scalars, so it is trivially `Send + Sync`
|
||||
//! as the [`Embedder`] trait requires.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use kebab_core::{Embedder, EmbeddingInput, EmbeddingKind, EmbeddingModelId, EmbeddingVersion};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Inputs per `/api/embed` request. Ollama handles arbitrary batch sizes, but
|
||||
/// a cap keeps a single HTTP body bounded and lets a partial failure retry a
|
||||
/// smaller unit.
|
||||
const BATCH: usize = 48;
|
||||
|
||||
/// Fail-soft retry attempts per batch before the error propagates. Cold model
|
||||
/// load on the Ollama side can transiently 500/timeout; a couple of retries
|
||||
/// smooth that over without masking a hard misconfiguration.
|
||||
const MAX_RETRIES: u32 = 3;
|
||||
|
||||
/// Default per-request HTTP timeout (seconds). Cold-loading an embedding model
|
||||
/// on first call can take tens of seconds; this matches the generous default
|
||||
/// used by the LLM adapter.
|
||||
const REQUEST_TIMEOUT_SECS: u64 = 300;
|
||||
|
||||
/// Resolve the (query_prefix, doc_prefix) for an Ollama embedding model tag.
|
||||
///
|
||||
/// Mirrors `kebab-embed-candle`'s `MODEL_REGISTRY`, but keyed on the **Ollama
|
||||
/// model tag** (which differs from the HF id — e.g. `snowflake-arctic-embed2`
|
||||
/// vs `Snowflake/snowflake-arctic-embed-l-v2.0`). Kept here rather than shared
|
||||
/// so this crate does not depend on the candle backend.
|
||||
///
|
||||
/// An unrecognized model gets no prefix (`("", "")`): many embedding models
|
||||
/// are not instruction-tuned, so embedding the raw text is the correct default
|
||||
/// — and a misspelled known model surfaces as a recall regression, not a silent
|
||||
/// wrong-prefix, because the dim check still passes either way.
|
||||
fn prefixes_for(model: &str) -> (&'static str, &'static str) {
|
||||
let m = model.to_ascii_lowercase();
|
||||
if m.contains("arctic-embed") {
|
||||
// arctic-embed v2.0: `query: ` on queries, documents embedded raw.
|
||||
("query: ", "")
|
||||
} else if m.contains("e5") {
|
||||
// multilingual-e5: `query: ` / `passage: `.
|
||||
("query: ", "passage: ")
|
||||
} else {
|
||||
("", "")
|
||||
}
|
||||
}
|
||||
|
||||
/// `reqwest::blocking` adapter implementing [`Embedder`] over Ollama's
|
||||
/// `/api/embed`. Construction is offline; the first network call happens in
|
||||
/// [`Embedder::embed`].
|
||||
pub struct OllamaEmbedder {
|
||||
client: reqwest::blocking::Client,
|
||||
/// Validated endpoint base (e.g. `"http://127.0.0.1:11434"`).
|
||||
endpoint: String,
|
||||
/// Ollama model tag (e.g. `"snowflake-arctic-embed2"`).
|
||||
model: String,
|
||||
query_prefix: &'static str,
|
||||
doc_prefix: &'static str,
|
||||
model_id: EmbeddingModelId,
|
||||
version: EmbeddingVersion,
|
||||
dimensions: usize,
|
||||
}
|
||||
|
||||
impl OllamaEmbedder {
|
||||
/// Build from a workspace [`kebab_config::Config`]. Reads
|
||||
/// `config.models.embedding.{model, dimensions}` and resolves the endpoint
|
||||
/// as `models.embedding.endpoint` → fallback `models.llm.endpoint`.
|
||||
///
|
||||
/// Does NOT touch the network. The caller (app layer) is expected to have
|
||||
/// validated `provider == "ollama"`.
|
||||
pub fn new(config: &kebab_config::Config) -> Result<Self> {
|
||||
let emb = &config.models.embedding;
|
||||
let endpoint = emb
|
||||
.endpoint
|
||||
.clone()
|
||||
.filter(|e| !e.is_empty())
|
||||
.unwrap_or_else(|| config.models.llm.endpoint.clone());
|
||||
if endpoint.is_empty() {
|
||||
anyhow::bail!(
|
||||
"ollama embedding provider needs an endpoint: set \
|
||||
`models.embedding.endpoint` (or `models.llm.endpoint`)"
|
||||
);
|
||||
}
|
||||
let client = reqwest::blocking::Client::builder()
|
||||
.timeout(Duration::from_secs(REQUEST_TIMEOUT_SECS))
|
||||
.build()
|
||||
.context("kb-embed-ollama: build reqwest client")?;
|
||||
let (query_prefix, doc_prefix) = prefixes_for(&emb.model);
|
||||
Ok(Self {
|
||||
client,
|
||||
endpoint,
|
||||
model: emb.model.clone(),
|
||||
query_prefix,
|
||||
doc_prefix,
|
||||
model_id: EmbeddingModelId(emb.model.clone()),
|
||||
// model_version = `ollama:{model}` so a provider/model switch
|
||||
// triggers the embedding_version cascade and never collides with
|
||||
// the candle path's version string for the same model.
|
||||
version: EmbeddingVersion(format!("ollama:{}", emb.model)),
|
||||
dimensions: emb.dimensions,
|
||||
})
|
||||
}
|
||||
|
||||
/// Embed one already-prefixed batch via `/api/embed`, with fail-soft retry.
|
||||
fn embed_batch(&self, prefixed: &[String]) -> Result<Vec<Vec<f32>>> {
|
||||
let url = format!("{}/api/embed", self.endpoint.trim_end_matches('/'));
|
||||
let body = EmbedRequest {
|
||||
model: &self.model,
|
||||
input: prefixed,
|
||||
};
|
||||
|
||||
let mut last_err: Option<anyhow::Error> = None;
|
||||
for attempt in 1..=MAX_RETRIES {
|
||||
match self.try_once(&url, &body) {
|
||||
Ok(resp) => return self.finalize(resp, prefixed.len()),
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
target: "kebab-embed-ollama",
|
||||
attempt,
|
||||
max = MAX_RETRIES,
|
||||
error = %e,
|
||||
"ollama /api/embed attempt failed; retrying"
|
||||
);
|
||||
last_err = Some(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(last_err.unwrap_or_else(|| {
|
||||
anyhow::anyhow!("kb-embed-ollama: all {MAX_RETRIES} attempts failed")
|
||||
}))
|
||||
}
|
||||
|
||||
/// One HTTP round-trip. Network / non-2xx / decode errors all map to
|
||||
/// `Err` so the retry loop can decide.
|
||||
fn try_once(&self, url: &str, body: &EmbedRequest<'_>) -> Result<EmbedResponse> {
|
||||
let resp = self
|
||||
.client
|
||||
.post(url)
|
||||
.json(body)
|
||||
.send()
|
||||
.with_context(|| format!("kb-embed-ollama: POST {url}"))?;
|
||||
let status = resp.status();
|
||||
if !status.is_success() {
|
||||
let text = resp.text().unwrap_or_default();
|
||||
anyhow::bail!("kb-embed-ollama: /api/embed returned {status}: {text}");
|
||||
}
|
||||
resp.json::<EmbedResponse>()
|
||||
.context("kb-embed-ollama: decode /api/embed response")
|
||||
}
|
||||
|
||||
/// Validate count + dim, then L2-normalize each vector.
|
||||
fn finalize(&self, resp: EmbedResponse, expected: usize) -> Result<Vec<Vec<f32>>> {
|
||||
if resp.embeddings.len() != expected {
|
||||
anyhow::bail!(
|
||||
"kb-embed-ollama: expected {expected} embeddings, got {}",
|
||||
resp.embeddings.len()
|
||||
);
|
||||
}
|
||||
let mut out = Vec::with_capacity(resp.embeddings.len());
|
||||
for v in resp.embeddings {
|
||||
if v.len() != self.dimensions {
|
||||
anyhow::bail!(
|
||||
"kb-embed-ollama: model returned dim {} but config expects {} \
|
||||
(check models.embedding.dimensions vs the Ollama model)",
|
||||
v.len(),
|
||||
self.dimensions
|
||||
);
|
||||
}
|
||||
out.push(l2_normalize(v));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
impl Embedder for OllamaEmbedder {
|
||||
fn model_id(&self) -> EmbeddingModelId {
|
||||
self.model_id.clone()
|
||||
}
|
||||
|
||||
fn model_version(&self) -> EmbeddingVersion {
|
||||
self.version.clone()
|
||||
}
|
||||
|
||||
fn dimensions(&self) -> usize {
|
||||
self.dimensions
|
||||
}
|
||||
|
||||
fn embed(&self, inputs: &[EmbeddingInput<'_>]) -> Result<Vec<Vec<f32>>> {
|
||||
if inputs.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let prefixed: Vec<String> = inputs.iter().map(|i| self.prefix(i)).collect();
|
||||
let mut out = Vec::with_capacity(prefixed.len());
|
||||
for chunk in prefixed.chunks(BATCH) {
|
||||
out.extend(self.embed_batch(chunk)?);
|
||||
}
|
||||
debug_assert_eq!(out.len(), inputs.len());
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
impl OllamaEmbedder {
|
||||
/// Prefix one input per the resolved model prefixes.
|
||||
fn prefix(&self, input: &EmbeddingInput<'_>) -> String {
|
||||
match input.kind {
|
||||
EmbeddingKind::Document => format!("{}{}", self.doc_prefix, input.text),
|
||||
EmbeddingKind::Query => format!("{}{}", self.query_prefix, input.text),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// L2-normalize a vector in place-ish (consumes + returns). A zero vector is
|
||||
/// returned unchanged (norm 0 → no division) so a degenerate embedding can
|
||||
/// never produce NaNs.
|
||||
fn l2_normalize(mut v: Vec<f32>) -> Vec<f32> {
|
||||
let norm = v.iter().map(|x| x * x).sum::<f32>().sqrt();
|
||||
if norm > 0.0 {
|
||||
for x in &mut v {
|
||||
*x /= norm;
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
// ── Wire types ──────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct EmbedRequest<'a> {
|
||||
model: &'a str,
|
||||
input: &'a [String],
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct EmbedResponse {
|
||||
embeddings: Vec<Vec<f32>>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn prefixes_for_arctic_is_query_only() {
|
||||
assert_eq!(prefixes_for("snowflake-arctic-embed2"), ("query: ", ""));
|
||||
assert_eq!(prefixes_for("snowflake-arctic-embed2:latest"), ("query: ", ""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefixes_for_e5_is_query_passage() {
|
||||
assert_eq!(prefixes_for("multilingual-e5-large"), ("query: ", "passage: "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefixes_for_unknown_is_bare() {
|
||||
assert_eq!(prefixes_for("nomic-embed-text"), ("", ""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn l2_normalize_unit_length() {
|
||||
let v = l2_normalize(vec![3.0, 4.0]);
|
||||
let norm = (v[0] * v[0] + v[1] * v[1]).sqrt();
|
||||
assert!((norm - 1.0).abs() < 1e-6, "norm = {norm}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn l2_normalize_zero_vector_is_unchanged() {
|
||||
assert_eq!(l2_normalize(vec![0.0, 0.0, 0.0]), vec![0.0, 0.0, 0.0]);
|
||||
}
|
||||
}
|
||||
99
crates/kebab-embed-ollama/tests/embed_mock.rs
Normal file
99
crates/kebab-embed-ollama/tests/embed_mock.rs
Normal file
@@ -0,0 +1,99 @@
|
||||
//! `/api/embed` behavior against a `wiremock`-hosted mock server.
|
||||
//!
|
||||
//! `wiremock` is async, so the tests are `#[tokio::test]`; the sync
|
||||
//! [`OllamaEmbedder`] is driven from `spawn_blocking` to keep `reqwest::blocking`
|
||||
//! off the async runtime (same pattern as `kebab-llm-local`'s streaming tests).
|
||||
//! tokio is a `dev-dependency` only.
|
||||
|
||||
use kebab_config::Config;
|
||||
use kebab_core::{Embedder, EmbeddingInput, EmbeddingKind};
|
||||
use kebab_embed_ollama::OllamaEmbedder;
|
||||
use wiremock::matchers::{method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
/// Config pointing at the mock server, with a small dim so the mock body is
|
||||
/// tiny. `model` is an arctic tag so prefix resolution is exercised.
|
||||
fn cfg_for(endpoint: &str, dim: usize) -> Config {
|
||||
let mut cfg = Config::defaults();
|
||||
cfg.models.embedding.provider = "ollama".to_string();
|
||||
cfg.models.embedding.model = "snowflake-arctic-embed2".to_string();
|
||||
cfg.models.embedding.dimensions = dim;
|
||||
cfg.models.embedding.endpoint = Some(endpoint.to_string());
|
||||
cfg
|
||||
}
|
||||
|
||||
async fn embed_blocking(
|
||||
cfg: Config,
|
||||
inputs: Vec<(String, EmbeddingKind)>,
|
||||
) -> anyhow::Result<Vec<Vec<f32>>> {
|
||||
tokio::task::spawn_blocking(move || -> anyhow::Result<Vec<Vec<f32>>> {
|
||||
let emb = OllamaEmbedder::new(&cfg)?;
|
||||
let refs: Vec<EmbeddingInput<'_>> = inputs
|
||||
.iter()
|
||||
.map(|(t, k)| EmbeddingInput { text: t, kind: *k })
|
||||
.collect();
|
||||
emb.embed(&refs)
|
||||
})
|
||||
.await
|
||||
.expect("blocking task panicked")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn embed_returns_l2_normalized_vectors() {
|
||||
let server = MockServer::start().await;
|
||||
// Two raw (un-normalized) vectors of dim 2; the adapter must L2-normalize.
|
||||
let body = r#"{"model":"snowflake-arctic-embed2","embeddings":[[3.0,4.0],[0.0,5.0]]}"#;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/embed"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(body))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let out = embed_blocking(
|
||||
cfg_for(&server.uri(), 2),
|
||||
vec![
|
||||
("스택 자료구조".to_string(), EmbeddingKind::Query),
|
||||
("후입선출".to_string(), EmbeddingKind::Document),
|
||||
],
|
||||
)
|
||||
.await
|
||||
.expect("embed should succeed");
|
||||
|
||||
assert_eq!(out.len(), 2);
|
||||
for v in &out {
|
||||
let norm = v.iter().map(|x| x * x).sum::<f32>().sqrt();
|
||||
assert!((norm - 1.0).abs() < 1e-5, "expected unit norm, got {norm}");
|
||||
}
|
||||
// [3,4] → [0.6, 0.8].
|
||||
assert!((out[0][0] - 0.6).abs() < 1e-5 && (out[0][1] - 0.8).abs() < 1e-5);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn embed_rejects_dim_mismatch() {
|
||||
let server = MockServer::start().await;
|
||||
// Server returns dim 3, config expects dim 2 → hard error.
|
||||
let body = r#"{"model":"snowflake-arctic-embed2","embeddings":[[1.0,2.0,3.0]]}"#;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/embed"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(body))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let err = embed_blocking(
|
||||
cfg_for(&server.uri(), 2),
|
||||
vec![("q".to_string(), EmbeddingKind::Query)],
|
||||
)
|
||||
.await
|
||||
.expect_err("dim mismatch must error");
|
||||
let msg = format!("{err:#}");
|
||||
assert!(msg.contains("dim"), "expected dim error, got: {msg}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn embed_empty_input_is_noop() {
|
||||
// No mock needed — empty input must never hit the network.
|
||||
let out = embed_blocking(cfg_for("http://127.0.0.1:1", 2), vec![])
|
||||
.await
|
||||
.expect("empty embed should be Ok(empty)");
|
||||
assert!(out.is_empty());
|
||||
}
|
||||
@@ -209,6 +209,13 @@ impl OllamaVisionOcr {
|
||||
self.max_pixels
|
||||
}
|
||||
|
||||
/// The Ollama model id this engine drives (e.g. `gemma4:e4b`).
|
||||
/// Surfaced so the ingest progress display can name the model
|
||||
/// running a slow OCR phase (`AssetPhase{phase:"ocr", model}`).
|
||||
pub fn model(&self) -> &str {
|
||||
&self.model
|
||||
}
|
||||
|
||||
fn build_prompt(&self, lang_hint: Option<&Lang>) -> String {
|
||||
let langs = if self.languages.is_empty() {
|
||||
"any".to_string()
|
||||
|
||||
@@ -123,29 +123,7 @@ impl Retriever for LexicalRetriever {
|
||||
};
|
||||
|
||||
let conn = self.store.read_conn();
|
||||
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 raw_rows = run_query(&conn, &match_str, self.snippet_words, filters, fetch_limit)?;
|
||||
|
||||
let mut hits: Vec<SearchHit> = Vec::with_capacity(raw_rows.len().min(k));
|
||||
let mut rank: u32 = 0;
|
||||
@@ -228,16 +206,6 @@ 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;
|
||||
@@ -274,7 +242,7 @@ fn build_match_string_for_column(text: &str, column: &str) -> Option<String> {
|
||||
(Some(w), Some(a)) if w == a => w,
|
||||
(Some(w), Some(a)) => format!("({w}) OR ({a})"),
|
||||
};
|
||||
Some(format!("{column} : ({expression})"))
|
||||
Some(format!("text : ({expression})"))
|
||||
}
|
||||
|
||||
/// Return `Some(inner)` if `s` is wrapped in a matching pair of single
|
||||
@@ -512,77 +480,6 @@ 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(
|
||||
|
||||
@@ -144,42 +144,6 @@ 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();
|
||||
@@ -1290,51 +1254,14 @@ fn lexical_raw_mode_can_opt_into_heading_path_filter() {
|
||||
);
|
||||
}
|
||||
|
||||
// ── doc-side expansion (V010) — body+alias merged search ──────────────────
|
||||
// ── body-only lexical recall (regression-safety) ──────────────────────────
|
||||
|
||||
/// 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.
|
||||
/// Body `chunks_fts` recall works for a plain term in the chunk text.
|
||||
/// (Was previously the `empty_aliases_table_matches_baseline` regression
|
||||
/// guard; doc-side expansion was removed 2026-06-03 so the body channel is
|
||||
/// the only lexical channel.)
|
||||
#[test]
|
||||
fn 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() {
|
||||
fn body_term_recalls_chunk() {
|
||||
let env = Env::new();
|
||||
let conn = env.raw_conn();
|
||||
insert_document(
|
||||
@@ -1346,7 +1273,6 @@ fn empty_aliases_table_matches_baseline() {
|
||||
"primary",
|
||||
&[],
|
||||
);
|
||||
// aliases=None → no chunk_aliases_fts row; body channel only.
|
||||
insert_chunk(
|
||||
&conn,
|
||||
&id32("c1"),
|
||||
@@ -1370,6 +1296,6 @@ fn empty_aliases_table_matches_baseline() {
|
||||
.unwrap();
|
||||
assert!(
|
||||
hits.iter().any(|h| h.chunk_id.0 == id32("c1")),
|
||||
"aliases 빈 상태에서 본문 매칭 청크가 정상 회수돼야 한다 (회귀 안전)"
|
||||
"본문 매칭 청크가 정상 회수돼야 한다 (회귀 안전)"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -123,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, aliases
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
tokenized_korean_text
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.map_err(StoreError::from)?;
|
||||
for chunk in chunks {
|
||||
@@ -153,7 +153,6 @@ impl kebab_core::DocumentStore for SqliteStore {
|
||||
block_ids,
|
||||
now,
|
||||
chunk.tokenized_korean_text.as_deref(),
|
||||
chunk.aliases.as_deref(),
|
||||
])
|
||||
.map_err(StoreError::from)?;
|
||||
}
|
||||
@@ -268,7 +267,6 @@ 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,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -1,220 +0,0 @@
|
||||
//! 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 에 색인되면 안 된다");
|
||||
}
|
||||
@@ -23,6 +23,8 @@ fn open_store(tmp: &TempDir) -> SqliteStore {
|
||||
/// Fresh store baseline: V004 seeds `corpus_revision = 0`, then V009,
|
||||
/// V010, and V011 migrations bump it by one each to invalidate any stale
|
||||
/// LRU cache — so a fresh store after `run_migrations()` reads back as `3`.
|
||||
/// (V012 derivation_cache + V013 drop-chunk-aliases are structural/additive
|
||||
/// and do NOT bump corpus_revision.)
|
||||
#[test]
|
||||
fn fresh_store_starts_at_post_migration_baseline() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
|
||||
@@ -160,7 +160,6 @@ fn put_chunks_cleans_original_and_sentinel_embeddings() {
|
||||
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();
|
||||
|
||||
@@ -270,7 +269,6 @@ fn put_chunks_cleans_per_alias_sentinel_embeddings() {
|
||||
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();
|
||||
|
||||
|
||||
@@ -98,7 +98,6 @@ 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,
|
||||
}]
|
||||
}
|
||||
|
||||
|
||||
@@ -154,7 +154,17 @@ fn apply_event(state: &mut IngestState, event: IngestEvent) {
|
||||
}
|
||||
// v0.20.0 sub-item 1: per-page PDF OCR events — TUI does not
|
||||
// surface per-page OCR progress in v1; no counter to update.
|
||||
IngestEvent::PdfOcrStarted { .. } | IngestEvent::PdfOcrFinished { .. } => {}
|
||||
IngestEvent::PdfOcrStarted { .. }
|
||||
| IngestEvent::PdfOcrFinished { .. }
|
||||
// v0.24.0 asset-internal phase events: the status-bar reducer tracks
|
||||
// per-asset counters, not sub-asset phase progress, so these are
|
||||
// no-ops here (the CLI / --json surfaces render them).
|
||||
| IngestEvent::AssetChunked { .. }
|
||||
| IngestEvent::AssetTimings { .. }
|
||||
// v0.26.1 slow-phase hint (ocr / caption / embed): the CLI bar uses
|
||||
// it for a live phase message; the TUI status-bar reducer tracks only
|
||||
// per-asset counters, so it's a no-op here.
|
||||
| IngestEvent::AssetPhase { .. } => {}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -114,7 +114,6 @@ fn make_chunk() -> Chunk {
|
||||
chunker_version: ChunkerVersion("md-heading-v1".into()),
|
||||
policy_hash: "deadbeefdeadbeef".into(),
|
||||
tokenized_korean_text: None,
|
||||
aliases: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ Cargo workspace, 함수 호출 기반 모듈러 모놀리스. UI binary (`kebab-
|
||||
| metadata | SQLite + FTS5 (lexical search + v0.20.1 한국어 형태소 tokenizer via lindera-ko-dic) |
|
||||
| vector | LanceDB (embedded, model 별 분리 table) |
|
||||
| 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 업그레이드) |
|
||||
| embedding | `fastembed-rs` (`multilingual-e5-large`, 1024d, v0.18.0부터 default 업그레이드). opt-in 대안: candle (e5 또는 `snowflake-arctic-embed-l-v2.0`) / Ollama `/api/embed`. arctic = 설명형 query recall 보강 (v0.26.0, 아래 결정표) |
|
||||
| 한국어 형태소분석 | `lindera-ko-dic` (FTS5 외부 tokenizer, v0.20.1) — 2자 이상 한국어 query 지원 |
|
||||
| LLM | Ollama HTTP (default `gemma4:e4b` ─ OCR / caption 와 family 통일. 사용자가 더 큰 variant `gemma4:26b` 등으로 override 가능) |
|
||||
| 음성 ASR | `whisper.cpp` (via `whisper-rs`) — P8 보류, 시스템 dep brainstorm 후 |
|
||||
@@ -32,8 +32,8 @@ Cargo workspace, 함수 호출 기반 모듈러 모놀리스. UI binary (`kebab-
|
||||
| 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). |
|
||||
| ~~doc-side expansion 별칭 (v0.21.0)~~ | **제거됨 (v0.25.0, HOTFIXES 2026-06-03)** — 색인-시 청크당 LLM 별칭 생성 + 별칭 검색 채널을 완전히 제거. 별칭 ROI 음수(cross-lingual 은 e5-large 단독으로 충분, 기여는 설명형 +2 그룹뿐인데 대가가 청크당 색인-시 LLM). V013 마이그레이션이 `chunk_aliases_fts` + `chunks.aliases` DROP. 기존 KB 의 잔존 별칭 벡터는 검색 시 `strip_alias_suffix` 로 본문 chunk 에 매핑(graceful)되거나 `kebab reset` 으로 정리. spec: `docs/superpowers/specs/2026-06-03-remove-doc-expansion-spec.md`. |
|
||||
| 파생물 캐시 `derivation_cache` (V012, v0.21.0) | 비싼 ingest 파생물(embedding 벡터)을 청크 **내용 해시** 키로 SQLite 에 캐싱 → 재색인 시 내용 불변 청크는 재계산 skip. `cache_key = blake3(kind ‖ text_blake3 ‖ version_key)[:32]`; version_key 에 model/dimensions 포함 → §9 cascade 와 정합(버전 bump 시 자동 miss). 위치 기반 `chunk_id` 와 달리 내용이 같으면 문서·위치 무관 동일 키. 순수 가산 — `corpus_revision` bump 안 함, 손상/삭제돼도 정확성 영향 0(miss → 재계산). search/ask 는 `kebab.sqlite`+`lancedb` 만으로 동작하므로 외부 서버 색인 후 DB 만 복사하는 이식 워크플로 가능 (HOTFIXES 2026-05-31). (별칭 LLM 캐싱 kind 는 v0.25.0 에서 제거 — embedding kind 만 남음.) |
|
||||
| layout | XDG (`~/.local/share/kebab/`, `~/.config/kebab/`, …) |
|
||||
|
||||
전체 frozen 설계는 [docs/superpowers/specs/2026-04-27-kebab-final-form-design.md](superpowers/specs/2026-04-27-kebab-final-form-design.md) 12 sections 참조.
|
||||
@@ -67,7 +67,8 @@ flowchart TB
|
||||
subgraph Adapters ["traits + adapters"]
|
||||
embed["kebab-embed<br/>(trait)"]
|
||||
embedlocal["kebab-embed-local<br/>(fastembed, default)"]
|
||||
embedcandle["kebab-embed-candle<br/>(candle, NUMA-safe opt-in)"]
|
||||
embedcandle["kebab-embed-candle<br/>(candle, e5+arctic, NUMA-safe opt-in)"]
|
||||
embedollama["kebab-embed-ollama<br/>(Ollama /api/embed, opt-in)"]
|
||||
llm["kebab-llm<br/>(trait)"]
|
||||
llmlocal["kebab-llm-local<br/>(Ollama)"]
|
||||
search["kebab-search"]
|
||||
@@ -94,6 +95,7 @@ flowchart TB
|
||||
app --> vector
|
||||
app --> embedlocal
|
||||
app --> embedcandle
|
||||
app --> embedollama
|
||||
app --> llmlocal
|
||||
app --> search
|
||||
app --> rag
|
||||
@@ -108,6 +110,8 @@ flowchart TB
|
||||
embedlocal --> embed
|
||||
embedcandle --> core
|
||||
embedcandle --> config
|
||||
embedollama --> core
|
||||
embedollama --> config
|
||||
llmlocal --> llm
|
||||
rag --> search
|
||||
rag --> llm
|
||||
@@ -136,6 +140,23 @@ UI → store/llm/parse 직접 의존 금지. 모든 user-facing 진입은 `kebab
|
||||
|
||||
`kebab-parse-code` 의 외부 tree-sitter grammar crate 의존: P10-1A-2 에서 `tree-sitter-rust` 추가, P10-1B 에서 `tree-sitter-python` / `tree-sitter-typescript` / `tree-sitter-javascript` 추가, P10-1C-Go 에서 `tree-sitter-go` 추가, P10-1C-JK 에서 `tree-sitter-java` / `tree-sitter-kotlin-ng` 추가, P10-1D 에서 `tree-sitter-c` / `tree-sitter-cpp` 추가. 모두 `kebab-parse-code` 에만 격리 (facade 룰 — UI crate / chunker 가 직접 import 금지). Kotlin 은 `tree-sitter-kotlin-ng` 사용 (bare `tree-sitter-kotlin` 은 tree-sitter 0.21–0.23 에 고착 — 사용 불가). v0.18.0+ 부터 `kebab-source-fs` 는 자체 `code_meta` 모듈 (lang detect + skip helpers + BUILTIN_BLACKLIST) 을 보유, kebab-parse-code 와 분리 (refactor 2026-05-26). v0.19.0 부터 `kebab-parse-md` 가 `kebab-parse-types` (parser intermediate types) + `kebab-normalize` (CanonicalDocument lift) 두 crate 를 흡수 — 24 → 22 crates, design §3.7b 재작성 (HOTFIXES 2026-05-26). v0.20.1 부터 `kebab-search` 가 `lindera-ko-dic` 를 의존해 한국어 FTS5 형태소 tokenizer 지원 — V009 migration 으로 2자 이상 한국어 query 매칭 (Bug #8 closure).
|
||||
|
||||
### 임베딩 백엔드 결정표 (v0.26.0)
|
||||
|
||||
| provider | 모델 | pooling / prefix | 위치 | 언제 |
|
||||
|---|---|---|---|---|
|
||||
| `fastembed` (기본) | `multilingual-e5-large` | mean / `query:`·`passage:` | in-process (onnxruntime) | 기본. 단일 소켓 호스트 |
|
||||
| `candle` | e5 또는 `snowflake-arctic-embed-l-v2.0` | 모델별 (e5=mean, arctic=CLS) / arctic=`query:`·무접두어 | in-process (pure Rust) | NUMA 서버 (onnxruntime 48-스레드 double-free 회피), Apple Silicon Metal GPU |
|
||||
| `ollama` | `snowflake-arctic-embed2` 등 | 모델 태그로 추론 / arctic=`query:`·무접두어 | 원격 HTTP (`/api/embed`) | candle 폴백, 측정에 쓴 경로 그대로 재현 |
|
||||
|
||||
**arctic-embed-l-v2.0 채택 근거**: 별칭(doc-side expansion) 제거(v0.25.0) 후 설명형
|
||||
query 의 recall 보강책. 측정(`/build/dogfood/logs/2026-06-03-method-measurements.md`)에서
|
||||
arctic = recall@10 130/132 (e5 대비 +7, 색인 1회·per-query 0·LLM 0, 용어 무손실).
|
||||
candle 이 주 백엔드(in-process, NUMA 안전), Ollama 가 폴백(측정 경로 재현). 두 경로의
|
||||
pooling/prefix 정확성은 `kebab-embed-candle/tests/arctic_ollama_parity.rs`
|
||||
(candle arctic vs Ollama arctic 코사인>0.99, `#[ignore]`) 로 고정. e5 → arctic 전환은
|
||||
`embedding_version` cascade (모델별 벡터 상이) → 재색인 필요. 기본값 e5 유지라 기존
|
||||
사용자 무영향. 자세한 내용: [tasks/HOTFIXES.md](../tasks/HOTFIXES.md) 2026-06-03 arctic entry.
|
||||
|
||||
## 디렉토리 구조
|
||||
|
||||
```text
|
||||
@@ -184,7 +205,8 @@ kebab/
|
||||
│ ├── kebab-store-sqlite/ # SQLite + FTS5 (V001/V002/V003) (P1-6, P2-1, P3-3). src/derivation_cache.rs = derivation_cache 테이블 저장소 (V012, v0.21.0)
|
||||
│ ├── kebab-search/ # Lexical + Vector + Hybrid retriever (P2-2, P3-4)
|
||||
│ ├── kebab-embed/ kebab-embed-local/ # Embedder trait + fastembed adapter (P3-1, P3-2)
|
||||
│ ├── kebab-embed-candle/ # candle (pure-Rust) Embedder, NUMA-safe opt-in provider=candle (Track 1, v0.22.0)
|
||||
│ ├── kebab-embed-candle/ # candle (pure-Rust) Embedder, 모델 레지스트리(e5 mean + arctic CLS), NUMA-safe opt-in provider=candle (Track 1, v0.22.0; arctic v0.26.0)
|
||||
│ ├── kebab-embed-ollama/ # Ollama /api/embed Embedder, opt-in provider=ollama (arctic 폴백 경로, v0.26.0)
|
||||
│ ├── kebab-store-vector/ # LanceDB VectorStore (P3-3, P7-3 follow-up)
|
||||
│ ├── kebab-llm/ kebab-llm-local/ # LanguageModel trait + Ollama adapter (P4-1, P4-2)
|
||||
│ ├── kebab-rag/ # RAG pipeline (P4-3)
|
||||
@@ -193,7 +215,7 @@ 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 본체). src/expansion.rs = 별칭 생성, src/derivation_payload.rs = 캐시 payload 인코딩 (v0.21.0)
|
||||
│ ├── kebab-app/ # facade (P0 시그니처 + P3-5/P6-4/P7-3 본체). src/derivation_payload.rs = 캐시 payload 인코딩 (v0.21.0)
|
||||
│ ├── kebab-tui/ # Ratatui shell + Library 패널 (P9-1)
|
||||
│ ├── kebab-mcp/ # stdio MCP server — tools: schema, doctor, search, ask (P9-FB-30)
|
||||
│ └── kebab-cli/ # binary (P0 → 핫픽스로 --config flag wiring 강화)
|
||||
|
||||
@@ -695,7 +695,7 @@ printf 'schema_version = 1\n\n[workspace]\nroot = "~/MyNotes"\ninclude = ["*.md"
|
||||
"$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.
|
||||
기대: dry-run 파일 미수정 → apply 시 `old.toml.bak`(원본 byte-identical) + `[ingest.code]`·`[logging]`·`[pdf.ocr]` 가시화 + 손본 `default_k`/주석 보존 + `workspace.include` 제거 → 재실행 멱등 → doctor `config_migration` ok. v0.21.1 evidence 는 `tasks/HOTFIXES.md` 2026-05-31.
|
||||
|
||||
## §10 Eval (P5)
|
||||
|
||||
|
||||
@@ -107,16 +107,18 @@ respect_markdown_headings = true
|
||||
chunker_version = "md-heading-v1"
|
||||
|
||||
[models.embedding]
|
||||
provider = "fastembed" # "fastembed"(기본) / "candle"(순수 Rust, NUMA-안전)
|
||||
# / "none"(lexical-only — Ollama 불필요)
|
||||
# ⚠ provider="candle" 사용 시 아래 model/dimensions 도
|
||||
# multilingual-e5-large / 1024 로 바꿔야 함
|
||||
# (candle 은 현재 e5-large 만 지원).
|
||||
model = "multilingual-e5-small"
|
||||
provider = "fastembed" # "fastembed"(기본, onnxruntime) / "candle"(순수 Rust, NUMA-안전)
|
||||
# / "ollama"(원격 HTTP /api/embed) / "none"(lexical-only — Ollama 불필요)
|
||||
# ⚠ provider/model 변경 시 아래 dimensions 도 맞춰야 함.
|
||||
model = "multilingual-e5-small" # candle/ollama 는 "snowflake-arctic-embed-l-v2.0"
|
||||
# (ollama 태그 "snowflake-arctic-embed2", 1024-dim) 도 지원 —
|
||||
# 설명형 query recall 보강. e5↔arctic 전환은
|
||||
# embedding_version cascade (재색인 필요).
|
||||
version = "v1"
|
||||
dimensions = 384
|
||||
dimensions = 384 # arctic / e5-large 는 1024.
|
||||
batch_size = 64
|
||||
num_threads = 0 # candle 전용 CPU 스레드 캡 (0=auto). env KEBAB_EMBED_THREADS 우선.
|
||||
# endpoint = "http://127.0.0.1:11434" # provider="ollama" 전용; 생략 시 [models.llm].endpoint fallback.
|
||||
|
||||
[models.llm]
|
||||
provider = "ollama"
|
||||
@@ -707,7 +709,7 @@ kebab --config /tmp/kebab-smoke/old.toml config migrate # 멱등: "c
|
||||
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`.
|
||||
기대: dry-run 은 추가될 섹션(`[ingest.code]`·`[logging]` 등)과 제거될 `workspace.include` 를 출력하고 **파일을 수정하지 않는다**. 적용 시 `old.toml.bak`(원본과 동일)이 생기고 빠진 섹션이 주석과 함께 추가되며 사용자가 손본 값·주석은 보존된다. 재실행은 멱등(`config 이미 최신입니다`), `--json` 은 `config_migration.v1`.
|
||||
|
||||
## 정리
|
||||
|
||||
|
||||
33
docs/superpowers/plans/2026-06-03-arctic-embedder-plan.md
Normal file
33
docs/superpowers/plans/2026-06-03-arctic-embedder-plan.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# Plan: arctic-embed-l-v2.0 임베더 통합 구현
|
||||
|
||||
spec: `docs/superpowers/specs/2026-06-03-arctic-embedder-spec.md`. 브랜치 `feat/arctic-embedder`. 빌드 `CARGO_TARGET_DIR=/build/out/cargo-target`, `-j 4`(전체 test `-j 1`). cli 통합테스트용 `target` 심링크 필요 후 정리.
|
||||
|
||||
## Task 1 — kebab-embed-candle 모델 레지스트리
|
||||
- e5 하드코딩(`HF_MODEL`/`SUPPORTED_MODEL`/mean pool/`query:`+`passage:`) → 레지스트리 구조체 `EmbedModelSpec { name, hf_repo, pooling: Pooling, query_prefix, doc_prefix, dim }`.
|
||||
- 등록: e5(`intfloat/multilingual-e5-large`, Mean, `query: `/`passage: `, 1024) + arctic(`Snowflake/snowflake-arctic-embed-l-v2.0`, Cls, `query: `/``, 1024). **arctic pooling 은 모델 `1_Pooling/config.json` 로 확인 후 확정(CLS 추정).**
|
||||
- `embed_batch` pooling 분기: Mean=기존 attention-mask-weighted, Cls=hidden_state[:,0,:]. tokenize/forward/L2 공유.
|
||||
- `CandleEmbedder::new` 가 config model 로 spec 조회, 없으면 에러. `model_id`/`model_version` 에 모델명 반영.
|
||||
- 단위테스트: 레지스트리 조회, prefix 적용, (가능하면) CLS vs mean pooling shape.
|
||||
|
||||
## Task 2 — kebab-embed-ollama 신규 크레이트
|
||||
- `Cargo.toml`(workspace member), `Embedder` 구현. `reqwest::blocking` POST `/api/embed`.
|
||||
- 배치(48) + fail-soft 재시도(3). query/doc prefix 모델별. L2 normalize. dim 검증(config 와 일치).
|
||||
- endpoint = config.models.embedding.endpoint ?? models.llm.endpoint. model_version=`ollama:{model}`.
|
||||
- 단위테스트: wiremock 으로 /api/embed mock → dim·정규화·prefix 검증.
|
||||
|
||||
## Task 3 — config + app 배선
|
||||
- `kebab-config`: `EmbeddingCfg.provider` 문서/검증에 `ollama` 추가, `endpoint: Option<String>` 필드(serde default None). migrate.rs 주석.
|
||||
- `kebab-app` `embedder()`(또는 해당 선택부, lib.rs ~836): provider match → fastembed | candle(레지스트리) | ollama. facade 통해 cfg 주입.
|
||||
- config 직렬화/round-trip 테스트 갱신.
|
||||
|
||||
## Task 4 — correctness 검증 테스트 (핵심)
|
||||
- candle arctic vs Ollama arctic 코사인>0.99 테스트: 테스트 문장 임베딩을 candle(arctic spec)로 1개 + Ollama(`snowflake-arctic-embed2` @192.168.0.47)로 1개 → cos>0.99 assert. live Ollama 의존이라 `#[ignore]`(이유: 외부 Ollama), 수동 실행 절차를 테스트 doc + HOTFIXES 에 기록. (CI 무인 환경 회피.)
|
||||
- 단, **리더가 머지 전 이 테스트를 수동 실행해 통과 확인**(pooling/prefix 정확성 게이트).
|
||||
|
||||
## Task 5 — 검증 + 문서
|
||||
- clippy 0 / 전체 test 통과(기존 e5 회귀 0).
|
||||
- provider=candle+arctic, ollama+arctic, fastembed+e5(기본) 각 로드 스모크.
|
||||
- 문서: README Configuration(provider candle/ollama + arctic + endpoint + metal), ARCHITECTURE(백엔드 그래프 + kebab-embed-ollama 크레이트 + 결정표), HANDOFF 1줄, HOTFIXES dated, Cargo.toml members + version minor bump(+Cargo.lock).
|
||||
|
||||
## 리뷰 루프
|
||||
구현 완료 → 리더가 (a) clippy/test 독립 재확인 (b) **candle≈Ollama 코사인>0.99 수동 검증** → `gitea-pr`(title `feat(embed): arctic-embed-l-v2.0 임베더(candle+ollama)`) → 리뷰 루프 → 사용자 머지. 머지 후 Mac Metal 도그푸딩(recall 130 재현).
|
||||
33
docs/superpowers/plans/2026-06-03-ingest-log-improve-plan.md
Normal file
33
docs/superpowers/plans/2026-06-03-ingest-log-improve-plan.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# Plan: ingest 로그 개선 구현
|
||||
|
||||
spec: `docs/superpowers/specs/2026-06-03-ingest-log-improve-spec.md`. 브랜치 `feat/ingest-log-improve`. 빌드 `CARGO_TARGET_DIR=/build/out/cargo-target -j 4`(전체 test `-j 1`). cli 통합테스트용 `target` 심링크 후 정리.
|
||||
|
||||
## Task 1 — wire 이벤트 (kebab-app/src/ingest_progress.rs)
|
||||
- `IngestEvent` 에 `AssetPhase { idx: u32, total: u32, phase: String, model: Option<String> }` variant 추가(serde tag 규약 기존과 동일, snake `asset_phase`).
|
||||
- `AssetTimings` 에 `ocr_ms: u64`, `caption_ms: u64` 필드 추가(기존 필드 뒤, serde default 0 → 구 소비자 호환).
|
||||
- 직렬화 테스트 추가(asset_phase, 확장 timings).
|
||||
|
||||
## Task 2 — emit 지점 (kebab-app/src/lib.rs)
|
||||
- 이미지 경로: `apply_ocr` 직전 `AssetPhase{phase:"ocr", model: <ocr model>}`, `apply_caption` 직전 `AssetPhase{phase:"caption", model: <llm model>}` emit. 각 호출 시간 측정 → `ocr_ms`/`caption_ms`.
|
||||
- 임베딩 루프 진입 직전 `AssetPhase{phase:"embed", model: embedder.model_id}` emit(텍스트 포함 전 asset).
|
||||
- `AssetTimings` 생성부에 ocr_ms/caption_ms 전달.
|
||||
- 짧은 phase(parse/chunk/store)는 emit 안 함.
|
||||
|
||||
## Task 3 — CLI 렌더 (kebab-cli/src/progress.rs)
|
||||
- **파일명**: AssetStarted TTY 핸들러 `bar.set_message(<path>)` (현재 위치-only 주석/로직 교체; path 길면 말미 축약). 비-TTY 줄 유지.
|
||||
- **phase+모델**: AssetPhase 수신 → `bar.set_message(format!("{path} · {phase}({model})…"))`. 현재 path 를 핸들러 상태로 보관(AssetStarted 에서 저장).
|
||||
- **heartbeat**: AssetStarted 에서 `Instant::now()` 보관 + `bar.enable_steady_tick(1s)` + 메시지 렌더에 경과초 `(Ns)`. AssetFinished/다음 AssetStarted 에서 리셋. (indicatif steady-tick + 커스텀 메시지.)
|
||||
- **slowest 요약**: 핸들러에 `Vec<(path, total_ms)>` 누적 — AssetStarted 로 idx→path, AssetTimings 로 idx→sum(parse+chunk+embed+store+ocr+caption). `Completed` 수신 시 상위 5개 stderr 표 출력(`⏱ 최장 소요:`). `--json` 모드 미출력, quiet 여도 요약은 출력.
|
||||
- `fmt_ms`(기존) 재사용.
|
||||
|
||||
## Task 4 — wire schema + 문서
|
||||
- `docs/wire-schema/v1/ingest_progress.schema.json`: `asset_phase` kind(phase enum, model) + `ocr_ms`/`caption_ms` 필드 추가(additive). verbatim 일치.
|
||||
- README(있으면 진행 표시 한 줄), HANDOFF 1줄, tasks/HOTFIXES dated entry, Cargo.toml version minor bump(+Cargo.lock).
|
||||
|
||||
## Task 5 — 검증
|
||||
- clippy 0, 전체 test 통과(기존 progress 테스트 갱신).
|
||||
- 스모크: 이미지/PDF 포함 임시 폴더 ingest → TTY 파일명+phase+모델+경과, 종료 top-N. 비-TTY 줄+요약. `--json` ndjson(asset_phase/ocr_ms) 확인, 사람텍스트 미혼입.
|
||||
- 결과 요약 `/tmp/ingestlog-result.md`(게이트 + 스모크 캡처).
|
||||
|
||||
## 리뷰 루프
|
||||
완료 → 리더 clippy/test 독립 재확인 → `gitea-pr`(title `feat(ingest): 진행 로그 개선 — 파일명/phase/heartbeat/slowest 요약`) → 리뷰 루프 → 사용자 머지. 머지 후 Obsidian 볼트 도그푸딩.
|
||||
@@ -0,0 +1,49 @@
|
||||
# Plan: doc-side expansion(별칭) 제거 구현
|
||||
|
||||
spec: `docs/superpowers/specs/2026-06-03-remove-doc-expansion-spec.md`. 브랜치 `refactor/remove-doc-expansion`. 빌드 `CARGO_TARGET_DIR=/build/out/cargo-target`, 직렬 `-j 4`(전체 테스트는 `-j 1`).
|
||||
|
||||
원칙: 작은 단위로 컴파일 가능 상태 유지. 각 단계 후 `cargo build -p <crate> -j 4`. 최종 clippy+test.
|
||||
|
||||
## Task 1 — kebab-core: Chunk.aliases 필드 제거
|
||||
- `chunk.rs`: `pub aliases: Option<String>` + serde default + `aliases_defaults_to_none_on_deserialize` 테스트 제거.
|
||||
- **금지**: `metadata.rs` `Metadata.aliases`(Vec) 는 손대지 않음.
|
||||
- 컴파일 깨짐 → Task 2~ 에서 Chunk 리터럴 정리하며 해소.
|
||||
|
||||
## Task 2 — Chunk 리터럴 정리 (kebab-chunk/*, kebab-parse-*/*, store-sqlite, app)
|
||||
- `grep -rn "aliases: None" crates/*/src` 로 Chunk 생성부 전수 → `aliases: None,` 줄 삭제.
|
||||
- store-sqlite `documents.rs`: chunks INSERT 컬럼리스트/바인딩에서 `aliases` 제거(line ~126/156), SELECT 매핑의 aliases 제거, `aliases: None`(271) 제거.
|
||||
|
||||
## Task 3 — kebab-app: expansion 모듈 + 루프 제거
|
||||
- `lib.rs`: `pub mod expansion;` 삭제, `expansion.rs` 파일 삭제.
|
||||
- ingest_one_asset: expansion 블록 전체(`if app.config.ingest.expansion.enabled { … }` + `alias_version_key`/`alias_cache_*`/`alias_touch_keys`/embed_aliases 임베딩/sentinel 벡터 생성) 제거. `expansion_ms` 타이밍은 0 고정 또는 AssetTimings 에서 필드 유지하되 항상 0 — **AssetTimings 의 expansion_ms 필드는 유지(wire 호환)**, 값 0.
|
||||
- alias sentinel 벡터 upsert 경로 제거, purge_vector_orphans 는 본문 벡터 정리로 유지.
|
||||
|
||||
## Task 4 — kebab-config: ExpansionCfg 제거
|
||||
- `lib.rs`: `ExpansionCfg` struct + `IngestCfg.expansion` 필드 + Default 제거.
|
||||
- `migrate.rs`: `[ingest.expansion]` 처리/주석 제거.
|
||||
- config 직렬화 테스트에서 expansion 기대 제거.
|
||||
|
||||
## Task 5 — kebab-search: alias lexical arm 제거
|
||||
- `lexical.rs`: `run_alias_query`, `merge_body_alias`, alias 분기 제거. body_rows 직접 사용으로 단순화. alias 관련 테스트 제거/갱신.
|
||||
|
||||
## Task 6 — wire/progress 정리
|
||||
- `kebab-app/ingest_progress.rs`: `IngestEvent::ExpansionProgress` variant + 직렬화 테스트 제거. AssetChunked/AssetTimings 유지.
|
||||
- `kebab-cli/progress.rs`, `kebab-tui/ingest_progress.rs`: ExpansionProgress 매치/렌더 제거.
|
||||
- `kebab-tui/inspect.rs`: 별칭 표시 제거.
|
||||
- `docs/wire-schema/v1/ingest_progress.schema.json`: expansion_progress kind 제거.
|
||||
|
||||
## Task 7 — sqlite 마이그레이션: DROP chunk_aliases_fts + chunks.aliases
|
||||
- `schema.rs`(refinery 마이그레이션 등록부) 확인 → 신규 forward 마이그레이션 추가: `DROP TABLE IF EXISTS chunk_aliases_fts` (+ 관련 트리거/shadow 테이블 chunk_aliases_fts_*), `ALTER TABLE chunks DROP COLUMN aliases`.
|
||||
- chunk_aliases_fts 를 만들던 기존 마이그레이션은 **수정 금지**(과거 마이그레이션 freeze) — 새 마이그레이션으로 덮어 제거.
|
||||
- `tests/chunk_aliases.rs` 삭제. `tests/migration.rs` 신규 마이그레이션 반영.
|
||||
- 번들 sqlite DROP COLUMN 지원 확인(3.35+); 미지원이면 테이블 재생성 패턴.
|
||||
|
||||
## Task 8 — 검증 + 문서
|
||||
- `cargo clippy --workspace --all-targets -j 4 -- -D warnings`.
|
||||
- `cargo test --workspace --no-fail-fast -j 1`.
|
||||
- fresh KB `.schema` 로 chunk_aliases_fts/aliases 부재 확인. `kebab ingest` 스모크(별칭 config 없이).
|
||||
- grep 잔존 0 (spec Acceptance 의 정규식).
|
||||
- 문서: HOTFIXES dated entry, 2026-05-30 doc-expansion-design spec Risks/notes cross-link, HANDOFF 1줄, wire schema, design 본문 removed 주석, Cargo.toml version bump.
|
||||
|
||||
## 리뷰 루프
|
||||
구현 완료 → `gitea-pr`(title `refactor(app): doc-side expansion(별칭) 제거`, body 요약/검증) → gitea-pr 리뷰 루프(actionable 해소까지) → 사용자 머지.
|
||||
@@ -0,0 +1,163 @@
|
||||
# Expansion 비용 재고 — 별칭(doc-side LLM expansion)을 대체할 방법 조사
|
||||
|
||||
**날짜**: 2026-06-03
|
||||
**상태**: 조사 완료, 검증(측정) 대기
|
||||
**선행**: [[2026-05-30-vocabulary-gap-recall-fix-research]] (당시 결론 = doc-side expansion), v0.21.0 별칭 구현(#195/#196)
|
||||
**계기**: 도그푸딩에서 expansion 이 ingest 임계경로의 압도적 병목으로 확정(청크당 gemma4:e4b ~1.3s, 5150 청크 ≈ 1.9h). 동시성(A)·모델스왑(D) 실측 소진. 사용자가 두 구조적 반론 제기.
|
||||
|
||||
---
|
||||
|
||||
## 1. 문제 재정의 — 왜 "반창고"가 다 실패하는가
|
||||
|
||||
별칭은 **청크마다 LLM 1회 호출**(`kebab-app/src/lib.rs` expansion 루프). 비용이 **코퍼스 크기에 비례**하고, KB 가 살아있으므로(문서 수정·추가) **갱신 청크를 영원히 따라가야 함**.
|
||||
|
||||
소진된 레버 (전부 *같은 총량을 언제/어떻게 나눌지*만 바꿈, 총량 불변):
|
||||
|
||||
| 레버 | 실측(2026-06-02~03, Mac M4 Pro Metal) | 판정 |
|
||||
|------|------|------|
|
||||
| A. `OLLAMA_NUM_PARALLEL` + 클라 동시요청 | 슬롯 2/4, 동시 2/4/8 → **최대 1.28×** (GPU compute 포화) | ✖ 불충분 |
|
||||
| D. 모델 스왑 | gemma4:e4b 1.22s/건(품질 합격선) · qwen2.5vl:3b 더 느림+무한반복 · **qwen3.5:2b-mlx 0.24s(~5배)지만 중국어(所有权系统)+47줄 degeneration** · 0.8b 입력에코 | ✖ gemma 품질 못 이김 |
|
||||
| B. 백그라운드/별도명령 | 총량·팬·리소스 불변, 유지보수 treadmill 잔존 | ✖ 사용자 반론으로 기각 |
|
||||
|
||||
**사용자 반론(정확)**: ① 별도 명령이어도 맥 팬·리소스 총량 동일 ② 청크당 이렇게 비싸면 갱신을 못 따라감. → "청크마다 미리 LLM" 구조 자체가 부적합. **아키텍처를 의심해야 하는 지점.**
|
||||
|
||||
---
|
||||
|
||||
## 2. 학계/웹 조사 핵심 발견
|
||||
|
||||
### 2.1 결정타 — Expansion 은 강한 검색기에 오히려 해롭다
|
||||
*"When do Generative Query and Document Expansions Fail?"* (arXiv 2309.08541): **검색기 성능과 expansion 이득 사이 강한 음의 상관**. 11개 expansion 기법 × 12개 데이터셋 × 24개 검색 모델에서 일관. 약한 모델엔 도움, **강한 모델엔 손해**(추가 noise 가 relevance 신호를 흐림, false positive 유발). 권고: *"target 이 학습 코퍼스와 크게 다르거나 약한 모델일 때만 expansion, 아니면 피하라."*
|
||||
|
||||
→ 함의: 별칭의 v0.21.0 이득이 **14/18→16/18(미미)** 였던 건 우연이 아님. e5-large 는 이미 준수한 다국어 검색기 → 별칭은 *목발*에 가깝고 ROI 가 0~음수 구간일 수 있음. **측정으로 즉시 확인 가능**(별칭 on/off 골든 비교).
|
||||
|
||||
### 2.2 어휘·교차언어 격차는 본질적으로 *임베딩* 문제
|
||||
별칭은 "역전파↔backpropagation 이 벡터공간에서 안 가깝다"를 색인-시 텍스트로 우회한 것. 정공법 = **교차언어가 강한 임베더로 벡터공간 자체에서 정렬**. 비용 = LLM 0, **색인 1회 재계산**(살아있는 KB 에서도 신규/수정 청크 임베딩은 어차피 하는 일 — treadmill 없음).
|
||||
|
||||
### 2.3 임베더 후보 (로컬·오픈, 2026)
|
||||
- **BGE-M3** (사용자 Mac 에 이미 pull 됨): XLM-RoBERTa-large 기반(= `kebab-embed-candle` 의 `XLMRobertaModel` 과 **동일 아키텍처**), dense **1024-dim**(= e5-large, 벡터스토어 그대로), **prefix 불필요**(e5 의 `query:`/`passage:` 와 달리), 단 **CLS pooling**(e5 는 mean pooling — 통합 시 분기 필요). **dense+sparse(lexical)+multi-vector(ColBERT)** 3-헤드.
|
||||
- 한↔영 실측(Belebele, 2507.08480): base bge-m3 ≈ base e5-large (EN→KO 는 e5 92.0 > m3 90.4, KO→EN 은 m3 88.4 > e5 86.5). **dense 단독 교체만으론 대박 아님**. 차별점은 sparse/multi-vector 헤드.
|
||||
- **Qwen3-Embedding** (2026 초, MTEB v2 오픈웨이트 1위; 8B 는 무거움, 0.6B/4B 변형 존재): 다국어 최상위. 소형 변형이 로컬 가용하면 dense 업그레이드 후보.
|
||||
- 다국어 일반 권고(2026 가이드들): "BGE-M3 또는 Nomic". e5-large 도 여전히 경쟁력.
|
||||
|
||||
### 2.4 Multi-vector(ColBERT)는 색인-전체가 아니라 *질의-시 rerank* 로
|
||||
ColBERT/multi-vector 는 토큰당 벡터 1개 → 저장 폭증(10M doc ≈ 6TB vs bi-encoder 30GB). **전체 코퍼스 색인 금지.** 실용 패턴 = dense 1차 검색 → **top-50/100 만 multi-vector late-interaction rerank(질의-시, O(질의))**. 진단된 "near-tie 벡터 불안정"([[project_crossscript_diagnosis]])을 정조준하면서 색인 비용 0.
|
||||
|
||||
### 2.5 굳이 expansion 한다면 — query-side, single-pass
|
||||
CTQE(2509.02377): LLM 한 번의 decoding 패스에서 candidate token 재활용 → **추가 inference 없이** query expansion. 비용 O(질의), 캐시 가능. doc-side 의 O(코퍼스)·treadmill 과 정반대.
|
||||
|
||||
---
|
||||
|
||||
## 3. 권고 아키텍처 — 청크당 LLM 0, 측정-우선 단계별
|
||||
|
||||
원칙: 비용을 **O(코퍼스 LLM)** 에서 **O(코퍼스 임베딩, 이미 수용중) + O(질의)** 로 이동. 각 단계는 기존 골든/variant eval 로 검증 후 다음 진행(사용자 "측정 먼저" 방법론).
|
||||
|
||||
- **Step 0 — 별칭 ROI 실측 (LLM 0, 코드 0)**: 현재 e5-large 에서 별칭 **on vs off** 골든/variant 비교. 2.1 예측대로 차이 미미/음수면 → **별칭 기능 통째 제거**(즉시 최대 승리: 청크당 LLM 영구 소멸). 차이가 유의미할 때만 Step 1+.
|
||||
- **Step 1 — 강한 dense 임베더 (LLM 0, 색인 1회)**: BGE-M3 를 `kebab-embed-candle` 로 dense 임베더 교체 검증(같은 XLM-R, CLS pooling + prefix 제거, 1024-dim 동일). 소형 Qwen3-Embedding 가용 시 병행. `embedding_version` cascade = 전체 1회 재임베딩(0.48s/asset 관측, 수용 범위, treadmill 아님).
|
||||
- **Step 2 — BGE-M3 sparse 헤드를 lexical arm 으로 (LLM 0)**: 학습된 sparse lexical 이 FTS5 보다 교차언어 우수. 같은 임베드 패스 산출물 → 추가 색인 비용 ≈ 0. RRF 의 lexical 항 보강/대체.
|
||||
- **Step 3 — (선택) 질의-시 multi-vector rerank**: 잔존 near-tie 순위 출렁이면 top-50 만 BGE-M3 multi-vector late-interaction rerank(O(질의), 색인 bloat 0).
|
||||
|
||||
**통합 이점**: 사용자가 이미 NUMA 대응으로 만든 `kebab-embed-candle`(XLM-RoBERTa)가 BGE-M3 와 동일 아키텍처 → 가중치/풀링/헤드 추가 위주로 재사용. fastembed 도 bge-m3 지원(단 NUMA double-free 회피 위해 candle 경로 선호).
|
||||
|
||||
**리스크/주의**: ① dense 단독 교체 이득은 한↔영 데이터상 작을 수 있음 → sparse/multi-vector 가 실질 차별점, Step 1 단독 성패로 판단 말 것. ② CLS vs mean pooling, prefix 차이 → 정확히 구현 안 하면 품질 급락(검증 필수). ③ `embedding_version` bump = breaking, 재임베딩 필요(versioning cascade). ④ Mono-IR 소폭 저하 가능(2507.08480) — 골든의 한국어-단일 케이스도 같이 측정.
|
||||
|
||||
---
|
||||
|
||||
## 4. Step 0 측정 결과 (2026-06-03, v0.24.0 fresh)
|
||||
|
||||
namu corpus(997 docs / 23151 chunks, e5-large) + `namu_golden_step0.yaml`(doc_id 재매핑, 18그룹×4변형+10대조) hybrid k=50.
|
||||
|
||||
| arm | fully_consistent | recall@10 | recall@50 | mean_spread@10 | 색인 LLM 비용 |
|
||||
|------|------|------|------|------|------|
|
||||
| **OFF (별칭 없음, fresh v0.24.0)** | **14/18** | **68/72 (0.944)** | 70/72 (0.972) | **0.222** | **0** |
|
||||
| ON (별칭, v0.21.0 prior, 동일 corpus/golden) | 16/18 | ~70/72 | ~72/72 | 0.111 | 별칭 LLM (정답 18문서만 **2.5h**, 전 corpus 수 시간) |
|
||||
|
||||
fresh OFF 가 이전 baseline(14/18, A2/B2, spread 0.222)을 **정확히 재현** → 이전 ON(16/18, A1/B1, spread 0.111, handoff 2026-05-31)과 직접 비교 유효. ON 재측정은 시드 캐시의 alias 행이 7개뿐이라 전 corpus cold 별칭생성=수 시간(= 사용자가 못 견디는 그 비용) → 비실시.
|
||||
|
||||
**변형 종류별 OFF recall@10 (별칭 0):**
|
||||
`en 18/18 · ko 18/18 · syn 11/11 · abbr 7/7 · para 14/18` — **교차언어(en↔ko)·동의어·약어는 별칭 없이 이미 완벽.** 유일한 약점 = 설명형(paraphrase) 4쿼리.
|
||||
|
||||
**결론 (Step 0)**:
|
||||
- 별칭이 정조준한 **cross-lingual 격차는 e5-large 단독으로 이미 top-10 완벽 해결**(역전파↔backprop 우려는 기우였음). 별칭의 실제 기여 = **paraphrase 그룹 +2(14→16)** 뿐, 그것도 stack/svm 설명형 잔존.
|
||||
- 그 +2 를 위해 **색인-시 수 시간 LLM + 살아있는 KB treadmill**(사용자 2대 반론) 을 지불 = ROI 음수 구간. §2.1 "강한 검색기엔 expansion 이 해롭다" 와 정합.
|
||||
- **권고**: 별칭 default-off 유지하다 **제거 후보**로 격하. 단 paraphrase 잔존(4쿼리)을 §3 Step 1(BGE-M3 dense, LLM 0/색인 1회)이 닫는지 먼저 측정 → 닫으면 별칭 완전 삭제, 못 닫으면 query-side single-pass(§2.5) 소폭 보강. **어느 쪽도 청크당 LLM 0.**
|
||||
|
||||
산출물: `/build/dogfood/_archive/step0/`(config-off/on, kb-off, namu_golden_step0.yaml, fill_docids_step0.py), OFF run `run_019e89c524ca76a1befae126f0c77336`, `/tmp/step0_off_variants.json`.
|
||||
|
||||
## 5. Step 1 측정 결과 (2026-06-03) — bge-m3 dense = lateral, 업그레이드 아님
|
||||
|
||||
kebab(fastembed 4.9.1)은 bge-m3 dense 미지원(reranker V2M3 / BGE EN·ZH v1.5 / e5 만). candle 은 e5 전용(mean pool+prefix). → **standalone 측정**: kb-off 청크 23151개 + 변형 72쿼리를 Ollama `bge-m3:latest`(Mac GPU, /api/embed, prefix 없음)로 임베딩, exact cosine top-k recall. e5 baseline = kebab `--mode vector`(run_019e89d0...). 청크 임베딩 911s(~25/s), npz 캐시 `bge_m3_chunks.npz`.
|
||||
|
||||
| 변형 | e5-large dense | bge-m3 dense | Δ |
|
||||
|------|------|------|------|
|
||||
| en (영→한 cross-lingual) | 18/18 | 17/18 | −1 |
|
||||
| ko | 18/18 | 18/18 | = |
|
||||
| syn (동의어) | 11/11 | 10/11 | −1 |
|
||||
| abbr (약어) | 7/7 | 6/7 | −1 |
|
||||
| **para (설명형)** | 14/18 | **17/18** | **+3** |
|
||||
| **recall@10 합계** | **68/72 (0.944)** | **68/72 (0.944)** | **0** |
|
||||
| recall@50 합계 | 70/72 | 71/72 | +1 |
|
||||
|
||||
bge-m3 미스(recall@10): nn_syn(뉴럴 네트워크 모델), dp_abbr(DP 알고리즘), **stk_para**(stack 설명형 — 양쪽 공통 잔존), re_en(regular expression).
|
||||
|
||||
**결론(Step 1)**: bge-m3 dense 는 **맞교환** — 설명형 +3, 용어/약어/영어 −3, 합계 동률. §2.3 KO-EN 연구("base bge-m3 ≈ base e5-large, 케이스별 한쪽씩")의 정량 재현. **dense 단독 임베더 교체는 정당화 안 됨**(이득 0, embedding_version cascade 재임베딩 비용만 발생). bge-m3 의 미검증 레버 = sparse+multivector hybrid(용어 손실을 sparse 가 회복하며 para 이득 유지 가능) — 단 별도/대형 작업(kebab 에 bge-m3 3-head 통합 필요).
|
||||
|
||||
## 6. 종합 결론 & 권고
|
||||
- **별칭(doc-side expansion) = 제거 확정 후보.** Step 0: cross-lingual 은 e5 단독으로 이미 완벽, 별칭 기여는 para +2 뿐, 대가는 청크당 LLM(살아있는 KB 에 지속 불가). §2.1 문헌과 정합. **권고 = 별칭 기능 제거(또는 영구 default-off + 문서화), e5-large 유지.** → 사용자 2대 반론(총량·treadmill) 즉시 해소.
|
||||
- **임베더 교체(e5→bge-m3 dense) = 보류.** Step 1: 이득 0(lateral). 추진 시에도 dense 단독 말고 **bge-m3 hybrid(sparse 포함)** 를 먼저 측정해야 의미 — 별도 조사 트랙.
|
||||
- **잔존 약점(설명형 ~4쿼리, 특히 stack)**: 별칭으로도 bge-m3 로도 안 닫힘 → 별도 소형 과제(query-side single-pass §2.5 또는 bge-m3 sparse)로 분리, 우선순위 낮음.
|
||||
|
||||
**다음 행동**: 별칭 제거 spec → plan → 구현(gitea-pr 리뷰루프). bge-m3 hybrid 는 후속 조사 항목으로 파킹. 관련 메모리 [[project_expansion_perf]] · [[project_paraphrase_robustness]] · [[project_embedding_numa_backends]].
|
||||
|
||||
## 7. 딥리서치 — 별칭 대체 방법 (2026-06-03, 4-agent 병렬)
|
||||
|
||||
별칭이 정조준했던 목적(설명형/풀어쓴 질의 recall)을 사용자 제약(로컬, 비용∝질의 또는 색인-1회, 청크당 LLM 금지)에서 달성하는 방법을 4갈래 병렬 조사. 출처는 각 절 말미.
|
||||
|
||||
### 7.1 핵심 재프레이밍 (agent 1·4 수렴)
|
||||
잔존 실패("마지막에 넣은 것을 먼저 꺼내는 자료구조"→스택)는 **reverse-dictionary / describe-to-term** 과제 — 설명에서 이름(용어)을 찾는, 본질적으로 **생성·추론** 문제. dense cosine(e5든 bge-m3든) 단독이 약한 이유. **함의: 빠진 표면 용어("스택/stack/LIFO")를 materialize 하는 방법 > 벡터만 평활화하는 방법(dense PRF 등).** 측정된 실패 분해(OFF): recall@10 68/72, recall@50 70/72 → **MisRanked ~2(top-50 안, top-10 밖) + Missing ~2(top-50 밖)**.
|
||||
|
||||
### 7.2 후보 shortlist (제약 적합)
|
||||
| # | 방법 | 비용모델 | 설명형 효과 | 로컬 경로 | 통합 난이도 | 한계 |
|
||||
|---|------|---------|-----------|----------|-----------|------|
|
||||
| **A** | **heading/title chunk enrichment** (제목+가장 가까운 heading 을 청크 임베드 텍스트/FTS5 필드에 주입) | **per-doc, LLM 0**(heading 추출만, kebab `heading_path` 이미 존재) | terse doc 에 "손잡이" 부여 → Missing 완화. MC-indexing +16~43% recall(무학습) | 색인 1회 재임베딩 | 낮음 | 순수 paraphrase(용어가 doc 본문에도 없음)엔 부분적 |
|
||||
| **B** | **임베더 교체 → `dragonkue/snowflake-arctic-embed-l-v2.0-ko`** | 색인 1회 재임베딩, LLM 0 | **e5 대비 Korean 전 벤치 우위**(Ko-StrategyQA·AutoRAGRetrieval·Belebele 설명형 + XPQA 용어 둘 다) — bge-m3 와 달리 **회귀 없는 업그레이드** | XLM-R-large·1024-dim·`query:` prefix = candle crate 거의 드롭인 | 낮음 | chunk >~1300토큰서 품질↓(긴 청크면 KURE-v1 고려) |
|
||||
| **C** | **query-time rerank `bge-reranker-v2-m3`** (RRF top-50 재정렬) | **per-query**(O질의) | MisRanked 설명형의 정석 해결(cross-encoder 토큰 상호작용); 긴/설명형서 이득 최대 | **fastembed 4.9.1 `BGERerankerV2M3` 이미 보유**(ONNX int8 CPU/ M4 GPU) | 가장 낮음(신규 인덱스 0) | **Missing 못 고침**(top-50 밖). CPU FP32 느림→int8 필수. `dragonkue/...-ko` 파인튠 +3.5% |
|
||||
| **D** | **term-style query expansion**(설명→핵심용어 ≤32토큰, 캐시, RRF 별 리스트 융합) | per-query, **캐시→amortized ~0** | reverse-dictionary 직접 공략(용어 materialize). pseudo-doc(HyDE) 아님 | 기존 Ollama 재사용 | 중간 | 드리프트 위험→원쿼리 융합 유지·하드질의만 게이트 |
|
||||
| **E** | **bge-m3 sparse 헤드**를 RRF lexical 항으로 | 색인 1회(dense+sparse 1패스), LLM 0 | dense 의 용어/약어 손실 회복 가능(MLDR서 sparse>dense +10NDCG) | fastembed `SparseTextEmbedding`/ bge-m3 ONNX | 높음(3rd 인덱스+가중치) | FTS5 와 중복, dense 항은 Korean서 arctic-ko 보다 약함 |
|
||||
|
||||
### 7.3 수렴한 경고 (피할 것)
|
||||
- **always-on HyDE / pseudo-document LLM**: 로컬 1~4B 에서 13s+/질의, 살아있는(long-tail) KB 서 환각·baseline 하회. → 쓰면 **term 변형 + 하드질의 게이트만**.
|
||||
- **dense PRF 를 주해법으로**: 이미 recalled facet 만 강화, Missing 못 살림, 드리프트. (싼 add-on 이상 금물.)
|
||||
- **학습형 QPP 를 트리거로**: 비신뢰·미일반화(2504.01101). → 대신 사용자가 이미 측정한 **near-tie Δcosine(0.003~0.005, [[project_crossscript_diagnosis]])** 를 corpus-보정 게이트로 사용.
|
||||
- **SPLADE 전면 도입 / ColBERT 전체 색인**: Korean 약함·저장 폭증. (multi-vector 는 top-k rerank 로만.)
|
||||
- **reranking 으로 Missing 기대**: 불가(1차에 없으면 못 살림).
|
||||
|
||||
### 7.4 권고 — 측정-우선 계층 (싼 것부터)
|
||||
0. **무료 점검**: e5 `query:`/`passage:` prefix 정확 적용 확인(불일치 시 verbose↔terse 격차 악화). 코드 0.
|
||||
1. **Layer A (heading enrichment)** — 가장 싸고 제약 완벽 적합, kebab 이 `heading_path` 보유. 재임베딩 1회 후 골든 측정. Missing 완화 기대.
|
||||
2. **Layer B (arctic-ko 임베더)** — bge-m3 와 달리 회귀 없는 Korean 업그레이드 가설. candle 드롭인. A 와 직교·중첩 가능. 측정.
|
||||
3. **Layer C (bge-reranker-v2-m3 top-50)** — MisRanked 해결 + **MisRanked:Missing 비율 진단 도구**(한 실험으로 둘 다). 이미 보유.
|
||||
4. **Layer D (near-tie 게이트 term-expansion/triggered HyDE)** — A~C 후에도 순수 paraphrase 잔존 시에만, 하드질의에만.
|
||||
|
||||
각 계층은 기존 golden/variant eval 로 검증, 회귀(잘 되던 질의) 감시.
|
||||
|
||||
### 7.5 반대 의견 (agent 4, 정직히 기록)
|
||||
단일 사용자 KB(본인이 쓴 corpus, 존재를 기억)에선 놓친 paraphrase 질의는 **용어로 재입력하면 초 단위 복구** — 멀티테넌트엔 없는 무료 fallback. 공격적 expansion 의 false-positive 비용 > 가끔의 miss 비용일 수 있음. reverse-dictionary 는 본질적으로 어려워(생성 추론) 무리한 추격은 16/18 잘 되는 걸 회귀시킬 위험([[project_ranking_deferred]]). **현실적 결론: 싼 A(+B) 로 80% 잡고, 잔존 paraphrase 꼬리는 "알려진 한계"로 문서화** — eval 이 그게 실사용의 큰 반복 비중임을 보이지 않는 한.
|
||||
|
||||
### 7.6 출처
|
||||
- reverse-dictionary: [GEAR 2412.06654](https://arxiv.org/pdf/2412.06654) · [unified RD 2205.04602](https://arxiv.org/abs/2205.04602) · [RD probe 2402.14404](https://arxiv.org/pdf/2402.14404)
|
||||
- query expansion: [CTQE 2509.02377](https://arxiv.org/abs/2509.02377) · [QE survey 2509.07794](https://arxiv.org/html/2509.07794) · [knowledge-leakage 2504.14175](https://arxiv.org/html/2504.14175v1) · [HyDE on local 1B/4B 2506.21568](https://arxiv.org/html/2506.21568v1)
|
||||
- PRF: [LLM-VPRF 2504.01448](https://arxiv.org/abs/2504.01448) · [PRF pitfalls TOIS 3570724](https://dl.acm.org/doi/10.1145/3570724)
|
||||
- rerank: [bge-reranker-v2-m3](https://huggingface.co/BAAI/bge-reranker-v2-m3) · [dragonkue ko 파인튠](https://huggingface.co/dragonkue/bge-reranker-v2-m3-ko) · [onnx-community ONNX](https://huggingface.co/onnx-community/bge-reranker-v2-m3-ONNX) · [FlashRank](https://github.com/PrithivirajDamodaran/FlashRank) · [Scaling Laws for Reranking 2603.04816](https://arxiv.org/pdf/2603.04816) · [SlideGar 2501.09186](https://arxiv.org/html/2501.09186v1)
|
||||
- 임베더: [arctic-embed-l-v2.0-ko](https://huggingface.co/dragonkue/snowflake-arctic-embed-l-v2.0-ko) · [Arctic-Embed 2.0 2412.04506](https://arxiv.org/html/2412.04506v1) · [ko-embedding-leaderboard](https://github.com/OnAnd0n/ko-embedding-leaderboard) · [KURE](https://github.com/nlpai-lab/KURE) · [bge-m3 2402.03216](https://arxiv.org/html/2402.03216v3) · [bge-m3-onnx dense+sparse](https://github.com/yuniko-software/bge-m3-onnx)
|
||||
- verbose-query/asymmetry: [Key Concepts in Verbose Queries (SIGIR'08)](https://dl.acm.org/doi/abs/10.1145/1390334.1390419) · [Collapse of Dense Retrievers 2503.05037](https://arxiv.org/pdf/2503.05037) · [MC-indexing 2404.15103](https://arxiv.org/pdf/2404.15103) · [Elastic title-into-chunk](https://www.elastic.co/search-labs/blog/multi-vector-documents) · [Adaptive-RAG 2403.14403](https://arxiv.org/pdf/2403.14403) · [QPP limits 2504.01101](https://arxiv.org/abs/2504.01101)
|
||||
|
||||
## 출처
|
||||
- [When do Generative Query and Document Expansions Fail? (2309.08541)](https://arxiv.org/pdf/2309.08541)
|
||||
- [Korean-English Cross-Lingual Retrieval data-centric study (2507.08480)](https://arxiv.org/html/2507.08480)
|
||||
- [BGE M3-Embedding (2402.03216)](https://arxiv.org/html/2402.03216v3) · [HF BAAI/bge-m3](https://huggingface.co/BAAI/bge-m3) · [Ollama bge-m3](https://ollama.com/library/bge-m3)
|
||||
- [Doc2Query++ (2510.09557)](https://arxiv.org/abs/2510.09557) · [Doc2Query-- When Less is More](https://www.semanticscholar.org/paper/7b2e78d4e7986914ae633fa6b30e73bad8a2b2c1)
|
||||
- [CTQE — Upcycling Candidate Tokens for Query Expansion (2509.02377)](https://arxiv.org/pdf/2509.02377)
|
||||
- [Query Expansion in the Age of LLMs: A Survey (2509.07794)](https://arxiv.org/abs/2509.07794)
|
||||
- [Best Open-Source Embedding Models 2026 (BentoML)](https://www.bentoml.com/blog/a-guide-to-open-source-embedding-models)
|
||||
- [ColBERT / late interaction storage tradeoff (Weaviate)](https://weaviate.io/blog/late-interaction-overview) · [PLAID (2205.09707)](https://arxiv.org/pdf/2205.09707)
|
||||
- [MILCO — multilingual learned sparse (2510.00671)](https://www.arxiv.org/pdf/2510.00671)
|
||||
@@ -15,6 +15,12 @@ contract_sections:
|
||||
|
||||
# 색인시 doc-side expansion — 설계 spec
|
||||
|
||||
> **⚠️ 제거됨 (2026-06-03).** 본 spec 이 도입한 doc-side expansion(별칭) 기능은
|
||||
> 2026-06-03 완전히 제거되었다. 근거: 별칭 ROI 음수(cross-lingual 은 e5-large
|
||||
> 단독으로 충분, 기여는 설명형 +2 그룹뿐인데 대가가 살아있는 KB 에 지속 불가한
|
||||
> 청크당 색인-시 LLM). 제거 spec: `docs/superpowers/specs/2026-06-03-remove-doc-expansion-spec.md`,
|
||||
> HOTFIXES dated entry 2026-06-03. 이 문서는 역사적 contract 로 freeze 유지.
|
||||
|
||||
## 0. 한 줄 요약
|
||||
|
||||
문서를 색인할 때(ingest) 각 청크마다 로컬 LLM(gemma)에게 "이 내용을 찾을 사람이 던질 법한 다른
|
||||
|
||||
66
docs/superpowers/specs/2026-06-03-arctic-embedder-spec.md
Normal file
66
docs/superpowers/specs/2026-06-03-arctic-embedder-spec.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# Spec: arctic-embed-l-v2.0 임베더 통합 (candle 우선 + Ollama provider)
|
||||
|
||||
**날짜**: 2026-06-03
|
||||
**유형**: feature (신규 임베딩 백엔드/모델)
|
||||
**근거**: `docs/superpowers/research/2026-06-03-expansion-cost-rethink-research.md` + `/build/dogfood/logs/2026-06-03-method-measurements.md`. 별칭 제거(v0.25.0) 후 설명형 recall 보강의 최선책. 측정: arctic-embed2 = recall@10 **130/132**, recall@50 **132/132**, **용어 무손실**(bge-m3 와 달리 syn/abbr/en 유지). e5 대비 +7, 색인 1회·per-query 0·LLM 0 = 살아있는 KB 최적합.
|
||||
**사용자 결정**: candle 우선 + Ollama embed provider 폴백 둘 다.
|
||||
|
||||
## 목표
|
||||
`models.embedding` 에서 arctic-embed-l-v2.0 을 선택 가능하게 한다. 두 백엔드:
|
||||
1. **candle** (주): `kebab-embed-candle` 를 e5 전용 → 다중 모델로 일반화, arctic 추가. in-process pure-Rust, NUMA 안전.
|
||||
2. **ollama** (폴백): 신규 Ollama embedding provider. 측정에 쓴 경로(`/api/embed`) 그대로 → 130 보장.
|
||||
기본 동작 불변(기본 provider=fastembed e5). arctic 은 opt-in.
|
||||
|
||||
## 모델 사실 (구현 기준)
|
||||
- 아키텍처: **XLM-RoBERTa-large** (candle `XLMRobertaModel` 로드 가능, e5 와 동일 계열).
|
||||
- dim: **1024** (e5 와 동일 → 벡터스토어/lancedb 테이블 차원 불변, 단 테이블명은 모델명 포함).
|
||||
- pooling: arctic-embed-l-v2.0 의 sentence-transformers `1_Pooling/config.json` 기준(**CLS 토큰 추정 — 반드시 config 로 확인**). e5 는 mean pooling → pooling 을 모델별 분기.
|
||||
- prefix: **query 에 `query: ` 접두어, 문서는 무접두어**(e5 의 `query:`/`passage:` 와 다름). 모델별 분기.
|
||||
- 정규화: L2 normalize (코사인 일관성, 기존 e5 경로와 동일).
|
||||
- HF repo: `Snowflake/snowflake-arctic-embed-l-v2.0` (candle 다운로드). Ollama: `snowflake-arctic-embed2`.
|
||||
|
||||
## 작업 A — kebab-embed-candle 다중 모델화
|
||||
- 현재 `HF_MODEL`/`SUPPORTED_MODEL` 상수(e5 하드코딩) → **모델 레지스트리**로: `{ name, hf_repo, pooling: Mean|Cls, query_prefix, doc_prefix, dim }`. e5(mean, `query: `/`passage: `) + arctic(cls, `query: `/``).
|
||||
- `embed_batch` 의 pooling 단계를 모델별 분기(mean=attention-mask-weighted mean / cls=first token). 나머지(tokenize→forward→L2)는 공유.
|
||||
- `model_id()` / `model_version()` 가 모델명+pooling 반영(전환 시 embedding_version cascade 트리거).
|
||||
- config `models.embedding.model` 이 레지스트리에 없으면 기존처럼 명확한 에러.
|
||||
- `[features] metal`/`mkl` 유지(arctic 도 동일 XLM-R 경로라 그대로 동작).
|
||||
|
||||
## 작업 B — Ollama embedding provider (신규)
|
||||
- 신규 크레이트 `kebab-embed-ollama` (또는 kebab-embed-local 내 모듈 — **새 크레이트 권장**, 의존 분리). `Embedder` trait 구현.
|
||||
- `reqwest::blocking` 으로 `POST {endpoint}/api/embed` `{model, input:[...]}` → `embeddings`. 배치(예: 48/req), fail-soft 재시도.
|
||||
- query/doc prefix 모델별(arctic: query 에 `query: `). 결과 **L2 normalize**(Ollama raw 반환 → 일관성 위해 정규화).
|
||||
- endpoint: `models.embedding.endpoint`(신규, 미설정 시 models.llm.endpoint fallback). model_version = `ollama:{model}`.
|
||||
|
||||
## 작업 C — config + app 배선
|
||||
- `kebab-config`: `EmbeddingCfg.provider` 에 `"ollama"` 허용. 신규 `endpoint: Option<String>`(ollama 용). serde forward-compat 유지.
|
||||
- `kebab-app`: embedder 선택 분기(`embedder()`)에 candle 다중모델 + ollama provider 추가. facade(`*_with_config`) 통해 config 주입(facade rule 준수).
|
||||
- UI 크레이트는 kebab-app 만 touch(불변).
|
||||
|
||||
## 결정 사항
|
||||
- **차원 1024 동일** → lancedb 테이블은 모델명 포함(`chunk_embeddings_{model}_{dim}`)이라 모델 전환 시 새 테이블, 충돌 없음.
|
||||
- **embedding_version cascade**: arctic 으로 전환 = embedding_version 변경 → 전체 재임베딩 필요(breaking). 기존 e5 KB 와 혼용 불가(명확). 기본값 e5 유지라 기존 사용자 무영향.
|
||||
- arctic **ko 파인튠(dragonkue)** 은 base(130) 로 충분 → 본 작업은 base. ko 는 후속 옵션(레지스트리에 추가만 하면 됨).
|
||||
- A(heading enrichment) 는 측정상 arctic 에서 악화 → **미적용**.
|
||||
|
||||
## 검증 기준 (Acceptance)
|
||||
- `cargo clippy --workspace --all-targets -j 4 -- -D warnings` 통과.
|
||||
- `cargo test --workspace --no-fail-fast -j 1` 통과 — 기존 e5-candle/fastembed 테스트 회귀 0.
|
||||
- **correctness 핵심**: candle arctic 으로 임베딩한 테스트 문장(예: `query: 스택 자료구조` + 문서 `후입선출 자료구조`)이 **Ollama `snowflake-arctic-embed2` 임베딩과 코사인 > 0.99 일치**(Ollama 192.168.0.47 도달 가능 — pooling/prefix 정확성 정밀 검증, 130 재현 위험 차단). live Ollama 없으면 `#[ignore]` + 수동 절차 문서화.
|
||||
- ollama provider: mock 또는 live 로 dim 1024 정규화 벡터 반환 smoke.
|
||||
- config provider=`candle`+arctic / `ollama`+arctic 각각 올바른 embedder 로드.
|
||||
- 기본 provider=fastembed e5 동작 불변(스모크).
|
||||
|
||||
## 도그푸딩 (별도, Mac Metal — 본 PR acceptance 아님)
|
||||
arctic 으로 namu 재임베딩 → `namu_golden_expanded.yaml` 로 recall@10 ≈ 130 재현 확인. CLAUDE.md §Dogfood trigger(embedder 모델 변경) 충족. 결과 HOTFIXES + release notes.
|
||||
|
||||
## 문서 동기화 (같은 PR)
|
||||
- README Configuration: provider=candle/ollama + arctic 모델 + endpoint + Apple Silicon(metal) 안내.
|
||||
- docs/ARCHITECTURE: 임베딩 백엔드 그래프 + 신규 크레이트(kebab-embed-ollama) + 결정 표(arctic 채택 근거 측정 링크).
|
||||
- HANDOFF 1줄. tasks/HOTFIXES dated entry(측정 근거 + cascade).
|
||||
- Cargo.toml workspace members += kebab-embed-ollama, version minor bump.
|
||||
|
||||
## 비범위
|
||||
- e5 KB 자동 마이그레이션(전환 = 수동 재임베딩, cascade 규칙대로).
|
||||
- dragonkue ko 파인튠(후속).
|
||||
- D(query-side)·C(reranker) 통합(별도 후속, 본 PR 은 임베더만).
|
||||
60
docs/superpowers/specs/2026-06-03-ingest-log-improve-spec.md
Normal file
60
docs/superpowers/specs/2026-06-03-ingest-log-improve-spec.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# Spec: ingest 로그 개선 (파일명·phase·heartbeat·slowest 요약)
|
||||
|
||||
**날짜**: 2026-06-03
|
||||
**유형**: feature (관측성/UX, additive wire)
|
||||
**근거**: arctic 도그푸딩 중 Obsidian 볼트(이미지/PDF 혼재 + OCR/caption on)에서 ingest 가 중간부터 느려졌는데, **TTY 진행바가 파일명·현재 phase·모델·경과시간을 안 보여줘** "멈춘 것처럼" 보였다. 원인(비전 모델 스와핑)을 로그만으로 파악 불가. v0.24.0 상세 진행 로깅의 후속 — 느린 phase(특히 이미지 OCR/caption)와 병목 파일을 가시화한다.
|
||||
|
||||
## 현재 한계 (코드 근거)
|
||||
- `kebab-cli/src/progress.rs:145` — TTY 에서 AssetStarted 는 **위치만 갱신, 파일명 메시지 미설정**(의도적; 비-TTY 줄에만 파일명). → 인터랙티브 실행 시 현재 파일 안 보임.
|
||||
- 이미지 **OCR/caption 진행 이벤트 없음** — `PdfOcrStarted/Finished`(PDF 페이지)만 존재. 이미지 OCR/caption(gemma 비전, 느림)은 무이벤트 → 진행바 정지처럼 보임(`lib.rs` apply_ocr/apply_caption 호출 주변).
|
||||
- 한 asset 이 오래 걸려도 **경과시간 heartbeat 없음**(완료 후 `AssetTimings` ⏱ 한 번).
|
||||
- 병목 파일을 **사후 파악할 요약 없음**.
|
||||
|
||||
## 목표 (사용자 결정: 1+2+3+4)
|
||||
1. **파일명**을 TTY 진행바 메시지에 표시.
|
||||
2. 느린 **phase(OCR/caption/embed) + 모델명** 실시간 표시.
|
||||
3. 현재 asset **경과시간 heartbeat**.
|
||||
4. 종료 시 **가장 오래 걸린 파일 top-N 요약**.
|
||||
|
||||
## 작업
|
||||
|
||||
### A. wire 이벤트 (additive, ingest_progress.v1)
|
||||
- **신규 `AssetPhase { idx, total, phase, model }`** — asset 이 느린 phase 진입 시 emit. `phase: &str` ∈ {`"ocr"`,`"caption"`,`"embed"`}; `model: Option<String>`(그 phase 를 수행하는 모델 — OCR/caption=비전 LLM 모델 id, embed=임베더 model_id). 짧은 phase(parse/chunk/store)는 emit 안 함(노이즈 방지).
|
||||
- **`AssetTimings` 확장**: `ocr_ms`, `caption_ms` 필드 추가(additive, 기본 0). 기존 parse/chunk/embed/store/expansion_ms 유지. → top-N 요약의 정확한 per-asset 총시간 계산 근거.
|
||||
- `PdfOcrStarted/Finished`(기존) 유지 — PDF 페이지 단위 진행은 이미 있음.
|
||||
- wire schema `docs/wire-schema/v1/ingest_progress.schema.json`: `asset_phase` kind + `phase`/`model` + `ocr_ms`/`caption_ms` 필드 문서화(additive, v1 유지).
|
||||
|
||||
### B. emit 지점 (kebab-app)
|
||||
- `ingest_one_asset` / 이미지·미디어 경로(`apply_ocr`/`apply_caption` 호출 직전, `lib.rs:~1568/1586`): 각각 `AssetPhase{phase:"ocr"|"caption", model}` emit. 임베딩 루프 진입 시 `AssetPhase{phase:"embed", model:embedder.model_id}` emit(텍스트 asset 도 적용).
|
||||
- OCR/caption 소요를 측정해 `AssetTimings.ocr_ms`/`caption_ms` 채움.
|
||||
|
||||
### C. CLI 렌더 (kebab-cli/src/progress.rs)
|
||||
1. **파일명**: AssetStarted TTY 핸들러에서 `bar.set_message(<path 축약>)`(현재 위치-only 주석 제거). 비-TTY 줄은 그대로.
|
||||
2. **phase+모델**: AssetPhase 수신 시 `bar.set_message("{path} · {phase}({model})…")`.
|
||||
3. **heartbeat**: AssetStarted 에서 현재 asset 시작 시각 기록 + steady-tick(예: 1s)으로 메시지 끝에 `(Ns)` 경과 갱신. asset 전환/완료 시 리셋.
|
||||
4. **slowest 요약**: AssetStarted(idx→path) + AssetTimings(idx→총ms=parse+chunk+embed+store+ocr+caption) 를 누적, `Completed` 수신 시 stderr 에 `⏱ 최장 소요 top-N`(기본 N=5) 표 출력. 비-TTY/quiet 에서도 요약은 출력(유용), `--json` 모드는 미출력(ndjson 오염 방지).
|
||||
|
||||
### 결정 사항
|
||||
- 모두 **additive wire** → `ingest_progress.v1` 유지(major bump 없음). 신규 소비자는 `asset_phase` 부재 허용.
|
||||
- AssetPhase 는 **emit 스로틀 불필요**(asset·phase 당 1회, 빈도 낮음). PDF 페이지 OCR 은 기존 PdfOcrStarted 가 담당(페이지 많으면 그쪽 스로틀은 별도 — 본 spec 비범위).
|
||||
- top-N 의 N: 상수 5(후속에 config 화 가능, 본 spec 비범위).
|
||||
- `--quiet` 시 진행바·phase 메시지는 억제하되 **slowest 요약은 출력**(짧고 유용). `--json` 은 전부 ndjson 으로만.
|
||||
|
||||
## 검증 기준
|
||||
- clippy 0 / 전체 test 통과(기존 진행 렌더 테스트 갱신 + 신규 이벤트 직렬화 테스트).
|
||||
- TTY 스모크: 이미지/PDF 포함 폴더 ingest 시 진행바에 **파일명 + OCR/caption/embed phase + 모델 + 경과초** 표시, 종료 시 **top-N 요약**.
|
||||
- 비-TTY: 기존 줄 로그 유지 + 종료 요약.
|
||||
- `--json`: `asset_phase`/확장 `asset_timings` ndjson 출력, 사람용 텍스트 미혼입.
|
||||
- wire schema 문서 동기화 + verbatim 일치(CI diff-check 있으면).
|
||||
|
||||
## 도그푸딩 (별도)
|
||||
사용자 Obsidian 볼트(이미지/PDF + OCR on)로 재현 — 느린 구간에서 어떤 파일·phase·모델인지 즉시 보이는지, 종료 요약이 병목 파일을 짚는지 확인. HOTFIXES + release notes.
|
||||
|
||||
## 문서 동기화 (같은 PR)
|
||||
- `docs/wire-schema/v1/ingest_progress.schema.json` (asset_phase, ocr_ms/caption_ms).
|
||||
- README(진행 표시 설명 있으면 갱신, 명령표 영향 없음), HANDOFF 1줄, tasks/HOTFIXES dated entry, Cargo.toml version minor bump.
|
||||
|
||||
## 비범위
|
||||
- PDF 페이지 OCR 진행 스로틀/요약(기존 이벤트 유지).
|
||||
- 모델 스와핑 자체 해결(그건 Ollama 설정/OCR off — 본 작업은 가시화만).
|
||||
- top-N 의 config 화.
|
||||
@@ -0,0 +1,55 @@
|
||||
# Spec: doc-side expansion(별칭) 기능 제거
|
||||
|
||||
**날짜**: 2026-06-03
|
||||
**유형**: 기능 제거 (refactor/removal)
|
||||
**근거**: `docs/superpowers/research/2026-06-03-expansion-cost-rethink-research.md` (Step 0/1 측정 + 딥리서치). 별칭 ROI 음수: cross-lingual 은 e5-large 단독으로 이미 완벽, 별칭 기여는 설명형 +2 그룹뿐인데 대가가 청크당 색인-시 LLM(살아있는 KB 에 지속 불가). 문헌(arXiv 2309.08541)도 "강한 검색기엔 expansion 해롭다" 확인.
|
||||
**design contract 영향**: design §(Phase 2 doc-side expansion) 에서 도입된 기능 제거 → `tasks/HOTFIXES.md` dated entry + 원 spec(`docs/superpowers/specs/2026-05-30-doc-side-expansion-design.md`)의 Risks/notes 에 제거 cross-link 1줄. design 본문은 별도 spec PR 이 아닌 본 PR 에서 "deprecated/removed" 주석만.
|
||||
|
||||
## 목표
|
||||
색인-시 청크당 LLM 별칭 생성 + 별칭 검색 경로를 **완전히 제거**한다. 기본 동작 불변(별칭은 이미 default-off)이라 일반 사용자 체감 0. 코드/스키마/wire 표면을 정리해 유지보수 부담을 없앤다.
|
||||
|
||||
## 제거 대상 (REMOVE)
|
||||
- `crates/kebab-app/src/expansion.rs` — 모듈 전체 (ExpansionGenerator, is_nav_boilerplate, parse_aliases, strip_list_marker).
|
||||
- `crates/kebab-app/src/lib.rs` — `pub mod expansion;`, ingest_one_asset 의 expansion 루프(별칭 생성·캐시 조회/저장·`alias_version_key`·`embed_aliases` 임베딩·alias sentinel 벡터 `{orig}#alias#N`), 관련 카운터(`alias_cache_hit/miss`, `alias_touch_keys`).
|
||||
- `crates/kebab-config/src/lib.rs` — `ExpansionCfg` 구조체 + `IngestCfg.expansion` 필드 + 기본값.
|
||||
- `crates/kebab-config/src/migrate.rs` — `[ingest.expansion]` 섹션 주석/마이그레이션 처리.
|
||||
- `crates/kebab-core/src/chunk.rs` — `Chunk.aliases: Option<String>` 필드 (+ 관련 serde default 테스트). **주의: `crates/kebab-core/src/metadata.rs` 의 `Metadata.aliases: Vec<String>` 는 문서 메타데이터(§3.6)로 무관 — 유지.**
|
||||
- `crates/kebab-search/src/lexical.rs` — `run_alias_query`, `merge_body_alias`, alias FTS 분기(`build_match_string_for_column(.., "aliases")`).
|
||||
- `crates/kebab-store-sqlite` — `chunk_aliases_fts` 테이블 + 트리거 + `chunks.aliases` 컬럼: **신규 forward 마이그레이션(V0XX)으로 DROP**. INSERT/SELECT 경로(`documents.rs` 의 aliases 컬럼 쓰기/읽기) 제거.
|
||||
- `crates/kebab-app/src/ingest_progress.rs` — `IngestEvent::ExpansionProgress` variant (+ 직렬화 테스트). **`AssetChunked`/`AssetTimings` 는 유지**(별칭과 무관, 청킹/타이밍 가시성).
|
||||
- `crates/kebab-cli/src/progress.rs` + `crates/kebab-tui/src/ingest_progress.rs` — ExpansionProgress 렌더(`별칭 확장 N/chunks`).
|
||||
- `crates/kebab-tui/src/inspect.rs` — chunk 별칭 표시(있으면).
|
||||
- derivation_cache 의 `"alias"` kind: 쓰기 경로 제거. 기존 행은 무해(읽지 않음), `kebab reset` 시 정리. kind enum 에서 alias 제거는 선택(read 호환 위해 남겨도 무방).
|
||||
|
||||
## 유지 (KEEP — 제거 금지)
|
||||
- `Metadata.aliases` (문서 메타데이터, metadata.rs).
|
||||
- `AssetChunked`, `AssetTimings` wire 이벤트 + 렌더.
|
||||
- derivation_cache 의 `embedding` kind (V012 임베딩 캐시 — 별칭과 독립, 성능 핵심).
|
||||
- `chunks_fts`(본문 FTS) 전부.
|
||||
- `Chunk` 구조체를 생성하는 모든 곳(kebab-chunk/*, kebab-parse-*/*): `aliases: None` 리터럴은 필드 제거에 맞춰 **삭제만**(기능 변경 아님).
|
||||
|
||||
## 결정 사항
|
||||
- **마이그레이션**: 신규 forward-only 마이그레이션으로 `chunk_aliases_fts`(+ 트리거)와 `chunks.aliases` 컬럼 DROP. SQLite 3.35+ `DROP COLUMN` 사용(번들 sqlite 확인). down 마이그레이션 불필요(refinery forward-only 관행 따름). 기존 KB: 별칭 default-off 라 대부분 빈 데이터 → 손실 없음. corpus_revision cascade 불필요(별칭은 검색 보조였을 뿐, 본문/임베딩 불변).
|
||||
- **wire schema**: `ingest_progress.v1` 에서 `expansion_progress` kind 제거. v0.24.0 에서 막 추가된 additive variant 라 소비자(agent/CLI)는 부재 허용 → major bump 불요. `docs/wire-schema/v1/ingest_progress.schema.json` 에서 해당 kind 정의 삭제 + 주석.
|
||||
- **버전**: workspace `version` patch/minor bump(별칭 제거 = surface 정리, breaking schema 아님 — 단 chunk_aliases_fts DROP 마이그레이션 포함이라 이전 binary 가 새 DB 열 때 영향 없음(컬럼 제거는 구 binary 의 SELECT 깨뜨릴 수 있으나 단일 사용자·forward-only 전제). minor bump 권장.
|
||||
- **config**: `[ingest.expansion]` 제거 후 기존 사용자 config.toml 에 해당 섹션이 있어도 serde forward-compat(unknown field ignore)로 무해. `kebab config migrate` 가 섹션 제거하도록 갱신(선택).
|
||||
|
||||
## 문서 동기화 (같은 PR)
|
||||
- `tasks/HOTFIXES.md`: dated entry — 제거 근거(연구 링크) + 마이그레이션 + wire 변경.
|
||||
- `docs/superpowers/specs/2026-05-30-doc-side-expansion-design.md`: Risks/notes 에 "2026-06-03 제거됨, 본 spec 참조" 1줄.
|
||||
- `README.md` / `HANDOFF.md`: 별칭이 README 에 노출돼 있으면 제거(default-off 라 노출 없을 가능성). HANDOFF 한 줄.
|
||||
- `docs/wire-schema/v1/ingest_progress.schema.json`: expansion_progress 제거.
|
||||
- design 본문(frozen contract)에 Phase 2 별칭 기술이 있으면 "removed (HOTFIXES 2026-06-03)" 주석.
|
||||
|
||||
## 검증 기준 (Acceptance)
|
||||
- `cargo clippy --workspace --all-targets -j 4 -- -D warnings` 통과.
|
||||
- `cargo test --workspace --no-fail-fast -j 1` 통과 — 별칭 전용 테스트(`tests/chunk_aliases.rs`, expansion.rs 테스트, lexical alias 테스트)는 삭제, 그 외 회귀 0.
|
||||
- 신규 마이그레이션 적용된 fresh KB 에 `chunk_aliases_fts`/`chunks.aliases` 부재 확인(`.schema`).
|
||||
- `kebab ingest`(별칭 config 없이) 정상 — AssetChunked/AssetTimings 진행 표시 유지, expansion_progress 미출력.
|
||||
- 기존 별칭 데이터가 있던 KB 도 마이그레이션 후 search/ask 정상(별칭 벡터는 무시/정리).
|
||||
- grep 잔존 0: `expansion::|ExpansionCfg|chunk_aliases|run_alias_query|merge_body_alias|ExpansionProgress|embed_aliases|is_nav_boilerplate|Chunk.*aliases`.
|
||||
|
||||
## 비범위 (out of scope)
|
||||
- 별칭 대체 방법(heading enrichment / arctic-ko 임베더 / reranker / query-side) — 후속 별 작업(연구문서 §7 Layer A~D).
|
||||
- `Metadata.aliases`(문서 메타) 변경.
|
||||
- derivation_cache GC wiring.
|
||||
@@ -14,6 +14,9 @@
|
||||
"scan_completed",
|
||||
"asset_started",
|
||||
"asset_finished",
|
||||
"asset_chunked",
|
||||
"asset_phase",
|
||||
"asset_timings",
|
||||
"embed_batch_started",
|
||||
"embed_batch_finished",
|
||||
"pdf_ocr_started",
|
||||
@@ -33,7 +36,16 @@
|
||||
"enum": ["new", "updated", "skipped", "error"],
|
||||
"description": "asset_finished: per-asset outcome (mirrors `ingest_report.v1.items[].kind`)."
|
||||
},
|
||||
"chunks": { "type": "integer", "minimum": 0, "description": "asset_finished: chunk count produced for this asset." },
|
||||
"chunks": { "type": "integer", "minimum": 0, "description": "asset_finished / asset_chunked (v0.24.0): chunk count produced for this asset." },
|
||||
"phase": { "type": "string", "enum": ["ocr", "caption", "embed"], "description": "asset_phase (v0.26.1): the slow internal phase the asset just entered. Short phases (parse/chunk/store) are not emitted." },
|
||||
"model": { "type": ["string", "null"], "description": "asset_phase (v0.26.1): model performing the phase — vision LLM id for ocr/caption, embedder model_id for embed. null when the phase runs without a configured model." },
|
||||
"parse_ms": { "type": "integer", "minimum": 0, "description": "asset_timings (v0.24.0, additive): parse phase wall-clock (ms). Emitted by markdown / image / PDF paths." },
|
||||
"chunk_ms": { "type": "integer", "minimum": 0, "description": "asset_timings (v0.24.0, additive): chunk phase wall-clock (ms). Emitted by markdown / image / PDF paths." },
|
||||
"expansion_ms": { "type": "integer", "minimum": 0, "description": "asset_timings (v0.24.0, additive): retained for wire compatibility but always 0 — doc-side expansion was removed (HOTFIXES 2026-06-03)." },
|
||||
"embed_ms": { "type": "integer", "minimum": 0, "description": "asset_timings (v0.24.0, additive): embed + vector phase wall-clock (ms) — embedding, vector upsert, and stale-vector purge." },
|
||||
"store_ms": { "type": "integer", "minimum": 0, "description": "asset_timings (v0.24.0, additive): SQLite persist phase wall-clock (ms) — put_asset/document/blocks/chunks only." },
|
||||
"ocr_ms": { "type": "integer", "minimum": 0, "description": "asset_timings (v0.26.1, additive, default 0): image/PDF OCR phase wall-clock (ms). 0 on the markdown path (no OCR)." },
|
||||
"caption_ms": { "type": "integer", "minimum": 0, "description": "asset_timings (v0.26.1, additive, default 0): image caption phase wall-clock (ms). 0 on markdown / PDF paths." },
|
||||
"n_chunks": { "type": "integer", "minimum": 0, "description": "embed_batch_started / embed_batch_finished: chunks in this embedding batch." },
|
||||
"ms": { "type": "integer", "minimum": 0, "description": "embed_batch_finished / pdf_ocr_finished: wall-clock duration (ms). pdf_ocr_finished skip path 의 의미는 mixed (DCTDecode 부재 시 0, engine 실패 시 latency-before-bail)." },
|
||||
"chars": { "type": "integer", "minimum": 0, "description": "pdf_ocr_finished: char count of OCR result. Skip 시 0." },
|
||||
|
||||
25
migrations/V013__drop_chunk_aliases.sql
Normal file
25
migrations/V013__drop_chunk_aliases.sql
Normal file
@@ -0,0 +1,25 @@
|
||||
-- V013__drop_chunk_aliases.sql — doc-side expansion(별칭) 채널 제거.
|
||||
--
|
||||
-- 근거: docs/superpowers/specs/2026-06-03-remove-doc-expansion-spec.md +
|
||||
-- tasks/HOTFIXES.md 2026-06-03 entry. V010 이 도입한 chunks.aliases 컬럼 +
|
||||
-- chunk_aliases_fts FTS5 테이블 + sync trigger 를 forward-only 로 DROP 한다.
|
||||
-- V010 자체는 과거 마이그레이션 freeze 규칙에 따라 무수정 — 본 마이그레이션이
|
||||
-- 덮어서 제거한다.
|
||||
--
|
||||
-- 별칭은 default-off 였으므로 대부분의 KB 는 빈 데이터 → 손실 없음. 본문/임베딩은
|
||||
-- 불변이라 corpus_revision cascade 불필요(spec §결정 사항) — in-process LRU 캐시는
|
||||
-- 프로세스별 휘발성이고 마이그레이션은 query 이전(store open 시)에 돌므로 bump 가
|
||||
-- 무의미. 따라서 본 마이그레이션은 순수 구조 변경(DROP)만 수행한다.
|
||||
-- body chunks_fts (chunks_ai/ad/au) 와 그 컬럼은 aliases 를 참조하지 않으므로
|
||||
-- DROP COLUMN 의 영향 없음. 번들 SQLite 3.45+ 가 ALTER TABLE DROP COLUMN 지원.
|
||||
|
||||
-- 1. aliases 를 참조하는 trigger 를 먼저 제거 (DROP COLUMN 전제).
|
||||
DROP TRIGGER IF EXISTS chunk_aliases_ai;
|
||||
DROP TRIGGER IF EXISTS chunk_aliases_ad;
|
||||
DROP TRIGGER IF EXISTS chunk_aliases_au;
|
||||
|
||||
-- 2. 별칭 전용 FTS5 테이블 제거 (shadow 테이블 chunk_aliases_fts_* 함께 정리됨).
|
||||
DROP TABLE IF EXISTS chunk_aliases_fts;
|
||||
|
||||
-- 3. 본문 chunks 의 별칭 컬럼 제거.
|
||||
ALTER TABLE chunks DROP COLUMN aliases;
|
||||
@@ -14,6 +14,237 @@ historical contract that was implemented; this file accumulates the
|
||||
deltas so phase 5+ readers can find the live behavior without diffing
|
||||
git history.
|
||||
|
||||
## 2026-06-03 — ingest 진행 로그 개선: 파일명·phase·heartbeat·slowest 요약 (v0.26.1)
|
||||
|
||||
**무엇을 왜 추가했나.** arctic 도그푸딩 중 이미지/PDF 혼재 + OCR/caption on 볼트에서
|
||||
ingest 가 중간부터 느려졌는데, TTY 진행바가 **파일명·현재 phase·모델·경과시간**을 안 보여
|
||||
"멈춘 것처럼" 보였다. 원인(비전 모델 스와핑)을 진행 표시만으로 파악 불가. v0.24.0 상세
|
||||
진행 로깅의 후속으로 느린 phase 와 병목 파일을 가시화했다. spec/plan:
|
||||
`docs/superpowers/specs/2026-06-03-ingest-log-improve-spec.md` / `…/plans/2026-06-03-ingest-log-improve-plan.md`.
|
||||
|
||||
**무엇이 바뀌었나 (additive, `ingest_progress.v1` 유지 — major bump 없음).**
|
||||
|
||||
- 신규 wire 이벤트 `asset_phase { idx, total, phase, model }` — asset 이 느린 phase
|
||||
(`ocr` / `caption` / `embed`) 진입 시 1회 emit. `model` 은 그 phase 의 모델 id
|
||||
(ocr/caption = 비전 LLM, embed = 임베더 model_id), 없으면 `null`. 짧은 phase
|
||||
(parse/chunk/store) 는 노이즈 방지로 미emit.
|
||||
- `asset_timings` 에 `ocr_ms` / `caption_ms` 필드 추가 (serde `default` 0 → 구 소비자
|
||||
호환). 이미지·PDF 경로도 이제 `asset_timings` 를 emit (이전엔 markdown 만) — slowest
|
||||
요약이 비전-모델 병목을 정확히 집계.
|
||||
- CLI 렌더(`kebab-cli/src/progress.rs`): AssetStarted 시 진행바 메시지에 파일명(긴 path 는
|
||||
말미 축약), AssetPhase 시 `{path} · {phase}({model})…`, steady-tick 1s 커스텀 키로
|
||||
경과초 `(Ns)` 라이브 갱신, `Completed` 시 stderr 에 `⏱ 최장 소요 top-5` 표.
|
||||
`--quiet` 여도 요약은 출력, `--json` 은 ndjson 만(사람텍스트 미혼입).
|
||||
|
||||
**emit 지점.** `kebab-app/src/lib.rs` — 이미지 경로 `apply_ocr`/`apply_caption` 직전
|
||||
+ ocr/caption 시간 측정, markdown/이미지/PDF 임베딩 루프 직전 `embed` phase, 각 경로
|
||||
`asset_timings` 에 측정값 채움. PDF `ocr_ms` 는 기존 page-OCR 총합 재사용.
|
||||
|
||||
**알려진 한계.** code asset 경로는 진행 이벤트(AssetChunked/Timings) 무emit 이라 slowest
|
||||
요약에 미포함(기존 동작 유지, 비범위). top-N 의 N=5 상수(config 화 비범위). PDF 페이지
|
||||
OCR 진행은 기존 `pdf_ocr_started/finished` 가 담당(본 작업 비범위).
|
||||
|
||||
**도그푸딩 (별도).** 사용자 Obsidian 볼트(이미지/PDF + OCR on) 재현 — 느린 구간의
|
||||
파일·phase·모델 즉시 가시 + 종료 요약이 병목 파일을 짚는지. release notes + 본 entry 갱신.
|
||||
|
||||
## 2026-06-03 — arctic-embed-l-v2.0 임베더 통합 (candle + Ollama) (v0.26.0)
|
||||
|
||||
**무엇을 왜 추가했나.** 별칭(doc-side expansion) 제거(v0.25.0) 후 설명형 query 의
|
||||
recall 보강책으로 `snowflake-arctic-embed-l-v2.0` 임베더를 두 백엔드로 통합했다.
|
||||
근거는 방법별 측정(`/build/dogfood/logs/2026-06-03-method-measurements.md`):
|
||||
arctic = recall@10 **130/132**, recall@50 **132/132**, **용어 무손실**(syn/abbr/en
|
||||
유지). e5-large 대비 +7, 색인 1회·per-query 0·LLM 0 = 살아있는 KB 에 지속 가능.
|
||||
별칭이 청크당 색인-시 LLM(나무위키 18문서 cold 2.5h)을 요구한 것과 대조.
|
||||
|
||||
**무엇을 건드렸나.**
|
||||
- `kebab-embed-candle`: e5 하드코딩(`HF_MODEL`/`SUPPORTED_MODEL`/mean/`query:`+`passage:`)을
|
||||
**모델 레지스트리**(`MODEL_REGISTRY`: `EmbedModelSpec { name, hf_repo, pooling, query_prefix, doc_prefix, dim, version_tag }`)로
|
||||
일반화. e5(mean, `query:`/`passage:`) + arctic(**CLS**, `query:`/무접두어). pooling
|
||||
은 모델별 분기(mean=attention-mask-weighted / CLS=`hidden[:,0,:]`), tokenize/forward/L2
|
||||
공유. arctic pooling=CLS 는 HF `1_Pooling/config.json`(`pooling_mode_cls_token:true`)로
|
||||
확인. `model_version` 은 arctic 일 때 `+arctic-cls` 태그(switch 시 embedding_version
|
||||
cascade 트리거); e5 는 fastembed-e5 와의 호환(NUMA 드롭인) 위해 plain `config.version` 유지.
|
||||
- `kebab-embed-ollama` (신규 크레이트): `Embedder` 구현, `reqwest::blocking` POST
|
||||
`/api/embed` `{model, input:[...]}` → `embeddings`. batch 48 + fail-soft 재시도 3,
|
||||
결과 **L2 정규화**(Ollama raw 반환), dim 검증, query/doc prefix 모델 태그로 추론
|
||||
(`arctic-embed`→`query:`/무접두어, `e5`→`query:`/`passage:`). `model_version=ollama:{model}`.
|
||||
endpoint = `models.embedding.endpoint` ?? `models.llm.endpoint`.
|
||||
- `kebab-config`: `EmbeddingModelCfg.endpoint: Option<String>`(serde default, ollama용) +
|
||||
`provider` 문서에 `ollama` 추가 + env `KEBAB_MODELS_EMBEDDING_ENDPOINT`.
|
||||
- `kebab-app::embedder()`: provider match 에 `ollama` 분기 추가(facade 경유).
|
||||
- workspace member += `kebab-embed-ollama`, version 0.25.0 → **0.26.0**(minor).
|
||||
|
||||
**correctness 게이트.** candle arctic 임베딩이 측정에 쓴 Ollama `snowflake-arctic-embed2`
|
||||
임베딩과 일치해야 pooling/prefix 정확성(=recall 130 재현)이 보장된다. 검증:
|
||||
`kebab-embed-candle/tests/arctic_ollama_parity.rs`(`#[ignore]`, live Ollama 의존) 가
|
||||
candle arctic vs 우리 Ollama 어댑터로 같은 문장(설명형/약어/영문 포함, doc+query
|
||||
양 경로)을 임베딩해 per-sentence **코사인 > 0.99** 를 assert. 수동 실행 결과(코사인값)는
|
||||
릴리스 전 본 entry 에 기록.
|
||||
|
||||
**수동 검증 결과** (2026-06-03 worker 실측, Ollama @192.168.0.47:11434
|
||||
`snowflake-arctic-embed2`): 8문장 × (doc+query) 16벡터 per-sentence 코사인
|
||||
**0.999984 ~ 0.999995**, `cosine_min = 0.999984` (게이트 0.99 대비 대폭 상회).
|
||||
설명형("후입선출 방식으로 동작하는 자료구조")·약어("SVM 은 support vector machine")·
|
||||
영문·한글 모두 일치. → candle arctic 의 CLS pooling + `query: ` prefix 가 Ollama 측정
|
||||
경로와 정확히 동일 = recall@10 130 재현 보장. Ollama raw 도 이미 L2-정규화(norm 1.0)라
|
||||
어댑터의 L2 정규화는 idempotent no-op. 로그: `/build/dogfood/logs/arctic-parity.log`,
|
||||
요약: `/tmp/arctic-result.md`.
|
||||
|
||||
**종단 도그푸딩** (2026-06-03, kebab **v0.26.0** 바이너리, provider=ollama
|
||||
`snowflake-arctic-embed2` @192.168.0.47). Python 하니스가 아닌 **실제 kebab
|
||||
ingest→store→search 파이프라인**으로 검증: namu 코퍼스 997 docs / 23151 chunks
|
||||
fresh 색인(`config-arctic.toml`, kb-arctic, errors=0) → 확장 골든
|
||||
(`namu_golden_expanded.yaml`, 24그룹/132변형) hybrid k=50 eval
|
||||
(run_019e8c5788a374e098d85d84eb900e23). 결과: **recall@10 130/132 (0.985)**,
|
||||
**recall@50 132/132 (완벽)**, fully_consistent **22/24**(baseline e5 19/24 대비 +3),
|
||||
MisRanked 2 / Missing 0, mean_spread@10 0.083(e5 0.208 대비 대폭 개선). 종류별
|
||||
recall@10: abbr 7/7 · en 24/24 · ko 24/24 · syn 17/17 · para 23/24 · para2 18/18 ·
|
||||
para3 17/18 = **용어 무손실 + 설명형 거의 완벽**. e5 baseline(123/132) + 측정 하니스
|
||||
arctic(130) 와 종단 일치 — 측정→구현→실파이프라인 재현 삼중 확인. 잔존 MisRanked
|
||||
2개는 D(query-side) 후속 보강 대상. 결과 `/tmp/arctic_e2e_variants.json`,
|
||||
baseline 비교 `/build/dogfood/logs/2026-06-03-new-baseline-v025.md`.
|
||||
|
||||
**호환성.** 기본 provider=fastembed e5 동작/벡터 불변(arctic 은 opt-in). dim 1024
|
||||
동일이나 LanceDB 테이블명에 모델명 포함(`chunk_embeddings_{model}_{dim}`)이라 충돌
|
||||
없음. e5 → arctic 전환 = `embedding_version` cascade(모델별 벡터 상이) → **재색인 필요**
|
||||
(기존 e5 KB 와 혼용 불가, 명확). A(heading enrichment)는 측정상 arctic 에서 악화 →
|
||||
미적용. spec: `docs/superpowers/specs/2026-06-03-arctic-embedder-spec.md`, plan: 동일
|
||||
디렉토리 `2026-06-03-arctic-embedder-plan.md`.
|
||||
|
||||
## 2026-06-03 — doc-side expansion(별칭) 기능 완전 제거 (v0.25.0)
|
||||
|
||||
**무엇을 왜 제거했나.** v0.21.0 (PR #195/#196) 에서 도입한 색인-시 청크당 LLM
|
||||
별칭 생성 + 별칭 검색 채널을 **완전히 제거**했다. 근거는 비용 재고 연구
|
||||
(`docs/superpowers/research/2026-06-03-expansion-cost-rethink-research.md`, Step 0/1
|
||||
측정 + 딥리서치): 별칭 ROI 가 음수였다 — cross-lingual 검색은 e5-large 임베더
|
||||
단독으로 이미 충분하고, 별칭의 실측 기여는 설명형 query +2 그룹(14/18→16/18)뿐인데,
|
||||
그 대가가 **청크당 색인-시 LLM 호출**(살아있는 KB 에 지속 불가능한 비용; 나무위키
|
||||
18문서 cold 2.5h)이었다. 문헌(arXiv 2309.08541)도 "강한 검색기에는 query/doc
|
||||
expansion 이 오히려 해롭다"를 확인. 별칭은 default-off 였으므로 일반 사용자 체감 0.
|
||||
|
||||
**무엇이 제거됐나 (코드/스키마/wire).**
|
||||
- 코드: `kebab-app/src/expansion.rs` 모듈 전체, `ingest_one_asset` 의 별칭 생성·캐시·
|
||||
임베딩 루프, `Chunk.aliases` 필드, `kebab-config` 의 `IngestExpansionCfg`
|
||||
(`[ingest.expansion]` 섹션 + `KEBAB_INGEST_EXPANSION_*` env), `kebab-search` 의
|
||||
`run_alias_query`/`merge_body_alias` alias lexical arm, alias sentinel 벡터 upsert
|
||||
경로 + `alias_sentinel_ids_to_delete`.
|
||||
- wire: `ingest_progress.v1` 의 `expansion_progress` kind 제거 (v0.24.0 에서 막
|
||||
추가된 additive variant 라 소비자는 부재 허용 → major bump 불요).
|
||||
`asset_timings.expansion_ms` 필드는 **wire 호환 위해 유지하되 값 항상 0**.
|
||||
- 스키마: 신규 forward-only 마이그레이션 **V013** 이 `chunk_aliases_fts`(+ 트리거)
|
||||
와 `chunks.aliases` 컬럼을 DROP. 과거 V010 은 freeze 무수정. 별칭 default-off 라
|
||||
기존 KB 대부분 빈 데이터 → 손실 없음. corpus_revision bump (검색 캐시 무효화).
|
||||
|
||||
**무엇을 유지했나 (제거 금지).** `Metadata.aliases`(문서 메타데이터 Vec, expansion
|
||||
과 무관), `AssetChunked`/`AssetTimings` wire 이벤트, derivation_cache 의 `embedding`
|
||||
kind(V012 임베딩 캐시 — 성능 핵심), `chunks_fts`(본문 FTS) 전부, `ALIAS_SUFFIX`/
|
||||
`strip_alias_suffix`(검색 시 기존 KB 의 잔존 별칭 벡터를 본문 chunk 로 graceful 매핑하는
|
||||
read-side 하위호환).
|
||||
|
||||
**기존 KB 영향.** 별칭 벡터가 있던 KB 도 마이그레이션 후 search/ask 정상 — 잔존 별칭
|
||||
sentinel 벡터(`{chunk}#alias#N`)는 검색 시 `strip_alias_suffix` 로 본문 chunk 에
|
||||
매핑되거나 `kebab reset` 으로 정리된다. 본문/임베딩 불변이라 재색인 불요.
|
||||
|
||||
**spec/plan.** `docs/superpowers/specs/2026-06-03-remove-doc-expansion-spec.md` +
|
||||
`docs/superpowers/plans/2026-06-03-remove-doc-expansion-plan.md`. 원 도입 spec
|
||||
`2026-05-30-doc-side-expansion-design.md` 에 제거 banner 추가.
|
||||
|
||||
## 2026-06-02 — 상세 ingest 진행 로깅 (asset 내부 phase 가시화, v0.24.0)
|
||||
|
||||
**무엇이 문제였나.** ingest 진행 이벤트가 asset(문서) 단위(`asset_started` /
|
||||
`asset_finished`)뿐이라 한 문서 내부의 parse / chunk / **expansion(별칭 LLM,
|
||||
청크당 순차 호출)** / embed / store 가 깜깜했다. expansion 은 청크당 ~1~4s
|
||||
(원격 GPU Ollama)이고 큰 문서는 청크 수백~천 개 → 그 한 문서에서 수십 분이
|
||||
걸리는데, 진행바는 `1/5150` 에 멈춘 듯 보여 사용자가 병목을 못 봤다.
|
||||
|
||||
**무엇을 추가했나 (wire `ingest_progress.v1` additive, 호환 유지).**
|
||||
`IngestEvent` 에 세 변이 추가 — `#[serde(tag="kind")]` 라 신규 `kind` 추가는
|
||||
wire v1 호환:
|
||||
|
||||
- `asset_chunked { idx, total, chunks }` — 청킹 직후(expansion/embed 전) 즉시
|
||||
"이 문서가 N청크" 노출. markdown / image / pdf 세 경로 모두 emit.
|
||||
- `expansion_progress { idx, total, done, chunks }` — expansion 루프 중
|
||||
**스로틀** 발신(매 25청크 또는 ≥1s, 종료 시 `done == chunks` 1프레임 더).
|
||||
캐시 히트 청크도 `done` 에 포함(warm 재색인 fast-forward 가시화). 채널 폭주
|
||||
방지 — 매 청크 emit 금지.
|
||||
- `asset_timings { idx, total, parse_ms, chunk_ms, expansion_ms, embed_ms,
|
||||
store_ms }` — asset 처리 phase 별 소요시간. **markdown 경로만** emit
|
||||
(image/pdf 는 phase shape 가 달라 생략; AssetChunked 만 emit).
|
||||
|
||||
**설계 결정 — AssetTimings 이벤트 vs AssetFinished 필드.** IMPL_BRIEF §1 은
|
||||
`AssetFinished` 에 optional phase-timing 필드를, §2 는 대안으로 신규
|
||||
`AssetTimings` 이벤트를 제시(권장). 후자를 택함 — `AssetFinished` 는 호출부
|
||||
(`ingest_with_config_progress` 루프)에서 만들어지는데 timing 데이터는
|
||||
`ingest_one_asset` 내부에만 있어, 필드를 채우려면 `kebab_core::IngestItem`
|
||||
(wire-stable struct) 변경 또는 별도 plumbing 이 필요. `ingest_one_asset` 가
|
||||
`progress` 핸들을 이미 들고 있으므로 새 이벤트를 직접 emit 하는 쪽이 crate
|
||||
경계(kebab-core 불변)도 지키고 더 깔끔. `AssetFinished` 는 손대지 않음.
|
||||
|
||||
**CLI 렌더(`kebab-cli` progress.rs).** `asset_chunked` → 진행바 message `→ N
|
||||
chunks`. `expansion_progress` → message `별칭 확장 {done}/{chunks}` (라이브).
|
||||
`asset_timings` → asset 종료 시 `⏱ parse Xs · chunk Ys · expand Zs · embed Ws
|
||||
· store Vs` 한 줄(`fmt_ms`: <1s 는 ms, ≥1s 는 1-decimal 초). `--json` 은
|
||||
`emit_json` 이 임의 이벤트를 직렬화하므로 자동 처리. `--quiet` 억제, 비-TTY
|
||||
expansion_progress 는 로그 폭주 방지로 기본 억제(진행바 message 로 커버).
|
||||
|
||||
**검증.** `cargo clippy --workspace --all-targets -- -D warnings` exit 0,
|
||||
`cargo test -p kebab-app -p kebab-cli` exit 0. 단위 테스트: ingest_progress.rs
|
||||
(3 신규 변이 직렬화 `kind` 판별 + 순서 불변식 재작성), progress.rs(`fmt_ms` 단위
|
||||
전환), 통합(`--json`/human stderr 에 새 이벤트 흐름). 실동작 smoke: 2-문서 ingest
|
||||
의 `--json` 에 `asset_chunked`/`asset_timings` 출현 + human `⏱ parse…·store…` 라인
|
||||
확인. expansion 라이브 카운터는 원격 LLM 필요라 단위/통합으로 커버.
|
||||
|
||||
**리뷰 반영.** (1) `store_ms` 경계 정정 — stale-vector orphan purge(LanceDB I/O)를
|
||||
`store_ms`(SQLite persist 전용)에서 빼 `embed_ms`(vector phase)로 이동. 진단
|
||||
정확도: store_ms 가 이제 SQLite put_* 만 의미(편집 재색인 시 920ms 가 실은 벡터
|
||||
삭제였던 오귀속 제거). purge 는 여전히 unconditional + 새 upsert 이전 실행 —
|
||||
기능 동등. (2) 최종 `expansion_progress` 프레임을 `done != last_done` 로 가드 —
|
||||
chunks 가 throttle 배수일 때의 중복 프레임 + chunks==0 시 0/0 프레임 제거.
|
||||
|
||||
**알려진 한계.** image/pdf 경로는 phase timing 없음(AssetChunked 만).
|
||||
expansion_progress 비-TTY 억제는 의도적(필요 시 `--json` 으로 전량 관측).
|
||||
|
||||
## 2026-06-02 — ingest 백엔드/디바이스 표시 + KB 이전 문서 (v0.23.1)
|
||||
|
||||
**동기.** Metal 빌드가 실제로 GPU 를 쓰는지 사용자가 터미널에서 못 봐서 Activity
|
||||
Monitor 로 확인해야 했다(`select_device()` 의 device 로그는 kb.log 파일로만, 기본
|
||||
EnvFilter=warn 이라 `--verbose` 필요). 또 "어떤 DB 파일을 옮기나" 가 README 에
|
||||
구체적이지 않았다.
|
||||
|
||||
**무엇.** (1) `kebab-cli` ingest 시작 시 임베딩 백엔드/모델/차원을 stderr 한 줄로
|
||||
표시(`임베딩 백엔드: candle (Metal/GPU 빌드) · 모델 …`), `--json`/`--quiet` 에선
|
||||
억제. Metal 표기는 `cfg!(feature="embed_metal")` 기반(빌드 사실); 확정 런타임
|
||||
디바이스는 여전히 kb.log(`candle device = …`). (2) README "외부 계산 + 로컬 검색"
|
||||
절에 복사 대상 2개(`kebab.sqlite`/`sqlite`, `lancedb/`/`vector_dir`)와 `[storage]`
|
||||
config 키·`models/`·`assets/` 복사 불필요·동일 버전/모델 조건·rsync 예시 추가.
|
||||
|
||||
**범위.** CLI 출력 + 문서만. 동작·wire·schema·벡터 변경 없음. 버전 0.23.0 → 0.23.1.
|
||||
|
||||
## 2026-06-02 — candle Metal(Apple Silicon GPU) opt-in build feature
|
||||
|
||||
**동기.** candle CPU 임베딩은 e5-large/512-tok 에서 ~1.5~1.9 s/chunk 로 느리고,
|
||||
코어를 더 줘도(rayon/MKL) 안 빨라진다(병목=커널 효율). 대용량 코퍼스(수만 청크)는
|
||||
CPU 로는 수 시간. 사용자 워크플로: **M4 Pro 맥에서 GPU 로 빠르게 색인 → sqlite +
|
||||
lancedb 만 Linux NUMA 서버로 복사 → 서버는 CPU candle 로 질의** (벡터 동일 모델이라
|
||||
호환, KB 이식성은 06-01 항목 + workspace_path 상대경로 + chunks.text 저장으로 확인).
|
||||
|
||||
**무엇.** `kebab-embed-candle` 에 `metal` feature 추가 →
|
||||
`candle-core/-nn/-transformers` 의 metal 백엔드 활성. `select_device()` 가 metal
|
||||
빌드 시 `Device::new_metal(0)` 선택(실패 시 CPU fallback), 비-metal 빌드는 기존
|
||||
`Device::Cpu` 그대로. host 복사 전 `.contiguous()` 추가(Metal 의 strided view 가
|
||||
`to_vec2` 거부 — CPU 는 허용). feature passthrough: `kebab-app/embed_metal` →
|
||||
`kebab-cli/embed_metal`. 빌드: `cargo build --release --features embed_metal`(macOS).
|
||||
|
||||
**제약 / 검증 분담.** metal 은 **macOS 전용 컴파일** — Linux CPU 머신(개발/서버)은
|
||||
비-metal 경로만 빌드(검증: clippy 0 + candle 단위 6 + thread_cap + parity, exit 0).
|
||||
**Metal 실행·속도·벡터 패리티(GPU vs CPU)는 M4 Pro 에서 사용자 검증** (Claude 의
|
||||
Linux 환경에서 불가). 로그 `candle device = Metal (GPU)` 로 GPU 사용 확인.
|
||||
|
||||
**호환성.** default(비-metal) 동작·벡터 불변. wire/schema 변경 없음. 버전 0.22.0 →
|
||||
**0.23.0** (신규 opt-in build feature surface).
|
||||
|
||||
amends: `docs/superpowers/specs/2026-06-01-embed-candle-track-spec.md` (§10 후속 — GPU 가속).
|
||||
|
||||
## 2026-06-01 — candle 임베딩 provider (NUMA double-free 회피, opt-in)
|
||||
|
||||
**무엇이 문제였나.** 듀얼소켓 NUMA 서버에서 `provider=fastembed`(onnxruntime)로
|
||||
|
||||
Reference in New Issue
Block a user