All findings · Lucene finding · LFV-654 · nori-userdict-right-id-from-line-end-not-surface

Nori user dictionary: a trailing space or inline comment on a noun's line can change how the next word is analysed

The nori user-dictionary lines 사람 (trailing space) and 사람 # memo define the same noun as 사람, yet with them KoreanAnalyzer indexes 사람이나 ("person or") as [사람][이] instead of [사람]. Nori reads the noun's ending from the end of the line, in every release since 7.4.0.

Summary

Priority
Low, rank 308Needs a nori user-dictionary line with trailing whitespace or an inline comment plus a context-dependent continuation.
Impact
silent wrong results
Since
7.4.0 (nori's first release, 2018)introduced 2018-04-13 · about 8 years
Affected
7.4.0 to 10.6.0 (10.6.0 by reading) and main
Status
documented
Found by
differential probe while modelling nori's lattice costs

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

Each user noun stores a right context id that says whether its last syllable ends in a consonant, because the reading of the next word depends on it. Nori takes the noun from the line's first whitespace-separated part, but the id from the line's last character.

Summary card: Lucene nori UserDictionary, a trailing space or a comment changes a user noun's right context id. UserDictionary.java:94 reads entry.charAt(entry.length() - 1), the last character of the line, instead of the last character of the noun token (line 90). Inside, on main: open keeps '사람 ' (the comment is removed, the space stays); token is 사람, the same noun as the plain line; lastChar is the space, so RIGHT_ID 3533 instead of RIGHT_ID_T 3535; Viterbi scores the next word from that id: from 3535 이나/JX (-10) beats 이/VCP + 나/EC (321), from 3533 이/VCP + 나/EC (184) beats 이나/JX (315); the stop filter drops JX and EC and keeps VCP, so the terms are [사람][이]. What you do: open a dictionary with the line '사람 # memo' and analyse 사람이나 with KoreanAnalyzer. Expected [사람], as with the plain line; actual [사람][이], right id 3533, not 3535.
One example, step by step: 사람 # memo loses its comment but keeps the space, the space selects right id 3533 instead of 3535, Viterbi then reads 이나 as 이/VCP 나/EC, and KoreanAnalyzer indexes the extra term 이. Select the still to open it at full size. Generated with AI, checked against the code.
  1. Give KoreanAnalyzer a user dictionary whose line for 사람 ("person") ends with a space, 사람 , or carries a comment, 사람 # memo. Nori deletes # memo but keeps the space before it.
  2. You expect the result of the plain line 사람: the parser splits lines on whitespace, so all three lines define the same noun. With 사람, 사람이나 ("person or") is indexed as [사람]: the stop filter drops the particle 이나 (tag JX).
  3. Instead the noun's right context id, which says whether its last syllable ends in a consonant, is read from the line's last character. A space is not Hangul, so 사람 gets the generic id 3533 instead of 3535.
  4. That id scores the next word. On main, from 3535 the particle 이나/JX is cheapest (cost -10 vs 321 for 이/VCP 나/EC); from 3533 이/VCP 나/EC wins (184 vs 315), so [사람][이] is indexed. On 9.12.3, 학교가방을 샀다 ("bought a school bag") with the line 학교 loses the term 가방 ("bag").
  5. No error or warning. Every nori release from 7.4.0 to 10.6.0 (10.6.0 by reading) and main is affected; a comment written with no space (사람# memo) is not.

Who is affected

Applications that give nori a user dictionary (opt-in) with a noun line ending in whitespace or in a # comment after a space. With correct context ids only some continuations change (이나, 과, 다, noun + noun compounds); on stock 10.x and main (LFV-658 mixed in) also 가 and 이. Silent, at index and query time, on every occurrence.

Mechanism, terms and numbers694 words

Nori's user dictionary stores, for each custom noun, a right context id that tells the tokenizer whether the noun's last syllable ends in a consonant (a coda), because the reading of the next word depends on it. UserDictionary takes the noun from the line's first whitespace-separated part but the id from the last character of the whole line (UserDictionary.java:94). A line with trailing whitespace, or with an inline # comment after a space (the comment is removed, the space stays), therefore gives the same noun the generic id 3533 instead of 3535 or 3534. The next morpheme can then get another part of speech, and KoreanAnalyzer's default stop filter keeps or drops other terms: 사람이나 ("person or") is [사람] with the line 사람 but [사람][이] with 사람 (9.12.3, 10.5.2 and main), and on 9.12.3 학교가방을 샀다 ("bought a school bag") loses the term 가방 ("bag") with 학교 . Silent and deterministic; opt-in (user dictionaries only); present since nori was added in 7.4.0. Fix: read the character from the noun, with a guard for an empty noun.

Terms

  • Nori user dictionary: a text file with one custom noun per line, optionally followed by its parts (세종시 세종 시); # starts a comment. It is loaded with UserDictionary.open(Reader) and passed to KoreanTokenizer, KoreanAnalyzer or the korean factory's userDictionary file.
  • Right context id: a number per dictionary entry that the tokenizer's Viterbi search uses to score the morpheme that follows. User nouns get one of three: 3535 when the last syllable has a final consonant (a coda, as in 사람), 3534 when it has none (학교), 3533 when the last character is not Hangul (UserDictionary.java:40-45). Korean particles depend on the coda: 사람이 but 학교가.
  • Part-of-speech stop filter: KoreanAnalyzer drops tokens whose tag is in KoreanPartOfSpeechStopFilter.DEFAULT_STOP_TAGS (:36-68): endings such as EC and particles such as JX; the copula VCP is kept.

The bug

  1. UserDictionary.open removes comments with line.replaceAll("#.*$", "") and keeps the line untrimmed (UserDictionary.java:56-64): 사람 # memo becomes 사람 .
  2. The constructor takes the noun from entry.split("\\s+")[0] (:89-90). Java's split drops trailing empty strings, so 사람, 사람 and 사람\t give the same noun, no segmentation and the same FST key (:125-131).
  3. But it picks the id from char lastChar = entry.charAt(entry.length() - 1);, the last character of the whole line (:94-103). A space or tab is not Hangul, so the noun gets 3533.
  4. Viterbi stores that id on the noun's node and uses it as the left side of the connection cost to the next morpheme (morph/Viterbi.java:469-472, :530-539), so another reading of what follows can win, and the stop filter then keeps or drops other terms. In every executed probe the noun itself is still emitted (the code does not guarantee it: the id enters the cost of every path through the noun).
  5. Secondary trigger, same line: a segmentation shorter than the noun, 세종시 세종, ends in 종 (coda), so 세종시 gets 3535 instead of 3534; only segmentations longer than the noun are rejected (:114-121).

The numbers

On main, the cheapest rest of the path for 사람이나 after the noun: from 3535, 이나/JX costs -10 and 이/VCP 나/EC 321, so the particle wins and the terms are [사람]; from 3533, 이/VCP 나/EC costs 184 and 이나/JX 315, so the terms are [사람][이].

versiontextplain lineline with trailing space or # memo
main, 10.5.2, 9.12.3KoreanAnalyzer: 사람이나[사람][사람][이]
maintagger: 사람이나사람/NNG 이나/JX사람/NNG 이/VCP 나/EC
9.12.3KoreanAnalyzer: 학교가방을 샀다[학교][가방][사][학교][방][사] (가방 lost)
9.12.3tagger: 학교이이/VCP이/J
main, 10.5.2tagger: 학교이 / 학교가이/JKS / 가/VV이/VCP / 가/JKS
main + the fixevery rowthe plain line's outputthe same as the plain line

(tagger = KoreanTokenizer with DecompoundMode.NONE.) On 10.x and main a separate bug, LFV-658, leaves the three constants stale for the dictionary that 10.0 switched to, which is why the plain line's 학교가 reads as 가/VV ("go"). The claim is invariance: the same noun must get the same id however its line is written.

Exposure and likelihood282 words
  • Who is exposed. Applications that load a nori user dictionary: the KoreanTokenizer constructors that take a UserDictionary (KoreanTokenizer.java:104-106, :121-123, :154-159), KoreanAnalyzer(userDict, ...) (KoreanAnalyzer.java:59), or the korean factory's userDictionary file (KoreanTokenizerFactory.java:44-57, :111-123). The classes are @lucene.experimental. Without a user dictionary nothing changes.
  • What it takes. A noun line that ends with whitespace, or with a # comment after a space. Both are easy to produce in a hand-edited file. A comment written right after the noun (사람# memo) is not affected.
  • When it shows. The text must then continue the noun with something whose reading depends on the context. With correct context ids (9.12.3, or main with LFV-658's fix) an independent reproducer saw 0 of 336 well-formed noun + matching-particle sentences change, but continuations such as 이나, 다, 지, 나, 과 and noun + noun compounds such as 학교가방 do: 67 of 1,420 probe sentences on main with LFV-658's fix, 113 of 1,420 on stock main, which mixes in LFV-658. The finder's sweep of 16 nouns x 21 particles (plain line vs # memo) found 34 differences on 9.12.3 and 61 on main (reported by the finder; the raw output was not kept and the sweep was not re-run).
  • Effect. Extra terms ([사람][이]), lost terms (가방 on 9.12.3) or another part of speech for the morpheme after the noun, at index and at query time alike. No exception, with assertions on or off.
  • Scale. No threshold: one dictionary line is enough, and it applies to every occurrence of that noun. A fix changes the terms of fields analysed with an affected line, so those fields need a reindex.

Minimal repro

The test shows that a nori user dictionary line 사람 (trailing space) changes how KoreanAnalyzer analyses the text after the noun, compared with the line 사람, because UserDictionary takes the noun's right context id from the last character of the line.

TestUserDictionaryTrailingSpace.java (JUnit test)53 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.ko;

import java.io.StringReader;
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.ko.dict.UserDictionary;
import org.apache.lucene.analysis.tokenattributes.CharTermAttribute;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestUserDictionaryTrailingSpace extends LuceneTestCase {

  private static List<String> terms(String userDictLine, String text) throws Exception {
    List<String> terms = new ArrayList<>();
    try (Analyzer analyzer =
            new KoreanAnalyzer(
                UserDictionary.open(new StringReader(userDictLine)),
                KoreanTokenizer.DEFAULT_DECOMPOUND,
                KoreanPartOfSpeechStopFilter.DEFAULT_STOP_TAGS,
                false);
        TokenStream ts = analyzer.tokenStream("f", text)) {
      CharTermAttribute term = ts.addAttribute(CharTermAttribute.class);
      ts.reset();
      while (ts.incrementToken()) {
        terms.add(term.toString());
      }
      ts.end();
    }
    return terms;
  }

  public void testTrailingSpaceInUserDictionaryLine() throws Exception {
    // expected line "사람 " to give the terms of line "사람" ([사람]); actual [사람, 이]
    assertEquals(terms("사람", "사람이나"), terms("사람 ", "사람이나"));
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-654/, 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/nori/build/libs/lucene-analysis-nori-$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.ko.TestUserDictionaryTrailingSpace
# the module(s) analysis-nori also need their third-party runtime jars
Expected vs actual
Expected the same terms for "사람이나" with both lines ([사람]); actual expected:<[사람]> but was:<[사람, 이]> (an extra term), with -ea and -da.

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

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:<[사람]> 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.analysis.ko.TestUserDictionaryTrailingSpace.testTrailingSpaceInUserDictionaryLine(TestUserDictionaryTrailingSpace.java:51)

Why it looks unintended

  • The constants' comments tie the id to the noun: "NNG right with hangul and a coda on the last char" (UserDictionary.java:42-45).
  • The same method treats trailing whitespace as a separator: 사람 and 사람 get the same noun and FST key; only the id differs.
  • LUCENE-8603 (7.7.0) fixed wrong user-noun right ids as a bug; no test pins the line-based choice.
The full argument266 words
  • The contract. RIGHT_ID_T is the "NNG right with hangul and a coda on the last char", RIGHT_ID_F "... and no coda on the last char" (UserDictionary.java:42-45). The coda belongs to the noun's last syllable, and the noun is token; the class javadoc speaks of "adding custom nouns (세종) or compounds (세종시 세종 시)" (:32-35).
  • The parser's own rules. Whitespace is a separator: split("\\s+") gives the same surface and the same (absent) segmentation for 사람 and 사람 (:89-90), and the FST key is the same (:125-131). The implementation removes # and everything after it anywhere on a line (// Remove comments above replaceAll("#.*$", ""), :57-58), although the line grammar is not documented; the space before # survives only because the line is not trimmed.
  • Tests. userdict.txt and TestKoreanTokenizerFactory.java:72 use only whole-line comments and complete segmentations, and no test pins a user entry's right-id value or checks that it is the same however the line is written (TestKoreanTokenizer.testDuplicate only checks getRightId(3) != 0 to count deduplicated entries, TestKoreanTokenizer.java:563-577). With the fix, all 85 upstream nori tests that ran pass.
  • History. The line is in nori's first commit (LUCENE-8231, e851b89cbeb1); neither #9278 (62 comments) nor any later commit explains reading the line's end. LUCENE-8603 (7.7.0, listed under "Bug fixes": "Fix the inversion of right ids for additional nouns in the Korean user dictionary", lucene/CHANGES.txt:6910-6911) fixed the values of the same three constants.
  • Design evidence only. An independent C++ port of nori strips comments and whitespace and takes the coda from the surface term.
The devil's-advocate check214 words

The strongest case for "not a bug" (the intended-behaviour checker, the cross-model intended lens and the adjudication): Lucene documents no line grammar for the nori user dictionary (open has no javadoc; the factory says only "User dictionary path"); the classes are @lucene.experimental; first-party tests use only whole-line comments; kuromoji accepts # comments only at line start since LUCENE-8596 (CHANGES.txt:4839-4841), so an inline comment may count as unsupported; and kuromoji rejects segmentations that do not add up to the surface (LUCENE-8933, #9976).

Why it fails: it does not cover plain trailing whitespace, which nori's own parser already treats as a separator, and nori strips # comments anywhere on the line (it never adopted LUCENE-8596's line-start rule). The constants' comments tie the id to the noun, and LUCENE-8603 treated wrong user-noun ids as a bug. The case is strong for the shorter-segmentation trigger (세종시 세종): the documented example is a complete segmentation, kuromoji requires the parts to add up to the surface, and the line is plausibly malformed input that nori accepts only because it does not validate it. Verdicts: devil's advocate real (medium-high for whitespace and comments, low for the segmentation); cross-model intended lens real (high), its intended argument "moderate" and failing for bare trailing whitespace; adjudication confirmed (high).

Suggested fix

Recommended Read the character from the noun, and reject an empty noun

In UserDictionary's constructor, read the coda from the noun (token) instead of the whole line, after rejecting an empty noun (+7/-1). Validated: the new test passes, as do the 85 upstream nori tests that ran and :lucene:analysis:nori:check. Ship it with the fix for the stale context ids (LFV-658); affected fields need a reindex.

@@ -91,7 +91,13 @@ public final class UserDictionary
       if (token.equals(lastToken)) {
         continue;
       }
-      char lastChar = entry.charAt(entry.length() - 1);
+      if (token.isEmpty()) {
+        throw new IllegalArgumentException(
+            "Illegal user dictionary entry " + entry + " - the surface form is empty");
+      }
+      // the right context depends on the last syllable of the surface form, not on whatever
+      // ends the line (trailing whitespace, or the space left before a removed comment)
+      char lastChar = token.charAt(token.length() - 1);
All options and trade-offs5 options · 737 words

One fix was built and validated with gradle in a scratch worktree at a9779ef3c193; it is the finder's one-line change plus the guard that an independent reviewer showed it needs. The alternatives are for discussion.

Read the character from the noun, and reject an empty noun (validated by the checks) (recommended)

Take lastChar from token, the surface form, instead of the cleaned line (+7/-1 in UserDictionary.java). The guard matters: a line that starts with whitespace ( 학교) gives token = "". Stock main rejects that line later with IllegalArgumentException: ... the segmentation is bigger than the surface form (); without the guard token.charAt(-1) would throw StringIndexOutOfBoundsException first. With the guard it stays an IllegalArgumentException, with a clearer message. Moving the right-id block below the segmentation check instead keeps the old message; that variant is the one the diagram's fix sketch shows, and it was also executed (every 사람 line form gives 3535 and [사람]; 사람 still throws IllegalArgumentException). It also fixes the secondary trigger (세종시 세종 now gets 시's id).

@@ -91,7 +91,13 @@ public final class UserDictionary
       if (token.equals(lastToken)) {
         continue;
       }
-      char lastChar = entry.charAt(entry.length() - 1);
+      if (token.isEmpty()) {
+        throw new IllegalArgumentException(
+            "Illegal user dictionary entry " + entry + " - the surface form is empty");
+      }
+      // the right context depends on the last syllable of the surface form, not on whatever
+      // ends the line (trailing whitespace, or the space left before a removed comment)
+      char lastChar = token.charAt(token.length() - 1);

Trade-offs

  • On 10.x and main, until LFV-658 is fixed, lines with trailing whitespace move from the generic id to the stale coda ids, so their analysis changes, and not always for the better: after this fix 학교가 reads as 가/VV with every line form. Ship both fixes together; with both, the independent reproducer's sweeps show 0 differences.
  • Fields analysed with an affected line need a reindex; worth a CHANGES line.
  • Cost: none to speak of (one charAt at dictionary load; not benchmarked).

What the checks validated

  • Gradle, JDK 25, scratch worktree: the new test fails 3 of 5 on stock (assertions on and off) and passes 5 of 5 with the fix; TestUserDictionary, TestKoreanTokenizer, TestKoreanAnalyzer, TestKoreanTokenizerFactory and the new test pass (39 tests, 2 nightly skipped); the whole module passes, 14 suites and 92 tests (87 upstream + 5 new; 4 skipped: 3 nightly and the @Ignored TestKoreanNumberFilter.testLargeData), with assertions on and off; the nightly run of TestKoreanTokenizer, TestKoreanAnalyzer and the new test passes 29 of 29; :lucene:analysis:nori:check passes (checkGoogleJavaFormat, ecjLint, forbiddenApis, tests); tidy changes nothing.
  • Standalone runner with the fix: the new test 5 of 5 (assertions on and off), the finder's 2 of 2 (it includes 세종시 세종), the independent reproducer's 7 of 7; every token-dump row gives the plain line's output for every line form.
  • Earlier: the one-line change without the guard passes the version-portable test on 7.4.0 and main (6 of 6).

Strip each line in open() (partly validated) (validated by the checks)

line = line.replaceAll("#.*$", "").strip(); in UserDictionary.open.

Trade-offs

Fixes trailing whitespace and the inline comment, not the shorter segmentation; changes another behaviour (leading whitespace accepted instead of rejected); strip() also removes Unicode spaces such as U+3000 that the \s+ split does not treat as separators. Can be combined with the recommended fix.

What the checks validated

Standalone runner: the new test's three bug tests pass, but the control testLeadingWhitespaceIsRejected fails (4 of 5), because 학교 is now accepted.

The bare one-liner (not recommended)

char lastChar = token.charAt(token.length() - 1); with no guard (the finder's first patch).

Trade-offs

Correct for every well-formed line (its own check and 32 upstream nori tests pass), but turns the leading-whitespace IllegalArgumentException into a StringIndexOutOfBoundsException (executed separately).

Validate that segmentations add up to the noun (separate issue)

As kuromoji does since LUCENE-8933.

Trade-offs

Would reject 세종시 세종 outright; it changes accepted input and touches the area of LFV-603 (part offsets), so keep it out of this fix. Not run.

Workaround until a fix ships

Keep noun lines free of trailing whitespace and put comments on their own line, or right after the noun with no space (사람# memo behaves like 사람, executed). A loader that strips each line removes the trailing-space form but not 사람 # memo unless it also removes comments.

Downstream note

Products that pass user-dictionary lines to nori unchanged inherit this, and after a fix, fields indexed with an affected line keep their old terms until they are reindexed.

How it was found

Semi-formal. While building an exhaustive minimum-cost reference for the CJK tokenizers' Viterbi lattice, the finder read UserDictionary.java:94, suspected that the id comes from the line, and confirmed it with a differential probe of 336 noun + particle inputs, plain line vs # memo (the finder reports 34 differences on 9.12.3 and 61 on main; not re-run).

The method in full182 words

Method: differential probe of the stock jar (the hunt's "semi-formal" tier), during a hunt unit that checked the CJK tokenizers' Viterbi output against an exhaustive minimum-cost path over the same dictionary data (kuromoji first, then nori), 2026-10-27, main a9779ef3c193.

  • While modelling nori's user-dictionary costs for that reference, the finder read UserDictionary.java:94 and saw that the right id comes from the cleaned line, not from the noun.
  • A differential probe, NoriWsSweep, analysed 16 user nouns x 21 particles = 336 inputs, each with the plain line and with the same line plus # memo: the finder reports 34 differences on 9.12.3 and 61 on main (the raw output was not kept; not re-run).
  • The finder then wrote a public-API JUnit (2 of 2 fail on main, 10.5.2, 9.12.3 and 9.0.0) and the one-line fix (later given an empty-noun guard). No solver or model checker produced the signal.
  • Independent AI tools reproduced and judged it (see the checks). The same unit also found a separate root cause in the same class, the stale context ids (LFV-658), filed as its own bug.

Prior reports, history and checks

Prior reports

No prior report: 133 distinct queries over Lucene issues, PRs and commits, the mailing lists, JIRA, downstream trackers and the web (4 in Korean), plus the files of all 408 open PRs (2026-10-27); re-checked the same day. Related only: kuromoji's # handling (#9642) and the constants' values (#9649).

Searches5 searches

No issue, PR, commit, JIRA entry, mailing-list thread or downstream report describes a user noun's right id taken from the line's end, or trailing whitespace or inline comments changing nori's analysis. Searches are lexical, Korean-language forums are weakly covered and private trackers are not visible, so this is "no report found".

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene)2026-10-27a scripted prior-report search (three runs) and 26 manual searches: UserDictionary, rightIds, RIGHT_ID_T, hasCoda, lastChar, coda / 받침 / 종성, right id, right context, user dictionary comment, trailing whitespace, particle, segmentation, 세종시; git log -S/-G and file historyno report; #9522 (LUCENE-8476) quotes the lastChar line in an unrelated cleanup and nobody remarks on it; #9578 (LUCENE-8532, open) is a trailing space in the analysed text, not in a dictionary line
Related items (read)2026-10-27#9642 / LUCENE-8596 and #8686 / LUCENE-7635, #9649 / LUCENE-8603, #9278 / LUCENE-8231 (62 comments), #13426 / #13427, #9976 / LUCENE-8933different defects: kuromoji # handling; the constants' values; duplicate entries; kuromoji segmentation validation
Open PRs2026-10-27the changed files of all 408 open apache/lucene PRs#14969 (stale) edits the same loop but keeps line 94; #16272 touches FST loading; neither fixes it
JIRA archive, mailing lists, downstream and the web2026-10-2713 JIRA queries (6 issues read in full); dev@, java-user@, issues@ and commits@ (35 queries); Elasticsearch, OpenSearch and Solr trackers; 13 web queries, 4 in Koreanno report; a downstream forum thread about a shorter segmentation belongs to LFV-603
Re-check (GitHub, read-only)2026-10-27four issue searches (nori user dictionary trailing, UserDictionary lastChar, nori userdict comment, nori right id coda) and the file on upstream mainnothing new; upstream main a95c754ee003 still reads the line's end at line 94

History

Present since nori was added: LUCENE-8231 (e851b89cbeb1, 2018-04-13), first released in 7.4.0. The version-portable test fails on all 67 nori releases from 7.4.0 to 10.5.2 and on main, and passes with the fix; line 94 is unchanged on upstream main.

Versions tested7 rows

Introduced by LUCENE-8231 "Add a new analysis module (nori) similar to Kuromoji but to handle Korean" (jimczi, 2018-04-13; JIRA patch, no PR; the issue is now #9278): e851b89cbeb1 on master and its same-day branch_7x copy 8651fbaa8bc6, which shipped in 7.4.0. Not a regression. git blame -w on main assigns line 94 (lastChar), line 58 (comment removal) and line 90 (token) to it; git log -G'entry\.charAt\(entry\.length\(\) ?- ?1\)' finds only it and the whitespace-only reformat 7f8b7ffbcad (LUCENE-9570); a git log -S with the current, spaced string finds only the reformat, because the original line had no spaces around -. LUCENE-8603 (53b05c5e7e8, 7.7.0) changed only the constants' values; later refactors did not touch the character choice.

By version. In 7.4.0 to 7.6.0 the constants were inverted and the generic id equalled the coda id, so only nouns without a coda (학교) changed. From 7.7.0 both kinds do, with the same split (3534 or 3535 vs 3533) up to main. From 10.0.0 the stale constants of LFV-658 change which line form reads plausibly.

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affectedthis doc's test 3 of 5 fail (gradle and the standalone runner, assertions on and off); the finder's 2 of 2; the independent reproducer's 5 of 7
10.0.0 to 10.5.2 (12 releases) affectedversion-portable test 4 of 6 fail; this doc's test 3 of 5 on 10.5.2 and 10.0.0; 사람이나 gives [사람] vs [사람][이]
7.7.0 to 9.12.3 (52 releases) affectedversion-portable test 4 of 6 fail; on 9.12.3 사람이나 gives [사람] vs [사람][이] and 학교가방을 샀다 loses 가방
7.4.0 to 7.6.0 (3 releases) no-coda nouns onlyversion-portable test 4 of 6 fail; the inverted constants (fixed by LUCENE-8603) made the generic id equal the coda id
7.3.1 no norino nori module
main + fix, 7.4.0 + fix not affectedmain: every repro passes, and the nori module's 92 tests and check pass; 7.4.0: the version-portable test passes 6 of 6
upstream main a95c754ee003 readline 94 unchanged (read 2026-10-27); also on branch_10x and branch_10_6

Independent checks

An independent reproducer, a devil's advocate, two executed cross-model reviews (OpenAI GPT-5.6, refute and intended lenses) and an adjudication all found it real with their own tests; the adjudication re-ran them (5 of 7 fail on main and 9.12.3, 7 of 7 pass with line 94 fixed). An audit and this doc's runs re-ran it on main.

Checks and verdicts365 words

Every verifier wrote or ran its own tests against the stock jars. The cross-model reviews ran in executed mode on the previous main ea57360912ed (nori unchanged since).

Each independent check and its verdict
CheckVerdictWhat it found
finder candidatePublic-API JUnit: 2 of 2 fail on main (assertions on and off), 10.5.2, 9.12.3 and 9.0.0; pass with the one-line fix; 32 upstream nori tests pass with it.
cross-model review, refute lens real (high)An independent reviewer from another model family (OpenAI GPT-5.6, executed): its own test fails on main (a spaced comment changes 바다이 from 이/JKS to 이/VCP), its controls pass, and it passes with the fix; counter-argument (no documented line grammar, experimental API) rated moderate.
Independent reproducer realOwn same-package and public-API tests, written from the claim only: 5 of 7 fail on main, 10.5.2 and 9.12.3 (each with assertions on and off) and 9.0.0; 7 of 7 pass with only line 94 changed; still 5 of 7 with only LFV-658's fix; 0 differences in its sweeps with both fixes. Corrected the claim: with correct context ids (9.12.3, or main with LFV-658's fix), well-formed noun + matching-particle sentences are unaffected (0 of 336).
Cross-model review, intended lens real (high)Same reviewer family, executed: its own test fails 3 of 3 on main, 10.5.2 and 9.12.3 (bare trailing whitespace, spaced comment, shorter segmentation) and passes with the fix; a separate run showed the bare fix throws StringIndexOutOfBoundsException for 학교.
Devil's advocate real; confidence by triggerMedium-high for trailing whitespace and the inline comment, low for the shorter segmentation (LUCENE-8933's rule makes 세종시 세종 arguably invalid); its default-KoreanAnalyzer probe differs on 9.12.3 and main and agrees with both fixes.
adjudication confirmed (high)Re-ran the independent reproduction's tests: 5 of 7 fail on main (assertions on and off) and 9.12.3; 7 of 7 pass with line 94 shadowed. Duplicate of none; not security-sensitive.
Follow-up, audit and this doc keepThe follow-up ran 67 releases plus main; the credibility audit and the registration check re-ran the finder's and the independent reproduction's tests on a9779ef3c193; this doc's test and fix were run through gradle and the standalone runner.

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

Finding record

LFV-654, snapshot of 2026-11-06

Registry title
Nori UserDictionary picks a user noun's right context id (last syllable with a final consonant, without one, or not Hangul) from the last character of the whole entry line instead of the noun, so a trailing space or an inline comment ('학교 # memo') changes how the word after the noun is analysed
Area
analysis / nori UserDictionary (right context id of user nouns, used by the Viterbi connection costs)
Priority
Low, rank 308: Needs a nori user-dictionary line with trailing whitespace or an inline comment plus a context-dependent continuation.
Impact class
silent wrong results
Impact note
Hits applications that give KoreanTokenizer, KoreanAnalyzer or KoreanTokenizerFactory a user dictionary (opt-in) in which a noun line ends with whitespace or carries an inline '#' comment after a space (secondary: a segmentation whose last part ends in another syllable than the noun, such as '세종시 세종'): after every occurrence of that noun the tokenizer scores the next morpheme with the wrong right context, so for some continuations (이, 가, 과, 와, 이나, noun+noun compounds) the next morpheme gets another part of speech and KoreanAnalyzer's default part-of-speech stop filter indexes a spurious term or loses or changes a real one (9.12.3: '학교가방을 샀다' loses the term 가방); ordinary text, no scale, deterministic with -ea and -da, no exception, the user noun itself is still emitted, and with correct context ids well-formed noun+matching-particle sentences were unaffected in the independent reproduction's probes; every release 7.4.0 to 10.6.0 (10.6.0 by reading) and main.
Summary
You load a nori user dictionary whose line for the noun 학교 ('school') ends with a space, or carries an inline comment as in '학교 # memo' (the parser deletes '#' to the end of the line but keeps the space before it), and expect the same analysis as with the plain line '학교', because the parser splits the line on whitespace and gets the same noun with no segmentation. Instead UserDictionary.java:94 takes the noun's right context id, which tells the tokenizer whether the noun's last syllable ends in a consonant, from the last character of the whole line, here the space, so the noun gets the generic id and the word after it is analysed differently: on 9.12.3 '학교이' gives 이/VCP with '학교' but 이/J with '학교 ', and KoreanAnalyzer turns '학교가방을 샀다' into [학교][가방][사] with '학교' but [학교][방][사] with '학교 ', losing the term 가방 ('bag'). The two line forms disagree in every release from 7.4.0 to 10.6.0 (10.6.0 by reading) and on main (main: '학교이' gives 이/JKS vs 이/VCP, '사람이나' gives [사람] vs [사람][이]), and a shorter segmentation such as '세종시 세종' takes the id from 종 (final consonant) instead of 시 (none) in the same way.
Introduced
e851b89cbeb1f55edc0f2c1276e2ae812eca2643 (LUCENE-8231 'Add a new analysis module (nori) similar to Kuromoji but to handle Korean', by jimczi; JIRA patch workflow, no pull request; the issue is now apache/lucene#9278) and its same-day branch_7x copy 8651fbaa8bc65fdfec397ad4e7ebe2b5acf78d7b, which shipped in 7.4.0 [read: git blame -w on main assigns UserDictionary.java:94 (lastChar from the entry line), :58 (comment removal that keeps the whitespace before '#') and :90 (surface token) to e851b89cbeb; git log -S/-G on the line returns only e851b89cbeb and the whitespace-only reformat 7f8b7ffbcad (LUCENE-9570); LUCENE-8603 (53b05c5e7e8 / 0d6d12a0ebb, 7.7.0) changed only the values of the three constants; LUCENE-8476, LUCENE-8954, LUCENE-10393 and #13427 refactored the class without touching the character choice; exec: the one-line fix turns the version-portable repro from 4 of 6 failing to 6 of 6 passing on 7.4.0, as on main]. Not a regression: the defect is present from the module's first commit (2018-04-13)
First affected version
7.4.0 [exec] (nori's first release, tagged 2018-06-22); 7.3.1 and earlier have no nori module [exec: no artifact on Maven Central, no nori files at the tag]
Affected versions
Every nori release from 7.4.0 through 10.5.2 (latest 9.x 9.12.3), 10.6.0 (by reading) and main a9779ef3c193; never fixed. [exec] the follow-up's version-portable JUnit (4-argument KoreanTokenizer constructor, right id read through reflection) fails 4 of 6 on all 67 nori releases on Maven Central (7.4.0-7.7.3, 8.0.0-8.11.4, 9.0.0-9.12.3, 10.0.0-10.5.2) and on main, also with -da on 7.4.0, 9.12.3, 10.5.2 and main, while its controls pass; the finder's JUnit fails 2 of 2 on main (-ea and -da), 10.5.2, 10.0.0, 9.12.3, 9.0.0, 8.11.4 and 8.2.0 (it does not compile on 8.1.1: its 5-argument constructor came with LUCENE-8784 in 8.2.0); the independent reproduction's tests fail 5 of 7 on main, 10.5.2 and 9.12.3 (each -ea and -da) and 9.0.0. Which line form reads wrong depends on the version: in 7.4.0-7.6.0 (inverted constants, fixed by LUCENE-8603) the generic id equals the coda id, so only vowel-final nouns (학교, 나무, 사과) are affected; from 7.7.0 nouns with and without a final consonant are affected; from 10.0.0 the stale context ids of the separate candidate nori-userdict-context-ids-stale-after-dictionary-upgrade make the plain line the one that reads wrong for some inputs ('학교이'), but the right-id split itself (3534 or 3535 vs 3533) is identical from 7.7.0 to main. [read] UserDictionary.java:94 is unchanged on upstream main a95c754ee003, branch_10x and branch_10_6 (prior-art check, 2026-10-27); the 7 commits ea57360912ed..a9779ef3c193 do not touch lucene/analysis/nori. All runs on JDK 25, aarch64
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that agreed, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
semi-formal: code reading
Upstream and prior reports
none found (2026-10-27 plus the finder's run, read-only; the prior-report record, verdict new): 133 distinct queries (215 logged) through scripted prior-report searches and 26 manual GitHub searches over apache/lucene issues and PRs (56), commits (6), downstream repositories (4), the dev@, java-user@, issues@ and commits@ lists (35), the JIRA archive (13, plus 6 issues read in full) and the web (13, 4 in Korean); all 408 open apache/lucene PRs checked by changed files; file history, branch_10x and branch_10_6 checked. Related, not this bug: #9642 / LUCENE-8596 and #8686 / LUCENE-7635 (kuromoji '#' comment handling; kuromoji now treats '#' as a comment only at line start, nori still strips '#.*$' anywhere), #9649 / LUCENE-8603 (inverted values of the same three constants, fixed in 7.7.0; shows that wrong user-noun right ids were treated as a bug), #9522 / LUCENE-8476 (quotes the lastChar line in an unrelated cleanup), #9578 / LUCENE-8532 (open: a trailing space in the analysed text, not in a dictionary line), #13426 / #13427 (duplicate entries), #9976 / LUCENE-8933 (kuromoji segmentation-length validation; relevant only to the secondary '세종시 세종' trigger); open PRs #14969 (stale; edits the same loop but keeps entry.charAt(entry.length() - 1)) and #16272 (FST loading) touch the file and neither fixes line 94. Gaps: lexical search only, throttled GitHub search quota, weak coverage of Korean forums and blogs, private trackers not visible
Lucene commit tested
main a9779ef3c193 (commit of 2026-10-26; tested 2026-10-27) through gradle in a scratch worktree and through the standalone JUnit runner, with assertions on and off; the release jars 10.5.2 and 10.0.0 with the same test, and 9.12.3 and 10.5.2 with a token-dump probe; the follow-up's version-portable test on all 67 nori releases (7.4.0 to 10.5.2) and main. The fix was validated in the scratch worktree: the new test; the nori module's upstream tests with assertions on and off (83 ran; 3 nightly tests and 1 @Ignored test were skipped); a nightly run of TestKoreanTokenizer and TestKoreanAnalyzer (85 distinct upstream tests in all); and :lucene:analysis:nori:check. Upstream main a95c754ee003 was read on 2026-10-27: line 94 unchanged.

Lucene and upstream sources

Finding record
  • lucene/analysis/nori/src/java/org/apache/lucene/analysis/ko/dict/UserDictionary.java:32-35 (at a9779ef3c193) (class javadoc: nouns (세종) or compounds (세종시 세종 시)), :40-45 (RIGHT_ID 3533 'NNG right', RIGHT_ID_T 3535 'with hangul and a coda on the last char', RIGHT_ID_F 3534 'no coda on the last char'), :56-64 (comment removal line.replaceAll("#.*$", "") keeps the text before '#', blank lines skipped, lines not trimmed), :88-90 (splits = entry.split("\\s+"); token = splits[0]), :94-103 (lastChar = entry.charAt(entry.length() - 1) picks RIGHT_ID_T, RIGHT_ID_F or RIGHT_ID), :105-121 (segmentation lengths; only a segmentation longer than the surface is rejected)
  • lucene/analysis/nori/src/java/org/apache/lucene/analysis/ko/dict/UserMorphData.java:42-45 (at a9779ef3c193) (getRightId returns rightIds[morphId])
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/morph/Viterbi.java:471, (at a9779ef3c193) :484 (connection cost costs.get(lastRightID, leftID) of the next morpheme) and :534 (morphData.getRightId(wordID) stored for the node)
  • lucene/analysis/nori/src/java/org/apache/lucene/analysis/ko/KoreanTokenizer.java:52, (at a9779ef3c193) KoreanTokenizerFactory.java:64, KoreanAnalyzer.java:33 (@lucene.experimental); lucene/analysis/nori/src/test/org/apache/lucene/analysis/ko/TestKoreanTokenizerFactory.java:72 and userdict.txt:1 (first-party '#' comment lines)
  • lucene/CHANGES.txt:6910 (at a9779ef3c193) (LUCENE-8603 'Fix the inversion of right ids for additional nouns in the Korean user dictionary', a bug fix)
The finding in 30 seconds
  • lucene/analysis/nori/src/java/org/apache/lucene/analysis/ko/dict/UserDictionary.java:32-35, 40-45, 56-64, 89-90, 94-103, 114-121, 125-131
  • lucene/analysis/nori/src/java/org/apache/lucene/analysis/ko/dict/UserMorphData.java:42-45
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/morph/Viterbi.java:469-472, 530-539
  • lucene/analysis/nori/src/java/org/apache/lucene/analysis/ko/KoreanPartOfSpeechStopFilter.java:36-68
Who is affected
  • lucene/analysis/nori/src/java/org/apache/lucene/analysis/ko/KoreanTokenizer.java:52, 104-106, 121-123, 154-159; KoreanAnalyzer.java:33, 59; KoreanTokenizerFactory.java:44-57, 64, 111-123
Why it looks unintended
  • lucene/analysis/nori/src/java/org/apache/lucene/analysis/ko/dict/UserDictionary.java:32-35, 40-45, 57-58, 89-90, 125-131
  • lucene/analysis/nori/src/test/org/apache/lucene/analysis/ko/TestKoreanTokenizerFactory.java:72; userdict.txt:1-10
  • lucene/analysis/nori/src/test/org/apache/lucene/analysis/ko/TestKoreanTokenizer.java:563-577 (testDuplicate: getRightId(3) != 0, only to count deduplicated entries)
  • lucene/CHANGES.txt:4839-4841 (LUCENE-8596), 6910-6911 (LUCENE-8603)

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. Every right id, token, tag, cost and term on screen comes from executed probes (main with assertions on and off, main with the fix, main with the stale-ids fix, 9.12.3); every numbered code line is a line of the code at a9779ef3c193, except the fix sketch's added line, marked "+". The fix sketch shows the variant that moves the right-id block below the segmentation check (executed, same results as the recommended fix for every 사람 line form).

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.