Skip to main content

vecdb_core/index/
backend.rs

1use std::collections::{HashMap, HashSet};
2use std::path::Path;
3
4use serde::{Deserialize, Serialize};
5
6use crate::errors::{Result, VecDbError};
7use crate::index::distance;
8use crate::types::{CollectionConfig, DistanceMetric, IndexType, Vector, VectorId};
9
10// ──────────────────────────────────────────────
11// IndexBackend trait
12// ──────────────────────────────────────────────
13
14pub trait IndexBackend: Send + Sync {
15    fn build(&mut self, vectors: Vec<(VectorId, Vector)>) -> Result<()>;
16    fn insert(&mut self, id: VectorId, vector: Vector) -> Result<()>;
17    fn search(&self, query: &Vector, k: usize) -> Result<Vec<(VectorId, f32)>>;
18    fn delete(&mut self, id: &VectorId) -> Result<()>;
19    fn save(&self, path: &Path) -> Result<()>;
20    fn load_from(path: &Path, config: &CollectionConfig) -> Result<Box<dyn IndexBackend>>
21    where
22        Self: Sized;
23    fn len(&self) -> usize;
24    fn is_empty(&self) -> bool {
25        self.len() == 0
26    }
27    fn index_type(&self) -> IndexType;
28    fn rebuild(&mut self, vectors: Vec<(VectorId, Vector)>) -> Result<()> {
29        self.build(vectors)
30    }
31}
32
33// ──────────────────────────────────────────────
34// Point wrapper for instant-distance
35// ──────────────────────────────────────────────
36
37/// Wrapper around a raw vector that carries the distance metric.
38/// Required by the `instant_distance::Point` trait.
39#[derive(Clone)]
40struct Point {
41    data: Vec<f32>,
42    metric: DistanceMetric,
43}
44
45impl instant_distance::Point for Point {
46    fn distance(&self, other: &Self) -> f32 {
47        distance::compute_distance(&self.data, &other.data, &self.metric)
48    }
49}
50
51// ──────────────────────────────────────────────
52// HnswConfig
53// ──────────────────────────────────────────────
54
55#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct HnswConfig {
57    /// Graph degree (M in the paper). Note: instant-distance hard-codes M=32;
58    /// this field is kept for API compatibility and future backends.
59    pub m: usize,
60    pub ef_construction: usize,
61    pub ef_search: usize,
62    pub metric: DistanceMetric,
63}
64
65impl Default for HnswConfig {
66    fn default() -> Self {
67        Self {
68            m: 16,
69            ef_construction: 200,
70            ef_search: 50,
71            metric: DistanceMetric::Cosine,
72        }
73    }
74}
75
76// ──────────────────────────────────────────────
77// HnswIndex
78// ──────────────────────────────────────────────
79
80pub struct HnswIndex {
81    pub dimension: usize,
82    pub metric: DistanceMetric,
83    pub config: HnswConfig,
84    /// VectorId → position in `vectors` / `pos_to_id`
85    id_to_pos: HashMap<VectorId, usize>,
86    /// Position → VectorId
87    pos_to_id: Vec<VectorId>,
88    /// Raw vectors in insertion order (parallel to pos_to_id)
89    vectors: Vec<Vector>,
90    /// Soft-deleted ids
91    deleted: HashSet<VectorId>,
92    /// Built HNSW structure; None until build() is called
93    hnsw: Option<instant_distance::HnswMap<Point, VectorId>>,
94    /// True when inserts/deletes have occurred since the last build
95    dirty: bool,
96}
97
98impl HnswIndex {
99    pub fn new(dimension: usize, config: HnswConfig) -> Self {
100        let metric = config.metric.clone();
101        Self {
102            dimension,
103            metric,
104            config,
105            id_to_pos: HashMap::new(),
106            pos_to_id: Vec::new(),
107            vectors: Vec::new(),
108            deleted: HashSet::new(),
109            hnsw: None,
110            dirty: false,
111        }
112    }
113
114    pub fn from_collection_config(config: &CollectionConfig) -> Self {
115        let hnsw_config = HnswConfig {
116            m: config.hnsw_m,
117            ef_construction: config.hnsw_ef_construction,
118            ef_search: config.hnsw_ef_search,
119            metric: config.metric.clone(),
120        };
121        Self::new(config.dimension, hnsw_config)
122    }
123
124    /// Load a saved index from disk and return a concrete `HnswIndex`
125    /// (avoids the need to downcast the trait object returned by `load_from`).
126    pub fn load_file(path: &Path, config: &CollectionConfig) -> Result<Self> {
127        let json = std::fs::read_to_string(path)
128            .map_err(|e| VecDbError::StorageError(format!("hnsw load failed: {e}")))?;
129        let saved: SerializedHnswIndex = serde_json::from_str(&json)
130            .map_err(|e| VecDbError::SerializationError(e.to_string()))?;
131
132        let hnsw_config = HnswConfig {
133            m: config.hnsw_m,
134            ef_construction: config.hnsw_ef_construction,
135            ef_search: config.hnsw_ef_search,
136            metric: config.metric.clone(),
137        };
138        let metric = hnsw_config.metric.clone();
139
140        let mut index = Self {
141            dimension: saved.dimension,
142            metric,
143            config: hnsw_config,
144            id_to_pos: saved.id_to_pos,
145            pos_to_id: saved.pos_to_id,
146            vectors: saved.vectors,
147            deleted: saved.deleted.into_iter().collect(),
148            hnsw: None,
149            dirty: false,
150        };
151
152        // Rebuild HNSW from stored vectors (live only)
153        let live: Vec<(VectorId, Vector)> = index
154            .pos_to_id
155            .iter()
156            .zip(index.vectors.iter())
157            .filter(|(id, _)| !index.deleted.contains(*id))
158            .map(|(id, v)| (id.clone(), v.clone()))
159            .collect();
160        if !live.is_empty() {
161            index.build(live)?;
162        }
163
164        tracing::info!("HNSW index loaded from {:?}", path);
165        Ok(index)
166    }
167
168    // ── private helpers ──────────────────────────────
169
170    fn maybe_normalize(&self, v: Vec<f32>) -> Vec<f32> {
171        if matches!(self.metric, DistanceMetric::Cosine) {
172            distance::normalize(&v)
173        } else {
174            v
175        }
176    }
177
178    fn to_score(&self, dist: f32) -> f32 {
179        match self.metric {
180            DistanceMetric::Cosine => 1.0 - dist,
181            DistanceMetric::Euclidean => 1.0 / (1.0 + dist),
182            DistanceMetric::DotProduct => 1.0 - dist,
183        }
184    }
185
186    fn brute_force_search(&self, query: &Vector, k: usize) -> Result<Vec<(VectorId, f32)>> {
187        let q = self.maybe_normalize(query.clone());
188        let mut scored: Vec<(VectorId, f32)> = self
189            .pos_to_id
190            .iter()
191            .enumerate()
192            .filter(|(_, id)| !self.deleted.contains(*id))
193            .map(|(pos, id)| {
194                let dist = distance::compute_distance(&q, &self.vectors[pos], &self.metric);
195                (id.clone(), self.to_score(dist))
196            })
197            .collect();
198
199        scored.sort_unstable_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
200        scored.truncate(k);
201        Ok(scored)
202    }
203
204    fn collect_live_vectors(&self) -> Vec<(VectorId, Vector)> {
205        self.pos_to_id
206            .iter()
207            .zip(self.vectors.iter())
208            .filter(|(id, _)| !self.deleted.contains(*id))
209            .map(|(id, v)| (id.clone(), v.clone()))
210            .collect()
211    }
212}
213
214// ──────────────────────────────────────────────
215// IndexBackend implementation
216// ──────────────────────────────────────────────
217
218impl IndexBackend for HnswIndex {
219    fn build(&mut self, vectors: Vec<(VectorId, Vector)>) -> Result<()> {
220        self.id_to_pos.clear();
221        self.pos_to_id.clear();
222        self.vectors.clear();
223        self.deleted.clear();
224
225        if vectors.is_empty() {
226            self.hnsw = None;
227            self.dirty = false;
228            return Ok(());
229        }
230
231        for (id, raw_vec) in vectors {
232            if raw_vec.len() != self.dimension {
233                return Err(VecDbError::DimensionMismatch {
234                    expected: self.dimension,
235                    got: raw_vec.len(),
236                });
237            }
238            let v = self.maybe_normalize(raw_vec);
239            let pos = self.vectors.len();
240            self.id_to_pos.insert(id.clone(), pos);
241            self.pos_to_id.push(id.clone());
242            self.vectors.push(v);
243        }
244
245        let points: Vec<Point> = self
246            .vectors
247            .iter()
248            .map(|v| Point {
249                data: v.clone(),
250                metric: self.metric.clone(),
251            })
252            .collect();
253        let values: Vec<VectorId> = self.pos_to_id.clone();
254
255        let hnsw = instant_distance::Builder::default()
256            .ef_construction(self.config.ef_construction)
257            .ef_search(self.config.ef_search)
258            .build(points, values);
259
260        self.hnsw = Some(hnsw);
261        self.dirty = false;
262
263        tracing::info!(
264            "HNSW index built: {} vectors, metric={:?}",
265            self.vectors.len(),
266            self.metric
267        );
268        Ok(())
269    }
270
271    fn insert(&mut self, id: VectorId, vector: Vector) -> Result<()> {
272        if vector.len() != self.dimension {
273            return Err(VecDbError::DimensionMismatch {
274                expected: self.dimension,
275                got: vector.len(),
276            });
277        }
278        let v = self.maybe_normalize(vector);
279
280        if let Some(&pos) = self.id_to_pos.get(&id) {
281            // Existing entry (live or deleted)
282            self.deleted.remove(&id);
283            self.vectors[pos] = v;
284        } else {
285            // New entry
286            let pos = self.vectors.len();
287            self.vectors.push(v);
288            self.id_to_pos.insert(id.clone(), pos);
289            self.pos_to_id.push(id.clone());
290        }
291
292        self.dirty = true;
293        tracing::debug!("HNSW insert: id={}, dirty={}", id, self.dirty);
294
295        // Auto-rebuild for small collections or at batch boundaries
296        let n = self.vectors.len();
297        if n <= 100 || n.is_multiple_of(1000) {
298            let live = self.collect_live_vectors();
299            self.build(live)?;
300        }
301
302        Ok(())
303    }
304
305    fn search(&self, query: &Vector, k: usize) -> Result<Vec<(VectorId, f32)>> {
306        if query.len() != self.dimension {
307            return Err(VecDbError::DimensionMismatch {
308                expected: self.dimension,
309                got: query.len(),
310            });
311        }
312
313        // Fall back to brute force when index unavailable or dirty
314        if self.hnsw.is_none() || self.dirty {
315            return self.brute_force_search(query, k);
316        }
317
318        let q = self.maybe_normalize(query.clone());
319        let query_point = Point {
320            data: q,
321            metric: self.metric.clone(),
322        };
323
324        let hnsw = self.hnsw.as_ref().unwrap();
325        let mut search = instant_distance::Search::default();
326
327        let mut results: Vec<(VectorId, f32)> = hnsw
328            .search(&query_point, &mut search)
329            .filter(|item| !self.deleted.contains(item.value))
330            .map(|item| (item.value.clone(), self.to_score(item.distance)))
331            .collect();
332
333        results.sort_unstable_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
334        results.truncate(k);
335        Ok(results)
336    }
337
338    fn delete(&mut self, id: &VectorId) -> Result<()> {
339        if !self.id_to_pos.contains_key(id) {
340            return Err(VecDbError::NotFound { id: id.clone() });
341        }
342        self.deleted.insert(id.clone());
343        self.dirty = true;
344
345        tracing::debug!(
346            "HNSW delete: id={}, deleted_count={}",
347            id,
348            self.deleted.len()
349        );
350
351        // Compact when dead weight exceeds 25 %
352        if self.deleted.len() > self.vectors.len() / 4 {
353            let live = self.collect_live_vectors();
354            self.build(live)?;
355        }
356
357        Ok(())
358    }
359
360    fn save(&self, path: &Path) -> Result<()> {
361        let saved = SerializedHnswIndex {
362            dimension: self.dimension,
363            config: self.config.clone(),
364            id_to_pos: self.id_to_pos.clone(),
365            pos_to_id: self.pos_to_id.clone(),
366            vectors: self.vectors.clone(),
367            deleted: self.deleted.iter().cloned().collect(),
368            version: 1,
369        };
370        let json = serde_json::to_string(&saved)
371            .map_err(|e| VecDbError::SerializationError(e.to_string()))?;
372        std::fs::write(path, json)
373            .map_err(|e| VecDbError::StorageError(format!("hnsw save failed: {e}")))?;
374        tracing::info!("HNSW index saved to {:?}", path);
375        Ok(())
376    }
377
378    fn load_from(path: &Path, config: &CollectionConfig) -> Result<Box<dyn IndexBackend>>
379    where
380        Self: Sized,
381    {
382        Ok(Box::new(Self::load_file(path, config)?))
383    }
384
385    fn len(&self) -> usize {
386        self.vectors.len().saturating_sub(self.deleted.len())
387    }
388
389    fn index_type(&self) -> IndexType {
390        IndexType::HNSW
391    }
392}
393
394// ──────────────────────────────────────────────
395// Serialization helper
396// ──────────────────────────────────────────────
397
398#[derive(Serialize, Deserialize)]
399struct SerializedHnswIndex {
400    dimension: usize,
401    config: HnswConfig,
402    id_to_pos: HashMap<VectorId, usize>,
403    pos_to_id: Vec<VectorId>,
404    vectors: Vec<Vector>,
405    deleted: Vec<VectorId>,
406    version: u32,
407}
408
409// ──────────────────────────────────────────────
410// Tests
411// ──────────────────────────────────────────────
412
413#[cfg(test)]
414mod tests {
415    use super::*;
416    use crate::types::{CollectionConfig, VectorRecord};
417    use chrono::Utc;
418    use serde_json::json;
419    use tempfile::tempdir;
420
421    fn make_index(dim: usize, metric: DistanceMetric) -> HnswIndex {
422        HnswIndex::new(
423            dim,
424            HnswConfig {
425                m: 16,
426                ef_construction: 100,
427                ef_search: 50,
428                metric,
429            },
430        )
431    }
432
433    fn dummy_record(id: &str, vector: Vector) -> VectorRecord {
434        VectorRecord {
435            id: id.to_string(),
436            vector,
437            payload: json!({ "id": id }),
438            text: Some(format!("text for {id}")),
439            created_at: Utc::now(),
440            updated_at: Utc::now(),
441        }
442    }
443
444    // ── Test 1 ───────────────────────────────────────
445    #[test]
446    fn test_distance_cosine_basic() {
447        let mut idx = make_index(3, DistanceMetric::Cosine);
448        idx.insert("a".into(), vec![1.0, 0.0, 0.0]).unwrap();
449        idx.insert("b".into(), vec![0.0, 1.0, 0.0]).unwrap();
450        idx.insert("c".into(), vec![0.0, 0.0, 1.0]).unwrap();
451        idx.insert("d".into(), vec![1.0, 0.0, 0.0]).unwrap();
452
453        let results = idx.search(&vec![1.0, 0.0, 0.0], 2).unwrap();
454        let ids: Vec<&str> = results.iter().map(|(id, _)| id.as_str()).collect();
455        assert!(ids.contains(&"a"), "expected 'a' in top-2");
456        assert!(ids.contains(&"d"), "expected 'd' in top-2");
457        assert!(results.iter().all(|(_, s)| *s > 0.99));
458    }
459
460    // ── Test 2 ───────────────────────────────────────
461    #[test]
462    fn test_euclidean_search() {
463        let mut idx = make_index(2, DistanceMetric::Euclidean);
464        idx.insert("near".into(), vec![1.0, 1.0]).unwrap();
465        idx.insert("far".into(), vec![100.0, 100.0]).unwrap();
466        idx.insert("medium".into(), vec![5.0, 5.0]).unwrap();
467
468        let results = idx.search(&vec![1.1, 1.1], 1).unwrap();
469        assert_eq!(results[0].0, "near");
470    }
471
472    // ── Test 3 ───────────────────────────────────────
473    #[test]
474    fn test_brute_force_fallback() {
475        // Insert without triggering auto-build by using dim > 100
476        // Actually auto-rebuild triggers for n <= 100; to avoid it we'd need n > 100.
477        // Instead just verify brute-force path works (it's also used on dirty index).
478        let mut idx = make_index(2, DistanceMetric::Euclidean);
479        // Force a state where hnsw is None by building only temporarily
480        // Insert 5, then call build with empty to clear hnsw, then search
481        for i in 0..5usize {
482            idx.insert(format!("v{i}"), vec![i as f32, 0.0]).unwrap();
483        }
484        // Clear hnsw to force brute force
485        idx.hnsw = None;
486        idx.dirty = false; // dirty=false + hnsw=None → brute force path
487
488        let results = idx.search(&vec![2.0, 0.0], 1).unwrap();
489        assert!(!results.is_empty());
490        assert_eq!(results[0].0, "v2");
491    }
492
493    // ── Test 4 ───────────────────────────────────────
494    #[test]
495    fn test_soft_delete() {
496        let mut idx = make_index(2, DistanceMetric::Euclidean);
497        idx.insert("a".into(), vec![1.0, 0.0]).unwrap();
498        idx.insert("b".into(), vec![2.0, 0.0]).unwrap();
499        idx.insert("c".into(), vec![3.0, 0.0]).unwrap();
500        idx.insert("d".into(), vec![4.0, 0.0]).unwrap();
501        idx.insert("e".into(), vec![5.0, 0.0]).unwrap();
502
503        idx.delete(&"a".to_string()).unwrap();
504        assert_eq!(idx.len(), 4);
505
506        let results = idx.search(&vec![1.0, 0.0], 5).unwrap();
507        let ids: Vec<&str> = results.iter().map(|(id, _)| id.as_str()).collect();
508        assert!(
509            !ids.contains(&"a"),
510            "deleted 'a' must not appear in results"
511        );
512    }
513
514    // ── Test 5 ───────────────────────────────────────
515    #[test]
516    fn test_save_and_load() {
517        let dir = tempdir().unwrap();
518        let path = dir.path().join("test.hnsw.json");
519        let config = CollectionConfig::new("test", 3);
520
521        let mut idx = HnswIndex::from_collection_config(&config);
522        for i in 0..10usize {
523            idx.insert(
524                format!("v{i}"),
525                vec![i as f32, (i + 1) as f32, (i + 2) as f32],
526            )
527            .unwrap();
528        }
529
530        let original = idx.search(&vec![5.0, 6.0, 7.0], 3).unwrap();
531        idx.save(&path).unwrap();
532
533        let loaded = HnswIndex::load_file(&path, &config).unwrap();
534        let after = loaded.search(&vec![5.0, 6.0, 7.0], 3).unwrap();
535
536        assert_eq!(original.len(), after.len());
537        // Top result should be the same
538        assert_eq!(original[0].0, after[0].0);
539    }
540
541    // ── Test 6 ───────────────────────────────────────
542    #[test]
543    fn test_dimension_mismatch_on_insert() {
544        let mut idx = make_index(4, DistanceMetric::Cosine);
545        let res = idx.insert("bad".into(), vec![1.0, 2.0, 3.0]);
546        assert!(
547            matches!(
548                res,
549                Err(VecDbError::DimensionMismatch {
550                    expected: 4,
551                    got: 3
552                })
553            ),
554            "expected DimensionMismatch"
555        );
556    }
557
558    // ── Test 7 ───────────────────────────────────────
559    #[test]
560    fn test_storage_search_integration() {
561        use crate::storage::Storage;
562
563        let dir = tempdir().unwrap();
564        let config = CollectionConfig::new("srch", 4);
565        let mut storage = Storage::create(dir.path(), &config).unwrap();
566
567        for i in 0..10usize {
568            let rec = dummy_record(
569                &format!("doc{i}"),
570                vec![
571                    (i as f32 + 1.0),
572                    (i as f32 + 2.0),
573                    (i as f32 + 3.0),
574                    (i as f32 + 4.0),
575                ],
576            );
577            storage.upsert(rec).unwrap();
578        }
579
580        let query = vec![5.0f32, 6.0, 7.0, 8.0];
581        let results = storage.search(&query, 10).unwrap();
582        assert!(!results.is_empty());
583        assert!(results.len() <= 10);
584
585        for r in &results {
586            assert!(
587                storage.metadata.get(&r.id).is_ok(),
588                "id {} not in metadata",
589                r.id
590            );
591            assert!(
592                r.score >= 0.0 && r.score <= 1.0,
593                "score {} out of [0,1]",
594                r.score
595            );
596        }
597    }
598
599    // ── Test 8 ───────────────────────────────────────
600    #[test]
601    fn test_index_rebuild_after_reopen() {
602        use crate::storage::Storage;
603
604        let dir = tempdir().unwrap();
605        let config = CollectionConfig::new("reopen", 2);
606
607        {
608            let mut storage = Storage::create(dir.path(), &config).unwrap();
609            for i in 0..5usize {
610                storage
611                    .upsert(dummy_record(&format!("r{i}"), vec![i as f32, 1.0]))
612                    .unwrap();
613            }
614            storage.save_indexes().unwrap();
615        }
616
617        let storage = Storage::open(dir.path(), "reopen").unwrap();
618        let results = storage.search(&vec![2.0, 1.0], 3).unwrap();
619        assert!(!results.is_empty());
620    }
621
622    // ── Test 9 ───────────────────────────────────────
623    #[test]
624    fn test_auto_rebuild_on_large_insert() {
625        let mut idx = make_index(2, DistanceMetric::Euclidean);
626        for i in 0..1000usize {
627            idx.insert(format!("v{i}"), vec![i as f32, 0.0]).unwrap();
628        }
629        assert_eq!(idx.len(), 1000);
630
631        let results = idx.search(&vec![500.0, 0.0], 1).unwrap();
632        assert!(!results.is_empty());
633        assert_eq!(results[0].0, "v500");
634    }
635}