Skip to main content

diskann_disk/search/provider/
disk_provider.rs

1/*
2 * Copyright (c) Microsoft Corporation.
3 * Licensed under the MIT license.
4 */
5
6use std::{
7    collections::HashMap,
8    num::NonZeroUsize,
9    sync::{
10        atomic::{AtomicU64, AtomicUsize},
11        Arc,
12    },
13    time::Instant,
14};
15
16use crate::data_model::GraphDataType;
17use diskann::{
18    error::IntoANNResult,
19    graph::{
20        self,
21        glue::{self, DefaultPostProcessor, SearchPostProcess, SearchStrategy},
22        search::Knn,
23        search_output_buffer, DiskANNIndex,
24    },
25    neighbor::{Neighbor, NeighborPriorityQueue},
26    provider::{DataProvider, DefaultContext, HasId, NoopGuard},
27    utils::{IntoUsize, VectorRepr},
28    ANNError, ANNResult,
29};
30use diskann_providers::storage::StorageReadProvider;
31use diskann_providers::{
32    model::compute_pq_distance,
33    storage::{get_compressed_pq_file, get_disk_index_file, get_pq_pivot_file, LoadWith},
34};
35use diskann_utils::object_pool::{ObjectPool, PoolOption, TryAsPooled};
36
37use crate::search::pq::{quantizer_preprocess, PQData, PQScratch};
38use diskann_vector::{distance::Metric, DistanceFunction};
39use tokio::runtime::Runtime;
40use tracing::debug;
41
42use crate::{
43    data_model::{CachingStrategy, GraphHeader},
44    filter_parameter::{default_vector_filter, VectorFilter},
45    search::{
46        provider::disk_vertex_provider_factory::DiskVertexProviderFactory,
47        traits::{VertexProvider, VertexProviderFactory},
48    },
49    storage::{api::AsyncDiskLoadContext, disk_index_reader::DiskIndexReader},
50    utils::AlignedFileReaderFactory,
51    utils::QueryStatistics,
52};
53
54///////////////////
55// Disk Provider //
56///////////////////
57
58/// The DiskProvider is a data provider that loads data from disk using the disk readers
59/// The data format for disk is different from that of the in-memory providers.
60/// The disk format stores both the vectors and the adjacency list next to each other for
61/// better locality for quicker access.
62/// Please refer to the RFC documentation at [`docs\rfcs\cy2025\disk_provider_for_async_index.md`] for design details.
63pub struct DiskProvider<Data>
64where
65    Data: GraphDataType<VectorIdType = u32>,
66{
67    /// Holds the graph header information that contains metadata about disk-index file.
68    graph_header: GraphHeader,
69
70    // Full precision distance comparer used in post_process to reorder results.
71    distance_comparer: <Data::VectorDataType as VectorRepr>::Distance,
72
73    /// The PQ data used for quantization.
74    pq_data: Arc<PQData>,
75
76    /// The number of points in the graph.
77    num_points: usize,
78
79    /// Metric used for distance computation.
80    metric: Metric,
81
82    /// The number of IO operations that can be done in parallel.
83    search_io_limit: usize,
84}
85
86impl<Data> DataProvider for DiskProvider<Data>
87where
88    Data: GraphDataType<VectorIdType = u32>,
89{
90    type Context = DefaultContext;
91
92    type InternalId = u32;
93
94    type ExternalId = u32;
95
96    type Guard = NoopGuard<u32>;
97
98    type Error = ANNError;
99
100    /// Translate an external id to its corresponding internal id.
101    fn to_internal_id(
102        &self,
103        _context: &DefaultContext,
104        gid: &Self::ExternalId,
105    ) -> Result<Self::InternalId, Self::Error> {
106        Ok(*gid)
107    }
108
109    /// Translate an internal id its corresponding external id.
110    fn to_external_id(
111        &self,
112        _context: &DefaultContext,
113        id: Self::InternalId,
114    ) -> Result<Self::ExternalId, Self::Error> {
115        Ok(id)
116    }
117}
118
119impl<Data> LoadWith<AsyncDiskLoadContext> for DiskProvider<Data>
120where
121    Data: GraphDataType<VectorIdType = u32>,
122{
123    type Error = ANNError;
124
125    async fn load_with<P>(provider: &P, ctx: &AsyncDiskLoadContext) -> ANNResult<Self>
126    where
127        P: StorageReadProvider,
128    {
129        debug!(
130            "DiskProvider::load_with() called with file: {:?}",
131            get_disk_index_file(ctx.quant_load_context.metadata.prefix())
132        );
133
134        let graph_header = {
135            let aligned_reader_factory = AlignedFileReaderFactory::new(get_disk_index_file(
136                ctx.quant_load_context.metadata.prefix(),
137            ));
138
139            let caching_strategy = if ctx.num_nodes_to_cache > 0 {
140                CachingStrategy::StaticCacheWithBfsNodes(ctx.num_nodes_to_cache)
141            } else {
142                CachingStrategy::None
143            };
144
145            let vertex_provider_factory = DiskVertexProviderFactory::<Data, _>::new(
146                aligned_reader_factory,
147                caching_strategy,
148            )?;
149            VertexProviderFactory::get_header(&vertex_provider_factory)?
150        };
151
152        let metric = ctx.quant_load_context.metric;
153        let num_points = ctx.num_points;
154
155        let index_path_prefix = ctx.quant_load_context.metadata.prefix();
156        let index_reader = DiskIndexReader::new(
157            get_pq_pivot_file(index_path_prefix),
158            get_compressed_pq_file(index_path_prefix),
159            provider,
160        )?;
161
162        Self::new(
163            &index_reader,
164            graph_header,
165            metric,
166            num_points,
167            ctx.search_io_limit,
168        )
169    }
170}
171
172impl<Data> DiskProvider<Data>
173where
174    Data: GraphDataType<VectorIdType = u32>,
175{
176    fn new(
177        disk_index_reader: &DiskIndexReader,
178        graph_header: GraphHeader,
179        metric: Metric,
180        num_points: usize,
181        search_io_limit: usize,
182    ) -> ANNResult<Self> {
183        let distance_comparer =
184            Data::VectorDataType::distance(metric, Some(graph_header.metadata().dims));
185
186        let pq_data = disk_index_reader.get_pq_data();
187
188        Ok(Self {
189            graph_header,
190            distance_comparer,
191            pq_data,
192            num_points,
193            metric,
194            search_io_limit,
195        })
196    }
197}
198
199/// The search strategy for the disk provider. This is used to create the search accessor
200/// for use in search in quant space and post_process function to reorder with full precision vectors.
201///
202/// # Why vertex_provider_factory and scratch_pool are here instead of DiskProvider
203///
204/// The DataProvider trait requires 'static bounds for multi-threaded async contexts,
205/// but vertex_provider_factory may have non-'static lifetime bounds (e.g., borrowing
206/// from local data structures). Moving these components to the search strategy allows
207/// DiskProvider to satisfy 'static constraints while enabling flexible per-search
208/// resource management.
209pub struct DiskSearchStrategy<'a, Data, ProviderFactory>
210where
211    Data: GraphDataType<VectorIdType = u32>,
212    ProviderFactory: VertexProviderFactory<Data>,
213{
214    // This needs to be Arc instead of Rc because DiskSearchStrategy has "Send" trait bound, though this is not expected to be shared across threads.
215    io_tracker: IOTracker,
216    vector_filter: &'a (dyn Fn(&u32) -> bool + Send + Sync), // Fn param is u32 as we validate "VectorIdType = u32" everywhere in this provider in trait bounds.
217
218    /// The vertex provider factory is used to create the vertex provider for each search instance.
219    vertex_provider_factory: &'a ProviderFactory,
220
221    /// Scratch pool for disk search operations that need allocations.
222    scratch_pool: &'a Arc<ObjectPool<DiskSearchScratch<Data, ProviderFactory::VertexProviderType>>>,
223}
224
225// Struct to track IO. This is used by single thread, but needs to be Atomic as the Strategy has "Send" trait bound.
226// There should be minimal to no overhead compared to using a raw reference.
227struct IOTracker {
228    io_time_us: AtomicU64,
229    preprocess_time_us: AtomicU64,
230    io_count: AtomicUsize,
231}
232
233impl Default for IOTracker {
234    fn default() -> Self {
235        Self {
236            io_time_us: AtomicU64::new(0),
237            preprocess_time_us: AtomicU64::new(0),
238            io_count: AtomicUsize::new(0),
239        }
240    }
241}
242
243impl IOTracker {
244    fn add_time(category: &AtomicU64, time: u64) {
245        category.fetch_add(time, std::sync::atomic::Ordering::Relaxed);
246    }
247
248    fn time(category: &AtomicU64) -> u64 {
249        category.load(std::sync::atomic::Ordering::Relaxed)
250    }
251
252    fn add_io_count(&self, count: usize) {
253        self.io_count
254            .fetch_add(count, std::sync::atomic::Ordering::Relaxed);
255    }
256
257    fn io_count(&self) -> usize {
258        self.io_count.load(std::sync::atomic::Ordering::Relaxed)
259    }
260}
261
262#[derive(Clone, Copy)]
263pub struct RerankAndFilter<'a> {
264    filter: &'a (dyn Fn(&u32) -> bool + Send + Sync),
265}
266
267impl<'a> RerankAndFilter<'a> {
268    fn new(filter: &'a (dyn Fn(&u32) -> bool + Send + Sync)) -> Self {
269        Self { filter }
270    }
271}
272
273impl<Data, VP>
274    SearchPostProcess<
275        DiskAccessor<'_, Data, VP>,
276        &[Data::VectorDataType],
277        (
278            <DiskProvider<Data> as DataProvider>::InternalId,
279            Data::AssociatedDataType,
280        ),
281    > for RerankAndFilter<'_>
282where
283    Data: GraphDataType<VectorIdType = u32>,
284    VP: VertexProvider<Data>,
285{
286    type Error = ANNError;
287    async fn post_process<I, B>(
288        &self,
289        accessor: &mut DiskAccessor<'_, Data, VP>,
290        query: &[Data::VectorDataType],
291        candidates: I,
292        output: &mut B,
293    ) -> Result<usize, Self::Error>
294    where
295        I: Iterator<Item = Neighbor<u32>> + Send,
296        B: search_output_buffer::SearchOutputBuffer<(u32, Data::AssociatedDataType)>
297            + Send
298            + ?Sized,
299    {
300        let provider = accessor.provider;
301
302        let mut uncached_ids = Vec::new();
303        let mut reranked = candidates
304            .map(|n| n.id)
305            .filter(|id| (self.filter)(id))
306            .filter_map(|n| {
307                if let Some(entry) = accessor.scratch.distance_cache.get(&n) {
308                    Some(Ok::<((u32, _), f32), ANNError>(((n, entry.1), entry.0)))
309                } else {
310                    uncached_ids.push(n);
311                    None
312                }
313            })
314            .collect::<Result<Vec<_>, _>>()?;
315        if !uncached_ids.is_empty() {
316            ensure_vertex_loaded(&mut accessor.scratch.vertex_provider, &uncached_ids)?;
317            for n in &uncached_ids {
318                let v = accessor.scratch.vertex_provider.get_vector(n)?;
319                let d = provider.distance_comparer.evaluate_similarity(query, v);
320                let a = accessor.scratch.vertex_provider.get_associated_data(n)?;
321                reranked.push(((*n, *a), d));
322            }
323        }
324
325        // Sort the full precision distances.
326        reranked
327            .sort_unstable_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
328        // Store the reranked results.
329        Ok(output.extend(reranked))
330    }
331}
332
333impl<'this, Data, ProviderFactory>
334    SearchStrategy<'this, DiskProvider<Data>, &'this [Data::VectorDataType]>
335    for DiskSearchStrategy<'this, Data, ProviderFactory>
336where
337    Data: GraphDataType<VectorIdType = u32>,
338    ProviderFactory: VertexProviderFactory<Data>,
339{
340    type SearchAccessor = DiskAccessor<'this, Data, ProviderFactory::VertexProviderType>;
341    type SearchAccessorError = ANNError;
342
343    fn search_accessor(
344        &'this self,
345        provider: &'this DiskProvider<Data>,
346        _context: &DefaultContext,
347        query: &'this [Data::VectorDataType],
348    ) -> Result<Self::SearchAccessor, Self::SearchAccessorError> {
349        DiskAccessor::new(
350            provider,
351            &self.io_tracker,
352            query,
353            self.vertex_provider_factory,
354            self.scratch_pool,
355        )
356    }
357}
358
359impl<'this, Data, ProviderFactory>
360    DefaultPostProcessor<
361        'this,
362        DiskProvider<Data>,
363        &'this [Data::VectorDataType],
364        (
365            <DiskProvider<Data> as DataProvider>::InternalId,
366            Data::AssociatedDataType,
367        ),
368    > for DiskSearchStrategy<'this, Data, ProviderFactory>
369where
370    Data: GraphDataType<VectorIdType = u32>,
371    ProviderFactory: VertexProviderFactory<Data>,
372{
373    type Processor = RerankAndFilter<'this>;
374
375    fn default_post_processor(&'this self) -> Self::Processor {
376        RerankAndFilter::new(self.vector_filter)
377    }
378}
379
380// Scratch space for disk search operations that need allocations.
381// These allocations are amortized across searches using the scratch pool.
382struct DiskSearchScratch<Data, VP>
383where
384    Data: GraphDataType<VectorIdType = u32>,
385    VP: VertexProvider<Data>,
386{
387    distance_cache: HashMap<u32, (f32, Data::AssociatedDataType)>,
388    pq_scratch: PQScratch,
389    vertex_provider: VP,
390}
391
392#[derive(Clone)]
393struct DiskSearchScratchArgs<'a, ProviderFactory> {
394    graph_degree: usize,
395    pq_dim: usize,
396    num_pq_chunks: usize,
397    num_pq_centers: usize,
398    vertex_factory: &'a ProviderFactory,
399    graph_header: &'a GraphHeader,
400}
401
402impl<Data, ProviderFactory> TryAsPooled<&DiskSearchScratchArgs<'_, ProviderFactory>>
403    for DiskSearchScratch<Data, ProviderFactory::VertexProviderType>
404where
405    Data: GraphDataType<VectorIdType = u32>,
406    ProviderFactory: VertexProviderFactory<Data>,
407{
408    type Error = ANNError;
409
410    fn try_create(args: &DiskSearchScratchArgs<ProviderFactory>) -> Result<Self, Self::Error> {
411        let pq_scratch = PQScratch::new(
412            args.graph_degree,
413            args.pq_dim,
414            args.num_pq_chunks,
415            args.num_pq_centers,
416        )?;
417
418        const DEFAULT_BEAM_WIDTH: usize = 0; // Setting as 0 to avoid preallocation of memory.
419        let vertex_provider = args
420            .vertex_factory
421            .create_vertex_provider(DEFAULT_BEAM_WIDTH, args.graph_header)?;
422
423        Ok(Self {
424            distance_cache: HashMap::new(),
425            pq_scratch,
426            vertex_provider,
427        })
428    }
429
430    fn try_modify(
431        &mut self,
432        _args: &DiskSearchScratchArgs<ProviderFactory>,
433    ) -> Result<(), Self::Error> {
434        self.distance_cache.clear();
435        self.vertex_provider.clear();
436        Ok(())
437    }
438}
439
440pub struct DiskAccessor<'a, Data, VP>
441where
442    Data: GraphDataType<VectorIdType = u32>,
443    VP: VertexProvider<Data>,
444{
445    provider: &'a DiskProvider<Data>,
446    io_tracker: &'a IOTracker,
447    scratch: PoolOption<DiskSearchScratch<Data, VP>>,
448    query: &'a [Data::VectorDataType],
449}
450
451impl<Data, VP> DiskAccessor<'_, Data, VP>
452where
453    Data: GraphDataType<VectorIdType = u32>,
454    VP: VertexProvider<Data>,
455{
456    // Compute the PQ distance between each ID in `ids` and the distance table stored in
457    // `self`, invoking the callback with the results of each computation in order.
458    fn pq_distances<F>(&mut self, ids: &[u32], mut f: F) -> ANNResult<()>
459    where
460        F: FnMut(f32, u32),
461    {
462        let pq_scratch = &mut self.scratch.pq_scratch;
463        compute_pq_distance(
464            ids,
465            self.provider.pq_data.get_num_chunks(),
466            &pq_scratch.aligned_pqtable_dist_scratch,
467            self.provider.pq_data.pq_compressed_data().as_slice(),
468            &mut pq_scratch.aligned_pq_coord_scratch,
469            &mut pq_scratch.aligned_dist_scratch,
470        )?;
471
472        for (i, id) in ids.iter().enumerate() {
473            let distance = self.scratch.pq_scratch.aligned_dist_scratch[i];
474            f(distance, *id);
475        }
476
477        Ok(())
478    }
479}
480
481impl<Data, VP> HasId for DiskAccessor<'_, Data, VP>
482where
483    Data: GraphDataType<VectorIdType = u32>,
484    VP: VertexProvider<Data>,
485{
486    type Id = u32;
487}
488
489impl<Data, VP> glue::SearchAccessor for DiskAccessor<'_, Data, VP>
490where
491    Data: GraphDataType<VectorIdType = u32>,
492    VP: VertexProvider<Data>,
493{
494    async fn starting_points(&self) -> ANNResult<Vec<u32>> {
495        let start_vertex_id = self.provider.graph_header.metadata().medoid as u32;
496        Ok(vec![start_vertex_id])
497    }
498
499    async fn start_point_distances<F>(&mut self, mut f: F) -> ANNResult<()>
500    where
501        F: FnMut(Self::Id, f32) + Send,
502    {
503        let start_vertex_id = self.provider.graph_header.metadata().medoid as u32;
504        self.pq_distances(&[start_vertex_id], |dist, id| f(id, dist))
505    }
506
507    fn expand_beam<Itr, P, F>(
508        &mut self,
509        ids: Itr,
510        mut pred: P,
511        mut f: F,
512    ) -> impl std::future::Future<Output = ANNResult<()>> + Send
513    where
514        Itr: Iterator<Item = Self::Id> + Send,
515        P: glue::HybridPredicate<Self::Id> + Send + Sync,
516        F: FnMut(Self::Id, f32) + Send,
517    {
518        let result = (|| {
519            let io_limit = self.provider.search_io_limit - self.io_tracker.io_count();
520            let load_ids: Box<[_]> = ids.take(io_limit).collect();
521
522            self.ensure_loaded(&load_ids)?;
523            let mut ids = Vec::new();
524            for i in load_ids {
525                ids.clear();
526                ids.extend(
527                    self.scratch
528                        .vertex_provider
529                        .get_adjacency_list(&i)?
530                        .iter()
531                        .copied()
532                        .filter(|id| pred.eval_mut(id)),
533                );
534
535                self.pq_distances(&ids, &mut |dist, id| f(id, dist))?;
536            }
537
538            Ok(())
539        })();
540
541        std::future::ready(result)
542    }
543
544    fn terminate_early(&mut self) -> bool {
545        self.io_tracker.io_count() > self.provider.search_io_limit
546    }
547}
548
549impl<'a, Data, VP> DiskAccessor<'a, Data, VP>
550where
551    Data: GraphDataType<VectorIdType = u32>,
552    VP: VertexProvider<Data>,
553{
554    fn new<VPF>(
555        provider: &'a DiskProvider<Data>,
556        io_tracker: &'a IOTracker,
557        query: &'a [Data::VectorDataType],
558        vertex_provider_factory: &'a VPF,
559        scratch_pool: &'a Arc<ObjectPool<DiskSearchScratch<Data, VP>>>,
560    ) -> ANNResult<Self>
561    where
562        VPF: VertexProviderFactory<Data, VertexProviderType = VP>,
563    {
564        let mut scratch = PoolOption::try_pooled(
565            scratch_pool,
566            &DiskSearchScratchArgs {
567                graph_degree: provider.graph_header.max_degree::<Data::VectorDataType>()?,
568                pq_dim: provider.pq_data.get_dim(),
569                num_pq_chunks: provider.pq_data.get_num_chunks(),
570                num_pq_centers: provider.pq_data.get_num_centers(),
571                vertex_factory: vertex_provider_factory,
572                graph_header: &provider.graph_header,
573            },
574        )?;
575
576        // Decode caller's native vector representation into `f32`; downstream PQ kernels operate purely on `&[f32]`.
577        let f32_query = Data::VectorDataType::as_f32(query).into_ann_result()?;
578        scratch.pq_scratch.set(&f32_query)?;
579        let start_vertex_id = provider.graph_header.metadata().medoid as u32;
580
581        let timer = Instant::now();
582        quantizer_preprocess(
583            &mut scratch.pq_scratch,
584            &provider.pq_data,
585            provider.metric,
586            &[start_vertex_id],
587        )?;
588        IOTracker::add_time(
589            &io_tracker.preprocess_time_us,
590            timer.elapsed().as_micros() as u64,
591        );
592
593        Ok(Self {
594            provider,
595            io_tracker,
596            scratch,
597            query,
598        })
599    }
600
601    fn ensure_loaded(&mut self, ids: &[u32]) -> Result<(), ANNError> {
602        if ids.is_empty() {
603            return Ok(());
604        }
605        let scratch = &mut self.scratch;
606        let timer = Instant::now();
607        ensure_vertex_loaded(&mut scratch.vertex_provider, ids)?;
608        IOTracker::add_time(
609            &self.io_tracker.io_time_us,
610            timer.elapsed().as_micros() as u64,
611        );
612        self.io_tracker.add_io_count(ids.len());
613        for id in ids {
614            let distance = self
615                .provider
616                .distance_comparer
617                .evaluate_similarity(self.query, scratch.vertex_provider.get_vector(id)?);
618            let associated_data = *scratch.vertex_provider.get_associated_data(id)?;
619            scratch
620                .distance_cache
621                .insert(*id, (distance, associated_data));
622        }
623        Ok(())
624    }
625}
626
627/// [`DiskIndexSearcher`] is a helper class to make it easy to construct index
628/// and do repeated search operations. It is a wrapper around the index.
629/// This is useful for drivers such as search_disk_index.exe in tools.
630pub struct DiskIndexSearcher<
631    Data,
632    ProviderFactory = DiskVertexProviderFactory<Data, AlignedFileReaderFactory>,
633> where
634    Data: GraphDataType<VectorIdType = u32>,
635    ProviderFactory: VertexProviderFactory<Data>,
636{
637    index: DiskANNIndex<DiskProvider<Data>>,
638    runtime: Runtime,
639
640    /// The vertex provider factory is used to create the vertex provider for each search instance.
641    vertex_provider_factory: ProviderFactory,
642
643    /// Scratch pool for disk search operations that need allocations.
644    scratch_pool: Arc<ObjectPool<DiskSearchScratch<Data, ProviderFactory::VertexProviderType>>>,
645}
646
647#[derive(Debug)]
648pub struct SearchResultStats {
649    pub cmps: u32,
650    pub result_count: u32,
651    pub query_statistics: QueryStatistics,
652}
653
654/// `SearchResult` is a struct representing the result of a search operation.
655///
656/// It contains a list of vector results and a statistics object
657///
658pub struct SearchResult<AssociatedData> {
659    /// A list of nearest neighbors resulting from the search.
660    pub results: Vec<SearchResultItem<AssociatedData>>,
661    pub stats: SearchResultStats,
662}
663
664/// `VectorResult` is a struct representing a nearest neighbor resulting from a search.
665///
666/// It contains the vertex id, associated data, and the distance to the query vector.
667///
668pub struct SearchResultItem<AssociatedData> {
669    /// The vertex id of the nearest neighbor.
670    pub vertex_id: u32,
671    /// The associated data of the nearest neighbor as a fixed size byte array.
672    /// The length is determined when the index is created.
673    pub data: AssociatedData,
674    /// The distance between the nearest neighbor and the query vector.
675    pub distance: f32,
676}
677
678impl<Data, ProviderFactory> DiskIndexSearcher<Data, ProviderFactory>
679where
680    Data: GraphDataType<VectorIdType = u32>,
681    ProviderFactory: VertexProviderFactory<Data>,
682{
683    /// Create a new asynchronous disk searcher instance.
684    ///
685    /// # Arguments
686    /// * `num_threads` - The maximum number of threads to use.
687    /// * `search_io_limit` - I/O operation limit.
688    /// * `disk_index_reader` - The disk index reader.
689    /// * `vertex_provider_factory` - The vertex provider factory.
690    /// * `metric` - Distance metric used for vector similarity calculations.
691    /// * `runtime` - Tokio runtime handle for executing async operations.
692    pub fn new(
693        num_threads: usize,
694        search_io_limit: usize,
695        disk_index_reader: &DiskIndexReader,
696        vertex_provider_factory: ProviderFactory,
697        metric: Metric,
698        runtime: Option<Runtime>,
699    ) -> ANNResult<Self> {
700        let runtime = match runtime {
701            Some(rt) => rt,
702            None => tokio::runtime::Builder::new_current_thread().build()?,
703        };
704
705        let graph_header = vertex_provider_factory.get_header()?;
706        let metadata = graph_header.metadata();
707        let max_degree = graph_header.max_degree::<Data::VectorDataType>()? as u32;
708
709        let config = graph::config::Builder::new(
710            max_degree.into_usize(),
711            graph::config::MaxDegree::default_slack(),
712            1, // build-search-list-size
713            metric.into(),
714        )
715        .build()?;
716
717        debug!("Creating DiskIndexSearcher with index_config: {:?}", config);
718
719        let graph_header = vertex_provider_factory.get_header()?;
720        let pq_data = disk_index_reader.get_pq_data();
721        let scratch_pool_args = DiskSearchScratchArgs {
722            graph_degree: graph_header.max_degree::<Data::VectorDataType>()?,
723            pq_dim: pq_data.get_dim(),
724            num_pq_chunks: pq_data.get_num_chunks(),
725            num_pq_centers: pq_data.get_num_centers(),
726            vertex_factory: &vertex_provider_factory,
727            graph_header: &graph_header,
728        };
729        let scratch_pool = Arc::new(ObjectPool::try_new(&scratch_pool_args, 0, None)?);
730
731        let disk_provider = DiskProvider::new(
732            disk_index_reader,
733            graph_header,
734            metric,
735            metadata.num_pts.into_usize(),
736            search_io_limit,
737        )?;
738
739        let index = DiskANNIndex::new(config, disk_provider, NonZeroUsize::new(num_threads));
740        Ok(Self {
741            index,
742            runtime,
743            vertex_provider_factory,
744            scratch_pool,
745        })
746    }
747
748    /// Helper method to create a DiskSearchStrategy with common parameters
749    fn search_strategy<'a>(
750        &'a self,
751        vector_filter: &'a (dyn Fn(&Data::VectorIdType) -> bool + Send + Sync),
752    ) -> DiskSearchStrategy<'a, Data, ProviderFactory> {
753        DiskSearchStrategy {
754            io_tracker: IOTracker::default(),
755            vector_filter,
756            vertex_provider_factory: &self.vertex_provider_factory,
757            scratch_pool: &self.scratch_pool,
758        }
759    }
760
761    /// Perform a brute-force linear scan of all points in the index, returning the
762    /// nearest neighbors that pass `vector_filter`.
763    ///
764    /// The top `neighbors_before_reranking` candidates from the quantized scan will be
765    /// provided to full-precision reranking.
766    async fn flat_search<OB>(
767        &self,
768        strategy: &DiskSearchStrategy<'_, Data, ProviderFactory>,
769        query: &[Data::VectorDataType],
770        vector_filter: &(dyn Fn(&u32) -> bool + Send + Sync),
771        neighbors_before_reranking: usize,
772        output: &mut OB,
773    ) -> ANNResult<graph::index::SearchStats>
774    where
775        OB: search_output_buffer::SearchOutputBuffer<(u32, Data::AssociatedDataType)> + Send,
776    {
777        let provider = self.index.provider();
778        let mut accessor = strategy
779            .search_accessor(provider, &DefaultContext, query)
780            .into_ann_result()?;
781
782        // Derive the batch size from the scratch data structure. Providing too many vectors
783        // will panic.
784        let batch_size = accessor.scratch.pq_scratch.max_vectors();
785
786        // This check should always hold since `graph_degree` comes from
787        // `diskann::graph::Config` and is forced to be non-zero. But this is defensive
788        // against misconfiguration.
789        if batch_size == 0 {
790            return Err(ANNError::message(
791                diskann::ANNErrorKind::IndexError,
792                "pq scratch must support at least one vector",
793            ));
794        }
795
796        let mut id_buffer = Vec::with_capacity(batch_size);
797
798        let mut best = NeighborPriorityQueue::new(neighbors_before_reranking);
799        let mut cmps = 0u32;
800
801        let mut iter = (0..provider.num_points as u32).filter(vector_filter);
802        loop {
803            id_buffer.clear();
804            id_buffer.extend(iter.by_ref().take(batch_size));
805
806            if id_buffer.is_empty() {
807                break;
808            }
809
810            accessor.pq_distances(&id_buffer, |dist, id| best.insert(Neighbor::new(id, dist)))?;
811            cmps += id_buffer.len() as u32;
812        }
813
814        let result_count = strategy
815            .default_post_processor()
816            .post_process(&mut accessor, query, best.iter(), output)
817            .await
818            .into_ann_result()?;
819
820        Ok(graph::index::SearchStats {
821            cmps,
822            hops: 0,
823            result_count: result_count as u32,
824            range_search_second_round: false,
825        })
826    }
827
828    /// Perform a search on the disk index.
829    /// return the list of nearest neighbors and associated data.
830    pub fn search(
831        &self,
832        query: &[Data::VectorDataType],
833        return_list_size: u32,
834        search_list_size: u32,
835        beam_width: Option<usize>,
836        vector_filter: Option<VectorFilter<Data>>,
837        is_flat_search: bool,
838    ) -> ANNResult<SearchResult<Data::AssociatedDataType>> {
839        let mut query_stats = QueryStatistics::default();
840        let mut indices = vec![0u32; return_list_size as usize];
841        let mut distances = vec![0f32; return_list_size as usize];
842        let mut associated_data =
843            vec![Data::AssociatedDataType::default(); return_list_size as usize];
844
845        let stats = self.search_internal(
846            query,
847            return_list_size as usize,
848            search_list_size,
849            beam_width,
850            &mut query_stats,
851            &mut indices,
852            &mut distances,
853            &mut associated_data,
854            &vector_filter.unwrap_or(default_vector_filter::<Data>()),
855            is_flat_search,
856        )?;
857
858        let mut search_result = SearchResult {
859            results: Vec::with_capacity(return_list_size as usize),
860            stats,
861        };
862
863        for ((vertex_id, distance), associated_data) in indices
864            .into_iter()
865            .zip(distances.into_iter())
866            .zip(associated_data.into_iter())
867        {
868            search_result.results.push(SearchResultItem {
869                vertex_id,
870                distance,
871                data: associated_data,
872            });
873        }
874
875        Ok(search_result)
876    }
877
878    /// Perform a raw search on the disk index.
879    /// This is a lower-level API that allows more control over the search parameters and output buffers.
880    #[allow(clippy::too_many_arguments)]
881    pub(crate) fn search_internal(
882        &self,
883        query: &[Data::VectorDataType],
884        k_value: usize,
885        search_list_size: u32,
886        beam_width: Option<usize>,
887        query_stats: &mut QueryStatistics,
888        indices: &mut [u32],
889        distances: &mut [f32],
890        associated_data: &mut [Data::AssociatedDataType],
891        vector_filter: &(dyn Fn(&Data::VectorIdType) -> bool + Send + Sync),
892        is_flat_search: bool,
893    ) -> ANNResult<SearchResultStats> {
894        let mut result_output_buffer = search_output_buffer::IdDistanceAssociatedData::new(
895            &mut indices[..k_value],
896            &mut distances[..k_value],
897            &mut associated_data[..k_value],
898        );
899
900        let strategy = self.search_strategy(vector_filter);
901        let timer = Instant::now();
902        let k = k_value;
903        let l = search_list_size as usize;
904        let stats = if is_flat_search {
905            self.runtime.block_on(self.flat_search(
906                &strategy,
907                query,
908                vector_filter,
909                l,
910                &mut result_output_buffer,
911            ))?
912        } else {
913            let knn_search = Knn::new(k, l, beam_width)?;
914            self.runtime.block_on(self.index.search(
915                knn_search,
916                &strategy,
917                &DefaultContext,
918                query,
919                &mut result_output_buffer,
920            ))?
921        };
922        query_stats.total_comparisons = stats.cmps;
923        query_stats.search_hops = stats.hops;
924
925        query_stats.total_execution_time_us = timer.elapsed().as_micros();
926        query_stats.io_time_us = IOTracker::time(&strategy.io_tracker.io_time_us) as u128;
927        query_stats.total_io_operations = strategy.io_tracker.io_count() as u32;
928        query_stats.total_vertices_loaded = strategy.io_tracker.io_count() as u32;
929        query_stats.query_pq_preprocess_time_us =
930            IOTracker::time(&strategy.io_tracker.preprocess_time_us) as u128;
931        query_stats.cpu_time_us = query_stats.total_execution_time_us
932            - query_stats.io_time_us
933            - query_stats.query_pq_preprocess_time_us;
934        Ok(SearchResultStats {
935            cmps: query_stats.total_comparisons,
936            result_count: stats.result_count,
937            query_statistics: query_stats.clone(),
938        })
939    }
940}
941
942/// Helper function to ensure vertices are loaded and processed.
943///
944/// This is a convenience function that combines `load_vertices` and `process_loaded_node`
945/// for each vertex ID. It first loads all the vertices in batch, then processes each
946/// loaded node.
947fn ensure_vertex_loaded<Data: GraphDataType, V: VertexProvider<Data>>(
948    vertex_provider: &mut V,
949    ids: &[Data::VectorIdType],
950) -> ANNResult<()> {
951    vertex_provider.load_vertices(ids)?;
952    for (idx, id) in ids.iter().enumerate() {
953        vertex_provider.process_loaded_node(id, idx)?;
954    }
955    Ok(())
956}
957
958#[cfg(test)]
959mod disk_provider_tests {
960    use crate::test_utils::{GraphDataF32VectorU32Data, GraphDataF32VectorUnitData};
961    use diskann::{
962        graph::{
963            search::{record::VisitedSearchRecord, Knn},
964            KnnSearchError,
965        },
966        utils::IntoUsize,
967        ANNErrorKind,
968    };
969    use diskann_providers::storage::{
970        DynWriteProvider, StorageReadProvider, VirtualStorageProvider,
971    };
972    use diskann_providers::utils::{create_thread_pool, PQPathNames, ParallelIteratorInPool};
973    use diskann_utils::{io::read_bin, test_data_root};
974    use diskann_vector::distance::Metric;
975    use rayon::prelude::IndexedParallelIterator;
976    use rstest::rstest;
977    use vfs::OverlayFS;
978
979    use super::*;
980    use crate::{
981        build::builder::core::disk_index_builder_tests::{IndexBuildFixture, TestParams},
982        utils::{QueryStatistics, VirtualAlignedReaderFactory},
983    };
984
985    const TEST_INDEX_PREFIX_128DIM: &str =
986        "/disk_index_search/disk_index_sift_learn_R4_L50_A1.2_truth_search";
987    const TEST_INDEX_128DIM: &str =
988        "/disk_index_search/disk_index_sift_learn_R4_L50_A1.2_truth_search_disk.index";
989    const TEST_PQ_PIVOT_128DIM: &str =
990        "/disk_index_search/disk_index_sift_learn_R4_L50_A1.2_truth_search_pq_pivots.bin";
991    const TEST_PQ_COMPRESSED_128DIM: &str =
992        "/disk_index_search/disk_index_sift_learn_R4_L50_A1.2_truth_search_pq_compressed.bin";
993    const TEST_TRUTH_RESULT_10PTS_128DIM: &str =
994        "/disk_index_search/disk_index_10pts_idx_uint32_truth_search_res.bin";
995    const TEST_QUERY_10PTS_128DIM: &str = "/disk_index_search/disk_index_sample_query_10pts.fbin";
996
997    const TEST_INDEX_PREFIX_100DIM: &str = "/disk_index_search/256pts_100dim_f32_truth_Index";
998    const TEST_INDEX_100DIM: &str = "/disk_index_search/256pts_100dim_f32_truth_Index_disk.index";
999    const TEST_PQ_PIVOT_100DIM: &str =
1000        "/disk_index_search/256pts_100dim_f32_truth_Index_pq_pivots.bin";
1001    const TEST_PQ_COMPRESSED_100DIM: &str =
1002        "/disk_index_search/256pts_100dim_f32_truth_Index_pq_compressed.bin";
1003    const TEST_TRUTH_RESULT_10PTS_100DIM: &str =
1004        "/disk_index_search/256pts_100dim_f32_truth_query_result.bin";
1005    const TEST_QUERY_10PTS_100DIM: &str = "/disk_index_search/10pts_100dim_f32_base_query.bin";
1006    const TEST_DATA_FILE: &str = "/disk_index_search/disk_index_siftsmall_learn_256pts_data.fbin";
1007    const TEST_INDEX: &str =
1008        "/disk_index_search/disk_index_sift_learn_R4_L50_A1.2_truth_search_disk.index";
1009    const TEST_INDEX_PREFIX: &str =
1010        "/disk_index_search/disk_index_sift_learn_R4_L50_A1.2_truth_search";
1011    const TEST_PQ_PIVOT: &str =
1012        "/disk_index_search/disk_index_sift_learn_R4_L50_A1.2_truth_search_pq_pivots.bin";
1013    const TEST_PQ_COMPRESSED: &str =
1014        "/disk_index_search/disk_index_sift_learn_R4_L50_A1.2_truth_search_pq_compressed.bin";
1015
1016    #[test]
1017    fn test_disk_search_k10_l20_single_or_multi_thread_100dim() {
1018        let storage_provider = Arc::new(VirtualStorageProvider::new_overlay(test_data_root()));
1019
1020        let search_engine = create_disk_index_searcher(
1021            CreateDiskIndexSearcherParams {
1022                max_thread_num: 5,
1023                pq_pivot_file_path: TEST_PQ_PIVOT_100DIM,
1024                pq_compressed_file_path: TEST_PQ_COMPRESSED_100DIM,
1025                index_path: TEST_INDEX_100DIM,
1026                index_path_prefix: TEST_INDEX_PREFIX_100DIM,
1027                ..Default::default()
1028            },
1029            &storage_provider,
1030        );
1031        // Test single thread.
1032        test_disk_search(TestDiskSearchParams {
1033            storage_provider: storage_provider.as_ref(),
1034            index_search_engine: &search_engine,
1035            thread_num: 1,
1036            query_file_path: TEST_QUERY_10PTS_100DIM,
1037            truth_result_file_path: TEST_TRUTH_RESULT_10PTS_100DIM,
1038            k: 10,
1039            l: 20,
1040        });
1041        // Test multi thread.
1042        test_disk_search(TestDiskSearchParams {
1043            storage_provider: storage_provider.as_ref(),
1044            index_search_engine: &search_engine,
1045            thread_num: 5,
1046            query_file_path: TEST_QUERY_10PTS_100DIM,
1047            truth_result_file_path: TEST_TRUTH_RESULT_10PTS_100DIM,
1048            k: 10,
1049            l: 20,
1050        });
1051    }
1052
1053    #[test]
1054    fn test_disk_search_k10_l20_single_or_multi_thread_128dim() {
1055        let storage_provider = Arc::new(VirtualStorageProvider::new_overlay(test_data_root()));
1056
1057        let search_engine = create_disk_index_searcher::<GraphDataF32VectorUnitData>(
1058            CreateDiskIndexSearcherParams {
1059                max_thread_num: 5,
1060                pq_pivot_file_path: TEST_PQ_PIVOT_128DIM,
1061                pq_compressed_file_path: TEST_PQ_COMPRESSED_128DIM,
1062                index_path: TEST_INDEX_128DIM,
1063                index_path_prefix: TEST_INDEX_PREFIX_128DIM,
1064                ..Default::default()
1065            },
1066            &storage_provider,
1067        );
1068        // Test single thread.
1069        test_disk_search(TestDiskSearchParams {
1070            storage_provider: storage_provider.as_ref(),
1071            index_search_engine: &search_engine,
1072            thread_num: 1,
1073            query_file_path: TEST_QUERY_10PTS_128DIM,
1074            truth_result_file_path: TEST_TRUTH_RESULT_10PTS_128DIM,
1075            k: 10,
1076            l: 20,
1077        });
1078        // Test multi thread.
1079        test_disk_search(TestDiskSearchParams {
1080            storage_provider: storage_provider.as_ref(),
1081            index_search_engine: &search_engine,
1082            thread_num: 5,
1083            query_file_path: TEST_QUERY_10PTS_128DIM,
1084            truth_result_file_path: TEST_TRUTH_RESULT_10PTS_128DIM,
1085            k: 10,
1086            l: 20,
1087        });
1088    }
1089
1090    fn get_truth_associated_data<StorageReader: StorageReadProvider>(
1091        storage_provider: &StorageReader,
1092    ) -> Vec<u32> {
1093        const ASSOCIATED_DATA_FILE: &str = "/sift/siftsmall_learn_256pts_u32_associated_data.fbin";
1094
1095        let data =
1096            read_bin::<u32>(&mut storage_provider.open_reader(ASSOCIATED_DATA_FILE).unwrap())
1097                .unwrap();
1098        data.into_inner().into_vec()
1099    }
1100
1101    #[test]
1102    fn test_disk_search_with_associated_data_k10_l20_single_or_multi_thread_128dim() {
1103        let storage_provider = VirtualStorageProvider::new_overlay(test_data_root());
1104        let index_path_prefix = "/disk_index_search/disk_index_sift_learn_R4_L50_A1.2_test_disk_index_search_associated_data";
1105        let params = TestParams {
1106            data_path: TEST_DATA_FILE.to_string(),
1107            index_path_prefix: index_path_prefix.to_string(),
1108            associated_data_path: Some(
1109                "/sift/siftsmall_learn_256pts_u32_associated_data.fbin".to_string(),
1110            ),
1111            ..TestParams::default()
1112        };
1113        let fixture = IndexBuildFixture::new(storage_provider, params).unwrap();
1114        // Build the index with the associated data
1115        fixture.build::<GraphDataF32VectorU32Data>().unwrap();
1116        {
1117            let search_engine = create_disk_index_searcher::<GraphDataF32VectorU32Data>(
1118                CreateDiskIndexSearcherParams {
1119                    max_thread_num: 5,
1120                    pq_pivot_file_path: format!("{}_pq_pivots.bin", index_path_prefix).as_str(),
1121                    pq_compressed_file_path: format!("{}_pq_compressed.bin", index_path_prefix)
1122                        .as_str(),
1123                    index_path: format!("{}_disk.index", index_path_prefix).as_str(), //TEST_INDEX_128DIM,
1124                    index_path_prefix,
1125                    ..Default::default()
1126                },
1127                &fixture.storage_provider,
1128            );
1129
1130            // Test single thread.
1131            test_disk_search_with_associated(
1132                TestDiskSearchAssociateParams {
1133                    storage_provider: fixture.storage_provider.as_ref(),
1134                    index_search_engine: &search_engine,
1135                    thread_num: 1,
1136                    query_file_path: TEST_QUERY_10PTS_128DIM,
1137                    truth_result_file_path: TEST_TRUTH_RESULT_10PTS_128DIM,
1138                    k: 10,
1139                    l: 20,
1140                },
1141                None,
1142            );
1143
1144            // Test multi thread.
1145            test_disk_search_with_associated(
1146                TestDiskSearchAssociateParams {
1147                    storage_provider: fixture.storage_provider.as_ref(),
1148                    index_search_engine: &search_engine,
1149                    thread_num: 5,
1150                    query_file_path: TEST_QUERY_10PTS_128DIM,
1151                    truth_result_file_path: TEST_TRUTH_RESULT_10PTS_128DIM,
1152                    k: 10,
1153                    l: 20,
1154                },
1155                None,
1156            );
1157        }
1158
1159        fixture
1160            .storage_provider
1161            .delete(&format!("{}_disk.index", index_path_prefix))
1162            .expect("Failed to delete file");
1163        fixture
1164            .storage_provider
1165            .delete(&format!("{}_pq_pivots.bin", index_path_prefix))
1166            .expect("Failed to delete file");
1167        fixture
1168            .storage_provider
1169            .delete(&format!("{}_pq_compressed.bin", index_path_prefix))
1170            .expect("Failed to delete file");
1171    }
1172
1173    struct CreateDiskIndexSearcherParams<'a> {
1174        max_thread_num: usize,
1175        pq_pivot_file_path: &'a str,
1176        pq_compressed_file_path: &'a str,
1177        index_path: &'a str,
1178        index_path_prefix: &'a str,
1179        io_limit: usize,
1180    }
1181
1182    impl Default for CreateDiskIndexSearcherParams<'_> {
1183        fn default() -> Self {
1184            Self {
1185                max_thread_num: 1,
1186                pq_pivot_file_path: "",
1187                pq_compressed_file_path: "",
1188                index_path: "",
1189                index_path_prefix: "",
1190                io_limit: usize::MAX,
1191            }
1192        }
1193    }
1194
1195    fn create_disk_index_searcher<Data>(
1196        params: CreateDiskIndexSearcherParams,
1197        storage_provider: &Arc<VirtualStorageProvider<OverlayFS>>,
1198    ) -> DiskIndexSearcher<
1199        Data,
1200        DiskVertexProviderFactory<Data, VirtualAlignedReaderFactory<OverlayFS>>,
1201    >
1202    where
1203        Data: GraphDataType<VectorIdType = u32>,
1204    {
1205        assert!(params.io_limit > 0);
1206
1207        let runtime = tokio::runtime::Builder::new_multi_thread()
1208            .worker_threads(params.max_thread_num)
1209            .build()
1210            .unwrap();
1211
1212        let disk_index_reader = DiskIndexReader::new(
1213            params.pq_pivot_file_path.to_string(),
1214            params.pq_compressed_file_path.to_string(),
1215            storage_provider.as_ref(),
1216        )
1217        .unwrap();
1218
1219        let aligned_reader_factory = VirtualAlignedReaderFactory::new(
1220            get_disk_index_file(params.index_path_prefix),
1221            Arc::clone(storage_provider),
1222        );
1223        let caching_strategy = CachingStrategy::None;
1224        let vertex_provider_factory =
1225            DiskVertexProviderFactory::<Data, _>::new(aligned_reader_factory, caching_strategy)
1226                .unwrap();
1227
1228        DiskIndexSearcher::<Data, DiskVertexProviderFactory<Data, _>>::new(
1229            params.max_thread_num,
1230            params.io_limit,
1231            &disk_index_reader,
1232            vertex_provider_factory,
1233            Metric::L2,
1234            Some(runtime),
1235        )
1236        .unwrap()
1237    }
1238
1239    fn load_query_result<StorageReader: StorageReadProvider>(
1240        storage_provider: &StorageReader,
1241        query_result_path: &str,
1242    ) -> Vec<u32> {
1243        let result =
1244            read_bin::<u32>(&mut storage_provider.open_reader(query_result_path).unwrap()).unwrap();
1245        result.into_inner().into_vec()
1246    }
1247
1248    struct TestDiskSearchParams<'a, StorageType> {
1249        storage_provider: &'a StorageType,
1250        index_search_engine: &'a DiskIndexSearcher<
1251            GraphDataF32VectorUnitData,
1252            DiskVertexProviderFactory<
1253                GraphDataF32VectorUnitData,
1254                VirtualAlignedReaderFactory<OverlayFS>,
1255            >,
1256        >,
1257        thread_num: u64,
1258        query_file_path: &'a str,
1259        truth_result_file_path: &'a str,
1260        k: usize,
1261        l: usize,
1262    }
1263
1264    struct TestDiskSearchAssociateParams<'a, StorageType> {
1265        storage_provider: &'a StorageType,
1266        index_search_engine: &'a DiskIndexSearcher<
1267            GraphDataF32VectorU32Data,
1268            DiskVertexProviderFactory<
1269                GraphDataF32VectorU32Data,
1270                VirtualAlignedReaderFactory<OverlayFS>,
1271            >,
1272        >,
1273        thread_num: u64,
1274        query_file_path: &'a str,
1275        truth_result_file_path: &'a str,
1276        k: usize,
1277        l: usize,
1278    }
1279
1280    fn test_disk_search<StorageType: StorageReadProvider>(
1281        params: TestDiskSearchParams<StorageType>,
1282    ) {
1283        let queries = read_bin::<f32>(
1284            &mut params
1285                .storage_provider
1286                .open_reader(params.query_file_path)
1287                .unwrap(),
1288        )
1289        .unwrap();
1290        let truth_result =
1291            load_query_result(params.storage_provider, params.truth_result_file_path);
1292
1293        let pool = create_thread_pool(params.thread_num.into_usize()).unwrap();
1294        queries
1295            .par_row_iter()
1296            .enumerate()
1297            .for_each_in_pool(pool.as_ref(), |(i, query)| {
1298                let mut query_stats = QueryStatistics::default();
1299                let mut indices = vec![0u32; 10];
1300                let mut distances = vec![0f32; 10];
1301                let mut associated_data = vec![(); 10];
1302
1303                let result = params.index_search_engine.search_internal(
1304                    query,
1305                    params.k,
1306                    params.l as u32,
1307                    None, // beam_width
1308                    &mut query_stats,
1309                    &mut indices,
1310                    &mut distances,
1311                    &mut associated_data,
1312                    &(|_| true),
1313                    false,
1314                );
1315
1316                // Calculate the range of the truth_result for this query
1317                let truth_slice = &truth_result[i * params.k..(i + 1) * params.k];
1318
1319                assert!(result.is_ok(), "Expected search to succeed");
1320
1321                let result_unwrapped = result.unwrap();
1322                assert!(
1323                    result_unwrapped.query_statistics.total_io_operations > 0,
1324                    "Expected IO operations to be greater than 0"
1325                );
1326                assert!(
1327                    result_unwrapped.query_statistics.total_vertices_loaded > 0,
1328                    "Expected vertices loaded to be greater than 0"
1329                );
1330
1331                // Compare res with truth_slice using assert_eq!
1332                assert_eq!(
1333                    indices, truth_slice,
1334                    "Results DO NOT match with the truth result for query {}",
1335                    i
1336                );
1337            });
1338    }
1339
1340    fn test_disk_search_with_associated<StorageType: StorageReadProvider>(
1341        params: TestDiskSearchAssociateParams<StorageType>,
1342        beam_width: Option<usize>,
1343    ) {
1344        let queries = read_bin::<f32>(
1345            &mut params
1346                .storage_provider
1347                .open_reader(params.query_file_path)
1348                .unwrap(),
1349        )
1350        .unwrap();
1351        let truth_result =
1352            load_query_result(params.storage_provider, params.truth_result_file_path);
1353        let pool = create_thread_pool(params.thread_num.into_usize()).unwrap();
1354        queries
1355            .par_row_iter()
1356            .enumerate()
1357            .for_each_in_pool(pool.as_ref(), |(i, query)| {
1358                let result = params
1359                    .index_search_engine
1360                    .search(query, params.k as u32, params.l as u32, beam_width, None, false)
1361                    .unwrap();
1362                let indices: Vec<u32> = result.results.iter().map(|item| item.vertex_id).collect();
1363                let associated_data: Vec<u32> =
1364                    result.results.iter().map(|item| item.data).collect();
1365                let truth_data = get_truth_associated_data(params.storage_provider);
1366                let associated_data_truth: Vec<u32> = indices
1367                    .iter()
1368                    .map(|&vid| truth_data[vid as usize])
1369                    .collect();
1370                assert_eq!(
1371                    associated_data, associated_data_truth,
1372                    "Associated data DO NOT match with the truth result for query {}, associated_data from search: {:?}, associated_data from truth result: {:?}",
1373                    i,associated_data, associated_data_truth
1374                );
1375                let truth_slice = &truth_result[i * params.k..(i + 1) * params.k];
1376                assert_eq!(
1377                    indices, truth_slice,
1378                    "Results DO NOT match with the truth result for query {}",
1379                    i
1380                );
1381            });
1382    }
1383
1384    #[test]
1385    fn test_disk_search_invalid_input() {
1386        let storage_provider = Arc::new(VirtualStorageProvider::new_overlay(test_data_root()));
1387        let ctx = &DefaultContext;
1388
1389        let params = CreateDiskIndexSearcherParams {
1390            max_thread_num: 5,
1391            pq_pivot_file_path: TEST_PQ_PIVOT_128DIM,
1392            pq_compressed_file_path: TEST_PQ_COMPRESSED_128DIM,
1393            index_path: TEST_INDEX_128DIM,
1394            index_path_prefix: TEST_INDEX_PREFIX_128DIM,
1395            ..Default::default()
1396        };
1397
1398        let paths = PQPathNames::for_disk_index(TEST_INDEX_PREFIX_128DIM);
1399        assert_eq!(
1400            paths.pivots, params.pq_pivot_file_path,
1401            "pq_pivot_file_path is not correct"
1402        );
1403        assert_eq!(
1404            paths.compressed_data, params.pq_compressed_file_path,
1405            "pq_compressed_file_path is not correct"
1406        );
1407        assert_eq!(
1408            params.index_path,
1409            format!("{}_disk.index", params.index_path_prefix),
1410            "index_path is not correct"
1411        );
1412
1413        // Test error case: l < k
1414        let res = Knn::new_default(20, 10);
1415        assert!(res.is_err());
1416        assert_eq!(
1417            <KnnSearchError as std::convert::Into<ANNError>>::into(res.unwrap_err()).kind(),
1418            ANNErrorKind::IndexError
1419        );
1420        // Test error case: beam_width = 0
1421        let res = Knn::new(10, 10, Some(0));
1422        assert!(res.is_err());
1423
1424        let search_engine =
1425            create_disk_index_searcher::<GraphDataF32VectorU32Data>(params, &storage_provider);
1426
1427        // minor validation tests to improve code coverage
1428        assert_eq!(
1429            search_engine
1430                .index
1431                .data_provider
1432                .to_external_id(ctx, 0)
1433                .unwrap(),
1434            0
1435        );
1436        assert_eq!(
1437            search_engine
1438                .index
1439                .data_provider
1440                .to_internal_id(ctx, &0)
1441                .unwrap(),
1442            0
1443        );
1444
1445        let provider_max_degree = search_engine
1446            .index
1447            .data_provider
1448            .graph_header
1449            .max_degree::<<GraphDataF32VectorU32Data as GraphDataType>::VectorDataType>()
1450            .unwrap();
1451        let index_max_degree = search_engine.index.config.pruned_degree().get();
1452        assert_eq!(provider_max_degree, index_max_degree);
1453
1454        let query = vec![0f32; 128];
1455        let mut query_stats = QueryStatistics::default();
1456        let mut indices = vec![0u32; 10];
1457        let mut distances = vec![0f32; 10];
1458        let mut associated_data = vec![0u32; 10];
1459
1460        // Set L: {} to a value of at least K:
1461        let result = search_engine.search_internal(
1462            &query,
1463            10,
1464            10 - 1,
1465            None,
1466            &mut query_stats,
1467            &mut indices,
1468            &mut distances,
1469            &mut associated_data,
1470            &|_| true,
1471            false,
1472        );
1473
1474        assert!(result.is_err());
1475        assert_eq!(result.unwrap_err().kind(), ANNErrorKind::IndexError);
1476    }
1477
1478    #[test]
1479    fn test_disk_search_beam_search() {
1480        let storage_provider = Arc::new(VirtualStorageProvider::new_overlay(test_data_root()));
1481
1482        let search_engine = create_disk_index_searcher::<GraphDataF32VectorUnitData>(
1483            CreateDiskIndexSearcherParams {
1484                max_thread_num: 1,
1485                pq_pivot_file_path: TEST_PQ_PIVOT,
1486                pq_compressed_file_path: TEST_PQ_COMPRESSED,
1487                index_path: TEST_INDEX,
1488                index_path_prefix: TEST_INDEX_PREFIX,
1489                ..Default::default()
1490            },
1491            &storage_provider,
1492        );
1493
1494        let query_vector: [f32; 128] = [1f32; 128];
1495        let mut indices = vec![0u32; 10];
1496        let mut distances = vec![0f32; 10];
1497        let mut associated_data = vec![(); 10];
1498
1499        let mut result_output_buffer = search_output_buffer::IdDistanceAssociatedData::new(
1500            &mut indices,
1501            &mut distances,
1502            &mut associated_data,
1503        );
1504        let strategy = search_engine.search_strategy(&|_| true);
1505        let mut search_record = VisitedSearchRecord::new(0);
1506        let search_params = Knn::new(10, 10, Some(4)).unwrap();
1507        let recorded_search =
1508            diskann::graph::search::RecordedKnn::new(search_params, &mut search_record);
1509        search_engine
1510            .runtime
1511            .block_on(search_engine.index.search(
1512                recorded_search,
1513                &strategy,
1514                &DefaultContext,
1515                query_vector.as_slice(),
1516                &mut result_output_buffer,
1517            ))
1518            .unwrap();
1519
1520        let ids = search_record
1521            .visited
1522            .iter()
1523            .map(|n| n.id)
1524            .collect::<Vec<_>>();
1525
1526        const EXPECTED_NODES: [u32; 18] = [
1527            72, 118, 108, 86, 84, 152, 170, 82, 114, 87, 207, 176, 79, 153, 67, 165, 141, 180,
1528        ]; //Expected nodes for query = [1f32; 128] with beam_width=4
1529
1530        assert_eq!(ids, &EXPECTED_NODES);
1531
1532        let return_list_size = 10;
1533        let search_list_size = 10;
1534        let result = search_engine.search(
1535            &query_vector,
1536            return_list_size,
1537            search_list_size,
1538            Some(4),
1539            None,
1540            false,
1541        );
1542        assert!(result.is_ok(), "Expected search to succeed");
1543        let search_result = result.unwrap();
1544        assert_eq!(
1545            search_result.results.len() as u32,
1546            return_list_size,
1547            "Expected result count to match"
1548        );
1549        assert_eq!(
1550            indices,
1551            vec![152, 72, 170, 118, 87, 165, 79, 141, 108, 86],
1552            "Expected indices to match"
1553        );
1554    }
1555
1556    #[cfg(feature = "experimental_diversity_search")]
1557    #[test]
1558    fn test_disk_search_diversity_search() {
1559        use diskann::graph::DiverseSearchParams;
1560        use diskann::neighbor::AttributeValueProvider;
1561        use std::collections::HashMap;
1562
1563        // Simple test attribute provider
1564        #[derive(Debug, Clone)]
1565        struct TestAttributeProvider {
1566            attributes: HashMap<u32, u32>,
1567        }
1568        impl TestAttributeProvider {
1569            fn new() -> Self {
1570                Self {
1571                    attributes: HashMap::new(),
1572                }
1573            }
1574            fn insert(&mut self, id: u32, attribute: u32) {
1575                self.attributes.insert(id, attribute);
1576            }
1577        }
1578        impl diskann::provider::HasId for TestAttributeProvider {
1579            type Id = u32;
1580        }
1581
1582        impl AttributeValueProvider for TestAttributeProvider {
1583            type Value = u32;
1584
1585            fn get(&self, id: Self::Id) -> Option<Self::Value> {
1586                self.attributes.get(&id).copied()
1587            }
1588        }
1589
1590        let storage_provider = Arc::new(VirtualStorageProvider::new_overlay(test_data_root()));
1591
1592        let search_engine = create_disk_index_searcher::<GraphDataF32VectorUnitData>(
1593            CreateDiskIndexSearcherParams {
1594                max_thread_num: 1,
1595                pq_pivot_file_path: TEST_PQ_PIVOT,
1596                pq_compressed_file_path: TEST_PQ_COMPRESSED,
1597                index_path: TEST_INDEX,
1598                index_path_prefix: TEST_INDEX_PREFIX,
1599                ..Default::default()
1600            },
1601            &storage_provider,
1602        );
1603
1604        let query_vector: [f32; 128] = [1f32; 128];
1605
1606        // Create attribute provider with random labels (1 to 3) for all vectors
1607        let mut attribute_provider = TestAttributeProvider::new();
1608        let num_vectors = 256; // Number of vectors in the test dataset
1609        for i in 0..num_vectors {
1610            // Assign labels 1-3 based on modulo to ensure distribution
1611            let label = (i % 15) + 1;
1612            attribute_provider.insert(i, label);
1613        }
1614        // Wrap in Arc once to avoid cloning the HashMap later
1615        let attribute_provider = std::sync::Arc::new(attribute_provider);
1616
1617        let mut indices = vec![0u32; 10];
1618        let mut distances = vec![0f32; 10];
1619        let mut associated_data = vec![(); 10];
1620
1621        let mut result_output_buffer = search_output_buffer::IdDistanceAssociatedData::new(
1622            &mut indices,
1623            &mut distances,
1624            &mut associated_data,
1625        );
1626        let strategy = search_engine.search_strategy(&|_| true);
1627
1628        // Create diverse search parameters with attribute provider
1629        let diverse_params = DiverseSearchParams::new(
1630            0, // diverse_attribute_id
1631            3, // diverse_results_k
1632            attribute_provider.clone(),
1633        );
1634
1635        let search_params = Knn::new(10, 20, None).unwrap();
1636
1637        let diverse_search = diskann::graph::search::Diverse::new(search_params, diverse_params);
1638        let stats = search_engine
1639            .runtime
1640            .block_on(search_engine.index.search(
1641                diverse_search,
1642                &strategy,
1643                &DefaultContext,
1644                query_vector.as_slice(),
1645                &mut result_output_buffer,
1646            ))
1647            .unwrap();
1648
1649        // Verify that search was performed and returned some results
1650        assert!(
1651            stats.result_count > 0,
1652            "Expected to get some results during diversity search"
1653        );
1654
1655        let return_list_size = 10;
1656        let search_list_size = 20;
1657        let diverse_results_k = 1;
1658        let diverse_params = DiverseSearchParams::new(
1659            0, // diverse_attribute_id
1660            diverse_results_k,
1661            attribute_provider.clone(),
1662        );
1663
1664        // Test diverse search using the search API
1665        let mut indices2 = vec![0u32; return_list_size as usize];
1666        let mut distances2 = vec![0f32; return_list_size as usize];
1667        let mut associated_data2 = vec![(); return_list_size as usize];
1668        let mut result_output_buffer2 = search_output_buffer::IdDistanceAssociatedData::new(
1669            &mut indices2,
1670            &mut distances2,
1671            &mut associated_data2,
1672        );
1673        let strategy2 = search_engine.search_strategy(&|_| true);
1674        let search_params2 =
1675            Knn::new(return_list_size as usize, search_list_size as usize, None).unwrap();
1676
1677        let diverse_search2 = diskann::graph::search::Diverse::new(search_params2, diverse_params);
1678        let stats = search_engine
1679            .runtime
1680            .block_on(search_engine.index.search(
1681                diverse_search2,
1682                &strategy2,
1683                &DefaultContext,
1684                query_vector.as_slice(),
1685                &mut result_output_buffer2,
1686            ))
1687            .unwrap();
1688
1689        // Verify results
1690        assert!(
1691            stats.result_count > 0,
1692            "Expected diversity search to return results"
1693        );
1694        assert!(
1695            stats.result_count <= return_list_size,
1696            "Expected result count to be <= {}",
1697            return_list_size
1698        );
1699
1700        // Verify that we got some results
1701        assert!(
1702            stats.result_count > 0,
1703            "Expected to get some search results"
1704        );
1705
1706        // Print search results with their attributes
1707        println!("\n=== Diversity Search Results ===");
1708        println!("Query: [1f32; 128]");
1709        println!("diverse_results_k: {}", diverse_results_k);
1710        println!("Total results: {}\n", stats.result_count);
1711        println!("{:<10} {:<15} {:<10}", "Vertex ID", "Distance", "Label");
1712        println!("{}", "-".repeat(35));
1713        for i in 0..stats.result_count as usize {
1714            let attribute_value = attribute_provider.get(indices2[i]).unwrap_or(0);
1715            println!(
1716                "{:<10} {:<15.2} {:<10}",
1717                indices2[i], distances2[i], attribute_value
1718            );
1719        }
1720
1721        // Verify that distances are non-negative and sorted
1722        for i in 0..(stats.result_count as usize).saturating_sub(1) {
1723            assert!(distances2[i] >= 0.0, "Expected non-negative distance");
1724            assert!(
1725                distances2[i] <= distances2[i + 1],
1726                "Expected distances to be sorted in ascending order"
1727            );
1728        }
1729
1730        // Verify diversity: Check that we have diverse attribute values in the results
1731        let mut attribute_counts = HashMap::new();
1732        for item in indices2.iter().take(stats.result_count as usize) {
1733            if let Some(attribute_value) = attribute_provider.get(*item) {
1734                *attribute_counts.entry(attribute_value).or_insert(0) += 1;
1735            }
1736        }
1737
1738        // Print attribute distribution
1739        println!("\n=== Attribute Distribution ===");
1740        let mut sorted_attrs: Vec<_> = attribute_counts.iter().collect();
1741        sorted_attrs.sort_by_key(|(k, _)| *k);
1742        for (attribute_value, count) in &sorted_attrs {
1743            println!(
1744                "Label {}: {} occurrences (max allowed: {})",
1745                attribute_value, count, diverse_results_k
1746            );
1747        }
1748        println!("Total unique labels: {}", attribute_counts.len());
1749        println!("================================\n");
1750
1751        // With diverse_results_k = 5, we expect at most 5 results per attribute value
1752        for (attribute_value, count) in &attribute_counts {
1753            println!(
1754                "Assert: Label {} has {} occurrences (max: {})",
1755                attribute_value, count, diverse_results_k
1756            );
1757            assert!(
1758                *count <= diverse_results_k,
1759                "Attribute value {} appears {} times, which exceeds diverse_results_k of {}",
1760                attribute_value,
1761                count,
1762                diverse_results_k
1763            );
1764        }
1765
1766        // Verify that we have multiple different attribute values (diversity)
1767        // With 3 possible labels and diverse_results_k=5, we should see at least 2 different labels
1768        println!(
1769            "Assert: Found {} unique labels (expected at least 2)",
1770            attribute_counts.len()
1771        );
1772        assert!(
1773            attribute_counts.len() >= 2,
1774            "Expected at least 2 different attribute values for diversity, got {}",
1775            attribute_counts.len()
1776        );
1777    }
1778
1779    #[rstest]
1780    // This case checks expected behavior of unfiltered search.
1781    #[case(
1782        |_id: &u32| true,
1783        false,
1784        10,
1785        vec![152, 118, 72, 170, 87, 141, 79, 207, 124, 86],
1786        vec![256101.7, 256675.3, 256709.69, 256712.5, 256760.08, 256958.5, 257006.1, 257025.7, 257105.67, 257107.67],
1787    )]
1788    // This case validates post-filtering using 2 ids which are not present in the unfiltered result set.
1789    // It is expected that the post-filtering will return an empty result
1790    #[case(
1791        |id: &u32| *id == 0 || *id == 1,
1792        false,
1793        0,
1794        vec![0; 10],
1795        vec![0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
1796    )]
1797    // This case validates pre-filtering using 2 ids which are not present in the unfiltered result set.
1798    // It is expected that the pre-filtering will do search over matching ids
1799    #[case(
1800        |id: &u32| *id == 0 || *id == 1,
1801        true,
1802        2,
1803        vec![1, 0, 0, 0, 0, 0, 0, 0, 0, 0],
1804        vec![257247.28, 258179.28, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
1805    )]
1806    // This case validates post-filtering using 3 ids from the unfiltered result set.
1807    // It is expected that the post-filtering will filter out non-matching ids
1808    #[case(
1809        |id: &u32| *id == 72 || *id == 87 || *id == 170,
1810        false,
1811        3,
1812        vec![72, 170, 87, 0, 0, 0, 0, 0, 0, 0],
1813        vec![256709.69, 256712.5, 256760.08, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
1814    )]
1815    // This case validates pre-filtering using 3 ids from the unfiltered result set.
1816    // It is expected that the pre-filtering will do search over matching ids
1817    #[case(
1818        |id: &u32| *id == 72 || *id == 87 || *id == 170,
1819        true,
1820        3,
1821        vec![72, 170, 87, 0, 0, 0, 0, 0, 0, 0],
1822        vec![256709.69, 256712.5, 256760.08, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
1823    )]
1824    fn test_search_with_vector_filter(
1825        #[case] vector_filter: fn(&u32) -> bool,
1826        #[case] is_flat_search: bool,
1827        #[case] expected_result_count: u32,
1828        #[case] expected_indices: Vec<u32>,
1829        #[case] expected_distances: Vec<f32>,
1830    ) {
1831        // Exact distances can vary slightly depending on the architecture used
1832        // to compute distances due to different unrolling strategies and SIMD widthd.
1833        //
1834        // This parameter allows for a small margin when matching distances.
1835        let check_distances = |got: &[f32], expected: &[f32]| -> bool {
1836            const ABS_TOLERANCE: f32 = 0.02;
1837            assert_eq!(got.len(), expected.len());
1838            for (i, (g, e)) in std::iter::zip(got.iter(), expected.iter()).enumerate() {
1839                if (g - e).abs() > ABS_TOLERANCE {
1840                    panic!(
1841                        "distances differ at position {} by more than {}\n\n\
1842                         got: {:?}\nexpected: {:?}",
1843                        i, ABS_TOLERANCE, got, expected,
1844                    );
1845                }
1846            }
1847            true
1848        };
1849
1850        let storage_provider = Arc::new(VirtualStorageProvider::new_overlay(test_data_root()));
1851
1852        let search_engine = create_disk_index_searcher::<GraphDataF32VectorUnitData>(
1853            CreateDiskIndexSearcherParams {
1854                max_thread_num: 5,
1855                pq_pivot_file_path: TEST_PQ_PIVOT_128DIM,
1856                pq_compressed_file_path: TEST_PQ_COMPRESSED_128DIM,
1857                index_path: TEST_INDEX_128DIM,
1858                index_path_prefix: TEST_INDEX_PREFIX_128DIM,
1859                ..Default::default()
1860            },
1861            &storage_provider,
1862        );
1863        let query = vec![0.1f32; 128];
1864        let mut query_stats = QueryStatistics::default();
1865        let mut indices = vec![0u32; 10];
1866        let mut distances = vec![0f32; 10];
1867        let mut associated_data = vec![(); 10];
1868
1869        let result = search_engine.search_internal(
1870            &query,
1871            10,
1872            10,
1873            None, // beam_width
1874            &mut query_stats,
1875            &mut indices,
1876            &mut distances,
1877            &mut associated_data,
1878            &vector_filter,
1879            is_flat_search,
1880        );
1881
1882        assert!(result.is_ok(), "Expected search to succeed");
1883        assert_eq!(
1884            result.unwrap().result_count,
1885            expected_result_count,
1886            "Expected result count to match"
1887        );
1888        assert_eq!(indices, expected_indices, "Expected indices to match");
1889        assert!(
1890            check_distances(&distances, &expected_distances),
1891            "Expected distances to match"
1892        );
1893
1894        let result_with_filter = search_engine.search(
1895            &query,
1896            10,
1897            10,
1898            None, // beam_width
1899            Some(Box::new(vector_filter)),
1900            is_flat_search,
1901        );
1902
1903        assert!(result_with_filter.is_ok(), "Expected search to succeed");
1904        let result_with_filter_unwrapped = result_with_filter.unwrap();
1905        assert_eq!(
1906            result_with_filter_unwrapped.stats.result_count, expected_result_count,
1907            "Expected result count to match"
1908        );
1909        let actual_indices = result_with_filter_unwrapped
1910            .results
1911            .iter()
1912            .map(|x| x.vertex_id)
1913            .collect::<Vec<_>>();
1914        assert_eq!(
1915            actual_indices, expected_indices,
1916            "Expected indices to match"
1917        );
1918        let actual_distances = result_with_filter_unwrapped
1919            .results
1920            .iter()
1921            .map(|x| x.distance)
1922            .collect::<Vec<_>>();
1923        assert!(
1924            check_distances(&actual_distances, &expected_distances),
1925            "Expected distances to match"
1926        );
1927    }
1928
1929    #[test]
1930    fn test_beam_search_respects_io_limit() {
1931        let io_limit = 11; // Set a small IO limit for testing
1932        let storage_provider = Arc::new(VirtualStorageProvider::new_overlay(test_data_root()));
1933
1934        let search_engine = create_disk_index_searcher::<GraphDataF32VectorUnitData>(
1935            CreateDiskIndexSearcherParams {
1936                max_thread_num: 1,
1937                pq_pivot_file_path: TEST_PQ_PIVOT,
1938                pq_compressed_file_path: TEST_PQ_COMPRESSED,
1939                index_path: TEST_INDEX,
1940                index_path_prefix: TEST_INDEX_PREFIX,
1941                io_limit,
1942            },
1943            &storage_provider,
1944        );
1945        let query_vector: [f32; 128] = [1f32; 128];
1946
1947        let mut indices = vec![0u32; 10];
1948        let mut distances = vec![0f32; 10];
1949        let mut associated_data = vec![(); 10];
1950
1951        let mut result_output_buffer = search_output_buffer::IdDistanceAssociatedData::new(
1952            &mut indices,
1953            &mut distances,
1954            &mut associated_data,
1955        );
1956
1957        let strategy = search_engine.search_strategy(&|_| true);
1958
1959        let mut search_record = VisitedSearchRecord::new(0);
1960        let search_params = Knn::new(10, 10, Some(4)).unwrap();
1961        let recorded_search =
1962            diskann::graph::search::RecordedKnn::new(search_params, &mut search_record);
1963        search_engine
1964            .runtime
1965            .block_on(search_engine.index.search(
1966                recorded_search,
1967                &strategy,
1968                &DefaultContext,
1969                query_vector.as_slice(),
1970                &mut result_output_buffer,
1971            ))
1972            .unwrap();
1973        let visited_ids = search_record
1974            .visited
1975            .iter()
1976            .map(|n| n.id)
1977            .collect::<Vec<_>>();
1978
1979        let query_stats = strategy.io_tracker;
1980        //Verify the IO limit was respected
1981        assert!(
1982            query_stats.io_count() <= io_limit,
1983            "Expected IO operations to be <= {}, but got {}",
1984            io_limit,
1985            query_stats.io_count()
1986        );
1987
1988        const EXPECTED_NODES: [u32; 17] = [
1989            72, 118, 108, 86, 84, 152, 170, 82, 114, 87, 207, 176, 79, 153, 67, 165, 141,
1990        ]; //Expected nodes for query = [1f32; 128] with beam_width=4
1991
1992        // Count matching results
1993        let mut matching_count = 0;
1994        for expected_node in EXPECTED_NODES.iter() {
1995            if visited_ids.contains(expected_node) {
1996                matching_count += 1;
1997            }
1998        }
1999
2000        // Calculate recall
2001        let recall = (matching_count as f32 / EXPECTED_NODES.len() as f32) * 100.0;
2002
2003        //Verify the recall is above 60%. The threshold her eis arbitrary, just to make sure when
2004        // search hits io_limit that it doesn't break and the recall degrades gracefully
2005        assert!(recall >= 60.0, "Match percentage is below 60%: {}", recall);
2006    }
2007}