Skip to main content

diskann_quantization/multi_vector/distance/
fallback.rs

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