All findings · Lucene finding · LFV-689 · fuzzycompletion-unicodeaware-boost-compares-codepoints-with-bytes

FuzzyCompletionQuery with unicodeAware=true compares the query's code points with the suggestion's UTF-8 bytes, so its prefix boost stops at the first non-ASCII letter

With unicodeAware=true, FuzzyCompletionQuery counts shared prefix letters by comparing the query's code points with the suggestion's UTF-8 bytes, so the count stops at the first non-ASCII letter. The query aéc then ranks the typo axcd (4.0) above aécd (3.0); in ASCII, abc ranks abcd (6.0) first.

Summary

Priority
Low, rank 383Ranking-only, under the opt-in unicodeAware flag of the experimental document suggester.
Impact
silent wrong results
Since
5.3.0 (2015; as old as the class)introduced 2015-05-28 · over 11 years
Affected
5.3.0 to 10.5.2 and main (11 releases and main run)
Status
documented
Found by
Bounded-exhaustive comparison with a reference written from the javadoc (semi-formal)

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

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.

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.
Left: the boost loop for 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.
  1. You index two suggestions, aécd (weight 3) and the typo axcd (weight 4), and run FuzzyCompletionQuery for aéc with the default fuzzy settings and unicodeAware=true, the option that measures the query in letters (code points) instead of bytes.
  2. 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, abcd gets 3 x 2 = 6.0 and axcd 4 x 1 = 4.0.
  3. You get axcd 4.0 above aécd 3.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).
  4. Why: the query is kept as code points (é = E9) but the matched suggestion arrives as UTF-8 bytes (é = C3 A9). The count matches a, then compares E9 with C3 and stops.
  5. 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).

Who is affected

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.

Terms

  • Document suggester: suggestions indexed as SuggestField (or ContextSuggestField) values, each with an integer weight, searched with SuggestIndexSearcher. Internally an FST whose labels are UTF-8 bytes.
  • FuzzyCompletionQuery (@lucene.experimental): returns suggestions whose prefix is within maxEdits of the typed text (defaults: maxEdits 1, transpositions on, nonFuzzyPrefix 1, minFuzzyLength 3). 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 query abc the exact suggestion abcd is scored on ab (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).

The bug

  1. createWeight analyzes the query with stream.toAutomaton(unicodeAware) (:150-153); in unicode-aware mode its labels are code points. toLevenshteinAutomata keeps every string of it as a boost reference, refs.add(IntsRef.deepCopyOf(string)) (:166-170).
  2. 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).
  3. NRTSuggester calls setNextMatch(path.input().get()) with the matched path in UTF-8 bytes (NRTSuggester.java:230-231); ContextQuery forwards the bytes after the context (ContextQuery.java:308-328). (A separate registered bug, LFV-254, can also change ContextQuery's context boost when a longer indexed context is matched through a prefix context or addAllContexts(); the ContextQuery cases cited here index every suggestion under one context, so there is no longer context for that bug to act on.)
  4. setNextMatch compares ref.ints[i] with pathPrefix.ints[i] position by position (:277) and keeps the longest run as the boost. No side is converted.

Worked example: query aéc over aécd (weight 3)

positionquery (code points)matched prefix aé (UTF-8 bytes)equal?
061 (a)61yes: count 1
1E9 (é)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 FuzzyCompletionQuery with the 9-argument constructor and unicodeAware=true (the class is @lucene.experimental; the flag defaults to false), directly or wrapped in ContextQuery, over SuggestField or ContextSuggestField values.
  • 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é over abéd/3 and axé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é over aéd/1 and aé©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éc over aécd/3 and axcd/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_aware as a fuzzy option (elastic/elasticsearch#10746, #107707), and OpenSearch's feeds its fuzzy options into FuzzyCompletionQuery (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).

Minimal repro

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.

TestFuzzyCompletionQueryUnicodeAwareBoost.java (JUnit test)63 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.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());
      }
    }
  }
}
Command
# 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éc over aécd/3 and the typo axcd/4 the same result as in ASCII, [aécd/6.0, axcd/4.0]; actual [axcd/4.0, aécd/3.0], with -ea and -da.

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

More files, environment, full output36 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
    => 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)

Why it looks unintended

  • 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.testFuzzyQuery pins the boost as the shared-prefix count (suaggestion 4 x 2, suggestion 2 x 3, sugfoo 1 x 3, ssuggestion 1 x 1). No upstream test builds FuzzyCompletionQuery with unicodeAware=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.

Suggested fix

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.copyUTF8Bytes calls UnicodeUtil.UTF8toUTF32, which creates one small UTF8CodePoint object 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=true today).

What the checks validated

  • Scratch worktree at a9779ef3c193, gradle, seed DEADBEEF: this doc's repro passes 3 of 3 with assertions on and off, and checkGoogleJavaFormat passes on it; with the finder's test, TestFuzzyCompletionQuery and TestContextQuery, 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, forbiddenApisTest and checkGoogleJavaFormat pass.
  • 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-1 aéc lookup returns [aécd/6.0].

The same idea with a String decode (the hunt's first version) (validated by the checks)

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

Encode the refs to UTF-8 once and count whole code points (not run)

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.

Compare and count bytes (not recommended)

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.

Workaround today (no Lucene change)

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.

Downstream note

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

How it was found

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, FuzzyCompletionWeight and the upstream TestFuzzyCompletionQuery expectations 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, SuggestField and ContextSuggestField, every key of length 0-3 and 240 FuzzyCompletionQuery settings (maxEdits 0-2, transpositions, nonFuzzyPrefix 0-3, minFuzzyLength 0-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 ContextQuery checks, plus 25,816 that also carry a separate ContextQuery bug) appeared only with unicodeAware=true on 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 separate ContextQuery context-boost bug (LFV-254), regex-query findings and, with assertions on, assertion errors in ContextQuery.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.

Prior reports, history and checks

Prior reports

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.

What was searched, when, and what turned up
WhereWhenHowFound
apache/lucene issues, PRs and commits; downstream repositories; dev@, java-user@, issues@, commits@; JIRA archive; the web2026-10-27the 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, OpenSearch2026-10-2738 manual searches, open PRs with the suggest label, the file's git historyelastic/elasticsearch#24903 (symptom only), #10746, #7060, #107707; OpenSearch#22924; nothing about the units
LUCENE and SOLR JIRA, lists.apache.org2026-10-276 JQL queries; list queries on dev@, java-user@ and issues@ over 20 yearsonly the LUCENE-6459, LUCENE-6365, LUCENE-5030 and LUCENE-8287/8288 threads
the web and Stack Overflow2026-10-275 queries (one throttled and re-run) plus the devil's advocate's symptom-angle searchesonly posts about the ASCII minimal-prefix behaviour

History

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.

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affectedthis 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 affectedthe 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 affectedthe 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 affectedthe probe fails (assertions on and off); with the hunt's first fix version (String decode) back-ported, it passes
5.2.1 not affectedno FuzzyCompletionQuery yet: the probe does not compile

Independent checks

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.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidateBounded-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 realOwn 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-sensitiveChanges 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 keepThe 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 validatedGradle 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.

Diagram file

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.

Sources and provenance

Finding record, Lucene and upstream sources, repro files and diagram notes1,461 words

Finding record

LFV-689, snapshot of 2026-11-06

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, plus TestFuzzyCompletionQuery, 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.

Lucene and upstream sources

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-286
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/document/NRTSuggester.java:230-231; (at a9779ef3c193) ContextQuery.java:308-328; CompletionScorer.java:98-106
  • lucene/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-136
  • lucene/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-73
  • lucene/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)

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

Repro files

About the diagrams

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.

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.