Without positions, the highlighter re-analyzes the stored values and keeps the tokens whose terms the query names. A term query names its term (consumeTerms). A prefix or wildcard query instead hands over an automaton that describes its terms (consumeTermsMatching), and OffsetsFromTokens never listens on that channel, so it keeps nothing.
tags = foo bar indexed without positions, the query ba*, the two QueryVisitor channels with only one listened to, the empty term set, then the TermQuery control that gets [4,7). Select the still to open it at full size. Generated with AI, checked against the code.- You index one document whose stored field
tagsholdsfoo bar, indexed without positions (DOCS_AND_FREQS; everyStringFieldhas no positions either), and searchPrefixQuery(tags:ba): 1 hit, doc 0. - You ask
MatchRegionRetriever(orMatchHighlighter), with its default strategies, where the match is. Without positions it re-analyzesfoo barintofoo[0,3) andbar[4,7) and keeps the tokens whose terms the query reports. - You expect
{tags=[[4,7)]}:TermQuery(tags:bar),FuzzyQuery(baz~1), the same prefix withSCORING_BOOLEAN_REWRITE, and the same prefix on a field with positions all give that. - Instead you get
{}, no hit region, andMatchHighlightertreats the field as unmatched (withskipRemaining()it drops it). The same forWildcardQuery(b?r),RegexpQuery(ba.),TermRangeQuery [b TO c]and the two-termTermInSetQuery(bar, qux). Hits and scores are correct. - Why: the default strategy,
OffsetsFromTokens, listens only toQueryVisitor#consumeTerms. These queries report their terms as an automaton throughconsumeTermsMatching, so its term set stays empty and no token is kept.
Applications that highlight a field without positions (a stored StringField, or any DOCS, DOCS_AND_FREQS or DOCS_AND_CUSTOM_FREQS field) with MatchRegionRetriever or MatchHighlighter and their default strategies, and run prefix, wildcard, regexp, range or multi-term set queries on it. It hits every such query (unless it names a single term) on every matching document, since 9.0.0; search results are unaffected.
Mechanism, terms and numbers722 words
For a field indexed without positions (DOCS, DOCS_AND_FREQS or DOCS_AND_CUSTOM_FREQS, for example a stored StringField), MatchRegionRetriever and the MatchHighlighter built on it use OffsetsFromTokens by default: it re-analyzes the stored values and keeps the tokens whose terms the matching query reports to a QueryVisitor. Its visitor overrides only consumeTerms. Constant-score multi-term queries report their terms through consumeTermsMatching (an automaton): PrefixQuery, WildcardQuery, RegexpQuery and TermRangeQuery with their default rewrite whenever their automaton accepts more than one term (a degenerate one that names a single term reports it through consumeTerms and highlights), and TermInSetQuery with two or more distinct terms. So the term set stays empty and the field gets no hit region, although the document is a hit and Weight#matches reports a match on that field. The outcome depends on how a query reports its terms, not on what matched: a one-term TermInSetQuery is highlighted and a two-term one is not; PrefixQuery with SCORING_BOOLEAN_REWRITE is highlighted and with the default rewrite it is not. Search results are correct; only the hit regions are missing. Depending on its chain, MatchHighlighter then omits the field, shows it unhighlighted or throws. Present since the package was added (9.0.0). The fix collects the consumeTermsMatching automata for the field too, once per distinct query.
- Hit regions:
MatchRegionRetrieverreturns, per document and field, the character ranges (OffsetRange) that the query matched;MatchHighlighterturns them into snippets. For each field it picks an offset strategy from the field's index options (MatchRegionRetriever.java:488-505). OffsetsFromTokens: the default strategy for fields indexedDOCS,DOCS_AND_FREQSorDOCS_AND_CUSTOM_FREQS(no positions; everyStringFieldisDOCS). It re-analyzes the stored values and keeps the tokens whose terms the query "hinted at when passed a QueryVisitor" (:498-501).QueryVisitor's two channels:consumeTermsis "Called by leaf queries that match on specific terms";consumeTermsMatchingis "Called by leaf queries that match on a class of terms" and passes an automaton (QueryVisitor.java:33-51).
highlightDocumentasksweight.matches(...)(MatchRegionRetriever.java:405), skips a field only when itsMatchesIteratoris null orMATCH_WITH_NO_TERMS(:411-416), calls the strategy (:434), and drops the field when the strategy returns no ranges (:435-437).- With their default rewrite (
CONSTANT_SCORE_BLENDED_REWRITE,PrefixQuery.java:32-34; also the classic query parser's default,QueryParserBase.java:83-84),PrefixQuery,WildcardQuery,RegexpQuery,TermRangeQueryandTermInSetQuerymatch through a constant-score wrapper whosematches()returnsDisjunctionMatchesIterator.fromTermsEnum(context, doc, q, ...)(AbstractMultiTermQueryConstantScoreWrapper.java:203-212): every sub-match'sgetQuery()is the multi-term query itself (DisjunctionMatchesIterator.java:94, 132,TermMatchesIterator.java:73-75). - Visited, that query reports through
consumeTermsMatching:AutomatonQuery.visitdelegates toCompiledAutomaton.visit(AutomatonQuery.java:150-154), which usesconsumeTermsMatchingforNORMALandALLautomata andconsumeTermsonly for aSINGLEterm (CompiledAutomaton.java:365-382);TermInSetQueryusesconsumeTermsfor 1 distinct term andconsumeTermsMatchingfor 2 or more (TermInSetQuery.java:182-187). So a degenerate automaton query that accepts exactly one term (WildcardQuery(lucene)without wildcard characters,RegexpQuery(lucene),TermRangeQuery [lucene TO lucene]) does highlight, while the match-allWildcardQuery(*)does not (executed). OffsetsFromTokens.getvisits each match'sgetQuery()with a visitor that overrides onlyconsumeTerms(OffsetsFromTokens.java:56-70) and keeps a token only ifmatchTerms.contains(...)(:82). The set stays empty, no token is kept, and step 1 drops the field.
Why the controls work: a TermQuery reports consumeTerms; SCORING_BOOLEAN_REWRITE and FuzzyQuery's default rewrite end in TermQuery clauses; a field with positions uses OffsetsFromPositions (MatchRegionRetriever.java:492-493), which uses the match positions instead of the term set.
One document, stored StringField tag = lucene, solr (indexed DOCS), plus a positional twin tag_pos; re-analysis with KeywordAnalyzer. Executed on main a9779ef3c193, assertions on and off:
| query (each matches the document) | tag (StringField, no positions) | tag_pos (positions) |
|---|---|---|
TermQuery(tag:lucene), 1-term TermInSetQuery, tag:lucene OR tag:elastic, PrefixQuery(luc) with SCORING_BOOLEAN_REWRITE (controls) | {tag=[[0,6)]} | - |
PrefixQuery(luc), default rewrite | {} | {tag_pos=[[0,6)]} |
TermInSetQuery(lucene, elastic), 2 terms | {} | - |
WildcardQuery(l*e), RegexpQuery(luc.*), TermRangeQuery [l TO m} | {} | {tag_pos=[[0,6)]} |
MatchHighlighter (highlighted then skipRemaining): TermQuery(tag:lucene) / PrefixQuery(tag:luc) | >lucene< / field absent | - |
In the hunt's bounded-exhaustive harness (1,295 enumerated documents, a 121-query catalogue, stock main jars) the region list was empty in 8,720 of 11,280 multi-term checks, for all 7 constant-score queries; the fuzzy and term queries all agreed.
Exposure and likelihood287 words
- Who. Users of the
matchhighlightpackage (MatchRegionRetrieverwith its default strategies, orMatchHighlighter) who highlight a field indexed without positions, typically a keyword-styleStringField(the upstream test calls such fields "often useful for multivalued 'keyword-like' fields",TestMatchRegionRetriever.java:686), and run a prefix, wildcard, regexp, term-range or multi-term set query on it. - Default vs opt-in. Nothing to opt into: the default-strategy constructors (
MatchRegionRetriever.java:107-109,MatchHighlighter.java:187-192), a field without positions, and a multi-term query with its default rewrite, which is also what the classic query parser builds fortag:luc*(read).TermInSetQuerywith two or more distinct terms reaches this defect from 9.6.0 (before that, sets of up to 16 terms were rewritten into term clauses and highlighted, while larger sets lost their regions by another route; see History),DOCS_AND_CUSTOM_FREQSfields from 10.5.0. - Effect. Silent missing highlights; hits, counts and scores are correct and the Matches API reports the match. In
MatchHighlighterthe field takes the application's no-match path: omitted withskipRemaining(), shown as leading text withmaxLeadingCharacters(...), and with no no-match highlighter in the chain the call throwsRuntimeException: No field highlighter could be matched to field(MatchHighlighter.java:301-308), as stock Lucene already does for a queried field without a match (executed on main and 10.5.2). - Scale. Deterministic, with assertions on and off, at any index size: one document is enough, and it fails on every document such a query returns.
- How likely. Rare: GitHub code search found the package used in none of Solr, Elasticsearch and OpenSearch (partly rate-limited; that says nothing about use elsewhere);
TextField, the usual full-text field, is indexed with positions (TextField.java:36, 40) and is not affected; the searches made found no report since 9.0.0.
The test shows that MatchRegionRetriever with its default strategies returns no hit region for a PrefixQuery on a field indexed without positions (DOCS_AND_FREQS): OffsetsFromTokens only listens to QueryVisitor#consumeTerms, while the constant-score PrefixQuery reports its terms through consumeTermsMatching.
/*
* 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.matchhighlight;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.apache.lucene.analysis.Analyzer;
import org.apache.lucene.analysis.standard.StandardAnalyzer;
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.IndexOptions;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.index.Term;
import org.apache.lucene.search.IndexSearcher;
import org.apache.lucene.search.PrefixQuery;
import org.apache.lucene.search.Query;
import org.apache.lucene.search.TopDocs;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
public class TestOffsetsFromTokensPrefixQuery extends LuceneTestCase {
public void testPrefixQueryOnFieldWithoutPositions() throws Exception {
Analyzer analyzer = new StandardAnalyzer();
FieldType noPositions = new FieldType(TextField.TYPE_STORED);
noPositions.setIndexOptions(IndexOptions.DOCS_AND_FREQS);
try (Directory dir = new ByteBuffersDirectory();
IndexWriter w = new IndexWriter(dir, new IndexWriterConfig(analyzer))) {
Document doc = new Document();
doc.add(new Field("f", "foo bar", noPositions));
w.addDocument(doc);
try (DirectoryReader reader = DirectoryReader.open(w)) {
IndexSearcher searcher = new IndexSearcher(reader);
Query query = searcher.rewrite(new PrefixQuery(new Term("f", "ba")));
TopDocs topDocs = searcher.search(query, 10);
assertEquals(1, topDocs.totalHits.value());
Map<String, List<OffsetRange>> regions = new HashMap<>();
new MatchRegionRetriever(searcher, query, analyzer, _ -> false, _ -> true)
.highlightDocuments(topDocs, (_, _, _, _, hits) -> regions.putAll(hits));
// expected: "bar" at [4, 7), as TermQuery(f:bar) gives; actual: {} (no hit region at all)
assertEquals(Map.of("f", List.of(new OffsetRange(4, 7))), regions);
}
}
}
}
# in a copy of this finding's repro folder (repro/LFV-276/, which holds src/)
# Lucene checkout at the tested commit, built with: ./gradlew --no-daemon assemble (JDK 25)
LUCENE=/path/to/lucene; V=12.0.0-SNAPSHOT
CP=$LUCENE/lucene/core/build/libs/lucene-core-$V.jar
CP=$CP:$LUCENE/lucene/codecs/build/libs/lucene-codecs-$V.jar
CP=$CP:$LUCENE/lucene/test-framework/build/libs/lucene-test-framework-$V.jar
CP=$CP:$LUCENE/lucene/highlighter/build/libs/lucene-highlighter-$V.jar
CP=$CP:$LUCENE/lucene/memory/build/libs/lucene-memory-$V.jar
CP=$CP:$LUCENE/lucene/queries/build/libs/lucene-queries-$V.jar
CP=$CP:randomizedtesting-runner-2.9.2.jar:junit-4.13.2.jar:hamcrest-3.0.jar # test-framework dependencies (Maven Central)
CP=$CP:junit-platform-console-standalone-6.1.3.jar # JUnit Platform console launcher (Maven Central)
javac -d classes -cp "$CP" $(find src -name '*.java')
# run 1: assertions on, seed DEADBEEF
java -ea -esa --add-modules jdk.incubator.vector --enable-native-access=ALL-UNNAMED -Dtests.seed=DEADBEEF \
-cp "classes:$CP" org.junit.platform.console.ConsoleLauncher \
execute --select-class org.apache.lucene.search.matchhighlight.TestOffsetsFromTokensPrefixQuery
- Expected vs actual
- Expected:
{f=[[from=4, to=7]]}("bar" in "foo bar", as TermQuery(f:bar) gives). Actual (-ea and -da):{}, although the document is a hit.
More files, environment, full output35 words
| lucene | Lucene main e1faeba08bde (2026-11-02) |
|---|---|
| jdk | 25.0.3+9-LTS |
# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
=> java.lang.AssertionError: expected:<{f=[[from=4, to=7]]}> but was:<{}>
org.junit.Assert.fail(Assert.java:89)
org.junit.Assert.failNotEquals(Assert.java:835)
org.junit.Assert.assertEquals(Assert.java:120)
org.junit.Assert.assertEquals(Assert.java:146)
org.apache.lucene.search.matchhighlight.TestOffsetsFromTokensPrefixQuery.testPrefixQueryOnFieldWithoutPositions(TestOffsetsFromTokensPrefixQuery.java:61)- The package promises "hit areas for a given Query"; the default strategy keeps the terms the query "hinted at" via a
QueryVisitor, andconsumeTermsMatchingis how these queries hint. - The answer follows the reporting channel, not the match: a 1-term
TermInSetQueryhighlights, a 2-term one does not. - LUCENE-9462 gave these queries matches on such fields; the UnifiedHighlighter honours that channel.
The full argument331 words
- Contract. Package javadoc:
MatchRegionRetriever"can be used to retrieve hit areas for a given Query" (package-info.java:22-23); class javadoc: "compute a list of 'match regions' for a given query" (MatchRegionRetriever.java:57-58);OffsetsFromTokens"works for fields where we know the match occurred" (OffsetsFromTokens.java:35-36); the default-strategy comment keeps the terms "that the query hinted at when passed a QueryVisitor" (MatchRegionRetriever.java:498-501);consumeTermsMatchingis for "leaf queries that match on a class of terms" (QueryVisitor.java:42). - The channel decides, not the match. On the same document, a 1-term
TermInSetQueryhighlights and a 2-term one (equivalent to aTermQuerydisjunction that does highlight) does not; the default and the scoring rewrite of the samePrefixQuerydisagree (executed).TermInSetQuerymoved toconsumeTermsMatchingfor a performance reason (LUCENE-9349: "to enable queries that build large automata to provide them lazily",CHANGES.txt:5695-5698). - The Matches change was made for this path. LUCENE-9462, "Fields without positions should still return MatchIterator" (
CHANGES.txt:4700-4701;6244383e0d5, same day and authors as the component, an ancestor of it), removed thehasPositions() == falsefallback fromTermQuery,SynonymQueryand the multi-term constant-score wrapper, so that these queries, multi-term ones included, return real matches iterators on these fields. - Sibling and goal. The UnifiedHighlighter in the same module consumes
consumeTermsMatching(uhighlight/MultiTermHighlighting.java:82-85). The umbrella issue #10501 / LUCENE-9461 asked that "highlights should be accurate for all sorts of queries: fuzzy, phrase, prefix, Boolean, spans, etc." - Tests and history. Upstream tests run prefix, range and fuzzy queries only on fields with positions (
TestMatchRegionRetriever.java:219-291) and the no-positions field only with aTermQuery(:683-707). No javadoc, test, CHANGES or MIGRATE entry, or design thread (#10479 / LUCENE-9439, apache/lucene-solr#1721, LUCENE-9461 to 9464) says multi-term queries are unsupported here;consumeTermsMatching(2019) predates the component (2020). - Legal trigger. Public API, the default-strategy constructor, a query rewritten against the searcher, an analyzer that reproduces the indexed tokens (as the constructor javadoc requires,
MatchRegionRetriever.java:115-117), ordinary queries with their default rewrites.
The devil's-advocate check272 words
The strongest "works as designed" case (the cross-model review, the intended-behaviour check, the refute review): the OffsetsFromTokens javadoc says it returns tokens "that are also returned by the query's term collector" (OffsetsFromTokens.java:38-39), and QueryVisitor.termCollector collects consumeTerms only (QueryVisitor.java:86-98); consumeTermsMatching is @lucene.experimental; MatchHighlighter calls itself "An example highlighter" and advises assembling your own (MatchHighlighter.java:38-43); the default-strategy comment says "possibly" and names alternative strategies (MatchRegionRetriever.java:498-504); in the design review the author wanted the no-positions path minimal, a corner case.
Why it fails: the javadoc phrase does not name termCollector and the code does not call it; termCollector's own javadoc promises "all terms that may match a query" (QueryVisitor.java:87), so its blindness to automata is a gap, not a documented exclusion. The strict reading makes the output depend on the rewrite method and on the number of terms in a set, which points to an accidental dependency on the reporting channel rather than a design choice (no design statement says either way). @lucene.experimental is about API stability, and the example status of MatchHighlighter does not cover MatchRegionRetriever's default strategy. No reviewer found the intended case convincing: the adjudication and the cross-model lenses rated it moderate, the intended-behaviour check found that it does not hold up, and the independent reproduction noted only that maintainers might file the fix as an improvement.
What it does not settle: maintainers may still label the fix an improvement rather than a bug fix (judge confidence medium). Framing it as "the default fallback ignores consumeTermsMatching" and leading with the two inconsistency pairs pre-empts the "term collector" reading.
Recommended Also honour consumeTermsMatching, once per distinct query (validated)
In OffsetsFromTokens, also collect the consumeTermsMatching automata for the field, visiting each distinct match query once, and keep tokens that any of them accepts (+34/-3). The repro, the hunt's tests and the whole highlighter module (728 tests) pass; lint, format and javadoc pass. Without the deduplication the cost explodes (112.6 s for one document).
Set<Query> visited = Collections.newSetFromMap(new IdentityHashMap<>());
while (matchesIterator.next()) {
Query q = matchesIterator.getQuery();
if (visited.add(q) == false) continue; // a multi-term query repeats itself per term
q.visit(new QueryVisitor() {
// consumeTerms(...) unchanged: adds exact terms to matchTerms
@Override
public void consumeTermsMatching(Query query, String queryField, Supplier<ByteRunnable> automaton) {
if (field.equals(queryField)) matchAutomata.add(automaton.get());
}
});
}
// per re-analyzed token:
if (matchTerms.contains(token) || acceptedByAny(matchAutomata, token)) { ranges.add(...); }All options and trade-offs5 options · 910 words
One fix was applied in a scratch worktree at a9779ef3c193 and run through gradle; options C to E were not run.
Workarounds today: give the multi-term query MultiTermQuery.SCORING_BOOLEAN_REWRITE (it then highlights; subject to the boolean clause limit and scoring cost); index the field with positions (a custom FieldType instead of StringField), so OffsetsFromPositions is used; or supply a custom OffsetsRetrievalStrategySupplier, for example OffsetsFromValues, which marks every stored value of the field in full (not run).
In OffsetsFromTokens.get, also override consumeTermsMatching and collect the automaton for the field; visit each distinct getQuery() once (an identity set), because a multi-term query returns itself for every matching term; keep a re-analyzed token when the term set contains it or any automaton accepts it. The class javadoc changes in the same diff. A second diff adds the repro as a regression test; a PR also needs a CHANGES entry. On 10.x the visitor method takes Supplier<ByteRunAutomaton> instead of Supplier<ByteRunnable> (#15961 changed it on main), so the backport of the fix changes only that type. The repro test uses unnamed _ lambda parameters (Java 22 or later); branch_10x compiles for Java 21, so a 10.x copy of the test needs named parameters.
Set<Query> visited = Collections.newSetFromMap(new IdentityHashMap<>());
while (matchesIterator.next()) {
Query q = matchesIterator.getQuery();
if (visited.add(q) == false) continue; // a multi-term query repeats itself per term
q.visit(new QueryVisitor() {
// consumeTerms(...) unchanged: adds exact terms to matchTerms
@Override
public void consumeTermsMatching(Query query, String queryField, Supplier<ByteRunnable> automaton) {
if (field.equals(queryField)) matchAutomata.add(automaton.get());
}
});
}
// per re-analyzed token:
if (matchTerms.contains(token) || acceptedByAny(matchAutomata, token)) { ranges.add(...); }Trade-offs
- Cost. Each re-analyzed token of the field is tested against the distinct automata of the query on that field (one per multi-term clause), the same order as the UnifiedHighlighter's multi-term highlighting (read; not benchmarked with many distinct clauses). The security probe's cost case, one document with 20,000 values in a
DOCSfield, half matching,WildcardQuery(a*z)over 32-character tokens: stock 51 ms, this fix 59 ms (best of 3, shared loaded host, orders of magnitude only). - Limits (read). The deduplication works within one
get(...)call, so once per document and field; nothing is cached across documents. An automaton query's supplier returns the automaton it already compiled (CompiledAutomaton.java:369, 486-492), butTermInSetQuery's builds a new automaton over the whole term set on every call (TermInSetQuery.java:186, 190-195, "pretty heavy-weight" in its own comment), so a large set pays that once per highlighted document. Deduplication is by object identity: Lucene's wrapper passes one query object to every term match, but a customMatchesIteratorreturning new, equal query objects would not be deduplicated. The visitor keeps the defaultacceptField; overriding it would skip other fields' clauses earlier. - Why the deduplication matters. The hunt's first draft added the automaton on every
matchesIterator.next()(once per matching term) and ran every copy against every non-matching token: 112,571 ms on that same document. Do not propose the draft as is. - Tokens are still matched only if the re-analysis analyzer reproduces the indexed terms, as before.
What the checks validated
- Stock main
a9779ef3c193(gradle): the repro fails 6 of 6 with assertions on and off and 60 of 60 over 10 iterations, every failure at a test's last assertion; the whole highlighter module has 728 tests with 6 failures, exactly the repro. - With the fix: the repro passes 6 of 6 with assertions on and off, and 60 of 60 over 10 iterations.
- With the fix, the whole
lucene/highlightermodule (classic, unified, match and vector highlighters, incl. the repro): 35 suites, 728 tests, 0 failures. ecjLint(main and test),checkGoogleJavaFormatandrenderJavadocfor the highlighter module pass;tidyleaves the fix unchanged; both diffs apply cleanly to a cleana9779ef3c193tree.- With the fix shadowed into the main jars: the finder's (5 of 5), the independent reproducer's (10 of 10) and the devil's advocate's (5 of 5) tests pass, and the security probe's regions equal a positional twin field's exactly (150 of 150, 0 extra, 0 missing).
- Not run: other modules' suites (the change is in
lucene/highlighteronly); a backport in a 10.x source tree; no benchmark beyond the probe's cost case, in particular none with a largeTermInSetQuery, many distinct multi-term clauses, or custom and composite queries.
The same visitor change, adding the automaton once per match.
Trade-offs
Same regions, but the cost grows with tokens x matching terms per document: 112.6 s for one 20,000-value document in the measurement above.
Make the constant-score wrapper's per-term matches return a TermQuery for the matched term from getQuery().
Trade-offs
consumeTerms would then see the matched terms, but it changes what getQuery() means for every Matches API consumer: much wider than this bug. In exchange it would report only the terms that actually matched, with no automaton to build per document.
Use OffsetsFromValues when the query reports an automaton.
Trade-offs
Simpler, but OffsetsFromValues ignores the matches and returns a range for every stored value of the field, including values that did not match (OffsetsFromValues.java:43-67), unlike the term-query behaviour on the same field.
State in OffsetsFromTokens and computeOffsetRetrievalStrategies that multi-term queries get no regions on fields without positions.
Trade-offs
No code change, but the silent missing highlights stay for anyone who misses the sentence.
GitHub code search found no use of the matchhighlight package in Solr, Elasticsearch or OpenSearch (partly rate-limited), so the effect is limited to applications that call MatchRegionRetriever or MatchHighlighter directly.
A bounded-exhaustive translation-validation harness compared MatchRegionRetriever on a field indexed DOCS with every occurrence of the query's terms, over 1,295 enumerated documents and a 121-query catalogue on the stock jars. 8,720 of 11,280 multi-term checks came back empty, for all 7 constant-score queries; the finder then wrote a public-API JUnit.
The method in full158 words
Method: bounded-exhaustive translation validation on the real jars (semi-formal tier), in a hunt unit on highlighter offset sources.
- A harness compared the highlighters' offset sources with an exact reference semantics and the Matches API on every document of a small enumerated document language (1,295 single-valued documents of up to 4 positions) and a 121-query catalogue, on the stock main jars. One relation compared
MatchRegionRetriever's default strategy on aDOCS-only field with every occurrence of the query's positive terms. - That relation failed in 8,720 of 11,280 multi-term-query checks, for every constant-score multi-term query in the catalogue (a prefix, 2 wildcard, 2 regexp and 2 term-range queries), while the 2 fuzzy queries and all term queries agreed. The finder traced it to the visitor and wrote a public-API JUnit.
- Found 2026-10-26 on main
ea57360912ed; then checked by an independent reproducer, an intended-behaviour devil's advocate, reading-only reviews by a model from another family (OpenAI GPT-5.6) and an adjudication.
No prior report found (2026-10-26: the prior-report tool twice over GitHub, the lists, the JIRA archive and the web, 17 manual GitHub searches, all 413 open PRs; 37 hits read). Closest: the strategy's design threads, and the same kind of visitor-channel gap fixed in other code (#10467 in the UnifiedHighlighter).
Searches4 searches
Verdict new: 37 hits read, none the same bug. Closest, context only: #10479 / LUCENE-9439 and apache/lucene-solr#1721 (the design discussion that created the strategy; terms are extracted via getQuery(), multi-term queries never discussed); #10501 / LUCENE-9461 (umbrella; goal "accurate for all sorts of queries: fuzzy, phrase, prefix, ..."); #10502 / LUCENE-9462 (the companion Matches change); #10467 / LUCENE-9427 (the UnifiedHighlighter missed FuzzyQuery the same way, fixed in 8.7 in different code); #13804 / #13806 (monitor's QueryAnalyzer and multi-term TermInSetQuery); #15961 (the current consumeTermsMatching signature on main). Keyword search cannot prove absence.
| Where | When | How | Found |
|---|---|---|---|
| apache/lucene issues, PRs and commits; downstream repositories; the dev@, java-user@, issues@ and commits@ lists; the JIRA archive; the web | 2026-10-26 | the hunt's prior-report tool, two runs: 24 GitHub issue/PR, 3 commit, 2 downstream, 20 + 20 mailing-list, 4 + 4 JIRA and 6 web queries; comments of the top 20 hits read | no report; historical analogues in other highlighters only |
| GitHub (apache/lucene, apache/solr, OpenSearch, Elasticsearch) | 2026-10-26 | 17 manual issue and PR searches, plus code search for MatchRegionRetriever and matchhighlight | no report; no downstream use found |
| LUCENE and SOLR JIRA projects; mailing-list threads | 2026-10-26 | JQL queries; full reads of LUCENE-9439, 9461, 9462, 9463, 9464, 9427 and 9062; design and user threads | no report; design context only |
| Open apache/lucene PRs and full history | 2026-10-26 | changed files of all 413 open PRs; git log of the matchhighlight files | no PR touches OffsetsFromTokens.java; last change #12881 (2023, performance) |
Present since the matchhighlight package was added (150a8dacb59, LUCENE-9463, merged 2020-08-14; first released in 9.0.0). Executed on 9.0.0, 9.5.0, 9.6.0, 9.12.3, 10.0.0, 10.5.2 and main: all fail, and 8.x has no such package; the two-term TermInSetQuery form starts in 9.6.0. Not a regression, not fixed.
Versions tested7 rows
Introduced by 150a8dacb59 (LUCENE-9463 "Query match region retrieval component, passage scoring and formatting", apache/lucene-solr#1750, author dweiss, merged 2020-08-14), which added OffsetsFromTokens with its consumeTerms-only visitor as the default for DOCS and DOCS_AND_FREQS fields; later commits only reformatted or refactored it (6faa4f98e04, 2c1f9381390, 755de5aae33 / #12881). The companion 6244383e0d5 (LUCENE-9462, same day, an ancestor) gave multi-term queries real matches iterators on fields without positions; the symptom needs both. consumeTermsMatching is older (bed694ec881, LUCENE-9062, 2019-11-27).
Variants. From 9.6.0 every TermInSetQuery with two or more distinct terms reaches the defect, since #12156 (3809106602a, backport fa1d050df1d) made it a MultiTermQuery. Before that its visit already used consumeTermsMatching for two or more terms, but sets of up to 16 terms were rewritten into TermQuery clauses and highlighted (executed: the two-term case on 9.5.0 and 9.0.0), while larger sets stayed a TermInSetQuery whose matches() on a field without positions fell back to MATCH_WITH_NO_TERMS, so they lost their regions by another route (read: 9.5.0 TermInSetQuery.java:79, 115-128, 242-246, Weight.java:78-94). The 9.6.0 change of the default rewrite (#12055) left the prefix, wildcard, regexp and range outcomes unchanged (executed). Other variants: DOCS_AND_CUSTOM_FREQS fields in 10.5.0 (#15584, read). Not fixed on main or branch_10x; no open PR touches the file; branch_10_6 (10.6.0, released 2026-10-29) is source-confirmed affected but not executed: the same consumeTerms-only visitor and switch (2763db11b0a, 2026-10-24).
| Version or commit | Verdict | Result |
|---|---|---|
main a9779ef3c193 | affected | compact JUnit 6 of 6 fail via gradle, assertions on and off; the finder's 3 of 5 and the independent reproduction's 7 of 10 still fail |
main ea57360912ed | affected | the finder's 3 of 5, the independent reproduction's 7 of 10, the intended-behaviour check's 2 of 5, controls passing, assertions on and off |
| 10.5.2, 10.0.0 | affected | compact JUnit 6 of 6 (10.5.2 also with assertions off); the finder's 3 of 5 (release jars, SHA-1 checked) |
| 9.12.3, 9.6.0 | affected | the finder's 3 of 5 (9.12.3); the independent reproduction's 7 of 10, the 2-term TermInSetQuery included |
| 9.5.0, 9.0.0 | no TermInSet form | the finder's 3 of 5; the independent reproduction's 6 of 10: the 2-term TermInSetQuery still highlights |
| 8.6.1, 8.11.4 | not affected | compile error: no matchhighlight package |
fix (scratch worktree at a9779ef3c193) | not affected | the repro 6 of 6 and the highlighter module (728 tests) pass, assertions on and off; the hunt's tests pass with it shadowed in |
An independent reproducer and an intended-behaviour devil's advocate reproduced it with their own tests, controls passing. Reading-only reviews by a model from another family (OpenAI GPT-5.6) agreed on the root cause. An adjudication confirmed it (medium: maintainers may call the fix an improvement), and this doc's gradle runs reproduce it on a9779ef3c193.
Checks and verdicts425 words
Every executing check wrote or re-ran its own JUnit on stock main; every control passed.
| Check | Verdict | What it found |
|---|---|---|
| finder | candidate | Translation-validation harness, then a JUnit: fails 3 of 5 with assertions on and off and on 10.5.2 and 9.12.3; the draft fix passes, and in a 476-test highlighter run it removes only the repro's failures. |
| independent reproducer | real | Own test from the claim only, separate package: 7 of 10 fail on main, 10.5.2 and 9.12.3, 6 of 10 on 9.0.0; the 3 controls pass; a mechanism probe shows the default PrefixQuery fires only consumeTermsMatching; 10 of 10 pass with the fix. |
| intended-behaviour devil's advocate | real; medium that maintainers call it a bug | Read the design history and found no 'unsupported' statement; its StringField probe fails 2 of 5 on main, 10.5.2 and 9.12.3 (the two inconsistency pairs); 5 of 5 pass with the fix. |
| cross-model reviews (refute, intended) | not real (low, procedural); real (medium) | An independent reviewer from another model family (OpenAI GPT-5.6) reviewed by reading only, since it could not run Java. The lenses agreed on the root cause, found the inputs in contract and rated the intended argument moderate; the refute lens's "not real" only means not reproduced by the reviewer. A separate sibling test failed 1 of 4 (RegexpQuery) while the UnifiedHighlighter highlighted the same field. |
| adjudication | confirmed, medium | High that the behaviour is real and the cause understood, medium that maintainers call it a bug rather than a missing feature. Re-ran the independent reproduction's test (7 of 10 fail, assertions on and off), the finder's (3 of 5), the mechanism probe (passes) and the independent reproduction with the fix (10 of 10). |
| security screen | none (fail-closed) | 18 query/field pairs, 64 document highlights: 0 extra regions, 114 missing; found the draft fix's cost blow-up (112.6 s vs 62 ms deduplicated, on one document with 20,000 values, half of them matching). |
| Follow-up and main refresh | reproduces; keep | History matrix from 8.6.1 to main; re-runs with new seeds; the main refresh to a9779ef3c193 replays all four runs with identical failures. |
| this doc's gradle runs | reproduces; fix validated | The compact JUnit through gradle on stock main (6 of 6 fail, assertions on and off, 60 of 60 over 10 iterations) and with the fix (all pass); the whole highlighter module: 6 failures stock (the repro), 0 with the fix (728 tests); lint, format and javadoc pass; the same JUnit fails 6 of 6 on the 10.5.2 and 10.0.0 jars. |
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 563 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,394 words
- Registry title
- MatchRegionRetriever's default OffsetsFromTokens strategy ignores QueryVisitor#consumeTermsMatching, so prefix, wildcard, regexp, term-range and multi-term TermInSetQuery matches on fields without positions get no hit regions
- Area
- highlighter / matchhighlight (MatchRegionRetriever default offset strategy OffsetsFromTokens for fields indexed without positions; MatchHighlighter on top of it)
- Priority
- Low, rank 360: Highlighting-only, in the little-used matchhighlight package, for multi-term queries on fields indexed without positions.
- Impact class
- silent wrong results
- Impact note
- Applications on 9.0.0 through 10.6.0 (10.6.0 by reading) or main that highlight with MatchRegionRetriever or MatchHighlighter and its default offset strategies a field indexed without positions (DOCS, DOCS_AND_FREQS or DOCS_AND_CUSTOM_FREQS, for example a stored StringField re-analysed with a keyword analyzer) silently get no hit regions for that field whenever the query reaches it as a default-rewrite PrefixQuery, WildcardQuery, RegexpQuery or TermRangeQuery, or (from 9.6.0) a TermInSetQuery with two or more terms; only highlighting is affected (hits, counts and scores are correct and the Matches API reports the match), it is deterministic at any index size with -ea and -da, and the matchhighlight package is far less used than the UnifiedHighlighter (code search found it in none of Solr, Elasticsearch and OpenSearch).
- Summary
- You index a stored field without positions (one document 'foo bar', IndexOptions DOCS_AND_FREQS, or a StringField), run a PrefixQuery('ba') that matches it, and ask MatchRegionRetriever (or MatchHighlighter) with its default strategies for the hit regions; you expect the region [4,7) for 'bar', which a TermQuery('bar') on the same field, a FuzzyQuery('baz'~1) and the same PrefixQuery on a field with positions all give. Instead the field gets no hit regions at all ({}), although the document is a hit and Weight#matches reports a match on that field. The default strategy for such fields, OffsetsFromTokens, collects the query's terms only through QueryVisitor#consumeTerms, while constant-score multi-term queries report theirs through consumeTermsMatching, so its term set stays empty and no re-analysed token is kept.
- Introduced
- 150a8dacb59712c731e66db1a56068326a0439f4 (LUCENE-9463 'Query match region retrieval component, passage scoring and formatting', apache/lucene-solr#1750, author dweiss, merged 2020-08-14) [read: git log --follow and git blame in the full-history clone; exec: fails on 9.0.0, does not compile on 8.6.1 and 8.11.4]. It added OffsetsFromTokens with a QueryVisitor that overrides only consumeTerms and made it the default for DOCS and DOCS_AND_FREQS fields; the visitor is unchanged on main apart from reformatting (6faa4f98e04) and refactors (2c1f9381390, 755de5aae33). The symptom also needs the companion commit 6244383e0d5 (LUCENE-9462, apache/lucene-solr#1749, same author, same day, an ancestor of 150a8dacb59), which made constant-score multi-term queries return real MatchesIterators on fields without positions; QueryVisitor#consumeTermsMatching predates both (bed694ec881, LUCENE-9062, 2019-11-27). The TermInSetQuery form starts with #12156 (3809106602a, backport fa1d050df1d, 9.6.0), which made TermInSetQuery a MultiTermQuery. Not a regression (2020-08-14)
- First affected version
- 9.0.0 [exec] (the first release with the matchhighlight package; 8.6.1 and 8.11.4 lack the API: compile error [exec]); the two-or-more-term TermInSetQuery form from 9.6.0 [exec: 9.5.0 highlights it, 9.6.0 does not]; DOCS_AND_CUSTOM_FREQS fields from 10.5.0 [read: #15584 added that option to the same switch case]
- Affected versions
- every 9.x and 10.x release, 9.0.0 through 10.6.0 (34 releases; 10.6.0 by reading), and main ea57360912ed [exec: the finder's JUnit fails 3 of 5 on 9.0.0, 9.5.0, 9.12.3, 10.0.0, 10.5.2 and main (-ea and -da on main); the independent reproduction's JUnit fails 7 of 10 on main (-ea and -da), 10.5.2 (-ea and -da), 9.12.3 and 9.6.0, and 6 of 10 on 9.5.0 and 9.0.0, where the two-term TermInSetQuery still highlights; read: the other releases by git history, the visitor and the default-strategy switch being unchanged in substance since 150a8dacb59]; not affected: 8.x and older (no matchhighlight package); not fixed on main or branch_10x (OffsetsFromTokens.java last changed by #12881 on 2023-12-07, still consumeTerms-only; no open PR touches it); 10.6.0 (released 2026-10-29) not executed, affected by reading (branch_10_6 2763db11b0a has the same consumeTerms-only visitor; the matchhighlight package is unchanged from there to 4ae946e5a9b)
- Status
- documented (brief, checks and a per-finding doc exist)
- Verification
- Confirmed by AI-run checks: an independent repro, a cross-model review that could only read the code (its low-confidence “not real” is procedural), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
- Found by
- semi-formal: exhaustive check
- Upstream and prior reports
- none found (2026-10-26, the prior-report record: two runs of the scripted prior-report search (24 GitHub issue and PR queries on apache/lucene, 3 commit queries, 2 downstream-repository queries, 20 plus 20 mailing-list queries over dev@, java-user@, issues@ and commits@, 4 plus 4 JIRA-archive queries, 6 web queries), 14 manual GitHub issue and PR searches including apache/solr, OpenSearch and Elasticsearch, downstream code search, the changed files of all 413 open apache/lucene PRs, direct LUCENE and SOLR JIRA queries with full reads of LUCENE-9439, 9461, 9462, 9463, 9464, 9427 and 9062, list threads and 6 web searches; 37 hits read, none the same bug; the finder, the independent reproduction, the intended-behaviour check and the adjudication found nothing either); not fixed upstream (OffsetsFromTokens.java last changed by 755de5aae33, #12881, 2023-12-07, on main and branch_10x, still consumeTerms-only at ea57360912ed; no open PR touches it); closest items, not reports: the design discussion that created the strategy (#10479 / LUCENE-9439, apache/lucene-solr#1721), the umbrella #10501 / LUCENE-9461 ('highlights should be accurate for all sorts of queries: fuzzy, phrase, prefix, Boolean, spans, etc.'), #10502 / LUCENE-9462 (the companion Matches change), analogous visitor-channel gaps in other consumers (#10467 / LUCENE-9427, UnifiedHighlighter and FuzzyQuery, fixed in 8.7; #13804 and #13806, monitor's QueryAnalyzer and multi-term TermInSetQuery) and #10104 / LUCENE-9062 (added consumeTermsMatching)
- Lucene commit tested
- main
a9779ef3c193(commit of 2026-10-26): a compact public-API JUnit through gradle in a scratch worktree, stock and with the fix, assertions on and off and 10 iterations, plus the wholelucene/highlightermodule stock and with the fix (2026-10-27); the same JUnit on the 10.5.2 (assertions on and off) and 10.0.0 jars. Hunt runs on mainea57360912edand the 9.0.0, 9.5.0, 9.6.0, 9.12.3, 10.0.0 and 10.5.2 jars (8.6.1 and 8.11.4 do not compile the repro); the main-refresh re-run ona9779ef3c193still reproduces.
- Finding record
lucene/highlighter/src/java/org/apache/lucene/search/matchhighlight/OffsetsFromTokens.java:34-40 (at ea57360912ed) (class javadoc: 'works for fields where we know the match occurred but there are no known positions or offsets'; 'tokens that are also returned by the query's term collector'), :56-70 (QueryVisitor overriding only consumeTerms, :62), :82 (a re-analysed token is kept only if matchTerms contains it)lucene/highlighter/src/java/org/apache/lucene/search/matchhighlight/MatchRegionRetriever.java:56-59 (at ea57360912ed) (class javadoc: 'match regions for a given query'), :411-437 (a field is skipped only for null or MATCH_WITH_NO_TERMS matches; empty range lists are dropped at :435), :495-505 (OffsetsFromTokens is the default for DOCS_AND_CUSTOM_FREQS, DOCS_AND_FREQS and DOCS: 'possibly narrowed down to only those terms that the query hinted at when passed a QueryVisitor')lucene/highlighter/src/java/org/apache/lucene/search/matchhighlight/package-info.java:23 (at ea57360912ed) ('hit areas for a given Query')lucene/core/src/java/org/apache/lucene/search/QueryVisitor.java:42-49 (at ea57360912ed) (consumeTermsMatching: 'Called by leaf queries that match on a class of terms'), :91 (termCollector)lucene/core/src/java/org/apache/lucene/util/automaton/CompiledAutomaton.java:365-378 (at ea57360912ed) (NORMAL and ALL automata report through consumeTermsMatching, SINGLE through consumeTerms); lucene/core/src/java/org/apache/lucene/search/TermInSetQuery.java:183-186 (1 term consumeTerms, more consumeTermsMatching); lucene/core/src/java/org/apache/lucene/search/AbstractMultiTermQueryConstantScoreWrapper.java:203-212 (matches(): DisjunctionMatchesIterator.fromTermsEnum with the multi-term query as getQuery())lucene/highlighter/src/java/org/apache/lucene/search/uhighlight/MultiTermHighlighting.java:83 (at ea57360912ed) (the sibling UnifiedHighlighter honours consumeTermsMatching)a Lucene clone tag releases/lucene/9.0.0 (tag commit date 2021-12-01, git log -1)
- The finding in 30 seconds
lucene/highlighter/src/java/org/apache/lucene/search/matchhighlight/MatchRegionRetriever.java:405-437, 488-505lucene/highlighter/src/java/org/apache/lucene/search/matchhighlight/OffsetsFromTokens.java:34-40, 56-70, 82lucene/core/src/java/org/apache/lucene/search/QueryVisitor.java:33-51lucene/core/src/java/org/apache/lucene/search/AbstractMultiTermQueryConstantScoreWrapper.java:202-213; DisjunctionMatchesIterator.java:82-94; TermMatchesIterator.java:73-75; AutomatonQuery.java:150-154; TermInSetQuery.java:177-188; PrefixQuery.java:27-34lucene/core/src/java/org/apache/lucene/util/automaton/CompiledAutomaton.java:364-382lucene/queryparser/src/java/org/apache/lucene/queryparser/classic/QueryParserBase.java:83-84
- Who is affected
lucene/highlighter/src/test/org/apache/lucene/search/matchhighlight/TestMatchRegionRetriever.java:683-707lucene/highlighter/src/java/org/apache/lucene/search/matchhighlight/MatchRegionRetriever.java:107-117; MatchHighlighter.java:187-201, 301-308lucene/core/src/java/org/apache/lucene/document/TextField.java:36, 40 (DOCS_AND_FREQS_AND_POSITIONS)
- Why it looks unintended
lucene/highlighter/src/java/org/apache/lucene/search/matchhighlight/package-info.java:22-23lucene/highlighter/src/java/org/apache/lucene/search/matchhighlight/MatchRegionRetriever.java:56-59, 115-117, 411-416, 498-504; OffsetsFromTokens.java:34-40; MatchHighlighter.java:37-44lucene/core/src/java/org/apache/lucene/search/QueryVisitor.java:41-51, 86-98lucene/highlighter/src/java/org/apache/lucene/search/uhighlight/MultiTermHighlighting.java:82-85lucene/highlighter/src/test/org/apache/lucene/search/matchhighlight/TestMatchRegionRetriever.java:219-291, 683-707lucene/CHANGES.txt:4700-4701, 4816-4817, 5695-5698, 6065-6067
- Suggested fix
lucene/core/src/java/org/apache/lucene/search/QueryVisitor.java:49; a Lucene clone releases/lucene/10.5.2 QueryVisitor.java:49-50 (Supplier<ByteRunAutomaton>)lucene/core/src/java/org/apache/lucene/search/TermInSetQuery.java:186, 190-195; util/automaton/CompiledAutomaton.java:369, 486-492; lucene/highlighter/.../matchhighlight/OffsetsFromValues.java:43-67a Lucene clone origin/branch_10x gradle/libs.versions.toml:42 (minJava = "21")
- History
lucene/CHANGES.txt:4700-4701, 4816-4817a Lucene clone releases/lucene/9.5.0 TermInSetQuery.java:79, 115-152, 241-252 and Weight.java:78-94 (same logic at 9.0.0); origin/branch_10_6 2763db11b0a OffsetsFromTokens.java, MatchRegionRetriever.java
- TestOffsetsFromTokensPrefixQuery.java (JUnit test):
repro/LFV-276/src/org/apache/lucene/search/matchhighlight/TestOffsetsFromTokensPrefixQuery.java(65 lines)
Made in parallel with this doc (2026-10-27) from an executed probe on main a9779ef3c193 (assertions on and off, the 10.5.2 jars, and with the hunt's fix shadowed in); every value on screen comes from the probe's log. The diagram uses the finder's example (foo bar, ba*, [4,7)); the compact repro uses a StringField (lucene, solr, [0,6)) for the same mechanism.
assets/LFV-276/bug.png(563 KB)
![Summary card: multi-term queries get no hit region on fields without positions. Example: one stored doc, tags = 'foo bar', indexed DOCS_AND_FREQS (no positions, no offsets). 1: the rewritten PrefixQuery(tags:ba) finds 1 hit, doc 0, and Weight#matches reports a match on tags (the term bar), so the hit region should be [4,7). 2: MatchRegionRetriever with the default strategies; no positions, so OffsetsFromTokens re-analyzes 'foo bar' and keeps the tokens in matchTerms. 3: the match's getQuery() is the PrefixQuery, which reports ba* through consumeTermsMatching; OffsetsFromTokens overrides only consumeTerms, so matchTerms stays empty. Expected {tags=[[4,7)]}; actual {}, although doc 0 is a hit. Channel picture: PrefixQuery tags:ba* to consumeTermsMatching, not overridden, a no-op, matchTerms = {}, result {}; TermQuery tags:bar to consumeTerms, matchTerms = {bar}, result {tags=[[4,7)]}. Also no region: WildcardQuery, RegexpQuery, TermRangeQuery, a 2-term TermInSetQuery; MatchHighlighter drops the field. Correct: TermQuery, 1-term TermInSetQuery, SCORING_BOOLEAN_REWRITE, FuzzyQuery, a field with positions. Affected: 9.0.0 to 10.5.2 and main; search hits, counts and scores are correct.](../assets/LFV-276/bug.png)