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