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// ─── Multi-Function Embedding (BGE-M3) ───────────────────────────
281
282/// Boxed future type alias for single multi-function embedding results.
283pub type MultiEmbedFuture<'a> =
284    Pin<Box<dyn Future<Output = Result<MultiFunctionEmbedding, MemoryError>> + Send + 'a>>;
285
286/// Boxed future type alias for batch multi-function embedding results.
287pub type MultiEmbedBatchFuture<'a> =
288    Pin<Box<dyn Future<Output = Result<Vec<MultiFunctionEmbedding>, MemoryError>> + Send + 'a>>;
289
290/// Sparse lexical weight representation (SPLADE-like).
291///
292/// Stores non-zero (dimension_index, weight) pairs, sorted by descending
293/// absolute weight. Used for sparse retrieval ranking.
294#[derive(Debug, Clone, PartialEq)]
295pub struct SparseWeights {
296    /// (index, weight) pairs, sorted by descending absolute weight.
297    pub entries: Vec<(usize, f32)>,
298}
299
300impl SparseWeights {
301    /// Build a sparse representation from a dense vector by keeping the top-k
302    /// dimensions with the largest absolute values, filtered by a minimum threshold.
303    ///
304    /// This is a pragmatic derivation from the dense embedding. When a native
305    /// SPLADE-style sparse output is available from the model, use
306    /// [`SparseWeights::from_entries`] instead.
307    #[must_use]
308    pub fn from_dense(vec: &[f32], top_k: usize, min_weight: f32) -> Self {
309        let mut entries: Vec<(usize, f32)> = vec
310            .iter()
311            .enumerate()
312            .map(|(i, &v)| (i, v))
313            .filter(|(_, v)| v.abs() >= min_weight)
314            .collect();
315        entries.sort_by(|a, b| {
316            b.1.abs()
317                .partial_cmp(&a.1.abs())
318                .unwrap_or(std::cmp::Ordering::Equal)
319        });
320        entries.truncate(top_k);
321        Self { entries }
322    }
323
324    /// Build directly from pre-computed (index, weight) entries.
325    #[must_use]
326    pub fn from_entries(mut entries: Vec<(usize, f32)>) -> Self {
327        entries.sort_by(|a, b| {
328            b.1.abs()
329                .partial_cmp(&a.1.abs())
330                .unwrap_or(std::cmp::Ordering::Equal)
331        });
332        Self { entries }
333    }
334
335    /// Number of non-zero entries.
336    pub fn len(&self) -> usize {
337        self.entries.len()
338    }
339
340    /// Whether the sparse vector is empty.
341    pub fn is_empty(&self) -> bool {
342        self.entries.is_empty()
343    }
344
345    /// Dot product between two sparse vectors.
346    ///
347    /// Efficiently computed by intersecting the sorted-by-index entries.
348    /// Since entries are sorted by weight (not index), we use a HashMap-based
349    /// merge for correctness.
350    pub fn dot(&self, other: &SparseWeights) -> f32 {
351        use std::collections::HashMap;
352        let map: HashMap<usize, f32> = other.entries.iter().copied().collect();
353        self.entries
354            .iter()
355            .map(|(idx, w)| w * map.get(idx).copied().unwrap_or(0.0))
356            .sum()
357    }
358}
359
360/// ColBERT-style multi-vector representation (per-token embeddings).
361///
362/// Each inner vector is the embedding of a single token. Used for late
363/// interaction scoring via MaxSim.
364#[derive(Debug, Clone, PartialEq)]
365pub struct MultiVectorEmbedding {
366    /// Per-token embedding vectors.
367    pub token_vectors: Vec<Vec<f32>>,
368}
369
370impl MultiVectorEmbedding {
371    /// Build a multi-vector representation by chunking a dense vector into
372    /// `num_tokens` roughly-equal sub-vectors.
373    ///
374    /// Each sub-vector acts as a pseudo per-token embedding. When native
375    /// per-token embeddings are available from the model, use
376    /// [`MultiVectorEmbedding::from_token_vectors`] instead.
377    #[must_use]
378    pub fn from_dense_chunked(vec: &[f32], num_tokens: usize) -> Self {
379        if vec.is_empty() || num_tokens == 0 {
380            return Self {
381                token_vectors: Vec::new(),
382            };
383        }
384        let chunk_size = (vec.len() + num_tokens - 1) / num_tokens; // ceil div
385        let token_vectors = vec
386            .chunks(chunk_size)
387            .map(|chunk| {
388                // Pad last chunk to full dimensionality for uniform length.
389                let mut v = chunk.to_vec();
390                v.resize(chunk_size, 0.0);
391                v
392            })
393            .collect();
394        Self { token_vectors }
395    }
396
397    /// Build directly from pre-computed per-token embedding vectors.
398    #[must_use]
399    pub fn from_token_vectors(token_vectors: Vec<Vec<f32>>) -> Self {
400        Self { token_vectors }
401    }
402
403    /// Number of token vectors.
404    pub fn len(&self) -> usize {
405        self.token_vectors.len()
406    }
407
408    /// Whether the multi-vector is empty.
409    pub fn is_empty(&self) -> bool {
410        self.token_vectors.is_empty()
411    }
412}
413
414/// Result of a single multi-function embedding call containing all three
415/// representations produced from one model invocation.
416#[derive(Debug, Clone)]
417pub struct MultiFunctionEmbedding {
418    /// Dense vector (standard embedding for dense retrieval).
419    pub dense: Vec<f32>,
420    /// Sparse lexical weights (SPLADE-like for sparse retrieval).
421    pub sparse: SparseWeights,
422    /// ColBERT-style multi-vector (per-token embeddings for late interaction).
423    pub multi_vec: MultiVectorEmbedding,
424}
425
426/// Trait for embedders that produce multiple representations from a single
427/// model call: dense, sparse, and multi-vector.
428///
429/// This is the BGE-M3 style multi-function interface. Implementations produce
430/// all three representations so that downstream retrieval can fuse them via
431/// Reciprocal Rank Fusion (RRF) or other methods.
432pub trait MultiFunctionEmbedder: Send + Sync {
433    /// Embed a single text, returning dense, sparse, and multi-vec representations.
434    fn embed_multi<'a>(&'a self, text: &'a str) -> MultiEmbedFuture<'a>;
435
436    /// Embed multiple texts in a batch, returning all three representations per text.
437    ///
438    /// Takes owned strings to avoid lifetime issues across async boundaries.
439    fn embed_batch_multi<'a>(&'a self, texts: Vec<String>) -> MultiEmbedBatchFuture<'a>;
440
441    /// The model name this embedder uses.
442    fn model_name(&self) -> &str;
443
444    /// Expected dense embedding dimensions.
445    fn dimensions(&self) -> usize;
446}
447
448/// Configuration for deriving sparse and multi-vec representations from dense
449/// embeddings when the backend (Ollama) does not natively expose them.
450#[derive(Debug, Clone)]
451pub struct BgeM3DeriveConfig {
452    /// Number of top dimensions to keep for sparse representation.
453    pub sparse_top_k: usize,
454    /// Minimum absolute weight threshold for sparse entries.
455    pub sparse_min_weight: f32,
456    /// Number of pseudo per-token vectors for multi-vec representation.
457    pub num_multi_vec_tokens: usize,
458}
459
460impl Default for BgeM3DeriveConfig {
461    fn default() -> Self {
462        Self {
463            sparse_top_k: 128,
464            sparse_min_weight: 0.01,
465            num_multi_vec_tokens: 32,
466        }
467    }
468}
469
470/// BGE-M3 multi-function embedder via Ollama.
471///
472/// Produces three representations from a single Ollama model call:
473/// 1. **Dense vector** — direct from Ollama's `/api/embed` endpoint.
474/// 2. **Sparse lexical weights** — derived from the dense vector via top-k
475///    thresholding (mimicking SPLADE-style sparse activation).
476/// 3. **ColBERT-style multi-vector** — dense vector chunked into pseudo
477///    per-token embeddings for late interaction scoring.
478///
479/// # Current Limitations
480///
481/// Ollama's embedding API (`/api/embed`) currently returns only dense vectors.
482/// The sparse and multi-vec representations are derived from the dense output
483/// as a pragmatic approximation. When Ollama (or another backend) exposes
484/// native BGE-M3 sparse and per-token outputs, this implementation can be
485/// updated to use them directly by replacing the derivation methods.
486///
487/// The model `bge-m3` produces 1024-dimensional dense embeddings.
488pub struct BgeM3Embedder {
489    client: reqwest::Client,
490    base_url: String,
491    model: String,
492    dimensions: usize,
493    batch_size: usize,
494    derive_config: BgeM3DeriveConfig,
495}
496
497impl BgeM3Embedder {
498    /// Create a new BgeM3Embedder from embedding config.
499    ///
500    /// Uses `bge-m3` as the model name with 1024 dimensions by default.
501    /// The Ollama URL and timeout are taken from the config.
502    pub fn try_new(config: &EmbeddingConfig) -> Result<Self, MemoryError> {
503        Self::try_new_with_derive(config, BgeM3DeriveConfig::default())
504    }
505
506    /// Create a new BgeM3Embedder with custom derivation parameters.
507    pub fn try_new_with_derive(
508        config: &EmbeddingConfig,
509        derive_config: BgeM3DeriveConfig,
510    ) -> Result<Self, MemoryError> {
511        let client = reqwest::Client::builder()
512            .timeout(std::time::Duration::from_secs(config.timeout_secs))
513            .build()
514            .map_err(|e| {
515                MemoryError::EmbedderUnavailable(format!("failed to build HTTP client: {e}"))
516            })?;
517
518        Ok(Self {
519            client,
520            base_url: config.ollama_url.trim_end_matches('/').to_string(),
521            model: config.model.clone(),
522            dimensions: config.dimensions,
523            batch_size: config.batch_size,
524            derive_config,
525        })
526    }
527
528    /// Create a BgeM3Embedder with explicit parameters (useful for testing
529    /// without an `EmbeddingConfig`).
530    pub fn with_params(
531        base_url: &str,
532        model: &str,
533        dimensions: usize,
534        batch_size: usize,
535        timeout_secs: u64,
536        derive_config: BgeM3DeriveConfig,
537    ) -> Result<Self, MemoryError> {
538        let client = reqwest::Client::builder()
539            .timeout(std::time::Duration::from_secs(timeout_secs))
540            .build()
541            .map_err(|e| {
542                MemoryError::EmbedderUnavailable(format!("failed to build HTTP client: {e}"))
543            })?;
544
545        Ok(Self {
546            client,
547            base_url: base_url.trim_end_matches('/').to_string(),
548            model: model.to_string(),
549            dimensions,
550            batch_size,
551            derive_config,
552        })
553    }
554
555    /// Derive all three representations from a batch of dense embeddings.
556    fn derive_multi_function(
557        &self,
558        dense_embeddings: Vec<Vec<f32>>,
559    ) -> Vec<MultiFunctionEmbedding> {
560        dense_embeddings
561            .into_iter()
562            .map(|dense| {
563                let sparse = SparseWeights::from_dense(
564                    &dense,
565                    self.derive_config.sparse_top_k,
566                    self.derive_config.sparse_min_weight,
567                );
568                let multi_vec = MultiVectorEmbedding::from_dense_chunked(
569                    &dense,
570                    self.derive_config.num_multi_vec_tokens,
571                );
572                MultiFunctionEmbedding {
573                    dense,
574                    sparse,
575                    multi_vec,
576                }
577            })
578            .collect()
579    }
580
581    /// Fetch raw dense embeddings from Ollama for a batch of texts.
582    async fn fetch_dense(&self, texts: &[String]) -> Result<Vec<Vec<f32>>, MemoryError> {
583        let mut all_embeddings = Vec::with_capacity(texts.len());
584
585        for batch in texts.chunks(self.batch_size) {
586            let input: Vec<&str> = batch.iter().map(|s| s.as_str()).collect();
587            let body = serde_json::json!({
588                "model": self.model,
589                "input": input
590            });
591
592            let url = format!("{}/api/embed", self.base_url);
593            let response = self
594                .client
595                .post(&url)
596                .json(&body)
597                .send()
598                .await
599                .map_err(|e| {
600                    if e.is_connect() {
601                        MemoryError::EmbedderUnavailable(format!(
602                            "Ollama not running at {}",
603                            self.base_url
604                        ))
605                    } else if e.is_timeout() {
606                        MemoryError::EmbedderUnavailable(format!(
607                            "Ollama embedding timed out: {}",
608                            e
609                        ))
610                    } else {
611                        MemoryError::EmbeddingRequest(e)
612                    }
613                })?;
614
615            if response.status() == reqwest::StatusCode::NOT_FOUND {
616                return Err(MemoryError::EmbedderUnavailable(format!(
617                    "Model '{}' not available in Ollama. Run: ollama pull {}",
618                    self.model, self.model
619                )));
620            }
621
622            if !response.status().is_success() {
623                let status = response.status();
624                let body = response
625                    .text()
626                    .await
627                    .map_err(|err| format!("failed to read Ollama error body: {err}"));
628                return Err(format_ollama_http_error(status, body));
629            }
630
631            let resp_body: serde_json::Value = response.json().await?;
632            let batch_embeddings = parse_embedding_response(&resp_body, self.dimensions)?;
633            all_embeddings.extend(batch_embeddings);
634        }
635
636        Ok(all_embeddings)
637    }
638}
639
640impl MultiFunctionEmbedder for BgeM3Embedder {
641    fn embed_multi<'a>(&'a self, text: &'a str) -> MultiEmbedFuture<'a> {
642        Box::pin(async move {
643            let mut results = self
644                .fetch_dense(&[text.to_string()])
645                .await?;
646            let dense = results.pop().ok_or_else(|| {
647                MemoryError::Other("Ollama returned empty embeddings for single text".to_string())
648            })?;
649            let sparse = SparseWeights::from_dense(
650                &dense,
651                self.derive_config.sparse_top_k,
652                self.derive_config.sparse_min_weight,
653            );
654            let multi_vec = MultiVectorEmbedding::from_dense_chunked(
655                &dense,
656                self.derive_config.num_multi_vec_tokens,
657            );
658            Ok(MultiFunctionEmbedding {
659                dense,
660                sparse,
661                multi_vec,
662            })
663        })
664    }
665
666    fn embed_batch_multi<'a>(&'a self, texts: Vec<String>) -> MultiEmbedBatchFuture<'a> {
667        Box::pin(async move {
668            let dense_embeddings = self.fetch_dense(&texts).await?;
669            Ok(self.derive_multi_function(dense_embeddings))
670        })
671    }
672
673    fn model_name(&self) -> &str {
674        &self.model
675    }
676
677    fn dimensions(&self) -> usize {
678        self.dimensions
679    }
680}
681
682/// BgeM3Embedder also implements the standard `Embedder` trait for compatibility,
683/// returning only the dense representation.
684impl Embedder for BgeM3Embedder {
685    fn embed<'a>(&'a self, text: &'a str) -> EmbedFuture<'a> {
686        Box::pin(async move {
687            let mut results = self.fetch_dense(&[text.to_string()]).await?;
688            results.pop().ok_or_else(|| {
689                MemoryError::Other("Ollama returned empty embeddings for single text".to_string())
690            })
691        })
692    }
693
694    fn embed_batch<'a>(&'a self, texts: Vec<String>) -> EmbedBatchFuture<'a> {
695        Box::pin(async move { self.fetch_dense(&texts).await })
696    }
697
698    fn model_name(&self) -> &str {
699        &self.model
700    }
701
702    fn dimensions(&self) -> usize {
703        self.dimensions
704    }
705}
706
707// ─── CandleEmbedder ─────────────────────────────────────────────
708
709/// In-process embedder using Candle (pure-Rust ML framework, CPU-only).
710///
711/// Downloads the model from HuggingFace Hub on first use (cached in
712/// `~/.cache/huggingface/hub`). No external process or server required.
713///
714/// Default model: `nomic-ai/nomic-embed-text-v1.5` (768 dimensions).
715/// The model matches the Ollama `nomic-embed-text` embedding, so an
716/// existing HNSW index built with Ollama's nomic-embed-text is compatible.
717#[cfg(feature = "candle-embedder")]
718pub struct CandleEmbedder {
719    model: candle_transformers::models::nomic_bert::NomicBertModel,
720    tokenizer: tokenizers::Tokenizer,
721    device: candle_core::Device,
722    model_id: String,
723    dimensions: usize,
724    max_seq_len: usize,
725}
726
727#[cfg(feature = "candle-embedder")]
728impl CandleEmbedder {
729    /// Create a new CandleEmbedder with the default model (nomic-embed-text-v1.5, 768d).
730    pub fn try_new(config: &EmbeddingConfig) -> Result<Self, MemoryError> {
731        Self::try_new_with_model(
732            "nomic-ai/nomic-embed-text-v1.5",
733            config,
734        )
735    }
736
737    /// Create a CandleEmbedder with a specific HuggingFace model ID.
738    ///
739    /// The model must be a NomicBert architecture (nomic-embed-text-v1.5).
740    /// For other architectures (BERT, MiniLM), use a different model loader.
741    pub fn try_new_with_model(model_id: &str, config: &EmbeddingConfig) -> Result<Self, MemoryError> {
742        let device = candle_core::Device::Cpu;
743        let dimensions = config.dimensions;
744        let max_seq_len = 8192; // nomic-embed-text supports up to 8192 tokens
745
746        // Download model files from HuggingFace Hub (cached after first download).
747        // We download individual files rather than a snapshot to keep the API
748        // simple and avoid pulling unnecessary files.
749        let (owner, name) = match model_id.split_once('/') {
750            Some((o, n)) => (o, n),
751            None => ("nomic-ai", model_id),
752        };
753
754        let api = hf_hub::HFClientSync::new().map_err(|e| {
755            MemoryError::EmbedderUnavailable(format!("failed to create HF Hub client: {e}"))
756        })?;
757        let repo = api.model(owner, name);
758
759        // Download required files. These are cached by hf-hub after first download.
760        let config_path = download_hf_file(&repo, "config.json")?;
761        let tokenizer_path = download_hf_file(&repo, "tokenizer.json")?;
762
763        // Try safetensors first, fall back to pytorch_model.bin.
764        let weights_path = download_hf_file(&repo, "model.safetensors")
765            .or_else(|_| download_hf_file(&repo, "pytorch_model.bin"))?;
766
767        // Load tokenizer.
768        let tokenizer = tokenizers::Tokenizer::from_file(&tokenizer_path).map_err(|e| {
769            MemoryError::EmbedderUnavailable(format!("failed to load tokenizer from {}: {e}", tokenizer_path.display()))
770        })?;
771
772        // Load model config.
773        let config_str = std::fs::read_to_string(&config_path).map_err(|e| {
774            MemoryError::EmbedderUnavailable(format!("failed to read config.json: {e}"))
775        })?;
776        let model_config: candle_transformers::models::nomic_bert::Config =
777            serde_json::from_str(&config_str).map_err(|e| {
778                MemoryError::EmbedderUnavailable(format!("failed to parse model config: {e}"))
779            })?;
780
781        // Verify dimensions match.
782        if model_config.n_embd != dimensions {
783            return Err(MemoryError::DimensionMismatch {
784                expected: dimensions,
785                actual: model_config.n_embd,
786            });
787        }
788
789        // Load model weights via mmap.
790        let dtype = candle_core::DType::F32;
791        // Read the safetensors file into memory and load from buffer.
792        // This avoids the unsafe mmap API (workspace lints deny unsafe_code).
793        let weights_bytes = std::fs::read(&weights_path).map_err(|e| {
794            MemoryError::EmbedderUnavailable(format!("failed to read weights file {}: {e}", weights_path.display()))
795        })?;
796        let vb = candle_nn::VarBuilder::from_buffered_safetensors(weights_bytes, dtype, &device)
797            .map_err(|e| {
798                MemoryError::EmbedderUnavailable(format!("failed to load model weights: {e}"))
799            })?;
800
801        let model = candle_transformers::models::nomic_bert::NomicBertModel::load(vb, &model_config)
802            .map_err(|e| {
803                MemoryError::EmbedderUnavailable(format!("failed to build NomicBert model: {e}"))
804            })?;
805
806        Ok(Self {
807            model,
808            tokenizer,
809            device,
810            model_id: model_id.to_string(),
811            dimensions,
812            max_seq_len,
813        })
814    }
815
816    /// Tokenize and embed a batch of texts, returning f32 vectors.
817    fn embed_batch_sync(&self, texts: &[String], _query_mode: bool) -> Result<Vec<Vec<f32>>, MemoryError> {
818        use candle_core::Tensor;
819        use candle_transformers::models::nomic_bert::{mean_pooling, l2_normalize};
820
821        let mut all_embeddings = Vec::with_capacity(texts.len());
822
823        // NOTE: Prefix handling (search_query: / search_document:) is done at
824        // the library level in embed_text_internal / embed_batch_internal.
825        // The embedder receives already-prefixed text and should NOT add
826        // its own prefix. The _query_mode parameter is kept for backward
827        // compatibility but is ignored.
828
829        // Process one at a time to keep memory bounded (CPU-only).
830        for text in texts {
831            let prefixed: &str = text;
832
833            let encoding = self.tokenizer.encode(prefixed, true).map_err(|e| {
834                MemoryError::Other(format!("tokenizer error: {e}"))
835            })?;
836
837            let input_ids = encoding.get_ids();
838            let attention_mask = encoding.get_attention_mask();
839
840            // Truncate to max_seq_len.
841            let seq_len = input_ids.len().min(self.max_seq_len);
842            let input_ids = &input_ids[..seq_len];
843            let attention_mask = &attention_mask[..seq_len];
844
845            let input_ids_tensor = Tensor::new(input_ids, &self.device)?
846                .unsqueeze(0)?; // (1, seq_len)
847            let attention_mask_tensor = Tensor::new(attention_mask, &self.device)?
848                .unsqueeze(0)?; // (1, seq_len)
849
850            // Run forward pass — NomicBertModel doesn't need token_type_ids
851            // (it uses rotary embeddings, and type_vocab_size may be 0).
852            let token_type_ids = input_ids_tensor.zeros_like()?;
853            let hidden_states = self.model.forward(
854                &input_ids_tensor,
855                Some(&token_type_ids),
856                Some(&attention_mask_tensor),
857            )?;
858
859            // Mean-pool using attention mask, then L2-normalize.
860            let pooled = mean_pooling(&hidden_states, &attention_mask_tensor)?;
861            let normalized = l2_normalize(&pooled)?;
862
863            // Extract the single embedding (batch size 1).
864            let embedding_vec = normalized.to_vec2::<f32>()?;
865            let embedding = embedding_vec.into_iter().next().ok_or_else(|| {
866                MemoryError::Other("model returned empty embedding".to_string())
867            })?;
868
869            if embedding.len() != self.dimensions {
870                return Err(MemoryError::DimensionMismatch {
871                    expected: self.dimensions,
872                    actual: embedding.len(),
873                });
874            }
875
876            all_embeddings.push(embedding);
877        }
878
879        Ok(all_embeddings)
880    }
881}
882
883/// Download a single file from a HuggingFace repo, returning the local path.
884/// The file is cached by hf-hub after the first download.
885#[cfg(feature = "candle-embedder")]
886fn download_hf_file(
887    repo: &hf_hub::HFRepositorySync<hf_hub::repository::RepoTypeModel>,
888    filename: &str,
889) -> Result<std::path::PathBuf, MemoryError> {
890    repo.download_file()
891        .filename(filename.to_string())
892        .send()
893        .map_err(|e| {
894            MemoryError::EmbedderUnavailable(format!("failed to download '{filename}': {e}"))
895        })
896}
897
898#[cfg(feature = "candle-embedder")]
899impl Embedder for CandleEmbedder {
900    fn embed<'a>(&'a self, text: &'a str) -> EmbedFuture<'a> {
901        let result = self.embed_batch_sync(&[text.to_string()], true);
902        Box::pin(async move {
903            let mut results = result?;
904            results.pop().ok_or_else(|| {
905                MemoryError::Other("Candle embedder returned empty results".to_string())
906            })
907        })
908    }
909
910    fn embed_batch<'a>(&'a self, texts: Vec<String>) -> EmbedBatchFuture<'a> {
911        let result = self.embed_batch_sync(&texts, false);
912        Box::pin(async move { result })
913    }
914
915    fn model_name(&self) -> &str {
916        &self.model_id
917    }
918
919    fn dimensions(&self) -> usize {
920        self.dimensions
921    }
922}
923
924// ─── Tests ──────────────────────────────────────────────────────
925
926#[cfg(test)]
927mod bge_m3_tests {
928    use super::*;
929
930    // ── SparseWeights tests ──
931
932    #[test]
933    fn sparse_from_dense_keeps_top_k_by_abs_weight() {
934        let dense = vec![0.5, -0.9, 0.01, 0.8, -0.3, 0.001];
935        let sparse = SparseWeights::from_dense(&dense, 3, 0.05);
936        assert_eq!(sparse.len(), 3);
937        // Sorted by descending absolute weight: 0.9, 0.8, 0.5
938        assert_eq!(sparse.entries[0], (1, -0.9));
939        assert_eq!(sparse.entries[1], (3, 0.8));
940        assert_eq!(sparse.entries[2], (0, 0.5));
941    }
942
943    #[test]
944    fn sparse_from_dense_filters_below_threshold() {
945        let dense = vec![0.5, 0.001, 0.9, 0.002];
946        let sparse = SparseWeights::from_dense(&dense, 100, 0.05);
947        assert_eq!(sparse.len(), 2);
948        assert_eq!(sparse.entries[0], (2, 0.9));
949        assert_eq!(sparse.entries[1], (0, 0.5));
950    }
951
952    #[test]
953    fn sparse_from_dense_empty_input() {
954        let sparse = SparseWeights::from_dense(&[], 10, 0.0);
955        assert!(sparse.is_empty());
956    }
957
958    #[test]
959    fn sparse_from_dense_truncates_to_top_k() {
960        let dense = vec![1.0; 100];
961        let sparse = SparseWeights::from_dense(&dense, 10, 0.0);
962        assert_eq!(sparse.len(), 10);
963    }
964
965    #[test]
966    fn sparse_dot_product() {
967        let a = SparseWeights::from_entries(vec![(0, 1.0), (2, 2.0), (5, 3.0)]);
968        let b = SparseWeights::from_entries(vec![(0, 0.5), (2, 1.0), (3, 10.0)]);
969        // dot = 0.5 + 2.0 + 0 = 2.5
970        let result = a.dot(&b);
971        assert!((result - 2.5).abs() < 1e-6);
972    }
973
974    #[test]
975    fn sparse_dot_product_no_overlap() {
976        let a = SparseWeights::from_entries(vec![(0, 1.0), (1, 2.0)]);
977        let b = SparseWeights::from_entries(vec![(2, 3.0), (3, 4.0)]);
978        assert_eq!(a.dot(&b), 0.0);
979    }
980
981    #[test]
982    fn sparse_dot_product_self() {
983        let a = SparseWeights::from_entries(vec![(0, 3.0), (1, 4.0)]);
984        // dot = 9 + 16 = 25
985        assert!((a.dot(&a) - 25.0).abs() < 1e-6);
986    }
987
988    #[test]
989    fn sparse_from_entries_sorts_by_abs_weight() {
990        let entries = vec![(0, 0.1), (1, -0.5), (2, 0.3)];
991        let sparse = SparseWeights::from_entries(entries);
992        assert_eq!(sparse.entries[0], (1, -0.5));
993        assert_eq!(sparse.entries[1], (2, 0.3));
994        assert_eq!(sparse.entries[2], (0, 0.1));
995    }
996
997    // ── MultiVectorEmbedding tests ──
998
999    #[test]
1000    fn multi_vec_from_dense_chunked() {
1001        let dense = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
1002        let mv = MultiVectorEmbedding::from_dense_chunked(&dense, 3);
1003        assert_eq!(mv.len(), 3);
1004        // 6 / 3 = 2 per chunk
1005        assert_eq!(mv.token_vectors[0], vec![1.0, 2.0]);
1006        assert_eq!(mv.token_vectors[1], vec![3.0, 4.0]);
1007        assert_eq!(mv.token_vectors[2], vec![5.0, 6.0]);
1008    }
1009
1010    #[test]
1011    fn multi_vec_from_dense_chunked_with_padding() {
1012        let dense = vec![1.0, 2.0, 3.0, 4.0, 5.0];
1013        let mv = MultiVectorEmbedding::from_dense_chunked(&dense, 2);
1014        assert_eq!(mv.len(), 2);
1015        // chunk_size = ceil(5/2) = 3
1016        assert_eq!(mv.token_vectors[0], vec![1.0, 2.0, 3.0]);
1017        assert_eq!(mv.token_vectors[1], vec![4.0, 5.0, 0.0]); // padded
1018    }
1019
1020    #[test]
1021    fn multi_vec_from_dense_chunked_empty() {
1022        let mv = MultiVectorEmbedding::from_dense_chunked(&[], 4);
1023        assert!(mv.is_empty());
1024    }
1025
1026    #[test]
1027    fn multi_vec_from_dense_chunked_zero_tokens() {
1028        let mv = MultiVectorEmbedding::from_dense_chunked(&[1.0, 2.0], 0);
1029        assert!(mv.is_empty());
1030    }
1031
1032    #[test]
1033    fn multi_vec_from_token_vectors() {
1034        let tokens = vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![1.0, 1.0]];
1035        let mv = MultiVectorEmbedding::from_token_vectors(tokens.clone());
1036        assert_eq!(mv.len(), 3);
1037        assert_eq!(mv.token_vectors, tokens);
1038    }
1039
1040    #[test]
1041    fn multi_vec_consistent_chunk_sizes() {
1042        let dense: Vec<f32> = (0..1024).map(|i| i as f32).collect();
1043        let mv = MultiVectorEmbedding::from_dense_chunked(&dense, 32);
1044        assert_eq!(mv.len(), 32);
1045        // All token vectors should have the same length
1046        let len0 = mv.token_vectors[0].len();
1047        for tv in &mv.token_vectors {
1048            assert_eq!(tv.len(), len0);
1049        }
1050    }
1051
1052    // ── MultiFunctionEmbedding integration tests ──
1053
1054    #[test]
1055    fn multi_function_embedding_holds_all_three() {
1056        let dense = vec![0.1, 0.5, 0.9, 0.3];
1057        let sparse = SparseWeights::from_dense(&dense, 2, 0.1);
1058        let multi_vec = MultiVectorEmbedding::from_dense_chunked(&dense, 2);
1059        let mfe = MultiFunctionEmbedding {
1060            dense: dense.clone(),
1061            sparse: sparse.clone(),
1062            multi_vec: multi_vec.clone(),
1063        };
1064        assert_eq!(mfe.dense, dense);
1065        assert_eq!(mfe.sparse, sparse);
1066        assert_eq!(mfe.multi_vec, multi_vec);
1067    }
1068
1069    // ── BgeM3DeriveConfig tests ──
1070
1071    #[test]
1072    fn derive_config_default_values() {
1073        let cfg = BgeM3DeriveConfig::default();
1074        assert_eq!(cfg.sparse_top_k, 128);
1075        assert_eq!(cfg.sparse_min_weight, 0.01);
1076        assert_eq!(cfg.num_multi_vec_tokens, 32);
1077    }
1078
1079    // ── BgeM3Embedder construction tests ──
1080
1081    #[test]
1082    fn bge_m3_embedder_with_params_constructs() {
1083        let embedder = BgeM3Embedder::with_params(
1084            "http://localhost:11434/",
1085            "bge-m3",
1086            1024,
1087            32,
1088            30,
1089            BgeM3DeriveConfig::default(),
1090        );
1091        assert!(embedder.is_ok());
1092        let embedder = embedder.unwrap();
1093        assert_eq!(Embedder::model_name(&embedder), "bge-m3");
1094        assert_eq!(Embedder::dimensions(&embedder), 1024);
1095    }
1096
1097    #[test]
1098    fn bge_m3_embedder_try_new_from_config() {
1099        let config = EmbeddingConfig {
1100            ollama_url: "http://localhost:11434".to_string(),
1101            model: "bge-m3".to_string(),
1102            dimensions: 1024,
1103            batch_size: 16,
1104            timeout_secs: 60,
1105        };
1106        let embedder = BgeM3Embedder::try_new(&config);
1107        assert!(embedder.is_ok());
1108        let embedder = embedder.unwrap();
1109        assert_eq!(Embedder::model_name(&embedder), "bge-m3");
1110        assert_eq!(Embedder::dimensions(&embedder), 1024);
1111    }
1112
1113    #[test]
1114    fn bge_m3_embedder_try_new_with_custom_derive() {
1115        let config = EmbeddingConfig {
1116            ollama_url: "http://localhost:11434".to_string(),
1117            model: "bge-m3".to_string(),
1118            dimensions: 1024,
1119            batch_size: 16,
1120            timeout_secs: 60,
1121        };
1122        let derive = BgeM3DeriveConfig {
1123            sparse_top_k: 64,
1124            sparse_min_weight: 0.05,
1125            num_multi_vec_tokens: 16,
1126        };
1127        let embedder = BgeM3Embedder::try_new_with_derive(&config, derive);
1128        assert!(embedder.is_ok());
1129    }
1130
1131    // ── Derivation logic tests (no network needed) ──
1132
1133    #[test]
1134    fn derive_multi_function_produces_correct_lengths() {
1135        let embedder = BgeM3Embedder::with_params(
1136            "http://localhost:11434",
1137            "bge-m3",
1138            1024,
1139            32,
1140            30,
1141            BgeM3DeriveConfig {
1142                sparse_top_k: 64,
1143                sparse_min_weight: 0.0,
1144                num_multi_vec_tokens: 16,
1145            },
1146        )
1147        .unwrap();
1148
1149        let dense_vec: Vec<f32> = (0..1024).map(|i| (i as f32) / 1024.0).collect();
1150        let results = embedder.derive_multi_function(vec![dense_vec.clone()]);
1151        assert_eq!(results.len(), 1);
1152
1153        let mfe = &results[0];
1154        assert_eq!(mfe.dense.len(), 1024);
1155        assert_eq!(mfe.sparse.len(), 64); // sparse_top_k
1156        assert_eq!(mfe.multi_vec.len(), 16); // num_multi_vec_tokens
1157    }
1158
1159    #[test]
1160    fn derive_multi_function_handles_multiple_inputs() {
1161        let embedder = BgeM3Embedder::with_params(
1162            "http://localhost:11434",
1163            "bge-m3",
1164            8,
1165            4,
1166            30,
1167            BgeM3DeriveConfig::default(),
1168        )
1169        .unwrap();
1170
1171        let inputs: Vec<Vec<f32>> = (0..5).map(|i| vec![i as f32; 8]).collect();
1172        let results = embedder.derive_multi_function(inputs);
1173        assert_eq!(results.len(), 5);
1174        for mfe in &results {
1175            assert_eq!(mfe.dense.len(), 8);
1176        }
1177    }
1178
1179    #[test]
1180    fn derive_multi_function_empty_input() {
1181        let embedder = BgeM3Embedder::with_params(
1182            "http://localhost:11434",
1183            "bge-m3",
1184            8,
1185            4,
1186            30,
1187            BgeM3DeriveConfig::default(),
1188        )
1189        .unwrap();
1190
1191        let results = embedder.derive_multi_function(vec![]);
1192        assert!(results.is_empty());
1193    }
1194
1195    // ── Ollama integration tests (require running Ollama with bge-m3) ──
1196
1197    #[tokio::test]
1198    #[ignore = "requires Ollama running with bge-m3 model pulled"]
1199    async fn bge_m3_embed_multi_live() {
1200        let embedder = BgeM3Embedder::with_params(
1201            "http://127.0.0.1:11434",
1202            "bge-m3",
1203            1024,
1204            32,
1205            60,
1206            BgeM3DeriveConfig::default(),
1207        )
1208        .unwrap();
1209
1210        let result = embedder.embed_multi("hello world").await;
1211        assert!(result.is_ok(), "Ollama call failed: {:?}", result.err());
1212        let mfe = result.unwrap();
1213        assert_eq!(mfe.dense.len(), 1024);
1214        assert!(!mfe.sparse.is_empty());
1215        assert!(!mfe.multi_vec.is_empty());
1216    }
1217
1218    #[tokio::test]
1219    #[ignore = "requires Ollama running with bge-m3 model pulled"]
1220    async fn bge_m3_embed_batch_multi_live() {
1221        let embedder = BgeM3Embedder::with_params(
1222            "http://127.0.0.1:11434",
1223            "bge-m3",
1224            1024,
1225            32,
1226            60,
1227            BgeM3DeriveConfig::default(),
1228        )
1229        .unwrap();
1230
1231        let texts = vec!["hello".to_string(), "world".to_string(), "test".to_string()];
1232        let results = embedder.embed_batch_multi(texts).await;
1233        assert!(results.is_ok(), "Ollama batch call failed: {:?}", results.err());
1234        let embeddings = results.unwrap();
1235        assert_eq!(embeddings.len(), 3);
1236        for mfe in &embeddings {
1237            assert_eq!(mfe.dense.len(), 1024);
1238        }
1239    }
1240
1241    #[tokio::test]
1242    #[ignore = "requires Ollama running with bge-m3 model pulled"]
1243    async fn bge_m3_embedder_as_standard_embedder_live() {
1244        let embedder = BgeM3Embedder::with_params(
1245            "http://127.0.0.1:11434",
1246            "bge-m3",
1247            1024,
1248            32,
1249            60,
1250            BgeM3DeriveConfig::default(),
1251        )
1252        .unwrap();
1253
1254        let result = embedder.embed("hello world").await;
1255        assert!(result.is_ok());
1256        let dense = result.unwrap();
1257        assert_eq!(dense.len(), 1024);
1258    }
1259}