feat(index): share IVF partition scans across batch vector queries#2
Open
sezruby wants to merge 1 commit into
Open
feat(index): share IVF partition scans across batch vector queries#2sezruby wants to merge 1 commit into
sezruby wants to merge 1 commit into
Conversation
Extend batch vector search (lance-format#6821) to the indexed/ANN path so a single multi-query request reads each IVF partition's storage once and scores every query that probes it, instead of re-running a full single-query plan per vector and unioning the results (which re-opens the index and rebuilds the prefilter for each query). - Add `VectorIndex::search_partitions_batch` + `supports_batch_partition_search` (defaulted so non-IVF indices stay explicitly unsupported). - Implement them for `IVFIndex` with a flat-style sub-index (IVF_FLAT/PQ/SQ/RQ): load each distinct partition once and accumulate one top-k heap per query, sharing the prefilter across the whole batch. - Add `ANNIvfBatchExec`, which ranks every query against the centroids, runs the shared-scan batch search, merges per-query top-k across deltas, and emits `query_index`-tagged results; route to it from `Scanner::batch_indexed_vector_search` when the gate below holds. - Normalize each query vector independently for cosine (`normalize_batch_query_for_index`): normalizing the concatenated batch key with one global norm would scale each vector by a batch-composition-dependent factor and break equivalence with single-query search. The shared-scan fast path is gated to cases that are provably equivalent to repeated single-query search: fixed nprobes (`minimum_nprobes == maximum_nprobes`), no refine step, an IVF flat-style index, and fully-indexed fragments. With adaptive nprobes the single-query path applies an `early_pruning` floor and late-search expansion that the batch path does not, so those queries fall back to the per-query loop, which stays exact. HNSW, refine, and mixed indexed/unindexed scans also fall back. Tests: plan shape; exact batch-vs-repeated-single equivalence (nprobes pinned); cosine regression; shared prefilter; multi-delta cross-delta merge; and fallbacks for refine and adaptive nprobes. Python parametrized over L2 + cosine; a batch-vs-repeated-single ANN benchmark. Closes lance-format#6822 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Batch vector search (#6821, PR lance-format#6828) made indexed multi-query search work by looping the full single-query plan once per query vector (re-opening the index and rebuilding the prefilter each time) and unioning the results. This PR makes the indexed/ANN path share index-level state across the batch: it reads each IVF partition's storage once and scores every query that probes it, with the prefilter built once and shared.
Approach
VectorIndextrait (rust/lance-index/src/vector.rs): add defaultedsupports_batch_partition_search()andsearch_partitions_batch(...)(default returnsnot_supported), so non-IVF indices remain explicitly unsupported.IVFIndex(rust/lance/src/index/vector/ivf/v2.rs): implement batch search for flat-style sub-indices (IVF_FLAT/PQ/SQ/RQ, i.e.supports_global_topk_heap()). Invert per-query partition lists, load each distinct partition once, accumulate one top-k heap per query, reusingaccumulate_prepared_partition_search/global_heap_to_batch.ANNIvfBatchExec(rust/lance/src/io/exec/knn.rs): ranks every query against the centroids, runs the shared-scan batch search per delta, merges per-query top-k across deltas, emits{query_index, _distance, _rowid}sorted by(query_index, _distance, _rowid).rust/lance/src/dataset/scanner.rs): take the fast path only when it is provably equivalent to repeated single-query search (see below); otherwise fall back to the per-query loop. No behavior regression.Cosine correctness
Each query vector is normalized independently (
normalize_batch_query_for_index). Normalizing the concatenated batch key with a single global norm would scale each vector by a batch-composition-dependent factor and break equivalence with single-query search for cosine.nprobes equivalence gate (correctness, not just perf)
The shared-scan path searches exactly
minimum_nprobespartitions per query. The single-query path is adaptive: it applies a k-dependentearly_pruningfloor and then expands probes up tomaximum_nprobes(late search) when a query has fewer thankresults. These differ unless nprobes is fixed, so the fast path is gated tominimum_nprobes == maximum_nprobes(whatnprobes=Nsets) — provably identical to single-query. Adaptive nprobes falls back to the per-query loop, which reuses the real adaptive search and stays exact.Measured before gating, an unpinned batch query diverged from repeated single-query on every query vector; after gating both the pinned (fast-path) and unpinned (fallback) cases match exactly.
Alternatives considered for adaptive nprobes (deferred follow-ups)
k, loading any new partitions once. Fully general (keeps scan-sharing for adaptive nprobes) but reimplements the adaptive loop in batched form; largest surface, best saved for a follow-up.maximum_nprobes— rejected: with the defaultmaximum_nprobes = Nonethis degenerates to scanning all partitions for every query, defeating IVF pruning.Other known limitations (deferred)
HNSW batch sharing, batch
refine_factor/reranking, and batch + unindexed-fragment combine fall back to the per-query loop.Test plan
cargo test -p lance --lib test_batch_knn— 9 tests: plan shape, exact batch-vs-repeated-single equivalence (nprobes pinned so it is deterministic), cosine regression, shared prefilter, multi-delta cross-delta merge, and fallbacks for refine and adaptive nprobes.cargo test -p lance --lib dataset::scanner::test::test_knn(29) andindex::vector::ivf::v2(88) — no regressions.cargo fmt --all&&cargo clippy -p lance -p lance-index --tests --benches -- -D warnings.uv run pytest python/tests/test_vector_index.py -k batch→ 8 passed (L2 + cosine × three/single queries).ruffclean repo-wide;pyrightclean on changed lines.benchmarks/test_search.py): batch vs repeated-single ANN. Standalone timing (50k rows, dim 128, IVF_PQ 64 partitions, m=32, k=10, nprobes=10): 2.48× speedup.Closes lance-format#6822