Repository navigation
feat(global-index): support row filters in full-text search - #485
Open
SteNicholas wants to merge 1 commit into
Open
SteNicholas wants to merge 1 commit into
SteNicholas wants to merge 1 commit into
Conversation
SteNicholas
force-pushed
the
PAIMON-405
branch
from
November 2, 2026 07:06
ac9a26f to
85b89c9
Compare
lszskye
reviewed
Nov 6, 2026
lszskye
reviewed
Nov 6, 2026
SteNicholas
force-pushed
the
PAIMON-405
branch
2 times, most recently
from
November 6, 2026 19:10
63a3f85 to
239fe73
Compare
Add FullTextSearchBuilder::WithFilter() so that a full-text search ranks only the rows matching a predicate. - FullTextSearchBuilder splits the filter into partition and data predicates and passes the data filter to the scan and the read. - DataEvolutionFullTextScan attaches to each split the intersecting files of the single-field scalar indexes on the filter fields, and IndexFullTextSearchSplit serializes them in version 2. - DataEvolutionFullTextRead resolves the data filter into matched rows with those files before ranking, and searches each split among them only. GlobalIndexScanImpl and GlobalIndexEvaluatorImpl report the fields that decided the result, so that an exact answer can be told from a superset. - The new FilteredRowIdReader verifies the rows of a superset against the data when global-index.filter.refine-from-data is set; otherwise they are excluded with a warning. - StaticFromTagStartingScanner reads tags from the branch of the scan instead of the main branch. - Add the options scalar-index.search-mode, of which only fast is supported so far, and global-index.filter.refine-from-data.
SteNicholas
force-pushed
the
PAIMON-405
branch
from
November 6, 2026 19:59
239fe73 to
72deab3
Compare
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Purpose
Linked issue: close #405
A full-text search in Paimon C++ could not carry a row filter, so a caller had to filter after
ranking and got fewer than
limitrows. This addsFullTextSearchBuilder::WithFilter(), alignedwith Java
FullTextSearchBuilder#withFilter(apache/paimon#9855): only the matching rows areranked, so the result is the top k among them, and they reach the existing index as include row
ids, so BM25 statistics stay those of the full corpus.
FullTextSearchBuilder: validates the filter and splits it as JavaPartitionPredicate#splitPartitionPredicatesAndDataPredicatesdoes. Conjuncts over partitionfields alone are ANDed into the partition filter, and the rest become the data filter. A conjunct
over both partition and data fields stays in the data filter and also restricts the partitions it
implies, projected as Java
PredicateProjectionConverterdoes, so(p = 1 AND a > 0) OR (p = 2 AND a > 0)also searches onlyp = 1 OR p = 2. Repeated calls areANDed.
DataEvolutionFullTextScan: also loads the index files whose indexed field is a filterfield, and attaches to each split the intersecting files of the single-field scalar indexes on
the filter fields. Unlike in Java, the full-text indexes of every column are left out, as
lucene-ftsanswers every scalar predicate with all of its rows, and so are multi-field indexes,which the read leaves out of the evaluation anyway. A scalar index file whose row range is
negative, reversed or overflows int64 fails the plan.
IndexFullTextSearchSplit: version 2 of the encoding appends the scalar index files, asversion 2 of Java
IndexFullTextSearchSplitdoes; version 1 splits are still read.DataEvolutionFullTextRead::MatchedRows(): resolves the data filter before ranking with thescalar indexes of the splits, read per field as one union (
GlobalIndexScanImpl:: CreateForScalarFilters()andScanWithCoverage(), as JavaDataEvolutionGlobalIndexScanner).fastscalar-index.search-mode. Ifno index can evaluate the filter at all, nothing is searched and a warning is logged.
so the search never returns a non-matching row. With the new
global-index.filter.refine-from-dataoption (defaultfalse, Java description), they areverified against the data instead.
searchRowRanges ∧ liveRows ∧ matchedRows.GlobalIndexEvaluatorImpl: reports the fields that decided a result(
EvaluateWithContributingFields(), as JavaGlobalIndexEvaluator.Evaluation), so that asuperset can be told from an exact answer: not every filter field contributed, or there is a
Contains,EndsWithorLikeleaf. Unlike in Java, a field with a conjunct that no index couldevaluate does not count even if another of its conjuncts was evaluated, as the result is then
only a superset for that field.
FilteredRowIdReader: as in Java, reads only the filter columns and_ROW_IDof thecandidates, with prefetch, pinned to the planned snapshot and with the schema that the search
resolved, so a column renamed or replaced since then cannot point the filter at another field.
Unlike in Java, once the planned snapshot has expired, a search of
scan.tag-namereads the taginstead, which must still refer to the planned snapshot.
StaticFromTagStartingScanner: reads tags from the branch of the scan, as in Java, insteadof always from the main branch.
scalar-index.search-modefalls back onglobal-index.search-mode, as JavaCoreOptions#scalarIndexSearchModedoes. Onlyfastis supported so far; a row filter infullor
detailfails withNotImplemented, as does a filter with a full-text match.Tests
UT:
src/paimon/core/table/source/data_evolution_full_text_read_test.cpp, with fake scalar indexers:TestFilterRanksOnlyMatchedRows,TestFilterExcludesRowsThatIndexesCannotDecide(no scalarfile, a declining one, a multi-field one that the read leaves out, a partly indexed conjunction,
Contains/EndsWith/Like, rows that no scalar file covers),TestFilterUnionsIndexesOfDifferentTypes, and filter validation inTestBuilderValidatesSettings(unknown field,_ROW_ID, mismatched type, full-text match, ascalar search mode other than
fast, null filter).src/paimon/core/table/source/data_evolution_full_text_scan_test.cpp:TestScalarPreFilterFiles(only single-field indexes that do not support full-text search, without multi-field indexes or
an empty filter) and
TestIntersectingFiles, including invalid row ranges.src/paimon/core/table/source/full_text_search_split_test.cpp:TestSerializeRoundTrip(theversion 2 bytes are pinned and version 1 bytes still read),
TestSerializeScalarIndexFiles, andinvalid scalar index files in
TestDeserializeRejectsInvalidBytes,TestDeserializeRejectsCorruptEntriesandTestCreateValidatesArguments.src/paimon/core/table/source/filtered_row_id_reader_test.cpp:TestExactWhenEveryFilterFieldContributedandTestCandidateOnlyLeavesAreNotExact.src/paimon/core/global_index/global_index_evaluator_impl_test.cpp:ReportsContributingFields,AndWithEmptyResultShortCircuitsandCollectFieldIds.src/paimon/core/core_options_test.cpp:TestParseIndexSearchModefor the full-text and scalarfamilies, and
global-index.filter.refine-from-datainTestDefaultValueandTestFromMap.IT (
test/inte/global_index_test.cpp), aligned with JavaNativeFullTextRowFilterTestandFullTextSearchBuilderTest:TestFullTextTableFullTextSearchWithFilterandTestLuceneTableFullTextSearchWithFilter:equality, range,
IN,NotEqual,ORacross fields, no and all matching rows, equality on aBTree of the text column, accumulated filters, the top row among matching rows with
limit1,a column without an index or with only a full-text index, a partly indexed conjunction and
Contains/EndsWith/Likewith refine on and off, scalar index files attached only for filteredcolumns, serialized splits, and refine with
scan.snapshot-id,scan.tag-nameandscan.timestamp-millis.TestFullTextTableFullTextSearchWithPartitionAndDataFilterandTestLuceneTableFullTextSearchWithPartitionAndDataFilter: partition and data predicates combined,with
SetPartitionFilter()and refine, and the partitions that a mixed conjunct implies.TestFullTextSearchSkipsRowsDeletedByDeletionVectorsandTestLuceneSearchSkipsRowsDeletedByDeletionVectors: a filter with deletion vectors, a scalar indexfile per shard, and refine across shards.
TestFilteredRowIdReader: deletion vectors, a pinned older snapshot, a tag moved to anothersnapshot, the tag fallback once the snapshot has expired (also with an empty plan), a branch tag,
and a column renamed or replaced after planning.
API and Format
New public API:
FullTextSearchBuilder::WithFilter(), andOptions::SCALAR_INDEX_SEARCH_MODEandOptions::GLOBAL_INDEX_FILTER_REFINE_FROM_DATA. The serialized form of full-text search splits,which is specific to Paimon C++, moves to version 2 and still reads version 1. No storage format or
protocol change.
Documentation
Yes.
docs/source/user_guide/global_index.rstadds a "Row Filters" section covering the predicatesplit, implied partitions, the
fastmode, excluded candidates andglobal-index.filter.refine-from-data, and the docs ofFullTextSearchBuilder,FullTextScan,FullTextReadand the new options describe the filter.Generative AI tooling
Generated-by: Claude Code 2.1.311 (Claude Opus 5.5)
🤖 Generated with Claude Code