Skip to main content

llm_kernel/embedding/
fastembed.rs

1//! Local ONNX embedding provider via fastembed-rs.
2//!
3//! Wraps [`fastembed::TextEmbedding`] behind the [`EmbeddingProvider`] trait.
4//! Models are downloaded from HuggingFace on first use and cached locally.
5//!
6//! ```ignore
7//! use llm_kernel::embedding::{EmbeddingModel, FastembedProvider};
8//! use llm_kernel::embedding::EmbeddingProvider;
9//!
10//! let provider = FastembedProvider::new(EmbeddingModel::BGESmallENV15, None)?;
11//! let result = provider.embed("hello world")?;
12//! assert_eq!(result.vector.len(), 384);
13//! ```
14
15use std::path::PathBuf;
16use std::sync::Mutex;
17
18use crate::embedding::catalog::EmbeddingModel;
19use crate::embedding::types::{EmbeddingProvider, EmbeddingResult};
20use crate::error::{KernelError, Result};
21
22/// Local ONNX embedding provider backed by fastembed-rs.
23///
24/// `TextEmbedding::embed()` requires `&mut self`, so the inner instance is
25/// protected by a `Mutex`. Thread-safety is guaranteed by the `Send + Sync`
26/// bounds on `EmbeddingProvider`.
27pub struct FastembedProvider {
28    inner: Mutex<fastembed::TextEmbedding>,
29    model: EmbeddingModel,
30}
31
32impl FastembedProvider {
33    /// Create a new provider.
34    ///
35    /// `cache_dir` overrides the HuggingFace model cache directory.
36    /// Pass `None` to use the default cache location.
37    pub fn new(model: EmbeddingModel, cache_dir: Option<PathBuf>) -> Result<Self> {
38        let mut options = fastembed::TextInitOptions::new(model.as_fastembed())
39            .with_show_download_progress(false);
40        if let Some(dir) = cache_dir {
41            options = options.with_cache_dir(dir);
42        }
43        let te = fastembed::TextEmbedding::try_new(options).map_err(KernelError::embedding)?;
44        Ok(Self {
45            inner: Mutex::new(te),
46            model,
47        })
48    }
49
50    /// Create with DirectML GPU execution on Windows.
51    ///
52    /// Requires the `embedding-fastembed-directml` feature and Windows OS.
53    /// The DirectML runtime DLL must be present on the target system.
54    ///
55    /// **Initialization cost:** the first call initialises the D3D12 device and
56    /// loads the DirectML DLL, which can take hundreds of milliseconds to
57    /// several seconds. Create the provider once and reuse it across requests.
58    ///
59    /// `cache_dir` overrides the HuggingFace model cache directory.
60    #[cfg(all(feature = "embedding-fastembed-directml", target_os = "windows"))]
61    pub fn new_with_directml(model: EmbeddingModel, cache_dir: Option<PathBuf>) -> Result<Self> {
62        use ort::execution_providers::DirectMLExecutionProvider;
63        let mut options = fastembed::TextInitOptions::new(model.as_fastembed())
64            .with_show_download_progress(false)
65            .with_execution_providers(vec![DirectMLExecutionProvider::default().build()]);
66        if let Some(dir) = cache_dir {
67            options = options.with_cache_dir(dir);
68        }
69        let te = fastembed::TextEmbedding::try_new(options).map_err(KernelError::embedding)?;
70        Ok(Self {
71            inner: Mutex::new(te),
72            model,
73        })
74    }
75
76    /// Create with CoreML GPU/ANE execution on macOS (feature `embedding-fastembed-coreml`).
77    ///
78    /// Accelerates ONNX inference via the CoreML execution provider — Neural Engine
79    /// / GPU on Apple Silicon. The CoreML runtime is bundled with macOS; no extra
80    /// dylib needed (unlike DirectML on Windows).
81    ///
82    /// Compiled CoreML models are cached under `<cache_dir>/coreml` and reused
83    /// across sessions, so repeated provider creation does not re-compile for
84    /// the Neural Engine.
85    ///
86    /// Known quirk (observed intermittently, macOS): on an ANE compile-cache
87    /// miss CoreML may silently fall back to its CPU (Espresso) path —
88    /// throughput drops sharply but memory stays bounded; re-creating the
89    /// provider re-normalizes.
90    #[cfg(all(feature = "embedding-fastembed-coreml", target_os = "macos"))]
91    pub fn new_with_coreml(model: EmbeddingModel, cache_dir: Option<PathBuf>) -> Result<Self> {
92        use ort::execution_providers::CoreMLExecutionProvider;
93        // Default to fastembed's own cache location so the CoreML model cache
94        // lives next to the downloaded ONNX models.
95        let hf_cache = cache_dir
96            .clone()
97            .unwrap_or_else(|| PathBuf::from(fastembed::get_cache_dir()));
98        let coreml_cache = hf_cache.join("coreml");
99        let _ = std::fs::create_dir_all(&coreml_cache);
100        let coreml_ep = CoreMLExecutionProvider::default()
101            // Reuse the compiled CoreML model across sessions. Without this,
102            // every provider creation re-compiles for ANE — slow and
103            // memory-heavy for per-request providers (research-agent incident,
104            // RSS 22.69GB / 35 threads).
105            .with_model_cache_dir(coreml_cache.to_string_lossy())
106            .build();
107        let mut options = fastembed::TextInitOptions::new(model.as_fastembed())
108            .with_show_download_progress(false)
109            // CoreML EP does the heavy compute; cap CPU intra threads instead
110            // of fastembed's available_parallelism default.
111            // ponytail: fixed 4 — raise if CPU-only fallback throughput matters.
112            .with_intra_threads(4)
113            .with_execution_providers(vec![coreml_ep]);
114        options = options.with_cache_dir(hf_cache);
115        let te = fastembed::TextEmbedding::try_new(options).map_err(KernelError::embedding)?;
116        Ok(Self {
117            inner: Mutex::new(te),
118            model,
119        })
120    }
121
122    /// Create with a custom maximum sequence length.
123    pub fn with_max_length(
124        model: EmbeddingModel,
125        cache_dir: Option<PathBuf>,
126        max_length: usize,
127    ) -> Result<Self> {
128        let mut options = fastembed::TextInitOptions::new(model.as_fastembed())
129            .with_show_download_progress(false)
130            .with_max_length(max_length);
131        if let Some(dir) = cache_dir {
132            options = options.with_cache_dir(dir);
133        }
134        let te = fastembed::TextEmbedding::try_new(options).map_err(KernelError::embedding)?;
135        Ok(Self {
136            inner: Mutex::new(te),
137            model,
138        })
139    }
140}
141
142use super::types::text_preview;
143
144impl EmbeddingProvider for FastembedProvider {
145    fn dim(&self) -> usize {
146        self.model.dimension()
147    }
148
149    fn name(&self) -> &str {
150        self.model.as_str()
151    }
152
153    fn embed(&self, text: &str) -> Result<EmbeddingResult> {
154        self.embed_with_prefix(text, self.model.query_prefix())
155    }
156
157    fn embed_document(&self, text: &str) -> Result<EmbeddingResult> {
158        // Asymmetric models (E5/BGE) train with a passage prefix for corpus text.
159        // query_prefix() is correct for queries; doc_prefix() is correct here.
160        self.embed_with_prefix(text, self.model.doc_prefix())
161    }
162
163    fn embed_batch(&self, texts: &[&str]) -> Result<Vec<EmbeddingResult>> {
164        self.embed_batch_with_prefix(texts, self.model.query_prefix())
165    }
166
167    fn embed_documents(&self, texts: &[&str]) -> Result<Vec<EmbeddingResult>> {
168        self.embed_batch_with_prefix(texts, self.model.doc_prefix())
169    }
170}
171
172impl FastembedProvider {
173    /// Single-text embed with a caller-chosen prefix (query vs document).
174    fn embed_with_prefix(&self, text: &str, prefix: Option<&str>) -> Result<EmbeddingResult> {
175        let owned = match prefix {
176            Some(p) => format!("{p}{text}"),
177            None => text.to_string(),
178        };
179        let mut te = self
180            .inner
181            .lock()
182            .map_err(|e| KernelError::Embedding(format!("lock: {e}")))?;
183        let embeddings = te
184            .embed(vec![owned], None)
185            .map_err(KernelError::embedding)?;
186        let vector = embeddings
187            .into_iter()
188            .next()
189            .ok_or_else(|| KernelError::Embedding("empty embedding output".into()))?;
190
191        Ok(EmbeddingResult {
192            vector,
193            text_preview: text_preview(text),
194        })
195    }
196
197    /// Batch embed with a caller-chosen prefix (query vs document).
198    fn embed_batch_with_prefix(
199        &self,
200        texts: &[&str],
201        prefix: Option<&str>,
202    ) -> Result<Vec<EmbeddingResult>> {
203        if texts.is_empty() {
204            return Ok(vec![]);
205        }
206        let prepared: Vec<String> = texts
207            .iter()
208            .map(|t| match prefix {
209                Some(p) => format!("{p}{t}"),
210                None => t.to_string(),
211            })
212            .collect();
213
214        let mut te = self
215            .inner
216            .lock()
217            .map_err(|e| KernelError::Embedding(format!("lock: {e}")))?;
218        let embeddings = te.embed(prepared, None).map_err(KernelError::embedding)?;
219
220        Ok(embeddings
221            .into_iter()
222            .zip(texts.iter())
223            .map(|(vector, &text)| EmbeddingResult {
224                vector,
225                text_preview: text_preview(text),
226            })
227            .collect())
228    }
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234
235    #[test]
236    fn provider_name_matches_model() {
237        // Doesn't need a model download — just checks the constructor doesn't
238        // change the name mapping.
239        for &m in EmbeddingModel::ALL {
240            // Verify as_str() round-trips through as_fastembed()
241            let fe = m.as_fastembed();
242            assert_eq!(format!("{fe:?}"), m.as_str());
243        }
244    }
245
246    #[test]
247    #[ignore = "requires model download"]
248    fn embed_single_text() {
249        let dir = tempfile::tempdir().unwrap();
250        let provider = FastembedProvider::new(
251            EmbeddingModel::BGESmallENV15,
252            Some(dir.path().to_path_buf()),
253        )
254        .unwrap();
255        let result = provider.embed("hello world").unwrap();
256        assert_eq!(result.vector.len(), 384);
257        assert!(!result.vector.is_empty());
258    }
259
260    #[test]
261    #[ignore = "requires model download"]
262    fn embed_batch_texts() {
263        let dir = tempfile::tempdir().unwrap();
264        let provider = FastembedProvider::new(
265            EmbeddingModel::BGESmallENV15,
266            Some(dir.path().to_path_buf()),
267        )
268        .unwrap();
269        let results = provider
270            .embed_batch(&["hello", "world", "foo bar"])
271            .unwrap();
272        assert_eq!(results.len(), 3);
273        for r in &results {
274            assert_eq!(r.vector.len(), 384);
275        }
276    }
277
278    // Research-agent incident regression: providers are created per request /
279    // per MCP call, so repeated create→embed→drop cycles must stay stable and
280    // reuse the CoreML model cache (second cycle compiles no fresh ANE model).
281    #[cfg(all(feature = "embedding-fastembed-coreml", target_os = "macos"))]
282    #[test]
283    #[ignore = "requires model download"]
284    fn coreml_repeated_session_cycles() {
285        let dir = tempfile::tempdir().unwrap();
286        let cache_dir = Some(dir.path().to_path_buf());
287        for _ in 0..10 {
288            let provider = FastembedProvider::new_with_coreml(
289                EmbeddingModel::BGESmallENV15,
290                cache_dir.clone(),
291            )
292            .unwrap();
293            drop(provider);
294        }
295        // Cached compile artifacts must exist after the cycles.
296        let cache = dir.path().join("coreml");
297        std::fs::read_dir(&cache).expect("coreml cache dir populated");
298        let provider =
299            FastembedProvider::new_with_coreml(EmbeddingModel::BGESmallENV15, cache_dir).unwrap();
300        let result = provider.embed("hello world").unwrap();
301        assert_eq!(result.vector.len(), 384);
302    }
303}