[core][spark][docs] Support row filters in full-text search - #9855
Open
zhuxiangyi wants to merge 1 commit into
Open
zhuxiangyi wants to merge 1 commit into
zhuxiangyi wants to merge 1 commit into
Conversation
Full-text search on data-evolution tables rejected non-partition filters, so users had to over-fetch and filter on the engine side, which is slow and drops matching rows ranked below the unfiltered top-k. The native reader already accepts a row-id bitmap (used for deletion vectors); this change feeds user predicates into it, mirroring the vector search pre-filter. - FullTextSearchBuilder.withFilter(Predicate): partition predicates are extracted as partition filters, the rest is applied before top-k ranking. - DataEvolutionFullTextScan attaches the scalar global index files of the filtered columns to each full-text split and, following scalar-index.search-mode, routes rows those indexes do not cover to the raw split (kept inside the full-text coverage in fast mode). - DataEvolutionFullTextRead resolves the filter through the scalar indexes into a row-id bitmap and ANDs it into the include set of every split; the raw path bounds its scan with the same indexes and evaluates the predicate row by row with executeFilter(). - Hybrid search forwards its filter to full-text routes, so both route types rank the same filtered candidate set. - Spark full_text_search and hybrid_search accept WHERE clauses on non-partition columns instead of throwing. - Primary-key full-text search still rejects row filters with a clear message; it is left for a follow-up. Tests cover filter-then-rank ordering, compound predicates, partial index coverage in fast and full modes, deletion vectors, multiple index ranges, split serialization, hybrid routes, the native engine, and Spark SQL.
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
Full-text search on data-evolution tables cannot be combined with a row predicate today. Spark rejects it outright:
So the most common retrieval pattern — top-k documents for this query, among rows where
category = 'x'/dt >= ...— forces users to over-fetch (limit × N) and filter on the engine side. That is slow and also wrong: the top-k is computed over the whole table, so rows that satisfy the filter but rank below k are silently dropped.The reason given in the message no longer holds.
NativeFullTextGlobalIndexReaderalready accepts a row-id bitmap (FullTextSearch.includeRowIds) and both read paths already pass one for deletion vectors and index coverage. Vector search already resolveswithFilter(Predicate)through scalar global indexes into exactly such a bitmap (AbstractDataEvolutionVectorRead.preFilters). This PR closes the gap by wiring user predicates into the same mechanism, mirroring the vector design so users learn one rule.What changes
Public API
FullTextSearchBuilder.withFilter(Predicate). Partition predicates in the filter are extracted and applied as partition filters; the remaining predicates are evaluated before top-k ranking, so the result is the top-k among matching rows. A default implementation throws, keeping third-party builders source compatible (same pattern asVectorSearchBuilder.withOptions).HybridSearchBuilder.withFilternow reaches full-text routes. Previously the filter was applied to vector routes only, so a filtered hybrid query fused filtered and unfiltered candidates.Scan (
DataEvolutionFullTextScan)IndexFullTextSearchSplitcarries the scalar files intersecting its range (scalarIndexFiles, serialized; split version bumped to 2, version 1 still deserializes).scalar-index.search-mode; infull-text-index.search-mode=fastthose ranges are additionally kept inside the full-text coverage.RawFullTextSearchSplitcarries the scalar files intersecting the raw ranges.Read (
DataEvolutionFullTextRead,RawFullTextReadImpl)DataEvolutionGlobalIndexScannerinto a row-id bitmap and AND-ed into every split's include set, next to the deletion-vector bitmap. The native call is unchanged, solimitis filter-then-rank inside the engine without over-fetching.rawPreFilter, as vector search does), reads only the text column plus the filter columns, and evaluates the predicate row by row withexecuteFilter().fastmode a filter no scalar index can evaluate excludes the indexed rows and logs aWARNonce per query.Coverage semantics (documented in
full-text.mdx)scalar-index.search-modefast(default)full/detailA partially indexed conjunction (
indexed = 1 AND unindexed = 2) is narrowed by the indexed members alone infastmode, so the candidate set is a superset and the engine's row-level filter still applies (same contract as vector search);fullmode is exact. Both are covered by tests.Spark
full_text_search(...)andhybrid_search(...)acceptWHEREclauses on non-partition columns;PaimonBaseScanpasses the pushed data filters to the builder instead of throwing. Predicates Spark cannot push down are applied after the search as for any scan.Deliberately unchanged (follow-ups)
UnsupportedOperationExceptionfrom the builder. It needs the per-file position mapping ofPrimaryKeyFullTextBucketSearchand the DV preconditionPrimaryKeyVectorScanhas; separate PR.sys.full_text_searchand PyPaimon do not expose the parameter yet; the core API is in place for both.pypaimonhybrid search keeps its current rejection.Tests
paimon-core—FullTextSearchBuilderTest(+12, 41 total) andPrimaryKeyFullTextSearchTest(+1):id >= 3, limit 2returns two of {3,4,5} with their original scores, never the higher-scoring rowswithFiltercalls accumulatefastmode without a scalar index excludes rows and produces no raw split;fullmode routes them to the raw path and returns the exact top-kfast+ scalarfull: the raw scan never leaves the full-text coverageRawFullTextSearchSplit.scalarIndexFilesnon-empty)fast, exact infull; OR with an unevaluable branchwithFilterscalarIndexFilesand equalitywithFilteron scan and read; the same table's non-PK column still takes the data-evolution pathpaimon-full-text—NativeFullTextRowFilterTestagainst the real native engine: 2,000 rows, 8 categories, btree oncategory; for every category,withFilter(category = c), limit 20equals the unfiltered full ranking restricted to that category (same rows, same BM25 scores, same cutoff).paimon-spark-ut—FullTextSearchTest(+7) andHybridSearchTest:WHEREon a btree column, a bitmap string column, a partition + data column, with deletion vectors,fastvsfullmode, a non-pushable predicate (id % 2 = 1) alone and mixed with a pushable one, and a hybrid full-text route withWHERE id = 1(replaces the former "rejects non-partition filters" test).Regression:
paimon-coretable.source.*+globalindex.**(346 tests) and the fullpaimon-full-textmodule pass.NativeFullTextGlobalIndexReaderTest.testCloseAttemptsAllResourcesWhenReaderCloseThrowsErrorfails identically on clean master (Mockito cannot mock the final native class in this environment) and is unrelated.Performance
NativeFullTextRowFilterTest.benchmarkRowFilterStrategies(manual,-DextraJavaTestArgs=-Dpaimon.benchmark=true), native engine, 200,000 rows, 100 categories,limit = 10, best of 10 after 3 warm-ups, Apple M-series:withFiltervia btree, 1% selectivewithFiltervia btree, dense bitmap (~99% of rows)limit × 100+ client-side filter (previous workaround)withFilteron an unindexed column,scalar-index.search-mode=full(raw scan of 200k rows)While benchmarking I found that
DataEvolutionFullTextRead.eval()asks the native reader forcandidateLimit(rowRangeStart, rowRangeEnd)— the whole range — rather than the userlimit, and materializes every candidate beforetopK(limit). That is what dominates the 312 ms baseline. It predates this PR (#8652) and is left as is here; I will open a separate issue/PR since it touches the compound-query contract (testCompoundFullTextSearchUsesFullLeafCandidatesBeforeFinalTopK).API and Format
FullTextSearchBuilder.withFilter(Predicate)with a throwing default.IndexFullTextSearchSplitJava serialization version 1 → 2 (appendsscalarIndexFiles; version 1 streams are still read).RawFullTextSearchSplitgains custom serialization forscalarIndexFiles. Splits are transient planning objects, no stored format changes.WHEREclauses now succeed instead of throwing.Documentation
docs/docs/multimodal-table/global-index/full-text.mdx: new Row Filters section (semantics, coverage table, partial-conjunction note), Spark SQL and Java examples.docs/docs/multimodal-table/global-index/hybrid-search.mdx: new Row Filters section.docs/docs/primary-key-table/global-index.mdx: the row-predicate limitation now scoped to primary-key full text, linking to the new section.