Skip to content

feat(index): share IVF partition scans across batch vector queries#7640

Open
sezruby wants to merge 1 commit into
lance-format:mainfrom
sezruby:knn-batch-6822
Open

feat(index): share IVF partition scans across batch vector queries#7640
sezruby wants to merge 1 commit into
lance-format:mainfrom
sezruby:knn-batch-6822

Conversation

@sezruby

@sezruby sezruby commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

Closes #6822.

Rationale for this change

Batch vector search (#6821, PR #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 for every query — and unioning the results. That throws away the main opportunity of a batch: query vectors mostly probe overlapping IVF partitions, so the same partition storage is read and decoded many times. This PR shares that index-level state across the batch.

What changes are included in this PR?

The indexed/ANN path now reads each probed IVF partition's storage once and scores every query that probes it, with the prefilter built once and shared.

  • VectorIndex trait (lance-index): defaulted supports_batch_partition_search() + search_partitions_batch(...) (default returns not_supported). Additive — no breaking change.
  • IVFIndex (ivf/v2.rs): batch search for flat-style sub-indices (IVF_FLAT/PQ/SQ/RQ). Invert per-query partition lists, load each distinct partition once, accumulate one top-k heap per query, reusing accumulate_prepared_partition_search / global_heap_to_batch.
  • ANNIvfBatchExec (io/exec/knn.rs): ranks each query against the centroids, runs the shared-scan batch search per delta, merges per-query top-k across deltas, emits {query_index, _distance, _rowid}. Prefilter wiring shared with the single-query node via build_dataset_prefilter.
  • Each query vector is normalized independently for cosine (normalize_batch_query_for_index).

Fallback matrix (no regression):

Case Behavior
IVF_FLAT/PQ/SQ/RQ, fixed nprobes, fully indexed shared-scan fast path
adaptive nprobes / refine_factor / IVF_HNSW_* / mixed indexed+unindexed per-query indexed loop (exact)

Design notes (anticipating review):

  • Why a new exec node, not a mode on the existing ANN nodes? The two-node single-query pipeline streams one partition-list per delta through a per-query top-k; sharing the scan requires inverting queries onto partitions and keeping one heap per query in a single pass — a different dataflow. The new node reuses the underlying primitives (partition load, build_dataset_prefilter, the index's per-partition accumulate) and leaves the single-query nodes untouched.
  • Why gate on index-type metadata, not supports_batch_partition_search()? The gate is a planning-time decision and the single-query path likewise doesn't open the index there; derive_vector_index_type reads metadata with no I/O. The opened index re-checks the trait as a defensive invariant.
  • nprobes gate (correctness). The shared path searches exactly minimum_nprobes partitions/query, but the single-query path is adaptive (early_pruning floor + late-search expansion), so the two only match when nprobes is fixed. The fast path is gated to minimum_nprobes == maximum_nprobes; adaptive nprobes falls back to the per-query loop (verified: an unpinned batch diverged on every query before the gate, 0 divergence after). Open question: fixed-nprobes-first with batched early/late as a follow-up, or the full adaptive path in one PR?
  • Memory. Peak = the union of probed partitions held during scoring — the same buffering the existing single-query global-heap path (search_partitions) uses, widened to the batch's partition union; per-delta output is k-bounded, so cross-delta accumulation is O(deltas × k).

Are these changes tested?

Yes.

  • cargo test -p lance --lib test_batch_knn15 tests: plan shape, exact batch-vs-repeated-single equivalence (nprobes pinned), cosine regression, shared prefilter, multi-delta cross-delta merge, and explicit fallbacks for refine, adaptive nprobes, and IVF_HNSW (acceptance criterion: "unsupported index types have explicit behavior and tests").
  • cargo test -p lance --lib dataset::scanner::test::test_knn (29) — no single-query regression (exercises the shared build_dataset_prefilter).
  • cargo fmt --all && cargo clippy -p lance -p lance-index --tests --benches -- -D warnings.
  • Python: pytest -k batch (L2 + cosine × three/single queries); index-type sweep (IVF_FLAT/PQ/SQ/HNSW_PQ/HNSW_SQ) matches repeated single-query; ruff clean; pyright clean on changed lines.
  • Benchmark (benchmarks/test_search.py): batch vs repeated-single ANN; local run (50k rows, dim 128, IVF_PQ 64 partitions, m=32, k=10, nprobes=10) → ~2.5× faster (ratio; absolute ms machine-dependent).

Are there any user-facing changes?

No API changes. The batch-query surface (2-D / list query with a query_index output column) already shipped in #6828; this PR only changes how indexed batch queries execute, so Python and Java bindings are unaffected. It is a performance improvement for fixed-nprobes indexed batch search; results are identical to issuing the queries one at a time. No format or compatibility change.

@github-actions github-actions Bot added A-python Python bindings A-index Vector index, linalg, tokenizer enhancement New feature or request labels Jul 6, 2026
@codecov

codecov Bot commented Jul 6, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 90.66667% with 56 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
rust/lance/src/io/exec/knn.rs 84.64% 33 Missing and 8 partials ⚠️
rust/lance/src/dataset/scanner.rs 97.48% 2 Missing and 5 partials ⚠️
rust/lance-index/src/vector.rs 0.00% 5 Missing ⚠️
rust/lance/src/index/vector/ivf/v2.rs 94.00% 1 Missing and 2 partials ⚠️

📢 Thoughts on this report? Let us know!

@wjones127
wjones127 self-requested a review July 23, 2026 21:46
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>
@coderabbitai

coderabbitai Bot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Adds indexed ANN batch vector search with shared IVF partition reads, per-query result grouping, cosine-safe normalization, correctness-gated scanner planning, fallback coverage, equivalence tests, and batch-versus-repeated-search benchmarks.

Changes

Indexed ANN batch search

Layer / File(s) Summary
Batch search contracts
rust/lance-index/src/vector.rs
Adds optional VectorIndex batch partition-search capability with an explicit unsupported default.
IVF partition execution
rust/lance/src/index/vector/ivf/v2.rs
Implements shared partition loading and independent per-query top-k accumulation for supported IVF sub-indexes.
Batch KNN execution
rust/lance/src/io/exec/knn.rs
Adds batch execution planning, cosine query normalization, shared prefilters, candidate aggregation, and grouped output assembly.
Scanner planning and validation
rust/lance/src/dataset/scanner.rs, rust/python/tests/test_vector_index.py
Gates the shared path on index and probe conditions, validates fallbacks, and compares indexed batch results with repeated single-query results.
ANN benchmarks
python/python/benchmarks/test_search.py
Adds benchmarks for one batched ANN request and repeated single-query ANN requests.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant Scanner
  participant ANNIvfBatchExec
  participant IVFIndex
  Client->>Scanner: batch nearest query
  Scanner->>ANNIvfBatchExec: plan indexed batch search
  ANNIvfBatchExec->>IVFIndex: search multiple queries across partitions
  IVFIndex-->>ANNIvfBatchExec: per-query top-k results
  ANNIvfBatchExec-->>Scanner: grouped query_index results
  Scanner-->>Client: batch result table
Loading

Possibly related PRs

Suggested labels: performance

Suggested reviewers: wjones127, bubblecal, xuanwo

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: sharing IVF partition scans across batch vector queries.
Description check ✅ Passed The description is directly related and accurately describes the indexed batch-search changes and benchmarks.
Linked Issues check ✅ Passed The changes add shared batch IVF scans, preserve per-query semantics, add indexed equivalence tests, and include explicit fallbacks.
Out of Scope Changes check ✅ Passed All changes are aligned with indexed batch-search support, tests, fallbacks, and benchmarks; no unrelated edits stand out.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🧹 Nitpick comments (1)
python/python/tests/test_vector_index.py (1)

240-241: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Seed the RNG so equivalence failures are reproducible.

This test asserts exact id equality between the shared-scan batch path and repeated single-query search over an approximate IVF_PQ index. With unseeded np.random.randn, any tie-break or ordering discrepancy (see the HashMap scoring-order note in rust/lance/src/index/vector/ivf/v2.rs) surfaces as an intermittent failure that cannot be replayed.

♻️ Suggested change
+    rng = np.random.default_rng(42)
     scales = np.linspace(0.1, 10.0, query_count).reshape(-1, 1)
-    queries = (np.random.randn(query_count, 128) * scales).astype(np.float32)
+    queries = (rng.standard_normal((query_count, 128)) * scales).astype(np.float32)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@python/python/tests/test_vector_index.py` around lines 240 - 241, Seed
NumPy’s random number generator before the randomized query construction in the
test containing the shared-scan and repeated single-query equivalence
assertions. Use a fixed, explicit seed so failures involving exact id ordering
are deterministic and reproducible, while leaving the existing query generation
and assertions unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@rust/lance/src/dataset/scanner.rs`:
- Around line 4089-4096: Update the refine-factor gate in the batch
indexed-search path to reject any Some value, not only factors greater than one,
so refine(1) and refine(0) fall back to the per-query loop for reranking and
validation. Extend test_batch_knn_indexed_refine_falls_back to cover both refine
factors while preserving the existing refine(2) coverage.
- Around line 4137-4158: Update the batch fast-path gate in the surrounding
scanner method to fall back whenever
self.overlay_stale_vector_rows(index_segments)? is non-empty, before
constructing the batch executor. Preserve the existing batch behavior when no
stale overlay rows exist, and add coverage using an overlaid fragment that
verifies execution takes the per-query fallback path.

In `@rust/lance/src/index/vector/ivf/v2.rs`:
- Around line 1920-1938: The batch search flow around load_parallelism and the
try_collect::<Vec<_>>() currently retains every probed partition before scoring.
Replace this all-at-once collection with bounded-window processing, using
STREAMING_SEARCH_BATCH_SIZE or the existing bounded-channel pattern: load one
window, dispatch its scoring through spawn_cpu, release partition entries, then
continue until all assignments are processed while preserving result ordering
and error propagation.
- Around line 1902-1916: Update OrderedNode::cmp to use row_id as the final
comparison key after distance, ensuring equal-distance candidates are
deterministically selected and ordered. Preserve the existing heap behavior and
make the resulting global_heap_to_batch output match repeated single-query
ordering.

In `@rust/lance/src/io/exec/knn.rs`:
- Around line 2401-2411: Update the per-query batch handling around the loop
over per_query: first validate that the returned batch count matches the query
count, returning a diagnosable Internal error on mismatch before indexing
candidates. Then replace positional batch.column access with column_by_name
using the schema’s distance and row-id field names, preserving the existing
candidate extension behavior.

---

Nitpick comments:
In `@python/python/tests/test_vector_index.py`:
- Around line 240-241: Seed NumPy’s random number generator before the
randomized query construction in the test containing the shared-scan and
repeated single-query equivalence assertions. Use a fixed, explicit seed so
failures involving exact id ordering are deterministic and reproducible, while
leaving the existing query generation and assertions unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: QUIET

Plan: Pro Plus

Run ID: c6efabac-70c1-4356-8665-8f6423a9f771

📥 Commits

Reviewing files that changed from the base of the PR and between 5deddec and e0f8167.

📒 Files selected for processing (6)
  • python/python/benchmarks/test_search.py
  • python/python/tests/test_vector_index.py
  • rust/lance-index/src/vector.rs
  • rust/lance/src/dataset/scanner.rs
  • rust/lance/src/index/vector/ivf/v2.rs
  • rust/lance/src/io/exec/knn.rs

Comment on lines +4089 to +4096
if matches!(q.refine_factor, Some(rf) if rf > 1) {
return Ok(false);
}
// Only fixed nprobes is provably equivalent to single-query search; see
// the method docs. Adaptive nprobes falls back to the per-query loop.
if q.maximum_nprobes != Some(q.minimum_nprobes) {
return Ok(false);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win

The refine gate lets refine(1) and refine(0) onto the non-reranking batch path.

The doc above states the requirement as "no refine step (the batch path does not yet rerank)", but the predicate only rejects rf > 1:

  • refine(1): the single-query path still re-ranks with the original vectors (if q.refine_factor.is_some() at Line 3989, and Scanner::refine documents "even if the factor is 1, the results will still be re-ranked"). The batch path performs no rerank, so distances/order diverge from repeated single-query search on quantized indexes (IVF_PQ/SQ/RQ) — exactly what this gate exists to prevent.
  • refine(0): the single-query path rejects it with Refine factor cannot be zero (Line 3963). The batch path skips that validation and reaches the index with heap_capacity = query.k * 0, silently returning empty results instead of an error.

Gating on is_some() fixes both: zero/one refine factors fall back to the per-query loop, which reranks and validates.

test_batch_knn_indexed_refine_falls_back only exercises refine(2), so neither case is covered — worth extending it with refine(1) and refine(0).

🐛 Proposed fix
-        if matches!(q.refine_factor, Some(rf) if rf > 1) {
+        // Any refine factor (including 1) means the single-query path reranks
+        // with the original vectors, and 0 is rejected outright there; the
+        // shared-scan path does neither, so fall back.
+        if q.refine_factor.is_some() {
             return Ok(false);
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if matches!(q.refine_factor, Some(rf) if rf > 1) {
return Ok(false);
}
// Only fixed nprobes is provably equivalent to single-query search; see
// the method docs. Adaptive nprobes falls back to the per-query loop.
if q.maximum_nprobes != Some(q.minimum_nprobes) {
return Ok(false);
}
// Any refine factor (including 1) means the single-query path reranks
// with the original vectors, and 0 is rejected outright there; the
// shared-scan path does neither, so fall back.
if q.refine_factor.is_some() {
return Ok(false);
}
// Only fixed nprobes is provably equivalent to single-query search; see
// the method docs. Adaptive nprobes falls back to the per-query loop.
if q.maximum_nprobes != Some(q.minimum_nprobes) {
return Ok(false);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rust/lance/src/dataset/scanner.rs` around lines 4089 - 4096, Update the
refine-factor gate in the batch indexed-search path to reject any Some value,
not only factors greater than one, so refine(1) and refine(0) fall back to the
per-query loop for reranking and validation. Extend
test_batch_knn_indexed_refine_falls_back to cover both refine factors while
preserving the existing refine(2) coverage.

Comment on lines +4137 to +4158
// Fast path: when every index segment is an IVF index with a flat-style
// sub-index (IVF_FLAT/PQ/SQ/RQ), search all query vectors in a single
// pass that reads each partition's storage once and shares the prefilter
// across the batch. HNSW, refine, and mixed indexed/unindexed scans fall
// back to the per-query loop below, which never regresses behavior.
if self
.batch_index_search_supported(index_name, index_segments, q)
.await?
{
let mut batch_query = q.clone();
batch_query.metric_type = Some(index_metric);
let prefilter_source = self
.prefilter_source(filter_plan, self.get_indexed_frags(index_segments))
.await?;
return new_knn_batch_exec(
self.dataset.clone(),
index_segments,
&batch_query,
self.nearest_query_count,
prefilter_source,
);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Batch path skips data-overlay stale-row reconciliation.

For the per-query path, vector_search computes overlay_stale_vector_rows / stale_rows_block_mask and passes an overlay_block into the ANN node, then re-scores those rows via knn_combined (Lines 3968-4012). The batch fast path builds its prefilter without an overlay block, and ANNIvfBatchExec::execute passes None explicitly (rust/lance/src/io/exec/knn.rs Lines 2334-2336, "The batch node has no data overlay to reconcile against"). Neither batch_index_search_supported nor this method checks for overlays, so a dataset whose fragments carry overlays committed after the vector index will get stale index entries from the batch path while the single-query path masks and re-scores them.

Simplest correctness-preserving fix: make the gate fall back when self.overlay_stale_vector_rows(index_segments)? is non-empty (it short-circuits cheaply when no fragment has overlays), and add a test with an overlaid fragment asserting the fallback.

🛡️ Suggested gate addition
         if self.fast_search {
             return Ok(true);
         }
+        // Overlay-stale rows must be blocked from the index and re-scored on a
+        // flat path; the batch node does neither, so fall back when any exist.
+        if !self.overlay_stale_vector_rows(index_segments)?.is_empty() {
+            return Ok(false);
+        }
         // The batch node only searches indexed partitions, so any unindexed
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rust/lance/src/dataset/scanner.rs` around lines 4137 - 4158, Update the batch
fast-path gate in the surrounding scanner method to fall back whenever
self.overlay_stale_vector_rows(index_segments)? is non-empty, before
constructing the batch executor. Preserve the existing batch behavior when no
stale overlay rows exist, and add coverage using an overlaid fragment that
verifies execution takes the per-query fallback path.

Comment on lines +1902 to +1916
// Invert the per-query partition lists so each distinct partition is
// loaded once and scored against every query that probes it.
let mut assignments: HashMap<u32, Vec<(usize, f32)>> = HashMap::new();
for (query_index, (parts, dists)) in partitions_per_query
.iter()
.zip(q_c_dists_per_query.iter())
.enumerate()
{
for (part_id, dist_q_c) in parts.values().iter().zip(dists.values().iter()) {
assignments
.entry(*part_id)
.or_default()
.push((query_index, *dist_q_c));
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Does the global top-k heap impose a total order that includes the row id?
rg -nP -C6 'struct OrderedNode|impl .*Ord.* for OrderedNode' --type=rust
ast-grep run --pattern 'fn global_heap_to_batch($$$) { $$$ }' --lang rust rust/lance/src/index/vector/ivf/v2.rs

Repository: lance-format/lance

Length of output: 156


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== files =="
git ls-files 'rust/lance/src/index/vector/ivf/v2.rs' 'rust/lance/src/dataset/scanner.rs' | sed -n '1,20p'

echo "== outline around top of v2.rs =="
wc -l rust/lance/src/index/vector/ivf/v2.rs rust/lance/src/dataset/scanner.rs
ast-grep outline rust/lance/src/index/vector/ivf/v2.rs --length 200 | sed -n '1,200p'

echo "== search relevant terms =="
rg -n "assignments|partitions_per_query|global_heap_to_batch|TestVectorDataset|assert_batch_matches_single_queries|OrderedNode|OrderedCandidate|global_top|topk" rust/lance/src -S

echo "== lines 1860-1945 in ivf/v2.rs =="
sed -n '1860,1945p' rust/lance/src/index/vector/ivf/v2.rs

echo "== lines around scanner tests =="
rg -n "assert_batch_matches_single_queries|TestVectorDataset" rust/lance/src/dataset/scanner.rs -A 80 -B 20

Repository: lance-format/lance

Length of output: 548


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== search relevant terms =="
rg -n "assignments|partitions_per_query|global_heap_to_batch|TestVectorDataset|assert_batch_matches_single_queries|OrderedNode|OrderedCandidate|global_top|topk" rust/lance/src -S || true

echo "== lines 1860-1945 in ivf/v2.rs =="
sed -n '1860,1945p' rust/lance/src/index/vector/ivf/v2.rs

echo "== lines around scanner tests =="
rg -n "assert_batch_matches_single_queries|TestVectorDataset" rust/lance/src/dataset/scanner.rs -A 100 -B 20 || true

echo "== import headers for v2.rs =="
sed -n '1,120p' rust/lance/src/index/vector/ivf/v2.rs

Repository: lance-format/lance

Length of output: 50375


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== ivf/v2.rs relevant scoring/heap/export lines =="
sed -n '800,935p' rust/lance/src/index/vector/ivf/v2.rs
sed -n '1945,2025p' rust/lance/src/index/vector/ivf/v2.rs

echo "== OrderedNode definition and Ord impl =="
rg -n -C 12 'struct OrderedNode|BinaryHeap<OrderedNode|Ord for Ordered|impl PartialOrd|impl PartialEq' rust/lance/src -S | sed -n '1,240p'

echo "== relevant tests around batch indexed equivalence =="
sed -n '6950,7825p' rust/lance/src/dataset/scanner.rs

Repository: lance-format/lance

Length of output: 50374


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== exact OrderedNode references in graph submodule =="
rg -n -C 20 'OrderedNode' rust/lance/src/index/vector/graph.rs rust/lance/src/index/vector -S | sed -n '1,260p'

echo "== ivf.rs OrderedNode/topk area =="
sed -n '3980,4095p' rust/lance/src/index/vector/ivf.rs

echo "== Behavioral probe: Rust BinaryHeap Ord ties (if cargo availability) =="
if cargo --version >/dev/null 2>&1 && rustc --version >/dev/null 2>&1; then
  tmp="$(mktemp -d)"
  cat > "$tmp/heap_order_probe.rs" <<'RS'
fn main() {
    let mut heap = std::collections::BinaryHeap::new();
    for row_id in 0u64..10 {
        heap.push((0.0f32, row_id));
    }
    let out: Vec<_> = heap.into_iter().map(|(d, id)| format!("({}, {})", d, id)).collect();
    for (i, item) in out.iter().enumerate() {
        println!("heap_order_probe.{}. {}", i, item);
    }
}
RS
  cargo run --manifest-path "$tmp/HeapOrderProbeCargo.toml" 2>/dev/null || (
    cat > "$tmp/Cargo.toml" <<'TOML'
[package]
name = "heap_order_probe"
version = "0.0.0"
edition = "2021"
TOML
    mkdir -p "$tmp/src"
    mv "$tmp/heap_order_probe.rs" "$tmp/src/main.rs"
    cargo run --manifest-path "$tmp/Cargo.toml"
  )
else
  echo "cargo/rustc unavailable"
fi

Repository: lance-format/lance

Length of output: 14303


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate graph.rs =="
git ls-files | rg '(^|/)graph\.rs$|lance_index/src|index/vector' | sed -n '1,120p'

echo "== graph.rs OrderedNode definitions/usages =="
while IFS= read -r f; do
  rg -n -C 8 'struct OrderedNode|impl .*OrderedNode|derive\(.*Ord.*\)|impl PartialOrd|impl Ord|BinaryHeap<OrderedNode' "$f"
done < <(git ls-files | rg '(^|/)graph\.rs$')

echo "== ivf storage topk calls into graph =="
rg -n -C 12 'accumulate_topk_with_scratch|topk|OrderedNode' rust/lance/src/index/vector -S | sed -n '1,260p'

Repository: lance-format/lance

Length of output: 1335


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== rust/lance-index/src/vector/graph.rs OrderedNode area =="
sed -n '1,180p' rust/lance-index/src/vector/graph.rs

echo "== binary heap tie ordering probe =="
python3 - <<'PY'
# Rust std::collections::BinaryHeap iterates from the maximum heap element;
# equal-order elements can emerge in different relative orders.
import random
heap = []
for row_id in range(9):
    random.shuffle(heap)
    heap.append((0.0, row_id))
    heap.sort(reverse=True)

seen = set()
for _ in range(200):
    q = heap.copy()
    out = []
    while q:
        _, row_id = q.pop()
        out.append(row_id)
    seen.add(tuple(out))
print("seen orderings:", len(seen))
PY

Repository: lance-format/lance

Length of output: 4451


Use a deterministic tie-break for batch top-k results.

OrderedNode only orders by distance, so equal-distance candidates are not deterministically selected or ordered before global_heap_to_batch emits them. With ties present, the shared batch path can produce the same top-k set in a different order than repeated single-query search, causing the indexed equivalence assertions to become flaky. Add the row id as the final comparison key in OrderedNode::cmp, or sort the heap output by (distance, row_id) before returning the batch.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rust/lance/src/index/vector/ivf/v2.rs` around lines 1902 - 1916, Update
OrderedNode::cmp to use row_id as the final comparison key after distance,
ensuring equal-distance candidates are deterministically selected and ordered.
Preserve the existing heap behavior and make the resulting global_heap_to_batch
output match repeated single-query ordering.

Comment on lines +1920 to +1938
// Load each distinct partition's storage exactly once. This shared I/O
// is the whole point of batch search versus repeated single queries.
let load_parallelism = get_num_compute_intensive_cpus().max(1);
let load_index = self.clone();
let load_metrics = metrics.clone();
let loaded = stream::iter(assignments)
.map(move |(part_id, probing_queries)| {
let index = load_index.clone();
let metrics = load_metrics.clone();
async move {
let part_entry = index
.load_partition(part_id as usize, true, metrics.as_ref())
.await?;
Result::Ok((part_id as usize, part_entry, probing_queries))
}
})
.buffered(load_parallelism)
.try_collect::<Vec<_>>()
.await?;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀 Performance & Scalability | 🟠 Major | 🏗️ Heavy lift

All probed partitions are materialized before any scoring — unbounded memory for wide batches.

try_collect::<Vec<_>>() holds every distinct probed partition's storage alive at once. For a batch of q queries with nprobes probes each, that is up to min(q * nprobes, num_partitions) partitions resident, and the Arc clones also pin those entries in the index cache. The single-query path deliberately avoids this by streaming prepared partitions through a bounded channel (STREAMING_SEARCH_BATCH_SIZE, Line 1663) after #7642.

Consider processing the load stream in chunks (load a bounded window, dispatch scoring for it via spawn_cpu, drop the entries, repeat) so peak memory is bounded by the window rather than by the probe fan-out.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rust/lance/src/index/vector/ivf/v2.rs` around lines 1920 - 1938, The batch
search flow around load_parallelism and the try_collect::<Vec<_>>() currently
retains every probed partition before scoring. Replace this all-at-once
collection with bounded-window processing, using STREAMING_SEARCH_BATCH_SIZE or
the existing bounded-channel pattern: load one window, dispatch its scoring
through spawn_cpu, release partition entries, then continue until all
assignments are processed while preserving result ordering and error
propagation.

Comment on lines +2401 to +2411
for (query_index, batch) in per_query.into_iter().enumerate() {
let dists = batch.column(0).as_primitive::<Float32Type>();
let row_ids = batch.column(1).as_primitive::<UInt64Type>();
candidates[query_index].extend(
dists
.values()
.iter()
.copied()
.zip(row_ids.values().iter().copied()),
);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Use column_by_name and validate the returned batch count.

Two issues in this loop:

  1. batch.column(0) / batch.column(1) bind result columns by position. As per coding guidelines, Use column_by_name() for RecordBatch column access in production code; positional access silently mis-reads distances as row ids if VECTOR_RESULT_SCHEMA field order ever changes.
  2. per_query comes from a trait method whose contract is "one batch per query". If an implementation returns more batches than query_count, candidates[query_index] panics inside the executor. A length check with an Internal error keeps the failure diagnosable.
♻️ Proposed fix
+                if per_query.len() != query_count {
+                    return Err(DataFusionError::Internal(format!(
+                        "ANNIvfBatchExec: index {} returned {} result batches for {query_count} queries",
+                        index_meta.uuid,
+                        per_query.len()
+                    )));
+                }
                 for (query_index, batch) in per_query.into_iter().enumerate() {
-                    let dists = batch.column(0).as_primitive::<Float32Type>();
-                    let row_ids = batch.column(1).as_primitive::<UInt64Type>();
+                    let dists = batch
+                        .column_by_name(DIST_COL)
+                        .ok_or_else(|| {
+                            DataFusionError::Internal(format!(
+                                "ANNIvfBatchExec: batch partition search result missing {DIST_COL}"
+                            ))
+                        })?
+                        .as_primitive::<Float32Type>();
+                    let row_ids = batch
+                        .column_by_name(ROW_ID)
+                        .ok_or_else(|| {
+                            DataFusionError::Internal(format!(
+                                "ANNIvfBatchExec: batch partition search result missing {ROW_ID}"
+                            ))
+                        })?
+                        .as_primitive::<UInt64Type>();
                     candidates[query_index].extend(
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
for (query_index, batch) in per_query.into_iter().enumerate() {
let dists = batch.column(0).as_primitive::<Float32Type>();
let row_ids = batch.column(1).as_primitive::<UInt64Type>();
candidates[query_index].extend(
dists
.values()
.iter()
.copied()
.zip(row_ids.values().iter().copied()),
);
}
if per_query.len() != query_count {
return Err(DataFusionError::Internal(format!(
"ANNIvfBatchExec: index {} returned {} result batches for {query_count} queries",
index_meta.uuid,
per_query.len()
)));
}
for (query_index, batch) in per_query.into_iter().enumerate() {
let dists = batch
.column_by_name(DIST_COL)
.ok_or_else(|| {
DataFusionError::Internal(format!(
"ANNIvfBatchExec: batch partition search result missing {DIST_COL}"
))
})?
.as_primitive::<Float32Type>();
let row_ids = batch
.column_by_name(ROW_ID)
.ok_or_else(|| {
DataFusionError::Internal(format!(
"ANNIvfBatchExec: batch partition search result missing {ROW_ID}"
))
})?
.as_primitive::<UInt64Type>();
candidates[query_index].extend(
dists
.values()
.iter()
.copied()
.zip(row_ids.values().iter().copied()),
);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rust/lance/src/io/exec/knn.rs` around lines 2401 - 2411, Update the per-query
batch handling around the loop over per_query: first validate that the returned
batch count matches the query count, returning a diagnosable Internal error on
mismatch before indexing candidates. Then replace positional batch.column access
with column_by_name using the schema’s distance and row-id field names,
preserving the existing candidate extension behavior.

Source: Coding guidelines

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

A-index Vector index, linalg, tokenizer A-python Python bindings enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Extend batch vector queries to ANN and indexed search

1 participant