feat(embed): candle 임베딩 provider (NUMA-안전, opt-in) + v0.22.0
duo-socket NUMA 서버에서 fastembed(onnxruntime)가 intra-op 스레드를 48개로 하드코딩해 NUMA 힙 손상 → double-free 로 ingest 가 죽는 문제를 회피하기 위해, 같은 multilingual-e5-large 모델을 순수 Rust(candle)로 돌리는 opt-in 임베딩 provider 를 추가한다. - 신규 crate kebab-embed-candle: CandleEmbedder (kebab_core::Embedder). hf-hub safetensors → XLMRobertaModel forward → mask mean-pool → L2 → e5 prefix. candle 의존성 트리를 이 crate 에 격리 (core/config 외 kebab-* 의존 0). - 스레드 캡: [models.embedding].num_threads + env KEBAB_EMBED_THREADS → 글로벌 rayon 풀 1회 캡 (NUMA-안전 레버). - kebab-app::embedder() 가 provider 분기 (fastembed/onnx/"" → 기존 경로 불변, candle → CandleEmbedder, 미지값 → 에러). - Phase 0 스파이크 crate 제거 (production 흡수). - 버전 0.21.1 → 0.22.0 (신규 config surface, pre-1.0 minor bump). 패리티: cosine_min=1.000000, max abs diff=2.01e-7 (< 1e-5) → embedding_version 유지, 재색인 0. fastembed default 동작/벡터 불변. wire schema 변경 없음. 검증(파일+exit code): clippy -D warnings EXIT=0(warning 0), test EXIT=0 (candle unit 5 + thread_cap rayon=4 + config 68), parity #[ignore] EXIT=0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
88
crates/kebab-embed-candle/tests/parity.rs
Normal file
88
crates/kebab-embed-candle/tests/parity.rs
Normal file
@@ -0,0 +1,88 @@
|
||||
//! Parity test (spec §7, `#[ignore]` — needs the ~2GB model + network).
|
||||
//!
|
||||
//! Confirms the candle backend reproduces the onnxruntime `FastembedEmbedder`
|
||||
//! vectors closely enough that no re-index is required (spec D-reindex):
|
||||
//! per-sentence cosine ≥ 0.9999, and reports the dimension-wise max absolute
|
||||
//! difference (the number the re-index decision hangs on).
|
||||
//!
|
||||
//! Run manually:
|
||||
//! CARGO_TARGET_DIR=/build/out/cargo-target/target \
|
||||
//! cargo test -p kebab-embed-candle --release -- --ignored --nocapture
|
||||
//!
|
||||
//! Uses the canonical dogfood config so both backends resolve the same model
|
||||
//! identifiers and cache roots.
|
||||
|
||||
use kebab_config::Config;
|
||||
use kebab_core::{Embedder, EmbeddingInput, EmbeddingKind};
|
||||
use kebab_embed_candle::CandleEmbedder;
|
||||
use kebab_embed_local::FastembedEmbedder;
|
||||
|
||||
const DOGFOOD_CONFIG: &str = "/build/dogfood/config.toml";
|
||||
|
||||
/// Mixed Korean / English parity set (≥ 8 sentences, mirrors the Phase 0 spike).
|
||||
const SENTENCES: &[&str] = &[
|
||||
"The quick brown fox jumps over the lazy dog.",
|
||||
"오늘 날씨가 정말 좋아서 산책을 나가고 싶다.",
|
||||
"Rust is a systems programming language focused on safety and performance.",
|
||||
"벡터 검색은 임베딩 사이의 코사인 유사도를 이용한다.",
|
||||
"Machine learning models require large amounts of training data.",
|
||||
"한국어와 영어가 섞인 문장도 멀티링구얼 모델은 잘 처리한다.",
|
||||
"The capital of France is Paris, a city known for its art and culture.",
|
||||
"이 프로젝트는 로컬 우선 지식 베이스와 검색 증강 생성을 목표로 한다.",
|
||||
"Database indexing dramatically speeds up query performance.",
|
||||
"임베딩 모델을 candle 로 옮기면 NUMA 서버에서 안전하게 돌릴 수 있다.",
|
||||
];
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs ~2GB model + network; run manually for the re-index decision"]
|
||||
fn candle_matches_fastembed() {
|
||||
let config = Config::load(Some(std::path::Path::new(DOGFOOD_CONFIG)))
|
||||
.expect("load dogfood config for parity baseline");
|
||||
|
||||
let candle = CandleEmbedder::new(&config).expect("build CandleEmbedder");
|
||||
let fastembed = FastembedEmbedder::new(&config).expect("build FastembedEmbedder");
|
||||
|
||||
let inputs: Vec<EmbeddingInput> = SENTENCES
|
||||
.iter()
|
||||
.map(|s| EmbeddingInput {
|
||||
text: s,
|
||||
kind: EmbeddingKind::Document,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let cv = candle.embed(&inputs).expect("candle embed");
|
||||
let fv = fastembed.embed(&inputs).expect("fastembed embed");
|
||||
|
||||
assert_eq!(cv.len(), fv.len(), "embedding counts must match");
|
||||
assert_eq!(candle.dimensions(), 1024);
|
||||
|
||||
let mut min_cos = f32::INFINITY;
|
||||
let mut max_abs_diff = 0f32;
|
||||
for (i, s) in SENTENCES.iter().enumerate() {
|
||||
assert_eq!(cv[i].len(), 1024, "candle dim");
|
||||
assert_eq!(fv[i].len(), 1024, "fastembed dim");
|
||||
let c = cosine(&cv[i], &fv[i]);
|
||||
min_cos = min_cos.min(c);
|
||||
let diff = cv[i]
|
||||
.iter()
|
||||
.zip(&fv[i])
|
||||
.map(|(a, b)| (a - b).abs())
|
||||
.fold(0f32, f32::max);
|
||||
max_abs_diff = max_abs_diff.max(diff);
|
||||
let preview: String = s.chars().take(40).collect();
|
||||
println!("[{i:>2}] cos={c:.6} max_abs_diff={diff:.6e} {preview}");
|
||||
}
|
||||
|
||||
println!("PARITY_SUMMARY cosine_min={min_cos:.6} max_abs_diff={max_abs_diff:.6e}");
|
||||
assert!(
|
||||
min_cos >= 0.9999,
|
||||
"candle vs fastembed cosine_min={min_cos:.6} < 0.9999 — investigate before merge"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user