Skip to content

branch-4.1: [fix](be) Score MATCH on keyword inverted indexes like SEARCH - #69791

Merged
yiguolei merged 3 commits into
apache:branch-4.1from
airborne12:fix-keyword-match-score-branch-4.1
Nov 5, 2026
Merged

yiguolei merged 3 commits into
apache:branch-4.1from
airborne12:fix-keyword-match-score-branch-4.1

Conversation

@airborne12

Copy link
Copy Markdown
Member

What problem does this PR solve?

Issue Number: None

Related PR: #69790

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 a match, term or match_phrase query on a keyword
field with BM25 in query context: no norms and no frequencies, so
freq = dl = avgdl = 1 and the score is the idf of the whole value
(0.2876821 above, 0.6931471 for N=4, n=2). Only a bool filter scores 0.
SEARCH already gives exactly these values; MATCH on keyword behaved like
a filter, so body MATCH 'apple' AND tag MATCH 'x' ranked as if the tag
clause were not there.

Root cause on branch-4.1, three places keep keyword MATCH out of scoring:
StringTypeInvertedIndexReader::query never sets is_similarity_score (and
answers score() queries from the query cache), MatchPredicateCollector
skips indexes without an analyzer, and is_need_similarity_score only
accepts phrase-capable or lowercase-normalizer indexes.

Fix: a keyword (non-analyzed) index is eligible for MATCH scoring
regardless of support_phrase; the keyword reader requests scoring for
the MATCH query types and bypasses the result cache like the full-text
reader does; MatchPredicateCollector collects the whole value as one
term (case and spaces kept), the term the reader looks up. The legacy
scorer already uses norm 1 when a field has none. Equality runs as
MATCH_ANY inside the reader but keeps EQUAL_QUERY for the decision, so
it stays a pure filter. MATCH_REGEXP and MATCH_PHRASE_PREFIX keep their
current scores.

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 and Elasticsearch, instead of 0.

Check List (For Author)

  • Test: Regression test / Unit Test / Manual test
    • Regression test: new test_match_keyword_score (V2/V3, implicit and
      explicit parser=none); its output equals the master PR's V2/V3
      blocks
    • Unit Test: IndexReaderHelperTest, CollectionStatisticsTest
    • Manual test: the reported table on a branch-4.1 ASAN build; MATCH
      equals SEARCH and Elasticsearch 9.5.5 to 7 digits, also for
      ARRAY, a column with both a keyword and an analyzed index,
      and OR/AND mixes
  • Behavior changed: Yes. Keyword MATCH contributes its BM25 score to
    score() instead of 0.
  • Does this need documentation: No

🤖 Generated with Claude Code

### What problem does this PR solve?

Issue Number: None

Related PR: (master PR of the same title, pending)

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 a match, term or match_phrase query on a keyword
field with BM25 in query context: no norms and no frequencies, so
freq = dl = avgdl = 1 and the score is the idf of the whole value
(0.2876821 above, 0.6931471 for N=4, n=2). Only a bool filter scores 0.
SEARCH already gives exactly these values; MATCH on keyword behaved like
a filter, so `body MATCH 'apple' AND tag MATCH 'x'` ranked as if the tag
clause were not there.

Root cause on branch-4.1, three places keep keyword MATCH out of scoring:
StringTypeInvertedIndexReader::query never sets is_similarity_score (and
answers score() queries from the query cache), MatchPredicateCollector
skips indexes without an analyzer, and is_need_similarity_score only
accepts phrase-capable or lowercase-normalizer indexes.

Fix: a keyword (non-analyzed) index is eligible for MATCH scoring
regardless of support_phrase; the keyword reader requests scoring for
the MATCH query types and bypasses the result cache like the full-text
reader does; MatchPredicateCollector collects the whole value as one
term (case and spaces kept), the term the reader looks up. The legacy
scorer already uses norm 1 when a field has none. Equality runs as
MATCH_ANY inside the reader but keeps EQUAL_QUERY for the decision, so
it stays a pure filter. MATCH_REGEXP and MATCH_PHRASE_PREFIX keep their
current scores.

### 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 and Elasticsearch, instead of 0.

### Check List (For Author)

- Test: Regression test / Unit Test / Manual test
    - Regression test: new test_match_keyword_score (V2/V3, implicit and
      explicit parser=none); its output equals the master PR's V2/V3
      blocks
    - Unit Test: IndexReaderHelperTest, CollectionStatisticsTest
    - Manual test: the reported table on a branch-4.1 ASAN build; MATCH
      equals SEARCH and Elasticsearch 9.5.5 to 7 digits, also for
      ARRAY<STRING>, a column with both a keyword and an analyzed index,
      and OR/AND mixes
- Behavior changed: Yes. Keyword MATCH contributes its BM25 score to
  score() instead of 0.
- 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

@airborne12
airborne12 marked this pull request as ready for review October 29, 2026 01:54
@airborne12
airborne12 requested a review from yiguolei as a code owner October 29, 2026 01:54
@airborne12

Copy link
Copy Markdown
Member Author

run buildall

@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.

Static review of all seven changed files at head 15b2d09 found two P2 regressions: mixed keyword/analyzed MATCH clauses on one column share incompatible BM25 statistics, and numeric BKD indexes become eligible for text-index statistics through supported casted MATCH predicates. Both are reported inline; no existing inline findings were supplied.

Critical checkpoints:

  1. Goal and proof: basic keyword MATCH/MATCH_ALL/MATCH_PHRASE scoring follows SEARCH, but the two inline cases prevent complete correctness. Added unit/regression tests cover the scalar path; neither failing case is covered.
  2. Scope and clarity: three focused production changes reuse the existing scoring machinery; the remaining changes are two unit tests and a regression input/output pair.
  3. Concurrency and locks: no new thread, lock, or shared-state protocol. TabletCollectionStatistics serializes collection under its existing mutex and copies counters into private scanner statistics; segment scores remain local. No new lock-order problem found.
  4. Lifecycle and static initialization: query/scorer/iterator ownership and RAII cache handles remain valid through search; variant metadata clones retain their owners. No added globals, initialization-order dependencies, or ownership cycles.
  5. Configuration: no configuration added; existing score runtime and query options gate scoring/cache behavior.
  6. Compatibility: no changed storage/wire format or public signature; existing keyword postings are reused. The BKD/text reader mismatch is the separate inline type-boundary bug.
  7. Thrift/protobuf: no definitions changed, so release-branch ID/enum comparisons are inapplicable.
  8. Parallel paths: both scoring-helper overloads are aligned; keyword reader cache bypass matches full-text behavior. Equality/ranges remain filters, one-term phrases delegate to term scoring, and prefix/regexp retain existing scores. V2/V3 fixtures are present. Mixed indexes and numeric casts are the two exceptions reported inline.
  9. Special conditions: comments explain the keyword whole-value term and score-only bitmap-cache bypass. The no-analyzer condition needs the type restriction described inline.
  10. Test coverage: inspected unit eligibility/collection tests and regression V2/V3 cases for one row, case/spaces, nulls, MATCH variants, SEARCH parity, mixed columns, and equality. Same-column different analyzers and casted numeric indexes need regressions. Array/variant, empty, ignore_above, and cache paths were traced statically.
  11. Expected results: all 22 new result blocks and 62 row-score pairs agree analytically with the checked-out BM25 formula, including ln(4/3), ln(2), and the analyzed-plus-keyword sums. Result-generation provenance cannot be established from static inspection.
  12. Observability: existing index-query/search/cache timers, error paths, and optional per-field statistics logging cover this change; no additional observability defect found.
  13. Transactions/persistence/failover: query-only changes introduce no EditLog, commit/replay, or persistent-state modifications.
  14. Data writes/atomicity/crashes: production writes, visible-version selection, delete bitmaps, and recovery remain unchanged; regression inserts only set up test data.
  15. FE/BE variables: none added; existing analyzer context and statistics propagation were traced through planner, scanner, and reader.
  16. Performance/memory: score-producing reads must enumerate postings instead of returning bitmap-only cache hits; unscored caching is preserved. Query-local score ownership is retained. No additional substantiated performance or allocation issue found.
  17. Other issues: the full-diff and upstream/downstream sweeps found only the two verified inline issues after deduplication.

User focus: no additional focus points were supplied. All three initial risk areas have explicit dispositions: same-field index identity is a finding; prefix expansion does not read expanded-term scores; keyword cache/norm/null/empty/array/ignore_above paths yielded no additional finding. The numeric-index admission issue was also independently confirmed.

Validation limits: static inspection and arithmetic only. No builds, Doris tests, benchmarks, or runtime validation were performed; no source edits or submodule initialization. CLucene implementation sources are absent from the uninitialized submodule, so format/iterator conclusions use the checked-in writer/reader contracts; the numeric cached-reader variant mismatch is explicit in local code. Review converged in two rounds with no unresolved candidates or unreviewed changed files.

Review converged after 2 round(s).

// A keyword index stores the whole value as one term, case and spaces included, and
// its reader looks it up unanalyzed: tag MATCH 'Hello World' scores the single term
// "Hello World", as SEARCH('tag:"Hello World"') does.
term_infos.emplace_back(value);

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 BM25 statistics separate for each selected index

On a STRING column with both a parser=standard, support_phrase=true index and a keyword index, insert apple banana, apple, and pear, then score (body MATCH 'apple' USING ANALYZER standard) OR (body MATCH 'apple' USING ANALYZER none). The clauses need different df(apple) values (2 versus 1) and average lengths (4/3 versus 1), but this newly admitted keyword term merges into the same field-only CollectInfoMap entry. Only the first index_meta survives, and CollectionStatistics/BM25Similarity use field/term keys without index identity, so at least one clause receives incorrect statistics regardless of collection order. Before this change the keyword clause was skipped and did not contaminate analyzed scoring. Carry selected-index identity through collection and scorer lookups, and cover this same-column mixed-index query.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Known limitation on this branch, not fixed here. Statistics on branch-4.1 are keyed per field, so a keyword index and an analyzed index of one column share one entry when both are scored in the same query, and at least one clause can get the wrong idf. It takes two indexes on one column, both selected in one query with USING ANALYZER; single-index queries are not affected.

Master fixed this separately in #69901 by keying MATCH statistics per index. That does not carry over to this branch directly, because its reader path differs, so it would be its own backport rather than part of this bug fix.

// postings score with unit frequency and length whatever support_phrase says, as an
// Elasticsearch keyword field does: over tags (x, x, y, z), tag MATCH 'x' scores
// idf(x) = ln(2) for both matching rows, the same as SEARCH('tag:x').
if (!inverted_index::InvertedIndexAnalyzer::should_analyzer(properties)) {

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] Restrict no-analyzer scoring eligibility to string indexes

Numeric BKD indexes also have no analyzer, so this condition admits them into the text scoring collector. For a nonempty duplicate-key table with n INT and an inverted index on n, SELECT n, score() FROM t WHERE CAST(n AS STRING) MATCH '1' is accepted and enters common-expression pushdown when enable_common_expr_pushdown=true. The collector strips the cast and selects the original numeric index, then CollectionStatistics tries to open its BKD directory as a CLucene text index (or extracts FulltextIndexSearcherPtr from a cached BKD reader). This fails before the normal incompatible-cast fallback; previously the no-analyzer metadata was skipped. Check the actual column/reader type before admitting these indexes and add this casted-numeric regression.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Confirmed and fixed in 79bc425. On this branch it was worse than a failed query: IndexReader::open has no try/catch here, so the CLuceneError aborted the BE, and a cached BKD searcher aborted it through std::get.

Fixed where the BKD index is opened rather than in this gate, because a VARIANT path reaches the same code with a schema type that says text: the path is typed per segment. A BKD index now adds no statistics, as a segment without the path does: the cache miss checks for the BKD data file before opening, the cache hit uses get_if. Those rows match without the index and score 0.

test_match_keyword_score covers the numeric cast, VARIANT numeric, text and sparse paths, and the score query after a numeric predicate, on V2/V3; with the old std::get the suite fails with the same abort.

@hello-stephen

Copy link
Copy Markdown
Contributor

BE Regression && UT Coverage Report

Increment line coverage 100.00% (27/27) 🎉

Increment coverage report
Complete coverage report

Category Coverage
Function Coverage 75.18% (32296/42960)
Line Coverage 59.67% (362685/607801)
Region Coverage 56.25% (301766/536496)
Branch Coverage 57.29% (137632/240228)

### What problem does this PR solve?

Issue Number: None

Related PR: apache#69790

Problem Summary: keyword MATCH scoring made statistics collection reach
indexes that are BKD, and on branch-4.1 that aborts the BE, because
IndexReader::open throws on a scanner thread with nothing to catch it:

    -- n INT with an inverted index
    SELECT score() FROM t WHERE body MATCH 'apple'
        AND CAST(n AS STRING) MATCH '1' ORDER BY score() DESC LIMIT 10;
    -- terminate called after throwing an instance of 'CLuceneError'
    -- what(): No segments* file found in DorisCompoundReader@

A VARIANT path reaches it too: it is typed per segment, so a numeric path
is BKD where it holds numbers while the schema calls it text. And when a
numeric predicate has cached the BKD searcher, the cache hit throws
std::bad_variant_access from std::get<FulltextIndexSearcherPtr> instead:

    SELECT COUNT(*) FROM t WHERE CAST(v['n'] AS BIGINT) = 1;
    SELECT score() FROM t WHERE CAST(v['n'] AS STRING) MATCH '1'
    ORDER BY score() DESC LIMIT 10;

Fix: a BKD index has no terms, so it adds no statistics, as a segment
without the path already does; the cache miss detects it by the BKD data
file in the compound directory and the cache hit by the searcher type.
Its rows match without the index and score 0, and text paths of the same
column keep their BM25 score.

The other follow-ups of apache#69790 need nothing here: branch-4.1 has no SNII,
so a VARIANT path absent from a segment was already skipped, and its
keyword reader takes the whole MATCH_PHRASE value as the term, so the
collector already agrees with it on a trailing " ~N".

### Release note

None

### Check List (For Author)

- Test: Regression test / Manual test
    - Regression test: test_match_keyword_score adds the numeric cast, a
      VARIANT numeric, text and sparse path, and the score query after a
      numeric predicate, on V2/V3. All 32 blocks equal the master PR's;
      with the old std::get the suite fails with the same abort;
      15 score-related suites in inverted_index_p0 and search pass
    - Unit Test: 65 tests in IndexReaderHelperTest, CollectionStatistics*
    - Manual test: keyword and analyzed VARIANT indexes on a branch-4.1
      ASAN build; the numeric cast aborted the BE before this change
- Behavior changed: Yes. These queries return rows instead of aborting
  the BE.
- Does this need documentation: No

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@airborne12

Copy link
Copy Markdown
Member Author

run buildall

yiguolei pushed a commit that referenced this pull request Oct 30, 2026
…ant like Elasticsearch (#69825)

### What problem does this PR solve?

Issue Number: None

Related PR: #69815, #69269

Problem Summary: score() of pattern queries disagrees with Elasticsearch
and with itself on branch-4.1:

    -- 'Inverted indexes are suitable for full text search',
    -- 'The parser splits text into multiple terms', ...
SELECT score() FROM document WHERE SEARCH('content:/.*ext/'); -- BM25
sum
SELECT score() FROM document WHERE SEARCH('content:*ext'); -- BM25 sum
    SELECT score() FROM document WHERE content MATCH_REGEXP 'ext'; -- 0

Elasticsearch scores a query_string /regexp/, wildcard or prefix, and a
regexp, wildcard or prefix query, with a constant 1 per matching row
(default rewrite constant_score_blended); measured on 9.5.5 with the
rows above: 1.0 for both matching rows. Only an explicit
"rewrite": "scoring_boolean" sums the BM25 of the expanded terms. Next
to a scored clause in a bool must, a regexp adds exactly 1.

#69269 (the branch-4.1 pick of #69052) made SEARCH regexp and wildcard
sum the BM25 of every expanded term, i.e. scoring_boolean always on,
with idf and avgdl taken from the current segment rather than the tablet
statistics MATCH uses, so identical rows in two segments scored
differently. MATCH_REGEXP never requested a scoring context, so it
scored 0 like a filter.

Fix, the same as #69815 on master:
1. Revert #69269: RegexpWeight scores every pattern with the constant
   scorer again; its suite that pinned the BM25 sums is removed.
2. MATCH_REGEXP requests scoring on every index, and the legacy
   RegexpQuery records 1 for every matching row when score() is
   requested. The MATCH statistics collector skips it, since a constant
   reads no statistics and the pattern is not text to analyze.

A MATCH_REGEXP on a keyword (untokenized) index also scores 1 once the
keyword reader requests scoring (#69791); until then it keeps 0.

### Release note

score() of SEARCH regexp and wildcard clauses is a constant 1 per
matching row again, like prefix clauses and Elasticsearch's default.
score() of MATCH_REGEXP on a tokenized index is 1 instead of 0.

### Check List (For Author)

- Test: Regression test / Unit Test / Manual test
    - Regression test: new search/test_search_score_pattern_constant
      (V2/V3, two segments); all 18 blocks equal the master PR's V2/V3
      blocks; 16 existing score suites in inverted_index_p0 and search
      pass unchanged
- Unit Test: 130 tests (IndexReaderHelperTest, CollectionStatistics*,
      RegexpQueryTest and the query_v2 regexp/wildcard tests restored by
      the revert)
    - Manual test: Elasticsearch 9.5.5 on the same rows; a two-segment
table now scores 1 for every pattern hit on a branch-4.1 ASAN build
- Behavior changed: Yes. SEARCH regexp/wildcard scores and MATCH_REGEXP
  scores change as described in the release note.
- Does this need documentation: No


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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…ch-score-branch-4.1

Resolve the conflicts with apache#69825 and the merge-on-write statistics change:

- index_reader_helper.h: keep the regexp shortcut in front of can_score_match.
- index_reader_helper_test.cpp: keep both tests.
- collection_statistics.cpp: keep the BKD check in front of the CLucene open,
  which is now in a try/catch, so a BKD index no longer needs to reach it.

With the keyword reader of this PR, a MATCH_REGEXP on a keyword index now scores
1, so test_search_score_pattern_constant gets the case it had to leave out.

Tested on the merged tree: test_match_keyword_score, the VARIANT and numeric
cases by hand, 17 score-related suites, 91 unit tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@airborne12

Copy link
Copy Markdown
Member Author

run buildall

@hello-stephen

Copy link
Copy Markdown
Contributor

BE Regression && UT Coverage Report

Increment line coverage 88.89% (32/36) 🎉

Increment coverage report
Complete coverage report

Category Coverage
Function Coverage 75.28% (32343/42962)
Line Coverage 59.71% (362972/607846)
Region Coverage 56.37% (302439/536511)
Branch Coverage 57.35% (137785/240253)

@hello-stephen

Copy link
Copy Markdown
Contributor

BE Regression && UT Coverage Report

Increment line coverage 88.89% (32/36) 🎉

Increment coverage report
Complete coverage report

Category Coverage
Function Coverage 75.28% (32343/42962)
Line Coverage 59.71% (362966/607846)
Region Coverage 56.35% (302305/536511)
Branch Coverage 57.35% (137779/240253)

@airborne12

Copy link
Copy Markdown
Member Author

run nonConcurrent

@hello-stephen

Copy link
Copy Markdown
Contributor

BE Regression && UT Coverage Report

Increment line coverage 88.89% (32/36) 🎉

Increment coverage report
Complete coverage report

Category Coverage
Function Coverage 75.28% (32343/42962)
Line Coverage 59.72% (362978/607846)
Region Coverage 56.36% (302367/536511)
Branch Coverage 57.35% (137777/240253)

@airborne12

Copy link
Copy Markdown
Member Author

run nonConcurrent

@hello-stephen

Copy link
Copy Markdown
Contributor

BE Regression && UT Coverage Report

Increment line coverage 88.89% (32/36) 🎉

Increment coverage report
Complete coverage report

Category Coverage
Function Coverage 75.28% (32342/42962)
Line Coverage 59.71% (362958/607846)
Region Coverage 56.34% (302291/536511)
Branch Coverage 57.35% (137780/240253)

@yiguolei
yiguolei merged commit a8f9f5d into apache:branch-4.1 Nov 5, 2026
29 of 30 checks passed
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