Repository navigation
feat(global-index): support table-level full-text search for data-evolution tables - #461
Merged
Merged
Conversation
SteNicholas
force-pushed
the
PAIMON-404
branch
2 times, most recently
from
October 24, 2026 16:40
531e393 to
7424815
Compare
lszskye
reviewed
Oct 29, 2026
lszskye
reviewed
Oct 29, 2026
lszskye
reviewed
Oct 29, 2026
lszskye
reviewed
Oct 29, 2026
lszskye
reviewed
Oct 29, 2026
…lution tables Add FullTextSearchBuilder, FullTextScan, FullTextRead and serializable full-text search splits, aligned with Java FullTextSearchBuilder, DataEvolutionFullTextScan and DataEvolutionFullTextRead. - The scan picks its snapshot as Java TimeTravelUtil does: from scan.snapshot-id, scan.tag-name, scan.timestamp or scan.timestamp-millis, or else the latest one. It first checks them against scan.mode with SchemaValidation::ValidateStartupMode, now implemented as Java SchemaValidation does, so that for example scan.snapshot-id is rejected with latest-full. The latest schema of the table is still used, not that of the snapshot. - It applies the partition filter and plans one split per index shard whose indexer supports full-text search. When shards overlap, including multi-field indexes that carry the column as an extra field, each row is ranked by one shard only. A full-text or lucene-fts index whose backend is not built fails the plan instead of being skipped. - The read searches the splits in parallel and keeps the global top-k. With deletion vectors it ranks only the rows live in the snapshot the splits were planned from, loading that snapshot from a tag once it has expired. - Splits carry their snapshot id and use a versioned encoding, so scan and read can run in different processes. The encoding is specific to Paimon C++, as Java serializes its splits with Java serialization. Decoding rejects a null buffer and checks every count, range and binary row layout before reading it, and the read rejects a split whose index files no longer index the column, for example after a column of the same name replaced it. - Add GlobalIndexer::SupportsFullTextSearch(), true for full-text and lucene-fts. - Add the full-text-index.search-mode and global-index.search-mode options, resolved as Java CoreOptions#fullTextIndexSearchMode does. Only the rows that an index covers are searched, so the builder rejects the full and detail modes with NotImplemented. - Share the scored top-k merge, the row id range helpers and executor creation with the existing global index reader and scan. The merge is also compiled into paimon_global_index, whose union reader calls it. Row filters are not supported yet, so splits do not carry the scalar index files that Java uses to pre-filter rows. Columns with a primary-key full-text index are rejected with NotImplemented for now.
SteNicholas
force-pushed
the
PAIMON-404
branch
from
October 29, 2026 19:58
7424815 to
83a29ba
Compare
SteNicholas
added a commit
to SteNicholas/paimon-cpp
that referenced
this pull request
Nov 2, 2026
Every Lucene global index reader and writer constructed its own cppjieba::Jieba, reloading about 11.6MB of dictionaries each time. A table-level full-text search creates one reader per shard, so every query paid this cost once per shard. After the table-level full-text search tests were added in apache#461, paimon-global-index-test on tsan-x86_64 grew from about 1400s to about 3500s and pushed the job past its 120-minute timeout. Add LuceneUtils::GetJieba(), which loads each dictionary directory once per process and shares a const segmenter with all readers and writers. Segmentation only calls const cppjieba methods, which keep no mutable state and are safe to call concurrently. The cache is keyed by the canonical absolute path, so a relative PAIMON_JIEBA_DICT_DIR resolves against the current working directory instead of reusing dictionaries loaded from another one, and a directory that cannot be resolved returns an IOError instead of aborting in cppjieba. Document how a relative directory is resolved and that dictionary changes take effect only after the process restarts. On tsan-x86_64, this cuts paimon-global-index-test from about 3500s to about 160s, and the job finishes in about an hour instead of timing out. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This was referenced Nov 2, 2026
SteNicholas
added a commit
to SteNicholas/paimon-cpp
that referenced
this pull request
Nov 2, 2026
Every Lucene global index reader and writer constructed its own cppjieba::Jieba, reloading about 11.6MB of dictionaries each time. A table-level full-text search creates one reader per shard, so every query paid this cost once per shard. After the table-level full-text search tests were added in apache#461, paimon-global-index-test on tsan-x86_64 grew from about 1400s to about 3500s and pushed the job past its 120-minute timeout. Add LuceneUtils::GetJieba(), which loads each dictionary directory once per process and shares a const segmenter with all readers and writers. Segmentation only calls const cppjieba methods, which keep no mutable state and are safe to call concurrently. The cache is keyed by the canonical absolute path, so a relative PAIMON_JIEBA_DICT_DIR resolves against the current working directory instead of reusing dictionaries loaded from another one, and a directory that cannot be resolved returns an IOError instead of aborting in cppjieba. Document how a relative directory is resolved and that dictionary changes take effect only after the process restarts. On tsan-x86_64, this cuts paimon-global-index-test from about 3500s to about 160s, and the job finishes in about an hour instead of timing out.
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 #404
Paimon C++ only exposed the reader primitive for full-text search: a caller had to call
GlobalIndexScan::CreateReaders, runVisitFullTextSearchon the readers and pass the result toScanContextBuilder::SetGlobalIndexResult, choosing index types and shard ranges along the way,with no serializable plan for running scan and read in different processes. This adds the
table-level API, aligned with Java
FullTextSearchBuilder,DataEvolutionFullTextScanandDataEvolutionFullTextRead.FullTextSearchBuilder:SetQuery,SetLimit,SetPartitionFilter,AddOption/SetOptions,WithMemoryPool/WithExecutor/WithFileSystem,NewFullTextScan(),NewFullTextRead()andExecuteLocal(). As in Java, the query column must exist in the latestschema and a read requires a positive limit. A column with a primary-key full-text index
(
pk-full-text.index.columns) is rejected withNotImplementedfor now; Java searches it withPrimaryKeyFullTextScanandPrimaryKeyFullTextRead.full-text-index.search-modeandglobal-index.search-modeoptions areresolved as Java
CoreOptions#fullTextIndexSearchModedoes (the full-text option, or else theglobal one, or else
fast). Only the rows that an index shard covers are searched, as infast,so the builder rejects
fullanddetailwithNotImplementeduntil [Feature] Support full-text search modes for unindexed row ranges #406 instead of silentlyleaving out rows written since the index was built.
DataEvolutionFullTextScan: first checks the time-travel options againstscan.modewithSchemaValidation::ValidateStartupMode, now implemented as JavaSchemaValidation#validateStartupMode(it was aNotImplementedstub), so for examplescan.snapshot-idis rejected withlatest-full. It then resolves the snapshot as JavaTimeTravelUtil#tryTravelOrLatestdoes: fromscan.snapshot-id,scan.tag-name,scan.timestampor
scan.timestamp-millis, or else the latest one. The column, partition keys and options stillcome from the latest schema, not from that of the snapshot (TODO). The scan keeps the index files
that pass the partition filter, index the column as their indexed field or as an extra field, and
whose indexer supports full-text search. As Java
chooseIndexRangesdoes ([global-index] Fix full-text range selection and scalar-filter coverage for multi-field indexes paimon#8547), itgives every row to one shard across overlapping and multi-field indexes, a dedicated index first,
then by index identity, start and end, and plans one split per chosen shard with its physical
range and its assigned search row ranges. A
full-textorlucene-ftsindex whose backend is notbuilt fails the plan instead of silently dropping its rows.
DataEvolutionFullTextRead: reads each split with an indexer created from its own indexfiles, including their extra fields, through
OffsetGlobalIndexReader, ranking only its searchrow ranges and live rows, and asks each split for
limitcandidates only ([core] Request only top-k candidates from data-evolution full-text indexes paimon#9955).Splits are searched in parallel on the given executor, or on one with
global-index.thread-numthreads. The results are merged, keeping the first score of a duplicate row id, and cut to the
global top-k by score descending, then row id ascending. Live rows are pinned to the snapshot the
splits were planned from ([core] Support full-text indexes on DV tables paimon#9031), which is loaded from a tag once it has expired.
FullTextSearchSplit/IndexFullTextSearchSplit: carry the snapshot id, column, physicalrange, search row ranges and index files, with a versioned encoding specific to Paimon C++. Java
serializes
IndexFullTextSearchSplitwith Java object serialization and pypaimon has noserialized form, so there is no cross-language format to match, as [Feature] Support table-level full-text search for data-evolution tables #404 notes. Deserialization
rejects a null buffer and checks every count, range and binary row layout before decoding it, and
the read rejects splits from different snapshots, of another column, or whose index files no
longer index the field id of the column, such as after it was replaced.
GlobalIndexer::SupportsFullTextSearch():falseby default andtrueforfull-textandlucene-fts, as JavaGlobalIndexerFactory#supportsFullTextSearch.ScoredGlobalIndexResultUtils::MergeTopK), the row id range helpers(
GlobalIndexUtils::RowIdsOf/RowIdsWithin) and executor creation(
GlobalIndexScanImpl::GetOrCreateExecutor) are shared with the existing union reader and scan.The merge is also compiled into
paimon_global_index, whose union reader calls it.Not part of this change:
withFilter, [Feature] Support row filters in full-text search #405), including the scalar index files that version 2 of JavaIndexFullTextSearchSplitcarries to pre-filter rows (TODO at the split version).fullanddetailsearch modes ([Feature] Support full-text search modes for unindexed row ranges #406), which also search the rows that no index covers.Tests
UT:
src/paimon/core/table/source/data_evolution_full_text_scan_test.cpp:TestIndexIdentityAndIndexedFields,TestDisjointShards(including a shard ending atINT64_MAX),TestOverlappingShardsOfOneIndex,TestDedicatedIndexBeforeExtraField,TestIdentityOrderBreaksTiesandTestServesFullTextSearch.src/paimon/core/table/source/data_evolution_full_text_read_test.cpp, with a fake full-textindexer that records each search:
TestMergesShardResultsIntoGlobalTopK,TestRanksOnlySearchRowRanges,TestIncludeRowIds,TestReadsEachSplitWithItsOwnIndexFields,TestRejectsInvalidSplits,TestRejectsSplitOfReplacedColumn,TestBuilderValidatesSettings(including a rejected
fullsearch mode),TestBuilderRejectsPrimaryKeyFullTextIndexandTestResolvesPlanSnapshot.src/paimon/core/table/source/full_text_search_split_test.cpp:TestSerializeRoundTrip(thecomplete version 1 bytes are pinned, with the index file entries cross-checked against Java
IndexFileMetaSerializer),TestDeserializeRejectsInvalidBytes,TestDeserializeRejectsCorruptFileLayout,TestDeserializeRejectsCorruptEntriesandTestCreateValidatesArguments.src/paimon/core/schema/schema_validation_test.cpp:TestStartupMode, everyscan.modewithmissing, conflicting and valid time-travel options.
src/paimon/core/core_options_test.cpp:TestParseFullTextIndexSearchMode, the fallback toglobal-index.search-mode, case-insensitive values and invalid values.src/paimon/common/global_index/global_index_utils_test.cpp:TestRowIdsOfAndWithin.SupportsFullTextSearch()is checked inglobal_indexer_factory_test.cpp(bitmap),full_text_global_index_test.cppandlucene_global_index_test.cpp.IT (
test/inte/global_index_test.cpp):TestFullTextTableFullTextSearchandTestLuceneTableFullTextSearch: two shards, the tablesearch equals the reader search for every limit, serialized splits read back, the snapshot
selected by
scan.snapshot-id(also withscan.mode=from-snapshot, and rejected withlatest-full),scan.tag-nameandscan.timestamp-millis, a plan of an earlier snapshot,reading
_INDEX_SCORE, and a bitmap index on the column is ignored. While only the first shard isindexed,
fastreturns only the indexed rows, even withglobal-index.search-mode=full, andfullanddetailare rejected.TestFullTextSearchOverlappingIndexRangesandTestLuceneSearchOverlappingIndexRanges: theplanned shards and search ranges, and the table search equals the reader search.
TestFullTextSearchSkipsRowsDeletedByDeletionVectorsandTestLuceneSearchSkipsRowsDeletedByDeletionVectors: only live rows are ranked, a plan made beforethe deletion still ranks its rows, the read falls back to a tag once the snapshot is deleted,
including when an unrelated tag is deleted while the tags are listed, and fails when the snapshot
is missing or unreadable.
TestWriteCommitScanReadFullTextIndexWithPartitionandTestWriteCommitScanReadLuceneIndexWithPartition: partition filters.API and Format
New public API in
include/paimon/table/source/:full_text_search_builder.h,full_text_scan.h,full_text_read.handfull_text_search_split.h, all markedPAIMON_EXPORT.GlobalIndexergains the virtual
SupportsFullTextSearch(), which defaults tofalse, andOptionsgainsGLOBAL_INDEX_SEARCH_MODEandFULL_TEXT_INDEX_SEARCH_MODE. Full-text search splits have a newserialized form (magic
PFTSPLT1, version 1), specific to Paimon C++. No storage format orprotocol change.
Documentation
Yes.
docs/source/user_guide/global_index.rstadds a "Table-Level Full-Text Search" section,covering local search, the supported search mode, pinning the data scan to the plan snapshot and
distributed search with serialized splits, and
docs/source/api/global_index.rstlists the newclasses.
Generative AI tooling
Generated-by: Claude Code 2.1.309 (Claude Opus 5.5)
🤖 Generated with Claude Code