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