All findings · Lucene finding · LFV-658 · nori-userdict-context-ids-stale-after-dictionary-upgrade

Nori user dictionary: since 10.0, user nouns carry the old dictionary's context ids, so the particle after them is misread

Since Lucene 10.0, nori's user-dictionary nouns join the next word with context ids written for the 9.x dictionary, so with 학교 ("school") as a user noun, 학교가 reads 가 as the verb "go" and KoreanAnalyzer indexes [학교, 가] instead of [학교]. All 10.x releases and main; not 9.x.

Summary

Priority
Medium, rank 137Since 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
silent wrong results
Since
10.0.0 (LUCENE-10416, the 2022 dictionary upgrade)introduced 2022-02-20 · about 4 years
Affected
10.0.0 to 10.6.0 (10.6.0 by reading) and main; not 9.x
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 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.

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.
One example step by step: the user entry 학교 gets right id 3534, which in the bundled 2.1.1 tables means a noun ending in a consonant, so Viterbi keeps the verb reading of 가 and KoreanAnalyzer indexes the extra term 가. Select the still to open it at full size. Generated with AI, checked against the code.
  1. Put 학교 ("school") in a nori user dictionary, UserDictionary.open(new StringReader("학교")), and analyse 학교가 ("the school" + subject particle 가) with KoreanAnalyzer. New words such as 깃허브 behave the same.
  2. You expect [학교], as without the user entry: after a noun ending in a vowel, 가 is the subject particle JKS, a default stop tag, so only the noun is indexed.
  3. 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.
  4. 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). KoreanAnalyzer indexes [학교, 가]; 학생이 likewise gives 이/VCP and [학생, 이].
  5. 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.

Who is affected

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.

Terms

  • Nori user dictionary: a text file with one custom noun per line (optionally its parts, 세종시 세종 시), loaded with UserDictionary.open(Reader) and passed to KoreanTokenizer, KoreanAnalyzer or the korean factory's userDictionary file.
  • 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) or T (coda) in its id definitions.

The bug

  1. UserDictionary stores 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_T 3535 (Hangul with a coda), RIGHT_ID_F 3534 (Hangul without), else RIGHT_ID 3533 (UserDictionary.java:94-103; constants and comments :40-45). Every entry has LEFT_ID 1781, cost -100000, and tag NNG (UserMorphData.java:23-26, :61-69).
  2. 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), whose left-id.def/right-id.def assign the numbers differently:
id2.0.3 (9.x)2.1.1 (10.0.0 and later)constant
left 1780NNBC (counter noun)NNG,*,*(none)
left 1781NNG,*,*NNG, state-change classLEFT_ID
right 3532NNBCNNG,*,*(none)
right 3533NNG,*,*NNG, no codaRIGHT_ID
right 3534NNG, no codaNNG, codaRIGHT_ID_F (meant: no coda)
right 3535NNG, codaNNG, state-change class, no codaRIGHT_ID_T (meant: coda)
  1. The running binary agrees: bundled 학교 and 나무 (no coda) are 1780/3533 and bundled 학생 and 가방 (coda) 1780/3534, but the same nouns as user entries get 1781/3534 and 1781/3535.
  2. 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 drops JKS but keeps VV and VCP (KoreanPartOfSpeechStopFilter.java:36-68), so 가 and 이 become index terms.

The numbers

Cost of the rest of the path when the particle ends the text, conn(R, left) + wordCost + conn(right, end), on main:

textfrom the bundled noun's right idfrom the user noun's right id
학교가3533: 가/JKS -955 (best), 가/VV+EC 2,2673534: 가/VV+EC 2,267 (best), 가/JKS 3,366
학생이3534: 이/JKS -984 (best), 이/VCP -1103535: 이/VCP 1,009 (best), 이/JKS 3,691
text (user entry)tags, without | with the user entryKoreanAnalyzer terms
학교가 (학교)가/JKS | 가/VV+EC[학교] | [학교, 가]
학생이 (학생)이/JKS | 이/VCP[학생] | [학생, 이]
학교가 있다 (학교)가/JKS 있/VA | 가/VV+EC 있/VX[학교, 있] | [학교, 가, 있]
학생이 있다 / 학교가 되었다JKS | JKCunchanged
학생이 중요하다 (학생)이/JKS | 이/MMunchanged
깃허브가 있다 (깃허브, no entry in the bundled dictionary)깃/NNG 허브/NNG 가/JKS 있/VA | 깃허브/NNG 가/VV+EC 있/VX[깃, 허브, 있] | [깃허브, 가, 있]
학교를, 학생을 (object particle)JKO both waysunchanged
main + the fixthe bundled reading in every rowno 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 KoreanTokenizer constructors that take a UserDictionary (KoreanTokenizer.java:104-110, :121-136, :154-163), KoreanAnalyzer(userDict, ...) (KoreanAnalyzer.java:59-69), or the korean factory's userDictionary file (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 fallback RIGHT_ID 3533, 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, and KoreanAnalyzer indexes 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 왔다 give JKC, 중요하다 and 필요하다 give MM (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 PartOfSpeechAttribute tags 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.

Minimal repro

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.

TestUserNounParticle.java (JUnit test)57 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.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;
  }
}
Command
# 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 -ea and -da.

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

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.TestUserNounParticle.testUserNounKeepsSubjectParticle(TestUserNounParticle.java:41)

Why it looks unintended

  • 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 reports NNG (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: TestUserDictionary checks lookup and segmentation, TestKoreanTokenizer.testUserDict has 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 (53b05c5e7e8 on master; 7.7.0 shipped the branch_7x copy 0d6d12a0ebb) 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 not UserDictionary or UserMorphData. 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).

Suggested fix

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.

Set the four constants to the 2.1.1 ids, and add the test (validated by the checks) (recommended)

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 as JKS (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 with TestUserDictionary, TestKoreanTokenizer, TestKoreanAnalyzer and TestKoreanTokenizerFactory: 37 tests, 2 nightly skipped, 0 failed; the whole module, 14 suites and 90 tests (87 upstream + 3 new; 4 skipped: 3 nightly and TestKoreanNumberFilter.testLargeData), passes with assertions on and off; the nightly run of TestKoreanTokenizer, TestKoreanAnalyzer and the new test passes 27 of 27; :lucene:analysis:nori:check passes (checkGoogleJavaFormat, ecjLint, forbiddenApis, checkLicenses, tests); tidy changes 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): tidy leaves the file unchanged; stock 3 of 3 fail with assertions on and off, the fix 3 of 3 pass, :lucene:analysis:nori:check with the fix passes (90 tests, 4 skipped); the standalone runner 3 of 3 fail on main and 3 of 3 pass with the fix.

Derive the ids from the dictionary data (not built)

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.

Detection only: the id test without the fix

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.

Downstream note

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.

How it was found

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 read left-id.def/right-id.def of 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).

Prior reports, history and checks

Prior reports

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

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene), downstream repositories, lists, JIRA and the web2026-10-27a scripted prior-report search, two runs (51 and 47 queries), plus the finder's 25-query and the devil's advocate's 24-query runsno report
Manual GitHub searches2026-10-27every apache/lucene issue and PR naming nori, Korean or KoreanTokenizer since 2022-02, downstream issues, a code search of forks and copiesno 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 noriprecedent (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 PRs2026-10-27the 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-27five 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 mainnothing new; upstream main a95c754ee003 still has 3533 / 3535 / 3534 and 1781

History

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.

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affectedthis 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) affectedthe 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/Bboth 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 affectedthe finder's test passes; they bundle mecab-ko-dic 2.0.3, which the constants match
7.4.0 to 7.6.0 earlier buga different inversion of the same constants, fixed by LUCENE-8603 in 7.7.0 (static check)
main + fix not affectedevery repro passes; the nori module's 90 tests and check pass
upstream main a95c754ee003 readthe four constants 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. 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).

Each independent check and its verdict
CheckVerdictWhat it found
finder candidatePublic-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 keepThe 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.

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

Finding record

LFV-658, snapshot of 2026-11-06

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 of TestKoreanTokenizer and TestKoreanAnalyzer, and :lucene:analysis:nori:check. Upstream main a95c754ee003 was read on 2026-10-27: the four constants unchanged.

Lucene and upstream sources

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-103
  • lucene/analysis/nori/src/java/org/apache/lucene/analysis/ko/dict/UserMorphData.java:23-26, 37-45, 61-69
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/morph/Viterbi.java:389, 459, 469-472, 530-539
  • lucene/analysis/nori/src/java/org/apache/lucene/analysis/ko/KoreanPartOfSpeechStopFilter.java:36-68
  • lucene/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-163
  • lucene/analysis/nori/src/test/org/apache/lucene/analysis/ko/TestKoreanTokenizer.java:431-472; dict/TestUserDictionary.java:26-60
  • lucene/CHANGES.txt:2330-2331 (LUCENE-10416, 10.0.0), 6910-6911 (LUCENE-8603, 7.7.0 Bug fixes)

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

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.