Skip to content

[core] Refine candidate-only scalar index answers before vector top-k - #9953

Merged
JingsongLi merged 3 commits into
apache:masterfrom
zhuxiangyi:vector-search-filter-exactness
Sep 20, 2026
Merged

JingsongLi merged 3 commits into
apache:masterfrom
zhuxiangyi:vector-search-filter-exactness

Conversation

@zhuxiangyi

@zhuxiangyi zhuxiangyi commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

Bug

A vector search with a row filter can return a row that does not satisfy the filter and drop the row that does. The scalar global index answer is used directly as the ANN include bitmap, but global index answers are candidates, not exact matches:

  • BTree answers contains / endsWith / like with every non-null row (BTreeIndexReader).
  • GlobalIndexEvaluator drops an AND conjunct that no index can evaluate.

The ANN ranks that superset, so a non-matching but closer row takes the top-k slot. The engine-side filter then removes it, but the matching row was already cut by top-k and cannot come back: the user gets fewer rows than exist, possibly none.

#9909 handled the case where no index can evaluate the filter at all; this is the remaining case where an index does answer, but with a superset. The same mechanism was caught in the review of #9855 for the new full-text pre-filter; the vector pre-filter has had it since #8459.

Reproduce

Data-evolution table (id INT, name STRING, vec ARRAY<FLOAT>), three rows, a vector index on vec and a BTree index on name:

id name vec
0 alpha (1.0, 0.0)
1 beta zeta (0.6, 0.8)
2 gamma (0.0, 1.0)

Query vector (1.0, 0.0), limit 1:

table.newVectorSearchBuilder()
     .withVector(new float[] {1.0f, 0.0f})
     .withVectorColumn("vec")
     .withLimit(1)
     .withFilter(builder.contains(1, BinaryString.fromString("zeta")))   // name contains 'zeta'
     .executeLocal()
     .results();
// expected: [1]   actual on master: [0]

Row 0 is the nearest neighbour but its name does not contain zeta; row 1 is the only match. The BTree returns all three rows as candidates, the ANN keeps row 0, and row 1 never appears. After the engine filters row 0 out, the query returns nothing.

Same outcome with a BTree on id only and the filter id >= 0 AND name = 'beta zeta': the name conjunct is dropped, every row is a candidate, and row 0 is returned instead of row 1.

Both are VectorSearchRowFilterExactnessTest.testContainsOnBTreeColumnRanksOnlyMatchingRows and testPartiallyIndexedConjunctionRanksOnlyMatchingRows; they fail on master and pass with this change.

Fix

AbstractDataEvolutionVectorRead.scalarMatchedRows uses scanWithCoverage; an answer that is not exact is never ranked. With global-index.filter.refine-from-data=true the candidates are verified through FilteredRowIdReader (added in #9855): read only the filter columns of the candidate rows, bounded to the split ranges, and evaluate the predicate on the data. With the default false they are excluded with a warning, so the result can hold fewer rows than requested but never a non-matching one — which the Java and Python API could not filter out afterwards. The option also gates the full-text refinement path from #9855. Rows whose filter columns have no index keep following scalar-index.search-mode. Batch vector search and hybrid vector routes go through the same path.

The exactness check — contributingFieldIds covers every predicate field and there is no Contains / EndsWith / Like leaf — moves from DataEvolutionFullTextRead to FilteredRowIdReader.isExact, so full-text and vector search share one rule.

rawPreFilter is unchanged: it only bounds the raw scan, and the raw read still evaluates the predicate with executeFilter().

Tests

VectorSearchRowFilterExactnessTest (12): the two reproductions above; endsWith / like; a candidate set that refines to nothing; an OR with an unevaluable branch; candidates refined per index range with two vector index files; refinement combined with deletion vectors; batch vector search and a hybrid vector route; a filter column with no index at all in fast and full mode (guards the #9909 behaviour). With the option off: the default is off and no data is read; every candidate-only operator and a partially indexed conjunction are excluded while exact operators on the same index still answer; batch and hybrid are excluded too; unindexed columns are still read in full mode.

FullTextSearchBuilderTest: the candidate-only and partially indexed cases are parameterized over fast / full × option on / off (48 total). Spark FullTextSearchTest: LIKE '%zeta%' on a BTree column is empty by default and returns the matching row after ALTER TABLE ... SET ('global-index.filter.refine-from-data' = 'true'); = on the same index answers either way.

Regression: paimon-core table.source.* + globalindex.** (377), VectorSearchBuilderTest (41), FullTextSearchBuilderTest (48), PrimaryKeyVectorSearchTest (8); Spark VectorSearchOptionsTest, PrimaryKeyVectorSearchTest, HybridSearchTest, FullTextSearchTest, TableValuedFunctionsTest (57); ConfigOptionsDocsCompletenessITCase.

API and Format

New table option global-index.filter.refine-from-data (boolean, default false), read by vector, hybrid and full-text search. No format changes. Behaviour change: a search whose filter is answered by the scalar index as candidates no longer ranks the superset; by default those candidates are excluded, with the option on they are verified from the data before ranking.

Vector search handed the raw scalar global index answer to the ANN as the
include bitmap. Global index answers are only candidates: BTree answers
contains / endsWith / like with every non-null row, and the evaluator
drops a conjunct no index can evaluate. Ranking that superset lets a
non-matching but closer row take a top-k slot, and the engine's filter
afterwards cannot bring the dropped matching row back, so users see fewer
rows than exist.

With rows (alpha, (1,0)), (beta zeta, (0.6,0.8)), (gamma, (0,1)), query
(1,0) and limit 1, both `contains(name, 'zeta')` with a BTree on name and
`id >= 0 AND name = 'beta zeta'` with a BTree on id only returned row 0
instead of row 1.

The same mechanism was caught in the review of apache#9855 for the new full-text
pre-filter; the vector pre-filter has had it since apache#8459. apache#9909 covered the
case where no index can evaluate the filter; this covers the case where the
index answers with a superset.

scalarMatchedRows now uses scanWithCoverage and, when the answer is not
exact, refines the candidates through FilteredRowIdReader, bounded to the
split ranges. The exactness check moves from DataEvolutionFullTextRead to
FilteredRowIdReader so both searches share one rule. Batch vector search
and hybrid vector routes go through the same path.
@JingsongLi

Copy link
Copy Markdown
Contributor

I can envision the severe single-thread performance bottleneck caused by FilteredRowIdReader. Please add an option to disable FilteredRowIdReader by default, applicable to both Vector queries and Full-Text queries.

@zhuxiangyi

Copy link
Copy Markdown
Contributor Author

@JingsongLi

Agreed, the refinement is a single-threaded read on the caller side, and a BTree answering contains makes it a whole column. I'll make it opt-in:

  • New option global-index.filter.refine-from-data (boolean, default false), read by vector, hybrid and full-text search.
  • When false, a scalar index answer that may be a superset (a dropped conjunct, or contains / endsWith / like on a BTree) is excluded instead of ranked, with a WARN. The result can be short but never contains a non-matching row; using the superset directly would return wrong rows through the Java/Python API, which has no engine-side post-filter.
  • When true, the current behaviour: refine the candidates with FilteredRowIdReader.
  • The full-text refinement path from [core][spark][docs] Support row filters in full-text search #9855 is switched to the same option.

One thing I would leave outside the option: in scalar-index.search-mode=full, rows whose filter columns have no index are already read from data (that replaced the temporary-index rebuild in #9855, which was more expensive). Please say if you want that gated as well.

Reading the filter columns of candidate rows is a single-threaded read on
the caller, and a BTree answering contains makes it a whole column. Gate
it behind global-index.filter.refine-from-data (default false) for vector,
hybrid and full-text search. When the option is off, an index answer that
may be a superset is excluded from the search with a warning: the result
can hold fewer rows than requested but never a non-matching row, which the
Java and Python API could not filter out afterwards. Rows whose filter
columns have no index keep following scalar-index.search-mode.
* support). {@code null} means "cannot decide", never "no rows match".
*/
@Nullable
/**

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.

This new javadoc was inserted after the old one, so scalarMatchedRows now carries two stacked javadoc blocks (@Nullable is sandwiched between them at line 224). The first block (lines 219-223) is the stale description of the previous no-refinement behavior — could you drop it so only the new doc remains directly above the method?

@zhuxiangyi

Copy link
Copy Markdown
Contributor Author

@JingsongLi Done. ba51fca adds global-index.filter.refine-from-data (default false) for vector, hybrid and full-text search; when off, candidate-only answers are excluded with a warning. 2f2bc83 merges the stacked javadoc. The scalar-index.search-mode=full data read for unindexed columns is left as is — say if you want it gated too.

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

Requirement fit: SUPPORTED (triage: GO)

Implementation: CLEAN

The opt-in refinement addresses a real false-negative case when an inexact scalar-index candidate set feeds ANN/top-k. The implementation centralizes exactness, pins refinement to the search snapshot, applies the row predicate only when coverage is inexact, and retains the requested default-off behavior. The tests cover scalar-only, vector, full-text, deleted rows, missing fields, and snapshot consistency. I did not find an actionable issue, and CI is green.

@JingsongLi
JingsongLi merged commit 1bd9271 into apache:master Sep 20, 2026
18 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