Skip to content

[core] Batch primary-key vector search over a shared plan#560

Open
JunRuiLee wants to merge 5 commits into
apache:mainfrom
JunRuiLee:pk-vec/2-batch-multi-query
Open

[core] Batch primary-key vector search over a shared plan#560
JunRuiLee wants to merge 5 commits into
apache:mainfrom
JunRuiLee:pk-vec/2-batch-multi-query

Conversation

@JunRuiLee

@JunRuiLee JunRuiLee commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Purpose

Part of #514, and the second of three PRs split out of the original combined change on the primary-key vector execute_read path. This PR adds batch multi-query search: N query vectors share one snapshot/manifest plan, segment preload, and opened readers. Single-query output stays byte-identical (single == batch-of-one).

Stacked PR. Its parent — streaming exact fallback (#559) — has merged, so this branch is now rebased onto main and the diff is just the two batch commits. Full stack:

  1. streaming exact fallback — [core] Stream primary-key vector exact fallback one batch at a time #559 (merged)
  2. this PR — batch multi-query
  3. parallel search — [core] Search primary-key vector buckets and files concurrently #556 (stacked on this)

Brief change log

  • feat(vindex): the bucket-local ANN search gains a batch entry so N queries share one pass over each ANN segment and each uncovered file. The ANN searcher builds the live-row mask once (query-independent), opens one reader per segment, and drives visit_batch_vector_search; the exact searcher scores all queries in one stream pass. A single query short-circuits to the existing single-query path so its result is unchanged. The batch ANN scorer dispatches Lumina vs. vindex readers per segment backend, matching the single-query path.
  • feat(table): BatchVectorSearchBuilder gains with_filter/with_projection and an execute_read returning one Arrow stream per query in input order (empty streams preserve arity; any query error fails the whole call). The builder factors the query-independent plan (snapshot, segment preload, residual allow-list, backend, ANN scorer) into a shared step reused across queries. The single-query plan_and_search_pk_candidates becomes a thin batch-of-one wrapper whose output is byte-identical. The scored execute() stays fail-loud on the primary-key path, and a data-evolution execute_read validates the target column exists and is a FLOAT vector column (ARRAY/VECTOR) up front so an unknown/scalar/non-float column fails loud instead of returning an empty stream.

Tests

  • Single == batch-of-one at every layer; per-query heaps independent under a shared residual/DV; arity preserved on an empty snapshot; malformed query, zero limit, and reserved/unknown/scalar/non-float columns fail loud (core + C FFI coverage).
  • cargo test -p paimon green; cargo build -p paimon-datafusion clean; cargo clippy -p paimon --all-targets -D warnings and cargo fmt --check clean.

API and Format

No on-disk format change, no new result columns. Single-query output is byte-identical. New public surface: BatchVectorSearchBuilder::{with_filter, with_projection, execute_read}.

Documentation

Code comments only; no user-facing docs change.

@JunRuiLee
JunRuiLee marked this pull request as draft July 20, 2026 12:37
@JunRuiLee
JunRuiLee force-pushed the pk-vec/2-batch-multi-query branch from c5d4766 to 2b4ca1d Compare July 21, 2026 06:12
@JunRuiLee
JunRuiLee marked this pull request as ready for review July 21, 2026 06:20
@JunRuiLee
JunRuiLee force-pushed the pk-vec/2-batch-multi-query branch from 2b4ca1d to 83da593 Compare July 21, 2026 06:34
@JingsongLi

Copy link
Copy Markdown
Contributor
  • ARRAY<FLOAT> is a valid PK-vector column, but batch searches only perform query validation in advance for VECTOR<FLOAT>.
  • For empty tables or empty execution plans, queries on ARRAY<FLOAT> that contain NaN or Inf will be returned as normal empty results rather than reporting an invalid input. The minimal regression I added consistently failed.

Add a batch entry to the bucket-local vector search so N query vectors share
one pass over each ANN segment and each uncovered data file. The ANN searcher
gains search_batch: it builds the live-row mask once (query-independent), opens
one reader per segment, and drives visit_batch_vector_search, mapping results
per query. The bucket search gains bucket_search_batch with per-query bounded
heaps; a single query short-circuits to the existing single-query path so its
result stays identical. The exact-fallback per-file search already scored all
queries in one stream pass. Single-query wrappers are retained throughout.
Add a batch multi-query entry to the primary-key vector read: N query vectors
share one snapshot/manifest plan, segment preload, per-file search closure and
residual allow-list, then each query runs its own bucket search, exact rerank
and merge before materializing into its own Arrow stream. The orchestrator
gains search_candidates_batch; the builder factors the query-independent plan
into a shared step reused across queries. BatchVectorSearchBuilder gains
with_filter/with_projection and an execute_read returning one stream per query
in input order (empty streams preserve arity, any query error fails the whole
call); its scored execute stays fail-loud on the primary-key path, and a table
without a primary-key vector index is rejected by the materialized read. The
single-query entries become thin wrappers whose output is unchanged.
@JunRuiLee
JunRuiLee force-pushed the pk-vec/2-batch-multi-query branch from aa874b5 to 2607378 Compare July 21, 2026 13:35
@JunRuiLee

Copy link
Copy Markdown
Contributor Author
  • ARRAY<FLOAT> is a valid PK-vector column, but batch searches only perform query validation in advance for VECTOR<FLOAT>.
  • For empty tables or empty execution plans, queries on ARRAY<FLOAT> that contain NaN or Inf will be returned as normal empty results rather than reporting an invalid input. The minimal regression I added consistently failed.

Thanks, fixed. Batch PK-vector query validation now also covers ARRAY<FLOAT> before planning, using the configured index dimension, so NaN/Inf queries fail loud even for empty plans. Added a regression test for the empty ARRAY<FLOAT> table case.

…dex dimension

The branch's new up-front query-dimension guard validates every query
against the vector column's dimension before planning and before the
empty-plan early return. For an ARRAY<FLOAT> IVF_FLAT column with no
explicit dimension, the resolver defaults to 128, so the dim-1 query
vectors (`vec![1.0]`) in two guard tests failed loud before reaching the
guards they exercise:

  - execute_read_empty_plan_reserved_projection_fails_loud
  - execute_read_filter_with_deletion_vectors_passes_guard

Pin `fields.embedding.dimension = 4` and size the query vectors to match,
so each test reaches its reserved-projection / deletion-vector residual
guard as intended. Test-only; production behavior is unchanged.
@JunRuiLee
JunRuiLee force-pushed the pk-vec/2-batch-multi-query branch 2 times, most recently from 87df59a to a9c1478 Compare July 21, 2026 14:02
@JingsongLi

JingsongLi commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

1. Missing authorization check

BatchVectorSearchBuilder::execute() no longer calls CoreOptions::ensure_read_authorized(), although the previous implementation performed this check before validation and empty-table fast paths. This method returns data-derived row IDs and scores outside TableScan / TableRead, so a table with query-auth.enabled=true can bypass the fail-closed read authorization through the batch scored-search API. Please restore the authorization check at the beginning of execute() and add a batch-specific regression test.

2. ARRAY<FLOAT> with Lumina is incorrectly handled as vindex

pk_vector_query_dimension() unconditionally uses VindexVectorIndexOptions for every ARRAY<FLOAT> column. For a valid PK-vector configuration using lumina or lumina-vector-ann, this fails with Unsupported vindex index type before planning, including on an empty table. Please resolve the dimension according to the configured backend: use the effective lumina.index.dimension for Lumina and VindexVectorIndexOptions only for vindex backends. A regression test covering ARRAY<FLOAT> with Lumina would also be useful.

3. Default projection no longer rejects reserved-column conflicts

The previous implementation validated the resolved field list after applying the projection, which also caught reserved-name conflicts in the default projection. The new resolver only validates explicitly requested projection names. As a result, a table containing a user column such as __paimon_search_score, _PKEY_VECTOR_POSITION, or _ROW_ID can silently pass on an empty result and may produce duplicate or incorrectly interpreted metadata columns for non-empty results. Please restore the post-resolution reserved-field validation for both the single-query and batch resolvers, along with the removed default-projection regression tests.

Three fixes from the PR review on the batch primary-key vector work:

1. The batch scored `execute()` now calls `ensure_read_authorized()`
   before any fast path, mirroring `execute_read` and the single-query
   builder. A `query-auth.enabled` table previously returned empty results
   on an empty snapshot and silently bypassed read authorization.

2. `pk_vector_query_dimension` resolves an `ARRAY<FLOAT>` column's
   dimension per the configured backend: Lumina via
   `LuminaVectorIndexOptions` (`lumina.index.dimension`), vindex/IVF via
   `VindexVectorIndexOptions`. A valid Lumina `ARRAY<FLOAT>` column no
   longer fails with "Unsupported vindex index type" before planning.

3. `resolve_materialize_read_type` validates the RESOLVED field list for
   reserved metadata column names, so the default (all user columns)
   projection also rejects a user column that collides with an injected
   metadata column, not only names in an explicit projection. Applied to
   both the single-query and batch resolvers.

Adds a regression test for each.
The primary-key vector read injects its score column as
`__paimon_search_score` (`SEARCH_SCORE_COLUMN`). `_PKEY_VECTOR_SCORE` was
that column's earlier name; the rename left it behind in the reserved-name
guard, two projection tests, and a few comments/assertion messages that
named a column no longer produced anywhere.

Remove `_PKEY_VECTOR_SCORE` from the reserved-column guard and the two
projection tests, and correct the stale references to `__paimon_search_score`
so the reserved set names only the three columns a read actually injects
(`__paimon_search_score`, `_PKEY_VECTOR_POSITION`, `_ROW_ID`). No behavior
change: nothing emits a `_PKEY_VECTOR_SCORE` column.
@JunRuiLee

Copy link
Copy Markdown
Contributor Author

Addressed in fd72a9a (fixes) and 5fd4157 (cleanup):

1. Missing authorization checkBatchVectorSearchBuilder::execute() now calls ensure_read_authorized() at the very top, before config validation and any fast path, matching execute_read and the single-query builder. A query-auth.enabled=true table previously returned empty results on an empty snapshot and bypassed the fail-closed guard; it now fails closed. Added a batch-specific regression test.

2. ARRAY<FLOAT> with Luminapk_vector_query_dimension() now resolves the dimension per the configured backend: Lumina via LuminaVectorIndexOptions (lumina.index.dimension), vindex/IVF via VindexVectorIndexOptions. A valid Lumina ARRAY<FLOAT> PK-vector column is no longer rejected as Unsupported vindex index type before planning (including on an empty table). Added a regression test covering ARRAY<FLOAT> + Lumina.

3. Default projection reserved-column conflicts — both resolve_materialize_read_type resolvers (single-query and batch) now validate the resolved field list against the reserved metadata names, so the default all-columns projection also rejects a colliding user column, not only names in an explicit projection. The explicit pre-check is kept so a requested reserved name that is not a real column still fails as a reserved-column error rather than "column not found". Restored the default-projection regression tests for both resolvers.

Separately, dropped the stale _PKEY_VECTOR_SCORE reserved name (5fd4157): it was the score column's pre-rename name and nothing emits it anymore — the injected metadata columns are __paimon_search_score, _PKEY_VECTOR_POSITION, and _ROW_ID.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants