Skip to main content

lean_ctx/core/
hnsw.rs

1//! Lightweight HNSW (Hierarchical Navigable Small World) index for approximate nearest neighbors.
2//!
3//! Scientific basis: Malkov & Yashunin, "Efficient and Robust Approximate Nearest Neighbor
4//! using Hierarchical Navigable Small World Graphs" (IEEE TPAMI 2018).
5//!
6//! This is a minimal implementation optimized for lean-ctx's embedding dimensions (384-d).
7//! For indices under BRUTE_FORCE_THRESHOLD chunks, falls back to exact linear scan
8//! with binary-heap top-k selection (O(n log k) instead of O(n log n)).
9//!
10//! All embeddings are stored in a single flat `Arc<[f32]>` allocation (row-major). This
11//! gives sequential memory access during the distance hot-loop — one dereference instead
12//! of `Arc → slice → Vec → f32 heap`, eliminates per-vector allocation overhead, and
13//! improves cache utilization for large corpora.
14
15use std::cmp::Ordering;
16use std::collections::BinaryHeap;
17use std::sync::Arc;
18
19const BRUTE_FORCE_THRESHOLD: usize = 1000;
20const M: usize = 16; // max connections per node per layer
21const EF_CONSTRUCTION: usize = 200; // search width during build
22const EF_SEARCH: usize = 64; // search width during query
23// ML = 1/ln(M) = 1/ln(16) ≈ 0.3607
24const ML: f64 = 0.360_674_0;
25
26/// Flat row-major embedding matrix: `data` is `n_vectors × dim` floats in one
27/// contiguous heap allocation. Used everywhere in the dense search pipeline so
28/// the same allocation backs both the HNSW graph cache and per-query scoring —
29/// zero copies between layers.
30#[derive(Clone)]
31pub struct FlatEmbeddings {
32    pub data: Arc<[f32]>,
33    pub dim: usize,
34}
35
36impl FlatEmbeddings {
37    /// Number of vectors in the matrix.
38    #[inline]
39    pub fn n_vectors(&self) -> usize {
40        if self.dim == 0 {
41            return 0;
42        }
43        self.data.len() / self.dim
44    }
45
46    /// View the i-th vector as a `&[f32]`.
47    #[inline]
48    pub fn get(&self, i: usize) -> &[f32] {
49        let start = i * self.dim;
50        &self.data[start..][..self.dim]
51    }
52
53    /// Extract the i-th vector into an owned `Vec<f32>`.
54    #[inline]
55    pub fn get_vec(&self, i: usize) -> Vec<f32> {
56        self.get(i).to_vec()
57    }
58
59    /// Build from a `Vec<Vec<f32>>` (for tests / migration).
60    pub fn from_vecs(vecs: Vec<Vec<f32>>) -> Self {
61        let dim = vecs.first().map_or(0, std::vec::Vec::len);
62        let n = vecs.len();
63        let mut data = Vec::with_capacity(n * dim);
64        for v in vecs {
65            debug_assert_eq!(v.len(), dim);
66            data.extend_from_slice(&v);
67        }
68        Self {
69            data: Arc::from(data),
70            dim,
71        }
72    }
73}
74
75impl std::fmt::Debug for FlatEmbeddings {
76    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
77        f.debug_struct("FlatEmbeddings")
78            .field("n_vectors", &self.n_vectors())
79            .field("dim", &self.dim)
80            .field("total_bytes", &(self.data.len() * 4))
81            .finish()
82    }
83}
84
85/// A scored item for the min-heap (lowest similarity first for top-k pruning).
86#[derive(Clone, PartialEq)]
87struct Candidate {
88    idx: usize,
89    sim: f32,
90}
91
92impl Eq for Candidate {}
93
94impl PartialOrd for Candidate {
95    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
96        Some(self.cmp(other))
97    }
98}
99
100impl Ord for Candidate {
101    fn cmp(&self, other: &Self) -> Ordering {
102        // Min-heap: lower similarity should be popped first
103        other.sim.partial_cmp(&self.sim).unwrap_or(Ordering::Equal)
104    }
105}
106
107/// Max-heap variant for HNSW traversal.
108#[derive(Clone, PartialEq)]
109struct MaxCandidate {
110    idx: usize,
111    sim: f32,
112}
113
114impl Eq for MaxCandidate {}
115
116impl PartialOrd for MaxCandidate {
117    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
118        Some(self.cmp(other))
119    }
120}
121
122impl Ord for MaxCandidate {
123    fn cmp(&self, other: &Self) -> Ordering {
124        self.sim.partial_cmp(&other.sim).unwrap_or(Ordering::Equal)
125    }
126}
127
128/// HNSW index node.
129struct Node {
130    connections: Vec<Vec<usize>>, // connections[layer] = list of neighbor indices
131}
132
133/// Approximate nearest neighbor index using HNSW for large datasets,
134/// with brute-force fallback for small ones.
135///
136/// Vectors are stored in a [`FlatEmbeddings`] — one contiguous `Arc<[f32]>`
137/// allocation — shared with the rest of the pipeline.
138pub struct AnnIndex {
139    embeddings: FlatEmbeddings,
140    nodes: Vec<Node>,
141    entry_point: usize,
142    max_level: usize,
143}
144
145impl AnnIndex {
146    /// Build the index from a [`FlatEmbeddings`].
147    ///
148    /// The corpus is shared via `FlatEmbeddings.data` (an `Arc::clone`, zero
149    /// bytes copied), so the cached HNSW index shares the *same* flat f32
150    /// allocation as the per-query aligned corpus.
151    pub fn build(embeddings: FlatEmbeddings) -> Self {
152        let n = embeddings.n_vectors();
153        if n == 0 {
154            return Self {
155                embeddings,
156                nodes: Vec::new(),
157                entry_point: 0,
158                max_level: 0,
159            };
160        }
161
162        if n < BRUTE_FORCE_THRESHOLD {
163            return Self {
164                embeddings,
165                nodes: Vec::new(),
166                entry_point: 0,
167                max_level: 0,
168            };
169        }
170
171        let mut index = Self {
172            embeddings,
173            nodes: Vec::with_capacity(n),
174            entry_point: 0,
175            max_level: 0,
176        };
177
178        for i in 0..n {
179            index.insert(i);
180        }
181
182        index
183    }
184
185    fn insert(&mut self, new_id: usize) {
186        let level = Self::level_for(new_id);
187
188        self.nodes.push(Node {
189            connections: vec![Vec::new(); level + 1],
190        });
191
192        if self.nodes.len() == 1 {
193            self.entry_point = 0;
194            self.max_level = level;
195            return;
196        }
197
198        let mut ep = self.entry_point;
199        let new_vec = self.embeddings.get(new_id);
200
201        // Traverse from top layer down to level+1 (greedy)
202        for lc in (level + 1..=self.max_level).rev() {
203            ep = self.search_layer_single(new_vec, ep, lc);
204        }
205
206        // Insert into layers [min(level, max_level) .. 0]
207        let insert_levels = level.min(self.max_level);
208        for lc in (0..=insert_levels).rev() {
209            let neighbors = self.search_layer(new_vec, ep, EF_CONSTRUCTION, lc);
210            let selected = Self::select_neighbors(&neighbors, M);
211
212            if lc < self.nodes[new_id].connections.len() {
213                self.nodes[new_id].connections[lc].clone_from(&selected);
214            }
215
216            for &neighbor in &selected {
217                if lc < self.nodes[neighbor].connections.len() {
218                    self.nodes[neighbor].connections[lc].push(new_id);
219                    if self.nodes[neighbor].connections[lc].len() > M * 2 {
220                        let nv = self.embeddings.get(neighbor);
221                        let mut scored: Vec<(usize, f32)> = self.nodes[neighbor].connections[lc]
222                            .iter()
223                            .map(|&n| (n, cosine_sim(nv, self.embeddings.get(n))))
224                            .collect();
225                        scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(Ordering::Equal));
226                        scored.truncate(M);
227                        self.nodes[neighbor].connections[lc] =
228                            scored.into_iter().map(|(id, _)| id).collect();
229                    }
230                }
231            }
232
233            if !neighbors.is_empty() {
234                ep = neighbors[0].0;
235            }
236        }
237
238        if level > self.max_level {
239            self.max_level = level;
240            self.entry_point = new_id;
241        }
242    }
243
244    fn search_layer_single(&self, query: &[f32], ep: usize, _layer: usize) -> usize {
245        let mut current = ep;
246        let mut best_sim = cosine_sim(query, self.embeddings.get(ep));
247
248        loop {
249            let mut improved = false;
250            let conns = &self.nodes[current].connections;
251            let layer_conns = if _layer < conns.len() {
252                &conns[_layer]
253            } else {
254                break;
255            };
256
257            for &neighbor in layer_conns {
258                let sim = cosine_sim(query, self.embeddings.get(neighbor));
259                if sim > best_sim {
260                    best_sim = sim;
261                    current = neighbor;
262                    improved = true;
263                }
264            }
265            if !improved {
266                break;
267            }
268        }
269        current
270    }
271
272    fn search_layer(&self, query: &[f32], ep: usize, ef: usize, layer: usize) -> Vec<(usize, f32)> {
273        let n = self.embeddings.n_vectors();
274        let mut visited = vec![false; n];
275        let mut candidates = BinaryHeap::<MaxCandidate>::new();
276        let mut results = BinaryHeap::<Candidate>::new();
277
278        let sim = cosine_sim(query, self.embeddings.get(ep));
279        visited[ep] = true;
280        candidates.push(MaxCandidate { idx: ep, sim });
281        results.push(Candidate { idx: ep, sim });
282
283        while let Some(MaxCandidate { idx: c, sim: _ }) = candidates.pop() {
284            let worst_result = results.peek().map_or(f32::MIN, |r| r.sim);
285            if cosine_sim(query, self.embeddings.get(c)) < worst_result && results.len() >= ef {
286                break;
287            }
288
289            let conns = &self.nodes[c].connections;
290            let layer_conns = if layer < conns.len() {
291                &conns[layer]
292            } else {
293                continue;
294            };
295
296            for &neighbor in layer_conns {
297                if visited[neighbor] {
298                    continue;
299                }
300                visited[neighbor] = true;
301
302                let n_sim = cosine_sim(query, self.embeddings.get(neighbor));
303                let worst = results.peek().map_or(f32::MIN, |r| r.sim);
304
305                if results.len() < ef || n_sim > worst {
306                    candidates.push(MaxCandidate {
307                        idx: neighbor,
308                        sim: n_sim,
309                    });
310                    results.push(Candidate {
311                        idx: neighbor,
312                        sim: n_sim,
313                    });
314                    if results.len() > ef {
315                        results.pop();
316                    }
317                }
318            }
319        }
320
321        let mut out: Vec<(usize, f32)> = results.into_iter().map(|c| (c.idx, c.sim)).collect();
322        out.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(Ordering::Equal));
323        out
324    }
325
326    fn select_neighbors(candidates: &[(usize, f32)], max_count: usize) -> Vec<usize> {
327        candidates
328            .iter()
329            .take(max_count)
330            .map(|&(idx, _)| idx)
331            .collect()
332    }
333
334    /// Deterministic geometric level draw seeded by the node's insertion index.
335    fn level_for(node_id: usize) -> usize {
336        let mut z = (node_id as u64).wrapping_add(0x9E37_79B9_7F4A_7C15);
337        z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
338        z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
339        z ^= z >> 31;
340        let r = (((z >> 11) as f64) + 1.0) / ((1u64 << 53) as f64 + 1.0);
341        (-r.ln() * ML).floor() as usize
342    }
343
344    /// Search for the top-k nearest neighbors of a query vector.
345    /// Returns (index, similarity) pairs sorted by descending similarity.
346    pub fn search(&self, query: &[f32], top_k: usize) -> Vec<(usize, f32)> {
347        if self.embeddings.n_vectors() == 0 {
348            return Vec::new();
349        }
350
351        // Brute-force for small indices (faster due to no graph overhead)
352        if self.nodes.is_empty() || self.embeddings.n_vectors() < BRUTE_FORCE_THRESHOLD {
353            return brute_force_topk(&self.embeddings, query, top_k);
354        }
355
356        // HNSW search
357        let mut ep = self.entry_point;
358        for lc in (1..=self.max_level).rev() {
359            ep = self.search_layer_single(query, ep, lc);
360        }
361
362        let mut results = self.search_layer(query, ep, EF_SEARCH.max(top_k), 0);
363        results.truncate(top_k);
364        results
365    }
366}
367
368/// O(n log k) brute-force top-k selection using a min-heap over a flat buffer.
369pub fn brute_force_topk(
370    embeddings: &FlatEmbeddings,
371    query: &[f32],
372    top_k: usize,
373) -> Vec<(usize, f32)> {
374    let n = embeddings.n_vectors();
375    let mut heap = BinaryHeap::<Candidate>::with_capacity(top_k + 1);
376
377    for i in 0..n {
378        let sim = cosine_sim(query, embeddings.get(i));
379        if heap.len() < top_k {
380            heap.push(Candidate { idx: i, sim });
381        } else if let Some(worst) = heap.peek()
382            && sim > worst.sim
383        {
384            heap.pop();
385            heap.push(Candidate { idx: i, sim });
386        }
387    }
388
389    let mut results: Vec<(usize, f32)> = heap.into_iter().map(|c| (c.idx, c.sim)).collect();
390    results.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
391    results
392}
393
394/// Cosine similarity via the shared SIMD-friendly dot product (turbovec-derived,
395/// autovectorized chunked accumulators) rather than a scalar triple-accumulate
396/// loop. This is the hot path for every brute-force and HNSW comparison, so the
397/// vectorized kernel matters: each query touches the distance fn O(n) (brute) or
398/// O(ef·log n) (HNSW) times.
399#[inline]
400fn cosine_sim(a: &[f32], b: &[f32]) -> f32 {
401    if a.len() != b.len() {
402        return 0.0;
403    }
404    crate::core::embeddings::cosine_similarity_raw(a, b)
405}
406
407#[cfg(test)]
408mod tests {
409    use super::*;
410
411    fn random_vec(dim: usize, seed: u64) -> Vec<f32> {
412        let mut v = Vec::with_capacity(dim);
413        let mut s = seed;
414        for _ in 0..dim {
415            s = s.wrapping_mul(6364136223846793005).wrapping_add(1);
416            v.push((s as f32 / u64::MAX as f32) * 2.0 - 1.0);
417        }
418        v
419    }
420
421    fn flat_from(vecs: Vec<Vec<f32>>) -> FlatEmbeddings {
422        FlatEmbeddings::from_vecs(vecs)
423    }
424
425    #[test]
426    fn brute_force_topk_correctness() {
427        let vectors = (0..100).map(|i| random_vec(16, i)).collect();
428        let flat = flat_from(vectors);
429        let query = random_vec(16, 999);
430
431        let results = brute_force_topk(&flat, &query, 5);
432        assert_eq!(results.len(), 5);
433
434        for w in results.windows(2) {
435            assert!(w[0].1 >= w[1].1);
436        }
437    }
438
439    #[test]
440    fn brute_force_topk_matches_exhaustive() {
441        let vectors: Vec<Vec<f32>> = (0..50).map(|i| random_vec(8, i + 42)).collect();
442        let flat = flat_from(vectors);
443        let query = random_vec(8, 123);
444
445        let top5 = brute_force_topk(&flat, &query, 5);
446
447        // Exhaustive comparison
448        let mut all: Vec<(usize, f32)> = (0..flat.n_vectors())
449            .map(|i| (i, cosine_sim(&query, flat.get(i))))
450            .collect();
451        all.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
452        all.truncate(5);
453
454        for (heap_r, exact_r) in top5.iter().zip(all.iter()) {
455            assert_eq!(heap_r.0, exact_r.0);
456            assert!((heap_r.1 - exact_r.1).abs() < 1e-6);
457        }
458    }
459
460    #[test]
461    fn empty_index_returns_empty() {
462        let flat = FlatEmbeddings {
463            data: Arc::from(Vec::new()),
464            dim: 2,
465        };
466        let index = AnnIndex::build(flat);
467        assert!(index.search(&[1.0, 0.0], 5).is_empty());
468    }
469
470    #[test]
471    fn small_index_uses_brute_force() {
472        let vectors: Vec<Vec<f32>> = (0..50).map(|i| random_vec(4, i)).collect();
473        let flat = flat_from(vectors);
474        let index = AnnIndex::build(flat);
475        assert!(index.nodes.is_empty()); // no HNSW graph built
476        let results = index.search(&random_vec(4, 999), 3);
477        assert_eq!(results.len(), 3);
478    }
479
480    #[test]
481    fn flat_embeddings_from_vecs_shape() {
482        let vecs = vec![
483            vec![1.0, 2.0, 3.0],
484            vec![4.0, 5.0, 6.0],
485            vec![7.0, 8.0, 9.0],
486        ];
487        let flat = FlatEmbeddings::from_vecs(vecs);
488        assert_eq!(flat.n_vectors(), 3);
489        assert_eq!(flat.dim, 3);
490        assert_eq!(flat.get(0), &[1.0, 2.0, 3.0]);
491        assert_eq!(flat.get(2), &[7.0, 8.0, 9.0]);
492    }
493
494    #[test]
495    fn flat_embeddings_clone_is_shallow() {
496        let vecs = vec![vec![1.0, 2.0]];
497        let a = FlatEmbeddings::from_vecs(vecs);
498        let b = a.clone();
499        // Arc: same ptr after clone
500        assert!(Arc::ptr_eq(&a.data, &b.data));
501    }
502}