Skip to content

feat(python): expose core vector search and scored results - #1087

Merged
JingsongLi merged 3 commits into
apache:mainfrom
JingsongLi:codex/native-vector-search
Oct 10, 2026
Merged

JingsongLi merged 3 commits into
apache:mainfrom
JingsongLi:codex/native-vector-search

Conversation

@JingsongLi

@JingsongLi JingsongLi commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Expose the existing Rust core vector-search pipeline to Python so PyPaimon can execute local searches without coordinating index evaluation, scalar filtering, raw scoring and refinement in Python.

  • Add single and batch vector builders, owned Scan/Plan/Read wrappers, scored search-result access and snapshot-scoped result materialization. Bindings delegate search and selection logic to Rust core.
  • Align repeated filter/options calls with Java accumulation and add explicit partition-only filters. Prune DE index manifest entries by the partition predicate.
  • Honor query metric options for raw DE scoring with Java's persisted-index/query/table/default precedence. Reject conflicting query metrics and mixed persisted shard metrics, including refinement after another shard was filtered out. Validation reuses already loaded index metadata.
  • Export scored PK selections with the Java SplitSerializer/IndexedSplit wire layout, preserve deletion metadata and mark selected PK splits non-raw, as Java does.
  • Update Python stubs and usage documentation. No dependency additions.

Java references

VectorSearchBuilderImpl, BatchVectorSearchBuilderImpl, AbstractDataEvolutionVectorRead, PrimaryKeyScoredResult, and IndexedSplit are the semantic references. Core already supports both DE and PK searches; this adds the Python surface and closes the gaps found during integration.

Initial verification

  • Full cargo test --locked -p paimon --lib: 3876 passed, 6 ignored (including 341 vector tests).
  • cargo test --locked -p paimon --lib table::source::tests: 57 passed.
  • Python vector/read/table/resolved-table binding regression suites: 197 tests; the vector suite was rerun after correcting a test's assumption about tied hits from partition file publication.
  • Real PyPaimon REST integration covers single/batch, FAST/FULL/DETAIL, raw/indexed tails, partition and scalar filters, Java PK indexes, score export and mixed-metric rejection.
  • cargo +1.98.0 clippy --locked --all-targets --workspace --features fulltext,vortex -- -D warnings and pinned formatting check passed.

Companion PyPaimon PR: apache/paimon#10476. Its full Native regression with plan/read/write/update/commit enabled passed 3000 tests and 127 subtests locally; 6 tests were skipped. It remains Draft until this API is available on Rust main.

Review fixes

  • Preserve Java's three scalar-filter outcomes in Rust core: exact matches feed ANN; inexact matches are refined only when global-index.filter.refine-from-data=true and otherwise excluded; unevaluable filters route vector-index-covered ranges to raw scoring in FAST/FULL/DETAIL.
  • Apply residual predicates to raw ranges independently of ANN candidate bitmaps, retaining matching unindexed tails. Prune partitions before scalar evaluation and exclude composite index definitions from search pre-filters, as Java does.
  • Track whether every predicate was evaluated, including dropped same-field AND/NOT constraints and fallback budget limits. This prevents partial answers from being treated as exact.
  • Validate all persisted shard metrics after raw routing and align with_query_vector(vector=...) with the published Python signature.

Verification for commit 2a2692f:

  • Rust vector regression tests: 482 passed.
  • Global-index scanner tests: 76 passed, 1 ignored.
  • Python vector/read/table/resolved-table binding suites: 198 passed.
  • Companion real REST vector suite: 54 passed, with 21 Native plans exercised. It covers the review reproductions, refinement on/off, unindexed tails, exact scalar indexes with partition filters, and unsupported same-field FM conjuncts.
  • Pinned fmt and full workspace Clippy (1.98.0, fulltext,vortex, -D warnings) passed.
  • Independent source review of the repaired core found no remaining actionable issues.

Scalar coverage review repair

Commit 68da723 fixes the remaining Java coverage finding. Scalar FULL/DETAIL now contribute predicate-field coverage gaps to raw scoring even when the scalar index lookup succeeds with an empty match set. Vector FAST intersects those extra ranges with the vector-index corpus; vector FULL/DETAIL retain their own uncovered ranges. Raw scoring executes the residual predicate against the pinned snapshot.

Search modes use table options overlaid by query options, matching DataEvolutionVectorScan.effectiveCoreOptions(). Coverage accounting reuses the existing range utilities, intersects coverage across all predicate fields, and excludes composite and other index families from scalar coverage.

Current repair verification:

  • Rust vector tests: 484 passed, including the 61 DE vector tests and the scalar/vector coverage matrix.
  • Python vector binding tests: 8 passed.
  • Rebuilt extension + companion real REST module: 94 passed, 49 Native plans exercised. Includes scalar/vector FAST/FULL/DETAIL, older scalar or vector coverage, table/query option precedence, refinement disabled, and two scalar fields with different coverage ranges.
  • Pinned fmt and workspace Clippy (1.98.0, fulltext,vortex, -D warnings) passed.
  • Independent source review found no remaining actionable Rust coverage issues.

@leaves12138 leaves12138 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Reviewed at cd2e2d1f14e83893513c4b487e432b1da04ad0f1 paired with apache/paimon#10476 at c846e956d4751d58b08673ff4b9a690ec53787fc. Requesting changes for Java DE scalar-filter parity and the public Python keyword contract.

Independent validation on the fixed heads:

  • Rebuilt and installed the companion Native extension from the reviewed Rust head.
  • Full Rust core unit suite: 3877 passed, 6 ignored.
  • Python binding vector/read/table/resolved-table suites: 197 passed.
  • New real-REST Native vector suite: 30 passed.
  • Fourteen related PyPaimon suites with all five Native switches enabled: 997 passed and 96 subtests passed; every update-family counter was nonzero.
  • Formatting, changed-file Flake8, and Python 3.6 syntax checks passed.
  • Additional reviewer Java/API parity cases: 13 failed, 6 passed. These expose the two DE scalar-filter semantic differences plus the single-vector keyword mismatch described inline.

The passing baseline suites do not cover these parity cases. Java references are the companion head's AbstractDataEvolutionVectorRead.prepareSplits (lines 159-181) and scalarMatchedRows (lines 246-266). The reproduced reference outputs use the classic Python path, checked against those Java implementations; I did not run a separate Java process.

builder.with_query_vector(query.clone());
}
let results = py
.detach(|| runtime().block_on(builder.execute()))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[P1] Preserve Java's raw-search routing for unevaluable scalar filters

This exposes the core DE filtered-search path, but DeVectorScan::plan always resolves the predicate by reading matching row IDs and passes that allow-list to ANN. That is not Java's AbstractDataEvolutionVectorRead.prepareSplits behavior: when no scalar index can evaluate the predicate, Java moves the covered vector-index ranges to raw scoring and applies the residual there. The companion Python PR implements that routing in its classic reader, then bypasses it here through Native execution.

I reproduced the difference on a real REST table using the companion vector_table fixture, 50 near vectors and 50 distant vectors in the same partition, an IVF-flat index with nlist=4, query [0, 0], filter id >= 100 with no scalar index on id, ivf.nprobe=1, and limit 3. The classic/reference path returns 3 matching hits; this binding and the new PyPaimon Native path both return 0. The difference occurs for single and batch searches in FAST, FULL, and DETAIL. Setting Native nprobe to 4 restores all 3 hits, confirming that filtered hits are lost through ANN candidate selection rather than being absent from the data.

Please implement Java's scalar-evaluation/range-routing distinction in core before exposing this path, including both single and batch readers. A data-derived allow-list sent to ANN is not a substitute for the required raw fallback.

builder.with_options(options.clone());
}
for filter in &self.filters {
builder.with_filter(filter.clone());

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[P2] Honor the Java scalar-candidate refinement switch

Both the single and batch configuration paths delegate all filters to core's unconditional matching_row_ids_for_filter data read. For an inexact scalar index answer this ignores global-index.filter.refine-from-data=false. Java's AbstractDataEvolutionVectorRead.scalarMatchedRows explicitly excludes those candidates when refinement is disabled; it does not read the data to turn them into exact matches.

A real REST reproduction uses names alpha, beta zeta, and gamma, an IVF-flat index on the vector column, a BTREE index on name, filter name LIKE '%zeta%', and limit 1. With refinement disabled the classic/Java-reference policy returns 0 hits, but both the Rust binding and Native PyPaimon return 1; batch returns [1, 1] instead of [0, 0]. All three search modes reproduce it. With refinement enabled the paths agree. Please preserve exact/unevaluable/inexact scalar evaluation state and apply the configured refinement policy before ANN, rather than always materializing an exact row-ID filter.

Comment thread bindings/python/src/vector_search.rs Outdated
slf.config.limit = Some(limit);
slf
}
fn with_query_vector(mut slf: PyRefMut<'_, Self>, vectors: Vec<f32>) -> PyRefMut<'_, Self> {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[P2] Match the published single-vector keyword argument

The new stub declares with_query_vector(self, vector: Sequence[float]), and the existing PyPaimon builder accepts that same vector keyword, but this PyO3 method names the argument vectors. Consequently builder.with_query_vector(vector=[1.0, 0.0]) raises TypeError: ... got an unexpected keyword argument 'vector' despite matching the public type signature. Positional-only test calls miss this. Please rename the parameter to vector (or set the equivalent explicit Python signature) and add a keyword-call regression.

@leaves12138 leaves12138 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Re-reviewed at 2a2692f5b85ab9317f4257982b37a7af3945ab6a, paired with apache/paimon#10476 at 7e6453d1a8ba8a80da647bc5e520260061248006. All three previous findings are fixed: the 19 independent regression cases now pass, including unevaluable-filter raw routing, the candidate-refinement switch, and the vector= keyword.

One Java-parity blocker remains: scalar-unindexed ranges are not added to raw vector scoring when scalar coverage is older/smaller than vector coverage and scalar-index.search-mode=full/detail. This silently omits matching rows on both the direct binding and the public Native PyPaimon path. See the inline reproduction; this is the Java coverage contract, not an additional requirement inferred only from Python behavior.

Independent validation on these fixed heads:

  • Rebuilt and installed the Native extension from this Rust head.
  • Full Rust core unit suite: 3882 passed, 6 ignored.
  • Binding vector/read/table/resolved-table suites: 198 passed.
  • Companion real-REST Native vector suite: 54 passed.
  • Fourteen related PyPaimon suites with all five Native switches enabled: 997 passed, 96 subtests passed; all six update-family counters were nonzero.
  • Previous reviewer parity suite: 19 passed.
  • Additional coverage matrix: 12 failed, 6 passed (scalar FULL/DETAIL fail for single/batch across vector FAST/FULL/DETAIL; scalar FAST controls pass).
  • Rust formatting and changed Python files' Python 3.6 syntax checks passed. The companion Python PR has two new configured Flake8 E501 errors, noted there.

Java semantics were checked against the companion fixed head's DataEvolutionVectorScan and DataEvolutionGlobalIndexCoverage; the executable reference in the coverage matrix is classic PyPaimon on the same real REST tables. No separate Java process was run.

}

if search_mode != GlobalIndexSearchMode::Fast {
if route_indexed_to_raw || search_mode != GlobalIndexSearchMode::Fast {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[P1] Include scalar-unindexed ranges in raw vector scoring

This raw fallback is still entered only for an unevaluable scalar predicate or a non-FAST vector mode, and its ranges are calculated only from vector coverage. An evaluable but partially covered scalar index returns Some(empty) for a matching row outside its coverage, so route_indexed_to_raw stays false. If the vector index already covers that row, FULL/DETAIL also have no vector-unindexed range to recover it.

Real REST reproduction using the companion vector_table fixture: (1) create a BTREE index on id over the initial six rows; (2) append {id: 7, embedding: [3, 3], pt: 1}; (3) create an IVF-flat index on embedding with dimension 2 and nlist 1, now covering all seven rows; (4) search [0, 0] with id = 7, limit 10, and scalar-index.search-mode=full or detail. Classic returns 1 hit; both the direct Rust binding and Native PyPaimon return 0 (batch: [1, 1] versus [0, 0]). All three vector modes reproduce this. Scalar FAST controls correctly return no hit on both paths. Python search was patched to raise during the Native calls, so fallback cannot mask the reproduction.

Java explicitly adds scalar-unindexed ranges using the configured scalar search mode in DataEvolutionVectorScan lines 174-191, intersecting them with vector coverage when vector mode is FAST. Please preserve that rule here: compute predicate-field scalar coverage, union its permitted uncovered ranges into raw scoring even when scalar evaluation succeeds, and apply the residual predicate there. In FAST vector mode, restrict only these scalar fallback ranges to vector-covered rows.

@leaves12138 leaves12138 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Re-reviewed at 68da723a4014508bb6584992bb3d4d0819d7e86d, paired with apache/paimon#10476 at 4e87ecdebab3c6dd9f72444ade0394655dfd24cf. The remaining scalar-coverage finding is fixed. No new actionable blocker found in the reviewed changes.

The raw fallback now includes scalar-unindexed ranges according to the effective scalar search mode, intersects those ranges with vector coverage for vector FAST, merges overlaps, and applies the residual predicate before Top-K. This matches the coverage/routing contract in Java DataEvolutionVectorScan lines 174-191 and DataEvolutionGlobalIndexCoverage. Query-level family modes also override table modes. The earlier raw-routing, refinement-switch, and vector= keyword fixes remain covered.

Independent validation on these exact heads, using a rebuilt and installed Native extension:

  • Full Rust core unit suite: 3884 passed, 6 ignored.
  • Binding vector/read/table/resolved-table suites: 198 passed.
  • Real-REST Native vector module: 94 passed, with 49 Native plans, 51 Native reads, 158 Native writes, and 295 Native commits exercised.
  • Reviewer parity cases: 49 passed (37 prior-finding/coverage cases plus 12 additional query-mode override cases). The direct binding and public Native paths were checked against classic results; Python single/batch readers were patched to raise during Native calls. This includes query FAST overriding a FULL table without mutating table options, with either index family being older.
  • Fourteen related Native suites: 997 passed, 96 subtests passed; all six update-family counters were nonzero.
  • Classic vector-filter suite: 79 passed, 19 subtests passed.
  • Rust fmt, configured changed-file Flake8, and Python 3.6 syntax checks passed; the companion's two previous E501 findings are fixed.

Java was used as the source-level semantic reference; no separate Java process was run. These are local/real-REST correctness checks, not production-storage or performance validation.

@JingsongLi
JingsongLi merged commit f5cdd74 into apache:main Oct 10, 2026
16 checks passed
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