Skip to content

branch-4.2: [fix](inverted index) Count only visible rows in BM25 statistics of merge-on-write tables #69438 - #69752

Merged
yiguolei merged 1 commit into
apache:branch-4.2from
guptashivam04:backport/4.2-bm25-visible-row-statistics
Oct 29, 2026
Merged

yiguolei merged 1 commit into
apache:branch-4.2from
guptashivam04:backport/4.2-bm25-visible-row-statistics

Conversation

@guptashivam04

Copy link
Copy Markdown
Contributor

What problem does this PR solve?

Issue Number: None

Related PR: #69438 (this is its backport to branch-4.2), #69352 and its branch-4.2 pick #69440 (BM25 statistics shared across the parallel scanners of a tablet)

Problem Summary:

Backport of #69438 to branch-4.2.

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 with replication_num = 3 a row has up to three scores, and pagination over ORDER 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.2 (3 BEs, replication_num = 3, 8 buckets, CDC-style upserts and delete-sign deletes, the replicas on one BE fully compacted), all 1,731 matching rows of a SEARCH(... OR ...) query got more than one score over 15 runs and differed between use_fix_replica = 0/1/2.

Fix: the same change as #69438. When the scan reads a delete bitmap (merge-on-write), CollectionStatistics describes only the rows the scan returns:

  • OlapScanner passes the scan's delete bitmap and read version (MowHiddenRows) to collect(), or to TabletCollectionStatistics::copy_to() for a split tablet (branch-4.2: [fix](inverted index) Share BM25 statistics across parallel scanners of a tablet #69352 #69440).
  • Per segment, the hidden rows are the rows the delete bitmap marks at the read version 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's enable_segment_cache and enable_page_cache.
  • Document count: the index's document count minus the hidden rows. Term document frequency: IndexSource::scoring_doc_freq_among() subtracts the hidden rows a term's postings list, leaping between them when they are much rarer than the postings, otherwise walking them alongside the decoded postings.
  • Token count: the sum of the document lengths the norms of the returned non-NULL rows stand for (NULL rows left out through the index's null bitmap), or the returned row count for an index without norms. Every replica counts the same, compacted or not.
  • CLucene norms are read one segment at a time into a buffer of the statistics' own and not kept; the non-NULL norm length sum of a field and the length sums of its last four sets of hidden rows are kept with the cached searcher (CluceneSearcherCacheValue).
  • A tablet whose rows are all hidden reports zero documents instead of failing the query.

Tables without a delete bitmap collect the same statistics as before. For every score() query, the statistics' I/O context follows disable_file_cache and inverted_index_snii_read_no_write_file_cache, and a CLucene searcher the statistics open drops the query's I/O context once its meta is read; a CLucene error while opening it fails the query.

Differences from #69438, for branch-4.2: segments are identified by their id instead of RowsetSegmentView, and the delete-sign column reader comes from Segment::get_column_reader() (branch-4.2 has no get_column_reader_for_pruning), a segment without the column reading as no deletes. The tests are adapted the same way.

Not covered (as in #69438): merge-on-read unique tables and duplicate tables with DELETE predicates; 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; an analyzed empty string, and on SNII any text that yields no token, counts as one token on a merge-on-write table; SNII norm length sums are not cached. 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:

    • Unit Test: the tests of [fix](inverted index) Count only visible rows in BM25 statistics of merge-on-write tables #69438 (CollectionStatisticsTest.HiddenRowsAreCollectedLikeCompactedAway, AllRowsHiddenCollectsEmptyStatistics, NullRowsCountAsDocumentsWithoutTokens, CollectionStatisticsHiddenRowsTest.* for count_listed_docs, exclude_hidden_docs and exclude_hidden_term_docs, CluceneSearcherCacheValueTest.*, MultiSegmentCollectorTest.NormLengthSumsReadEachSegment, OlapScannerTest.BuildScoreRuntimeCollectionIoContextFollowsQueryFileCacheOptions), with the existing collection statistics, BM25, searcher cache, multi-segment, query engine, SNII and SEARCH function tests: 427 tests in 35 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.2 without this change, same data and compaction state for both builds:

      Check branch-4.2 with this change
      CDC-style table (8 buckets, upserts, delete-sign deletes, NULLs, 4 fields), one BE's replicas fully compacted with inverted index compaction off: rows with more than one score over 15 runs / differing between replicas 0, 1, 2 (of 1,731) 1,731 / 1,731 0 / 0
      Same, scores differing from a freshly loaded copy of the visible rows — 0 of 1,731
      Partial-column updates, sequence column, stream load with the delete sign and merge_type: DELETE, group commit, keyword and lowercase-normalizer indexes, partitions, variant, SNII: rows differing between replicas after one replica compacted 84–790 per table (8 of 9 checks) 0 in every check, all equal to a fresh copy
      Merge-on-write vs duplicate table with 50 % NULL rows, V3 and SNII 0 of 250 differ 0 of 250 differ

      Cost, query SEARCH('content:jobs OR content:try') ORDER BY score() DESC LIMIT 100, one bucket, median of 15 warm runs; StatisticsCollectTime with one scanner, query total of SEARCH('content:jobs') with the default parallel scan:

      Table Statistics Query total
      V2, 8 M rows + 200 k overwritten + 50 k delete-sign rows 0.3 ms → 6.7 ms 68 ms → 76 ms
      V3, 6 M rows + 2 M overwritten + 200 k delete-sign rows (2.2 M hidden, 6 % NULL) 0.3 ms → 55 ms 61 ms → 113 ms
      V2, 6 M rows + 2 M overwritten, no NULL rows 0.3 ms → 7.2 ms 56 ms → 61 ms
      SNII, 2 M rows + 500 k overwritten + 200 k delete-sign rows 0.2 ms → 23 ms 49 ms → 71 ms

      The cost grows with the rows compaction has not removed yet. With NULL rows, most of it is leaving the NULL rows out of the hidden rows on every query (exclude_hidden_docs), as in [fix](inverted index) Count only visible rows in BM25 statistics of merge-on-write tables #69438.

      Memory (BE allocated bytes, sum of 3 BEs, a 4-field merge-on-write table of 41 segments): no growth beyond the noise of the measurement (after 4 queries +26 MB → +25 MB with key pruning; +142 MB → below the starting value scoring all segments).

  • Behavior changed:

    • Yes. On merge-on-write tables, BM25 statistics leave out rows hidden by the delete bitmap and rows with the delete sign, and avgdl is computed from the norms of the visible rows, so scores no longer depend on the replica or on compaction. Scores of merge-on-write tables shift once, also without deletes, because norms store long lengths approximately.
  • Does this need documentation?

    • No.

Check List (For Reviewer who merge this PR)

  • Confirm the release note
  • Confirm test cases
  • Confirm document
  • Add branch pick label

…tistics of merge-on-write tables apache#69438

Backport of apache#69438 to branch-4.2.

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, and the token count is the sum of the lengths the norms of the
returned non-NULL rows stand for. A tablet whose rows are all hidden reports
zero documents instead of failing. The statistics' I/O context follows the
query's file cache options, and a CLucene searcher the statistics open drops
the query's I/O context once its meta is read; a CLucene error while opening
it fails the query instead of escaping the scanner thread.

Adapted to branch-4.2: segments are identified by their id instead of
RowsetSegmentView, and the delete-sign column reader comes from
Segment::get_column_reader(), a segment without the column reading as no
deletes.
@hello-stephen

Copy link
Copy Markdown
Contributor

Thank you for your contribution to Apache Doris.
Don't know what should be done next? See How to process your PR.

Please clearly describe your PR:

  1. What problem was fixed (it's best to include specific error reporting information). How it was fixed.
  2. Which behaviors were modified. What was the previous behavior, what is it now, why was it modified, and what possible impacts might there be.
  3. What features were added. Why was this function added?
  4. Which code was refactored and why was this part of the code refactored?
  5. Which functions were optimized and what is the difference before and after the optimization?

@yiguolei

Copy link
Copy Markdown
Contributor

run buildall

@guptashivam04

Copy link
Copy Markdown
Contributor Author

run cloud_p0

@hello-stephen

Copy link
Copy Markdown
Contributor

BE Regression && UT Coverage Report

Increment line coverage 94.85% (350/369) 🎉

Increment coverage report
Complete coverage report

Category Coverage
Function Coverage 76.03% (34463/45327)
Line Coverage 60.86% (388873/639010)
Region Coverage 57.62% (327579/568488)
Branch Coverage 58.62% (151283/258096)

@yiguolei
yiguolei merged commit ddc779b into apache:branch-4.2 Oct 29, 2026
28 of 29 checks passed
yiguolei pushed a commit that referenced this pull request Oct 30, 2026
…blet idf of the terms the prefix expands to #69698 (#69801)

### What problem does this PR solve?

Issue Number: None

Related PR: #69698, #69752

Problem Summary:
Backport of #69698 to branch-4.2. score() over MATCH_PHRASE_PREFIX
ignored the prefix and
differed between inverted index storage formats:

1. The idf of a phrase prefix now counts every term the prefix expands
to, as Lucene's
multi-phrase query and Elasticsearch do, and all idfs come from the
tablet statistics. The
statistics expand the prefix in every segment and keep the
`inverted_index_max_expansions`
smallest terms of the tablet that a returned row holds (a session limit
of 0 keeps 50), so a
term's document frequency is counted in every segment that holds it and
merge-on-write hidden
rows do not change the scores after a compaction. A term whose bytes are
not valid UTF-8 is not
   scored.
2. A phrase prefix of one token scores 1 on every storage format; on
V2/V3 it read 0, so
   `score() > 0` dropped every row it matched.

See #69698 for the full description and the review rounds.

This change builds on #69438 (BM25 statistics count only visible rows of
merge-on-write tables),
whose branch-4.2 backport #69752 is merged. The PR is rebased onto the
current branch-4.2 and
contains only the backport commit; its patch is identical to the one
validated below.

### Branch adaptations

- No production code adaptation. The automatic pick conflicted only
because #69438 was not on
branch-4.2 yet; on top of #69752 the change applies with one conflict,
in the context of
  `collection_statistics_test.cpp`: branch-4.2 already has
`CollectConvertsAnalyzerFailureToStatus` elsewhere in the file, so the
copy that master shows
  next to the renamed test is not added again.

### Release note

score() over MATCH_PHRASE_PREFIX counts the terms the prefix expands to,
and a phrase prefix of
one token scores 1 on every inverted index storage format.

### Check List (For Author)

- Test: Regression test, Unit Test
- Validated locally on branch-4.2 at 711db43, the commit before
#69625, with the same two
commits on top: the newest branch-4.2 calls a lance-c API that no
thirdparty build on the
machine used here provides. The PR branch itself is on the current
branch-4.2 and is built
      by CI.
- test_match_phrase_prefix_score passes with the `.out` of master
unchanged (V2, V3, SNII and
      the capped two-segment case).
- test_bm25_score, test_bm25_score_parallel_scan_statistics,
test_bm25_score_range_filter,
test_bm25_score_replica_statistics, test_bm25_score_variant,
test_score_filter_matrix,
test_score_filter_usage and test_search_score_topn_predicates pass.
- 1644 related BE unit tests pass (PhrasePrefixQueryV2Test,
CollectionStatistics*,
PrefixExpansionSetTest, SNII, InvertedIndexReaderTest,
MultiSegmentCollectorTest,
      BM25SimilarityTest).
- Behavior changed: Yes (scores of MATCH_PHRASE_PREFIX change)
- Does this need documentation: No

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants