Serbian is written in two scripts, Cyrillic and Latin (with č ć š ž đ). SerbianAnalyzer indexes both as plain ASCII Latin, but the query-time shortcut that the parsers use for partial words leaves that step out.
- Index the text
Београд Čačakwith the stocknew SerbianAnalyzer(). Its last filter,SerbianNormalizationFilter, turns Cyrillic into Latin and removes the accents on č ć š ž đ, so the index holdsbeogradandcack. - Search
Беогр*,Čač*orБеоград~1withQueryParser. A partial word cannot go through the full analyzer, so the parser callsAnalyzer.normalize, which is documented to return "the representation that it would have in the index". - You expect 1 hit for each query, the same as
Beogr*andCac*get. - You get 0 hits for each.
SerbianAnalyzer.normalizeonly lower-cases, so the queries becomeбеогр*,čač*andбеоград~1, and no indexed term contains those letters. Calling it directly:normalize("Ђак")returnsђакwhile the index holdsdjak, even with stemming switched off. - Only multi-term queries are affected; a plain
Београдterm query works. In a bounded scan with stemming off, 5,334 of 14,794 words (36%) normalize to something other than what indexing stores.
Anyone who indexes Serbian text with the stock SerbianAnalyzer (default constructor) and accepts prefix, wildcard, fuzzy, regexp or range syntax through Lucene's classic or flexible query parser (prefix and fuzzy through the simple parser). Serbian is written in Cyrillic and Latin, so such queries are ordinary. No size or setting is needed; the symptom is silent: missed documents, no error.
Mechanism, terms and numbers565 words
SerbianAnalyzer indexes StandardTokenizer -> LowerCaseFilter -> StopFilter -> [SetKeywordMarkerFilter] -> SnowballFilter(SerbianStemmer) -> SerbianNormalizationFilter (SerbianAnalyzer.java:118-127). The last filter maps Cyrillic to Latin and removes the accents on Latin letters (č and ć become c, š becomes s, ž becomes z, đ becomes dj). So Београд is stored as beograd and Čačak as cack. SerbianAnalyzer.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) (:129-132). Analyzer#normalize is documented to give "the representation that it would have in the index", without tokenizing or stemming (Analyzer.java:203-207). The result: Беогр*, Čač* and Београд~1 find nothing while Beogr* and Cac* find the document, and normalize("Ђак") returns ђак while the index holds djak even with stemming switched off. Term and phrase queries are unaffected. Every release since the class was added (9.0.0) to 10.5.2 and main has it. The fix applies the filter in normalize() too, as RomanianAnalyzer does; it only changes the query side, so no reindex is needed.
SerbianNormalizationFilter: "Normalizes Serbian Cyrillic and Latin characters to \"bald\" Latin" (SerbianNormalizationFilter.java:25). It maps all 30 lower-case Serbian Cyrillic letters and the Latin letters č ć š ž đ to ASCII (:41-170; ђ and đ become dj, љ becomes lj, њ becomes nj, џ becomes dz). It is the last filter ofSerbianAnalyzer's chain (SerbianAnalyzer.java:125), so no indexed term of the field contains any of those 35 letters.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 contain the normalizing filters and leave out the tokenizer and the stemmer (Analyzer.java:42-60,:203-212).- Multi-term queries: prefix (
Беогр*), wildcard (Бео?рад), fuzzy (Београд~1), regexp (/бео.*/) and range ([a TO š]). Term and phrase queries run the full analyzer and are not affected.
SerbianAnalyzer.normalize()isreturn new LowerCaseFilter(in);(SerbianAnalyzer.java:129-132).SerbianNormalizationFilteris missing.- 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). The flexible parser's processors do the same (range bounds only when they do not parse as dates), and the simple parser does it for its only multi-term syntax, prefix (SimpleQueryParser.java:605) and fuzzy (:573). Беогр*therefore becomesbody:беогр*andČač*becomesbody:čač*. The index holdsbeogradandcack, so neither prefix can match any term.
One document Београд Čačak, default SerbianAnalyzer: Беогр*, Čač* and Београд~1 each find 0 documents; Beogr* and Cac* find 1. With stemming switched off through the stem exclusion set, normalize("Ђак") = ђак (index djak) and normalize("Čačak") = čačak (index cacak). The hunt's bounded scan found 5,334 of 14,794 one-token words that disagree with stemming off; every one of the 3,223 checked disagreeing words was missed by classic and flexible prefix, wildcard, regexp and range queries, while fuzzy queries still hit 1,804 of them within the edit distance.
Exposure and likelihood338 words
- Who is exposed. Applications that use the stock
SerbianAnalyzer, the default constructor included, and pass user text throughQueryParser,StandardQueryParserorSimpleQueryParser, or callAnalyzer.normalizethemselves. Analyzer wrappers call the wrapped analyzer'snormalize, and Lucene's own wrappers add nothing to it (AnalyzerWrapper.java:81-92,:124-126;DelegatingAnalyzerWrapper.java:57-60, the base ofPerFieldAnalyzerWrapper), so wrapped uses inherit the gap unless a custom wrapper adds the filter inwrapTokenStreamForNormalization. ACustomAnalyzerbuilt from the same factories is not affected:SerbianNormalizationFilterFactory.normalize()returnscreate(input)andCustomAnalyzer.normalizeapplies it. - Default, not opt-in. Default analyzer and default parser settings. The only choice involved is using the Serbian analyzer and multi-term syntax.
- Ordinary input. Serbian is written in both Cyrillic and Latin; Latin spelling uses č ć š ž đ. Any multi-term query with one of these 35 letters is hit. The same query typed in plain Latin (
Beogr*,Cac*) works, which is a workaround, not a fix. - What the damage looks like. Prefix, wildcard and regexp terms that contain one of the letters cannot match any indexed term: 0 hits, no error. Ranges compare their bounds in the wrong form and can drop or add terms (
[a TO š]admitted 6 of 6 probe documents; the indexed-form[a TO s]admits 2). Fuzzy terms can still hit within the edit distance, so say "miss", not "never match". - Scale. One document and one query are enough. In the hunt's scan, with stemming off, 36% of the one-token words over a Serbian alphabet normalize differently from how they are indexed.
- Downstream (code reading only, nothing run). Elasticsearch exposes this class as its built-in
serbiananalyzer (elastic/elasticsearch#100921, 8.12.0), and itsquery_stringparser normalizes prefix, wildcard, fuzzy and range terms with the field's analyzer, so it would inherit the miss. - Limit. After the fix, prefixes that run past the stem stay best effort, as with every stemming analyzer (
Čača*becomescaca, while the index holdscack). This bug is only the script and accent mismatch.
SerbianAnalyzer.normalize only lower-cases and skips SerbianNormalizationFilter, so it does not return the form the analyzer indexes (which the query parsers rely on for prefix, wildcard, fuzzy, regexp and range terms).
/*
* 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.sr;
import org.apache.lucene.analysis.Analyzer;
import org.apache.lucene.tests.analysis.BaseTokenStreamTestCase;
public class TestSerbianAnalyzerNormalize extends BaseTokenStreamTestCase {
public void testNormalizeReturnsIndexedForm() throws Exception {
try (Analyzer a = new SerbianAnalyzer()) {
checkOneTerm(a, "Ђак", "djak");
// expected the indexed form "djak", actual "ђак" (normalize only lower-cases)
assertEquals("djak", a.normalize("f", "Ђак").utf8ToString());
}
}
}
# in a copy of this finding's repro folder (repro/LFV-534/, 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: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.sr.TestSerbianAnalyzerNormalize
- Expected vs actual
- Expected:
normalize("f", "Ђак")=djak(the indexed term); actual:ђак.
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:<[djak]> but was:<[ђак]>
org.junit.Assert.assertEquals(Assert.java:117)
org.junit.Assert.assertEquals(Assert.java:146)
org.apache.lucene.analysis.sr.TestSerbianAnalyzerNormalize.testNormalizeReturnsIndexedForm(TestSerbianAnalyzerNormalize.java:28)Analyzer#normalizemust return "the representation that it would have in the index", minus tokenizing and stemming. This filter is a character mapping that its own factory marks as normalization.- With stemming off,
Čačakis indexed ascacakbut normalizes točačak. - Fixed as a bug in Telugu (#13059); #13233 added Romanian's filter to
normalize()"for e.g. wildcard searches".
The full argument275 words
- The contract.
Analyzer.java:203-207: "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". - Lucene classes this filter as normalization.
TokenFilterFactory.java:101-105: "filters that should be applied at normalization time can delegate to create";SerbianNormalizationFilterFactory.java:71-74does exactly that, and the factory has been multi-term aware since it was added (LUCENE-6053, 2014). ACustomAnalyzerbuilt from the same factories normalizes correctly. - Siblings.
RomanianAnalyzer.java:128-133appliesLowerCaseFilterandRomanianNormalizationFilterinnormalize(). Of the stock analyzers that overridenormalize()and use a filter whose factory overridesnormalize(), every other one applies that filter there (German, Romanian, Persian, Arabic, Hindi, Bengali, Telugu, Thai, Greek, Turkish and others);SerbianAnalyzeris the only exception. - Tests. No upstream test asserts the current behaviour:
TestSerbianAnalyzer's four tests cover resources, stemming and a stop word, the exclusion set and random strings; none asserts whatnormalize()returns. - History. The lower-case-only body came with the class (
c7029b12541d, LUCENE-9313) from the contributor's patch; the issue thread never mentionsnormalize, wildcards or prefixes. The contract (6.2.0) and the factory'snormalize() -> create()(8.0.0) are older. Precedents fixed the same shape: #13059 ("DecimalDigitFilter and IndicNormalizationFilter were mistakenly omitted", CHANGES "Bug Fixes") and #13233 (a committer added the Romanian filter to both the chain andnormalize(), for "Analyzer.normalize()(normalizing the characters for e.g. wildcard searches)" and for "Users who make use of stemExclusionSet"); #16717 (Thai) did the same.
The devil's-advocate check244 words
The strongest "intended" case (the devil's-advocate verifier and the cross-model reviewers): normalize() deliberately excludes stemming; the Snowball SerbianStemmer itself converts Cyrillic to Latin as its first step; and SerbianNormalizationFilter sits after the stemmer, so it could be read as stemmer clean-up. Also, a comment in the classic parser's wildcard code says "best effort" (QueryParserBase.java:638), and its getPrefixQuery javadoc says a prefix term does not go "through the default Analyzer" (:695-697).
Why it fails: the stem-excluded, Latin-only case Čačak involves no stemmer and still disagrees (index cacak, normalize čačak); #13233 is the same situation, a stemmer that already normalizes, and the committer still added the filter to normalize() for wildcard searches and stem-excluded words; the Analyzer javadoc decides by what a filter does, not where it sits; the "best effort" comment is about keeping wildcard and escape characters out of normalize(), and the prefix javadoc's "will not go through the default Analyzer" can at most mean the full analysis chain: the code a few lines below passes the prefix through the analyzer's normalize() (:712). Every reviewer rated the intended case weak as evidence of intent. The counter-case is right that multi-term queries on a stemmed field stay approximate after the fix and that the fix changes how some regexps and fuzzy terms are read (see the fix's limits); those are limits to state in the report, not signs that the gap is intended. Verdict: a defect.
Recommended Apply SerbianNormalizationFilter in normalize()
Apply the filter in normalize() too, as RomanianAnalyzer does: return new SerbianNormalizationFilter(new LowerCaseFilter(in));. It changes only the query side, so no reindex is needed. With it the repro passes, and so do all 1,911 tests of lucene/analysis/common.
@Override
protected TokenStream normalize(String fieldName, TokenStream in) {
TokenStream result = new LowerCaseFilter(in);
return new SerbianNormalizationFilter(result);
}All options and trade-offs4 options · 603 words
One fix was built and checked in a scratch worktree of main ea57360912ed; the other options are optional extras or workarounds.
Two lines in SerbianAnalyzer.normalize(), the RomanianAnalyzer pattern. The filter "expects lowercased input" (SerbianNormalizationFilter.java:30), which the LowerCaseFilter before it provides. A Telugu-style regression test would be assertEquals(new BytesRef("djak"), a.normalize("dummy", "Ђак")) in TestSerbianAnalyzer, or the repro's test class.
@Override
protected TokenStream normalize(String fieldName, TokenStream in) {
TokenStream result = new LowerCaseFilter(in);
return new SerbianNormalizationFilter(result);
}Trade-offs
- Query side only: the index already holds the plain-Latin form, so no reindex.
- Behaviour change: the tested Cyrillic and accented prefix, wildcard, fuzzy, regexp and range queries now use the indexed form and hit (executed), which is the intent.
- Cost: one character-mapping filter per normalized query term.
- Applies unchanged to every affected branch: the method body is identical from 9.0.0 to main.
- Prefixes that run past the stem stay best effort, as with every stemming analyzer (
Čača*becomescaca*, 0 hits, the index holdscack; executed). - Escaped letters in a wildcard query skip
normalize()(QueryParserBase.java:637-659):Бео\г?адparses tobody:beo\г?ad, 0 hits, with or without the fix (executed). - The whole regexp string goes through
normalize()(QueryParserBase.java:683), so classes and repeats are rewritten in Latin:/[đž]ak/becomes/[djz]ak/and matcheszak,dakandjakbut notdjak;/đ+ak/becomes/dj+ak/(executed). - Fuzzy distances are counted on the Latin form, where đ ђ љ њ џ are two letters:
Ђак~1becomesdjak~1and matchesdjak,dakandjakbut notak, one edit fromђакin Cyrillic (executed). - Ranges compare the UTF-8 bytes of the indexed terms, not Serbian alphabetical order (
TermRangeQuery.java:27-28), with or without the fix. Analyzer.normalize()is public: application code that calls it directly also gets the Latin form after the fix.
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;org.apache.lucene.analysis.sr.*: 5 suites (4 upstream with 15 tests, plus the new one), 17 tests, 0 failed (seed 1A2B3C4D, and 77AA77AA with assertions off);:lucene:analysis:common:test: 340 suites, 1,911 tests, 18 skipped, 0 failed;checkGoogleJavaFormatpasses. - Standalone runner with the fix shadowed: the new test 2 of 2 (assertions on and off), the QueryParser test 3 of 3 and the security probe 9 of 9 pass; earlier, the independent reproducer's 8-test JUnit passed 8 of 8 (twice, including the adjudication's re-run) and the upstream
TestSerbianAnalyzer4 of 4.
SerbianAnalyzer.java:39 says @since 8.6, but no 8.x release contains the class (8.6.3 and 8.11.4: the repro does not compile) and CHANGES lists it under 9.0.0.
Trade-offs
Javadoc only; unrelated to the behaviour, so it could also be a separate change.
Hardening: a generic test that normalize() equals the indexed token for stem-excluded words (not run)
The hunt's scan did exactly this across all stock analyzers and found sibling gaps.
Trade-offs
Larger change: stemming and tokenization must be kept out per analyzer, and some sibling gaps would fail it today, so each needs its own fix first.
Subclass SerbianAnalyzer (it is not final) and override normalize() as above; or build the chain with CustomAnalyzer from the factories, whose normalize() already applies serbianNormalization; or type multi-term queries in plain Latin (Beogr*, Cac*).
Trade-offs
The subclass and CustomAnalyzer routes need no reindex either; asking users to type plain Latin is not a real fix.
A restriction written as a parsed multi-term query goes through the same normalize(), so a Cyrillic or accented literal in its pattern never matches an indexed term. A MUST_NOT rule then removes nothing, silently. 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 every stock analyzer: normalize(w) must equal the token indexing stores for w, for every short word over an alphabet with Serbian Cyrillic and Latin letters (1,437,218 comparisons). SerbianAnalyzer disagreed on 5,334 words with stemming off; a second scan confirmed the misses through the parsers, and code reading found the cause.
The method in full187 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. Inputs: every word of length 1-2 over a 110-code-point alphabet that includes Serbian Cyrillic and Latin letters, plus 4,000 random words each of length 3 and 4, times 99 analyzer configurations (stemming switched off through stem exclusion sets where the analyzer has one): 1,437,218 comparisons.SerbianAnalyzerwith stemming off disagreed on 5,334 of 14,794 words. - 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 3,223 checked disagreeing words were missed by prefix, wildcard, regexp and range; fuzzy still hit 1,804.
- A second pass read every
normalize()override in Lucene side by side with itscreateComponents;SerbianAnalyzerwas the only one leaving out a filter that its factory declares as normalization. - Then an AI finder wrote the public-API JUnit, and independent AI tools reproduced and judged it (see the checks).
No prior report: 273 queries over GitHub issues and PRs, commits, mailing lists, the JIRA archive and the web (2026-10-26), 22 hits read. Only precedents in other analyzers exist: #13059 (Telugu), #13233 (Romanian), #16717 (Thai).
Searches6 searches
No issue, PR, commit, JIRA entry, mailing-list thread, forum post or web page reports this bug. Only four Lucene issues name the Serbian classes at all (#7115, #7933, #10353, #10860); all were read in full and none discusses normalize() or multi-term queries. None of the 408 open PRs touches analysis/sr. Upstream main still has the lower-case-only normalize().
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues and PRs (apache/lucene), commits | 2026-10-26 | three runs of a scripted prior-report search plus 74 GraphQL searches (60 on apache/lucene, 14 on apache/lucene-solr, lucenenet and all of GitHub): class names, Serbian with normalize, wildcard, prefix, fuzzy, Cyrillic, Latin, diacritics, Cyrillic words, Analyzer#normalize, normalize-missing-filter, LUCENE keys | no report; #10353 (LUCENE-9313) added the code and never mentions normalize |
| Open PRs | 2026-10-26 | all 408 open apache/lucene PRs with their touched files | none touches analysis/sr |
| JIRA archive | 2026-10-26 | 10 JQL searches over LUCENE and SOLR; LUCENE-9313 and LUCENE-6053 read with comments | no report |
| Mailing lists (lists.apache.org) | 2026-10-26 | 48 searches over dev@, java-user@, issues@, commits@ and the Solr lists | no report; the contributor's 2020 contribution thread only |
| Downstream repositories and the web | 2026-10-26 | 24 GraphQL searches over Elasticsearch, OpenSearch and Solr; 11 web searches, 3 forum threads read | no report; elastic/elasticsearch#100921 exposes the class; forum threads concern custom chains |
| Related items (read) | 2026-10-26 | precedents and neighbours | #13059 (Telugu normalize, fixed as a bug), #13233 (Romanian), #16717 (Thai), #9234 and #8657 (CustomAnalyzer), #12613 (open), #8489 (open, wildcard tokenization) |
Present since the class was added by LUCENE-9313 (c7029b12541d, committed 2020-08-26) and first released in 9.0.0; the method never changed. Fails on 9.0.0, 9.1.0, 9.12.3, 10.0.0, 10.5.2 and main; 8.x has no SerbianAnalyzer, despite its @since 8.6.
Versions tested6 rows
Introduced by c7029b12541d "LUCENE-9313: Analyzer for Serbian language based on Snowball stemmer" (#10353), committed by rmuir on 2020-08-26 from the contributor's JIRA patch, which already had the lower-case-only normalize(); there was no PR. git blame and git log -L on the normalize() lines list only this commit, so it is not a regression. CHANGES lists the analyzer under 9.0.0 (54a5dc2e6e3 moved the entry from 8.7 because the Snowball prerequisite only existed on 9.0). The Analyzer#normalize contract (LUCENE-7355, 6.2.0) and the factory's normalize() -> create() (LUCENE-8497, 8.0.0) predate it. Git reports the new file as a 73% copy of RomanianAnalyzer.java, whose normalize() was lower-case only at the time (Romanian's chain then had no separate normalization filter); an observation about the origin, not a claim of why.
| Version or commit | Verdict | Result |
|---|---|---|
main ea57360912ed | affected | the brief's test 2 of 2 fail (gradle and the standalone runner, assertions on and off); QueryParser test 3 of 3 fail; independent 8-test JUnit 6 of 8 fail, controls pass |
| 10.5.2 | affected | the brief's test 2 of 2, the QueryParser test 3 of 3 and the independent JUnit 6 of 8 fail |
| 9.0.0, 9.1.0, 9.12.3, 10.0.0 | affected | QueryParser test 3 of 3 fail on each (on 9.0.0 with its LuceneTestCase import adapted to that release, nothing else changed); the independent JUnit 6 of 8 on 9.0.0 and 9.12.3 |
| 9.2.0 to 10.5.1 (28 tags) | read only | identical normalize() body and filter line |
| 8.6.3, 8.11.4 | class absent | the test does not compile: SerbianAnalyzer does not exist |
| main + fix | not affected | all repros pass; lucene/analysis/common 1,911 tests, 0 failed |
An independent reproducer, a devil's advocate and an adjudication all found it real and re-ran it on main with and without assertions. Reviews by another model family (reading only) agreed on the root cause; one said "not real" only because it could not run code. A final audit re-ran it with new seeds.
Checks and verdicts317 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 one-line fix passes 3 of 3 and the upstream TestSerbianAnalyzer 4 of 4. |
| 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 weak. |
| Independent reproducer | real | Own 8-test JUnit (2 controls, including a CustomAnalyzer from the same factories) fails 6 of 8 on main, 10.5.2, 9.12.3 and 9.0.0; its own fix passes 8 of 8. |
| Cross-model review, intended lens | real (medium) | OpenAI GPT-5.6, reading only: the intended-behaviour argument is weak; cites the Telugu fix #13059. |
| Devil's advocate | real | Re-ran the repro on main and 10.5.2; its own probe isolates the accent case from stemming (Čačak); found no surviving intended-behaviour argument; scanned every normalize() override. |
| security assessment | out of scope of SECURITY.md | Deny rules written as parsed prefix, wildcard or regexp exclude nothing; 8 of 9 probe checks fail on main and 10.5.2, 9 of 9 pass with the fix. |
| adjudication | confirmed (high) | Re-ran the independent reproduction's test (6 of 8 fail, controls pass), the finder's with assertions off (3 of 3 fail) and the independent reproduction's with the fix (8 of 8 pass). |
| Follow-up re-run and audit | keep | New seeds on main: the finder's test 3 of 3 fail, the independent reproduction's 6 of 8 fail with both controls passing; with the fix 8 of 8 pass. |
Finding record, Lucene and upstream sources and repro files1,225 words
- Registry title
- SerbianAnalyzer.normalize only lower-cases and skips SerbianNormalizationFilter (Cyrillic to Latin, diacritics removed), so prefix, wildcard, fuzzy, regexp and range queries typed in Cyrillic or with č ć š ž đ miss the indexed terms
- Area
- analysis-common / language analyzers (SerbianAnalyzer.normalize, used by the query parsers for multi-term queries)
- Priority
- Medium, rank 121: The stock SerbianAnalyzer silently misses every Cyrillic or diacritic prefix, wildcard, fuzzy or range query, the same normalize() gap as LFV-557 in a comparable language population, so it follows it.
- Impact class
- silent wrong results
- Impact note
- Every application that indexes Serbian text with the stock SerbianAnalyzer and accepts prefix, wildcard, fuzzy, regexp or range syntax through Lucene's classic, flexible or simple query parser (or calls Analyzer.normalize itself) silently gets too few hits, and for ranges and some fuzzy terms the wrong terms, whenever the query text contains a Cyrillic letter or one of č ć š ž đ, because the index holds only the 'bald' Latin form; term and phrase queries are unaffected, no scale or setting is needed, every release from 9.0.0 (the first with the class) to 10.5.2 and main has it, and by code reading a downstream search server's built-in 'serbian' analyzer and query_string parser inherit it.
- Summary
- You index Serbian text with SerbianAnalyzer, which lower-cases, stems and then maps Cyrillic to Latin and removes diacritics (SerbianNormalizationFilter), so 'Београд' is stored as 'beograd' and 'Čačak' as 'cack', and you search with a prefix, wildcard, fuzzy, regexp or range query through a query parser, which passes the term through Analyzer.normalize, documented to give 'the representation that it would have in the index'. SerbianAnalyzer.normalize only lower-cases, so 'Беогр*' becomes the prefix 'беогр' and 'Čač*' becomes 'čač', and both find nothing while 'Beogr*' and 'Cac*' find the documents. Directly, normalize("Ђак") returns 'ђак' while the index holds 'djak', even with stemming switched off.
- Introduced
- c7029b12541d908e9c86f1f9946a3369aea5236a (LUCENE-9313, GitHub issue apache/lucene#10353 'Analyzer for Serbian language based on Snowball stemmer'; committed by rmuir on 2020-08-26 from the contributor's JIRA patch LUCENE-9313.patch, no PR) [read: git blame and git log -L on the normalize() lines list only this commit, which created the file with createComponents ending in SerbianNormalizationFilter and normalize() = new LowerCaseFilter(in); the attached patch already had that body]. Not a regression: no later commit changed normalize(); the Analyzer#normalize contract (LUCENE-7355, 6.2.0) and the factory's normalize() -> create() (LUCENE-8497, 8.0.0) predate it (2020-08-26)
- First affected version
- 9.0.0 [exec] (the first release that contains SerbianAnalyzer; the class javadoc's '@since 8.6' is wrong: 8.6.3 and 8.11.4 do not have the class [exec, compile error 'cannot find symbol: class SerbianAnalyzer'], and 54a5dc2e6e3 moved the CHANGES entry to 9.0)
- Affected versions
- main ea57360912ed (-ea and -da), 10.5.2, 10.0.0, 9.12.3, 9.1.0 and 9.0.0 [exec, follow-up: the finder's repro fails 3 of 3 on each; the independent reproduction's independent repro fails 6 of 8 on main, 10.5.2, 9.12.3 and 9.0.0]; the other 28 release tags 9.2.0 to 10.5.1 [read: the normalize() body and the createComponents SerbianNormalizationFilter line are identical in all 33 9.x/10.x tags]; not affected: 8.x (API absent; 8.6.3 and 8.11.4 [exec]); upstream apache/lucene main unchanged [read, 2026-10-26]
- 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): 273 queries (scripted prior-report search v1-v3 over GitHub issues and PRs, commits, downstream repositories, dev@, java-user@, issues@ and commits@ and the JIRA archive; 98 GitHub GraphQL searches; 10 JIRA REST searches; 48 lists.apache.org searches; 11 web searches), 22 hits read, none the same bug. Only four apache/lucene issues name SerbianAnalyzer or SerbianNormalizationFilter (#7115 LUCENE-6053, #7933 LUCENE-6875, #10353 LUCENE-9313 which introduced the code, #10860 LUCENE-9821); all were read in full and none discusses normalize() or multi-term queries. Related, not reports: #13059 (TeluguAnalyzer.normalize omitted its normalization filters, fixed as a bug), #13233 (RomanianNormalizationFilter added to normalize() for wildcard searches), #16717 (Thai normalization applied in normalize()), #9234 and #8657 (CustomAnalyzer normalize bugs, different class), #12613 (open, PatternReplaceFilterFactory), #8489 (open, wildcard terms not tokenized, different problem). Downstream reach, not reports: elastic/elasticsearch#100921 (built-in 'serbian' analyzer wraps this class, 8.12.0) and opensearch-project/OpenSearch#14571 (open PR to do the same); three forum threads about Cyrillic or Serbian prefix misses concern custom chains or an unanalyzed field, not this method. Not fixed: upstream main still has normalize() = LowerCaseFilter only (last change to the file 51b3a5baa73, 2025-06-18, import cleanup), and none of the 408 open PRs touches the analysis/sr package. Gaps: keyword search only, the first results page per GitHub query, two web queries throttled once and retried, private reports and chat not searchable
- 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.0.0, 9.1.0, 9.12.3, 10.0.0 and 10.5.2 (the finder's QueryParser test fails on each, and the brief's test fails on 10.5.2); 8.6.3 and 8.11.4 (the class does not exist there). The fix was validated in the scratch worktree: the new test, the 5srsuites and the wholelucene/analysis/commontest suite.
- Finding record
lucene/analysis/common/src/java/org/apache/lucene/analysis/sr/SerbianAnalyzer.java:36-40 (at ea57360912ed) (class javadoc, '@since 8.6' at :39), :108-127 (createComponents javadoc and chain StandardTokenizer -> LowerCaseFilter -> StopFilter -> [SetKeywordMarkerFilter] -> SnowballFilter(SerbianStemmer) -> SerbianNormalizationFilter at :125), :129-132 (normalize returns only new LowerCaseFilter(in))lucene/core/src/java/org/apache/lucene/analysis/Analyzer.java:42-60 (at ea57360912ed) (class example: a filter 'about normalization' belongs in normalize(), one 'about stemming' does not), :203-210 (normalize javadoc: 'the representation that it would have in the index', 'without tokenizing or stemming')lucene/analysis/common/src/java/org/apache/lucene/analysis/sr/SerbianNormalizationFilter.java:25-30 (at ea57360912ed) ('Normalizes Serbian Cyrillic and Latin characters to "bald" Latin'), lucene/analysis/common/src/java/org/apache/lucene/analysis/sr/SerbianNormalizationFilterFactory.java:71-74 (normalize(input) returns create(input)), lucene/core/src/java/org/apache/lucene/analysis/TokenFilterFactory.java:101-105 ('filters that should be applied at normalization time can delegate to create')lucene/queryparser/src/java/org/apache/lucene/queryparser/classic/QueryParserBase.java:550, (at ea57360912ed) :556 (range bounds), :646, :656 (wildcard chunks), :683 (regexp), :712 (prefix), :729 (fuzzy); lucene/queryparser/src/java/org/apache/lucene/queryparser/simple/SimpleQueryParser.java:573 (fuzzy), :605 (prefix); lucene/queryparser/src/java/org/apache/lucene/queryparser/flexible/standard/processors/WildcardQueryNodeProcessor.java:66, :76, FuzzyQueryNodeProcessor.java:59, RegexpQueryNodeProcessor.java:43, TermRangeQueryNodeProcessor.java:138-139 (all call Analyzer.normalize)lucene/analysis/common/src/java/org/apache/lucene/analysis/ro/RomanianAnalyzer.java:128-133 (at ea57360912ed) (sibling: normalize applies LowerCaseFilter and RomanianNormalizationFilter)https://github.com/apache/lucene/issues/10353 (LUCENE-9313, introducing issue; no mention of normalize)https://github.com/apache/lucene/pull/13233 and https://github.com/apache/lucene/pull/13059 (precedents: normalization filters added to or restored in normalize())
- The finding in 30 seconds
Lucene main ea57360912ed: lucene/analysis/common/src/java/org/apache/lucene/analysis/sr/SerbianAnalyzer.java:118-127 (chain), :125 (SerbianNormalizationFilter), :129-132 (normalize)Lucene main ea57360912ed: lucene/analysis/common/src/java/org/apache/lucene/analysis/sr/SerbianNormalizationFilter.java:24-31 (javadoc), :41-170 (mapping)Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/analysis/Analyzer.java:42-60 (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/queryparser/src/java/org/apache/lucene/queryparser/classic/QueryParserBase.java:547-557 (open range bounds); flexible/standard/processors/TermRangeQueryNodeProcessor.java:100-139 (date parsing first)
- Who is affected
Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/analysis/AnalyzerWrapper.java:81-92, :124-126; DelegatingAnalyzerWrapper.java:57-60Lucene main ea57360912ed: lucene/analysis/common/src/java/org/apache/lucene/analysis/sr/SerbianNormalizationFilterFactory.java:71-74; lucene/analysis/common/src/java/org/apache/lucene/analysis/custom/CustomAnalyzer.java:171-174
- Why it looks unintended
Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/analysis/Analyzer.java:53-58, :203-207Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/analysis/TokenFilterFactory.java:101-105; lucene/analysis/common/src/java/org/apache/lucene/analysis/sr/SerbianNormalizationFilterFactory.java:71-74Lucene main ea57360912ed: lucene/analysis/common/src/java/org/apache/lucene/analysis/ro/RomanianAnalyzer.java:128-133Lucene main ea57360912ed: lucene/analysis/common/src/test/org/apache/lucene/analysis/sr/TestSerbianAnalyzer.javaLucene main ea57360912ed: lucene/queryparser/src/java/org/apache/lucene/queryparser/classic/QueryParserBase.java:638, :695-697, :712https://github.com/apache/lucene/pull/13059; https://github.com/apache/lucene/pull/13233
- Suggested fix
Lucene main ea57360912ed: lucene/analysis/common/src/java/org/apache/lucene/analysis/sr/SerbianAnalyzer.java:39, :129-132; SerbianNormalizationFilter.java:30Lucene main ea57360912ed: lucene/analysis/common/src/test/org/apache/lucene/analysis/te/TestTeluguAnalyzer.java:52-61 (test style)Lucene main ea57360912ed: lucene/queryparser/src/java/org/apache/lucene/queryparser/classic/QueryParserBase.java:637-659, :683; lucene/core/src/java/org/apache/lucene/search/TermRangeQuery.java:27-28
- History
Lucene main ea57360912ed: lucene/CHANGES.txt:4597, :4610
- TestSerbianAnalyzerNormalize.java (JUnit test):
repro/LFV-534/src/org/apache/lucene/analysis/sr/TestSerbianAnalyzerNormalize.java(31 lines)