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.
사람 # 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.- Give
KoreanAnalyzera user dictionary whose line for 사람 ("person") ends with a space,사람, or carries a comment,사람 # memo. Nori deletes# memobut keeps the space before it. - 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). - 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.
- That id scores the next word. On main, from 3535 the particle
이나/JXis cheapest (cost -10 vs 321 for이/VCP 나/EC); from 3533이/VCP 나/ECwins (184 vs 315), so[사람][이]is indexed. On 9.12.3,학교가방을 샀다("bought a school bag") with the line학교loses the term 가방 ("bag"). - 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.
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.
- Nori user dictionary: a text file with one custom noun per line, optionally followed by its parts (
세종시 세종 시);#starts a comment. It is loaded withUserDictionary.open(Reader)and passed toKoreanTokenizer,KoreanAnalyzeror thekoreanfactory'suserDictionaryfile. - 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:
KoreanAnalyzerdrops tokens whose tag is inKoreanPartOfSpeechStopFilter.DEFAULT_STOP_TAGS(:36-68): endings such asECand particles such asJX; the copulaVCPis kept.
UserDictionary.openremoves comments withline.replaceAll("#.*$", "")and keeps the line untrimmed (UserDictionary.java:56-64):사람 # memobecomes사람.- The constructor takes the noun from
entry.split("\\s+")[0](:89-90). Java'ssplitdrops trailing empty strings, so사람,사람and사람\tgive the same noun, no segmentation and the same FST key (:125-131). - 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. - 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). - 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).
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 [사람][이].
| version | text | plain line | line with trailing space or # memo |
|---|---|---|---|
| main, 10.5.2, 9.12.3 | KoreanAnalyzer: 사람이나 | [사람] | [사람][이] |
| main | tagger: 사람이나 | 사람/NNG 이나/JX | 사람/NNG 이/VCP 나/EC |
| 9.12.3 | KoreanAnalyzer: 학교가방을 샀다 | [학교][가방][사] | [학교][방][사] (가방 lost) |
| 9.12.3 | tagger: 학교이 | 이/VCP | 이/J |
| main, 10.5.2 | tagger: 학교이 / 학교가 | 이/JKS / 가/VV | 이/VCP / 가/JKS |
| main + the fix | every row | the plain line's output | the 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
KoreanTokenizerconstructors that take aUserDictionary(KoreanTokenizer.java:104-106,:121-123,:154-159),KoreanAnalyzer(userDict, ...)(KoreanAnalyzer.java:59), or thekoreanfactory'suserDictionaryfile (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.
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.
/*
* 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("사람 ", "사람이나"));
}
}
# 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-eaand-da.
More files, environment, full output35 words
| lucene | Lucene main e1faeba08bde (2026-11-02) |
|---|---|
| jdk | 25.0.3+9-LTS |
# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
=> java.lang.AssertionError: expected:<[사람]> 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)- 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_Tis 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 istoken; 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 commentsabovereplaceAll("#.*$", ""),:57-58), although the line grammar is not documented; the space before#survives only because the line is not trimmed. - Tests.
userdict.txtandTestKoreanTokenizerFactory.java:72use 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.testDuplicateonly checksgetRightId(3) != 0to 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).
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.
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
charAtat 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,TestKoreanTokenizerFactoryand 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@IgnoredTestKoreanNumberFilter.testLargeData), with assertions on and off; the nightly run ofTestKoreanTokenizer,TestKoreanAnalyzerand the new test passes 29 of 29;:lucene:analysis:nori:checkpasses (checkGoogleJavaFormat, ecjLint, forbiddenApis, tests);tidychanges 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).
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.
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).
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.
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.
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.
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:94and 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.
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".
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene) | 2026-10-27 | a 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 history | no 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-8933 | different defects: kuromoji # handling; the constants' values; duplicate entries; kuromoji segmentation validation |
| Open PRs | 2026-10-27 | the 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 web | 2026-10-27 | 13 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 Korean | no report; a downstream forum thread about a shorter segmentation belongs to LFV-603 |
| Re-check (GitHub, read-only) | 2026-10-27 | four issue searches (nori user dictionary trailing, UserDictionary lastChar, nori userdict comment, nori right id coda) and the file on upstream main | nothing new; upstream main a95c754ee003 still reads the line's end at line 94 |
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.
| Version or commit | Verdict | Result |
|---|---|---|
main a9779ef3c193 | affected | this 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) | affected | version-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) | affected | version-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 only | version-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 nori | no nori module |
| main + fix, 7.4.0 + fix | not affected | main: 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 | read | line 94 unchanged (read 2026-10-27); also on branch_10x and branch_10_6 |
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).
| Check | Verdict | What it found |
|---|---|---|
| finder | candidate | Public-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 | real | Own 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 trigger | Medium-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 | keep | The 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. |
The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.
- PNG 460 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,592 words
- 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 ofTestKoreanTokenizerandTestKoreanAnalyzer(85 distinct upstream tests in all); and:lucene:analysis:nori:check. Upstream maina95c754ee003was read on 2026-10-27: line 94 unchanged.
- 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-131lucene/analysis/nori/src/java/org/apache/lucene/analysis/ko/dict/UserMorphData.java:42-45lucene/analysis/common/src/java/org/apache/lucene/analysis/morph/Viterbi.java:469-472, 530-539lucene/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-131lucene/analysis/nori/src/test/org/apache/lucene/analysis/ko/TestKoreanTokenizerFactory.java:72; userdict.txt:1-10lucene/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)
- TestUserDictionaryTrailingSpace.java (JUnit test):
repro/LFV-654/src/org/apache/lucene/analysis/ko/TestUserDictionaryTrailingSpace.java(53 lines)
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).
assets/LFV-654/bug.png(460 KB)
![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.](../assets/LFV-654/bug.png)