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<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 = graph::search::MultihopSearch::new(*parameters, &*self.labels[index]);
115        let stats = self
116            .index
117            .search(
118                multihop_search,
119                self.strategy.get(index)?,
120                &context,
121                self.queries.row(index),
122                buffer,
123            )
124            .await?;
125
126        Ok(super::knn::Metrics {
127            comparisons: stats.cmps,
128            hops: stats.hops,
129        })
130    }
131}
132
133///////////
134// Tests //
135///////////
136
137#[cfg(test)]
138mod tests {
139    use std::num::NonZeroUsize;
140
141    use super::*;
142
143    use crate::recall::GroundTruthMode;
144    use diskann::graph::{index::QueryLabelProvider, test::provider};
145
146    // A simple [`QueryLabelProvider`] that rejects odd indices.
147    #[derive(Debug)]
148    struct NoOdds;
149
150    impl graph::index::QueryLabelProvider<u32> for NoOdds {
151        fn is_match(&self, id: u32) -> bool {
152            id.is_multiple_of(2)
153        }
154    }
155
156    #[test]
157    fn test_multihop() {
158        let nearest_neighbors = 5;
159
160        let index = search::graph::test_grid_provider();
161
162        let mut queries = Matrix::new(0.0f32, 5, index.provider().dim());
163        queries.row_mut(0).copy_from_slice(&[0.0, 0.0, 0.0, 0.0]);
164        queries.row_mut(1).copy_from_slice(&[4.0, 0.0, 0.0, 0.0]);
165        queries.row_mut(2).copy_from_slice(&[0.0, 4.0, 0.0, 0.0]);
166        queries.row_mut(3).copy_from_slice(&[0.0, 0.0, 4.0, 0.0]);
167        queries.row_mut(4).copy_from_slice(&[0.0, 0.0, 0.0, 4.0]);
168
169        let queries = Arc::new(queries);
170
171        let multihop = MultiHop::new(
172            index,
173            queries.clone(),
174            Strategy::broadcast(provider::Strategy::new()),
175            (0..queries.nrows())
176                .map(|_| -> Arc<dyn QueryLabelProvider<_>> { Arc::new(NoOdds {}) })
177                .collect(),
178        )
179        .unwrap();
180
181        // Test the standard search interface.
182        let rt = crate::tokio::runtime(2).unwrap();
183        let results = search::search(
184            multihop.clone(),
185            graph::search::Knn::new(nearest_neighbors, 10, None).unwrap(),
186            NonZeroUsize::new(2).unwrap(),
187            &rt,
188        )
189        .unwrap();
190
191        assert_eq!(results.len(), queries.nrows());
192        let rows = results.ids().as_rows();
193        assert_eq!(*rows.row(0).first().unwrap(), 0);
194
195        // Check that only even IDs are returned.
196        for r in 0..rows.nrows() {
197            assert_eq!(rows.row(r).len(), nearest_neighbors);
198            for &id in rows.row(r) {
199                assert_eq!(id % 2, 0, "Found odd ID {} in row {}", id, r);
200            }
201        }
202
203        const TWO: NonZeroUsize = NonZeroUsize::new(2).unwrap();
204        let setup = search::Setup {
205            threads: TWO,
206            tasks: TWO,
207            reps: TWO,
208        };
209
210        // Try the aggregated strategy.
211        let parameters = [
212            search::Run::new(
213                graph::search::Knn::new(nearest_neighbors, 10, None).unwrap(),
214                setup.clone(),
215            ),
216            search::Run::new(
217                graph::search::Knn::new(nearest_neighbors, 15, None).unwrap(),
218                setup.clone(),
219            ),
220        ];
221
222        let recall_k = nearest_neighbors;
223        let recall_n = nearest_neighbors;
224
225        let all = search::search_all(
226            multihop,
227            parameters,
228            search::graph::knn::Aggregator::new(
229                rows,
230                recall_k,
231                recall_n,
232                GroundTruthMode::Flexible,
233            ),
234        )
235        .unwrap();
236
237        assert_eq!(all.len(), 2);
238        for summary in all {
239            assert_eq!(summary.setup, setup);
240            assert_eq!(summary.end_to_end_latencies.len(), TWO.get());
241            assert_eq!(summary.mean_latencies.len(), TWO.get());
242            assert_eq!(summary.p90_latencies.len(), TWO.get());
243            assert_eq!(summary.p99_latencies.len(), TWO.get());
244
245            assert_ne!(summary.mean_cmps, 0.0);
246            assert_ne!(summary.mean_hops, 0.0);
247
248            let recall = summary.recall;
249            assert_eq!(recall.recall_k, recall_k);
250            assert_eq!(recall.recall_n, recall_n);
251            assert_eq!(recall.num_queries, queries.nrows());
252            assert_eq!(recall.average, 1.0, "we used a search as the groundtruth");
253        }
254    }
255
256    #[test]
257    fn test_multihop_error() {
258        let index = search::graph::test_grid_provider();
259        let queries = Arc::new(Matrix::new(0.0f32, 2, index.provider().dim()));
260
261        let labels: Arc<[_]> = (0..queries.nrows() + 1)
262            .map(|_| -> Arc<dyn QueryLabelProvider<_>> { Arc::new(NoOdds {}) })
263            .collect();
264
265        let strategy = provider::Strategy::new();
266
267        // Error for a mismatch between strategies and queries.
268        let err = MultiHop::new(
269            index.clone(),
270            queries.clone(),
271            Strategy::collection([strategy.clone()]),
272            labels.clone(),
273        )
274        .unwrap_err();
275        let msg = err.to_string();
276        assert!(
277            msg.contains("1 strategy was provided when 2 were expected"),
278            "failed with {msg}"
279        );
280
281        // Error for a mismatch between label providers and queries.
282        let err = MultiHop::new(
283            index,
284            queries.clone(),
285            Strategy::broadcast(strategy.clone()),
286            labels.clone(),
287        )
288        .unwrap_err();
289        let msg = err.to_string();
290        assert!(
291            msg.contains(
292                "Number of label providers (3) must be equal to the number of queries (2)"
293            ),
294            "failed with {msg}"
295        );
296    }
297}