guptashivam04 opened a new pull request, #69438:
URL: https://github.com/apache/doris/pull/69438

   ### What problem does this PR solve?
   
   Issue Number: None
   
   Related PR: #69352 (BM25 statistics shared across the parallel scanners of a 
tablet; this PR is its follow-up for replicas), #69313 (BM25 token totals after 
CLucene compaction)
   
   Problem Summary:
   
   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. Pagination over `ORDER 
BY score()` then repeats and skips rows from one page to the next. Each replica 
is stable on its own (`use_fix_replica` pins the scores), but the replicas 
disagree.
   
   `CollectionStatistics::process_segment()` takes the document count, the 
token count and each term's document frequency straight from every segment's 
inverted index. Under merge-on-write, a row overwritten or deleted by a later 
load stays in its segment's index, marked only in the delete bitmap. A row 
written with the delete sign, which CDC deletes and `DELETE` produce, stays as 
a normal row that queries filter out by predicate. Both kinds are counted until 
compaction removes them, and each replica compacts on its own schedule. So each 
replica counts a different set of rows nobody can see.
   
   Reproduction (3 BEs, `replication_num = 3`, 8 buckets, auto compaction off): 
8 loads, 3 rounds of overwriting a quarter of the rows, a `DELETE`, and full 
rows inserted with `__DORIS_DELETE_SIGN__ = 1`. Then a full compaction (`POST 
/api/compaction/run?compact_type=full`) of the tablets on one BE only. On 
master with #69352, the query below changed the score of all 3,799 matching 
rows from run to run: 1,900 distinct scores instead of 950. With 
`use_fix_replica = 0/1/2` each replica was stable, but the three disagreed.
   
   ```sql
   SELECT id, score() FROM rt WHERE SEARCH('content:jobs OR content:try') ORDER 
BY score() DESC LIMIT 100000;
   ```
   
   The token total has a second, replica-independent problem: CLucene index 
compaction (`inverted_index_compaction_enable`, on by default) copies the 
source token totals even when it drops rows, which #69313 addresses. On master, 
the same rows scored differently in a compacted table and in a table freshly 
loaded with its visible rows.
   
   Fix:
   
   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. Every scanner of a tablet reads with the same bitmap and 
version, so the statistics shared by #69352 stay correct.
   - For each segment, the hidden rows are the rows the delete bitmap marks at 
the read version (`get_agg`, the same call the rowset reader uses), plus the 
rows whose hidden `__DORIS_DELETE_SIGN__` column is set. Every unique-key table 
has that column, and `DELETE` on a merge-on-write table writes such rows unless 
`enable_mow_light_delete` is on. The delete-sign column is read only for 
segments whose zone map shows a set sign, so a segment without CDC deletes 
costs nothing extra.
   - Document count: the index's document count minus the hidden rows.
   - Term document frequency: `IndexSource::scoring_doc_freq_among()` counts 
how many hidden rows a term's postings list. When the hidden rows are much 
rarer than the term's postings, it leaps between them with `seek_block`. 
Otherwise it decodes the postings once and tests each against a bitset of the 
hidden rows, so its cost follows the smaller of the two. SNII overrides it so 
that reserved-namespace scoring terms resolve as `scoring_doc_freq` resolves 
them.
   - Token count: the sum of the document lengths the norms of the returned 
rows stand for (`IndexSource::sum_norm_lengths()` minus 
`sum_norm_lengths_of(hidden rows)`), or the returned row count for an index 
without norms. The exact length of a hidden row is not stored anywhere: CLucene 
and SNII keep only the encoded norm (exact up to 23 tokens for CLucene, up to 
254 for SNII). Subtracting decoded lengths from the exact `sumTotalTermFreq` 
would still leave replicas apart. A row's norm is the same in every segment it 
is written to, so summing norms gives every replica the same count. It is also 
the same quantity the BM25 tf normalization already divides, `dl / avgdl`, with 
`dl` taken from the same norm.
   
   Why the token count has to change too: a variant that left out hidden rows 
only from the document count and the document frequencies, keeping today's 
token total, made the replicas agree only while every replica compacted through 
inverted index compaction, which carries dropped rows' token counts forward. 
With one replica compacted with `inverted_index_compaction_enable = false` (the 
index rebuilt from column data), every row again scored differently on that 
replica. Its scores were also 12–31 % off a fresh load of the visible rows on 
average, because its token total still counted the hidden rows.
   
   Tables without a delete bitmap (duplicate key, merge-on-read, or 
`skip_delete_bitmap`) are unchanged.
   
   Not covered:
   
   - Merge-on-read unique tables and duplicate tables with `DELETE` predicates 
also count rows that compaction later drops. They need merge or predicate 
evaluation to tell which rows are visible, and are left for a follow-up.
   - With `skip_delete_sign = true`, rows written with the delete sign are 
returned but still left out of the statistics.
   - Statistics stay per tablet, as before.
   
   Interaction with #69313: that PR makes the token total of a CLucene index 
built by compaction exact. With this PR, merge-on-write scans no longer read 
that total, so their scores do not depend on it. Scans of other tables still 
do. The two changes are independent.
   
   ### 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 rows 
visible to the query, and the average document length comes from the indexed 
norms.
   
   ### Check List (For Author)
   
   - Test
       - [x] Regression test: 
`inverted_index_p0/test_bm25_score_replica_statistics`. It loads overwrites, 
`DELETE`s and delete-sign rows, then expects every row to score exactly as in a 
table freshly loaded with the visible rows. It checks before compaction, with 
one replica compacted (≥ 3 BEs: 10 runs and `use_fix_replica = 0/1/2`), and 
after compacting all replicas. Without this change it fails before any 
compaction (`1352 rows score differently from a freshly loaded table`). With 
it, it passes on a 3-BE cluster.
       - [x] Existing suites: all 24 suites that use `score()` (in 
`inverted_index_p0`, `search`, `ann_index_p0`, `variant_p0` and 
`nereids_rules_p0`) pass on a 3-BE cluster. One expected value changes: in 
`test_bm25_score`, table `t2` is a merge-on-write table whose row `a = 3` was 
updated, so the old version of that row no longer counts. Two documents count 
instead of three, and the score of `hello world` goes from 1.0925692 to 
0.8025915.
       - [x] Unit Test: 
`CollectionStatisticsTest.HiddenRowsAreCollectedLikeCompactedAway` (real 
CLucene segments: a segment with a hidden 31-token row collects the same doc 
count, token count, df, avgdl and idf as the segment without the row), 
`CollectionStatisticsHiddenRowsTest.*` (counting hidden rows in postings by 
scanning and by leaping, over sparse and dense blocks; norm sums; rows beyond 
the index; indexes without norms). The test file compiles against current 
master with `-Werror` and no warnings; running it needs the full BE unit-test 
binary, which did not finish building in reasonable time on the test machine, 
so CI is the first run.
       - [x] Manual test: master with #69352, with and without this change, 
macOS arm64, 3 BEs on one host, the reproduction above. `use_fix_replica` pins 
one replica; "random" lets the FE pick a replica per tablet per query.
   
         | State | without this change | with this change |
         |---|---|---|
         | replicas identical | 0 of 3,799 rows change across 10 runs, 950 
scores | 0 rows change, 950 scores |
         | full compaction on one replica, random replica, 10 runs | **3,799 of 
3,799 rows change, 1,900 scores** | 0 rows change, 950 scores |
         | same, `use_fix_replica = 0/1/2` compared | **every row differs 
between replicas** | identical |
         | all replicas compacted | stable, but differs from a fresh load of 
the same rows on every row | identical to the one-replica-compacted state and 
to a fresh load, bit for bit |
   
         Same result for a 2-replica table with `BUCKETS AUTO`, the `unicode` 
parser with `lower_case`, 4 indexed text columns queried by `SEARCH('a:x OR b:x 
OR c:x OR d:x')` plus a non-index filter, auto compaction on, updates by 
`INSERT INTO ... SELECT` and deletes by `DELETE FROM`. Before: every row 
differed between the replicas (e.g. 3.30 vs 2.69). After: identical on both and 
equal to a fresh load. The same holds with 1–10 MB text values.
   
         Overhead, measured from query profiles with a 4-field `SEARCH(... OR 
...)` and a non-index filter. Each number is the median of 15 runs on the same 
data and query:
   
         | Table | without this change | with this change |
         |---|---|---|
         | 11 k rows, 3 replicas | 27 ms | 15 ms (within noise) |
         | 1.75 M visible rows, 1.3 M hidden (43 % of stored rows), 8 buckets | 
35 ms | 36 ms |
   
         The cost grows with the rows compaction has not removed yet, and comes 
from two parts. To count the hidden rows that hold a term, the collector leaps 
from hidden row to hidden row when they are much rarer than the term's postings 
(32 times rarer). Otherwise it decodes the postings once and tests each against 
a bitset of the hidden rows. Norms are summed through a length table.
       - [ ] No need to test or manual test.
   
   - Behavior changed:
       - [x] Yes. On merge-on-write tables, BM25 statistics leave out rows 
hidden by the delete bitmap or the delete sign, and avgdl is computed from the 
norms of the visible rows. Scores no longer depend on the replica or on 
compaction. They also shift once on tables without deletes, because norms store 
long lengths only approximately. Measured on freshly loaded tables:
   
         | Texts | Mean score change | Largest change | Row pairs that swap 
order | Top 100 |
         |---|---|---|---|---|
         | short to medium (up to ~45 words) | 0.27 % | 0.59 % | 0.021 % | 
unchanged |
         | long (~380 words on average) | 1.75 % | 2.6 % | 0.085 %, all 
near-ties | 1 near-tie at the boundary |
         | with 19 documents of 1–10 MB | 1.47 % | 6.2 % | 0.11 % | unchanged |
   
   - Does this need documentation?
       - [x] No.
   
   ### Check List (For Reviewer who merge this PR)
   
   - [ ] Confirm the release note
   - [ ] Confirm test cases
   - [ ] Confirm document
   - [ ] Add branch pick label
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to