feat(p3-2): fastembed-adapter — kb-embed-local 크레이트 + FastembedEmbedder #15
Reference in New Issue
Block a user
Delete Branch "feat/p3-2-fastembed-adapter"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
변경 요약
P3-2 fastembed-adapter 작업입니다. 새 크레이트
kb-embed-local를 추가해서 P3-1에서 정의한Embeddertrait의 첫 번째 실제 구현을 제공합니다. fastembed-rs (ONNX runtime)을 통해multilingual-e5-small(384 dims) 모델을 로컬에서 실행합니다.무엇을 했는가
핵심 동작
TextEmbedding::get_model_info로 모델 메타만 조회한 뒤config.models.embedding.dimensions와 비교. 불일치 시 ~470MB ONNX 초기화를 시작하기 전에 bail합니다.EmbeddingKind::Document은"passage: ",Query는"query: "을 prepend. 단위 테스트로 정확한 prefix 문자열을 pin.config.models.embedding.batch_size단위로 chunk하고 결과를 입력 순서대로 concat.fastembed/src/text_embedding/output.rs:43) adapter에서는 재정규화하지 않습니다. 통합 테스트가‖v‖ ≈ 1.0 ± 1e-3을 pin해서 향후 fastembed bump이 이 invariant을 깨면 즉시 fail합니다.TextEmbedding::try_new전후로tracing::info!두 번. 470MB을 30-60초간 침묵 속에서 받지 않게 됩니다.Mutex<TextEmbedding>로 직렬화. ORT Session 자체는 Send+Sync지만 caller (kb-app indexer)가 어차피 sequential batch라 보수적 선택입니다 — 프로파일링에서 contention이 보이면 P3-3+에서 재검토.테스트
check_dim일치/불일치,prefix_inputDocument/Query/empty,resolve_modelknown/unknown,expand_path4가지 케이스 (substitution, no-op, XDG 설정, XDG 미설정 →~/.local/share재귀 확장). edition 2024가set_var/remove_var을 unsafe로 만들어서 XDG 테스트는 정적 Mutex + RAII guard로 직렬화합니다.#[ignore]통합 라인 (7건): 실제 모델을 로드해야 하는 spec 시나리오 모두 — default config 생성, dim mismatch belt-and-braces, Document vs Query cosine differential, L2 unit norm, byte-equal determinism, batch-64 성능 < 5s, 5-문장 multilingual fixture snapshot.SNAPSHOT_HASH_BASELINE = 0은 silent pass가 아니라 panic을 발생시킵니다. 첫--ignored실행에서 측정값과 paste-back 가이드를 출력하고 명시적으로 fail — maintainer가 보지 못하고 지나가는 케이스를 차단합니다.cargo clippy --workspace --all-targets -- -D warningsclean.의존성
Allowed deps 준수:
kb-config,kb-embed(kb-core trait 표면 재노출),fastembed = \"4.9\",tracing,anyhow(trait 반환 타입이 강제).tokenizers/ort는 fastembed 통해 transitive로만 들어옵니다 — direct dep로 등록하면 unused 경고.reqwest/hyper/hf-hub도 transitive (모델 다운로드는 fastembed 책임 — spec carve-out).kb-coredirect dep와thiserror는 둘 다 unused로 판명되어 제거했습니다 (kb-embed이 trait 표면을 그대로 재노출하므로kb-core직접 의존 불필요, error 처리는anyhow만).fastembed는 4.x 라인에 pin했습니다. 5.x가 stable로 나와 있지만 P3-3 (lancedb-store)이 embedder output 형태를 consume할 때 같이 보는 게 안전하다고 판단 — bump은 그때.Forbidden deps (
kb-source-fs,kb-parse-md,kb-normalize,kb-chunk,kb-store-*,kb-search,kb-llm*,kb-rag,kb-tui,kb-desktop) 어느 것도cargo tree -p kb-embed-local에 등장하지 않습니다.변경 파일
crates/kb-embed-local/Cargo.toml(신규)crates/kb-embed-local/src/lib.rs(신규 —FastembedEmbedder+ helpers)crates/kb-embed-local/tests/embed_model.rs(신규 — 11 default + 7 ignored)crates/kb-embed-local/tests/fixtures/embed/known-sentences.json(신규)Cargo.toml(workspace member 등록 +fastembed = \"4.9\")Cargo.lock후속 작업 후보
expand_path을 kb-config의 public API로 promote — 현재 kb-store-sqlite와 kb-embed-local 두 곳에 같은 helper가 hand-rolled됨. 별도 spec 변경이 필요해서 본 PR 범위 밖.Mutex<TextEmbedding>→ bareArc<TextEmbedding>전환. ORT Session이 Send+Sync라 가능하지만 contention이 실제로 발생하는지 확인 후 P3-3+에서.fastembed = \"5\"bump — P3-3 진입 시점에 같이 검토.--ignoredlane에서 panic 메시지의 hash을 PR로 받아 const에 paste.Out of scope
design §7.2, §6.4, §9 / 보고서 §11.3 참고.
First real Embedder implementation. Wraps fastembed-rs (ONNX runtime) with the e5 prefix convention, batching, and {data_dir}/${XDG_DATA_HOME} template expansion so model files land under config.storage.model_dir/ fastembed/ without polluting kb-config's public API. Public surface: - pub struct FastembedEmbedder - pub fn new(config: &Config) -> Result<Self> - impl kb_core::Embedder (via kb-embed re-export) Behavior: - Default model multilingual-e5-small (384 dims). model_id and model_version come from config.models.embedding.{model,version}. - Pre-load dim check via TextEmbedding::get_model_info: dim mismatch bails before paying the ~470MB ONNX init cost. - e5 prefix applied BEFORE tokenization: "passage: " for EmbeddingKind::Document, "query: " for EmbeddingKind::Query. Pinned by prefix_input unit tests. - Batches inputs into chunks of config.models.embedding.batch_size, concatenates results in input order. - L2 normalization is performed by fastembed 4.9's default transformer pipeline (verified at fastembed/src/text_embedding/output.rs:43); we skip re-normalization. Integration test pins ‖v‖ ≈ 1.0 ± 1e-3 so a future fastembed bump that drops this invariant fails loudly. - Synchronous (no async runtime). Mutex serializes calls into the underlying ONNX session — conservative; ORT Session is Send+Sync but callers (kb-app indexer) batch sequentially anyway. Revisit if profiling shows contention. - First-run model download surfaces via tracing::info before/after TextEmbedding::try_new — users no longer stare at a silent 30-60s pause during the 470MB pull. Tests: - 11 default-lane tests covering: check_dim match/mismatch (no model load), prefix_input Document/Query/empty, resolve_model known/unknown, expand_path substitution + no-op + XDG_DATA_HOME set + XDG_DATA_HOME unset (falls back to ~/.local/share with recursive ~ expansion). XDG tests serialize on a Mutex + RAII guard since edition 2024 makes set_var/remove_var unsafe. - 7 #[ignore] integration tests covering: full construction with default config, dim-mismatch belt-and-braces, Document vs Query cosine differential, L2 unit norm, byte-equal determinism, batch-64 performance under 5s, snapshot-hash stability over a 5-sentence multilingual fixture. - Snapshot test fails LOUDLY when SNAPSHOT_HASH_BASELINE is 0 — prints the captured hash and panics with paste-back instructions, so first --ignored run forces the maintainer to pin the baseline rather than silently passing. - Workspace: 222 tests pass (default lane); clippy clean. Allowed deps respected: kb-config, kb-embed (re-exports kb-core trait surface), fastembed = "4.9", tracing, anyhow. tokenizers and ort enter transitively through fastembed; reqwest/hyper/hf-hub also transitive (model download is fastembed's responsibility per spec carve-out). No direct kb-core dep needed — re-exports cover it. Pinned to fastembed 4.x rather than the recent 5.x to limit blast radius; consider bump when p3-3 (lancedb-store) consumes the embedder output shape. Out of scope: reranker (P+), Ollama embedding endpoint, candle adapter, image embeddings (P6). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>P3-2 코드 리뷰 — 셀프 머지 게이트로 인해 COMMENT only.
spec compliance 리뷰 결과 BLOCKER / MUST-FIX 모두 0건, 1건의 medium-severity FAIL (snapshot baseline silent pass)만 있었습니다. code quality 리뷰 결과 BLOCKER 0건, MUST-FIX 3건 + IMPORTANT 3건. 모든 MUST-FIX (snapshot baseline panic, unused thiserror 제거, unused kb-core 제거) + 모든 IMPORTANT (first-run download tracing, XDG fallback 테스트 커버리지, Mutex justification 코멘트 정리)을 PR에 반영했습니다.
핵심 포인트:
inline 코멘트는 모두 잘 만든 결정에 대한 노트입니다. 머지 진행해도 됩니다.
후속 작업 후보 5건 (expand_path을 kb-config로 promote, Mutex 제거 검토, fastembed 5.x bump 시점, snapshot baseline pin via CI, multi-arch snapshot stability)은 PR 본문에 정리해두었습니다.
@@ -0,0 +15,4 @@# needed for the multilingual-e5-small path.fastembed = { workspace = true }tracing = { workspace = true }anyhow = { workspace = true }Allowed deps 목록에 있던
kb-core와thiserror을 둘 다 제거한 결정이 정답입니다 —kb-embed이 trait 표면을 재노출하므로kb-coredirect dep는 redundant, error path는anyhow만 사용. clippyunused-crate-dependencies가 잡았을 cruft를 spec compliance를 좁게 해석하지 않고 "실제로 쓰는 deps만 선언" 원칙으로 정리한 게 좋습니다.@@ -0,0 +82,4 @@.context("fastembed: get_model_info")?;check_dim(model_info.dim, config.models.embedding.dimensions)?;tracing::info!(Pre-load dim 검증이 핵심입니다.
TextEmbedding::get_model_info가 ONNX session 초기화 없이 모델 메타만 정적으로 반환하는 점 (fastembed 4.9.1 기준 검증됨)을 활용해서 ~470MB 다운로드 + ONNX init를 시작하기 전에 bail합니다. 사용자가 dim 설정을 잘못 적었을 때 "커피 한 잔 마시고 와서 fail"이 아니라 즉시 fail하는 UX 차이가 큽니다.@@ -0,0 +102,4 @@model = %config.models.embedding.model,cache_dir = %cache_dir.display(),"loading embedding model (first run will download ~470MB)");첫 실행 다운로드 가시화.
show_download_progress: false로 fastembed 자체 progress bar는 끄고 그 대신tracing::info!두 번 (load 시작 시점에 "~470MB will download" 명시 + 성공 후 dimensions와 함께 confirm)으로 깔끔하게 처리한 게 정답입니다. progress bar는 stdout 파괴적이라 CLI 출력 포맷을 망가뜨리는데, tracing은 user가 RUST_LOG로 통제할 수 있어 production에서 안정적입니다.@@ -0,0 +137,4 @@}fn embed(&self, inputs: &[EmbeddingInput<'_>]) -> Result<Vec<Vec<f32>>> {if inputs.is_empty() {L2 정규화는 fastembed 4.9의 default transformer가 이미 한다는 점을 코드 코멘트와 검증 path 둘 다로 박아둔 게 좋습니다. 통합 테스트
output_vectors_are_l2_normalized가‖v‖ ≈ 1.0 ± 1e-3을 pin해서 향후 fastembed bump이 normalize를 빼면 즉시 fail합니다. "외부 라이브러리가 알아서 하리라"는 가정을 테스트로 못 박는 정확한 패턴입니다.@@ -0,0 +412,4 @@let _lock = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());let _guard = XdgGuard::capture();// SAFETY: lock held for the duration of this test.unsafe { std::env::set_var("XDG_DATA_HOME", "/custom/path") };XDG 테스트의 직렬화 패턴 — 정적 Mutex + RAII guard로 환경변수 snapshot/restore. edition 2024가
set_var/remove_var을 unsafe로 분류한 이유가 정확히 이것 (다른 스레드가 환경변수를 동시 read하면 UB)인데,ENV_LOCK로 cross-test 직렬화 +XdgGuard로 prior value 복원하는 두 단계 모두 갖췄습니다. 향후 환경변수를 건드리는 테스트가 같은 ENV_LOCK을 공유하도록 확장 가능한 구조이기도 합니다.@@ -0,0 +267,4 @@// so we hard-fail until a maintainer commits the pin. Both// hex (paste-friendly) and decimal forms are printed.eprintln!("kb-embed-local snapshot baseline (paste into SNAPSHOT_HASH_BASELINE): \Snapshot baseline 정책이 정확합니다.
SNAPSHOT_HASH_BASELINE = 0이 silent pass가 아니라 panic — 측정값을 출력하고 "paste back into the const" 가이드를 함께 띄웁니다. snapshot test의 본질이 "pin할 때까지 fail해야 의미가 있다"인데 그 invariant이 실제로 enforce됩니다. 일반적인 "if baseline == 0 then return" 패턴의 함정 (한 번도 진짜 검증되지 않은 채 green 유지)을 정확히 피했습니다.