Skip to main content

nodedb_vector/
matryoshka.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Matryoshka adaptive-dim querying.
4//!
5//! For embedding models trained with Matryoshka Representation Learning
6//! (MRL), prefix-truncated vectors retain semantic structure. We exploit
7//! this for memory-bandwidth-bound HNSW: coarse pass at low dim → exact
8//! rerank at full dim.
9//!
10//! # Cache-efficiency
11//!
12//! A 256-dim coarse pass reads 256 × 4 = 1 KiB per vector vs 1536 × 4 = 6 KiB
13//! for full-dim traversal — a ≥6× reduction in L1/L2 memory bandwidth during
14//! the dominant HNSW graph traversal phase.
15//!
16//! # Supported models
17//!
18//! - `text-embedding-3` (OpenAI): `[256, 512, 1024, 1536]`
19//! - `Gemini Embedding`: `[256, 512, 768, 3072]`
20//! - `Nomic Embed`: `[64, 128, 256, 512, 768]`
21
22use std::collections::BinaryHeap;
23
24use crate::distance::distance;
25use crate::error::VectorError;
26use nodedb_types::vector_distance::DistanceMetric;
27
28/// Per-collection Matryoshka configuration.
29#[derive(Debug, Clone)]
30pub struct MatryoshkaSpec {
31    /// Sorted ascending list of valid truncation dimensions.
32    /// E.g. for text-embedding-3 (1536-dim base): `[256, 512, 1024, 1536]`.
33    pub truncation_dims: Vec<u32>,
34}
35
36impl MatryoshkaSpec {
37    /// Create a new spec. Sorts the provided dims ascending.
38    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    /// Pick the largest supported dim ≤ `requested`.
45    ///
46    /// Returns the full dim (last in the list) when `requested` is `None`.
47    /// Returns the smallest available dim when `requested` is smaller than
48    /// all supported dims.
49    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        // Find largest dim that is ≤ req.
54        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    /// Return `true` if `dim` is one of the supported truncation dims.
63    pub fn is_valid(&self, dim: u32) -> bool {
64        self.truncation_dims.contains(&dim)
65    }
66}
67
68/// Stride-truncate a vector to its first `dim` components.
69///
70/// If `dim` exceeds `v.len()` the full slice is returned (no panic).
71#[inline]
72pub fn truncate(v: &[f32], dim: usize) -> &[f32] {
73    &v[..dim.min(v.len())]
74}
75
76/// Options for a two-stage Matryoshka search.
77pub struct MatryoshkaSearchOptions {
78    /// Dimensionality used for the coarse pass.
79    pub coarse_dim: u32,
80    /// Full dimensionality used for the rerank pass.
81    pub full_dim: u32,
82    /// Oversample factor: coarse pass collects `oversample × k` candidates.
83    /// Typical values: 3–5.
84    pub oversample: u8,
85    /// Final result count.
86    pub k: usize,
87}
88
89/// Entry in the coarse-pass max-heap (ordered by distance descending so we
90/// can evict the worst candidate when the heap overflows).
91#[derive(PartialEq)]
92struct HeapEntry {
93    /// Distance (higher = worse, evict first).
94    dist: f32,
95    id: u32,
96    /// Index into the collected vectors buffer for full-dim rerank.
97    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        // Max-heap by distance (NaN treated as largest).
111        self.dist
112            .partial_cmp(&other.dist)
113            .unwrap_or(std::cmp::Ordering::Equal)
114    }
115}
116
117/// Two-stage Matryoshka search.
118///
119/// # Phase 1 — coarse pass
120/// Iterates `candidates`, computes distance on the first `options.coarse_dim`
121/// components, and keeps a bounded max-heap of size `oversample × k`.
122///
123/// # Phase 2 — rerank
124/// Recomputes full-dim distance for all coarse survivors and returns the top-k
125/// by ascending full-dim distance.
126///
127/// # Notes
128/// - `candidates` yields `(id, vector)` pairs; each vector carries at least
129///   `full_dim` components, and components past `full_dim` are ignored.
130/// - When `coarse_dim == full_dim` the method degenerates to a single-pass
131///   top-k scan with one distance call per candidate (no duplicated work).
132///
133/// # Errors
134/// - [`VectorError::InvalidInput`] when `coarse_dim` exceeds `full_dim`.
135/// - [`VectorError::DimensionMismatch`] when `query` has fewer than
136///   `full_dim` components.
137/// - [`VectorError::StoredDimensionMismatch`] when a candidate vector has
138///   fewer than `full_dim` components.
139pub 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    // --- Phase 1: coarse pass ---
166    // We collect full-dim vectors for survivors so Phase 2 doesn't need the
167    // original iterator again.
168    let mut coarse_heap: BinaryHeap<HeapEntry> = BinaryHeap::with_capacity(pool_size + 1);
169    // Store owned copies of the surviving vectors for rerank.
170    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        // Maintain a max-heap of capacity `pool_size`.
183        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                // Evict worst; its survivor_vec slot becomes orphaned but that
201                // is acceptable — we only rerank heap survivors.
202                coarse_heap.pop();
203            }
204        }
205    }
206
207    // --- Phase 2: rerank ---
208    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    // ── MatryoshkaSpec::pick ──────────────────────────────────────────────────
229
230    #[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    // ── MatryoshkaSpec::is_valid ──────────────────────────────────────────────
255
256    #[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    // ── truncate ─────────────────────────────────────────────────────────────
269
270    #[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    // ── matryoshka_search ────────────────────────────────────────────────────
287
288    /// Build 100 random-ish vectors of dimension 128.
289    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        // Direct top-10 search for reference.
326        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        // Matryoshka with coarse_dim == full_dim — no truncation effect.
335        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        // Same IDs in same order.
348        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}