Skip to main content

ruvector_matryoshka/
lib.rs

1//! Matryoshka-aware coarse-to-fine vector search for RuVector.
2//!
3//! Three ANN search variants that trade distance-computation cost against recall:
4//!
5//! | Variant       | Stages                          | Distance ops            |
6//! |---------------|---------------------------------|-------------------------|
7//! | `FullDim`     | Single HNSW at full dim         | All at D dims           |
8//! | `TwoStage`    | Coarse HNSW + full-dim rerank   | Traverse at D1, rerank at D |
9//! | `ThreeStage`  | Coarse → mid → full-dim funnel  | Traverse D1, filter D2, rerank D |
10//!
11//! The primary metric is *recall@k*: fraction of the true top-k (found by brute-force
12//! at full dimension) that each variant recovers.
13
14pub mod dataset;
15pub mod hnsw;
16
17use hnsw::{l2_sq_prefix, HnswConfig, HnswGraph};
18
19// ─── Config ──────────────────────────────────────────────────────────────────
20
21#[derive(Clone, Debug)]
22pub struct MatryoshkaConfig {
23    pub full_dim: usize,
24    pub coarse_dim: usize,
25    pub mid_dim: usize,
26    /// HNSW graph degree.
27    pub m: usize,
28    pub ef_construction: usize,
29    /// Candidate set size for TwoStage coarse retrieval.
30    pub two_stage_candidates: usize,
31    /// Candidate set sizes for ThreeStage (coarse → mid).
32    pub three_stage_coarse_candidates: usize,
33    pub three_stage_mid_candidates: usize,
34}
35
36impl MatryoshkaConfig {
37    /// Sensible defaults for a 128-dim collection with 32/64 coarse/mid dims.
38    pub fn default_128() -> Self {
39        Self {
40            full_dim: 128,
41            coarse_dim: 32,
42            mid_dim: 64,
43            m: 16,
44            ef_construction: 100,
45            two_stage_candidates: 100,
46            three_stage_coarse_candidates: 150,
47            three_stage_mid_candidates: 50,
48        }
49    }
50}
51
52// ─── Distance helpers ─────────────────────────────────────────────────────────
53
54#[inline(always)]
55pub fn l2_sq(a: &[f32], b: &[f32]) -> f32 {
56    l2_sq_prefix(a, b, a.len().min(b.len()))
57}
58
59/// L2-normalise a vector in-place.
60pub fn l2_normalize(v: &mut [f32]) {
61    let norm = v.iter().map(|x| x * x).sum::<f32>().sqrt();
62    if norm > 1e-10 {
63        let inv = 1.0 / norm;
64        v.iter_mut().for_each(|x| *x *= inv);
65    }
66}
67
68/// Return the first `dim` elements, L2-normalised.
69pub fn prefix_project(v: &[f32], dim: usize) -> Vec<f32> {
70    let mut out: Vec<f32> = v[..dim.min(v.len())].to_vec();
71    l2_normalize(&mut out);
72    out
73}
74
75// ─── Searcher trait ───────────────────────────────────────────────────────────
76
77pub trait Searcher {
78    /// Build an index over the provided full-dim vectors.
79    fn build(config: &MatryoshkaConfig, vectors: &[Vec<f32>]) -> Self
80    where
81        Self: Sized;
82
83    /// Return approximate top-k full-dim nearest-neighbour node ids.
84    fn search(&self, query: &[f32], k: usize, ef: usize) -> Vec<usize>;
85
86    fn name(&self) -> &'static str;
87}
88
89// ─── Variant 1: FullDimIndex ─────────────────────────────────────────────────
90
91/// Standard HNSW search at the full embedding dimension.
92/// Baseline for both recall and latency.
93pub struct FullDimIndex {
94    graph: HnswGraph,
95}
96
97impl Searcher for FullDimIndex {
98    fn build(config: &MatryoshkaConfig, vectors: &[Vec<f32>]) -> Self {
99        let hcfg = HnswConfig::new(config.full_dim, config.m, config.ef_construction);
100        let mut graph = HnswGraph::new(hcfg);
101        for v in vectors {
102            let projected = prefix_project(v, config.full_dim);
103            graph.insert(projected);
104        }
105        Self { graph }
106    }
107
108    fn search(&self, query: &[f32], k: usize, ef: usize) -> Vec<usize> {
109        let q_proj = prefix_project(query, self.graph.config.dim);
110        self.graph
111            .search(&q_proj, k, ef)
112            .into_iter()
113            .map(|id| id as usize)
114            .collect()
115    }
116
117    fn name(&self) -> &'static str {
118        "FullDimHNSW"
119    }
120}
121
122// ─── Variant 2: TwoStageIndex ─────────────────────────────────────────────────
123
124/// Coarse HNSW at `coarse_dim`, then full-dim rerank of the candidate set.
125///
126/// Distance ops breakdown:
127///   - Graph traversal: O(ef × M) comparisons at `coarse_dim` dims
128///   - Rerank: O(candidates) comparisons at `full_dim` dims
129pub struct TwoStageIndex {
130    config: MatryoshkaConfig,
131    /// HNSW built on coarse-projected vectors.
132    coarse_graph: HnswGraph,
133    /// Full-dim vectors for reranking.
134    full_vecs: Vec<Vec<f32>>,
135}
136
137impl Searcher for TwoStageIndex {
138    fn build(config: &MatryoshkaConfig, vectors: &[Vec<f32>]) -> Self {
139        let hcfg = HnswConfig::new(config.coarse_dim, config.m, config.ef_construction);
140        let mut coarse_graph = HnswGraph::new(hcfg);
141        let mut full_vecs = Vec::with_capacity(vectors.len());
142        for v in vectors {
143            let coarse = prefix_project(v, config.coarse_dim);
144            coarse_graph.insert(coarse);
145            full_vecs.push(prefix_project(v, config.full_dim));
146        }
147        Self {
148            config: config.clone(),
149            coarse_graph,
150            full_vecs,
151        }
152    }
153
154    fn search(&self, query: &[f32], k: usize, ef: usize) -> Vec<usize> {
155        let candidates = self.config.two_stage_candidates.max(k);
156        let q_coarse = prefix_project(query, self.config.coarse_dim);
157        let coarse_ids = self
158            .coarse_graph
159            .search(&q_coarse, candidates, ef.max(candidates));
160
161        // Rerank at full_dim.
162        let q_full = prefix_project(query, self.config.full_dim);
163        let mut scored: Vec<(f32, usize)> = coarse_ids
164            .iter()
165            .map(|&id| (l2_sq(&q_full, &self.full_vecs[id as usize]), id as usize))
166            .collect();
167        scored.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
168        scored.into_iter().take(k).map(|(_, id)| id).collect()
169    }
170
171    fn name(&self) -> &'static str {
172        "TwoStage"
173    }
174}
175
176// ─── Variant 3: ThreeStageIndex ──────────────────────────────────────────────
177
178/// Three-stage funnel: coarse_dim → mid_dim filter → full_dim rerank.
179///
180/// Distance ops breakdown:
181///   - Stage 1 traversal: O(ef × M) at `coarse_dim` dims
182///   - Stage 2 filter:    O(coarse_candidates) at `mid_dim` dims
183///   - Stage 3 rerank:    O(mid_candidates) at `full_dim` dims
184pub struct ThreeStageIndex {
185    config: MatryoshkaConfig,
186    coarse_graph: HnswGraph,
187    mid_vecs: Vec<Vec<f32>>,
188    full_vecs: Vec<Vec<f32>>,
189}
190
191impl Searcher for ThreeStageIndex {
192    fn build(config: &MatryoshkaConfig, vectors: &[Vec<f32>]) -> Self {
193        let hcfg = HnswConfig::new(config.coarse_dim, config.m, config.ef_construction);
194        let mut coarse_graph = HnswGraph::new(hcfg);
195        let mut mid_vecs = Vec::with_capacity(vectors.len());
196        let mut full_vecs = Vec::with_capacity(vectors.len());
197        for v in vectors {
198            let coarse = prefix_project(v, config.coarse_dim);
199            coarse_graph.insert(coarse);
200            mid_vecs.push(prefix_project(v, config.mid_dim));
201            full_vecs.push(prefix_project(v, config.full_dim));
202        }
203        Self {
204            config: config.clone(),
205            coarse_graph,
206            mid_vecs,
207            full_vecs,
208        }
209    }
210
211    fn search(&self, query: &[f32], k: usize, ef: usize) -> Vec<usize> {
212        let coarse_n = self.config.three_stage_coarse_candidates.max(k);
213        let mid_n = self.config.three_stage_mid_candidates.max(k);
214
215        // Stage 1: coarse HNSW retrieval.
216        let q_coarse = prefix_project(query, self.config.coarse_dim);
217        let coarse_ids = self
218            .coarse_graph
219            .search(&q_coarse, coarse_n, ef.max(coarse_n));
220
221        // Stage 2: mid-dim filtering.
222        let q_mid = prefix_project(query, self.config.mid_dim);
223        let mut mid_scored: Vec<(f32, u32)> = coarse_ids
224            .iter()
225            .map(|&id| (l2_sq(&q_mid, &self.mid_vecs[id as usize]), id))
226            .collect();
227        mid_scored.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
228        let mid_ids: Vec<u32> = mid_scored
229            .into_iter()
230            .take(mid_n)
231            .map(|(_, id)| id)
232            .collect();
233
234        // Stage 3: full-dim rerank.
235        let q_full = prefix_project(query, self.config.full_dim);
236        let mut full_scored: Vec<(f32, usize)> = mid_ids
237            .iter()
238            .map(|&id| (l2_sq(&q_full, &self.full_vecs[id as usize]), id as usize))
239            .collect();
240        full_scored.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
241        full_scored.into_iter().take(k).map(|(_, id)| id).collect()
242    }
243
244    fn name(&self) -> &'static str {
245        "ThreeStage"
246    }
247}
248
249// ─── Recall and ground-truth ──────────────────────────────────────────────────
250
251/// Brute-force exact top-k at full dimension (ground truth).
252pub fn brute_force_knn(vectors: &[Vec<f32>], query: &[f32], k: usize, dim: usize) -> Vec<usize> {
253    let mut dists: Vec<(f32, usize)> = vectors
254        .iter()
255        .enumerate()
256        .map(|(i, v)| (l2_sq_prefix(query, v, dim), i))
257        .collect();
258    dists.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
259    dists.into_iter().take(k).map(|(_, i)| i).collect()
260}
261
262/// Recall@k: fraction of ground-truth top-k that appear in the result set.
263pub fn recall_at_k(results: &[usize], ground_truth: &[usize]) -> f32 {
264    if ground_truth.is_empty() {
265        return 1.0;
266    }
267    let hits: usize = results
268        .iter()
269        .filter(|&&r| ground_truth.contains(&r))
270        .count();
271    hits as f32 / ground_truth.len() as f32
272}
273
274// ─── Tests ───────────────────────────────────────────────────────────────────
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279    use dataset::generate_matryoshka_dataset;
280
281    const N: usize = 500;
282    const K: usize = 10;
283    const EF: usize = 50;
284    const N_QUERIES: usize = 20;
285    const SEED: u64 = 0xDEAD_BEEF;
286
287    fn build_and_recall<S: Searcher>(seed: u64) -> f32 {
288        let cfg = MatryoshkaConfig::default_128();
289        let (vectors, queries) =
290            generate_matryoshka_dataset(N, N_QUERIES, cfg.full_dim, cfg.coarse_dim, seed);
291        let idx = S::build(&cfg, &vectors);
292        let mut total = 0.0f32;
293        for q in &queries {
294            let gt = brute_force_knn(&vectors, q, K, cfg.full_dim);
295            let res = idx.search(q, K, EF);
296            total += recall_at_k(&res, &gt);
297        }
298        total / N_QUERIES as f32
299    }
300
301    #[test]
302    fn full_dim_recall_passes_threshold() {
303        // N=500, ef=50, M=16: small unit-test params. Benchmark uses N=3000, ef=64.
304        let recall = build_and_recall::<FullDimIndex>(SEED);
305        assert!(
306            recall >= 0.75,
307            "FullDimHNSW recall@10 = {:.3} < 0.75",
308            recall
309        );
310    }
311
312    #[test]
313    fn two_stage_recall_passes_threshold() {
314        let recall = build_and_recall::<TwoStageIndex>(SEED);
315        assert!(recall >= 0.65, "TwoStage recall@10 = {:.3} < 0.65", recall);
316    }
317
318    #[test]
319    fn three_stage_recall_passes_threshold() {
320        let recall = build_and_recall::<ThreeStageIndex>(SEED);
321        assert!(
322            recall >= 0.58,
323            "ThreeStage recall@10 = {:.3} < 0.58",
324            recall
325        );
326    }
327
328    #[test]
329    fn brute_force_is_perfect() {
330        let cfg = MatryoshkaConfig::default_128();
331        let (vectors, queries) =
332            generate_matryoshka_dataset(200, 10, cfg.full_dim, cfg.coarse_dim, 42);
333        for q in &queries {
334            let gt = brute_force_knn(&vectors, q, K, cfg.full_dim);
335            let recall = recall_at_k(&gt, &gt);
336            assert!((recall - 1.0).abs() < 1e-6, "brute force must be perfect");
337        }
338    }
339}