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