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    /// Approximate resident bytes: the flat f32 corpus plus the graph's
368    /// adjacency lists. Used by the eviction orchestrator (#685) to weigh
369    /// this index against the RSS budget.
370    #[must_use]
371    pub fn memory_usage_bytes(&self) -> usize {
372        let embedding_bytes = self.embeddings.data.len() * std::mem::size_of::<f32>();
373        let graph_bytes: usize = self
374            .nodes
375            .iter()
376            .map(|n| {
377                n.connections
378                    .iter()
379                    .map(|layer| layer.len() * std::mem::size_of::<usize>())
380                    .sum::<usize>()
381            })
382            .sum();
383        embedding_bytes + graph_bytes
384    }
385}
386
387/// O(n log k) brute-force top-k selection using a min-heap over a flat buffer.
388pub fn brute_force_topk(
389    embeddings: &FlatEmbeddings,
390    query: &[f32],
391    top_k: usize,
392) -> Vec<(usize, f32)> {
393    let n = embeddings.n_vectors();
394    let mut heap = BinaryHeap::<Candidate>::with_capacity(top_k + 1);
395
396    for i in 0..n {
397        let sim = cosine_sim(query, embeddings.get(i));
398        if heap.len() < top_k {
399            heap.push(Candidate { idx: i, sim });
400        } else if let Some(worst) = heap.peek()
401            && sim > worst.sim
402        {
403            heap.pop();
404            heap.push(Candidate { idx: i, sim });
405        }
406    }
407
408    let mut results: Vec<(usize, f32)> = heap.into_iter().map(|c| (c.idx, c.sim)).collect();
409    results.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
410    results
411}
412
413/// Cosine similarity via the shared SIMD-friendly dot product (turbovec-derived,
414/// autovectorized chunked accumulators) rather than a scalar triple-accumulate
415/// loop. This is the hot path for every brute-force and HNSW comparison, so the
416/// vectorized kernel matters: each query touches the distance fn O(n) (brute) or
417/// O(ef·log n) (HNSW) times.
418#[inline]
419fn cosine_sim(a: &[f32], b: &[f32]) -> f32 {
420    if a.len() != b.len() {
421        return 0.0;
422    }
423    crate::core::embeddings::cosine_similarity_raw(a, b)
424}
425
426#[cfg(test)]
427mod tests {
428    use super::*;
429
430    fn random_vec(dim: usize, seed: u64) -> Vec<f32> {
431        let mut v = Vec::with_capacity(dim);
432        let mut s = seed;
433        for _ in 0..dim {
434            s = s.wrapping_mul(6364136223846793005).wrapping_add(1);
435            v.push((s as f32 / u64::MAX as f32) * 2.0 - 1.0);
436        }
437        v
438    }
439
440    fn flat_from(vecs: Vec<Vec<f32>>) -> FlatEmbeddings {
441        FlatEmbeddings::from_vecs(vecs)
442    }
443
444    #[test]
445    fn brute_force_topk_correctness() {
446        let vectors = (0..100).map(|i| random_vec(16, i)).collect();
447        let flat = flat_from(vectors);
448        let query = random_vec(16, 999);
449
450        let results = brute_force_topk(&flat, &query, 5);
451        assert_eq!(results.len(), 5);
452
453        for w in results.windows(2) {
454            assert!(w[0].1 >= w[1].1);
455        }
456    }
457
458    #[test]
459    fn brute_force_topk_matches_exhaustive() {
460        let vectors: Vec<Vec<f32>> = (0..50).map(|i| random_vec(8, i + 42)).collect();
461        let flat = flat_from(vectors);
462        let query = random_vec(8, 123);
463
464        let top5 = brute_force_topk(&flat, &query, 5);
465
466        // Exhaustive comparison
467        let mut all: Vec<(usize, f32)> = (0..flat.n_vectors())
468            .map(|i| (i, cosine_sim(&query, flat.get(i))))
469            .collect();
470        all.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
471        all.truncate(5);
472
473        for (heap_r, exact_r) in top5.iter().zip(all.iter()) {
474            assert_eq!(heap_r.0, exact_r.0);
475            assert!((heap_r.1 - exact_r.1).abs() < 1e-6);
476        }
477    }
478
479    #[test]
480    fn empty_index_returns_empty() {
481        let flat = FlatEmbeddings {
482            data: Arc::from(Vec::new()),
483            dim: 2,
484        };
485        let index = AnnIndex::build(flat);
486        assert!(index.search(&[1.0, 0.0], 5).is_empty());
487    }
488
489    #[test]
490    fn small_index_uses_brute_force() {
491        let vectors: Vec<Vec<f32>> = (0..50).map(|i| random_vec(4, i)).collect();
492        let flat = flat_from(vectors);
493        let index = AnnIndex::build(flat);
494        assert!(index.nodes.is_empty()); // no HNSW graph built
495        let results = index.search(&random_vec(4, 999), 3);
496        assert_eq!(results.len(), 3);
497    }
498
499    #[test]
500    fn flat_embeddings_from_vecs_shape() {
501        let vecs = vec![
502            vec![1.0, 2.0, 3.0],
503            vec![4.0, 5.0, 6.0],
504            vec![7.0, 8.0, 9.0],
505        ];
506        let flat = FlatEmbeddings::from_vecs(vecs);
507        assert_eq!(flat.n_vectors(), 3);
508        assert_eq!(flat.dim, 3);
509        assert_eq!(flat.get(0), &[1.0, 2.0, 3.0]);
510        assert_eq!(flat.get(2), &[7.0, 8.0, 9.0]);
511    }
512
513    #[test]
514    fn flat_embeddings_clone_is_shallow() {
515        let vecs = vec![vec![1.0, 2.0]];
516        let a = FlatEmbeddings::from_vecs(vecs);
517        let b = a.clone();
518        // Arc: same ptr after clone
519        assert!(Arc::ptr_eq(&a.data, &b.data));
520    }
521}