Repository navigation
branch-4.1: [fix](inverted index) Count only visible rows in BM25 statistics of merge-on-write tables #69438 - #69751
Merged
yiguolei merged 3 commits intoOct 29, 2026
Conversation
…tistics of merge-on-write tables apache#69438 Backport of apache#69438 to branch-4.1. On a merge-on-write table, BM25 score() gave the same row a different score on each replica of its tablet: the statistics counted rows a later load overwrote or deleted (marked only in the delete bitmap) and rows written with the delete sign until compaction removed them, and each replica compacts on its own schedule. When the scan reads a delete bitmap, CollectionStatistics now describes only the rows the scan returns: per segment, the rows the delete bitmap marks at the read version and the rows with the delete sign (read only for segments whose zone map shows a non-zero sign, with the query's segment and page cache settings) are left out of the document count and of each term's document frequency (counted by leaping over or walking alongside the postings), and the token count is the sum of the lengths the norms of the returned non-NULL rows stand for. NULL rows are left out of the token count through the index null bitmap. Norms are read per CLucene segment into a buffer of the statistics' own; the non-NULL norm length sum of a field and the length sums of its last few sets of hidden rows are kept with the cached searcher. A tablet whose rows are all hidden reports zero documents instead of failing. For every score() query, the statistics' I/O context follows the query's disable_file_cache, and a CLucene searcher the statistics open drops the query's I/O context once its meta is read, and a CLucene error while opening it fails the query instead of escaping the scanner thread. Adapted to branch-4.1: CollectionStatistics lives in storage/compaction and reads terms with TermIterator, so the hidden-row counting and norm reading are helpers in collection_statistics.cpp instead of IndexSource methods; the norm length sums are kept on InvertedIndexSearcherCache::CacheValue; the null bitmap read of InvertedIndexReader is exposed as read_inverted_index_null_bitmap(). branch-4.1 has no SNII format, so the SNII parts of apache#69438 are not included.
Contributor
|
Thank you for your contribution to Apache Doris. Please clearly describe your PR:
|
Contributor
|
run buildall |
…ollection_statistics.cpp Builds without precompiled headers failed on CL_NS_DEF from CLucene/util/Array.h.
Contributor
Author
|
run buildall |
…ica_statistics branch-4.1 fuzzes enable_common_expr_pushdown, and SEARCH() fails without it.
Contributor
Author
|
run buildall |
Contributor
BE Regression && UT Coverage ReportIncrement line coverage Increment coverage report
|
yiguolei
approved these changes
Oct 29, 2026
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.
What problem does this PR solve?
Issue Number: None
Related PR: #69438 (this is its backport to branch-4.1), #69352 and its branch-4.1 pick #69418 (BM25 statistics shared across the parallel scanners of a tablet)
Problem Summary:
Backport of #69438 to branch-4.1.
On a merge-on-write table, BM25
score()gives the same row a different score on each replica of its tablet. A query picks one replica per tablet, so withreplication_num = 3a row has up to three scores, and pagination overORDER BY score()repeats and skips rows from one page to the next.CollectionStatistics::process_segment()takes the document count, the token count and each term's document frequency straight from every segment's inverted index. Rows a later load overwrote or deleted (marked only in the delete bitmap) and rows written with the delete sign (CDC deletes,DELETE) stay in the index and are counted until compaction removes them, and each replica compacts on its own schedule.On branch-4.1 (3 BEs,
replication_num = 3, 8 buckets, CDC-style upserts and delete-sign deletes, the replicas on one BE fully compacted), the 1,731 matching rows of aSEARCH(... OR ...)query had 1,294 rows with more than one score over 15 runs, and all 1,731 differed betweenuse_fix_replica = 0/1/2.Fix (same as #69438):
When the scan reads a delete bitmap (merge-on-write),
CollectionStatisticsdescribes only the rows the scan returns:OlapScannerpasses the scan's delete bitmap and read version (MowHiddenRows) tocollect(), or toTabletCollectionStatistics::copy_to()for a split tablet (branch-4.1: [fix](inverted index) Share BM25 statistics across parallel scanners of a tablet #69352 #69418). Every scanner of a tablet reads with the same bitmap and version.get_agg), plus the rows whose__DORIS_DELETE_SIGN__is set. The delete-sign column is read only for segments whose zone map shows a non-zero sign (or cannot tell), following the query'senable_segment_cacheandenable_page_cache.IndexReader::norms(field, bytes)) and keep none. What they keep is small and lives with the cached searcher: a field's non-NULL norm length sum, and the length sums of the last four sets of hidden rows of a field. The hidden and NULL row ID lists are charged to the query's memory.Tables without a delete bitmap (duplicate key, merge-on-read,
skip_delete_bitmap) collect the same statistics as before.For every
score()query: the statistics' I/O context follows the query'sdisable_file_cache, and a CLucene searcher the statistics open drops the query's I/O context once its meta is read, as the query path does. A CLucene error while opening it now fails the query; before, it escaped the scanner thread and aborted the backend (reproduced on branch-4.1 byfault_injection_p0/test_bm25_score_statistics_searcher_io_context).Differences from #69438, for branch-4.1:
IndexSourcelayer. The hidden-row counting works onTermIterator(read_range/advance) and the norm reading on the CLucene reader directly; both are helpers instorage/compaction/collection_statistics.cpp(collection_statistics_detail).InvertedIndexSearcherCache::CacheValue; branch-4.1 has noCluceneSearcherCacheValue.InvertedIndexReader::read_null_bitmapis split so that the statistics can read the null bitmap through the query cache without anInvertedIndexReader(read_inverted_index_null_bitmap()).inverted_index_snii_read_no_write_file_cachedoes not exist on branch-4.1, so onlydisable_file_cacheis followed.Not covered (as in #69438): merge-on-read unique tables and duplicate tables with
DELETEpredicates; rows deleted by a delete predicate (enable_mow_light_delete); rows written with the delete sign that the query returns (skip_delete_sign,show_hidden_columns) are still left out of the statistics; an analyzed empty string counts as one token on a merge-on-write table. Statistics stay per tablet.Release note
Fix BM25
score()on merge-on-write tables differing between the replicas of a tablet, and before and after compaction. Statistics now count only the rows a query can return, and the average document length comes from the indexed norms.Check List (For Author)
Test
Regression test:
inverted_index_p0/test_bm25_score_replica_statistics(3 replicas, overwrites,DELETE, delete-sign rows, one replica fully compacted, scores compared with a freshly loaded table; NULL rows vs a duplicate table; all rows deleted) andfault_injection_p0/test_bm25_score_statistics_searcher_io_context(same suites as [fix](inverted index) Count only visible rows in BM25 statistics of merge-on-write tables #69438)..out:test_bm25_score.outand the V2/V3 merge-on-write cases oftest_score_filter_matrix.out, to the same values as [fix](inverted index) Count only visible rows in BM25 statistics of merge-on-write tables #69438.score()(inverted_index_p0,search,ann_index_p0,variant_p0,nereids_rules_p0,fault_injection_p0) pass with this change on a 3-BE cluster. Without it, the two new suites and the two updated ones fail; the fault-injection suite aborts two of the three backends with an uncaughtCLuceneError.Unit Test:
CollectionStatisticsTest.HiddenRowsAreCollectedLikeCompactedAway(real CLucene segments: a segment with a hidden 45-token row collects the same doc count, token count, df, avgdl and idf as the segment without it),CollectionStatisticsTest.AllRowsHiddenCollectsEmptyStatistics,CollectionStatisticsTest.NullRowsCountAsDocumentsWithoutTokens(real segment with NULL rows, with and without a delete bitmap),CollectionStatisticsTest.CountListedDocsLeapsOrScansOverHiddenRows(both counting paths, dense blocks, norm length sums),CollectionStatisticsHiddenRowsTest.*(exclude_hidden_docswith and without norms and NULL rows, rows beyond the index),InvertedIndexSearcherCacheValueTest.*(norm length sums computed once per field and per set of hidden rows, failures not kept, oldest set evicted),MultiSegmentCollectorTest.NormLengthSumsReadEachSegment(an index of three CLucene segments sums its norms per segment, equal to the combined norms),OlapScannerTest.BuildScoreRuntimeCollectionIoContextFollowsQueryFileCacheOptions. With the existing collection statistics, collection similarity, BM25, searcher cache, searcher builder, olap scanner, multi-segment, boolean query, null predicate andSEARCHfunction tests: 233 tests in 26 suites pass in an ASAN build on macOS arm64.Manual test (3 BEs, macOS arm64,
replication_num = 3, auto compaction off,enable_sql_cache = false), base = branch-4.1 without this change, same data and compaction state for both builds:merge_type: DELETE, group commit, keyword and lowercase-normalizer indexes, partitions, variant: rows differing between replicas after one replica compactedCost, query
SEARCH('content:jobs OR content:try') ORDER BY score() DESC LIMIT 100, one bucket, median of 15 warm runs:StatisticsCollectTime, one scannerThe first query after a restart also sums each segment's norms (8 M rows: 5 ms → 32 ms; 2.2 M hidden: 6 ms → 99 ms). The cost grows with the rows compaction has not removed yet.
Memory (BE allocated bytes, sum of 3 BEs, a 4-field merge-on-write table of 41 segments): after 4 queries +16 MB → +22 MB with key pruning and +109 MB → +75 MB scoring all segments, within the noise of the measurement.
Behavior changed:
Does this need documentation?
Check List (For Reviewer who merge this PR)