1pub mod download;
14pub mod model_registry;
15pub mod pooling;
16pub mod tokenizer;
17
18use std::path::{Path, PathBuf};
19
20use model_registry::{EmbeddingModel, ModelConfig, VocabSource};
21use tokenizer::{TokenizedInput, WordPieceTokenizer};
22
23#[cfg(feature = "embeddings")]
24use std::sync::Arc;
25
26#[cfg(feature = "embeddings")]
27use rten::Model;
28
29pub struct EmbeddingEngine {
30 #[cfg(feature = "embeddings")]
31 model: Arc<Model>,
32 tokenizer: TokenizerKind,
33 dimensions: usize,
34 max_seq_len: usize,
35 model_id: EmbeddingModel,
36 model_config: ModelConfig,
37 #[cfg(feature = "embeddings")]
38 graph_inputs: GraphInputs,
39 #[cfg(feature = "embeddings")]
40 output_id: rten::NodeId,
41}
42
43enum TokenizerKind {
45 WordPiece(WordPieceTokenizer),
46 HfTokenizer(tokenizer::HfTokenizerWrapper),
47}
48
49#[cfg(feature = "embeddings")]
56enum GraphInputs {
57 Transformer {
58 input_ids: rten::NodeId,
59 attention_mask: rten::NodeId,
60 token_type_ids: Option<rten::NodeId>,
61 },
62 EmbeddingBag {
63 input_ids: rten::NodeId,
64 offsets: rten::NodeId,
65 },
66}
67
68#[cfg(feature = "embeddings")]
72fn is_embedding_bag_signature(input_names: &[Option<&str>]) -> bool {
73 input_names.len() == 2 && input_names[1] == Some("offsets")
74}
75
76impl EmbeddingEngine {
77 #[cfg(feature = "embeddings")]
80 pub fn load(model_dir: &Path) -> anyhow::Result<Self> {
81 let selected = model_registry::resolve_model();
82 Self::load_model(model_dir, selected)
83 }
84
85 #[cfg(feature = "embeddings")]
87 pub fn load_model(base_dir: &Path, model_id: EmbeddingModel) -> anyhow::Result<Self> {
88 let config = model_id.config();
89 let model_dir = base_dir.join(model_id.storage_dir_name());
90
91 download::ensure_model(&model_dir, &config)?;
92
93 let tokenizer = load_tokenizer(&model_dir, &config)?;
94 let model_path = model_dir.join("model.onnx");
95 let model = Model::load_file(&model_path)?;
96
97 let model_inputs = model.input_ids();
98 if model_inputs.len() < 2 {
99 anyhow::bail!(
100 "Expected model with at least 2 inputs (input_ids, attention_mask), got {}",
101 model_inputs.len()
102 );
103 }
104
105 let names: Vec<Option<&str>> = model_inputs
109 .iter()
110 .map(|id| model.node_info(*id).and_then(|n| n.name()))
111 .collect();
112 let graph_inputs = if is_embedding_bag_signature(&names) {
113 GraphInputs::EmbeddingBag {
114 input_ids: model_inputs[0],
115 offsets: model_inputs[1],
116 }
117 } else {
118 let token_type_ids = if config.needs_token_type_ids {
119 if model_inputs.len() < 3 {
120 anyhow::bail!(
121 "Model {} requires token_type_ids but only has {} inputs",
122 config.name,
123 model_inputs.len()
124 );
125 }
126 Some(model_inputs[2])
127 } else if model_inputs.len() >= 3 {
128 Some(model_inputs[2])
129 } else {
130 None
131 };
132 GraphInputs::Transformer {
133 input_ids: model_inputs[0],
134 attention_mask: model_inputs[1],
135 token_type_ids,
136 }
137 };
138
139 let output_id = *model
140 .output_ids()
141 .first()
142 .ok_or_else(|| anyhow::anyhow!("Model has no outputs"))?;
143
144 let dimensions = detect_dimensions(
145 &model,
146 &tokenizer,
147 &graph_inputs,
148 output_id,
149 config.max_seq_len,
150 )
151 .unwrap_or(config.dimensions);
152
153 tracing::info!(
154 "Embedding engine loaded: model={}, {}d, max_seq_len={}, topology={}",
155 config.name,
156 dimensions,
157 config.max_seq_len,
158 match graph_inputs {
159 GraphInputs::Transformer { .. } => "transformer",
160 GraphInputs::EmbeddingBag { .. } => "embedding-bag (model2vec)",
161 },
162 );
163
164 Ok(Self {
165 model: Arc::new(model),
166 tokenizer,
167 dimensions,
168 max_seq_len: config.max_seq_len,
169 model_id,
170 model_config: config,
171 graph_inputs,
172 output_id,
173 })
174 }
175
176 #[cfg(not(feature = "embeddings"))]
177 pub fn load(_model_dir: &Path) -> anyhow::Result<Self> {
178 anyhow::bail!("Embeddings feature not enabled. Compile with --features embeddings")
179 }
180
181 pub fn load_default() -> anyhow::Result<Self> {
183 Self::load(&Self::model_directory())
184 }
185
186 pub fn embed(&self, text: &str) -> anyhow::Result<Vec<f32>> {
188 let prefixed;
189 let input_text = if let Some(prefix) = &self.model_config.document_prefix {
190 prefixed = format!("{prefix}{text}");
191 &prefixed
192 } else {
193 text
194 };
195 let input = tokenize(&self.tokenizer, input_text, self.max_seq_len);
196 self.run_inference(&input)
197 }
198
199 pub fn embed_query(&self, query: &str) -> anyhow::Result<Vec<f32>> {
202 let prefixed;
203 let input_text = if let Some(prefix) = &self.model_config.query_prefix {
204 prefixed = format!("{prefix}{query}");
205 &prefixed
206 } else {
207 query
208 };
209 let input = tokenize(&self.tokenizer, input_text, self.max_seq_len);
210 self.run_inference(&input)
211 }
212
213 pub fn embed_batch(&self, texts: &[&str]) -> anyhow::Result<Vec<Vec<f32>>> {
215 texts.iter().map(|t| self.embed(t)).collect()
216 }
217
218 pub fn dimensions(&self) -> usize {
219 self.dimensions
220 }
221
222 pub fn model_id(&self) -> &EmbeddingModel {
223 &self.model_id
224 }
225
226 pub fn model_name(&self) -> &str {
227 &self.model_config.name
228 }
229
230 pub fn model_directory() -> PathBuf {
232 if let Ok(dir) = std::env::var("LEAN_CTX_MODELS_DIR") {
233 return PathBuf::from(dir);
234 }
235 if let Ok(d) = crate::core::data_dir::lean_ctx_data_dir() {
236 return d.join("models");
237 }
238 PathBuf::from("models")
239 }
240
241 pub fn is_available() -> bool {
243 let base_dir = Self::model_directory();
244 let selected = model_registry::resolve_model();
245 let config = selected.config();
246 let model_dir = base_dir.join(selected.storage_dir_name());
247 model_dir.join("model.onnx").exists()
248 && model_dir.join(config.vocab_file.filename()).exists()
249 }
250
251 #[cfg(feature = "embeddings")]
252 fn run_inference(&self, input: &TokenizedInput) -> anyhow::Result<Vec<f32>> {
253 use rten_tensor::NdTensor;
254
255 let seq_len = input.input_ids.len();
256
257 let mut embedding = match &self.graph_inputs {
258 GraphInputs::Transformer {
259 input_ids,
260 attention_mask,
261 token_type_ids,
262 } => {
263 let ids_tensor = NdTensor::from_data([1, seq_len], input.input_ids.clone());
264 let mask_tensor = NdTensor::from_data([1, seq_len], input.attention_mask.clone());
265
266 let mut inputs = vec![
267 (*input_ids, ids_tensor.into()),
268 (*attention_mask, mask_tensor.into()),
269 ];
270
271 if let Some(type_id) = token_type_ids {
272 let type_tensor =
273 NdTensor::from_data([1, seq_len], input.token_type_ids.clone());
274 inputs.push((*type_id, type_tensor.into()));
275 }
276
277 let hidden = self.run_to_vec(inputs)?;
278 pooling::mean_pool(&hidden, &input.attention_mask, seq_len, self.dimensions)
279 }
280 GraphInputs::EmbeddingBag { input_ids, offsets } => {
281 if seq_len == 0 {
284 return Ok(vec![0.0; self.dimensions]);
285 }
286 let ids_tensor = NdTensor::from_data([seq_len], input.input_ids.clone());
289 let offsets_tensor = NdTensor::from_data([1], vec![0i32]);
290 self.run_to_vec(vec![
291 (*input_ids, ids_tensor.into()),
292 (*offsets, offsets_tensor.into()),
293 ])?
294 }
295 };
296
297 pooling::normalize_l2(&mut embedding);
298 Ok(embedding)
299 }
300
301 #[cfg(feature = "embeddings")]
303 fn run_to_vec(
304 &self,
305 inputs: Vec<(rten::NodeId, rten::ValueOrView)>,
306 ) -> anyhow::Result<Vec<f32>> {
307 use rten_tensor::AsView;
308
309 let outputs = self.model.run(inputs, &[self.output_id], None)?;
310 Ok(outputs
311 .into_iter()
312 .next()
313 .ok_or_else(|| anyhow::anyhow!("No output from model"))?
314 .into_tensor::<f32>()
315 .ok_or_else(|| anyhow::anyhow!("Model output is not float32"))?
316 .to_vec())
317 }
318
319 #[cfg(not(feature = "embeddings"))]
320 fn run_inference(&self, _input: &TokenizedInput) -> anyhow::Result<Vec<f32>> {
321 anyhow::bail!("Embeddings feature not enabled")
322 }
323}
324
325fn load_tokenizer(model_dir: &Path, config: &ModelConfig) -> anyhow::Result<TokenizerKind> {
327 match &config.vocab_file {
328 VocabSource::VocabTxt(filename) => {
329 let path = model_dir.join(filename);
330 let tok = WordPieceTokenizer::from_file(&path)?;
331 Ok(TokenizerKind::WordPiece(tok))
332 }
333 VocabSource::TokenizerJson(filename) => {
334 let path = model_dir.join(filename);
335 let tok = tokenizer::HfTokenizerWrapper::from_file(&path).map_err(|e| {
336 anyhow::anyhow!(
337 "Failed to load tokenizer.json for {}: {e}. Custom models must ship a \
338 HuggingFace tokenizer.json with a supported model type (WordPiece/BPE).",
339 config.name
340 )
341 })?;
342 Ok(TokenizerKind::HfTokenizer(tok))
343 }
344 }
345}
346
347fn tokenize(tokenizer: &TokenizerKind, text: &str, max_len: usize) -> TokenizedInput {
349 match tokenizer {
350 TokenizerKind::WordPiece(wp) => wp.encode(text, max_len),
351 TokenizerKind::HfTokenizer(hf) => hf.encode(text, max_len),
352 }
353}
354
355#[cfg(feature = "embeddings")]
357fn detect_dimensions(
358 model: &Model,
359 tokenizer: &TokenizerKind,
360 graph_inputs: &GraphInputs,
361 output_id: rten::NodeId,
362 max_seq_len: usize,
363) -> Option<usize> {
364 use rten_tensor::{Layout, NdTensor};
365
366 let dummy = tokenize(tokenizer, "test", max_seq_len.min(8));
367 let seq_len = dummy.input_ids.len();
368
369 let inputs: Vec<(rten::NodeId, rten::ValueOrView)> = match graph_inputs {
370 GraphInputs::Transformer {
371 input_ids,
372 attention_mask,
373 token_type_ids,
374 } => {
375 let ids = NdTensor::from_data([1, seq_len], dummy.input_ids);
376 let mask = NdTensor::from_data([1, seq_len], dummy.attention_mask);
377 let mut inputs = vec![(*input_ids, ids.into()), (*attention_mask, mask.into())];
378 if let Some(type_id) = token_type_ids {
379 let types = NdTensor::from_data([1, seq_len], dummy.token_type_ids);
380 inputs.push((*type_id, types.into()));
381 }
382 inputs
383 }
384 GraphInputs::EmbeddingBag { input_ids, offsets } => {
385 if seq_len == 0 {
386 return None;
387 }
388 let ids = NdTensor::from_data([seq_len], dummy.input_ids);
389 let offs = NdTensor::from_data([1], vec![0i32]);
390 vec![(*input_ids, ids.into()), (*offsets, offs.into())]
391 }
392 };
393
394 let outputs = model.run(inputs, &[output_id], None).ok()?;
395 let tensor = outputs.into_iter().next()?.into_tensor::<f32>()?;
396 let shape = tensor.shape();
397
398 match graph_inputs {
399 GraphInputs::Transformer { .. } => shape.last().copied(),
401 GraphInputs::EmbeddingBag { .. } => {
405 if shape.len() == 2 {
406 shape.last().copied()
407 } else {
408 None
409 }
410 }
411 }
412}
413
414pub fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
420 debug_assert_eq!(a.len(), b.len(), "vectors must have equal dimensions");
421 crate::core::embedding_quant::dot_f32(a, b)
422}
423
424pub fn cosine_similarity_raw(a: &[f32], b: &[f32]) -> f32 {
426 debug_assert_eq!(a.len(), b.len());
427 use crate::core::embedding_quant::dot_f32;
428 let dot = dot_f32(a, b);
429 let norm_a = dot_f32(a, a).sqrt();
430 let norm_b = dot_f32(b, b).sqrt();
431 if norm_a == 0.0 || norm_b == 0.0 {
432 return 0.0;
433 }
434 dot / (norm_a * norm_b)
435}
436
437#[cfg(feature = "embeddings")]
438static SHARED_ENGINE: std::sync::OnceLock<anyhow::Result<EmbeddingEngine>> =
439 std::sync::OnceLock::new();
440
441#[cfg(feature = "embeddings")]
446pub fn shared_engine() -> Option<&'static EmbeddingEngine> {
447 SHARED_ENGINE
448 .get_or_init(EmbeddingEngine::load_default)
449 .as_ref()
450 .ok()
451}
452
453#[cfg(feature = "embeddings")]
456pub fn try_shared_engine() -> Option<&'static EmbeddingEngine> {
457 SHARED_ENGINE.get()?.as_ref().ok()
458}
459
460#[cfg(test)]
461mod tests {
462 use super::*;
463
464 #[test]
465 fn cosine_similarity_identical() {
466 let a = vec![1.0, 0.0, 0.0];
467 let b = vec![1.0, 0.0, 0.0];
468 assert!((cosine_similarity(&a, &b) - 1.0).abs() < 1e-6);
469 }
470
471 #[test]
472 fn cosine_similarity_orthogonal() {
473 let a = vec![1.0, 0.0, 0.0];
474 let b = vec![0.0, 1.0, 0.0];
475 assert!(cosine_similarity(&a, &b).abs() < 1e-6);
476 }
477
478 #[test]
479 fn cosine_similarity_opposite() {
480 let a = vec![1.0, 0.0, 0.0];
481 let b = vec![-1.0, 0.0, 0.0];
482 assert!((cosine_similarity(&a, &b) + 1.0).abs() < 1e-6);
483 }
484
485 #[test]
486 fn cosine_similarity_raw_unnormalized() {
487 let a = vec![3.0, 4.0];
488 let b = vec![3.0, 4.0];
489 assert!((cosine_similarity_raw(&a, &b) - 1.0).abs() < 1e-6);
490 }
491
492 #[test]
493 fn cosine_similarity_raw_zero_vector() {
494 let a = vec![0.0, 0.0];
495 let b = vec![1.0, 2.0];
496 assert_eq!(cosine_similarity_raw(&a, &b), 0.0);
497 }
498
499 #[test]
500 fn model_directory_env_override_and_availability() {
501 let unique = "/tmp/lean_ctx_test_embed_42xyz";
502 std::env::set_var("LEAN_CTX_MODELS_DIR", unique);
503 let dir = EmbeddingEngine::model_directory();
504 assert_eq!(dir.to_string_lossy(), unique);
505 assert!(!EmbeddingEngine::is_available());
506 std::env::remove_var("LEAN_CTX_MODELS_DIR");
507 }
508
509 #[test]
513 #[cfg(feature = "embeddings")]
514 fn embedding_bag_signature_detection() {
515 assert!(is_embedding_bag_signature(&[
517 Some("input_ids"),
518 Some("offsets")
519 ]));
520 assert!(!is_embedding_bag_signature(&[
522 Some("input_ids"),
523 Some("attention_mask")
524 ]));
525 assert!(!is_embedding_bag_signature(&[
526 Some("input_ids"),
527 Some("attention_mask"),
528 Some("token_type_ids")
529 ]));
530 assert!(!is_embedding_bag_signature(&[Some("input_ids"), None]));
532 assert!(!is_embedding_bag_signature(&[Some("offsets")]));
533 assert!(!is_embedding_bag_signature(&[
534 Some("input_ids"),
535 Some("offsets"),
536 Some("extra")
537 ]));
538 }
539
540 #[test]
552 #[ignore = "downloads a real model from HuggingFace (~91MB)"]
553 #[cfg(feature = "embeddings")]
554 fn custom_hf_model_end_to_end() {
555 let model = model_registry::EmbeddingModel::from_str_name(
556 "hf:sentence-transformers/all-MiniLM-L6-v2@main",
557 )
558 .expect("valid hf: spec");
559
560 let base = std::env::temp_dir().join("lean_ctx_test_custom_hf_e2e");
561 let engine = EmbeddingEngine::load_model(&base, model.clone()).expect("load custom model");
562
563 assert_eq!(engine.dimensions(), 384, "probed dims from ONNX graph");
564 assert_eq!(
565 engine.model_name(),
566 "hf:sentence-transformers/all-MiniLM-L6-v2@main"
567 );
568
569 let v = engine.embed("fn main() { println!(\"hello\"); }").unwrap();
570 assert_eq!(v.len(), 384);
571 let norm: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
572 assert!((norm - 1.0).abs() < 1e-3, "L2-normalized, got {norm}");
573
574 let lock_path = base.join(model.storage_dir_name()).join("model.lock.json");
576 let lock: std::collections::BTreeMap<String, String> =
577 serde_json::from_str(&std::fs::read_to_string(&lock_path).unwrap()).unwrap();
578 assert!(lock.contains_key("model.onnx"));
579 assert!(lock.contains_key("tokenizer.json"));
580
581 let a = engine.embed("read a file from disk").unwrap();
583 let b = engine.embed("load file contents from filesystem").unwrap();
584 let c = engine.embed("the weather in Zurich is sunny").unwrap();
585 assert!(
586 cosine_similarity(&a, &b) > cosine_similarity(&a, &c),
587 "related texts must be closer"
588 );
589 }
590
591 #[test]
596 #[ignore = "downloads a real model from HuggingFace (~30MB)"]
597 #[cfg(feature = "embeddings")]
598 fn model2vec_potion_end_to_end() {
599 let model =
600 model_registry::EmbeddingModel::from_str_name("hf:minishlab/potion-base-8M@main")
601 .expect("valid hf: spec");
602
603 let base = std::env::temp_dir().join("lean_ctx_test_model2vec_e2e");
604 let engine =
605 EmbeddingEngine::load_model(&base, model.clone()).expect("load model2vec model");
606
607 assert_eq!(engine.dimensions(), 256, "probed dims from EmbeddingBag");
610
611 let code_vec = engine.embed("fn main() { println!(\"hello\"); }").unwrap();
612 assert_eq!(code_vec.len(), 256);
613 let norm: f32 = code_vec.iter().map(|x| x * x).sum::<f32>().sqrt();
614 assert!((norm - 1.0).abs() < 1e-3, "L2-normalized, got {norm}");
615
616 let sql_vec = engine.embed("SELECT * FROM users WHERE id = 1").unwrap();
618 assert!(cosine_similarity(&code_vec, &sql_vec) < 0.999);
619
620 let read_vec = engine.embed("read a file from disk").unwrap();
622 let load_vec = engine.embed("load file contents from filesystem").unwrap();
623 let weather_vec = engine.embed("the weather in Zurich is sunny").unwrap();
624 assert!(
625 cosine_similarity(&read_vec, &load_vec) > cosine_similarity(&read_vec, &weather_vec),
626 "related texts must be closer"
627 );
628
629 let empty_vec = engine.embed("").unwrap();
631 assert_eq!(empty_vec.len(), 256);
632 }
633}