Skip to main content

astraea_vector/
index.rs

1//! Thread-safe wrapper around [`HnswIndex`] implementing the [`VectorIndex`] trait.
2
3use std::path::Path;
4
5use parking_lot::RwLock;
6
7use astraea_core::error::Result;
8use astraea_core::traits::VectorIndex;
9use astraea_core::types::{DistanceMetric, NodeId, SimilarityResult};
10
11use crate::hnsw::HnswIndex;
12
13/// Default number of connections per node per layer.
14const DEFAULT_M: usize = 16;
15/// Default beam width during construction.
16const DEFAULT_EF_CONSTRUCTION: usize = 200;
17/// Default beam width during search.
18const DEFAULT_EF_SEARCH: usize = 50;
19
20/// A thread-safe HNSW-based vector index.
21///
22/// Wraps [`HnswIndex`] with a `parking_lot::RwLock` so that multiple readers
23/// can search concurrently, while writes (insert/remove) acquire exclusive access.
24pub struct HnswVectorIndex {
25    inner: RwLock<HnswIndex>,
26    ef_search: usize,
27}
28
29impl HnswVectorIndex {
30    /// Create a new HNSW vector index with default parameters.
31    ///
32    /// - `m = 16`
33    /// - `ef_construction = 200`
34    /// - `ef_search = 50`
35    pub fn new(dimension: usize, metric: DistanceMetric) -> Self {
36        Self {
37            inner: RwLock::new(HnswIndex::new(
38                dimension,
39                metric,
40                DEFAULT_M,
41                DEFAULT_EF_CONSTRUCTION,
42            )),
43            ef_search: DEFAULT_EF_SEARCH,
44        }
45    }
46
47    /// Create a new HNSW vector index with custom parameters.
48    pub fn with_params(
49        dimension: usize,
50        metric: DistanceMetric,
51        m: usize,
52        ef_construction: usize,
53        ef_search: usize,
54    ) -> Self {
55        Self {
56            inner: RwLock::new(HnswIndex::new(dimension, metric, m, ef_construction)),
57            ef_search,
58        }
59    }
60
61    /// Create a new HNSW vector index with a fixed RNG seed for reproducible
62    /// level sampling — useful for tests and benchmarks where the exact
63    /// graph layout needs to be deterministic. Uses the same default
64    /// parameters as [`Self::new`] otherwise. astraeadb-issues.md #18.
65    pub fn with_seed(dimension: usize, metric: DistanceMetric, seed: u64) -> Self {
66        Self {
67            inner: RwLock::new(HnswIndex::with_seed(
68                dimension,
69                metric,
70                DEFAULT_M,
71                DEFAULT_EF_CONSTRUCTION,
72                seed,
73            )),
74            ef_search: DEFAULT_EF_SEARCH,
75        }
76    }
77
78    /// Persist the index to the given file path.
79    ///
80    /// Acquires a read lock on the inner index and writes the full
81    /// HNSW state to a versioned binary file.
82    pub fn save_to_file(&self, path: &Path) -> Result<()> {
83        let idx = self.inner.read();
84        idx.save(path)
85    }
86
87    /// Load an index from the given file path.
88    ///
89    /// Reads and validates the binary file, then wraps the deserialized
90    /// `HnswIndex` in a new `HnswVectorIndex` with the default `ef_search`.
91    pub fn load_from_file(path: &Path) -> Result<Self> {
92        let idx = HnswIndex::load(path)?;
93        Ok(Self {
94            inner: RwLock::new(idx),
95            ef_search: DEFAULT_EF_SEARCH,
96        })
97    }
98}
99
100impl VectorIndex for HnswVectorIndex {
101    fn insert(&self, node_id: NodeId, embedding: &[f32]) -> Result<()> {
102        let mut idx = self.inner.write();
103        idx.insert(node_id, embedding)
104    }
105
106    fn remove(&self, node_id: NodeId) -> Result<bool> {
107        let mut idx = self.inner.write();
108        idx.remove(node_id)
109    }
110
111    fn search(&self, query: &[f32], k: usize) -> Result<Vec<SimilarityResult>> {
112        let idx = self.inner.read();
113        let raw_results = idx.search(query, k, self.ef_search)?;
114        Ok(raw_results
115            .into_iter()
116            .map(|(node_id, distance)| SimilarityResult { node_id, distance })
117            .collect())
118    }
119
120    fn dimension(&self) -> usize {
121        let idx = self.inner.read();
122        idx.dimension()
123    }
124
125    fn metric(&self) -> DistanceMetric {
126        let idx = self.inner.read();
127        idx.metric()
128    }
129
130    fn len(&self) -> usize {
131        let idx = self.inner.read();
132        idx.len()
133    }
134
135    fn node_ids(&self) -> Vec<NodeId> {
136        let idx = self.inner.read();
137        idx.node_ids()
138    }
139
140    fn save_to_path(&self, path: &Path) -> Result<()> {
141        self.save_to_file(path)
142    }
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148
149    #[test]
150    fn test_vector_index_trait_basic() {
151        let idx = HnswVectorIndex::new(3, DistanceMetric::Euclidean);
152        assert_eq!(idx.dimension(), 3);
153        assert_eq!(idx.metric(), DistanceMetric::Euclidean);
154        assert!(idx.is_empty());
155        assert_eq!(idx.len(), 0);
156
157        idx.insert(NodeId(1), &[1.0, 0.0, 0.0]).unwrap();
158        idx.insert(NodeId(2), &[0.0, 1.0, 0.0]).unwrap();
159        idx.insert(NodeId(3), &[0.0, 0.0, 1.0]).unwrap();
160
161        assert_eq!(idx.len(), 3);
162        assert!(!idx.is_empty());
163
164        let results = idx.search(&[1.0, 0.0, 0.0], 2).unwrap();
165        assert_eq!(results.len(), 2);
166        assert_eq!(results[0].node_id, NodeId(1));
167        assert!(results[0].distance < 1e-6);
168    }
169
170    #[test]
171    fn test_vector_index_trait_remove() {
172        let idx = HnswVectorIndex::new(2, DistanceMetric::Cosine);
173        idx.insert(NodeId(1), &[1.0, 0.0]).unwrap();
174        idx.insert(NodeId(2), &[0.0, 1.0]).unwrap();
175
176        assert!(idx.remove(NodeId(1)).unwrap());
177        assert_eq!(idx.len(), 1);
178        assert!(!idx.remove(NodeId(99)).unwrap());
179    }
180
181    #[test]
182    fn test_vector_index_custom_params() {
183        let idx = HnswVectorIndex::with_params(4, DistanceMetric::DotProduct, 8, 100, 30);
184        assert_eq!(idx.dimension(), 4);
185        assert_eq!(idx.metric(), DistanceMetric::DotProduct);
186    }
187
188    // --- Task 2 & 3 (issue-26 §11): node_ids and save_to_path through the trait ---
189
190    /// node_ids on HnswVectorIndex reflects inserts/removes through the VectorIndex trait.
191    #[test]
192    fn test_node_ids_via_trait() {
193        let idx: Box<dyn VectorIndex> =
194            Box::new(HnswVectorIndex::new(2, DistanceMetric::Euclidean));
195
196        assert!(idx.node_ids().is_empty());
197
198        idx.insert(NodeId(10), &[1.0, 0.0]).unwrap();
199        idx.insert(NodeId(20), &[0.0, 1.0]).unwrap();
200
201        let mut ids = idx.node_ids();
202        ids.sort();
203        assert_eq!(ids, vec![NodeId(10), NodeId(20)]);
204
205        idx.remove(NodeId(10)).unwrap();
206        let ids = idx.node_ids();
207        assert_eq!(ids, vec![NodeId(20)]);
208    }
209
210    /// save_to_path on HnswVectorIndex persists a file that load_from_file can read back.
211    #[test]
212    fn test_save_to_path_via_trait() {
213        use astraea_core::traits::VectorIndex as VTrait;
214
215        let tmp = tempfile::NamedTempFile::new().unwrap();
216        let path = tmp.path().to_owned();
217        // Close the NamedTempFile so save_to_file can create the file (it calls File::create).
218        drop(tmp);
219
220        let original: Box<dyn VTrait> =
221            Box::new(HnswVectorIndex::new(3, DistanceMetric::Euclidean));
222        original.insert(NodeId(1), &[1.0, 0.0, 0.0]).unwrap();
223        original.insert(NodeId(2), &[0.0, 1.0, 0.0]).unwrap();
224
225        // Save through the trait.
226        original.save_to_path(&path).unwrap();
227
228        // Load back directly (the Graph layer's load path).
229        let loaded = HnswVectorIndex::load_from_file(&path).unwrap();
230        let mut ids = loaded.node_ids();
231        ids.sort();
232        assert_eq!(ids, vec![NodeId(1), NodeId(2)]);
233        assert_eq!(loaded.dimension(), 3);
234    }
235}