Repository: apache/lucene
Pull request #14701: Refactor main top-n bulk scorers to evaluate hits in a more term-at-a-time fashion.
Author: login: jpountz
Labels: name: module:core/search
Created: 2025-05-22T11:46:24Z | Merged: 2025-05-25T19:18:52Z

`MaxScoreBulkScorer` and `BlockMaxConjunctionBulkScorer` currently evaluate hits in a doc-at-a-time (DAAT) fashion, meaning that they they look at all their clauses to find the next doc and so forth until all docs from the window are evaluated. This changes evaluation to run in a more term-at-a-time fashion (TAAT) within scoring windows, meaning that each clause is fully evaluated within the window before moving on to the next clause.

Note that this isn't completely new, `BooleanScorer` has been doing this to exhaustively evaluate disjunctive queries, by loading their matches into a bit set, one clause at a time. Also note that this is a bit different from traditional TAAT as this is scoped to small-ish windows of doc IDs, not the entire doc ID space.

This in-turn allows these scorers to take advantage of the new `Scorer#nextDocsAndScores` API, and provides a good speedup. A downside is that we may need to perform more memory copying in some cases, and evaluate a bit more documents, but the change still looks like a win in general.


Diff:
diff --git a/lucene/CHANGES.txt b/lucene/CHANGES.txt
index eff4b71a20a3..af9bca69d605 100644
--- a/lucene/CHANGES.txt
+++ b/lucene/CHANGES.txt
@@ -119,6 +119,9 @@ Optimizations
 * GITHUB#14700: Return MatchNoDocsQuery when IndexOrDocValuesQuery::rewrite does not match
   (Chris Hegarty)
 
+* GITHUB#14701: Optimize top-n bulk scorers by evaluating scoring windows in a
+  term-at-a-time fashion instead of doc-at-a-time. (Adrien Grand)
+
 Bug Fixes
 ---------------------
 * GITHUB#14654: ValueSource.fromDoubleValuesSource(dvs).getSortField() would throw errors when
diff --git a/lucene/core/src/java/org/apache/lucene/search/BlockMaxConjunctionBulkScorer.java b/lucene/core/src/java/org/apache/lucene/search/BlockMaxConjunctionBulkScorer.java
index fd89153ffe6d..7091777a2625 100644
--- a/lucene/core/src/java/org/apache/lucene/search/BlockMaxConjunctionBulkScorer.java
+++ b/lucene/core/src/java/org/apache/lucene/search/BlockMaxConjunctionBulkScorer.java
@@ -22,7 +22,6 @@
 import java.util.List;
 import org.apache.lucene.search.Weight.DefaultBulkScorer;
 import org.apache.lucene.util.Bits;
-import org.apache.lucene.util.MathUtil;
 
 /**
  * BulkScorer implementation of {@link BlockMaxConjunctionScorer} that focuses on top-level
@@ -38,11 +37,12 @@ final class BlockMaxConjunctionBulkScorer extends BulkScorer {
   private final Scorer[] scorers;
   private final Scorable[] scorables;
   private final DocIdSetIterator[] iterators;
-  private final DocIdSetIterator lead1, lead2;
-  private final Scorable scorer1, scorer2;
+  private final DocIdSetIterator lead;
   private final DocAndScore scorable = new DocAndScore();
   private final double[] sumOfOtherClauses;
   private final int maxDoc;
+  private final DocAndScoreBuffer docAndScoreBuffer = new DocAndScoreBuffer();
+  private final DocAndScoreAccBuffer docAndScoreAccBuffer = new DocAndScoreAccBuffer();
 
   BlockMaxConjunctionBulkScorer(int maxDoc, List<Scorer> scorers) throws IOException {
     if (scorers.size() <= 1) {
@@ -54,14 +54,9 @@ final class BlockMaxConjunctionBulkScorer extends BulkScorer {
         Arrays.stream(this.scorers).map(ScorerUtil::likelyTermScorer).toArray(Scorable[]::new);
     this.iterators =
         Arrays.stream(this.scorers).map(Scorer::iterator).toArray(DocIdSetIterator[]::new);
-    lead1 = ScorerUtil.likelyImpactsEnum(iterators[0]);
-    lead2 = ScorerUtil.likelyImpactsEnum(iterators[1]);
-    scorer1 = this.scorables[0];
-    scorer2 = this.scorables[1];
+    lead = ScorerUtil.likelyImpactsEnum(iterators[0]);
     this.sumOfOtherClauses = new double[this.scorers.length];
-    for (int i = 0; i < sumOfOtherClauses.length; i++) {
-      sumOfOtherClauses[i] = Double.POSITIVE_INFINITY;
-    }
+    Arrays.fill(sumOfOtherClauses, Double.POSITIVE_INFINITY);
     this.maxDoc = maxDoc;
   }
 
@@ -86,7 +81,7 @@ private float computeMaxScore(int windowMin, int windowMax) throws IOException {
   public int score(LeafCollector collector, Bits acceptDocs, int min, int max) throws IOException {
     collector.setScorer(scorable);
 
-    int windowMin = Math.max(lead1.docID(), min);
+    int windowMin = Math.max(lead.docID(), min);
     while (windowMin < max) {
       // Use impacts of the least costly scorer to compute windows
       // NOTE: windowMax is inclusive
@@ -97,7 +92,7 @@ public int score(LeafCollector collector, Bits acceptDocs, int min, int max) thr
         maxWindowScore = computeMaxScore(windowMin, windowMax);
       }
       scoreWindow(collector, acceptDocs, windowMin, windowMax + 1, maxWindowScore);
-      windowMin = Math.max(lead1.docID(), windowMax + 1);
+      windowMin = Math.max(lead.docID(), windowMax + 1);
     }
 
     return windowMin >= maxDoc ? DocIdSetIterator.NO_MORE_DOCS : windowMin;
@@ -111,111 +106,49 @@ private void scoreWindow(
       return;
     }
 
-    if (lead1.docID() < min) {
-      lead1.advance(min);
+    if (lead.docID() < min) {
+      lead.advance(min);
     }
-    if (lead1.docID() >= max) {
+    if (lead.docID() >= max) {
       return;
     }
 
-    Scorable scorer1 = this.scorer1;
-    if (scorers[0].getMaxScore(max - 1) == 0f) {
-      // Null out scorer1 if it may only produce 0 scores over this window. In practice, this is
-      // mostly useful because FILTER clauses are pushed as constant-scoring MUST clauses with a
-      // 0 score to this scorer. Setting it to null instead of using a different impl helps
-      // reduce polymorphism of calls to Scorable#score and skip the check of whether the leading
-      // clause produced a high-enough score for the doc to be competitive.
-      scorer1 = null;
-    }
+    for (scorers[0].nextDocsAndScores(max, acceptDocs, docAndScoreBuffer);
+        docAndScoreBuffer.size > 0;
+        scorers[0].nextDocsAndScores(max, acceptDocs, docAndScoreBuffer)) {
 
-    final double sumOfOtherMaxScoresAt1 = sumOfOtherClauses[1];
+      docAndScoreAccBuffer.copyFrom(docAndScoreBuffer);
 
-    advanceHead:
-    for (int doc = lead1.docID(); doc < max; ) {
-      if (acceptDocs != null && acceptDocs.get(doc) == false) {
-        doc = lead1.nextDoc();
-        continue;
-      }
-
-      // Compute the score as we find more matching clauses, in order to skip advancing other
-      // clauses if the total score has no chance of being competitive. This works well because
-      // computing a score is usually cheaper than decoding a full block of postings and
-      // frequencies.
-      final boolean hasMinCompetitiveScore = scorable.minCompetitiveScore > 0;
-      double currentScore;
-      if (scorer1 != null && hasMinCompetitiveScore) {
-        currentScore = scorer1.score();
-
-        // This is the same logic as in the below for loop, specialized for the 2nd least costly
-        // clause. This seems to help the JVM.
-
-        // First check if we have a chance of having a match based on max scores
-        if ((float) MathUtil.sumUpperBound(currentScore + sumOfOtherMaxScoresAt1, scorers.length)
-            < scorable.minCompetitiveScore) {
-          doc = lead1.nextDoc();
-          continue advanceHead;
+      for (int i = 1; i < scorers.length; ++i) {
+        if (scorable.minCompetitiveScore > 0) {
+          ScorerUtil.filterCompetitiveHits(
+              docAndScoreAccBuffer,
+              sumOfOtherClauses[i],
+              scorable.minCompetitiveScore,
+              scorers.length);
         }
-      } else {
-        currentScore = 0;
-      }
 
-      // NOTE: lead2 may be on `doc` already if we `continue`d on the previous loop iteration.
-      if (lead2.docID() < doc) {
-        int next = lead2.advance(doc);
-        if (next != doc) {
-          doc = lead1.advance(next);
-          continue advanceHead;
-        }
-      }
-      assert lead2.docID() == doc;
-      if (hasMinCompetitiveScore) {
-        currentScore += scorer2.score();
+        ScorerUtil.applyRequiredClause(docAndScoreAccBuffer, iterators[i], scorables[i]);
       }
 
-      for (int i = 2; i < iterators.length; ++i) {
-        // First check if we have a chance of having a match based on max scores
-        if (hasMinCompetitiveScore
-            && (float) MathUtil.sumUpperBound(currentScore + sumOfOtherClauses[i], scorers.length)
-                < scorable.minCompetitiveScore) {
-          doc = lead1.nextDoc();
-          continue advanceHead;
-        }
-
-        // NOTE: these iterators may be on `doc` already if we called `continue advanceHead` on the
-        // previous loop iteration.
-        if (iterators[i].docID() < doc) {
-          int next = iterators[i].advance(doc);
-          if (next != doc) {
-            doc = lead1.advance(next);
-            continue advanceHead;
-          }
-        }
-        assert iterators[i].docID() == doc;
-        if (hasMinCompetitiveScore) {
-          currentScore += scorables[i].score();
-        }
-      }
-
-      if (hasMinCompetitiveScore == false) {
-        for (Scorable scorer : scorables) {
-          currentScore += scorer.score();
-        }
-      }
-      scorable.score = (float) currentScore;
-      collector.collect(doc);
-      // The collect() call may have updated the minimum competitive score.
-      if (maxWindowScore < scorable.minCompetitiveScore) {
-        // no more hits are competitive
-        return;
+      for (int i = 0; i < docAndScoreAccBuffer.size; ++i) {
+        scorable.score = (float) docAndScoreAccBuffer.scores[i];
+        collector.collect(docAndScoreAccBuffer.docs[i]);
       }
+    }
 
-      doc = lead1.nextDoc();
+    int maxOtherDoc = -1;
+    for (int i = 1; i < iterators.length; ++i) {
+      maxOtherDoc = Math.max(iterators[i].docID(), maxOtherDoc);
+    }
+    if (lead.docID() < maxOtherDoc) {
+      lead.advance(maxOtherDoc);
     }
   }
 
   @Override
   public long cost() {
-    return lead1.cost();
+    return lead.cost();
   }
 
   private static class DocAndScore extends Scorable {
diff --git a/lucene/core/src/java/org/apache/lucene/search/DocAndScoreAccBuffer.java b/lucene/core/src/java/org/apache/lucene/search/DocAndScoreAccBuffer.java
new file mode 100644
index 000000000000..dd05e53aac20
--- /dev/null
+++ b/lucene/core/src/java/org/apache/lucene/search/DocAndScoreAccBuffer.java
@@ -0,0 +1,74 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.lucene.search;
+
+import org.apache.lucene.util.ArrayUtil;
+import org.apache.lucene.util.IntsRef;
+
+/**
+ * Wrapper around parallel arrays storing doc IDs and their corresponding score accumulators.
+ *
+ * @lucene.internal
+ */
+public final class DocAndScoreAccBuffer {
+
+  private static final double[] EMPTY_DOUBLES = new double[0];
+
+  /** Doc IDs */
+  public int[] docs = IntsRef.EMPTY_INTS;
+
+  /** Scores */
+  public double[] scores = EMPTY_DOUBLES;
+
+  /** Number of valid entries in the doc ID and score arrays. */
+  public int size;
+
+  /** Sole constructor. */
+  public DocAndScoreAccBuffer() {}
+
+  /**
+   * Grow both arrays to ensure that they can store at least the given number of entries. Existing
+   * content may be discarded.
+   */
+  public void growNoCopy(int minSize) {
+    if (docs.length < minSize) {
+      docs = ArrayUtil.growNoCopy(docs, minSize);
+      scores = new double[docs.length];
+    }
+  }
+
+  /**
+   * Grow both arrays to ensure that they can store at least the given number of entries. Existing
+   * content is preserved.
+   */
+  public void grow(int minSize) {
+    if (docs.length < minSize) {
+      docs = ArrayUtil.grow(docs, minSize);
+      scores = ArrayUtil.growExact(scores, docs.length);
+    }
+  }
+
+  /** Copy content from the given {@link DocAndScoreBuffer}, expanding float scores to doubles. */
+  public void copyFrom(DocAndScoreBuffer buffer) {
+    growNoCopy(buffer.size);
+    System.arraycopy(buffer.docs, 0, docs, 0, buffer.size);
+    for (int i = 0; i < buffer.size; ++i) {
+      scores[i] = buffer.scores[i];
+    }
+    this.size = buffer.size;
+  }
+}
diff --git a/lucene/core/src/java/org/apache/lucene/search/MaxScoreBulkScorer.java b/lucene/core/src/java/org/apache/lucene/search/MaxScoreBulkScorer.java
index 847fdb010a96..cb212096f75b 100644
--- a/lucene/core/src/java/org/apache/lucene/search/MaxScoreBulkScorer.java
+++ b/lucene/core/src/java/org/apache/lucene/search/MaxScoreBulkScorer.java
@@ -48,10 +48,11 @@ final class MaxScoreBulkScorer extends BulkScorer {
   final double[] maxScoreSums;
   private final DisiWrapper filter;
 
-  private final long[] windowMatches = new long[FixedBitSet.bits2words(INNER_WINDOW_SIZE)];
+  private final FixedBitSet windowMatches = new FixedBitSet(INNER_WINDOW_SIZE);
   private final double[] windowScores = new double[INNER_WINDOW_SIZE];
 
   private final DocAndScoreBuffer docAndScoreBuffer = new DocAndScoreBuffer();
+  private final DocAndScoreAccBuffer docAndScoreAccBuffer = new DocAndScoreAccBuffer();
 
   MaxScoreBulkScorer(int maxDoc, List<Scorer> scorers, Scorer filter) throws IOException {
     this.maxDoc = maxDoc;
@@ -182,6 +183,7 @@ private void scoreInnerWindowWithFilter(
     int innerWindowMin = top.doc;
     int innerWindowMax = (int) Math.min(max, (long) innerWindowMin + INNER_WINDOW_SIZE);
 
+    docAndScoreAccBuffer.size = 0;
     while (top.doc < innerWindowMax) {
       assert filter.doc <= top.doc; // invariant
       if (filter.doc < top.doc) {
@@ -208,10 +210,15 @@ private void scoreInnerWindowWithFilter(
         } while (top.doc == doc);
 
         if (match) {
-          scoreNonEssentialClauses(collector, doc, score, firstEssentialScorer);
+          docAndScoreAccBuffer.grow(docAndScoreAccBuffer.size + 1);
+          docAndScoreAccBuffer.docs[docAndScoreAccBuffer.size] = doc;
+          docAndScoreAccBuffer.scores[docAndScoreAccBuffer.size] = score;
+          docAndScoreAccBuffer.size++;
         }
       }
     }
+
+    scoreNonEssentialClauses(collector, docAndScoreAccBuffer, firstEssentialScorer);
   }
 
   private void scoreInnerWindowSingleEssentialClause(
@@ -223,13 +230,9 @@ private void scoreInnerWindowSingleEssentialClause(
     for (top.scorer.nextDocsAndScores(upTo, acceptDocs, docAndScoreBuffer);
         docAndScoreBuffer.size > 0;
         top.scorer.nextDocsAndScores(upTo, acceptDocs, docAndScoreBuffer)) {
-      for (int i = 0; i < docAndScoreBuffer.size; ++i) {
-        scoreNonEssentialClauses(
-            collector,
-            docAndScoreBuffer.docs[i],
-            docAndScoreBuffer.scores[i],
-            firstEssentialScorer);
-      }
+
+      docAndScoreAccBuffer.copyFrom(docAndScoreBuffer);
+      scoreNonEssentialClauses(collector, docAndScoreAccBuffer, firstEssentialScorer);
     }
 
     top.doc = top.iterator.docID();
@@ -243,63 +246,29 @@ private void scoreInnerWindowAsConjunction(LeafCollector collector, Bits acceptD
     DisiWrapper lead1 = allScorers[allScorers.length - 1];
     assert essentialQueue.size() == 1;
     assert lead1 == essentialQueue.top();
-    DisiWrapper lead2 = allScorers[allScorers.length - 2];
-    if (lead1.doc < lead2.doc) {
-      lead1.doc = lead1.iterator.advance(Math.min(lead2.doc, max));
-    }
-    // maximum score contribution of all scorers but the lead
-    double maxScoreSumAtLead2 = maxScoreSums[allScorers.length - 2];
-
-    outer:
-    while (lead1.doc < max) {
-
-      if (acceptDocs != null && acceptDocs.get(lead1.doc) == false) {
-        lead1.doc = lead1.iterator.nextDoc();
-        continue;
-      }
 
-      double score = lead1.scorable.score();
+    for (lead1.scorer.nextDocsAndScores(max, acceptDocs, docAndScoreBuffer);
+        docAndScoreBuffer.size > 0;
+        lead1.scorer.nextDocsAndScores(max, acceptDocs, docAndScoreBuffer)) {
 
-      // We specialize handling the second best scorer, which seems to help a bit with performance.
-      // But this is the exact same logic as in the below for loop.
-      if ((float) MathUtil.sumUpperBound(score + maxScoreSumAtLead2, allScorers.length)
-          < minCompetitiveScore) {
-        // a competitive match is not possible according to max scores, skip to the next candidate
-        lead1.doc = lead1.iterator.nextDoc();
-        continue;
-      }
+      docAndScoreAccBuffer.copyFrom(docAndScoreBuffer);
 
-      if (lead2.doc < lead1.doc) {
-        lead2.doc = lead2.iterator.advance(lead1.doc);
-      }
-      if (lead2.doc != lead1.doc) {
-        lead1.doc = lead1.iterator.advance(Math.min(lead2.doc, max));
-        continue;
-      }
+      for (int i = allScorers.length - 2; i >= firstRequiredScorer; --i) {
 
-      score += lead2.scorable.score();
-
-      for (int i = allScorers.length - 3; i >= firstRequiredScorer; --i) {
-        if ((float) MathUtil.sumUpperBound(score + maxScoreSums[i], allScorers.length)
-            < minCompetitiveScore) {
-          // a competitive match is not possible according to max scores, skip to the next candidate
-          lead1.doc = lead1.iterator.nextDoc();
-          continue outer;
+        if (minCompetitiveScore > 0) {
+          ScorerUtil.filterCompetitiveHits(
+              docAndScoreAccBuffer, maxScoreSums[i], minCompetitiveScore, allScorers.length);
         }
 
-        DisiWrapper w = allScorers[i];
-        if (w.doc < lead1.doc) {
-          w.doc = w.iterator.advance(lead1.doc);
-        }
-        if (w.doc != lead1.doc) {
-          lead1.doc = lead1.iterator.advance(Math.min(w.doc, max));
-          continue outer;
-        }
-        score += w.scorable.score();
+        DisiWrapper scorer = allScorers[i];
+        ScorerUtil.applyRequiredClause(docAndScoreAccBuffer, scorer.iterator, scorer.scorable);
       }
 
-      scoreNonEssentialClauses(collector, lead1.doc, score, firstRequiredScorer);
-      lead1.doc = lead1.iterator.nextDoc();
+      scoreNonEssentialClauses(collector, docAndScoreAccBuffer, firstRequiredScorer);
+    }
+
+    for (int i = allScorers.length - 1; i >= firstRequiredScorer; --i) {
+      allScorers[i].doc = allScorers[i].iterator.docID();
     }
   }
 
@@ -309,6 +278,7 @@ private void scoreInnerWindowMultipleEssentialClauses(
 
     int innerWindowMin = top.doc;
     int innerWindowMax = (int) Math.min(max, (long) innerWindowMin + INNER_WINDOW_SIZE);
+    int innerWindowSize = innerWindowMax - innerWindowMin;
 
     // Collect matches of essential clauses into a bitset
     do {
@@ -319,7 +289,7 @@ private void scoreInnerWindowMultipleEssentialClauses(
           final int doc = docAndScoreBuffer.docs[index];
           final float score = docAndScoreBuffer.scores[index];
           final int i = doc - innerWindowMin;
-          windowMatches[i >>> 6] |= 1L << i;
+          windowMatches.set(i);
           windowScores[i] += score;
         }
       }
@@ -328,20 +298,21 @@ private void scoreInnerWindowMultipleEssentialClauses(
       top = essentialQueue.updateTop();
     } while (top.doc < innerWindowMax);
 
-    for (int wordIndex = 0; wordIndex < windowMatches.length; ++wordIndex) {
-      long bits = windowMatches[wordIndex];
-      windowMatches[wordIndex] = 0L;
-      while (bits != 0L) {
-        int ntz = Long.numberOfTrailingZeros(bits);
-        bits ^= 1L << ntz;
-        int index = wordIndex << 6 | ntz;
-        int doc = innerWindowMin + index;
-        double score = windowScores[index];
-        windowScores[index] = 0d;
-
-        scoreNonEssentialClauses(collector, doc, score, firstEssentialScorer);
-      }
-    }
+    docAndScoreAccBuffer.growNoCopy(windowMatches.cardinality(0, innerWindowSize));
+    docAndScoreAccBuffer.size = 0;
+    windowMatches.forEach(
+        0,
+        innerWindowSize,
+        0,
+        index -> {
+          docAndScoreAccBuffer.docs[docAndScoreAccBuffer.size] = innerWindowMin + index;
+          docAndScoreAccBuffer.scores[docAndScoreAccBuffer.size] = windowScores[index];
+          docAndScoreAccBuffer.size++;
+          windowScores[index] = 0d;
+        });
+    windowMatches.clear(0, innerWindowSize);
+
+    scoreNonEssentialClauses(collector, docAndScoreAccBuffer, firstEssentialScorer);
   }
 
   private int computeOuterWindowMax(int windowMin) throws IOException {
@@ -394,31 +365,24 @@ void updateMaxWindowScores(int windowMin, int windowMax) throws IOException {
   }
 
   private void scoreNonEssentialClauses(
-      LeafCollector collector, int doc, double essentialScore, int numNonEssentialClauses)
+      LeafCollector collector, DocAndScoreAccBuffer buffer, int numNonEssentialClauses)
       throws IOException {
+    numCandidates += buffer.size;
 
-    ++numCandidates;
-
-    double score = essentialScore;
     for (int i = numNonEssentialClauses - 1; i >= 0; --i) {
-      float maxPossibleScore =
-          (float) MathUtil.sumUpperBound(score + maxScoreSums[i], allScorers.length);
-      if (maxPossibleScore < minCompetitiveScore) {
-        // Hit is not competitive.
-        return;
-      }
-
       DisiWrapper scorer = allScorers[i];
-      if (scorer.doc < doc) {
-        scorer.doc = scorer.iterator.advance(doc);
-      }
-      if (scorer.doc == doc) {
-        score += scorer.scorable.score();
-      }
+      assert minCompetitiveScore > 0
+          : "All clauses are essential if minCompetitiveScore is equal to zero";
+      ScorerUtil.filterCompetitiveHits(
+          buffer, maxScoreSums[i], minCompetitiveScore, allScorers.length);
+      ScorerUtil.applyOptionalClause(buffer, scorer.iterator, scorer.scorable);
+      scorer.doc = scorer.iterator.docID();
     }
 
-    scorable.score = (float) score;
-    collector.collect(doc);
+    for (int i = 0; i < buffer.size; ++i) {
+      scorable.score = (float) buffer.scores[i];
+      collector.collect(buffer.docs[i]);
+    }
   }
 
   boolean partitionScorers() {
diff --git a/lucene/core/src/java/org/apache/lucene/search/ScorerUtil.java b/lucene/core/src/java/org/apache/lucene/search/ScorerUtil.java
index a08b36413a58..082ffd469de9 100644
--- a/lucene/core/src/java/org/apache/lucene/search/ScorerUtil.java
+++ b/lucene/core/src/java/org/apache/lucene/search/ScorerUtil.java
@@ -16,12 +16,14 @@
  */
 package org.apache.lucene.search;
 
+import java.io.IOException;
 import java.util.stream.LongStream;
 import java.util.stream.StreamSupport;
 import org.apache.lucene.codecs.lucene103.Lucene103PostingsFormat;
 import org.apache.lucene.index.ImpactsEnum;
 import org.apache.lucene.util.Bits;
 import org.apache.lucene.util.FixedBitSet;
+import org.apache.lucene.util.MathUtil;
 import org.apache.lucene.util.PriorityQueue;
 
 /** Util class for Scorer related methods */
@@ -117,4 +119,69 @@ public int length() {
       return in.length();
     }
   }
+
+  static void filterCompetitiveHits(
+      DocAndScoreAccBuffer buffer,
+      double maxRemainingScore,
+      float minCompetitiveScore,
+      int numScorers) {
+    int newSize = 0;
+    for (int i = 0; i < buffer.size; ++i) {
+      float maxPossibleScore =
+          (float) MathUtil.sumUpperBound(buffer.scores[i] + maxRemainingScore, numScorers);
+      if (maxPossibleScore >= minCompetitiveScore) {
+        buffer.docs[newSize] = buffer.docs[i];
+        buffer.scores[newSize] = buffer.scores[i];
+        newSize++;
+      }
+    }
+    buffer.size = newSize;
+  }
+
+  /**
+   * Apply the provided {@link Scorable} as a required clause on the given {@link
+   * DocAndScoreAccBuffer}. This filters out documents from the buffer that do not match, and adds
+   * the scores of this {@link Scorable} to the scores.
+   *
+   * <p><b>NOTE</b>: The provided buffer must contain doc IDs in sorted order, with no duplicates.
+   */
+  static void applyRequiredClause(
+      DocAndScoreAccBuffer buffer, DocIdSetIterator iterator, Scorable scorable)
+      throws IOException {
+    int intersectionSize = 0;
+    int curDoc = iterator.docID();
+    for (int i = 0; i < buffer.size; ++i) {
+      int targetDoc = buffer.docs[i];
+      if (curDoc < targetDoc) {
+        curDoc = iterator.advance(targetDoc);
+      }
+      if (curDoc == targetDoc) {
+        buffer.docs[intersectionSize] = targetDoc;
+        buffer.scores[intersectionSize] = buffer.scores[i] + scorable.score();
+        intersectionSize++;
+      }
+    }
+    buffer.size = intersectionSize;
+  }
+
+  /**
+   * Apply the provided {@link Scorable} as an optional clause on the given {@link
+   * DocAndScoreAccBuffer}. This adds the scores of this {@link Scorer} to the existing scores.
+   *
+   * <p><b>NOTE</b>: The provided buffer must contain doc IDs in sorted order, with no duplicates.
+   */
+  static void applyOptionalClause(
+      DocAndScoreAccBuffer buffer, DocIdSetIterator iterator, Scorable scorable)
+      throws IOException {
+    int curDoc = iterator.docID();
+    for (int i = 0; i < buffer.size; ++i) {
+      int targetDoc = buffer.docs[i];
+      if (curDoc < targetDoc) {
+        curDoc = iterator.advance(targetDoc);
+      }
+      if (curDoc == targetDoc) {
+        buffer.scores[i] += scorable.score();
+      }
+    }
+  }
 }


Reviews:
user: login: gf2121
body: I like it, simpler and faster!
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-23T13:10:38Z
user: login: jpountz
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-23T13:38:29Z
user: login: jpountz
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-23T13:39:13Z
user: login: jpountz
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-23T13:42:05Z
user: login: gf2121
body: Thank you!
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-23T13:49:26Z

Review comments:
diff_hunk: @@ -55,9 +55,6 @@ final class BlockMaxConjunctionBulkScorer extends BulkScorer {
     this.iterators =
         Arrays.stream(this.scorers).map(Scorer::iterator).toArray(DocIdSetIterator[]::new);
     lead1 = ScorerUtil.likelyImpactsEnum(iterators[0]);
-    lead2 = ScorerUtil.likelyImpactsEnum(iterators[1]);
-    scorer1 = this.scorables[0];
-    scorer2 = this.scorables[1];
     this.sumOfOtherClauses = new double[this.scorers.length];
     for (int i = 0; i < sumOfOtherClauses.length; i++) {
path: lucene/core/src/java/org/apache/lucene/search/BlockMaxConjunctionBulkScorer.java
user: login: gf2121
body: Not introduced in this PR, but maybe change this to a `Arrays.fill` by the way :)
created_at: 2025-05-22T13:32:59Z
author_association: CONTRIBUTOR
diff_hunk: @@ -38,11 +37,12 @@ final class BlockMaxConjunctionBulkScorer extends BulkScorer {
   private final Scorer[] scorers;
   private final Scorable[] scorables;
   private final DocIdSetIterator[] iterators;
-  private final DocIdSetIterator lead1, lead2;
-  private final Scorable scorer1, scorer2;
+  private final DocIdSetIterator lead1;
path: lucene/core/src/java/org/apache/lucene/search/BlockMaxConjunctionBulkScorer.java
user: login: gf2121
body: Maybe just name it `lead` since we do not have `lead2` now.
created_at: 2025-05-22T13:33:22Z
author_association: CONTRIBUTOR
diff_hunk: @@ -118,98 +115,36 @@ private void scoreWindow(
       return;
     }
 
-    Scorable scorer1 = this.scorer1;
-    if (scorers[0].getMaxScore(max - 1) == 0f) {
-      // Null out scorer1 if it may only produce 0 scores over this window. In practice, this is
-      // mostly useful because FILTER clauses are pushed as constant-scoring MUST clauses with a
-      // 0 score to this scorer. Setting it to null instead of using a different impl helps
-      // reduce polymorphism of calls to Scorable#score and skip the check of whether the leading
-      // clause produced a high-enough score for the doc to be competitive.
-      scorer1 = null;
-    }
-
-    final double sumOfOtherMaxScoresAt1 = sumOfOtherClauses[1];
-
-    advanceHead:
-    for (int doc = lead1.docID(); doc < max; ) {
-      if (acceptDocs != null && acceptDocs.get(doc) == false) {
-        doc = lead1.nextDoc();
-        continue;
-      }
+    for (scorers[0].nextDocsAndScores(max, acceptDocs, docAndScoreBuffer);
+        docAndScoreBuffer.size > 0;
+        scorers[0].nextDocsAndScores(max, acceptDocs, docAndScoreBuffer)) {
 
-      // Compute the score as we find more matching clauses, in order to skip advancing other
-      // clauses if the total score has no chance of being competitive. This works well because
-      // computing a score is usually cheaper than decoding a full block of postings and
-      // frequencies.
-      final boolean hasMinCompetitiveScore = scorable.minCompetitiveScore > 0;
-      double currentScore;
-      if (scorer1 != null && hasMinCompetitiveScore) {
-        currentScore = scorer1.score();
-
-        // This is the same logic as in the below for loop, specialized for the 2nd least costly
-        // clause. This seems to help the JVM.
-
-        // First check if we have a chance of having a match based on max scores
-        if ((float) MathUtil.sumUpperBound(currentScore + sumOfOtherMaxScoresAt1, scorers.length)
-            < scorable.minCompetitiveScore) {
-          doc = lead1.nextDoc();
-          continue advanceHead;
-        }
-      } else {
-        currentScore = 0;
-      }
-
-      // NOTE: lead2 may be on `doc` already if we `continue`d on the previous loop iteration.
-      if (lead2.docID() < doc) {
-        int next = lead2.advance(doc);
-        if (next != doc) {
-          doc = lead1.advance(next);
-          continue advanceHead;
-        }
-      }
-      assert lead2.docID() == doc;
-      if (hasMinCompetitiveScore) {
-        currentScore += scorer2.score();
-      }
+      docAndScoreAccBuffer.copyFrom(docAndScoreBuffer);
 
-      for (int i = 2; i < iterators.length; ++i) {
-        // First check if we have a chance of having a match based on max scores
-        if (hasMinCompetitiveScore
-            && (float) MathUtil.sumUpperBound(currentScore + sumOfOtherClauses[i], scorers.length)
-                < scorable.minCompetitiveScore) {
-          doc = lead1.nextDoc();
-          continue advanceHead;
+      for (int i = 1; i < scorers.length; ++i) {
+        if (scorable.minCompetitiveScore > 0) {
+          ScorerUtil.filterCompetitiveHits(
+              docAndScoreAccBuffer,
+              sumOfOtherClauses[i],
+              scorable.minCompetitiveScore,
+              scorers.length);
         }
 
-        // NOTE: these iterators may be on `doc` already if we called `continue advanceHead` on the
-        // previous loop iteration.
-        if (iterators[i].docID() < doc) {
-          int next = iterators[i].advance(doc);
-          if (next != doc) {
-            doc = lead1.advance(next);
-            continue advanceHead;
-          }
-        }
-        assert iterators[i].docID() == doc;
-        if (hasMinCompetitiveScore) {
-          currentScore += scorables[i].score();
-        }
+        ScorerUtil.applyRequiredClause(docAndScoreAccBuffer, iterators[i], scorables[i]);
       }
 
-      if (hasMinCompetitiveScore == false) {
-        for (Scorable scorer : scorables) {
-          currentScore += scorer.score();
-        }
-      }
-      scorable.score = (float) currentScore;
-      collector.collect(doc);
-      // The collect() call may have updated the minimum competitive score.
-      if (maxWindowScore < scorable.minCompetitiveScore) {
-        // no more hits are competitive
-        return;
+      for (int i = 0; i < docAndScoreAccBuffer.size; ++i) {
+        scorable.score = (float) docAndScoreAccBuffer.scores[i];
+        collector.collect(docAndScoreAccBuffer.docs[i]);
       }
+    }
 
-      doc = lead1.nextDoc();
+    int maxOtherDoc = -1;
+    for (int i = 0; i < iterators.length; ++i) {
path: lucene/core/src/java/org/apache/lucene/search/BlockMaxConjunctionBulkScorer.java
user: login: gf2121
body: Not important but since we are naming `maxOtherDoc` let's start `i` from 1?
created_at: 2025-05-22T15:59:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -394,31 +365,22 @@ void updateMaxWindowScores(int windowMin, int windowMax) throws IOException {
   }
 
   private void scoreNonEssentialClauses(
-      LeafCollector collector, int doc, double essentialScore, int numNonEssentialClauses)
+      LeafCollector collector, DocAndScoreAccBuffer buffer, int numNonEssentialClauses)
       throws IOException {
+    numCandidates += buffer.size;
 
-    ++numCandidates;
-
-    double score = essentialScore;
     for (int i = numNonEssentialClauses - 1; i >= 0; --i) {
-      float maxPossibleScore =
-          (float) MathUtil.sumUpperBound(score + maxScoreSums[i], allScorers.length);
-      if (maxPossibleScore < minCompetitiveScore) {
-        // Hit is not competitive.
-        return;
-      }
-
       DisiWrapper scorer = allScorers[i];
-      if (scorer.doc < doc) {
-        scorer.doc = scorer.iterator.advance(doc);
-      }
-      if (scorer.doc == doc) {
-        score += scorer.scorable.score();
-      }
+      ScorerUtil.filterCompetitiveHits(
path: lucene/core/src/java/org/apache/lucene/search/MaxScoreBulkScorer.java
user: login: gf2121
body: Can we skip this if `minCompetitiveScore == 0f`, like other place?
created_at: 2025-05-23T11:42:05Z
author_association: CONTRIBUTOR
diff_hunk: @@ -117,4 +119,69 @@ public int length() {
       return in.length();
     }
   }
+
+  static void filterCompetitiveHits(
+      DocAndScoreAccBuffer buffer,
+      double maxRemainingScore,
+      float minCompetitiveScore,
+      int numScorers) {
+    int newSize = 0;
+    for (int i = 0; i < buffer.size; ++i) {
+      float maxPossibleScore =
+          (float) MathUtil.sumUpperBound(buffer.scores[i] + maxRemainingScore, numScorers);
+      if (maxPossibleScore >= minCompetitiveScore) {
+        buffer.docs[newSize] = buffer.docs[i];
+        buffer.scores[newSize] = buffer.scores[i];
+        newSize++;
+      }
+    }
+    buffer.size = newSize;
+  }
+
+  /**
+   * Apply the provided {@link Scorable} as a required clause on the given {@link
+   * DocAndScoreAccBuffer}. This filters out documents from the buffer that do not match, and adds
+   * the scores of this {@link Scorable} to the scores.
+   *
+   * <p><b>NOTE</b>: The provided buffer must contain doc IDs in sorted order, with no duplicates.
+   */
+  static void applyRequiredClause(
+      DocAndScoreAccBuffer buffer, DocIdSetIterator iterator, Scorable scorable)
+      throws IOException {
+    int intersectionSize = 0;
+    int curDoc = iterator.docID();
+    for (int i = 0; i < buffer.size; ++i) {
+      int targetDoc = buffer.docs[i];
+      if (curDoc < targetDoc) {
+        curDoc = iterator.advance(targetDoc);
+      }
+      if (curDoc == targetDoc) {
path: lucene/core/src/java/org/apache/lucene/search/ScorerUtil.java
user: login: gf2121
body: Out of curiosity, could `VectorUtil#findNextGTE` help here?
created_at: 2025-05-23T12:31:58Z
author_association: CONTRIBUTOR
diff_hunk: @@ -118,98 +115,36 @@ private void scoreWindow(
       return;
     }
 
-    Scorable scorer1 = this.scorer1;
-    if (scorers[0].getMaxScore(max - 1) == 0f) {
path: lucene/core/src/java/org/apache/lucene/search/BlockMaxConjunctionBulkScorer.java
user: login: gf2121
body: I was wondering why the `FilteredAnd` tasks, which use `BlockMaxConjunctionBulkScorer`, are getting more slow down than other tasks. Could the removal of this special optimization be the reason? Do we intend to remove this for simplification?
created_at: 2025-05-23T13:09:27Z
author_association: CONTRIBUTOR
diff_hunk: @@ -394,31 +365,22 @@ void updateMaxWindowScores(int windowMin, int windowMax) throws IOException {
   }
 
   private void scoreNonEssentialClauses(
-      LeafCollector collector, int doc, double essentialScore, int numNonEssentialClauses)
+      LeafCollector collector, DocAndScoreAccBuffer buffer, int numNonEssentialClauses)
       throws IOException {
+    numCandidates += buffer.size;
 
-    ++numCandidates;
-
-    double score = essentialScore;
     for (int i = numNonEssentialClauses - 1; i >= 0; --i) {
-      float maxPossibleScore =
-          (float) MathUtil.sumUpperBound(score + maxScoreSums[i], allScorers.length);
-      if (maxPossibleScore < minCompetitiveScore) {
-        // Hit is not competitive.
-        return;
-      }
-
       DisiWrapper scorer = allScorers[i];
-      if (scorer.doc < doc) {
-        scorer.doc = scorer.iterator.advance(doc);
-      }
-      if (scorer.doc == doc) {
-        score += scorer.scorable.score();
-      }
+      ScorerUtil.filterCompetitiveHits(
path: lucene/core/src/java/org/apache/lucene/search/MaxScoreBulkScorer.java
user: login: jpountz
body: This shouldn't be needed as this evaluates non-essential clauses, and there can only be non-essential clauses if minCompetitiveScore > 0. I'll add an assertion to make this clearer.
created_at: 2025-05-23T13:38:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -117,4 +119,69 @@ public int length() {
       return in.length();
     }
   }
+
+  static void filterCompetitiveHits(
+      DocAndScoreAccBuffer buffer,
+      double maxRemainingScore,
+      float minCompetitiveScore,
+      int numScorers) {
+    int newSize = 0;
+    for (int i = 0; i < buffer.size; ++i) {
+      float maxPossibleScore =
+          (float) MathUtil.sumUpperBound(buffer.scores[i] + maxRemainingScore, numScorers);
+      if (maxPossibleScore >= minCompetitiveScore) {
+        buffer.docs[newSize] = buffer.docs[i];
+        buffer.scores[newSize] = buffer.scores[i];
+        newSize++;
+      }
+    }
+    buffer.size = newSize;
+  }
+
+  /**
+   * Apply the provided {@link Scorable} as a required clause on the given {@link
+   * DocAndScoreAccBuffer}. This filters out documents from the buffer that do not match, and adds
+   * the scores of this {@link Scorable} to the scores.
+   *
+   * <p><b>NOTE</b>: The provided buffer must contain doc IDs in sorted order, with no duplicates.
+   */
+  static void applyRequiredClause(
+      DocAndScoreAccBuffer buffer, DocIdSetIterator iterator, Scorable scorable)
+      throws IOException {
+    int intersectionSize = 0;
+    int curDoc = iterator.docID();
+    for (int i = 0; i < buffer.size; ++i) {
+      int targetDoc = buffer.docs[i];
+      if (curDoc < targetDoc) {
+        curDoc = iterator.advance(targetDoc);
+      }
+      if (curDoc == targetDoc) {
path: lucene/core/src/java/org/apache/lucene/search/ScorerUtil.java
user: login: jpountz
body: I wondered the same. :) Let's keep it this way (slightly simpler) and look into whether this can be made faster with VectorUtil in a follow-up?
created_at: 2025-05-23T13:39:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -118,98 +115,36 @@ private void scoreWindow(
       return;
     }
 
-    Scorable scorer1 = this.scorer1;
-    if (scorers[0].getMaxScore(max - 1) == 0f) {
path: lucene/core/src/java/org/apache/lucene/search/BlockMaxConjunctionBulkScorer.java
user: login: jpountz
body: This may be a contributing factor indeed. I suspect that another factor is that filtered tasks use the default impl of `nextDocsAndScores` which adds some overhead compared to what we were doing before since it first needs to copy matching docs to an array before doing work on this array. I was keen to looking into the performance of filtered tasks in a follow-up, I worry it's not going to be easy to recover performance.
created_at: 2025-05-23T13:42:05Z
author_association: CONTRIBUTOR

PR comments:
user: login: github-actions[bot]
created_at: 2025-05-22T11:47:20Z
body: This PR does not have an entry in lucene/CHANGES.txt. Consider adding one. If the PR doesn't need a changelog entry, then add the skip-changelog-check label to it and you will stop receiving this reminder on future updates to the PR.
author_association: CONTRIBUTOR
user: login: jpountz
created_at: 2025-05-22T11:49:36Z
body: Filtered queries get a slowdown, but some important queries get a big speedup:

```
                            TaskQPS baseline      StdDevQPS my_modified_version      StdDev                Pct diff p-value
             FilteredAndHighHigh       65.66      (1.9%)       59.10      (4.6%)  -10.0% ( -16% -   -3%) 0.000
            FilteredAndStopWords       45.10      (2.4%)       41.04      (3.9%)   -9.0% ( -14% -   -2%) 0.000
               FilteredAnd3Terms      181.24      (1.7%)      165.29      (3.2%)   -8.8% ( -13% -   -3%) 0.000
     FilteredAnd2Terms2StopWords      178.47      (1.3%)      162.78      (2.9%)   -8.8% ( -12% -   -4%) 0.000
                          Fuzzy1      102.41      (2.9%)       98.67      (3.2%)   -3.6% (  -9% -    2%) 0.000
             FilteredOrStopWords       46.85      (1.6%)       45.27      (2.5%)   -3.4% (  -7% -    0%) 0.000
              FilteredOrHighHigh       68.45      (0.9%)       66.49      (1.9%)   -2.9% (  -5% -    0%) 0.000
                          Fuzzy2       85.94      (2.6%)       83.58      (2.8%)   -2.7% (  -7% -    2%) 0.002
                      OrHighRare      291.53      (3.3%)      283.82      (6.1%)   -2.6% ( -11% -    6%) 0.096
              FilteredAndHighMed      126.38      (2.6%)      123.62      (5.5%)   -2.2% ( -10% -    6%) 0.119
                      DismaxTerm      486.16      (2.2%)      478.90      (3.2%)   -1.5% (  -6% -    3%) 0.089
               FilteredOrHighMed      153.91      (0.8%)      151.71      (1.3%)   -1.4% (  -3% -    0%) 0.000
      FilteredOr2Terms2StopWords      148.64      (1.1%)      146.57      (1.5%)   -1.4% (  -3% -    1%) 0.001
             CombinedAndHighHigh       11.47      (2.6%)       11.33      (2.3%)   -1.2% (  -5% -    3%) 0.132
                FilteredOr3Terms      167.24      (1.1%)      165.61      (1.6%)   -1.0% (  -3% -    1%) 0.030
                        Wildcard       94.57      (2.8%)       93.67      (2.7%)   -1.0% (  -6% -    4%) 0.281
              CombinedOrHighHigh       18.94      (1.8%)       18.80      (2.1%)   -0.7% (  -4% -    3%) 0.243
                            Term      439.45      (3.6%)      436.34      (4.5%)   -0.7% (  -8% -    7%) 0.595
                      TermDTSort      389.67      (4.7%)      386.92      (4.8%)   -0.7% (  -9% -    9%) 0.647
                    FilteredTerm      159.78      (1.6%)      158.69      (2.3%)   -0.7% (  -4% -    3%) 0.282
                 AndHighOrMedMed       46.65      (1.1%)       46.40      (1.8%)   -0.5% (  -3% -    2%) 0.267
                        PKLookup      320.16      (4.3%)      318.93      (4.7%)   -0.4% (  -8% -    8%) 0.791
                 FilteredPrefix3      154.48      (2.3%)      154.23      (2.6%)   -0.2% (  -4% -    4%) 0.840
                          Phrase       14.34      (2.2%)       14.32      (2.3%)   -0.1% (  -4% -    4%) 0.858
                          IntNRQ      309.51      (0.8%)      309.13      (0.9%)   -0.1% (  -1% -    1%) 0.641
                         Prefix3      163.72      (2.3%)      163.54      (2.7%)   -0.1% (  -4% -    4%) 0.893
                 CountAndHighMed      312.21      (1.2%)      312.08      (1.6%)   -0.0% (  -2% -    2%) 0.929
                IntervalsOrdered        2.29      (2.9%)        2.29      (2.5%)    0.1% (  -5% -    5%) 0.919
                  FilteredIntNRQ      301.55      (1.0%)      301.84      (0.9%)    0.1% (  -1% -    2%) 0.760
             CountFilteredOrMany       27.46      (1.0%)       27.50      (1.3%)    0.1% (  -2% -    2%) 0.754
          CountFilteredOrHighMed      148.19      (0.7%)      148.39      (0.6%)    0.1% (  -1% -    1%) 0.509
                     CountOrMany       30.46      (1.5%)       30.52      (1.5%)    0.2% (  -2% -    3%) 0.712
                       CountTerm     8687.88      (2.8%)     8708.11      (4.5%)    0.2% (  -6% -    7%) 0.850
         CountFilteredOrHighHigh      136.50      (0.7%)      136.90      (0.6%)    0.3% (  -1% -    1%) 0.178
                CountAndHighHigh      359.19      (1.7%)      360.43      (1.6%)    0.3% (  -2% -    3%) 0.520
                  CountOrHighMed      366.72      (1.5%)      368.05      (1.8%)    0.4% (  -2% -    3%) 0.486
                    CombinedTerm       30.67      (3.5%)       30.78      (4.0%)    0.4% (  -6% -    8%) 0.751
                 CountOrHighHigh      346.98      (1.1%)      348.48      (1.5%)    0.4% (  -2% -    3%) 0.304
                  FilteredPhrase       32.93      (1.8%)       33.07      (1.5%)    0.4% (  -2% -    3%) 0.413
             CountFilteredPhrase       25.29      (3.2%)       25.50      (2.5%)    0.8% (  -4% -    6%) 0.377
                   TermTitleSort       85.82     (10.1%)       86.55      (7.5%)    0.9% ( -15% -   20%) 0.767
                   TermMonthSort     3181.64      (2.9%)     3213.86      (2.2%)    1.0% (  -3% -    6%) 0.227
                  FilteredOrMany       16.50      (2.7%)       16.67      (2.5%)    1.0% (  -4% -    6%) 0.221
                     CountPhrase        4.15      (3.2%)        4.20      (2.0%)    1.3% (  -3% -    6%) 0.137
               TermDayOfYearSort      283.82      (3.8%)      287.84      (1.8%)    1.4% (  -4% -    7%) 0.146
               CombinedOrHighMed       72.56      (2.4%)       73.69      (1.8%)    1.6% (  -2% -    5%) 0.024
                          OrMany       20.45      (5.3%)       21.05      (6.0%)    2.9% (  -8% -   15%) 0.114
              CombinedAndHighMed       38.55      (2.3%)       39.92      (2.2%)    3.6% (   0% -    8%) 0.000
                DismaxOrHighHigh      118.14      (2.1%)      122.80      (4.3%)    3.9% (  -2% -   10%) 0.000
                 DismaxOrHighMed      173.28      (2.3%)      183.27      (2.2%)    5.8% (   1% -   10%) 0.000
                        Or3Terms      165.74      (5.5%)      197.36      (7.9%)   19.1% (   5% -   34%) 0.000
                       And3Terms      172.23      (4.2%)      205.61      (7.9%)   19.4% (   7% -   32%) 0.000
              Or2Terms2StopWords      159.77      (5.6%)      191.42      (6.9%)   19.8% (   6% -   34%) 0.000
             And2Terms2StopWords      161.65      (4.1%)      194.38      (7.1%)   20.2% (   8% -   32%) 0.000
                AndMedOrHighHigh       65.43      (2.8%)       79.68      (2.7%)   21.8% (  15% -   28%) 0.000
                    AndStopWords       29.75      (6.7%)       36.45     (12.7%)   22.5% (   2% -   44%) 0.000
                     OrStopWords       32.42      (9.6%)       39.95     (13.0%)   23.2% (   0% -   50%) 0.000
                       OrHighMed      192.11      (4.0%)      244.47      (2.7%)   27.3% (  19% -   35%) 0.000
                      OrHighHigh       51.75      (4.9%)       66.11      (9.2%)   27.8% (  13% -   44%) 0.000
                     AndHighHigh       43.14      (1.5%)       56.30     (11.8%)   30.5% (  16% -   44%) 0.000
                      AndHighMed      135.13      (0.9%)      183.67      (4.2%)   35.9% (  30% -   41%) 0.000
```
author_association: CONTRIBUTOR
user: login: jpountz
created_at: 2025-05-31T06:16:18Z
body: Nightly benchmarks saw a good speedup for some queries (`OrHighMed`, `AndHighHigh`, ...) but the slowdown on filtered queries was higher than what I was observing locally: https://benchmarks.mikemccandless.com/2025.05.29.13.41.40.html
author_association: CONTRIBUTOR
user: login: jpountz
created_at: 2025-05-31T07:43:46Z
body: I'll spend some time better understanding what happens with these filtered tasks. It's not too surprising to me that there is some slowdown, but I wouldn't have expected this magnitude.
author_association: CONTRIBUTOR
user: login: jpountz
created_at: 2025-05-31T13:53:59Z
body: I think I found the reason, the new approach to scoring conjunctions appears to be slower in the ramp-up phase, when less than `k` hits have been collected and faster in the steady state when most hits have a score that is less than the minimum competitive score. Filtered conjunctions are slower because they have barely more than k=100 hits globally. I'll open a PR shortly.
author_association: CONTRIBUTOR