Skip to main content

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}