Skip to main content

diskann_benchmark_core/search/graph/
multihop.rs

1/*
2 * Copyright (c) Microsoft Corporation.
3 * Licensed under the MIT license.
4 */
5
6use std::sync::Arc;
7
8use diskann::{
9    ANNResult,
10    graph::{self, glue},
11    provider,
12};
13use diskann_utils::{future::AsyncFriendly, views::Matrix};
14
15use crate::search::{self, Search, graph::Strategy};
16
17/// A built-in helper for benchmarking filtered K-nearest neighbors search
18/// using the multi-hop search method.
19///
20/// This is intended to be used in conjunction with [`search::search`] or [`search::search_all`]
21/// and provides some basic additional metrics for the latter. Result aggregation for
22/// [`search::search_all`] is provided by the [`search::graph::knn::Aggregator`] type (same
23/// aggregator as [`search::graph::knn::KNN`]).
24///
25/// The provided implementation of [`Search`] accepts [`graph::search::Knn`]
26/// and returns [`search::graph::knn::Metrics`] as additional output.
27#[derive(Debug)]
28pub struct MultiHop<DP, T, S>
29where
30    DP: provider::DataProvider,
31{
32    index: Arc<graph::DiskANNIndex<DP>>,
33    queries: Arc<Matrix<T>>,
34    strategy: Strategy<S>,
35    labels: Arc<[Arc<dyn graph::index::QueryLabelProvider<DP::InternalId>>]>,
36}
37
38impl<DP, T, S> MultiHop<DP, T, S>
39where
40    DP: provider::DataProvider,
41{
42    /// Construct a new [`MultiHop`] searcher.
43    ///
44    /// If `strategy` is one of the container variants of [`Strategy`], its length
45    /// must match the number of rows in `queries`. If this is the case, then the
46    /// strategies will have a querywise correspondence (see [`search::SearchResults`])
47    /// with the query matrix.
48    ///
49    /// Additionally, the length of `labels` must match the number of rows in `queries`
50    /// and will be used in querywise correspondence with `queries`.
51    ///
52    /// # Errors
53    ///
54    /// Returns an error under the following conditions.
55    ///
56    /// 1. The number of elements in `strategy` is not compatible with the number of rows in
57    ///    `queries`.
58    ///
59    /// 2. The number of label providers in `labels` is not equal to the number of rows in
60    ///    `queries`.
61    pub fn new(
62        index: Arc<graph::DiskANNIndex<DP>>,
63        queries: Arc<Matrix<T>>,
64        strategy: Strategy<S>,
65        labels: Arc<[Arc<dyn graph::index::QueryLabelProvider<DP::InternalId>>]>,
66    ) -> anyhow::Result<Arc<Self>> {
67        strategy.length_compatible(queries.nrows())?;
68
69        if labels.len() != queries.nrows() {
70            Err(anyhow::anyhow!(
71                "Number of label providers ({}) must be equal to the number of queries ({})",
72                labels.len(),
73                queries.nrows()
74            ))
75        } else {
76            Ok(Arc::new(Self {
77                index,
78                queries,
79                strategy,
80                labels,
81            }))
82        }
83    }
84}
85
86impl<DP, T, S> Search for MultiHop<DP, T, S>
87where
88    DP: provider::DataProvider<Context: Default, ExternalId: search::Id>,
89    S: for<'a> glue::DefaultSearchStrategy<'a, DP, &'a [T], DP::ExternalId> + Clone + AsyncFriendly,
90    T: AsyncFriendly + Clone,
91{
92    type Id = DP::ExternalId;
93    type Parameters = graph::search::Knn;
94    type Output = super::knn::Metrics;
95
96    fn num_queries(&self) -> usize {
97        self.queries.nrows()
98    }
99
100    fn id_count(&self, parameters: &Self::Parameters) -> search::IdCount {
101        search::IdCount::Fixed(parameters.k_value())
102    }
103
104    async fn search<O>(
105        &self,
106        parameters: &Self::Parameters,
107        buffer: &mut O,
108        index: usize,
109    ) -> ANNResult<Self::Output>
110    where
111        O: graph::SearchOutputBuffer<DP::ExternalId> + Send,
112    {
113        let context = DP::Context::default();
114        let multihop_search =
115            graph::search::MultihopFilterSearch::new(*parameters, &*self.labels[index]);
116        let stats = self
117            .index
118            .search(
119                multihop_search,
120                self.strategy.get(index)?,
121                &context,
122                self.queries.row(index),
123                buffer,
124            )
125            .await?;
126
127        Ok(super::knn::Metrics {
128            comparisons: stats.cmps,
129            hops: stats.hops,
130        })
131    }
132}
133
134///////////
135// Tests //
136///////////
137
138#[cfg(test)]
139mod tests {
140    use std::num::NonZeroUsize;
141
142    use super::*;
143
144    use crate::recall::GroundTruthMode;
145    use diskann::graph::{index::QueryLabelProvider, test::provider};
146
147    // A simple [`QueryLabelProvider`] that rejects odd indices.
148    #[derive(Debug)]
149    struct NoOdds;
150
151    impl graph::index::QueryLabelProvider<u32> for NoOdds {
152        fn is_match(&self, id: u32) -> bool {
153            id.is_multiple_of(2)
154        }
155    }
156
157    #[test]
158    fn test_multihop() {
159        let nearest_neighbors = 5;
160
161        let index = search::graph::test_grid_provider();
162
163        let mut queries = Matrix::new(0.0f32, 5, index.provider().dim());
164        queries.row_mut(0).copy_from_slice(&[0.0, 0.0, 0.0, 0.0]);
165        queries.row_mut(1).copy_from_slice(&[4.0, 0.0, 0.0, 0.0]);
166        queries.row_mut(2).copy_from_slice(&[0.0, 4.0, 0.0, 0.0]);
167        queries.row_mut(3).copy_from_slice(&[0.0, 0.0, 4.0, 0.0]);
168        queries.row_mut(4).copy_from_slice(&[0.0, 0.0, 0.0, 4.0]);
169
170        let queries = Arc::new(queries);
171
172        let multihop = MultiHop::new(
173            index,
174            queries.clone(),
175            Strategy::broadcast(provider::Strategy::new()),
176            (0..queries.nrows())
177                .map(|_| -> Arc<dyn QueryLabelProvider<_>> { Arc::new(NoOdds {}) })
178                .collect(),
179        )
180        .unwrap();
181
182        // Test the standard search interface.
183        let rt = crate::tokio::runtime(2).unwrap();
184        let results = search::search(
185            multihop.clone(),
186            graph::search::Knn::new(nearest_neighbors, 10, None).unwrap(),
187            NonZeroUsize::new(2).unwrap(),
188            &rt,
189        )
190        .unwrap();
191
192        assert_eq!(results.len(), queries.nrows());
193        let rows = results.ids().as_rows();
194        assert_eq!(*rows.row(0).first().unwrap(), 0);
195
196        // Check that only even IDs are returned.
197        for r in 0..rows.nrows() {
198            assert_eq!(rows.row(r).len(), nearest_neighbors);
199            for &id in rows.row(r) {
200                assert_eq!(id % 2, 0, "Found odd ID {} in row {}", id, r);
201            }
202        }
203
204        const TWO: NonZeroUsize = NonZeroUsize::new(2).unwrap();
205        let setup = search::Setup {
206            threads: TWO,
207            tasks: TWO,
208            reps: TWO,
209        };
210
211        // Try the aggregated strategy.
212        let parameters = [
213            search::Run::new(
214                graph::search::Knn::new(nearest_neighbors, 10, None).unwrap(),
215                setup.clone(),
216            ),
217            search::Run::new(
218                graph::search::Knn::new(nearest_neighbors, 15, None).unwrap(),
219                setup.clone(),
220            ),
221        ];
222
223        let recall_k = nearest_neighbors;
224        let recall_n = nearest_neighbors;
225
226        let all = search::search_all(
227            multihop,
228            parameters,
229            search::graph::knn::Aggregator::new(
230                rows,
231                recall_k,
232                recall_n,
233                GroundTruthMode::Flexible,
234            ),
235        )
236        .unwrap();
237
238        assert_eq!(all.len(), 2);
239        for summary in all {
240            assert_eq!(summary.setup, setup);
241            assert_eq!(summary.end_to_end_latencies.len(), TWO.get());
242            assert_eq!(summary.mean_latencies.len(), TWO.get());
243            assert_eq!(summary.p90_latencies.len(), TWO.get());
244            assert_eq!(summary.p99_latencies.len(), TWO.get());
245
246            assert_ne!(summary.mean_cmps, 0.0);
247            assert_ne!(summary.mean_hops, 0.0);
248
249            let recall = summary.recall;
250            assert_eq!(recall.recall_k, recall_k);
251            assert_eq!(recall.recall_n, recall_n);
252            assert_eq!(recall.num_queries, queries.nrows());
253            assert_eq!(recall.average, 1.0, "we used a search as the groundtruth");
254        }
255    }
256
257    #[test]
258    fn test_multihop_error() {
259        let index = search::graph::test_grid_provider();
260        let queries = Arc::new(Matrix::new(0.0f32, 2, index.provider().dim()));
261
262        let labels: Arc<[_]> = (0..queries.nrows() + 1)
263            .map(|_| -> Arc<dyn QueryLabelProvider<_>> { Arc::new(NoOdds {}) })
264            .collect();
265
266        let strategy = provider::Strategy::new();
267
268        // Error for a mismatch between strategies and queries.
269        let err = MultiHop::new(
270            index.clone(),
271            queries.clone(),
272            Strategy::collection([strategy.clone()]),
273            labels.clone(),
274        )
275        .unwrap_err();
276        let msg = err.to_string();
277        assert!(
278            msg.contains("1 strategy was provided when 2 were expected"),
279            "failed with {msg}"
280        );
281
282        // Error for a mismatch between label providers and queries.
283        let err = MultiHop::new(
284            index,
285            queries.clone(),
286            Strategy::broadcast(strategy.clone()),
287            labels.clone(),
288        )
289        .unwrap_err();
290        let msg = err.to_string();
291        assert!(
292            msg.contains(
293                "Number of label providers (3) must be equal to the number of queries (2)"
294            ),
295            "failed with {msg}"
296        );
297    }
298}