Skip to main content

klieo_embed_common/
fastembed.rs

1//! `FastEmbedEmbedder` — fastembed-rs ONNX-runtime CPU embedder.
2//!
3//! Default model: `AllMiniLML6V2` (384-dim, ~80 MB ONNX file
4//! auto-downloaded on first use to the user's cache directory).
5//!
6//! Heavy first-build (ONNX runtime crate is ~5-10 minutes from cold
7//! cache). Subsequent builds reuse the cached compile.
8
9use async_trait::async_trait;
10use fastembed::{EmbeddingModel, InitOptions, TextEmbedding};
11use klieo_core::error::MemoryError;
12use std::sync::{Arc, Mutex};
13
14use crate::Embedder;
15
16/// Default model: 384-dimensional `AllMiniLML6V2`.
17pub const DEFAULT_MODEL: EmbeddingModel = EmbeddingModel::AllMiniLML6V2;
18/// Output dimension of [`DEFAULT_MODEL`].
19pub const DEFAULT_DIM: usize = 384;
20
21/// fastembed-rs–backed `Embedder`.
22///
23/// fastembed 5.x requires `&mut TextEmbedding` for `embed`; the trait
24/// surface gives us `&self`. Interior `Mutex` serialises concurrent
25/// embed calls — acceptable because ONNX inference is already CPU-
26/// saturating.
27pub struct FastEmbedEmbedder {
28    inner: Arc<Mutex<TextEmbedding>>,
29    dim: usize,
30}
31
32impl FastEmbedEmbedder {
33    /// Build with [`DEFAULT_MODEL`]. Triggers model download on first
34    /// call if not already cached.
35    pub fn new() -> Result<Self, MemoryError> {
36        Self::with_model(DEFAULT_MODEL, DEFAULT_DIM)
37    }
38
39    /// Build with the supplied fastembed model. The caller asserts the
40    /// model's output dimension; mismatch is reported at first
41    /// `embed` call against a dimension-checked store.
42    pub fn with_model(model: EmbeddingModel, dim: usize) -> Result<Self, MemoryError> {
43        let inner = TextEmbedding::try_new(InitOptions::new(model))
44            .map_err(|e| MemoryError::Embedding(format!("fastembed init: {e}")))?;
45        Ok(Self {
46            inner: Arc::new(Mutex::new(inner)),
47            dim,
48        })
49    }
50}
51
52#[async_trait]
53impl Embedder for FastEmbedEmbedder {
54    fn dimension(&self) -> usize {
55        self.dim
56    }
57
58    async fn embed(&self, texts: &[String]) -> Result<Vec<Vec<f32>>, MemoryError> {
59        let texts_owned: Vec<String> = texts.to_vec();
60        let inner = self.inner.clone();
61        let dim = self.dim;
62        tokio::task::spawn_blocking(move || -> Result<Vec<Vec<f32>>, MemoryError> {
63            // `TextEmbedding`'s mutated state is the ort `Session`; a
64            // panic inside one inference does not leave any invariants
65            // broken for the next call. Recover from poison by taking
66            // the inner guard so a single panic does not permanently
67            // disable long-term memory writes.
68            //
69            // Held inside `spawn_blocking`, never across `.await` — use
70            // `std::sync::Mutex`, not `tokio::sync::Mutex`. Do NOT move
71            // this lock onto an async path without revisiting that
72            // choice.
73            let mut guard = inner.lock().unwrap_or_else(|p| p.into_inner());
74            let docs = guard
75                .embed(texts_owned, None)
76                .map_err(|e| MemoryError::Embedding(format!("fastembed embed: {e}")))?;
77            for v in &docs {
78                if v.len() != dim {
79                    return Err(MemoryError::Embedding(format!(
80                        "fastembed produced {}-dim vector, expected {dim}",
81                        v.len()
82                    )));
83                }
84            }
85            Ok(docs)
86        })
87        .await
88        .map_err(|e| MemoryError::Embedding(format!("blocking task join: {e}")))?
89    }
90}