1pub mod download;
18#[cfg(all(test, feature = "embeddings"))]
19mod eval;
20pub mod model_registry;
21pub mod pooling;
22#[cfg(feature = "embeddings")]
23mod static_model;
24pub mod tokenizer;
25
26use std::path::{Path, PathBuf};
27
28#[cfg(feature = "embeddings")]
29use model_registry::VocabSource;
30use model_registry::{EmbeddingModel, ModelConfig};
31use tokenizer::TokenizedInput;
32#[cfg(feature = "embeddings")]
33use tokenizer::WordPieceTokenizer;
34
35use rayon::prelude::*;
39#[cfg(feature = "embeddings")]
40use std::sync::Mutex;
41
42pub struct EmbeddingEngine {
43 tokenizer: TokenizerKind,
44 dimensions: usize,
45 max_seq_len: usize,
46 model_id: EmbeddingModel,
47 model_config: ModelConfig,
48 #[cfg(feature = "embeddings")]
50 backend: Backend,
51}
52
53#[cfg(feature = "embeddings")]
54enum Backend {
55 Onnx(OnnxSession),
56 Static(static_model::StaticModel),
57}
58
59#[cfg(feature = "embeddings")]
60struct OnnxSession {
61 session: Mutex<ort::session::Session>,
62 graph_inputs: GraphInputs,
63 output_name: String,
64}
65
66pub enum TokenizerKind {
69 #[cfg(feature = "embeddings")]
70 WordPiece(WordPieceTokenizer),
71 #[cfg(feature = "embeddings")]
72 HfTokenizer(tokenizer::HfTokenizerWrapper),
73}
74
75#[cfg(feature = "embeddings")]
82enum GraphInputs {
83 Transformer {
84 input_ids: String,
85 attention_mask: String,
86 token_type_ids: Option<String>,
87 },
88 EmbeddingBag {
89 input_ids: String,
90 offsets: String,
91 },
92}
93
94fn default_embedding_batch_size() -> usize {
97 #[cfg(feature = "embeddings")]
98 if crate::core::ort_execution_providers::gpu_active() {
99 return 256;
100 }
101 64
102}
103
104#[cfg(feature = "embeddings")]
108fn is_embedding_bag_signature(input_names: &[String]) -> bool {
109 input_names.len() == 2 && input_names[1] == "offsets"
110}
111
112impl EmbeddingEngine {
113 #[cfg(feature = "embeddings")]
116 pub fn load(model_dir: &Path) -> anyhow::Result<Self> {
117 let selected = model_registry::resolve_model();
118 Self::load_model(model_dir, selected)
119 }
120
121 #[cfg(feature = "embeddings")]
123 pub fn load_model(base_dir: &Path, model_id: EmbeddingModel) -> anyhow::Result<Self> {
124 let config = model_id.config();
125 let model_dir = base_dir.join(model_id.storage_dir_name());
126
127 download::ensure_model(&model_dir, &config)?;
128
129 if config.format == model_registry::ModelFormat::StaticSafetensors {
130 return Self::load_static(&model_dir, model_id, config);
131 }
132
133 let tokenizer = load_tokenizer(&model_dir, &config)?;
134 let model_path = model_dir.join("model.onnx");
135
136 let deterministic = deterministic_inference();
142 let eps = if deterministic {
143 vec![ort::ep::CPU::default().build()]
144 } else {
145 crate::core::ort_execution_providers::execution_providers()
146 };
147 if !deterministic
150 && let Some(msg) = crate::core::ort_execution_providers::gpu_fallback_warning()
151 {
152 tracing::warn!("{msg}");
153 }
154 let num_cpus = if deterministic {
155 1
156 } else {
157 std::thread::available_parallelism().map_or(4, |n| n.get().max(1))
158 };
159 tracing::info!(
160 model = %config.name,
161 model_path = %model_path.display(),
162 deterministic,
163 num_cpus,
164 execution_providers = eps.len(),
165 "Loading ONNX embedding model"
166 );
167 crate::core::ort_environment::ensure_ort_env(&eps)?;
168 tracing::debug!("ONNX Runtime environment ready; creating embedding session builder");
169 let mut session = ort::session::Session::builder()
170 .map_err(|e| anyhow::anyhow!("ORT builder: {e}"))?
171 .with_intra_threads(num_cpus)
172 .map_err(|e| anyhow::anyhow!("ORT intra threads: {e}"))?
173 .with_optimization_level(ort::session::builder::GraphOptimizationLevel::All)
174 .map_err(|e| anyhow::anyhow!("ORT optimization: {e}"))?
175 .commit_from_file(&model_path)
176 .map_err(|e| anyhow::anyhow!("ORT load model: {e}"))?;
177 tracing::debug!("ONNX embedding session loaded; inspecting graph signature");
178
179 let input_names: Vec<String> = session
180 .inputs()
181 .iter()
182 .map(|i| i.name().to_string())
183 .collect();
184
185 if input_names.len() < 2 {
186 anyhow::bail!(
187 "Expected model with at least 2 inputs (input_ids, attention_mask), got {}",
188 input_names.len()
189 );
190 }
191
192 let graph_inputs = if is_embedding_bag_signature(&input_names) {
196 GraphInputs::EmbeddingBag {
197 input_ids: input_names[0].clone(),
198 offsets: input_names[1].clone(),
199 }
200 } else {
201 let token_type_ids = if config.needs_token_type_ids {
202 if input_names.len() < 3 {
203 anyhow::bail!(
204 "Model {} requires token_type_ids but only has {} inputs",
205 config.name,
206 input_names.len()
207 );
208 }
209 Some(input_names[2].clone())
210 } else if input_names.len() >= 3 {
211 Some(input_names[2].clone())
212 } else {
213 None
214 };
215 GraphInputs::Transformer {
216 input_ids: input_names[0].clone(),
217 attention_mask: input_names[1].clone(),
218 token_type_ids,
219 }
220 };
221
222 let output_name = session
223 .outputs()
224 .first()
225 .map(|o| o.name().to_string())
226 .ok_or_else(|| anyhow::anyhow!("Model has no named outputs"))?;
227
228 let dimensions = detect_dimensions(
229 &mut session,
230 &tokenizer,
231 &graph_inputs,
232 &output_name,
233 config.max_seq_len,
234 )
235 .unwrap_or(config.dimensions);
236 tracing::debug!(dimensions, "ONNX embedding dimensions resolved");
237
238 tracing::info!(
239 "Embedding engine loaded: model={}, {}d, max_seq_len={}, topology={}",
240 config.name,
241 dimensions,
242 config.max_seq_len,
243 match graph_inputs {
244 GraphInputs::Transformer { .. } => "transformer",
245 GraphInputs::EmbeddingBag { .. } => "embedding-bag (model2vec)",
246 },
247 );
248
249 Ok(Self {
250 tokenizer,
251 dimensions,
252 max_seq_len: config.max_seq_len,
253 model_id,
254 model_config: config,
255 backend: Backend::Onnx(OnnxSession {
256 session: Mutex::new(session),
257 graph_inputs,
258 output_name,
259 }),
260 })
261 }
262
263 #[cfg(feature = "embeddings")]
266 fn load_static(
267 model_dir: &Path,
268 model_id: EmbeddingModel,
269 config: ModelConfig,
270 ) -> anyhow::Result<Self> {
271 let tokenizer = tokenizer::HfTokenizerWrapper::from_file_static(
272 &model_dir.join(config.vocab_file.filename()),
273 )?;
274 let model = static_model::StaticModel::load(&model_dir.join(config.local_model_file()))?;
275 if model.vocab_size() != tokenizer.vocab_size() {
276 anyhow::bail!(
277 "{}: tokenizer has {} tokens but the weights cover {}",
278 config.name,
279 tokenizer.vocab_size(),
280 model.vocab_size()
281 );
282 }
283 tracing::info!(
284 "Embedding engine loaded: model={}, {}d, static (model2vec, no ONNX Runtime)",
285 config.name,
286 model.dimensions()
287 );
288 Ok(Self {
289 tokenizer: TokenizerKind::HfTokenizer(tokenizer),
290 dimensions: model.dimensions(),
291 max_seq_len: config.max_seq_len,
292 model_id,
293 model_config: config,
294 backend: Backend::Static(model),
295 })
296 }
297
298 #[cfg(feature = "embeddings")]
300 fn static_model(&self) -> Option<&static_model::StaticModel> {
301 match &self.backend {
302 Backend::Static(model) => Some(model),
303 Backend::Onnx(_) => None,
304 }
305 }
306
307 #[cfg(feature = "embeddings")]
308 fn onnx(&self) -> anyhow::Result<&OnnxSession> {
309 match &self.backend {
310 Backend::Onnx(onnx) => Ok(onnx),
311 Backend::Static(_) => anyhow::bail!("static embedding model has no ONNX session"),
312 }
313 }
314
315 #[cfg(not(feature = "embeddings"))]
316 pub fn load(_model_dir: &Path) -> anyhow::Result<Self> {
317 anyhow::bail!("Embeddings feature not enabled. Compile with --features embeddings")
318 }
319
320 pub fn load_default() -> anyhow::Result<Self> {
322 Self::load(&Self::model_directory())
323 }
324
325 pub fn embed(&self, text: &str) -> anyhow::Result<Vec<f32>> {
327 let prefixed;
328 let input_text = if let Some(prefix) = &self.model_config.document_prefix {
329 prefixed = format!("{prefix}{text}");
330 &prefixed
331 } else {
332 text
333 };
334 #[cfg(feature = "embeddings")]
335 if let Some(model) = self.static_model() {
336 return Ok(model.embed_ids(&self.plain_ids(input_text)));
337 }
338 let input = tokenize(&self.tokenizer, input_text, self.max_seq_len);
339 self.run_inference(&input)
340 }
341
342 #[cfg(feature = "embeddings")]
344 fn plain_ids(&self, text: &str) -> Vec<i32> {
345 match &self.tokenizer {
346 TokenizerKind::WordPiece(wp) => wp.encode_plain(text, self.max_seq_len),
347 TokenizerKind::HfTokenizer(hf) => hf.encode_plain(text, self.max_seq_len),
348 }
349 }
350
351 pub fn embed_query(&self, query: &str) -> anyhow::Result<Vec<f32>> {
354 let prefixed;
355 let input_text = if let Some(prefix) = &self.model_config.query_prefix {
356 prefixed = format!("{prefix}{query}");
357 &prefixed
358 } else {
359 query
360 };
361 #[cfg(feature = "embeddings")]
362 if let Some(model) = self.static_model() {
363 return Ok(model.embed_ids(&self.plain_ids(input_text)));
364 }
365 let input = tokenize(&self.tokenizer, input_text, self.max_seq_len);
366 self.run_inference(&input)
367 }
368
369 pub fn embed_batch(&self, texts: &[&str]) -> anyhow::Result<Vec<Vec<f32>>> {
375 if texts.is_empty() {
376 return Ok(Vec::new());
377 }
378
379 #[cfg(feature = "embeddings")]
382 if let Some(model) = self.static_model() {
383 let mut results = Vec::with_capacity(texts.len());
384 for slice in texts.chunks(4096) {
385 if crate::core::interrupt::is_cancelled()
386 || crate::core::memory_guard::abort_requested()
387 {
388 anyhow::bail!(
389 "embedding cancelled after {}/{} chunks",
390 results.len(),
391 texts.len()
392 );
393 }
394 results.extend(
395 slice
396 .par_iter()
397 .map(|text| model.embed_ids(&self.plain_ids(text)))
398 .collect::<Vec<_>>(),
399 );
400 }
401 return Ok(results);
402 }
403
404 let configured_batch_size: usize = std::env::var("LEAN_CTX_EMBEDDING_BATCH_SIZE")
409 .ok()
410 .and_then(|v| v.parse().ok())
411 .filter(|&v| v >= 1)
412 .unwrap_or_else(default_embedding_batch_size);
413 let estimated_bytes_per_text = self
416 .max_seq_len
417 .saturating_mul(self.dimensions.max(1))
418 .saturating_mul(std::mem::size_of::<f32>())
419 .saturating_mul(8) as u64;
420 let batch_size = crate::core::memory_guard::adaptive_batch_size(
421 1,
422 configured_batch_size,
423 estimated_bytes_per_text.max(1),
424 );
425
426 let total = texts.len();
427 let total_batches = total.div_ceil(batch_size);
428 let mut results = Vec::with_capacity(total);
429 let start = std::time::Instant::now();
430
431 for (batch_idx, text_chunk) in texts.chunks(batch_size).enumerate() {
432 if crate::core::interrupt::is_cancelled()
436 || crate::core::memory_guard::abort_requested()
437 {
438 anyhow::bail!(
439 "embedding cancelled after {done}/{total_batches} batches ({embedded}/{total} chunks)",
440 done = batch_idx,
441 embedded = results.len(),
442 );
443 }
444
445 let prefixed: Vec<String> = text_chunk
446 .iter()
447 .map(|text| {
448 if let Some(prefix) = &self.model_config.document_prefix {
449 format!("{prefix}{text}")
450 } else {
451 (*text).to_owned()
452 }
453 })
454 .collect();
455 let tokenized: Vec<TokenizedInput> = prefixed
456 .par_iter()
457 .map(|text| tokenize(&self.tokenizer, text, self.max_seq_len))
458 .collect();
459
460 let batch_start = std::time::Instant::now();
461 let batch_out = self.run_inference_batch(&tokenized)?;
462 results.extend(batch_out);
463 tracing::info!(
464 batch = batch_idx + 1,
465 total_batches,
466 embedded = results.len(),
467 total,
468 batch_ms = batch_start.elapsed().as_millis(),
469 elapsed_ms = start.elapsed().as_millis(),
470 "embedding progress"
471 );
472 }
473 Ok(results)
474 }
475
476 pub fn dimensions(&self) -> usize {
477 self.dimensions
478 }
479
480 pub fn model_id(&self) -> &EmbeddingModel {
481 &self.model_id
482 }
483
484 pub fn model_name(&self) -> &str {
485 &self.model_config.name
486 }
487
488 pub fn model_directory() -> PathBuf {
490 if let Ok(dir) = std::env::var("LEAN_CTX_MODELS_DIR") {
491 return PathBuf::from(dir);
492 }
493 if let Ok(d) = crate::core::paths::cache_dir() {
494 return d.join("models");
495 }
496 PathBuf::from("models")
497 }
498
499 pub fn is_available() -> bool {
501 let base_dir = Self::model_directory();
502 let selected = model_registry::resolve_model();
503 let config = selected.config();
504 let model_dir = base_dir.join(selected.storage_dir_name());
505 model_dir.join(config.local_model_file()).exists()
506 && model_dir.join(config.vocab_file.filename()).exists()
507 }
508
509 pub fn ensure_downloaded() -> anyhow::Result<()> {
520 let base_dir = Self::model_directory();
521 let selected = model_registry::resolve_model();
522 let config = selected.config();
523 let model_dir = base_dir.join(selected.storage_dir_name());
524 download::ensure_model(&model_dir, &config)?;
525 Ok(())
526 }
527
528 #[cfg(feature = "embeddings")]
529 fn run_inference(&self, input: &TokenizedInput) -> anyhow::Result<Vec<f32>> {
530 let onnx = self.onnx()?;
531 let seq_len = input.input_ids.len();
532
533 let mut embedding = match &onnx.graph_inputs {
534 GraphInputs::Transformer {
535 input_ids,
536 attention_mask,
537 token_type_ids,
538 } => {
539 let ids_vec: Vec<i64> = input.input_ids.iter().map(|&x| x as i64).collect();
540 let mask_vec: Vec<i64> = input.attention_mask.iter().map(|&x| x as i64).collect();
541 let ids_array = ndarray::Array2::from_shape_vec((1, seq_len), ids_vec)?;
542 let mask_array = ndarray::Array2::from_shape_vec((1, seq_len), mask_vec)?;
543 let ids_tensor = ort::value::Tensor::from_array(ids_array)?;
544 let mask_tensor = ort::value::Tensor::from_array(mask_array)?;
545
546 let hidden = if let Some(type_id) = token_type_ids {
547 let type_vec: Vec<i64> =
548 input.token_type_ids.iter().map(|&x| x as i64).collect();
549 let type_array = ndarray::Array2::from_shape_vec((1, seq_len), type_vec)?;
550 let type_tensor = ort::value::Tensor::from_array(type_array)?;
551 let mut _guard = onnx
552 .session
553 .lock()
554 .unwrap_or_else(std::sync::PoisonError::into_inner);
555 let outputs = _guard.run(ort::inputs![
556 input_ids.as_str() => ids_tensor,
557 attention_mask.as_str() => mask_tensor,
558 type_id.as_str() => type_tensor,
559 ])?;
560 let (_, data) =
561 outputs[onnx.output_name.as_str()].try_extract_tensor::<f32>()?;
562 data.to_vec()
563 } else {
564 let mut _guard = onnx
565 .session
566 .lock()
567 .unwrap_or_else(std::sync::PoisonError::into_inner);
568 let outputs = _guard.run(ort::inputs![
569 input_ids.as_str() => ids_tensor,
570 attention_mask.as_str() => mask_tensor,
571 ])?;
572 let (_, data) =
573 outputs[onnx.output_name.as_str()].try_extract_tensor::<f32>()?;
574 data.to_vec()
575 };
576 pooling::mean_pool(&hidden, &input.attention_mask, seq_len, self.dimensions)
577 }
578 GraphInputs::EmbeddingBag { input_ids, offsets } => {
579 if seq_len == 0 {
580 return Ok(vec![0.0; self.dimensions]);
581 }
582 let ids_vec: Vec<i64> = input.input_ids.iter().map(|&x| x as i64).collect();
583 let ids_array = ndarray::Array1::from_shape_vec(seq_len, ids_vec)?;
584 let offsets_array = ndarray::Array1::from_shape_vec(1, vec![0i64])?;
585 let ids_tensor = ort::value::Tensor::from_array(ids_array)?;
586 let offsets_tensor = ort::value::Tensor::from_array(offsets_array)?;
587 let mut _guard = onnx
588 .session
589 .lock()
590 .unwrap_or_else(std::sync::PoisonError::into_inner);
591 let outputs = _guard.run(ort::inputs![
592 input_ids.as_str() => ids_tensor,
593 offsets.as_str() => offsets_tensor,
594 ])?;
595 let (_, data) = outputs[onnx.output_name.as_str()].try_extract_tensor::<f32>()?;
596 data.to_vec()
597 }
598 };
599
600 pooling::normalize_l2(&mut embedding);
601 Ok(embedding)
602 }
603
604 #[cfg(feature = "embeddings")]
613 fn run_inference_batch(&self, inputs: &[TokenizedInput]) -> anyhow::Result<Vec<Vec<f32>>> {
614 if inputs.is_empty() {
615 return Ok(Vec::new());
616 }
617 let onnx = self.onnx()?;
618
619 match &onnx.graph_inputs {
620 GraphInputs::Transformer {
621 input_ids: input_id,
622 attention_mask: mask_id,
623 token_type_ids,
624 } => {
625 let batch = inputs.len();
626 let max_len = inputs.iter().map(|i| i.input_ids.len()).max().unwrap_or(0);
627
628 let mut ids_data: Vec<i64> = Vec::with_capacity(batch * max_len);
629 let mut mask_data: Vec<i64> = Vec::with_capacity(batch * max_len);
630 let mut type_data: Vec<i64> = Vec::with_capacity(batch * max_len);
631 let mut per_seq_masks: Vec<&[i32]> = Vec::with_capacity(batch);
632
633 for inp in inputs {
634 let seq_len = inp.input_ids.len();
635 ids_data.extend(inp.input_ids.iter().map(|&x| x as i64));
636 ids_data.resize(ids_data.len() + (max_len - seq_len), 0);
637
638 mask_data.extend(inp.attention_mask.iter().map(|&x| x as i64));
639 mask_data.resize(mask_data.len() + (max_len - seq_len), 0);
640
641 type_data.extend(inp.token_type_ids.iter().map(|&x| x as i64));
642 type_data.resize(type_data.len() + (max_len - seq_len), 0);
643
644 per_seq_masks.push(inp.attention_mask.as_slice());
645 }
646
647 let ids_array = ndarray::Array2::from_shape_vec((batch, max_len), ids_data)?;
648 let mask_array = ndarray::Array2::from_shape_vec((batch, max_len), mask_data)?;
649 let ids_tensor = ort::value::Tensor::from_array(ids_array)?;
650 let mask_tensor = ort::value::Tensor::from_array(mask_array)?;
651
652 let hidden = if let Some(type_id) = token_type_ids {
653 let type_array = ndarray::Array2::from_shape_vec((batch, max_len), type_data)?;
654 let type_tensor = ort::value::Tensor::from_array(type_array)?;
655 let mut _guard = onnx
656 .session
657 .lock()
658 .unwrap_or_else(std::sync::PoisonError::into_inner);
659 let outputs = _guard.run(ort::inputs![
660 input_id.as_str() => ids_tensor,
661 mask_id.as_str() => mask_tensor,
662 type_id.as_str() => type_tensor,
663 ])?;
664 let (_, data) =
665 outputs[onnx.output_name.as_str()].try_extract_tensor::<f32>()?;
666 data.to_vec()
667 } else {
668 let mut _guard = onnx
669 .session
670 .lock()
671 .unwrap_or_else(std::sync::PoisonError::into_inner);
672 let outputs = _guard.run(ort::inputs![
673 input_id.as_str() => ids_tensor,
674 mask_id.as_str() => mask_tensor,
675 ])?;
676 let (_, data) =
677 outputs[onnx.output_name.as_str()].try_extract_tensor::<f32>()?;
678 data.to_vec()
679 };
680
681 let mut results =
682 pooling::mean_pool_batch(&hidden, &per_seq_masks, max_len, self.dimensions);
683 for emb in &mut results {
684 pooling::normalize_l2(emb);
685 }
686 Ok(results)
687 }
688 GraphInputs::EmbeddingBag { input_ids, offsets } => {
689 let batch = inputs.len();
690 let mut flat_ids: Vec<i64> = Vec::new();
691 let mut adjusted_offsets: Vec<i64> = Vec::with_capacity(batch);
692 let mut last_offset = 0i64;
693
694 for inp in inputs {
695 adjusted_offsets.push(last_offset);
696 if !inp.input_ids.is_empty() {
697 flat_ids.extend(inp.input_ids.iter().map(|&x| x as i64));
698 last_offset = flat_ids.len() as i64;
699 }
700 }
701
702 if flat_ids.is_empty() {
703 return Ok(vec![vec![0.0; self.dimensions]; batch]);
704 }
705
706 let ids_array = ndarray::Array1::from_shape_vec(flat_ids.len(), flat_ids)?;
707 let offsets_array = ndarray::Array1::from_shape_vec(batch, adjusted_offsets)?;
708 let ids_tensor = ort::value::Tensor::from_array(ids_array)?;
709 let offsets_tensor = ort::value::Tensor::from_array(offsets_array)?;
710
711 let mut _guard = onnx
712 .session
713 .lock()
714 .unwrap_or_else(std::sync::PoisonError::into_inner);
715 let outputs = _guard.run(ort::inputs![
716 input_ids.as_str() => ids_tensor,
717 offsets.as_str() => offsets_tensor,
718 ])?;
719 let (_, out_data) =
720 outputs[onnx.output_name.as_str()].try_extract_tensor::<f32>()?;
721 let out = out_data.to_vec();
722
723 let mut results: Vec<Vec<f32>> = out
724 .chunks_exact(self.dimensions)
725 .map(<[f32]>::to_vec)
726 .collect();
727 while results.len() < batch {
728 results.push(vec![0.0; self.dimensions]);
729 }
730 for emb in &mut results {
731 pooling::normalize_l2(emb);
732 }
733 Ok(results)
734 }
735 }
736 }
737
738 #[cfg(not(feature = "embeddings"))]
739 fn run_inference(&self, _input: &TokenizedInput) -> anyhow::Result<Vec<f32>> {
740 anyhow::bail!("Embeddings feature not enabled")
741 }
742
743 #[cfg(not(feature = "embeddings"))]
744 fn run_inference_batch(&self, _inputs: &[TokenizedInput]) -> anyhow::Result<Vec<Vec<f32>>> {
745 anyhow::bail!("Embeddings feature not enabled")
746 }
747}
748
749#[cfg(feature = "embeddings")]
751fn load_tokenizer(model_dir: &Path, config: &ModelConfig) -> anyhow::Result<TokenizerKind> {
752 match &config.vocab_file {
753 VocabSource::VocabTxt(filename) => {
754 let path = model_dir.join(filename);
755 let tok = WordPieceTokenizer::from_file(&path)?;
756 Ok(TokenizerKind::WordPiece(tok))
757 }
758 VocabSource::TokenizerJson(filename) => {
759 let path = model_dir.join(filename);
760 let tok = tokenizer::HfTokenizerWrapper::from_file(&path).map_err(|e| {
761 anyhow::anyhow!(
762 "Failed to load tokenizer.json for {}: {e}. Custom models must ship a \
763 HuggingFace tokenizer.json with a supported model type (WordPiece/BPE).",
764 config.name
765 )
766 })?;
767 Ok(TokenizerKind::HfTokenizer(tok))
768 }
769 }
770}
771
772pub fn tokenize(tokenizer: &TokenizerKind, text: &str, max_len: usize) -> TokenizedInput {
774 #[cfg(not(feature = "embeddings"))]
775 let _ = (text, max_len);
776 match *tokenizer {
777 #[cfg(feature = "embeddings")]
778 TokenizerKind::WordPiece(ref wp) => wp.encode(text, max_len),
779 #[cfg(feature = "embeddings")]
780 TokenizerKind::HfTokenizer(ref hf) => hf.encode(text, max_len),
781 }
782}
783
784#[cfg(feature = "embeddings")]
786fn detect_dimensions(
787 session: &mut ort::session::Session,
788 tokenizer: &TokenizerKind,
789 graph_inputs: &GraphInputs,
790 output_name: &str,
791 max_seq_len: usize,
792) -> Option<usize> {
793 let dummy = tokenize(tokenizer, "test", max_seq_len.min(8));
794 let seq_len = dummy.input_ids.len();
795 if seq_len == 0 {
796 return None;
797 }
798
799 let outputs = match graph_inputs {
800 GraphInputs::Transformer {
801 input_ids,
802 attention_mask,
803 token_type_ids,
804 } => {
805 let ids_vec: Vec<i64> = dummy.input_ids.iter().map(|&x| x as i64).collect();
806 let mask_vec: Vec<i64> = dummy.attention_mask.iter().map(|&x| x as i64).collect();
807 let ids_array = ndarray::Array2::from_shape_vec((1, seq_len), ids_vec).ok()?;
808 let mask_array = ndarray::Array2::from_shape_vec((1, seq_len), mask_vec).ok()?;
809 let ids_tensor = ort::value::Tensor::from_array(ids_array).ok()?;
810 let mask_tensor = ort::value::Tensor::from_array(mask_array).ok()?;
811
812 if let Some(type_id) = token_type_ids {
813 let type_vec: Vec<i64> = dummy.token_type_ids.iter().map(|&x| x as i64).collect();
814 let type_array = ndarray::Array2::from_shape_vec((1, seq_len), type_vec).ok()?;
815 let type_tensor = ort::value::Tensor::from_array(type_array).ok()?;
816 session
817 .run(ort::inputs![
818 input_ids.as_str() => ids_tensor,
819 attention_mask.as_str() => mask_tensor,
820 type_id.as_str() => type_tensor,
821 ])
822 .ok()?
823 } else {
824 session
825 .run(ort::inputs![
826 input_ids.as_str() => ids_tensor,
827 attention_mask.as_str() => mask_tensor,
828 ])
829 .ok()?
830 }
831 }
832 GraphInputs::EmbeddingBag { input_ids, offsets } => {
833 let ids_vec: Vec<i64> = dummy.input_ids.iter().map(|&x| x as i64).collect();
834 let ids_array = ndarray::Array1::from_shape_vec(seq_len, ids_vec).ok()?;
835 let offsets_array = ndarray::Array1::from_shape_vec(1, vec![0i64]).ok()?;
836 let ids_tensor = ort::value::Tensor::from_array(ids_array).ok()?;
837 let offsets_tensor = ort::value::Tensor::from_array(offsets_array).ok()?;
838 session
839 .run(ort::inputs![
840 input_ids.as_str() => ids_tensor,
841 offsets.as_str() => offsets_tensor,
842 ])
843 .ok()?
844 }
845 };
846
847 let (shape, _) = outputs[output_name].try_extract_tensor::<f32>().ok()?;
848
849 match graph_inputs {
850 GraphInputs::Transformer { .. } => shape.last().copied().map(|s| s as usize),
852 GraphInputs::EmbeddingBag { .. } => {
856 if shape.len() == 2 {
857 shape.last().copied().map(|s| s as usize)
858 } else {
859 None
860 }
861 }
862 }
863}
864
865pub fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
871 debug_assert_eq!(a.len(), b.len(), "vectors must have equal dimensions");
872 crate::core::embedding_quant::dot_f32(a, b)
873}
874
875pub fn cosine_similarity_raw(a: &[f32], b: &[f32]) -> f32 {
877 debug_assert_eq!(a.len(), b.len());
878 use crate::core::embedding_quant::dot_f32;
879 let dot = dot_f32(a, b);
880 let norm_a = dot_f32(a, a).sqrt();
881 let norm_b = dot_f32(b, b).sqrt();
882 if norm_a == 0.0 || norm_b == 0.0 {
883 return 0.0;
884 }
885 dot / (norm_a * norm_b)
886}
887
888#[cfg(feature = "embeddings")]
889static SHARED_ENGINE: std::sync::OnceLock<anyhow::Result<EmbeddingEngine>> =
890 std::sync::OnceLock::new();
891
892#[cfg(feature = "embeddings")]
897pub fn shared_engine() -> Option<&'static EmbeddingEngine> {
898 shared_engine_result().ok()
899}
900
901#[cfg(feature = "embeddings")]
904pub fn shared_engine_result() -> anyhow::Result<&'static EmbeddingEngine> {
905 SHARED_ENGINE
906 .get_or_init(EmbeddingEngine::load_default)
907 .as_ref()
908 .map_err(|e| anyhow::anyhow!("{e}"))
909}
910
911#[cfg(feature = "embeddings")]
914pub fn try_shared_engine() -> Option<&'static EmbeddingEngine> {
915 SHARED_ENGINE.get()?.as_ref().ok()
916}
917
918#[cfg(feature = "embeddings")]
922fn deterministic_inference() -> bool {
923 if let Ok(v) = std::env::var("LEAN_CTX_EMBEDDING_DETERMINISTIC") {
924 return matches!(
925 v.trim().to_lowercase().as_str(),
926 "1" | "true" | "yes" | "on"
927 );
928 }
929 crate::core::config::Config::load()
930 .embedding
931 .deterministic
932 .unwrap_or(false)
933}
934
935#[cfg(feature = "embeddings")]
953pub fn background_load_allowed() -> bool {
954 if cfg!(test) {
958 return false;
959 }
960 !current_exe_is_test_artifact()
961}
962
963#[cfg(feature = "embeddings")]
965fn current_exe_is_test_artifact() -> bool {
966 std::env::current_exe()
967 .ok()
968 .as_deref()
969 .is_some_and(exe_path_is_test_artifact)
970}
971
972#[cfg(feature = "embeddings")]
978fn exe_path_is_test_artifact(path: &Path) -> bool {
979 path.parent()
980 .and_then(Path::file_name)
981 .and_then(|name| name.to_str())
982 .is_some_and(|name| name == "deps")
983}
984
985#[cfg(test)]
986mod tests {
987 use super::*;
988
989 #[test]
990 fn cosine_similarity_identical() {
991 let a = vec![1.0, 0.0, 0.0];
992 let b = vec![1.0, 0.0, 0.0];
993 assert!((cosine_similarity(&a, &b) - 1.0).abs() < 1e-6);
994 }
995
996 #[test]
997 fn cosine_similarity_orthogonal() {
998 let a = vec![1.0, 0.0, 0.0];
999 let b = vec![0.0, 1.0, 0.0];
1000 assert!(cosine_similarity(&a, &b).abs() < 1e-6);
1001 }
1002
1003 #[test]
1004 fn cosine_similarity_opposite() {
1005 let a = vec![1.0, 0.0, 0.0];
1006 let b = vec![-1.0, 0.0, 0.0];
1007 assert!((cosine_similarity(&a, &b) + 1.0).abs() < 1e-6);
1008 }
1009
1010 #[test]
1011 fn cosine_similarity_raw_unnormalized() {
1012 let a = vec![3.0, 4.0];
1013 let b = vec![3.0, 4.0];
1014 assert!((cosine_similarity_raw(&a, &b) - 1.0).abs() < 1e-6);
1015 }
1016
1017 #[test]
1018 fn cosine_similarity_raw_zero_vector() {
1019 let a = vec![0.0, 0.0];
1020 let b = vec![1.0, 2.0];
1021 assert_eq!(cosine_similarity_raw(&a, &b), 0.0);
1022 }
1023
1024 #[test]
1025 fn model_directory_env_override_and_availability() {
1026 let _lock = crate::core::data_dir::test_env_lock();
1029 let unique = "/tmp/lean_ctx_test_embed_42xyz";
1030 crate::test_env::set_var("LEAN_CTX_MODELS_DIR", unique);
1031 let dir = EmbeddingEngine::model_directory();
1032 assert_eq!(dir.to_string_lossy(), unique);
1033 assert!(!EmbeddingEngine::is_available());
1034 crate::test_env::remove_var("LEAN_CTX_MODELS_DIR");
1035 }
1036
1037 #[test]
1044 fn ensure_downloaded_is_offline_noop_when_model_present() {
1045 let _lock = crate::core::data_dir::test_env_lock();
1048 let tmp = std::env::temp_dir().join(format!("lc-embed-545-{}", std::process::id()));
1049 crate::test_env::set_var("LEAN_CTX_MODELS_DIR", tmp.to_str().unwrap());
1050
1051 let selected = model_registry::resolve_model();
1052 let config = selected.config();
1053 let model_dir = tmp.join(selected.storage_dir_name());
1054 std::fs::create_dir_all(&model_dir).unwrap();
1055 std::fs::write(model_dir.join(config.local_model_file()), b"weights").unwrap();
1058 std::fs::write(model_dir.join(config.vocab_file.filename()), b"vocab").unwrap();
1059
1060 assert!(EmbeddingEngine::is_available());
1061 assert!(EmbeddingEngine::ensure_downloaded().is_ok());
1062
1063 crate::test_env::remove_var("LEAN_CTX_MODELS_DIR");
1064 std::fs::remove_dir_all(&tmp).ok();
1065 }
1066
1067 #[test]
1071 #[cfg(feature = "embeddings")]
1072 fn exe_path_test_artifact_detection() {
1073 assert!(exe_path_is_test_artifact(Path::new(
1075 "/repo/rust/target/debug/deps/conformance_suite-0a1b2c3d4e5f6789"
1076 )));
1077 assert!(exe_path_is_test_artifact(Path::new(
1078 "/repo/rust/target/release/deps/lean_ctx-deadbeefcafef00d"
1079 )));
1080 assert!(!exe_path_is_test_artifact(Path::new(
1082 "/usr/local/bin/lean-ctx"
1083 )));
1084 assert!(!exe_path_is_test_artifact(Path::new(
1085 "/Users/x/.local/bin/lean-ctx"
1086 )));
1087 assert!(!exe_path_is_test_artifact(Path::new("/opt/tools/ctx")));
1089 assert!(!exe_path_is_test_artifact(Path::new(
1092 "/repo/rust/target/debug/lean-ctx"
1093 )));
1094 }
1095
1096 #[test]
1100 #[cfg(feature = "embeddings")]
1101 fn background_load_disallowed_in_tests() {
1102 assert!(!background_load_allowed());
1103 }
1104
1105 #[test]
1109 #[cfg(feature = "embeddings")]
1110 fn embedding_bag_signature_detection() {
1111 assert!(is_embedding_bag_signature(&[
1113 "input_ids".to_string(),
1114 "offsets".to_string()
1115 ]));
1116 assert!(!is_embedding_bag_signature(&[
1118 "input_ids".to_string(),
1119 "attention_mask".to_string()
1120 ]));
1121 assert!(!is_embedding_bag_signature(&[
1122 "input_ids".to_string(),
1123 "attention_mask".to_string(),
1124 "token_type_ids".to_string()
1125 ]));
1126 assert!(!is_embedding_bag_signature(&["input_ids".to_string()]));
1128 assert!(!is_embedding_bag_signature(&[
1129 "input_ids".to_string(),
1130 "offsets".to_string(),
1131 "extra".to_string()
1132 ]));
1133 }
1134
1135 #[test]
1147 #[ignore = "downloads a real model from HuggingFace (~91MB)"]
1148 #[cfg(feature = "embeddings")]
1149 fn custom_hf_model_end_to_end() {
1150 let model = model_registry::EmbeddingModel::from_str_name(
1151 "hf:sentence-transformers/all-MiniLM-L6-v2@main",
1152 )
1153 .expect("valid hf: spec");
1154
1155 let base = std::env::temp_dir().join("lean_ctx_test_custom_hf_e2e");
1156 let engine = EmbeddingEngine::load_model(&base, model.clone()).expect("load custom model");
1157
1158 assert_eq!(engine.dimensions(), 384, "probed dims from ONNX graph");
1159 assert_eq!(
1160 engine.model_name(),
1161 "hf:sentence-transformers/all-MiniLM-L6-v2@main"
1162 );
1163
1164 let v = engine.embed("fn main() { println!(\"hello\"); }").unwrap();
1165 assert_eq!(v.len(), 384);
1166 let norm: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
1167 assert!((norm - 1.0).abs() < 1e-3, "L2-normalized, got {norm}");
1168
1169 let lock_path = base.join(model.storage_dir_name()).join("model.lock.json");
1171 let lock: std::collections::BTreeMap<String, String> =
1172 serde_json::from_str(&std::fs::read_to_string(&lock_path).unwrap()).unwrap();
1173 assert!(lock.contains_key("model.onnx"));
1174 assert!(lock.contains_key("tokenizer.json"));
1175
1176 let a = engine.embed("read a file from disk").unwrap();
1178 let b = engine.embed("load file contents from filesystem").unwrap();
1179 let c = engine.embed("the weather in Zurich is sunny").unwrap();
1180 assert!(
1181 cosine_similarity(&a, &b) > cosine_similarity(&a, &c),
1182 "related texts must be closer"
1183 );
1184 }
1185
1186 #[test]
1191 #[ignore = "downloads a real model from HuggingFace (~30MB)"]
1192 #[cfg(feature = "embeddings")]
1193 fn model2vec_potion_end_to_end() {
1194 let model =
1195 model_registry::EmbeddingModel::from_str_name("hf:minishlab/potion-base-8M@main")
1196 .expect("valid hf: spec");
1197
1198 let base = std::env::temp_dir().join("lean_ctx_test_model2vec_e2e");
1199 let engine =
1200 EmbeddingEngine::load_model(&base, model.clone()).expect("load model2vec model");
1201
1202 assert_eq!(engine.dimensions(), 256, "probed dims from EmbeddingBag");
1205
1206 let code_vec = engine.embed("fn main() { println!(\"hello\"); }").unwrap();
1207 assert_eq!(code_vec.len(), 256);
1208 let norm: f32 = code_vec.iter().map(|x| x * x).sum::<f32>().sqrt();
1209 assert!((norm - 1.0).abs() < 1e-3, "L2-normalized, got {norm}");
1210
1211 let sql_vec = engine.embed("SELECT * FROM users WHERE id = 1").unwrap();
1213 assert!(cosine_similarity(&code_vec, &sql_vec) < 0.999);
1214
1215 let read_vec = engine.embed("read a file from disk").unwrap();
1217 let load_vec = engine.embed("load file contents from filesystem").unwrap();
1218 let weather_vec = engine.embed("the weather in Zurich is sunny").unwrap();
1219 assert!(
1220 cosine_similarity(&read_vec, &load_vec) > cosine_similarity(&read_vec, &weather_vec),
1221 "related texts must be closer"
1222 );
1223
1224 let empty_vec = engine.embed("").unwrap();
1226 assert_eq!(empty_vec.len(), 256);
1227 }
1228}