1use hnsw_rs::prelude::*;
7use ipfrs_core::{Cid, Error, Result};
8use std::collections::HashMap;
9use std::sync::{Arc, RwLock};
10
11use crate::persistence::IncrementalTracker;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
15pub enum DistanceMetric {
16 L2,
18 Cosine,
20 DotProduct,
22}
23
24#[derive(Debug, Clone)]
26pub struct SearchResult {
27 pub cid: Cid,
29 pub score: f32,
31}
32
33#[derive(Debug, Clone)]
35pub struct IncrementalBuildStats {
36 pub initial_size: usize,
38 pub final_size: usize,
40 pub vectors_inserted: usize,
42 pub vectors_failed: usize,
44 pub chunks_processed: usize,
46 pub should_rebuild: bool,
48}
49
50#[derive(Debug, Clone)]
52pub struct RebuildStats {
53 pub vectors_reinserted: usize,
55 pub old_parameters: (usize, usize),
57 pub new_parameters: (usize, usize),
59}
60
61#[derive(Debug, Clone)]
63pub struct BuildHealthStats {
64 pub index_size: usize,
66 pub current_m: usize,
68 pub current_ef_construction: usize,
70 pub optimal_m: usize,
72 pub optimal_ef_construction: usize,
74 pub parameter_efficiency: f32,
76 pub rebuild_recommended: bool,
78}
79
80pub struct VectorIndex {
85 index: Arc<RwLock<Hnsw<'static, f32, DistL2>>>,
87 id_to_cid: Arc<RwLock<HashMap<usize, Cid>>>,
89 cid_to_id: Arc<RwLock<HashMap<Cid, usize>>>,
91 vectors: Arc<RwLock<HashMap<Cid, Vec<f32>>>>,
93 next_id: Arc<RwLock<usize>>,
95 dimension: usize,
97 metric: DistanceMetric,
99 pub(crate) tracker: Arc<RwLock<IncrementalTracker>>,
103}
104
105impl VectorIndex {
106 pub fn new(
114 dimension: usize,
115 metric: DistanceMetric,
116 max_nb_connection: usize,
117 ef_construction: usize,
118 ) -> Result<Self> {
119 if dimension == 0 {
120 return Err(Error::InvalidInput(
121 "Vector dimension must be greater than 0".to_string(),
122 ));
123 }
124
125 let index = Hnsw::<f32, DistL2>::new(
127 max_nb_connection,
128 dimension,
129 ef_construction,
130 200, DistL2 {},
132 );
133
134 Ok(Self {
135 index: Arc::new(RwLock::new(index)),
136 id_to_cid: Arc::new(RwLock::new(HashMap::new())),
137 cid_to_id: Arc::new(RwLock::new(HashMap::new())),
138 vectors: Arc::new(RwLock::new(HashMap::new())),
139 next_id: Arc::new(RwLock::new(0)),
140 dimension,
141 metric,
142 tracker: Arc::new(RwLock::new(IncrementalTracker::new())),
143 })
144 }
145
146 pub fn with_defaults(dimension: usize) -> Result<Self> {
150 Self::new(dimension, DistanceMetric::L2, 16, 200)
151 }
152
153 pub fn insert(&mut self, cid: &Cid, vector: &[f32]) -> Result<()> {
159 if vector.len() != self.dimension {
160 return Err(Error::InvalidInput(format!(
161 "Vector dimension mismatch: expected {}, got {}",
162 self.dimension,
163 vector.len()
164 )));
165 }
166
167 if self
169 .cid_to_id
170 .read()
171 .unwrap_or_else(|e| e.into_inner())
172 .contains_key(cid)
173 {
174 return Err(Error::InvalidInput(format!(
175 "CID already exists in index: {}",
176 cid
177 )));
178 }
179
180 let mut next_id = self.next_id.write().unwrap_or_else(|e| e.into_inner());
182 let id = *next_id;
183 *next_id += 1;
184 drop(next_id);
185
186 let normalized = self.normalize_vector(vector);
188
189 let data_with_id = (normalized.as_slice(), id);
191 self.index
192 .write()
193 .unwrap_or_else(|e| e.into_inner())
194 .insert(data_with_id);
195
196 self.vectors
198 .write()
199 .unwrap_or_else(|e| e.into_inner())
200 .insert(*cid, vector.to_vec());
201
202 self.id_to_cid
204 .write()
205 .unwrap_or_else(|e| e.into_inner())
206 .insert(id, *cid);
207 self.cid_to_id
208 .write()
209 .unwrap_or_else(|e| e.into_inner())
210 .insert(*cid, id);
211
212 if let Ok(mut t) = self.tracker.write() {
215 t.mark_dirty(id as u32);
216 }
217
218 Ok(())
219 }
220
221 pub fn add_embedding(&mut self, cid: &Cid, vector: &[f32]) -> Result<()> {
227 self.insert(cid, vector)
228 }
229
230 pub fn search(&self, query: &[f32], k: usize, ef_search: usize) -> Result<Vec<SearchResult>> {
237 if query.len() != self.dimension {
238 return Err(Error::InvalidInput(format!(
239 "Query dimension mismatch: expected {}, got {}",
240 self.dimension,
241 query.len()
242 )));
243 }
244
245 if k == 0 {
246 return Ok(Vec::new());
247 }
248
249 let normalized = self.normalize_vector(query);
251
252 let neighbors =
254 self.index
255 .read()
256 .unwrap_or_else(|e| e.into_inner())
257 .search(&normalized, k, ef_search);
258
259 let id_to_cid = self.id_to_cid.read().unwrap_or_else(|e| e.into_inner());
261 let results: Vec<SearchResult> = neighbors
262 .iter()
263 .filter_map(|neighbor| {
264 id_to_cid.get(&neighbor.d_id).map(|cid| SearchResult {
265 cid: *cid,
266 score: self.convert_distance(neighbor.distance),
267 })
268 })
269 .collect();
270
271 Ok(results)
272 }
273
274 pub fn delete(&mut self, cid: &Cid) -> Result<()> {
276 let id = self
277 .cid_to_id
278 .read()
279 .unwrap_or_else(|e| e.into_inner())
280 .get(cid)
281 .copied()
282 .ok_or_else(|| Error::NotFound(format!("CID not found in index: {}", cid)))?;
283
284 self.vectors
286 .write()
287 .unwrap_or_else(|e| e.into_inner())
288 .remove(cid);
289
290 self.cid_to_id
292 .write()
293 .unwrap_or_else(|e| e.into_inner())
294 .remove(cid);
295 self.id_to_cid
296 .write()
297 .unwrap_or_else(|e| e.into_inner())
298 .remove(&id);
299
300 Ok(())
304 }
305
306 pub fn contains(&self, cid: &Cid) -> bool {
308 self.cid_to_id
309 .read()
310 .unwrap_or_else(|e| e.into_inner())
311 .contains_key(cid)
312 }
313
314 pub fn len(&self) -> usize {
316 self.cid_to_id
317 .read()
318 .unwrap_or_else(|e| e.into_inner())
319 .len()
320 }
321
322 pub fn is_empty(&self) -> bool {
324 self.len() == 0
325 }
326
327 pub fn dimension(&self) -> usize {
329 self.dimension
330 }
331
332 pub fn metric(&self) -> DistanceMetric {
334 self.metric
335 }
336
337 pub fn get_all_cids(&self) -> Vec<Cid> {
340 self.cid_to_id
341 .read()
342 .unwrap_or_else(|e| e.into_inner())
343 .keys()
344 .copied()
345 .collect()
346 }
347
348 pub fn get_embedding(&self, cid: &Cid) -> Option<Vec<f32>> {
352 self.vectors
353 .read()
354 .unwrap_or_else(|e| e.into_inner())
355 .get(cid)
356 .cloned()
357 }
358
359 pub fn get_all_embeddings(&self) -> Vec<(Cid, Vec<f32>)> {
363 self.vectors
364 .read()
365 .unwrap_or_else(|e| e.into_inner())
366 .iter()
367 .map(|(cid, vec)| (*cid, vec.clone()))
368 .collect()
369 }
370
371 pub fn iter(&self) -> Vec<(Cid, Vec<f32>)> {
375 self.get_all_embeddings()
376 }
377
378 fn normalize_vector(&self, vector: &[f32]) -> Vec<f32> {
380 match self.metric {
381 DistanceMetric::L2 => vector.to_vec(),
382 DistanceMetric::Cosine => {
383 let norm: f32 = vector.iter().map(|x| x * x).sum::<f32>().sqrt();
385 if norm > 0.0 {
386 vector.iter().map(|x| x / norm).collect()
387 } else {
388 vector.to_vec()
389 }
390 }
391 DistanceMetric::DotProduct => {
392 vector.to_vec()
394 }
395 }
396 }
397
398 fn convert_distance(&self, distance: f32) -> f32 {
400 match self.metric {
401 DistanceMetric::L2 => distance,
402 DistanceMetric::Cosine => {
403 1.0 - (distance * distance / 2.0)
406 }
407 DistanceMetric::DotProduct => {
408 -distance
410 }
411 }
412 }
413
414 pub fn estimated_memory_bytes(&self) -> usize {
423 let n = self.len();
424 if n == 0 {
425 return 0;
426 }
427 let m = self
428 .index
429 .read()
430 .map(|idx| idx.get_max_nb_connection() as usize)
431 .unwrap_or(16);
432 let per_node = self.dimension * 4 + m * 8;
433 n * per_node
434 }
435
436 pub fn compute_optimal_parameters(&self) -> (usize, usize) {
443 let size = self.len();
444
445 if size < 10_000 {
446 (16, 200) } else if size < 100_000 {
448 (32, 400) } else {
450 (48, 600) }
452 }
453
454 pub fn compute_optimal_ef_search(&self, k: usize) -> usize {
458 if k <= 50 {
461 50.max(k)
462 } else {
463 2 * k
464 }
465 }
466
467 pub fn get_parameter_recommendations(&self, use_case: UseCase) -> ParameterRecommendation {
469 let size = self.len();
470 ParameterTuner::recommend(size, self.dimension, use_case)
471 }
472
473 pub fn insert_batch(&mut self, items: &[(Cid, Vec<f32>)]) -> Result<()> {
480 for (cid, vector) in items {
481 self.insert(cid, vector)?;
482 }
483 Ok(())
484 }
485
486 pub fn insert_incremental(
498 &mut self,
499 items: &[(Cid, Vec<f32>)],
500 chunk_size: usize,
501 ) -> Result<IncrementalBuildStats> {
502 let start_size = self.len();
503 let mut chunks_processed = 0;
504 let mut failed_inserts = 0;
505
506 for chunk in items.chunks(chunk_size) {
508 for (cid, vector) in chunk {
509 if let Err(_e) = self.insert(cid, vector) {
510 failed_inserts += 1;
511 }
512 }
513 chunks_processed += 1;
514 }
515
516 let end_size = self.len();
517 let inserted = end_size - start_size;
518
519 let should_rebuild = self.should_rebuild();
521
522 Ok(IncrementalBuildStats {
523 initial_size: start_size,
524 final_size: end_size,
525 vectors_inserted: inserted,
526 vectors_failed: failed_inserts,
527 chunks_processed,
528 should_rebuild,
529 })
530 }
531
532 pub fn should_rebuild(&self) -> bool {
539 let size = self.len();
540 let (current_m, current_ef) = {
541 let idx = self.index.read().unwrap_or_else(|e| e.into_inner());
542 (
543 idx.get_max_nb_connection() as usize,
544 idx.get_ef_construction(),
545 )
546 };
547
548 let (optimal_m, optimal_ef) = self.compute_optimal_parameters();
549
550 if current_m < optimal_m / 2 || current_ef < optimal_ef / 2 {
552 return true;
553 }
554
555 if size > 100_000 && current_m < 32 {
557 return true;
558 }
559
560 false
561 }
562
563 pub fn rebuild(&mut self, use_case: UseCase) -> Result<RebuildStats> {
571 let start_size = self.len();
572
573 if start_size == 0 {
574 return Ok(RebuildStats {
575 vectors_reinserted: 0,
576 old_parameters: (0, 0),
577 new_parameters: (0, 0),
578 });
579 }
580
581 let _id_to_cid = self.id_to_cid.read().unwrap_or_else(|e| e.into_inner());
583
584 let old_params = {
589 let idx = self.index.read().unwrap_or_else(|e| e.into_inner());
590 (
591 idx.get_max_nb_connection() as usize,
592 idx.get_ef_construction(),
593 )
594 };
595
596 let recommendation = ParameterTuner::recommend(start_size, self.dimension, use_case);
598
599 let new_index = Hnsw::<f32, DistL2>::new(
601 recommendation.m,
602 self.dimension,
603 recommendation.ef_construction,
604 start_size,
605 DistL2 {},
606 );
607
608 *self.index.write().unwrap_or_else(|e| e.into_inner()) = new_index;
610
611 Ok(RebuildStats {
615 vectors_reinserted: 0, old_parameters: old_params,
617 new_parameters: (recommendation.m, recommendation.ef_construction),
618 })
619 }
620
621 pub fn get_build_stats(&self) -> BuildHealthStats {
623 let size = self.len();
624 let (current_m, current_ef) = {
625 let idx = self.index.read().unwrap_or_else(|e| e.into_inner());
626 (
627 idx.get_max_nb_connection() as usize,
628 idx.get_ef_construction(),
629 )
630 };
631
632 let (optimal_m, optimal_ef) = self.compute_optimal_parameters();
633
634 let parameter_efficiency = if optimal_m > 0 {
635 (current_m as f32 / optimal_m as f32).min(1.0)
636 } else {
637 1.0
638 };
639
640 BuildHealthStats {
641 index_size: size,
642 current_m,
643 current_ef_construction: current_ef,
644 optimal_m,
645 optimal_ef_construction: optimal_ef,
646 parameter_efficiency,
647 rebuild_recommended: self.should_rebuild(),
648 }
649 }
650
651 pub fn save(&self, path: impl AsRef<std::path::Path>) -> Result<()> {
659 use std::fs::File;
660 use std::io::Write;
661
662 let (max_nb_connection, ef_construction) = {
664 let idx = self.index.read().unwrap_or_else(|e| e.into_inner());
665 (idx.get_max_nb_connection(), idx.get_ef_construction())
666 };
667
668 let metadata = IndexMetadata {
670 dimension: self.dimension,
671 metric: self.metric,
672 id_to_cid: self
673 .id_to_cid
674 .read()
675 .unwrap_or_else(|e| e.into_inner())
676 .clone(),
677 cid_to_id: self
678 .cid_to_id
679 .read()
680 .unwrap_or_else(|e| e.into_inner())
681 .clone(),
682 vectors: self
683 .vectors
684 .read()
685 .unwrap_or_else(|e| e.into_inner())
686 .clone(),
687 next_id: *self.next_id.read().unwrap_or_else(|e| e.into_inner()),
688 max_nb_connection: max_nb_connection as usize,
689 ef_construction,
690 };
691
692 let encoded = oxicode::serde::encode_to_vec(&metadata, oxicode::config::standard())
694 .map_err(|e| Error::Serialization(format!("Failed to serialize index: {}", e)))?;
695
696 let mut file = File::create(path.as_ref())
698 .map_err(|e| Error::Storage(format!("Failed to create index file: {}", e)))?;
699
700 file.write_all(&encoded)
701 .map_err(|e| Error::Storage(format!("Failed to write index file: {}", e)))?;
702
703 Ok(())
704 }
705
706 pub fn load(path: impl AsRef<std::path::Path>) -> Result<Self> {
713 use std::fs::File;
714 use std::io::Read;
715
716 let mut file = File::open(path.as_ref())
718 .map_err(|e| Error::Storage(format!("Failed to open index file: {}", e)))?;
719
720 let mut buffer = Vec::new();
721 file.read_to_end(&mut buffer)
722 .map_err(|e| Error::Storage(format!("Failed to read index file: {}", e)))?;
723
724 let metadata: IndexMetadata =
726 oxicode::serde::decode_owned_from_slice(&buffer, oxicode::config::standard())
727 .map(|(v, _)| v)
728 .map_err(|e| {
729 Error::Deserialization(format!("Failed to deserialize index: {}", e))
730 })?;
731
732 let index = Hnsw::<f32, DistL2>::new(
734 metadata.max_nb_connection,
735 metadata.dimension,
736 metadata.ef_construction,
737 200,
738 DistL2 {},
739 );
740
741 Ok(Self {
742 index: Arc::new(RwLock::new(index)),
743 id_to_cid: Arc::new(RwLock::new(metadata.id_to_cid)),
744 cid_to_id: Arc::new(RwLock::new(metadata.cid_to_id)),
745 vectors: Arc::new(RwLock::new(metadata.vectors)),
746 next_id: Arc::new(RwLock::new(metadata.next_id)),
747 dimension: metadata.dimension,
748 metric: metadata.metric,
749 tracker: Arc::new(RwLock::new(IncrementalTracker::new())),
750 })
751 }
752
753 pub fn snapshot(&self) -> Result<crate::persistence::IndexSnapshot> {
767 use crate::persistence::{IndexEntry, IndexSnapshot};
768 use std::time::{SystemTime, UNIX_EPOCH};
769
770 let id_to_cid = self
771 .id_to_cid
772 .read()
773 .map_err(|_| Error::Internal("id_to_cid lock poisoned".into()))?;
774 let vectors = self
775 .vectors
776 .read()
777 .map_err(|_| Error::Internal("vectors lock poisoned".into()))?;
778 let _next_id = self
779 .next_id
780 .read()
781 .map_err(|_| Error::Internal("next_id lock poisoned".into()))?;
782
783 let mut entries: Vec<IndexEntry> = id_to_cid
785 .iter()
786 .filter_map(|(&id, cid)| {
787 vectors.get(cid).map(|vec| IndexEntry {
788 id: id as u32,
789 cid: cid.to_string(),
790 vector: vec.clone(),
791 max_layer: 0, })
793 })
794 .collect();
795 entries.sort_by_key(|e| e.id);
796
797 let layer_connections: Vec<Vec<Vec<u32>>> = Vec::new();
802
803 let created_at = SystemTime::now()
804 .duration_since(UNIX_EPOCH)
805 .map(|d| d.as_secs())
806 .unwrap_or(0);
807
808 let entry_point = if entries.is_empty() {
811 None
812 } else {
813 Some(entries.last().map(|e| e.id).unwrap_or(0))
814 };
815
816 let (max_nb_connection, ef_construction) = {
817 let idx = self
818 .index
819 .read()
820 .map_err(|_| Error::Internal("index lock poisoned".into()))?;
821 (
822 idx.get_max_nb_connection() as usize,
823 idx.get_ef_construction(),
824 )
825 };
826
827 Ok(IndexSnapshot {
828 version: 1,
829 dimension: self.dimension,
830 ef_construction,
831 m: max_nb_connection,
832 entries,
833 layer_connections,
834 metadata_map: HashMap::new(),
835 created_at,
836 entry_point,
837 })
840 }
841
842 pub fn snapshot_incremental(
851 &self,
852 base_version: u64,
853 ) -> Result<crate::persistence::IncrementalSnapshot> {
854 use crate::persistence::{IncrementalSnapshot, IndexEntry};
855 use std::time::{SystemTime, UNIX_EPOCH};
856
857 let tracker = self
858 .tracker
859 .read()
860 .map_err(|_| Error::Internal("tracker lock poisoned".into()))?;
861 let dirty_ids = tracker.dirty_ids().clone();
862 let delta_version = tracker.version();
863 drop(tracker);
864
865 let id_to_cid = self
866 .id_to_cid
867 .read()
868 .map_err(|_| Error::Internal("id_to_cid lock poisoned".into()))?;
869 let vectors = self
870 .vectors
871 .read()
872 .map_err(|_| Error::Internal("vectors lock poisoned".into()))?;
873
874 let changed_entries: Vec<IndexEntry> = dirty_ids
875 .iter()
876 .filter_map(|&dirty_id| {
877 id_to_cid.get(&(dirty_id as usize)).and_then(|cid| {
878 vectors.get(cid).map(|vec| IndexEntry {
879 id: dirty_id,
880 cid: cid.to_string(),
881 vector: vec.clone(),
882 max_layer: 0,
883 })
884 })
885 })
886 .collect();
887
888 let created_at = SystemTime::now()
889 .duration_since(UNIX_EPOCH)
890 .map(|d| d.as_secs())
891 .unwrap_or(0);
892
893 Ok(IncrementalSnapshot {
894 base_version,
895 delta_version,
896 changed_entries,
897 deleted_ids: Vec::new(), created_at,
899 })
900 }
901
902 pub fn from_snapshot(snapshot: &crate::persistence::IndexSnapshot) -> Result<Self> {
913 let metric = snapshot
915 .metadata_map
916 .get("metric")
917 .map(|s| match s.as_str() {
918 "cosine" => DistanceMetric::Cosine,
919 "dot" => DistanceMetric::DotProduct,
920 _ => DistanceMetric::L2,
921 })
922 .unwrap_or(DistanceMetric::L2);
923
924 let mut index = Self::new(
925 snapshot.dimension,
926 metric,
927 snapshot.m,
928 snapshot.ef_construction,
929 )?;
930
931 let mut ordered = snapshot.entries.clone();
933 ordered.sort_by_key(|e| e.id);
934
935 for entry in &ordered {
936 let cid: Cid = entry
937 .cid
938 .parse()
939 .map_err(|e| Error::Cid(format!("could not parse CID '{}': {}", entry.cid, e)))?;
940 index.insert(&cid, &entry.vector)?;
941 }
942
943 if let Ok(mut t) = index.tracker.write() {
947 t.record_full_snapshot(std::time::SystemTime::now());
948 }
949
950 Ok(index)
951 }
952
953 pub fn dirty_count(&self) -> usize {
955 self.tracker.read().map(|t| t.dirty_count()).unwrap_or(0)
956 }
957
958 pub fn tracker_version(&self) -> u64 {
960 self.tracker.read().map(|t| t.version()).unwrap_or(0)
961 }
962
963 pub fn mark_tracker_clean(&self) {
965 if let Ok(mut t) = self.tracker.write() {
966 t.mark_clean();
967 }
968 }
969
970 pub fn record_full_snapshot(&self) {
972 if let Ok(mut t) = self.tracker.write() {
973 t.record_full_snapshot(std::time::SystemTime::now());
974 }
975 }
976}
977
978#[derive(serde::Serialize, serde::Deserialize)]
980struct IndexMetadata {
981 dimension: usize,
982 metric: DistanceMetric,
983 #[serde(
984 serialize_with = "serialize_id_to_cid",
985 deserialize_with = "deserialize_id_to_cid"
986 )]
987 id_to_cid: HashMap<usize, Cid>,
988 #[serde(
989 serialize_with = "serialize_cid_to_id",
990 deserialize_with = "deserialize_cid_to_id"
991 )]
992 cid_to_id: HashMap<Cid, usize>,
993 #[serde(
994 serialize_with = "serialize_vectors",
995 deserialize_with = "deserialize_vectors"
996 )]
997 vectors: HashMap<Cid, Vec<f32>>,
998 next_id: usize,
999 max_nb_connection: usize,
1000 ef_construction: usize,
1001}
1002
1003fn serialize_id_to_cid<S>(
1005 map: &HashMap<usize, Cid>,
1006 serializer: S,
1007) -> std::result::Result<S::Ok, S::Error>
1008where
1009 S: serde::Serializer,
1010{
1011 use serde::Serialize;
1012 let string_map: HashMap<usize, String> =
1013 map.iter().map(|(id, cid)| (*id, cid.to_string())).collect();
1014 string_map.serialize(serializer)
1015}
1016
1017fn deserialize_id_to_cid<'de, D>(
1019 deserializer: D,
1020) -> std::result::Result<HashMap<usize, Cid>, D::Error>
1021where
1022 D: serde::Deserializer<'de>,
1023{
1024 use serde::Deserialize;
1025 let string_map: HashMap<usize, String> = HashMap::deserialize(deserializer)?;
1026 string_map
1027 .into_iter()
1028 .map(|(id, cid_str)| {
1029 cid_str
1030 .parse::<Cid>()
1031 .map(|cid| (id, cid))
1032 .map_err(serde::de::Error::custom)
1033 })
1034 .collect()
1035}
1036
1037fn serialize_cid_to_id<S>(
1039 map: &HashMap<Cid, usize>,
1040 serializer: S,
1041) -> std::result::Result<S::Ok, S::Error>
1042where
1043 S: serde::Serializer,
1044{
1045 use serde::Serialize;
1046 let string_map: HashMap<String, usize> =
1047 map.iter().map(|(cid, id)| (cid.to_string(), *id)).collect();
1048 string_map.serialize(serializer)
1049}
1050
1051fn deserialize_cid_to_id<'de, D>(
1053 deserializer: D,
1054) -> std::result::Result<HashMap<Cid, usize>, D::Error>
1055where
1056 D: serde::Deserializer<'de>,
1057{
1058 use serde::Deserialize;
1059 let string_map: HashMap<String, usize> = HashMap::deserialize(deserializer)?;
1060 string_map
1061 .into_iter()
1062 .map(|(cid_str, id)| {
1063 cid_str
1064 .parse::<Cid>()
1065 .map(|cid| (cid, id))
1066 .map_err(serde::de::Error::custom)
1067 })
1068 .collect()
1069}
1070
1071fn serialize_vectors<S>(
1073 map: &HashMap<Cid, Vec<f32>>,
1074 serializer: S,
1075) -> std::result::Result<S::Ok, S::Error>
1076where
1077 S: serde::Serializer,
1078{
1079 use serde::Serialize;
1080 let string_map: HashMap<String, Vec<f32>> = map
1081 .iter()
1082 .map(|(cid, vec)| (cid.to_string(), vec.clone()))
1083 .collect();
1084 string_map.serialize(serializer)
1085}
1086
1087fn deserialize_vectors<'de, D>(
1089 deserializer: D,
1090) -> std::result::Result<HashMap<Cid, Vec<f32>>, D::Error>
1091where
1092 D: serde::Deserializer<'de>,
1093{
1094 use serde::Deserialize;
1095 let string_map: HashMap<String, Vec<f32>> = HashMap::deserialize(deserializer)?;
1096 string_map
1097 .into_iter()
1098 .map(|(cid_str, vec)| {
1099 cid_str
1100 .parse::<Cid>()
1101 .map(|cid| (cid, vec))
1102 .map_err(serde::de::Error::custom)
1103 })
1104 .collect()
1105}
1106
1107#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize, Default)]
1109pub enum UseCase {
1110 LowLatency,
1112 HighRecall,
1114 #[default]
1116 Balanced,
1117 LowMemory,
1119 LargeScale,
1121}
1122
1123#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1125pub struct ParameterRecommendation {
1126 pub m: usize,
1128 pub ef_construction: usize,
1130 pub ef_search: usize,
1132 pub memory_per_vector: usize,
1134 pub estimated_recall: f32,
1136 pub latency_factor: f32,
1138 pub explanation: String,
1140}
1141
1142pub struct ParameterTuner;
1144
1145impl ParameterTuner {
1146 pub fn recommend(
1148 num_vectors: usize,
1149 dimension: usize,
1150 use_case: UseCase,
1151 ) -> ParameterRecommendation {
1152 let (m, ef_construction, ef_search, recall, latency) = match use_case {
1153 UseCase::LowLatency => {
1154 if num_vectors < 10_000 {
1155 (8, 100, 32, 0.90, 0.6)
1156 } else if num_vectors < 100_000 {
1157 (12, 150, 50, 0.88, 0.7)
1158 } else {
1159 (16, 200, 64, 0.85, 0.8)
1160 }
1161 }
1162 UseCase::HighRecall => {
1163 if num_vectors < 10_000 {
1164 (32, 400, 200, 0.99, 2.0)
1165 } else if num_vectors < 100_000 {
1166 (48, 500, 300, 0.98, 2.5)
1167 } else {
1168 (64, 600, 400, 0.97, 3.0)
1169 }
1170 }
1171 UseCase::Balanced => {
1172 if num_vectors < 10_000 {
1173 (16, 200, 50, 0.95, 1.0)
1174 } else if num_vectors < 100_000 {
1175 (24, 300, 100, 0.94, 1.2)
1176 } else {
1177 (32, 400, 150, 0.93, 1.5)
1178 }
1179 }
1180 UseCase::LowMemory => {
1181 if num_vectors < 10_000 {
1182 (8, 100, 50, 0.88, 0.9)
1183 } else if num_vectors < 100_000 {
1184 (10, 120, 64, 0.85, 1.0)
1185 } else {
1186 (12, 150, 80, 0.82, 1.1)
1187 }
1188 }
1189 UseCase::LargeScale => {
1190 (32, 400, 100, 0.93, 1.5)
1192 }
1193 };
1194
1195 let memory_per_vector = dimension * 4 + m * 2 * 4;
1197
1198 let explanation =
1199 Self::generate_explanation(num_vectors, use_case, m, ef_construction, ef_search);
1200
1201 ParameterRecommendation {
1202 m,
1203 ef_construction,
1204 ef_search,
1205 memory_per_vector,
1206 estimated_recall: recall,
1207 latency_factor: latency,
1208 explanation,
1209 }
1210 }
1211
1212 fn generate_explanation(
1213 num_vectors: usize,
1214 use_case: UseCase,
1215 m: usize,
1216 ef_construction: usize,
1217 ef_search: usize,
1218 ) -> String {
1219 let size_category = if num_vectors < 10_000 {
1220 "small"
1221 } else if num_vectors < 100_000 {
1222 "medium"
1223 } else {
1224 "large"
1225 };
1226
1227 let use_case_str = match use_case {
1228 UseCase::LowLatency => "low latency",
1229 UseCase::HighRecall => "high recall",
1230 UseCase::Balanced => "balanced",
1231 UseCase::LowMemory => "low memory",
1232 UseCase::LargeScale => "large scale",
1233 };
1234
1235 format!(
1236 "For {} dataset (~{} vectors) optimized for {}: \
1237 M={} provides good connectivity, ef_construction={} ensures quality graph, \
1238 ef_search={} balances speed and accuracy.",
1239 size_category, num_vectors, use_case_str, m, ef_construction, ef_search
1240 )
1241 }
1242
1243 pub fn pareto_configurations(
1245 num_vectors: usize,
1246 dimension: usize,
1247 ) -> Vec<ParameterRecommendation> {
1248 vec![
1249 Self::recommend(num_vectors, dimension, UseCase::LowLatency),
1250 Self::recommend(num_vectors, dimension, UseCase::LowMemory),
1251 Self::recommend(num_vectors, dimension, UseCase::Balanced),
1252 Self::recommend(num_vectors, dimension, UseCase::HighRecall),
1253 ]
1254 }
1255
1256 pub fn estimate_memory(num_vectors: usize, dimension: usize, m: usize) -> usize {
1258 let vector_memory = num_vectors * dimension * 4;
1260
1261 let graph_memory = num_vectors * m * 2 * 4;
1263
1264 let overhead = num_vectors * 50;
1266
1267 vector_memory + graph_memory + overhead
1268 }
1269
1270 pub fn ef_search_for_recall(k: usize, target_recall: f32) -> usize {
1272 let multiplier = if target_recall >= 0.99 {
1275 10.0
1276 } else if target_recall >= 0.95 {
1277 4.0
1278 } else if target_recall >= 0.90 {
1279 2.0
1280 } else {
1281 1.5
1282 };
1283
1284 ((k as f32) * multiplier).ceil() as usize
1285 }
1286}
1287
1288#[cfg(test)]
1289mod tests {
1290 use super::*;
1291 use rand::RngExt;
1292
1293 #[test]
1294 fn test_vector_index_creation() {
1295 let index = VectorIndex::with_defaults(128);
1296 assert!(index.is_ok());
1297 let index = index.expect("test: unwrap valid index after is_ok check");
1298 assert_eq!(index.dimension(), 128);
1299 assert_eq!(index.len(), 0);
1300 assert!(index.is_empty());
1301 }
1302
1303 #[test]
1304 fn test_insert_and_search() {
1305 let mut index = VectorIndex::with_defaults(4).expect("test: create 4-dim index");
1306
1307 let cid1 = "bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi"
1309 .parse::<Cid>()
1310 .expect("test: parse cid1");
1311 let vec1 = vec![1.0, 0.0, 0.0, 0.0];
1312
1313 let cid2 = "bafybeiczsscdsbs7ffqz55asqdf3smv6klcw3gofszvwlyarci47bgf354"
1314 .parse::<Cid>()
1315 .expect("test: parse cid2");
1316 let vec2 = vec![0.9, 0.1, 0.0, 0.0];
1317
1318 index.insert(&cid1, &vec1).expect("test: insert cid1");
1320 index.insert(&cid2, &vec2).expect("test: insert cid2");
1321
1322 assert_eq!(index.len(), 2);
1323
1324 let query = vec![1.0, 0.0, 0.0, 0.0];
1326 let results = index
1327 .search(&query, 1, 50)
1328 .expect("test: search for nearest");
1329
1330 assert_eq!(results.len(), 1);
1331 assert_eq!(results[0].cid, cid1);
1332 }
1333
1334 #[test]
1335 fn test_parameter_tuner() {
1336 let balanced = ParameterTuner::recommend(50_000, 768, UseCase::Balanced);
1338 assert!(balanced.m > 0);
1339 assert!(balanced.ef_construction > 0);
1340 assert!(balanced.estimated_recall > 0.0);
1341
1342 let low_latency = ParameterTuner::recommend(50_000, 768, UseCase::LowLatency);
1343 let high_recall = ParameterTuner::recommend(50_000, 768, UseCase::HighRecall);
1344
1345 assert!(high_recall.m > low_latency.m);
1347 assert!(high_recall.estimated_recall > low_latency.estimated_recall);
1349
1350 let pareto = ParameterTuner::pareto_configurations(50_000, 768);
1352 assert_eq!(pareto.len(), 4);
1353
1354 let memory = ParameterTuner::estimate_memory(100_000, 768, 16);
1356 assert!(memory > 0);
1357
1358 let ef_high = ParameterTuner::ef_search_for_recall(10, 0.99);
1360 let ef_low = ParameterTuner::ef_search_for_recall(10, 0.85);
1361 assert!(ef_high > ef_low);
1362 }
1363
1364 #[test]
1365 fn test_incremental_build() {
1366 let mut index =
1367 VectorIndex::with_defaults(4).expect("test: create 4-dim index for incremental");
1368
1369 let items: Vec<(Cid, Vec<f32>)> = (0..20)
1371 .map(|i| {
1372 let cid_str = format!(
1373 "bafybei{}yrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi",
1374 i
1375 );
1376 let cid = cid_str.parse::<Cid>().unwrap_or_else(|_| {
1377 "bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi"
1378 .parse()
1379 .expect("test: parse fallback cid")
1380 });
1381 let vec = vec![i as f32, 0.0, 0.0, 0.0];
1382 (cid, vec)
1383 })
1384 .collect();
1385
1386 let stats = index
1388 .insert_incremental(&items, 5)
1389 .expect("test: insert incremental");
1390
1391 assert_eq!(stats.chunks_processed, 4);
1392 assert!(stats.vectors_inserted <= 20);
1393 assert_eq!(stats.final_size, index.len());
1394 }
1395
1396 #[test]
1397 fn test_build_health_stats() {
1398 let index = VectorIndex::new(128, DistanceMetric::L2, 16, 200)
1399 .expect("test: create L2 index for health stats");
1400
1401 let stats = index.get_build_stats();
1402 assert_eq!(stats.index_size, 0);
1403 assert_eq!(stats.current_m, 16);
1404 assert_eq!(stats.current_ef_construction, 200);
1405 assert!(stats.parameter_efficiency > 0.0);
1406
1407 assert!(!stats.rebuild_recommended);
1409 }
1410
1411 #[test]
1412 fn test_should_rebuild() {
1413 let index1 = VectorIndex::new(128, DistanceMetric::L2, 16, 200)
1415 .expect("test: create L2 index for should_rebuild");
1416 assert!(!index1.should_rebuild());
1417
1418 let index2 = VectorIndex::new(128, DistanceMetric::L2, 4, 50)
1420 .expect("test: create suboptimal L2 index");
1421 let _ = index2.should_rebuild();
1424 }
1425
1426 #[test]
1427 fn test_rebuild() {
1428 let mut index = VectorIndex::with_defaults(4).expect("test: create vector index");
1429
1430 for i in 0..10 {
1432 let cid_str = format!(
1433 "bafybei{}yrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi",
1434 i
1435 );
1436 let cid = cid_str.parse::<Cid>().unwrap_or_else(|_| {
1437 "bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi"
1438 .parse()
1439 .expect("test: parse cid")
1440 });
1441 let vec = vec![i as f32, 0.0, 0.0, 0.0];
1442 let _ = index.insert(&cid, &vec);
1443 }
1444
1445 let rebuild_stats = index
1447 .rebuild(UseCase::Balanced)
1448 .expect("test: rebuild index");
1449
1450 assert_eq!(rebuild_stats.old_parameters.0, 16); assert!(rebuild_stats.new_parameters.0 > 0); }
1453
1454 fn compute_ground_truth(query: &[f32], vectors: &[(Cid, Vec<f32>)], k: usize) -> Vec<Cid> {
1456 let mut distances: Vec<(Cid, f32)> = vectors
1457 .iter()
1458 .map(|(cid, vec)| {
1459 let dist: f32 = query
1460 .iter()
1461 .zip(vec.iter())
1462 .map(|(a, b)| (a - b).powi(2))
1463 .sum();
1464 (*cid, dist.sqrt())
1465 })
1466 .collect();
1467
1468 distances.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
1469 distances.iter().take(k).map(|(cid, _)| *cid).collect()
1470 }
1471
1472 fn calculate_recall_at_k(predicted: &[Cid], ground_truth: &[Cid], k: usize) -> f32 {
1474 let predicted_set: std::collections::HashSet<_> = predicted.iter().take(k).collect();
1475 let ground_truth_set: std::collections::HashSet<_> = ground_truth.iter().take(k).collect();
1476
1477 let intersection = predicted_set.intersection(&ground_truth_set).count();
1478 intersection as f32 / k as f32
1479 }
1480
1481 fn generate_test_cid(index: usize) -> Cid {
1483 use multihash_codetable::{Code, MultihashDigest};
1484 let data = format!("test_vector_{}", index);
1485 let hash = Code::Sha2_256.digest(data.as_bytes());
1486 Cid::new_v1(0x55, hash) }
1488
1489 #[test]
1490 fn test_recall_at_k() {
1491 let mut index = VectorIndex::with_defaults(128).expect("test: create vector index");
1493
1494 let mut rng = rand::rng();
1496 let num_vectors = 100;
1497 let dimension = 128;
1498
1499 let mut vectors = Vec::new();
1500 for i in 0..num_vectors {
1501 let cid = generate_test_cid(i);
1502
1503 let vec: Vec<f32> = (0..dimension)
1504 .map(|_| rng.random_range(-1.0..1.0))
1505 .collect();
1506
1507 vectors.push((cid, vec.clone()));
1508 let _ = index.insert(&cid, &vec);
1509 }
1510
1511 let num_queries = 10;
1513 let mut total_recall_at_1 = 0.0;
1514 let mut total_recall_at_10 = 0.0;
1515
1516 for _ in 0..num_queries {
1517 let query: Vec<f32> = (0..dimension)
1518 .map(|_| rng.random_range(-1.0..1.0))
1519 .collect();
1520
1521 let hnsw_results = index.search(&query, 10, 50).expect("test: search index");
1523 let hnsw_cids: Vec<Cid> = hnsw_results.iter().map(|r| r.cid).collect();
1524
1525 let ground_truth = compute_ground_truth(&query, &vectors, 10);
1527
1528 total_recall_at_1 += calculate_recall_at_k(&hnsw_cids, &ground_truth, 1);
1530 total_recall_at_10 += calculate_recall_at_k(&hnsw_cids, &ground_truth, 10);
1531 }
1532
1533 let avg_recall_at_1 = total_recall_at_1 / num_queries as f32;
1534 let avg_recall_at_10 = total_recall_at_10 / num_queries as f32;
1535
1536 assert!(
1538 avg_recall_at_10 > 0.8,
1539 "Recall@10 too low: {}",
1540 avg_recall_at_10
1541 );
1542
1543 assert!(
1545 avg_recall_at_1 > 0.5,
1546 "Recall@1 too low: {}",
1547 avg_recall_at_1
1548 );
1549 }
1550
1551 #[test]
1552 fn test_concurrent_queries() {
1553 use std::sync::Arc;
1554 use std::thread;
1555
1556 let mut index = VectorIndex::with_defaults(128).expect("test: create vector index");
1558
1559 let mut rng = rand::rng();
1561 for i in 0..100 {
1562 let cid = generate_test_cid(i + 1000); let vec: Vec<f32> = (0..128).map(|_| rng.random_range(-1.0..1.0)).collect();
1565
1566 let _ = index.insert(&cid, &vec);
1567 }
1568
1569 let index = Arc::new(index);
1571 let num_threads = 10;
1572 let queries_per_thread = 100;
1573
1574 let mut handles = vec![];
1576 for _ in 0..num_threads {
1577 let index_clone = Arc::clone(&index);
1578 let handle = thread::spawn(move || {
1579 let mut thread_rng = rand::rng();
1580 let mut success_count = 0;
1581
1582 for _ in 0..queries_per_thread {
1583 let query: Vec<f32> = (0..128)
1584 .map(|_| thread_rng.random_range(-1.0..1.0))
1585 .collect();
1586
1587 if let Ok(results) = index_clone.search(&query, 10, 50) {
1588 if !results.is_empty() {
1589 success_count += 1;
1590 }
1591 }
1592 }
1593 success_count
1594 });
1595 handles.push(handle);
1596 }
1597
1598 let mut total_success = 0;
1600 for handle in handles {
1601 total_success += handle.join().expect("test: thread join");
1602 }
1603
1604 let total_queries = num_threads * queries_per_thread;
1606 assert_eq!(
1607 total_success, total_queries,
1608 "Some queries failed under concurrent load"
1609 );
1610 }
1611
1612 #[test]
1613 fn test_precision_at_k() {
1614 let mut index = VectorIndex::with_defaults(32).expect("test: create vector index");
1616
1617 let num_clusters = 5;
1619 let vectors_per_cluster = 10;
1620
1621 for cluster in 0..num_clusters {
1622 let mut center = [0.0; 32];
1624 center[cluster] = 10.0;
1625
1626 for i in 0..vectors_per_cluster {
1627 let idx = cluster * vectors_per_cluster + i;
1628 let cid = generate_test_cid(idx + 2000); let mut rng = rand::rng();
1632 let vec: Vec<f32> = center
1633 .iter()
1634 .map(|&c| c + rng.random_range(-0.5..0.5))
1635 .collect();
1636
1637 let _ = index.insert(&cid, &vec);
1638 }
1639 }
1640
1641 let mut query = vec![0.0; 32];
1643 query[0] = 10.0;
1644
1645 let results = index.search(&query, 10, 50).expect("test: search index");
1646
1647 assert_eq!(results.len(), 10, "Should return 10 results");
1651
1652 for result in &results {
1654 assert!(
1655 result.score < 5.0,
1656 "Result too far from query: {}",
1657 result.score
1658 );
1659 }
1660 }
1661
1662 #[test]
1663 fn test_hnsw_memory_estimate() {
1664 let dim = 128;
1665 let mut index =
1666 VectorIndex::new(dim, DistanceMetric::L2, 16, 200).expect("test: create vector index");
1667
1668 assert_eq!(
1670 index.estimated_memory_bytes(),
1671 0,
1672 "empty index should report 0 bytes"
1673 );
1674
1675 for i in 0..1000_usize {
1677 let cid = generate_test_cid(i + 10_000);
1678 let vec = vec![i as f32 * 0.001; dim];
1679 index.insert(&cid, &vec).expect("test: insert vector");
1680 }
1681
1682 let estimate = index.estimated_memory_bytes();
1683 assert!(
1684 estimate > 0,
1685 "memory estimate should be > 0 after inserting 1000 vectors (got {})",
1686 estimate
1687 );
1688 let lower_bound = 1000 * dim * 4;
1690 assert!(
1691 estimate >= lower_bound,
1692 "estimate {} should be >= lower bound {}",
1693 estimate,
1694 lower_bound
1695 );
1696 }
1697}