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}