Skip to main content

sochdb_query/
embedding_provider.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2// SochDB - LLM-Optimized Embedded Database
3// Copyright (C) 2026 Sushanth Reddy Vanagala (https://github.com/sushanthpy)
4//
5// This program is free software: you can redistribute it and/or modify
6// it under the terms of the GNU Affero General Public License as published by
7// the Free Software Foundation, either version 3 of the License, or
8// (at your option) any later version.
9//
10// This program is distributed in the hope that it will be useful,
11// but WITHOUT ANY WARRANTY; without even the implied warranty of
12// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13// GNU Affero General Public License for more details.
14//
15// You should have received a copy of the GNU Affero General Public License
16// along with this program. If not, see <https://www.gnu.org/licenses/>.
17
18//! Automatic Embedding Generation (Task 2)
19//!
20//! This module provides colocated embedding resolution for text-to-vector conversion.
21//! It enables first-class text search by automatically generating embeddings.
22//!
23//! ## Design
24//!
25//! ```text
26//! search_text(collection, text, k)
27//!     │
28//!     ▼
29//! ┌─────────────────┐
30//! │ EmbeddingProvider │
31//! │  ├─ LRU Cache    │
32//! │  └─ ONNX Runtime │
33//! └─────────────────┘
34//!     │
35//!     ▼
36//! search_by_embedding(collection, embedding, k)
37//! ```
38//!
39//! ## Providers
40//!
41//! - `LocalProvider`: Uses FastEmbed/ONNX for offline embedding
42//! - `CachedProvider`: LRU cache wrapper for any provider
43//! - `MockProvider`: For testing
44//!
45//! ## Complexity
46//!
47//! - Embedding generation: O(n) where n = text length (transformer inference)
48//! - Cache lookup: O(1) expected (hash-based LRU)
49//! - Batch embedding: O(k) compute with ~O(1) ONNX session overhead
50
51use moka::sync::Cache;
52use std::sync::Arc;
53
54// ============================================================================
55// Embedding Provider Trait
56// ============================================================================
57
58/// Error type for embedding operations
59#[derive(Debug, Clone)]
60pub enum EmbeddingError {
61    /// Model not loaded or unavailable
62    ModelNotAvailable(String),
63    /// Text too long for model
64    TextTooLong { max_length: usize, actual: usize },
65    /// Dimension mismatch
66    DimensionMismatch { expected: usize, actual: usize },
67    /// Provider error
68    ProviderError(String),
69    /// Cache error
70    CacheError(String),
71}
72
73impl std::fmt::Display for EmbeddingError {
74    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
75        match self {
76            Self::ModelNotAvailable(model) => write!(f, "Embedding model not available: {}", model),
77            Self::TextTooLong { max_length, actual } => {
78                write!(f, "Text too long: {} > {} max", actual, max_length)
79            }
80            Self::DimensionMismatch { expected, actual } => {
81                write!(
82                    f,
83                    "Dimension mismatch: expected {}, got {}",
84                    expected, actual
85                )
86            }
87            Self::ProviderError(msg) => write!(f, "Provider error: {}", msg),
88            Self::CacheError(msg) => write!(f, "Cache error: {}", msg),
89        }
90    }
91}
92
93impl std::error::Error for EmbeddingError {}
94
95/// Result type for embedding operations
96pub type EmbeddingResult<T> = Result<T, EmbeddingError>;
97
98/// Embedding provider trait
99pub trait EmbeddingProvider: Send + Sync {
100    /// Get the model name
101    fn model_name(&self) -> &str;
102
103    /// Get the embedding dimension
104    fn dimension(&self) -> usize;
105
106    /// Maximum text length (in characters or tokens)
107    fn max_length(&self) -> usize;
108
109    /// Whether this provider produces SEMANTICALLY meaningful embeddings.
110    ///
111    /// Real model-backed providers return `true` (the default). The hash-based
112    /// `MockEmbeddingProvider` returns `false` — callers use this to avoid fusing
113    /// a meaningless cosine lane into hybrid results (which would corrupt recall
114    /// silently). Defaulted so real providers need not implement it.
115    fn is_semantic(&self) -> bool {
116        true
117    }
118
119    /// Generate embedding for a single text (document side).
120    fn embed(&self, text: &str) -> EmbeddingResult<Vec<f32>>;
121
122    /// Embed a QUERY for asymmetric retrieval. Defaults to `embed` (symmetric).
123    /// Providers for instruction-tuned models (e.g. BGE) override this to apply
124    /// the query-side instruction so query and document embeddings align — a
125    /// large recall lever for those models.
126    fn embed_query(&self, text: &str) -> EmbeddingResult<Vec<f32>> {
127        self.embed(text)
128    }
129
130    /// Generate embeddings for multiple texts (batch)
131    fn embed_batch(&self, texts: &[&str]) -> EmbeddingResult<Vec<Vec<f32>>> {
132        // Default implementation: sequential embedding
133        texts.iter().map(|t| self.embed(t)).collect()
134    }
135
136    /// Normalize an embedding vector (L2 normalization)
137    fn normalize(&self, embedding: &mut [f32]) {
138        let norm: f32 = embedding.iter().map(|x| x * x).sum::<f32>().sqrt();
139        if norm > 1e-10 {
140            for x in embedding.iter_mut() {
141                *x /= norm;
142            }
143        }
144    }
145}
146
147// ============================================================================
148// Embedding Configuration
149// ============================================================================
150
151/// Configuration for embedding providers
152#[derive(Debug, Clone)]
153pub struct EmbeddingConfig {
154    /// Model identifier (e.g., "all-MiniLM-L6-v2")
155    pub model: String,
156
157    /// Model path (for local ONNX models)
158    pub model_path: Option<String>,
159
160    /// Embedding dimension
161    pub dimension: usize,
162
163    /// Maximum text length
164    pub max_length: usize,
165
166    /// Whether to normalize embeddings
167    pub normalize: bool,
168
169    /// Batch size for embedding generation
170    pub batch_size: usize,
171
172    /// Cache size (number of embeddings to cache)
173    pub cache_size: usize,
174
175    /// Cache TTL in seconds (0 = no expiry)
176    pub cache_ttl_secs: u64,
177}
178
179impl Default for EmbeddingConfig {
180    fn default() -> Self {
181        Self {
182            model: "all-MiniLM-L6-v2".to_string(),
183            model_path: None,
184            dimension: 384, // MiniLM dimension
185            max_length: 512,
186            normalize: true,
187            batch_size: 32,
188            cache_size: 10_000,
189            cache_ttl_secs: 3600, // 1 hour
190        }
191    }
192}
193
194impl EmbeddingConfig {
195    /// Create config for sentence-transformers models
196    pub fn sentence_transformer(model: &str) -> Self {
197        let dimension = match model {
198            "all-MiniLM-L6-v2" => 384,
199            "all-MiniLM-L12-v2" => 384,
200            "all-mpnet-base-v2" => 768,
201            "paraphrase-MiniLM-L6-v2" => 384,
202            "multi-qa-MiniLM-L6-cos-v1" => 384,
203            _ => 384, // Default
204        };
205
206        Self {
207            model: model.to_string(),
208            dimension,
209            ..Default::default()
210        }
211    }
212
213    /// Create config for OpenAI-compatible models
214    pub fn openai(model: &str) -> Self {
215        let dimension = match model {
216            "text-embedding-ada-002" => 1536,
217            "text-embedding-3-small" => 1536,
218            "text-embedding-3-large" => 3072,
219            _ => 1536,
220        };
221
222        Self {
223            model: model.to_string(),
224            dimension,
225            max_length: 8192,
226            ..Default::default()
227        }
228    }
229}
230
231// ============================================================================
232// Mock Embedding Provider (for testing)
233// ============================================================================
234
235/// Mock embedding provider for testing
236pub struct MockEmbeddingProvider {
237    config: EmbeddingConfig,
238    /// Deterministic embeddings based on text hash
239    use_hash: bool,
240}
241
242impl MockEmbeddingProvider {
243    /// Create a new mock provider
244    pub fn new(dimension: usize) -> Self {
245        Self {
246            config: EmbeddingConfig {
247                model: "mock".to_string(),
248                dimension,
249                ..Default::default()
250            },
251            use_hash: true,
252        }
253    }
254
255    /// Create with custom config
256    pub fn with_config(config: EmbeddingConfig) -> Self {
257        Self {
258            config,
259            use_hash: true,
260        }
261    }
262
263    /// Generate a deterministic embedding from text
264    fn hash_embed(&self, text: &str) -> Vec<f32> {
265        use std::collections::hash_map::DefaultHasher;
266        use std::hash::{Hash, Hasher};
267
268        let mut embedding = Vec::with_capacity(self.config.dimension);
269
270        // Generate pseudo-random values based on text hash
271        for i in 0..self.config.dimension {
272            let mut hasher = DefaultHasher::new();
273            text.hash(&mut hasher);
274            i.hash(&mut hasher);
275            let hash = hasher.finish();
276
277            // Convert to f32 in range [-1, 1]
278            let value = ((hash as f64) / (u64::MAX as f64) * 2.0 - 1.0) as f32;
279            embedding.push(value);
280        }
281
282        embedding
283    }
284}
285
286impl EmbeddingProvider for MockEmbeddingProvider {
287    fn model_name(&self) -> &str {
288        &self.config.model
289    }
290
291    fn dimension(&self) -> usize {
292        self.config.dimension
293    }
294
295    fn max_length(&self) -> usize {
296        self.config.max_length
297    }
298
299    /// Hash-based embeddings have no semantic meaning — cosine over them is
300    /// noise, so hybrid retrieval must NOT fuse the vector lane when this
301    /// provider is in use.
302    fn is_semantic(&self) -> bool {
303        false
304    }
305
306    fn embed(&self, text: &str) -> EmbeddingResult<Vec<f32>> {
307        if text.len() > self.config.max_length {
308            return Err(EmbeddingError::TextTooLong {
309                max_length: self.config.max_length,
310                actual: text.len(),
311            });
312        }
313
314        let mut embedding = if self.use_hash {
315            self.hash_embed(text)
316        } else {
317            vec![0.0; self.config.dimension]
318        };
319
320        if self.config.normalize {
321            self.normalize(&mut embedding);
322        }
323
324        Ok(embedding)
325    }
326}
327
328// ============================================================================
329// Cached Embedding Provider
330// ============================================================================
331
332/// LRU-cached embedding provider wrapper
333pub struct CachedEmbeddingProvider<P: EmbeddingProvider> {
334    /// Inner provider
335    inner: P,
336
337    /// LRU cache: text hash -> embedding
338    cache: Cache<u64, Vec<f32>>,
339
340    /// Cache statistics
341    stats: Arc<CacheStats>,
342}
343
344/// Cache statistics
345#[derive(Debug, Default)]
346pub struct CacheStats {
347    /// Number of cache hits
348    pub hits: std::sync::atomic::AtomicUsize,
349    /// Number of cache misses
350    pub misses: std::sync::atomic::AtomicUsize,
351    /// Number of embeddings cached
352    pub size: std::sync::atomic::AtomicUsize,
353}
354
355impl CacheStats {
356    /// Get hit rate
357    pub fn hit_rate(&self) -> f64 {
358        let hits = self.hits.load(std::sync::atomic::Ordering::Relaxed);
359        let misses = self.misses.load(std::sync::atomic::Ordering::Relaxed);
360        let total = hits + misses;
361        if total == 0 {
362            0.0
363        } else {
364            hits as f64 / total as f64
365        }
366    }
367}
368
369impl<P: EmbeddingProvider> CachedEmbeddingProvider<P> {
370    /// Create a new cached provider
371    pub fn new(inner: P, cache_size: usize) -> Self {
372        Self {
373            inner,
374            cache: Cache::new(cache_size as u64),
375            stats: Arc::new(CacheStats::default()),
376        }
377    }
378
379    /// Create with TTL
380    pub fn with_ttl(inner: P, cache_size: usize, ttl_secs: u64) -> Self {
381        let cache = Cache::builder()
382            .max_capacity(cache_size as u64)
383            .time_to_live(std::time::Duration::from_secs(ttl_secs))
384            .build();
385
386        Self {
387            inner,
388            cache,
389            stats: Arc::new(CacheStats::default()),
390        }
391    }
392
393    /// Get cache statistics
394    pub fn stats(&self) -> &Arc<CacheStats> {
395        &self.stats
396    }
397
398    /// Compute hash for cache key
399    fn text_hash(text: &str) -> u64 {
400        use std::collections::hash_map::DefaultHasher;
401        use std::hash::{Hash, Hasher};
402
403        let mut hasher = DefaultHasher::new();
404        text.hash(&mut hasher);
405        hasher.finish()
406    }
407}
408
409impl<P: EmbeddingProvider> EmbeddingProvider for CachedEmbeddingProvider<P> {
410    fn model_name(&self) -> &str {
411        self.inner.model_name()
412    }
413
414    fn dimension(&self) -> usize {
415        self.inner.dimension()
416    }
417
418    fn max_length(&self) -> usize {
419        self.inner.max_length()
420    }
421
422    fn embed(&self, text: &str) -> EmbeddingResult<Vec<f32>> {
423        let hash = Self::text_hash(text);
424
425        // Check cache
426        if let Some(cached) = self.cache.get(&hash) {
427            self.stats
428                .hits
429                .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
430            return Ok(cached);
431        }
432
433        self.stats
434            .misses
435            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
436
437        // Generate embedding
438        let embedding = self.inner.embed(text)?;
439
440        // Cache result
441        self.cache.insert(hash, embedding.clone());
442        self.stats
443            .size
444            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
445
446        Ok(embedding)
447    }
448
449    fn embed_batch(&self, texts: &[&str]) -> EmbeddingResult<Vec<Vec<f32>>> {
450        let mut results = Vec::with_capacity(texts.len());
451        let mut uncached: Vec<(usize, &str)> = Vec::new();
452
453        // Check cache for each text
454        for (i, text) in texts.iter().enumerate() {
455            let hash = Self::text_hash(text);
456            if let Some(cached) = self.cache.get(&hash) {
457                self.stats
458                    .hits
459                    .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
460                results.push((i, cached));
461            } else {
462                self.stats
463                    .misses
464                    .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
465                uncached.push((i, *text));
466            }
467        }
468
469        // Generate embeddings for uncached texts
470        if !uncached.is_empty() {
471            let uncached_texts: Vec<&str> = uncached.iter().map(|(_, t)| *t).collect();
472            let embeddings = self.inner.embed_batch(&uncached_texts)?;
473
474            for ((i, text), embedding) in uncached.iter().zip(embeddings.into_iter()) {
475                let hash = Self::text_hash(text);
476                self.cache.insert(hash, embedding.clone());
477                self.stats
478                    .size
479                    .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
480                results.push((*i, embedding));
481            }
482        }
483
484        // Sort by original index
485        results.sort_by_key(|(i, _)| *i);
486        Ok(results.into_iter().map(|(_, e)| e).collect())
487    }
488}
489
490// ============================================================================
491// Local ONNX Provider (Stub)
492// ============================================================================
493
494/// Local ONNX-based embedding provider
495///
496/// This is a stub implementation. In production, this would use:
497/// - ort (ONNX Runtime) for model inference
498/// - fastembed-rs for pre-packaged models
499/// - tokenizers for text preprocessing
500#[derive(Debug)]
501pub struct LocalOnnxProvider {
502    config: EmbeddingConfig,
503    /// Model weights (placeholder)
504    #[allow(dead_code)]
505    model_loaded: bool,
506}
507
508impl LocalOnnxProvider {
509    /// Create a new local ONNX provider
510    pub fn new(config: EmbeddingConfig) -> EmbeddingResult<Self> {
511        // In production: Load ONNX model from path
512        Ok(Self {
513            config,
514            model_loaded: false,
515        })
516    }
517
518    /// Load a pre-trained model by name
519    pub fn load_pretrained(model_name: &str) -> EmbeddingResult<Self> {
520        let config = EmbeddingConfig::sentence_transformer(model_name);
521        Self::new(config)
522    }
523}
524
525impl EmbeddingProvider for LocalOnnxProvider {
526    fn model_name(&self) -> &str {
527        &self.config.model
528    }
529
530    fn dimension(&self) -> usize {
531        self.config.dimension
532    }
533
534    fn max_length(&self) -> usize {
535        self.config.max_length
536    }
537
538    fn embed(&self, text: &str) -> EmbeddingResult<Vec<f32>> {
539        // Stub: Return mock embedding
540        // In production: Run ONNX inference
541        let mock = MockEmbeddingProvider::with_config(self.config.clone());
542        mock.embed(text)
543    }
544}
545
546// ============================================================================
547// FastEmbed (ONNX) semantic provider  —  feature = "fastembed"
548// ============================================================================
549
550/// Real semantic embedding provider backed by fastembed-rs (ONNX runtime).
551///
552/// Only compiled with the `fastembed` feature (the `ort` native dep is heavy).
553/// The model is downloaded + cached on first construction. `TextEmbedding::embed`
554/// takes `&mut self`, so the model is held behind a `Mutex` to satisfy the
555/// `&self` trait contract (embedding is the bottleneck anyway, so the lock is
556/// not the limiting factor).
557#[cfg(feature = "fastembed")]
558pub struct FastEmbedProvider {
559    model: std::sync::Mutex<fastembed::TextEmbedding>,
560    model_name: String,
561    dimension: usize,
562    max_length: usize,
563}
564
565#[cfg(feature = "fastembed")]
566impl std::fmt::Debug for FastEmbedProvider {
567    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
568        f.debug_struct("FastEmbedProvider")
569            .field("model_name", &self.model_name)
570            .field("dimension", &self.dimension)
571            .finish()
572    }
573}
574
575#[cfg(feature = "fastembed")]
576impl FastEmbedProvider {
577    /// Construct from a short model alias, e.g. `bge-small-en`, `all-minilm`,
578    /// `bge-base-en`, `bge-large-en`. Downloads + caches the ONNX model on first
579    /// use (set `FASTEMBED_CACHE_DIR` to control the cache location).
580    pub fn new(model_alias: &str) -> EmbeddingResult<Self> {
581        use fastembed::{EmbeddingModel, InitOptions, TextEmbedding};
582        let (model, dimension) = match model_alias.to_ascii_lowercase().as_str() {
583            "bge-small-en" | "bge-small-en-v1.5" | "bge-small" => {
584                (EmbeddingModel::BGESmallENV15, 384)
585            }
586            "all-minilm" | "all-minilm-l6-v2" | "minilm" => (EmbeddingModel::AllMiniLML6V2, 384),
587            "bge-base-en" | "bge-base-en-v1.5" | "bge-base" => (EmbeddingModel::BGEBaseENV15, 768),
588            "bge-large-en" | "bge-large-en-v1.5" | "bge-large" => {
589                (EmbeddingModel::BGELargeENV15, 1024)
590            }
591            other => {
592                return Err(EmbeddingError::ModelNotAvailable(format!(
593                    "fastembed:{other}"
594                )));
595            }
596        };
597        let model =
598            TextEmbedding::try_new(InitOptions::new(model).with_show_download_progress(false))
599                .map_err(|e| {
600                    EmbeddingError::ProviderError(format!("fastembed init failed: {e}"))
601                })?;
602        Ok(Self {
603            model: std::sync::Mutex::new(model),
604            model_name: format!("fastembed:{model_alias}"),
605            dimension,
606            max_length: 512,
607        })
608    }
609}
610
611#[cfg(feature = "fastembed")]
612impl EmbeddingProvider for FastEmbedProvider {
613    fn model_name(&self) -> &str {
614        &self.model_name
615    }
616    fn dimension(&self) -> usize {
617        self.dimension
618    }
619    fn max_length(&self) -> usize {
620        self.max_length
621    }
622    fn embed(&self, text: &str) -> EmbeddingResult<Vec<f32>> {
623        let mut m = self
624            .model
625            .lock()
626            .map_err(|_| EmbeddingError::ProviderError("embedder mutex poisoned".into()))?;
627        let mut out = m
628            .embed(vec![text], None)
629            .map_err(|e| EmbeddingError::ProviderError(format!("fastembed embed failed: {e}")))?;
630        out.pop()
631            .ok_or_else(|| EmbeddingError::ProviderError("fastembed returned no embedding".into()))
632    }
633    fn embed_query(&self, text: &str) -> EmbeddingResult<Vec<f32>> {
634        // BGE v1.5 models are asymmetric: queries are embedded WITH the retrieval
635        // instruction prefix, documents without it. Embedding queries as plain
636        // documents (no prefix) misaligns them with the indexed docs and depresses
637        // recall — more so for the more instruction-tuned bge-base. MiniLM and
638        // other symmetric models need no prefix and fall through to `embed`.
639        if self.model_name.contains("bge") {
640            self.embed(&format!(
641                "Represent this sentence for searching relevant passages: {text}"
642            ))
643        } else {
644            self.embed(text)
645        }
646    }
647    fn embed_batch(&self, texts: &[&str]) -> EmbeddingResult<Vec<Vec<f32>>> {
648        let mut m = self
649            .model
650            .lock()
651            .map_err(|_| EmbeddingError::ProviderError("embedder mutex poisoned".into()))?;
652        m.embed(texts.to_vec(), None)
653            .map_err(|e| EmbeddingError::ProviderError(format!("fastembed batch failed: {e}")))
654    }
655}
656
657/// Build an embedding provider from the `SOCHDB_EMBEDDER` environment variable.
658///
659/// Accepted values:
660/// * `fastembed:<model>` — real semantic embeddings (requires the `fastembed`
661///   feature; e.g. `fastembed:bge-small-en`).
662/// * `mock` / `hash` / unset — deterministic [`MockEmbeddingProvider`] (384-d).
663///
664/// When `SOCHDB_EMBEDDER=fastembed:...` is set but cannot be honored (binary
665/// built without the feature, or the model fails to load), this PANICS at
666/// startup rather than silently falling back to the non-semantic mock embedder
667/// — a silent fallback corrupts retrieval quality without any error. An unset
668/// or `mock`/`hash` spec uses the mock provider normally.
669pub fn embedder_from_env() -> std::sync::Arc<dyn EmbeddingProvider> {
670    let spec = std::env::var("SOCHDB_EMBEDDER").unwrap_or_default();
671    embedder_from_spec(&spec)
672}
673
674/// Like [`embedder_from_env`] but from an explicit spec string.
675pub fn embedder_from_spec(spec: &str) -> std::sync::Arc<dyn EmbeddingProvider> {
676    let spec = spec.trim();
677    if let Some(model) = spec.strip_prefix("fastembed:") {
678        #[cfg(feature = "fastembed")]
679        {
680            match FastEmbedProvider::new(model) {
681                Ok(p) => {
682                    tracing::info!("memory embedder: fastembed:{model} (dim={})", p.dimension());
683                    return std::sync::Arc::new(p);
684                }
685                Err(e) => panic!(
686                    "SOCHDB_EMBEDDER=fastembed:{model} was requested but the model failed to \
687                     load: {e}. Refusing to start with a non-semantic mock embedder, which would \
688                     silently corrupt retrieval. Fix the model/cache (FASTEMBED_CACHE_DIR) or \
689                     unset SOCHDB_EMBEDDER to use the mock explicitly."
690                ),
691            }
692        }
693        #[cfg(not(feature = "fastembed"))]
694        {
695            panic!(
696                "SOCHDB_EMBEDDER=fastembed:{model} was requested but this binary was built \
697                 WITHOUT the `fastembed` feature. Rebuild with `--features fastembed`, or unset \
698                 SOCHDB_EMBEDDER to use the mock embedder explicitly. Refusing to silently fall \
699                 back to a non-semantic mock."
700            );
701        }
702    } else {
703        tracing::info!("memory embedder: mock (384-d) [SOCHDB_EMBEDDER={spec:?}]");
704    }
705    std::sync::Arc::new(MockEmbeddingProvider::new(384))
706}
707
708// ============================================================================
709// Embedding-Enabled Vector Index
710// ============================================================================
711
712/// Vector index with automatic text embedding
713pub struct EmbeddingVectorIndex<V, P>
714where
715    V: crate::context_query::VectorIndex,
716    P: EmbeddingProvider,
717{
718    /// Underlying vector index
719    index: Arc<V>,
720
721    /// Embedding provider
722    provider: Arc<P>,
723}
724
725impl<V, P> EmbeddingVectorIndex<V, P>
726where
727    V: crate::context_query::VectorIndex,
728    P: EmbeddingProvider,
729{
730    /// Create a new embedding-enabled vector index
731    pub fn new(index: Arc<V>, provider: Arc<P>) -> Self {
732        Self { index, provider }
733    }
734
735    /// Search by text (automatically generates embedding)
736    pub fn search_text(
737        &self,
738        collection: &str,
739        text: &str,
740        k: usize,
741        min_score: Option<f32>,
742    ) -> Result<Vec<crate::context_query::VectorSearchResult>, String> {
743        // Generate embedding
744        let embedding = self.provider.embed(text).map_err(|e| e.to_string())?;
745
746        // Search by embedding
747        self.index
748            .search_by_embedding(collection, &embedding, k, min_score)
749    }
750
751    /// Search by embedding (pass-through)
752    pub fn search_embedding(
753        &self,
754        collection: &str,
755        embedding: &[f32],
756        k: usize,
757        min_score: Option<f32>,
758    ) -> Result<Vec<crate::context_query::VectorSearchResult>, String> {
759        // Validate dimension
760        if embedding.len() != self.provider.dimension() {
761            return Err(format!(
762                "Embedding dimension mismatch: expected {}, got {}",
763                self.provider.dimension(),
764                embedding.len()
765            ));
766        }
767
768        self.index
769            .search_by_embedding(collection, embedding, k, min_score)
770    }
771
772    /// Get the embedding provider
773    pub fn provider(&self) -> &Arc<P> {
774        &self.provider
775    }
776
777    /// Get the underlying index
778    pub fn index(&self) -> &Arc<V> {
779        &self.index
780    }
781}
782
783impl<V, P> crate::context_query::VectorIndex for EmbeddingVectorIndex<V, P>
784where
785    V: crate::context_query::VectorIndex,
786    P: EmbeddingProvider,
787{
788    fn search_by_embedding(
789        &self,
790        collection: &str,
791        embedding: &[f32],
792        k: usize,
793        min_score: Option<f32>,
794    ) -> Result<Vec<crate::context_query::VectorSearchResult>, String> {
795        self.search_embedding(collection, embedding, k, min_score)
796    }
797
798    fn search_by_text(
799        &self,
800        collection: &str,
801        text: &str,
802        k: usize,
803        min_score: Option<f32>,
804    ) -> Result<Vec<crate::context_query::VectorSearchResult>, String> {
805        self.search_text(collection, text, k, min_score)
806    }
807
808    fn stats(&self, collection: &str) -> Option<crate::context_query::VectorIndexStats> {
809        self.index.stats(collection)
810    }
811}
812
813// ============================================================================
814// Convenience Functions
815// ============================================================================
816
817/// Create a cached mock embedding provider for testing
818pub fn create_mock_provider(
819    dimension: usize,
820    cache_size: usize,
821) -> CachedEmbeddingProvider<MockEmbeddingProvider> {
822    let mock = MockEmbeddingProvider::new(dimension);
823    CachedEmbeddingProvider::new(mock, cache_size)
824}
825
826/// Create an embedding-enabled vector index with mock provider
827pub fn create_embedding_index<V: crate::context_query::VectorIndex>(
828    index: Arc<V>,
829    dimension: usize,
830) -> EmbeddingVectorIndex<V, CachedEmbeddingProvider<MockEmbeddingProvider>> {
831    let provider = Arc::new(create_mock_provider(dimension, 10_000));
832    EmbeddingVectorIndex::new(index, provider)
833}
834
835// ============================================================================
836// Tests
837// ============================================================================
838
839#[cfg(test)]
840mod tests {
841    use super::*;
842
843    #[test]
844    fn test_mock_embedding_deterministic() {
845        let provider = MockEmbeddingProvider::new(384);
846
847        let emb1 = provider.embed("hello world").unwrap();
848        let emb2 = provider.embed("hello world").unwrap();
849
850        assert_eq!(emb1, emb2);
851        assert_eq!(emb1.len(), 384);
852    }
853
854    #[test]
855    fn test_mock_embedding_different_texts() {
856        let provider = MockEmbeddingProvider::new(384);
857
858        let emb1 = provider.embed("hello").unwrap();
859        let emb2 = provider.embed("world").unwrap();
860
861        assert_ne!(emb1, emb2);
862    }
863
864    #[test]
865    fn test_cached_provider() {
866        let mock = MockEmbeddingProvider::new(128);
867        let cached = CachedEmbeddingProvider::new(mock, 100);
868
869        // First call - miss
870        let _ = cached.embed("test text").unwrap();
871        assert_eq!(
872            cached
873                .stats()
874                .hits
875                .load(std::sync::atomic::Ordering::Relaxed),
876            0
877        );
878        assert_eq!(
879            cached
880                .stats()
881                .misses
882                .load(std::sync::atomic::Ordering::Relaxed),
883            1
884        );
885
886        // Second call - hit
887        let _ = cached.embed("test text").unwrap();
888        assert_eq!(
889            cached
890                .stats()
891                .hits
892                .load(std::sync::atomic::Ordering::Relaxed),
893            1
894        );
895        assert_eq!(
896            cached
897                .stats()
898                .misses
899                .load(std::sync::atomic::Ordering::Relaxed),
900            1
901        );
902
903        assert!(cached.stats().hit_rate() > 0.4);
904    }
905
906    #[test]
907    fn test_batch_embedding() {
908        let mock = MockEmbeddingProvider::new(128);
909        let cached = CachedEmbeddingProvider::new(mock, 100);
910
911        let texts = vec!["hello", "world", "test"];
912        let embeddings = cached.embed_batch(&texts).unwrap();
913
914        assert_eq!(embeddings.len(), 3);
915        for emb in &embeddings {
916            assert_eq!(emb.len(), 128);
917        }
918    }
919
920    #[test]
921    fn test_normalization() {
922        let provider = MockEmbeddingProvider::new(3);
923        let emb = provider.embed("test").unwrap();
924
925        // Check L2 norm is approximately 1
926        let norm: f32 = emb.iter().map(|x| x * x).sum::<f32>().sqrt();
927        assert!((norm - 1.0).abs() < 1e-5);
928    }
929
930    #[test]
931    fn test_text_too_long() {
932        let config = EmbeddingConfig {
933            max_length: 10,
934            ..Default::default()
935        };
936        let provider = MockEmbeddingProvider::with_config(config);
937
938        let result = provider.embed("this is a very long text that exceeds the limit");
939        assert!(matches!(result, Err(EmbeddingError::TextTooLong { .. })));
940    }
941
942    #[cfg(feature = "fastembed")]
943    #[test]
944    fn fastembed_provider_real_semantic_embeddings() {
945        // Downloads + caches bge-small-en on first run. Validates: correct dim,
946        // non-trivial (non-zero) vectors, and real semantics (synonyms rank more
947        // similar than unrelated text) — i.e. NOT the mock fallback.
948        let p = FastEmbedProvider::new("bge-small-en").expect("load bge-small-en");
949        assert_eq!(p.dimension(), 384);
950        let cat = p.embed("a cat sat on the mat").unwrap();
951        let feline = p.embed("a feline rested on the rug").unwrap();
952        let finance = p.embed("quarterly financial earnings report").unwrap();
953        assert_eq!(cat.len(), 384);
954        assert!(
955            cat.iter().any(|&x| x.abs() > 1e-6),
956            "embedding must be non-zero"
957        );
958        let cos = |x: &[f32], y: &[f32]| {
959            let d: f32 = x.iter().zip(y).map(|(a, b)| a * b).sum();
960            let nx: f32 = x.iter().map(|a| a * a).sum::<f32>().sqrt();
961            let ny: f32 = y.iter().map(|a| a * a).sum::<f32>().sqrt();
962            d / (nx * ny + 1e-9)
963        };
964        assert!(
965            cos(&cat, &feline) > cos(&cat, &finance),
966            "synonyms ({}) should outrank unrelated text ({})",
967            cos(&cat, &feline),
968            cos(&cat, &finance)
969        );
970
971        // factory path must yield a real provider (not mock) under the feature
972        let from_spec = embedder_from_spec("fastembed:bge-small-en");
973        assert!(from_spec.model_name().starts_with("fastembed:"));
974    }
975}