Skip to main content

ruvector_core/index/
hnsw.rs

1//! HNSW (Hierarchical Navigable Small World) index implementation
2
3use crate::distance::distance;
4use crate::error::{Result, RuvectorError};
5use crate::index::flat_graph::FlatGraph;
6use crate::index::VectorIndex;
7use crate::types::{DistanceMetric, HnswConfig, SearchResult, VectorId};
8use bincode::{Decode, Encode};
9use dashmap::DashMap;
10use parking_lot::RwLock;
11use std::sync::Arc;
12
13/// HNSW index wrapper
14pub struct HnswIndex {
15    inner: Arc<RwLock<HnswInner>>,
16    config: HnswConfig,
17    metric: DistanceMetric,
18    dimensions: usize,
19}
20
21struct HnswInner {
22    hnsw: FlatGraph,
23    vectors: DashMap<VectorId, Vec<f32>>,
24    id_to_idx: DashMap<VectorId, usize>,
25    idx_to_id: DashMap<usize, VectorId>,
26    next_idx: usize,
27}
28
29/// Serializable HNSW index state
30#[derive(Encode, Decode, Clone)]
31pub struct HnswState {
32    vectors: Vec<(String, Vec<f32>)>,
33    id_to_idx: Vec<(String, usize)>,
34    idx_to_id: Vec<(usize, String)>,
35    next_idx: usize,
36    config: SerializableHnswConfig,
37    dimensions: usize,
38    metric: SerializableDistanceMetric,
39}
40
41#[derive(Encode, Decode, Clone)]
42struct SerializableHnswConfig {
43    m: usize,
44    ef_construction: usize,
45    ef_search: usize,
46    max_elements: usize,
47}
48
49#[derive(Encode, Decode, Clone, Copy)]
50enum SerializableDistanceMetric {
51    Euclidean,
52    Cosine,
53    DotProduct,
54    Manhattan,
55}
56
57impl From<DistanceMetric> for SerializableDistanceMetric {
58    fn from(metric: DistanceMetric) -> Self {
59        match metric {
60            DistanceMetric::Euclidean => SerializableDistanceMetric::Euclidean,
61            DistanceMetric::Cosine => SerializableDistanceMetric::Cosine,
62            DistanceMetric::DotProduct => SerializableDistanceMetric::DotProduct,
63            DistanceMetric::Manhattan => SerializableDistanceMetric::Manhattan,
64        }
65    }
66}
67
68impl From<SerializableDistanceMetric> for DistanceMetric {
69    fn from(metric: SerializableDistanceMetric) -> Self {
70        match metric {
71            SerializableDistanceMetric::Euclidean => DistanceMetric::Euclidean,
72            SerializableDistanceMetric::Cosine => DistanceMetric::Cosine,
73            SerializableDistanceMetric::DotProduct => DistanceMetric::DotProduct,
74            SerializableDistanceMetric::Manhattan => DistanceMetric::Manhattan,
75        }
76    }
77}
78
79impl HnswIndex {
80    /// Create a new HNSW index
81    pub fn new(dimensions: usize, metric: DistanceMetric, config: HnswConfig) -> Result<Self> {
82        let hnsw = FlatGraph::new(dimensions, metric, config.m, config.ef_construction);
83
84        Ok(Self {
85            inner: Arc::new(RwLock::new(HnswInner {
86                hnsw,
87                vectors: DashMap::new(),
88                id_to_idx: DashMap::new(),
89                idx_to_id: DashMap::new(),
90                next_idx: 0,
91            })),
92            config,
93            metric,
94            dimensions,
95        })
96    }
97
98    /// Get configuration
99    /// Structural violations of the layer invariants restored by the
100    /// ruvnet/RuVector#773 fix: `(edges above an endpoint's level, points
101    /// unreachable over layer-0 edges)`.
102    ///
103    /// Both are empty on a healthy index. Exposed so the regression suite can
104    /// assert the invariant EXHAUSTIVELY on any index, instead of sampling
105    /// `search()` results and arguing from probability — levels come from
106    /// `StdRng::from_entropy()`, so a sampled trial cannot be made
107    /// deterministic, while these hold for every draw.
108    #[doc(hidden)]
109    #[cfg(feature = "hnsw-structural-diagnostics")]
110    pub fn structural_violations(&self) -> (Vec<String>, Vec<usize>) {
111        let guard = self.inner.read();
112        (
113            guard.hnsw.edges_above_endpoint_level(),
114            guard.hnsw.points_unreachable_at_layer0(),
115        )
116    }
117
118    pub fn config(&self) -> &HnswConfig {
119        &self.config
120    }
121
122    /// Set efSearch parameter for query-time accuracy tuning.
123    ///
124    /// Higher values increase recall at the cost of search latency.
125    /// Typical range: 50–500. Must be >= k for meaningful results.
126    pub fn set_ef_search(&mut self, ef_search: usize) {
127        self.config.ef_search = ef_search;
128    }
129
130    /// Serialize the index to bytes using bincode
131    pub fn serialize(&self) -> Result<Vec<u8>> {
132        let inner = self.inner.read();
133
134        let state = HnswState {
135            vectors: inner
136                .vectors
137                .iter()
138                .map(|entry| (entry.key().clone(), entry.value().clone()))
139                .collect(),
140            id_to_idx: inner
141                .id_to_idx
142                .iter()
143                .map(|entry| (entry.key().clone(), *entry.value()))
144                .collect(),
145            idx_to_id: inner
146                .idx_to_id
147                .iter()
148                .map(|entry| (*entry.key(), entry.value().clone()))
149                .collect(),
150            next_idx: inner.next_idx,
151            config: SerializableHnswConfig {
152                m: self.config.m,
153                ef_construction: self.config.ef_construction,
154                ef_search: self.config.ef_search,
155                max_elements: self.config.max_elements,
156            },
157            dimensions: self.dimensions,
158            metric: self.metric.into(),
159        };
160
161        bincode::encode_to_vec(&state, bincode::config::standard()).map_err(|e| {
162            RuvectorError::SerializationError(format!("Failed to serialize HNSW index: {}", e))
163        })
164    }
165
166    /// Deserialize the index from bytes using bincode
167    pub fn deserialize(bytes: &[u8]) -> Result<Self> {
168        let (state, _): (HnswState, usize) =
169            bincode::decode_from_slice(bytes, bincode::config::standard()).map_err(|e| {
170                RuvectorError::SerializationError(format!(
171                    "Failed to deserialize HNSW index: {}",
172                    e
173                ))
174            })?;
175
176        let config = HnswConfig {
177            m: state.config.m,
178            ef_construction: state.config.ef_construction,
179            ef_search: state.config.ef_search,
180            max_elements: state.config.max_elements,
181        };
182
183        let dimensions = state.dimensions;
184        let metric: DistanceMetric = state.metric.into();
185
186        let mut hnsw = FlatGraph::new(dimensions, metric, config.m, config.ef_construction);
187
188        // Rebuild the index by inserting all vectors.
189        // Build a HashMap first to avoid O(n^2) linear search in the loop below.
190        let vectors_lookup: std::collections::HashMap<&str, &Vec<f32>> = state
191            .vectors
192            .iter()
193            .map(|(id, v)| (id.as_str(), v))
194            .collect();
195
196        let id_to_idx: DashMap<VectorId, usize> = state.id_to_idx.into_iter().collect();
197        let idx_to_id: DashMap<usize, VectorId> = state.idx_to_id.into_iter().collect();
198
199        // Insert vectors into HNSW in index order for deterministic reconstruction.
200        let mut sorted_entries: Vec<_> = idx_to_id
201            .iter()
202            .map(|e| (*e.key(), e.value().clone()))
203            .collect();
204        sorted_entries.sort_unstable_by_key(|(idx, _)| *idx);
205
206        for (idx, id) in &sorted_entries {
207            if let Some(vector) = vectors_lookup.get(id.as_str()) {
208                hnsw.insert(vector, *idx);
209            }
210        }
211
212        let vectors_map: DashMap<VectorId, Vec<f32>> = state.vectors.into_iter().collect();
213
214        Ok(Self {
215            inner: Arc::new(RwLock::new(HnswInner {
216                hnsw,
217                vectors: vectors_map,
218                id_to_idx,
219                idx_to_id,
220                next_idx: state.next_idx,
221            })),
222            config,
223            metric,
224            dimensions,
225        })
226    }
227
228    /// Search with custom efSearch parameter.
229    ///
230    /// `ef_search` must be >= `k`; values smaller than `k` are clamped to `k`
231    /// to avoid silent under-recall.  Results are returned sorted by ascending
232    /// distance (closest first).
233    pub fn search_with_ef(
234        &self,
235        query: &[f32],
236        k: usize,
237        ef_search: usize,
238    ) -> Result<Vec<SearchResult>> {
239        if query.len() != self.dimensions {
240            return Err(RuvectorError::DimensionMismatch {
241                expected: self.dimensions,
242                actual: query.len(),
243            });
244        }
245
246        if k == 0 {
247            return Ok(vec![]);
248        }
249
250        let inner = self.inner.read();
251
252        // hnsw_rs panics in its BinaryHeap traversal when the index is empty
253        // or contains only a single element (the candidate/return-point loop
254        // calls .peek().unwrap() without an emptiness guard).  Return early
255        // to surface a clean error instead of an assertion panic.
256        if inner.vectors.is_empty() {
257            return Ok(vec![]);
258        }
259
260        // ef_search < k causes hnsw_rs to return fewer than k candidates; clamp.
261        let effective_ef = ef_search.max(k);
262
263        let neighbors = inner.hnsw.search(query, k, effective_ef);
264
265        let mut results: Vec<SearchResult> = neighbors
266            .into_iter()
267            .filter_map(|(idx, distance)| {
268                inner.idx_to_id.get(&idx).map(|id| SearchResult {
269                    id: id.clone(),
270                    score: distance,
271                    vector: None,
272                    metadata: None,
273                })
274            })
275            .collect();
276
277        // hnsw_rs does not guarantee sort order — ensure ascending distance.
278        results.sort_unstable_by(|a, b| {
279            a.score
280                .partial_cmp(&b.score)
281                .unwrap_or(std::cmp::Ordering::Equal)
282        });
283
284        Ok(results)
285    }
286}
287
288impl VectorIndex for HnswIndex {
289    fn add(&mut self, id: VectorId, vector: Vec<f32>) -> Result<()> {
290        if vector.len() != self.dimensions {
291            return Err(RuvectorError::DimensionMismatch {
292                expected: self.dimensions,
293                actual: vector.len(),
294            });
295        }
296
297        let mut inner = self.inner.write();
298        let idx = inner.next_idx;
299        inner.next_idx += 1;
300
301        inner.hnsw.insert(&vector, idx);
302
303        // Store mappings
304        inner.vectors.insert(id.clone(), vector);
305        inner.id_to_idx.insert(id.clone(), idx);
306        inner.idx_to_id.insert(idx, id);
307
308        Ok(())
309    }
310
311    fn add_batch(&mut self, entries: Vec<(VectorId, Vec<f32>)>) -> Result<()> {
312        // Validate all dimensions first
313        for (_, vector) in &entries {
314            if vector.len() != self.dimensions {
315                return Err(RuvectorError::DimensionMismatch {
316                    expected: self.dimensions,
317                    actual: vector.len(),
318                });
319            }
320        }
321
322        let mut inner = self.inner.write();
323
324        // Assign indices, then insert in order. (The graph has one writer; batches are not split across threads.)
325        let data_with_ids: Vec<_> = entries
326            .into_iter()
327            .enumerate()
328            .map(|(i, (id, vector))| (id, inner.next_idx + i, vector))
329            .collect();
330        inner.next_idx += data_with_ids.len();
331        let items: Vec<(&[f32], usize)> = data_with_ids
332            .iter()
333            .map(|(_, idx, v)| (v.as_slice(), *idx))
334            .collect();
335        inner.hnsw.insert_batch(&items);
336
337        // Store mappings
338        for (id, idx, vector) in data_with_ids {
339            inner.vectors.insert(id.clone(), vector);
340            inner.id_to_idx.insert(id.clone(), idx);
341            inner.idx_to_id.insert(idx, id);
342        }
343
344        Ok(())
345    }
346
347    fn search(&self, query: &[f32], k: usize) -> Result<Vec<SearchResult>> {
348        // Use configured ef_search
349        self.search_with_ef(query, k, self.config.ef_search)
350    }
351
352    fn search_ef(
353        &self,
354        query: &[f32],
355        k: usize,
356        ef_search: Option<usize>,
357    ) -> Result<Vec<SearchResult>> {
358        self.search_with_ef(query, k, ef_search.unwrap_or(self.config.ef_search))
359    }
360
361    fn remove(&mut self, id: &VectorId) -> Result<bool> {
362        let inner = self.inner.write();
363
364        // Note: hnsw_rs doesn't support direct deletion
365        // We remove from our mappings but the graph structure remains
366        // This is a known limitation of HNSW
367        let removed = inner.vectors.remove(id).is_some();
368
369        if removed {
370            if let Some((_, idx)) = inner.id_to_idx.remove(id) {
371                inner.idx_to_id.remove(&idx);
372            }
373        }
374
375        Ok(removed)
376    }
377
378    fn len(&self) -> usize {
379        self.inner.read().vectors.len()
380    }
381}
382
383#[cfg(test)]
384mod tests {
385    use super::*;
386
387    fn generate_random_vectors(count: usize, dimensions: usize) -> Vec<Vec<f32>> {
388        use rand::Rng;
389        let mut rng = rand::thread_rng();
390
391        (0..count)
392            .map(|_| (0..dimensions).map(|_| rng.gen::<f32>()).collect())
393            .collect()
394    }
395
396    fn normalize_vector(v: &[f32]) -> Vec<f32> {
397        let norm = v.iter().map(|x| x * x).sum::<f32>().sqrt();
398        if norm > 0.0 {
399            v.iter().map(|x| x / norm).collect()
400        } else {
401            v.to_vec()
402        }
403    }
404
405    #[test]
406    fn test_search_ef_overrides_the_configured_value() -> Result<()> {
407        let (n, dim, k) = (1500usize, 16usize, 10usize);
408        let base = generate_random_vectors(n, dim);
409        let queries = generate_random_vectors(60, dim);
410        let config = HnswConfig {
411            m: 16,
412            ef_construction: 200,
413            ef_search: k,
414            max_elements: n + 10,
415        };
416        let mut index = HnswIndex::new(dim, DistanceMetric::Euclidean, config)?;
417        for (i, v) in base.iter().enumerate() {
418            index.add(i.to_string(), v.clone())?;
419        }
420        let truth = |q: &[f32]| -> Vec<String> {
421            let mut d: Vec<(f32, usize)> = base
422                .iter()
423                .enumerate()
424                .map(|(i, v)| {
425                    (
426                        v.iter().zip(q).map(|(a, b)| (a - b) * (a - b)).sum::<f32>(),
427                        i,
428                    )
429                })
430                .collect();
431            d.sort_by(|a, b| a.0.total_cmp(&b.0));
432            d.into_iter().take(k).map(|x| x.1.to_string()).collect()
433        };
434        let (mut low, mut high) = (0usize, 0usize);
435        for q in &queries {
436            let t = truth(q);
437            let count = |r: Vec<SearchResult>| r.iter().filter(|x| t.contains(&x.id)).count();
438            assert_eq!(
439                index
440                    .search_ef(q, k, None)?
441                    .iter()
442                    .map(|r| r.id.clone())
443                    .collect::<Vec<_>>(),
444                index
445                    .search(q, k)?
446                    .iter()
447                    .map(|r| r.id.clone())
448                    .collect::<Vec<_>>(),
449                "None means the configured value"
450            );
451            low += count(index.search_ef(q, k, Some(k))?);
452            high += count(index.search_ef(q, k, Some(300))?);
453        }
454        let (low, high) = (
455            low as f64 / (queries.len() * k) as f64,
456            high as f64 / (queries.len() * k) as f64,
457        );
458        assert!(
459            high >= low,
460            "a larger ef must not lower recall: {low} -> {high}"
461        );
462        assert!(high > 0.97, "ef=300 recall {high}");
463        Ok(())
464    }
465
466    #[test]
467    fn test_hnsw_index_creation() -> Result<()> {
468        let config = HnswConfig::default();
469        let index = HnswIndex::new(128, DistanceMetric::Cosine, config)?;
470        assert_eq!(index.len(), 0);
471        Ok(())
472    }
473
474    #[test]
475    fn test_hnsw_insert_and_search() -> Result<()> {
476        let config = HnswConfig {
477            m: 16,
478            ef_construction: 100,
479            ef_search: 50,
480            max_elements: 1000,
481        };
482
483        let mut index = HnswIndex::new(128, DistanceMetric::Cosine, config)?;
484
485        // Insert a few vectors
486        let vectors = generate_random_vectors(100, 128);
487        for (i, vector) in vectors.iter().enumerate() {
488            let normalized = normalize_vector(vector);
489            index.add(format!("vec_{}", i), normalized)?;
490        }
491
492        assert_eq!(index.len(), 100);
493
494        // Search for the first vector
495        let query = normalize_vector(&vectors[0]);
496        let results = index.search(&query, 10)?;
497
498        assert!(!results.is_empty());
499        assert_eq!(results[0].id, "vec_0");
500
501        Ok(())
502    }
503
504    #[test]
505    fn test_hnsw_batch_insert() -> Result<()> {
506        let config = HnswConfig::default();
507        let mut index = HnswIndex::new(128, DistanceMetric::Cosine, config)?;
508
509        let vectors = generate_random_vectors(100, 128);
510        let entries: Vec<_> = vectors
511            .iter()
512            .enumerate()
513            .map(|(i, v)| (format!("vec_{}", i), normalize_vector(v)))
514            .collect();
515
516        index.add_batch(entries)?;
517        assert_eq!(index.len(), 100);
518
519        Ok(())
520    }
521
522    #[test]
523    fn test_hnsw_serialization() -> Result<()> {
524        let config = HnswConfig {
525            m: 16,
526            ef_construction: 100,
527            ef_search: 50,
528            max_elements: 1000,
529        };
530
531        let mut index = HnswIndex::new(128, DistanceMetric::Cosine, config)?;
532
533        // Insert vectors
534        let vectors = generate_random_vectors(50, 128);
535        for (i, vector) in vectors.iter().enumerate() {
536            let normalized = normalize_vector(vector);
537            index.add(format!("vec_{}", i), normalized)?;
538        }
539
540        // Serialize
541        let bytes = index.serialize()?;
542
543        // Deserialize
544        let restored_index = HnswIndex::deserialize(&bytes)?;
545
546        assert_eq!(restored_index.len(), 50);
547
548        // Test search on restored index
549        let query = normalize_vector(&vectors[0]);
550        let results = restored_index.search(&query, 5)?;
551
552        assert!(!results.is_empty());
553
554        Ok(())
555    }
556
557    #[test]
558    fn test_dimension_mismatch() -> Result<()> {
559        let config = HnswConfig::default();
560        let mut index = HnswIndex::new(128, DistanceMetric::Cosine, config)?;
561
562        let result = index.add("test".to_string(), vec![1.0; 64]);
563        assert!(result.is_err());
564
565        Ok(())
566    }
567}