feat(p3-1): embedder-trait — kb-embed 크레이트 + MockEmbedder #14
Reference in New Issue
Block a user
Delete Branch "feat/p3-1-embedder-trait"
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-1 embedder-trait 작업입니다. 새 크레이트
kb-embed를 추가해서 P3 후속 작업(p3-2 fastembed, 향후 ollama-embed/candle 등)이 의존할 안정적인 trait 표면을 마련하고, downstream 테스트에서 사용할 결정적 mock을 feature flag로 노출합니다.무엇을 했는가
트레이트 재노출
kb_core::{Embedder, EmbeddingInput, EmbeddingKind, EmbeddingModelId, EmbeddingVersion}을 그대로pub use합니다. 이 크레이트 자체에는 새로운 타입을 추가하지 않습니다 — kb-core가 재구성되더라도 downstream 호출 사이트가kb_embed::Embedder를 통해 안정적으로 import 할 수 있게 하는 layer입니다.MockEmbedder (
#[cfg(feature = "mock")], default OFF)결정적 vector 생성기입니다. 동일 입력은 byte-identical vector, 다른 입력은 거의 직교에 가까운 vector를 만듭니다:
blake3(seed_le8 || kind_byte || text_len_le8 || text || i_le8). text 앞에 길이를 넣어서("ABCDEFGH", 0)과("", u64::from_le_bytes(*b"ABCDEFGH"))같은 byte-shift 충돌을 차단했습니다 (리뷰 NIT 반영).Document = 0u8,Query = 1u8— 동일 텍스트라도kind가 다르면 다른 vector. 실제 e5 같은 모델의 prefix 동작을 흉내내기 위함입니다.i64::MIN은-1.0000000000000002로 떨어지지만 f32 캐스트가-1.0으로 라운드해서[-1, 1]범위는 유지됩니다 (코멘트로 명시).with_seed(...)추가 생성자: 같은 모델 identity 두 개로 서로 다른 vector를 만들고 싶을 때 (downstream parametric test) 사용.헬퍼
assert_vector_shape(vecs, dims)— 길이 + finite 검사.assert_unit_norm(vecs, tolerance)— caller가 tolerance를 지정.dims = 384에서는5e-4가 f32 epsilon × √dims 기반 안전선이라고 doc에 명시.테스트
cargo test -p kb-embed(no features): 2건 (reexport / dyn-dispatch 컴파일 확인).cargo test -p kb-embed --features mock: 7건. 100-case proptest가 다음을 모두 검증합니다 —len == dims, 모든 컴포넌트 finite,‖v‖ ≈ 1.0(tolerance 5e-4),Doc(text) == Doc(text)(재결정성),Doc(text) ≠ Query(text)(kind 차별),Doc(text1) ≠ Doc(text2)(텍스트 차별). proptestdims하한을 1에서 2로 올렸습니다 —dims = 1에서는 unit-norm 값이[1.0]또는[-1.0]둘 뿐이라 kind/텍스트 차별 invariant이 확률적으로 깨��� 수 있어서.cargo clippy --workspace --all-targets [--features kb-embed/mock] -- -D warnings두 feature 조합 모두 clean.Symbol gating 검증
cargo build --release -p kb-embed후nm target/release/libkb_embed.rlib결과:MockEmbedder심볼 0건.--features mock: trait impl 심볼 3건 (embed,model_id,model_version).spec Risks/notes의 "릴리스 빌드(no
--features mock)는 MockEmbedder 심볼을 포함해서는 안 된다"가 심볼 레벨에서 검증됩니다.의존성
Allowed deps 준수:
kb-core,kb-config,serde,thiserror,tracing. 추가:anyhow:Embedder::embed이anyhow::Result를 반환하므로 trait 구현에 강제됨.blake3: MockEmbedder 결정성 contract에 필요. workspace lockfile에 kb-core 통해 이미 들어와 있어 build cost 증가 없음.Forbidden deps (
fastembed,ort,tokenizers,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에 없습니다.변경 파일
crates/kb-embed/Cargo.toml(신규)crates/kb-embed/src/lib.rs(신규 — 재노출 + 두 helper)crates/kb-embed/src/mock.rs(신규)crates/kb-embed/tests/mock.rs(신규)crates/kb-embed/tests/reexports.rs(신규)Cargo.toml(workspace member 등록 +proptest을[workspace.dependencies]로)Cargo.lockOut of scope
kb-embed-local= p3-2).Follow-up 후보
nm기반 symbol absence를 CI에서 강제하는 셸 스크립트 또는 통합 테스트 — 본 PR은 소스 레벨#[cfg(feature = "mock")]만 enforce.blake3 = { workspace = true, optional = true }+mock = ["dep:blake3"]로 옮기는 micro-tightening — 현재는 workspace lockfile 공유라 build cost 영향 없음.design §3.7, §7.1, §7.2 / 보고서 §11 참고.
Establishes the kb-embed trait crate so concrete embedding adapters (p3-2 fastembed, future ollama-embed/candle) target a stable surface. Pure re-export of kb_core::{Embedder, EmbeddingInput, EmbeddingKind, EmbeddingModelId, EmbeddingVersion} plus a feature-gated deterministic mock for downstream tests. MockEmbedder (cfg(feature = "mock"), default OFF): - Per-component hash recipe: blake3(seed_le8 || kind_byte || text_len_le8 || text || i_le8). Length-prefixed text avoids the domain-separation ambiguity where two (text, i) pairs could shift bytes between text tail and the i field. - Document = 0u8, Query = 1u8 — same text different kind yields different vectors (mirrors e5 prefix behaviour). - Per component: blake3 first 8 bytes → u64 → reinterpret as i64 → f64/i64::MAX → f32. i64::MIN gives -1.0000000000000002 which f32 rounds to -1.0; range [-1, 1] holds. - L2 unit-normalised. Norm sums in f64 (avoid catastrophic precision loss) before f32 cast. Zero-norm guard skips the divide. - with_seed(...) constructor lets two embedders share identity but produce different vectors — useful for downstream parametric tests. Helpers: - assert_vector_shape(vecs, dims) — len + finite check. - assert_unit_norm(vecs, tolerance) — caller-supplied tolerance; 5e-4 documented as safe for dims=384 under f32 epsilon × √dims. Tests: - cargo test -p kb-embed (no features): 2 reexport/dyn-dispatch tests. - cargo test -p kb-embed --features mock: 7 tests including 100-case proptest asserting len == dims, all finite, ‖v‖ ≈ 1.0 within tolerance, Doc(text) byte-equal Doc(text), Doc(text) ≠ Query(text), Doc(text1) ≠ Doc(text2). - All 220 workspace tests pass; clippy clean for both default and mock-on feature configurations. Symbol gating: nm on the release rlib confirms zero MockEmbedder symbols under default features; three trait impl symbols under --features mock. Spec invariant "release builds MUST NOT include MockEmbedder" verified at the symbol level. Allowed deps respected: kb-core, kb-config, serde, thiserror, tracing, plus anyhow (forced by trait return type) and blake3 (justified by the determinism contract; already in workspace lockfile via kb-core). No fastembed/ort/tokenizers anywhere. Out of scope: real adapter (p3-2), reranker traits (P+). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>P3-1 코드 리뷰 — 셀프 머지 게이트로 인해 COMMENT only.
spec compliance + code quality 양쪽 리뷰 결과 BLOCKER / MUST-FIX 모두 0건. 코드 품질 NIT 8건 중 핵심 3건 (해시 도메인 분리 — text length prefix, L2 unit-norm precision helper + doc tightening, proptest 커버리지 확장)을 PR에 반영했습니다. 나머지 NIT은 PR 본문 Follow-up 후보에 정리해두었습니다.
핵심 포인트:
#[cfg(feature = "mock")]+ default OFF가 심볼 레벨에서 검증되었습니다 (nm결과: default 0, --features mock 3).inline 코멘트는 모두 잘 만든 결정에 대한 노트입니다. 머지 진행해도 됩니다.
@@ -0,0 +19,4 @@# workspace lockfile (transitively via kb-core); pulling it in here adds zero# build cost and keeps Cargo.toml simple.blake3 = { workspace = true }[features] default = []+mock = []. spec이 요구한 default OFF가 정확히 적용되어 있고,cargo build --release -p kb-embed산출물에MockEmbedder심볼이 0건이라는 점도 nm으로 별도 검증되었습니다. release 바이너리에 mock이 새지 않는다는 spec invariant이 심볼 레벨에서 보장됩니다.@@ -0,0 +67,4 @@(norm - 1.0).abs(),);}}assert_unit_norm(vecs, tolerance)helper. tolerance를 caller가 직접 받게 한 점이 정답입니다 —dims에 따라 안전선이 달라지므로 (f32 epsilon × √dims) 일률적인 default를 박는 것보다 caller가 자기 dims에 맞게 결정하게 두는 쪽이 안전합니다. doc에5e-4 for dims = 384을 가이드로 적어둔 점도 좋습니다.@@ -0,0 +92,4 @@hasher.update(&self.seed.to_le_bytes());hasher.update(&[Self::kind_byte(kind)]);// Length-prefix `text` (LE u64) so the boundary between `text` and the// trailing `i` field is unambiguous — without this, `("ABCDEFGH", 0)`blake3 입력에
text.len() as u64를 prepend한 게 핵심입니다. 길이 prefix 없으면("ABCDEFGH", 0)과("", u64::from_le_bytes(*b"ABCDEFGH"))같은 byte-shift 충돌이 가능했습니다. 모킹용 결정성 contract지 보안 primitive는 아니지만, downstream test가 의존하는 invariant를 흔들 수 있는 corner case를 미리 닫아둔 점이 좋습니다.@@ -0,0 +106,4 @@// Map to [-1.0, 1.0]. `i64::MAX` is finite in f64 so the ratio is// always finite. Casting back to f32 cannot produce a NaN/Inf for// values in this range.// Note: i64::MIN/i64::MAX gives -1.0000000000000002 → f32 cast rounds to -1.0; range [-1, 1] holds in f32 even with this asymmetry.u64 → i64bit-reinterpret +i64::MAX분모 패턴.i64::MIN이 -1.0000000000000002로 떨어진다는 점을 한 줄 코멘트로 박아둔 게 좋습니다. f32 캐스트가-1.0으로 라운드해서[-1, 1]invariant이 살아있다는 점도 함께. 미래 reader가 "asymmetric range bug"로 오해해서(i64::MAX as f64) + 1.0으로 "고치는" 회귀를 막아줍니다.@@ -0,0 +122,4 @@fn dimensions(&self) -> usize {self.dimensions}norm 누산을 f64에서 한 다음 f32로 캐스트하는 패턴. f32 누산은 catastrophic precision loss를 일으키는 고전 함정인데 (
dims = 384같은 값이면 ULP가 누적), 표준대로 f64로 누산 후 한 번에 캐스트한 점이 단단합니다. zero-norm divide guard도 있고요.@@ -0,0 +167,4 @@.unwrap();prop_assert_ne!(&doc_a, &q, "Doc(text) must differ from Query(text)");// Text differential: Doc(text) != Doc(text2) when text != text2.100-case proptest에 5개 invariant을 한 번에 묶었습니다 — 길이/finite/unit-norm/byte-equal-redet/Doc≠Query/text differential. 이 cluster가 향후 hash recipe를 손댈 때 모두 fail해야 정상이라는 의미라 회귀 안전망이 단단해졌습니다.
dims하한 1→2 상향과 그 이유 (dims = 1에서는 unit-norm 값이±1.0두 개 뿐이라 kind/text 차별 invariant이 확률적으로 깨짐)도 코멘트로 박아둔 점이 좋습니다.@@ -0,0 +29,4 @@fn embed(&self, inputs: &[EmbeddingInput<'_>]) -> anyhow::Result<Vec<Vec<f32>>> {Ok(inputs.iter().map(|_| vec![0.0; self.dims]).collect())}}테스트 안에서
ZeroEmbedder를 직접 정의해서 trait surface 자체만 가지고 dyn dispatch가 동작함을 보인 게 의도가 좋습니다 — re-export 크레이트의 진짜 책임 ("upstream trait를 호환되게 노출하기")을 mock에 의존하지 않고 순수하게 검증합니다. mock feature가 꺼진 상태에서도 의미 있는 테스트가 남는 형태.