The part's text and its offsets are computed from opposite ends of the word. They meet only when the parts cover the whole word.
- Nori's dictionary marks some nouns as COMPOUND and stores their parts as lengths. 가락지나물 is stored as 3 + 2 characters, 가락지 and 나물. A user dictionary line such as
세종시 세종 시does the same for 세종시. - In DISCARD mode (the default) the tokenizer emits the parts instead of the word, each with a start and end offset: the positions of its characters in the source text. Highlighters and snippets use these offsets to mark matches.
- Nori splits compound nouns into parts and gives each part its own offsets, the characters it came from: 가락지나물 becomes 가락지 [0,3) and 나물 [3,5).
- You add the user-dictionary entry
세종시 세종(the word 세종시 with one part, 세종) and tokenize "세종시" in the default DISCARD mode. You expect 세종 at [0,2). - Instead you get 세종 at [1,3), which covers '종시'. Nori cuts each part's text from the start of the word but counts its offsets back from the end, and here the part covers only 2 of the 3 characters.
- With no configuration at all, 7 compounds of the bundled dictionary do the same. 픽토그램 ('pictogram') is stored as 픽토 + 그, so the default
KoreanAnalyzergives 픽토 [1,3) = '토그' and 그 [3,4) = '램', and a highlighter marks 픽토그램 for the term 픽토 (픽토그램 for the phrase 픽토그램), also inside ordinary sentences. - Only offsets are wrong: terms and positions stay the same, and in the tested setups matches and scores were unchanged and nothing failed. Every release tested, from 7.4.0 through 10.6.0, and main does it.
Applications that analyze Korean with nori in DISCARD (the default, KoreanAnalyzer) or MIXED mode and use offsets for highlighting, snippets, term vectors or postings. Default settings hit 7 bundled compounds (of 160,029) wherever the tokenizer picks their stored split; so does any user entry whose parts are shorter than the word. Matching and scoring are unaffected.
Mechanism, terms and numbers678 words
KoreanTokenizer (nori) splits a COMPOUND dictionary entry into parts in DISCARD mode (the default, used by KoreanAnalyzer) and MIXED mode. Each part's text is cut from the start of the word using the stored part lengths (UserMorphData.java:85, TokenInfoMorphData.java:124), but its offsets are counted back from the end of the word (Viterbi.java:264, :267-292). The two agree only when the parts add up to the whole word. When they add up to less, every part's offsets move right by the uncovered length, onto other characters of the same word: the user-dictionary entry 세종시 세종 gives 세종 [1,3) = '종시', and the bundled 픽토그램 (parts 픽토 + 그) gives 픽토 [1,3) = '토그' and 그 [3,4) = '램'. Lucene accepts both inputs: UserDictionary rejects only a segmentation longer than the word (UserDictionary.java:114), and 7 of the 160,029 bundled compounds have parts that do not add up to the word. Highlights, snippets and stored offsets mark the wrong characters; terms and positions are unchanged, and so were matches and scores in the tested setups. Present since nori's first commit: it fails on every release tested from 7.4.0 through 10.6.0 and on main. Counting the offsets from the start of the word fixes it; rejecting such user entries, as the Japanese tokenizer does, and repairing the 7 bundled rows are complementary.
// Viterbi.java, backtrace: the whole word is [backWordPos, backWordPos + length) (:238-247)
int endOffset = backWordPos + length; // :264 end of the WHOLE word
for (int i = morphemes.length - 1; i >= 0; i--) { // :267 last part first
...
if (token.getPOSType() == POS.Type.COMPOUND) {
compoundToken = new DecompoundToken(morpheme.posTag(), morpheme.surfaceForm(),
endOffset - morpheme.surfaceForm().length(), // :276
endOffset, backType); // :277
} else { /* INFLECT parts: the whole token's offsets */ }
...
endOffset -= morpheme.surfaceForm().length(); // :292
}
// UserMorphData.getMorphemes: part text cut from the START of the word (:85)
morphemes[i] = new Morpheme(POS.Tag.NNG, new String(surfaceForm, off + offset, segs[i]));
// TokenInfoMorphData.getMorphemes, bundled dictionary: the same left cut (:124)
form = new String(surfaceForm, off + surfaceOffset, formLen);With part lengths summing to C and the word length L, the parts' offsets are shifted right by L - C against their text. For 세종시 세종, L = 3 and C = 2: 세종 gets [3 - 2, 3) = [1,3). In DISCARD mode '시' is in no token; MIXED mode emits 세종시 [0,3) and 세종 [1,3). KoreanTokenizer emits the offsets as given (KoreanTokenizer.java:236).
Nothing rejects such input: UserDictionary throws only if (offset > token.length()) (dict/UserDictionary.java:114), and the dictionary builder asserts only compoundOffset <= entry[0].length() (dict/TokenInfoDictionaryEntryWriter.java:161). The Japanese tokenizer's UserDictionary rejects any segmentation that does not concatenate to the word (ja/dict/UserDictionary.java:114, LUCENE-8933).
Found by scanning all 816,283 entries (160,029 compounds) of the bundled dictionary. Offsets are relative to the word's start, as emitted on main by the default KoreanAnalyzer inside ordinary sentences:
| word | stored parts | shift | parts emitted |
|---|---|---|---|
| 그레이맨 | 그레이 + (empty, dropped) | 1 | 그레이 [1,4) = '레이맨' |
| 픽토그램 | 픽토 + 그 | 1 | 픽토 [1,3) = '토그', 그 [3,4) = '램' |
| 녹색시민연합 | 녹색 + 시민 + 연 | 1 | 녹색 [1,3) = '색시', 시민 [3,5) = '민연', 연 [5,6) = '합' |
| 포럼신사고 | 포럼 + 신사 | 1 | 포럼 [1,3) = '럼신', 신사 [3,5) = '사고' |
| 안지랑역 | 안지 + 역 | 1 | 안지 [1,3) = '지랑', 랑 [3,4) = '역' |
| 일로역 | 일 + 역 | 1 | 일 [1,2) = '로', 로 [2,3) = '역' |
| 테크노파크역 | 테 + 파크 + 역 | 2 | 테 [2,3) = '노', 크노 [3,5) = '파크', 파 [5,6) = '역' |
In the last three rows a part is missing in the middle of the stored row, so even the part texts are wrong (랑, 로, 크노, 파); only repairing the data fixes those. In a sentence the shift is the same: in '도쿄 올림픽 개막식의 픽토그램 공연이 화제가 되었다.' 픽토 is at [13,15), covering '토그'. Since 10.0.0 일로역 on its own is tokenized as 일 + 로역, but inside '일로역에 도착했다.' it still hits.
Exposure and likelihood284 words
- Default settings. The 7 bundled words need no configuration. Each was hit in every sentence tested, also inside ordinary sentences with particles (all 7 on main and 10.5.2). Whether a word is hit depends on the tokenizer choosing its stored compound in that context: since 10.0.0, 일로역 on its own is split as 일 [0,1) + 로역 [1,3) and is not hit, while '일로역에 도착했다.' still hits.
- User dictionaries. Any entry whose segmentation is shorter than the word, for example to keep only the first part of a name, is accepted and misplaces the kept part.
- What goes wrong. Highlights and snippets mark characters 1-2 positions to the right (bundled words) or shifted by the uncovered length (user entries), always inside the same word. Term vectors and postings store the same shifted offsets.
- What does not change. Terms, positions and position lengths: only the offset arithmetic is wrong. In a check with 148 tokens, 88 of them misplaced (assertions on and off), all misplaced offsets stayed inside their own word,
IndexWriteraccepted them (they never went backwards),CheckIndexwas clean and 46 phrase queries matched their documents. Each per-term span of the unified highlighter stayed inside its own word (term 픽토 gives 픽토그램); a phrase span covers the matched words, as usual, but starts one character late (phrase 픽토그램 gives 픽토그램, phrase '오늘 픽토그램 소식' gives 오늘 픽토그램 소식). Only the unified highlighter was run. - Not affected: DecompoundMode NONE, which does not split compounds.
- How likely. Narrow: 7 of 160,029 bundled compounds, or a user entry of this shape. No remark on the misplaced offset was found since 2018, although it is visible in output posted on #9570.
One JUnit test, single thread, public API only: a user dictionary with 세종시 세종, then tokenize "세종시" and assert that the part 세종 sits at [0,2). It fails on main with assertions on and off, and on 10.6.0 and 9.12.3; it passes with the fix.
/*
* 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 org.apache.lucene.analysis.Tokenizer;
import org.apache.lucene.analysis.ko.KoreanTokenizer.DecompoundMode;
import org.apache.lucene.analysis.ko.dict.UserDictionary;
import org.apache.lucene.tests.analysis.BaseTokenStreamTestCase;
public class TestCompoundPartOffsets extends BaseTokenStreamTestCase {
/** The compound 세종시 is segmented into one part, 세종, its first two characters. */
public void testSegmentationShorterThanWord() throws IOException {
UserDictionary dict = UserDictionary.open(new StringReader("세종시 세종"));
Tokenizer tokenizer =
new KoreanTokenizer(newAttributeFactory(), dict, DecompoundMode.DISCARD, false);
tokenizer.setReader(new StringReader("세종시"));
// the part 세종 is characters [0,2) of the input
assertTokenStreamContents(tokenizer, new String[] {"세종"}, new int[] {0}, new int[] {2});
}
}
cd $LUCENE
cp $REPRO/TestCompoundPartOffsets.java lucene/analysis/nori/src/test/org/apache/lucene/analysis/ko/
./gradlew :lucene:analysis:nori:test --tests org.apache.lucene.analysis.ko.TestCompoundPartOffsets -Ptests.seed=DEADBEEF
# assertions off fails the same way: add -Ptests.asserts=false
- Expected
- The part 세종 is reported at [0,2), the two characters it was cut from.
- Observed
AssertionError: startOffset 0 term=세종 expected:<0> but was:<1>: 세종 is at [1,3), covering '종시'.
More files, environment, full output192 words
The outcome does not depend on the seed: the test's only random choice is the attribute factory (newAttributeFactory() picks one of three, BaseTokenStreamTestCase.java:1543-1559), and run once with each of the three it fails with the same message, assertions on and off. It belongs in lucene/analysis/nori/src/test/org/apache/lucene/analysis/ko/ and passes :lucene:analysis:nori:check -x test (formatting and static checks; that task runs no tests). It uses the user-dictionary trigger because that one does not depend on the bundled data; the bundled words fail the same way (픽토그램 gives 픽토 [1,3) and 그 [3,4)), but their correct parts are themselves in question.
Regression test: this same test. It pins the recommended fix. If maintainers choose to reject such entries instead, UserDictionary.open throws and the test becomes an expectThrows(IllegalArgumentException.class, ...).
| lucene | main 5adff78bb168 (12.0.0-SNAPSHOT); the same test also fails on 10.6.0 and 9.12.3 |
|---|---|
| jdk | OpenJDK 25.0.3 |
| os | Linux, aarch64 |
TestCompoundPartOffsets > testSegmentationShorterThanWord FAILED
java.lang.AssertionError: startOffset 0 term=세종 expected:<0> but was:<1>
at __randomizedtesting.SeedInfo.seed([DEADBEEF:7B056883CFD98131]:0)
at junit@4.13.2/org.junit.Assert.fail(Assert.java:89)
at junit@4.13.2/org.junit.Assert.failNotEquals(Assert.java:835)
at junit@4.13.2/org.junit.Assert.assertEquals(Assert.java:647)
at org.apache.lucene.test_framework@12.0.0-SNAPSHOT/org.apache.lucene.tests.analysis.BaseTokenStreamTestCase.assertTokenStreamContents(BaseTokenStreamTestCase.java:264)
...
at org.apache.lucene.analysis.ko.TestCompoundPartOffsets.testSegmentationShorterThanWord(TestCompoundPartOffsets.java:35)
:lucene:analysis:nori:test (FAILURE): 1 test, 1 failure
Assertions off (-Ptests.asserts=false): the same failure. Release jars 10.6.0 and 9.12.3: the same failure.- Upstream tests assert source-aligned part offsets (가락지나물 gives 가락지 [0,3) and 나물 [3,5),
TestKoreanTokenizer.java:246-252) and define parts as left-to-right cuts of the word (TestTokenInfoDictionary.java:168-183). OffsetAttributesays the offsets are the source characters "corresponding to this token"; 세종 [1,3) covers '종시'.- Lucene accepts both triggers silently, and the bundled one needs no configuration.
The full argument304 words
- Contract.
OffsetAttribute.startOffset(): "the position of the first character corresponding to this token in the source text";endOffset(): "one greater than the position of the last character corresponding to this token" (OffsetAttribute.java:23-50). For COMPOUND parts nori computes a separate sub-span per part, and the characters corresponding to a part are the ones its text was cut from. - Tests.
testPartOfSpeechWithCompoundexpects 가락지 [0,3) and 나물 [3,5) (TestKoreanTokenizer.java:246-252).TestTokenInfoDictionary.java:168-183checks that each COMPOUND part equals the next left-to-right substring of the word. - Javadoc.
UserDictionarypresents an entry as a split of the word: "custom nouns (세종) or compounds (세종시 세종 시)" (UserDictionary.java:33-34). - History. Right-anchored offsets, left-cut text and the longer-only check all came with nori's first commit (LUCENE-8231). The saved design thread (#9278) gives no rationale for counting part offsets from the word's end. There Jim Ferenczi wrote "For compound nouns I serialize the segmentations with the term but we could just use offset from the surface form", which treats the parts as pieces of the word; a later patch there "fixes an issue with offsets of the compound nouns", without detail. The introducing commit's test
userdict.txthas the entry세종시 세종 시, which covers the word. - Precedent. For the Japanese tokenizer, Tomoko Uchida wrote on #9976 (LUCENE-8933) that "the sum of length of segments are expected to be exactly same to the length of corresponding surface form", and that such entries should be rejected "even if this does not cause any exceptions". That thread checked nori only with an entry without segmentation.
- The right contract to state. "A COMPOUND part's offsets must cover the characters its text was cut from", not "a token's text equals its offset slice": INFLECT parts carry the whole word's offsets by design (
TestKoreanTokenizer.java:311-317: 감싸이 [0,3) and 어 [0,3) for 감싸여).
The devil's-advocate check204 words
The strongest case for "intended" or "misuse": nori tolerates parts that do not cover the word on purpose (the <= in TestTokenInfoDictionary.java:183 and in the dictionary builder since 2018); the 7 bundled rows are errors in the source data (Jim Ferenczi called #9570's 그레이맨 case one of the "bugs due to invalid rules in the mecab-ko-dic"); a shorter user segmentation is arguably a misconfiguration; KoreanTokenizer is @lucene.experimental; and it is excluded from TestRandomChains with @IgnoreRandomChains(reason = "LUCENE-10359: fails with incorrect offsets") (KoreanTokenizer.java:54), which the cross-model intended lens called "the strongest documented warning supporting a works-as-designed defense".
Why it fails: the tolerance confirms that parts are left-anchored text, yet nothing documents or tests offsets counted from the word's end; the annotation points to #11395, a different defect (its failing chain had no Korean text, and Robert Muir and Tomoko Uchida traced it to KoreanNumberFilter on that thread); Lucene accepts both inputs without a warning; the bundled case needs only default settings. Even if dropping a suffix were deliberate, the kept part's offsets must still point at its own text. Verdict: a defect. The adjudication kept medium confidence because maintainers may prefer repairing the data and rejecting such entries over changing Viterbi.
Recommended A: count part offsets from the start of the word (Viterbi)
Count the parts' offsets from the start of the word: end them at backWordPos + min(length, sum of part lengths) in Viterbi. Validated with gradle on main: the repro passes with assertions on and off, and the whole nori suite (88 tests) and :lucene:analysis:nori:check -x test pass.
All options and trade-offs3 options · 729 words
Validated with gradle in a clean scratch worktree of 5adff78bb168 (OpenJDK 25.0.3, seed DEADBEEF, 2026-11-05): the repro fails stock with assertions on and off and passes with fix A, on and off; with fix A the whole nori test suite passes (88 tests including the repro, 4 skipped) and so does :lucene:analysis:nori:check -x test. With fix B the nori suite passes (87 tests without the repro, 4 skipped) and the repro fails at UserDictionary.open, as B intends.
A fixes the offsets for every input. B and C are hardening: B stops new bad user entries, C repairs the data, which is the only way to fix the wrong part texts of 안지랑역, 일로역 and 테크노파크역. Maintainers may prefer B + C alone. The cross-model fact check would lead with B + C, with A as a consistency repair, because A keeps the 3 wrong part texts; both readings are defensible. A touches the same loop as the open PR #14969 (label Stale, its four commits from 2025-07-20), which keeps the offset formula.
Sum the part lengths and end the last part at the end of the covered prefix instead of the end of the word. Offsets of parts that cover the word are unchanged (the sum equals length); INFLECT parts keep the whole token's offsets.
--- a/lucene/analysis/nori/src/java/org/apache/lucene/analysis/ko/Viterbi.java
+++ b/lucene/analysis/nori/src/java/org/apache/lucene/analysis/ko/Viterbi.java
@@ -261,7 +261,13 @@ final class Viterbi
System.out.println(" add token=" + pending.get(pending.size() - 1));
}
} else {
- int endOffset = backWordPos + length;
+ // The parts' text is cut from the start of the word (getMorphemes), so their offsets
+ // must be counted from the start too, also when the parts do not cover the whole word.
+ int coveredLength = 0;
+ for (KoMorphData.Morpheme morpheme : morphemes) {
+ coveredLength += morpheme.surfaceForm().length();
+ }
+ int endOffset = backWordPos + Math.min(length, coveredLength);
int posLen = 0;
// decompose the compound
for (int i = morphemes.length - 1; i >= 0; i--) {Trade-offs
+7/-1 lines (two of the added lines are a comment), no format or API change. In DISCARD mode the uncovered tail of the word stays in no token. A makes each part's offsets match the text nori emits, not a linguistically correct split: for 안지랑역, 일로역 and 테크노파크역 the part texts themselves are wrong (랑, 로, 크노, 파) and only C fixes them. A does not guard against parts longer than the word (then min keeps the old end anchor); such data cannot be loaded today (UserDictionary.java:114 rejects it, TokenInfoDictionaryEntryWriter.java:161 asserts against it) and the bundled scan found none.
What the checks validated
Yes: repro on and off, the whole nori suite (88 tests, 4 skipped) and :lucene:analysis:nori:check -x test pass (gradle, 5adff78bb168, 2026-11-05).
B: reject a user segmentation that does not cover the word (UserDictionary) (validated by the checks)
As the Japanese UserDictionary does since LUCENE-8933: throw when the part lengths do not add up to the word, not only when they exceed it (UserDictionary.java:114).
--- 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
@@ -111,11 +111,11 @@ public final class UserDictionary implements Dictionary<UserMorphData> {
length[i - 1] = splits[i].length();
offset += splits[i].length();
}
- if (offset > token.length()) {
+ if (offset != token.length()) {
throw new IllegalArgumentException(
"Illegal user dictionary entry "
+ entry
- + " - the segmentation is bigger than the surface form ("
+ + " - the segmentation does not match the surface form ("
+ token
+ ")");
}Trade-offs
Existing user dictionaries with such entries stop loading: a behaviour change that needs a CHANGES entry. Does nothing for the bundled words.
What the checks validated
Partly: the nori suite passes (87 tests without the repro, 4 skipped); the repro then throws IllegalArgumentException: Illegal user dictionary entry 세종시 세종 - the segmentation does not match the surface form (세종시).
Fix the stored parts so they add up to the word, or have the dictionary builder store no parts for a COMPOUND whose parts do not cover the word; then make the builder's assertion (TokenInfoDictionaryEntryWriter.java:161) and TestTokenInfoDictionary.java:183 require equality.
Trade-offs
Needs regenerating the bundled dictionary. The only option that also fixes the wrong part texts.
What the checks validated
Not run.
Search products that highlight or snippet Korean documents with nori show the mark shifted inside the same word (1-2 characters for the bundled words, the uncovered length for a user entry); matching, filtering and scoring are unaffected.
A bounded-exhaustive sweep of Lucene's tokenizers checked, for every input of up to 6 symbols, that each token's offsets point at its own text; it failed for a nori user entry with a shorter segmentation. An exhaustive scan of all 816,283 bundled dictionary entries then found the 7 compounds with the same shape.
The method in full121 words
Method: semi-formal (bounded-exhaustive translation validation of the stock analysis jars with a twin-run provenance oracle), on main a9779ef3c193, 2026-10-26.
- The sweep ran every input of up to 6 symbols per component over 136 component and char-filter configurations, 74,769,564 checks. Its check that a tokenizer's token slices its own text failed only for the Korean user-dictionary configuration with a shorter segmentation.
- An exhaustive scan of all 816,283 bundled nori entries found the 7 COMPOUND entries, of 160,029, whose parts do not cover the word, and a script traced them to their source rows.
- No solver or model checker produced the signal. An independent reproducer, reviews by another model family (OpenAI GPT-5.6), a devil's-advocate verifier and an adjudication then checked the claim.
No prior report of this defect: 195 logged queries over Lucene issues, PRs, commits, the JIRA archive, mailing lists, downstream projects and the web (2026-10-26), re-checked 2026-11-03 and 2026-11-05. Posted output on #9570 (2018) already showed the misplaced 그레이맨 offset, but that issue and its fix were about an extra empty token.
Searches3 searches
No item describes misplaced COMPOUND-part offsets, their cause, the user-dictionary trigger or the other 6 words. Related, not this defect: #9570 (LUCENE-8524, closed) item D and its downstream original elastic/elasticsearch#34283 posted 7.4.0 output with 그레이 at start_offset 1, end_offset 4, but discussed only the extra empty token, and the fix 6f291d402b9 (7.6.0) left offsets alone; #9976 (LUCENE-8933), the Japanese validation precedent, which checked nori only with an entry without segmentation; #11395 (LUCENE-10359, open, "KoreanTokenizer: TestRandomChains fails with incorrect offsets": the reason of KoreanTokenizer's @IgnoreRandomChains, but its failing chain had no Korean text and Robert Muir and Tomoko Uchida traced it to KoreanNumberFilter); open PR #14969 (same loop, keeps the formula); #16272 (UserDictionary FST loading). Earlier readings differed: the first prior-art pass, verifiers and the adjudication rated #9570 a partial overlap and called the bug known; a re-check on 2026-11-03 found that #9570 shows the offset only in posted output and is about the extra empty token, so it does not describe this defect. A maintainer may still see #9570 item D as earlier evidence. Gaps: lexical search only; Korean blogs and forums weakly covered.
| Where | When | How | Found |
|---|---|---|---|
| apache/lucene issues, PRs and commits (incl. the JIRA archive); dev@, java-user@, issues@, commits@; Elasticsearch, OpenSearch, Solr; web (English and Korean) | 2026-10-26 | 195 logged queries: two scripted prior-report search runs, 67 GitHub searches including a title sweep of all 132 nori/Korean issues and PRs, 5 JIRA, 11 mailing-list and 8 web searches, about 30 threads and PR diffs read | only the related items above |
| Open PRs | 2026-10-26 | the changed files of all 407 open apache/lucene PRs | only #14969 and #16272 touch these files |
| Recheck (GitHub) | 2026-11-03, 2026-11-05 | two independent passes (2026-11-03); nori, Korean offset, decompound and user-dictionary queries for items updated since 2026-11-01, and upstream commits on Viterbi.java and UserDictionary.java (2026-11-05) | nothing new (the one 'decompound' hit, #16446, is a MultiFieldQueryParser fix); no upstream commit on either file since 2026-05-14 |
Present since nori's first commit (e851b89cbeb, LUCENE-8231, 2018), first released in 7.4.0. Every release tested fails (12 releases from 7.4.0 to 10.6.0, and main); the releases in between are inferred from unchanged code. Not fixed upstream as of 2026-11-05.
Versions tested5 rows
Introduced by e851b89cbeb "LUCENE-8231: Add a new analysis module (nori) similar to Kuromoji but to handle Korean" (2018-04-13; issue #9278, a JIRA patch, no pull request) and the same commit on branch_7x, 8651fbaa8bc, which shipped in 7.4.0. Right-anchored offsets, left-cut part text and the longer-only check all arrived together. Later commits kept the offset formula: LUCENE-8524 (6f291d402b9 on main; 7.6.0 ships its branch_7x backport 403babcfd6d) dropped empty parts, LUCENE-10493 (c89f8a7ea1e) moved the backtrace to Viterbi.java, LUCENE-10416 (76c9fd4e38a, 10.0.0) updated the dictionary and kept the 7 rows. 7.3.1 has no nori module. From 10.0.0 일로역 alone is tokenized as 일 + 로역, but it still hits inside a sentence. Viterbi.java last changed upstream in 942065cc3e7 (2024) and UserDictionary.java in dbf0d1426f1 (2026-05-14), both before 5adff78bb168; upstream main aa366ae209ae (2026-11-04) still has the formula.
| Version or commit | Verdict | Result |
|---|---|---|
| 7.3.1 | no API | no nori module |
| 7.4.0 | first: 7.4.0 | fails (version-neutral probe: 세종 [1,3); all 7 bundled words) |
| 7.5.0, 7.6.0, 7.7.3, 8.0.0, 8.11.4, 9.0.0, 9.1.0, 9.12.3, 10.0.0, 10.5.2 | affected | fail (version-neutral probe or JUnit); releases between the tested ones are inferred from unchanged code |
| 9.12.3, 10.6.0 | affected | the one-test repro fails (release jars) |
| main 5adff78bb168 | affected | the one-test repro fails through gradle, assertions on and off; passes with fix A |
An independent reproducer, a devil's-advocate verifier and an adjudication rated it real (adjudication: confirmed, medium confidence); read-only reviews by another model family (OpenAI GPT-5.6) gave two real and one procedural not-real. This doc's gradle runs reproduced it on main and validated the fix.
Checks and verdicts312 words
The reviews and verifiers ran on main a9779ef3c193, whose nori module is identical to 5adff78bb168; the doc's own runs used 5adff78bb168.
| Check | Verdict | What it found |
|---|---|---|
| finder | found | Two-test JUnit (user entry, 3 bundled words) fails on main (on, off), 10.5.2 and 9.12.3; the fix passes it and 51 of 51 upstream nori tests. |
| independent reproducer | real | Own JUnit from the claim: 2 of 4 fail on main (on, off), 10.5.2 and 9.12.3; both controls pass (the covering entry 세종시 세종 시 gives 세종 [0,2) and 시 [2,3)); 4 of 4 with the fix. |
| cross-model review, refute lens (OpenAI GPT-5.6, read-only) | not real (low), procedural | Could not run Java; agreed with the root cause and that the inputs are legal. |
| cross-model review, intended lens (OpenAI GPT-5.6, read-only) | real (medium) | Strongest case: the <= tolerance, @lucene.experimental and the @IgnoreRandomChains annotation for LUCENE-10359 (KoreanTokenizer.java:54, a different defect); neither documents right-anchored offsets. A separate run re-ran the JUnit on main: 2 of 2 fail. |
| cross-model review, root-cause lens (OpenAI GPT-5.6, read-only) | real (medium) | First wrong state at Viterbi.java:264. Runs made for this review (the reviewer itself only read code): 2 of 2 fail on main and 10.5.2, pass with the fix; 69 of 69 upstream nori tests pass with the fix. |
| devil's-advocate verifier | real (medium) | Tested five intended, misuse or accepted arguments; none survives. Its 7-observation probe passes on main (on, off) and 10.5.2. |
| adjudication | confirmed (medium), minor | Re-ran the reproducer's JUnit with a new seed on main (on, off): 2 of 4 fail, controls pass. Medium because maintainers may prefer a data repair plus validation. |
| this doc (gradle, 5adff78bb168) | reproduced; fix validated | One-test repro fails on and off and on 10.6.0 and 9.12.3; fix A passes it, the nori suite and the module check. |
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 431 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,587 words
- Registry title
- KoreanTokenizer gives the parts of a decompounded word offsets counted back from the end of the word while their text is cut from the start of the word, so when the parts do not cover the whole word (a user-dictionary entry with a shorter segmentation such as '세종시 세종', or 7 compounds of the bundled dictionary such as 픽토그램) the offsets point at other characters (픽토 at [1,3) covers '토그')
- Area
- analysis / nori KoreanTokenizer (Viterbi decompound backtrace; UserDictionary and bundled-dictionary compounds)
- Priority
- Low, rank 1045: Nori puts decompounded-part offsets on the wrong characters for 7 bundled compounds and for short user-dictionary segmentations, which affects offsets (highlighting) only (output posted on #9570 in 2018 showed one such offset without describing it).
- Impact class
- minor
- Impact note
- Applications that analyze Korean text with the default KoreanAnalyzer or KoreanTokenizer (decompound mode DISCARD, the default, or MIXED) and use offsets, mainly highlighting and snippets, term vectors or offsets in postings, in every release from 7.4.0 to 10.6.0 and main: every occurrence of 7 bundled compounds (그레이맨, 픽토그램, 녹색시민연합, 포럼신사고, 안지랑역, 일로역, 테크노파크역; all 7 also inside ordinary sentences on main [exec]) and of any user-dictionary compound whose segmentation is shorter than its word gets part offsets shifted right by the uncovered length (1-2 characters for the bundled words), always inside the same word, so a highlight marks the wrong characters; terms, positions, matches and scores are unchanged, there is no exception or index damage, and no scale is needed.
- Summary
- You analyze text containing 픽토그램 ('pictogram') with the default KoreanAnalyzer, or 세종시 with the user dictionary entry '세종시 세종', and expect each decompounded part's offsets to cover the characters its text came from, as they do when the parts cover the whole word (가락지나물 gives 가락지[0,3) and 나물[3,5)). Instead nori cuts each part's text from the start of the word but counts its offsets back from the end of the word, so 픽토그램 gives 픽토[1,3), covering '토그', and 그[3,4), covering '램', and '세종시 세종' gives 세종[1,3), covering '종시'; highlighters mark the wrong characters. Lucene accepts both inputs without a check: UserDictionary rejects only a segmentation longer than the word, and 7 compounds in the bundled dictionary have parts that do not add up to the word.
- Introduced
- e851b89cbeb1f55edc0f2c1276e2ae812eca2643 (LUCENE-8231 'Add a new analysis module (nori) similar to Kuromoji but to handle Korean', GitHub issue #9278, by jimczi; JIRA patch workflow, no pull request) and its branch_7x backport 8651fbaa8bc65fdfec397ad4e7ebe2b5acf78d7b, the commit contained in 7.4.0 [read: git history; all three ingredients arrived together in that commit: KoreanTokenizer.java:877-894 'int endOffset = backWordPos + length;' with parts at [endOffset - len, endOffset), the left cut of part text in BinaryDictionary.java:207-209 and UserDictionary.java:198, and validation that rejects only a longer segmentation (UserDictionary 'offset > token.length()', BinaryDictionaryWriter.java:166 'assert compoundOffset <= entry[0].length()')]. Not a regression; later commits left the offset computation unchanged: 6f291d402b9 (LUCENE-8524, 7.6.0, drops empty parts), bd22f199dec (LUCENE-10393), 13630d361e2 and c89f8a7ea1e (LUCENE-10493, backtrace moved verbatim to Viterbi.java), 942065cc3e7 (#13328), 76c9fd4e38a (LUCENE-10416, dictionary update that kept the 7 rows) (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 lucene/analysis/nori at the tag]
- Affected versions
- Every nori release from 7.4.0 through 10.6.0 (released 2026-10-29), and main a9779ef3c193 and 5adff78bb168; never fixed. [exec] on 2026-11-05 the one-test repro TestCompoundPartOffsets fails on the released 10.6.0 and 9.12.3 jars (-ea and -da) and on main 5adff78bb168 (-ea and -da), and the finder's two-test JUnit fails on 10.6.0 (-da); earlier, a version-neutral public-API probe shows the bug on 7.4.0 (-ea and -da), 7.5.0, 7.6.0, 7.7.3, 8.0.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 and main; the finder's JUnit fails 2 of 2 on main (-ea and -da), 10.5.2, 9.12.3, 10.0.0 and 9.1.0, and the finder's main() probe fails on 7.4.0, 8.11.4 and 9.12.3; the independent reproduction's JUnit fails 2 of 4 on main (-ea and -da), 10.5.2 and 9.12.3; the sentence probe fails on main and 10.5.2 with identical output. The user-dictionary trigger is present in every version. For isolated words the bundled trigger hits 7 of 7 words through 9.12.3 and 6 of 7 from 10.0.0 (mecab-ko-dic 2.1.1: 일로역 alone becomes 일 + 로역), but inside a sentence ('일로역에 도착했다.') all 7 still hit on main and 10.5.2. In 7.4.0 and 7.5.0 그레이맨 also yields an extra empty token (removed in 7.6.0 by LUCENE-8524, offsets untouched). [read] the offset code is unchanged in the releases between the executed ones; Viterbi.java, KoreanTokenizer.java, UserDictionary.java, UserMorphData.java, TokenInfoMorphData.java, TokenInfoDictionaryEntryWriter.java and the dictionary buffer are identical on upstream main a95c754ee003 and branch_10x (2026-10-26); 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 could only read the code (its low-confidence “not real” is procedural), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
- Found by
- semi-formal: exhaustive check
- Upstream and prior reports
- related items only, no report of this defect; not fixed (searched 2026-10-26, read-only, re-checked 2026-11-03 and 2026-11-05; 195 logged queries: two scripted prior-report searches over apache/lucene issues, PRs and commits, downstream repositories, the dev@, java-user@, issues@ and commits@ lists and the JIRA archive; 67 GitHub GraphQL searches including a title sweep of all 132 nori/Korean issues and PRs; 5 JIRA, 11 mailing-list and 8 web searches; about 30 threads and PR diffs read; all 407 open apache/lucene PRs checked by changed files; plus the finder's 38-query and the intended-behaviour check's 29-query scripted prior-report searches). Earlier sighting: #9570 (LUCENE-8524, closed 2018) item D and its downstream original elastic/elasticsearch#34283 posted 7.4.0 output for 그레이맨 that shows 그레이 at start_offset 1, end_offset 4, this bug's misplaced offset; the report and the maintainer's reply ('bugs due to invalid rules in the mecab-ko-dic') concern only the extra empty token, and the fix 6f291d402b9 (7.6.0) dropped empty parts without touching offsets. Not reported anywhere found: misplaced COMPOUND-part offsets as a defect, the right-anchored offset loop as the cause, the user-dictionary trigger, the other 6 bundled words. Related, not this bug: #9976 (LUCENE-8933, kuromoji, fixed 2019: the maintainers agreed that segment lengths must add up to the surface and that a shorter entry should be rejected; nori was checked only with an entry that has no segmentation and judged unaffected), #8236 (LUCENE-7181, kuromoji validation, open), #11395 (LUCENE-10359, KoreanNumberFilter offsets, open), #9752 (LUCENE-8706, positions with synonyms), #13426, #11452 (LUCENE-10416). Open PR #14969 (stale since 2025) edits the same decompound loop but keeps the offset formula, so a fix would conflict with it; #16272 touches UserDictionary FST loading only. Gaps: lexical search, weak coverage of Korean blogs and forums, throttled web search, private trackers not visible
- Lucene commit tested
- main
5adff78bb168(2026-11-05): the one-test repro through gradle in a clean scratch worktree, assertions on and off, stock and with fix A (then the whole nori test suite, 88 tests, and:lucene:analysis:nori:check -x test), and the nori suite with fix B; a standalone JUnit runner on main (on and off) and on the release jars 10.6.0 and 9.12.3; the finder's two-test JUnit (user entry and 픽토그램) on main and 10.6.0; a sentence probe of all 7 bundled words with the defaultKoreanAnalyzeron main. Earlier checks: a version-neutral probe on 7.4.0, 7.5.0, 7.6.0, 7.7.3, 8.0.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0 and 10.5.2, the reviews and verifiers on maina9779ef3c193(nori identical to5adff78bb168), and a re-run on maine1faeba08bde(2026-11-02). All runs used OpenJDK 25.0.3 on Linux aarch64.
- Finding record
lucene/analysis/nori/src/java/org/apache/lucene/analysis/ko/Viterbi.java:264 (at a9779ef3c193) ('int endOffset = backWordPos + length;'), :267-292 (COMPOUND part i gets [endOffset - len, endOffset), walking from the last part; other types get the whole token's offsets)lucene/analysis/nori/src/java/org/apache/lucene/analysis/ko/dict/UserMorphData.java:82-87 (at a9779ef3c193) and dict/TokenInfoMorphData.java:114-126 (part text cut from the start of the surface by stored lengths)lucene/analysis/nori/src/java/org/apache/lucene/analysis/ko/dict/UserDictionary.java:34 (at a9779ef3c193) (javadoc example '세종시 세종 시'), :105-121 (only 'offset > token.length()' is rejected) and dict/TokenInfoDictionaryEntryWriter.java:150-161 ('assert compoundOffset <= entry[0].length()')lucene/analysis/nori/src/java/org/apache/lucene/analysis/ko/KoreanTokenizer.java:236 (at a9779ef3c193) (offsets emitted as correctOffset(start), correctOffset(end))lucene/core/src/java/org/apache/lucene/analysis/tokenattributes/OffsetAttribute.java:23-50 (at a9779ef3c193) (startOffset/endOffset: the first and one past the last source character corresponding to the token)lucene/analysis/nori/src/test/org/apache/lucene/analysis/ko/TestKoreanTokenizer.java:246-252 (at a9779ef3c193) (가락지[0,3), 나물[3,5)) and :300-320 (INFLECT parts keep the whole word's offsets); dict/TestTokenInfoDictionary.java:168-183 (parts are left-to-right cuts, then only 'offset <= surfaceForm.length()')lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/dict/UserDictionary.java:97, (at a9779ef3c193) 114-123 (kuromoji rejects a segmentation that does not equal the surface, LUCENE-8933)
- The finding in 30 seconds
lucene@5adff78bb168 lucene/analysis/nori/src/java/org/apache/lucene/analysis/ko/Viterbi.java:238-247, :264-292lucene@5adff78bb168 lucene/analysis/nori/src/java/org/apache/lucene/analysis/ko/dict/UserMorphData.java:76-89 (:85 left cut)lucene@5adff78bb168 lucene/analysis/nori/src/java/org/apache/lucene/analysis/ko/dict/TokenInfoMorphData.java:114-126 (:124 left cut)lucene@5adff78bb168 lucene/analysis/nori/src/java/org/apache/lucene/analysis/ko/dict/UserDictionary.java:105-121 (:114 only a longer segmentation rejected)lucene@5adff78bb168 lucene/analysis/nori/src/java/org/apache/lucene/analysis/ko/dict/TokenInfoDictionaryEntryWriter.java:150-161lucene@5adff78bb168 lucene/analysis/nori/src/java/org/apache/lucene/analysis/ko/KoreanTokenizer.java:236lucene@5adff78bb168 lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/dict/UserDictionary.java:114
- Who is affected
lucene@5adff78bb168 lucene/analysis/nori/src/java/org/apache/lucene/analysis/ko/KoreanTokenizer.java:72-73 (DEFAULT_DECOMPOUND = DISCARD), KoreanAnalyzer.java:46
- Minimal repro
lucene@5adff78bb168 lucene/test-framework/src/java/org/apache/lucene/tests/analysis/BaseTokenStreamTestCase.java:1543-1559
- Why it looks unintended
lucene@5adff78bb168 lucene/core/src/java/org/apache/lucene/analysis/tokenattributes/OffsetAttribute.java:23-50lucene@5adff78bb168 lucene/analysis/nori/src/test/org/apache/lucene/analysis/ko/TestKoreanTokenizer.java:224-252 (testPartOfSpeechWithCompound), :300-317 (testPartOfSpeechWithInflects)lucene@5adff78bb168 lucene/analysis/nori/src/test/org/apache/lucene/analysis/ko/dict/TestTokenInfoDictionary.java:168-183lucene@5adff78bb168 lucene/analysis/nori/src/java/org/apache/lucene/analysis/ko/dict/UserDictionary.java:33-34lucene@5adff78bb168 lucene/analysis/nori/src/java/org/apache/lucene/analysis/ko/KoreanTokenizer.java:52 (@lucene.experimental)lucene@5adff78bb168 lucene/analysis/nori/src/java/org/apache/lucene/analysis/ko/KoreanTokenizer.java:54 (@IgnoreRandomChains, LUCENE-10359)
- Prior reports
GitHub search and commits API, 2026-11-05 (upstream main aa366ae209ae)
- History
git diff releases/lucene/10.6.0 5adff78bb168 -- lucene/analysis/nori (Viterbi, UserDictionary, UserMorphData, TokenInfoMorphData identical)GitHub commits API for Viterbi.java and UserDictionary.java, 2026-11-05
- Diagrams
lucene@5adff78bb168 lucene/analysis/nori/src/java/org/apache/lucene/analysis/ko/Viterbi.java:264-292
- TestCompoundPartOffsets.java (one test, lucene/analysis/nori):
repro/LFV-603/TestCompoundPartOffsets.java(37 lines)
A step-by-step picture of the mechanism, made in parallel with this doc from the code at 5adff78bb168 and executed probes; the PNG is the summary still.
assets/LFV-603/bug.png(431 KB)
