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