Nori drops punctuation by default, and its opt-in unigram mode splits unknown words into characters. Together, the characters of an unknown word skip the punctuation check.
가격: $100 becomes [가격, $, 1, 0, 0] in nori's unigram mode, why the phrase 가격 100 then misses, and what the fix changes. Select the still to open it at full size. Generated with AI, checked against the code.- Build
KoreanTokenizerwithoutputUnknownUnigrams=true, which splits words the dictionary does not know into single characters, and the defaultdiscardPunctuation=true, which drops punctuation. Analyze the price가격: $100("price: $100"). - You expect
[가격, 1, 0, 0]: the:, the space and the$dropped,100split into digits. Lucene 8.1.1 gives exactly that; with unigrams off you get[가격, 100]. - You get
[가격, $, 1, 0, 0].$and100are unknown words, so they go through the unigram branch, which adds every character without the punctuation check that the other branch applies.:is a dictionary word and is still dropped. $is a real term at position 1, so the digits move one position on: the exact phrase가격 100finds 0 documents instead of 1. Line breaks,%,@, tabs and doubled spaces leak the same way, with no error.- It has worked this way since 8.2.0 (2019), when LUCENE-8784 removed the check from this branch while adding the flag. Putting the check back fixes it; kuromoji checks every unigram.
Applications that turn on nori's outputUnknownUnigrams (default off) and keep the default discardPunctuation=true, through KoreanTokenizer, the korean factory or KoreanAnalyzer(..., true). Prices, e-mail addresses, line breaks, tabs and doubled spaces then add one-character terms and break exact phrases. Elasticsearch and OpenSearch cannot turn the option on.
Mechanism, terms and numbers742 words
Nori's KoreanTokenizer drops punctuation, symbols and whitespace by default (discardPunctuation=true). Its opt-in outputUnknownUnigrams=true splits each word the dictionary does not know into one-character tokens. With both on, the unigram branch of Viterbi.backtrace adds every character without the shouldFilterToken check that the other branch applies, so the punctuation, symbol and whitespace characters inside an unknown word become terms of their own: 가격: $100 gives [가격, $, 1, 0, 0], 다음\n\n문장 gives [다음, \n, \n, 문장], 50% 할인 gives [5, 0, %, ' ', 할인], and an e-mail address keeps its @ and .. The extra terms carry the SY tag of every unigram, so the default part-of-speech stop filter keeps them, and each one pushes later words one position on, so exact (zero-slop) phrases across them miss (가격 100 finds 0 documents instead of 1). No error. It is opt-in (the korean factory, the constructors and KoreanAnalyzer can turn it on; Elasticsearch and OpenSearch cannot). LUCENE-8784 removed the check in 8.2.0, in the same diff that made shouldFilterToken honour the new flag; 8.1.1 and kuromoji filter each unigram, and putting the check back fixes it.
- Nori: Lucene's Korean analysis module.
KoreanTokenizerfinds the cheapest split of the text into dictionary words with a Viterbi search; a stretch the dictionary does not know becomes an unknown word. discardPunctuation(default true): drop tokens that are punctuation, symbols or whitespace. The check isshouldFilterToken: the flag is on and the token's first character is punctuation, a separator, a control or format character, or a math, currency, modifier or other symbol (Viterbi.java:412-445).outputUnknownUnigrams(default false): emit each unknown word as one token per character instead of one token for the word.
Viterbi.backtraceturns the best path into tokens in two branches. Theelsebranch (Viterbi.java:237on) emits a word as one token; a single-morpheme word, or any word in modeNONE(:248-249), is added onlyif (shouldFilterToken(token) == false)(:250). Unknown words and dictionary punctuation such as:take this checked path (with unigrams off,:and$are dropped).- With unigrams on, an unknown word takes the first branch (
:214): it loops over the word's characters, builds one token per character from the NGRAM entry of the unknown dictionary, and callspending.add(token)with no check (:232). - So every punctuation, symbol or whitespace character that is part of an unknown word is emitted, whatever
discardPunctuationsays. - Every unigram gets the part-of-speech tag
SY(dict/UnknownDictionaryBuilder.java:31), which is not inKoreanPartOfSpeechStopFilter.DEFAULT_STOP_TAGS(:36-68). The leaked tokens passKoreanAnalyzer's stop filter, and no part-of-speech filter can remove only them: letters and digits of unknown words carry the same tag. - Each leaked token takes a position, so every later token moves one position on per leaked token.
가격: $100: the best path is 가격 (a dictionary noun), : (a dictionary token, tag SC), $ (an unknown word after the space) and 100 (an unknown word). Without unigrams, : and $ fail the check and 100 stays whole: [가격, 100]. With unigrams, : still goes through the checked branch and is dropped, but $ and 100 are split and added unchecked: 가격@0, $@1, 1@2, 0@3, 0@4. With the fix: 가격@0, 1@1, 0@2, 0@3, which is what the phrase query "가격 1 0 0" (the analyzed 가격 100) needs.
| input | unigrams off | unigrams on (main) | unigrams on, with the fix |
|---|---|---|---|
가격: $100 | 가격, 100 | 가격, $, 1, 0, 0 | 가격, 1, 0, 0 |
다음\n\n문장 | 다음, 문장 | 다음, \n, \n, 문장 | 다음, 문장 |
50% 할인 | 50, 할인 | 5, 0, %, ' ', 할인 | 5, 0, 할인 |
ab cd (two spaces) | ab, cd | a, b, ' ', c, d | a, b, c, d |
a\tb | a, b | a, \t, b | a, b |
test@example.com | test, example, com | t, e, s, t, @, e, ..., ., c, o, m | the same without @ and . |
10.1 인치 모니터 | 10, 1, 인치, 모니터 | 1, 0, ., 1, 인치, 모니터 | 1, 0, 1, 인치, 모니터 |
Hello, world. | Hello, world | H, e, l, l, o, w, o, r, l, d | the same |
Hello, world. does not leak: , and . are dictionary tokens there, handled by the checked branch, and a single space between words is a separator. Kuromoji's equivalent loop (EXTENDED mode) checks each unigram: if (!discardPunctuation || !isPunctuation(fragment[offset + i])) (ViterbiNBest.java:448).
Exposure and likelihood432 words
- Who is exposed. Nori users who turn on unigram mode and keep the default flag: the 4-argument
KoreanTokenizerconstructor (it always passesdiscardPunctuation=true,KoreanTokenizer.java:104-110) or the 5-argument one withtrue, true; thekoreanfactory withoutputUnknownUnigrams="true"(discardPunctuationdefaults to true,KoreanTokenizerFactory.java:96-97; Solr schemas expose both);KoreanAnalyzer(userDict, mode, stopTags, true), which has nodiscardPunctuationoption and always uses true (KoreanAnalyzer.java:57-74). The classes are@lucene.experimental. - Not exposed. The default configuration (unigrams off), and users who set
discardPunctuation=false. Elasticsearch and OpenSearch pass a literalfalseforoutputUnknownUnigramsin their nori tokenizer and analyzer, so their settings cannot reach it (read in their source; not executed). - What triggers it. Ordinary text with a punctuation, symbol or whitespace character inside an unknown-word run: prices (
$100,50%), e-mail addresses,C++,ab#cd, tabs, line breaks, a space after a symbol, and since 8.10.0 doubled spaces. Deterministic: every such document, every time. - Effect on the index. Extra one-code-point terms such as
$,@,.,%, a newline or a space, which the default stop filter keeps; and shifted positions, so an exact (zero-slop) phrase across the character misses; slop can absorb the gap. Executed: the phrase가격 100misses가격: $100; the phrase회의 자료("meeting materials") misses documents with$, U+200B, a doubled space, two newlines or U+00AD between the words, and matches them with the fix; the same probe's slop-2 phrase matches the same documents with and without the fix. - Effect on queries. Query text with such a character gains a junk term (
createBooleanQuery("$")becomesbody:$). - Not affected. Term queries over punctuation-free words; no term the fixed code emits is lost; offsets are exact and
CheckIndexis clean. No hang or amplification: 1M-character punctuation inputs ran at 0.97-1.24x an all-letter control, at most one token per character. - No equivalent stock token-filter workaround. A token filter that empties punctuation-only tokens and drops empty ones removes the terms but keeps the position gaps (
가격@0, 1@2, ..., executed), so exact phrases would still miss. ApatternReplacechar filter that turns[\p{P}\p{S}\p{Cc}\p{Cf}\p{Z}]+into one space in front of the tokenizer gave gap-free output on the 7 inputs tried (가격: $100->[가격@0, 1@1, 0@2, 0@3], executed), but it changes the text the tokenizer segments, removes punctuation the dictionary would handle, was tried on 7 inputs only and must be in both the index-time and the query-time analyzer. Otherwise turn unigrams off, or apply the fix and reindex: existing indexes keep the extra terms until then.
The test shows that KoreanTokenizer with outputUnknownUnigrams=true and discardPunctuation=true keeps the $ of 가격: $100 as a token (the unigram branch of Viterbi.backtrace skips the shouldFilterToken check).
/*
* 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.TokenStream;
import org.apache.lucene.analysis.Tokenizer;
import org.apache.lucene.analysis.ko.KoreanTokenizer.DecompoundMode;
import org.apache.lucene.tests.analysis.BaseTokenStreamTestCase;
public class TestKoreanUnigramsDiscardPunctuation extends BaseTokenStreamTestCase {
public void testUnigramsKeepPunctuation() throws IOException {
Tokenizer t =
new KoreanTokenizer(
TokenStream.DEFAULT_TOKEN_ATTRIBUTE_FACTORY, null, DecompoundMode.DISCARD, true, true);
t.setReader(new StringReader("가격: $100"));
// expected [가격, 1, 0, 0] as with outputUnknownUnigrams=false; actual [가격, $, 1, 0, 0]
assertTokenStreamContents(t, new String[] {"가격", "1", "0", "0"});
}
}
# in a copy of this finding's repro folder (repro/LFV-444/, 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.TestKoreanUnigramsDiscardPunctuation
# the module(s) analysis-nori also need their third-party runtime jars
- Expected vs actual
- Expected, per the
discardPunctuationjavadoc and theoutputUnknownUnigrams=falseoutput:[가격, 1, 0, 0]. Actual:[가격, $, 1, 0, 0](term 1 expected:<[1]> but was:<[$]>).
More files, environment, full output38 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
=> org.junit.ComparisonFailure: term 1 expected:<[1]> but was:<[$]>
org.junit.Assert.assertEquals(Assert.java:117)
org.apache.lucene.tests.analysis.BaseTokenStreamTestCase.assertTokenStreamContents(BaseTokenStreamTestCase.java:262)
org.apache.lucene.tests.analysis.BaseTokenStreamTestCase.assertTokenStreamContents(BaseTokenStreamTestCase.java:513)
org.apache.lucene.tests.analysis.BaseTokenStreamTestCase.assertTokenStreamContents(BaseTokenStreamTestCase.java:598)
org.apache.lucene.tests.analysis.BaseTokenStreamTestCase.assertTokenStreamContents(BaseTokenStreamTestCase.java:622)
org.apache.lucene.tests.analysis.BaseTokenStreamTestCase.assertTokenStreamContents(BaseTokenStreamTestCase.java:689)
org.apache.lucene.analysis.ko.TestKoreanUnigramsDiscardPunctuation.testUnigramsKeepPunctuation(TestKoreanUnigramsDiscardPunctuation.java:34)- The
discardPunctuationjavadoc is unconditional: "true if punctuation tokens should be dropped from the output" (KoreanTokenizer.java:119). - 8.1.1 drops the
$; 8.2.0 keeps it; 8.2.0 with only the removed check restored drops it again. - Kuromoji checks every unigram (
ViterbiNBest.java:448), and the 8.2.0 CHANGES entry keeps punctuation only when the flag is false.
The full argument371 words
- The contract. "
@param discardPunctuationtrue if punctuation tokens should be dropped from the output" (KoreanTokenizer.java:119,:150; the factory says the same,KoreanTokenizerFactory.java:61).outputUnknownUnigramsis documented only as "if true outputs unigrams for unknown words" (:102,:118,:149): how unknown words are split, not that punctuation is kept. - The introducing change's own words. CHANGES 8.2.0: "LUCENE-8784: The KoreanTokenizer now preserves punctuations if discardPunctuation is set to false (defaults to true)." (
lucene/CHANGES.txt:6318-6319). - History. Nori's first release (LUCENE-8231, 7.4.0) checked every unigram; the check is in every tag from 7.4.0 to 8.1.1. LUCENE-8784 (
a556925eb8a, 8.2.0) madeshouldFilterTokenreturndiscardPunctuation && isPunctuation(...)and, in the same diff, replaced the checked add in the unigram branch with an unconditional one. The flag-aware check already returns false when the flag is false, so the option did not need the check removed: on 8.2.0 with the check restored,discardPunctuation=falsestill keeps every character (가격: $10.5gives[가격, :, ' ', $, 1, 0, ., 5]), and the default drops punctuation again. Removing it changed only the default case. - The record. Neither the commit message, the CHANGES entry nor any of the 17 comments of #9828 mentions the punctuation check in the unigram branch or a change to the default output. Two of the comments are the commit notices of
db334c792beand its branch_8x copy, which a day later restored the NGRAM part-of-speech tag that the patch had changed "inadvertenly" (sic) on the unigram path; they did not touch the check. That shows one unintended side effect of the patch; it is circumstantial, not evidence about the removed check itself. - Maintainers on record. "Punctuations are removed by default" (#10020) and "The tokenizer removes punctuations so it's expected that some characters are filtered" (#9739).
- The sibling. Kuromoji checks each EXTENDED-mode unigram (
ViterbiNBest.java:448); executed (saved run of the verifier),JapaneseTokenizergives価格: $100->[価格, 1, 0, 0]andab cd->[a, b, c, d]. - Tests. The only deterministic unigram input in
TestKoreanTokenizeris2018 평창 동계올림픽대회, with no punctuation (two assertions on it,:366-372and:374-405); its punctuation tests usediscardPunctuation=falsewith unigrams off. No test asserts punctuation unigrams.
The devil's-advocate check274 words
The strongest case for "works as designed" (the intended-behaviour checker): read literally, "outputs unigrams for unknown words" covers every character of an unknown word; a committer signed off on the removal; LUCENE-8784 was about keeping the decimal point, and unigram mode does keep the . of 10.1; the classes are @lucene.experimental; no report was found since 2019; the flag still drops separators and dictionary punctuation in unigram mode; users can filter the tokens themselves; and changing it now changes the analysis for anyone who relies on the punctuation unigrams.
Why it fails: the discardPunctuation javadoc is unconditional and the CHANGES entry keeps punctuation only when the flag is false. 7.4.0 to 8.1.1 filtered, and restoring only the check on 8.2.0 brings that back without hurting the new option. Kuromoji filters each unigram, and maintainers said punctuation is removed by default. The record shows no decision to change the default; the next-day NGRAM tag fix is circumstantial only. Keeping the . also when the flag is true goes beyond the issue's stated design, though that does not settle what the patch author intended. Filtering downstream does not work cleanly with token filters: the leaked tokens share the SY tag with every unigram, and a token filter leaves position gaps (a char filter avoids them on the inputs tried but changes the text the tokenizer sees). Few reports fit an opt-in option that two prominent downstream servers cannot turn on. The compatibility point and the explicit patch review are real: the fix changes analysis output for unigram users and needs a CHANGES line and a reindex note. Verdict: on balance not intended, a regression.
Recommended Check each unigram with shouldFilterToken
Check each unigram with shouldFilterToken, as the other branch does and as 7.4.0 to 8.1.1 did: wrap pending.add(token) at Viterbi.java:232 in if (shouldFilterToken(token) == false). Validated: the new test passes, as do the 84 upstream nori tests that ran and :lucene:analysis:nori:check. Affected fields need a reindex; unigram mode then also drops the . of 10.1.
All options and trade-offs4 options · 725 words
One fix was built and validated with gradle in a scratch worktree at a9779ef3c193; it is the same change as the finder's and the independent reproducer's. The alternatives and the workaround are for discussion.
Restores the 7.4.0 to 8.1.1 code in the unigram branch of Viterbi.backtrace (+5/-3 in Viterbi.java). It mirrors the other branch's check at :250; with discardPunctuation=false the check returns false, so LUCENE-8784's option is unchanged.
@@ -229,9 +229,11 @@ final class Viterbi
charLen,
backWordPos + i,
backWordPos + i + charLen);
- pending.add(token);
- if (VERBOSE) {
- System.out.println(" add token=" + pending.get(pending.size() - 1));
+ if (shouldFilterToken(token) == false) {
+ pending.add(token);
+ if (VERBOSE) {
+ System.out.println(" add token=" + pending.get(pending.size() - 1));
+ }
}
}
} else {Trade-offs
- Behaviour: unigram mode with the default flag drops punctuation again, including the
.of10.1([1, 0, ., 1]becomes[1, 0, 1]). Users who want it setdiscardPunctuation=false, which keeps it with or without the fix, or follow the keepDecimalPoint proposal #15894. - Existing indexes keep the extra terms until reindexed; after the upgrade, exact phrases across punctuation match newly indexed documents but still miss old ones (reasoning). Worth a CHANGES line.
- Cost: one check per unigram and fewer tokens; no slowdown seen in the follow-up's 1M-character runs (one shared host, so only "no measurable change" is claimed).
- Backport: on 8.x and 9.x the same block is in
KoreanTokenizer.backtrace(the code moved toViterbiin 10.0.0); only main was built and tested with gradle. - Not covered (pre-existing, both branches):
shouldFilterTokenchecks only the firstchar, so an emoji or other supplementary symbol is still emitted, and a BMP non-spacing mark such as U+FE0F is not in its classes either; with the fix the phrase회의 자료still misses회의😀자료.
What the checks validated
- Gradle, JDK 25: the new test fails 4 of 6 on stock (assertions on and off) and passes 6 of 6 with the fix; with the fix
TestKoreanTokenizer,TestKoreanTokenizerFactory,TestKoreanAnalyzerand the new test pass (38 tests, 2 nightly skipped); the whole module passes, 14 suites and 93 tests (87 upstream + 6 new; 4 skipped: 3 nightly and the@IgnoredTestKoreanNumberFilter.testLargeData, so 83 upstream tests ran), with assertions on and off; the nightly run ofTestKoreanTokenizerand the new test (checkRandomDataover the unigram analyzer, strings of up to 8192 characters) passes 23 of 23, so 84 distinct upstream tests ran and passed;:lucene:analysis:nori:checkpasses (checkGoogleJavaFormat, ecjLint, forbiddenApis, tests);tidychanges nothing. - Standalone runner with the fix: the brief's test 6/6 (assertions on and off), the finder's 4/4, the independent reproducer's 6/6; earlier, the follow-up's security probe 3/3 and a four-configuration token dump (
discardPunctuation=falsestill keeps every character). - On 8.2.0, restoring the same check makes the history probe and the 8.x JUnit port pass.
if (!discardPunctuation || !isPunctuation(fragment[fragmentOffset + i])) { ... pending.add(...) }, the form of ViterbiNBest.java:448.
Trade-offs
Same result for these inputs and skips allocating dropped tokens; the recommended form is the smaller diff and matches nori's other branch.
Keep the code and add a javadoc exception.
Trade-offs
Contradicts the 7.4.0 to 8.1.1 behaviour, kuromoji and the maintainers' statements, and leaves SY-tagged junk terms that no stock token filter was shown to remove without position gaps.
Turn outputUnknownUnigrams off. Or remove the terms with a token filter: a patternReplace filter that empties punctuation-only tokens (^[\p{P}\p{S}\p{Z}\p{Cc}\p{Cf}]+$ to "") followed by length with min=1 (executed). Or rewrite the text first with a patternReplace char filter that turns [\p{P}\p{S}\p{Cc}\p{Cf}\p{Z}]+ into one space in front of the korean tokenizer (executed on 7 inputs).
Trade-offs
The token filter keeps the position gaps (가격@0, 1@2, ..., executed), so exact phrases across the removed characters would still miss (reasoning). The char filter gave gap-free output on the 7 inputs tried, the same as the fixed tokenizer where compared, but it changes the text the tokenizer segments, removes punctuation the dictionary would handle and must be in both the index-time and the query-time analyzer (reasoning). Turning unigrams off changes how every unknown word is indexed.
Downstream, an exact-phrase rule on a field analyzed this way, allow or deny, can miss a document whose two words are separated by $, a line break or another leaked character; term-based rules are unaffected.
Semi-formal. A bounded-exhaustive sweep of 91 SPI-built analysis chains checked, among eight oracles, that a probe's first character appears in the output as often near a buffer edge as far from one. Nori's unigram mode with a space probe was the only hit not explained by design; the finder reduced it to the general punctuation leak and found the cause.
The method in full254 words
Method: bounded-exhaustive buffer-edge sweep with metamorphic oracles (the hunt's "semi-formal" tier), hunt unit "SPI analysis-chain fuzzing", 2026-10-26.
- The sweep built 91 analysis chains only through SPI names and legal parameters (
CustomAnalyzer) and analyzed, for every chain, filler alphabet (13), probe string (13) and insertion position around buffer and refill boundaries (255 to 16384), plus a control position far from any boundary: 900,263 checks per run, two runs. - Eight oracles: no exception, offsets in range, start offsets non-decreasing, final offset, position increments and lengths, no unpaired surrogates, probe preservation (W7: the probe's first code point occurs in the emitted terms as often as at the control position), and reuse identity.
- W7 fired on many chains; the hunt triaged the other hits as by design (filters that multiply, merge or drop tokens, the 255-character token limit, dictionary lemmas and transliterations that contain spaces). The
koreantokenizer withoutputUnknownUnigrams=trueand the space probe was the only W7 hit not explained by design and the only nori configuration with any violation: 71 rows in the first run (5 in the second), where the number of spaces in the emitted terms differed from the control position. That a space shows up in the terms at all is the leak. - The finder saw that the leak does not depend on buffer edges, cut it down by hand to the general punctuation leak, found the cause by reading
Viterbi.backtraceand the LUCENE-8784 diff, and wrote the public-API JUnit. Independent AI tools then reproduced and judged it (see the checks).
No prior report: two scripted prior-report search runs (81 queries), 19 manual GitHub searches, all 132 nori and Korean issues and PRs, the files of 408 open PRs, JIRA, the mailing lists, five downstream trackers and the web (2026-10-26); re-checked 2026-10-27. Related only: #9828 introduced it; #10020, #9739 and #15894 concern other behaviour.
Searches5 searches
No issue, PR, commit, JIRA entry, mailing-list thread or downstream report describes punctuation or whitespace unigrams with discardPunctuation=true, or the removed check. Searches are lexical, Korean-language blogs and Q&A are weakly covered, and private trackers are not visible, so this is "no report found".
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene) | 2026-10-26 | two runs of a scripted prior-report search (51 and 30 queries; identifiers such as outputUnknownUnigrams, discardPunctuation, shouldFilterToken, and symptom phrasings), 19 manual GraphQL searches, a title sweep of all 132 issues and PRs matching nori or Korean with the plausible ones opened; git log -S in the history clone | no report; #9828 (LUCENE-8784) is the introducing change, and none of its 17 comments mentions the unigram punctuation check (two are the commit notices of the NGRAM part-of-speech fix) |
| Open PRs | 2026-10-26 | the changed files of all 408 open apache/lucene PRs | 3 touch nori; #14969 edits ko/Viterbi.java but only the non-unigram branches |
| JIRA archive and mailing lists | 2026-10-26 | 12 JQL queries over LUCENE, SOLR and HIVEMALL; dev@, java-user@ and issues@ searches, every non-notification dev@ nori thread since 2019-05 | no report; the 2026 keepDecimalPoint proposal (#15894) is a different request |
| Downstream and the web | 2026-10-26 | Elasticsearch, OpenSearch, Solr, lucene-solr and Lucene.NET trackers and code; 16 web queries including Korean-language ones | no report; Elasticsearch and OpenSearch hard-code outputUnknownUnigrams=false; elastic/elasticsearch#80521 is about segmentation next to ), without unigrams |
| Re-check (GitHub, read-only) | 2026-10-27 | searches for outputUnknownUnigrams, nori punctuation, nori unigram and the newest KoreanTokenizer issues; the file on upstream main | nothing new; upstream main a95c754ee003 still adds unigrams unchecked |
A regression from LUCENE-8784 (a556925eb8a, branch_8x backport 990f62aca47, committed 2019-05-27), first released in 8.2.0. It fails on all 21 releases run from 8.2.0 to 10.5.2 and on main; it passes on 7.4.0 to 8.1.1, and on 8.2.0 with only the removed check restored.
Versions tested8 rows
Introduced by LUCENE-8784 (issue #9828, "Nori(Korean) tokenizer removes the decimal point."; author danmuzi, committer jimczi), committed from a JIRA patch with no pull request: a556925eb8a on master (first released in 9.0.0) and 990f62aca47 on branch_8x (the same Java changes, shipped in 8.2.0). It added discardPunctuation, made shouldFilterToken flag-aware and dropped the check from the unigram branch. LUCENE-10493 (c89f8a7ea1e, 10.0.0) moved the code into nori's Viterbi unchanged. git show a556925eb8a shows it removing the if around what are now Viterbi.java:232-234; whitespace-sensitive git blame -C -C -M credits those lines to it, while git blame -w credits their unchanged text to older commits. Nori's first code (LUCENE-8231: e851b89cbeb on master, and the branch_7x backport 8651fbaa8bc that 7.4.0 ships) had the check.
A second trigger from 8.10.0: LUCENE-10081 (4df8d641ace, a correct fix) stopped skipping runs of spaces, so the second space of ab cd lands in an unknown word and leaks. Reverting only it on 9.0.0 removes the space token and keeps the other leaks.
Static check: the unigram branch is checked in every tag from 7.4.0 to 8.1.1 and unchecked in every tag from 8.2.0 to 8.8.1 and 9.0.0 to 10.5.2 (the history clone lacks the 8.8.2 to 8.11.4 tags; those releases were executed). Not fixed upstream: main a95c754ee003 (2026-10-26) still adds unigrams unchecked.
| Version or commit | Verdict | Result |
|---|---|---|
main a9779ef3c193 | affected | the brief's test 4 of 6 fail (gradle and the standalone runner, assertions on and off); the finder's 3 of 4 |
| 10.5.2, 9.12.3 | affected | the brief's test 4 of 6 fail; the finder's 3 of 4; the independent reproducer's 4 of 6 |
| 8.10.0 to 8.11.4 (7 releases), 9.0.0, 10.0.0 | history probe | history probe fails (punctuation leaks); the doubled-space token appears too (LUCENE-10081) |
| 8.3.0 to 8.9.0 (9 releases) | history probe | history probe fails (punctuation leaks) |
| 8.2.0 | first affected | history probe fails; 8.x JUnit port 3 of 4 fail; the independent reproducer's 4 of 6 |
| 8.2.0 with only the check restored | isolates the line | history probe and 8.x JUnit pass; discardPunctuation=false still keeps every character |
| 7.4.0, 7.7.3, 8.0.0, 8.1.0, 8.1.1 | not affected | history probe passes; on 8.1.1 the 8.x JUnit port 4 of 4 and the independent reproducer's 6 of 6 pass |
| main + fix | not affected | all repros pass; the nori module's 93 tests and check pass |
An independent reproducer, a devil's advocate and an adjudication found it real with their own runs on main, assertions on and off. Three read-only review runs by another model family (two refute, one intended-behaviour) agreed on the root cause; the refute runs said "not real" only because they could not run code. Later audits and fact checks re-ran it.
Checks and verdicts478 words
Every verifier wrote or ran its own tests against the stock jars; the cross-model reviews could only read code, so no cross-model reviewer executed the repro.
| Check | Verdict | What it found |
|---|---|---|
| finder | candidate | JUnit fails 3 of 4 on main (assertions on and off), 10.5.2 and 9.12.3; passes 4 of 4 with the fix; upstream TestKoreanTokenizer 16 of 16 with the fix. |
| cross-model review, refute lens | not real (low), procedural | An independent reviewer from another model family (OpenAI GPT-5.6, reading only; this run before the candidate was filed, a second refute run in the cross-model review) could not run code, so it voted not real on procedure only ("not a substantive refutation"); it agreed with the root cause, found the inputs in contract and rated the intended case weak. A separate run re-ran the repro: 3 of 4 fail. |
| Independent reproducer | real | Own JUnit from the claim only: 4 of 6 fail on main (on and off), 8.2.0, 9.12.3 and 10.5.2; 6 of 6 on 8.1.1 and with its fix (user@host.org gives [u, s, e, r, @, h, o, s, t, ., o, r, g]); also through the factory and KoreanAnalyzer; kuromoji EXTENDED drops the same characters. |
| Cross-model review, the lenses | intended lens real (medium); refute lens procedural | OpenAI GPT-5.6, reading only. Its AI tool's runs: the repro fails on main (on and off), the bisect passes on 8.1.1 and fails on 8.2.0, the fix passes 4 of 4, and a four-configuration token dump shows the leak only with unigrams on. |
| Devil's advocate | real (high) | Built the strongest works-as-designed case and showed why it fails; re-ran the repro (3 of 4 fail, on and off) and its own probe, also through KoreanAnalyzer. |
| adjudication | confirmed (high) | Re-ran the independent reproduction's JUnit with assertions on (4 of 6 fail), the finder's with assertions off (3 of 4 fail) and the independent reproduction's fix (6 of 6 pass); checked the root cause in the source and the history clone. |
| Follow-up, audit and registration check | keep | History runs on 26 releases and main with causality runs on 8.2.0 and 9.0.0; the security probe; the audit re-ran the finder's test with a fresh seed (3 of 4 fail, on and off); the registration check re-ran it on a9779ef3c193 (still reproduces). |
| fact checks of this doc (2026-10-27) | bug holds; text errors fixed | An independent fact check re-ran the brief's gradle command (assertions on, off and not set: 4 of 6 fail each time), the fix (module: 93 tests, 4 skipped; nightly: 23 of 23; tidy a no-op), the standalone runner on main and 10.5.2, and the history probe (8.1.1 clean, 8.2.0 leaks, 8.2.0 with only the check restored clean, 9.12.3 leaks). A cross-model fact check (reading only) found no high-severity error. Their text corrections are applied. |
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 477 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,450 words
- Registry title
- KoreanTokenizer with outputUnknownUnigrams=true emits every punctuation, symbol and whitespace character inside an unknown word as its own token ('$', '@', '.', ' ', '\t', '\n') although discardPunctuation=true (the default): LUCENE-8784 (8.2.0) dropped the punctuation check from the unigram branch
- Area
- analysis / nori KoreanTokenizer (Viterbi.backtrace unigram branch; KoreanTokenizerFactory, KoreanAnalyzer)
- Priority
- Medium, rank 139: With nori's opt-in outputUnknownUnigrams, ordinary Korean text with prices, e-mails or line breaks deterministically gets extra punctuation tokens that break phrase matching, the same opt-in CJK silent-analysis profile as LFV-360.
- Impact class
- silent wrong results
- Impact note
- Applications that analyze Korean text with KoreanTokenizer, the 'korean' tokenizer factory or KoreanAnalyzer with the opt-in outputUnknownUnigrams=true (default false) and the default discardPunctuation=true, in every release from 8.2.0 to 10.5.2 and main, index every punctuation, symbol, format or whitespace character that falls inside an unknown-word run (prices such as '$100', e-mail addresses, line breaks, tabs and, since 8.10.0, doubled spaces) as its own one-character term with part-of-speech tag SY, which the default stop tags do not remove; each such term takes a position, so exact-phrase and other position queries across it miss the document (executed), queries analyzed with the same analyzer gain such terms, and no term the fixed code emits is lost; no exception, no index damage, ordinary text at any size; Elasticsearch and OpenSearch nori settings cannot enable the option (read in source).
- Summary
- You analyze '가격: $100' or '다음\n\n문장' with KoreanTokenizer(factory, null, DISCARD, outputUnknownUnigrams=true, discardPunctuation=true), or with the 'korean' factory given only outputUnknownUnigrams="true", and expect punctuation and whitespace to be dropped, as the discardPunctuation javadoc says ('true if punctuation tokens should be dropped from the output') and as happens with outputUnknownUnigrams=false ([가격, 100], [다음, 문장]) and in 7.4.0-8.1.1. Instead you get [가격, $, 1, 0, 0] and [다음, \n, \n, 문장], and 'test@example.com' keeps its '@' and '.': the unigram branch of nori's Viterbi.backtrace adds each single-character token of an unknown word without the shouldFilterToken check that the other branch applies. Every such character becomes an index term and pushes the following words one position further, so an exact phrase across it no longer matches.
- Introduced
- a556925eb8aaea61e55010a6e5c90520139b56c3 (master; first released in 9.0.0) and its branch_8x backport 990f62aca47fc0571c79e80f25ee1d0b7dc4a824 (same Java changes; the commit 8.2.0 ships): LUCENE-8784 'The KoreanTokenizer now preserves punctuations if discardPunctuation is set to false (defaults to true)' (GitHub issue apache/lucene#9828; committed from a JIRA patch attachment, no pull request; author danmuzi, committer jimczi). In KoreanTokenizer.backtrace it replaced 'if (shouldFilterToken(token) == false) { pending.add(token); ... }' in the outputUnknownUnigrams branch with an unconditional pending.add(token), in the same diff that made shouldFilterToken return 'discardPunctuation && isPunctuation(...)' [read: git show; git log -S shouldFilterToken finds no other change to the guard; git blame -C -C -M on today's Viterbi.java:232-234 still attributes the lines to a556925eb8a] [exec: on 8.2.0, restoring only the removed guard turns the probe and the 8.x JUnit from fail to pass, and discardPunctuation=false still keeps punctuation with or without the guard]. The code moved unchanged into nori Viterbi.java with LUCENE-10493 (c89f8a7ea1e, #805, first released in 10.0.0). Regression: the original nori code (LUCENE-8231, e851b89cbeb, 7.4.0) filtered unigrams. LUCENE-10081 (4df8d641ace, #272, released in 8.10.0 and 9.0.0) later widened the inputs that reach the branch (the second of two spaces now lands in an unknown word); it is a correct fix, not a second root cause [exec: reverting only it on 9.0.0 removes the ' ' token and leaves the '$', newline, '@' and '.' leaks] (2019-05-27 (commit date of a556925eb8a; authored 2019-05-24))
- First affected version
- 8.2.0 [exec: the version-neutral main() probe, the 8.x JUnit port (3 of 4 fail), the independent reproduction's JUnit (4 of 6 fail) and the cross-model review's bisect all fail]; 8.1.1 is the last unaffected release [exec: probe, 8.x JUnit 4 of 4 and the independent reproduction's JUnit 6 of 6 pass]; nori first shipped in 7.4.0, which is unaffected [exec]
- Affected versions
- every release from 8.2.0 through 10.5.2 (8.2.0-8.11.4, 9.0.0-9.12.3, 10.0.0-10.5.2) and main ea57360912ed. [exec, bug present]: 8.2.0, 8.3.0, 8.4.0, 8.5.0, 8.6.0, 8.7.0, 8.8.0, 8.8.1, 8.8.2, 8.9.0, 8.10.0, 8.10.1, 8.11.0, 8.11.1, 8.11.2, 8.11.3, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 and main (follow-up probe); the finder's JUnit also fails on main with -ea and -da, on 9.12.3 and on 10.5.2; the independent reproduction's JUnit fails on 8.2.0, 9.12.3, 10.5.2 and main (-ea and -da). [exec, bug absent]: 7.4.0, 7.7.3, 8.0.0, 8.1.0, 8.1.1. [read]: the unigram-branch guard is present in every 7.4.0-8.1.1 release tag and absent in every 8.2.0-8.8.1 and 9.0.0-10.5.2 tag of the history clone (the clone lacks tags 8.8.2-8.11.4, which were executed); upstream main 49483e82b357 (2026-10-26) and branch_10x still have the unguarded add; not fixed anywhere. Caveats: all runs on JDK 25 with checksum-verified release jars; releases before 9.1 ran a version-neutral main() probe and a JUnit port with the pre-9.1 test-framework package; releases between the executed ones (for example 9.1.0-9.12.2 and 10.1.0-10.5.1) were checked by tag scan only; the doubled-space symptom starts in 8.10.0 (LUCENE-10081)
- 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; also reached by code reading
- Upstream and prior reports
- none found (2026-10-26, follow-up prior-report record verdict new: two scripted prior-report searches (run-a: 51 queries, 16 GitHub issue/PR, 3 commit, 2 downstream, 20 mailing-list, 4 JIRA, 6 web; run-b: 30 list, JIRA and web queries, its GitHub part aborted by the shared search throttle and redone by hand), 19 manual GitHub GraphQL searches, a title sweep of all 132 apache/lucene issues and PRs matching nori or Korean, the file lists of 408 open PRs, manual JIRA searches (LUCENE, SOLR, HIVEMALL), dev@, java-user@ and issues@ searches, the Elasticsearch, OpenSearch, Solr, lucene-solr and Lucene.NET trackers and code, and the web (16 recorded queries, including Korean-language ones); the finder's 44 and the intended-behaviour check's 39 scripted prior-report search queries agree): no issue, PR, commit, JIRA entry, list thread or downstream report describes punctuation or whitespace unigrams with discardPunctuation=true, or the removed guard; not fixed: upstream main 49483e82b357 and branch_10x still have the unguarded pending.add, and no open PR touches the unigram branch (#14969 edits only the non-unigram branches); related, not reports of this bug: #9828 (LUCENE-8784, the introducing change), db334c792be (same issue, restored the NGRAM part-of-speech tag that the patch had changed 'inadvertently'), #10020 (LUCENE-8977, punctuation grouping with discardPunctuation=false; a maintainer wrote 'Punctuations are removed by default'), #9739 (LUCENE-8693, the opposite request), #15894 and the closed PR #15895 (keepDecimalPoint option), #10009 (LUCENE-8966); gaps: GitHub and list search are lexical, Korean-language blogs and Q&A are weakly covered, private trackers are not visible
- Lucene commit tested
- main
a9779ef3c193(commit of 2026-10-26; tested 2026-10-27) through gradle in a scratch worktree and through the standalone JUnit runner, with assertions on and off; the release jars 10.5.2 and 9.12.3 with the same test; the earlier runs onea57360912ed(nori and kuromoji unchanged since) and the release history: 7.4.0 to 8.1.1 not affected, every one of 21 releases run from 8.2.0 to 10.5.2 affected, and 8.2.0 with only the removed check restored not affected. The fix was validated in the scratch worktree: the new test; the module's upstream nori tests with assertions on and off (83 ran; 3 nightly tests and 1@Ignored test were skipped); the nightly run ofTestKoreanTokenizer, which adds its random-data test (84 distinct upstream tests in all); and:lucene:analysis:nori:check.
- Finding record
lucene/analysis/nori/src/java/org/apache/lucene/analysis/ko/Viterbi.java:214-236 (unigram branch, unguarded pending.add at :232), :251 (guarded add in the other branch), :412-414 (shouldFilterToken)lucene/analysis/nori/src/java/org/apache/lucene/analysis/ko/KoreanTokenizer.java:52 (@lucene.experimental), :102-110 (4-argument constructor, discardPunctuation=true), :118-119 and :149-150 (discardPunctuation javadoc)lucene/analysis/nori/src/java/org/apache/lucene/analysis/ko/KoreanTokenizerFactory.java:60-61 (parameter docs), :96-97 (defaults false and true)lucene/analysis/nori/src/java/org/apache/lucene/analysis/ko/KoreanAnalyzer.java:57-74 (public constructor with outputUnknownUnigrams)lucene/analysis/nori/src/java/org/apache/lucene/analysis/ko/dict/UnknownDictionaryBuilder.java:31 (NGRAM entry, part of speech SY)lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/ViterbiNBest.java:448 (kuromoji filters each unigram with '!discardPunctuation || !isPunctuation(...)')
- The finding in 30 seconds
lucene/analysis/nori/src/java/org/apache/lucene/analysis/ko/Viterbi.java:214-260, 412-445 (a9779ef3c193)lucene/analysis/nori/src/java/org/apache/lucene/analysis/ko/dict/UnknownDictionaryBuilder.java:31; KoreanPartOfSpeechStopFilter.java:36-68lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/ViterbiNBest.java:436-460
- Who is affected
lucene/analysis/nori/src/java/org/apache/lucene/analysis/ko/KoreanTokenizer.java:52, 92-110, 112-136; KoreanTokenizerFactory.java:46-47, 60-64, 96-97; KoreanAnalyzer.java:33, 57-79
- Why it looks unintended
lucene/analysis/nori/src/java/org/apache/lucene/analysis/ko/KoreanTokenizer.java:102, 118-119, 149-150; KoreanTokenizerFactory.java:60-61lucene/CHANGES.txt:6318-6320 (Lucene 8.2.0, New Features)lucene/analysis/nori/src/test/org/apache/lucene/analysis/ko/TestKoreanTokenizer.java:75-93, 366-372, 374-405
- Suggested fix
lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/ViterbiNBest.java:448
- TestKoreanUnigramsDiscardPunctuation.java (JUnit test):
repro/LFV-444/src/org/apache/lucene/analysis/ko/TestKoreanUnigramsDiscardPunctuation.java(36 lines)
Made in parallel with this doc from executed probes on main a9779ef3c193 (stock and with the fix shadowed). An independent diagram check compared the diagram with the code and the executed runs.
assets/LFV-444/bug.png(477 KB)
![Summary of the bug: nori KoreanTokenizer with outputUnknownUnigrams=true and the default discardPunctuation=true analyzes the price 가격: $100 as [가격, $, 1, 0, 0] instead of [가격, 1, 0, 0]. The unknown words $ and 100 take the unigram branch of Viterbi.backtrace, which adds each character without the shouldFilterToken check that the other branch applies, so $ takes position 1 and the exact phrase 가격 100 finds 0 documents instead of 1. Releases 8.2.0 to 10.5.2 and main; 8.1.1 dropped the $. Fix: check each unigram with shouldFilterToken.](../assets/LFV-444/bug.png)