All findings · Lucene finding · LFV-832 · fvh-stacked-position-only-first-mapped-token-tried

FastVectorHighlighter loses a match at a position with stacked index-time synonyms when the first stacked token starts a phrase that does not continue

With index-time synonyms, FastVectorHighlighter can drop a match because another token at the same position starts a query phrase: on my dogs (synonym dog stacked on dogs), dogs OR "dog house" matches, but getBestFragment returns null instead of my <b>dogs</b>. A silent regression since 4.7.1; 4.7.0 highlights it.

Summary

Priority
Medium, rank 210FastVectorHighlighter has silently dropped highlights since 4.7.1 on its default phrase path when index-time stacked tokens meet a term-or-phrase query, with hits still correct, a narrow silent-highlight-loss case.
Impact
silent wrong results
Since
4.7.1 (LUCENE-5538, 2014); 4.7.0 highlights correctlyintroduced 2014-03-19 · over 12 years
Affected
every release from 4.7.1 to 10.6.0, and main
Status
documented
Found by
a bounded-exhaustive comparison with an exact match reference, then a JUnit test (semi-formal)

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

At a position that holds several tokens, FastVectorHighlighter starts with the first token the query knows, even when it is only the first word of a phrase. If the phrase does not continue there, it gives up on the whole position, so another token there that the query matches is never highlighted.

Background in three steps

  1. Index-time synonyms put several tokens at one position: my dogs with the synonym dog is indexed as my (position 0), dogs and dog (both position 1, offsets 3-7).
  2. FastVectorHighlighter reads the document's term vector and keeps the query's terms in a stack ordered by position. Since 4.7.1 the tokens of one position form one stack entry, in term order: dog, then dogs.
  3. It turns the query into a phrase map: a term (dogs) is a complete entry; a phrase ("dog house") is a chain whose first word (dog) is not complete on its own.
A flow diagram on a light card: the query dogs OR "dog house" becomes a map of query words (dogs a whole term; dog, then house, a phrase), and the document "my dogs", with the synonym dog stacked on dogs, becomes a stack with one entry per position in which stacked tokens share an entry (position 1 = dog, then dogs); both feed FieldPhraseList, drawn in red with a cross and the label that only the first token with a map entry is tried at the start, which feeds the matches marked with <b>...</b>; a zoom on position 1 shows dog, tried first, as a dead end because house does not follow, and dogs, the term the query asks for, crossed out as never tried; at the bottom an orange Expected panel (try dog, then dogs: "my <b>dogs</b>") and a blue Actual panel with a red cross (dogs is never tried: getBestFragment = null, no error).
FastVectorHighlighter keeps the stacked tokens of a position in one stack entry, and FieldPhraseList starts a match only with the first of them that has a query-map entry, so a phrase that starts with that token and does not go on hides the matching word. Generated with AI, checked against the code.
Diagram of the bug: the document my dogs with dog stacked on dogs at position 1, and the query dogs OR "dog house". FastVectorHighlighter keeps dog and dogs in one stack entry in term order, starts with dog because it begins the phrase dog house, finds no house after it, adds nothing and never tries dogs, so the highlight is null; the term query dogs alone gives my <b>dogs</b>.
dogs OR "dog house" on my dogs: position 1 holds dog and dogs. The highlighter starts at dog, the first word of the phrase, finds no house, gives up on the position and never tries dogs, so getBestFragment returns null instead of my <b>dogs</b>. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Index my dogs with an index-time synonym filter that adds dog at the position of dogs (a field with term vectors, positions and offsets). Search dogs OR "dog house": 1 hit, through dogs; the phrase matches nothing.
  2. You expect getBestFragment to return my <b>dogs</b>: Weight#matches reports dogs at offsets 3-7, and the query dogs alone gives exactly that fragment.
  3. Instead you get null. Position 1 holds dog and dogs, tried in term order. dog is the first word of "dog house", so it becomes the start; no house follows, nothing is added, and dogs is never tried.
  4. No error, and hits and scores are correct. Lucene 4.7.0 highlights it; every release since 4.7.1 (LUCENE-5538) returns null. SynonymGraphFilter + FlattenGraphFilter and KeywordRepeatFilter + a stemmer hit it the same way.

Who is affected

Users of FastVectorHighlighter with phrase highlighting (the default) on term-vector fields whose index-time analysis stacks tokens at one position: synonyms, KeywordRepeatFilter plus a stemmer. It takes a query phrase that starts with the stacked token that sorts first and does not continue there. Hits and scores are unaffected.

Mechanism, terms and numbers605 words

With phrase highlighting (the default), FastVectorHighlighter groups all tokens of one position into one entry of its FieldTermStack, as a circular list in term order. FieldPhraseList starts a candidate with the first of those tokens that has any entry in the query's phrase map, even when that entry is only the first word of a phrase. If the phrase does not continue there, nothing is added and the other tokens of the position are never tried. Index my dogs with an index-time synonym dog stacked on dogs and search dogs OR "dog house": the document matches through dogs (Weight#matches reports offsets 3-7), but getBestFragment returns null instead of my <b>dogs</b>, which dogs alone gives. Search hits and scores are unaffected and nothing fails loudly. Lucene 4.7.0 highlights the input; the loop came with LUCENE-5538 in 4.7.1 (2014), and every release since is affected, up to 10.6.0 and main. A one-file fix that tries each token of the start position passes the repro and every highlighter-module test.

The mechanism (main 5adff78bb168, lucene/highlighter/.../vectorhighlight/)

  1. One entry per position. FieldTermStack collects the query's terms from the term vector in term order, sorts them by position (FieldTermStack.java:105-106; TermInfo.compareTo compares positions only, :225-227) and links the tokens of one position into a circular next list, removing all but the first from the stack (:108-130). For my dogs the stack holds one entry, position 1: dog -> dogs -> dog (my is not a query term).
  2. The phrase map. QueryPhraseMap.add gives a PhraseQuery a non-terminal map for each leading term and marks only the last one terminal; a TermQuery gets a terminal map (FieldQuery.java:398-418).
  3. The start loop. The FieldPhraseList constructor pops the entry and walks its alternatives only while getFieldTermMap(...) returns null (FieldPhraseList.java:73-78). dog sorts first and has a (non-terminal) map, so the walk stops at dog. The entry is skipped only when no token has a map (:80-81).
  4. The dead end. The next pop finds nothing (:86), so the phrase cannot continue (:95). The one-token candidate [dog] is not a valid term or phrase, because its map is not terminal (FieldQuery.java:460-465), and backtracking runs only while the candidate has more than one token (FieldPhraseList.java:102). Nothing is added (:97-113), and dogs, which shares the popped entry, is never tried. The phrase list is empty and getBestFragment returns null.
  5. Order matters. With dog indexed and dogs stacked on it, dog OR "dogs house" is highlighted. The analysis docs leave that order undefined (lucene/core/.../analysis/package-info.java:341-344). A token pushed back after a failed phrase can also become the one tried first: the exhaustive check shows a OR "b b" losing the a of b z a/b (a and b stacked at position 2).
  6. Why 4.7.0 was right. Before LUCENE-5538 every stacked token was its own stack entry, so after the dead end at dog the next pop was dogs, which was added. LUCENE-5538 collapsed them into one entry and added the start loop, which stops at the first token with any map.

The numbers

query, document (dog stacked on dogs)Weight#matchesFastVectorHighlighter
dogs OR "dog house", my dogsdogs at 3-7null
dogs alone, my dogsdogs at 3-7my <b>dogs</b>
dog OR "dogs house", my dog (dogs stacked on dog)dog at 3-6my <b>dog</b>
"dogs bark" OR "dog house", dogs bark0-9null
dogs OR "dog house", my dogs, with the UnifiedHighlighter or phraseHighlight=falsedogs at 3-7my <b>dogs</b> (both)
Exposure and likelihood305 words
  • Who is exposed. Applications that highlight with FastVectorHighlighter and phrase highlighting on (DEFAULT_PHRASE_HIGHLIGHT = true, FastVectorHighlighter.java:28, default constructor :37-38), on a field with term vectors, positions and offsets (an opt-in field setting FVH requires), analyzed at index time with a chain that stacks tokens.
  • Production chains (executed on 5adff78bb168). SynonymGraphFilter + FlattenGraphFilter, as the SynonymGraphFilter javadoc prescribes for indexing (tv -> television; the classic QueryParser query tv OR "television stand" on my tv returns null); KeywordRepeatFilter + PorterStemFilter (running OR "run fast" on she was running returns null). By reading, any other filter that stacks tokens (for example a word-delimiter filter that keeps the original) can do it too.
  • The trigger. At a position with a query match, the token tried first (the one that sorts first among the position's tokens the query knows, or one pushed back after a failed phrase) is only the first word of a query phrase, and that phrase does not continue there. Phrase OR phrase is hit too: "dogs bark" OR "dog house" on dogs bark returns null.
  • Silent. No exception with assertions on or off. getBestFragment returns null when it was the field's only match; otherwise that occurrence is just not marked. Search hits, counts and scores are correct.
  • Not affected in the tested cases, and workarounds. The UnifiedHighlighter and FastVectorHighlighter with phraseHighlight=false both give my <b>dogs</b> for dogs OR "dog house" on my dogs, and both highlight tv OR "television stand" on my tv (SynonymGraphFilter + FlattenGraphFilter). This is shown for the tested inputs only.
  • How likely. Rare: each ingredient is ordinary for FVH users with index-time synonyms, but the combination is narrow, and the searches found no report of it in the 12 years since 4.7.1. No scale is needed: one two-word document.

Minimal repro

One JUnit test, one method, public API: one document my dogs, the test framework's MockSynonymAnalyzer (adds dog at the position of dogs), term vectors, a plain IndexSearcher and the default FastVectorHighlighter. Deterministic; it fails with assertions on and off and passes with the fix.

TestFastVectorHighlighterStackedSynonym.java (one test method)67 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.vectorhighlight;

import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.FieldType;
import org.apache.lucene.document.TextField;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.index.Term;
import org.apache.lucene.search.BooleanClause.Occur;
import org.apache.lucene.search.BooleanQuery;
import org.apache.lucene.search.IndexSearcher;
import org.apache.lucene.search.PhraseQuery;
import org.apache.lucene.search.Query;
import org.apache.lucene.search.TermQuery;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.analysis.MockSynonymAnalyzer;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestFastVectorHighlighterStackedSynonym extends LuceneTestCase {

  public void testTermAtStackedPositionIsHighlighted() throws Exception {
    FieldType ft = new FieldType(TextField.TYPE_STORED);
    ft.setStoreTermVectors(true);
    ft.setStoreTermVectorPositions(true);
    ft.setStoreTermVectorOffsets(true);
    try (Directory dir = new ByteBuffersDirectory()) {
      // MockSynonymAnalyzer indexes "dogs" and the synonym "dog" at the same position
      try (IndexWriter w = new IndexWriter(dir, new IndexWriterConfig(new MockSynonymAnalyzer()))) {
        Document doc = new Document();
        doc.add(new Field("f", "my dogs", ft));
        w.addDocument(doc);
      }
      try (DirectoryReader reader = DirectoryReader.open(dir)) {
        // dogs OR "dog house": the document matches through the term "dogs"
        Query query =
            new BooleanQuery.Builder()
                .add(new TermQuery(new Term("f", "dogs")), Occur.SHOULD)
                .add(new PhraseQuery("f", "dog", "house"), Occur.SHOULD)
                .build();
        assertEquals(1, new IndexSearcher(reader).count(query));
        FastVectorHighlighter fvh = new FastVectorHighlighter();
        String fragment =
            fvh.getBestFragment(fvh.getFieldQuery(query, reader), reader, 0, "f", 100);
        assertEquals("my <b>dogs</b>", fragment);
      }
    }
  }
}
Command
./gradlew :lucene:highlighter:test --tests org.apache.lucene.search.vectorhighlight.TestFastVectorHighlighterStackedSynonym -Ptests.seed=DEADBEEF
Expected
my <b>dogs</b>: the document is a hit only through dogs, which the term query alone highlights.
Observed
null (expected:<my <b>dogs</b>> but was:<null>), with assertions on and off.

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

More files, environment, full output348 words

The test sits in org.apache.lucene.search.vectorhighlight only for locality; it uses only public API. ByteBuffersDirectory, a plain IndexWriterConfig with MockSynonymAnalyzer, a stored TextField with term vectors, positions and offsets, one document, single thread. The test body draws nothing at random; as a LuceneTestCase it still gets the framework's per-seed defaults (codec, locale, time zone), and a fixed seed makes a run reproducible. The miss did not depend on the seed in any run (the independent reproducer's three claim tests failed on all 20 random seeds). The count assertion shows the document is a hit; the last assertion fails. The same test is the suggested regression test.

MockSynonymAnalyzer is used because it is the smallest input; the same miss happens with SynonymGraphFilter + FlattenGraphFilter at index time and a classic QueryParser query, and with KeywordRepeatFilter + PorterStemFilter (see "Who is affected").

It needs Lucene 9.1 or later as written (the org.apache.lucene.tests.* packages); the earlier history sweep used era-adapted copies (an inline stacking filter before 6.2, older imports) for 4.x to 9.0.

Environment

lucenemain 5adff78bb168 (12.0.0-SNAPSHOT); also fails on 10.6.0, 10.5.2, 10.0.0, 9.12.3 and 9.1.0
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64
Command (environment)
cp $REPRO/TestFastVectorHighlighterStackedSynonym.java $LUCENE/lucene/highlighter/src/test/org/apache/lucene/search/vectorhighlight/
cd $LUCENE
./gradlew :lucene:highlighter:test --tests org.apache.lucene.search.vectorhighlight.TestFastVectorHighlighterStackedSynonym -Ptests.seed=DEADBEEF
# production setting (assertions off): add -Ptests.asserts=false

Observed output

# gradle, main 5adff78bb168, seed DEADBEEF, assertions on (the same with -Ptests.asserts=false)
TestFastVectorHighlighterStackedSynonym > testTermAtStackedPositionIsHighlighted FAILED
    java.lang.AssertionError: expected:<my <b>dogs</b>> but was:<null>

# with the recommended fix: BUILD SUCCESSFUL, assertions on and off

# JUnit runner, seed DEADBEEF, assertions on: the same failure on 10.6.0, 10.5.2, 10.0.0, 9.12.3 and 9.1.0

# earlier tests re-run on 5adff78bb168 (JUnit runner, seed 5ADFF78B, assertions on)
finder's test (MockSynonymAnalyzer): count 1, Weight#matches [3,7), dogs alone 'my <b>dogs</b>'; dogs OR "dog house" -> null
independent reproducer: 3 of 5 fail
  testTermOrDeadEndPhraseStackedFilter  FieldPhraseList must hold the TermQuery occurrence expected:<1> but was:<0>
  testTermOrDeadEndPhraseSynonymGraph   expected:<my <b>dogs</b>> but was:<null>
  testPhraseOrDeadEndPhrase             expected:<<b>dogs bark</b>> but was:<null>
  controls (dogs alone, mirrored order) pass
devil's-advocate verifier: 2 of 5 fail
  testSynonymGraphTermOrPhraseParsedQuery  expected:<my <b>tv</b>> but was:<null>
  testKeywordRepeatStemTermOrPhrase        expected:<she was <b>running</b>> but was:<null>
  controls (UnifiedHighlighter, phraseHighlight=false, mirrored order) pass

Why it looks unintended

  • LUCENE-5538 added this code in 4.7.1 as a bug fix for this symptom class: "(P1 OR PQ) returns no results, even though individually, both P1 or P2 by themselves will highlight correctly". 4.7.0 highlights the input.
  • A SHOULD phrase that matches nothing removes the highlight of a matching term.
  • The package docs' worked example is a term OR phrase query.
The full argument354 words
  • A regression of a bug fix. CHANGES for 4.7.1: "LUCENE-5538: Fix FastVectorHighlighter bug with index-time synonyms when the query is more complex than a single phrase." (lucene/CHANGES.txt:12742-12743). The issue text (#6601): "in some situations a query of (P1 OR PQ) returns no results, even though individually, both P1 or P2 by themselves will highlight correctly". The shape is similar but the input is not the same: in #6601 both phrases highlight on their own; here the term alone highlights and the phrase alone matches nothing, yet the disjunction loses the term's highlight.
  • The fix author's intent. On #6601, Robert Muir described their first patch: "when we reach a dead-end (nextMap == null), we keep looking as long as the incoming position is the same until we find a match", and the final one: "This should be correct for all crazy combinations." Adrien Grand gave a +1. The committed code keeps looking for the continuation token (FieldPhraseList.java:90-93), but at the start only while no map is found (:75).
  • 4.7.0 highlights the input; 4.7.1 returns null, and swapping only the two source files LUCENE-5538 changed (FieldPhraseList.java, FieldTermStack.java) flips the result in both directions. 4.7.0 gets this input right because each stacked token was its own stack entry there; it is not more correct in general (it lacks LUCENE-5538's own fix).
  • Documented feature. The package javadoc lists "support phrase-unit highlighting with slops" (package-info.java:26); its worked example is a TermQuery OR a PhraseQuery (Lucene^2 OR "search library"~1, :47-53), whose phrase list holds one entry for the term and one for the phrase (:124-137). Stacked tokens are a documented analysis pattern with undefined order (analysis/package-info.java:341-344), and SynonymGraphFilter tells index-time users to stack with FlattenGraphFilter (SynonymGraphFilter.java:67-68).
  • No test pins the current output. TestIndexTimeSynonym's term-or-phrase cases (pc OR "personal computer", TestIndexTimeSynonym.java:167-175, :218) put the term first in sort order, so the dead end is never reached; LUCENE-5538's testBooleanPhraseWithSynonym (TestFastVectorHighlighter.java:714) uses two phrases that both match. All 723 highlighter-module tests pass with the fix.
The devil's-advocate check302 words

The strongest case for "intended": FastVectorHighlighter is a greedy heuristic that promises fragments, not every match. On LUCENE-4734 (#5799, open since 2013) a contributor wrote "FVH works great for simple phrase and single term queries but it has so many corner cases", and Adrien Grand ("This is my feeling too") and Simon Willnauer agreed; a broader FieldPhraseList rewrite that also handled "several terms at the same position" was committed and reverted before any release because "it makes the runtime of FastVectorHighlighter very bad". #16820 (open) proposes deprecating FVH. getBestFragments documents a null result "when no fragments created" (FastVectorHighlighter.java:131; getBestFragment itself only says "the best fragment (snippet) string", :107). Users who need exact highlights should use the UnifiedHighlighter.

Why it fails: the revert was in 2013; the defective lines came later, in 2014, from LUCENE-5538, a bug fix whose stated goal this input violates and which turned a correct 4.7.0 result into null. No document states the limitation. This is not a missed corner of a complex query but the loss of a plain term match caused by adding a SHOULD clause that matches nothing. The documented null of getBestFragments ("when no fragments created") does not say that a matching term query may go unhighlighted. The fix is bounded to the tokens of the start position (not the reverted search), and FVH is not deprecated on main. The cross-model intended-behaviour review (OpenAI GPT-5.6, with executed tests) rated the argument weak and the bug real (high); the devil's-advocate verifier found that no intended-behaviour argument survives; the independent reproducer rated the argument medium-strong before rebutting it, and rated the bug real. Expect the answer "won't fix, use the UnifiedHighlighter" anyway, especially without a worst-case cost bound for the fix: a later cross-model review rated the "intended" case moderate for that reason.

Suggested fix

Recommended A. Try every token of the start position (validated)

In the FieldPhraseList constructor, try every token of the start position in turn until one yields a term or phrase, instead of stopping at the first that has any query-map entry (one file). The repro passes with assertions on and off, and all 723 highlighter-module tests pass.

final TermInfo start = fieldTermStack.pop();
TermInfo alt = start;
do {
  QueryPhraseMap startMap = fieldQuery.getFieldTermMap(field, alt.getText());
  if (startMap != null && searchFrom(fieldTermStack, fieldQuery, field, alt, startMap)) {
    break;
  }
  alt = alt.getNext();
} while (alt != start);
All options and trade-offs2 options ยท 588 words

Two options. A was built and validated on main 5adff78bb168; B was not built. Until a fix lands, new FastVectorHighlighter(false, true) (no phrase highlighting) or the UnifiedHighlighter highlight the match in the tested cases.

A. Try every token of the start position (validated) (recommended)

Pop the entry and walk its alternatives; for each one with a map, run the existing longest-phrase search from it, and stop at the first that reaches addIfNoOverlap. The search moves unchanged into a private helper searchFrom(...) that returns whether it did; a dead end restores the stack, as today's backtracking already does. One file, +51/-46.

final TermInfo start = fieldTermStack.pop();
TermInfo alt = start;
do {
  QueryPhraseMap startMap = fieldQuery.getFieldTermMap(field, alt.getText());
  if (startMap != null && searchFrom(fieldTermStack, fieldQuery, field, alt, startMap)) {
    break;
  }
  alt = alt.getNext();
} while (alt != start);

Trade-offs

  • Cost: at most one more run of the existing phrase search per extra token at a start position, and only after a dead end: at a position with k stacked tokens, at most k runs instead of one, each walking at most as far as today's search and restoring the stack when it fails. Bounded, not the combinatorial search reverted in LUCENE-4734, but no worst-case benchmark was run, and the passing tests are not a proof that every dead-end path restores the stack. It keeps LUCENE-5538's data structure and tests.
  • It keeps term order as the preference among a position's tokens: when several of them start a valid term or phrase (with different boosts, or overlapping other hits), the first in term order still wins, and no test settles which one should.
  • It does not cover a related trigger where the first-tried token's candidate is valid but rejected by addIfNoOverlap for overlap (the helper returns true once it calls addIfNoOverlap); that trigger did not lose highlights on main in the earlier probe. The helper's javadoc ("returns true if a phrase was added") should say "offered to addIfNoOverlap".

What the checks validated

  • Gradle in a scratch worktree of 5adff78bb168, OpenJDK 25.0.3, seed DEADBEEF: stock, the repro fails with assertions on and off; with the fix it passes, on and off.
  • All tests of the highlighter module pass with the fix and the repro in place: 35 classes, 723 tests, 0 failures, 0 skipped (the 13 vectorhighlight classes have 123 tests).
  • checkGoogleJavaFormat, ecjLint, forbiddenApisMain and renderJavadoc of lucene/highlighter pass (the build disables the stock javadoc task, which the log shows as SKIPPED, in favour of renderJavadoc).
  • The independent reproducer's and the devil's-advocate verifier's suites (production analysis chains included) pass 5 of 5 each with the fix applied on 5adff78bb168.
  • In the bounded-exhaustive run, with the study's fix sketches for several highlighter findings on the classpath together (among them a start-token retry like fix A; the run does not isolate fix A), the class "term OR phrase, a clause not highlighted" dropped from 401 cases to 0 and "phrase OR phrase" from 118 to 0. An independent verifier's own equivalent fix passed the same tests.

B. Also retry when the candidate is rejected for overlap (not built)

Make the helper return whether the candidate was actually added (a private boolean variant of addIfNoOverlap, which is public void), so the next token of the start position is tried after an overlap rejection too.

Trade-offs

Covers the related trigger as well, but changes which highlight wins when stacked tokens overlap an earlier hit, which #8644 (LUCENE-7593) shows is already delicate; it needs its own tests.

Downstream note

Products that show FastVectorHighlighter snippets as evidence of what matched (for example review views over synonym-expanded fields) can show no snippet, or an unmarked one, for a document that does match; search results and filtering are unaffected.

How it was found

Semi-formal. A bounded-exhaustive check compared FastVectorHighlighter's phrase list with an exact match reference, cross-checked with Weight#matches: 152 documents of 1-3 words, some with stacked tokens, 2,139 queries, 153,082 checks. 401 term-OR-phrase cases lost a clause's highlight; code reading traced them to this loop, and a public-API JUnit test confirmed it.

The method in full196 words

Method: semi-formal (bounded-exhaustive translation validation). A test program enumerated every single-valued document of 1 to 3 words over {a, b, c, a position hole, and a/b, two tokens stacked at one position with the same offsets} (152 documents) and 2,139 queries (terms, SynonymQuery, phrases with gaps and slop, MultiPhraseQuery, and term OR phrase / phrase OR phrase booleans). It compared the offsets in FastVectorHighlighter's FieldPhraseList (and the classic Highlighter's marks) with an exact match semantics written independently from the code, cross-checked against Weight#matches on every hit: 153,082 document-query checks on the stock main jars.

  • The mismatch class "term OR phrase, a matching clause not highlighted" had 401 cases (for example a OR "b b" on b z a/b highlights nothing).
  • Code reading traced it to the start-position loop; the case was reduced to a public-API JUnit test on the stock jar, which failed with assertions on and off.
  • With the study's fix sketches for several highlighter findings on the classpath together (among them a start-token retry like fix A) the class dropped to 0 (and "phrase OR phrase" from 118 to 0); the remaining highlighter mismatches belong to other, already reported defects.

Prior reports, history and checks

Prior reports

New: the searches found no Lucene issue, PR, JIRA entry, list thread or commit, and nothing downstream, that describes this defect (searched 2026-10-28, re-checked 2026-11-03 and 2026-11-04). Related: LUCENE-5538 (#6601) introduced the loop while fixing another defect; LUCENE-4734 (#5799) is another trigger; an Elasticsearch upgrade PR accepted highlights lost through a different trigger.

Searches3 searches

No report of this defect was found (re-checked 2026-11-03 in two independent passes, and again on 2026-11-04). Related prior items, none of them this defect:

  • #6601 (LUCENE-5538, "FastVectorHighlighter fails with booleans of phrases", closed 2014). The change that introduced the loop. It fixed a different defect: a phrase over index-time synonyms could dead-end when the next token popped was a synonym at the right position but not the phrase's next word, so (P1 OR P2) could highlight nothing. Its tests pass on main.
  • #5799 (LUCENE-4734, open since 2013). A sloppy phrase interrupted by a query term at another position ("B E"~10 D). Same constructor, different trigger; fix A leaves that class unchanged. Its 2013 rewrite, reverted for runtime cost, is the background for the "bounded fix" point.
  • elastic/elasticsearch#5635 (merged 2014-04-01, another contributor's upgrade to Lucene 4.7.1): "Fixed Highlight test that has different results due to LUCENE-5538". It changed the expected output of a word-delimiter FVH test (fewer com highlights) without reporting anything. The earlier study replayed that input (4.7.0: 10 com highlights, 4.7.1: 0) and, by code reading, attributes it to the same start loop through a different trigger: the first-tried stacked token's candidate is valid but rejected for overlap. On main the phrase list for that input holds each URL as one whole span (28 entries covering every word and URL, so nothing drops out, though com is no longer marked on its own). It does not describe a term lost because the first stacked token only starts a phrase that does not continue.
  • Items checked and set aside: #8644 (LUCENE-7593, which of two overlapping hits is kept), #8536 / LUCENE-7484 and elastic/elasticsearch#20781 (query-time SynonymQuery, fixed 2016), elastic/elasticsearch#45486 (query-time synonym_graph phrases), #8602, #4405, #8604 (phrase gaps, MultiPhraseQuery), #16246 (fragments builder), #16822 (package javadoc example), #16820 (deprecation proposal).
What was searched, when, and what turned up
WhereWhenHowFound
GitHub apache/lucene issues, PRs and commits (incl. the migrated JIRA archive); dev@, java-user@, issues@, commits@; JIRA; Elasticsearch, OpenSearch, Solr and Lucene.NET trackers; Stack Overflow; web2026-10-28about 200 logged read-only queries (two runs of the prior-report tool with 108 queries, 54 manual GitHub searches, list and JIRA queries, 10 web searches, every fast-vector-highlighter question on Stack Overflow) and the changed files of all 411 open apache/lucene PRsonly the related items above
Re-check: the same sources2026-11-03two independent passes over the earlier hitsthe items found earlier are related but about a different defect
Re-check for this doc: GitHub issues, PRs and commits, downstream trackers, the four lists, JIRA, web, upstream file history, open PRs2026-11-04the prior-report tool with a new spec (45 queries, 236 distinct hits; the closest are #5799, #6601 and four 2011-2013 feature requests); GitHub searches for items updated since 2026-10-27; open PRs touching the three files; Elasticsearch and OpenSearch searches; 3 web queries; the commit history of the two filesno report of this defect; not fixed upstream (main f72004515ec2 has the same loop)

History

Introduced in 4.7.1 by LUCENE-5538 (673b1f8d73c, 2014-03-19; shipped through 88df0bca7cc). 4.7.0 passes, and swapping only the two changed source files between the 4.7.0 and 4.7.1 jars flips the result both ways. Fails on every release run from 4.7.1 to 10.6.0 and on main. Not fixed upstream.

Versions tested6 rows

Introduced by LUCENE-5538, "FastVectorHighlighter fails with booleans of phrases" (#6601), by Robert Muir with a +1 from Adrien Grand; a JIRA patch, no pull request. Commit 673b1f8d73c (trunk, svn r1579255, 2014-03-19), backported the same day as 6c9608f484d (branch_4x, r1579264) and 88df0bca7cc (lucene_solr_4_7, r1579269), which shipped in 4.7.1 (tagged 2014-04-01). It added the circular same-position list in FieldTermStack and the start loop in FieldPhraseList; git log -S 'getNext() != first' on main's history finds only this commit (with --all it also lists the two backports and aad44bc6e36, a 2021 commit that removed the whole tree on an old branch).

Commit-level proof. The only highlighter source change between the 4.7.0 and 4.7.1 tags is 88df0bca7cc. On the 4.7.0 jars, swapping in only the two source files at 88df0bca7cc makes the test fail; on the 4.7.1 jars, swapping in the two files at 88df0bca7cc^ makes it pass.

Every tag from 4.7.1 on contains the loop and no earlier tag does; releases between the executed ones are covered by that code identity. 2.9.x to 3.6.x count as unaffected by reading only.

Not fixed upstream: FieldPhraseList.java was last changed by 54283150a816 (#16255, 2026-06-14, typo checks); upstream main f72004515ec2 (2026-11-03) has the same loop. Later changes (#15635, boost accumulation, 10.4.0; #15704, list type) leave it alone.

Versions and branches executed, with results
Version or commitVerdictResult
2.9.0 to 4.6.1 not affectedno start loop; the finder's test passes on 4.0.0 and 4.6.1 (2.9.x-3.6.x by reading)
4.7.0 last release before the changethe test passes (two seeds); with only the two 4.7.1 files swapped in it fails
4.7.1 to 9.0.0 affectedfails on 4.7.1, 4.7.2, 4.8.0, 4.10.4, 5.5.5, 6.6.6, 7.7.3, 8.11.4 and 9.0.0 (era-adapted copies); 4.7.1 with the two 4.7.0 files swapped in passes
9.1.0 to 10.6.0 affectedthe minimal test fails on 9.1.0, 9.12.3, 10.0.0, 10.5.2 and 10.6.0; the finder's test also on 10.3.2 and 10.4.0
main 5adff78bb168 affectedthe minimal test fails, assertions on and off (gradle and JUnit runner)
main 5adff78bb168 + fix A not affectedthe repro and all 723 highlighter-module tests pass

Independent checks

An independent reproducer, a devil's-advocate verifier and reviews by a model from another family (OpenAI GPT-5.6), each with its own executed tests, rated it real; the adjudication confirmed it. This doc's runs reproduced it on 5adff78bb168 and on 10.6.0 back to 9.1.0, and validated the fix.

Checks and verdicts345 words

The verifiers and judge ran on main a9779ef3c193, the cross-model reviews on ea57360912ed; the highlighter code is identical there and at 5adff78bb168. A re-verification on e1faeba08bde (2026-11-02) and this doc's runs on 5adff78bb168 reproduced it.

Each independent check and its verdict
CheckVerdictWhat it found
finder foundBounded-exhaustive comparison, then a public-API JUnit test failing with assertions on and off on main, 10.5.2 and 9.12.3; passes with the fix, as do the 122 upstream vectorhighlight tests.
refute review (OpenAI GPT-5.6, executed) real (high)Own test with a custom stacking tokenizer (rapid OR "quick fox" on red rapid/quick): only the target fails, over three seeds, assertions on and off; both controls pass; the fix passes its suite and the 122 upstream tests; intended argument weak.
independent reproducer realOwn 5-test suite (own stacking filter and a SynonymGraphFilter chain, Weight#matches reference): 3 of 5 fail on main, 10.5.2 and 9.12.3, 60 of 100 over 20 random seeds (the 3 claim tests every time); a main() program passes on 4.7.0 and fails on 4.7.1; its own equivalent fix passes everything.
intended-behaviour review (OpenAI GPT-5.6, executed) real (high)"Works as designed" rated weak; own test fails on main, 10.5.2 and 9.12.3; its UnifiedHighlighter sibling highlights; 4.7.0 passes, 4.7.1 fails; the fix passes.
devil's-advocate verifier real (high)Production chains (SynonymGraphFilter + FlattenGraphFilter with a classic QueryParser query; KeywordRepeatFilter + PorterStemFilter) fail on main and 10.5.2; built the strongest by-design case (LUCENE-4734, #16820) and rejected it.
adjudication confirmed (high)Re-ran the independent reproducer's suite (3 of 5 fail, assertions on and off, controls pass) and the finder's test (1 of 1 fail); read the root cause in the source.
this doc reproduced; fix validatedOne-method repro fails on main 5adff78bb168 (gradle and JUnit runner, assertions on and off) and on 10.6.0, 10.5.2, 10.0.0, 9.12.3 and 9.1.0; fix A passes it and all 723 highlighter-module tests; the finder's test and the independent reproducer's and devil's-advocate verifier's suites re-run on 5adff78bb168 fail stock and pass with the fix.

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,562 words

Finding record

LFV-832, snapshot of 2026-11-06

Registry title
FastVectorHighlighter drops a match at a position that holds stacked tokens (index-time synonyms, stems) when the token that sorts first there starts a query phrase that does not continue: FieldPhraseList tries only the first stacked token with any query-map entry, so dogs OR "dog house" on 'my dogs' (synonym dog stacked on dogs) highlights nothing (a LUCENE-5538 regression, 4.7.1 on)
Area
highlighter / vectorhighlight FieldPhraseList and FieldTermStack (FastVectorHighlighter phrase search over stacked tokens)
Priority
Medium, rank 210: FastVectorHighlighter has silently dropped highlights since 4.7.1 on its default phrase path when index-time stacked tokens meet a term-or-phrase query, with hits still correct, a narrow silent-highlight-loss case.
Impact class
silent wrong results
Impact note
Applications on 4.7.1 through 10.6.0 or main that highlight with FastVectorHighlighter (a field with term vectors, positions and offsets; the default phraseHighlight=true) over text indexed with several tokens at one position (index-time SynonymGraphFilter+FlattenGraphFilter, KeywordRepeatFilter plus a stemmer, the test framework's MockSynonymAnalyzer) lose the highlight of a matching term or phrase whenever the query also holds a phrase that starts with the token that sorts first at that position and does not continue there: getBestFragment returns null if that was the field's only match, otherwise that occurrence is silently left unmarked; hits, counts and scores are correct, there is no error with -ea or -da, no scale is needed, and the UnifiedHighlighter, FastVectorHighlighter with phraseHighlight=false, and the lexically mirrored token order all highlight the same match.
Summary
You index 'my dogs' with an index-time synonym filter that stacks 'dog' on 'dogs' (term vectors with positions and offsets), search dogs OR "dog house", which matches through dogs (Weight#matches reports offsets 3-7), and call FastVectorHighlighter.getBestFragment, expecting 'my <b>dogs</b>', which the TermQuery alone gives. Instead you get null, with no error. FieldPhraseList starts a candidate with the first token at that position that has any entry in the query's phrase map ('dog', which sorts first and only starts the phrase "dog house"), finds that the phrase does not continue, adds nothing and never tries 'dogs' at the same position; Lucene 4.7.0 highlights this correctly, and the LUCENE-5538 fix in 4.7.1 introduced the loop.
Introduced
673b1f8d73cedf8e3afd04d7389466c41be91f61 (LUCENE-5538 'FastVectorHighlighter fails with booleans of phrases', trunk svn r1579255, by rmuir with a +1 from jpountz on the issue; a JIRA patch, no pull request; the issue is now apache/lucene#6601), backported the same day as 6c9608f484d (branch_4x, r1579264) and 88df0bca7cc (lucene_solr_4_7, r1579269, the commit that shipped in 4.7.1). It collapsed the tokens of one position into one FieldTermStack entry with a circular next list (FieldTermStack.java:108-130 at a9779ef3c193) and added the start loop that scans those alternatives only while currMap == null (FieldPhraseList.java:73-81) [exec: the only highlighter source change between the 4.7.0 and 4.7.1 tags is 88df0bca7cc; on 4.7.0 jars, swapping in only FieldPhraseList and FieldTermStack at 88df0bca7cc makes the repro fail, and on 4.7.1 jars, swapping them in at 88df0bca7cc^ makes it pass; read: git log -S 'getNext() != first' finds only this commit, git blame attributes both blocks to it]. A regression: before it every stacked token was its own stack entry, so a dead-end start fell through to the next token at the same position (2014-03-19 (commit date of 673b1f8d73c and of both backports))
First affected version
4.7.1 [exec] (tag 2014-04-01, via 88df0bca7cc); 4.7.0 (2014-02-25) highlights the same input correctly [exec]; on the 4.8 line 4.8.0 [exec] via the branch_4x backport
Affected versions
every release from 4.7.1 through 10.6.0 and main a9779ef3c193 [exec: the minimal test fails on the released 10.6.0 (-ea, 2026-11-04); the finder's JUnit, or an era-adapted copy (an inline stacking filter instead of MockSynonymAnalyzer before 6.2, no Weight#matches reference before 7.x, older test-framework imports before 9.1), fails on 4.7.1, 4.7.2, 4.8.0, 4.10.4, 5.5.5, 6.6.6, 7.7.3, 8.11.4, 9.0.0, 9.1.0, 9.12.3, 10.0.0, 10.3.2, 10.4.0, 10.5.2 and main (-ea and -da); the verifiers' own tests fail on main, 10.5.2, 9.12.3 and 4.7.1; the other releases by code identity and git tag --contains]; unaffected: 2.9.0 (the first release with FastVectorHighlighter) through 4.7.0, which lack the loop [exec: 4.0.0, 4.6.1 and 4.7.0 pass; 2.9.x-3.6.x by reading only]; not fixed on origin/main 20ced71c1e6, branch_10x 657014b3c579 or branch_10_6 4ae946e5a9b9 (2026-10-26/27) [read]
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
no report of this defect; a related downstream item only, never reported to Lucene (searched 2026-10-28, after the searches of the finder, the independent reproduction, the intended-behaviour check and the adjudication, all read-only: about 200 logged queries (two scripted prior-report searches with 108 queries over apache/lucene issues, PRs and commits, downstream repositories, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive and the web; 54 own GitHub searches over apache/lucene, Elasticsearch, OpenSearch, Solr, Lucene.NET and all of GitHub; 4 PR searches by file and class name; 18 list and 6 JIRA queries; 10 web searches and every fast-vector-highlighter question on StackOverflow), the changed files of all 411 open apache/lucene PRs, every PR updated since 2026-10-26 and the local history; re-check: nothing new). Related: elastic/elasticsearch#5635 (merged 2014-04-01, another contributor's upgrade to Lucene 4.7.1) 'Fixed Highlight test that has different results due to LUCENE-5538', accepting fewer FastVectorHighlighter highlights in a word-delimiter test; the follow-up probe replays that input (4.7.0: 10 'com' highlights, 4.7.1: 0) and a code reading of 4.7.1 attributes it to the same start-position loop through a different trigger (the first-tried stacked catenation token's candidate is dropped by addIfNoOverlap, so the 'com' token at that position is never tried); nobody identified or reported it as a bug, and on main that input no longer loses highlights. Nearest Lucene items, none the same bug: #6601 (LUCENE-5538, closed 2014, the introducing change), #5799 (LUCENE-4734, open: a sloppy phrase interrupted by a query term at another position; a broader rewrite that also handled same-position terms was committed in 2013 and reverted before any release for runtime cost), #8644 (LUCENE-7593, open: addIfNoOverlap keeps the first of two overlapping hits), #8536 (LUCENE-7484, SynonymQuery flatten, fixed 2016), #8602 / #4405 / #8604 (positions or MultiPhraseQuery ignored), #16820 (open proposal to deprecate the classic and fast-vector highlighters), #16246 (open, BaseFragmentsBuilder only); downstream elastic#20781 and elastic#45486 are other mechanisms. No open PR touches FieldPhraseList.java or FieldTermStack.java
Lucene commit tested
main 5adff78bb168 (gradle in a scratch worktree and the JUnit runner, assertions on and off, stock and with the fix) and the released 10.6.0, 10.5.2, 10.0.0, 9.12.3 and 9.1.0 jars, 2026-11-04. Earlier runs: main a9779ef3c193 (the highlighter module is identical at 5adff78bb168), e1faeba08bde (re-verification) and ea57360912ed (the cross-model reviews; same highlighter code); a history sweep from 4.0.0 to 10.5.2 with era-adapted copies of the test, and a two-file swap between the 4.7.0 and 4.7.1 jars.

Lucene and upstream sources

Finding record
  • lucene/highlighter/src/java/org/apache/lucene/search/vectorhighlight/FieldPhraseList.java:61-120 (at a9779ef3c193) (constructor: the start loop at :73-78 scans the stacked alternatives only while currMap == null, :81 skips the entry when no token maps, :84-118 searches the longest phrase; dead end at :95-113: isValidTermOrPhrase fails for the one-token candidate, the backtrack loop runs only while phraseCandidate.size() > 1, nothing is added and the other tokens of the start position are never retried)
  • lucene/highlighter/src/java/org/apache/lucene/search/vectorhighlight/FieldTermStack.java:105-130 (at a9779ef3c193) (sort by position, then link the tokens of one position in a circular next list in term order, removing them from the stack)
  • lucene/highlighter/src/java/org/apache/lucene/search/vectorhighlight/FieldQuery.java:398-418 (at a9779ef3c193) (QueryPhraseMap.add: a PhraseQuery creates non-terminal maps for its leading terms and marks only the last terminal; a TermQuery goes through addTerm)
  • lucene/highlighter/src/java/org/apache/lucene/search/vectorhighlight/package-info.java:26 (at a9779ef3c193) ('support phrase-unit highlighting with slops'), :47-53 (the worked example is a BooleanQuery of a TermQuery and a PhraseQuery), :124-137 (Step 3: FieldPhraseList holds an entry for the term and for the phrase); lucene/highlighter/src/java/org/apache/lucene/search/vectorhighlight/FastVectorHighlighter.java:28-29, :37-38 (DEFAULT_PHRASE_HIGHLIGHT = true, DEFAULT_FIELD_MATCH = true, default constructor)
  • lucene/core/src/java/org/apache/lucene/analysis/package-info.java:341-346 (at a9779ef3c193) (synonyms go at the original token's position and the output order of original and synonym is undefined); lucene/analysis/common/src/java/org/apache/lucene/analysis/synonym/SynonymGraphFilter.java:67 (follow it with FlattenGraphFilter when used during indexing); lucene/CHANGES.txt:12688-12689 (4.7.1 bug fix LUCENE-5538: 'Fix FastVectorHighlighter bug with index-time synonyms when the query is more complex than a single phrase')
  • lucene/highlighter/src/test/org/apache/lucene/search/vectorhighlight/TestIndexTimeSynonym.java:167, (at a9779ef3c193) :218 (term+phrase cases where the terminal token 'pc' sorts first, so the dead-end order is never exercised); TestFastVectorHighlighter.java:714 (testBooleanPhraseWithSynonym, both phrases match)
The finding in 30 seconds
  • lucene/highlighter/src/java/org/apache/lucene/search/vectorhighlight/FieldTermStack.java:79-130, (at 5adff78bb168) :225-227
  • lucene/highlighter/src/java/org/apache/lucene/search/vectorhighlight/FieldQuery.java:398-418, (at 5adff78bb168) :460-476
  • lucene/highlighter/src/java/org/apache/lucene/search/vectorhighlight/FieldPhraseList.java:61-120 (at 5adff78bb168) (start loop :73-78, skip :80-81, dead end :95-113, backtrack :102)
  • lucene/core/src/java/org/apache/lucene/analysis/package-info.java:341-346 (at 5adff78bb168)
Who is affected
  • lucene/highlighter/src/java/org/apache/lucene/search/vectorhighlight/FastVectorHighlighter.java:28-29, (at 5adff78bb168) :37-38
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/synonym/SynonymGraphFilter.java:67-68 (at 5adff78bb168)
Why it looks unintended
  • lucene/CHANGES.txt:12742-12743 (at 5adff78bb168)
  • lucene/highlighter/src/java/org/apache/lucene/search/vectorhighlight/package-info.java:26, (at 5adff78bb168) :47-53, :124-137; FieldPhraseList.java:73-78, :90-93
  • lucene/highlighter/src/test/org/apache/lucene/search/vectorhighlight/TestIndexTimeSynonym.java:167-175, (at 5adff78bb168) :218; TestFastVectorHighlighter.java:714-767 (query3, the OR of the two phrases, at :756-762)
  • https://github.com/apache/lucene/issues/16820 (Highlighters section)
  • lucene/highlighter/src/java/org/apache/lucene/search/vectorhighlight/FastVectorHighlighter.java:107, (at 5adff78bb168) :131 (getBestFragment / getBestFragments @return)
Prior reports
  • https://github.com/apache/lucene/issues/6601, https://github.com/apache/lucene/issues/5799, https://github.com/elastic/elasticsearch/pull/5635
Diagrams
  • lucene/highlighter/src/java/org/apache/lucene/search/vectorhighlight/FieldPhraseList.java:73-113; (at 5adff78bb168) FieldTermStack.java:105-130

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

Repro files

About the diagrams

A walk-through of the minimal repro (dogs OR "dog house" on my dogs), made from the code at 5adff78bb168; the PNG is the summary still.

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.