[core] Refine candidate-only scalar index answers before vector top-k - #9953
Conversation
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.
|
I can envision the severe single-thread performance bottleneck caused by |
|
Agreed, the refinement is a single-threaded read on the caller side, and a BTree answering
One thing I would leave outside the option: in |
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 | ||
| /** |
There was a problem hiding this comment.
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?
|
@JingsongLi Done. ba51fca adds |
JingsongLi
left a comment
There was a problem hiding this comment.
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.
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:
contains/endsWith/likewith every non-null row (BTreeIndexReader).GlobalIndexEvaluatordrops anANDconjunct 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 onvecand a BTree index onname:Query vector
(1.0, 0.0),limit 1: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
idonly and the filterid >= 0 AND name = 'beta zeta': thenameconjunct is dropped, every row is a candidate, and row 0 is returned instead of row 1.Both are
VectorSearchRowFilterExactnessTest.testContainsOnBTreeColumnRanksOnlyMatchingRowsandtestPartiallyIndexedConjunctionRanksOnlyMatchingRows; they fail on master and pass with this change.Fix
AbstractDataEvolutionVectorRead.scalarMatchedRowsusesscanWithCoverage; an answer that is not exact is never ranked. Withglobal-index.filter.refine-from-data=truethe candidates are verified throughFilteredRowIdReader(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 defaultfalsethey 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 followingscalar-index.search-mode. Batch vector search and hybrid vector routes go through the same path.The exactness check —
contributingFieldIdscovers every predicate field and there is noContains/EndsWith/Likeleaf — moves fromDataEvolutionFullTextReadtoFilteredRowIdReader.isExact, so full-text and vector search share one rule.rawPreFilteris unchanged: it only bounds the raw scan, and the raw read still evaluates the predicate withexecuteFilter().Tests
VectorSearchRowFilterExactnessTest(12): the two reproductions above;endsWith/like; a candidate set that refines to nothing; anORwith 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 infastandfullmode (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 infullmode.FullTextSearchBuilderTest: the candidate-only and partially indexed cases are parameterized overfast/full× option on / off (48 total). SparkFullTextSearchTest:LIKE '%zeta%'on a BTree column is empty by default and returns the matching row afterALTER TABLE ... SET ('global-index.filter.refine-from-data' = 'true');=on the same index answers either way.Regression:
paimon-coretable.source.*+globalindex.**(377),VectorSearchBuilderTest(41),FullTextSearchBuilderTest(48),PrimaryKeyVectorSearchTest(8); SparkVectorSearchOptionsTest,PrimaryKeyVectorSearchTest,HybridSearchTest,FullTextSearchTest,TableValuedFunctionsTest(57);ConfigOptionsDocsCompletenessITCase.API and Format
New table option
global-index.filter.refine-from-data(boolean, defaultfalse), 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.