All findings · Lucene finding · LFV-829 · highlighter-multiphrase-leading-position-counted-as-gap

The classic Highlighter counts the empty positions before a MultiPhraseQuery as gaps and marks pairs that do not match

With a stop-word and synonym analyzer, the phrase "the dogs bark" becomes a MultiPhraseQuery starting at position 1, and the classic Highlighter marks dogs often bark as well as the real match dogs bark. It counts the empty position before the phrase as a gap, in every release since 2.4.0.

Summary

Priority
Low, rank 355Over-highlighting only, in the classic Highlighter, and it needs a stop-word-plus-synonym analyzer with a phrase that starts with a stop word.
Impact
silent wrong results
Since
2.4.0 (LUCENE-794, 2008)introduced 2008-04-29 · over 18 years
Affected
2.4.0 to 10.6.0 and main; 17 releases run, 10.6.0 by reading
Status
documented
Found by
bounded-exhaustive comparison with Weight#matches

AI-assisted page: this finding was first signalled by a semi-formal check (an exact check of the real jar, or a modelling hypothesis that a tool then witnessed) and then checked with AI tools; the checks it went through, and what each found, are under Prior reports, history and checks. Not yet triaged by a human. Snapshot of 2026-11-06.

The finding in 30 seconds

To find a phrase in the text, the classic Highlighter turns it into a span query whose slop is the phrase's slop plus its gaps. For a MultiPhraseQuery it counts the gaps from position 0, so empty positions before the phrase's first word count too.

The example: "the dogs bark" becomes a MultiPhraseQuery with [dogs|dog] at position 1 and bark at position 2, position 0 left empty by the removed stop word. The classic Highlighter's conversion counts that empty position as a gap and builds an ordered span query with slop 1, so on the text 'dogs often bark but my dogs bark' it marks dogs often bark as well as dogs bark, while Weight#matches reports only positions 5-6.
The example step by step: the empty position 0 before the phrase is counted as a gap, so the converted span query allows one extra word and dogs often bark is marked next to the real match dogs bark. Select the still to open it at full size. Generated with AI, checked against the code.
  1. You parse the quoted phrase "the dogs bark" with QueryParser or QueryBuilder and an analyzer that removes stop words and adds dog as a synonym of dogs. The dropped the keeps position 0, so you get a MultiPhraseQuery: [dogs|dog] at position 1, bark at 2, slop 0.
  2. You index dogs often bark but my dogs bark (words at positions 0 to 6). The phrase matches once: dogs bark at positions 5-6, as Weight#matches reports.
  3. You highlight it with Highlighter and QueryScorer and expect dogs often bark but my <B>dogs</B> <B>bark</B>. The same query moved to positions 0 and 1, and a PhraseQuery at positions 1 and 2, give exactly that.
  4. Instead you get <B>dogs</B> often <B>bark</B> but my <B>dogs</B> <B>bark</B>, with no error. The highlighter turns the phrase into a span query and counts every empty position from 0 as a gap between the words, so the empty position 0 becomes slop 1 and dogs often bark qualifies.
  5. Each further leading position adds one more: "the a dogs bark" (positions 2 and 3) marks a pair with two words between them. Sloppy phrases get the extra unit too.

Who is affected

Applications that highlight with the classic Highlighter and QueryScorer, or with the UnifiedHighlighter with weight matches off (its default in 6.3 to 8.11), when a quoted phrase starts with a removed stop word and contains a synonym. Near-miss pairs get extra marks; hits and scores are correct. The default UnifiedHighlighter since 9.0 is exact.

Mechanism, terms and numbers772 words

The classic Highlighter with QueryScorer converts a MultiPhraseQuery into a SpanNearQuery. WeightedSpanTermExtractor walks the position slots from 0 to the last position and counts every empty slot as a gap, including the slots before the phrase's first position, and adds that count to the slop. A MultiPhraseQuery that starts at position k > 0 therefore gets k extra units of slop. QueryBuilder, and so the classic QueryParser, build such a query for a quoted phrase that starts with a removed stop word and contains a same-position synonym: "the dogs bark" becomes [dogs|dog]@1 bark@2, and on dogs often bark but my dogs bark the highlighter also marks dogs often bark, although Weight#matches reports only positions 5-6. The PhraseQuery branch of the same method measures gaps from the first position and is correct. Search hits, counts and scores are not affected. The UnifiedHighlighter shows the same marks with weight matches off (its default in 6.3.0 to 8.11.x); its default since 9.0 is exact. Every release from 2.4.0 to 10.6.0 and main is affected (17 releases and main run, the rest by code history; 10.6.0 by reading). A one-condition fix passes all 732 tests of the highlighter module, the repro tests included.

Terms

  • Classic Highlighter: org.apache.lucene.search.highlight.Highlighter with a QueryScorer. The scorer asks WeightedSpanTermExtractor which words of the text belong to a match of the query; for phrases it converts the query to a SpanQuery and runs it on a one-document index of the text.
  • Positions: each word of a phrase query has a position. A removed stop word keeps its position, so "the dogs bark" gives dogs position 1 and bark position 2. MultiPhraseQuery documents its positions as relative: shifting them all by the same amount does not change what matches.
  • MultiPhraseQuery: a phrase with several alternatives at one position, here [dogs|dog]. QueryBuilder builds one when the analyzer stacks a token at the same position, for example a single-word synonym. Without a stacked token the same input becomes a PhraseQuery.
  • Slop of a SpanNearQuery: "the maximum number of intervening unmatched positions" between its clauses (SpanNearQuery.java:38-40).

What happens (main a9779ef3c193, WeightedSpanTermExtractor.java)

// MultiPhraseQuery branch
final List<SpanQuery>[] disjunctLists = new List[maxPosition + 1];   // :200  slots 0..maxPosition
...
for (List<SpanQuery> disjuncts : disjunctLists) {                    // :218
  if (disjuncts != null) {
    clauses[position++] = new SpanOrQuery(disjuncts.toArray(SpanQuery[]::new));
  } else {
    ++positionGaps;          // :222  also for the empty slots BEFORE the first used slot
  }
}
SpanNearQuery sp = new SpanNearQuery(clauses, slop + positionGaps, inorder);   // :232, inorder = (slop == 0)

// PhraseQuery branch, for comparison: relative to the first position
positionGaps = Math.max(0, positions[positions.length - 1] - positions[0] - positions.length + 1);  // :154-155
  1. For [dogs|dog]@1 bark@2 the array has 3 slots. Slot 0 is empty (the dropped the); slots 1 and 2 hold the clauses.
  2. The loop counts slot 0 as a gap: positionGaps = 1, although nothing lies between the two clauses.
  3. The span query is spanNear([spanOr([dogs, dog]), spanOr([bark])], 1, true): ordered, slop 1. A trace prints slop 0, 1 and 2 for the same query starting at position 0, 1 and 2.
  4. On dogs often bark but my dogs bark that span query also matches dogs(0) ... bark(2), so the extractor records bark at positions [0,2] and [5,6] instead of [5,6] only, and QueryScorer marks the first pair too.

Where the input comes from: QueryBuilder.analyzeMultiPhrase starts at position = -1 and adds each token's increment (QueryBuilder.java:464-497), and position increments are on by default (QueryBuilder.java:198-206), so a removed leading stop word makes the first position 1. The classic QueryParser builds the same query from "the dogs bark".

The numbers

query (text dogs often bark but my dogs bark)Weight#matchesclassic Highlighter on main
MultiPhraseQuery [dogs|dog]@1 bark@2, slop 0 (from "the dogs bark")positions 5-6<B>dogs</B> often <B>bark</B> but my <B>dogs</B> <B>bark</B> (wrong)
the same at @0 @1 (control)positions 5-6dogs often bark but my <B>dogs</B> <B>bark</B>
PhraseQuery dogs@1 bark@2 (control)positions 5-6dogs often bark but my <B>dogs</B> <B>bark</B>
the same MultiPhraseQuery at @2 @3 or @3 @4positions 5-6wrong, as at @1 @2

With two leading stop words ("the a dogs bark", positions 2 and 3) a pair with two words between them is marked; slop 1 from position 1 marks a pair that needs slop 2. A text with no match can get marks too: [a|x]@1 b@2, slop 0, on a z b gives <B>a</B> z <B>b</B>, where the query at positions 0 and 1 returns no fragment.

Exposure and likelihood430 words
  • Who. Users of the classic Highlighter with QueryScorer (position-aware by default since 2.9.0), on re-analysed text and on term-vector token streams; and users of the UnifiedHighlighter with withWeightMatches(false) (a builder option since 9.1.0), whose PhraseHelper reuses the same extractor. From 6.3.0 to 8.11.x that was the UnifiedHighlighter's default path. By reading, any configuration that keeps strict phrase highlighting but loses weight matches gets there too: weight matches are set only when multi-term handling, strict phrases and passage relevancy are all on (UnifiedHighlighter.java:518-527), so withHandleMultiTermQuery(false) or withPassageRelevancyOverSpeed(false) also use PhraseHelper (:1219-1235). The default UnifiedHighlighter of 9.0 and later uses Weight#matches and is exact.
  • Trigger. A MultiPhraseQuery whose smallest position is above 0. In practice: a query analyzer that removes stop words and stacks same-position tokens (single-word synonyms, or any filter that emits a token with position increment 0 and position length 1; a longer token sends QueryBuilder to its graph path, which builds phrase queries per path instead), and a quoted phrase that starts with a stop word and contains such a token. With Lucene's SynonymGraphFilter the filter order matters: synonyms before stop words trigger it for this example, stop words first gave positions 0 and 1 and an exact highlight (traced, this example only). Hand-built queries that start above 0 do the same. Plain stop-word phrases become a PhraseQuery and are not affected.
  • Effect. Extra marks: pairs of the phrase's words that are up to k positions further apart than the query allows, where k is the first position (one per leading removed word); sloppy phrases too. A text without a match can get marks. Hits, counts and scores do not change, and there is no error with assertions on or off. By reading, not run: QueryScorer scores fragments by the query terms they contain, so the extra marks can also change which fragment is picked.
  • Scale. A bounded-exhaustive harness (152 documents of 1-3 words, 2,139 queries, 153,082 checks, one per query hit, against an exact phrase reference cross-checked with Weight#matches) found this class in 92 cases in each of the two token-stream modes, and none for a PhraseQuery with the same positions.
  • How likely. Rare: each ingredient is ordinary, but the combination plus a near-miss pair in the text is uncommon, and nobody has reported it in 18 years. The classic Highlighter is the older highlighter; #16820 (open) proposes deprecating it, nothing is deprecated yet.
  • Workaround. Use the UnifiedHighlighter with weight matches (its default since 9.0), or shift the MultiPhraseQuery so its positions start at 0 before highlighting; the controls show both highlight exactly.

Minimal repro

The test shows that the classic Highlighter with QueryScorer adds one unit of slop for each unused position before a MultiPhraseQuery's first position, so [dogs|dog]@1 bark@2 (slop 0, what QueryBuilder builds for "the dogs bark" with a stop word and a synonym) also marks the non-adjacent "dogs often bark".

TestMultiPhraseLeadingPositionHighlight.java (JUnit test)41 lines
/*
 * 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.highlight;

import org.apache.lucene.index.Term;
import org.apache.lucene.search.MultiPhraseQuery;
import org.apache.lucene.search.Query;
import org.apache.lucene.tests.analysis.MockAnalyzer;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestMultiPhraseLeadingPositionHighlight extends LuceneTestCase {

  public void testFirstPositionAboveZeroAddsNoSlop() throws Exception {
    // expected: only the adjacent "dogs bark" marked; actual: "dogs often bark" too (as slop 1)
    Query query =
        new MultiPhraseQuery.Builder()
            .add(new Term[] {new Term("f", "dogs"), new Term("f", "dog")}, 1)
            .add(new Term[] {new Term("f", "bark")}, 2)
            .build();
    Highlighter highlighter = new Highlighter(new QueryScorer(query, "f"));
    highlighter.setTextFragmenter(new NullFragmenter());
    assertEquals(
        "dogs often bark but my <B>dogs</B> <B>bark</B>",
        highlighter.getBestFragment(
            new MockAnalyzer(random()), "f", "dogs often bark but my dogs bark"));
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-829/, which holds src/)
# Lucene checkout at the tested commit, built with: ./gradlew --no-daemon assemble   (JDK 25)
LUCENE=/path/to/lucene; V=12.0.0-SNAPSHOT
CP=$LUCENE/lucene/core/build/libs/lucene-core-$V.jar
CP=$CP:$LUCENE/lucene/codecs/build/libs/lucene-codecs-$V.jar
CP=$CP:$LUCENE/lucene/test-framework/build/libs/lucene-test-framework-$V.jar
CP=$CP:$LUCENE/lucene/highlighter/build/libs/lucene-highlighter-$V.jar
CP=$CP:$LUCENE/lucene/memory/build/libs/lucene-memory-$V.jar
CP=$CP:$LUCENE/lucene/queries/build/libs/lucene-queries-$V.jar
CP=$CP:randomizedtesting-runner-2.9.2.jar:junit-4.13.2.jar:hamcrest-3.0.jar   # test-framework dependencies (Maven Central)
CP=$CP:junit-platform-console-standalone-6.1.3.jar   # JUnit Platform console launcher (Maven Central)

javac -d classes -cp "$CP" $(find src -name '*.java')

# run 1: assertions on, seed DEADBEEF
java -ea -esa --add-modules jdk.incubator.vector --enable-native-access=ALL-UNNAMED -Dtests.seed=DEADBEEF \
     -cp "classes:$CP" org.junit.platform.console.ConsoleLauncher \
     execute --select-class org.apache.lucene.search.highlight.TestMultiPhraseLeadingPositionHighlight
Expected vs actual
Expected dogs often bark but my <B>dogs</B> <B>bark</B>; actual <B>dogs</B> often <B>bark</B> but my <B>dogs</B> <B>bark</B>, with -ea and -da.

All files of this repro: repro/LFV-829/

More files, environment, full output44 words

Environment

luceneLucene main e1faeba08bde (2026-11-02)
jdk25.0.3+9-LTS

Observed output

# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
    => org.junit.ComparisonFailure: expected:<[dogs often bark] but my <B>dogs</B> ...> but was:<[<B>dogs</B> often <B>bark</B>] but my <B>dogs</B> ...>
       org.junit.Assert.assertEquals(Assert.java:117)
       org.junit.Assert.assertEquals(Assert.java:146)
       org.apache.lucene.search.highlight.TestMultiPhraseLeadingPositionHighlight.testFirstPositionAboveZeroAddsNoSlop(TestMultiPhraseLeadingPositionHighlight.java:36)

Why it looks unintended

  • MultiPhraseQuery documents its positions as relative; the same query at positions 0 and 1 is highlighted exactly.
  • The PhraseQuery branch of the same method measures gaps from the first position, so identical positions give different highlights.
  • The accepted approximation covers holes between positions; no test or decision covers empty positions before the first.
The full argument210 words
  • Positions are relative by contract. MultiPhraseQuery.Builder.add(Term[], int): "Allows to specify the relative position of terms within the phrase" (MultiPhraseQuery.java:122); getPositions(): "Returns the relative positions of terms in this phrase" (:176). Weight#matches gives the same positions for the query at 0,1 and at 1,2, and PhraseQuery.rewrite subtracts positions[0] (PhraseQuery.java:309-314).
  • The same method already does it right for PhraseQuery. Since LUCENE-5503 (5.4.0) that branch uses positions[last] - positions[0] - n + 1 (:154-155). Its CHANGES entry says the extractor "converts a PhraseQuery to an equivalent SpanQuery" (CHANGES.txt:10042-10045), and PhraseHelper.java:107 says "WSTE will convert to an equivalent SpanQuery" (a code comment; PhraseHelper is @lucene.internal).
  • The scorer's stated goal. QueryScorer "attempts to score only those terms that participated in generating the 'hit' on the document" (QueryScorer.java:33-37).
  • No test covers it. The upstream highlighter tests that use a MultiPhraseQuery all start at position 0: TestHighlighter.java:820-849 (two tests) and :1732-1740 (a one-term query), and TestUnifiedHighlighterStrictPhrases.testSynonyms (:261-279, positions 0 and 1). The contributed patch that introduced the loop (LUCENE-794, 2008) tested only positions starting at 0. No CHANGES, MIGRATE or won't-fix entry mentions it.
  • History. LUCENE-7417 (2016) edited this loop for a one-clause crash and kept the leading-slot count.
The devil's-advocate check182 words

Strongest case (an intended-behaviour checker and a reviewer from another model family, OpenAI GPT-5.6): the classic phrase-to-span conversion is a known approximation. On LUCENE-2003 markrmiller called adding holes to the slop "one - uh - sloppy fix ... I think that keeps a fairly good approximation"; QueryScorer only "attempts" to score participating terms; the sandbox PayloadSpanUtil copies the same loop, so it looks like a convention; and #16820 proposes deprecating the classic Highlighter.

Why it fails: the accepted approximation is about holes between positions (the sibling bug LFV-827). A slot before the first position is not a hole, and every formula committers discussed gives 0 for it: "maxPos-minPos+1-numTokens" (yonik, LUCENE-2003) and LUCENE-5503's positions[last] - positions[0] - n + 1. Shifting the query by a constant, which the API defines as meaningless, changes the highlight. A copied loop (in a sandbox class marked @lucene.experimental) is not a decision, a proposed deprecation is not a decision, and the UnifiedHighlighter's weight-matches-off path is not part of that proposal. The reviewer rated the argument moderate and outweighed; the checker and the adjudication: not intended.

Suggested fix

Recommended A. Count empty slots only after the first used slot (recommended)

Count an empty slot as a gap only after the first used slot: } else if (position > 0) { in the MultiPhraseQuery branch of WeightedSpanTermExtractor.extract (+2/-1). The 10 repro tests and all 722 other highlighter-module tests pass, with assertions on and off. Interior holes are counted as before.

         for (List<SpanQuery> disjuncts : disjunctLists) {
           if (disjuncts != null) {
             clauses[position++] = new SpanOrQuery(disjuncts.toArray(SpanQuery[]::new));
-          } else {
+          } else if (position > 0) {
+            // only unused positions between the first and the last query position are gaps
             ++positionGaps;
           }
         }
All options and trade-offs4 options ยท 670 words

Fix A and fix B were built and run in a scratch worktree at a9779ef3c193 with the whole :lucene:highlighter:test task (the three repro test classes added). Option C was not run. The sandbox PayloadSpanUtil.java:137-152 has the same loop; a fix may want to cover it (not run). Related: LFV-827 (interior holes of an exact phrase become slop, same method) and LFV-397 (int overflow of the same slop + positionGaps sum).

A. Count empty slots only after the first used slot (recommended) (validated by the checks)

position counts the clauses filled so far, so empty slots before the first clause are skipped; the array ends at the last position, so there are no trailing empty slots.

         for (List<SpanQuery> disjuncts : disjunctLists) {
           if (disjuncts != null) {
             clauses[position++] = new SpanOrQuery(disjuncts.toArray(SpanQuery[]::new));
-          } else {
+          } else if (position > 0) {
+            // only unused positions between the first and the last query position are gaps
             ++positionGaps;
           }
         }

Trade-offs

  • Smallest change; interior holes keep today's handling (that part is LFV-827's topic).
  • One comparison per empty slot; not benchmarked.
  • The array still has slots below the first position (option C removes them).

What the checks validated

  • Gradle on a9779ef3c193, seed DEADBEEF: :lucene:highlighter:test with the 3 repro classes passes with assertions on and off: 37 classes, 732 tests, 0 failures (722 upstream tests, e.g. TestHighlighter 59, TestHighlighterPhrase 7, TestUnifiedHighlighter 156, plus the 10 repro tests).
  • checkGoogleJavaFormat, forbiddenApisMain, forbiddenApisTest, ecjLintMain and ecjLintTest pass with the fix and the repro test.
  • With the standalone runner (fix as a shadow class): the independent reproducer's 8 of 8 and a reviewer's 2 of 2 pass, and 79 classic highlight-package tests; the intended-behaviour checker's probe now highlights exactly for the parsed query, for starts 0-3, and in the UnifiedHighlighter with weight matches off.
  • In the bounded-exhaustive harness, with this and two sibling fix sketches (which change other code) on the classpath, the class drops from 92 to 0 cases per token-stream mode.

B. Compute the gaps from the first and the last position (validated by the checks)

positionGaps = (maxPosition - minPosition + 1) - distinctPositions, with minPosition the smallest position, and the else branch removed (+6/-3). It is the formula yonik proposed on LUCENE-2003 for the PhraseQuery branch and the form that branch uses today, so both branches then read alike.

-        int positionGaps = 0;
+        int minPosition = positions[0];
+        for (int p : positions) {
+          minPosition = Math.min(minPosition, p);
+        }
+        // gaps are the unused positions between the first and the last
+        int positionGaps = (maxPosition - minPosition + 1) - distinctPositions;
         ...
           if (disjuncts != null) {
             clauses[position++] = new SpanOrQuery(disjuncts.toArray(SpanQuery[]::new));
-          } else {
-            ++positionGaps;
           }

Trade-offs

Same result as A, a few more lines; one extra pass over the positions.

What the checks validated

  • Gradle on a9779ef3c193, assertions on: :lucene:highlighter:test with the 3 repro classes passes, 37 classes, 732 tests, 0 failures; the same lint tasks pass. With the standalone runner the independent reproducer's 8 of 8 and 70 upstream highlight tests pass. Not run with assertions off.

C. Shift the positions to start at 0 first

Subtract the smallest position from every position before building disjunctLists, as PhraseQuery.rewrite does.

Trade-offs

Same result as A and B for non-negative positions, and the array no longer has slots below the first position; more lines. Not run. MultiPhraseQuery.Builder.add does not reject a negative position: A and B would still index disjunctLists[-1] there, as today, and C would not.

Not a fix: user-side workarounds (validated by the checks)

Until a fix ships: highlight with the UnifiedHighlighter and weight matches (its default since 9.0), or rebuild the MultiPhraseQuery with positions starting at 0 before passing it to QueryScorer.

Trade-offs

Application code only; it does not fix Lucene.

What the checks validated

  • Main a9779ef3c193: the same query at positions 0,1 highlights exactly (the repro's control 2), and the default UnifiedHighlighter marks only dogs bark for the query at 1,2.

Downstream note

By code reading only, the plain highlighters of Elasticsearch and OpenSearch subclass QueryScorer and pass a MultiPhraseQuery to this same extractor branch; nothing was run downstream and no downstream report was found.

How it was found

Semi-formal: a bounded-exhaustive comparison of the classic Highlighter's marks with an exact phrase reference, cross-checked against Weight#matches (153,082 checks of query hits), flagged multi-phrase queries that start at position 1. Code reading traced it to the gap loop, and it was reduced to a public-API JUnit with a QueryBuilder query.

The method in full150 words
  • Harness (bounded-exhaustive translation validation of the stock jars). All single-valued documents of 1-3 words over a small alphabet (152 documents, including position holes and stacked tokens) against 2,139 queries: term, synonym, phrase and multi-phrase queries with holes, slop 0-2 and start position 0 or 1, and Boolean combinations; 153,082 checks, one per query hit. Each highlight was compared with an independent exact phrase reference, itself cross-checked against Weight#matches on every hit (0 calibration mismatches), for re-analysed text and for term-vector token streams.
  • Signal. The class classic EXTRA mpq+start: 92 cases per mode, for example [a]@1 [b]@2 on a a b marks the first a. The PhraseQuery with the same positions had no such class, which pointed at the MultiPhraseQuery branch.
  • Reduction. Code reading found the slot loop; the finder wrote the public-API JUnit with a QueryBuilder-built query and ran it on main and on 10.5.2 and 9.12.3.

Prior reports, history and checks

Prior reports

No prior report found (172 logged searches and reads over GitHub, the Lucene and Solr lists, JIRA and the web, 2026-10-28). Nearest: LUCENE-5503 (#6566) and LUCENE-2003 (#3078), which fixed only the PhraseQuery branch, and LUCENE-7417 (#8470), which edited this loop and kept it.

Searches5 searches

Registry verdict new, confidence high. None of the items below reports positions before a MultiPhraseQuery's first position being counted as gaps. Also read and unrelated: #11645 (open, extra terms inside nested span windows), #5568 (LUCENE-4502, open, a missing highlight), #8629 (LUCENE-7578, open, a nested-span inaccuracy in PhraseHelper; lists spans converted from a phrase or multi-phrase query as "not a problem" for that defect), #9208 (LUCENE-8160, open refactor whose unmerged patch copies the loop).

What was searched, when, and what turned up
WhereWhenHowFound
GitHub apache/lucene issues, PRs and commits2026-10-28two scripted prior-report searches (identifiers, 51 queries; symptoms, 56 queries) and 18 own searches; the file's history on main, branch_10x and branch_10_6; git log -S positionGapsno report; nearest #6566, #3078, #1869, #8470, #9208, #8629
Open and recent PRs2026-10-28the changed files of all 411 open apache/lucene PRs; PRs created since 2026-10-25; re-checkedonly #12295 touches the file (casts only); no fix in flight
Downstream (Elasticsearch, OpenSearch, Solr)2026-10-2811 own GitHub searches, the scripted prior-report search downstream legs and the plain-highlighter sourceno report; their plain highlighters reach this branch (code reading)
Mailing lists and JIRA2026-10-28list searches including solr-user@, users@solr and dev@solr; 10 JQL searches over LUCENE and SOLR; 6 issues read in fullno report
Web2026-10-2810 searches and the Stack Exchange APIno report

History

Present since the code was written: LUCENE-794 (35c7eb36dfb, 2008-04-29). First affected 2.4.0; 17 releases from 2.4.0 to 10.5.2 and main give byte-identical wrong output. The UnifiedHighlighter was affected by default in 6.3.0 to 8.11.x. Not fixed upstream.

Versions tested7 rows

Introduced by 35c7eb36dfb (svn trunk r652164), "Commit of LUCENE-794 patch - adding phrase/span query support to highlighter" (2008-04-29, apache/lucene#1869). The MultiPhraseQuery code came from a patch contributed on LUCENE-794 on 2008-03-09, whose tests start at position 0. Not a regression; svn era, no PR.

Never changed in substance: git log -L lists 13 later commits on the branch (a whole-file rewrite, generics, the builder, LUCENE-7417's one-clause case, reformatting); none changes the gap count. The PhraseQuery branch became relative in LUCENE-2003 (2.9.1) and LUCENE-5503 (5.4.0); the MultiPhraseQuery branch was never aligned.

First affected release 2.4.0, through the opt-in SpanScorer; the default QueryScorer is position-aware from 2.9.0. Not fixed upstream: main 20ced71c1e6e, branch_10x and branch_10_6 still have the loop (2026-10-28).

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affected7 of 10 repro tests fail (gradle and runner, assertions on and off)
2.9.0, 2.9.4, 3.0.3, 3.6.2, 4.10.4, 5.5.5, 6.0.0, 6.6.6, 7.0.0, 7.7.3, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 affectedbyte-identical wrong output (history program; the JUnit repro also fails on 9.12.3, 10.0.0, 10.5.2)
2.4.0, 2.4.1 SpanScorerthe same wrong output through the opt-in SpanScorer
2.3.2 no such codeno position-aware scorer
UnifiedHighlighter 6.3.0, 7.7.3, 8.11.4 defaultthe default UnifiedHighlighter marks the same extra pair
UnifiedHighlighter 9.0.0, 9.12.3, 10.5.2, main weight matches offdefault exact (weight matches); withWeightMatches(false) (9.1.0 and later) marks the extra pair
main + fix A or fix B not affectedall 732 highlighter-module tests pass, the repro tests included

Independent checks

The finder, an independent reproducer, an intended-behaviour checker and a reviewer from another model family (OpenAI GPT-5.6) each ran their own tests: all said real. A second cross-model review read the code and agreed. An adjudication re-ran with a new seed and confirmed; an audit kept it; this doc's gradle runs reproduced it and validated two fixes.

Checks and verdicts354 words

Every check executed code on main except the intended-lens cross-model review, which read files only (a separate run re-ran the finder's test and its own shift-invariance test). The refute-lens review ran on main ea57360912ed, which has the same code.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidateHarness class mpq+start (92 cases per mode, 0 with the fix sketches); JUnit fails 1 of 1 on main (-ea, -da), 10.5.2 and 9.12.3; the fix passes it and 79 upstream classic highlight tests.
refute review (OpenAI GPT-5.6) real, highOwn JUnit ([sun|solar]@3 moon@4) fails 1 of 2 with three seeds, one with -da; the zero-based control passes; with the fix 2 of 2 and 79 of 79 classic tests pass. Intended argument moderate.
independent reproducer realOwn 8-test JUnit with a Weight#matches oracle: 5 of 8 fail on main (-ea, -da), 10.5.2 (-ea, -da) and 9.12.3; controls pass; its own fix (form B) passes 8 of 8 and 70 upstream tests.
intended-lens review (OpenAI GPT-5.6, reading only) real, medium'Works as designed' argument moderate and outweighed; a separate run re-ran the finder's test (fails on main and 10.5.2) and its own shift-invariance test (fails; passes with the finder's fix and with a separate fix).
intended-behaviour checker real, highThe 'approximate conversion' remarks cover holes between positions, not this case; its probe showed the classic QueryParser path and the UnifiedHighlighter weight-matches-off path.
adjudication confirmed, highRe-ran the finder's test (1 of 1) and the reproducer's (5 of 8) with seed 5EED1313, -ea and -da; the reproducer's passes 8 of 8 with the fix. Not a duplicate of LFV-397 or LFV-827.
credibility audit keepRe-ran the finder's test (fails, -ea and -da); its own shift test (starts 0-4) highlights consistently with a converted slop equal to the first position.
doc step (gradle) reproduced; fixes validatedStock main: 7 of 10 repro tests fail with -ea and -da. Fix A: 732 of 732 module tests pass with -ea and -da, format and lint clean. Fix B: 732 of 732 with -ea, lint clean.

Diagram file

The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.

Sources and provenance

Finding record, Lucene and upstream sources, repro files and diagram notes1,537 words

Finding record

LFV-829, snapshot of 2026-11-06

Registry title
Classic Highlighter counts the unused positions before a MultiPhraseQuery's first position as gaps, so a phrase that starts at position 1 or later (a leading stop word plus a synonym) is highlighted with extra slop and non-matching pairs get marked
Area
highlighter / original Highlighter, QueryScorer and WeightedSpanTermExtractor (MultiPhraseQuery-to-span conversion)
Priority
Low, rank 355: Over-highlighting only, in the classic Highlighter, and it needs a stop-word-plus-synonym analyzer with a phrase that starts with a stop word.
Impact class
silent wrong results
Impact note
Applications on any release from 2.4.0 through 10.6.0 (10.6.0 by reading) or main that highlight with the original Highlighter and the default QueryScorer (or with the UnifiedHighlighter with weight matches turned off, through PhraseHelper, which was the UnifiedHighlighter default from 6.3.0 through 8.11.x) a MultiPhraseQuery whose first position is above 0, which QueryParser and QueryBuilder build for a quoted phrase that starts with a removed stop word and contains a same-position synonym, get extra highlights on texts where the phrase's terms also occur in order with up to one extra word between them per leading removed word: that pair is marked although it does not match; search hits, counts and scores are correct, there is no error with -ea or -da, no setting or scale is needed beyond such an analyzer, and the default UnifiedHighlighter of 9.0 and later (weight matches on) highlights correctly.
Summary
You search for the quoted phrase "the dogs bark" with an analyzer that removes the stop word 'the' and adds the synonym dog for dogs (QueryParser and QueryBuilder build a MultiPhraseQuery [dogs|dog] at position 1, bark at position 2, slop 0), and highlight the text 'dogs often bark but my dogs bark' with Highlighter and QueryScorer, expecting 'dogs often bark but my <B>dogs</B> <B>bark</B>' (Weight#matches reports only positions 5-6, and the same query starting at position 0, or a PhraseQuery at positions 1 and 2, is highlighted that way). Instead you get '<B>dogs</B> often <B>bark</B> but my <B>dogs</B> <B>bark</B>', with no error. WeightedSpanTermExtractor counts every empty slot from index 0 up to the last position as a position gap, including the unused slot 0 below the first position, so the phrase becomes an ordered SpanNearQuery with slop 1 instead of 0 (one extra unit per leading slot, also for sloppy phrases), while its PhraseQuery branch measures gaps from the first position.
Introduced
35c7eb36dfb07ff919147d7570158433e48cd092 (svn trunk r652164, 'Commit of LUCENE-794 patch - adding phrase/span query support to highlighter', committed by Mark Harwood; JIRA LUCENE-794 is now apache/lucene#1869) wrote the first MultiPhraseQuery-to-SpanNearQuery conversion, already with the slot loop that counts every null slot of disjunctLists[0..maxPosition] as a gap and 'new SpanNearQuery(clauses, slop + positionGaps, slop == 0)' [read: git blame -w -M -C of main :215-224 and :229-232 in the full-history clone]; the MultiPhraseQuery code came from 'MultiPhraseQueryExtraction.patch', attached to LUCENE-794 by Benjamin Keil on 2008-03-09 and folded into the main patch by Mark Miller on 2008-03-10, whose tests use only positions starting at 0. Later commits did not change the counted text: f32b5a56986 (LUCENE-1285, whole-file rewrite) and 3966f998214 (LUCENE-7417, 2016, one-clause special case) kept the loop [read: git log -L]. The PhraseQuery branch never had this defect (relative since LUCENE-2003 in 2.9.1 and LUCENE-5503 in 5.4.0) and the MultiPhraseQuery branch was never aligned with it. Present since the code was written, not a regression; svn era, so no pull request (2008-04-29 (author and commit date of 35c7eb36dfb; the contributed MultiPhraseQuery patch is from 2008-03-09))
First affected version
2.4.0 [exec] (release 2008-10-08), through the opt-in SpanScorer that LUCENE-794 added; the default QueryScorer is position-aware from 2.9.0 (LUCENE-1685, 10b41d2dce2) [exec]; 2.3.2, the last release before the introducing commit, has no position-aware scorer and no WeightedSpanTermExtractor [exec: NOT APPLICABLE]
Affected versions
every release from 2.4.0 through 10.5.2, 10.6.0 (by reading) and main a9779ef3c193; no version has a fix [exec: the history program (MultiPhraseQuery [dogs|dog]@1 bark@2 slop 0, positions 2,3, and positions 1,2 slop 1; whitespace tokens; exact two-term phrase oracle; -ea; JDK 25; SHA-1-verified release jars) fails byte-identically on 17 releases, 2.4.0, 2.4.1 (SpanScorer), 2.9.0, 2.9.4, 3.0.3, 3.6.2, 4.10.4, 5.5.5, 6.0.0, 6.6.6, 7.0.0, 7.7.3, 8.11.4, 9.0.0, 9.12.3, 10.0.0 and 10.5.2, and on main, with the PhraseQuery@1,2 and MultiPhraseQuery@0,1 controls passing on all, and passes on main with the fix shadowed; the finder's JUnit (QueryBuilder query) fails on 9.12.3, 10.0.0, 10.5.2 and main -ea/-da; the independent reproduction's JUnit fails 5 of 8 on 9.12.3, 10.5.2 (-ea, -da) and main (-ea, -da)]. UnifiedHighlighter (PhraseHelper reuses the extractor since 6.3.0): affected in its default configuration on 6.3.0, 7.7.3 and 8.11.4 [exec]; default exact from 9.0.0 (LUCENE-9431 made WEIGHT_MATCHES the default) [exec 9.0.0]; affected with Builder#withWeightMatches(false) from 9.1.0 [exec 9.12.3, 10.5.2, main]. Releases between the executed ones are covered by blame and git log -L [read]. Upstream main 20ced71c1e6e, branch_10x 657014b3c579 and branch_10_6 4ae946e5a9b9 still have the loop; the newest commit touching the file is dbf0d1426f13 (author date 2026-05-14, toArray clean-up) [read]. 10.6.0 (released 2026-10-29) was read at 4ae946e5a9b9 (above), not executed. Every run used JDK 25
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that agreed, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
semi-formal: exhaustive check; also reached by code reading
Upstream and prior reports
none found (the prior-report record, verdict new, confidence high, searched 2026-10-28 after legs by the finder, the independent reproduction, the intended-behaviour check and the adjudication, all read-only: 172 recorded queries, two scripted prior-report searches (identifiers, 51 queries; symptoms, 56 queries) over apache/lucene issues, PRs and commits, downstream repositories, the mailing lists (including solr-user@, users@solr and dev@solr), the JIRA archive and the web, 29 manual GitHub searches on apache/lucene and downstream repositories, 10 JQL searches over LUCENE and SOLR, a changed-file scan of all 411 open apache/lucene PRs (only #12295 touches the file, casts only), and the file history on main, branch_10x and branch_10_6; re-check: no new PR, issue or commit). Nearest items, none the same bug: #6566 (LUCENE-5503, fixed 2015: made the PhraseQuery branch relative to positions[0], left the MultiPhraseQuery loop), #3078 (LUCENE-2003, fixed 2.9.1: PhraseQuery gap handling, interior gaps only), #1869 (LUCENE-794, introduced the loop), #8470 (LUCENE-7417, one-clause MultiPhraseQuery crash, kept the loop), #9208 (LUCENE-8160, open refactor proposal whose unmerged patch copies the loop), #8629 (LUCENE-7578, open: a committer says spans converted from a MultiPhraseQuery are 'not a problem'), #11645 (open, extra terms inside nested span windows, different mechanism), #5568 (LUCENE-4502, open, missing highlight); registry LFV-397 (int overflow of the same 'slop + positionGaps' sum) and the sibling highlighter-phrase-holes-become-slop (interior holes become slop)
Lucene commit tested
main a9779ef3c193 (gradle in a scratch worktree, assertions on and off, and the standalone JUnit runner with several seeds); the history program on the release jars 2.3.2, 2.4.0, 2.4.1, 2.9.0, 2.9.4, 3.0.3, 3.6.2, 4.10.4, 5.5.5, 6.0.0, 6.6.6, 7.0.0, 7.7.3, 8.11.4, 9.0.0, 9.12.3, 10.0.0 and 10.5.2; the UnifiedHighlighter program on 6.2.1, 6.3.0, 7.7.3, 8.11.4, 9.0.0, 9.12.3, 10.5.2 and main; the JUnit repro on 9.12.3, 10.0.0 and 10.5.2; fixes A and B on a9779ef3c193 with the whole :lucene:highlighter:test task. JDK 25.0.3, Linux aarch64.

Lucene and upstream sources

Finding record
  • lucene/highlighter/src/java/org/apache/lucene/search/highlight/WeightedSpanTermExtractor.java:187-235 (at a9779ef3c193) (MultiPhraseQuery branch: disjunctLists = new List[maxPosition + 1] at :200, the slot loop that increments positionGaps for every null slot from index 0 at :215-224, new SpanNearQuery(clauses, slop + positionGaps, inorder = (slop == 0)) at :229-232), :139-164 (PhraseQuery branch: positionGaps = positions[last] - positions[0] - n + 1 at :150-156, relative to the first position)
  • lucene/core/src/java/org/apache/lucene/search/MultiPhraseQuery.java:122 (at a9779ef3c193) (Builder.add(Term[], int): 'Allows to specify the relative position of terms within the phrase'), :176 (getPositions: 'Returns the relative positions of terms in this phrase')
  • lucene/core/src/java/org/apache/lucene/util/QueryBuilder.java:464-497 (at a9779ef3c193) (analyzeMultiPhrase: position = -1 at :472, plus each position increment, so a leading removed stop word gives first position 1)
  • lucene/highlighter/src/java/org/apache/lucene/search/highlight/QueryScorer.java:33-37 (at a9779ef3c193) (class javadoc: converts queries to SpanQuerys and 'attempts to score only those terms that participated in generating the hit'); lucene/highlighter/src/java/org/apache/lucene/search/uhighlight/PhraseHelper.java:53, :107-109 ('WSTE will convert to an equivalent SpanQuery'); lucene/highlighter/src/java/org/apache/lucene/search/uhighlight/UnifiedHighlighter.java:110 (DEFAULT_ENABLE_WEIGHT_MATCHES = true)
  • lucene/sandbox/src/java/org/apache/lucene/payloads/PayloadSpanUtil.java:137-152 (at a9779ef3c193) (sandbox copy of the same slot loop; out of hunt scope)
The finding in 30 seconds
  • lucene/highlighter/src/java/org/apache/lucene/search/highlight/WeightedSpanTermExtractor.java:187-235 (at a9779ef3c193) (MultiPhraseQuery branch: :200 disjunctLists, :215-224 slot loop, :229-232 SpanNearQuery), :139-164 (PhraseQuery branch, :154-155)
  • lucene/queries/src/java/org/apache/lucene/queries/spans/SpanNearQuery.java:38-40 (at a9779ef3c193) (slop javadoc)
  • lucene/core/src/java/org/apache/lucene/util/QueryBuilder.java:198-206 (at a9779ef3c193) (position increments on by default), :464-497 (analyzeMultiPhrase, position = -1 at :472)
  • lucene/core/src/java/org/apache/lucene/search/MultiPhraseQuery.java:122, (at a9779ef3c193) :176 (relative positions)
Who is affected
  • lucene/highlighter/src/java/org/apache/lucene/search/uhighlight/PhraseHelper.java:53, (at a9779ef3c193) :107-109 (reuses WeightedSpanTermExtractor; 'WSTE will convert to an equivalent SpanQuery')
  • lucene/highlighter/src/java/org/apache/lucene/search/uhighlight/UnifiedHighlighter.java:110 (at a9779ef3c193) (DEFAULT_ENABLE_WEIGHT_MATCHES = true), :340-352 (withWeightMatches)
  • lucene/highlighter/src/java/org/apache/lucene/search/highlight/QueryScorer.java:33-37 (at a9779ef3c193)
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/search/MultiPhraseQuery.java:122, (at a9779ef3c193) :176; lucene/core/src/java/org/apache/lucene/search/PhraseQuery.java:309-314
  • lucene/highlighter/src/java/org/apache/lucene/search/highlight/WeightedSpanTermExtractor.java:149-162; (at a9779ef3c193) lucene/CHANGES.txt:10042-10045 (LUCENE-5503), :8909-8911 (LUCENE-7417); lucene/highlighter/src/java/org/apache/lucene/search/uhighlight/PhraseHelper.java:107
  • lucene/highlighter/src/java/org/apache/lucene/search/highlight/QueryScorer.java:33-37 (at a9779ef3c193)
  • lucene/highlighter/src/test/org/apache/lucene/search/highlight/TestHighlighter.java:820-849, (at a9779ef3c193) :1732-1740; lucene/highlighter/src/test/org/apache/lucene/search/uhighlight/TestUnifiedHighlighterStrictPhrases.java:261-279
  • lucene/sandbox/src/java/org/apache/lucene/payloads/PayloadSpanUtil.java:48 (at a9779ef3c193) (@lucene.experimental), :137-152; lucene/highlighter/src/java/org/apache/lucene/search/uhighlight/PhraseHelper.java:62 (@lucene.internal)
Suggested fix
  • lucene/core/src/java/org/apache/lucene/search/PhraseQuery.java:309-314; (at a9779ef3c193) lucene/sandbox/src/java/org/apache/lucene/payloads/PayloadSpanUtil.java:137-152

Other statements trace to the hunt's working files (models, logs, reviews and briefs), which are not published.

Repro files

About the diagrams

A step-by-step picture of the repro's example, made in parallel with this doc.

Built 2026-11-06 from the hunt's record of this finding and its doc file. Lucene main under test in this hunt: ea57360912ed. Every listed finding was re-run on Lucene main e1faeba08bde on 2026-11-02.