1use moka::sync::Cache;
52use std::sync::Arc;
53
54#[derive(Debug, Clone)]
60pub enum EmbeddingError {
61 ModelNotAvailable(String),
63 TextTooLong { max_length: usize, actual: usize },
65 DimensionMismatch { expected: usize, actual: usize },
67 ProviderError(String),
69 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
95pub type EmbeddingResult<T> = Result<T, EmbeddingError>;
97
98pub trait EmbeddingProvider: Send + Sync {
100 fn model_name(&self) -> &str;
102
103 fn dimension(&self) -> usize;
105
106 fn max_length(&self) -> usize;
108
109 fn is_semantic(&self) -> bool {
116 true
117 }
118
119 fn embed(&self, text: &str) -> EmbeddingResult<Vec<f32>>;
121
122 fn embed_query(&self, text: &str) -> EmbeddingResult<Vec<f32>> {
127 self.embed(text)
128 }
129
130 fn embed_batch(&self, texts: &[&str]) -> EmbeddingResult<Vec<Vec<f32>>> {
132 texts.iter().map(|t| self.embed(t)).collect()
134 }
135
136 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#[derive(Debug, Clone)]
153pub struct EmbeddingConfig {
154 pub model: String,
156
157 pub model_path: Option<String>,
159
160 pub dimension: usize,
162
163 pub max_length: usize,
165
166 pub normalize: bool,
168
169 pub batch_size: usize,
171
172 pub cache_size: usize,
174
175 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, max_length: 512,
186 normalize: true,
187 batch_size: 32,
188 cache_size: 10_000,
189 cache_ttl_secs: 3600, }
191 }
192}
193
194impl EmbeddingConfig {
195 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, };
205
206 Self {
207 model: model.to_string(),
208 dimension,
209 ..Default::default()
210 }
211 }
212
213 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
231pub struct MockEmbeddingProvider {
237 config: EmbeddingConfig,
238 use_hash: bool,
240}
241
242impl MockEmbeddingProvider {
243 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 pub fn with_config(config: EmbeddingConfig) -> Self {
257 Self {
258 config,
259 use_hash: true,
260 }
261 }
262
263 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 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 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 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
328pub struct CachedEmbeddingProvider<P: EmbeddingProvider> {
334 inner: P,
336
337 cache: Cache<u64, Vec<f32>>,
339
340 stats: Arc<CacheStats>,
342}
343
344#[derive(Debug, Default)]
346pub struct CacheStats {
347 pub hits: std::sync::atomic::AtomicUsize,
349 pub misses: std::sync::atomic::AtomicUsize,
351 pub size: std::sync::atomic::AtomicUsize,
353}
354
355impl CacheStats {
356 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 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 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 pub fn stats(&self) -> &Arc<CacheStats> {
395 &self.stats
396 }
397
398 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 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 let embedding = self.inner.embed(text)?;
439
440 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 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 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 results.sort_by_key(|(i, _)| *i);
486 Ok(results.into_iter().map(|(_, e)| e).collect())
487 }
488}
489
490#[derive(Debug)]
501pub struct LocalOnnxProvider {
502 config: EmbeddingConfig,
503 #[allow(dead_code)]
505 model_loaded: bool,
506}
507
508impl LocalOnnxProvider {
509 pub fn new(config: EmbeddingConfig) -> EmbeddingResult<Self> {
511 Ok(Self {
513 config,
514 model_loaded: false,
515 })
516 }
517
518 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 let mock = MockEmbeddingProvider::with_config(self.config.clone());
542 mock.embed(text)
543 }
544}
545
546#[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 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 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
657pub 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
674pub 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
708pub struct EmbeddingVectorIndex<V, P>
714where
715 V: crate::context_query::VectorIndex,
716 P: EmbeddingProvider,
717{
718 index: Arc<V>,
720
721 provider: Arc<P>,
723}
724
725impl<V, P> EmbeddingVectorIndex<V, P>
726where
727 V: crate::context_query::VectorIndex,
728 P: EmbeddingProvider,
729{
730 pub fn new(index: Arc<V>, provider: Arc<P>) -> Self {
732 Self { index, provider }
733 }
734
735 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 let embedding = self.provider.embed(text).map_err(|e| e.to_string())?;
745
746 self.index
748 .search_by_embedding(collection, &embedding, k, min_score)
749 }
750
751 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 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 pub fn provider(&self) -> &Arc<P> {
774 &self.provider
775 }
776
777 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
813pub 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
826pub 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#[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 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 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 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 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 let from_spec = embedder_from_spec("fastembed:bge-small-en");
973 assert!(from_spec.model_name().starts_with("fastembed:"));
974 }
975}