1pub mod dataset;
15pub mod hnsw;
16
17use hnsw::{l2_sq_prefix, HnswConfig, HnswGraph};
18
19#[derive(Clone, Debug)]
22pub struct MatryoshkaConfig {
23 pub full_dim: usize,
24 pub coarse_dim: usize,
25 pub mid_dim: usize,
26 pub m: usize,
28 pub ef_construction: usize,
29 pub two_stage_candidates: usize,
31 pub three_stage_coarse_candidates: usize,
33 pub three_stage_mid_candidates: usize,
34}
35
36impl MatryoshkaConfig {
37 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#[inline(always)]
55pub fn l2_sq(a: &[f32], b: &[f32]) -> f32 {
56 l2_sq_prefix(a, b, a.len().min(b.len()))
57}
58
59pub 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
68pub 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
75pub trait Searcher {
78 fn build(config: &MatryoshkaConfig, vectors: &[Vec<f32>]) -> Self
80 where
81 Self: Sized;
82
83 fn search(&self, query: &[f32], k: usize, ef: usize) -> Vec<usize>;
85
86 fn name(&self) -> &'static str;
87}
88
89pub 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
122pub struct TwoStageIndex {
130 config: MatryoshkaConfig,
131 coarse_graph: HnswGraph,
133 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 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
176pub 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 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 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 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
249pub 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
262pub 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#[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, >);
297 }
298 total / N_QUERIES as f32
299 }
300
301 #[test]
302 fn full_dim_recall_passes_threshold() {
303 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(>, >);
336 assert!((recall - 1.0).abs() < 1e-6, "brute force must be perfect");
337 }
338 }
339}