Skip to content

[c] Support vector search materialized read in C FFI bindings#537

Merged
JingsongLi merged 3 commits into
apache:mainfrom
JunRuiLee:feat/c-ffi-vector-search
Jul 20, 2026
Merged

[c] Support vector search materialized read in C FFI bindings#537
JingsongLi merged 3 commits into
apache:mainfrom
JunRuiLee:feat/c-ffi-vector-search

Conversation

@JunRuiLee

@JunRuiLee JunRuiLee commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

Purpose

Part of #514. Expose vector search to C/C++/Go consumers (e.g. Doris) via paimon-c. Primary-key vector search produces materialized rows (physical (file, position) coordinates, no global row ids), so the unified user-facing shape across storage types is materialized rows + a score column — matching Java Spark, where both primary-key and data-evolution reads surface rows plus a __paimon_search_score metadata column. This PR wires that through: execute_read() gains a data-evolution branch, and a single C entry returns the existing Arrow-stream reader.

Brief change log

  • feat(table): VectorSearchBuilder::execute_read() now materializes rows for both primary-key and data-evolution vector search. Unified user-visible score column __paimon_search_score (was PK-only _PKEY_VECTOR_SCORE, kept as a rejected projection alias). Data-evolution rows are joined to their scores by global _ROW_ID, reordered to the search's best-first rank (not re-sorted by score), then _ROW_ID is stripped; any missing / null / wrong-type / unknown id, or a row count that disagrees with the scored result, fails loud (never a NaN score or a silently dropped row). A filter on the data-evolution path fails loud (parity is a follow-up); the primary-key filter path is unchanged.
  • feat(c): new bindings/c/src/vector_search.rs — a builder handle over owned state, setters (with_vector_column / with_query_vector / with_limit / with_options / with_filter), and a single terminal paimon_vector_search_builder_execute_read returning paimon_result_record_batch_reader (reuses the existing Arrow C Data Interface reader; consumers drain it with the existing paimon_record_batch_reader_next / paimon_arrow_batch_free / paimon_record_batch_reader_free). The entry is storage-agnostic — C does no table-type routing; the core decides. Errors surface via the result's error field; an empty result is a normal end-of-stream reader. Compile-time ABI signature guards pin every exported symbol.

Tests

  • Core: data-evolution materialization end to end; rank-order fidelity (proven with a score tie); row-id alignment across batches; fail-loud branches (missing / null / wrong-type / unknown _ROW_ID, count mismatch); reserved-projection rejection.
  • C: 6 integration tests comparing the C Arrow stream against an independent core execute_read() reference across primary-key and data-evolution tables; a primary-key filter that excludes a neighbor; the data-evolution filter InvalidInput error; setter validation; and the empty end-of-stream case.
  • cargo test -p paimon and cargo test -p paimon-c green; cargo build -p paimon-datafusion clean (cross-crate Send gate); cargo clippy -D warnings and cargo fmt --check clean.

API and Format

No on-disk format change. New C ABI symbols only (add-only, each guarded by a compile-time signature assertion). This replaces the earlier scored (row-ids + scores) C entry, which was never released.

Documentation

Doc comments on the new FFI functions; no user-facing docs change.

@JunRuiLee
JunRuiLee marked this pull request as ready for review July 18, 2026 03:42
@JunRuiLee
JunRuiLee marked this pull request as draft July 18, 2026 07:39
@JunRuiLee
JunRuiLee force-pushed the feat/c-ffi-vector-search branch from a2e6b72 to 7097120 Compare July 19, 2026 11:21
@JunRuiLee JunRuiLee changed the title [c] Support vector search in C FFI bindings [c] Support vector search materialized read in C FFI bindings Jul 19, 2026
…e_read

Extend VectorSearchBuilder::execute_read() to materialize rows for
data-evolution (global-index) vector search, matching the primary-key path,
so both storage types return projected user columns plus a unified score
column.

- Unify the user-visible score column to __paimon_search_score (constant
  SEARCH_SCORE_COLUMN), matching the engine metadata column name. Reject
  _ROW_ID, _PKEY_VECTOR_POSITION, __paimon_search_score, and the legacy
  _PKEY_VECTOR_SCORE alias as read projections.
- Add attach_scores_by_row_id: join materialized rows to their (rank, score)
  by global _ROW_ID, reorder to the search's best-first rank (not re-sorted by
  score), append the score column, and strip _ROW_ID. Fail loud on missing /
  null / wrong-type / unknown _ROW_ID or a row count that disagrees with the
  scored result; never emit NaN or silently drop a row.
- Add the data-evolution branch to execute_read: run the scored search, build
  the row-range read exposing _ROW_ID, materialize, and attach scores. Validate
  ids fit i64 (via to_row_ranges) before building the score map. A filter stays
  unsupported here and fails loud inside the scored search; the primary-key
  filter path is unchanged.

Covers rank-order fidelity, row-id alignment, and the fail-loud branches with
unit and end-to-end tests.
Add a C FFI vector-search surface over the Rust VectorSearchBuilder, so
C/C++/Go consumers can run a vector search and read back the matched rows.

- Builder handle over owned state (table + accumulated query params) plus
  setters for vector column, query vector, limit, options, and an optional
  scalar filter. The query vector and options are copied at the setter; the
  filter is consumed, matching the read-builder contract.
- A single terminal, paimon_vector_search_builder_execute_read, runs the
  search and returns the existing streaming record-batch reader
  (paimon_result_record_batch_reader); consumers drain it with the existing
  reader-next / arrow-batch-free / reader-free functions. The entry is
  storage-agnostic and does no table-type routing — the core decides. Errors
  (including a filter on the append / data-evolution path) surface through the
  result's error field; an empty result is a normal end-of-stream reader.
- Compile-time ABI signature guards pin every exported symbol.

Integration tests compare the C stream against an independent Rust reference
across primary-key and data-evolution tables, cover a primary-key filter that
excludes a neighbor, the data-evolution filter error, setter validation, and
the empty end-of-stream case.
@JunRuiLee
JunRuiLee force-pushed the feat/c-ffi-vector-search branch from 7097120 to f264394 Compare July 20, 2026 05:14
@JunRuiLee
JunRuiLee marked this pull request as ready for review July 20, 2026 05:16

@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.

The focused core and C FFI tests pass locally, but I found two correctness/API issues in the new data-evolution materialized-read path: default projection can produce duplicate reserved score columns, and invalid vector-column names can silently return an empty reader. Please address these before merge.

for name in names {
if name == PKEY_VECTOR_POSITION_COLUMN
|| name == PKEY_VECTOR_SCORE_COLUMN
|| name == SEARCH_SCORE_COLUMN

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This reserved-name validation only runs for Some(names). With the default projection, a valid table column named __paimon_search_score is retained, and attach_scores_by_row_id then appends another field with the same name. I reproduced an output schema containing two __paimon_search_score columns. The legacy _PKEY_VECTOR_SCORE alias is likewise rejected only for explicit projections. Please validate the resolved/default field list as well, and add default-projection collision coverage for both PK and DE materialization.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch — fixed in e3dbea0. resolve_materialize_read_type now runs the reserved-name check on the resolved field list (both the explicit-projection and default cases), so a user column literally named __paimon_search_score / _PKEY_VECTOR_SCORE is rejected before it can collide with the appended score column. The raw-request check is kept for the explicit-projection path so a requested reserved name still gets the clear "reserved column" error rather than a confusing "not found" from field resolution. Added default-projection collision coverage for both the primary-key and data-evolution materialization paths.

// Data-evolution (global-index) vector search: materialize rows from the
// scored global row-ids and attach the unified score column. A non-vector
// column or a set filter fails loud inside execute_scored below.
self.execute_de_vector_read().await

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This fallback can turn an invalid vector-column name into a successful empty reader. evaluate_batch_vector_search returns an empty result when field lookup fails, and the new test explicitly expects with_vector_column("other") on the empty table to succeed. Through the C API, a typo therefore looks like normal EOF instead of an input error, contrary to the execute_read fail-loud contract. Please validate that the DE target field exists and has a supported vector type before returning an empty result, with core/C coverage for unknown and scalar columns.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in e3dbea0. execute_read on the data-evolution path now validates up front that the target column exists and is a FLOAT vector column (ARRAY<FLOAT> or VECTOR<FLOAT>) before doing any search, so an unknown, scalar, or non-float (e.g. ARRAY<INT>) column fails loud with InvalidInput instead of surfacing as an empty EOF reader through the C API. execute_scored's existing empty-on-unknown-field behavior is left unchanged. Added core coverage (unknown / scalar / non-float-array) and C FFI coverage (unknown and scalar columns mapping to InvalidInput).

…ed vector read

Address two review findings on the materialized read path:

- The reserved output-column-name check ran only for an explicit projection, so
  a user table column literally named __paimon_search_score (or the legacy
  _PKEY_VECTOR_SCORE alias) survived the default projection and collided with the
  score column appended during materialization, producing two identically named
  columns. The check now also runs on the resolved field list, covering the
  default projection, for both primary-key and data-evolution reads.

- The data-evolution execute_read fell through to a search that returns an empty
  result for an unknown field, so a typo'd or scalar vector column looked like a
  normal empty (EOF) read instead of an input error — violating execute_read's
  fail-loud contract, especially through the C API. execute_read now validates up
  front that the target column exists and is a FLOAT vector column (ARRAY<FLOAT>
  or VECTOR<FLOAT>); unknown, scalar, and non-float-element columns fail loud.

Tests: default-projection reserved-name collision (PK and DE); unknown, scalar,
and ARRAY<INT> columns fail loud on the core execute_read; unknown and scalar
columns fail loud through the C API.
@JunRuiLee
JunRuiLee force-pushed the feat/c-ffi-vector-search branch from d9530de to e3dbea0 Compare July 20, 2026 06:33

@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 the follow-up fixes. Reserved-column validation now covers the resolved default projection, invalid data-evolution vector targets fail loudly with appropriate input errors, and the focused core/C FFI regression tests pass locally. Looks good to me.

@JingsongLi
JingsongLi merged commit ddd8792 into apache:main Jul 20, 2026
12 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.

3 participants