All findings · Lucene finding · LFV-276 · offsetsfromtokens-ignores-multiterm-queries

MatchRegionRetriever's default OffsetsFromTokens strategy ignores consumeTermsMatching, so multi-term queries get no hit regions on fields without positions

On a field indexed without positions (for example a stored StringField), MatchRegionRetriever and MatchHighlighter find no hit region for prefix, wildcard, regexp, range and multi-term set queries (unless the query names a single term), although the document matches: ba* on foo bar gives {} instead of [4,7). Search results are correct; every release since 9.0.0 is affected.

Summary

Priority
Low, rank 360Highlighting-only, in the little-used matchhighlight package, for multi-term queries on fields indexed without positions.
Impact
silent wrong results
Since
9.0.0 (150a8dacb59, LUCENE-9463, 2020), when the package was addedintroduced 2020-08-14 · about 6 years
Affected
9.0.0 to 10.6.0 (10.6.0 by reading) and main; the two-term TermInSetQuery form from 9.6.0
Status
documented
Found by
bounded-exhaustive translation validation on the stock jars, then a JUnit

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

The finding in 30 seconds

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.

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.
Step by step through an executed probe on main: one document, 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.
  1. You index one document whose stored field tags holds foo bar, indexed without positions (DOCS_AND_FREQS; every StringField has no positions either), and search PrefixQuery(tags:ba): 1 hit, doc 0.
  2. You ask MatchRegionRetriever (or MatchHighlighter), with its default strategies, where the match is. Without positions it re-analyzes foo bar into foo [0,3) and bar [4,7) and keeps the tokens whose terms the query reports.
  3. You expect {tags=[[4,7)]}: TermQuery(tags:bar), FuzzyQuery(baz~1), the same prefix with SCORING_BOOLEAN_REWRITE, and the same prefix on a field with positions all give that.
  4. Instead you get {}, no hit region, and MatchHighlighter treats the field as unmatched (with skipRemaining() it drops it). The same for WildcardQuery(b?r), RegexpQuery(ba.), TermRangeQuery [b TO c] and the two-term TermInSetQuery(bar, qux). Hits and scores are correct.
  5. Why: the default strategy, OffsetsFromTokens, listens only to QueryVisitor#consumeTerms. These queries report their terms as an automaton through consumeTermsMatching, so its term set stays empty and no token is kept.

Who is affected

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.

Terms

  • Hit regions: MatchRegionRetriever returns, per document and field, the character ranges (OffsetRange) that the query matched; MatchHighlighter turns 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 indexed DOCS, DOCS_AND_FREQS or DOCS_AND_CUSTOM_FREQS (no positions; every StringField is DOCS). 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: consumeTerms is "Called by leaf queries that match on specific terms"; consumeTermsMatching is "Called by leaf queries that match on a class of terms" and passes an automaton (QueryVisitor.java:33-51).

The bug

  1. highlightDocument asks weight.matches(...) (MatchRegionRetriever.java:405), skips a field only when its MatchesIterator is null or MATCH_WITH_NO_TERMS (:411-416), calls the strategy (:434), and drops the field when the strategy returns no ranges (:435-437).
  2. 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, TermRangeQuery and TermInSetQuery match through a constant-score wrapper whose matches() returns DisjunctionMatchesIterator.fromTermsEnum(context, doc, q, ...) (AbstractMultiTermQueryConstantScoreWrapper.java:203-212): every sub-match's getQuery() is the multi-term query itself (DisjunctionMatchesIterator.java:94, 132, TermMatchesIterator.java:73-75).
  3. Visited, that query reports through consumeTermsMatching: AutomatonQuery.visit delegates to CompiledAutomaton.visit (AutomatonQuery.java:150-154), which uses consumeTermsMatching for NORMAL and ALL automata and consumeTerms only for a SINGLE term (CompiledAutomaton.java:365-382); TermInSetQuery uses consumeTerms for 1 distinct term and consumeTermsMatching for 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-all WildcardQuery(*) does not (executed).
  4. OffsetsFromTokens.get visits each match's getQuery() with a visitor that overrides only consumeTerms (OffsetsFromTokens.java:56-70) and keeps a token only if matchTerms.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.

The numbers

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 matchhighlight package (MatchRegionRetriever with its default strategies, or MatchHighlighter) who highlight a field indexed without positions, typically a keyword-style StringField (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 for tag:luc* (read). TermInSetQuery with 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_FREQS fields from 10.5.0.
  • Effect. Silent missing highlights; hits, counts and scores are correct and the Matches API reports the match. In MatchHighlighter the field takes the application's no-match path: omitted with skipRemaining(), shown as leading text with maxLeadingCharacters(...), and with no no-match highlighter in the chain the call throws RuntimeException: 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.

Minimal repro

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.

TestOffsetsFromTokensPrefixQuery.java (JUnit test)65 lines
/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.apache.lucene.search.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);
      }
    }
  }
}
Command
# 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.

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

More files, environment, full output35 words

Environment

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

Observed output

# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
    => 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)

Why it looks unintended

  • The package promises "hit areas for a given Query"; the default strategy keeps the terms the query "hinted at" via a QueryVisitor, and consumeTermsMatching is how these queries hint.
  • The answer follows the reporting channel, not the match: a 1-term TermInSetQuery highlights, 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); consumeTermsMatching is 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 TermInSetQuery highlights and a 2-term one (equivalent to a TermQuery disjunction that does highlight) does not; the default and the scoring rewrite of the same PrefixQuery disagree (executed). TermInSetQuery moved to consumeTermsMatching for 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 the hasPositions() == false fallback from TermQuery, SynonymQuery and 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 a TermQuery (: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.

Suggested fix

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).

Also honour consumeTermsMatching, once per distinct query (validated) (recommended)

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 DOCS field, 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), but TermInSetQuery'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 custom MatchesIterator returning new, equal query objects would not be deduplicated. The visitor keeps the default acceptField; 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/highlighter module (classic, unified, match and vector highlighters, incl. the repro): 35 suites, 728 tests, 0 failures.
  • ecjLint (main and test), checkGoogleJavaFormat and renderJavadoc for the highlighter module pass; tidy leaves the fix unchanged; both diffs apply cleanly to a clean a9779ef3c193 tree.
  • 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/highlighter only); a backport in a 10.x source tree; no benchmark beyond the probe's cost case, in particular none with a large TermInSetQuery, many distinct multi-term clauses, or custom and composite queries.

The hunt's draft without deduplication

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.

Change the Matches side instead (not run)

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.

Fall back to whole-value highlighting for automata (not run)

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.

Document the limitation (not run)

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.

Downstream note

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.

How it was found

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 a DOCS-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.

Prior reports, history and checks

Prior reports

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.

What was searched, when, and what turned up
WhereWhenHowFound
apache/lucene issues, PRs and commits; downstream repositories; the dev@, java-user@, issues@ and commits@ lists; the JIRA archive; the web2026-10-26the 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 readno report; historical analogues in other highlighters only
GitHub (apache/lucene, apache/solr, OpenSearch, Elasticsearch)2026-10-2617 manual issue and PR searches, plus code search for MatchRegionRetriever and matchhighlightno report; no downstream use found
LUCENE and SOLR JIRA projects; mailing-list threads2026-10-26JQL queries; full reads of LUCENE-9439, 9461, 9462, 9463, 9464, 9427 and 9062; design and user threadsno report; design context only
Open apache/lucene PRs and full history2026-10-26changed files of all 413 open PRs; git log of the matchhighlight filesno PR touches OffsetsFromTokens.java; last change #12881 (2023, performance)

History

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).

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affectedcompact 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 affectedthe 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 affectedcompact 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 affectedthe 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 formthe 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 affectedcompile error: no matchhighlight package
fix (scratch worktree at a9779ef3c193) not affectedthe repro 6 of 6 and the highlighter module (728 tests) pass, assertions on and off; the hunt's tests pass with it shadowed in

Independent checks

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.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidateTranslation-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 realOwn 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 bugRead 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, mediumHigh 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; keepHistory 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 validatedThe 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.

Diagram file

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

Sources and provenance

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

Finding record

LFV-276, snapshot of 2026-11-06

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 whole lucene/highlighter module 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 main ea57360912ed and 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 on a9779ef3c193 still reproduces.

Lucene and upstream sources

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-505
  • lucene/highlighter/src/java/org/apache/lucene/search/matchhighlight/OffsetsFromTokens.java:34-40, 56-70, 82
  • lucene/core/src/java/org/apache/lucene/search/QueryVisitor.java:33-51
  • lucene/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-34
  • lucene/core/src/java/org/apache/lucene/util/automaton/CompiledAutomaton.java:364-382
  • lucene/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-707
  • lucene/highlighter/src/java/org/apache/lucene/search/matchhighlight/MatchRegionRetriever.java:107-117; MatchHighlighter.java:187-201, 301-308
  • lucene/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-23
  • lucene/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-44
  • lucene/core/src/java/org/apache/lucene/search/QueryVisitor.java:41-51, 86-98
  • lucene/highlighter/src/java/org/apache/lucene/search/uhighlight/MultiTermHighlighting.java:82-85
  • lucene/highlighter/src/test/org/apache/lucene/search/matchhighlight/TestMatchRegionRetriever.java:219-291, 683-707
  • lucene/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-67
  • a Lucene clone origin/branch_10x gradle/libs.versions.toml:42 (minJava = "21")
History
  • lucene/CHANGES.txt:4700-4701, 4816-4817
  • a 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

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

Repro files

About the diagrams

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.

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