Each user noun gets hard-coded context ids that tell the tokenizer how it joins the next morpheme. They are mecab-ko-dic 2.0.3 numbers; since 10.0 nori bundles 2.1.1, where the same numbers name other noun classes and the final-consonant flag is inverted.
KoreanAnalyzer indexes the extra term 가. Select the still to open it at full size. Generated with AI, checked against the code.- Put 학교 ("school") in a nori user dictionary,
UserDictionary.open(new StringReader("학교")), and analyse학교가("the school" + subject particle 가) withKoreanAnalyzer. New words such as 깃허브 behave the same. - You expect
[학교], as without the user entry: after a noun ending in a vowel, 가 is the subject particleJKS, a default stop tag, so only the noun is indexed. - Instead the user entry gets the context ids 1781/3534, numbers from mecab-ko-dic 2.0.3 (the 9.x dictionary). In the 2.1.1 dictionary bundled since 10.0, right id 3534 means "noun ending in a consonant": bundled 학교 has 3533, bundled 학생 ("student") has 3534.
- After a consonant the subject particle would be 이, so the cheapest reading of 가 becomes the verb 가 "go" (cost 2,267 against 3,366 for 가/JKS; from 3533, 가/JKS costs -955).
KoreanAnalyzerindexes[학교, 가];학생이likewise gives 이/VCP and[학생, 이]. - No error. All 12 10.x releases from 10.0.0 to 10.5.2 and main fail the finder's test, and 10.6.0 has the same ids (by reading); 9.x passes it. Many particles (을/를, 은/는, 의, 도) are unaffected, and in every probe the user noun's own term is the same on stock and with the fix.
Applications that give KoreanTokenizer, KoreanAnalyzer or the korean factory any user dictionary on 10.0.0 or later. The subject particle after a user noun is misread in common contexts (150 of 150 nouns in {N}가 있다 and in {N}이 있다), adding terms such as 가 or 이; the noun terms stay correct.
Mechanism, terms and numbers889 words
Nori's user dictionary gives every custom noun a hard-coded left context id and one of three right context ids, which the tokenizer's Viterbi search uses to score the morpheme that follows: RIGHT_ID 3533 "NNG right", RIGHT_ID_T 3535 "... with hangul and a coda on the last char", RIGHT_ID_F 3534 "... no coda" (UserDictionary.java:40-45) and LEFT_ID 1781 "NNG left" (UserMorphData.java:25-26). These are the ids of mecab-ko-dic 2.0.3, the dictionary 9.x ships. LUCENE-10416 (76c9fd4e38a, 2022, first released in 10.0.0) moved nori to mecab-ko-dic-2.1.1-20180720, in which the same numbers name other contexts: 3534 is now the common noun with a final consonant, 3535 a "state change" subclass without one, 1781 the "state change" left class; the plain-noun ids are now 1780 and 3532/3533/3534. So on 10.x the coda flag is inverted: on the usual one-word line, a user noun ending in a vowel is scored as if it ended in a consonant, and the reverse. The particle after it is then misread: 학교가 gives 가/VV ("go") instead of 가/JKS, 학생이 gives 이/VCP (copula) instead of 이/JKS, and KoreanAnalyzer indexes [학교, 가] and [학생, 이]; a new word fares the same (깃허브가 있다 gives [깃허브, 가, 있]). Silent, opt-in (needs a user dictionary), in every 10.x release and main, not in 9.x, whose dictionary matches the ids. Fix: the four 2.1.1 numbers, plus a test that ties them to the bundled dictionary; fields indexed with a user dictionary then need a reindex.
- Nori user dictionary: a text file with one custom noun per line (optionally its parts,
세종시 세종 시), loaded withUserDictionary.open(Reader)and passed toKoreanTokenizer,KoreanAnalyzeror thekoreanfactory'suserDictionaryfile. - Context ids: every dictionary entry has a left id (how it joins what comes before) and a right id (how it joins what comes after). The Viterbi search adds
ConnectionCosts.get(rightId of the previous morpheme, leftId of the next one)from a matrix built from the bundled dictionary (morph/Viterbi.java:459,:469-472,:530-539; end of text:389). - Coda: the final consonant of a Hangul syllable. Korean particles depend on it: the subject particle is 가 after a vowel (학교가) and 이 after a consonant (학생이). mecab-ko-dic marks it as
F(no coda) orT(coda) in its id definitions.
UserDictionarystores a right id per entry, chosen from the last character of the entry's line (for the usual one-word line, the noun's last syllable):RIGHT_ID_T3535 (Hangul with a coda),RIGHT_ID_F3534 (Hangul without), elseRIGHT_ID3533 (UserDictionary.java:94-103; constants and comments:40-45). Every entry hasLEFT_ID1781, cost -100000, and tagNNG(UserMorphData.java:23-26,:61-69).- Those are the ids of mecab-ko-dic 2.0.3. Since 10.0.0 nori is built from mecab-ko-dic-2.1.1-20180720 (
nori/build.gradle:51), whoseleft-id.def/right-id.defassign the numbers differently:
| id | 2.0.3 (9.x) | 2.1.1 (10.0.0 and later) | constant |
|---|---|---|---|
| left 1780 | NNBC (counter noun) | NNG,*,* | (none) |
| left 1781 | NNG,*,* | NNG, state-change class | LEFT_ID |
| right 3532 | NNBC | NNG,*,* | (none) |
| right 3533 | NNG,*,* | NNG, no coda | RIGHT_ID |
| right 3534 | NNG, no coda | NNG, coda | RIGHT_ID_F (meant: no coda) |
| right 3535 | NNG, coda | NNG, state-change class, no coda | RIGHT_ID_T (meant: coda) |
- The running binary agrees: bundled 학교 and 나무 (no coda) are
1780/3533and bundled 학생 and 가방 (coda)1780/3534, but the same nouns as user entries get1781/3534and1781/3535. - Told that 학교 ends in a consonant, the search finds the subject particle 가 expensive and takes the verb 가 "go"; told that 학생 ends in a vowel, it reads 이 as the copula.
KoreanAnalyzer's stop filter dropsJKSbut keepsVVandVCP(KoreanPartOfSpeechStopFilter.java:36-68), so 가 and 이 become index terms.
Cost of the rest of the path when the particle ends the text, conn(R, left) + wordCost + conn(right, end), on main:
| text | from the bundled noun's right id | from the user noun's right id |
|---|---|---|
| 학교가 | 3533: 가/JKS -955 (best), 가/VV+EC 2,267 | 3534: 가/VV+EC 2,267 (best), 가/JKS 3,366 |
| 학생이 | 3534: 이/JKS -984 (best), 이/VCP -110 | 3535: 이/VCP 1,009 (best), 이/JKS 3,691 |
| text (user entry) | tags, without | with the user entry | KoreanAnalyzer terms |
|---|---|---|
| 학교가 (학교) | 가/JKS | 가/VV+EC | [학교] | [학교, 가] |
| 학생이 (학생) | 이/JKS | 이/VCP | [학생] | [학생, 이] |
| 학교가 있다 (학교) | 가/JKS 있/VA | 가/VV+EC 있/VX | [학교, 있] | [학교, 가, 있] |
| 학생이 있다 / 학교가 되었다 | JKS | JKC | unchanged |
| 학생이 중요하다 (학생) | 이/JKS | 이/MM | unchanged |
| 깃허브가 있다 (깃허브, no entry in the bundled dictionary) | 깃/NNG 허브/NNG 가/JKS 있/VA | 깃허브/NNG 가/VV+EC 있/VX | [깃, 허브, 있] | [깃허브, 가, 있] |
| 학교를, 학생을 (object particle) | JKO both ways | unchanged |
| main + the fix | the bundled reading in every row | no extra terms |
Only the right ids cause the observed analysis effects: an independent reproducer's three particle and term tests pass with just the right ids corrected and are unchanged with just LEFT_ID corrected (the noun was at the start of the text in its probes); its separate id test still fails with only the right ids corrected, because LEFT_ID stays 1781. LEFT_ID is stale by the same definitions and belongs in the fix, but no probe isolates its effect with a morpheme before the noun, so that part rests on the definitions. The stale ids still index inside the cost matrix, so nothing throws.
Exposure and likelihood493 words
- Who is exposed. Applications that load a nori user dictionary on 10.0.0 or later: the
KoreanTokenizerconstructors that take aUserDictionary(KoreanTokenizer.java:104-110,:121-136,:154-163),KoreanAnalyzer(userDict, ...)(KoreanAnalyzer.java:59-69), or thekoreanfactory'suserDictionaryfile (KoreanTokenizerFactory.java:44-57,:111-123). Downstream products whose nori plugins expose a user-dictionary setting inherit it. Without a user dictionary nothing changes. The classes are@lucene.experimental. - What it takes. A user noun whose line ends with the noun itself (the usual one-word line,
학교). The id is picked from the last character of the whole line (UserDictionary.java:94), so a line that ends in whitespace or a comment (학교,학교 # memo) gets the fallbackRIGHT_ID3533, which in 2.1.1 happens to be the no-coda noun id: on such lines vowel-final nouns are read correctly (학교가gives[학교],깃허브가 있다gives[깃허브, 있]), while consonant-final nouns stay affected (학생: 이/VCP and[학생, 이]). That line-end behaviour is the sibling LFV-654. - New words. Adding words the bundled dictionary has no entry for is a common reason for a user dictionary, and they are affected the same way (
깃허브가 있다). A word the bundled dictionary has under another tag is too: 루씬 is a bundled proper noun (NNP), and as a user noun루씬이gives 이/VCP and[루씬, 이]instead of 루씬/NNP 이/JKS and[루씬]. - How often (grammatical probes only): over 300 nouns a verifier chose (150 with and 150 without a final consonant) in 8 sentence templates each, the particle tag changes in
{N}가 있다and{N}이 있다for 150 of 150 nouns each, andKoreanAnalyzerindexes an extra 가 in{N}가 있다for 150 of 150; 0 on 9.12.3 and 0 with the fix. Bare{N}가and{N}이give 가/VV and 이/VCP plus an extra term for every noun compared (147 and 150; the verifier counts only nouns whose bundled analysis is the noun as one NNG token);되었다and왔다giveJKC,중요하다and필요하다giveMM(tag only). An independent reproducer's 39 grammatical probes: 15 particle tags and 11 term lists differ. - Unaffected in those templates: 을/를, 은/는, 의, 도, 에서, 로/으로, 와/과 함께, and
{N}가/이before 없다, 많다, 좋다, 크다, 아니다. In every printed probe the user noun's own term is the same on stock and with the fix. - Effect. Wrong
PartOfSpeechAttributetags for the particle (sometimes for the next word too: 있/VA becomes 있/VX) and, with the default stop tags, an extra one-syllable term, at index and query time alike when both use this analyzer configuration. No exception, with assertions on or off. - Scale. No threshold: every occurrence, in an affected context, of every user noun whose line ends with the noun (and of consonant-final nouns also when the line ends in whitespace or a comment). A fix changes the terms of fields analysed with a user dictionary, so those fields need a reindex.
The test shows that adding the plain noun 학교 to a nori user dictionary changes how KoreanAnalyzer analyses 학교가, because the user-noun context ids are still those of mecab-ko-dic 2.0.3 while the bundled dictionary is 2.1.1.
/*
* 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.IOException;
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 TestUserNounParticle extends LuceneTestCase {
public void testUserNounKeepsSubjectParticle() throws IOException {
UserDictionary userDict = UserDictionary.open(new StringReader("학교"));
try (Analyzer plain = new KoreanAnalyzer();
Analyzer withUserNoun =
new KoreanAnalyzer(
userDict,
KoreanTokenizer.DEFAULT_DECOMPOUND,
KoreanPartOfSpeechStopFilter.DEFAULT_STOP_TAGS,
false)) {
// expected: [학교], as without the user entry 학교; actual: [학교, 가]
assertEquals(terms(plain, "학교가"), terms(withUserNoun, "학교가"));
}
}
private static List<String> terms(Analyzer analyzer, String text) throws IOException {
List<String> terms = new ArrayList<>();
try (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;
}
}
# in a copy of this finding's repro folder (repro/LFV-658/, 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.TestUserNounParticle
# the module(s) analysis-nori also need their third-party runtime jars
- Expected vs actual
- Expected
[학교], the terms without the user entry; actual:[학교, 가](expected:<[학교]> but was:<[학교, 가]>), 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.TestUserNounParticle.testUserNounKeepsSubjectParticle(TestUserNounParticle.java:41)- The constants' comments name their contexts ("NNG right with hangul and a coda on the last char", "... no coda"); in the bundled 2.1.1 dictionary those numbers mean the opposite.
- LUCENE-8603 (7.7.0) fixed these same constants as a bug when they drifted from the dictionary.
- 9.x, whose dictionary matches the ids, passes the same tests.
The full argument272 words
- The contract. The comments say what each constant is meant to be: "NNG right", "NNG right with hangul and a coda on the last char", "NNG right with hangul and no coda on the last char" (
UserDictionary.java:40-45), "NNG left" (UserMorphData.java:25). The code picks between them by the coda of the line's last character, for a one-word line the noun's last syllable (:94-103), and the entry reportsNNG(UserMorphData.java:61-69). The default constructors use "the system and unknown dictionaries shipped with Lucene" (KoreanTokenizer.java:97,:113), so the bundled 2.1.1 matrix is what these numbers are read against. - The oracle. The tests compare with nori's own reading of the same noun from its bundled dictionary, not with an outside linguistic judgement; on 9.x, where the ids match the dictionary, the same tests pass.
- Tests. No upstream test pins either behaviour:
TestUserDictionarychecks lookup and segmentation,TestKoreanTokenizer.testUserDicthas no particle after a user noun. The module's 87 upstream tests pass on stock and with the fix. - Precedent. LUCENE-8603 (#9649, 7.7.0, under "Bug fixes": "Fix the inversion of right ids for additional nouns in the Korean user dictionary",
CHANGES.txt:6910-6911) fixed these constants because "the ids were not in sync with the version of the dictionary that is used by the library" (@jimczi). Its values (53b05c5e7e8on master; 7.7.0 shipped the branch_7x copy0d6d12a0ebb) are today's, correct for 2.0.3. - History. LUCENE-10416 (
76c9fd4e38a, CHANGES 10.0.0,CHANGES.txt:2330-2331) changed the dictionary and updated the unknown-word NGRAM left id (1798 to 1801,UnknownDictionaryBuilder.java:31), but notUserDictionaryorUserMorphData. Its discussion (#11452) covers reindexing only.
The devil's-advocate check177 words
The strongest case for "not a bug" (argued by the devil's-advocate verifier and by the cross-model reviews; the reviews rated it "moderate", the verifier gave no rating): no javadoc promises that a user noun connects like a bundled noun; nori is @lucene.experimental; user words get a dominant cost of -100000, so connection costs look secondary; hard-coded ids cannot be guaranteed to fit an arbitrary custom system dictionary (the experimental constructor that takes a TokenInfoDictionary and ConnectionCosts, LUCENE-9253); and LUCENE-10416 was a deliberate upgrade.
Why it fails: the cost decides whether the user word wins, while the right id decides how the next morpheme is read, and that is exactly what changes. The comments name the contexts and the code selects them by coda. "Experimental" allows API changes, not wrong output. The custom-dictionary point argues for deriving the ids, not for keeping numbers that are wrong for the bundled dictionary every default constructor uses. LUCENE-8603 already treated this exact mismatch as a bug. Verdicts: devil's advocate real (high); the cross-model reviews real (high); adjudication confirmed (high).
Recommended Set the four constants to the 2.1.1 ids, and add the test
Set the four constants to the plain-noun ids of the bundled mecab-ko-dic 2.1.1: LEFT_ID 1780, RIGHT_ID 3532, RIGHT_ID_T 3534, RIGHT_ID_F 3533, and add the test. Validated: the new test, the module's 87 upstream tests and :lucene:analysis:nori:check pass. Fields indexed with a user dictionary need a reindex.
All options and trade-offs3 options · 836 words
One fix was built and validated with gradle in a scratch worktree at a9779ef3c193: the finder's values, with comments that name the definitions. Deriving the ids from the dictionary data is the sturdier design but was not built.
UserDictionary: RIGHT_ID 3533 -> 3532 (NNG,*,*), RIGHT_ID_T 3535 -> 3534 (NNG,*,T), RIGHT_ID_F 3534 -> 3533 (NNG,*,F); UserMorphData: LEFT_ID 1781 -> 1780 (NNG,*,*). 2 files, +10/-8. The new test's id check compares with the bundled dictionary, so a renumbering like this one fails the build instead of silently shifting the ids again (it checks two nouns, so it is a guard rather than a proof).
--- a/lucene/analysis/nori/src/java/org/apache/lucene/analysis/ko/dict/UserDictionary.java
+++ b/lucene/analysis/nori/src/java/org/apache/lucene/analysis/ko/dict/UserDictionary.java
- // NNG right
- private static final short RIGHT_ID = 3533;
- // NNG right with hangul and a coda on the last char
- private static final short RIGHT_ID_T = 3535;
- // NNG right with hangul and no coda on the last char
- private static final short RIGHT_ID_F = 3534;
+ // Right context ids of a plain NNG in the bundled dictionary (right-id.def of
+ // mecab-ko-dic-2.1.1-20180720); update them together with the dictionary.
+ // NNG right (NNG,*,*)
+ private static final short RIGHT_ID = 3532;
+ // NNG right with hangul and a coda on the last char (NNG,*,T)
+ private static final short RIGHT_ID_T = 3534;
+ // NNG right with hangul and no coda on the last char (NNG,*,F)
+ private static final short RIGHT_ID_F = 3533;
--- a/lucene/analysis/nori/src/java/org/apache/lucene/analysis/ko/dict/UserMorphData.java
+++ b/lucene/analysis/nori/src/java/org/apache/lucene/analysis/ko/dict/UserMorphData.java
- // NNG left
- private static final short LEFT_ID = 1781;
+ // NNG left (NNG,*,* in left-id.def of mecab-ko-dic-2.1.1-20180720, the bundled dictionary)
+ private static final short LEFT_ID = 1780;Trade-offs
- Changes 10.x tokenization after user nouns: fields indexed with a user dictionary keep the old terms until reindexed; worth a CHANGES line and a decision on which release gets it (the 9.x backport of the dictionary upgrade was reverted over reindexing).
- The ids stay hard-coded: the fix moves them from the 2.0.3 layout to the bundled 2.1.1 one, so a user of the experimental custom-dictionary constructor whose dictionary keeps the 2.0.3 numbering gets fitting ids today and would not after the fix; a dictionary with yet another numbering fits neither.
- It also moves the fallback
RIGHT_ID(lines that end in a Latin letter, a digit, whitespace or a comment) from 3533 to the generic 3532; from 3532 both 가 and 이 read asJKS(probe COST lines), but no test targets those lines. - Cost: none (constants).
- Ship it with, or link it to, the sibling fix for the line-end coda bug (LFV-654), which chooses among these same three right ids.
What the checks validated
- Gradle, JDK 25, scratch worktree at
a9779ef3c193: the new test fails 3 of 3 on stock (assertions on and off) and passes 3 of 3 with the fix; the new test withTestUserDictionary,TestKoreanTokenizer,TestKoreanAnalyzerandTestKoreanTokenizerFactory: 37 tests, 2 nightly skipped, 0 failed; the whole module, 14 suites and 90 tests (87 upstream + 3 new; 4 skipped: 3 nightly andTestKoreanNumberFilter.testLargeData), passes with assertions on and off; the nightly run ofTestKoreanTokenizer,TestKoreanAnalyzerand the new test passes 27 of 27;:lucene:analysis:nori:checkpasses (checkGoogleJavaFormat, ecjLint, forbiddenApis, checkLicenses, tests);tidychanges nothing. - Standalone runner with the fix: the new test 3 of 3 (assertions on and off), the finder's test 2 of 2, the independent reproducer's 4 of 4; every row of the token dump gives the bundled reading.
- Earlier, with the same values: 4 upstream nori suites run by the finder (34 tests started, 32 passed, 2 nightly aborted, 0 failed), and every verifier's tests (adjudication: 3 of 3 pass).
- Re-run after the fact check (the test helper's javadoc corrected, a comment-only change):
tidyleaves the file unchanged; stock 3 of 3 fail with assertions on and off, the fix 3 of 3 pass,:lucene:analysis:nori:checkwith the fix passes (90 tests, 4 skipped); the standalone runner 3 of 3 fail on main and 3 of 3 pass with the fix.
Have the dictionary build read the plain-noun ids (NNG,*,*, NNG,*,T, NNG,*,F) from left-id.def/right-id.def and store them with the bundled dictionary, so the user dictionary uses the ids of whatever dictionary was built. An independent C++ re-implementation of nori looks the ids up this way; LUCENE-8816 (#9860) notes that MeCab can derive user-entry ids.
Trade-offs
Sturdier against the next upgrade, but a build or resource-format change. UserDictionary.open(Reader) is independent of the system dictionary, so serving custom system dictionaries would also need an API that ties the two.
The test's id check fails the build while the constants are stale.
Trade-offs
Reports the problem instead of fixing it; option A already includes it as a regression guard.
Products whose nori plugins load user dictionaries on Lucene 10.x inherit this, and after a fix their existing indexes keep the old terms until they are reindexed.
Semi-formal. While building a minimum-cost reference for the CJK tokenizers' Viterbi lattice, a differential probe of user-dictionary entries gave odd particle readings. The finder then dumped the context ids of bundled nouns on main and on 9.0.0 and read the dictionary's id definitions, which showed the user ids now name other noun classes.
The method in full168 words
Method: differential probe plus a data-definition cross-check (the hunt's "semi-formal" tier), during a hunt unit that checked, bounded-exhaustively on the stock jar, that the CJK tokenizers' Viterbi output is the minimum-cost path of their own lattice (for nori that check found 0 mismatches), 2026-10-27, main a9779ef3c193.
- While modelling nori's user-dictionary costs, a differential probe (the same entry with and without trailing whitespace) gave different particle analyses. That led to the sibling bug LFV-654 (the coda read from the end of the line) and to this one.
- The finder dumped the context ids of bundled nouns on main and on 9.0.0 (main: 학교
1780/3533; 9.0.0:1781/3534) and readleft-id.def/right-id.defof mecab-ko-dic 2.1.1: the user constants match the 9.x numbering, not the bundled one. - A public-API JUnit then compared the analysis of each noun from the bundled dictionary with the same noun as a one-line user entry. No solver or model checker produced the signal.
- Independent AI tools reproduced and judged it (see the checks).
No prior report: two scripted prior-report search runs (98 queries over Lucene issues, PRs, commits, mailing lists, JIRA, downstream trackers and the web), manual GitHub searches of every nori item since 2022 and the open PRs' diffs (2026-10-27); re-checked the same day. LUCENE-8603 (2018) fixed an earlier mismatch of these constants: precedent, not a report.
Searches5 searches
No issue, PR, commit, JIRA entry, mailing-list thread or downstream report says that nori's user-dictionary context ids are stale after the mecab-ko-dic 2.1.1 upgrade, or that particles after user nouns are misread on 10.x. 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), downstream repositories, lists, JIRA and the web | 2026-10-27 | a scripted prior-report search, two runs (51 and 47 queries), plus the finder's 25-query and the devil's advocate's 24-query runs | no report |
| Manual GitHub searches | 2026-10-27 | every apache/lucene issue and PR naming nori, Korean or KoreanTokenizer since 2022-02, downstream issues, a code search of forks and copies | no report; no fork has corrected values |
| Related items (read) | 2026-10-27 | #9649 / LUCENE-8603, #11452 / LUCENE-10416, #9860 / LUCENE-8816, LUCENE-9253, #13427, #16754, an independent C++ re-implementation of nori | precedent (LUCENE-8603: the same constants out of sync with 2.0.3, fixed 2018), the cause (LUCENE-10416, reindexing discussion only), design threads; none reports this bug |
| Open PRs | 2026-10-27 | the diffs of the open PRs touching UserDictionary.java and UserMorphData.java | #14969 and #16272 touch these files but keep the constants |
| Re-check (GitHub, read-only) | 2026-10-27 | five issue searches (nori user dictionary context id, nori userdict particle, UserDictionary RIGHT_ID, mecab-ko-dic 2.1.1 user dictionary, nori user dictionary 10.0), the five newest nori items, and the files on upstream main | nothing new; upstream main a95c754ee003 still has 3533 / 3535 / 3534 and 1781 |
Introduced by LUCENE-10416 (76c9fd4e38a, 2022-02-20), which moved nori to mecab-ko-dic 2.1.1 without updating these ids; first released in 10.0.0. Its 9.x backport was reverted, so 9.x is clean. The test fails on all 12 10.x releases up to 10.5.2 and main and passes on all 21 9.x releases and 8.11.4; 10.6.0 has the same ids (by reading).
Versions tested7 rows
Introduced by LUCENE-10416 "Update Korean Dictionary to mecab-ko-dic-2.1.1-20180720 for Nori" (76c9fd4e38a, 2022-02-20 on main; a JIRA patch by @uihyun committed by @mocobeta, no PR; now #11452). It regenerated the dictionary data and updated the NGRAM left id in UnknownDictionaryBuilder, but not the user-dictionary ids. The branch_9x backport (b2b35964663) was reverted two days later (c22d6d09d9b) after a question about reindexing, so 9.x kept mecab-ko-dic 2.0.3.
Origin of the constants. e851b89cbeb (LUCENE-8231, 7.4.0) hard-coded them, inverted for 2.0.3; 53b05c5e7e8 (LUCENE-8603, on master; 7.7.0 shipped the branch_7x copy 0d6d12a0ebb) corrected them; bd22f199dec (LUCENE-10393) moved LEFT_ID into UserMorphData unchanged. A static check of 59 binaries (7.4.0 to 10.5.2 and main; constants via javap, cost matrix by SHA-1) agrees: 7.7.0 to 9.12.3 carry the 2.0.3 matrix with matching constants, 10.0.0 to 10.5.2 and main the 2.1.1 matrix with the same constants.
| Version or commit | Verdict | Result |
|---|---|---|
main a9779ef3c193 | affected | this doc's test 3 of 3 fail (gradle and the standalone runner, assertions on and off); the finder's 2 of 2; the independent reproducer's 3 of 3 |
| 10.0.0 to 10.5.2 (12 releases) | affected | the finder's test 2 of 2 fail on each; this doc's test 3 of 3 on 10.5.2 and 10.0.0 |
| 9.1.0 jars + the introducing commit's dictionary data | data A/B | both the finder's and the reproducer's tests fail; with the parent commit's data both pass |
| 9.0.0 to 9.12.3 (21 releases), 8.11.4 | not affected | the finder's test passes; they bundle mecab-ko-dic 2.0.3, which the constants match |
| 7.4.0 to 7.6.0 | earlier bug | a different inversion of the same constants, fixed by LUCENE-8603 in 7.7.0 (static check) |
| main + fix | not affected | every repro passes; the nori module's 90 tests and check pass |
upstream main a95c754ee003 | read | the four constants 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. A data A/B on 9.1.0 jars pins the cause on the dictionary data; an audit and this doc's runs re-ran it on main.
Checks and verdicts379 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.0.0 and 10.5.2; pass on 9.0.0, 9.12.3 and with the fix; 4 upstream nori suites pass with it (32 of 34 tests passed, 2 nightly aborted, 0 failed). |
| 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, 10.0.0 and 10.5.2 and passes on 9.12.3; its live id probe finds the user profile 1781/3534 among none of 학교's bundled profiles; counter-argument rated moderate. |
| Independent reproducer | real (high) | Own 39-probe public-API JUnit (3 tests), written from the claim: 3 of 3 fail on main (assertions on and off), 10.0.0 and 10.5.2, pass on 9.12.3; its same-package id test fails on main and 10.5.2 (not run on 10.0.0 or 9.x); re-extracted both dictionaries' definitions; fixing only the right ids makes the three particle and term tests pass (the id test still fails, as LEFT_ID stays 1781), fixing only LEFT_ID changes nothing in its probes. |
| Cross-model review, intended lens | real (high) | Same reviewer family, executed: its own tests reproduce with noun-alone controls passing; its version control fails on main and 10.5.2 and passes on 9.12.3; re-run on a9779ef3c193. |
| Devil's advocate | real (high) | Five "intended" arguments tested, none survives; 300-noun sentence probes: 300 tag and 150 term differences on main and 10.5.2, 0 on 9.12.3 and with the fix; a new word (깃허브) is affected too. |
| adjudication | confirmed (high) | Re-ran the independent reproduction's test (3 of 3 fail on main; 3 of 3 pass with the fix) and the finder's with assertions off (2 of 2 fail). Duplicate of none; not security-sensitive; its framing (grammatical probes, context-dependent, reindex note) is followed in this doc. |
| Follow-up, audit and this doc | keep | The follow-up ran 34 releases plus main and a data A/B; 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 ran 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 491 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,469 words
- Registry title
- Nori: user-dictionary nouns still use the context ids of mecab-ko-dic 2.0.3 after Lucene 10.0 moved to mecab-ko-dic 2.1.1, so the particle after a user noun gets the wrong part of speech (학교가: 가 becomes the verb 'go'; 학생이: 이 becomes the copula) and KoreanAnalyzer indexes spurious terms such as 가 and 이
- Area
- analysis / nori KoreanTokenizer user dictionary (UserDictionary right ids, UserMorphData left id vs the bundled mecab-ko-dic-2.1.1 connection costs)
- Priority
- Medium, rank 137: Since 10.0.0 every nori user dictionary gives the subject particles after a user noun the wrong part of speech and indexes extra one-syllable terms while the noun terms stay correct, a broader trigger than LFV-360 but milder damage than the text-loss rows above.
- Impact class
- silent wrong results
- Impact note
- Every application that analyzes Korean text with KoreanTokenizer or KoreanAnalyzer and any user dictionary (UserDictionary.open, KoreanTokenizerFactory's userDictionary, or a downstream nori plugin's user-dictionary setting) on 10.0.0 through 10.6.0 (10.6.0 by reading) and main, but not on 9.x: whenever ordinary text puts certain particles after a user-dictionary noun, most often the subject particle 가/이 (bare, or before 있다, 되었다, 왔다, 중요하다, ...), the particle gets a wrong part-of-speech tag, and in part of these contexts KoreanAnalyzer with its default stop tags indexes an extra one-syllable term (가, 이, 과) that the same noun from the bundled dictionary does not produce; the noun's own term is unchanged in every printed probe, many particles (을/를, 은/는, 의, 도, 에서, ...) are unaffected, nothing throws, and no scale is needed.
- Summary
- You add a noun such as 학교 ('school') to a nori user dictionary, or a new word such as 깃허브, and analyze '학교가' or '깃허브가 있다' with KoreanTokenizer or KoreanAnalyzer, and expect the noun to connect to the following particle like a plain common noun (NNG) with the same final syllable, which is what the constants' own comments say ('NNG left', 'NNG right with hangul and a coda on the last char', '... no coda') and what 9.x does. Instead the four hard-coded user-noun context ids are still the numbers of mecab-ko-dic 2.0.3, while 10.0.0 switched the bundled dictionary to mecab-ko-dic 2.1.1, in which those numbers name other contexts (the coda flag is inverted and the left id is a 'state change' noun subclass), so the subject particle 가 is analyzed as the verb 가 'go' (VV) and 이 as the copula (VCP) or a complement particle, and KoreanAnalyzer indexes [학교, 가] instead of [학교] and [깃허브, 가, 있] instead of [깃허브, 있]. Setting the constants to the 2.1.1 ids (LEFT_ID 1780, RIGHT_ID 3532, RIGHT_ID_T 3534, RIGHT_ID_F 3533) restores the analysis the bundled dictionary gives the same noun.
- Introduced
- 76c9fd4e38af65d28236d5a2695348fbd8fa3ed8 (LUCENE-10416 'Update Korean Dictionary to mecab-ko-dic-2.1.1-20180720 for Nori', GitHub issue #11452; a JIRA patch by @uihyun committed by @mocobeta, no pull request) [read: git history; confirmed by a data A/B, since the commit's only runtime change is the bundled dictionary data: 9.1.0 jars with the commit's dictionary data fail, with its parent's data pass]: it switched the dictionary from mecab-ko-dic 2.0.3 to 2.1.1, regenerated ConnectionCosts.dat and the dictionary data and updated UnknownDictionaryBuilder's NGRAM left id (1798 -> 1801), but left UserDictionary RIGHT_ID 3533, RIGHT_ID_T 3535 and RIGHT_ID_F 3534 (set for 2.0.3 by LUCENE-8603, 53b05c5e7e8, 7.7.0) and the user LEFT_ID 1781 (from LUCENE-8231, e851b89cbeb; moved unchanged into UserMorphData by LUCENE-10393, bd22f199dec) as they were; the branch_9x backport b2b35964663 was reverted by c22d6d09d9b two days later, before 9.1.0 (2022-02-20 (committed to main; first released in 10.0.0, 2024-10))
- First affected version
- 10.0.0 [exec]; every 9.x release (9.0.0-9.12.3) and 8.11.4 pass [exec] because branch_9x reverted the dictionary upgrade and kept mecab-ko-dic 2.0.3, which the constants match
- Affected versions
- Every 10.x release (10.0.0, 10.1.0, 10.2.0, 10.2.1, 10.2.2, 10.3.0, 10.3.1, 10.3.2, 10.4.0, 10.5.0, 10.5.1, 10.5.2, and 10.6.0 by reading) and main a9779ef3c193; never fixed. [exec] the finder's JUnit fails 2 of 2 on all 12 10.x releases up to 10.5.2 and on main (main with -ea and -da) and passes on 8.11.4 and on all 21 9.x releases (follow-up matrix); the independent reproduction's grammatical-probe JUnit fails 3 of 3 on 10.0.0, 10.5.2 and main and passes on 9.1.0 and 9.12.3; data A/B on 9.1.0 jars: with the introducing commit's dictionary data both tests fail, with its parent's data both pass. [read] static check of 59 binaries (cached release jars 7.4.0-10.5.2 and the main build; constants via javap, ConnectionCosts.dat SHA-1): 7.7.0-9.12.3 carry the 2.0.3 matrix with matching constants, 10.0.0-10.5.2 and main the 2.1.1 matrix with the stale constants; 7.4.0-7.6.0 had an earlier, different mismatch of the same constants, fixed by LUCENE-8603 in 7.7.0. The constants and the dictionary name are unchanged on upstream main a95c754ee00 (2026-10-26), branch_10x 1309534296e and branch_10_6 2763db11b0a (2026-10-24) [read, local clone]; 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
- Upstream and prior reports
- none found (searched 2026-10-27, plus the finder's run and the intended-behaviour check's list and JIRA run, read-only; the prior-report record, verdict new): two hunt prior-report tool runs (98 queries over apache/lucene GitHub issues, PRs and commits, Elasticsearch, OpenSearch and Solr, the dev@, java-user@, issues@ and commits@ lists, the LUCENE JIRA archive and the web), the finder's 25-query and the intended-behaviour check's 24-query runs, manual GitHub searches of every apache/lucene issue and PR naming nori, Korean or KoreanTokenizer since 2022-02 plus downstream issues, the diffs of the open PRs that touch these files, a code search of forks and copies (none has corrected values), JIRA JQL and the comments of the related issues; a re-check at registry time (gh, 2026-10-27) found no newer nori issue or PR about this. Not fixed upstream: the four constants are unchanged on main a95c754ee00 and on branch_10x and branch_10_6 [read, local clone]; open PRs #14969 and #16272 touch UserDictionary.java (#14969 also UserMorphData.java) but keep the constants. Nearest items, none a report of this bug: #9649 (LUCENE-8603, fixed 2018: these constants were inverted against mecab-ko-dic 2.0.3; its fix produced today's values, and a committer, @jimczi, wrote 'the ids were not in sync with the version of the dictionary that is used by the library'; precedent), #11452 (LUCENE-10416, the introducing change; its discussion covers reindexing only), #9860 (LUCENE-8816, design thread; notes MeCab can derive ids for user entries), LUCENE-9253 (custom system dictionaries), #13427 and #16754 (other UserDictionary changes), and the outside C++ re-implementation jeongukjae/nori-clone (derives the NNG ids from left-id.def/right-id.def; prior design for a fix, never says Lucene is wrong). Gaps: GitHub search is phrase-like and was rate-limited; Korean blogs, Q&A sites and downstream forums only through general web search
- 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; the finder's public-API test on 8.11.4, all 21 9.x and all 12 10.x releases, plus a dictionary-data A/B on the 9.1.0 jars (follow-up). The fix was validated in the scratch worktree: the new test, the nori module's 87 upstream tests with assertions on and off, a nightly run ofTestKoreanTokenizerandTestKoreanAnalyzer, and:lucene:analysis:nori:check. Upstream maina95c754ee003was read on 2026-10-27: the four constants unchanged.
- Finding record
lucene/analysis/nori/src/java/org/apache/lucene/analysis/ko/dict/UserDictionary.java :32-35 (class javadoc: custom nouns or compounds), :40-45 (RIGHT_ID 3533 'NNG right', RIGHT_ID_T 3535 'NNG right with hangul and a coda on the last char', RIGHT_ID_F 3534 '... no coda'), :94-103 (right id chosen from the last character's coda)lucene/analysis/nori/src/java/org/apache/lucene/analysis/ko/dict/UserMorphData.java :23 (WORD_COST -100000), :25-26 (LEFT_ID 1781 'NNG left'), :62-68 (getLeftPOS/getRightPOS return NNG); dict/UnknownDictionaryBuilder.java:31 (NGRAM entry, left id 1801); lucene/analysis/nori/build.gradle:51 (dictionaryName mecab-ko-dic-2.1.1-20180720)
- The finding in 30 seconds
lucene/analysis/nori/src/java/org/apache/lucene/analysis/ko/dict/UserDictionary.java:32-35, 40-45, 94-103lucene/analysis/nori/src/java/org/apache/lucene/analysis/ko/dict/UserMorphData.java:23-26, 37-45, 61-69lucene/analysis/common/src/java/org/apache/lucene/analysis/morph/Viterbi.java:389, 459, 469-472, 530-539lucene/analysis/nori/src/java/org/apache/lucene/analysis/ko/KoreanPartOfSpeechStopFilter.java:36-68lucene/analysis/nori/build.gradle:51 (dictionaryName mecab-ko-dic-2.1.1-20180720)
- Who is affected
lucene/analysis/nori/src/java/org/apache/lucene/analysis/ko/KoreanTokenizer.java:52, 104-110, 121-136, 154-163; KoreanAnalyzer.java:33, 59-69; KoreanTokenizerFactory.java:44-57, 64, 111-123
- Why it looks unintended
lucene/analysis/nori/src/java/org/apache/lucene/analysis/ko/dict/UserDictionary.java:40-45, 94-103; dict/UserMorphData.java:23-26, 61-69; KoreanTokenizer.java:96-97, 112-113, 138-163lucene/analysis/nori/src/test/org/apache/lucene/analysis/ko/TestKoreanTokenizer.java:431-472; dict/TestUserDictionary.java:26-60lucene/CHANGES.txt:2330-2331 (LUCENE-10416, 10.0.0), 6910-6911 (LUCENE-8603, 7.7.0 Bug fixes)
- TestUserNounParticle.java (JUnit test):
repro/LFV-658/src/org/apache/lucene/analysis/ko/TestUserNounParticle.java(57 lines)
Made in parallel with this doc. Every id, cost, tag and term on screen comes from the code at a9779ef3c193, the context-id definitions of both dictionary archives, or executed probes (main with assertions on and off, main with the fix, 9.12.3, 10.0.0 and 10.5.2). The PNG is the summary card.
assets/LFV-658/bug.png(491 KB)
![Summary card: Lucene nori, user-dictionary nouns still use the context ids of the old dictionary. Since 10.0.0 nori bundles mecab-ko-dic 2.1.1, but UserDictionary keeps the 2.0.3 ids, so the particle after a user noun gets the wrong part of speech and KoreanAnalyzer indexes extra terms; 10.0.0 to 10.5.2 and main, 9.x not affected. Panel 1: you add 학교 'school' and index 학교가; 교 has no coda, so RIGHT_ID_F 3534 and LEFT_ID 1781, while the bundled 학교 is 1780/3533. Panel 2: in 2.1.1 those ids name other rows: 3534 is NNG with a coda, 3535 a state-change class without one, 3533 NNG without a coda, 1781 the state-change class. Panel 3: Viterbi keeps the cheapest reading of 가: after right id 3533, 가/JKS -955 wins over 가/VV + 아/EC 2,267; after 3534, 가/VV + 아/EC 2,267 wins over 가/JKS 3,366. Expected 학교/NNG 가/JKS and terms [학교]; actual 학교/NNG 가/VV 아/EC and [학교, 가]; also 학생이 gives 이/VCP and [학생, 이], 깃허브가 있다 gives [깃허브, 가, 있]. Fix: LEFT_ID 1780, RIGHT_ID 3532, RIGHT_ID_T 3534, RIGHT_ID_F 3533.](../assets/LFV-658/bug.png)