1use std::collections::BinaryHeap;
23
24use crate::distance::distance;
25use crate::error::VectorError;
26use nodedb_types::vector_distance::DistanceMetric;
27
28#[derive(Debug, Clone)]
30pub struct MatryoshkaSpec {
31 pub truncation_dims: Vec<u32>,
34}
35
36impl MatryoshkaSpec {
37 pub fn new(mut truncation_dims: Vec<u32>) -> Self {
39 truncation_dims.sort_unstable();
40 truncation_dims.dedup();
41 Self { truncation_dims }
42 }
43
44 pub fn pick(&self, requested: Option<u32>) -> u32 {
50 let Some(req) = requested else {
51 return *self.truncation_dims.last().copied().get_or_insert(0);
52 };
53 self.truncation_dims
55 .iter()
56 .rev()
57 .find(|&&d| d <= req)
58 .copied()
59 .unwrap_or_else(|| self.truncation_dims.first().copied().unwrap_or(req))
60 }
61
62 pub fn is_valid(&self, dim: u32) -> bool {
64 self.truncation_dims.contains(&dim)
65 }
66}
67
68#[inline]
72pub fn truncate(v: &[f32], dim: usize) -> &[f32] {
73 &v[..dim.min(v.len())]
74}
75
76pub struct MatryoshkaSearchOptions {
78 pub coarse_dim: u32,
80 pub full_dim: u32,
82 pub oversample: u8,
85 pub k: usize,
87}
88
89#[derive(PartialEq)]
92struct HeapEntry {
93 dist: f32,
95 id: u32,
96 vec_idx: usize,
98}
99
100impl Eq for HeapEntry {}
101
102impl PartialOrd for HeapEntry {
103 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
104 Some(self.cmp(other))
105 }
106}
107
108impl Ord for HeapEntry {
109 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
110 self.dist
112 .partial_cmp(&other.dist)
113 .unwrap_or(std::cmp::Ordering::Equal)
114 }
115}
116
117pub fn matryoshka_search<'a, I>(
140 candidates: I,
141 query: &[f32],
142 options: &MatryoshkaSearchOptions,
143 metric: DistanceMetric,
144) -> Result<Vec<(u32, f32)>, VectorError>
145where
146 I: Iterator<Item = (u32, &'a [f32])>,
147{
148 let coarse = options.coarse_dim as usize;
149 let full = options.full_dim as usize;
150 if coarse > full {
151 return Err(VectorError::InvalidInput {
152 detail: format!("Matryoshka coarse dimension {coarse} exceeds full dimension {full}"),
153 });
154 }
155 if query.len() < full {
156 return Err(VectorError::DimensionMismatch {
157 expected: full,
158 got: query.len(),
159 });
160 }
161 let pool_size = (options.oversample as usize).max(1) * options.k.max(1);
162
163 let query_coarse = truncate(query, coarse);
164
165 let mut coarse_heap: BinaryHeap<HeapEntry> = BinaryHeap::with_capacity(pool_size + 1);
169 let mut survivor_vecs: Vec<Vec<f32>> = Vec::with_capacity(pool_size);
171
172 for (id, vec) in candidates {
173 if vec.len() < full {
174 return Err(VectorError::StoredDimensionMismatch {
175 expected: full,
176 got: vec.len(),
177 });
178 }
179 let vec_coarse = truncate(vec, coarse);
180 let d = distance(query_coarse, vec_coarse, metric);
181
182 let should_insert = coarse_heap.len() < pool_size
184 || coarse_heap
185 .peek()
186 .map(|worst| d < worst.dist)
187 .unwrap_or(true);
188
189 if should_insert {
190 let vec_idx = survivor_vecs.len();
191 survivor_vecs.push(truncate(vec, full).to_vec());
192
193 coarse_heap.push(HeapEntry {
194 dist: d,
195 id,
196 vec_idx,
197 });
198
199 if coarse_heap.len() > pool_size {
200 coarse_heap.pop();
203 }
204 }
205 }
206
207 let query_full = truncate(query, full);
209
210 let mut reranked: Vec<(u32, f32)> = coarse_heap
211 .into_iter()
212 .map(|entry| {
213 let full_vec = &survivor_vecs[entry.vec_idx];
214 let d_full = distance(query_full, full_vec.as_slice(), metric);
215 (entry.id, d_full)
216 })
217 .collect();
218
219 reranked.sort_unstable_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
220 reranked.truncate(options.k);
221 Ok(reranked)
222}
223
224#[cfg(test)]
225mod tests {
226 use super::*;
227
228 #[test]
231 fn pick_largest_leq_requested() {
232 let spec = MatryoshkaSpec::new(vec![256, 512, 1024]);
233 assert_eq!(spec.pick(Some(300)), 256);
234 }
235
236 #[test]
237 fn pick_exact_match() {
238 let spec = MatryoshkaSpec::new(vec![256, 512, 1024]);
239 assert_eq!(spec.pick(Some(512)), 512);
240 }
241
242 #[test]
243 fn pick_none_returns_full_dim() {
244 let spec = MatryoshkaSpec::new(vec![256, 512, 1024]);
245 assert_eq!(spec.pick(None), 1024);
246 }
247
248 #[test]
249 fn pick_smaller_than_all_returns_smallest() {
250 let spec = MatryoshkaSpec::new(vec![256, 512, 1024]);
251 assert_eq!(spec.pick(Some(10)), 256);
252 }
253
254 #[test]
257 fn is_valid_known_dim() {
258 let spec = MatryoshkaSpec::new(vec![256, 512, 1024]);
259 assert!(spec.is_valid(512));
260 }
261
262 #[test]
263 fn is_valid_unknown_dim() {
264 let spec = MatryoshkaSpec::new(vec![256, 512, 1024]);
265 assert!(!spec.is_valid(100));
266 }
267
268 #[test]
271 fn truncate_clips_to_requested_dim() {
272 let v: Vec<f32> = (0..1536).map(|i| i as f32).collect();
273 let t = truncate(&v, 256);
274 assert_eq!(t.len(), 256);
275 assert_eq!(t[0], 0.0);
276 assert_eq!(t[255], 255.0);
277 }
278
279 #[test]
280 fn truncate_does_not_exceed_vec_len() {
281 let v = vec![1.0f32; 10];
282 let t = truncate(&v, 9999);
283 assert_eq!(t.len(), 10);
284 }
285
286 fn make_vecs(n: usize, dim: usize) -> Vec<Vec<f32>> {
290 (0..n)
291 .map(|i| {
292 (0..dim)
293 .map(|j| ((i * dim + j) as f32 * 0.01).sin())
294 .collect()
295 })
296 .collect()
297 }
298
299 #[test]
300 fn search_returns_k_results() {
301 let vecs = make_vecs(100, 128);
302 let query: Vec<f32> = (0..128).map(|i| (i as f32 * 0.007).cos()).collect();
303
304 let candidates = vecs
305 .iter()
306 .enumerate()
307 .map(|(i, v)| (i as u32, v.as_slice()));
308
309 let opts = MatryoshkaSearchOptions {
310 coarse_dim: 64,
311 full_dim: 128,
312 oversample: 3,
313 k: 10,
314 };
315
316 let results = matryoshka_search(candidates, &query, &opts, DistanceMetric::L2).unwrap();
317 assert_eq!(results.len(), 10, "expected exactly k=10 results");
318 }
319
320 #[test]
321 fn coarse_equal_to_full_matches_direct_search() {
322 let vecs = make_vecs(100, 128);
323 let query: Vec<f32> = (0..128).map(|i| (i as f32 * 0.007).cos()).collect();
324
325 let mut direct: Vec<(u32, f32)> = vecs
327 .iter()
328 .enumerate()
329 .map(|(i, v)| (i as u32, distance(&query, v.as_slice(), DistanceMetric::L2)))
330 .collect();
331 direct.sort_unstable_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
332 direct.truncate(10);
333
334 let candidates = vecs
336 .iter()
337 .enumerate()
338 .map(|(i, v)| (i as u32, v.as_slice()));
339 let opts = MatryoshkaSearchOptions {
340 coarse_dim: 128,
341 full_dim: 128,
342 oversample: 1,
343 k: 10,
344 };
345 let mrl = matryoshka_search(candidates, &query, &opts, DistanceMetric::L2).unwrap();
346
347 let direct_ids: Vec<u32> = direct.iter().map(|(id, _)| *id).collect();
349 let mrl_ids: Vec<u32> = mrl.iter().map(|(id, _)| *id).collect();
350 assert_eq!(
351 direct_ids, mrl_ids,
352 "coarse==full should equal direct search"
353 );
354 }
355
356 #[test]
357 fn short_query_or_candidate_is_a_typed_error() {
358 let vecs = make_vecs(4, 8);
359 let opts = MatryoshkaSearchOptions {
360 coarse_dim: 4,
361 full_dim: 8,
362 oversample: 2,
363 k: 2,
364 };
365 let candidates = vecs
366 .iter()
367 .enumerate()
368 .map(|(i, v)| (i as u32, v.as_slice()));
369 assert!(matches!(
370 matryoshka_search(candidates, &[0.0; 6], &opts, DistanceMetric::L2),
371 Err(VectorError::DimensionMismatch {
372 expected: 8,
373 got: 6
374 })
375 ));
376
377 let short = [0.0_f32; 5];
378 let candidates = std::iter::once((0_u32, &short[..]));
379 assert!(matches!(
380 matryoshka_search(candidates, &[0.0; 8], &opts, DistanceMetric::L2),
381 Err(VectorError::StoredDimensionMismatch {
382 expected: 8,
383 got: 5
384 })
385 ));
386
387 let inverted = MatryoshkaSearchOptions {
388 coarse_dim: 16,
389 full_dim: 8,
390 oversample: 2,
391 k: 2,
392 };
393 let candidates = vecs
394 .iter()
395 .enumerate()
396 .map(|(i, v)| (i as u32, v.as_slice()));
397 assert!(matches!(
398 matryoshka_search(candidates, &[0.0; 8], &inverted, DistanceMetric::L2),
399 Err(VectorError::InvalidInput { .. })
400 ));
401 }
402}