Skip to main content

semantic_memory/
embedder.rs

1//! Embedding trait and implementations.
2//!
3//! Provides the [`Embedder`] trait for text-to-vector conversion,
4//! with [`CandleEmbedder`] (default, in-process pure-Rust), [`OllamaEmbedder`]
5//! (external Ollama server), and [`MockEmbedder`] (testing).
6
7use crate::config::EmbeddingConfig;
8use crate::error::MemoryError;
9use std::future::Future;
10use std::hash::{Hash, Hasher};
11use std::pin::Pin;
12
13/// Boxed future type alias for single embedding results.
14pub type EmbedFuture<'a> = Pin<Box<dyn Future<Output = Result<Vec<f32>, MemoryError>> + Send + 'a>>;
15
16/// Boxed future type alias for batch embedding results.
17pub type EmbedBatchFuture<'a> =
18    Pin<Box<dyn Future<Output = Result<Vec<Vec<f32>>, MemoryError>> + Send + 'a>>;
19
20/// Trait for embedding text into vectors.
21///
22/// Implement this to swap embedding providers.
23pub trait Embedder: Send + Sync {
24    /// Embed a single text. Returns a vector of f32.
25    fn embed<'a>(&'a self, text: &'a str) -> EmbedFuture<'a>;
26
27    /// Embed multiple texts in a batch.
28    ///
29    /// Takes owned strings to avoid lifetime issues across async boundaries.
30    fn embed_batch<'a>(&'a self, texts: Vec<String>) -> EmbedBatchFuture<'a>;
31
32    /// The model name this embedder uses.
33    fn model_name(&self) -> &str;
34
35    /// Expected embedding dimensions.
36    fn dimensions(&self) -> usize;
37}
38
39// ─── OllamaEmbedder ─────────────────────────────────────────────
40
41/// Embedding provider that calls Ollama's `/api/embed` endpoint.
42pub struct OllamaEmbedder {
43    client: reqwest::Client,
44    base_url: String,
45    model: String,
46    dimensions: usize,
47    batch_size: usize,
48}
49
50impl OllamaEmbedder {
51    /// Create a new OllamaEmbedder from config.
52    ///
53    /// Returns an error if the HTTP client cannot be constructed (e.g. TLS backend
54    /// is unavailable).
55    pub fn try_new(config: &EmbeddingConfig) -> Result<Self, MemoryError> {
56        let client = reqwest::Client::builder()
57            .timeout(std::time::Duration::from_secs(config.timeout_secs))
58            .build()
59            .map_err(|e| {
60                MemoryError::EmbedderUnavailable(format!("failed to build HTTP client: {e}"))
61            })?;
62
63        Ok(Self {
64            client,
65            base_url: config.ollama_url.trim_end_matches('/').to_string(),
66            model: config.model.clone(),
67            dimensions: config.dimensions,
68            batch_size: config.batch_size,
69        })
70    }
71
72    // GOV-005: Deprecated `new()` method removed — all consumers should use `try_new()`.
73}
74
75impl Embedder for OllamaEmbedder {
76    fn embed<'a>(&'a self, text: &'a str) -> EmbedFuture<'a> {
77        Box::pin(async move {
78            let mut results = self.embed_batch(vec![text.to_string()]).await?;
79            results.pop().ok_or_else(|| {
80                MemoryError::Other("Ollama returned empty embeddings for single text".to_string())
81            })
82        })
83    }
84
85    fn embed_batch<'a>(&'a self, texts: Vec<String>) -> EmbedBatchFuture<'a> {
86        Box::pin(async move {
87            let mut all_embeddings = Vec::with_capacity(texts.len());
88
89            for batch in texts.chunks(self.batch_size) {
90                let input: Vec<&str> = batch.iter().map(|s| s.as_str()).collect();
91                let body = serde_json::json!({
92                    "model": self.model,
93                    "input": input
94                });
95
96                let url = format!("{}/api/embed", self.base_url);
97                let response = self
98                    .client
99                    .post(&url)
100                    .json(&body)
101                    .send()
102                    .await
103                    .map_err(|e| {
104                        if e.is_connect() {
105                            MemoryError::EmbedderUnavailable(format!(
106                                "Ollama not running at {}",
107                                self.base_url
108                            ))
109                        } else if e.is_timeout() {
110                            MemoryError::EmbedderUnavailable(format!(
111                                "Ollama embedding timed out: {}",
112                                e
113                            ))
114                        } else {
115                            MemoryError::EmbeddingRequest(e)
116                        }
117                    })?;
118
119                if response.status() == reqwest::StatusCode::NOT_FOUND {
120                    return Err(MemoryError::EmbedderUnavailable(format!(
121                        "Model '{}' not available in Ollama. Run: ollama pull {}",
122                        self.model, self.model
123                    )));
124                }
125
126                if !response.status().is_success() {
127                    let status = response.status();
128                    let body = response
129                        .text()
130                        .await
131                        .map_err(|err| format!("failed to read Ollama error body: {err}"));
132                    return Err(format_ollama_http_error(status, body));
133                }
134
135                let resp_body: serde_json::Value = response.json().await?;
136                let batch_embeddings = parse_embedding_response(&resp_body, self.dimensions)?;
137                all_embeddings.extend(batch_embeddings);
138            }
139
140            Ok(all_embeddings)
141        })
142    }
143
144    fn model_name(&self) -> &str {
145        &self.model
146    }
147
148    fn dimensions(&self) -> usize {
149        self.dimensions
150    }
151}
152
153#[doc(hidden)]
154pub fn format_ollama_http_error(
155    status: reqwest::StatusCode,
156    body: Result<String, String>,
157) -> MemoryError {
158    match body {
159        Ok(body) => MemoryError::Other(format!(
160            "Ollama returned HTTP {}: {}",
161            status,
162            &body[..body.len().min(500)]
163        )),
164        Err(err) => MemoryError::Other(format!("Ollama returned HTTP {status}; {err}")),
165    }
166}
167
168/// Parse an Ollama embedding response body into vectors.
169///
170/// Validates that all values are numeric and dimensions match.
171#[doc(hidden)]
172pub fn parse_embedding_response(
173    body: &serde_json::Value,
174    expected_dims: usize,
175) -> Result<Vec<Vec<f32>>, MemoryError> {
176    let embeddings = body["embeddings"].as_array().ok_or_else(|| {
177        MemoryError::Other("Ollama response missing 'embeddings' field".to_string())
178    })?;
179
180    let mut result = Vec::with_capacity(embeddings.len());
181    for embedding_val in embeddings {
182        let raw_array = embedding_val
183            .as_array()
184            .ok_or_else(|| MemoryError::Other("Embedding is not an array".to_string()))?;
185
186        let mut embedding = Vec::with_capacity(raw_array.len());
187        for (i, v) in raw_array.iter().enumerate() {
188            let val = v.as_f64().ok_or_else(|| {
189                MemoryError::Other(format!(
190                    "Embedding dimension {} contains non-numeric value: {}",
191                    i, v
192                ))
193            })?;
194            embedding.push(val as f32);
195        }
196
197        if embedding.len() != expected_dims {
198            return Err(MemoryError::DimensionMismatch {
199                expected: expected_dims,
200                actual: embedding.len(),
201            });
202        }
203
204        result.push(embedding);
205    }
206
207    Ok(result)
208}
209
210// ─── MockEmbedder ────────────────────────────────────────────────
211
212/// Deterministic embedder for unit tests.
213///
214/// Generates consistent embeddings based on a hash of the input text.
215/// Same text always produces the same embedding. Output is normalized.
216pub struct MockEmbedder {
217    dimensions: usize,
218}
219
220impl MockEmbedder {
221    /// Create a new MockEmbedder with the given dimensions.
222    pub fn new(dimensions: usize) -> Self {
223        Self { dimensions }
224    }
225}
226
227impl Embedder for MockEmbedder {
228    fn embed<'a>(&'a self, text: &'a str) -> EmbedFuture<'a> {
229        let embedding = deterministic_embedding(text, self.dimensions);
230        Box::pin(async move { Ok(embedding) })
231    }
232
233    fn embed_batch<'a>(&'a self, texts: Vec<String>) -> EmbedBatchFuture<'a> {
234        let embeddings: Vec<Vec<f32>> = texts
235            .iter()
236            .map(|t| deterministic_embedding(t, self.dimensions))
237            .collect();
238        Box::pin(async move { Ok(embeddings) })
239    }
240
241    fn model_name(&self) -> &str {
242        "mock-embedder"
243    }
244
245    fn dimensions(&self) -> usize {
246        self.dimensions
247    }
248}
249
250/// Generate a deterministic embedding from text using a hash-seeded xorshift RNG.
251fn deterministic_embedding(text: &str, dimensions: usize) -> Vec<f32> {
252    let mut hasher = std::hash::DefaultHasher::new();
253    text.hash(&mut hasher);
254    let mut state = hasher.finish();
255    if state == 0 {
256        state = 1;
257    }
258
259    let mut values = Vec::with_capacity(dimensions);
260    for _ in 0..dimensions {
261        // xorshift64
262        state ^= state << 13;
263        state ^= state >> 7;
264        state ^= state << 17;
265        let val = ((state as f64) / (u64::MAX as f64)) * 2.0 - 1.0;
266        values.push(val as f32);
267    }
268
269    // Normalize to unit length
270    let magnitude: f32 = values.iter().map(|v| v * v).sum::<f32>().sqrt();
271    if magnitude > 0.0 {
272        for v in &mut values {
273            *v /= magnitude;
274        }
275    }
276
277    values
278}
279
280// ─── CandleEmbedder ─────────────────────────────────────────────
281
282/// In-process embedder using Candle (pure-Rust ML framework, CPU-only).
283///
284/// Downloads the model from HuggingFace Hub on first use (cached in
285/// `~/.cache/huggingface/hub`). No external process or server required.
286///
287/// Default model: `nomic-ai/nomic-embed-text-v1.5` (768 dimensions).
288/// The model matches the Ollama `nomic-embed-text` embedding, so an
289/// existing HNSW index built with Ollama's nomic-embed-text is compatible.
290#[cfg(feature = "candle-embedder")]
291pub struct CandleEmbedder {
292    model: candle_transformers::models::nomic_bert::NomicBertModel,
293    tokenizer: tokenizers::Tokenizer,
294    device: candle_core::Device,
295    model_id: String,
296    dimensions: usize,
297    max_seq_len: usize,
298}
299
300#[cfg(feature = "candle-embedder")]
301impl CandleEmbedder {
302    /// Create a new CandleEmbedder with the default model (nomic-embed-text-v1.5, 768d).
303    pub fn try_new(config: &EmbeddingConfig) -> Result<Self, MemoryError> {
304        Self::try_new_with_model(
305            "nomic-ai/nomic-embed-text-v1.5",
306            config,
307        )
308    }
309
310    /// Create a CandleEmbedder with a specific HuggingFace model ID.
311    ///
312    /// The model must be a NomicBert architecture (nomic-embed-text-v1.5).
313    /// For other architectures (BERT, MiniLM), use a different model loader.
314    pub fn try_new_with_model(model_id: &str, config: &EmbeddingConfig) -> Result<Self, MemoryError> {
315        let device = candle_core::Device::Cpu;
316        let dimensions = config.dimensions;
317        let max_seq_len = 8192; // nomic-embed-text supports up to 8192 tokens
318
319        // Download model files from HuggingFace Hub (cached after first download).
320        // We download individual files rather than a snapshot to keep the API
321        // simple and avoid pulling unnecessary files.
322        let (owner, name) = match model_id.split_once('/') {
323            Some((o, n)) => (o, n),
324            None => ("nomic-ai", model_id),
325        };
326
327        let api = hf_hub::HFClientSync::new().map_err(|e| {
328            MemoryError::EmbedderUnavailable(format!("failed to create HF Hub client: {e}"))
329        })?;
330        let repo = api.model(owner, name);
331
332        // Download required files. These are cached by hf-hub after first download.
333        let config_path = download_hf_file(&repo, "config.json")?;
334        let tokenizer_path = download_hf_file(&repo, "tokenizer.json")?;
335
336        // Try safetensors first, fall back to pytorch_model.bin.
337        let weights_path = download_hf_file(&repo, "model.safetensors")
338            .or_else(|_| download_hf_file(&repo, "pytorch_model.bin"))?;
339
340        // Load tokenizer.
341        let tokenizer = tokenizers::Tokenizer::from_file(&tokenizer_path).map_err(|e| {
342            MemoryError::EmbedderUnavailable(format!("failed to load tokenizer from {}: {e}", tokenizer_path.display()))
343        })?;
344
345        // Load model config.
346        let config_str = std::fs::read_to_string(&config_path).map_err(|e| {
347            MemoryError::EmbedderUnavailable(format!("failed to read config.json: {e}"))
348        })?;
349        let model_config: candle_transformers::models::nomic_bert::Config =
350            serde_json::from_str(&config_str).map_err(|e| {
351                MemoryError::EmbedderUnavailable(format!("failed to parse model config: {e}"))
352            })?;
353
354        // Verify dimensions match.
355        if model_config.n_embd != dimensions {
356            return Err(MemoryError::DimensionMismatch {
357                expected: dimensions,
358                actual: model_config.n_embd,
359            });
360        }
361
362        // Load model weights via mmap.
363        let dtype = candle_core::DType::F32;
364        // Read the safetensors file into memory and load from buffer.
365        // This avoids the unsafe mmap API (workspace lints deny unsafe_code).
366        let weights_bytes = std::fs::read(&weights_path).map_err(|e| {
367            MemoryError::EmbedderUnavailable(format!("failed to read weights file {}: {e}", weights_path.display()))
368        })?;
369        let vb = candle_nn::VarBuilder::from_buffered_safetensors(weights_bytes, dtype, &device)
370            .map_err(|e| {
371                MemoryError::EmbedderUnavailable(format!("failed to load model weights: {e}"))
372            })?;
373
374        let model = candle_transformers::models::nomic_bert::NomicBertModel::load(vb, &model_config)
375            .map_err(|e| {
376                MemoryError::EmbedderUnavailable(format!("failed to build NomicBert model: {e}"))
377            })?;
378
379        Ok(Self {
380            model,
381            tokenizer,
382            device,
383            model_id: model_id.to_string(),
384            dimensions,
385            max_seq_len,
386        })
387    }
388
389    /// Tokenize and embed a batch of texts, returning f32 vectors.
390    fn embed_batch_sync(&self, texts: &[String]) -> Result<Vec<Vec<f32>>, MemoryError> {
391        use candle_core::Tensor;
392        use candle_transformers::models::nomic_bert::{mean_pooling, l2_normalize};
393
394        let mut all_embeddings = Vec::with_capacity(texts.len());
395
396        // Process one at a time to keep memory bounded (CPU-only).
397        for text in texts {
398            // nomic-embed-text expects a task prefix. For storing/searching
399            // documents, the prefix is "search_document:". For queries, it's
400            // "search_query:". Since we store facts (documents) and search
401            // with queries, we need to prefix appropriately.
402            // However, since both add and search go through this same embedder,
403            // and nomic-embed-text-v1.5 is designed for asymmetric search, we
404            // use the same "search_document:" prefix for everything to keep
405            // it simple. The quality difference is minor for a knowledge base
406            // that stores and retrieves facts.
407            let prefixed = format!("search_document: {text}");
408
409            let encoding = self.tokenizer.encode(prefixed.as_str(), true).map_err(|e| {
410                MemoryError::Other(format!("tokenizer error: {e}"))
411            })?;
412
413            let input_ids = encoding.get_ids();
414            let attention_mask = encoding.get_attention_mask();
415
416            // Truncate to max_seq_len.
417            let seq_len = input_ids.len().min(self.max_seq_len);
418            let input_ids = &input_ids[..seq_len];
419            let attention_mask = &attention_mask[..seq_len];
420
421            let input_ids_tensor = Tensor::new(input_ids, &self.device)?
422                .unsqueeze(0)?; // (1, seq_len)
423            let attention_mask_tensor = Tensor::new(attention_mask, &self.device)?
424                .unsqueeze(0)?; // (1, seq_len)
425
426            // Run forward pass — NomicBertModel doesn't need token_type_ids
427            // (it uses rotary embeddings, and type_vocab_size may be 0).
428            let token_type_ids = input_ids_tensor.zeros_like()?;
429            let hidden_states = self.model.forward(
430                &input_ids_tensor,
431                Some(&token_type_ids),
432                Some(&attention_mask_tensor),
433            )?;
434
435            // Mean-pool using attention mask, then L2-normalize.
436            let pooled = mean_pooling(&hidden_states, &attention_mask_tensor)?;
437            let normalized = l2_normalize(&pooled)?;
438
439            // Extract the single embedding (batch size 1).
440            let embedding_vec = normalized.to_vec2::<f32>()?;
441            let embedding = embedding_vec.into_iter().next().ok_or_else(|| {
442                MemoryError::Other("model returned empty embedding".to_string())
443            })?;
444
445            if embedding.len() != self.dimensions {
446                return Err(MemoryError::DimensionMismatch {
447                    expected: self.dimensions,
448                    actual: embedding.len(),
449                });
450            }
451
452            all_embeddings.push(embedding);
453        }
454
455        Ok(all_embeddings)
456    }
457}
458
459/// Download a single file from a HuggingFace repo, returning the local path.
460/// The file is cached by hf-hub after the first download.
461#[cfg(feature = "candle-embedder")]
462fn download_hf_file(
463    repo: &hf_hub::HFRepositorySync<hf_hub::repository::RepoTypeModel>,
464    filename: &str,
465) -> Result<std::path::PathBuf, MemoryError> {
466    repo.download_file()
467        .filename(filename.to_string())
468        .send()
469        .map_err(|e| {
470            MemoryError::EmbedderUnavailable(format!("failed to download '{filename}': {e}"))
471        })
472}
473
474#[cfg(feature = "candle-embedder")]
475impl Embedder for CandleEmbedder {
476    fn embed<'a>(&'a self, text: &'a str) -> EmbedFuture<'a> {
477        let result = self.embed_batch_sync(&[text.to_string()]);
478        Box::pin(async move {
479            let mut results = result?;
480            results.pop().ok_or_else(|| {
481                MemoryError::Other("Candle embedder returned empty results".to_string())
482            })
483        })
484    }
485
486    fn embed_batch<'a>(&'a self, texts: Vec<String>) -> EmbedBatchFuture<'a> {
487        let result = self.embed_batch_sync(&texts);
488        Box::pin(async move { result })
489    }
490
491    fn model_name(&self) -> &str {
492        &self.model_id
493    }
494
495    fn dimensions(&self) -> usize {
496        self.dimensions
497    }
498}