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.
- Index-time synonyms put several tokens at one position:
my dogswith the synonymdogis indexed asmy(position 0),dogsanddog(both position 1, offsets 3-7). FastVectorHighlighterreads 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, thendogs.- 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.

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.- Index
my dogswith an index-time synonym filter that addsdogat the position ofdogs(a field with term vectors, positions and offsets). Searchdogs OR "dog house": 1 hit, throughdogs; the phrase matches nothing. - You expect
getBestFragmentto returnmy <b>dogs</b>:Weight#matchesreportsdogsat offsets 3-7, and the querydogsalone gives exactly that fragment. - Instead you get
null. Position 1 holdsdoganddogs, tried in term order.dogis the first word of"dog house", so it becomes the start; nohousefollows, nothing is added, anddogsis never tried. - 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+FlattenGraphFilterandKeywordRepeatFilter+ a stemmer hit it the same way.
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.
- One entry per position.
FieldTermStackcollects the query's terms from the term vector in term order, sorts them by position (FieldTermStack.java:105-106;TermInfo.compareTocompares positions only,:225-227) and links the tokens of one position into a circularnextlist, removing all but the first from the stack (:108-130). Formy dogsthe stack holds one entry, position 1:dog -> dogs -> dog(myis not a query term). - The phrase map.
QueryPhraseMap.addgives aPhraseQuerya non-terminal map for each leading term and marks only the last one terminal; aTermQuerygets a terminal map (FieldQuery.java:398-418). - The start loop. The
FieldPhraseListconstructor pops the entry and walks its alternatives only whilegetFieldTermMap(...)returnsnull(FieldPhraseList.java:73-78).dogsorts first and has a (non-terminal) map, so the walk stops atdog. The entry is skipped only when no token has a map (:80-81). - 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), anddogs, which shares the popped entry, is never tried. The phrase list is empty andgetBestFragmentreturnsnull. - Order matters. With
dogindexed anddogsstacked 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 showsa OR "b b"losing theaofb z a/b(aandbstacked at position 2). - Why 4.7.0 was right. Before LUCENE-5538 every stacked token was its own stack entry, so after the dead end at
dogthe next pop wasdogs, which was added. LUCENE-5538 collapsed them into one entry and added the start loop, which stops at the first token with any map.
query, document (dog stacked on dogs) | Weight#matches | FastVectorHighlighter |
|---|---|---|
dogs OR "dog house", my dogs | dogs at 3-7 | null |
dogs alone, my dogs | dogs at 3-7 | my <b>dogs</b> |
dog OR "dogs house", my dog (dogs stacked on dog) | dog at 3-6 | my <b>dog</b> |
"dogs bark" OR "dog house", dogs bark | 0-9 | null |
dogs OR "dog house", my dogs, with the UnifiedHighlighter or phraseHighlight=false | dogs at 3-7 | my <b>dogs</b> (both) |
Exposure and likelihood305 words
- Who is exposed. Applications that highlight with
FastVectorHighlighterand 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 theSynonymGraphFilterjavadoc prescribes for indexing (tv -> television; the classicQueryParserquerytv OR "television stand"onmy tvreturnsnull);KeywordRepeatFilter+PorterStemFilter(running OR "run fast"onshe was runningreturnsnull). 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"ondogs barkreturnsnull. - Silent. No exception with assertions on or off.
getBestFragmentreturnsnullwhen 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
UnifiedHighlighterandFastVectorHighlighterwithphraseHighlight=falseboth givemy <b>dogs</b>fordogs OR "dog house"onmy dogs, and both highlighttv OR "television stand"onmy 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.
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.
/*
* 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);
}
}
}
}
./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 throughdogs, which the term query alone highlights.- Observed
null(expected:<my <b>dogs</b>> but was:<null>), with assertions on and off.
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.
| lucene | main 5adff78bb168 (12.0.0-SNAPSHOT); also fails on 10.6.0, 10.5.2, 10.0.0, 9.12.3 and 9.1.0 |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux, aarch64 |
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
# 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- 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 aTermQueryOR aPhraseQuery(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), andSynonymGraphFiltertells index-time users to stack withFlattenGraphFilter(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'stestBooleanPhraseWithSynonym(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.
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.
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
addIfNoOverlapfor overlap (the helper returns true once it callsaddIfNoOverlap); 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,forbiddenApisMainandrenderJavadocoflucene/highlighterpass (the build disables the stockjavadoctask, which the log shows as SKIPPED, in favour ofrenderJavadoc).- 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.
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.
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.
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"onb z a/bhighlights 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.
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
comhighlights) without reporting anything. The earlier study replayed that input (4.7.0: 10comhighlights, 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, thoughcomis 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-timesynonym_graphphrases), #8602, #4405, #8604 (phrase gaps, MultiPhraseQuery), #16246 (fragments builder), #16822 (package javadoc example), #16820 (deprecation proposal).
| Where | When | How | Found |
|---|---|---|---|
| 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; web | 2026-10-28 | about 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 PRs | only the related items above |
| Re-check: the same sources | 2026-11-03 | two independent passes over the earlier hits | the 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 PRs | 2026-11-04 | the 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 files | no report of this defect; not fixed upstream (main f72004515ec2 has the same loop) |
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.
| Version or commit | Verdict | Result |
|---|---|---|
| 2.9.0 to 4.6.1 | not affected | no 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 change | the test passes (two seeds); with only the two 4.7.1 files swapped in it fails |
| 4.7.1 to 9.0.0 | affected | 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 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 | affected | the 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 | affected | the minimal test fails, assertions on and off (gradle and JUnit runner) |
main 5adff78bb168 + fix A | not affected | the repro and all 723 highlighter-module tests pass |
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.
| Check | Verdict | What it found |
|---|---|---|
| finder | found | Bounded-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 | real | Own 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 validated | One-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. |
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.
- PNG 496 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,562 words
- 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: maina9779ef3c193(the highlighter module is identical at5adff78bb168),e1faeba08bde(re-verification) andea57360912ed(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.
- 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-227lucene/highlighter/src/java/org/apache/lucene/search/vectorhighlight/FieldQuery.java:398-418, (at 5adff78bb168) :460-476lucene/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-38lucene/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-93lucene/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
- TestFastVectorHighlighterStackedSynonym.java (one test method):
repro/LFV-832/TestFastVectorHighlighterStackedSynonym.java(67 lines)
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.
assets/LFV-832/bug.png(496 KB)
