Skip to main content

do_memory_core/embeddings/
local.rs

1//! Local embedding provider using sentence transformers
2//!
3//! This provider runs embedding models locally using candle-transformers,
4//! providing offline capability with no external API dependencies.
5
6use super::config::LocalConfig;
7use super::provider::EmbeddingProvider;
8use anyhow::{Context, Result};
9use async_trait::async_trait;
10use std::sync::Arc;
11use tokio::sync::RwLock;
12
13/// Local embedding provider using sentence transformers
14///
15/// Runs embedding models locally using candle-transformers or similar.
16/// Provides offline embedding generation with no external dependencies.
17///
18/// # Models Supported
19/// - sentence-transformers/all-MiniLM-L6-v2 (384 dims, default)
20/// - sentence-transformers/all-mpnet-base-v2 (768 dims, higher quality)
21/// - sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2 (384 dims, multilingual)
22///
23/// # Example
24/// ```no_run
25/// use do_memory_core::embeddings::{EmbeddingProvider, LocalEmbeddingProvider, LocalConfig};
26///
27/// #[tokio::main]
28/// async fn main() -> anyhow::Result<()> {
29///     let config = LocalConfig::new(
30///         "sentence-transformers/all-MiniLM-L6-v2",
31///         384
32///     );
33///     let provider = LocalEmbeddingProvider::new(config).await?;
34///
35///     let embedding = provider.embed_text("Hello world").await?;
36///     println!("Generated embedding with {} dimensions", embedding.len());
37///     Ok(())
38/// }
39/// ```
40pub struct LocalEmbeddingProvider {
41    /// Model configuration
42    config: LocalConfig,
43    /// Embedding model (placeholder for actual model implementation)
44    model: Arc<RwLock<Option<Box<dyn LocalEmbeddingModel>>>>,
45    /// Model cache directory
46    cache_dir: std::path::PathBuf,
47}
48
49impl LocalEmbeddingProvider {
50    /// Create a new local embedding provider
51    ///
52    /// # Arguments
53    /// * `config` - Model configuration specifying which model to use
54    ///
55    /// # Returns
56    /// Configured local embedding provider
57    pub async fn new(config: LocalConfig) -> Result<Self> {
58        let cache_dir = Self::get_cache_dir().await?;
59
60        let provider = Self {
61            config,
62            model: Arc::new(RwLock::new(None)),
63            cache_dir,
64        };
65
66        // Initialize/load the model
67        provider.load_model().await?;
68
69        Ok(provider)
70    }
71
72    /// Load the embedding model
73    async fn load_model(&self) -> Result<()> {
74        tracing::info!("Loading local embedding model: {}", self.config.model_name);
75
76        #[cfg(feature = "local-embeddings")]
77        {
78            // Try to load real ONNX model, fallback to mock if fails
79            match self.try_load_real_model().await {
80                Ok(real_model) => {
81                    let fallback_model = Box::new(RealEmbeddingModelWithFallback::new(
82                        self.config.model_name.clone(),
83                        self.config.embedding_dimension,
84                        Some(real_model),
85                    ));
86
87                    let mut model_guard = self.model.write().await;
88                    *model_guard = Some(fallback_model);
89
90                    tracing::info!("Local embedding model loaded with real ONNX backend");
91                }
92                Err(e) => {
93                    tracing::warn!("Failed to load real embedding model: {}", e);
94                    tracing::warn!(
95                        "Falling back to mock embeddings - semantic search will not work correctly"
96                    );
97
98                    let mock_fallback = Box::new(RealEmbeddingModelWithFallback::new(
99                        self.config.model_name.clone(),
100                        self.config.embedding_dimension,
101                        None,
102                    ));
103
104                    let mut model_guard = self.model.write().await;
105                    *model_guard = Some(mock_fallback);
106
107                    tracing::info!("Local embedding model loaded with mock fallback");
108                }
109            }
110        }
111
112        #[cfg(not(feature = "local-embeddings"))]
113        {
114            tracing::warn!(
115                "PRODUCTION WARNING: Using mock embeddings - semantic search will not work correctly"
116            );
117            tracing::warn!(
118                "To enable real embeddings, add 'local-embeddings' feature and ensure ONNX models are available"
119            );
120
121            let mock_fallback = Box::new(super::mock_model::MockLocalModel::new(
122                self.config.model_name.clone(),
123                self.config.embedding_dimension,
124            ));
125
126            let mut model_guard = self.model.write().await;
127            *model_guard = Some(mock_fallback);
128
129            tracing::info!("Local embedding model loaded with mock implementation");
130        }
131
132        Ok(())
133    }
134
135    /// Try to load real ONNX model
136    #[cfg(feature = "local-embeddings")]
137    async fn try_load_real_model(&self) -> Result<RealEmbeddingModel> {
138        RealEmbeddingModel::try_load_from_cache(&self.config, &self.cache_dir).await
139    }
140
141    /// Get the cache directory for models
142    ///
143    /// On WASM targets, returns a temporary in-memory path.
144    #[cfg(target_arch = "wasm32")]
145    async fn get_cache_dir() -> Result<std::path::PathBuf> {
146        // WASM has no filesystem - return a placeholder path
147        Ok(std::path::PathBuf::from("/tmp/memory-core-embeddings"))
148    }
149
150    /// Get the cache directory for models
151    #[cfg(not(target_arch = "wasm32"))]
152    async fn get_cache_dir() -> Result<std::path::PathBuf> {
153        let home = std::env::var("HOME")
154            .or_else(|_| std::env::var("USERPROFILE"))
155            .context("Could not determine home directory")?;
156
157        let cache_dir = std::path::Path::new(&home)
158            .join(".cache")
159            .join("memory-core")
160            .join("embeddings");
161
162        tokio::fs::create_dir_all(&cache_dir)
163            .await
164            .context("Failed to create cache directory")?;
165
166        Ok(cache_dir)
167    }
168
169    /// Check if model is loaded
170    pub async fn is_loaded(&self) -> bool {
171        let model_guard = self.model.read().await;
172        model_guard.is_some()
173    }
174
175    /// Get model information
176    #[must_use]
177    pub fn model_info(&self) -> serde_json::Value {
178        serde_json::json!({
179            "name": self.config.model_name,
180            "dimension": self.config.embedding_dimension,
181            "type": "local",
182            "cache_dir": self.cache_dir,
183        })
184    }
185}
186
187#[async_trait]
188impl EmbeddingProvider for LocalEmbeddingProvider {
189    async fn embed_text(&self, text: &str) -> Result<Vec<f32>> {
190        let model_guard = self.model.read().await;
191        let model = model_guard.as_ref().context("Model not loaded")?;
192
193        model.embed(text).await
194    }
195
196    async fn embed_batch(&self, texts: &[String]) -> Result<Vec<Vec<f32>>> {
197        let model_guard = self.model.read().await;
198        let model = model_guard.as_ref().context("Model not loaded")?;
199
200        model.embed_batch(texts).await
201    }
202
203    fn embedding_dimension(&self) -> usize {
204        self.config.embedding_dimension
205    }
206
207    fn model_name(&self) -> &str {
208        &self.config.model_name
209    }
210
211    async fn is_available(&self) -> bool {
212        self.is_loaded().await
213    }
214
215    async fn warmup(&self) -> Result<()> {
216        // Test embedding generation
217        let _embedding = self.embed_text("warmup test").await?;
218        Ok(())
219    }
220
221    fn metadata(&self) -> serde_json::Value {
222        serde_json::json!({
223            "model": self.model_name(),
224            "dimension": self.embedding_dimension(),
225            "type": "local",
226            "provider": "sentence-transformers",
227            "cache_dir": self.cache_dir
228        })
229    }
230}
231
232/// Trait for local embedding models
233#[async_trait]
234#[allow(dead_code)] // Trait methods used by implementations, not called directly in this crate
235pub trait LocalEmbeddingModel: Send + Sync {
236    /// Generate embedding for single text
237    async fn embed(&self, text: &str) -> Result<Vec<f32>>;
238
239    /// Generate embeddings for batch of texts
240    async fn embed_batch(&self, texts: &[String]) -> Result<Vec<Vec<f32>>>;
241
242    /// Get model name
243    fn name(&self) -> &str;
244
245    /// Get embedding dimension
246    fn dimension(&self) -> usize;
247}
248
249/// Import real model implementation
250#[cfg(feature = "local-embeddings")]
251#[allow(unused)]
252pub use crate::embeddings::real_model::RealEmbeddingModel;
253
254/// Import mock model implementations
255#[cfg(feature = "local-embeddings")]
256#[allow(unused)]
257pub use crate::embeddings::mock_model::{MockLocalModel, RealEmbeddingModelWithFallback};
258
259/// Re-export utilities from the utils module
260#[allow(unused)]
261pub use crate::embeddings::utils::{
262    LocalModelUseCase, get_recommended_model, list_available_models,
263};
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268
269    #[tokio::test]
270    async fn test_local_provider_creation() {
271        let config = LocalConfig::new("test-model", 384);
272
273        let provider = LocalEmbeddingProvider::new(config).await.unwrap();
274        assert!(provider.is_loaded().await);
275        assert_eq!(provider.embedding_dimension(), 384);
276        assert_eq!(provider.model_name(), "test-model");
277    }
278
279    #[tokio::test]
280    async fn test_embed_text() {
281        let config = LocalConfig::new("test-model", 384);
282
283        let provider = LocalEmbeddingProvider::new(config).await.unwrap();
284
285        let embedding = provider.embed_text("Hello world").await.unwrap();
286        assert_eq!(embedding.len(), 384);
287
288        // Test deterministic behavior
289        let embedding2 = provider.embed_text("Hello world").await.unwrap();
290        assert_eq!(embedding, embedding2);
291
292        // Different text should produce different embedding
293        let embedding3 = provider.embed_text("Different text").await.unwrap();
294        assert_ne!(embedding, embedding3);
295    }
296
297    #[tokio::test]
298    async fn test_embed_batch() {
299        let config = LocalConfig::new("test-model", 384);
300
301        let provider = LocalEmbeddingProvider::new(config).await.unwrap();
302
303        let texts = vec![
304            "First text".to_string(),
305            "Second text".to_string(),
306            "Third text".to_string(),
307        ];
308
309        let embeddings = provider.embed_batch(&texts).await.unwrap();
310        assert_eq!(embeddings.len(), 3);
311
312        for embedding in embeddings {
313            assert_eq!(embedding.len(), 384);
314        }
315    }
316
317    #[tokio::test]
318    async fn test_similarity_calculation() {
319        let config = LocalConfig::new("test-model", 384);
320
321        let provider = LocalEmbeddingProvider::new(config).await.unwrap();
322
323        // Identical texts should have high similarity
324        let similarity = provider
325            .similarity("Hello world", "Hello world")
326            .await
327            .unwrap();
328        assert!((similarity - 1.0).abs() < 0.001);
329
330        // Different texts should have lower similarity
331        let similarity = provider
332            .similarity("Hello world", "Goodbye universe")
333            .await
334            .unwrap();
335        assert!(similarity < 1.0);
336    }
337
338    #[tokio::test]
339    #[ignore = "Requires local-embeddings feature with ONNX models - blocked by ort crate Send trait issue"]
340    #[cfg(feature = "local-embeddings")]
341    async fn test_real_embedding_generation() {
342        // This test only runs when local-embeddings feature is enabled
343        // and real ONNX models are available
344
345        // Create a temporary directory for model cache
346        let temp_dir = tempfile::TempDir::new().unwrap();
347        let cache_path = temp_dir.path().join("models");
348
349        // Try to load a real model if available
350        // In CI, this might not have actual model files
351        if cache_path.exists() || std::env::var("CI").is_ok() {
352            tracing::info!("Skipping real embedding test - no model files available");
353            return;
354        }
355
356        let config = LocalConfig::new("sentence-transformers/all-MiniLM-L6-v2", 384);
357
358        let provider = LocalEmbeddingProvider::new(config).await.unwrap();
359
360        // Generate embeddings for semantically similar texts
361        let embedding1 = provider
362            .embed_text("machine learning algorithms")
363            .await
364            .unwrap();
365        let embedding2 = provider
366            .embed_text("artificial intelligence models")
367            .await
368            .unwrap();
369        let embedding3 = provider
370            .embed_text("cooking recipes for pasta")
371            .await
372            .unwrap();
373
374        assert_eq!(embedding1.len(), 384);
375        assert_eq!(embedding2.len(), 384);
376        assert_eq!(embedding3.len(), 384);
377
378        // Calculate similarities
379        let similarity_ai_ml = provider
380            .similarity("machine learning", "artificial intelligence")
381            .await
382            .unwrap();
383        let similarity_cooking = provider
384            .similarity("machine learning", "cooking recipes")
385            .await
386            .unwrap();
387
388        // Semantically similar texts should have higher similarity
389        assert!(
390            similarity_ai_ml > similarity_cooking,
391            "AI/ML similarity ({similarity_ai_ml}) should be higher than ML/cooking ({similarity_cooking})"
392        );
393
394        // Both should be positive (cosine similarity range)
395        assert!(similarity_ai_ml > 0.0);
396        assert!(similarity_cooking > 0.0);
397    }
398
399    #[tokio::test]
400    async fn test_embedding_vector_properties() {
401        let config = LocalConfig::new("test-model", 384);
402
403        let provider = LocalEmbeddingProvider::new(config).await.unwrap();
404
405        let embedding = provider.embed_text("test text").await.unwrap();
406
407        // Check that embedding is properly normalized (unit vector)
408        let norm: f32 = embedding.iter().map(|x| x * x).sum::<f32>().sqrt();
409        assert!((norm - 1.0).abs() < 0.001, "Embedding should be normalized");
410
411        // Check that values are in reasonable range
412        for &value in &embedding {
413            assert!(
414                (-1.0..=1.0).contains(&value),
415                "Embedding values should be in [-1, 1]"
416            );
417        }
418    }
419
420    #[tokio::test]
421    async fn test_model_metadata() {
422        let config = LocalConfig::new("sentence-transformers/test-model", 768);
423
424        let provider = LocalEmbeddingProvider::new(config).await.unwrap();
425
426        let metadata = provider.metadata();
427        assert_eq!(metadata["model"], "sentence-transformers/test-model");
428        assert_eq!(metadata["dimension"], 768);
429        assert_eq!(metadata["type"], "local");
430
431        let model_info = provider.model_info();
432        assert_eq!(model_info["name"], "sentence-transformers/test-model");
433        assert_eq!(model_info["dimension"], 768);
434        assert_eq!(model_info["type"], "local");
435    }
436
437    #[tokio::test]
438    async fn test_error_handling() {
439        let config = LocalConfig::new("nonexistent-model", 384);
440
441        // Test with non-existent model - should fall back to mock or fail gracefully
442        let result = LocalEmbeddingProvider::new(config).await;
443
444        match result {
445            Ok(provider) => {
446                // If successful, it should be a mock implementation
447                assert!(provider.is_loaded().await);
448                let embedding = provider.embed_text("test").await.unwrap();
449                assert_eq!(embedding.len(), 384);
450            }
451            Err(e) => {
452                // Should provide meaningful error message
453                assert!(e.to_string().contains("model") || e.to_string().contains("load"));
454            }
455        }
456    }
457
458    #[tokio::test]
459    async fn test_warmup_functionality() {
460        let config = LocalConfig::new("test-model", 384);
461
462        let provider = LocalEmbeddingProvider::new(config).await.unwrap();
463
464        // Warmup should succeed
465        let result = provider.warmup().await;
466        assert!(result.is_ok(), "Warmup should succeed");
467    }
468
469    #[test]
470    fn test_utils_list_models() {
471        let models = list_available_models();
472        assert!(!models.is_empty());
473
474        for model in models {
475            assert!(!model.model_name.is_empty());
476            assert!(model.embedding_dimension > 0);
477        }
478    }
479
480    #[test]
481    fn test_utils_recommended_models() {
482        let fast_model = get_recommended_model(LocalModelUseCase::Fast);
483        assert_eq!(fast_model.embedding_dimension, 384);
484
485        let quality_model = get_recommended_model(LocalModelUseCase::Quality);
486        assert_eq!(quality_model.embedding_dimension, 768);
487
488        let multilingual_model = get_recommended_model(LocalModelUseCase::Multilingual);
489        assert_eq!(multilingual_model.embedding_dimension, 384);
490    }
491
492    #[tokio::test]
493    async fn test_production_warning_behavior() {
494        let config = LocalConfig::new("test-model", 384);
495
496        // This should emit a warning if not in test mode
497        let provider = LocalEmbeddingProvider::new(config).await.unwrap();
498
499        // Verify the provider works but may be using mock embeddings
500        let embedding1 = provider.embed_text("test").await.unwrap();
501        let embedding2 = provider.embed_text("test").await.unwrap();
502
503        // In test mode, embeddings should be deterministic (same)
504        assert_eq!(embedding1, embedding2);
505        assert_eq!(embedding1.len(), 384);
506    }
507}