Skip to content

Commit 35e21ad

Browse files
sezrubyclaude
andcommitted
feat(index): share IVF partition scans across batch vector queries
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>
1 parent f405b34 commit 35e21ad

6 files changed

Lines changed: 993 additions & 7 deletions

File tree

python/python/benchmarks/test_search.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,49 @@ def test_ann_with_refine(test_dataset, benchmark):
208208
assert result.num_rows > 0
209209

210210

211+
N_BATCH_QUERIES = 32
212+
213+
214+
@pytest.mark.benchmark(group="query_ann_batch")
215+
def test_batch_ann_search(test_dataset, benchmark):
216+
# One request carrying all query vectors: the index shares each partition's
217+
# scan across the batch (issue #6822).
218+
queries = np.random.randn(N_BATCH_QUERIES, N_DIMS).astype(np.float32)
219+
result = benchmark(
220+
test_dataset.to_table,
221+
columns=[],
222+
with_row_id=True,
223+
nearest=dict(
224+
column="vector",
225+
q=queries,
226+
k=100,
227+
nprobes=10,
228+
),
229+
)
230+
assert result.num_rows > 0
231+
232+
233+
@pytest.mark.benchmark(group="query_ann_batch")
234+
def test_repeated_single_ann_search(test_dataset, benchmark):
235+
# Baseline: the same query vectors issued one indexed search at a time.
236+
queries = np.random.randn(N_BATCH_QUERIES, N_DIMS).astype(np.float32)
237+
238+
def run():
239+
for q in queries:
240+
test_dataset.to_table(
241+
columns=[],
242+
with_row_id=True,
243+
nearest=dict(
244+
column="vector",
245+
q=q,
246+
k=100,
247+
nprobes=10,
248+
),
249+
)
250+
251+
benchmark(run)
252+
253+
211254
@pytest.mark.benchmark(group="query_ann")
212255
@pytest.mark.parametrize("selectivity", (0.25, 0.75))
213256
@pytest.mark.parametrize("prefilter", (False, True))

python/python/tests/test_vector_index.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,46 @@ def test_batch_flat_query_matches_repeated_single_queries(dataset, queries):
222222
)
223223

224224

225+
@pytest.mark.parametrize("metric", ["l2", "cosine"])
226+
@pytest.mark.parametrize("query_count", [3, 1], ids=["three_queries", "single_query"])
227+
def test_batch_indexed_query_matches_repeated_single_queries(
228+
dataset, metric, query_count
229+
):
230+
indexed = dataset.create_index(
231+
"vector",
232+
index_type="IVF_PQ",
233+
num_partitions=4,
234+
num_sub_vectors=16,
235+
metric=metric,
236+
)
237+
# Give the query vectors deliberately different magnitudes: a cosine batch
238+
# that normalized the whole concatenated key by one global norm would scale
239+
# them unequally and diverge from per-query single search.
240+
scales = np.linspace(0.1, 10.0, query_count).reshape(-1, 1)
241+
queries = (np.random.randn(query_count, 128) * scales).astype(np.float32)
242+
k = 5
243+
244+
# nprobes covers every partition so the shared-scan batch path and the
245+
# repeated single-query path search the same partitions deterministically.
246+
nearest_kwargs = {"use_index": True, "nprobes": 4}
247+
batch = indexed.to_table(
248+
columns=["id"],
249+
nearest={"column": "vector", "q": queries, "k": k, **nearest_kwargs},
250+
)
251+
252+
assert batch.column_names == ["query_index", "id", "_distance"]
253+
assert batch["query_index"].to_pylist() == sum(
254+
[[i] * k for i in range(query_count)], []
255+
)
256+
257+
_assert_batch_matches_single_queries(
258+
indexed,
259+
queries,
260+
k=k,
261+
nearest_kwargs=nearest_kwargs,
262+
)
263+
264+
225265
def _assert_batch_matches_single_queries(ds, queries, k, nearest_kwargs):
226266
batch = ds.to_table(
227267
columns=["id"],

rust/lance-index/src/vector.rs

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,54 @@ pub trait VectorIndex: Send + Sync + std::fmt::Debug + Index {
353353
)))
354354
}
355355

356+
/// Whether this index can search multiple query vectors in a single pass
357+
/// via [`VectorIndex::search_partitions_batch`], reading each partition's
358+
/// storage once and scoring every query that probes it.
359+
///
360+
/// Defaults to `false`; the batch scan planner falls back to repeated
361+
/// single-query search for indices that return `false`.
362+
fn supports_batch_partition_search(&self) -> bool {
363+
false
364+
}
365+
366+
/// Search a batch of query vectors against a shared set of partitions.
367+
///
368+
/// `query.key` holds all query vectors concatenated (length
369+
/// `query_count * dim`, where `query_count == partitions_per_query.len()`).
370+
/// `partitions_per_query[i]` / `q_c_dists_per_query[i]` are the ranked
371+
/// partition ids and query-to-centroid distances for query `i`.
372+
///
373+
/// Returns one [RecordBatch] per query (in query order) with the
374+
/// [`VECTOR_RESULT_SCHEMA`] (`_distance`, `_rowid`) and at most `query.k`
375+
/// rows each. Implementations should read each distinct partition's storage
376+
/// only once and score every query assigned to it against the loaded data.
377+
///
378+
/// The default implementation returns an error; callers must gate on
379+
/// [`VectorIndex::supports_batch_partition_search`].
380+
#[allow(clippy::too_many_arguments)]
381+
async fn search_partitions_batch(
382+
self: Arc<Self>,
383+
query: Query,
384+
partitions_per_query: Vec<Arc<UInt32Array>>,
385+
q_c_dists_per_query: Vec<Arc<Float32Array>>,
386+
pre_filter: Arc<dyn PreFilter>,
387+
metrics: Arc<dyn MetricsCollector>,
388+
) -> Result<Vec<RecordBatch>>
389+
where
390+
Self: 'static,
391+
{
392+
let _ = (
393+
query,
394+
partitions_per_query,
395+
q_c_dists_per_query,
396+
pre_filter,
397+
metrics,
398+
);
399+
Err(Error::not_supported(
400+
"batch partition search is not supported for this index",
401+
))
402+
}
403+
356404
/// If the index is loadable by IVF, so it can be a sub-index that
357405
/// is loaded on demand by IVF.
358406
fn is_loadable(&self) -> bool;

0 commit comments

Comments
 (0)