All findings · Lucene finding · LFV-620 · opennlp-lemmatizer-drops-sentence-final-keywords

OpenNLPLemmatizerFilter drops keyword-marked words that end a sentence

Protect a word from the OpenNLP lemmatizer with a keyword marker, and it disappears whenever it ends a sentence: They sent him running in the evening with evening protected is indexed as [they, send, he, run, in, the]. A regression since 9.5.0; the fix changes one condition.

Summary

Priority
Medium, rank 138Since 9.5.0 the OpenNLP lemmatizer silently drops sentence-final keyword tokens in ordinary short text such as titles and queries, but the chain is opt-in.
Impact
silent wrong results
Since
9.5.0 (#11734, 2022)introduced 2022-09-23 · about 4 years
Affected
9.5.0 to 10.5.2 (26 releases) and main
Status
documented
Found by
bounded-exhaustive translation validation against a reference model

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

A keyword-aware filter is supposed to leave protected words alone. This one loses them when they end a sentence.

Summary card: Lucene OpenNLPLemmatizerFilter silently drops keyword tokens that end a sentence. A keyword marker protects `evening` in front of the OpenNLP lemmatizer; the text They sent him running in the evening is indexed without evening, and a TermQuery for it finds 0 documents. Inside incrementToken, calls 1-6 emit one buffered token each and the non-keyword ones take the next lemma; at call 7, line 64 tests lemmaNum >= lemmas.length, 6 >= 6, the sentence is over, no input is left and evening is never emitted. A sentence of only keywords keeps just its first token. Releases 9.5.0 to 10.5.2 and main; 9.4.2 kept the token. Fix: end the sentence when the token iterator is exhausted, as before 9.5.0; reindex affected documents.
The example sentence through the lemmatizer call by call: six lemmas for the six unprotected words, line 64 ends the sentence after the sixth, and the protected evening is left in the buffer; then the controls, release 9.4.2 and the fix. Select the still to open it at full size. Generated with AI, checked against the code.
  1. Analyze They sent him running in the evening (no final period, like a title) with the OpenNLP tokenizer, OpenNLPPOSFilter, KeywordMarkerFilter with the pattern evening, and OpenNLPLemmatizerFilter. The marker asks the lemmatizer to leave evening unchanged.
  2. You expect 7 tokens, [they, send, he, run, in, the, evening]: six lemmas and the protected word. The same chain without the marker gives exactly that.
  3. You get 6: [they, send, he, run, in, the]. The filter buffers the sentence, makes one lemma per unprotected word (6), and declares the sentence over once the 6 lemmas are used. evening has no lemma, so it is still in the buffer and is thrown away.
  4. No error, with assertions on or off. The index has no evening (a TermQuery for it finds 0 documents), and the query running in the evening, analyzed the same way, becomes [run, in, the].
  5. A sentence made only of protected words keeps just its first word: Acme Widget becomes [Acme]. A final . hides the bug, so titles, names, short fields and queries are the usual victims; 9.4.2 and earlier kept the word.

Who is affected

Users of the opt-in OpenNLP module who protect words from the lemmatizer with KeywordMarkerFilter, StemmerOverrideFilter or a custom keyword filter. Any value or query whose last words are protected and lack final punctuation loses them (titles, names, product fields, queries), at index and query time. Chains without a marker and the KeywordRepeatFilter chain are not affected.

Mechanism, terms and numbers494 words

OpenNLPLemmatizerFilter leaves keyword-marked tokens unlemmatized, but since 9.5.0 it ends each sentence as soon as it has emitted one lemma per non-keyword token. Keyword tokens get no lemma, so protected words after the last ordinary word of a sentence are silently dropped: They sent him running in the evening with evening protected by KeywordMarkerFilter becomes [they, send, he, run, in, the], and a sentence made only of protected words keeps just its first word. The words are missing at index and at query time, with no error. It needs the opt-in OpenNLP module with a keyword marker in front of the lemmatizer, and it hits short text whose last words are protected and have no final punctuation. Introduced by #11734 (9.5.0); 9.4.2 kept the word; restoring the pre-9.5 end-of-sentence test fixes it.

Terms

  • Keyword token: a token whose KeywordAttribute.isKeyword() is true. Filters such as KeywordMarkerFilter (a protected-word set or a pattern) and StemmerOverrideFilter set it so that stemmers and lemmatizers leave the word unchanged; the KeywordAttribute javadoc says stemming filters use it to "conditionally skip a term".
  • OpenNLP chain: OpenNLPTokenizer splits text into sentences and tokens (each token carries its sentence index), OpenNLPPOSFilter adds part-of-speech tags, and OpenNLPLemmatizerFilter replaces each word by its dictionary form, one sentence at a time. All of it lives in the opt-in analysis-opennlp module and needs user-supplied models.

The bug

  1. For each sentence, nextSentence() buffers every token, keyword or not, but sends only the non-keyword ones to the lemmatizer (OpenNLPLemmatizerFilter.java:86-90, :94 at a9779ef3c193). So lemmas holds one entry per non-keyword token (:52).
  2. incrementToken() emits the buffered tokens in order and takes the next lemma only for a non-keyword token (:74-75).
  3. The end-of-sentence test is lemmaNum >= lemmas.length (:64). It turns true as soon as the last non-keyword token is out, while keyword tokens after it are still buffered.
  4. The next call then returns false (no more sentences) or loads the next sentence and replaces the token iterator (:95). Either way those keyword tokens are never emitted.
  5. A sentence with no non-keyword token has lemmas.length == 0, so only its first token gets out.

The numbers

With the module's test models and evening protected: 7 buffered tokens, 6 lemmas [they, send, he, run, in, the]. Calls 1-6 emit them; at call 7, 6 >= 6 ends the sentence, there is no more input, and the filter returns false with evening still in the iterator. The indexed terms are [he, in, run, send, the, they]. With a final . the output is [they, send, he, run, in, the, evening, .], because the . is the last non-keyword token. Acme Widget with both words protected gives [Acme]; I like Acme Widget gives [I, like].

The sibling filters count buffered tokens instead (OpenNLPPOSFilter.java:50, OpenNLPChunkerFilter.java:53: tokenNum >= sentenceTokenAttrs.size()); only the lemmatizer counts lemmas, because only it skips some tokens.

Exposure and likelihood433 words
  • Who is exposed. Chains built from OpenNLPTokenizer, OpenNLPPOSFilter and OpenNLPLemmatizerFilter (user-supplied models) with a filter that marks single words as keywords in front of the lemmatizer: KeywordMarkerFilter with protected words or a pattern, StemmerOverrideFilter, or a custom filter. The marker may sit before or after the POS filter (both executed). A typical use is keeping brand, product or person names out of lemmatization. SuggestStopFilter (suggest module) also marks a final stopword with no separator after it as a keyword (SuggestStopFilter.java:29-34, :108), so it would trigger the bug in front of the lemmatizer (read in the code, not executed).
  • Not exposed. Chains without a keyword filter, and the documented KeywordRepeatFilter chain: it emits each token first as keyword and then as non-keyword (KeywordRepeatFilter.java:44-58), so the last token of a sentence always has a lemma. That chain lost 0 tokens over 404 generated documents. This holds with RemoveDuplicatesTokenFilter after the lemmatizer, as in the upstream tests; placed before it, that filter would remove the non-keyword copies and leave only keyword tokens (read in the code, not executed).
  • What triggers it. A keyword token at the end of a sentence. Sentence-final punctuation is an ordinary token, so in prose only sentences that end in protected (or earlier removed) punctuation are hit. The everyday case is a field value or query without a final period, or a value made only of protected words. Each value of a multi-valued field is analyzed on its own.
  • Effect. The index does not have the words (a stored copy of the value, if the field is stored, still shows them): term, phrase and all-words queries miss the document. When an inner sentence loses tokens, the later tokens move up (in the probe, they sits at position 4 instead of 6; offsets stay correct), so phrase queries can see words as adjacent that were not. On the query side, the classic QueryParser turns the phrase "running in the evening" into "run in the", which also matches They sent him running in the morning; QueryBuilder's AND query for Acme Widget becomes Acme. With splitOnWhitespace=true the classic parser analyzes each word alone and keeps them. Documents keep the missing terms until reindexed.
  • Scale. Ordinary inputs, deterministic, every affected value. No crash, CheckIndex clean, no obvious slowdown in one noisy run (1019 ms vs 996 ms for its control, 20,000 sentences). In a 404-document corpus generated from a small vocabulary, through the keyword-marker chain, 6 documents lost 8 tokens.
  • Since. 9.5.0 (#11734); 26 releases, 9.5.0 to 9.12.3 and 10.0.0 to 10.5.2, and main.

Minimal repro

The test shows that OpenNLPLemmatizerFilter drops a keyword-marked token that ends a sentence (opennlp tokenizer -> POS -> keywordMarker(pattern=evening) -> dictionary lemmatizer, module test models).

TestLemmatizerSentenceFinalKeyword.java (JUnit test)55 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.analysis.opennlp;

import java.util.ArrayList;
import java.util.List;
import org.apache.lucene.analysis.Analyzer;
import org.apache.lucene.analysis.TokenStream;
import org.apache.lucene.analysis.custom.CustomAnalyzer;
import org.apache.lucene.analysis.tokenattributes.CharTermAttribute;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.util.ClasspathResourceLoader;

public class TestLemmatizerSentenceFinalKeyword extends LuceneTestCase {

  public void testKeywordAtEndOfSentence() throws Exception {
    List<String> terms = new ArrayList<>();
    try (Analyzer a =
            CustomAnalyzer.builder(new ClasspathResourceLoader(getClass()))
                .withTokenizer(
                    "opennlp",
                    "tokenizerModel",
                    "en-test-tokenizer.bin",
                    "sentenceModel",
                    "en-test-sent.bin")
                .addTokenFilter("opennlpPOS", "posTaggerModel", "en-test-pos-maxent.bin")
                .addTokenFilter("keywordMarker", "pattern", "evening")
                .addTokenFilter("opennlplemmatizer", "dictionary", "en-test-lemmas.dict")
                .build();
        TokenStream ts = a.tokenStream("f", "They sent him running in the evening")) {
      CharTermAttribute term = ts.addAttribute(CharTermAttribute.class);
      ts.reset();
      while (ts.incrementToken()) {
        terms.add(term.toString());
      }
      ts.end();
    }
    // the keyword "evening" must pass through unlemmatized; actual: it is dropped
    assertEquals(List.of("they", "send", "he", "run", "in", "the", "evening"), terms);
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-620/, 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/analysis/common/build/libs/lucene-analysis-common-$V.jar
CP=$CP:$LUCENE/lucene/analysis/opennlp/build/libs/lucene-analysis-opennlp-$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.analysis.opennlp.TestLemmatizerSentenceFinalKeyword
# the module(s) analysis-opennlp also need their third-party runtime jars
Expected vs actual
Expected [they, send, he, run, in, the, evening] for "They sent him running in the evening"; actual [they, send, he, run, in, the] on main, with -ea and -da.

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

More files, environment, full output45 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:<[they, send, he, run, in, the, evening]> but was:<[they, send, he, run, in, the]>
       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.analysis.opennlp.TestLemmatizerSentenceFinalKeyword.testKeywordAtEndOfSentence(TestLemmatizerSentenceFinalKeyword.java:53)

Why it looks unintended

  • The filter skips keyword tokens on purpose and copies them through unchanged (OpenNLPLemmatizerFilter.java:74-76); the note that added this says lemmatization "won't be performed" on them.
  • 7.3.0 to 9.4.2 kept the word; 9.5.0 with only the end-of-sentence test reverted keeps it again.
  • Upstream fixed two earlier lost-token bugs in this filter as bugs (GITHUB#11735, GITHUB#11771, both labeled type:bug).
The full argument319 words
  • Design. The filter leaves keyword tokens out of lemmatization and copies them through unchanged (OpenNLPLemmatizerFilter.java:74-76, :86-90; the field comment at :52 reads "lemmas for non-keyword tokens"). Nothing in the class removes tokens on purpose.
  • The note that added the feature (LUCENE-2899, GitHub #3973, migrated JIRA comment by sarowe): "Added KeywordAttribute awareness to OpenNLPLemmatizationFilter, so that lemmatization won't be performed on tokens with isKeyword()==true."
  • KeywordAttribute javadoc: keyword-aware streams "can decide to modify a token"; stemmers "conditionally skip a term". Skipping means leaving the token as it is. It is not an explicit promise that every token is kept, but nothing in it suggests dropping keyword tokens.
  • No chain rule. The class and factory javadoc name no restriction on the filters before the lemmatizer and never say tokens are removed.
  • History. From 7.3.0 to 9.4.2 a sentence ended when its buffered tokens ran out (sentenceTokenAttrsIter == null || !sentenceTokenAttrsIter.hasNext()); 9.4.2 passes the repro. #11734 ("Fix repeating token sentence boundary bug", by kotman12, merged by dweiss in 2022) switched the lemmatizer to the lemma count; its review does not mention keyword tokens at a sentence end.
  • Precedent. #11734 fixed GITHUB#11735 and GITHUB#11771, both lost tokens in this filter, both labeled type:bug on GitHub and listed in CHANGES 9.5.0 (under its "Optimizations" heading, lucene/CHANGES.txt:3854; entries :3859, :3862). The #11735 reporter wrote that a dropped last token "is usually not a big deal when that token is punctuation (most of the time) but can become especially problematic when the last bit of text of a stream has no punctuation": the same situation this bug creates for keyword tokens.
  • Why the tests miss it. Every keyword test in TestOpenNLPLemmatizerFilterFactory (:230, :252, :274, :301, :323, :359) uses KeywordRepeatFilter, whose last token per sentence is never a keyword, and TestRandomChains skips the filter (@IgnoreRandomChains, OpenNLPLemmatizerFilter.java:45).
The devil's-advocate check160 words

The strongest case for "works as designed" (checkers): keyword support here was motivated by the KeywordRepeatFilter pattern (index lemma and original side by side), every upstream keyword test uses it, and the class javadoc promises nothing about keywords, so a standalone marker is an untested, unsupported shape.

Why it fails: untested is not intended. The code reads KeywordAttribute whoever set it, and the design note states a general rule, keyword tokens are not lemmatized. (Granted: no javadoc formally promises one output token per input token; the case rests on the implementation, its history and the design note.) The same chain kept the words from 7.3.0 to 9.4.2, until #11734, whose purpose was sentence boundaries with repeated tokens. A lemmatizer has no reason to delete tokens, and upstream fixed two token-loss bugs in this filter as bugs. "Rare, because sentences end with punctuation" limits reach, not intent: short text without a final period is everyday input. Verdict: not intended, a regression.

Suggested fix

Recommended End the sentence when its buffered tokens are used up (validated)

End the sentence when its buffered tokens are used up, the pre-9.5 test: sentenceTokenAttrsIter == null || !sentenceTokenAttrsIter.hasNext() instead of lemmaNum >= lemmas.length (OpenNLPLemmatizerFilter.java:64). Validated: the new test passes, as do all 39 module tests and :lucene:analysis:opennlp:check. Affected fields need a reindex.

  @Override
  public final boolean incrementToken() throws IOException {
-    boolean isEndOfCurrentSentence = lemmaNum >= lemmas.length;
+    // A sentence ends when all its buffered tokens have been emitted. Keyword tokens have no lemma,
+    // so counting lemmas would drop the keyword tokens that end a sentence.
+    boolean isEndOfCurrentSentence =
+        sentenceTokenAttrsIter == null || !sentenceTokenAttrsIter.hasNext();
     if (isEndOfCurrentSentence) {
All options and trade-offs3 options · 570 words

One fix was built and validated with gradle in a scratch worktree at a9779ef3c193; the alternative and the workaround are for discussion.

End the sentence when its buffered tokens are used up (validated) (recommended)

Restores the end-of-sentence test of 7.3.0 to 9.4.2 (+4/-1 in OpenNLPLemmatizerFilter.java, with a two-line comment). Keyword tokens no longer decide when a sentence ends, so every buffered token is emitted.

  @Override
  public final boolean incrementToken() throws IOException {
-    boolean isEndOfCurrentSentence = lemmaNum >= lemmas.length;
+    // A sentence ends when all its buffered tokens have been emitted. Keyword tokens have no lemma,
+    // so counting lemmas would drop the keyword tokens that end a sentence.
+    boolean isEndOfCurrentSentence =
+        sentenceTokenAttrsIter == null || !sentenceTokenAttrsIter.hasNext();
     if (isEndOfCurrentSentence) {

Trade-offs

  • Restores the 9.4.2 output for sentence-final keywords; an all-keyword sentence now keeps all its tokens, which no release did (9.4.2 emitted none).
  • Existing indexes keep the missing terms, while the fixed query side keeps the protected words (the AND query Acme Widget needs both terms again), so such queries can stop matching old documents until the field is reindexed. Worth a CHANGES note (reasoning, not executed).
  • Cost: no obvious slowdown; in one best-of-3 run of 20,000 sentences on a shared host, the fixed chain took 1024 ms and its control 981 ms. One noisy run, not a benchmark.
  • The same line is in every release from 9.5.0, so the patch should apply to the maintained branches; only main was built and tested with gradle.

What the checks validated

  • Gradle, JDK 25: the new test fails 4 of 6 on stock and passes 6 of 6 with the fix; with the fix, TestOpenNLPLemmatizerFilterFactory (12 upstream tests, including the KeywordRepeatFilter, early-exit and empty-field tests of #11734) and the new test pass, 18 of 18; the whole module passes, 6 suites and 39 tests (33 upstream + 6 new), with assertions on and off; :lucene:analysis:opennlp:check passes (ecjLint, forbiddenApis, google-java-format, tests).
  • Standalone runner with the fix: the brief's test 6/6 (assertions on and off) and the finder's 3/3; the verifiers' runs: the independent reproducer's 12 tests plus the 12 upstream lemmatizer tests, 24/24, and the devil's advocate's 3/3.
  • The bounded-exhaustive harness then reports no sentence-final-keyword mismatch (0 of 55,987 cases per lemmatizer variant).
  • It does not bring back #11735 or #11771: it changes only the end-of-sentence test, keeps #11734's sentence grouping by SentenceAttributeExtractor (9.4.2 used the per-token end-of-sentence flag) and does not restore 9.4.2's early return false for a sentence without non-keyword tokens; the three lemmatizer tests #11734 added pass with it.

Count buffered tokens, like the POS and chunker filters (not run)

Add a tokenNum counter and end the sentence at tokenNum >= sentenceAttributes.size(), as OpenNLPPOSFilter.java:50 and OpenNLPChunkerFilter.java:53 do.

Trade-offs

Same semantics as the recommended fix and makes the three filters look alike; a few more lines (a field and its resets in nextSentence() and clear()).

Workaround until a fix ships: the KeywordRepeatFilter chain

Use KeywordRepeatFilter, with RemoveDuplicatesTokenFilter after the lemmatizer, instead of a standalone marker; it lost 0 tokens over 404 generated documents. Ending each value with punctuation also avoids the sentence-final case (executed), if the chain keeps the punctuation as an unmarked token, but not every inner sentence.

Trade-offs

Different behaviour: it indexes the lemma next to the original word for every token instead of protecting chosen words. Appending punctuation changes the tokens and does nothing for already indexed documents.

Downstream note

Downstream, an exclusion or alerting rule that matches a protected word on such a field misses documents whose value ends with that word: a MUST_NOT body:evening clause left the example document visible.

How it was found

Semi-formal. While reading the filter to build a reference model, the finder suspected the lemma-count test. A bounded-exhaustive comparison of the stock filter with an independent per-sentence reference (55,987 cases per lemmatizer variant) found 9,446 per variant that match this bug exactly, none unexplained; then public-API JUnit tests reproduced it.

The method in full196 words

Method: bounded-exhaustive translation validation on the stock main jars (the hunt's "semi-formal" tier), hunt unit "OpenNLP filters versus the OpenNLP library", 2026-10-26.

  • The hypothesis came first, from reading OpenNLPLemmatizerFilter while writing the model; the enumeration then confirmed it. So it is not a blind discovery.
  • Reference: an independent per-sentence model that calls the OpenNLP ops directly. Inputs: every sentence-index sequence over {0,1,2}^n and every keyword pattern over {0,1}^n for n <= 6, 55,987 cases per lemmatizer variant (dictionary, MaxEnt, both). On main, 9,446 cases per variant match this bug's hypothesis exactly; the other mismatches belong to the sibling bug LFV-621 (a first sentence index other than 0); 0 cases are unexplained. With this fix shadowed, the class disappears; with both fixes, 0 mismatches remain.
  • A generated-text differential run: 404 documents generated from a small fixed vocabulary, through OpenNLPTokenizer, with the library called directly as the reference. In the chain POS -> keyword marker -> lemmatizer, 6 documents lost 8 tokens; 0 with this fix and LFV-621's fix both shadowed (the only fixed run of the corpus).
  • Then an AI finder wrote the public-API JUnit, and independent AI tools reproduced and judged it (see the checks).

Prior reports, history and checks

Prior reports

No prior report: 177 queries over GitHub issues, PRs and commits, all 407 open PRs' files, mailing lists, JIRA and the web (2026-10-26), 15 hits read; re-checked 2026-10-27. Related history only: #11734 introduced it while fixing #11735 and #11771.

Searches6 searches

No issue, PR, JIRA entry, mailing-list post or web page reports that keyword tokens at the end of a sentence are dropped, or that an all-keyword sentence keeps only its first token. Caveat: #11771's trigger (a sentence with zero non-keyword tokens) has the same shape as the all-keyword sub-case, but its symptom (the rest of the stream lost) and its code are gone; the current symptom comes from a different line. The headline symptom has no prior report either way.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene)2026-10-26two runs of a scripted prior-report search (51 and 57 queries) plus manual searches: class names, keyword, KeywordMarkerFilter, last token, sentence, SentenceAttributeExtractor; #11734, #11735, #11771 and #11802 read in full; git log -S for the changed lineno report; #11734 introduced the line while fixing #11735 and #11771
Open PRs2026-10-26the changed files of all 407 open apache/lucene PRsnone changes the logic; #16911 only deletes branch_10x; #11955 and #14194 touch other files of the module
JIRA archive2026-10-2614 JQL queries over SOLR, LUCENE and OPENNLP; all SOLR titles mentioning OpenNLP and LUCENE titles since 2018 readno report
Mailing lists (lists.apache.org)2026-10-2624 queries over the Lucene, Solr and OpenNLP user and dev listsno report; review requests for #11734 and an unrelated thread-safety thread
Downstream repositories and the web2026-10-26Solr, Elasticsearch and OpenSearch repositories; 10 web queries and 2 Stack Exchange searchesno report; lucenenet#890 is a different code base and chain
Re-check (GitHub, read-only)2026-10-27open PRs and issues for OpenNLPLemmatizerFilter; the file on upstream mainnothing new; upstream main still has lemmaNum >= lemmas.length at line 64

History

A regression from #11734 (3a04aa44c22, merged 2022-09-23; 9.x backport b253e4353f0), first released in 9.5.0. Fails on 9.5.0, 9.12.3, 10.0.0, 10.5.2 and main; passes on 7.3.0, 8.11.4, 9.0.0, 9.4.2, and on 9.5.0 with only that test reverted.

Versions tested7 rows

Introduced by #11734 "Fix repeating token sentence boundary bug" (by kotman12, merged by dweiss): 3a04aa44c22 on main (2022-09-23) and the 9.x backport b253e4353f0 (2022-09-24; an earlier backport of the same day was reverted because a new test failed on JDK 11, and no release came in between). It replaced the end-of-sentence test sentenceTokenAttrsIter == null || !sentenceTokenAttrsIter.hasNext() with lemmaNum >= lemmas.length and removed the old "zero non-keyword tokens" exit. git tag --contains: 9.5.0 to 9.12.3 and 10.0.0 to 10.5.2, 26 releases. No later commit changed the logic (#14804 imports, #16056 toArray).

The all-keyword sentence has its own history: wrong in every tested release. 8.11.4 and 9.4.2 emit no token and lose the rest of the stream; 9.5.0, 10.5.2 and main keep only the first token. Describe it as "still wrong after #11734", not as introduced by it.

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affectedthe brief's test 4 of 6 fail (gradle and the standalone runner, assertions on and off); the finder's 2 of 3; the independent reproducer's 8 of 12
10.5.2 affectedthe brief's test 4 of 6 fail; the finder's 2 of 3; the independent reproducer's 8 of 12
9.5.0, 9.12.3, 10.0.0 affectedthe finder's test 2 of 3 fail on each; the independent reproducer's 8 of 12 on 9.5.0 and 9.12.3
9.6.0 to 9.12.2, 10.1.0 to 10.5.1 read onlythe same lemmaNum >= lemmas.length test, at line 61 (static check of every tag)
9.5.0 with only the end-of-sentence test reverted isolates the line3 of 3 pass; the line is 61 there (64 on main); same OpenNLP library and test models as 9.4.2
7.3.0, 8.11.4, 9.0.0, 9.4.2 sentence-final keptthe finder's test 3 of 3 pass (import-only ports before 9.1; 8.11.4 with the 8.8.1 test models, assumed equal); the all-keyword sentence emits no token and ends the stream (8.11.4, 9.4.2)
main + fix not affectedall repros pass; lucene/analysis/opennlp 39 tests and check pass

Independent checks

An independent reproducer, a devil's advocate and an adjudication found it real and re-ran it on main with and without assertions. Reviews by another model family (reading only) agreed on the root cause; one said "not real" only because it could not run code. A final audit re-ran it with new seeds.

Checks and verdicts375 words

The finder, the independent reproducer, the devil's advocate, the adjudication and the audits wrote or ran their own tests against the stock jars; the cross-model reviews could only read code, and their own test runs did not happen.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidateJUnit fails 2 of 3 on main (assertions on and off), 9.5.0, 9.12.3 and 10.5.2; passes on 9.4.2 and with the fix.
cross-model review, refute lens not real (low), proceduralAn independent reviewer from another model family (OpenAI GPT-5.6, reading only) could not run code, so it voted not real on procedure only ("It should not be read as a substantive refutation."); it agreed with the root cause, judged the inputs in contract and rated the intended case weak.
Independent reproducer realOwn factory-chain and model-free tests: 8 of 12 fail on main (on and off), 10.5.2, 9.12.3 and 9.5.0, the 4 controls pass, also with the MaxEnt lemmatizer; on 9.4.2 only its 7 factory-chain tests compile: 6 pass and the all-keyword test fails (0 tokens). Its own fix: 12 of 12 plus the 12 upstream lemmatizer tests.
Cross-model review, intended lens real (medium)OpenAI GPT-5.6, reading only: the works-as-designed case is weak; its strongest point is that every upstream keyword test uses KeywordRepeatFilter. The AI tool that ran the review then re-ran the finder's test: 2 of 3 fail on main (assertions on and off), 3 of 3 pass with the fix.
Devil's advocate real (high)Found no document, test or discussion that accepts the drop; own test fails 2 of 3 on main (also with the marker before the POS filter), 3 of 3 with the fix; split the history of the all-keyword case.
adjudication confirmed (high)Re-ran the independent reproduction's tests with assertions off (8 of 12 fail), the finder's with assertions on (2 of 3 fail) and the independent reproduction's with the fix (12 of 12 pass).
Follow-up, registration check and audit keepHistory runs on 11 targets; fresh seeds on main: the independent reproduction's tests 8 of 12 fail (twice), the finder's 2 of 3; with the fix 12 of 12, and 24 of 24 together with the upstream lemmatizer tests.

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

Finding record

LFV-620, snapshot of 2026-11-06

Registry title
OpenNLPLemmatizerFilter silently drops keyword-marked tokens that end a sentence (and every token but the first of a sentence made only of keyword tokens), because since 9.5.0 it ends a sentence once it has used all lemmas, and keyword tokens get no lemma
Area
analysis-opennlp / OpenNLPLemmatizerFilter (sentence buffering, KeywordAttribute handling)
Priority
Medium, rank 138: Since 9.5.0 the OpenNLP lemmatizer silently drops sentence-final keyword tokens in ordinary short text such as titles and queries, but the chain is opt-in.
Impact class
silent wrong results
Impact note
Users of the opt-in analysis-opennlp module (user-supplied models) who put a filter that marks standalone tokens as keywords (KeywordMarkerFilter, PatternKeywordMarkerFilter, StemmerOverrideFilter or a custom filter) in front of OpenNLPLemmatizerFilter silently lose, at index and query time, every keyword token that ends a sentence, in practice the protected last word or words of a title, name, short field or query without terminal punctuation, and a sentence made only of protected words keeps just its first word, so term, phrase and all-words queries on those words miss or over-match; the documented KeywordRepeatFilter chain and chains without a keyword filter are not affected; ordinary scale, deterministic, no error, with -ea and -da; released 9.5.0 through 10.5.2 and on main.
Summary
You analyze text with OpenNLPTokenizer, OpenNLPPOSFilter, a keyword marker that protects some words (for example KeywordMarkerFilter with the pattern 'evening') and OpenNLPLemmatizerFilter, and expect the protected words to pass through unlemmatized, which is what the filter's KeywordAttribute handling is for. Instead 'They sent him running in the evening' becomes [they, send, he, run, in, the]: the protected last word is gone, and when every word of a sentence is protected only its first word survives ([They]). The filter ends a sentence once it has emitted one lemma per non-keyword token (OpenNLPLemmatizerFilter.java:64, a 9.5.0 change from #11734), so the keyword tokens after the last non-keyword token are thrown away.
Introduced
3a04aa44c22a (apache/lucene#11734 'Fix repeating token sentence boundary bug', by GitHub kotman12, merged by dweiss; it fixed GITHUB#11735 and GITHUB#11771) on main, and its branch_9x backport b253e4353f09 (re-applied 2022-09-24; the first backport b79edbb54acd of 2022-09-23 was reverted the same day by 64b124349bac because a new upstream test failed on JDK 11; no release in between) [read: git log -S 'lemmaNum >= lemmas.length' over all refs of the history clone, git blame on main a9779ef3c193 line 64, git tag --contains; exec: 9.4.2 passes, 9.5.0 fails, and 9.5.0 with only that line reverted (--shadow-src) passes]; the change replaced the end-of-sentence test 'sentenceTokenAttrsIter == null || !sentenceTokenAttrsIter.hasNext()' by 'lemmaNum >= lemmas.length'; a regression for the sentence-final case; the all-keyword-sentence case was already wrong before (9.4.2 emits nothing and loses the rest of the stream through the old 'zero non-keyword tokens' exit) and #11734 only changed its symptom; no later commit changed this logic (#14804 imports, #16056 toArray calls) (2022-09-23 (main, #11734 merged); 2022-09-24 (branch_9x backport re-applied))
First affected version
9.5.0 [exec] (release commit dated 2023-01-25; 9.4.2, the last release before, keeps sentence-final keyword tokens [exec]); the filter itself exists since 7.3.0 (LUCENE-2899, 3e2f9e62d77)
Affected versions
sentence-final keyword drop: 9.5.0 through 9.12.3 and 10.0.0 through 10.5.2 (26 releases) and main a9779ef3c193 [exec: the finder's repro fails 2 of 3 on 9.5.0, 9.12.3, 10.0.0, 10.5.2 and main -ea and -da; the independent reproduction's tests fail 8 of 12 on 9.5.0, 9.12.3, 10.5.2 and main -ea and -da; the other releases by a static check of every 9.x and 10.x tag]; not affected: 7.3.0 (first release with the filter), 8.11.4, 9.0.0 and 9.4.2 [exec: pass 3 of 3; import-only ports before 9.1]; all-keyword sentence: wrong in every tested release, 8.11.4 and 9.4.2 emit no token and lose the rest of the stream, 9.5.0, 10.5.2 and main keep only the first token [exec]; branch_10x has the same code (no logic change since #11734 per the upstream commits API) [read]; branch_10_6 not checked; not fixed anywhere, and no open PR changes the file (#16911 only deletes branch_10x) [read]
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that 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, code reading
Upstream and prior reports
none found (2026-10-26: the finder's scripted prior-report search with 51 queries; follow-up prior-report record, verdict new: a second scripted prior-report search with 57 queries plus manual searches, 177 logged queries in all, over GitHub issues, PRs and commits, the changed files of all 407 open apache/lucene PRs, downstream repositories, the Lucene, Solr and OpenNLP mailing lists, the SOLR, LUCENE and OPENNLP JIRA archives and the web; 15 hits read, none the same bug): no issue, PR, JIRA entry, list post or web page reports that keyword tokens at the end of a sentence are dropped, or that an all-keyword sentence keeps only its first token; not fixed upstream; related, not reports of this bug: #11734 (the introducing PR; its thread and 2 review comments do not mention keyword tokens at a sentence end), #11735 and #11771 (pre-9.5 bugs of the same filter with KeywordRepeatFilter, fixed by #11734; #11771's trigger, a sentence with zero non-keyword tokens, has the same shape as the all-keyword sub-case, but its symptom, the old exit that ended the whole stream, and that code are gone), #11802 (closed as a duplicate of #11734), open PRs #11955 and #14194 (touch the module, not this file), #16911 (deletes branch_10x), #11208 (LUCENE-10171), #3973 (LUCENE-2899), lucenenet#890 (a different code base and chain). Update 2026-11-02: #16911 (open when searched) merged 2026-10-29 as 48cf3f515d6, which emptied branch_10x to a README tombstone (10.6 is the last 10.x minor; 10.x fixes go to branch_10_6); it fixes nothing here.
Lucene commit tested
main a9779ef3c193 (commit of 2026-10-26; tested 2026-10-26 and 2026-10-27) through gradle in a scratch worktree and through the standalone JUnit runner, with assertions on and off; the release jars 7.3.0, 8.11.4, 9.0.0 and 9.4.2 (not affected), 9.5.0, 9.12.3, 10.0.0 and 10.5.2 (affected), each with its release's test models (8.11.4: the 8.8.1 models, assumed equal); 9.5.0 with only the end-of-sentence test reverted (line 61 there, 64 on main; not affected). The fix was validated in the scratch worktree: the new test, the 12 upstream lemmatizer tests, all 39 tests of lucene/analysis/opennlp with assertions on and off, and :lucene:analysis:opennlp:check.

Lucene and upstream sources

Finding record
  • lucene/analysis/opennlp/src/java/org/apache/lucene/analysis/opennlp/OpenNLPLemmatizerFilter.java:34-44 (javadoc), 52 (lemmas only for non-keyword tokens), 62-78 (incrementToken; 64 end-of-sentence test; 74-75 lemma only for non-keyword tokens), 80-97 (nextSentence; 86-90 keyword tokens not lemmatized; 95 iterator replaced) @ a9779ef3c193
  • lucene/core/src/java/org/apache/lucene/analysis/tokenattributes/KeywordAttribute.java (javadoc: keyword aware filters 'conditionally skip a term')
  • lucene/analysis/opennlp/src/test/org/apache/lucene/analysis/opennlp/TestOpenNLPLemmatizerFilterFactory.java:230-301 (every keyword test uses KeywordRepeatFilter)
  • lucene/CHANGES.txt:3859, 3862 @ a9779ef3c193 (9.5.0 bug fixes GITHUB#11735 and GITHUB#11771 in this filter)
The finding in 30 seconds
  • lucene/analysis/opennlp/src/java/org/apache/lucene/analysis/opennlp/OpenNLPLemmatizerFilter.java:52-53, 62-78, 80-97 (a9779ef3c193)
  • lucene/analysis/opennlp/src/java/org/apache/lucene/analysis/opennlp/OpenNLPPOSFilter.java:50; OpenNLPChunkerFilter.java:53
  • lucene/core/src/java/org/apache/lucene/analysis/tokenattributes/KeywordAttribute.java:23-27
Who is affected
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/miscellaneous/KeywordRepeatFilter.java:44-58
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/analyzing/SuggestStopFilter.java:29-34, 108; lucene/analysis/common/src/java/org/apache/lucene/analysis/miscellaneous/RemoveDuplicatesTokenFilter.java:47-67; TestOpenNLPLemmatizerFilterFactory.java:236-238
Why it looks unintended
  • GitHub apache/lucene issue #11735 body and #3973 comment 1223158751 (read 2026-10-27, gh, read-only)
  • lucene/CHANGES.txt:3682 (9.5.0), 3812 (Bug Fixes), 3854 (Optimizations), 3859, 3862; lucene/analysis/opennlp/src/test/org/apache/lucene/analysis/opennlp/TestOpenNLPLemmatizerFilterFactory.java:230-365; OpenNLPLemmatizerFilter.java:34-45, 52, 74-76, 86-90; OpenNLPLemmatizerFilterFactory.java:29-47
  • a Lucene clone releases/lucene/9.4.2:lucene/analysis/opennlp/src/java/org/apache/lucene/analysis/opennlp/OpenNLPLemmatizerFilter.java:71
  • GitHub apache/lucene issues #11735 and #11771: label type:bug (gh issue view, read-only, 2026-10-27)
Suggested fix
  • a Lucene clone releases/lucene/9.4.2:lucene/analysis/opennlp/src/java/org/apache/lucene/analysis/opennlp/OpenNLPLemmatizerFilter.java:73-76, 99; history commit 3a04aa44c22 (tests added to TestOpenNLPLemmatizerFilterFactory)
Prior reports
  • GitHub API, read-only, 2026-10-27: commits for OpenNLPLemmatizerFilter.java (latest dbf0d1426f1, #16056); contents on main (line 64); gh search prs/issues
History
  • a Lucene clone: git tag --contains 3a04aa44c22 and b253e4353f0 (read 2026-10-27)

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

Repro files

About the diagrams

A light, step-by-step picture of an executed probe (the public factories, the module's test models, the text They sent him running in the evening with evening protected; the filter's counters read before and after each call, for the picture only), made in parallel with this doc from runs on main a9779ef3c193 with assertions on and off, 10.5.2, 9.4.2 and main with the fix. The PNG is the final summary card.

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.