All findings · Lucene finding · LFV-534 · serbian-analyzer-normalize-omits-serbian-normalization

SerbianAnalyzer.normalize skips SerbianNormalizationFilter, so Cyrillic and diacritic prefix, wildcard and fuzzy queries miss

SerbianAnalyzer indexes words in plain Latin (Београд as beograd), but its normalize() only lower-cases, so prefix, wildcard, fuzzy, regexp and range queries in Cyrillic or with č ć š ž đ miss: Беогр* finds 0 documents, Beogr* finds 1. Every release since 9.0.0 has it; the fix is one line.

Summary

Priority
Medium, rank 121The 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
silent wrong results
Since
9.0.0 (LUCENE-9313, committed 2020-08-26)introduced 2020-08-26 · about 6 years
Affected
9.0.0 to 10.5.2 and main
Status
documented
Found by
bounded-exhaustive metamorphic scan: normalize() vs the indexed token

AI-assisted page: this finding was first signalled by a semi-formal check (an exact check of the real jar, or a modelling hypothesis that a tool then witnessed) and then checked with AI tools; the checks it went through, and what each found, are under Prior reports, history and checks. Not yet triaged by a human. Snapshot of 2026-11-06.

The finding in 30 seconds

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.

  1. Index the text Београд Čačak with the stock new SerbianAnalyzer(). Its last filter, SerbianNormalizationFilter, turns Cyrillic into Latin and removes the accents on č ć š ž đ, so the index holds beograd and cack.
  2. Search Беогр*, Čač* or Београд~1 with QueryParser. A partial word cannot go through the full analyzer, so the parser calls Analyzer.normalize, which is documented to return "the representation that it would have in the index".
  3. You expect 1 hit for each query, the same as Beogr* and Cac* get.
  4. You get 0 hits for each. SerbianAnalyzer.normalize only lower-cases, so the queries become беогр*, čač* and београд~1, and no indexed term contains those letters. Calling it directly: normalize("Ђак") returns ђак while the index holds djak, even with stemming switched off.
  5. 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.

Who is affected

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.

Terms

  • 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 of SerbianAnalyzer'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's normalize(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.

The bug

  1. SerbianAnalyzer.normalize() is return new LowerCaseFilter(in); (SerbianAnalyzer.java:129-132). SerbianNormalizationFilter is missing.
  2. 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).
  3. Беогр* therefore becomes body:беогр* and Čač* becomes body:čač*. The index holds beograd and cack, so neither prefix can match any term.

The numbers

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 through QueryParser, StandardQueryParser or SimpleQueryParser, or call Analyzer.normalize themselves. Analyzer wrappers call the wrapped analyzer's normalize, and Lucene's own wrappers add nothing to it (AnalyzerWrapper.java:81-92, :124-126; DelegatingAnalyzerWrapper.java:57-60, the base of PerFieldAnalyzerWrapper), so wrapped uses inherit the gap unless a custom wrapper adds the filter in wrapTokenStreamForNormalization. A CustomAnalyzer built from the same factories is not affected: SerbianNormalizationFilterFactory.normalize() returns create(input) and CustomAnalyzer.normalize applies 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 serbian analyzer (elastic/elasticsearch#100921, 8.12.0), and its query_string parser 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* becomes caca, while the index holds cack). This bug is only the script and accent mismatch.

Minimal repro

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

TestSerbianAnalyzerNormalize.java (JUnit test)31 lines
/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.apache.lucene.analysis.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());
    }
  }
}
Command
# 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: ђак.

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

More files, environment, full output32 words

Environment

luceneLucene main e1faeba08bde (2026-11-02)
jdk25.0.3+9-LTS

Observed output

# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
    => 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)

Why it looks unintended

  • Analyzer#normalize must 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čak is indexed as cacak but 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-74 does exactly that, and the factory has been multi-term aware since it was added (LUCENE-6053, 2014). A CustomAnalyzer built from the same factories normalizes correctly.
  • Siblings. RomanianAnalyzer.java:128-133 applies LowerCaseFilter and RomanianNormalizationFilter in normalize(). Of the stock analyzers that override normalize() and use a filter whose factory overrides normalize(), every other one applies that filter there (German, Romanian, Persian, Arabic, Hindi, Bengali, Telugu, Thai, Greek, Turkish and others); SerbianAnalyzer is 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 what normalize() returns.
  • History. The lower-case-only body came with the class (c7029b12541d, LUCENE-9313) from the contributor's patch; the issue thread never mentions normalize, wildcards or prefixes. The contract (6.2.0) and the factory's normalize() -> 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 and normalize(), 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.

Suggested fix

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.

Apply SerbianNormalizationFilter in normalize() (validated by the checks) (recommended)

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* becomes caca*, 0 hits, the index holds cack; executed).
  • Escaped letters in a wildcard query skip normalize() (QueryParserBase.java:637-659): Бео\г?ад parses to body: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 matches zak, dak and jak but not djak; /đ+ak/ becomes /dj+ak/ (executed).
  • Fuzzy distances are counted on the Latin form, where đ ђ љ њ џ are two letters: Ђак~1 becomes djak~1 and matches djak, dak and jak but not ak, 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; checkGoogleJavaFormat passes.
  • 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 TestSerbianAnalyzer 4 of 4.

Correct @since 8.6 to @since 9.0 in the same change (not run)

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.

Workaround for users until a fix ships

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.

Downstream note

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.

How it was found

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 for w. 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. SerbianAnalyzer with 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 its createComponents; SerbianAnalyzer was 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).

Prior reports, history and checks

Prior reports

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

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues and PRs (apache/lucene), commits2026-10-26three 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 keysno report; #10353 (LUCENE-9313) added the code and never mentions normalize
Open PRs2026-10-26all 408 open apache/lucene PRs with their touched filesnone touches analysis/sr
JIRA archive2026-10-2610 JQL searches over LUCENE and SOLR; LUCENE-9313 and LUCENE-6053 read with commentsno report
Mailing lists (lists.apache.org)2026-10-2648 searches over dev@, java-user@, issues@, commits@ and the Solr listsno report; the contributor's 2020 contribution thread only
Downstream repositories and the web2026-10-2624 GraphQL searches over Elasticsearch, OpenSearch and Solr; 11 web searches, 3 forum threads readno report; elastic/elasticsearch#100921 exposes the class; forum threads concern custom chains
Related items (read)2026-10-26precedents and neighbours#13059 (Telugu normalize, fixed as a bug), #13233 (Romanian), #16717 (Thai), #9234 and #8657 (CustomAnalyzer), #12613 (open), #8489 (open, wildcard tokenization)

History

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.

Versions and branches executed, with results
Version or commitVerdictResult
main ea57360912ed affectedthe 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 affectedthe 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 affectedQueryParser 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 onlyidentical normalize() body and filter line
8.6.3, 8.11.4 class absentthe test does not compile: SerbianAnalyzer does not exist
main + fix not affectedall repros pass; lucene/analysis/common 1,911 tests, 0 failed

Independent checks

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

Each independent check and its verdict
CheckVerdictWhat it found
finder candidateQueryParser 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), proceduralAn 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 realOwn 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 realRe-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.mdDeny 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 keepNew 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.

Sources and provenance

Finding record, Lucene and upstream sources and repro files1,225 words

Finding record

LFV-534, snapshot of 2026-11-06

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 5 sr suites and the whole lucene/analysis/common test suite.

Lucene and upstream sources

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-139
  • Lucene 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-60
  • Lucene 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-207
  • Lucene 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-74
  • Lucene main ea57360912ed: lucene/analysis/common/src/java/org/apache/lucene/analysis/ro/RomanianAnalyzer.java:128-133
  • Lucene main ea57360912ed: lucene/analysis/common/src/test/org/apache/lucene/analysis/sr/TestSerbianAnalyzer.java
  • Lucene main ea57360912ed: lucene/queryparser/src/java/org/apache/lucene/queryparser/classic/QueryParserBase.java:638, :695-697, :712
  • https://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:30
  • Lucene 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

Other statements trace to the hunt's working files (models, logs, reviews and briefs), which are not published.

Repro files

Built 2026-11-06 from the hunt's record of this finding and its doc file. Lucene main under test in this hunt: ea57360912ed. Every listed finding was re-run on Lucene main e1faeba08bde on 2026-11-02.