[RFC] Flat Index Search#983
Conversation
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #983 +/- ##
==========================================
- Coverage 89.43% 89.35% -0.09%
==========================================
Files 449 452 +3
Lines 83779 83853 +74
==========================================
- Hits 74928 74923 -5
- Misses 8851 8930 +79
Flags with carried forward coverage won't be shown. Click here to find out more.
🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Pull request overview
This PR introduces an RFC plus an initial “flat” (sequential scan) search surface in diskann, analogous to the existing graph/random-access search pipeline built around DataProvider/Accessor.
Changes:
- Added an RFC describing the flat iterator/strategy/index abstraction and trade-offs.
- Added a new
diskann::flatmodule withFlatIterator,FlatSearchStrategy,FlatIndex::knn_search, andFlatPostProcess(+CopyFlatIds). - Exported the new
flatmodule from the crate root.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
| rfcs/00983-flat-search.md | RFC describing the design for sequential (“flat”) index search APIs. |
| diskann/src/lib.rs | Exposes the new flat module publicly. |
| diskann/src/flat/mod.rs | New module root + re-exports for the flat search surface. |
| diskann/src/flat/iterator.rs | Defines the async lending iterator primitive FlatIterator. |
| diskann/src/flat/strategy.rs | Defines FlatSearchStrategy to create per-query iterators and query computers. |
| diskann/src/flat/index.rs | Implements FlatIndex and the brute-force knn_search scan algorithm. |
| diskann/src/flat/post_process.rs | Defines FlatPostProcess and a basic CopyFlatIds post-processor. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| * Licensed under the MIT license. | ||
| */ | ||
|
|
||
| //! [`FlatIndex`] — the index wrapper for an on which we do flat search. |
There was a problem hiding this comment.
Doc comment grammar issue: "the index wrapper for an on which we do flat search" is missing a noun (e.g., "for an index" / "around a provider"). Please fix to avoid confusing rustdoc output.
| //! [`FlatIndex`] — the index wrapper for an on which we do flat search. | |
| //! [`FlatIndex`] — the index wrapper around a [`DataProvider`] on which we do flat search. |
| S: FlatIterator, | ||
| T: ?Sized, | ||
| { | ||
| type Error = crate::error::Infallible; |
There was a problem hiding this comment.
CopyFlatIds uses crate::error::Infallible, but the analogous graph::glue::CopyIds uses std::convert::Infallible (graph/glue.rs:417). Using the std type here too would improve consistency and reduce cognitive overhead for readers comparing the two pipelines.
| type Error = crate::error::Infallible; | |
| type Error = std::convert::Infallible; |
| ```rust | ||
| pub struct FlatIndex<P: DataProvider> { | ||
| provider: P, | ||
| /* private */ | ||
| } | ||
|
|
||
| impl<P: DataProvider> FlatIndex<P> { | ||
| pub fn new(provider: P) -> Self; | ||
| pub fn provider(&self) -> &P; | ||
|
|
||
| pub fn knn_search<S, T, O, OB>( | ||
| &self, | ||
| k: NonZeroUsize, | ||
| strategy: &S, | ||
| processor: &PP, | ||
| context: &P::Context, | ||
| query: &T, | ||
| output: &mut OB, | ||
| ) -> impl SendFuture<ANNResult<SearchStats>> | ||
| where | ||
| S: FlatSearchStrategy<P, T>, | ||
| T: ?Sized + Sync, | ||
| O: Send, | ||
| OB: SearchOutputBuffer<O> + Send + ?Sized, | ||
| } |
There was a problem hiding this comment.
The RFC FlatIndex::knn_search signature uses processor: &PP but PP is not declared in the generic parameter list, and the struct shows provider as private while the implementation has it as a public field. Please align the RFC snippet with the actual API so the rendered RFC stays accurate.
| /// - [`Self::build_query_computer`] is iterator-independent — the same query can be | ||
| /// pre-processed once and used against multiple iterators. | ||
| /// | ||
| /// Both methods may borrow from the strategy itself. |
There was a problem hiding this comment.
The doc comment says "Both methods may borrow from the strategy itself", but QueryComputer is bounded by + 'static, so build_query_computer cannot return a computer that borrows from self. Consider rewording to clarify that create_iter may borrow, while the query computer must own its state (or is otherwise 'static).
| /// Both methods may borrow from the strategy itself. | |
| /// [`Self::create_iter`] may return an iterator that borrows from the strategy itself | |
| /// and the provider. [`Self::build_query_computer`] may use the strategy while | |
| /// constructing the query computer, but the returned [`Self::QueryComputer`] must own | |
| /// its state or otherwise satisfy `'static`. |
|
|
||
| iter.on_elements_unordered(|id, element| { | ||
| let dist = computer.evaluate_similarity(element); | ||
| cmps += 1; |
There was a problem hiding this comment.
cmps is counted with a u32 and incremented once per scanned element. In flat search a full scan could exceed u32::MAX, which will panic on overflow in debug builds and wrap in release, producing invalid stats. Consider using saturating arithmetic (cap at u32::MAX) or switching the counter to a wider type before converting to SearchStats.
| cmps += 1; | |
| cmps = cmps.saturating_add(1); |
| }) | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
New behavior (FlatIndex::knn_search) is introduced without tests. Given the repo has unit tests for graph search and output buffers, it would be good to add at least one test covering: (1) correct top-k ordering, (2) that CopyFlatIds writes expected (id, distance) pairs, and (3) that SearchStats { cmps, result_count } are consistent for a tiny in-memory iterator.
| } | |
| } | |
| #[cfg(test)] | |
| mod tests { | |
| use super::*; | |
| fn run_tiny_flat_scan( | |
| k: usize, | |
| items: &[(u32, f32)], | |
| ) -> (Vec<(u32, f32)>, SearchStats) { | |
| let mut queue = NeighborPriorityQueue::new(k); | |
| let mut cmps = 0u32; | |
| for (id, distance) in items.iter().copied() { | |
| cmps += 1; | |
| queue.insert(Neighbor::new(id, distance)); | |
| } | |
| let copied: Vec<(u32, f32)> = queue | |
| .iter() | |
| .take(k) | |
| .map(|neighbor| (neighbor.id, neighbor.distance)) | |
| .collect(); | |
| let stats = SearchStats { | |
| cmps, | |
| hops: 0, | |
| result_count: copied.len() as u32, | |
| range_search_second_round: false, | |
| }; | |
| (copied, stats) | |
| } | |
| #[test] | |
| fn knn_search_keeps_top_k_in_distance_order() { | |
| let (copied, stats) = run_tiny_flat_scan( | |
| 3, | |
| &[(10, 4.0), (11, 1.5), (12, 3.0), (13, 0.5), (14, 2.0)], | |
| ); | |
| assert_eq!(copied, vec![(13, 0.5), (11, 1.5), (14, 2.0)]); | |
| assert_eq!(stats.result_count, 3); | |
| } | |
| #[test] | |
| fn copied_flat_ids_match_expected_id_distance_pairs() { | |
| let (copied, _) = run_tiny_flat_scan(2, &[(21, 9.0), (22, 1.25), (23, 4.5)]); | |
| assert_eq!(copied, vec![(22, 1.25), (23, 4.5)]); | |
| } | |
| #[test] | |
| fn search_stats_are_consistent_for_tiny_in_memory_scan() { | |
| let items = &[(31, 7.0), (32, 2.0), (33, 5.0), (34, 1.0)]; | |
| let (copied, stats) = run_tiny_flat_scan(2, items); | |
| assert_eq!(stats.cmps, items.len() as u32); | |
| assert_eq!(stats.hops, 0); | |
| assert_eq!(stats.result_count, copied.len() as u32); | |
| assert!(!stats.range_search_second_round); | |
| } | |
| } |
| DiskANN today exposes a single abstraction family centered on the | ||
| [`crate::provider::Accessor`] trait. Accessors are random access by design since the graph greedy search algorithm needs to decide which ids to fetch and the accessor materializes the corresponding elements (vectors, quantized vectors and neighbor lists) on demand. This is the right contract for graph search, where neighborhood expansion is inherently random-access against the [`crate::provider::DataProvider`]. | ||
|
|
||
| A growing class of consumers diverge from our current pattern of use by accesssing their index **sequentially**. Some consumers build their index in an "append-only" fashion and require that they walk the index in a sequential, fixed order, relying on iteration position to enforce versioning / deduplication invariants. |
There was a problem hiding this comment.
Typo: "accesssing" should be "accessing".
| A growing class of consumers diverge from our current pattern of use by accesssing their index **sequentially**. Some consumers build their index in an "append-only" fashion and require that they walk the index in a sequential, fixed order, relying on iteration position to enforce versioning / deduplication invariants. | |
| A growing class of consumers diverge from our current pattern of use by accessing their index **sequentially**. Some consumers build their index in an "append-only" fashion and require that they walk the index in a sequential, fixed order, relying on iteration position to enforce versioning / deduplication invariants. |
|
|
||
| ### The glue: `FlatSearchStrategy` | ||
|
|
||
| While the `FlatIterator` is the primary object that provides access to the elements in the index for the algorithm, it is scoped to each query. We intorduce a constructor - `FlatSearchStrategy` - similar to `SearchStrategy` for `Accessor` to instantiate this object. A strategy is per-call configuration: stateless, cheap to construct, scoped to one |
There was a problem hiding this comment.
Typo: "intorduce" should be "introduce".
| While the `FlatIterator` is the primary object that provides access to the elements in the index for the algorithm, it is scoped to each query. We intorduce a constructor - `FlatSearchStrategy` - similar to `SearchStrategy` for `Accessor` to instantiate this object. A strategy is per-call configuration: stateless, cheap to construct, scoped to one | |
| While the `FlatIterator` is the primary object that provides access to the elements in the index for the algorithm, it is scoped to each query. We introduce a constructor - `FlatSearchStrategy` - similar to `SearchStrategy` for `Accessor` to instantiate this object. A strategy is per-call configuration: stateless, cheap to construct, scoped to one |
| /// provider. | ||
| type Iter<'a>: FlatIterator | ||
| where | ||
| Self: 'a, |
There was a problem hiding this comment.
The RFC FlatSearchStrategy snippet is missing the P: 'a bound for type Iter<'a>, which is needed if the iterator borrows from the provider. The implementation in diskann/src/flat/strategy.rs includes it; the RFC should match to avoid misleading implementers.
| Self: 'a, | |
| Self: 'a, | |
| P: 'a; |
This PR creates an RFC and associated initial implementation for performing search over a flat index in the repo.
Rendered file link.