Return native indexed vectors from disk search - #1345
Conversation
There was a problem hiding this comment.
Pull request overview
This PR extends diskann-disk’s disk-index search surface with a new API that returns only valid ANN hits, each bundled with its canonical (native) indexed vector as stored in the on-disk graph. This supports downstream consumers that need full-precision indexed vectors alongside IDs/distances, while keeping the existing search() API unchanged.
Changes:
- Added
search_with_indexed_vectors(...)plus new result types to return owned per-hit indexed vectors without padding. - Implemented request-local indexed-vector capture via an
IndexedVectorCollector, with different capture strategies per search mode and a post-rerank “missing winner” batch fetch. - Expanded tests (including builder tests) to validate result parity, ownership/capacity behavior, and stored-vector round-trip behavior (including MinMax).
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| diskann-disk/src/search/provider/disk_provider.rs | Adds the new public search API, request-local vector collector, capture logic across traversal/post-processing, and associated tests. |
| diskann-disk/src/build/builder/tests.rs | Extends disk index build tests to validate returned indexed vectors against stored rows. |
| diskann-disk/src/build/builder/core.rs | Adds a test helper that verifies indexed vectors returned by search match the stored dataset rows. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #1345 +/- ##
==========================================
+ Coverage 91.58% 91.59% +0.01%
==========================================
Files 521 521
Lines 99598 99788 +190
==========================================
+ Hits 91212 91396 +184
- Misses 8386 8392 +6
Flags with carried forward coverage won't be shown. Click here to find out more.
🚀 New features to boost your workflow:
|
| if let Some(collector) = accessor.indexed_vector_capture.collector() { | ||
| let result_limit = output.size_hint().unwrap_or(reranked.len()); | ||
| let winner_ids: Vec<_> = reranked | ||
| .iter() | ||
| .take(result_limit) | ||
| .map(|winner| winner.id().0) | ||
| .collect(); | ||
| let winner_id_set = winner_ids.iter().copied().collect(); | ||
|
|
||
| // Distance-cache residency and indexed-vector residency are independent. Retain only | ||
| // final winners, reuse vectors from the current exact-score batch, then batch-load any | ||
| // still-missing cached winners from the graph. | ||
| collector.retain(&winner_id_set)?; | ||
| let missing_ids = collector.missing(&winner_ids)?; | ||
| let uncached_id_set: HashSet<_> = uncached_ids.iter().copied().collect(); | ||
| let mut final_fetch_ids = Vec::with_capacity(missing_ids.len()); | ||
| for id in missing_ids { | ||
| if uncached_id_set.contains(&id) { | ||
| let vector = accessor.scratch.vertex_provider.get_vector(&id)?; | ||
| collector.capture_required(id, vector)?; | ||
| } else { | ||
| final_fetch_ids.push(id); | ||
| } | ||
| } | ||
|
|
||
| if !final_fetch_ids.is_empty() { | ||
| info!( | ||
| uncached_vertices = uncached_ids.len(), | ||
| final_fetch_vertices = final_fetch_ids.len(), | ||
| "Fetching missing indexed vectors after rerank" | ||
| ); | ||
| ensure_vertex_loaded(&mut accessor.scratch.vertex_provider, &final_fetch_ids)?; | ||
| for id in final_fetch_ids { | ||
| let vector = accessor.scratch.vertex_provider.get_vector(&id)?; | ||
| collector.capture_required(id, vector)?; | ||
| } | ||
| } | ||
| } | ||
|
|
There was a problem hiding this comment.
Thanks for working on this Yujie. I must admit, I am bit confused by the complexity of the approach here.
There's a few problems from what I'm seeing in the current design-
- The collector seems to be an additional abstraction just to store full precision vectors, if needed, during the traversal but the
CachedDiskVertexProvideris already doing this. - With the way the collector is implemented (i.e.
MutexLock, hash-lookup and hash-map on insertion), this seems to be pretty expensive operations in the hot-path (i.e. inensure_loaded). - We're threading the collector through the strategy and the accessor and collecting results there when the
glue::SearchPostProcessarchitecture is explicitly designed to be generic over the shape of the output. So it feels weird to me that you need to have a new structure to collect results in the accessor.
I'm curious, have we tried doing the simple thing of just loading the vertices (by calling load_vertices on the vertex provider) from the result set after the regular search path? Why can't we just do that in search_with_indexed_vectors? It seems like that should allow you to get rid of the collector completely
Let me know if I'm missing something here
|
The API and its intended behavior make sense, but I’m concerned about the complexity and performance cost of the collector design.
I also think the |
2e63be2 to
faf5b0b
Compare
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
faf5b0b to
4efb698
Compare
Summary
Adds an opt-in disk search API that returns each valid ANN result with its canonical native indexed vector:
The existing
search()API and its padded result behavior remain unchanged.Public contract
results.len() == stats.result_count.ANNError, not a panic.For reviewers
Recommended review order:
SearchResultWithIndexedVectors,SearchResultItemWithIndexedVector, andsearch_with_indexed_vectors.SearchPayloadandSearchOutputextend the existing slice-backed output without changing legacysearch().DiskAccessor::ensure_loadedoptionally stores the native vector beside the existing exact-distance cache entry.extend_outputmoves traversal-cached winner vectors into results or copies winners from the post-process batch already loaded by the existing rerank path.The intended invariants are:
vertex_idas its result.search()does not allocate or copy indexed vectors.Key data structures
Internal search payload
The
Optionis internal: legacy search writesNone; indexed-vector search writesSome(vector). The public indexed-vector result contains a non-optionalBox<[V]>.Output adapter
SearchOutputkeeps the existing ID/distance/associated-data buffers and adds an optional vector lane. Onepushwrites all fields at the same position, preserving ID/vector alignment without replacing the existing search pipeline.Request-local traversal cache
The map already existed for exact distance and associated data. When indexed vectors are requested, the same entry also owns the native vector loaded during traversal. There is no collector, mutex, or second hash map.
Data flow
extend_outputnever callsload_vertices, so it does not introduce a final fallback I/O pass.Validation
cargo fmt --all -- --checkcargo check -p diskann-disk --all-targets --all-featuresCachingStrategy::Noneand static caching, unchanged IDs/distances, and returned vectors against stored rows.Closes #1339