Chinese text can contain full-width Latin letters and digits (Lucene, 2026). SmartChineseAnalyzer indexes them as plain ASCII, but the query-time shortcut that the parsers use for partial words leaves that step out.
- Index
我们使用Lucene("we use Lucene", the Latin word in full-width letters, which Chinese text can contain) with the stocknew SmartChineseAnalyzer(). Its tokenizer turns full-width letters and digits into ASCII and lower-cases them, so the index holds我们,使用andlucen(Porter-stemmed). - Search
Luc*withQueryParser. A partial word cannot go through the full analyzer, so the parser callsAnalyzer.normalize, documented to return "the representation that it would have in the index". - You expect 1 hit, the same as
Luc*and the plain term queryLuceneget. - You get 0 hits, no error.
SmartChineseAnalyzer.normalizeonly lower-cases, so the query becomesluc*, still full-width, and no indexed term starts with it. Called directly,normalize("ABC")returnsabcandnormalize("123")returns123, while the index holdsabcand123. - The same miss shows for wildcard (
Lu?en), fuzzy (Lucen~1), regexp and range ([Lua TO Luz]) terms in the classic and flexible parsers and for the simple parser's prefix and fuzzy terms, whichever width the document used; fuzzy terms can still hit when the edit distance absorbs the width difference. Parsed term and phrase queries run the full analyzer and are unaffected.
Applications that index Chinese or mixed Chinese-English text with the stock SmartChineseAnalyzer and accept prefix, wildcard, fuzzy, regexp or range syntax through Lucene's query parsers. The query term must hold full-width letters or digits next to an ASCII operator, for example pasted full-width text plus an added *. The only symptom is silently fewer hits.
Mechanism, terms and numbers742 words
SmartChineseAnalyzer does its case and width handling inside the tokenizer: HMMChineseTokenizer's SegTokenFilter turns full-width Latin letters and digits into ASCII and lower-cases A-Z (SegTokenFilter.java:39-62), and createComponents has no LowerCaseFilter (SmartChineseAnalyzer.java:124-134). So 我们使用Lucene is indexed as 我们, 使用, lucen. SmartChineseAnalyzer.normalize(), which the classic and flexible query parsers use for prefix terms, wildcard chunks, fuzzy terms, regexps and range bounds (the simple parser for its prefix and fuzzy terms), returns only new LowerCaseFilter(in) (:137-140), although Analyzer#normalize is documented to give "the representation that it would have in the index", without tokenizing or stemming (Analyzer.java:203-208). The result: Luc*, Lucen~1 and [Lua TO Luz] find nothing while Luc* and the term query Lucene find the document, and normalize("ABC") returns abc while the index holds abc. A secondary, narrower direction: normalize lower-cases non-ASCII capitals such as É that the index keeps. Parsed term and phrase queries and the indexed data are unaffected. Every release from 6.2.0 to 10.6.0 (10.6.0 by reading) and main has it. The fix makes normalize() apply the tokenizer's folding; it changes only the query side, so no reindex is needed.
- Full-width forms: the letters
A-Z,a-z(U+FF21-U+FF3A, U+FF41-U+FF5A) and digits0-9(U+FF10-U+FF19), each as wide as a Chinese character. Chinese input methods produce them in full-width mode, giving mixed text such as我们使用Luceneor2026年. SegTokenFilter: part ofHMMChineseTokenizer, so it runs inside the tokenizer, not as a token filter. For full-width tokens it subtracts0xFEE0from each character (full-width to ASCII) and then lower-cases A-Z; for ASCII tokens it lower-cases A-Z (SegTokenFilter.java:39-62; javadoc "Full-width latin will be converted to half-width, then all latin will be lowercased").createComponentsadds noLowerCaseFilter: "LowerCaseFilter is not needed, as SegTokenFilter lowercases Basic Latin text" (SmartChineseAnalyzer.java:128).Analyzer.normalize(field, text): what the query parsers call for a partial word, because the full analyzer would tokenize or stem it. It runs the analyzer'snormalize(String, TokenStream)override, which should hold the normalizing steps and leave out the tokenizer and the stemmer (Analyzer.java:53-58,:203-212).- Multi-term queries: prefix (
Luc*), wildcard (Lu?en), fuzzy (Lucen~1), regexp (/luc.*/) and range ([Lua TO Luz]). Parsed term and phrase queries run the full analyzer and are not affected.
SmartChineseAnalyzer.normalize()isreturn new LowerCaseFilter(in);(SmartChineseAnalyzer.java:137-140). Because the folding lives in the tokenizer, whichnormalizeskips, it is lost.- The classic parser passes the prefix through it (
QueryParserBase.java:712,BytesRef term = getAnalyzer().normalize(field, termStr);), and likewise the fuzzy term (:729), the regexp (:683), each wildcard chunk (:646,:656) and the range bounds (:550,:556; an open*bound is skipped,:547-557). The flexible parser's processors do the same (range bounds only when they do not parse as dates,TermRangeQueryNodeProcessor.java:100-139). The simple parser has only prefix and fuzzy syntax and normalizes both (SimpleQueryParser.java:573,:605). Luc*therefore becomesbody:luc*. The index holdslucen, so the prefix cannot match any term.LowerCaseFilteralso appliesCharacter.toLowerCaseto every code point, while the index lower-cases only A-Z, sonormalize("É")isébut the index keepsÉ. This secondary direction matters for single-letter terms, because the tokenizer emits such letters one per token, and for range bounds:[Аz TO Яz](Cyrillic capitals) built fromnormalizeadmitsбinstead of the indexedБ.- The sibling analyzers fold width in normalization:
CJKAnalyzer.normalizeisCJKWidthFilter+LowerCaseFilter(CJKAnalyzer.java:101-106), andJapaneseAnalyzerappliesCJKWidthCharFilterininitReaderForNormalization(JapaneseAnalyzer.java:130-133).
One document 我们使用Lucene, default SmartChineseAnalyzer, classic QueryParser: Luc* and Lucen~1 find 0 documents; Luc*, Lucen~1 and the term query Lucene find 1. normalize("ABC") = abc (index abc), normalize("123") = 123 (index 123). The hunt's scan found 76 of 285 single-token words that disagree; every one of the 73 that the parser scan checked (the other 3 were not in its word set) was missed by classic and flexible prefix, wildcard, regexp and range queries and by simple-parser prefix queries, while fuzzy still hit 61 within the edit distance. An exhaustive scan of every BMP character indexed as one token found 1,141 of 61,527 disagreeing (the 62 full-width letters and digits and 1,079 non-ASCII capitals) and all 2,704 two-letter full-width words.
Exposure and likelihood410 words
- Who is exposed. Applications that use the stock
SmartChineseAnalyzer, the default constructor included, and pass user text throughQueryParser,StandardQueryParserorSimpleQueryParser(prefix and fuzzy only), or callAnalyzer.normalizethemselves. Analyzer wrappers delegatenormalizeto the wrapped analyzer (AnalyzerWrapper.java:123-127), so wrapped uses inherit it unless the wrapper adds its own normalization step (as the workaround below does). The class targets "Chinese or mixed Chinese-English text" (SmartChineseAnalyzer.java:33). - Default, not opt-in. Default analyzer and default parser settings. The only choices involved are the smartcn analyzer (an optional module, marked
@lucene.experimental) and multi-term syntax. - What it takes. Full-width letters or digits in the query term next to an ASCII
*,?,~,/or range syntax: mixed-width input, or an application that appends*or builds a range around user text. By the classic grammar, a full-width*is an ordinary character, soLuc*typed entirely in full-width mode becomes the plain term querybody:luc, not a prefix query, and does not matchlucen(QueryParser.jj:183-187,:208-211; executed). Documents written in full width are still found by half-width queries that fit the stemmed term:Luc*finds both widths,Lucene*neither, because the index holdslucen. The registry rates the likelihood rare; no report was found in the searched public sources. - What the damage looks like. Prefix terms, and wildcard or regexp literals, that require a full-width letter or digit cannot match any indexed term: 0 hits, no error, whichever width the document used. Range bounds compare in the wrong form and can drop or add terms (
[a TO z]admitted 7 of 8 probe documents; the indexed-form[a TO z]admits 3). Fuzzy terms can still hit within the edit distance, so say "miss", not "never match". - Scale. One document and one query are enough. All 62 full-width letters and digits and all 2,704 two-letter full-width words normalize differently from how they are indexed.
- Downstream (code reading only, nothing run). Elasticsearch's analysis-smartcn plugin registers the stock analyzer, and its
query_stringparser normalizes range, fuzzy, prefix and wildcard terms with the field's analyzer, so it would inherit the miss; another downstream server's smartcn plugin also registers the stock class. - Limit. After the fix, prefixes that run past the Porter stem stay best effort, as with every stemming analyzer (
Lu?enemisses even with the fix, becauseLuceneis indexed aslucen). This bug is only the width and case mismatch.
The test shows that SmartChineseAnalyzer indexes the full-width word ABC as abc, but normalize("f", "ABC") (used by the query parsers for prefix, wildcard, regexp, range and fuzzy terms) keeps it full-width.
/*
* 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.cn.smart;
import org.apache.lucene.analysis.Analyzer;
import org.apache.lucene.tests.analysis.BaseTokenStreamTestCase;
public class TestSmartChineseNormalizeFullWidth extends BaseTokenStreamTestCase {
public void testNormalizeMatchesIndexedTerm() throws Exception {
try (Analyzer a = new SmartChineseAnalyzer()) {
assertAnalyzesTo(a, "ABC", new String[] {"abc"});
// expected: normalize returns the indexed term "abc"; actual: "abc" (full-width kept)
assertEquals("abc", a.normalize("f", "ABC").utf8ToString());
}
}
}
# in a copy of this finding's repro folder (repro/LFV-565/, 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/smartcn/build/libs/lucene-analysis-smartcn-$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.cn.smart.TestSmartChineseNormalizeFullWidth
- Expected vs actual
- Expected:
normalizereturns the indexed termabc. Actual:abc.
More files, environment, full output32 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: expected:<[abc]> but was:<[abc]>
org.junit.Assert.assertEquals(Assert.java:117)
org.junit.Assert.assertEquals(Assert.java:146)
org.apache.lucene.analysis.cn.smart.TestSmartChineseNormalizeFullWidth.testNormalizeMatchesIndexedTerm(TestSmartChineseNormalizeFullWidth.java:28)Analyzer#normalizemust return "the representation that it would have in the index", minus only tokenizing and stemming. Width and case folding are neither.- The index-side folding is documented and tested:
testFullWidthassertsTestsbecomestestand1234becomes1234. - The same 2016 commit gave
CJKAnalyzer.normalizewidth folding;SmartChineseAnalyzeris the outlier.
The full argument243 words
- The contract.
Analyzer.java:203-208: "Normalize a string down to the representation that it would have in the index. This is typically used by query parsers in order to generate a query on a given term, without tokenizing or stemming". The class example (:53-58) keeps a filter that is "about normalization" and drops one that is "about stemming". - The analyzer documents its folding as index behaviour.
SegTokenFilter's javadoc ("Full-width latin will be converted to half-width") and theSmartChineseAnalyzer.java:128comment;TestSmartChineseAnalyzer.testFullWidth("Full width alphas and numerics are folded to half-width",:160-170) asserts it. - Design intent. On LUCENE-7355 (#8409), jpountz, who added
normalize, described it as applying "all normalization filters (lowercasing, ascii folding, etc.) but not transformations (stop word removal, stemming, etc.)". - Siblings. The same commit gave
CJKAnalyzer.normalizeCJKWidthFilter+LowerCaseFilterandJapaneseAnalyzerwidth folding (today throughinitReaderForNormalization).SmartChineseAnalyzer, whose folding is hidden in the tokenizer, got onlyLowerCaseFilter. - Tests. No smartcn test calls
normalize;BaseTokenStreamTestCaseonly checks that it does not throw (:1478). Nothing pins the current output. - Precedents fixed as bugs. LUCENE-8186 (#9234, fixed in 7.4 and 8.0):
CustomAnalyzerwith a lower-casing tokenizer did not normalize multi-term queries, fixed so thatnormalizeapplies the tokenizer's normalization; LUCENE-7606 (#8657, fixed in 6.4 and 7.0):CustomAnalyzer.normalizeapplied only the last token filter. Separately, in LUCENE-8022 (#9070, closed as Invalid) a maintainer told a user that their analyzer must overridenormalize()to mirror its index-time lower-casing.
The devil's-advocate check313 words
The strongest "intended" case (the cross-model reviews rated it moderate; the independent reproducer rated it weak and the devil's-advocate verifier rejected it): SmartChineseAnalyzer is @lucene.experimental; normalize deliberately skips tokenization, and here the folding lives in the tokenizer; the classic parser's javadoc says "normal Analyzers are unlikely to work properly with wildcard templates" (QueryParserBase.java:611, :696; :667 says the same of "regular expression templates"), and a comment in its wildcard code calls keeping wildcard and escaped characters away from normalize a "best effort" (:638); and LowerCaseFilter keeps roughly what the parsers did before 6.2 (lowercaseExpandedTerms).
Why it fails: "experimental" marks API stability, not licence for query and index to disagree; the javadoc excludes tokenizing and stemming, not character folding, and LUCENE-8186 made normalize mirror a tokenizer's normalization; the wildcard javadoc predates LUCENE-7355 and is about splitting templates, while the prefix code a few lines below calls normalize (:712); back-compat with the old lower-casing makes the behaviour long-standing, not intended, since LUCENE-7355 aimed beyond lower-casing and added width folding wherever the index chain visibly folds width. No test, discussion or decision supporting the current output was found. Verdict: a defect. What the counter-case gets right: a fix changes long-standing query behaviour; the recommended fix copies the tokenizer's private folding rules into a second place; full-width text next to ASCII operators is unusual input; and the old parser lower-casing (String.toLowerCase(locale)) is close to, not identical with, LowerCaseFilter (per-code-point Character.toLowerCase; they differ for locale-sensitive and contextual mappings such as the Turkish dotless i and the Greek final sigma). These argue for a careful, tested change, not for keeping the mismatch. For the secondary É direction one could argue the index side deviates (SegTokenFilter's javadoc says "all latin will be lowercased" while the code lower-cases only A-Z), so present that direction as a question.
Recommended normalize() applies the tokenizer's folding (validated)
Make normalize() apply the tokenizer's folding: full-width letters and digits to ASCII, then lower-case A-Z only (a small private filter replacing LowerCaseFilter). It changes only the query side, so no reindex is needed. With it the repro and all 23 smartcn tests pass, and normalize agrees with indexing on all 61,527 single-character BMP tokens tested.
All options and trade-offs4 options · 924 words
Two fixes were built and checked in a scratch worktree of main ea57360912ed; A mirrors the index exactly and is recommended. The other options are a larger refactoring and a workaround.
Replace new LowerCaseFilter(in) with a private filter that does what SegTokenFilter does at index time, restricted to the three ranges that Utility.getCharType puts in the full-width classes (U+FF10-U+FF19, U+FF21-U+FF3A, U+FF41-U+FF5A). The LowerCaseFilter import goes; TokenFilter and CharTermAttribute are imported (+36/-2, formatted by tidy). Same logic as the finder's fix sketch. A regression test: the repro's class, or its assertions added to TestSmartChineseAnalyzer next to testFullWidth.
@Override
protected TokenStream normalize(String fieldName, TokenStream in) {
return new SegTokenNormalizationFilter(in);
}
/**
* Applies the case and width folding that SegTokenFilter applies at index time: full-width Latin
* letters and digits become half-width, then only Basic Latin A-Z is lower-cased.
*/
private static final class SegTokenNormalizationFilter extends TokenFilter {
private final CharTermAttribute termAtt = addAttribute(CharTermAttribute.class);
SegTokenNormalizationFilter(TokenStream in) {
super(in);
}
@Override
public boolean incrementToken() throws IOException {
if (input.incrementToken() == false) {
return false;
}
final char[] buffer = termAtt.buffer();
for (int i = 0; i < termAtt.length(); i++) {
char c = buffer[i];
if ((c >= 0xFF10 && c <= 0xFF19)
|| (c >= 0xFF21 && c <= 0xFF3A)
|| (c >= 0xFF41 && c <= 0xFF5A)) {
c -= 0xFEE0;
}
if (c >= 'A' && c <= 'Z') {
c += 0x20;
}
buffer[i] = c;
}
return true;
}
}Trade-offs
- Query side only: the index already holds the folded form, so no reindex.
- Behaviour change: the tested full-width multi-term queries now hit (the intent); a full-width
*or.stays a literal character (Lu*?becomeslu*?, 0 hits; executed). Non-ASCII capitals are no longer lower-cased innormalize, soÉ*matches the indexedÉinstead ofé; consistent with the index, but worth raising as a question with maintainers. - Cost: one character loop per normalized query term.
- Maintenance: the filter copies the tokenizer's private folding rules (
Utility.getCharType,SegTokenFilter), so the two can drift apart again unless a test pins them together. - The override has not changed since 2016, so the same patch should apply to maintained branches; only main was built and tested.
- Prefixes that run past the Porter stem stay best effort, as with every stemming analyzer.
What the checks validated
- Scratch worktree at
ea57360912ed, gradle, JDK 25: the new test fails 2 of 2 on stock and passes with the fix;:lucene:analysis:smartcn:checkpasses (checkGoogleJavaFormat, ecjLint, forbiddenApis, and 3 suites / 23 tests:TestSmartChineseAnalyzer19,TestHMMChineseTokenizerFactory2, the new 2); the module tests pass with assertions off (seed 77AA0565), and the new test 20 iterations (40 tests). - Standalone runner with the fix shadowed: the new test 2 of 2 (assertions on and off), the QueryParser test 3 of 3, the independent reproducer's 5 of 5, the security probe 11 of 11; the exhaustive scan finds 0 of 61,527 single-character tokens and 0 of 2,704 two-letter full-width words disagreeing, and a scan of U+FF00-U+FFEF 0 of 202 (not scanned: supplementary characters and mixed, escaped, wildcard or regexp inputs). The finder's equivalent sketch passed the upstream
TestSmartChineseAnalyzer(19 of 19) earlier. - These standalone runs shadowed the fix before
tidyre-wrapped itsifcondition (same logic). The fact check re-ran the new test (assertions on and off), the QueryParser test and the security probe with the final source (brief/fix/src-a); all pass.
TokenStream result = new CJKWidthFilter(in); result = new LowerCaseFilter(result); return result; (+4/-1). The repro passes and :lucene:analysis:smartcn:check passes (23 tests).
TokenStream result = new CJKWidthFilter(in);
result = new LowerCaseFilter(result);
return result;Trade-offs
Smaller and reuses an existing filter, but it does not mirror the index: with it, the exhaustive scan still finds 1,138 of 61,527 single-character tokens disagreeing. That is the 1,079 non-ASCII capitals as before, plus 59 new ones in U+FF65-U+FF9F, which the index keeps half-width: CJKWidthFilter maps the half-width katakana and middle dot to their full-width forms, and the two half-width sound marks U+FF9E and U+FF9F to the combining marks U+3099 and U+309A, or merges them into a preceding katakana (CJKWidthFilter.java:67-76). It also folds full-width punctuation (U+FF01-U+FF5E), so a full-width * or . inside a wildcard or regexp term becomes an ASCII operator: Lu*? becomes lu*? and /Luc.*/ becomes /luc.*/, and both match lucen (executed). The independent reproducer's É test still fails.
The direction of an unmerged 2009 patch on LUCENE-2023 (#3098), written for speed: split SegTokenFilter's lower-casing and full-width conversion into filters, which normalize could then reuse.
Trade-offs
By inference (not run): it could change indexed terms for some inputs and would then need a back-compat story (reindex), so it is a larger change than the query-side fix.
SmartChineseAnalyzer is final, so wrap it: an AnalyzerWrapper whose getWrappedAnalyzer returns the stock analyzer and whose wrapTokenStreamForNormalization returns new CJKWidthFilter(in) (it runs after the stock LowerCaseFilter). Indexing is unchanged. On the stock main jars, with assertions on and off, Luc*, Lucen~1 and [Lua TO Luz] then find the document. Or type multi-term queries in half-width (Luc*).
Trade-offs
No reindex. It carries the CJKWidthFilter caveats of the previous option (half-width katakana; full-width * and . become operators) and leaves the É direction as it is, because the stock LowerCaseFilter runs first (É* becomes é*, 0 hits; executed).
A restriction written as a parsed multi-term query goes through the same normalize(), so a full-width pattern never equals an indexed term, and a MUST_NOT rule such as -Luc* removes nothing. Lucene's SECURITY.md (lines 28, 50) leaves authorization to the application and promises no untrusted-input guarantee here, so it is out of scope; no exploit is known.
A bounded-exhaustive metamorphic scan of the stock analyzers: normalize(w) must equal the single token indexing stores for w. SmartChineseAnalyzer disagreed on 76 of 285 single-token words (full-width letters and digits, and capitals such as É). A second scan showed the misses through the query parsers, and code reading found the cause.
The method in full227 words
Method: a bounded-exhaustive metamorphic scan on the stock main jars (the study's "semi-formal" tier). A hunt unit, "analyzer normalize consistency", 2026-10-26.
- Relation N1:
Analyzer.normalize(f, w)equals the single full-span token that indexing produces forw, checked over bounded word sets for 99 analyzer configurations (every public stockAnalyzerwith a no-arg constructor in the module jars, 5 hand-built ones, and stemmer-free variants of 38 language analyzers).SmartChineseAnalyzerdisagreed on 76 of 285 single-token words: full-width Latin letters and digits, and non-ASCII capitals such asÉ,Ø,İ,ẞ. - Relation N2 (parser reachability): each disagreeing word, used as a prefix, wildcard, fuzzy, regexp and range query through the classic, flexible and simple parsers, must still find its document. All 73 checked disagreeing words were missed by classic and flexible prefix, wildcard, regexp and range queries and by simple-parser prefix queries; fuzzy still hit 61. Every agreeing word was found. The flexible parser's multi-field mode, which has a separate bug of its own, differs: there prefix and wildcard hit 19 of the 73 and missed 79 agreeing words.
- Code reading then located the cause: the folding lives in the tokenizer's
SegTokenFilter, whichnormalizeskips. - An AI finder wrote the public-API JUnit; independent AI tools then reproduced and judged it, and an exhaustive scan over every BMP character confirmed the scope (see the checks).
No prior report: 198 queries over GitHub issues, PRs and commits, the mailing lists, the JIRA archive (every smartcn issue title read) and the web in English and Chinese (2026-10-26); 28 hits read, none this bug. Closest: LUCENE-2023 (#3098), an unmerged 2009 speed refactoring of the same folding.
Searches6 searches
No issue, PR, commit, JIRA entry, mailing-list thread, downstream issue or web page reports this bug, by anyone. #8409 (LUCENE-7355), which added the override, has 34 comments and none mentions smartcn or width folding. None of the 406 open PRs changes normalize. Upstream main a95c754ee003 (2026-10-26) still returns new LowerCaseFilter(in).
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues and PRs (apache/lucene), commits | 2026-10-26 | four runs of a scripted prior-report search (160 queries in total, including downstream, list and JIRA queries) plus 7 manual GitHub searches and open-PR lists: class names, normalize with full-width, fullwidth, wildcard, SegTokenFilter, HMMChineseTokenizer, Analyzer#normalize | no report; #8409 added the code and never mentions smartcn |
| Open PRs | 2026-10-26 | the touched paths of all 406 open apache/lucene PRs | #681 (module-info lint) and #202 (WordDelimiterFilter removal) touch smartcn; neither changes normalize |
| JIRA archive | 2026-10-26 | 6 JQL searches over LUCENE and SOLR; all 56 LUCENE and 16 SOLR smartcn issue titles read | no report |
| Mailing lists (lists.apache.org) | 2026-10-26 | 17 manual searches over java-user@, dev@ and issues@ (smartcn, SmartChineseAnalyzer, HMMChineseTokenizer, fullwidth, full-width, halfwidth), all distinct subjects read | no report |
| Downstream repositories and the web | 2026-10-26 | downstream-repository queries; 8 web searches in English and Chinese | no report; the closest downstream items concern normalizer wildcard escaping and Japanese numbers |
| Related items (read) | 2026-10-26 | 12 issues read in full | #3098 (LUCENE-2023, open speed refactoring), #9234 (LUCENE-8186) and LUCENE-7606 (same defect class in CustomAnalyzer, fixed), #9070 (LUCENE-8022, closed as Invalid with a maintainer's answer to a user), #8489 (open, wildcard tokenization), #10453 and #10892 (Japanese width folding) |
Introduced by LUCENE-7355 (7c2e7a0fb80a and e92a38af90d1, committed 2016-07-12), which added Analyzer#normalize with this lower-case-only override; first released in 6.2.0, unchanged since. Fails on every release run from 6.2.0 to 10.5.2 and on main. Before 7.0 the default classic parser already missed these queries through its own lower-casing.
Versions tested8 rows
Introduced by LUCENE-7355 (#8409, "Leverage MultiTermAwareComponent in query parsers"; commit subject "Add Analyzer#normalize() and use it in query parsers"; jpountz; a JIRA patch, no PR): 7c2e7a0fb80a on branch_6x (first released in 6.2.0) and e92a38af90d1 on master (first released in 7.0.0), both committed 2016-07-12. The same diff gave CJKAnalyzer and JapaneseAnalyzer width folding in normalization. git log -S on the override lists only this commit. The index-side folding is from 2009 (e01aad89fe2, LUCENE-1629) and unchanged except for reformatting.
The symptom is older than the defect in the default classic parser. Before 7.0 that parser lower-cased multi-term text itself (lowercaseExpandedTerms, String.toLowerCase, no width folding), so it missed full-width multi-term queries already on 5.5.5, 6.0.0 and 6.1.0. From 7.0.0, which removed that option and routes every multi-term term through Analyzer#normalize, this override is the cause: the parser probe passes on main with the fix. In 6.2.0 it is a real regression only for AnalyzingQueryParser and setAnalyzeRangeTerms(true), which ran the full chain, width folding included, before.
| Version or commit | Verdict | Result |
|---|---|---|
main ea57360912ed | affected | this doc's test 2 of 2 fail (gradle and the standalone runner, assertions on and off); QueryParser test 3 of 3; independent 5-test JUnit 4 of 5, control passes |
| 10.5.2, 9.12.3 | affected | this doc's test 2 of 2, the QueryParser test 3 of 3 and the independent JUnit 4 of 5 fail |
| 9.1.0, 10.0.0 | affected | QueryParser test 3 of 3 fail |
| 6.2.0, 6.6.6, 7.0.0, 7.7.3, 8.11.4, 9.0.0 | affected | normalize-vs-index probe 3 of 4 fail; the independent JUnit 4 of 5 on 8.11.4 |
| 8.8.2, 8.9.0, 8.10.0, 8.10.1, 8.11.0, 8.11.1, 8.11.2, 8.11.3 | affected | normalize-vs-index probe 3 of 4 fail (run on 2026-10-27 for this doc's checks; these releases are not tagged in the history clone) |
| other tags 6.2.0 to 10.5.2 | read only | the introducing commits are in every tag from 6.2.0 to 6.6.6, 7.0.0 to 8.8.1 and 9.0.0 to 10.5.2; the untagged 8.8.2 to 8.11.4 were executed |
| 5.5.5, 6.0.0, 6.1.0 | older parser symptom | no Analyzer#normalize; the default classic parser misses full-width multi-term queries through its own lower-casing (4 of 5 fail), AnalyzingQueryParser finds them (6 of 6 pass on 5.5.5 and 6.1.0; not run on 6.0.0) |
| main + fix | not affected | all repros pass; smartcn check task and 23 tests pass |
An independent reproducer, a devil's advocate and an adjudication found it real and re-ran it on main with assertions on and off. Reviews by another model family (reading only) agreed on the root cause; one said "not real" only because it could not run code. An audit kept it and lowered the likelihood to rare.
Checks and verdicts412 words
Every verifier wrote or ran its own test against the stock jars; the cross-model reviews could only read code, and a separate run re-ran the finder's test for each (3 of 3 fail).
| Check | Verdict | What it found |
|---|---|---|
| finder | candidate | QueryParser JUnit fails 3 of 3 on main (assertions on and off), 10.5.2 and 9.12.3; the fix sketch passes 3 of 3 and the upstream TestSmartChineseAnalyzer 19 of 19. |
| cross-model review, refute lens | not real (low), procedural | An independent reviewer from another model family (OpenAI GPT-5.6, reading only) could not run code, so it voted not real on procedure only; it agreed with the root cause, judged the inputs in contract and rated the intended case moderate. |
| Independent reproducer | real | Own 5-test JUnit fails 4 of 5 on main, 10.5.2, 9.12.3 and 8.11.4, control passes; with the fix 5 of 5 pass. Exhaustive BMP scan: 1,141 of 61,527 characters disagree, 0 with the fix. |
| Cross-model review, intended lens | real (medium) | OpenAI GPT-5.6, reading only: the intended-behaviour case is moderate and rejected for full-width Latin. A separate probe fails 2 of 3 on main; its CJKAnalyzer control passes. |
| Devil's advocate | real | Re-ran the repro on main; its probe shows CJKAnalyzer folds width in normalize and that full-width prefix and fuzzy queries miss documents in either width while the term query finds both; no intended-behaviour argument survives. |
| security assessment | out of scope of SECURITY.md | Deny rules written as full-width parsed prefix, wildcard, regexp or fuzzy terms exclude nothing; 10 of 11 probe checks fail on main, 10.5.2 and 9.12.3, 11 of 11 pass with the fix. |
| adjudication | confirmed (high) | Re-ran the independent reproduction's test on main with assertions on and off (4 of 5 fail, control passes) and with the fix (5 of 5 pass). |
| registry re-run and audit | downgrade (kept, likelihood rare) | New seeds on main: the finder's test 3 of 3 fail; an own probe (202*, flexible Lu?en) fails 2 of 3 on main and 10.5.2, passes 3 of 3 with the fix. |
| confirmed; wording fixed | An independent fact check re-ran the gradle command verbatim (assertions on and off: 2 of 2 fail; with fix A the smartcn check task and 23 tests pass) and every standalone repro with new seeds; a second fact check by the other model family (reading only) found no error in the root cause. Their wording corrections are applied. |
Finding record, Lucene and upstream sources and repro files1,691 words
- Registry title
- SmartChineseAnalyzer.normalize only lower-cases, but indexing folds full-width Latin letters and digits to half-width (in the tokenizer's SegTokenFilter), so full-width prefix, wildcard, regexp, range and fuzzy terms from the query parsers miss documents that the same word as a term query finds
- Area
- analysis-smartcn / language analyzers (SmartChineseAnalyzer normalization: Analyzer#normalize vs the width and case folding inside HMMChineseTokenizer's SegTokenFilter, used by the query parsers for multi-term queries)
- Priority
- Low, rank 302: Needs full-width Latin letters or digits in a multi-term query on a SmartChineseAnalyzer field, rare because common input methods produce half-width Latin.
- Impact class
- silent wrong results
- Impact note
- Every application that indexes Chinese or mixed Chinese-English text with the stock SmartChineseAnalyzer and accepts prefix, wildcard, regexp, range or fuzzy syntax through Lucene's classic, flexible or simple query parser (or calls Analyzer.normalize itself) silently gets no hits whenever the query term contains full-width Latin letters or digits (for example 'Luc*', 'Lu?e*', '[lua TO luz]' or '202*'), because the index holds only the half-width, lower-cased form and normalize keeps the full-width one; fuzzy queries can still hit when the edit distance absorbs the difference, single-letter multi-term terms with a non-ASCII capital (É*, Б*) disagree in the other direction, term and phrase queries and the indexed data are unaffected, no setting or scale is needed, every release from 6.2.0 to 10.6.0 (10.6.0 by reading) and main has it (the default classic parser shows the same miss even before, through its own pre-7.0 lower-casing), and by code reading two downstream search servers' smartcn plugins register the stock class unchanged.
- Summary
- You index the text '我们使用Lucene' (or '我们使用Lucene') with the stock SmartChineseAnalyzer and search with the classic QueryParser: the term query 'Lucene' and the half-width prefix 'Luc*' find the document, so you expect the full-width prefix 'Luc*' to find it too. Instead it finds 0 documents, with no error: the tokenizer's SegTokenFilter folds full-width Latin letters and digits to half-width and lower-cases A-Z, so the index holds 'lucen', but SmartChineseAnalyzer.normalize, which the query parsers apply to prefix, wildcard, regexp, range and fuzzy terms, is only a LowerCaseFilter, so the query term stays full-width ('luc*'). Likewise Analyzer.normalize returns 'abc' for 'ABC' and '123' for '123' (the index holds 'abc' and '123') and 'é' for 'É' (the index keeps 'É').
- Introduced
- 7c2e7a0fb80a5bf733cf710aee6cbf01d02629eb on branch_6x (first released in 6.2.0) and its master twin e92a38af90d12e51390b4307ccbe0c24ac7b6b4e (first released in 7.0.0): LUCENE-7355 'Add Analyzer#normalize() and use it in query parsers' (jpountz; GitHub issue apache/lucene#8409, JIRA patch with 8 versions, no PR), which added SmartChineseAnalyzer.normalize(String, TokenStream) { return new LowerCaseFilter(in); } (main :137-140) while giving CJKAnalyzer and JapaneseAnalyzer CJKWidthFilter + LowerCaseFilter [read: git blame of :137-140 and git log -S 'normalize(String fieldName, TokenStream in)' --follow on the file list only this commit; the later commits c07df196664, 7f8b7ffbcad, 2971f311a2b and cc342ea7407 did not touch normalize]. The index-side folding it fails to mirror dates from 2009 (SegTokenFilter full-width folding e01aad89fe2, LUCENE-1629; the createComponents comment 'SegTokenFilter lowercases Basic Latin text' 5265dc1bb20) and is unchanged except for reformatting (2016-07-12 (commit date of both commits; author date 2016-06-28))
- First affected version
- 6.2.0 [exec] (the normalize-contract probe fails 3 of 4 on 6.2.0 and does not compile on 6.1.0, which has no Analyzer#normalize). In 6.2.0 it is a regression for AnalyzingQueryParser and QueryParser.setAnalyzeRangeTerms(true), which ran the analyzer's full chain, width folding included, before (probe C passes 6 of 6 on 5.5.5 and 6.1.0 and fails 5 of 6 on 6.2.0 and 6.6.6) [exec]. The default classic parser misses full-width multi-term queries already on 5.5.5, 6.0.0 and 6.1.0 through its own lowercaseExpandedTerms (String.toLowerCase, no width folding), a limitation of that parser rather than this defect; from 7.0.0, which removed that option and routes every multi-term term through Analyzer#normalize, this override is the cause (probe B passes 5 of 5 on main with the fix shadowed) [exec]
- Affected versions
- Every release 6.2.0 to 10.6.0 (10.6.0, released 2026-10-29, by reading) and main ea57360912ed (unreleased 11.0). [exec] probe A (normalize vs the indexed token for 'ABC', '123' and 'É', with a half-width control) fails 3 of 4 on 6.2.0, 6.6.6, 7.0.0, 7.7.3, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 and main (-ea and -da) and passes 4 of 4 on main with the fix; probe B (default classic parser end to end: 'Luc*', 'Lu?en', 'Lucen~1', '[Lua TO Luz]', with half-width controls) fails 4 of 5 on 5.5.5, 6.0.0, 6.1.0, 6.2.0, 6.6.6, 7.0.0, 8.11.4, 9.12.3, 10.5.2 and main (-ea and -da) and passes 5 of 5 on main with the fix; the finder's JUnit fails 3 of 3 on 9.1.0, 9.12.3, 10.0.0, 10.5.2 and main (-ea and -da); the independent reproduction's JUnit fails 4 of 5 on main (-ea and -da), 10.5.2, 9.12.3 and 8.11.4. [read] by tag containment 7c2e7a0fb80 is in the 15 tags 6.2.0 to 6.6.6 and e92a38af90d in the 65 tags 7.0.0 to 8.8.1 and 9.0.0 to 10.5.2; 8.8.2 to 8.11.3 are not tagged in the history clone (between 8.8.1 [read] and 8.11.4 [exec]). Not fixed upstream: the override is unchanged on upstream main a95c754ee003 (2026-10-26) and, in the local clone, on origin/main a9779ef3c19 and the branch_10x and branch_10_6 tips; no open PR changes it. Not affected: 6.1.0 and earlier (no Analyzer#normalize; for the older classic-parser symptom see first_affected_version). Not executed: x86
- 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
- none found (2026-10-26, read-only; the prior-report record verdict new, confidence high): 198 queries: a scripted prior-report search 160 (the finder's run 45 and three follow-up runs 115: GitHub issue, PR and commit queries on apache/lucene, downstream-repository queries on Elasticsearch, OpenSearch and Solr, mailing-list queries over dev@, java-user@, issues@ and commits@, JIRA-archive queries), 7 manual GitHub searches and open-PR lists, 6 JQL queries over LUCENE and SOLR (all 56 LUCENE and 16 SOLR smartcn issue titles read), 17 lists.apache.org queries (all distinct subjects read) and 8 web queries in English and Chinese; 12 issues read in full; the touched paths of all 406 open PRs; git history. 28 hits judged, none the same bug. Closest: #3098 (LUCENE-2023, open since 2009, an unmerged patch by rmuir that moves SegTokenFilter's lower-casing and full-width conversion into separate filters for speed, written before Analyzer#normalize existed and saying nothing about queries); #8409 (LUCENE-7355, which added the override; its 34 comments never mention smartcn or width folding). Same defect class in other code, fixed as bugs (precedents, not reports): #9234 (LUCENE-8186, CustomAnalyzer with LowerCaseTokenizerFactory) and LUCENE-7606 (CustomAnalyzer.normalize); #9070 (LUCENE-8022: a user's analyzer must override normalize). Unrelated: #8489 (LUCENE-7437, open, wildcard tokenization), #10453 and #10892 (LUCENE-9413, LUCENE-9853, Japanese width folding), the other smartcn issues (segmentation, stemming, surrogates, offsets, reset, stopwords). Not fixed: upstream main a95c754ee003 (2026-10-26) still returns new LowerCaseFilter(in); SmartChineseAnalyzer.java last changed 2022-01-03; the only open PRs that touch the smartcn module (#681, module-info lint; #202, WordDelimiterFilter removal) do not change normalize. Gaps: GitHub search reads only the first 50-100 results per query and the broad 'normalize' query only to its first page; web search was a general engine and Chinese-language forums were sampled, not covered; private reports to security@lucene.apache.org are not visible
- Lucene commit tested
- main
ea57360912ed(commit of 2026-10-24; tested 2026-10-26) through gradle in a scratch worktree and through the standalone JUnit runner, with assertions on and off; the Maven Central jars 9.12.3 and 10.5.2 (the doc's test), 9.1.0 and 10.0.0 (the finder's test), 8.11.4 (the independent reproducer's test), and 5.5.5 to 10.5.2 for the history probes (8.8.2 to 8.11.3 added on 2026-10-27 for this doc's checks). Fix A (recommended) and fix B were validated in the scratch worktree with the smartcn module'schecktask.
- Finding record
lucene/analysis/smartcn/src/java/org/apache/lucene/analysis/cn/smart/SmartChineseAnalyzer.java:47 (at ea57360912ed) ('@lucene.experimental'), :124-134 (createComponents: HMMChineseTokenizer -> PorterStemFilter -> [StopFilter]; :128 'LowerCaseFilter is not needed, as SegTokenFilter lowercases Basic Latin text'), :137-140 (normalize returns new LowerCaseFilter(in))lucene/analysis/smartcn/src/java/org/apache/lucene/analysis/cn/smart/hhmm/SegTokenFilter.java:22-37 (at ea57360912ed) (javadoc 'Full-width latin will be converted to half-width, then all latin will be lowercased'), :39-62 (FULLWIDTH_NUMBER and FULLWIDTH_STRING: chars >= U+FF10 minus 0xFEE0, then A-Z lower-cased; STRING: A-Z lower-cased); lucene/analysis/smartcn/src/java/org/apache/lucene/analysis/cn/smart/Utility.java:140-158 (getCharType: U+FF21-U+FF3A and U+FF41-U+FF5A FULLWIDTH_LETTER, U+FF10-U+FF19 FULLWIDTH_DIGIT)lucene/core/src/java/org/apache/lucene/analysis/Analyzer.java:203-213 (at ea57360912ed) (normalize(String, String): 'the representation that it would have in the index', 'without tokenizing or stemming'); lucene/analysis/common/src/java/org/apache/lucene/analysis/cjk/CJKAnalyzer.java:101-106 (normalize: CJKWidthFilter + LowerCaseFilter); lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/JapaneseAnalyzer.java:119-133 (normalize LowerCaseFilter; initReaderForNormalization CJKWidthCharFilter); lucene/analysis/smartcn/src/test/org/apache/lucene/analysis/cn/smart/TestSmartChineseAnalyzer.java:160-170 (testFullWidth: 'Tests' -> 'test', '1234' -> '1234')lucene/queryparser/src/java/org/apache/lucene/queryparser/classic/QueryParserBase.java:550, (at ea57360912ed) :556, :646, :656, :683, :712, :729 (Analyzer.normalize calls for range bounds, wildcard chunks, regexp, prefix, fuzzy; follow-up the security record), :611, :638, :667, :696 ('normal Analyzers are unlikely to work properly with wildcard templates', 'best effort'); lucene/queryparser/src/java/org/apache/lucene/queryparser/classic/QueryParser.jj:183-187, :208-211 (only ASCII '*' and '?' form PREFIXTERM and WILDTERM; reading for the likelihood note)https://github.com/apache/lucene/issues/8409 (LUCENE-7355, introduces Analyzer#normalize and this override), https://github.com/apache/lucene/issues/3098 (LUCENE-2023, closest design context), https://github.com/apache/lucene/issues/9234 (LUCENE-8186, precedent fixed as a bug), https://issues.apache.org/jira/browse/LUCENE-7606 (precedent), https://github.com/apache/lucene/issues/9070 (LUCENE-8022)
- The finding in 30 seconds
Lucene main ea57360912ed: lucene/analysis/smartcn/src/java/org/apache/lucene/analysis/cn/smart/SmartChineseAnalyzer.java:124-134 (chain), :127-128 (comment), :137-140 (normalize)Lucene main ea57360912ed: lucene/analysis/smartcn/src/java/org/apache/lucene/analysis/cn/smart/hhmm/SegTokenFilter.java:22-37 (javadoc), :39-62 (filter)Lucene main ea57360912ed: lucene/analysis/smartcn/src/java/org/apache/lucene/analysis/cn/smart/Utility.java:140-158 (getCharType)Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/analysis/Analyzer.java:53-58 (class example), :203-212 (normalize javadoc)Lucene main ea57360912ed: lucene/queryparser/src/java/org/apache/lucene/queryparser/classic/QueryParserBase.java:550, :556, :646, :656, :683, :712, :729; lucene/queryparser/src/java/org/apache/lucene/queryparser/simple/SimpleQueryParser.java:573, :605; flexible/standard/processors/WildcardQueryNodeProcessor.java:66, :76, FuzzyQueryNodeProcessor.java:59, RegexpQueryNodeProcessor.java:43, TermRangeQueryNodeProcessor.java:138-139Lucene main ea57360912ed: lucene/analysis/common/src/java/org/apache/lucene/analysis/cjk/CJKAnalyzer.java:101-106; lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/JapaneseAnalyzer.java:130-133Lucene main ea57360912ed: lucene/queryparser/src/java/org/apache/lucene/queryparser/classic/QueryParserBase.java:547-557, QueryParser.jj:350-362 (open range bounds); flexible/standard/processors/TermRangeQueryNodeProcessor.java:100-139 (date bounds); simple/SimpleQueryParser.java:108-136 (operators)Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/analysis/LowerCaseFilter.java:38; CharacterUtils.java:54-69
- Who is affected
Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/analysis/AnalyzerWrapper.java:123-127Lucene main ea57360912ed: lucene/queryparser/src/java/org/apache/lucene/queryparser/classic/QueryParser.jj:183-187, :208-211Lucene main ea57360912ed: lucene/analysis/smartcn/src/java/org/apache/lucene/analysis/cn/smart/SmartChineseAnalyzer.java:33, :47
- Why it looks unintended
Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/analysis/Analyzer.java:53-58, :203-208Lucene main ea57360912ed: lucene/analysis/smartcn/src/java/org/apache/lucene/analysis/cn/smart/hhmm/SegTokenFilter.java:22-37; lucene/analysis/smartcn/src/java/org/apache/lucene/analysis/cn/smart/SmartChineseAnalyzer.java:47, :128Lucene main ea57360912ed: lucene/analysis/smartcn/src/test/org/apache/lucene/analysis/cn/smart/TestSmartChineseAnalyzer.java:160-170Lucene main ea57360912ed: lucene/test-framework/src/java/org/apache/lucene/tests/analysis/BaseTokenStreamTestCase.java:1478Lucene main ea57360912ed: lucene/queryparser/src/java/org/apache/lucene/queryparser/classic/QueryParserBase.java:611, :638, :667, :696, :712Lucene main ea57360912ed: lucene/analysis/common/src/java/org/apache/lucene/analysis/cjk/CJKAnalyzer.java:101-106https://github.com/apache/lucene/issues/8409; https://github.com/apache/lucene/issues/9234; https://github.com/apache/lucene/issues/8657 (LUCENE-7606, fixed 6.4/7.0); https://github.com/apache/lucene/issues/9070 (LUCENE-8022, resolution Invalid)
- Suggested fix
Lucene main ea57360912ed: lucene/analysis/smartcn/src/java/org/apache/lucene/analysis/cn/smart/SmartChineseAnalyzer.java:50 (final), :137-140; lucene/analysis/smartcn/src/java/org/apache/lucene/analysis/cn/smart/Utility.java:153-156Lucene main ea57360912ed: lucene/analysis/common/src/java/org/apache/lucene/analysis/cjk/CJKWidthFilter.java:67-76
- TestSmartChineseNormalizeFullWidth.java (JUnit test):
repro/LFV-565/src/org/apache/lucene/analysis/cn/smart/TestSmartChineseNormalizeFullWidth.java(31 lines)