feat(p3-1): embedder-trait — kb-embed 크레이트 + MockEmbedder #14

Merged
altair823 merged 1 commits from feat/p3-1-embedder-trait into main 2026-05-01 08:21:39 +00:00
Owner

변경 요약

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를 만듭니다:

  • 컴포넌트별 해시 recipe: 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 동작을 흉내내기 위함입니다.
  • 컴포넌트당 blake3 첫 8바이트 → u64 → bit-reinterpret as i64 → f64 / i64::MAX → f32. i64::MIN-1.0000000000000002로 떨어지지만 f32 캐스트가 -1.0으로 라운드해서 [-1, 1] 범위는 유지됩니다 (코멘트로 명시).
  • L2 unit-norm. norm 누산은 f64에서 (catastrophic precision loss 방지) 한 뒤 f32로 캐스트. 모든 컴포넌트가 0인 corner case에는 divide guard.
  • 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) (텍스트 차별). proptest dims 하한을 1에서 2로 올렸습니다 — dims = 1에서는 unit-norm 값이 [1.0] 또는 [-1.0] 둘 뿐이라 kind/텍스트 차별 invariant이 확률적으로 깨��� 수 있어서.
  • 워크스페이스 220건 전체 통과. cargo clippy --workspace --all-targets [--features kb-embed/mock] -- -D warnings 두 feature 조합 모두 clean.

Symbol gating 검증

cargo build --release -p kb-embednm target/release/libkb_embed.rlib 결과:

  • default features: 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::embedanyhow::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.lock

Out of scope

  • 실제 adapter (kb-embed-local = p3-2).
  • Reranker trait (P+).

Follow-up 후보

  • N5 (리뷰): nm 기반 symbol absence를 CI에서 강제하는 셸 스크립트 또는 통합 테스트 — 본 PR은 소스 레벨 #[cfg(feature = "mock")]만 enforce.
  • N6 (리뷰): blake3 = { workspace = true, optional = true } + mock = ["dep:blake3"]로 옮기는 micro-tightening — 현재는 workspace lockfile 공유라 build cost 영향 없음.

design §3.7, §7.1, §7.2 / 보고서 §11 참고.

## 변경 요약 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를 만듭니다: - 컴포넌트별 해시 recipe: `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 동작을 흉내내기 위함입니다. - 컴포넌트당 blake3 첫 8바이트 → u64 → bit-reinterpret as i64 → f64 / i64::MAX → f32. `i64::MIN`은 `-1.0000000000000002`로 떨어지지만 f32 캐스트가 `-1.0`으로 라운드해서 `[-1, 1]` 범위는 유지됩니다 (코멘트로 명시). - L2 unit-norm. norm 누산은 f64에서 (catastrophic precision loss 방지) 한 뒤 f32로 캐스트. 모든 컴포넌트가 0인 corner case에는 divide guard. - `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)` (텍스트 차별). proptest `dims` 하한을 1에서 2로 올렸습니다 — `dims = 1`에서는 unit-norm 값이 `[1.0]` 또는 `[-1.0]` 둘 뿐이라 kind/텍스트 차별 invariant이 확률적으로 깨��� 수 있어서. - 워크스페이스 220건 전체 통과. `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` 결과: - default features: `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.lock` ## Out of scope - 실제 adapter (`kb-embed-local` = p3-2). - Reranker trait (P+). ## Follow-up 후보 - N5 (리뷰): `nm` 기반 symbol absence를 CI에서 강제하는 셸 스크립트 또는 통합 테스트 — 본 PR은 소스 레벨 `#[cfg(feature = "mock")]`만 enforce. - N6 (리뷰): `blake3 = { workspace = true, optional = true }` + `mock = ["dep:blake3"]`로 옮기는 micro-tightening — 현재는 workspace lockfile 공유라 build cost 영향 없음. design §3.7, §7.1, §7.2 / 보고서 §11 참고.
altair823 added 1 commit 2026-05-01 08:16:33 +00:00
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>
claude-reviewer-01 reviewed 2026-05-01 08:17:13 +00:00
claude-reviewer-01 left a comment
Member

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 후보에 정리해두었습니다.

핵심 포인트:

  • MockEmbedder는 #[cfg(feature = "mock")] + default OFF가 심볼 레벨에서 검증되었습니다 (nm 결과: default 0, --features mock 3).
  • 100-case proptest이 5개 invariant (length, finite, unit-norm, redeterminism, kind/text 차별)을 한 번에 묶어서 hash recipe regressions를 다층으로 catch합니다.
  • f64 norm 누산 + f32 캐스트, zero-norm divide guard, i64::MIN asymmetry 코멘트 등 부동소수점 corner case가 정확히 다뤄져 있습니다.

inline 코멘트는 모두 잘 만든 결정에 대한 노트입니다. 머지 진행해도 됩니다.

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 후보에 정리해두었습니다. 핵심 포인트: - MockEmbedder는 `#[cfg(feature = "mock")]` + default OFF가 심볼 레벨에서 검증되었습니다 (`nm` 결과: default 0, --features mock 3). - 100-case proptest이 5개 invariant (length, finite, unit-norm, redeterminism, kind/text 차별)을 한 번에 묶어서 hash recipe regressions를 다층으로 catch합니다. - f64 norm 누산 + f32 캐스트, zero-norm divide guard, i64::MIN asymmetry 코멘트 등 부동소수점 corner case가 정확히 다뤄져 있습니다. 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이 심볼 레벨에서 보장됩니다.

`[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을 가이드로 적어둔 점도 좋습니다.

`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를 미리 닫아둔 점이 좋습니다.

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 → i64 bit-reinterpret + i64::MAX 분모 패턴. i64::MIN이 -1.0000000000000002로 떨어진다는 점을 한 줄 코멘트로 박아둔 게 좋습니다. f32 캐스트가 -1.0으로 라운드해서 [-1, 1] invariant이 살아있다는 점도 함께. 미래 reader가 "asymmetric range bug"로 오해해서 (i64::MAX as f64) + 1.0으로 "고치는" 회귀를 막아줍니다.

`u64 → i64` bit-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도 있고요.

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이 확률적으로 깨짐)도 코멘트로 박아둔 점이 좋습니다.

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가 꺼진 상태에서도 의미 있는 테스트가 남는 형태.

테스트 안에서 `ZeroEmbedder`를 직접 정의해서 trait surface 자체만 가지고 dyn dispatch가 동작함을 보인 게 의도가 좋습니다 — re-export 크레이트의 진짜 책임 ("upstream trait를 호환되게 노출하기")을 mock에 의존하지 않고 순수하게 검증합니다. mock feature가 꺼진 상태에서도 의미 있는 테스트가 남는 형태.
altair823 merged commit 9f2afc73dc into main 2026-05-01 08:21:39 +00:00
altair823 deleted branch feat/p3-1-embedder-trait 2026-05-01 08:21:40 +00:00
Sign in to join this conversation.
No Reviewers
No Label
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: altair823-org/kebab#14