mnemo_core/embedding/onnx.rs
1//! ONNX Runtime local embedding provider.
2//!
3//! Provides local embedding inference using ONNX Runtime, eliminating the
4//! need for an external API. Supports sentence-transformer models such as
5//! `all-MiniLM-L6-v2` exported to ONNX format.
6//!
7//! # Feature gating
8//!
9//! When compiled **without** the `onnx` feature the module provides a stub
10//! that validates the model path but returns [`Error::Embedding`] from
11//! `embed()` and `embed_batch()`.
12//!
13//! When compiled **with** the `onnx` feature the module loads the ONNX
14//! session and a HuggingFace tokenizer, then performs real local inference
15//! with mean-pooling and L2 normalisation.
16//!
17//! ```toml
18//! [features]
19//! onnx = ["dep:ort", "dep:tokenizers", "dep:ndarray"]
20//!
21//! [dependencies]
22//! ort = { version = "2", optional = true }
23//! tokenizers = { version = "0.21", optional = true, default-features = false }
24//! ndarray = { version = "0.16", optional = true }
25//! ```
26//!
27//! # Example (stub)
28//!
29//! ```rust,no_run
30//! use mnemo_core::embedding::onnx::OnnxEmbedding;
31//! use mnemo_core::embedding::EmbeddingProvider;
32//!
33//! // Will succeed only if the path exists on disk.
34//! let provider = OnnxEmbedding::new("/models/all-MiniLM-L6-v2.onnx", 384)
35//! .expect("model path must exist");
36//!
37//! assert_eq!(provider.dimensions(), 384);
38//! assert_eq!(provider.model_path(), "/models/all-MiniLM-L6-v2.onnx");
39//! ```
40
41use crate::embedding::EmbeddingProvider;
42use crate::error::{Error, Result};
43
44// ---------------------------------------------------------------------------
45// Real implementation (feature = "onnx")
46// ---------------------------------------------------------------------------
47#[cfg(feature = "onnx")]
48mod inner {
49 use super::*;
50 use ndarray::Array2;
51 use ort::session::Session;
52 use ort::value::Tensor;
53 use std::path::Path;
54 use std::sync::{Arc, Mutex};
55 use tokenizers::Tokenizer;
56
57 /// ONNX-based local embedding provider.
58 ///
59 /// Wraps an ONNX sentence-transformer model (e.g. `all-MiniLM-L6-v2`)
60 /// together with a HuggingFace tokenizer for on-device vector generation.
61 pub struct OnnxEmbedding {
62 dimensions: usize,
63 model_path: String,
64 // ort 2.0.0-rc.11's `Session::run` takes `&mut self`, so the session is
65 // behind a `Mutex` (interior mutability) rather than a bare `Arc` — the
66 // `Arc<Mutex<_>>` still moves cheaply into `spawn_blocking`.
67 session: Arc<Mutex<Session>>,
68 tokenizer: Arc<Tokenizer>,
69 }
70
71 // `ort::Session` is Send in ort v2; `Mutex<Session>` gives the `&mut` the
72 // run API needs. `tokenizers::Tokenizer` is Send + Sync.
73
74 // Manual Debug because Session/Tokenizer do not implement Debug.
75 impl std::fmt::Debug for OnnxEmbedding {
76 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
77 f.debug_struct("OnnxEmbedding")
78 .field("dimensions", &self.dimensions)
79 .field("model_path", &self.model_path)
80 .finish_non_exhaustive()
81 }
82 }
83
84 impl OnnxEmbedding {
85 /// Create a new ONNX embedding provider from a model path.
86 ///
87 /// The model should be an ONNX file for a sentence-transformer model
88 /// (e.g. `all-MiniLM-L6-v2` exported to ONNX format).
89 ///
90 /// A `tokenizer.json` file **must** exist in the same directory as the
91 /// model file. This is the standard layout produced by
92 /// `optimum-cli export onnx` or manual HuggingFace model export.
93 ///
94 /// # Errors
95 ///
96 /// Returns [`Error::Validation`] if the model file does not exist.
97 /// Returns [`Error::Embedding`] if the ONNX session or tokenizer
98 /// fails to load.
99 pub fn new(model_path: &str, dimensions: usize) -> Result<Self> {
100 let model = Path::new(model_path);
101 if !model.exists() {
102 return Err(Error::Validation(format!(
103 "ONNX model not found at: {model_path}"
104 )));
105 }
106
107 // Locate tokenizer.json next to the model file.
108 let tokenizer_path = model
109 .parent()
110 .map(|p| p.join("tokenizer.json"))
111 .unwrap_or_else(|| Path::new("tokenizer.json").to_path_buf());
112
113 if !tokenizer_path.exists() {
114 return Err(Error::Embedding(format!(
115 "tokenizer.json not found next to ONNX model (expected at {})",
116 tokenizer_path.display()
117 )));
118 }
119
120 let session = Session::builder()
121 .map_err(|e| {
122 Error::Embedding(format!("failed to create ONNX session builder: {e}"))
123 })?
124 .with_intra_threads(4)
125 .map_err(|e| Error::Embedding(format!("failed to set intra threads: {e}")))?
126 .commit_from_file(model_path)
127 .map_err(|e| Error::Embedding(format!("failed to load ONNX model: {e}")))?;
128
129 let tokenizer = Tokenizer::from_file(&tokenizer_path)
130 .map_err(|e| Error::Embedding(format!("failed to load tokenizer: {e}")))?;
131
132 Ok(Self {
133 dimensions,
134 model_path: model_path.to_string(),
135 session: Arc::new(Mutex::new(session)),
136 tokenizer: Arc::new(tokenizer),
137 })
138 }
139
140 /// Get the model path.
141 #[must_use]
142 pub fn model_path(&self) -> &str {
143 &self.model_path
144 }
145
146 /// Tokenize a batch of texts and return (input_ids, attention_mask,
147 /// token_type_ids) as 2-D i64 arrays with shape `[batch, max_len]`.
148 fn tokenize_batch(
149 tokenizer: &Tokenizer,
150 texts: &[&str],
151 ) -> Result<(Array2<i64>, Array2<i64>, Array2<i64>)> {
152 let encodings = tokenizer
153 .encode_batch(texts.to_vec(), true)
154 .map_err(|e| Error::Embedding(format!("tokenization failed: {e}")))?;
155
156 let batch_size = encodings.len();
157 let max_len = encodings
158 .iter()
159 .map(|e| e.get_ids().len())
160 .max()
161 .unwrap_or(0);
162
163 let mut input_ids = Array2::<i64>::zeros((batch_size, max_len));
164 let mut attention_mask = Array2::<i64>::zeros((batch_size, max_len));
165 let mut token_type_ids = Array2::<i64>::zeros((batch_size, max_len));
166
167 for (i, enc) in encodings.iter().enumerate() {
168 for (j, &id) in enc.get_ids().iter().enumerate() {
169 input_ids[[i, j]] = i64::from(id);
170 }
171 for (j, &mask) in enc.get_attention_mask().iter().enumerate() {
172 attention_mask[[i, j]] = i64::from(mask);
173 }
174 for (j, &tid) in enc.get_type_ids().iter().enumerate() {
175 token_type_ids[[i, j]] = i64::from(tid);
176 }
177 }
178
179 Ok((input_ids, attention_mask, token_type_ids))
180 }
181
182 /// Mean-pool the last hidden state over the token dimension, weighted
183 /// by the attention mask, then L2-normalise each vector.
184 fn mean_pool_and_normalize(
185 hidden: &Array2<f32>,
186 mask: &Array2<i64>,
187 batch_size: usize,
188 seq_len: usize,
189 hidden_dim: usize,
190 ) -> Vec<Vec<f32>> {
191 // hidden shape: [batch * seq_len, hidden_dim] (flattened) OR
192 // we receive it already as [batch, hidden_dim] after manual pooling.
193 // We handle the [batch, seq_len, hidden_dim] case by reshaping.
194 let _ = seq_len; // used only for the assertion below
195
196 let mut results = Vec::with_capacity(batch_size);
197
198 for i in 0..batch_size {
199 let mut pooled = vec![0.0f32; hidden_dim];
200 let mut count = 0.0f32;
201
202 for j in 0..seq_len {
203 let m = mask[[i, j]] as f32;
204 if m > 0.0 {
205 for k in 0..hidden_dim {
206 pooled[k] += hidden[[i * seq_len + j, k]] * m;
207 }
208 count += m;
209 }
210 }
211
212 if count > 0.0 {
213 for v in &mut pooled {
214 *v /= count;
215 }
216 }
217
218 // L2 normalise
219 let norm: f32 = pooled.iter().map(|x| x * x).sum::<f32>().sqrt();
220 if norm > 0.0 {
221 for v in &mut pooled {
222 *v /= norm;
223 }
224 }
225
226 results.push(pooled);
227 }
228
229 results
230 }
231
232 /// Run inference on a batch of texts. This is the shared
233 /// implementation used by both `embed` and `embed_batch`.
234 async fn run_inference(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>> {
235 if texts.is_empty() {
236 return Ok(Vec::new());
237 }
238
239 let session = Arc::clone(&self.session);
240 let tokenizer = Arc::clone(&self.tokenizer);
241 let dims = self.dimensions;
242 let owned_texts: Vec<String> = texts.iter().map(|t| (*t).to_string()).collect();
243
244 tokio::task::spawn_blocking(move || -> Result<Vec<Vec<f32>>> {
245 let text_refs: Vec<&str> = owned_texts.iter().map(String::as_str).collect();
246 let (input_ids, attention_mask, token_type_ids) =
247 Self::tokenize_batch(&tokenizer, &text_refs)?;
248
249 let batch_size = input_ids.nrows();
250 let seq_len = input_ids.ncols();
251
252 // `attention_mask` is consumed by the input tensor below but is
253 // still needed for mean-pooling, so keep an owned copy.
254 let mask_for_pool = attention_mask.clone();
255
256 // ort 2.0.0-rc.11: inputs are `Value`s built via
257 // `Tensor::from_array` (an owned ndarray impls
258 // `OwnedTensorArrayData`); the `inputs!` macro returns a `Vec`
259 // (not a `Result`), and `run` takes `&mut self`.
260 let ids_t = Tensor::from_array(input_ids)
261 .map_err(|e| Error::Embedding(format!("input_ids tensor: {e}")))?;
262 let mask_t = Tensor::from_array(attention_mask)
263 .map_err(|e| Error::Embedding(format!("attention_mask tensor: {e}")))?;
264 let tt_t = Tensor::from_array(token_type_ids)
265 .map_err(|e| Error::Embedding(format!("token_type_ids tensor: {e}")))?;
266
267 let mut sess = session
268 .lock()
269 .map_err(|e| Error::Embedding(format!("onnx session lock poisoned: {e}")))?;
270 let outputs = sess
271 .run(ort::inputs![
272 "input_ids" => ids_t,
273 "attention_mask" => mask_t,
274 "token_type_ids" => tt_t,
275 ])
276 .map_err(|e| Error::Embedding(format!("ONNX inference failed: {e}")))?;
277
278 // Sentence-transformer models typically output
279 // "last_hidden_state" at index 0 with shape
280 // [batch, seq_len, hidden_dim]. rc.11: `try_extract_array`
281 // returns an `ndarray::ArrayViewD` (the old `try_extract_tensor`
282 // now returns a `(&Shape, &[T])` tuple). `.get()` yields a
283 // `ValueRef` and the iterator yields `&Value`, so each arm
284 // extracts and copies to an owned array to unify the type and
285 // release the borrow on `outputs`.
286 let extract_owned = |e: ort::Error| {
287 Error::Embedding(format!("failed to extract output tensor: {e}"))
288 };
289 let output_array: ndarray::ArrayD<f32> = match outputs.get("last_hidden_state")
290 {
291 Some(v) => v.try_extract_array::<f32>().map_err(extract_owned)?.to_owned(),
292 None => outputs
293 .iter()
294 .next()
295 .ok_or_else(|| {
296 Error::Embedding("no output tensor from ONNX model".to_string())
297 })?
298 .1
299 .try_extract_array::<f32>()
300 .map_err(extract_owned)?
301 .to_owned(),
302 };
303
304 let shape = output_array.shape();
305
306 // Handle different output shapes:
307 // - [batch, seq_len, hidden_dim]: needs mean-pooling
308 // - [batch, hidden_dim]: already pooled (e.g. sentence_embedding output)
309 if shape.len() == 3 {
310 let hidden_dim = shape[2];
311 if hidden_dim != dims {
312 return Err(Error::Embedding(format!(
313 "model hidden dim ({hidden_dim}) does not match configured dimensions ({dims})"
314 )));
315 }
316
317 // Reshape to [batch * seq_len, hidden_dim] for pooling
318 let flat = output_array
319 .to_shape((batch_size * seq_len, hidden_dim))
320 .map_err(|e| Error::Embedding(format!("reshape failed: {e}")))?;
321
322 let flat_owned: Array2<f32> = flat.to_owned();
323 Ok(Self::mean_pool_and_normalize(
324 &flat_owned,
325 &mask_for_pool,
326 batch_size,
327 seq_len,
328 hidden_dim,
329 ))
330 } else if shape.len() == 2 {
331 // Already pooled output [batch, hidden_dim]
332 let hidden_dim = shape[1];
333 if hidden_dim != dims {
334 return Err(Error::Embedding(format!(
335 "model hidden dim ({hidden_dim}) does not match configured dimensions ({dims})"
336 )));
337 }
338
339 let mut results = Vec::with_capacity(batch_size);
340 for i in 0..batch_size {
341 let mut vec = Vec::with_capacity(hidden_dim);
342 for j in 0..hidden_dim {
343 vec.push(output_array[[i, j]]);
344 }
345 // L2 normalise
346 let norm: f32 = vec.iter().map(|x| x * x).sum::<f32>().sqrt();
347 if norm > 0.0 {
348 for v in &mut vec {
349 *v /= norm;
350 }
351 }
352 results.push(vec);
353 }
354 Ok(results)
355 } else {
356 Err(Error::Embedding(format!(
357 "unexpected output tensor shape: {shape:?}"
358 )))
359 }
360 })
361 .await
362 .map_err(|e| Error::Embedding(format!("inference task panicked: {e}")))?
363 }
364 }
365
366 #[async_trait::async_trait]
367 impl EmbeddingProvider for OnnxEmbedding {
368 /// Generate an embedding vector for a single text input.
369 ///
370 /// Tokenizes the input, runs ONNX inference, applies mean-pooling
371 /// weighted by the attention mask, and L2-normalises the result.
372 ///
373 /// # Errors
374 ///
375 /// Returns [`Error::Embedding`] if tokenization or inference fails.
376 async fn embed(&self, text: &str) -> Result<Vec<f32>> {
377 let mut results = self.run_inference(&[text]).await?;
378 results
379 .pop()
380 .ok_or_else(|| Error::Embedding("empty inference result".to_string()))
381 }
382
383 /// Generate embedding vectors for a batch of text inputs.
384 ///
385 /// Processes all texts in a single batched ONNX inference call for
386 /// maximum throughput.
387 ///
388 /// # Errors
389 ///
390 /// Returns [`Error::Embedding`] if tokenization or inference fails.
391 async fn embed_batch(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>> {
392 self.run_inference(texts).await
393 }
394
395 fn dimensions(&self) -> usize {
396 self.dimensions
397 }
398 }
399}
400
401// ---------------------------------------------------------------------------
402// Stub implementation (no onnx feature)
403// ---------------------------------------------------------------------------
404#[cfg(not(feature = "onnx"))]
405mod inner {
406 use super::*;
407
408 /// ONNX-based local embedding provider.
409 ///
410 /// Wraps an ONNX sentence-transformer model for on-device vector generation.
411 /// When the `onnx` feature is not enabled, `embed` and `embed_batch` return
412 /// an [`Error::Embedding`] explaining how to enable full inference.
413 #[derive(Debug)]
414 pub struct OnnxEmbedding {
415 dimensions: usize,
416 model_path: String,
417 // In a full implementation, this would hold:
418 // session: ort::Session,
419 // tokenizer: tokenizers::Tokenizer,
420 }
421
422 impl OnnxEmbedding {
423 /// Create a new ONNX embedding provider from a model path.
424 ///
425 /// The model should be an ONNX sentence-transformer model
426 /// (e.g., `all-MiniLM-L6-v2` exported to ONNX format).
427 ///
428 /// # Errors
429 ///
430 /// Returns [`Error::Validation`] if the file at `model_path` does not
431 /// exist on disk.
432 pub fn new(model_path: &str, dimensions: usize) -> Result<Self> {
433 if !std::path::Path::new(model_path).exists() {
434 return Err(Error::Validation(format!(
435 "ONNX model not found at: {model_path}"
436 )));
437 }
438 Ok(Self {
439 dimensions,
440 model_path: model_path.to_string(),
441 })
442 }
443
444 /// Get the model path.
445 #[must_use]
446 pub fn model_path(&self) -> &str {
447 &self.model_path
448 }
449 }
450
451 #[async_trait::async_trait]
452 impl EmbeddingProvider for OnnxEmbedding {
453 /// Generate an embedding vector for a single text input.
454 ///
455 /// # Errors
456 ///
457 /// Currently returns [`Error::Embedding`] because full ONNX Runtime
458 /// inference requires the `onnx` feature (with `ort`, `tokenizers`,
459 /// and `ndarray` crates).
460 async fn embed(&self, _text: &str) -> Result<Vec<f32>> {
461 Err(Error::Embedding(
462 "ONNX Runtime not available: compile with full onnx dependencies \
463 (ort, tokenizers, ndarray) to enable local inference"
464 .to_string(),
465 ))
466 }
467
468 /// Generate embedding vectors for a batch of text inputs.
469 ///
470 /// # Errors
471 ///
472 /// Currently returns [`Error::Embedding`] because full ONNX Runtime
473 /// inference requires the `onnx` feature (with `ort`, `tokenizers`,
474 /// and `ndarray` crates).
475 async fn embed_batch(&self, _texts: &[&str]) -> Result<Vec<Vec<f32>>> {
476 Err(Error::Embedding(
477 "ONNX Runtime not available: compile with full onnx dependencies \
478 (ort, tokenizers, ndarray) to enable local inference"
479 .to_string(),
480 ))
481 }
482
483 fn dimensions(&self) -> usize {
484 self.dimensions
485 }
486 }
487}
488
489// Re-export `OnnxEmbedding` from the active inner module so that
490// downstream code can use `crate::embedding::onnx::OnnxEmbedding`
491// regardless of the feature flag.
492pub use inner::OnnxEmbedding;
493
494#[cfg(test)]
495mod tests {
496 use super::*;
497
498 #[test]
499 fn test_onnx_missing_model() {
500 let result = OnnxEmbedding::new("/nonexistent/path/model.onnx", 384);
501 assert!(result.is_err());
502 let err = result.unwrap_err();
503 let msg = err.to_string();
504 assert!(
505 msg.contains("ONNX model not found"),
506 "unexpected error message: {msg}"
507 );
508 }
509
510 #[test]
511 fn test_onnx_dimensions() {
512 // Use Cargo.toml as a stand-in file that is guaranteed to exist.
513 let path = concat!(env!("CARGO_MANIFEST_DIR"), "/Cargo.toml");
514 #[cfg(not(feature = "onnx"))]
515 {
516 let provider = OnnxEmbedding::new(path, 384).expect("file should exist");
517 assert_eq!(provider.dimensions(), 384);
518 }
519 // When the onnx feature is on, construction also requires
520 // tokenizer.json, so we only test that the path validation
521 // passes for the stub variant.
522 #[cfg(feature = "onnx")]
523 {
524 // Without a tokenizer.json next to Cargo.toml, we expect an
525 // embedding error rather than a validation error.
526 let result = OnnxEmbedding::new(path, 384);
527 assert!(result.is_err());
528 let msg = result.unwrap_err().to_string();
529 assert!(
530 msg.contains("tokenizer.json"),
531 "expected tokenizer.json error, got: {msg}"
532 );
533 }
534 }
535
536 #[test]
537 fn test_onnx_model_path() {
538 let path = concat!(env!("CARGO_MANIFEST_DIR"), "/Cargo.toml");
539 #[cfg(not(feature = "onnx"))]
540 {
541 let provider = OnnxEmbedding::new(path, 768).expect("file should exist");
542 assert_eq!(provider.model_path(), path);
543 }
544 #[cfg(feature = "onnx")]
545 {
546 let result = OnnxEmbedding::new(path, 768);
547 assert!(result.is_err());
548 }
549 }
550
551 #[cfg(not(feature = "onnx"))]
552 #[tokio::test]
553 async fn test_onnx_embed_returns_error_without_runtime() {
554 let path = concat!(env!("CARGO_MANIFEST_DIR"), "/Cargo.toml");
555 let provider = OnnxEmbedding::new(path, 384).expect("file should exist");
556 let result = provider.embed("hello world").await;
557 assert!(result.is_err());
558 let msg = result.unwrap_err().to_string();
559 assert!(
560 msg.contains("ONNX Runtime not available"),
561 "unexpected error: {msg}"
562 );
563 }
564
565 #[cfg(not(feature = "onnx"))]
566 #[tokio::test]
567 async fn test_onnx_embed_batch_returns_error_without_runtime() {
568 let path = concat!(env!("CARGO_MANIFEST_DIR"), "/Cargo.toml");
569 let provider = OnnxEmbedding::new(path, 384).expect("file should exist");
570 let result = provider.embed_batch(&["a", "b"]).await;
571 assert!(result.is_err());
572 let msg = result.unwrap_err().to_string();
573 assert!(
574 msg.contains("ONNX Runtime not available"),
575 "unexpected error: {msg}"
576 );
577 }
578}