Skip to main content

diskann_quantization/multi_vector/distance/
fallback.rs

1// Copyright (c) Microsoft Corporation. All rights reserved.
2// Licensed under the MIT license.
3
4//! Fallback kernel implementation of multi-vector distance computation.
5
6use std::ops::Deref;
7
8use diskann_vector::distance::InnerProduct;
9use diskann_vector::{DistanceFunctionMut, PureDistanceFunction};
10
11use super::max_sim::{Chamfer, MaxSim};
12use super::projected_eigen::ProjectedEigen;
13use crate::multi_vector::{MatRef, MaxSimError, Repr, Standard};
14
15/////////////////
16// QueryMatRef //
17/////////////////
18
19/// A query matrix view for asymmetric distance functions.
20///
21/// This wrapper distinguishes query matrices from document matrices
22/// at compile time, preventing accidental argument swapping in asymmetric
23/// distance computations like [`MaxSim`] and [`Chamfer`].
24///
25/// # Example
26///
27/// ```
28/// use diskann_quantization::multi_vector::{MatRef, Standard};
29/// use diskann_quantization::multi_vector::distance::QueryMatRef;
30///
31/// let data = [1.0f32, 2.0, 3.0, 4.0];
32/// let view = MatRef::new(Standard::new(2, 2).unwrap(), &data).unwrap();
33/// let query: QueryMatRef<_> = view.into();
34/// ```
35#[derive(Debug, Clone, Copy)]
36pub struct QueryMatRef<'a, T: Repr>(pub MatRef<'a, T>);
37
38impl<'a, T: Repr> From<MatRef<'a, T>> for QueryMatRef<'a, T> {
39    fn from(view: MatRef<'a, T>) -> Self {
40        Self(view)
41    }
42}
43
44/// Deref so that we can transparently access the `MatRef` in distance functions.
45impl<'a, T: Repr> Deref for QueryMatRef<'a, T> {
46    type Target = MatRef<'a, T>;
47
48    fn deref(&self) -> &Self::Target {
49        &self.0
50    }
51}
52
53////////////////////
54// FallbackKernel //
55////////////////////
56
57/// Fallback double-loop kernel to compute max-sim distances over multi-vectors.
58///
59/// This kernel performs a simple double-loop over the rows of `query`
60/// and the `doc` and dispatches to [`InnerProduct`] to compute the similarity.
61pub struct FallbackKernel;
62
63impl FallbackKernel {
64    /// Core kernel for computing per-query-vector max similarities (min negated inner-product).
65    ///
66    /// For each `query` vector, computes the maximum similarity (negated inner product)
67    /// to any document vector, then calls `f(index, score)` with the result. If
68    /// there are no vectors in the `doc`, the kernel returns immediately.
69    ///
70    /// The callback can be used to aggregate or set scores as needed - as is the
71    /// case with [`MaxSim`] and [`Chamfer`].
72    ///
73    /// # Arguments
74    ///
75    /// * `query` - The query multi-vector (wrapped as [`QueryMatRef`])
76    /// * `doc` - The document multi-vector
77    /// * `f` - Callback invoked with `(query_index, similarity)` for each query vector
78    #[inline]
79    pub(crate) fn max_sim_kernel<F, T: Copy>(
80        query: QueryMatRef<'_, Standard<T>>,
81        doc: MatRef<'_, Standard<T>>,
82        mut f: F,
83    ) where
84        F: FnMut(usize, f32),
85        InnerProduct: for<'a, 'b> PureDistanceFunction<&'a [T], &'b [T], f32>,
86    {
87        // Early exit if no doc vectors - callback should never be invoked
88        if doc.num_vectors() == 0 {
89            return;
90        }
91
92        for (i, q_vec) in query.rows().enumerate() {
93            // `InnerProduct::evaluate` returns negated inner product
94            let mut min_dist = f32::MAX;
95
96            for d_vec in doc.rows() {
97                let dist = InnerProduct::evaluate(q_vec, d_vec);
98                min_dist = min_dist.min(dist);
99            }
100
101            f(i, min_dist);
102        }
103    }
104
105    /// Core kernel for computing per-query-vector projected-eigen scores.
106    ///
107    /// For each `query` vector, sums the negated squared inner product
108    /// against every document vector, then calls `f(index, score)` with the
109    /// result. If there are no vectors in the `doc`, the kernel returns
110    /// immediately.
111    ///
112    /// The callback can be used to aggregate scores as needed - as is the
113    /// case with [`ProjectedEigen`].
114    ///
115    /// # Arguments
116    ///
117    /// * `query` - The query multi-vector (wrapped as [`QueryMatRef`])
118    /// * `doc` - The document multi-vector
119    /// * `f` - Callback invoked with `(query_index, score)` for each query vector
120    #[inline]
121    pub(crate) fn projected_eigen_kernel<F, T: Copy>(
122        query: QueryMatRef<'_, Standard<T>>,
123        doc: MatRef<'_, Standard<T>>,
124        mut f: F,
125    ) where
126        F: FnMut(usize, f32),
127        InnerProduct: for<'a, 'b> PureDistanceFunction<&'a [T], &'b [T], f32>,
128    {
129        // Early exit if no doc vectors - callback should never be invoked
130        if doc.num_vectors() == 0 {
131            return;
132        }
133
134        for (i, q_vec) in query.rows().enumerate() {
135            let mut sum = 0.0f32;
136
137            for d_vec in doc.rows() {
138                // `InnerProduct::evaluate` returns the negated inner product;
139                // squaring discards the sign, so negate the squared value to
140                // obtain `-IP(q, d)²`.
141                let ip = InnerProduct::evaluate(q_vec, d_vec);
142                sum += -(ip * ip);
143            }
144
145            f(i, sum);
146        }
147    }
148}
149
150////////////
151// MaxSim //
152////////////
153
154impl<T: Copy>
155    DistanceFunctionMut<
156        QueryMatRef<'_, Standard<T>>,
157        MatRef<'_, Standard<T>>,
158        Result<(), MaxSimError>,
159    > for MaxSim<'_>
160where
161    InnerProduct: for<'a, 'b> PureDistanceFunction<&'a [T], &'b [T], f32>,
162{
163    #[inline(always)]
164    fn evaluate(
165        &mut self,
166        query: QueryMatRef<'_, Standard<T>>,
167        doc: MatRef<'_, Standard<T>>,
168    ) -> Result<(), MaxSimError> {
169        let size = self.size();
170        let n_queries = query.num_vectors();
171
172        if self.size() != query.num_vectors() {
173            return Err(MaxSimError::InvalidBufferLength(size, n_queries));
174        }
175
176        FallbackKernel::max_sim_kernel(query, doc, |i, score| {
177            // SAFETY: We asserted that self.size() == query.num_vectors(),
178            // and i < query.num_vectors() due to the kernel loop bound.
179            unsafe { *self.scores.get_unchecked_mut(i) = score };
180        });
181
182        Ok(())
183    }
184}
185
186/////////////
187// Chamfer //
188/////////////
189
190impl<T: Copy> PureDistanceFunction<QueryMatRef<'_, Standard<T>>, MatRef<'_, Standard<T>>, f32>
191    for Chamfer
192where
193    InnerProduct: for<'a, 'b> PureDistanceFunction<&'a [T], &'b [T], f32>,
194{
195    #[inline(always)]
196    fn evaluate(query: QueryMatRef<'_, Standard<T>>, doc: MatRef<'_, Standard<T>>) -> f32 {
197        let mut sum = 0.0f32;
198
199        FallbackKernel::max_sim_kernel(query, doc, |_i, score| {
200            sum += score;
201        });
202
203        sum
204    }
205}
206
207/////////////////////
208// ProjectedEigen //
209/////////////////////
210
211impl<T: Copy> PureDistanceFunction<QueryMatRef<'_, Standard<T>>, MatRef<'_, Standard<T>>, f32>
212    for ProjectedEigen
213where
214    InnerProduct: for<'a, 'b> PureDistanceFunction<&'a [T], &'b [T], f32>,
215{
216    #[inline(always)]
217    fn evaluate(query: QueryMatRef<'_, Standard<T>>, doc: MatRef<'_, Standard<T>>) -> f32 {
218        let mut sum = 0.0f32;
219
220        FallbackKernel::projected_eigen_kernel(query, doc, |_i, score| {
221            sum += score;
222        });
223
224        sum
225    }
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231
232    /// Helper to create a QueryMatRef from raw data
233    fn make_query(data: &[f32], nrows: usize, ncols: usize) -> QueryMatRef<'_, Standard<f32>> {
234        MatRef::new(Standard::new(nrows, ncols).unwrap(), data)
235            .unwrap()
236            .into()
237    }
238
239    /// Helper to create a MatRef from raw data
240    fn make_doc(data: &[f32], nrows: usize, ncols: usize) -> MatRef<'_, Standard<f32>> {
241        MatRef::new(Standard::new(nrows, ncols).unwrap(), data).unwrap()
242    }
243
244    /// Naive implementation of max-sim for a single query vector against all doc vectors.
245    fn naive_max_sim_single(query_vec: &[f32], doc: &MatRef<'_, Standard<f32>>) -> f32 {
246        doc.rows()
247            .map(|d_vec| {
248                let ip: f32 = query_vec.iter().zip(d_vec.iter()).map(|(a, b)| a * b).sum();
249                -ip
250            })
251            .fold(f32::MAX, f32::min)
252    }
253
254    /// Naive implementation of projected-eigen for a single query vector
255    /// against all doc vectors: `\sum_{j} -IP(q, d_{j})^2`.
256    fn naive_projected_eigen_single(query_vec: &[f32], doc: &MatRef<'_, Standard<f32>>) -> f32 {
257        doc.rows()
258            .map(|d_vec| {
259                let ip: f32 = query_vec.iter().zip(d_vec.iter()).map(|(a, b)| a * b).sum();
260                -(ip * ip)
261            })
262            .sum()
263    }
264
265    /// Generate deterministic test data.
266    fn make_test_data(len: usize, ceil: usize, shift: usize) -> Vec<f32> {
267        (0..len).map(|v| ((v + shift) % ceil) as f32).collect()
268    }
269
270    mod query_mat_ref {
271        use super::*;
272
273        #[test]
274        fn from_mat_ref_and_deref() {
275            let data = [1.0f32, 2.0, 3.0, 4.0, 5.0, 6.0];
276            let view = MatRef::new(Standard::new(2, 3).unwrap(), &data).unwrap();
277            let query: QueryMatRef<_> = view.into();
278
279            // Deref access works
280            assert_eq!(query.num_vectors(), 2);
281            assert_eq!(query.vector_dim(), 3);
282            assert_eq!(query.get_row(0), Some(&[1.0f32, 2.0, 3.0][..]));
283        }
284
285        #[test]
286        fn is_copy() {
287            let data = [1.0f32, 2.0];
288            let query = make_query(&data, 1, 2);
289            let copy = query;
290            let _ = (query, copy); // Both usable
291        }
292    }
293
294    mod distance_functions {
295        use diskann_utils::Reborrow;
296
297        use super::*;
298
299        #[test]
300        fn max_sim_panics_on_size_mismatch() {
301            let query = make_query(&[1.0, 2.0, 3.0, 4.0], 2, 2);
302            let doc = make_doc(&[1.0, 1.0], 1, 2);
303
304            let mut scores = vec![0.0f32; 3]; // Wrong size
305            let r = MaxSim::new(&mut scores).evaluate(query, doc);
306            assert!(r.is_err());
307        }
308
309        /// Tests both MaxSim and Chamfer against naive implementations across
310        /// various matrix sizes including edge cases (single row/col).
311        #[test]
312        fn matches_naive_implementation() {
313            let test_cases = [
314                (1, 1, 4),   // Single query, single doc
315                (1, 5, 8),   // Single query, multiple docs
316                (5, 1, 8),   // Multiple queries, single doc
317                (3, 4, 16),  // General case
318                (7, 7, 32),  // Square case
319                (2, 3, 128), // Larger dimension
320            ];
321
322            for (nq, nd, dim) in test_cases.iter() {
323                let query_data = make_test_data(nq * dim, *dim, dim / 2);
324                let doc_data = make_test_data(nd * dim, *dim, *dim);
325
326                let query = make_query(&query_data, *nq, *dim);
327                let doc = make_doc(&doc_data, *nd, *dim);
328
329                // Test MaxSim
330                let mut scores = vec![0.0f32; *nq];
331                let r = MaxSim::new(&mut scores).evaluate(query, doc);
332                assert!(r.is_ok());
333
334                let expected_scores: Vec<f32> = query
335                    .rows()
336                    .map(|q_vec| naive_max_sim_single(q_vec, &doc))
337                    .collect();
338
339                for i in 0..*nq {
340                    assert!(
341                        (scores[i] - expected_scores[i]).abs() < 1e-10,
342                        "MaxSim mismatch at {} for ({},{},{})",
343                        i,
344                        nq,
345                        nd,
346                        dim
347                    );
348                }
349
350                // Check that FallbackKernel produces the same values as the naive reference.
351                FallbackKernel::max_sim_kernel(query, doc, |i, score| {
352                    assert!((expected_scores[i] - score).abs() <= 1e-10)
353                });
354
355                // Test Chamfer
356                let chamfer = Chamfer::evaluate(query, doc);
357                let expected_chamfer: f32 = expected_scores.iter().sum();
358
359                assert!(
360                    (chamfer - expected_chamfer).abs() < 1e-10,
361                    "Chamfer mismatch for ({},{},{})",
362                    nq,
363                    nd,
364                    dim
365                );
366
367                // Test ProjectedEigen
368                let projected = ProjectedEigen::evaluate(query, doc);
369                let expected_projected: f32 = query
370                    .rows()
371                    .map(|q_vec| naive_projected_eigen_single(q_vec, &doc))
372                    .sum();
373
374                assert!(
375                    (projected - expected_projected).abs()
376                        < 1e-6 * expected_projected.abs().max(1.0),
377                    "ProjectedEigen mismatch for ({},{},{})",
378                    nq,
379                    nd,
380                    dim
381                );
382            }
383        }
384
385        #[test]
386        fn chamfer_with_zero_queries_returns_zero() {
387            let query = make_query(&[], 0, 2);
388            let doc = make_doc(&[1.0, 0.0, 0.0, 1.0], 2, 2);
389
390            let result = Chamfer::evaluate(query, doc);
391
392            // No query vectors means sum is 0
393            assert_eq!(result, 0.0);
394
395            let result = Chamfer::evaluate(QueryMatRef::from(doc), query.deref().reborrow());
396
397            assert_eq!(result, 0.0);
398        }
399
400        #[test]
401        fn projected_eigen_with_zero_docs_returns_zero() {
402            let query = make_query(&[1.0, 0.0, 0.0, 1.0], 2, 2);
403            let doc = make_doc(&[], 0, 2);
404
405            // No document vectors means no pairs contribute, so the sum is 0.
406            let result = ProjectedEigen::evaluate(query, doc);
407            assert_eq!(result, 0.0);
408        }
409    }
410}