Skip to main content

ruvector_core/
embeddings.rs

1//! Text Embedding Providers
2//!
3//! This module provides a pluggable embedding system for AgenticDB.
4//!
5//! ## Available Providers
6//!
7//! - **HashEmbedding**: Fast hash-based placeholder (default, not semantic)
8//! - **OnnxEmbedding**: Real semantic embeddings using ONNX Runtime (feature: `onnx-embeddings`) ✅ RECOMMENDED
9//! - **LatticeEmbedding**: Real semantic embeddings using lattice-embed, pure-Rust native inference (feature: `lattice-embeddings`)
10//! - **CandleEmbedding**: Real embeddings using candle-transformers (feature: `real-embeddings`)
11//! - **ApiEmbedding**: External API calls (OpenAI, Anthropic, Cohere, etc.)
12//!
13//! ## Usage
14//!
15//! ```rust,no_run
16//! use ruvector_core::embeddings::{EmbeddingProvider, HashEmbedding};
17//!
18//! // Default: Hash-based (fast, but not semantic)
19//! let hash_provider = HashEmbedding::new(384);
20//! let embedding = hash_provider.embed("hello world")?;
21//!
22//! # Ok::<(), Box<dyn std::error::Error>>(())
23//! ```
24//!
25//! ## ONNX Embeddings (Recommended for Production)
26//!
27//! ```rust,ignore
28//! use ruvector_core::embeddings::{EmbeddingProvider, OnnxEmbedding};
29//!
30//! // Real semantic embeddings using all-MiniLM-L6-v2
31//! let provider = OnnxEmbedding::from_pretrained("sentence-transformers/all-MiniLM-L6-v2")?;
32//! let embedding = provider.embed("hello world")?;
33//! // "dog" and "cat" WILL be similar (semantic understanding!)
34//! ```
35
36use crate::error::Result;
37#[cfg(any(
38    feature = "real-embeddings",
39    feature = "api-embeddings",
40    feature = "lattice-embeddings"
41))]
42use crate::error::RuvectorError;
43use std::sync::Arc;
44
45/// Trait for text embedding providers
46pub trait EmbeddingProvider: Send + Sync {
47    /// Generate embedding vector for the given text
48    fn embed(&self, text: &str) -> Result<Vec<f32>>;
49
50    /// Get the dimensionality of embeddings produced by this provider
51    fn dimensions(&self) -> usize;
52
53    /// Get a description of this provider (for logging/debugging)
54    fn name(&self) -> &str;
55}
56
57/// Hash-based embedding provider (placeholder, not semantic)
58///
59/// ⚠️ **WARNING**: This does NOT produce semantic embeddings!
60/// - "dog" and "cat" will NOT be similar
61/// - "dog" and "god" WILL be similar (same characters)
62///
63/// Use this only for:
64/// - Testing
65/// - Prototyping
66/// - When semantic similarity is not required
67#[derive(Debug, Clone)]
68pub struct HashEmbedding {
69    dimensions: usize,
70}
71
72impl HashEmbedding {
73    /// Create a new hash-based embedding provider
74    pub fn new(dimensions: usize) -> Self {
75        Self { dimensions }
76    }
77}
78
79impl EmbeddingProvider for HashEmbedding {
80    fn embed(&self, text: &str) -> Result<Vec<f32>> {
81        let mut embedding = vec![0.0; self.dimensions];
82        let bytes = text.as_bytes();
83
84        for (i, byte) in bytes.iter().enumerate() {
85            embedding[i % self.dimensions] += (*byte as f32) / 255.0;
86        }
87
88        // Normalize
89        let norm: f32 = embedding.iter().map(|x| x * x).sum::<f32>().sqrt();
90        if norm > 0.0 {
91            for val in &mut embedding {
92                *val /= norm;
93            }
94        }
95
96        Ok(embedding)
97    }
98
99    fn dimensions(&self) -> usize {
100        self.dimensions
101    }
102
103    fn name(&self) -> &str {
104        "HashEmbedding (placeholder)"
105    }
106}
107
108/// Real embeddings using candle-transformers
109///
110/// Requires feature flag: `real-embeddings`
111///
112/// ⚠️ **Note**: Full candle integration is complex and model-specific.
113/// For production use, we recommend:
114/// 1. Using the API-based providers (simpler, always up-to-date)
115/// 2. Using ONNX Runtime with pre-exported models
116/// 3. Implementing your own candle wrapper for your specific model
117///
118/// This is a stub implementation showing the structure.
119/// Users should implement `EmbeddingProvider` trait for their specific models.
120#[cfg(feature = "real-embeddings")]
121pub mod candle {
122    use super::*;
123
124    /// Candle-based embedding provider stub
125    ///
126    /// This is a placeholder. For real implementation:
127    /// 1. Add candle dependencies for your specific model type
128    /// 2. Implement model loading and inference
129    /// 3. Handle tokenization appropriately
130    ///
131    /// Example structure:
132    /// ```rust,ignore
133    /// pub struct CandleEmbedding {
134    ///     model: YourModelType,
135    ///     tokenizer: Tokenizer,
136    ///     device: Device,
137    ///     dimensions: usize,
138    /// }
139    /// ```
140    pub struct CandleEmbedding {
141        dimensions: usize,
142        model_id: String,
143    }
144
145    impl CandleEmbedding {
146        /// Create a stub candle embedding provider
147        ///
148        /// **This is not a real implementation!**
149        /// For production, implement with actual model loading.
150        ///
151        /// # Example
152        /// ```rust,no_run
153        /// # #[cfg(feature = "real-embeddings")]
154        /// # {
155        /// use ruvector_core::embeddings::candle::CandleEmbedding;
156        ///
157        /// // This returns an error - real implementation required
158        /// let result = CandleEmbedding::from_pretrained(
159        ///     "sentence-transformers/all-MiniLM-L6-v2",
160        ///     false
161        /// );
162        /// assert!(result.is_err());
163        /// # }
164        /// ```
165        pub fn from_pretrained(model_id: &str, _use_gpu: bool) -> Result<Self> {
166            Err(RuvectorError::ModelLoadError(format!(
167                "Candle embedding support is a stub. Please:\n\
168                     1. Use ApiEmbedding for production (recommended)\n\
169                     2. Or implement CandleEmbedding for model: {}\n\
170                     3. See docs for ONNX Runtime integration examples",
171                model_id
172            )))
173        }
174    }
175
176    impl EmbeddingProvider for CandleEmbedding {
177        fn embed(&self, _text: &str) -> Result<Vec<f32>> {
178            Err(RuvectorError::ModelInferenceError(
179                "Candle embedding not implemented - use ApiEmbedding instead".to_string(),
180            ))
181        }
182
183        fn dimensions(&self) -> usize {
184            self.dimensions
185        }
186
187        fn name(&self) -> &str {
188            "CandleEmbedding (stub - not implemented)"
189        }
190    }
191}
192
193#[cfg(feature = "real-embeddings")]
194pub use candle::CandleEmbedding;
195
196/// API-based embedding provider (OpenAI, Anthropic, Cohere, etc.)
197///
198/// Supports any API that accepts JSON and returns embeddings in a standard format.
199///
200/// # Example (OpenAI)
201/// ```rust,no_run
202/// use ruvector_core::embeddings::{EmbeddingProvider, ApiEmbedding};
203///
204/// let provider = ApiEmbedding::openai("sk-...", "text-embedding-3-small");
205/// let embedding = provider.embed("hello world")?;
206/// # Ok::<(), Box<dyn std::error::Error>>(())
207/// ```
208#[cfg(feature = "api-embeddings")]
209#[derive(Clone)]
210pub struct ApiEmbedding {
211    api_key: String,
212    endpoint: String,
213    model: String,
214    dimensions: usize,
215    client: reqwest::blocking::Client,
216}
217
218#[cfg(feature = "api-embeddings")]
219impl ApiEmbedding {
220    /// Create a new API embedding provider
221    ///
222    /// # Arguments
223    /// * `api_key` - API key for authentication
224    /// * `endpoint` - API endpoint URL
225    /// * `model` - Model identifier
226    /// * `dimensions` - Expected embedding dimensions
227    pub fn new(api_key: String, endpoint: String, model: String, dimensions: usize) -> Self {
228        Self {
229            api_key,
230            endpoint,
231            model,
232            dimensions,
233            client: reqwest::blocking::Client::new(),
234        }
235    }
236
237    /// Create OpenAI embedding provider
238    ///
239    /// # Models
240    /// - `text-embedding-3-small` - 1536 dimensions, $0.02/1M tokens
241    /// - `text-embedding-3-large` - 3072 dimensions, $0.13/1M tokens
242    /// - `text-embedding-ada-002` - 1536 dimensions (legacy)
243    pub fn openai(api_key: &str, model: &str) -> Self {
244        let dimensions = match model {
245            "text-embedding-3-large" => 3072,
246            _ => 1536, // text-embedding-3-small and ada-002
247        };
248
249        Self::new(
250            api_key.to_string(),
251            "https://api.openai.com/v1/embeddings".to_string(),
252            model.to_string(),
253            dimensions,
254        )
255    }
256
257    /// Create Cohere embedding provider
258    ///
259    /// # Models
260    /// - `embed-english-v3.0` - 1024 dimensions
261    /// - `embed-multilingual-v3.0` - 1024 dimensions
262    pub fn cohere(api_key: &str, model: &str) -> Self {
263        Self::new(
264            api_key.to_string(),
265            "https://api.cohere.ai/v1/embed".to_string(),
266            model.to_string(),
267            1024,
268        )
269    }
270
271    /// Create Voyage AI embedding provider
272    ///
273    /// # Models
274    /// - `voyage-2` - 1024 dimensions
275    /// - `voyage-large-2` - 1536 dimensions
276    pub fn voyage(api_key: &str, model: &str) -> Self {
277        let dimensions = if model.contains("large") { 1536 } else { 1024 };
278
279        Self::new(
280            api_key.to_string(),
281            "https://api.voyageai.com/v1/embeddings".to_string(),
282            model.to_string(),
283            dimensions,
284        )
285    }
286}
287
288#[cfg(feature = "api-embeddings")]
289impl EmbeddingProvider for ApiEmbedding {
290    fn embed(&self, text: &str) -> Result<Vec<f32>> {
291        let request_body = serde_json::json!({
292            "input": text,
293            "model": self.model,
294        });
295
296        let response = self
297            .client
298            .post(&self.endpoint)
299            .header("Authorization", format!("Bearer {}", self.api_key))
300            .header("Content-Type", "application/json")
301            .json(&request_body)
302            .send()
303            .map_err(|e| {
304                RuvectorError::ModelInferenceError(format!("API request failed: {}", e))
305            })?;
306
307        if !response.status().is_success() {
308            let status = response.status();
309            let error_text = response
310                .text()
311                .unwrap_or_else(|_| "Unknown error".to_string());
312            return Err(RuvectorError::ModelInferenceError(format!(
313                "API returned error {}: {}",
314                status, error_text
315            )));
316        }
317
318        let response_json: serde_json::Value = response.json().map_err(|e| {
319            RuvectorError::ModelInferenceError(format!("Failed to parse response: {}", e))
320        })?;
321
322        // Handle different API response formats
323        let embedding = if let Some(data) = response_json.get("data") {
324            // OpenAI format: {"data": [{"embedding": [...]}]}
325            data.as_array()
326                .and_then(|arr| arr.first())
327                .and_then(|obj| obj.get("embedding"))
328                .and_then(|emb| emb.as_array())
329                .ok_or_else(|| {
330                    RuvectorError::ModelInferenceError("Invalid OpenAI response format".to_string())
331                })?
332        } else if let Some(embeddings) = response_json.get("embeddings") {
333            // Cohere format: {"embeddings": [[...]]}
334            embeddings
335                .as_array()
336                .and_then(|arr| arr.first())
337                .and_then(|emb| emb.as_array())
338                .ok_or_else(|| {
339                    RuvectorError::ModelInferenceError("Invalid Cohere response format".to_string())
340                })?
341        } else {
342            return Err(RuvectorError::ModelInferenceError(
343                "Unknown API response format".to_string(),
344            ));
345        };
346
347        let embedding_vec: Result<Vec<f32>> = embedding
348            .iter()
349            .map(|v| {
350                v.as_f64().map(|f| f as f32).ok_or_else(|| {
351                    RuvectorError::ModelInferenceError("Invalid embedding value".to_string())
352                })
353            })
354            .collect();
355
356        embedding_vec
357    }
358
359    fn dimensions(&self) -> usize {
360        self.dimensions
361    }
362
363    fn name(&self) -> &str {
364        "ApiEmbedding"
365    }
366}
367
368// ============================================================================
369// ONNX Embeddings (Recommended for Production)
370// ============================================================================
371
372/// ONNX-based embedding provider using ONNX Runtime
373///
374/// Provides **real semantic embeddings** using transformer models like all-MiniLM-L6-v2.
375/// This is the **recommended** embedding provider for production use.
376///
377/// Requires feature flag: `onnx-embeddings`
378///
379/// ## Features
380/// - Real semantic understanding ("dog" and "cat" ARE similar)
381/// - Local inference (no API calls, works offline)
382/// - Fast inference (5-50ms per embedding)
383/// - Automatic model download from HuggingFace
384///
385/// ## Supported Models
386/// - `sentence-transformers/all-MiniLM-L6-v2` (384 dims, recommended)
387/// - `sentence-transformers/all-mpnet-base-v2` (768 dims)
388/// - `BAAI/bge-small-en-v1.5` (384 dims)
389///
390/// # Example
391/// ```rust,ignore
392/// use ruvector_core::embeddings::{EmbeddingProvider, OnnxEmbedding};
393///
394/// let provider = OnnxEmbedding::from_pretrained("sentence-transformers/all-MiniLM-L6-v2")?;
395/// let embedding = provider.embed("hello world")?;
396/// assert_eq!(embedding.len(), 384);
397/// ```
398#[cfg(feature = "onnx-embeddings")]
399pub mod onnx {
400    use super::*;
401    use crate::error::RuvectorError;
402    use ort::session::Session;
403    use ort::value::{Tensor, ValueType};
404    use parking_lot::RwLock;
405    use std::path::PathBuf;
406    use tokenizers::Tokenizer;
407
408    /// ONNX-based embedding provider
409    pub struct OnnxEmbedding {
410        session: RwLock<Session>,
411        tokenizer: RwLock<Tokenizer>,
412        dimensions: usize,
413        model_id: String,
414        #[allow(dead_code)]
415        max_length: usize,
416    }
417
418    impl OnnxEmbedding {
419        /// Load a pre-trained embedding model from HuggingFace
420        ///
421        /// The model will be downloaded and cached automatically.
422        ///
423        /// # Arguments
424        /// * `model_id` - HuggingFace model identifier (e.g., "sentence-transformers/all-MiniLM-L6-v2")
425        ///
426        /// # Example
427        /// ```rust,ignore
428        /// let provider = OnnxEmbedding::from_pretrained("sentence-transformers/all-MiniLM-L6-v2")?;
429        /// ```
430        pub fn from_pretrained(model_id: &str) -> Result<Self> {
431            let api = hf_hub::api::sync::Api::new().map_err(|e| {
432                RuvectorError::ModelLoadError(format!("Failed to create HuggingFace API: {}", e))
433            })?;
434
435            let repo = api.model(model_id.to_string());
436
437            // Download model files
438            let model_path = repo
439                .get("model.onnx")
440                .or_else(|_| {
441                    // Try alternative path for some models
442                    repo.get("onnx/model.onnx")
443                })
444                .map_err(|e| {
445                    RuvectorError::ModelLoadError(format!(
446                        "Failed to download ONNX model from {}: {}. \
447                     Make sure the model has an ONNX export available.",
448                        model_id, e
449                    ))
450                })?;
451
452            let tokenizer_path = repo.get("tokenizer.json").map_err(|e| {
453                RuvectorError::ModelLoadError(format!(
454                    "Failed to download tokenizer from {}: {}",
455                    model_id, e
456                ))
457            })?;
458
459            Self::from_files(&model_path, &tokenizer_path, model_id)
460        }
461
462        /// Load from local files
463        ///
464        /// # Arguments
465        /// * `model_path` - Path to the ONNX model file
466        /// * `tokenizer_path` - Path to the tokenizer.json file
467        /// * `model_id` - Model identifier for logging
468        pub fn from_files(
469            model_path: &PathBuf,
470            tokenizer_path: &PathBuf,
471            model_id: &str,
472        ) -> Result<Self> {
473            // Initialize ONNX Runtime (returns bool, true = first init)
474            let _ = ort::init().commit();
475
476            // Load the ONNX session
477            let session = Session::builder()
478                .map_err(|e| {
479                    RuvectorError::ModelLoadError(format!(
480                        "Failed to create session builder: {}",
481                        e
482                    ))
483                })?
484                .with_intra_threads(4)
485                .map_err(|e| {
486                    RuvectorError::ModelLoadError(format!("Failed to set thread count: {}", e))
487                })?
488                .commit_from_file(model_path)
489                .map_err(|e| {
490                    RuvectorError::ModelLoadError(format!("Failed to load ONNX model: {}", e))
491                })?;
492
493            // Load tokenizer
494            let tokenizer = Tokenizer::from_file(tokenizer_path).map_err(|e| {
495                RuvectorError::ModelLoadError(format!("Failed to load tokenizer: {}", e))
496            })?;
497
498            // Determine dimensions from model output
499            let dimensions = Self::infer_dimensions(&session, model_id)?;
500
501            // Determine max_length from model (default to 512 for sentence transformers)
502            let max_length = 512;
503
504            tracing::info!(
505                "Loaded ONNX embedding model: {} ({}D)",
506                model_id,
507                dimensions
508            );
509
510            Ok(Self {
511                session: RwLock::new(session),
512                tokenizer: RwLock::new(tokenizer),
513                dimensions,
514                model_id: model_id.to_string(),
515                max_length,
516            })
517        }
518
519        fn infer_dimensions(session: &Session, model_id: &str) -> Result<usize> {
520            // Common dimensions for known models
521            let dimensions = match model_id {
522                id if id.contains("all-MiniLM-L6") => 384,
523                id if id.contains("all-mpnet-base") => 768,
524                id if id.contains("bge-small") => 384,
525                id if id.contains("bge-base") => 768,
526                id if id.contains("bge-large") => 1024,
527                id if id.contains("e5-small") => 384,
528                id if id.contains("e5-base") => 768,
529                id if id.contains("e5-large") => 1024,
530                _ => {
531                    // Try to infer from output shape via session.outputs() method
532                    if let Some(output) = session.outputs().first() {
533                        if let ValueType::Tensor { shape, .. } = output.dtype() {
534                            let dims: Vec<i64> = shape.iter().copied().collect();
535                            if dims.len() >= 2 {
536                                let last_dim = dims[dims.len() - 1];
537                                if last_dim > 0 {
538                                    return Ok(last_dim as usize);
539                                }
540                            }
541                        }
542                    }
543                    // Default to 384 (most common)
544                    384
545                }
546            };
547
548            Ok(dimensions)
549        }
550
551        /// Embed multiple texts in a batch (more efficient than individual calls)
552        pub fn embed_batch(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>> {
553            texts.iter().map(|text| self.embed(text)).collect()
554        }
555
556        fn mean_pooling(
557            token_embeddings: &[f32],
558            attention_mask: &[i64],
559            seq_len: usize,
560            hidden_size: usize,
561        ) -> Vec<f32> {
562            let mut pooled = vec![0.0f32; hidden_size];
563            let mut mask_sum = 0.0f32;
564
565            for i in 0..seq_len {
566                let mask = attention_mask[i] as f32;
567                mask_sum += mask;
568                for j in 0..hidden_size {
569                    pooled[j] += token_embeddings[i * hidden_size + j] * mask;
570                }
571            }
572
573            // Avoid division by zero
574            if mask_sum > 0.0 {
575                for val in &mut pooled {
576                    *val /= mask_sum;
577                }
578            }
579
580            // L2 normalize
581            let norm: f32 = pooled.iter().map(|x| x * x).sum::<f32>().sqrt();
582            if norm > 0.0 {
583                for val in &mut pooled {
584                    *val /= norm;
585                }
586            }
587
588            pooled
589        }
590    }
591
592    impl EmbeddingProvider for OnnxEmbedding {
593        fn embed(&self, text: &str) -> Result<Vec<f32>> {
594            // Tokenize
595            let encoding = {
596                let tokenizer = self.tokenizer.read();
597                tokenizer.encode(text, true).map_err(|e| {
598                    RuvectorError::ModelInferenceError(format!("Tokenization failed: {}", e))
599                })?
600            };
601
602            // Prepare inputs
603            let input_ids: Vec<i64> = encoding.get_ids().iter().map(|&x| x as i64).collect();
604            let attention_mask: Vec<i64> = encoding
605                .get_attention_mask()
606                .iter()
607                .map(|&x| x as i64)
608                .collect();
609            let token_type_ids: Vec<i64> =
610                encoding.get_type_ids().iter().map(|&x| x as i64).collect();
611
612            let seq_len = input_ids.len();
613
614            // Create ONNX tensors using ort 2.0 API (batch_size=1)
615            // Tensor::from_array takes (shape, owned_data)
616            let input_ids_tensor =
617                Tensor::<i64>::from_array(([1, seq_len], input_ids.clone().into_boxed_slice()))
618                    .map_err(|e| {
619                        RuvectorError::ModelInferenceError(format!(
620                            "Failed to create input_ids tensor: {}",
621                            e
622                        ))
623                    })?;
624
625            let attention_mask_tensor = Tensor::<i64>::from_array((
626                [1, seq_len],
627                attention_mask.clone().into_boxed_slice(),
628            ))
629            .map_err(|e| {
630                RuvectorError::ModelInferenceError(format!(
631                    "Failed to create attention_mask tensor: {}",
632                    e
633                ))
634            })?;
635
636            let token_type_ids_tensor =
637                Tensor::<i64>::from_array(([1, seq_len], token_type_ids.into_boxed_slice()))
638                    .map_err(|e| {
639                        RuvectorError::ModelInferenceError(format!(
640                            "Failed to create token_type_ids tensor: {}",
641                            e
642                        ))
643                    })?;
644
645            // Run inference and extract output (needs mutable access to session)
646            // We must extract all data while holding the lock since SessionOutputs has a lifetime
647            let (output_data, output_shape_vec) = {
648                let mut session = self.session.write();
649                let outputs = session
650                    .run(ort::inputs![
651                        "input_ids" => input_ids_tensor,
652                        "attention_mask" => attention_mask_tensor,
653                        "token_type_ids" => token_type_ids_tensor,
654                    ])
655                    .map_err(|e| {
656                        RuvectorError::ModelInferenceError(format!("ONNX inference failed: {}", e))
657                    })?;
658
659                // Extract output using indexing (ort 2.0 API)
660                // Sentence transformers output shape: [batch_size, seq_len, hidden_size]
661                let output_value = &outputs[0];
662
663                // Extract as ndarray view
664                let output_array = output_value.try_extract_array::<f32>().map_err(|e| {
665                    RuvectorError::ModelInferenceError(format!(
666                        "Failed to extract output tensor: {}",
667                        e
668                    ))
669                })?;
670
671                let output_shape_vec: Vec<usize> = output_array.shape().to_vec();
672                let output_data_vec: Vec<f32> = output_array.iter().copied().collect();
673
674                (output_data_vec, output_shape_vec)
675            };
676
677            // Determine if we need pooling based on output shape
678            let embedding = if output_shape_vec.len() == 3 {
679                // Shape: [batch_size, seq_len, hidden_size] - needs pooling
680                let hidden_size = output_shape_vec[2];
681                Self::mean_pooling(&output_data, &attention_mask, seq_len, hidden_size)
682            } else if output_shape_vec.len() == 2 {
683                // Shape: [batch_size, hidden_size] - already pooled
684                let mut emb = output_data;
685                // L2 normalize
686                let norm: f32 = emb.iter().map(|x| x * x).sum::<f32>().sqrt();
687                if norm > 0.0 {
688                    for val in &mut emb {
689                        *val /= norm;
690                    }
691                }
692                emb
693            } else {
694                return Err(RuvectorError::ModelInferenceError(format!(
695                    "Unexpected output shape: {:?}",
696                    output_shape_vec
697                )));
698            };
699
700            Ok(embedding)
701        }
702
703        fn dimensions(&self) -> usize {
704            self.dimensions
705        }
706
707        fn name(&self) -> &str {
708            &self.model_id
709        }
710    }
711
712    impl std::fmt::Debug for OnnxEmbedding {
713        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
714            f.debug_struct("OnnxEmbedding")
715                .field("model_id", &self.model_id)
716                .field("dimensions", &self.dimensions)
717                .field("max_length", &self.max_length)
718                .finish()
719        }
720    }
721}
722
723#[cfg(feature = "onnx-embeddings")]
724pub use onnx::OnnxEmbedding;
725
726// ============================================================================
727// Lattice Embeddings (pure-Rust, native, no C++ FFI / no ONNX Runtime)
728// ============================================================================
729
730/// Native embedding provider backed by [`lattice-embed`](https://crates.io/crates/lattice-embed),
731/// a pure-Rust transformer inference engine (SIMD matmul, safetensors weight
732/// loading, no ONNX Runtime, no C++ FFI).
733///
734/// Requires feature flag: `lattice-embeddings`
735///
736/// ## Supported models
737/// - `bge-small-en-v1.5` / `BAAI/bge-small-en-v1.5` (384 dims, default, recommended for `.rvf` packs)
738/// - `bge-base-en-v1.5` / `BAAI/bge-base-en-v1.5` (768 dims)
739/// - `bge-large-en-v1.5` / `BAAI/bge-large-en-v1.5` (1024 dims)
740/// - `multilingual-e5-small` / `intfloat/multilingual-e5-small` (384 dims)
741/// - `multilingual-e5-base` / `intfloat/multilingual-e5-base` (768 dims)
742/// - `all-minilm-l6-v2` / `sentence-transformers/all-MiniLM-L6-v2` (384 dims)
743/// - `paraphrase-multilingual-minilm-l12-v2` / `sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2` (384 dims)
744/// - `qwen3-embedding-0.6b` / `Qwen/Qwen3-Embedding-0.6B` (1024 dims)
745/// - `qwen3-embedding-4b` / `Qwen/Qwen3-Embedding-4B` (2560 dims)
746///
747/// Model-id parsing is delegated to `lattice_embed::EmbeddingModel`'s own
748/// `FromStr` impl (case-insensitive, accepts display names, short names, and
749/// HuggingFace ids) rather than re-implementing the mapping here, so this
750/// provider stays in sync with lattice-embed's canonical model table.
751///
752/// ## CPU / native, no GPU
753/// This provider uses lattice-embed's default `native` feature (CPU-only,
754/// SIMD-accelerated). It does **not** enable lattice-embed's `metal-gpu`
755/// feature.
756///
757/// ## Minimum Supported Rust Version
758/// Enabling the `lattice-embeddings` feature raises the effective MSRV for
759/// this crate to Rust 1.93 (edition 2024), since `lattice-embed` requires it.
760/// Cargo has no mechanism to express a per-feature `rust-version`, so this is
761/// not reflected in `rust-version.workspace = true` above — it only applies
762/// when this feature is enabled. The crate's default build (feature
763/// disabled) keeps the workspace MSRV of 1.77.
764///
765/// ## Model download
766/// BERT-family models (BGE, E5, MiniLM) download automatically from
767/// HuggingFace into `~/.lattice/models` on first use. Qwen3-Embedding models
768/// must be placed at `~/.lattice/models/qwen3-embedding-{0.6b,4b}/` manually
769/// (or pointed to via `LATTICE_QWEN_MODEL_DIR`) before first use.
770///
771/// ## Asymmetric retrieval (query vs. passage prefixing)
772/// BGE, E5, and Qwen3-Embedding are asymmetric retrievers: the query side is
773/// prefixed with a retrieval instruction, the document side is not.
774/// [`EmbeddingProvider::embed`] always takes the **passage/document** side (no
775/// query instruction) via `lattice_embed::EmbeddingService::embed_passage`.
776/// Use the inherent [`LatticeEmbedding::embed_query`] method for query text —
777/// it applies the model's query instruction via
778/// `EmbeddingService::embed_query`: BGE v1.5 prefixes queries with
779/// `"Represent this sentence for searching relevant passages: "`, E5 with
780/// `"query: "`, and Qwen3-Embedding with its search instruction. For all three
781/// families `embed_query` and `embed` therefore produce different vectors,
782/// which is what makes asymmetric retrieval correct. MiniLM is genuinely
783/// symmetric (contrastive training on raw text, no prefix), so its two methods
784/// are equivalent.
785///
786/// ## Normalization
787/// Both [`EmbeddingProvider::embed`] and [`LatticeEmbedding::embed_query`]
788/// return L2-normalized vectors (unit length): `lattice-embed`'s BERT-family
789/// encode path (used for BGE, E5, and MiniLM) calls `l2_normalize`
790/// unconditionally on the pooled output, both for single-text and batched
791/// encoding (`BertModel::encode` / `encode_batch` in
792/// `crates/inference/src/model/bert.rs`, upstream in
793/// [`lattice-embed`](https://crates.io/crates/lattice-embed)'s
794/// `lattice-inference` dependency). This holds regardless of distance
795/// metric — safe to use with a dot-product index as well as cosine.
796///
797/// # Example
798/// ```rust,no_run
799/// use ruvector_core::embeddings::{EmbeddingProvider, LatticeEmbedding};
800///
801/// let provider = LatticeEmbedding::from_pretrained("bge-small-en-v1.5")?;
802///
803/// // Document side: no query instruction.
804/// let doc_embedding = provider.embed("The cat sat on the mat.")?;
805/// assert_eq!(doc_embedding.len(), 384);
806///
807/// // Query side: applies the model's query instruction, if any.
808/// let query_embedding = provider.embed_query("Where did the cat sit?")?;
809/// assert_eq!(query_embedding.len(), 384);
810/// # Ok::<(), Box<dyn std::error::Error>>(())
811/// ```
812#[cfg(feature = "lattice-embeddings")]
813pub mod lattice_native {
814    use super::*;
815    use lattice_embed::{
816        EmbeddingModel as LatticeEmbeddingModel, EmbeddingService, NativeEmbeddingService,
817    };
818    use std::sync::mpsc;
819    use std::sync::Mutex;
820    use std::thread;
821
822    /// Which side of asymmetric retrieval a queued embedding request is for.
823    enum EmbedKind {
824        Query,
825        Passage,
826    }
827
828    /// A single embedding request sent to the worker thread, with a
829    /// per-request reply channel for the result.
830    struct EmbedRequest {
831        kind: EmbedKind,
832        text: String,
833        reply_tx: mpsc::Sender<std::result::Result<Vec<f32>, String>>,
834    }
835
836    /// See the [module-level docs](self) for the full provider description.
837    ///
838    /// # Examples
839    /// Embed a passage and a query on an asymmetric BGE model. The query is
840    /// embedded with [`embed_query`](LatticeEmbedding::embed_query), which
841    /// applies BGE's retrieval instruction, so it produces a different vector
842    /// than passing the same text through [`EmbeddingProvider::embed`] (the
843    /// passage side). Using `embed_query` for queries is what makes
844    /// query-to-passage retrieval scores correct on asymmetric models.
845    /// ```rust,no_run
846    /// use ruvector_core::embeddings::{EmbeddingProvider, LatticeEmbedding};
847    ///
848    /// let provider = LatticeEmbedding::from_pretrained("bge-small-en-v1.5")?;
849    ///
850    /// let passage = provider.embed("The Eiffel Tower is in Paris, France.")?;
851    /// let query = provider.embed_query("Where is the Eiffel Tower?")?;
852    /// assert_eq!(passage.len(), provider.dimensions());
853    /// assert_eq!(query.len(), provider.dimensions());
854    /// # Ok::<(), Box<dyn std::error::Error>>(())
855    /// ```
856    /// A runnable version that prints the cosine similarities of the query and
857    /// passage vectors is in `examples/lattice_embedding_example.rs`.
858    ///
859    /// # Threading model
860    /// `lattice-embed`'s [`EmbeddingService`] is `async`-only (no sync/blocking
861    /// API), but [`EmbeddingProvider::embed`] is a sync method that ruvector-core
862    /// callers may invoke from anywhere, including from inside an existing Tokio
863    /// runtime (e.g. an async server handler). Bridging via a stored
864    /// `Runtime::block_on` would panic in that case (`block_on` cannot be
865    /// called from within an already-running runtime). Instead, the runtime and
866    /// the embedding service live on a dedicated worker thread with no ambient
867    /// async context of its own; `embed` / `embed_query` send a request over a
868    /// channel and block on `Receiver::recv`, which is safe to call from any
869    /// context, sync or async.
870    pub struct LatticeEmbedding {
871        model: LatticeEmbeddingModel,
872        model_id: &'static str,
873        dimensions: usize,
874        request_tx: Mutex<mpsc::Sender<EmbedRequest>>,
875        // Keeps the worker thread's handle alive for the lifetime of this
876        // provider. Not joined on drop (that would block); dropping
877        // `request_tx` closes the channel, which ends the worker's `recv`
878        // loop and lets the thread exit on its own.
879        _worker: thread::JoinHandle<()>,
880    }
881
882    impl LatticeEmbedding {
883        /// Load a pre-trained embedding model by id.
884        ///
885        /// Accepts display names (`"bge-small-en-v1.5"`), short names
886        /// (`"bge-small"`, `"small"`), and HuggingFace ids
887        /// (`"BAAI/bge-small-en-v1.5"`) — see [`lattice_embed::EmbeddingModel`]'s
888        /// `FromStr` impl for the full accepted set. Returns an error for any
889        /// unrecognized id, and for any id that resolves to a model
890        /// [`lattice_embed`]'s native service cannot run locally (e.g. the
891        /// remote-only OpenAI variants).
892        ///
893        /// # Example
894        /// ```rust,no_run
895        /// use ruvector_core::embeddings::LatticeEmbedding;
896        ///
897        /// let provider = LatticeEmbedding::from_pretrained("bge-small-en-v1.5")?;
898        /// # Ok::<(), Box<dyn std::error::Error>>(())
899        /// ```
900        pub fn from_pretrained(model_id: &str) -> Result<Self> {
901            let model: LatticeEmbeddingModel = model_id.parse().map_err(|e: String| {
902                RuvectorError::ModelLoadError(format!(
903                    "unknown lattice-embed model id '{model_id}': {e}"
904                ))
905            })?;
906            Self::with_model(model)
907        }
908
909        /// Load a pre-trained embedding model from an already-resolved
910        /// [`lattice_embed::EmbeddingModel`] variant.
911        pub fn with_model(model: LatticeEmbeddingModel) -> Result<Self> {
912            if !model.is_local() {
913                return Err(RuvectorError::ModelLoadError(format!(
914                    "'{model}' cannot be loaded natively: lattice-embed's \
915                     NativeEmbeddingService only supports models it can run \
916                     on-device. Remote/API-only models (e.g. the OpenAI \
917                     text-embedding-* family) are not supported by LatticeEmbedding."
918                )));
919            }
920
921            let runtime = tokio::runtime::Builder::new_current_thread()
922                .enable_all()
923                .build()
924                .map_err(|e| {
925                    RuvectorError::ModelLoadError(format!(
926                        "failed to build tokio runtime for LatticeEmbedding: {e}"
927                    ))
928                })?;
929            let service = NativeEmbeddingService::with_model(model);
930
931            let (request_tx, request_rx) = mpsc::channel::<EmbedRequest>();
932            let worker = thread::Builder::new()
933                .name("lattice-embed-worker".to_string())
934                .spawn(move || {
935                    // No ambient Tokio runtime exists on this thread, so
936                    // `block_on` here can never panic on nested-runtime
937                    // grounds regardless of the caller's own context.
938                    for request in request_rx {
939                        let outcome = runtime.block_on(async {
940                            match request.kind {
941                                EmbedKind::Query => {
942                                    service.embed_query(&[request.text], model).await
943                                }
944                                EmbedKind::Passage => {
945                                    service.embed_passage(&[request.text], model).await
946                                }
947                            }
948                        });
949                        let mapped =
950                            outcome
951                                .map_err(|e| e.to_string())
952                                .and_then(|mut embeddings| {
953                                    embeddings.pop().ok_or_else(|| {
954                                        "lattice-embed returned no embedding".to_string()
955                                    })
956                                });
957                        // Ignore send errors: they only occur if the caller
958                        // already dropped its reply receiver.
959                        let _ = request.reply_tx.send(mapped);
960                    }
961                })
962                .map_err(|e| {
963                    RuvectorError::ModelLoadError(format!(
964                        "failed to spawn LatticeEmbedding worker thread: {e}"
965                    ))
966                })?;
967
968            Ok(Self {
969                model,
970                model_id: model.model_id(),
971                dimensions: model.dimensions(),
972                request_tx: Mutex::new(request_tx),
973                _worker: worker,
974            })
975        }
976
977        /// Get the dimensionality of embeddings produced by the loaded model.
978        pub fn dimensions(&self) -> usize {
979            self.dimensions
980        }
981
982        /// Embed **query** text, applying the model's query-side prompt
983        /// instruction if it uses one (BGE v1.5's `"Represent this sentence
984        /// for searching relevant passages: "` prefix, E5's `"query: "`
985        /// prefix, Qwen3's search-query instruction). For those asymmetric
986        /// models this produces a different vector than
987        /// [`EmbeddingProvider::embed`]; only MiniLM is symmetric, so its two
988        /// methods are equivalent.
989        ///
990        /// This is what makes asymmetric retrieval correct: index documents
991        /// via [`EmbeddingProvider::embed`] (passage side, no prefix) and
992        /// embed the search query via this method (query side, prefixed).
993        ///
994        /// Safe to call from any context, including from inside a Tokio
995        /// runtime — see the [threading model](LatticeEmbedding#threading-model).
996        pub fn embed_query(&self, text: &str) -> Result<Vec<f32>> {
997            self.send_request(EmbedKind::Query, text)
998        }
999
1000        /// Send an embedding request to the worker thread and block on the
1001        /// reply. Never calls `block_on` on the caller's thread, so this is
1002        /// safe to invoke from inside an existing async runtime.
1003        fn send_request(&self, kind: EmbedKind, text: &str) -> Result<Vec<f32>> {
1004            let (reply_tx, reply_rx) = mpsc::channel();
1005            let request = EmbedRequest {
1006                kind,
1007                text: text.to_string(),
1008                reply_tx,
1009            };
1010
1011            self.request_tx
1012                .lock()
1013                .map_err(|_| {
1014                    RuvectorError::ModelInferenceError(
1015                        "lattice-embed embedding worker request channel poisoned".to_string(),
1016                    )
1017                })?
1018                .send(request)
1019                .map_err(|_| {
1020                    RuvectorError::ModelInferenceError(
1021                        "lattice-embed embedding worker unavailable".to_string(),
1022                    )
1023                })?;
1024
1025            reply_rx
1026                .recv()
1027                .map_err(|_| {
1028                    RuvectorError::ModelInferenceError(
1029                        "lattice-embed embedding worker unavailable".to_string(),
1030                    )
1031                })?
1032                .map_err(|e| {
1033                    RuvectorError::ModelInferenceError(format!(
1034                        "lattice-embed embedding failed: {e}"
1035                    ))
1036                })
1037        }
1038    }
1039
1040    impl EmbeddingProvider for LatticeEmbedding {
1041        /// Embed **passage/document** text (no query instruction applied).
1042        ///
1043        /// Use [`LatticeEmbedding::embed_query`] for the query side of
1044        /// asymmetric retrieval. Safe to call from any context, including
1045        /// from inside a Tokio runtime — see the
1046        /// [threading model](LatticeEmbedding#threading-model).
1047        fn embed(&self, text: &str) -> Result<Vec<f32>> {
1048            self.send_request(EmbedKind::Passage, text)
1049        }
1050
1051        fn dimensions(&self) -> usize {
1052            self.dimensions
1053        }
1054
1055        fn name(&self) -> &str {
1056            self.model_id
1057        }
1058    }
1059
1060    #[cfg(test)]
1061    mod tests {
1062        use super::*;
1063
1064        #[test]
1065        fn from_pretrained_rejects_remote_only_models() {
1066            // "text-embedding-3-small" and "openai" both parse successfully
1067            // to `EmbeddingModel::TextEmbedding3Small` (see lattice-embed's
1068            // `FromStr` impl) but that variant is remote/API-only —
1069            // `NativeEmbeddingService` cannot run it. Both aliases must be
1070            // rejected at construction time, not on first `embed()` call.
1071            assert!(
1072                LatticeEmbedding::from_pretrained("text-embedding-3-small").is_err(),
1073                "remote-only model 'text-embedding-3-small' must be rejected at construction"
1074            );
1075            assert!(
1076                LatticeEmbedding::from_pretrained("openai").is_err(),
1077                "remote-only model alias 'openai' must be rejected at construction"
1078            );
1079        }
1080
1081        #[test]
1082        fn from_pretrained_accepts_native_local_model() {
1083            assert!(
1084                LatticeEmbedding::from_pretrained("bge-small-en-v1.5").is_ok(),
1085                "native local model 'bge-small-en-v1.5' must construct successfully"
1086            );
1087        }
1088
1089        /// #662: pins the bge-small alias surface this provider accepts.
1090        /// `ruvector-extensions`' `LatticeWasmEmbeddings` (the WASM sibling of
1091        /// this provider) mirrors this same alias set
1092        /// (`normalizeLatticeWasmModel` in
1093        /// `npm/packages/ruvector-extensions/src/embeddings.ts`) so a model id
1094        /// valid for one Lattice-backed provider is valid for the other.
1095        #[test]
1096        fn from_pretrained_accepts_bge_small_alias_surface() {
1097            for alias in [
1098                "bge-small-en-v1.5",
1099                "bge-small-en",
1100                "bge-small",
1101                "small",
1102                "BAAI/bge-small-en-v1.5",
1103                "BGE_SMALL_EN_V1.5",
1104            ] {
1105                let provider = LatticeEmbedding::from_pretrained(alias)
1106                    .unwrap_or_else(|e| panic!("alias '{alias}' must resolve to bge-small: {e}"));
1107                assert_eq!(
1108                    provider.dimensions(),
1109                    384,
1110                    "alias '{alias}' resolved to the wrong dimensionality"
1111                );
1112                assert_eq!(
1113                    provider.name(),
1114                    "BAAI/bge-small-en-v1.5",
1115                    "alias '{alias}' resolved to a different model than 'bge-small-en-v1.5'"
1116                );
1117            }
1118        }
1119
1120        /// Regression test for the nested-runtime panic: `embed` / `embed_query`
1121        /// used to call `Runtime::block_on` on a `Runtime` stored on the
1122        /// provider, which panics when invoked from inside an already-running
1123        /// Tokio runtime. The worker-thread bridge has no ambient runtime on
1124        /// the calling side, so both calls must succeed here instead.
1125        #[tokio::test]
1126        async fn embed_from_inside_async_runtime_does_not_panic() {
1127            let provider = LatticeEmbedding::from_pretrained("bge-small-en-v1.5")
1128                .expect("bge-small-en-v1.5 is a native local model");
1129
1130            let doc = provider
1131                .embed("a nested-runtime regression test")
1132                .expect("embed must not panic or error from inside a Tokio runtime");
1133            assert_eq!(doc.len(), provider.dimensions());
1134
1135            let query = provider
1136                .embed_query("a nested-runtime regression test")
1137                .expect("embed_query must not panic or error from inside a Tokio runtime");
1138            assert_eq!(query.len(), provider.dimensions());
1139        }
1140
1141        /// Cross-provider contract test (maintainer follow-up on #663).
1142        ///
1143        /// This provider never builds the prefixed query string itself: `embed_query`
1144        /// forwards raw `text` to `EmbeddingService::embed_query`, which prepends
1145        /// `model.query_instruction()` internally (see `send_request` above and
1146        /// `lattice_embed::EmbeddingService::embed_query`'s default impl). So the
1147        /// prefix this provider *effectively* applies for a given model **is**
1148        /// `LatticeEmbeddingModel::query_instruction()` / `document_instruction()` --
1149        /// both documented `**Stable**` in lattice-embed's own API-stability
1150        /// convention (`crates/embed/src/model.rs` in ohdearquant/lattice).
1151        ///
1152        /// `ruvector-extensions`' WASM sibling provider has no such delegation
1153        /// (`@khive-ai/lattice-embed-wasm`'s `embed()` binding takes raw text only,
1154        /// no prefix concept), so it hardcodes the same prefixes as a TS literal
1155        /// map (`LATTICE_WASM_QUERY_INSTRUCTIONS` in
1156        /// `npm/packages/ruvector-extensions/src/embeddings.ts`) and asserts against
1157        /// the identical fixture in its own contract test
1158        /// (`npm/packages/ruvector-extensions/tests/lattice-prefix-contract.test.ts`).
1159        /// Both tests read `fixtures/lattice-embed/query-prefixes.json` at the repo
1160        /// root, so a future lattice-embed bump that changes either model's
1161        /// convention fails this test on the Rust side (and its TS sibling
1162        /// independently), instead of the two providers silently re-diverging the
1163        /// way they did before #663.
1164        #[test]
1165        fn cross_provider_query_prefix_contract() {
1166            let fixture: serde_json::Value = serde_json::from_str(include_str!(
1167                "../../../fixtures/lattice-embed/query-prefixes.json"
1168            ))
1169            .expect("fixtures/lattice-embed/query-prefixes.json must be valid JSON");
1170
1171            let models = fixture["models"]
1172                .as_object()
1173                .expect("fixture must have a top-level 'models' object");
1174            assert!(
1175                !models.is_empty(),
1176                "fixture 'models' must not be empty -- an empty fixture would make this \
1177                 contract test vacuously pass"
1178            );
1179            assert!(
1180                models.contains_key("bge-small"),
1181                "fixture must cover 'bge-small' -- the model #662 was about"
1182            );
1183            assert!(
1184                models.contains_key("minilm"),
1185                "fixture must cover 'minilm' as the symmetric control case"
1186            );
1187
1188            for (alias, expected) in models {
1189                let model: LatticeEmbeddingModel = alias.parse().unwrap_or_else(|e| {
1190                    panic!(
1191                        "fixture alias '{alias}' must be a valid lattice_embed::EmbeddingModel: {e}"
1192                    )
1193                });
1194
1195                let expected_query_prefix = expected["query_prefix"].as_str();
1196                assert_eq!(
1197                    model.query_instruction(),
1198                    expected_query_prefix,
1199                    "query prefix mismatch for '{alias}': lattice_embed::EmbeddingModel::\
1200                     query_instruction() returned {:?} but fixtures/lattice-embed/\
1201                     query-prefixes.json expects {:?}. If lattice-embed intentionally changed \
1202                     this model's convention, update the fixture AND the TS sibling test in \
1203                     npm/packages/ruvector-extensions/tests/lattice-prefix-contract.test.ts \
1204                     together.",
1205                    model.query_instruction(),
1206                    expected_query_prefix
1207                );
1208
1209                let expected_passage_prefix = expected["passage_prefix"].as_str();
1210                assert_eq!(
1211                    model.document_instruction(),
1212                    expected_passage_prefix,
1213                    "passage prefix mismatch for '{alias}': lattice_embed::EmbeddingModel::\
1214                     document_instruction() returned {:?} but the fixture expects {:?}",
1215                    model.document_instruction(),
1216                    expected_passage_prefix
1217                );
1218            }
1219        }
1220    }
1221}
1222
1223#[cfg(feature = "lattice-embeddings")]
1224pub use lattice_native::LatticeEmbedding;
1225
1226/// Type-erased embedding provider for dynamic dispatch
1227pub type BoxedEmbeddingProvider = Arc<dyn EmbeddingProvider>;
1228
1229#[cfg(test)]
1230mod tests {
1231    use super::*;
1232
1233    #[test]
1234    fn test_hash_embedding() {
1235        let provider = HashEmbedding::new(128);
1236
1237        let emb1 = provider.embed("hello world").unwrap();
1238        let emb2 = provider.embed("hello world").unwrap();
1239
1240        assert_eq!(emb1.len(), 128);
1241        assert_eq!(emb1, emb2, "Same text should produce same embedding");
1242
1243        // Check normalization
1244        let norm: f32 = emb1.iter().map(|x| x * x).sum::<f32>().sqrt();
1245        assert!((norm - 1.0).abs() < 1e-5, "Embedding should be normalized");
1246    }
1247
1248    #[test]
1249    fn test_hash_embedding_different_text() {
1250        let provider = HashEmbedding::new(128);
1251
1252        let emb1 = provider.embed("hello").unwrap();
1253        let emb2 = provider.embed("world").unwrap();
1254
1255        assert_ne!(
1256            emb1, emb2,
1257            "Different text should produce different embeddings"
1258        );
1259    }
1260
1261    #[cfg(feature = "real-embeddings")]
1262    #[test]
1263    #[ignore] // Requires model download
1264    fn test_candle_embedding() {
1265        let provider =
1266            CandleEmbedding::from_pretrained("sentence-transformers/all-MiniLM-L6-v2", false)
1267                .unwrap();
1268
1269        let embedding = provider.embed("hello world").unwrap();
1270        assert_eq!(embedding.len(), 384);
1271
1272        // Check normalization
1273        let norm: f32 = embedding.iter().map(|x| x * x).sum::<f32>().sqrt();
1274        assert!((norm - 1.0).abs() < 1e-5, "Embedding should be normalized");
1275    }
1276
1277    #[test]
1278    #[ignore] // Requires API key
1279    fn test_api_embedding_openai() {
1280        let api_key = std::env::var("OPENAI_API_KEY").unwrap();
1281        let provider = ApiEmbedding::openai(&api_key, "text-embedding-3-small");
1282
1283        let embedding = provider.embed("hello world").unwrap();
1284        assert_eq!(embedding.len(), 1536);
1285    }
1286
1287    #[cfg(feature = "onnx-embeddings")]
1288    mod onnx_tests {
1289        use super::*;
1290
1291        #[test]
1292        #[ignore] // Requires model download (~90MB)
1293        fn test_onnx_embedding_minilm() {
1294            let provider =
1295                OnnxEmbedding::from_pretrained("sentence-transformers/all-MiniLM-L6-v2").unwrap();
1296
1297            let embedding = provider.embed("hello world").unwrap();
1298            assert_eq!(embedding.len(), 384);
1299
1300            // Check normalization
1301            let norm: f32 = embedding.iter().map(|x| x * x).sum::<f32>().sqrt();
1302            assert!(
1303                (norm - 1.0).abs() < 1e-4,
1304                "Embedding should be normalized, got norm={}",
1305                norm
1306            );
1307        }
1308
1309        #[test]
1310        #[ignore] // Requires model download
1311        fn test_onnx_semantic_similarity() {
1312            let provider =
1313                OnnxEmbedding::from_pretrained("sentence-transformers/all-MiniLM-L6-v2").unwrap();
1314
1315            let emb_dog = provider.embed("dog").unwrap();
1316            let emb_cat = provider.embed("cat").unwrap();
1317            let emb_car = provider.embed("car").unwrap();
1318
1319            // Cosine similarity (embeddings are normalized, so dot product = cosine)
1320            let sim_dog_cat: f32 = emb_dog.iter().zip(&emb_cat).map(|(a, b)| a * b).sum();
1321            let sim_dog_car: f32 = emb_dog.iter().zip(&emb_car).map(|(a, b)| a * b).sum();
1322
1323            // dog and cat should be more similar than dog and car
1324            assert!(
1325                sim_dog_cat > sim_dog_car,
1326                "Expected dog-cat similarity ({}) > dog-car similarity ({})",
1327                sim_dog_cat,
1328                sim_dog_car
1329            );
1330        }
1331
1332        #[test]
1333        #[ignore] // Requires model download
1334        fn test_onnx_batch_embedding() {
1335            let provider =
1336                OnnxEmbedding::from_pretrained("sentence-transformers/all-MiniLM-L6-v2").unwrap();
1337
1338            let texts = vec!["hello world", "goodbye world", "rust programming"];
1339            let embeddings = provider.embed_batch(&texts).unwrap();
1340
1341            assert_eq!(embeddings.len(), 3);
1342            for emb in &embeddings {
1343                assert_eq!(emb.len(), 384);
1344            }
1345        }
1346    }
1347
1348    #[cfg(feature = "lattice-embeddings")]
1349    mod lattice_tests {
1350        use super::*;
1351        use crate::embeddings::LatticeEmbedding;
1352
1353        /// Pure model-id mapping test — no network, no model load.
1354        /// `LatticeEmbedding::from_pretrained` delegates to
1355        /// `lattice_embed::EmbeddingModel::from_str`; this test locks in that
1356        /// bge-small resolves from both its display name and its HuggingFace
1357        /// id, and that an unrecognized id errors instead of silently
1358        /// defaulting.
1359        #[test]
1360        fn test_lattice_from_pretrained_model_id_mapping() {
1361            let by_display_name = LatticeEmbedding::from_pretrained("bge-small-en-v1.5").unwrap();
1362            assert_eq!(by_display_name.dimensions(), 384);
1363            assert_eq!(EmbeddingProvider::dimensions(&by_display_name), 384);
1364
1365            let by_hf_id = LatticeEmbedding::from_pretrained("BAAI/bge-small-en-v1.5").unwrap();
1366            assert_eq!(by_hf_id.dimensions(), 384);
1367
1368            let unknown = LatticeEmbedding::from_pretrained("not-a-real-model");
1369            assert!(
1370                unknown.is_err(),
1371                "unknown model id should error, not default"
1372            );
1373        }
1374
1375        #[test]
1376        fn test_lattice_from_pretrained_minilm_mapping() {
1377            let by_short = LatticeEmbedding::from_pretrained("all-minilm-l6-v2").unwrap();
1378            assert_eq!(by_short.dimensions(), 384);
1379
1380            let by_hf_id =
1381                LatticeEmbedding::from_pretrained("sentence-transformers/all-MiniLM-L6-v2")
1382                    .unwrap();
1383            assert_eq!(by_hf_id.dimensions(), 384);
1384        }
1385
1386        /// Real end-to-end embedding test. Requires the bge-small-en-v1.5
1387        /// model to be downloaded from HuggingFace on first use (~130MB) —
1388        /// network access, not run in CI. Run manually with:
1389        ///   cargo test -p ruvector-core --features lattice-embeddings -- --ignored lattice_tests
1390        #[test]
1391        #[ignore]
1392        fn test_lattice_embedding_real() {
1393            let provider = LatticeEmbedding::from_pretrained("bge-small-en-v1.5").unwrap();
1394
1395            let embedding = provider.embed("hello world").unwrap();
1396            assert_eq!(embedding.len(), 384);
1397            assert!(embedding.iter().all(|v| v.is_finite()));
1398
1399            let norm: f32 = embedding.iter().map(|x| x * x).sum::<f32>().sqrt();
1400            assert!(
1401                (norm - 1.0).abs() < 1e-3,
1402                "embedding should be L2-normalized, got norm={norm}"
1403            );
1404        }
1405    }
1406}