A fuzzy completion's boost is the number of letters the suggestion's matched prefix shares with the query. In unicode-aware mode the query side holds code points and the suggestion side UTF-8 bytes, and the count compares them as if they were the same unit.
aécd, query code points 61 E9 63 against the matched prefix's UTF-8 bytes 61 C3 A9, so boost 1 instead of 2. Right: expected and actual results of the repro. Select the still to open it at full size. Generated with AI, checked against the code.- You index two suggestions,
aécd(weight 3) and the typoaxcd(weight 4), and runFuzzyCompletionQueryforaécwith the default fuzzy settings andunicodeAware=true, the option that measures the query in letters (code points) instead of bytes. - You expect what the same data gives in ASCII. The score is weight x boost (the weight alone when the boost is 0), and the boost counts the letters the suggestion's matched prefix shares with the query: for
abc,abcdgets 3 x 2 = 6.0 andaxcd4 x 1 = 4.0. - You get
axcd4.0 aboveaécd3.0: the exact prefix gets boost 1, the same as the typo. With no ASCII letter at all every boost is 0:котranks the typoкиты(4.0) aboveкоты(3.0). - Why: the query is kept as code points (
é= E9) but the matched suggestion arrives as UTF-8 bytes (é= C3 A9). The count matchesa, then compares E9 with C3 and stops. - Which suggestions match stays right; their scores and order change, so a top-n cut can drop the exact prefix (with n = 1 the query returns only
axcd). The default byte mode and ASCII-only text are not affected. Since 5.3.0 (2015).
Users of FuzzyCompletionQuery, plain or in ContextQuery, with unicodeAware=true (default false) on non-ASCII text. The boost is capped at the ASCII letters before the first non-ASCII query letter (0 if there is none); every lookup whose matched prefix reaches such a letter is affected. A heavier typo can then rank first, and a top-n limit can drop the exact prefix.
Mechanism, terms and numbers818 words
FuzzyCompletionQuery, the document suggester's fuzzy prefix query, boosts each suggestion by the number of leading letters its matched prefix shares with the typed text, so an exact prefix normally gets a larger boost than a typo (a typo in the last letter ties with it, the minimal-prefix NOTE). With unicodeAware=true, documented as measuring the query "in Unicode code points (actual letters) instead of bytes", that count compares two units: the query is kept as code points, the matched suggestion arrives as UTF-8 byte labels (FuzzyCompletionWeight.setNextMatch, FuzzyCompletionQuery.java:266-286). ASCII letters have the same value in both, so the count runs through ASCII letters and, when the comparison reaches the first non-ASCII query letter, stops there; an all-non-ASCII query gets boost 0, and a code point can accidentally equal an unrelated byte (U+00C3 and the first byte of é), which can even give a typo a larger boost than the exact prefix. With the default fuzzy settings, aéc over aécd/3 and the typo axcd/4 gives [axcd/4.0, aécd/3.0] instead of the ASCII twin's [abcd/6.0, axcd/4.0], and кот over коты/3 and киты/4 gives [киты/4.0, коты/3.0]. Which suggestions match is right; their scores and order change, and with them which matches make a top-n list, in the plain query and inside ContextQuery. It has been this way since the class was added (LUCENE-6459, first release 5.3.0). Decoding the matched path to code points before comparing (+15/-2, one file) fixes it, and the whole suggest module still passes.
- Document suggester: suggestions indexed as
SuggestField(orContextSuggestField) values, each with an integer weight, searched withSuggestIndexSearcher. Internally an FST whose labels are UTF-8 bytes. FuzzyCompletionQuery(@lucene.experimental): returns suggestions whose prefix is withinmaxEditsof the typed text (defaults:maxEdits1, transpositions on,nonFuzzyPrefix1,minFuzzyLength3). Score = weight x boost; a boost of 0 gives the weight (CompletionScorer.java:98-106).- Boost: for each matched prefix path, the longest run of leading letters it shares with the query (
FuzzyCompletionWeight.setNextMatch,FuzzyCompletionQuery.java:266-286). The path is the shortest prefix the fuzzy automaton accepts, so for the queryabcthe exact suggestionabcdis scored onab(the NOTE at:268-271): that is why the expected boosts are 2 and 1, not 3 and 1. unicodeAware(default false,:61): "Measure maxEdits, minFuzzyLength, transpositions and nonFuzzyPrefix parameters in Unicode code points (actual letters) instead of bytes" (:57-59).
createWeightanalyzes the query withstream.toAutomaton(unicodeAware)(:150-153); in unicode-aware mode its labels are code points.toLevenshteinAutomatakeeps every string of it as a boost reference,refs.add(IntsRef.deepCopyOf(string))(:166-170).- Because the FST has byte labels, only the match automaton is converted to UTF-8 with
UTF32ToUTF8(:156-160); the refs are passed on unchanged (:163). NRTSuggestercallssetNextMatch(path.input().get())with the matched path in UTF-8 bytes (NRTSuggester.java:230-231);ContextQueryforwards the bytes after the context (ContextQuery.java:308-328). (A separate registered bug, LFV-254, can also changeContextQuery's context boost when a longer indexed context is matched through a prefix context oraddAllContexts(); theContextQuerycases cited here index every suggestion under one context, so there is no longer context for that bug to act on.)setNextMatchcomparesref.ints[i]withpathPrefix.ints[i]position by position (:277) and keeps the longest run as the boost. No side is converted.
| position | query (code points) | matched prefix aé (UTF-8 bytes) | equal? |
|---|---|---|---|
| 0 | 61 (a) | 61 | yes: count 1 |
| 1 | E9 (é) | C3 (first byte of é) | no: stop |
Boost 1, score 3 x 1 = 3.0; counting letters gives 2 and 6.0. The typo axcd (matched prefix axc) gets boost 1 either way, 4.0. So the typo ranks first.
The result is neither a code-point count nor a byte count. Every ASCII letter (0-127) has the same value in both units; the first non-ASCII query letter is compared with a single byte of the suggestion and differs, except by accident when the code point has the same value as that byte: à is U+00C3 and é starts with the byte C3, so for the query aÃc the typo aécd is credited with 2 letters. After such an accident the next query code point meets a continuation byte (80-BF), so a code point U+0080-U+00BF can extend the run: for the query aé (61 C3 A9) the typo aé©d (bytes 61 C3 A9 C2 A9) gets boost 3 and the exact aéd (matched prefix aà = 61 C3 83) only 2, so with equal weights the typo ranks first, [aé©d/3.0, aéd/2.0].
A matched path is scored right when it ends, or its comparison stops at an ASCII mismatch, before the first non-ASCII query letter: abé over abéd/3 and axéd/4 gives the correct [abéd/6.0, axéd/4.0] (the exact suggestion is scored on ab).
Exposure and likelihood429 words
- Who. Applications that build
FuzzyCompletionQuerywith the 9-argument constructor andunicodeAware=true(the class is@lucene.experimental; the flag defaults to false), directly or wrapped inContextQuery, overSuggestFieldorContextSuggestFieldvalues. - When. A matched path is scored wrong when its comparison with the query reaches a non-ASCII query letter: the boost is then capped at the ASCII letters before it, and is 0 when there is none (unless a code point accidentally equals a byte). A path that ends, or stops at an ASCII mismatch, before that letter is scored right (
abéoverabéd/3 andaxéd/4:[abéd/6.0, axéd/4.0]). With the default settings the exact prefix of a query of 3 or more letters is scored on all letters but the last, so its boost is wrong whenever a non-ASCII letter comes before the last letter. In the examples, without the bug the exact prefix wins whenever its weight x 2 beats the typo's weight; with it the weights alone decide: the order changes when the typo is heavier, and with equal weights they tie. - How bad. The accidental byte equality can give a typo the exact prefix's boost (
aÃc:[aécd/8.0, aÃcd/6.0]) or a larger one:aéoveraéd/1 andaé©d/1 ranks the typo first with equal weights,[aé©d/3.0, aéd/2.0]. - What stays right. Which suggestions match the query: the match automaton does not use the boost. Scores and order change, and so, under a top-n limit, does which matches are returned (
aécoveraécd/3 andaxcd/4 with n = 1:[axcd/4.0]; with the fix[aécd/6.0]). Nothing throws or logs. Default byte mode and ASCII-only text are unaffected. In the hunt's bounded-exhaustive comparison (ranked top-n lists for n = 1, 3 and 200) every mismatch of this bug's class was explained by the boost units alone. - Scale and settings. Deterministic (20 iterations, always the same two failures). Executed on two-suggestion indexes and on the hunt's bounded indexes (39 suggestions; one, two or three segments, or index-sorted); nothing in the mechanism depends on index size (read). The repros use the default base codec with the completion postings format on the suggest field, as the upstream tests do; assertions on or off.
- Downstream (read, not tested). Elasticsearch's completion suggester exposes
unicode_awareas a fuzzy option (elastic/elasticsearch#10746, #107707), and OpenSearch's feeds its fuzzy options intoFuzzyCompletionQuery(opensearch-project/OpenSearch#22924). - How likely. Rated plausible: deterministic for every affected lookup, but only a ranking change, easy to mistake for the known minimal-prefix limitation, and no prior report was found (searches of 2026-10-27).
The test shows that FuzzyCompletionQuery with unicodeAware=true compares the query's code points with the matched suggestion's UTF-8 bytes, so its prefix boost stops at the first non-ASCII letter and an exact prefix ranks below a typo.
/*
* 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.search.suggest.document;
import java.util.Arrays;
import java.util.List;
import org.apache.lucene.analysis.Analyzer;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.index.Term;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.analysis.MockAnalyzer;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.tests.util.TestUtil;
import org.apache.lucene.util.automaton.Operations;
public class TestFuzzyCompletionQueryUnicodeAwareBoost extends LuceneTestCase {
public void testOneNonAsciiLetter() throws Exception {
Analyzer analyzer = new MockAnalyzer(random());
IndexWriterConfig iwc = new IndexWriterConfig(analyzer);
iwc.setCodec(TestUtil.alwaysPostingsFormat(new Completion104PostingsFormat()));
try (Directory dir = newDirectory();
IndexWriter w = new IndexWriter(dir, iwc)) {
w.addDocument(List.of(new SuggestField("suggest", "aécd", 3)));
w.addDocument(List.of(new SuggestField("suggest", "axcd", 4)));
try (DirectoryReader reader = DirectoryReader.open(w)) {
boolean unicodeAware = true;
FuzzyCompletionQuery query =
new FuzzyCompletionQuery(
analyzer,
new Term("suggest", "aéc"),
null,
FuzzyCompletionQuery.DEFAULT_MAX_EDITS,
FuzzyCompletionQuery.DEFAULT_TRANSPOSITIONS,
FuzzyCompletionQuery.DEFAULT_NON_FUZZY_PREFIX,
FuzzyCompletionQuery.DEFAULT_MIN_FUZZY_LENGTH,
unicodeAware,
Operations.DEFAULT_DETERMINIZE_WORK_LIMIT);
TopSuggestDocs hits = new SuggestIndexSearcher(reader).suggest(query, 10, false);
// expected as for "abc" over abcd/3, axcd/4: [aécd/6.0, axcd/4.0]; actual: aécd/3.0 last
assertEquals(
List.of("aécd/6.0", "axcd/4.0"),
Arrays.stream(hits.scoreLookupDocs()).map(d -> d.key + "/" + d.score).toList());
}
}
}
}
# in a copy of this finding's repro folder (repro/LFV-689/, 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/suggest/build/libs/lucene-suggest-$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.search.suggest.document.TestFuzzyCompletionQueryUnicodeAwareBoost
- Expected vs actual
- Expected for
aécoveraécd/3 and the typoaxcd/4 the same result as in ASCII,[aécd/6.0, axcd/4.0]; actual[axcd/4.0, aécd/3.0], with-eaand-da.
More files, environment, full output36 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
=> java.lang.AssertionError: expected:<[aécd/6.0, axcd/4.0]> but was:<[axcd/4.0, aécd/3.0]>
org.junit.Assert.fail(Assert.java:89)
org.junit.Assert.failNotEquals(Assert.java:835)
org.junit.Assert.assertEquals(Assert.java:120)
org.junit.Assert.assertEquals(Assert.java:146)
org.apache.lucene.search.suggest.document.TestFuzzyCompletionQueryUnicodeAwareBoost.testOneNonAsciiLetter(TestFuzzyCompletionQueryUnicodeAwareBoost.java:57)- The javadoc: unicodeAware measures the query "in Unicode code points (actual letters) instead of bytes".
- The change that added the class defines "boost = # of prefix characters matched"; the upstream test pins that count.
- The same data ranks correctly in ASCII and in byte mode; renaming one letter to another single code point changes the score.
The full argument270 words
- Contract. Class javadoc (
FuzzyCompletionQuery.java:43-44): "This query boost documents relative to how similar the indexed terms are to the provided prefix."DEFAULT_UNICODE_AWARE(:57-59): "Measure maxEdits, minFuzzyLength, transpositions and nonFuzzyPrefix parameters in Unicode code points (actual letters) instead of bytes." Constructor (:123): "@param unicodeAware treat prefix as unicode rather than bytes".isUnicodeAware(:227): "lengths are measured in unicode code-points rather than bytes". - Intent on record. LUCENE-6459 (#7518), which added the class: "Documents are 'boosted' by the number of matching prefix letters of the suggestion with respect to the original term text" and "
boost = # of prefix characters matched". Its patch author, in the comments:FuzzyCompletionWeight"would count the number of prefix characters a matched input has w.r.t. the analyzed query prefix and set the boost for it to the number of common prefix length". A Lucene committer in elastic/elasticsearch#24903 (2019): "a boost per path that is equal to the length of the shared prefix". None of them mentions unicode, code points or bytes. - Tests.
TestFuzzyCompletionQuery.testFuzzyQuerypins the boost as the shared-prefix count (suaggestion4 x 2,suggestion2 x 3,sugfoo1 x 3,ssuggestion1 x 1). No upstream test buildsFuzzyCompletionQuerywithunicodeAware=true. - History. The comparison loop is unchanged since the class was added (
git blame -w -M -C); no commit, CHANGES or MIGRATE entry or discussion calls the mixed units a choice. - Controls. On the same Cyrillic data the default byte mode ranks the exact prefix first (
[коты/15.0, киты/12.0]), and the Latin twin does too; only the documented unicode option breaks it.
The devil's-advocate check170 words
The strongest case for "works as designed": the class is @lucene.experimental; no document gives the boost as a number or names its unit; the unicodeAware javadoc lists only the edit parameters; CompletionWeight.setNextMatch documents pathPrefix only as "the prefix of a matched path"; and fuzzy completion scoring already has an accepted weak spot, the minimal-prefix NOTE in setNextMatch, which a committer defended in elastic/elasticsearch#24903 ("I agree that it can be misleading but this limitation is needed"), with the same symptom: exact matches tie with typos.
Why it fails: #24903 is ASCII and comes from which path is scored; it cannot cut the count at the first non-ASCII letter, give 0 to an all-Cyrillic query, or match a code point with an unrelated byte. The Latin control and byte mode on the same Cyrillic data pass, and fixing either problem leaves the other. Cross-model reviews (OpenAI GPT-5.6) rated the intended-behaviour argument moderate and still concluded real; the devil's advocate and the adjudication found no argument that survives. Verdict: a defect.
Recommended Decode the matched path to code points when unicodeAware (reused buffers)
Pass unicodeAware to FuzzyCompletionWeight and, in setNextMatch, decode the matched UTF-8 path to code points with two reused buffers before comparing (+15/-2, one file). The repro passes, and so do the whole suggest module (242 tests, 0 failures) and the format and lint checks. Add a CHANGES entry: scores change for unicode-aware users.
@Override
protected void setNextMatch(IntsRef pathPrefix) {
if (unicodeAware) {
// refs hold code points, but the matched path holds UTF-8 byte labels: compare letters
codePointScratch.copyUTF8Bytes(Util.toBytesRef(pathPrefix, utf8Scratch));
pathPrefix = codePointScratch.get();
}
// ... the existing comparison loop, unchanged
}All options and trade-offs5 options · 722 words
The recommended fix was applied in a scratch worktree at a9779ef3c193 and run through gradle (seed DEADBEEF), with assertions on and off; the worktree was removed afterwards. The hunt's first version of the same idea was validated earlier by shadowing the fixed class into the stock jars. Options B and C were not run.
Decode the matched path to code points when unicodeAware (reused buffers) (validated by the checks) (recommended)
Give FuzzyCompletionWeight the unicodeAware flag (from createWeight) and, at the top of setNextMatch, turn the UTF-8 byte labels into code points with Util.toBytesRef and IntsRefBuilder.copyUTF8Bytes into two reused buffers; the comparison loop stays as it is. Units then match the javadoc's "code points (actual letters)". Byte mode is untouched. The accepted path is valid UTF-8 by construction, because the match automaton is the UTF32ToUTF8 conversion, so decoding is well defined.
@Override
protected void setNextMatch(IntsRef pathPrefix) {
if (unicodeAware) {
// refs hold code points, but the matched path holds UTF-8 byte labels: compare letters
codePointScratch.copyUTF8Bytes(Util.toBytesRef(pathPrefix, utf8Scratch));
pathPrefix = codePointScratch.get();
}
// ... the existing comparison loop, unchanged
}Trade-offs
- One decode per matched prefix path. The byte and int buffers are reused once grown, but
IntsRefBuilder.copyUTF8BytescallsUnicodeUtil.UTF8toUTF32, which creates one smallUTF8CodePointobject per non-empty decode (the JIT may remove it); performance was not measured. - The weight already keeps per-match state (
currentBoost), so the buffers add no new thread-safety concern. - Behaviour change: scores rise for unicode-aware users with non-ASCII text; say so in a CHANGES entry (Bug Fixes) and add the repro as a test (no upstream test covers
unicodeAware=truetoday).
What the checks validated
- Scratch worktree at
a9779ef3c193, gradle, seed DEADBEEF: this doc's repro passes 3 of 3 with assertions on and off, andcheckGoogleJavaFormatpasses on it; with the finder's test,TestFuzzyCompletionQueryandTestContextQuery, 24 of 24 pass, on and off. - The whole suggest module with both repro tests added: 33 classes, 242 tests, 7 skipped, 0 failures;
ecjLintMain,ecjLintTest,forbiddenApisMain,forbiddenApisTestandcheckGoogleJavaFormatpass. - Without the fix the same worktree fails both repro tests 2 of 3, with assertions on and off.
- The fact check shadowed the same fix into the stock jars: the reproducer's test passes 12 of 12 and the devil's advocate's 6 of 6 (
ContextQuery, 2-, 3- and 4-byte letters,aÃc);aéthen ranks[aéd/2.0, aé©d/1.0]and the top-1aéclookup returns[aécd/6.0].
Decode the path with new String(bytes, UTF_8).codePoints().toArray() on every match.
Trade-offs
Allocates a byte array, a String and an int array per matched path; the reused-buffer version avoids that.
What the checks validated
With the fixed class shadowed into the stock jars: the finder's test 3 of 3, the independent reproducer's 12 of 12 (the adjudication's run), the intended-lens reviewer's and the finder's tests together 6 of 6, the refute reviewer's test and the 3 upstream TestFuzzyCompletionQuery tests; in the hunt's bounded-exhaustive comparison, re-run with it and the hunt's other candidate fix shadowed, the unit-mismatch class is gone and only unrelated classes remain; back-ported to 5.3.0 it turns the probe from fail to pass. The reproducer's own variant (a hand-written decoder) also passes the upstream TestFuzzyCompletionQuery, TestContextQuery, TestPrefixCompletionQuery and TestSuggestField (47 of 47).
In createWeight, keep the refs as UTF-8 bytes; in setNextMatch compare bytes but add 1 to the count only when a whole code point has matched.
Trade-offs
No per-match decode; the same results as the recommended option in principle, with more logic in the loop. Not run.
Encode the refs to UTF-8 and count matching bytes.
Trade-offs
Consistent, but the boost becomes a byte count: a shared first byte earns partial credit and a non-Latin letter counts 2-4 times. By hand, коты and киты would tie at 12. Not run.
Leave unicodeAware at its default (false). Byte mode ranks the exact prefix first on the Cyrillic example ([коты/15.0, киты/12.0]).
Trade-offs
Then maxEdits, nonFuzzyPrefix and minFuzzyLength count UTF-8 bytes, so a typo on a multi-byte letter can cost more than one edit (read, not tested): the problem unicodeAware exists to solve.
Not a Lucene security issue (it only changes the scores and order of the suggestions the query matches, and so the top-n cut among them); Elasticsearch's completion suggester exposes this flag as its fuzzy unicode_aware option, so its users with non-ASCII suggestions would see the same ranking (read from its issue tracker, not tested).
A bounded-exhaustive comparison of the stock jar with an independent reference written from the javadoc and the tests, over every key of up to 3 letters and every fuzzy setting on three alphabets. One class of score mismatches appeared only with unicodeAware=true on non-ASCII text and vanished with the fix; code reading explained every case.
The method in full253 words
Method: bounded-exhaustive translation validation on the stock jar (semi-formal). No model checker, solver or prover was involved.
- A completion-suggester hunt wrote an independent reference of the document suggester from the javadoc,
FuzzyCompletionWeightand the upstreamTestFuzzyCompletionQueryexpectations before running it, and compared it with the stock main jar: every suggestion of length 1-3 on three alphabets ({a, b, c},{a, é, b},{a, U+65E5, U+1F600}), 4 index layouts,SuggestFieldandContextSuggestField, every key of length 0-3 and 240FuzzyCompletionQuerysettings (maxEdits0-2, transpositions,nonFuzzyPrefix0-3,minFuzzyLength0-4,unicodeAware) plus prefix and regex queries: 2,085,120 checks per run, with assertions on and off. - This bug's mismatch class (68,832 plain and 282,264
ContextQuerychecks, plus 25,816 that also carry a separateContextQuerybug) appeared only withunicodeAware=trueon the non-ASCII alphabets; the hypothesis "query code points compared with UTF-8 bytes" explained every case, 0 unexplained. With this fix (and the hunt's other candidate fix) shadowed, the class disappears. Vacuity: a deliberately wrong reference ("fuzzy boost + 1") produced 857,592 unexplained mismatches, so the comparison does see boost errors. The logs also hold other classes from other code paths: a separateContextQuerycontext-boost bug (LFV-254), regex-query findings and, with assertions on, assertion errors inContextQuery.setInnerWeight. - The finder then wrote a public-API JUnit (2026-10-27), checked by an independent reproducer, two executed reviews by a model from another family (OpenAI GPT-5.6), an intended-behaviour devil's advocate, an adjudication, a history sweep, a prior-report search, a security screen and a credibility audit.
No prior report found (2026-10-27) in over 150 queries over GitHub, JIRA, the mailing lists, downstream engines and the web. Closest: elastic/elasticsearch#24903, ASCII exact matches tying with typos because of the minimal-prefix design; same symptom, different cause.
Searches4 searches
Verdict new: no report says the unicode-aware boost compares code points with bytes, not fixed upstream, and no open PR touches FuzzyCompletionQuery.java. Context: #6094 (LUCENE-5030, unicodeAware in the separate FuzzySuggester, which has no prefix boost), #7425 (LUCENE-6365, size of the refs set), #9334 / #9335 (other ContextQuery defects on the same path). Keyword search cannot prove absence; GitHub tokenises identifiers poorly and part of the web search was throttled.
| Where | When | How | Found |
|---|---|---|---|
| apache/lucene issues, PRs and commits; downstream repositories; dev@, java-user@, issues@, commits@; JIRA archive; the web | 2026-10-27 | the hunt's prior-report tool, two runs (45 and 51 queries: class and method names, unicodeAware, fuzzy boost and score, non-ASCII, CJK, multibyte) | 14 and 101 distinct hits, none about this defect; context only: LUCENE-6459, LUCENE-5030, LUCENE-3846, LUCENE-8287 |
| GitHub: apache/lucene, apache/solr, Elasticsearch, OpenSearch | 2026-10-27 | 38 manual searches, open PRs with the suggest label, the file's git history | elastic/elasticsearch#24903 (symptom only), #10746, #7060, #107707; OpenSearch#22924; nothing about the units |
| LUCENE and SOLR JIRA, lists.apache.org | 2026-10-27 | 6 JQL queries; list queries on dev@, java-user@ and issues@ over 20 years | only the LUCENE-6459, LUCENE-6365, LUCENE-5030 and LUCENE-8287/8288 threads |
| the web and Stack Overflow | 2026-10-27 | 5 queries (one throttled and re-run) plus the devil's advocate's symptom-angle searches | only posts about the ASCII minimal-prefix behaviour |
Introduced with the class by LUCENE-6459 (be4d58c7740, 2015-05-28; branch_5x 380bb29f395), first released in 5.3.0. Every executed release from 5.3.0 to 10.5.2, and main, fails with the same numbers. Not a regression, and not fixed upstream.
Versions tested5 rows
LUCENE-6459 "[suggest] Query Interface for suggest API" (#7518; patch by @areek, committed by @mikemccand; svn era, no PR) created FuzzyCompletionQuery and setNextMatch with code-point refs, a converted match automaton and byte-label paths. Later commits (LUCENE-6515, LUCENE-6640, LUCENE-6365, LUCENE-8332, the 2021 reformat, the determinize work limit, #15573) change neither unit. Releases between the executed ones are affected by tag containment and blame (8.8.2 to 8.11.3 inferred, not tagged in the local clones). Upstream main a95c754ee003 (2026-10-26) has no change under suggest/document since 2026-09-01. The 5.3.0 fix check used the hunt's first fix version (the String decode, adapted to the 5.3.0 source), not the recommended diff, which was run on main only. In the 日本語 case both suggestions score 1.0 in every release, but the tie is listed 日本語 first up to 6.6.6 and 日大語 first from 7.7.3 on; compare scores, not the order of tied entries.
| Version or commit | Verdict | Result |
|---|---|---|
main a9779ef3c193 | affected | this doc's JUnit 2 of 3 fail through gradle, assertions on and off; the finder's 2 of 3, the reproducer's 6 of 12, the devil's advocate's 3 of 6 |
| 10.5.2, 9.12.3 | affected | the finder's JUnit 2 of 3 fail (assertions on and off); the reproducer's 6 of 12; the probe fails |
| 10.0.0, 9.0.0, 8.11.4, 7.7.3, 6.6.6, 6.0.0, 5.5.5, 5.3.2 | affected | the probe fails with the same numbers (aéc: aécd/3.0 instead of 6.0; 日本語: 1.0 instead of 2.0) |
| 5.3.0 | affected | the probe fails (assertions on and off); with the hunt's first fix version (String decode) back-ported, it passes |
| 5.2.1 | not affected | no FuzzyCompletionQuery yet: the probe does not compile |
An independent reproducer, an intended-behaviour devil's advocate and two executed reviews by a model from another family (OpenAI GPT-5.6) each reproduced it with their own tests; an adjudication, a credibility audit and this doc's gradle runs confirmed it and validated the fix.
Checks and verdicts467 words
Every check ran its own or the finder's test on stock main; every ASCII and byte-mode control passed. The reviews from the other model family ran on an earlier main (ea57360912ed), whose suggest module is identical.
| Check | Verdict | What it found |
|---|---|---|
| finder | candidate | Bounded-exhaustive comparison: this bug's mismatch class appears only with unicodeAware=true on non-ASCII text and is gone with the fix; the JUnit fails 2 of 3 on main (on and off), 10.5.2 and 9.12.3. |
| cross-model review, refute lens (executed) | real (high) | An independent reviewer from another model family (OpenAI GPT-5.6) wrote its own Greek test: the exact prefix kπta scores 5.0 instead of 10.0 and ranks below the typo kqta (7.0); the ASCII control passes; the fix passes. |
| independent reproducer | real | Own test, 6 of 12 fail on main (on and off), 10.5.2 and 9.12.3: code points of 2, 3 and 4 bytes, ContextQuery, all-Cyrillic дом, the aÃc accident; 6 controls pass; its fix passes 12 of 12. |
| cross-model review, intended lens (the cross-model review, executed) | real (high) | The other model family again: intended-behaviour argument moderate; its Greek test fails on main and 10.5.2 while its byte-mode control passes; re-run on a9779ef3c193, both tests pass with the fix. |
| intended-behaviour devil's advocate | real (high) | Built the strongest "intended" case (the minimal-prefix limitation, #24903) and showed why it fails; found the intended unit in the LUCENE-6459 thread; its test fails 3 of 6 on main and 10.5.2. |
| adjudication | confirmed (high) | Re-ran the reproducer's test with new seeds (6 of 12 fail, on and off), with the fix (12 of 12 pass) and the finder's test (2 of 3 fail); duplicate of none. |
| security screen | not security-sensitive | Changes only the scores and order of the suggestions the query matches (and so the top-n cut among them); no extra match, no bypass or exposure, and no new work; breaks no guarantee Lucene's SECURITY.md documents for untrusted input. |
| registry re-run and credibility audit | keep | The registration check and the audit re-ran the finder's test on a clean main with four fresh seeds: 2 of 3 fail each time, assertions on and off; with the fix 3 of 3 pass; the audit also checked the worked example by hand. |
| this doc | reproduced; fix validated | Gradle in a scratch worktree at a9779ef3c193: on stock, this doc's test and the finder's each fail 2 of 3 (on and off); with the fix both pass, with TestFuzzyCompletionQuery and TestContextQuery 24 of 24, the whole suggest module passes (242 tests, 0 failures) and the format and lint checks pass. The fact check shadowed the same fix into the stock jars: the reproducer's test 12 of 12 and the devil's advocate's 6 of 6 pass. |
The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.
- PNG 577 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,461 words
- Registry title
- FuzzyCompletionQuery with unicodeAware=true compares the query's code points with the UTF-8 bytes of the matched suggestion, so its prefix-similarity boost stops at the first non-ASCII letter and exact prefixes lose their boost advantage over typos
- Area
- suggest / document (NRT) suggester (FuzzyCompletionQuery: FuzzyCompletionWeight.setNextMatch prefix-similarity boost in unicodeAware mode, also inside ContextQuery)
- Priority
- Low, rank 383: Ranking-only, under the opt-in unicodeAware flag of the experimental document suggester.
- Impact class
- silent wrong results
- Impact note
- Hits applications that use the document suggester's FuzzyCompletionQuery (directly or wrapped in a ContextQuery) with the opt-in unicodeAware=true on suggestions with non-ASCII letters, in every release from 5.3.0 to 10.5.2 and on main, with the default codec and with -ea or -da: on every such lookup the prefix-similarity boost counts only the ASCII letters before the first non-ASCII one (0 for an all-non-ASCII query) and can count an unrelated code point and UTF-8 byte of the same value as equal, so an exact prefix gets the same boost as a typo and ties with it or ranks below a heavier typo, depending on the index weights; the set of suggestions is right, nothing throws, any scale; the default byte mode and ASCII-only text are unaffected.
- Summary
- You build a FuzzyCompletionQuery with unicodeAware=true, the documented option that measures the query 'in Unicode code points (actual letters) instead of bytes', and expect a suggestion that shares more leading letters with the typed prefix to get a larger boost, exactly as for ASCII text. Instead FuzzyCompletionWeight.setNextMatch compares the query's code points with the UTF-8 byte labels of the matched suggestion, so the count stops at the first non-ASCII letter: for the query 'aéc' over aécd (weight 3) and the typo axcd (weight 4) both get boost 1 and the exact prefix ranks second, [axcd/4.0, aécd/3.0], while the same data in ASCII ('abc' over abcd and axcd) gives [abcd/6.0, axcd/4.0]; for an all-CJK or all-Cyrillic query every suggestion gets boost 0, so the exact prefix only ties with typos and the weight alone decides. Only scores and ranking are wrong (the suggestions found are right), for the plain query and inside a ContextQuery.
- Introduced
- be4d58c7740bea4e797eb1acff6355eb260e7c93 (trunk, LUCENE-6459 '[suggest] Query Interface for suggest API', now apache/lucene#7518; patch by @areek, committed by @mikemccand; svn era, no pull request), branch_5x backport 380bb29f39595de1c3947075296d1d0a36f02fd8 the same day: the commit that created FuzzyCompletionQuery already kept the boost references as code-point strings from stream.toAutomaton(unicodeAware), converted only the match automaton with UTF32ToUTF8, and had NRTSuggester pass the FST byte-label path to setNextMatch [read: git blame -w -M -C of the comparison loop on main and of the introducing commit in the full-history clone; later commits (LUCENE-6515, LUCENE-6640, LUCENE-6365, LUCENE-8332, the 2021 reformat, the determinize work limit, #15573 javadoc) change neither unit; exec: the candidate fix back-ported to the 5.3.0 FuzzyCompletionQuery.java turns the 5.3.0 history probe from fail into pass]; not a regression: the bug is as old as the class (2015-05-28 (trunk be4d58c7740 07:53Z; branch_5x backport 380bb29f395 08:31Z))
- First affected version
- 5.3.0 [exec: the history probe fails with -ea and -da; CHANGES.txt lists LUCENE-6459 under 'Lucene 5.3.0, New Features']; 5.2.1 and older have no FuzzyCompletionQuery or CompletionQuery, so the probe does not compile [exec]. Correction to the candidate's and judge's 'since 5.2': the backport reached branch_5x after the 5.2 branch was cut
- Affected versions
- every release from 5.3.0 through 10.5.2 and main a9779ef3c193 (unreleased 11.0) [exec: the version-portable history probe (an ASCII case vs the same case with letters renamed to single non-ASCII code points) fails with the same numbers ('aéc' -> aécd/3.0 vs abcd/6.0; '日本語' -> 日本語/1.0 vs abc/2.0) on 5.3.0 (-ea, -da), 5.3.2, 5.5.5, 6.0.0, 6.6.6, 7.7.3, 8.11.4, 9.0.0, 9.12.3 (-ea, -da), 10.0.0, 10.5.2 (-ea, -da) and main; the finder's JUnit fails 2 of 3 on 9.12.3, 10.5.2 and main (-ea, -da); the independent reproduction's test fails 6 of 12 on main, 10.5.2 and 9.12.3 (-ea, -da)]; the releases in between [read: tag containment of be4d58c7740 / 380bb29f395 plus blame, setNextMatch unchanged]. Not fixed upstream: no commit under lucene/suggest/document since 2026-09-01 on upstream main a95c754ee003 and no open PR touches FuzzyCompletionQuery.java (2026-10-27) [read]. Executed on Linux aarch64 with JDK 25 only.
- Status
- documented (brief, checks and a per-finding doc exist)
- Verification
- Confirmed by AI-run checks: an independent repro, a cross-model review that agreed, 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-27, read-only; the prior-report record verdict new): two scripted prior-report searches (45 and 51 queries over apache/lucene issues, PRs and commits, downstream repositories, the dev@, java-user@ and issues@ lists, the JIRA archive and the web; 14 and 101 distinct hits, none about this defect), 38 manual GitHub searches of apache/lucene, apache/solr and two downstream search-engine repositories, 6 JIRA REST queries (LUCENE, SOLR), 16 list queries, 5 web searches, the git history of FuzzyCompletionQuery.java (13 commits since be4d58c7740, none changes setNextMatch semantics) and the open apache/lucene PRs with the suggest label (none touches FuzzyCompletionQuery.java); the intended-behaviour check ran its own symptom-angle scripted prior-report search and manual searches and found nothing either. Nearest items, none the same bug: #7518 (LUCENE-6459, the introducing change, whose description gives the intended boost as 'boost = # of prefix characters matched' and never mentions unicode), elastic/elasticsearch#24903 (ASCII fuzzy completion where exact matches tie with or rank below typos, closed by a committer as the deliberate minimal-prefix limitation that the setNextMatch NOTE describes: same symptom, different cause and fix), elastic/elasticsearch#10746 (completion suggester design discussion; the same NOTE limitation), #6094 (LUCENE-5030, unicodeAware for the analyzing FuzzySuggester, a different class with no prefix boost), #7425 (LUCENE-6365, refs set size only), #9334 / #9335 (LUCENE-8287 / LUCENE-8288, other ContextQuery defects on the same call path). Not fixed upstream. Gap: GitHub full-text search tokenises identifiers poorly and the web search was partly throttled.
- Lucene commit tested
- main
a9779ef3c193(commit 2026-10-26; tested 2026-10-27 and 2026-10-28) on OpenJDK 25.0.3: this doc's JUnit and the finder's through gradle in a scratch worktree, stock and with the recommended fix, with assertions on and off, plusTestFuzzyCompletionQuery,TestContextQuery, the whole suggest module and the format and lint checks with the fix (the worktree was removed after the runs); the hunt's tests through the standalone JUnit runner on main and on the 10.5.2 and 9.12.3 jars (SHA-1 checked); a version-portable probe on 5.2.1 (API absent), 5.3.0, 5.3.2, 5.5.5, 6.0.0, 6.6.6, 7.7.3, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 and main.
- Finding record
lucene/suggest/src/java/org/apache/lucene/search/suggest/document/FuzzyCompletionQuery.java:43-44 (at a9779ef3c193) (class javadoc: 'This query boost documents relative to how similar the indexed terms are to the provided prefix'), :53 (@lucene.experimental), :57-61 (DEFAULT_UNICODE_AWARE: 'Measure maxEdits, minFuzzyLength, transpositions and nonFuzzyPrefix parameters in Unicode code points (actual letters) instead of bytes'), :123 ('@param unicodeAware treat prefix as unicode rather than bytes'), :227 (isUnicodeAware: 'lengths are measured in unicode code-points rather than bytes')lucene/suggest/src/java/org/apache/lucene/search/suggest/document/FuzzyCompletionQuery.java:150-163 (at a9779ef3c193) (createWeight: originalAutomata = stream.toAutomaton(unicodeAware); refs filled by toLevenshteinAutomata; only the match automaton converted with UTF32ToUTF8), :166-170 (refs.add(IntsRef.deepCopyOf(string)): the code-point strings kept as the boost reference)lucene/suggest/src/java/org/apache/lucene/search/suggest/document/FuzzyCompletionQuery.java:266-286 (at a9779ef3c193) (FuzzyCompletionWeight.setNextMatch: NOTE about the last letter of an exact match, :268-271; ref.ints[i + ref.offset] == pathPrefix.ints[i + pathPrefix.offset], :277, no unit conversion; currentBoost = maxCount)lucene/suggest/src/java/org/apache/lucene/search/suggest/document/NRTSuggester.java:230-231 (at a9779ef3c193) (scorer.weight.setNextMatch(path.input().get()): the FST input path, UTF-8 byte labels)lucene/suggest/src/java/org/apache/lucene/search/suggest/document/ContextQuery.java:308-328 (at a9779ef3c193) (setInnerWeight copies the byte labels after the context separator and calls innerWeight.setNextMatch, so ContextQuery(FuzzyCompletionQuery) is affected too)lucene/suggest/src/java/org/apache/lucene/search/suggest/document/CompletionScorer.java:98-106 (at a9779ef3c193) (score(weight, boost): weight when boost is 0, else weight * boost)lucene/suggest/src/test/org/apache/lucene/search/suggest/document/TestFuzzyCompletionQuery.java (at a9779ef3c193) (testFuzzyQuery pins the boost as the shared-leading-unit count, ASCII and byte mode only; no upstream test constructs FuzzyCompletionQuery with unicodeAware=true)
- The finding in 30 seconds
lucene/suggest/src/java/org/apache/lucene/search/suggest/document/FuzzyCompletionQuery.java:57-61, (at a9779ef3c193) 150-170, 256-286lucene/suggest/src/java/org/apache/lucene/search/suggest/document/NRTSuggester.java:230-231; (at a9779ef3c193) ContextQuery.java:308-328; CompletionScorer.java:98-106lucene/analysis/common/src/java/org/apache/lucene/analysis/miscellaneous/ConcatenateGraphFilter.java:226-242 (at a9779ef3c193) (setUnicodeArcs(unicodeAware))
- Who is affected
lucene/suggest/src/java/org/apache/lucene/search/suggest/document/FuzzyCompletionQuery.java:53, (at a9779ef3c193) 61, 127-136lucene/suggest/src/test/org/apache/lucene/search/suggest/document/TestSuggestField.java (at a9779ef3c193) (iwcWithSuggestField: FilterCodec over TestUtil.getDefaultCodec() with Completion104PostingsFormat)
- Why it looks unintended
lucene/suggest/src/java/org/apache/lucene/search/suggest/document/FuzzyCompletionQuery.java:43-44, (at a9779ef3c193) 53, 57-61, 123, 227, 268-271; CompletionWeight.java:64-73lucene/suggest/src/test/org/apache/lucene/search/suggest/document/TestFuzzyCompletionQuery.java:47-75 (at a9779ef3c193)
- Suggested fix
lucene/core/src/java/org/apache/lucene/util/IntsRefBuilder.java:97-104 (at a9779ef3c193) (copyUTF8Bytes); lucene/core/src/java/org/apache/lucene/util/fst/Util.java:787-797 (toBytesRef)lucene/core/src/java/org/apache/lucene/util/UnicodeUtil.java:477-506 (at a9779ef3c193) (UTF8toUTF32: reuse = null at :484; codePointAt allocates at :502-504)
- TestFuzzyCompletionQueryUnicodeAwareBoost.java (JUnit test):
repro/LFV-689/src/org/apache/lucene/search/suggest/document/TestFuzzyCompletionQueryUnicodeAwareBoost.java(63 lines)
Made in parallel with this doc from the executed repros on main a9779ef3c193; the PNG is the final summary frame. Step 6 (the fix) shows the recommended fix and its results; the summary card has the top-n note. Its line numbers (:152, :156-159, :170, :276-282, NRTSuggester.java:231, CompletionScorer.java:98-106) are at a9779ef3c193.
assets/LFV-689/bug.png(577 KB)
![Diagram of the bug. Left: setNextMatch for the suggestion aécd with the key aéc and the matched prefix aé; the ref in code points (61, E9, 63) is compared with the pathPrefix in UTF-8 bytes (61, C3, A9): 61 equals 61, E9 differs from C3, so the boost is 1 where it should be 2. Right: results executed on main with assertions on and off: key aéc over aécd/3 and axcd/4 expected [aécd/6.0, axcd/4.0], actual [axcd/4.0, aécd/3.0]; key кот over коты/3 and киты/4 expected [коты/6.0, киты/4.0], actual [киты/4.0, коты/3.0]; the ASCII control abc gives [abcd/6.0, axcd/4.0]. Notes below: the coincidence U+00C3 versus the first byte C3 of é for the key aÃc ([aécd/8.0, aÃcd/6.0] instead of [aÃcd/6.0, aécd/4.0]), and for the key aé a typo with a larger boost (3) than the exact prefix (2); an all-CJK key gets boost 0 and ContextQuery is affected too; under a top-n limit the wrong scores also change which matches are returned (aéc with n = 1 gives only [axcd/4.0]); which suggestions match is right, and byte mode and ASCII-only text are unaffected; the fix decodes the path to code points and every example then ranks as expected; affected 5.3.0 to 10.5.2 and main.](../assets/LFV-689/bug.png)