You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@lucene.apache.org by mc...@apache.org on 2026/06/04 03:06:49 UTC

(lucene) branch branch_10x updated: Restore WANDScorer for TOP_SCORES + minShouldMatch > 1 (#16176)

This is an automated email from the ASF dual-hosted git repository.

mccullocht pushed a commit to branch branch_10x
in repository https://gitbox.apache.org/repos/asf/lucene.git


The following commit(s) were added to refs/heads/branch_10x by this push:
     new 4f720f944b8 Restore WANDScorer for TOP_SCORES + minShouldMatch > 1 (#16176)
4f720f944b8 is described below

commit 4f720f944b86c6687d00c006ffbd39db451dd7f7
Author: Tianxiao Wei <37...@users.noreply.github.com>
AuthorDate: Wed Jun 3 19:53:10 2026 -0700

    Restore WANDScorer for TOP_SCORES + minShouldMatch > 1 (#16176)
    
    Lucene #13408 (released in 10.0) dropped a guard in BooleanScorerSupplier.optionalBulkScorer that previously kept TOP_SCORES + minShouldMatch > 1 queries on WANDScorer. As a side effect, when matches are dense, dispatch now lands on BooleanScorer, which has no top-K impact pruning — causing 10–100× latency regressions on pure-should compound queries with minimumShouldMatch > 1.
---
 lucene/CHANGES.txt                                 |  2 +
 .../lucene/search/BooleanScorerSupplier.java       |  9 +++-
 .../apache/lucene/search/TestBooleanScorer.java    | 50 ++++++++++++++++++++++
 3 files changed, 60 insertions(+), 1 deletion(-)

diff --git a/lucene/CHANGES.txt b/lucene/CHANGES.txt
index dd4cda32f39..1800c6c1f54 100644
--- a/lucene/CHANGES.txt
+++ b/lucene/CHANGES.txt
@@ -119,6 +119,8 @@ Improvements
 
 Optimizations
 ---------------------
+* GITHUB#16176: Restore WANDScorer for TOP_SCORES + minShouldMatch > 1. (Tianxiao Wei)
+
 * GITHUB#16050, GITHUB#16177: Doc-values range queries are now always two-phase iterators, with
   SIMD-accelerated bulk range evaluation for dense numeric doc values exposed through
   TwoPhaseIterator#intoBitSet (overridable for SIMD block evaluation) instead of a separate plain
diff --git a/lucene/core/src/java/org/apache/lucene/search/BooleanScorerSupplier.java b/lucene/core/src/java/org/apache/lucene/search/BooleanScorerSupplier.java
index 04e75cf10fa..01647a6ba31 100644
--- a/lucene/core/src/java/org/apache/lucene/search/BooleanScorerSupplier.java
+++ b/lucene/core/src/java/org/apache/lucene/search/BooleanScorerSupplier.java
@@ -292,7 +292,14 @@ final class BooleanScorerSupplier extends ScorerSupplier {
       return subs.get(Occur.SHOULD).iterator().next().bulkScorer();
     }
 
-    if (scoreMode == ScoreMode.TOP_SCORES && minShouldMatch <= 1) {
+    if (scoreMode == ScoreMode.TOP_SCORES) {
+      if (minShouldMatch > 1) {
+        // Fall back to BS2/WANDScorer: it supports both block-max impact
+        // pruning and minShouldMatch > 1. BooleanScorer (the fall-through
+        // below) does not consult score upper bounds and would score every
+        // doc in the 2048-doc window, defeating top-K pruning.
+        return null;
+      }
       List<Scorer> optionalScorers = new ArrayList<>();
       for (ScorerSupplier ss : subs.get(Occur.SHOULD)) {
         optionalScorers.add(ss.get(Long.MAX_VALUE));
diff --git a/lucene/core/src/test/org/apache/lucene/search/TestBooleanScorer.java b/lucene/core/src/test/org/apache/lucene/search/TestBooleanScorer.java
index adb8db7e3ba..399a73ffaa8 100644
--- a/lucene/core/src/test/org/apache/lucene/search/TestBooleanScorer.java
+++ b/lucene/core/src/test/org/apache/lucene/search/TestBooleanScorer.java
@@ -623,4 +623,54 @@ public class TestBooleanScorer extends LuceneTestCase {
     w.close();
     dir.close();
   }
+
+  /**
+   * Make sure a SHOULD-only BooleanQuery with TOP_SCORES and minShouldMatch > 1 is dispatched to
+   * WandScorer
+   */
+  public void testTopScoresWithMinShouldMatchFallsBackToWand() throws IOException {
+    Directory dir = newDirectory();
+    RandomIndexWriter w = new RandomIndexWriter(random(), dir);
+    for (int i = 0; i < 100; i++) {
+      Document doc = new Document();
+      doc.add(new StringField("foo", "bar", Store.NO));
+      doc.add(new StringField("foo", "baz", Store.NO));
+      doc.add(new StringField("foo", "qux", Store.NO));
+      w.addDocument(doc);
+    }
+    IndexReader reader = w.getReader();
+    IndexSearcher searcher = new IndexSearcher(reader);
+    searcher.setQueryCache(null); // so that weights are not wrapped
+    final LeafReaderContext ctx = reader.leaves().get(0);
+
+    Query query =
+        new BooleanQuery.Builder()
+            .add(new TermQuery(new Term("foo", "bar")), Occur.SHOULD)
+            .add(new TermQuery(new Term("foo", "baz")), Occur.SHOULD)
+            .add(new TermQuery(new Term("foo", "qux")), Occur.SHOULD)
+            .setMinimumNumberShouldMatch(2)
+            .build();
+
+    Weight weight = searcher.createWeight(searcher.rewrite(query), ScoreMode.TOP_SCORES, 1);
+    ScorerSupplier ss = weight.scorerSupplier(ctx);
+    assertNull(
+        "TOP_SCORES + minShouldMatch > 1 must skip BooleanScorer and fall back to BS2/WAND",
+        ((BooleanScorerSupplier) ss).booleanScorer());
+
+    // Sanity: bulkScorer() itself returns a non-null BS2 (DefaultBulkScorer
+    // wrapping WANDScorer).
+    assertTrue(ss.bulkScorer() instanceof DefaultBulkScorer);
+
+    // Non-TOP_SCORES with minShouldMatch > 1 still uses BooleanScorer when
+    // matches are dense.
+    weight = searcher.createWeight(searcher.rewrite(query), ScoreMode.COMPLETE, 1);
+    ss = weight.scorerSupplier(ctx);
+    assertTrue(
+        "COMPLETE + minShouldMatch > 1 (dense) should still use BooleanScorer",
+        ((BooleanScorerSupplier) ss).booleanScorer() instanceof BooleanScorer);
+
+    reader.close();
+    w.close();
+    dir.close();
+  }
 }