1use crate::diskann::DiskANNIndex;
8use crate::hnsw::{DistanceMetric, SearchResult, VectorIndex};
9use crate::quantization::{dequantize_i8_to_f32, quantize_f32_to_i8};
10use ipfrs_core::{Cid, Error, Result};
11use lru::LruCache;
12use std::collections::hash_map::DefaultHasher;
13use std::hash::{Hash, Hasher};
14use std::num::NonZeroUsize;
15use std::sync::{Arc, RwLock};
16
17#[derive(Debug, Clone, Default)]
27pub enum IndexBackend {
28 #[default]
30 Hnsw,
31 DiskAnn {
33 graph_path: std::path::PathBuf,
35 },
36}
37
38enum IndexHandle {
40 Hnsw(Arc<RwLock<VectorIndex>>),
41 DiskAnn(Arc<RwLock<DiskANNIndex>>),
42}
43
44#[derive(Debug, Clone)]
46pub struct RouterConfig {
47 pub dimension: usize,
49 pub metric: DistanceMetric,
51 pub max_connections: usize,
53 pub ef_construction: usize,
55 pub ef_search: usize,
57 pub cache_size: usize,
59 pub index_backend: IndexBackend,
62 pub quantize_vectors: bool,
65 pub quantization_bits: u8,
68}
69
70impl Default for RouterConfig {
71 fn default() -> Self {
72 Self {
73 dimension: 768, metric: DistanceMetric::Cosine,
75 max_connections: 16,
76 ef_construction: 200,
77 ef_search: 50,
78 cache_size: 1000, index_backend: IndexBackend::Hnsw,
80 quantize_vectors: false,
81 quantization_bits: 8,
82 }
83 }
84}
85
86impl RouterConfig {
87 pub fn low_latency(dimension: usize) -> Self {
101 Self {
102 dimension,
103 metric: DistanceMetric::Cosine,
104 max_connections: 12,
105 ef_construction: 150,
106 ef_search: 32,
107 cache_size: 2000,
108 ..Self::default()
109 }
110 }
111
112 pub fn high_recall(dimension: usize) -> Self {
126 Self {
127 dimension,
128 metric: DistanceMetric::Cosine,
129 max_connections: 32,
130 ef_construction: 400,
131 ef_search: 200,
132 cache_size: 1000,
133 ..Self::default()
134 }
135 }
136
137 pub fn memory_efficient(dimension: usize) -> Self {
151 Self {
152 dimension,
153 metric: DistanceMetric::Cosine,
154 max_connections: 8,
155 ef_construction: 100,
156 ef_search: 50,
157 cache_size: 500,
158 ..Self::default()
159 }
160 }
161
162 pub fn large_scale(dimension: usize) -> Self {
176 Self {
177 dimension,
178 metric: DistanceMetric::Cosine,
179 max_connections: 24,
180 ef_construction: 300,
181 ef_search: 100,
182 cache_size: 5000,
183 ..Self::default()
184 }
185 }
186
187 pub fn balanced(dimension: usize) -> Self {
201 Self {
202 dimension,
203 metric: DistanceMetric::Cosine,
204 max_connections: 16,
205 ef_construction: 200,
206 ef_search: 50,
207 cache_size: 1000,
208 ..Self::default()
209 }
210 }
211
212 pub fn with_metric(dimension: usize, metric: DistanceMetric) -> Self {
222 Self {
223 dimension,
224 metric,
225 ..Self::balanced(dimension)
226 }
227 }
228
229 pub fn with_cache_size(mut self, size: usize) -> Self {
239 self.cache_size = size;
240 self
241 }
242
243 pub fn with_ef_search(mut self, ef_search: usize) -> Self {
255 self.ef_search = ef_search;
256 self
257 }
258}
259
260#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
262pub struct QueryFilter {
263 pub min_score: Option<f32>,
265 pub max_score: Option<f32>,
267 pub max_results: Option<usize>,
269 pub cid_prefix: Option<String>,
271}
272
273impl Default for QueryFilter {
274 fn default() -> Self {
275 Self {
276 min_score: None,
277 max_score: None,
278 max_results: Some(10),
279 cid_prefix: None,
280 }
281 }
282}
283
284impl QueryFilter {
285 pub fn range(min: f32, max: f32) -> Self {
287 Self {
288 min_score: Some(min),
289 max_score: Some(max),
290 max_results: None,
291 cid_prefix: None,
292 }
293 }
294
295 pub fn threshold(min: f32) -> Self {
297 Self {
298 min_score: Some(min),
299 max_score: None,
300 max_results: None,
301 cid_prefix: None,
302 }
303 }
304
305 pub fn prefix(prefix: String) -> Self {
307 Self {
308 min_score: None,
309 max_score: None,
310 max_results: None,
311 cid_prefix: Some(prefix),
312 }
313 }
314
315 pub fn and(mut self, other: QueryFilter) -> Self {
317 if let Some(min) = other.min_score {
318 self.min_score = Some(self.min_score.unwrap_or(f32::MIN).max(min));
319 }
320 if let Some(max) = other.max_score {
321 self.max_score = Some(self.max_score.unwrap_or(f32::MAX).min(max));
322 }
323 if let Some(max_results) = other.max_results {
324 self.max_results = Some(self.max_results.unwrap_or(usize::MAX).min(max_results));
325 }
326 if other.cid_prefix.is_some() {
327 self.cid_prefix = other.cid_prefix;
328 }
329 self
330 }
331
332 pub fn limit(mut self, max: usize) -> Self {
334 self.max_results = Some(max);
335 self
336 }
337}
338
339type QueryCacheKey = u64;
341
342pub struct SemanticRouter {
348 index: IndexHandle,
350 config: RouterConfig,
352 query_cache: Arc<RwLock<LruCache<QueryCacheKey, Vec<SearchResult>>>>,
354}
355
356impl SemanticRouter {
357 pub fn new(config: RouterConfig) -> Result<Self> {
362 let cache_size = NonZeroUsize::new(config.cache_size)
363 .unwrap_or_else(|| NonZeroUsize::new(1000).expect("1000 is non-zero"));
364 let query_cache = LruCache::new(cache_size);
365
366 let index = match &config.index_backend {
367 IndexBackend::Hnsw => {
368 let hnsw = VectorIndex::new(
369 config.dimension,
370 config.metric,
371 config.max_connections,
372 config.ef_construction,
373 )?;
374 IndexHandle::Hnsw(Arc::new(RwLock::new(hnsw)))
375 }
376 IndexBackend::DiskAnn { graph_path } => {
377 use crate::diskann::DiskANNConfig;
378 let da_config = DiskANNConfig {
379 dimension: config.dimension,
380 ..DiskANNConfig::default()
381 };
382 let mut da_index = DiskANNIndex::new(da_config);
383 da_index.create(graph_path)?;
384 IndexHandle::DiskAnn(Arc::new(RwLock::new(da_index)))
385 }
386 };
387
388 Ok(Self {
389 index,
390 config,
391 query_cache: Arc::new(RwLock::new(query_cache)),
392 })
393 }
394
395 pub fn with_defaults() -> Result<Self> {
397 Self::new(RouterConfig::default())
398 }
399
400 fn maybe_quantize(&self, embedding: &[f32]) -> Vec<f32> {
405 if self.config.quantize_vectors {
406 let (q, scale, zero_point) = quantize_f32_to_i8(embedding);
407 dequantize_i8_to_f32(&q, scale, zero_point)
408 } else {
409 embedding.to_vec()
410 }
411 }
412
413 fn diskann_to_search_result(r: crate::diskann::SearchResult) -> SearchResult {
418 SearchResult {
419 cid: r.cid,
420 score: 1.0 / (1.0 + r.distance),
421 }
422 }
423
424 pub fn add(&self, cid: &Cid, embedding: &[f32]) -> Result<()> {
433 let v = self.maybe_quantize(embedding);
434 match &self.index {
435 IndexHandle::Hnsw(idx) => idx
436 .write()
437 .map_err(|_| Error::Internal("HNSW index lock poisoned".into()))?
438 .insert(cid, &v),
439 IndexHandle::DiskAnn(idx) => idx
440 .write()
441 .map_err(|_| Error::Internal("DiskANN index lock poisoned".into()))?
442 .insert(cid, &v),
443 }
444 }
445
446 pub fn add_batch(&self, items: &[(Cid, Vec<f32>)]) -> Result<()> {
454 match &self.index {
455 IndexHandle::Hnsw(idx) => {
456 if self.config.quantize_vectors {
457 let quantized: Vec<(Cid, Vec<f32>)> = items
458 .iter()
459 .map(|(cid, emb)| (*cid, self.maybe_quantize(emb)))
460 .collect();
461 idx.write()
462 .map_err(|_| Error::Internal("HNSW index lock poisoned".into()))?
463 .insert_batch(&quantized)
464 } else {
465 idx.write()
466 .map_err(|_| Error::Internal("HNSW index lock poisoned".into()))?
467 .insert_batch(items)
468 }
469 }
470 IndexHandle::DiskAnn(idx) => {
471 for (cid, emb) in items {
472 let v = self.maybe_quantize(emb);
473 idx.write()
474 .map_err(|_| Error::Internal("DiskANN index lock poisoned".into()))?
475 .insert(cid, &v)?;
476 }
477 Ok(())
478 }
479 }
480 }
481
482 pub fn remove(&self, cid: &Cid) -> Result<()> {
487 match &self.index {
488 IndexHandle::Hnsw(idx) => idx
489 .write()
490 .map_err(|_| Error::Internal("HNSW index lock poisoned".into()))?
491 .delete(cid),
492 IndexHandle::DiskAnn(_) => Err(Error::InvalidInput(
493 "DiskANN backend does not support deletion".to_string(),
494 )),
495 }
496 }
497
498 pub fn contains(&self, cid: &Cid) -> bool {
503 match &self.index {
504 IndexHandle::Hnsw(idx) => idx.read().map(|g| g.contains(cid)).unwrap_or(false),
505 IndexHandle::DiskAnn(_) => false,
506 }
507 }
508
509 pub async fn query(&self, query_embedding: &[f32], k: usize) -> Result<Vec<SearchResult>> {
515 self.query_with_filter(query_embedding, k, QueryFilter::default())
516 .await
517 }
518
519 pub async fn query_auto(&self, query_embedding: &[f32], k: usize) -> Result<Vec<SearchResult>> {
528 let optimal_ef_search = match &self.index {
529 IndexHandle::Hnsw(idx) => idx
530 .read()
531 .map(|g| g.compute_optimal_ef_search(k))
532 .unwrap_or(self.config.ef_search),
533 IndexHandle::DiskAnn(_) => self.config.ef_search,
534 };
535 self.query_with_ef(query_embedding, k, optimal_ef_search)
536 .await
537 }
538
539 pub async fn query_with_ef(
549 &self,
550 query_embedding: &[f32],
551 k: usize,
552 ef_search: usize,
553 ) -> Result<Vec<SearchResult>> {
554 let cache_key = Self::compute_cache_key(query_embedding, k, &QueryFilter::default());
555
556 if let Some(cached) = self
557 .query_cache
558 .write()
559 .map_err(|_| Error::Internal("cache lock poisoned".into()))?
560 .get(&cache_key)
561 {
562 return Ok(cached.clone());
563 }
564
565 let results = self.search_backend(query_embedding, k, ef_search)?;
566
567 self.query_cache
568 .write()
569 .map_err(|_| Error::Internal("cache lock poisoned".into()))?
570 .put(cache_key, results.clone());
571
572 Ok(results)
573 }
574
575 fn search_backend(
577 &self,
578 query: &[f32],
579 k: usize,
580 ef_search: usize,
581 ) -> Result<Vec<SearchResult>> {
582 match &self.index {
583 IndexHandle::Hnsw(idx) => idx
584 .read()
585 .map_err(|_| Error::Internal("HNSW index lock poisoned".into()))?
586 .search(query, k, ef_search),
587 IndexHandle::DiskAnn(idx) => {
588 let raw = idx
589 .read()
590 .map_err(|_| Error::Internal("DiskANN index lock poisoned".into()))?
591 .search(query, k)?;
592 Ok(raw
593 .into_iter()
594 .map(Self::diskann_to_search_result)
595 .collect())
596 }
597 }
598 }
599
600 pub async fn query_with_filter(
607 &self,
608 query_embedding: &[f32],
609 k: usize,
610 filter: QueryFilter,
611 ) -> Result<Vec<SearchResult>> {
612 let cache_key = Self::compute_cache_key(query_embedding, k, &filter);
613
614 if filter.min_score.is_none() && filter.cid_prefix.is_none() {
615 if let Some(cached) = self
616 .query_cache
617 .write()
618 .map_err(|_| Error::Internal("cache lock poisoned".into()))?
619 .get(&cache_key)
620 {
621 return Ok(cached.clone());
622 }
623 }
624
625 let fetch_k = if filter.min_score.is_some() || filter.cid_prefix.is_some() {
626 k * 2
627 } else {
628 k
629 };
630
631 let mut results = self.search_backend(query_embedding, fetch_k, self.config.ef_search)?;
632
633 if let Some(min_score) = filter.min_score {
634 results.retain(|r| r.score >= min_score);
635 }
636
637 if let Some(max_score) = filter.max_score {
638 results.retain(|r| r.score <= max_score);
639 }
640
641 if let Some(ref prefix) = filter.cid_prefix {
642 results.retain(|r| r.cid.to_string().starts_with(prefix));
643 }
644
645 if let Some(max_results) = filter.max_results {
646 results.truncate(max_results);
647 }
648
649 if filter.min_score.is_none() && filter.cid_prefix.is_none() {
650 self.query_cache
651 .write()
652 .map_err(|_| Error::Internal("cache lock poisoned".into()))?
653 .put(cache_key, results.clone());
654 }
655
656 Ok(results)
657 }
658
659 fn compute_cache_key(embedding: &[f32], k: usize, filter: &QueryFilter) -> QueryCacheKey {
661 let mut hasher = DefaultHasher::new();
662
663 for (i, &val) in embedding.iter().enumerate().step_by(8) {
665 (i, (val * 1000.0) as i32).hash(&mut hasher);
666 }
667
668 k.hash(&mut hasher);
669 filter.max_results.hash(&mut hasher);
670
671 hasher.finish()
672 }
673
674 pub fn clear_cache(&self) {
676 if let Ok(mut cache) = self.query_cache.write() {
677 cache.clear();
678 }
679 }
680
681 pub fn cache_stats(&self) -> CacheStats {
683 match self.query_cache.read() {
684 Ok(cache) => CacheStats {
685 size: cache.len(),
686 capacity: cache.cap().get(),
687 },
688 Err(_) => CacheStats {
689 size: 0,
690 capacity: 0,
691 },
692 }
693 }
694
695 pub fn stats(&self) -> RouterStats {
697 match &self.index {
698 IndexHandle::Hnsw(idx) => {
699 let guard = idx.read().unwrap_or_else(|p| p.into_inner());
700 RouterStats {
701 num_vectors: guard.len(),
702 dimension: guard.dimension(),
703 metric: guard.metric(),
704 }
705 }
706 IndexHandle::DiskAnn(idx) => {
707 let guard = idx.read().unwrap_or_else(|p| p.into_inner());
708 let s = guard.stats();
709 RouterStats {
710 num_vectors: s.num_vectors,
711 dimension: s.dimension,
712 metric: DistanceMetric::L2,
713 }
714 }
715 }
716 }
717
718 pub fn estimated_memory_bytes(&self) -> ipfrs_core::Result<usize> {
723 match &self.index {
724 IndexHandle::Hnsw(idx) => {
725 let guard = idx.read().map_err(|_| {
726 ipfrs_core::Error::Storage("HNSW index lock poisoned".to_string())
727 })?;
728 Ok(guard.estimated_memory_bytes())
729 }
730 IndexHandle::DiskAnn(idx) => {
731 let guard = idx.read().map_err(|_| {
732 ipfrs_core::Error::Storage("DiskANN index lock poisoned".to_string())
733 })?;
734 Ok(guard.stats().estimated_disk_size)
735 }
736 }
737 }
738
739 pub fn optimization_recommendations(&self) -> OptimizationRecommendations {
744 match &self.index {
745 IndexHandle::Hnsw(idx) => {
746 let guard = idx.read().unwrap_or_else(|p| p.into_inner());
747 let (m, ef_construction) = guard.compute_optimal_parameters();
748 OptimizationRecommendations {
749 recommended_m: m,
750 recommended_ef_construction: ef_construction,
751 current_size: guard.len(),
752 }
753 }
754 IndexHandle::DiskAnn(idx) => {
755 let guard = idx.read().unwrap_or_else(|p| p.into_inner());
756 let s = guard.stats();
757 OptimizationRecommendations {
758 recommended_m: 64,
759 recommended_ef_construction: 100,
760 current_size: s.num_vectors,
761 }
762 }
763 }
764 }
765
766 pub async fn save_index<P: AsRef<std::path::Path>>(&self, path: P) -> Result<()> {
774 match &self.index {
775 IndexHandle::Hnsw(idx) => idx
776 .read()
777 .map_err(|_| Error::Internal("HNSW index lock poisoned".into()))?
778 .save(path.as_ref()),
779 IndexHandle::DiskAnn(idx) => idx
780 .read()
781 .map_err(|_| Error::Internal("DiskANN index lock poisoned".into()))?
782 .save(),
783 }
784 }
785
786 pub async fn save_index_smart<P: AsRef<std::path::Path>>(&self, path: P) -> Result<()> {
793 match &self.index {
794 IndexHandle::Hnsw(idx) => {
795 use crate::persistence::IndexPersistence;
796 let persistence = IndexPersistence::new(path.as_ref());
797 let index_guard = idx.read().map_err(|_| {
798 ipfrs_core::Error::Internal("index lock poisoned in save_index_smart".into())
799 })?;
800 persistence.save_smart(&index_guard)
801 }
802 IndexHandle::DiskAnn(idx) => idx
803 .read()
804 .map_err(|_| Error::Internal("DiskANN index lock poisoned".into()))?
805 .save(),
806 }
807 }
808
809 pub async fn load_index_with_delta<P: AsRef<std::path::Path>>(&self, path: P) -> Result<()> {
817 match &self.index {
818 IndexHandle::Hnsw(idx) => {
819 use crate::persistence::{IndexPersistence, IndexPersistence as IP};
820 let persistence = IP::new(path.as_ref());
821
822 let mut snap = persistence.load()?;
823
824 if let Ok(delta) = persistence.load_incremental() {
825 if delta.delta_version > delta.base_version {
826 tracing::debug!(
827 base_version = delta.base_version,
828 delta_version = delta.delta_version,
829 changed = delta.changed_entries.len(),
830 "Applying incremental HNSW delta on top of full snapshot"
831 );
832 IndexPersistence::apply_incremental(&mut snap, &delta)?;
833 }
834 }
835
836 let loaded_index = VectorIndex::from_snapshot(&snap)?;
837 *idx.write().map_err(|_| {
838 ipfrs_core::Error::Internal(
839 "index write lock poisoned in load_index_with_delta".into(),
840 )
841 })? = loaded_index;
842
843 self.clear_cache();
844 Ok(())
845 }
846 IndexHandle::DiskAnn(_) => Err(Error::InvalidInput(
847 "load_index_with_delta is not supported for DiskANN backend".to_string(),
848 )),
849 }
850 }
851
852 pub async fn load_index<P: AsRef<std::path::Path>>(&self, path: P) -> Result<()> {
860 match &self.index {
861 IndexHandle::Hnsw(idx) => {
862 let loaded_index = VectorIndex::load(path.as_ref())?;
863 *idx.write()
864 .map_err(|_| Error::Internal("HNSW index lock poisoned".into()))? =
865 loaded_index;
866 self.clear_cache();
867 Ok(())
868 }
869 IndexHandle::DiskAnn(_) => Err(Error::InvalidInput(
870 "load_index is not supported for DiskANN backend; use RouterConfig at construction"
871 .to_string(),
872 )),
873 }
874 }
875
876 pub fn clear(&self) -> Result<()> {
881 match &self.index {
882 IndexHandle::Hnsw(idx) => {
883 let new_index = VectorIndex::new(
884 self.config.dimension,
885 self.config.metric,
886 self.config.max_connections,
887 self.config.ef_construction,
888 )?;
889 *idx.write()
890 .map_err(|_| Error::Internal("HNSW index lock poisoned".into()))? = new_index;
891 self.clear_cache();
892 Ok(())
893 }
894 IndexHandle::DiskAnn(_) => Err(Error::InvalidInput(
895 "clear is not supported for DiskANN backend".to_string(),
896 )),
897 }
898 }
899
900 pub async fn query_with_aggregations(
909 &self,
910 query_embedding: &[f32],
911 k: usize,
912 filter: QueryFilter,
913 ) -> Result<(Vec<SearchResult>, SearchAggregations)> {
914 let results = self.query_with_filter(query_embedding, k, filter).await?;
915 let aggregations = SearchAggregations::from_results(&results);
916 Ok((results, aggregations))
917 }
918
919 pub async fn query_batch(
931 &self,
932 query_embeddings: &[Vec<f32>],
933 k: usize,
934 ) -> Result<Vec<Vec<SearchResult>>> {
935 self.query_batch_with_filter(query_embeddings, k, QueryFilter::default())
936 .await
937 }
938
939 pub async fn query_batch_with_filter(
951 &self,
952 query_embeddings: &[Vec<f32>],
953 k: usize,
954 filter: QueryFilter,
955 ) -> Result<Vec<Vec<SearchResult>>> {
956 use rayon::prelude::*;
957
958 let ef_search = self.config.ef_search;
959
960 let results: Result<Vec<Vec<SearchResult>>> = query_embeddings
961 .par_iter()
962 .map(|embedding| {
963 let cache_key = Self::compute_cache_key(embedding, k, &filter);
964
965 if filter.min_score.is_none() && filter.cid_prefix.is_none() {
966 if let Ok(mut cache) = self.query_cache.write() {
967 if let Some(cached) = cache.get(&cache_key) {
968 return Ok(cached.clone());
969 }
970 }
971 }
972
973 let fetch_k = if filter.min_score.is_some() || filter.cid_prefix.is_some() {
974 k * 2
975 } else {
976 k
977 };
978
979 let mut results = self.search_backend(embedding, fetch_k, ef_search)?;
980
981 if let Some(min_score) = filter.min_score {
982 results.retain(|r| r.score >= min_score);
983 }
984 if let Some(max_score) = filter.max_score {
985 results.retain(|r| r.score <= max_score);
986 }
987 if let Some(ref prefix) = filter.cid_prefix {
988 results.retain(|r| r.cid.to_string().starts_with(prefix));
989 }
990 if let Some(max_results) = filter.max_results {
991 results.truncate(max_results);
992 }
993
994 if filter.min_score.is_none() && filter.cid_prefix.is_none() {
995 if let Ok(mut cache) = self.query_cache.write() {
996 cache.put(cache_key, results.clone());
997 }
998 }
999
1000 Ok(results)
1001 })
1002 .collect();
1003
1004 results
1005 }
1006
1007 pub async fn query_batch_with_ef(
1019 &self,
1020 query_embeddings: &[Vec<f32>],
1021 k: usize,
1022 ef_search: usize,
1023 ) -> Result<Vec<Vec<SearchResult>>> {
1024 use rayon::prelude::*;
1025
1026 let results: Result<Vec<Vec<SearchResult>>> = query_embeddings
1027 .par_iter()
1028 .map(|embedding| {
1029 let cache_key = Self::compute_cache_key(embedding, k, &QueryFilter::default());
1030
1031 if let Ok(mut cache) = self.query_cache.write() {
1032 if let Some(cached) = cache.get(&cache_key) {
1033 return Ok(cached.clone());
1034 }
1035 }
1036
1037 let results = self.search_backend(embedding, k, ef_search)?;
1038
1039 if let Ok(mut cache) = self.query_cache.write() {
1040 cache.put(cache_key, results.clone());
1041 }
1042
1043 Ok(results)
1044 })
1045 .collect();
1046
1047 results
1048 }
1049
1050 pub fn batch_stats(&self, batch_results: &[Vec<SearchResult>]) -> BatchStats {
1054 let total_queries = batch_results.len();
1055 let total_results: usize = batch_results.iter().map(|r| r.len()).sum();
1056 let avg_results_per_query = if total_queries > 0 {
1057 total_results as f32 / total_queries as f32
1058 } else {
1059 0.0
1060 };
1061
1062 let all_scores: Vec<f32> = batch_results
1063 .iter()
1064 .flat_map(|results| results.iter().map(|r| r.score))
1065 .collect();
1066
1067 let avg_score = if !all_scores.is_empty() {
1068 all_scores.iter().sum::<f32>() / all_scores.len() as f32
1069 } else {
1070 0.0
1071 };
1072
1073 BatchStats {
1074 total_queries,
1075 total_results,
1076 avg_results_per_query,
1077 avg_score,
1078 }
1079 }
1080}
1081
1082#[derive(Debug, Clone)]
1084pub struct RouterStats {
1085 pub num_vectors: usize,
1087 pub dimension: usize,
1089 pub metric: DistanceMetric,
1091}
1092
1093#[derive(Debug, Clone)]
1095pub struct CacheStats {
1096 pub size: usize,
1098 pub capacity: usize,
1100}
1101
1102#[derive(Debug, Clone)]
1104pub struct BatchStats {
1105 pub total_queries: usize,
1107 pub total_results: usize,
1109 pub avg_results_per_query: f32,
1111 pub avg_score: f32,
1113}
1114
1115#[derive(Debug, Clone)]
1117pub struct OptimizationRecommendations {
1118 pub recommended_m: usize,
1120 pub recommended_ef_construction: usize,
1122 pub current_size: usize,
1124}
1125
1126#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1128pub struct SearchAggregations {
1129 pub total_count: usize,
1131 pub avg_score: f32,
1133 pub min_score: f32,
1135 pub max_score: f32,
1137 pub score_buckets: Vec<ScoreBucket>,
1139}
1140
1141#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1143pub struct ScoreBucket {
1144 pub range: (f32, f32),
1146 pub count: usize,
1148}
1149
1150impl SearchAggregations {
1151 pub fn from_results(results: &[SearchResult]) -> Self {
1153 if results.is_empty() {
1154 return Self {
1155 total_count: 0,
1156 avg_score: 0.0,
1157 min_score: 0.0,
1158 max_score: 0.0,
1159 score_buckets: Vec::new(),
1160 };
1161 }
1162
1163 let total_count = results.len();
1164 let sum: f32 = results.iter().map(|r| r.score).sum();
1165 let avg_score = sum / total_count as f32;
1166 let min_score = results
1167 .iter()
1168 .map(|r| r.score)
1169 .min_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
1170 .expect("results is non-empty (total_count > 0)");
1171 let max_score = results
1172 .iter()
1173 .map(|r| r.score)
1174 .max_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
1175 .expect("results is non-empty (total_count > 0)");
1176
1177 let bucket_count = 10;
1179 let range = max_score - min_score;
1180 let bucket_size = if range > 0.0 {
1181 range / bucket_count as f32
1182 } else {
1183 1.0
1184 };
1185
1186 let mut buckets = vec![0; bucket_count];
1187 for result in results {
1188 let bucket_idx = if range > 0.0 {
1189 ((result.score - min_score) / bucket_size).floor() as usize
1190 } else {
1191 0
1192 };
1193 let bucket_idx = bucket_idx.min(bucket_count - 1);
1194 buckets[bucket_idx] += 1;
1195 }
1196
1197 let score_buckets = buckets
1198 .into_iter()
1199 .enumerate()
1200 .map(|(i, count)| ScoreBucket {
1201 range: (
1202 min_score + i as f32 * bucket_size,
1203 min_score + (i + 1) as f32 * bucket_size,
1204 ),
1205 count,
1206 })
1207 .collect();
1208
1209 Self {
1210 total_count,
1211 avg_score,
1212 min_score,
1213 max_score,
1214 score_buckets,
1215 }
1216 }
1217}
1218
1219impl Default for SemanticRouter {
1220 fn default() -> Self {
1221 Self::with_defaults().expect("Failed to create default SemanticRouter")
1222 }
1223}
1224
1225#[cfg(test)]
1226mod tests {
1227 use super::*;
1228
1229 #[tokio::test]
1230 async fn test_router_creation() {
1231 let router = SemanticRouter::with_defaults();
1232 assert!(router.is_ok());
1233 }
1234
1235 #[tokio::test]
1236 async fn test_add_and_query() {
1237 let router =
1238 SemanticRouter::with_defaults().expect("test: SemanticRouter::with_defaults failed");
1239
1240 let cid1 = "bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi"
1241 .parse::<Cid>()
1242 .expect("test: CID parse failed");
1243 let embedding1 = vec![0.5; 768];
1244
1245 router
1246 .add(&cid1, &embedding1)
1247 .expect("test: router.add cid1 failed");
1248
1249 let results = router
1250 .query(&embedding1, 1)
1251 .await
1252 .expect("test: router.query failed");
1253 assert_eq!(results.len(), 1);
1254 assert_eq!(results[0].cid, cid1);
1255 }
1256
1257 #[tokio::test]
1258 async fn test_filtering() {
1259 let router =
1260 SemanticRouter::with_defaults().expect("test: SemanticRouter::with_defaults failed");
1261
1262 let cid1 = "bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi"
1263 .parse::<Cid>()
1264 .expect("test: CID parse failed");
1265 let embedding1 = vec![0.5; 768];
1266
1267 router
1268 .add(&cid1, &embedding1)
1269 .expect("test: router.add cid1 failed");
1270
1271 let filter = QueryFilter {
1273 min_score: Some(0.9),
1274 max_score: None,
1275 max_results: Some(10),
1276 cid_prefix: None,
1277 };
1278
1279 let results = router
1280 .query_with_filter(&embedding1, 10, filter)
1281 .await
1282 .expect("test: router.query_with_filter failed");
1283
1284 assert!(!results.is_empty());
1286 }
1287
1288 #[tokio::test]
1289 async fn test_integration_with_blocks() {
1290 use bytes::Bytes;
1291 use ipfrs_core::Block;
1292
1293 let router = SemanticRouter::new(RouterConfig {
1295 dimension: 3,
1296 ..Default::default()
1297 })
1298 .expect("test: SemanticRouter::new with dimension 3 failed");
1299
1300 let data1 = Bytes::from_static(b"Hello, semantic search!");
1302 let data2 = Bytes::from_static(b"Goodbye, semantic search!");
1303 let data3 = Bytes::from_static(b"Hello, world!");
1304
1305 let block1 = Block::new(data1).expect("test: Block::new data1 failed");
1306 let block2 = Block::new(data2).expect("test: Block::new data2 failed");
1307 let block3 = Block::new(data3).expect("test: Block::new data3 failed");
1308
1309 let embedding1 = vec![1.0, 0.0, 0.0]; let embedding2 = vec![0.0, 1.0, 0.0]; let embedding3 = vec![0.9, 0.1, 0.0]; router
1317 .add(block1.cid(), &embedding1)
1318 .expect("test: router.add block1 failed");
1319 router
1320 .add(block2.cid(), &embedding2)
1321 .expect("test: router.add block2 failed");
1322 router
1323 .add(block3.cid(), &embedding3)
1324 .expect("test: router.add block3 failed");
1325
1326 let query_embedding = vec![1.0, 0.0, 0.0];
1328 let results = router
1329 .query(&query_embedding, 2)
1330 .await
1331 .expect("test: router.query failed");
1332
1333 assert_eq!(results.len(), 2);
1335 assert_eq!(results[0].cid, *block1.cid());
1336 }
1337
1338 #[tokio::test]
1339 async fn test_integration_with_tensor_metadata() {
1340 use ipfrs_core::{TensorDtype, TensorMetadata, TensorShape};
1341
1342 let router = SemanticRouter::new(RouterConfig {
1343 dimension: 2,
1344 ..Default::default()
1345 })
1346 .expect("test: SemanticRouter::new with dimension 2 failed");
1347
1348 let shape1 = TensorShape::new(vec![1, 768]);
1350 let mut metadata1 = TensorMetadata::new(shape1, TensorDtype::F32);
1351 metadata1.name = Some("vision_embedding".to_string());
1352 metadata1
1353 .metadata
1354 .insert("semantic_tag".to_string(), "vision".to_string());
1355
1356 let shape2 = TensorShape::new(vec![1, 768]);
1357 let mut metadata2 = TensorMetadata::new(shape2, TensorDtype::F32);
1358 metadata2.name = Some("text_embedding".to_string());
1359 metadata2
1360 .metadata
1361 .insert("semantic_tag".to_string(), "text".to_string());
1362
1363 let vision_embedding = vec![1.0, 0.0];
1365 let text_embedding = vec![0.0, 1.0];
1366
1367 let cid1 = "bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi"
1369 .parse::<Cid>()
1370 .expect("test: CID1 parse failed");
1371 let cid2 = "bafybeibazl2z6vqxqqzmhmvx2hfpxqtwggqgbbyy3sxkq4vzq6cqsvwbjy"
1372 .parse::<Cid>()
1373 .expect("test: CID2 parse failed");
1374
1375 router
1377 .add(&cid1, &vision_embedding)
1378 .expect("test: router.add cid1 vision failed");
1379 router
1380 .add(&cid2, &text_embedding)
1381 .expect("test: router.add cid2 text failed");
1382
1383 let results = router
1385 .query(&vision_embedding, 1)
1386 .await
1387 .expect("test: router.query vision failed");
1388 assert_eq!(results.len(), 1);
1389 assert_eq!(results[0].cid, cid1);
1390 }
1391
1392 #[tokio::test]
1393 async fn test_large_scale_indexing() {
1394 use rand::RngExt;
1395
1396 let dimension = 128;
1397
1398 let router = SemanticRouter::new(RouterConfig {
1400 dimension,
1401 ..Default::default()
1402 })
1403 .expect("test: SemanticRouter::new with dimension 128 failed");
1404
1405 let mut rng = rand::rng();
1407 let num_items = 1000;
1408
1409 let mut indexed_cids = Vec::new();
1410
1411 for i in 0..num_items {
1412 use multihash_codetable::{Code, MultihashDigest};
1414 let data = format!("large_scale_test_{}", i);
1415 let hash = Code::Sha2_256.digest(data.as_bytes());
1416 let cid = Cid::new_v1(0x55, hash);
1417
1418 let embedding: Vec<f32> = (0..dimension)
1420 .map(|_| rng.random_range(-1.0..1.0))
1421 .collect();
1422
1423 router
1424 .add(&cid, &embedding)
1425 .expect("test: router.add large scale failed");
1426 indexed_cids.push((cid, embedding));
1427 }
1428
1429 let stats = router.stats();
1431 assert_eq!(stats.num_vectors, num_items);
1432
1433 let (test_cid, test_embedding) = &indexed_cids[42];
1435 let results = router
1436 .query(test_embedding, 1)
1437 .await
1438 .expect("test: router.query large scale failed");
1439
1440 assert_eq!(results.len(), 1);
1442 assert_eq!(results[0].cid, *test_cid);
1443 }
1444
1445 #[tokio::test]
1446 async fn test_cache_effectiveness() {
1447 let router =
1448 SemanticRouter::with_defaults().expect("test: SemanticRouter::with_defaults failed");
1449
1450 let cid1 = "bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi"
1451 .parse::<Cid>()
1452 .expect("test: CID parse failed");
1453 let embedding1 = vec![0.5; 768];
1454
1455 router
1456 .add(&cid1, &embedding1)
1457 .expect("test: router.add cid1 failed");
1458
1459 for _ in 0..10 {
1461 let _ = router
1462 .query(&embedding1, 1)
1463 .await
1464 .expect("test: router.query cache test failed");
1465 }
1466
1467 let cache_stats = router.cache_stats();
1469 assert_eq!(cache_stats.size, 1, "Cache should have 1 unique query");
1470 assert!(cache_stats.capacity > 0, "Cache should have capacity");
1471 }
1472
1473 #[tokio::test]
1474 async fn test_batch_query() {
1475 use rand::RngExt;
1476
1477 let dimension = 128;
1478
1479 let router = SemanticRouter::new(RouterConfig {
1481 dimension,
1482 ..Default::default()
1483 })
1484 .expect("test: SemanticRouter::new with dimension 128 failed");
1485
1486 let mut rng = rand::rng();
1488 let num_items = 100;
1489
1490 for i in 0..num_items {
1491 use multihash_codetable::{Code, MultihashDigest};
1493 let data = format!("batch_test_{}", i);
1494 let hash = Code::Sha2_256.digest(data.as_bytes());
1495 let cid = Cid::new_v1(0x55, hash);
1496
1497 let embedding: Vec<f32> = (0..dimension)
1499 .map(|_| rng.random_range(-1.0..1.0))
1500 .collect();
1501
1502 router
1503 .add(&cid, &embedding)
1504 .expect("test: router.add batch test failed");
1505 }
1506
1507 let batch_size = 10;
1509 let query_batch: Vec<Vec<f32>> = (0..batch_size)
1510 .map(|_| {
1511 (0..dimension)
1512 .map(|_| rng.random_range(-1.0..1.0))
1513 .collect()
1514 })
1515 .collect();
1516
1517 let results = router
1519 .query_batch(&query_batch, 5)
1520 .await
1521 .expect("test: router.query_batch failed");
1522
1523 assert_eq!(results.len(), batch_size);
1525 for result in &results {
1526 assert!(!result.is_empty());
1527 assert!(result.len() <= 5);
1528 }
1529
1530 let stats = router.batch_stats(&results);
1532 assert_eq!(stats.total_queries, batch_size);
1533 assert!(stats.total_results > 0);
1534 assert!(stats.avg_results_per_query > 0.0);
1535 }
1536
1537 #[tokio::test]
1538 async fn test_batch_query_with_filter() {
1539 use rand::RngExt;
1540
1541 let dimension = 64;
1542
1543 let router = SemanticRouter::new(RouterConfig {
1544 dimension,
1545 ..Default::default()
1546 })
1547 .expect("test: SemanticRouter::new with dimension 64 failed");
1548
1549 let mut rng = rand::rng();
1551 let num_items = 50;
1552
1553 for i in 0..num_items {
1554 use multihash_codetable::{Code, MultihashDigest};
1555 let data = format!("filter_batch_test_{}", i);
1556 let hash = Code::Sha2_256.digest(data.as_bytes());
1557 let cid = Cid::new_v1(0x55, hash);
1558
1559 let embedding: Vec<f32> = (0..dimension)
1560 .map(|_| rng.random_range(-1.0..1.0))
1561 .collect();
1562
1563 router
1564 .add(&cid, &embedding)
1565 .expect("test: router.add filter batch test failed");
1566 }
1567
1568 let batch_size = 5;
1570 let query_batch: Vec<Vec<f32>> = (0..batch_size)
1571 .map(|_| {
1572 (0..dimension)
1573 .map(|_| rng.random_range(-1.0..1.0))
1574 .collect()
1575 })
1576 .collect();
1577
1578 let filter = QueryFilter {
1580 min_score: Some(0.0),
1581 max_results: Some(3),
1582 ..Default::default()
1583 };
1584
1585 let results = router
1586 .query_batch_with_filter(&query_batch, 5, filter)
1587 .await
1588 .expect("test: router.query_batch_with_filter failed");
1589
1590 assert_eq!(results.len(), batch_size);
1592 for result in &results {
1593 assert!(result.len() <= 3); }
1595 }
1596
1597 #[tokio::test]
1598 async fn test_batch_query_with_ef() {
1599 use rand::RngExt;
1600
1601 let dimension = 64;
1602
1603 let router = SemanticRouter::new(RouterConfig {
1604 dimension,
1605 ..Default::default()
1606 })
1607 .expect("test: SemanticRouter::new with dimension 64 failed");
1608
1609 let mut rng = rand::rng();
1611 let num_items = 50;
1612
1613 for i in 0..num_items {
1614 use multihash_codetable::{Code, MultihashDigest};
1615 let data = format!("ef_batch_test_{}", i);
1616 let hash = Code::Sha2_256.digest(data.as_bytes());
1617 let cid = Cid::new_v1(0x55, hash);
1618
1619 let embedding: Vec<f32> = (0..dimension)
1620 .map(|_| rng.random_range(-1.0..1.0))
1621 .collect();
1622
1623 router
1624 .add(&cid, &embedding)
1625 .expect("test: router.add ef batch test failed");
1626 }
1627
1628 let batch_size = 5;
1630 let query_batch: Vec<Vec<f32>> = (0..batch_size)
1631 .map(|_| {
1632 (0..dimension)
1633 .map(|_| rng.random_range(-1.0..1.0))
1634 .collect()
1635 })
1636 .collect();
1637
1638 let results = router
1640 .query_batch_with_ef(&query_batch, 3, 100)
1641 .await
1642 .expect("test: router.query_batch_with_ef failed");
1643
1644 assert_eq!(results.len(), batch_size);
1646 for result in &results {
1647 assert!(!result.is_empty());
1648 assert!(result.len() <= 3);
1649 }
1650 }
1651
1652 #[tokio::test]
1657 async fn test_router_diskann_backend_selection() {
1658 let tmp = std::env::temp_dir().join(format!(
1660 "ipfrs_diskann_test_{}.idx",
1661 std::time::SystemTime::now()
1662 .duration_since(std::time::UNIX_EPOCH)
1663 .map(|d| d.as_nanos())
1664 .unwrap_or(0)
1665 ));
1666
1667 let config = RouterConfig {
1668 dimension: 8,
1669 index_backend: IndexBackend::DiskAnn {
1670 graph_path: tmp.clone(),
1671 },
1672 ..RouterConfig::balanced(8)
1673 };
1674
1675 let router = SemanticRouter::new(config).expect("should create DiskANN router");
1676
1677 use multihash_codetable::{Code, MultihashDigest};
1679 for i in 0..3usize {
1680 let data = format!("diskann_test_{i}");
1681 let hash = Code::Sha2_256.digest(data.as_bytes());
1682 let cid = Cid::new_v1(0x55, hash);
1683 let emb: Vec<f32> = (0..8).map(|d| (i * 8 + d) as f32 * 0.01).collect();
1684 router.add(&cid, &emb).expect("insert should succeed");
1685 }
1686
1687 let s = router.stats();
1689 assert_eq!(s.num_vectors, 3, "DiskANN should report 3 vectors");
1690 assert_eq!(s.dimension, 8);
1691
1692 let query: Vec<f32> = (0..8).map(|d| d as f32 * 0.01).collect();
1694 let results = router
1695 .query(&query, 2)
1696 .await
1697 .expect("search should succeed");
1698 assert!(!results.is_empty(), "DiskANN search should return results");
1699
1700 let _ = std::fs::remove_file(&tmp);
1702 let _ = std::fs::remove_file(format!("{}.vectors", tmp.display()));
1703 }
1704
1705 #[tokio::test]
1706 async fn test_router_quantized_mode() {
1707 use multihash_codetable::{Code, MultihashDigest};
1708
1709 let config = RouterConfig {
1710 dimension: 16,
1711 quantize_vectors: true,
1712 quantization_bits: 8,
1713 ..RouterConfig::balanced(16)
1714 };
1715
1716 let router = SemanticRouter::new(config).expect("should create quantized router");
1717
1718 let mut cids = Vec::new();
1720 for i in 0..5usize {
1721 let data = format!("quant_test_{i}");
1722 let hash = Code::Sha2_256.digest(data.as_bytes());
1723 let cid = Cid::new_v1(0x55, hash);
1724 let emb: Vec<f32> = (0..16).map(|d| (i as f32 + d as f32) * 0.05).collect();
1725 router
1726 .add(&cid, &emb)
1727 .expect("quantized insert should succeed");
1728 cids.push((cid, emb));
1729 }
1730
1731 assert_eq!(router.stats().num_vectors, 5);
1732
1733 let (_, ref query_emb) = cids[0];
1735 let results = router
1736 .query(query_emb, 3)
1737 .await
1738 .expect("quantized search should succeed");
1739 assert!(
1740 !results.is_empty(),
1741 "quantized search should return results"
1742 );
1743 }
1744}