Skip to content

[fix](be) Keep BM25 statistics per index for MATCH on one column - #69901

Merged
airborne12 merged 1 commit into
apache:masterfrom
airborne12:fix-per-index-bm25-stats
Nov 2, 2026
Merged

airborne12 merged 1 commit into
apache:masterfrom
airborne12:fix-per-index-bm25-stats

Conversation

@airborne12

Copy link
Copy Markdown
Member

What problem does this PR solve?

Issue Number: None

Related PR: #69790

Problem Summary: score() over MATCH clauses that read different inverted
indexes of one column fails:

INDEX idx_english(name) USING INVERTED PROPERTIES("parser" = "english", ...),
INDEX idx_standard(name) USING INVERTED PROPERTIES("parser" = "standard", ...)

SELECT id, score() FROM t
WHERE name MATCH 'apple' USING ANALYZER english
  AND name MATCH 'apple' USING ANALYZER standard
ORDER BY score() DESC LIMIT 10;
-- Scoring predicates for one field select different inverted indexes

BM25 statistics are keyed by field (column unique id plus suffix path),
but each index of a column holds its own terms and lengths: over Apple
and apple, an index that folds case has df(apple) = 2 and one that does
not has df(apple) = 1. One entry cannot serve both, so the collector
refuses the query.

Fix: a MATCH keeps its statistics under the field and the index id.
CollectInfo records the field as indexed for opening the index, since the
map key no longer is one. The readers bind their source to the real field
and pass the statistics name to run_leaf, so the scorers and similarities
need no change: they already look statistics up by the name they are
given. Each clause now scores with the statistics of its own index, and
the combined score is the sum of what each clause scores alone.

SEARCH keeps its own statistics names and its rejection of two indexes on
one field; only MATCH changes. A MATCH and a SEARCH on one index keep
separate entries with the same values.

Release note

score() over MATCH clauses that use different inverted indexes of one
column, selected with USING ANALYZER, no longer fails.

Check List (For Author)

  • Test: Regression test / Unit Test / Manual test
    • Regression test: new test_match_score_multi_index on V2/V3/SNII,
      with two indexes that differ in case folding, so df(apple) is 3 in
      one and 1 in the other: both clauses score as they do alone, and
      identically on the three formats. 26 suites in inverted_index_p0
      and search pass, including the multi-analyzer ones
    • Unit Test: the collector test that pinned the rejection now pins
      both entries; tests that read MATCH statistics use the new key
  • Behavior changed: Yes. The query above returns scores instead of an
    error.
  • Does this need documentation: No

🤖 Generated with Claude Code

### What problem does this PR solve?

Issue Number: None

Related PR: apache#69790

Problem Summary: score() over MATCH clauses that read different inverted
indexes of one column fails:

    INDEX idx_english(name) USING INVERTED PROPERTIES("parser" = "english", ...),
    INDEX idx_standard(name) USING INVERTED PROPERTIES("parser" = "standard", ...)

    SELECT id, score() FROM t
    WHERE name MATCH 'apple' USING ANALYZER english
      AND name MATCH 'apple' USING ANALYZER standard
    ORDER BY score() DESC LIMIT 10;
    -- Scoring predicates for one field select different inverted indexes

BM25 statistics are keyed by field (column unique id plus suffix path),
but each index of a column holds its own terms and lengths: over Apple
and apple, an index that folds case has df(apple) = 2 and one that does
not has df(apple) = 1. One entry cannot serve both, so the collector
refuses the query.

Fix: a MATCH keeps its statistics under the field and the index id.
CollectInfo records the field as indexed for opening the index, since the
map key no longer is one. The readers bind their source to the real field
and pass the statistics name to run_leaf, so the scorers and similarities
need no change: they already look statistics up by the name they are
given. Each clause now scores with the statistics of its own index, and
the combined score is the sum of what each clause scores alone.

SEARCH keeps its own statistics names and its rejection of two indexes on
one field; only MATCH changes. A MATCH and a SEARCH on one index keep
separate entries with the same values.

### Release note

score() over MATCH clauses that use different inverted indexes of one
column, selected with USING ANALYZER, no longer fails.

### Check List (For Author)

- Test: Regression test / Unit Test / Manual test
    - Regression test: new test_match_score_multi_index on V2/V3/SNII,
      with two indexes that differ in case folding, so df(apple) is 3 in
      one and 1 in the other: both clauses score as they do alone, and
      identically on the three formats. 26 suites in inverted_index_p0
      and search pass, including the multi-analyzer ones
    - Unit Test: the collector test that pinned the rejection now pins
      both entries; tests that read MATCH statistics use the new key
- Behavior changed: Yes. The query above returns scores instead of an
  error.
- Does this need documentation: No

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@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?

@airborne12

Copy link
Copy Markdown
Member Author

run buildall

@airborne12

Copy link
Copy Markdown
Member Author

/review

@github-actions

Copy link
Copy Markdown
Contributor

Codex automated review failed and did not complete.

Error: Selected model is at capacity. Please try a different model.
Workflow run: https://github.com/apache/doris/actions/runs/41062405504

Please inspect the workflow logs and rerun the review after the underlying issue is resolved.

@hello-stephen

Copy link
Copy Markdown
Contributor
TPC-H: Total hot run time: 28484 ms
machine: 'aliyun_ecs.c7a.8xlarge_32C64G'
scripts: https://github.com/apache/doris/tree/master/tools/tpch-tools
Tpch sf100 test result on commit 91eace3b0f728ae067fcba3dd8faa60107d35809, data reload: false

------ Round 1 ----------------------------------
============================================
q1	17611	5212	5079	5079
q2	2150	304	252	252
q3	10243	1431	834	834
q4	4684	466	337	337
q5	7426	794	527	527
q6	183	188	156	156
q7	764	793	594	594
q8	9239	1379	1120	1120
q9	5692	4227	4197	4197
q10	6822	1298	1021	1021
q11	430	243	219	219
q12	638	401	274	274
q13	18085	3617	3068	3068
q14	273	267	247	247
q15	q16	722	723	652	652
q17	1204	752	588	588
q18	6980	6181	6131	6131
q19	1104	952	595	595
q20	375	336	227	227
q21	5018	2120	2283	2120
q22	339	295	246	246
Total cold run time: 99982 ms
Total hot run time: 28484 ms

----- Round 2, with runtime_filter_mode=off -----
============================================
q1	5570	5589	5621	5589
q2	686	566	562	562
q3	5248	5353	4925	4925
q4	2006	2154	1564	1564
q5	5267	5176	5718	5176
q6	265	193	148	148
q7	2295	2060	1875	1875
q8	3084	2686	2641	2641
q9	8041	8065	7956	7956
q10	4040	3668	3273	3273
q11	640	445	436	436
q12	695	710	544	544
q13	3556	3929	3290	3290
q14	310	315	298	298
q15	q16	718	699	660	660
q17	8027	6997	6903	6903
q18	13053	12223	12918	12223
q19	914	786	792	786
q20	2153	2136	1928	1928
q21	5559	4583	4675	4583
q22	497	447	421	421
Total cold run time: 72624 ms
Total hot run time: 65781 ms

@hello-stephen

Copy link
Copy Markdown
Contributor
TPC-DS: Total hot run time: 150956 ms
machine: 'aliyun_ecs.c7a.8xlarge_32C64G'
scripts: https://github.com/apache/doris/tree/master/tools/tpcds-tools
TPC-DS sf100 test result on commit 91eace3b0f728ae067fcba3dd8faa60107d35809, data reload: false

query5	4316	575	448	448
query6	427	214	195	195
query7	4812	452	226	226
query8	339	164	158	158
query9	8821	3161	3175	3161
query10	486	311	244	244
query11	6009	3861	3570	3570
query12	157	91	86	86
query13	1257	429	329	329
query14	6144	3957	3634	3634
query14_1	3498	3376	3383	3376
query15	205	198	178	178
query16	939	424	398	398
query17	852	629	507	507
query18	2420	410	296	296
query19	205	155	133	133
query20	84	82	82	82
query21	202	128	104	104
query22	12850	12833	12810	12810
query23	13500	12862	12372	12372
query23_1	12532	12551	12504	12504
query24	7377	821	468	468
query24_1	471	465	480	465
query25	512	396	348	348
query26	1253	263	136	136
query27	2777	433	266	266
query28	4644	2039	2046	2039
query29	1568	606	460	460
query30	305	224	188	188
query31	993	930	816	816
query32	141	92	97	92
query33	530	303	246	246
query34	905	860	515	515
query35	737	755	649	649
query36	805	837	717	717
query37	120	95	86	86
query38	1773	1743	1663	1663
query39	791	792	740	740
query39_1	754	731	741	731
query40	223	118	102	102
query41	74	70	69	69
query42	90	86	85	85
query43	346	364	301	301
query44	1302	690	721	690
query45	181	180	163	163
query46	838	970	561	561
query47	2867	3021	2625	2625
query48	310	313	239	239
query49	580	389	304	304
query50	681	266	215	215
query51	10518	10344	10600	10344
query52	87	86	74	74
query53	189	216	157	157
query54	253	208	207	207
query55	82	71	67	67
query56	240	245	229	229
query57	1626	1631	1558	1558
query58	295	272	264	264
query59	2196	2221	2052	2052
query60	316	263	240	240
query61	171	161	165	161
query62	388	334	290	290
query63	194	162	166	162
query64	2887	1044	754	754
query65	3315	3272	3291	3272
query66	1768	415	300	300
query67	20263	20060	20002	20002
query68	3053	1008	611	611
query69	394	291	252	252
query70	864	799	762	762
query71	294	242	212	212
query72	2807	2652	2326	2326
query73	513	538	292	292
query74	4621	4493	4325	4325
query75	2363	2296	1900	1900
query76	2308	1018	610	610
query77	347	384	298	298
query78	9848	9764	9206	9206
query79	995	861	559	559
query80	1038	440	338	338
query81	583	315	277	277
query82	577	141	105	105
query83	299	193	179	179
query84	319	120	103	103
query85	881	441	367	367
query86	364	238	232	232
query87	2002	1931	1780	1780
query88	3745	2787	2754	2754
query89	338	276	245	245
query90	1818	180	164	164
query91	165	152	130	130
query92	98	91	93	91
query93	1006	971	572	572
query94	572	326	276	276
query95	556	411	299	299
query96	696	490	238	238
query97	2466	2468	2327	2327
query98	163	152	149	149
query99	774	771	671	671
Total cold run time: 235394 ms
Total hot run time: 150956 ms

@hello-stephen

Copy link
Copy Markdown
Contributor
ClickBench: Total hot run time: 24.37 s
machine: 'aliyun_ecs.c7a.8xlarge_32C64G'
scripts: https://github.com/apache/doris/tree/master/tools/clickbench-tools
ClickBench test result on commit 91eace3b0f728ae067fcba3dd8faa60107d35809, data reload: false

query1	0.01	0.01	0.00
query2	0.09	0.05	0.06
query3	0.27	0.09	0.10
query4	1.61	0.09	0.11
query5	0.23	0.23	0.24
query6	1.17	0.68	0.68
query7	0.04	0.01	0.01
query8	0.06	0.04	0.03
query9	0.40	0.35	0.35
query10	0.60	0.57	0.58
query11	0.21	0.15	0.14
query12	0.19	0.15	0.14
query13	0.50	0.50	0.49
query14	1.04	1.02	1.01
query15	0.63	0.60	0.62
query16	0.33	0.36	0.34
query17	1.19	1.17	1.16
query18	0.20	0.19	0.18
query19	2.08	2.03	2.01
query20	0.02	0.01	0.01
query21	15.42	0.20	0.14
query22	4.89	0.05	0.05
query23	16.26	0.33	0.13
query24	2.67	0.53	0.49
query25	0.11	0.06	0.05
query26	1.46	0.22	0.16
query27	0.05	0.03	0.04
query28	3.78	0.74	0.36
query29	12.49	4.04	3.20
query30	0.28	0.15	0.10
query31	2.77	0.56	0.33
query32	3.24	0.59	0.51
query33	3.23	3.17	3.20
query34	15.35	3.96	3.37
query35	3.37	3.33	3.35
query36	0.56	0.45	0.42
query37	0.10	0.06	0.07
query38	0.05	0.04	0.03
query39	0.03	0.03	0.03
query40	0.18	0.15	0.14
query41	0.08	0.04	0.03
query42	0.04	0.03	0.03
query43	0.04	0.04	0.03
Total cold run time: 97.32 s
Total hot run time: 24.37 s

@hello-stephen

Copy link
Copy Markdown
Contributor

BE Regression && UT Coverage Report

Increment line coverage 100.00% (6/6) 🎉

Increment coverage report
Complete coverage report

Category Coverage
Function Coverage 77.68% (36714/47261)
Line Coverage 63.03% (415827/659717)
Region Coverage 59.31% (351057/591922)
Branch Coverage 60.28% (163040/270476)

@airborne12

Copy link
Copy Markdown
Member Author

/review

@airborne12
airborne12 marked this pull request as ready for review October 31, 2026 02:18

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed all 13 changed files at head 91eace3. One verified P2 finding is attached inline: MATCH's synthetic statistics key can collide with a raw SEARCH VARIANT field and reject a previously valid mixed scoring query. No existing inline findings were present; existing_blocking_comment_ids is empty.

Flow inspected: MATCH expression -> selected index -> field/index statistics -> physical CLucene or SNII source -> BM25 -> score collection. SEARCH retains its raw field identity and shares the statistics collection map.

Critical checkpoint conclusions:

  1. Goal: per-index terms and lengths support the intended multi-index MATCH behavior on ordinary fields. The mixed VARIANT namespace collision still needs correction.
  2. Scope/clarity: the producer/consumer identity change and its tests are focused; the physical field is explicitly retained.
  3. Concurrency: no new threads or locks. Shared tablet statistics retain their existing collection mutex, and derived scoring caches remain scanner-local. No new lock-order issue found.
  4. Lifecycle/initialization: schema and shared ownership retain generated index metadata and opened readers through synchronous scoring. No new dangling reference or cross-translation-unit initialization dependency found.
  5. Configuration: no configuration items added or changed.
  6. Compatibility: no on-disk, wire-format or libc dependency change. The legacy unscored CLucene overload remains available; both CLucene formats and SNII are updated.
  7. Thrift/protobuf: no definition or field-ID changes; release-branch ID comparison is inapplicable.
  8. Parallel paths: inspected MATCH/SEARCH, V2/V3/SNII, direct and inherited/field-pattern VARIANT indexes, term sets, phrases, prefixes, regexp, phrase-edge, keyword/normalizer and candidate-restricted paths. No additional verified issue.
  9. Conditions/errors: no new corrective branch; the key helper explains its Apple/apple example. Existing Status and exception boundaries propagate failure. Hidden-row norm callbacks use the synthetic name only as a cache label and compute through the physical source.
  10. Test coverage: inspected changed collector/SNII unit tests and the regression covering each analyzer alone, AND/OR sums, same-index reuse and phrase-plus-term across all three formats. A mixed colliding VARIANT-key regression is missing. Existing hidden/NULL/cache and prefix paths were also inspected.
  11. Test results: all 18 new regression result blocks agree with the source BM25 formula and score composition by arithmetic inspection. Test execution and result-generation provenance were not independently verified.
  12. Observability: existing errors and statistics diagnostics retain field/index context; no additional metric requirement identified for this local change.
  13. Transactions/persistence: no transaction or persistence behavior changed. Hidden rows still use the scan's read-version delete bitmap, and document counts are accumulated once per segment.
  14. Data writes/crashes: no write or asynchronous publication path changed. Existing ownership and failure cleanup remain intact; no additional leak or atomicity defect found.
  15. FE/BE variables: none added. Existing FE parsing/binding/serialization was traced to establish that the reported VARIANT path is reachable.
  16. Performance/memory: additional strings and entries scale with selected indexes/terms. No new large buffer, ownership leak or material algorithmic regression identified; MATCH/SEARCH can repeat statistics work for the same index under separate labels.
  17. Other/core invariants: committed-row visibility, version bounds, delete-bitmap alignment and nullable execution are unchanged by the identity change. No additional verified defect or unresolved candidate remains.

No additional user focus was supplied. Review converged after 2 rounds: all three reviewers returned NO_NEW_VALUABLE_FINDINGS in round 2, all risk points have evidence-backed dispositions, and the final changed-file/anchor sweep is complete. This was static inspection only: no builds, tests, benchmarks or runtime reproduction were performed, and no repository source was modified.

Review converged after 2 round(s).

// terms and lengths under the same field, so the field alone cannot key them: over Apple and
// apple, an index that folds case has df(apple) = 2 and one that does not has df(apple) = 1.
inline std::wstring match_statistics_field(const std::wstring& field, int64_t index_id) {
return field + L"@" + std::to_wstring(index_id);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Keep MATCH keys disjoint from raw SEARCH fields

A synthetic MATCH key can also be a valid SEARCH field for a VARIANT subcolumn. For a scoring-capable parent index with ID 2001 and parent column UID 1, MATCH on v['a'] collects under 1.v.a@2001, exactly the raw key used by search('alpha', '{"default_field":"v.a@2001"}'). Both collectors write to the same map, but their inherited index suffixes differ (v%2Ea versus v%2Ea@2001), so validate_same_physical_index() rejects whichever predicate is collected second. This makes a previously valid mixed MATCH/SEARCH query fail when it requests score(). Use a tagged or structured identity that cannot collide with raw SEARCH fields, and cover a VARIANT key ending in @<actual-index-id>.

@hello-stephen

Copy link
Copy Markdown
Contributor

BE Regression && UT Coverage Report

Increment line coverage 100.00% (6/6) 🎉

Increment coverage report
Complete coverage report

Category Coverage
Function Coverage 77.68% (36714/47261)
Line Coverage 63.03% (415847/659717)
Region Coverage 59.32% (351111/591922)
Branch Coverage 60.28% (163053/270476)

@airborne12
airborne12 merged commit c4b11b5 into apache:master Nov 2, 2026
43 checks passed
@airborne12
airborne12 deleted the fix-per-index-bm25-stats branch November 2, 2026 01:25
guptashivam04 pushed a commit to guptashivam04/doris that referenced this pull request Nov 2, 2026
… MATCH statistics key

### What problem does this PR solve?

Issue Number: None

Related PR: apache#69901

Problem Summary: apache#69901 keeps the BM25 statistics of a MATCH under the field and the index id
(`match_statistics_field`), so CollectionStatisticsTest.DeleteSignRowsCountWhenTheScanReturnsThem,
which collects them for a MATCH and still read them under the field alone, failed on master with
"Not such column 1". The test now reads them under `kMatchField`, as the other MATCH tests of the
file do.

### Release note

None

### Check List (For Author)

- Test: Unit Test
    - The test fails on master without this change ("Not such column 1") and passes with it; the
      BM25, statistics, scanner and scan-operator unit tests pass on ASAN (258 tests).
- Behavior changed: No
- Does this need documentation: No
yiguolei pushed a commit that referenced this pull request Nov 5, 2026
…ARCH #69790 (#70008)

Backport of #69790 to branch-4.2.

### What problem does this PR solve?

Issue Number: None

Related PR: #69790, #69051

Problem Summary: score() of a MATCH on an untokenized (keyword) inverted
index is always 0, while SEARCH on the same field scores BM25:

    INDEX idx_redirect (redirect) USING INVERTED
    -- one row ('BP')
    SELECT score() FROM t WHERE redirect MATCH 'BP';  -- 0
    SELECT score() FROM t WHERE SEARCH('redirect:BP'); -- 0.2876821

Elasticsearch scores match, term and match_phrase on a keyword field
with BM25 in query context, so the score is the idf of the whole value.
#69051 (already on 4.2) made the engine score keyword postings but kept
MATCH out. This makes a keyword index eligible for MATCH scoring and
collects its whole value as one term, the one the reader looks up. It
also carries the follow-up fixes from #69790's review:
- A keyword MATCH_PHRASE drops its ` ~N` slop before collecting.
- A MATCH on a non-string leaf such as `CAST(n AS STRING)` collects
nothing.
- A VARIANT path that a segment indexed as BKD, or does not hold, adds
no statistics. This covers the BKD searcher coming from the searcher
cache, and SNII.
- A SNII index kind is looked up without copying the directory.

### Branch adaptations

- `can_score_match` keeps 4.2's analyzed rule (support_phrase only);
branch-4.2 has no lowercase-normalizer clause.
- `test_omit_norms` does not exist on 4.2, so its `.out` change is
dropped. The branch was cut before #69922 (the 4.2 backport of #69815)
and then merged with branch-4.2 to resolve its conflict in
`index_reader_helper_test.cpp`: both tests are kept, as on master.
- `test_match_keyword_score` inserts its VARIANT rows as JSON strings:
`parse_to_variant` in `VALUES` fails on 4.2 with a max-subcolumns
conversion error. The expected output is master's file, unchanged.
- **#69901 (BM25 statistics per index) is not on 4.2.** A MATCH on a
keyword index and a MATCH on an analyzed index of the same column in one
`score()` query (`name MATCH 'apple' USING ANALYZER english AND name
MATCH 'apple' USING ANALYZER none`) now fails with "Scoring predicates
for one field select different inverted indexes". Master behaved the
same before #69901. Before this change, that query ran with the keyword
clause scoring 0. Each clause alone scores fine. Two analyzed indexes on
one column already fail this way on 4.2. Backporting #69901 lifts this.

### Validation

On this branch merged with branch-4.2 c2aaed9, ASAN build:
- BE UT: 1611 tests in `IndexReaderHelperTest`, `CollectionStatistics*`
and `*Snii*` pass.
- Regression: `test_match_keyword_score` passes against master's `.out`
on V2/V3/SNII. The following also pass:
`test_match_phrase_prefix_score`, `test_bm25_score`,
`test_score_filter_matrix`, `test_score_filter_usage`,
`test_search_score_topn_predicates`, `test_multi_analyzer_query`,
`test_match_using_analyzer`, `test_single_column_multi_index` (with
debug points on), `test_search_score_keyword_filters`,
`test_search_score_raw_index`, `test_search_score_pattern_constant`.
- Local note: the local thirdparty predates the lance-c upgrade, so
`lance_reader.cpp`'s `lance_scanner_set_scalar_index_auto_selection`
call was stubbed for the local build only. It is not part of this PR.

### Release note

score() of MATCH / MATCH_ALL / MATCH_PHRASE on a keyword (untokenized)
inverted index now returns the BM25 score of the value, the same as
SEARCH, instead of 0.

### Check List (For Author)

- Test: Regression test / Unit Test (see Validation)
- Behavior changed: Yes. Keyword MATCH contributes its BM25 score to
score() instead of 0.
- Does this need documentation: No

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants