All findings · Lucene finding · LFV-518 · spellchecker-indexdictionary-duplicates-within-one-call

SpellChecker.indexDictionary indexes a word twice when one dictionary repeats it, so suggestSimilar returns duplicates

When one indexDictionary call gets the same word twice, say a word list with a repeated line, the classic SpellChecker indexes it twice and suggestSimilar("recieve", 2) returns [relieve, relieve], not [relieve, receive]. Its duplicate check sees only the index from before the call; 24 releases since 1.9.1 reproduce it.

Summary

Priority
Low, rank 448The legacy SpellChecker indexes a word repeated in one dictionary several times so duplicates push out distinct suggestions, a rare silent spell-suggestion error that leads the new suggestion-quality rows after LFV-240.
Impact
silent wrong results
Since
1.9 (2006); the 2004 first commitintroduced 2004-11-02 · over 22 years
Affected
1.9 to 10.5.2 and main (source history); 24 releases from 1.9.1 run
Status
documented
Found by
code reading while modelling the spell index

AI-assisted page: this finding was first signalled by code reading or testing 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

The spell index is a small Lucene index with one document per word. Building it should skip words already there, but the skip check cannot see what the same call has just added.

The indexDictionary loop over the entries relieve, relieve, receive. The skip check reads the spell index as it was before the call, which is empty, while each added document goes to the new writer and stays invisible until the call ends; so the second relieve is added again and the spell index holds relieve twice. suggestSimilar("recieve", 2) then fills both slots with relieve (score 0.857) and drops receive (0.714). Split over two calls, the words are stored once and the result is relieve, receive.
The repro's example: one call indexes relieve, relieve, receive; the skip check sees only the index from before the call, so relieve is stored twice and suggestSimilar("recieve", 2) returns it twice. Select the still to open it at full size. Generated with AI, checked against the code.
  1. Build a spell index from a word list with a repeated line, relieve, relieve, receive, in one call: indexDictionary(new PlainTextDictionary(reader), new IndexWriterConfig(), false) on a default SpellChecker.
  2. You expect each word once: indexDictionary skips words that are already indexed, and the same words split over two calls are indexed once. So suggestSimilar("recieve", 2) should be [relieve, receive] (1 and 2 edits away).
  3. You get [relieve, relieve], and the spell index holds 2 documents for relieve. No error.
  4. Why: the skip check looks each word up in the index as it was when the call started (here empty); the documents the call adds become visible only after it ends. The second relieve is added again, the two copies fill both result slots, and receive is dropped.
  5. More copies crowd out more: with 30 copies of lantern among five other words, suggestSimilar("lanterm", 3) gives [lantern, lantern, lantern] instead of [lanter, lantern, lanterns].

Who is affected

Applications that build the classic n-gram SpellChecker in one call from a word list with repeated lines (PlainTextDictionary, FileDictionary) or a DocumentDictionary over repeated stored values, with default settings. Index-term dictionaries (LuceneDictionary, HighFrequencyDictionary) are unique and safe. Rare, silent, and only spelling suggestions change.

Mechanism, terms and numbers606 words

SpellChecker.indexDictionary skips a word that is already in the spell index, but it checks only the index as it was when the call started: the documents it adds become visible after the call. So a word that one Dictionary yields twice, such as a repeated line in a PlainTextDictionary word list or a value stored by two documents under a DocumentDictionary, is indexed twice. suggestSimilar keeps every hit, so the copies take result slots: after indexing relieve, relieve, receive in one call, suggestSimilar("recieve", 2) returns [relieve, relieve] instead of [relieve, receive]. The same words split over two calls are indexed once, as LUCENE-883 made them in 2007. The gap dates from the spellchecker's first commit (2004); it reproduces on 24 releases from 1.9.1 to 10.5.2 and on main, and the source history shows the same check in the releases between. A per-call set of the words added (+8 lines) fixes it, and the suggest module's tests pass with it.

Terms

  • SpellChecker (lucene-suggest, org.apache.lucene.search.spell) is the classic n-gram spell checker. It keeps a separate spell index with one document per dictionary word: the word itself in field word, plus its n-grams.
  • indexDictionary(Dictionary, IndexWriterConfig, boolean fullMerge) reads every entry of a Dictionary and adds a document for each word of at least 3 characters, unless the word is already in the spell index.
  • suggestSimilar(word, numSug) runs one n-gram query against the spell index for the top 10 * numSug hits, scores each hit with the StringDistance (default LevenshteinDistance: 1 - edits / max length), and keeps the best numSug in a queue.

The bug

  1. Before its loop, indexDictionary takes the current searcher and collects one TermsEnum of field word per segment (SpellChecker.java:497-508).
  2. For each entry it skips the word if one of those enums finds it (:525-531), else it adds a document to a new IndexWriter (:534-535). The searcher is replaced only after writer.close() (:544, :550).
  3. So the enums never see words added earlier in the same call, and a repeated entry is added again. On an empty spell index there are no enums and no check at all (:510).
  4. suggestSimilar inserts every hit that passes its filters (not the query word itself, not below the accuracy cutoff) into its queue with no check that the same string is already there (:395). Each copy is its own document and its own hit, so it takes its own slot; once the queue is full, the accuracy cutoff rises to the queue's lowest score (:396-399) and shuts out lower-scored distinct words.

The numbers

One call, default SpellChecker and IndexWriterConfig, query recieve, numSug 2:

dictionary entriesspell-index docs for relievesuggestSimilar
relieve, relieve, receive (one call)2[relieve, relieve]
relieve, then relieve, receive (two calls)1[relieve, receive]

relieve is 1 edit from recieve (score 0.857), receive 2 edits (0.714). The same happens on a non-empty spell index, where the check does run, and with a DocumentDictionary over a stored field that two documents share.

More copies crowd out more. Dictionary: lantern N times plus lanterns, pattern, latern, lanter, lanternfish:

NquerynumSugwith the bugeach word once
2lanterm3[lanter, lantern, lantern][lanter, lantern, lanterns]
30lanterm3[lantern, lantern, lantern][lanter, lantern, lanterns]
50lanterm5five times lantern[lanter, lantern, lanterns, latern, pattern]
20lantern2[][lanterns, lanter]

In the last row the 20 copies fill the whole 20-hit window and are all skipped as the query word itself (:377).

Exposure and likelihood283 words
  • Who. Users of the classic n-gram SpellChecker of lucene-suggest whose single indexDictionary call receives the same word twice: a PlainTextDictionary or FileDictionary over a word list with repeated lines (for example concatenated or merged lists), a DocumentDictionary over a stored field whose values repeat across documents, or a custom dictionary. The class javadoc's example indexes "a file containing words" with PlainTextDictionary (through a one-argument indexDictionary that no longer exists). Default constructor and IndexWriterConfig suffice; fullMerge does not help.
  • Not affected. LuceneDictionary and HighFrequencyDictionary enumerate the terms of an index field, which are unique, so the common index-based build is safe. DirectSpellChecker, the newer spell checker that needs no separate spell index, does not use this code. Search results never change.
  • Effect. Two copies already push a distinct word out when the repeated word ranks first. With enough copies (at most 10 * numSug) they can fill the whole hit window, as in the probe: every slot is that word, and a lookup of the word itself returns nothing. Whether they do depends on how the copies rank in the n-gram query against the other words. The spell index grows by one document per extra copy.
  • Downstream (from reading source, nothing run there). Solr's FileBasedSpellChecker without a fieldType clears the spell index and rebuilds it in one call over a PlainTextDictionary of its source file, so repeated lines in that file trigger it; its fieldType path and IndexBasedSpellChecker use HighFrequencyDictionary and are not affected. GitHub code search (default branches only) found no indexDictionary call in Elasticsearch or OpenSearch.
  • How likely. Rare: it needs a repeated entry within one call, and the searches below found no report since the API shipped in 1.9 (2006).

Minimal repro

SpellChecker.indexDictionary indexes a word twice when one dictionary repeats it, so suggestSimilar returns the same word twice and drops a distinct suggestion.

TestSpellCheckerDuplicateDictionaryWords.java (JUnit test)41 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.spell;

import java.io.StringReader;
import java.util.Arrays;
import java.util.List;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestSpellCheckerDuplicateDictionaryWords extends LuceneTestCase {

  public void testWordRepeatedInOneDictionary() throws Exception {
    try (Directory spellDir = new ByteBuffersDirectory();
        SpellChecker spellChecker = new SpellChecker(spellDir)) {
      spellChecker.indexDictionary(
          new PlainTextDictionary(new StringReader("relieve\nrelieve\nreceive\n")),
          new IndexWriterConfig(),
          false);
      // expected each word once, [relieve, receive]; main indexes relieve twice: [relieve, relieve]
      assertEquals(
          List.of("relieve", "receive"), Arrays.asList(spellChecker.suggestSimilar("recieve", 2)));
    }
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-518/, 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.spell.TestSpellCheckerDuplicateDictionaryWords
Expected vs actual
Expected suggestSimilar("recieve", 2) = [relieve, receive]; main returns [relieve, relieve].

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

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:<[relieve, receive]> but was:<[relieve, relieve]>
       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.spell.TestSpellCheckerDuplicateDictionaryWords.testWordRepeatedInOneDictionary(TestSpellCheckerDuplicateDictionaryWords.java:37)

Why it looks unintended

  • The method is built to skip words already indexed, and LUCENE-883 (2007) fixed the same duplicates across consecutive calls, calling "a spelling index with duplicate words" the problem.
  • Upstream testBuild asserts that an overlapping second dictionary adds only the new word.
  • Split over two calls the words are indexed once; in one call, twice. No javadoc asks for duplicate-free dictionaries.
The full argument229 words
  • Design. The existence check is as old as the class: the 2004 first version skipped a word with if (this.exist(word)) { // if the word already exist in the gramindex. createDocument says the word field is "indexed so it can be quickly checked for rebuild (and stored for retrieval)" (SpellChecker.java:576-577).
  • LUCENE-883 (#1958, 2007): "The problem without the patch is that consecutive calls to indexDictionary() will create a spelling index with duplicate words." CHANGES 2.2: "LUCENE-883: consecutive calls to Spellchecker.indexDictionary() won't insert terms twice anymore."
  • Test. TestSpellChecker.testBuild (:113-124) indexes two overlapping fields one after the other and asserts num_field2 == num_field1 + 1.
  • Inconsistent. The same input split over two calls is indexed once; in one call it is indexed twice.
  • Contract. No javadoc requires a Dictionary to be duplicate-free: it is "a list of entries, where every entry consists of term, weight and payload" (Dictionary.java:23-24); PlainTextDictionary documents "1 word per line"; DocumentDictionary yields every stored value of the field from each live document, so values repeated across documents are normal. No javadoc promises distinct suggestions either, but a copy carries no information (the spell index stores no weight or frequency) while it removes a distinct word.
  • No test pins it. No upstream test feeds a repeated entry in one call, and with fix A every suite in the suggest module passes.
The devil's-advocate check181 words

The strongest works-as-designed case (the cross-model lenses, the independent reproducer, the devil's advocate and the adjudication): Dictionary is documented as a list, not a set; suggestSimilar never promises distinct words; LUCENE-883 is worded for consecutive calls only; and LUCENE-2391 (#3466, 2011) deliberately skips the check on an empty index: "we don't do any of the exists() logic if the index is empty (this is the case for i think solr which completely rebuilds ...)". On that reading the authors assumed each word comes once, as LuceneDictionary guarantees, and repeated lines are the caller's problem.

Why it fails: that uniqueness assumption is written nowhere (no javadoc, CHANGES or MIGRATE entry, test or issue), and three dictionaries shipped in the same module legally yield repeats. The empty-index skip is a performance shortcut: on a non-empty index the check runs and still misses repeats from the same call. The outcome is exactly what LUCENE-883 called the problem. A maintainer could still reasonably prefer a javadoc note "entries should be unique" over a code change. Verdict: not intended; a within-call gap in the LUCENE-883 de-duplication.

Suggested fix

Recommended A: skip words this call has already added (BytesRefHash)

Keep a BytesRefHash of the words the call has added and skip an entry it already holds; insert after addDocument, so an over-long entry still fails there as today (+8 lines). All 247 suggest-module tests pass with it, repros included, assertions on and off. A HashSet<String> (fix B) works too; it is simpler and keys on the stored string.

boolean isEmpty = termsEnums.isEmpty();
// words added by this call: termsEnums only see the index as it was before the call
final BytesRefHash addedInThisCall = new BytesRefHash();
...
if (addedInThisCall.find(currentTerm) >= 0) {
  continue; // the dictionary repeated a word that this call has already added
}
// ok index the word
Document doc = createDocument(word, getMin(len), getMax(len));
writer.addDocument(doc);
addedInThisCall.add(currentTerm);
All options and trade-offs5 options · 770 words

Two fixes of the same shape were built and run in a scratch worktree at a9779ef3c193: remember the words this call has added and skip repeats. A uses a BytesRefHash and is recommended; B, a HashSet<String>, is the finder's and the independent reproducer's fix.

A: skip words this call has already added (BytesRefHash) (validated by the checks) (recommended)

Keep a BytesRefHash of the words added in this call. After the existing check against the old index, skip the entry if the hash already has it; add it to the hash after addDocument. +8 lines in SpellChecker.java.

boolean isEmpty = termsEnums.isEmpty();
// words added by this call: termsEnums only see the index as it was before the call
final BytesRefHash addedInThisCall = new BytesRefHash();
...
if (addedInThisCall.find(currentTerm) >= 0) {
  continue; // the dictionary repeated a word that this call has already added
}
// ok index the word
Document doc = createDocument(word, getMin(len), getMax(len));
writer.addDocument(doc);
addedInThisCall.add(currentTerm);

Trade-offs

  • Heap grows with the distinct words added in one call (the bytes in shared blocks plus two int arrays, no object per word); words already in the index are not stored. The empty-index rebuild keeps its shortcut (no seekExact) and gains one hash lookup per entry of 3 or more characters and one insert per distinct word indexed; on a non-empty index, entries found in the old index cost neither. Not benchmarked.
  • The insert comes after addDocument on purpose: BytesRefHash rejects entries over 32,766 bytes with its own exception. With the insert first (variant A0, also run: every suite passes), such an entry fails with MaxBytesLengthExceededException instead of the IllegalArgumentException that IndexWriter throws for an immense term today. With A it still fails in addDocument, as today.
  • Two limits that B does not have (by reading; not run through indexDictionary): BytesRefHash stores at most 2 GB of bytes in total, so one call cannot add more distinct word bytes than that; and the hash keys on the entry's raw bytes, while the stored word is the decoded string. utf8ToString does not fully validate UTF-8, so two byte sequences can decode to the same word (a small probe: the overlong C1 A1 62 63 and abc both give "abc", and the hash does not match them). A dictionary that yields such bytes, for example binary stored values through DocumentDictionary, would get both indexed under A.

What the checks validated

Gradle in a scratch worktree at a9779ef3c193, seed DEADBEEF: the whole :lucene:suggest:test with the three repro classes and an edge-case probe added, 35 classes, 247 tests, 0 failures (7 skipped by the module itself), with assertions on and off; the 7 repro tests that fail on stock pass, and an over-long entry still raises IllegalArgumentException. :lucene:suggest:check -x test with the fix and the primary test (format, ecj lint, forbidden APIs, licenses): BUILD SUCCESSFUL. Not benchmarked.

B: the same with a HashSet<String> (validated by the checks)

final Set<String> addedInThisCall = new HashSet<>(); and if (addedInThisCall.add(word) == false) continue; before createDocument. +8 lines with the two imports. The finder and the independent reproducer each wrote this change.

Trade-offs

Simplest: the String already exists for every entry, and it is the stored word, so there is no raw-byte or 2 GB caveat. Keeps one String and one map entry per distinct word added, so it needs more heap than A for very large dictionaries. Over-long entries still fail in addDocument as today.

What the checks validated

Gradle, assertions on, seed DEADBEEF: the three repro classes, TestSpellChecker (6) and TestPlainTextDictionary (1) and the edge-case probe, 18 tests, 0 failures; an over-long entry still raises IllegalArgumentException. The whole module was not run with B.

Document that entries must be unique

Add "entries should be unique within one call" to indexDictionary (and maybe Dictionary).

Trade-offs

A legitimate maintainer choice, but it leaves PlainTextDictionary, FileDictionary and DocumentDictionary users to de-duplicate themselves, and the behaviour stays inconsistent with two calls. Not recommended.

Skip a string already in the result queue in suggestSimilar

Hide the symptom at query time.

Trade-offs

Also cleans spell indexes that already hold copies, but the copies still fill the 10 * numSug hit window, so with many copies distinct words are still lost, and the index stays bloated. At most a complement to A or B. Not run.

Use writer.updateDocument(new Term(F_WORD, word), doc)

Let IndexWriter replace an earlier document of the same word (suggested by a cross-model reviewer).

Trade-offs

Buffers one delete term per word and applies them at flush, which costs more than a hash lookup on a full rebuild. Not run or benchmarked.

Downstream note

Solr's file-based spell checker without a fieldType rebuilds its spell index in one call over its word file, so repeated lines in that file trigger this; this is from reading Solr's source, not from a run.

How it was found

Code reading. While writing a reference model of SpellChecker that treats the spell index as a set of words, the finder noticed that the skip check reads the index from before the call. A small probe on the stock jar confirmed it, and a public-API JUnit test is the evidence.

The method in full132 words

Method: code reading during modelling (the hunt's "non-formal" tier; no model checker, solver or proof found it).

  • The finder was writing a bounded translation-validation reference for SpellChecker, which had to model the spell index as a set. That made the question "what if a word comes twice?" explicit, and reading indexDictionary showed that the TermsEnums come from the searcher opened before the call.
  • The reference harness itself did not witness it: its random 1-12-word dictionaries rarely repeated a word.
  • A main() probe on the stock jar indexed abcd, abcd, abce in one call and got [abcd, abcd, abce] as the suggestions for abcf; the finder reduced it to the JUnit test, and an independent reproducer then wrote its own test with other words and a DocumentDictionary trigger.

Prior reports, history and checks

Prior reports

No prior report found (92 recorded queries, 51 by the search tool and 41 by hand, plus two more tool runs, over Lucene issues, PRs, commits, JIRA, mailing lists, downstream trackers and the web, 2026-10-26). The closest item, LUCENE-883 (2007), fixed the same duplicates across consecutive calls and never mentions repeats within one call.

Searches4 searches

Registry verdict new: nobody else has reported or discussed this bug. 22 hits were read and classified; none is this bug. Nearest items: #1958 / LUCENE-883 (the cross-call variant, fixed in 2.2.0; its body, 4 comments, commit and CHANGES entry are all about consecutive calls), #3466 / LUCENE-2391 (wrote today's loop and the empty-index skip; no discussion of repeated entries) and #2229 / LUCENE-1152 (same stale-view family, opposite symptom: words skipped after clearIndex, fixed 2008). Two other bugs in this method are tracked separately: the writer is not closed when the build throws, and an OpenMode.CREATE rebuild checks the old index and drops words; they need different fixes, and the per-call set here composes with either.

What was searched, when, and what turned up
WhereWhenHowFound
apache/lucene issues, PRs and commits; dev@, java-user@, issues@, commits@; LUCENE JIRA archive; downstream; web2026-10-26a scripted prior-report search, merged report of 51 queries (0 errors); the finder's run (12 of 25 answered under the shared search throttle); a verifier's 24 list and JIRA queriesno report; nearest LUCENE-883 (cross-call, fixed 2007)
GitHub (apache/lucene, apache/lucene-solr, apache/lucenenet, apache/solr, elastic/elasticsearch, opensearch-project/OpenSearch)2026-10-2619 manual searches (indexDictionary, PlainTextDictionary, duplicate words, duplicate suggestions)no report; the Lucene.NET port has the same loop (source, not a report)
JIRA (LUCENE, SOLR), lists.apache.org (including solr-user@)2026-10-2610 JIRA REST queries and 7 list queriesno report; SOLR-5995 and SOLR-572 are different defects
Open PRs and git history2026-10-26changed files of all 408 open apache/lucene PRs (the first 100 files of each); git pickaxe over the full historyonly #13951 touches SpellChecker.java (an Analyzer cast); no commit ever added a per-call set

History

Present since the spellchecker's first commit, 57cd0765650 (2004-11-02); first released in 1.9 (2006). Every rewrite of the check kept the before-the-call view; today's loop is from LUCENE-2391 (2011). The repro fails on 24 releases from 1.9.1 to 10.5.2 and on main.

Versions tested7 rows

Introduced by 57cd0765650d0f73db37e090eaed4748fcd2fc82 "prelim checking of spellchecker, v1.1" (David Spencer, 2004-11-02, svn r151014), the sandbox import of the contributed spellchecker; its commit message names no issue. Its exist() read an IndexReader opened at the first call and kept until the end of the indexing call. LUCENE-883 (3c4dc0484b8, 2.2.0) fixed only consecutive calls; LUCENE-1152 (a379a678759, 2.4.0), LUCENE-2108 (ab447b6af06, 2009; first released in 2.9.2 and 3.0.1 through backports) and LUCENE-2391 (2011) all kept the before-the-call view. LUCENE-2391 added the empty-index skip in two commits: on trunk, c26fd7564a5 (first in 4.0.0-alpha) wrote today's per-segment TermsEnum loop; on 3.x, 9b6c71a5920 (first in 3.1) checked docFreq on each sub-reader instead. Not a regression: there is no unaffected release, because 1.4.3 ships no spellchecker. Upstream branch_10x and branch_10_6 have the 10.5.2 code; nothing has fixed it.

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affected3 of 4 primary repro tests fail through gradle, assertions on and off; the control passes
main ea57360912ed affectedfinder, independent reproducer, two more probes and adjudication: fail with -ea and -da, several seeds
10.5.2, 10.0.0, 9.12.3 affectedthe finder's JUnit fails 1 of 2 (published jars); the independent reproducer's fails 3 of 4 on 10.5.2 and 9.12.3
1.9.1 to 9.0.0 (21 releases) affecteda main() probe: docFreq(word:relieve) = 2 after one call on a fresh and on a non-empty index; the split control gives 1 (2 on 2.1.0, a separate cross-call bug fixed in 2.2.0)
57cd0765650 (2004 code over the 1.9.1 core) introducing committhe same probe reproduces
1.4.3 and older API absentno spellchecker in the release
fix A on a9779ef3c193 not affectedthe whole suggest module, repros included: 247 tests, 0 failures, assertions on and off

Independent checks

An independent reproducer, two more probes from an intended-behaviour angle, cross-model reviews and an adjudication. Every re-run failed on main with assertions on and off and passed with the fix; a cross-model review could not run code and voted not real on procedure only while agreeing on the root cause.

Checks and verdicts338 words

Each check ran or read on its own; the registration check re-ran the tests with fresh seeds, and this doc re-ran them through gradle on a9779ef3c193.

Each independent check and its verdict
CheckVerdictWhat it found
finder (code reading, probe, JUnit) candidateJUnit fails on main (-ea, -da), 10.5.2 and 9.12.3; the split control passes; with the fix 2 of 2 pass and upstream TestSpellChecker passes 18 of 18.
cross-model review, refute lens not real, low (procedural)An independent reviewer from another model family (OpenAI GPT-5.6) could not run code, so it voted not real on procedure only. It agreed with the root cause, found the inputs in contract and rated the intended argument moderate; a separate re-run failed.
independent reproducer realOwn test with other words (lantern), including a DocumentDictionary trigger: 3 of 4 fail on main (-ea, -da), 10.5.2 and 9.12.3; own fix passes 4 of 4; upstream TestSpellChecker and TestPlainTextDictionary pass with it.
cross-model review, intended lens + probe real, mediumIntended argument rated moderate; a separate probe fails 2 of 3 on main (DocumentDictionary: [relieve, relieve]; fullMerge with numSug 5: [relieve, retrieve, receive, receive, receive]) and passes with the fix.
intended-behaviour devil's advocate real, mediumBuilt the unstated-uniqueness case from LUCENE-2391 and showed why it fails; own test fails 4 of 5 on main and 10.5.2 (fresh and non-empty index, DocumentDictionary, fullMerge).
adjudication confirmed, highRe-ran the independent test (-ea) and the finder's (-da, seed CAFEBABE): both fail.
security probe security noneCrowding thresholds on main and 10.5.2; index size and build time linear in the entries; distinct near-variants already crowd suggestions on correct code.
this doc's gradle runs stock fails, fixes passStock main: 7 of 11 repro tests fail with assertions on and off, the controls pass. Fix A: all 247 suggest-module tests pass, on and off; fix B: 18 of 18; the module's static checks pass with fix A and the primary test.

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,378 words

Finding record

LFV-518, snapshot of 2026-11-06

Registry title
SpellChecker.indexDictionary checks for already-indexed words only against the spell index as it was before the call, so a word that one dictionary repeats is indexed once per occurrence and suggestSimilar returns it several times, pushing distinct suggestions out
Area
suggest / spell: SpellChecker.indexDictionary (n-gram spell index build)
Priority
Low, rank 448: The legacy SpellChecker indexes a word repeated in one dictionary several times so duplicates push out distinct suggestions, a rare silent spell-suggestion error that leads the new suggestion-quality rows after LFV-240.
Impact class
silent wrong results
Impact note
Applications that build the classic n-gram SpellChecker of lucene-suggest (default constructor and IndexWriterConfig suffice) from a Dictionary that yields the same word more than once in one indexDictionary call, for example a PlainTextDictionary or FileDictionary word list with repeated lines or a DocumentDictionary over a stored field whose values repeat, get one spell-index document per occurrence, and every later suggestSimilar can return that word several times and drop distinct suggestions (with 10 x numSug copies it fills every slot), with no error; LuceneDictionary and HighFrequencyDictionary yield unique terms and are not affected, search results never change, every release from 1.9 to 10.5.2 is affected and no scale is needed.
Summary
You index a word list into SpellChecker with one indexDictionary call, and the list contains a word twice, for example a PlainTextDictionary with the lines relieve, relieve, receive. You expect each word to be indexed once, as indexDictionary already ensures for words that were in the spell index before the call, so suggestSimilar("recieve", 2) returns [relieve, receive]. Instead the duplicate check looks only at the spell index as it was before the call, so the repeated word is indexed twice and suggestSimilar returns [relieve, relieve], pushing receive out; the same words split over two calls are de-duplicated correctly.
Introduced
57cd0765650d0f73db37e090eaed4748fcd2fc82 (svn r151014, 2004-11-02, 'prelim checking of spellchecker, v1.1', the sandbox import of the contributed spellchecker; no pull request or JIRA issue, it predates both) [read: git blame -w -M -C, git tag --contains; exec: the 2004 sources compiled over the 1.9.1 core jar reproduce]. In that first version indexDictionnary() skipped a word if exist(word), and exist() read an IndexReader opened at its first call and kept until the end of the indexing call, so words the writer had just added were never seen. Every rewrite kept that pre-call view: LUCENE-1152 a379a678759 (2.4.0), LUCENE-2108 ab447b6af06 (3.1) and LUCENE-2391 c26fd7564a5 / 9b6c71a5920 (3.1, rmuir, 2011-01-05), which wrote today's per-segment seekExact loop over the pre-call searcher and the skip on an empty index (main lines 497-512 and 525-530 blame there). LUCENE-883 3c4dc0484b8 (2.2.0) fixed only the consecutive-call case. Latent since the API was added, not a regression. (2004-11-02 (57cd0765650); the current form of the check dates from 2011-01-05 (LUCENE-2391, c26fd7564a5))
First affected version
1.9 (2006-02-27, the first release that ships contrib/spellchecker) [read: git tag --contains 57cd0765650; its spellchecker sources are identical to 1.9.1]; 1.9.1 is the earliest release executed [exec] (1.9 has no Maven Central artifact); there is no unaffected earlier release: 1.4.3 ships no spellchecker
Affected versions
Every release from 1.9 to 10.5.2 (the last release before 10.6.0), plus main ea57360912ed. [exec] the follow-up's main() history probe (oracle: docFreq(word:relieve) after one call, on a fresh and on a non-empty index, with a split-across-calls control) is affected on 24 releases: 1.9.1, 2.0.0, 2.1.0, 2.2.0, 2.3.1, 2.4.1, 2.9.4, 3.0.3, 3.1.0, 3.4.0, 3.5.0, 3.6.2, 4.0.0, 4.10.4, 5.0.0, 5.5.5, 6.6.6, 7.7.3, 8.0.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0 and 10.5.2 (-ea; also -da on 9.12.3 and 10.5.2) and on main (-ea and -da), and passes on main with the fix shadowed; the 2004 introducing code compiled over the 1.9.1 core is affected too; the finder's JUnit fails on main (-ea and -da), 10.5.2 (-ea and -da), 10.0.0 and 9.12.3 (-ea and -da); the independent reproduction's JUnit fails on main (-ea and -da), 10.5.2 and 9.12.3; the intended-behaviour check's on main (-ea and -da) and 10.5.2. In 2.1.0 the across-call control also duplicates and suggestSimilar returns [] (stale searcher), both fixed in 2.2.0 by LUCENE-883; the within-call duplicate is present as everywhere else. [read] upstream origin/main a9779ef3c193 has SpellChecker.java identical to ea57360912ed, and branch_10x 1309534296e3 and branch_10_6 2763db11b0a7 identical to 10.5.2 (2026-10-26). Not executed: 1.9 itself (sources identical to 1.9.1), releases outside the matrix (same check structure by reading), x86.
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that could only read the code (its low-confidence “not real” is procedural), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
non-formal: code reading
Upstream and prior reports
none found (searched 2026-10-26, the finder's scripted prior-report search, verifier's list and JIRA run and the follow-up step, read-only; the prior-report record): 51 scripted prior-report search queries in the merged report (16 GitHub issue and PR, 3 commit, 2 downstream, 20 mailing-list over dev@, java-user@, issues@ and commits@, 4 JIRA, 6 web; 0 errors), the finder's run (25 planned, 12 answered under the shared search throttle), the intended-behaviour check's 24 list and JIRA queries, 10 manual JIRA queries (LUCENE and SOLR), 7 manual list queries, 19 manual GitHub searches (apache/lucene, apache/lucene-solr, apache/lucenenet, apache/solr, elastic/elasticsearch, opensearch-project/OpenSearch), a scan of the changed files of all 408 open apache/lucene PRs and git pickaxe over the full history. Not fixed upstream: SpellChecker.java on main and branch_10x still has only the pre-call seekExact check, no commit ever added a per-call set, and the only open PR touching the file (#13951) adds an (Analyzer) cast. Nearest items, not reports of this bug: #1958 / LUCENE-883 (2007, fixed the consecutive-call duplicates, 'consecutive calls to indexDictionary() will create a spelling index with duplicate words'; body, 4 comments, commit 3c4dc0484b8 and CHANGES 2.2 all scoped to consecutive calls), #3466 / LUCENE-2391 (2011, wrote today's loop and the empty-index skip; 5 comments, no mention of repeated entries), #2229 / LUCENE-1152 (same stale-view family, opposite symptom: words skipped after clearIndex, fixed 2008), #1940, #1707, #5553, #9020, SOLR-5995, SOLR-572 (different defects). Gaps: keyword search only; GitHub search is phrase-like and only the first 50 results per query were read; Stack Overflow pages could not be opened (titles only); private reports to security@lucene.apache.org are not visible.
Lucene commit tested
main a9779ef3c193 (gradle in a scratch worktree: the repro tests stock with assertions on and off, fix A on the whole suggest module with assertions on and off, fix B on the repro and spell-checker tests, the module's static checks with fix A) and main ea57360912ed (hunt, independent reproducer, two more probes, adjudication, several seeds, assertions on and off); the release jars 9.12.3, 10.0.0 and 10.5.2 (JUnit), and 24 releases from 1.9.1 to 10.5.2 plus the 2004 introducing code (a main() probe). JDK 25.0.3, Linux aarch64.

Lucene and upstream sources

Finding record
  • lucene/suggest/src/java/org/apache/lucene/search/spell/SpellChecker.java :491 (indexDictionary), :497 (obtainSearcher before the loop), :498-508 (TermsEnums of the pre-call reader), :510 (isEmpty: no check at all on an empty index), :525-531 (seekExact against those enums only), :535 (writer.addDocument), :550 (swapSearcher only after writer.close()), :361-364 (maxHits = 10 * numSug), :395 (queue insert without a distinctness check); class javadoc :51-62 ('To index a file containing words: ... new PlainTextDictionary(...)')
  • lucene/suggest/src/java/org/apache/lucene/search/spell/Dictionary.java :23-24 ('a list of entries', no uniqueness promise) and PlainTextDictionary.java :33-39 ('Format allowed: 1 word per line')
  • lucene/suggest/src/test/org/apache/lucene/search/spell/TestSpellChecker.java :113-124 (testBuild asserts num_field2 == num_field1 + 1: an overlapping second dictionary adds only the new word)
The finding in 30 seconds
  • a Lucene main checkout lucene/suggest/src/java/org/apache/lucene/search/spell/SpellChecker.java:118-127, 307-413 (361-364, 377, 395, 396-399), 491-552 (497-508, 510, 525-531, 534-535, 544, 550), 574-582
Who is affected
  • a Lucene main checkout lucene/suggest/src/java/org/apache/lucene/search/spell/SpellChecker.java:57-61 (class javadoc example)
Why it looks unintended
  • a Lucene main checkout lucene/suggest/src/java/org/apache/lucene/search/spell/SpellChecker.java:57-61, 303-305, 576-577; Dictionary.java:23-24; PlainTextDictionary.java:33-39
  • a Lucene main checkout lucene/suggest/src/test/org/apache/lucene/search/spell/TestSpellChecker.java:113-124
  • a Lucene main checkout lucene/CHANGES.txt:20871-20872 (LUCENE-883 entry)
Suggested fix
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/util/BytesRefHash.java:310-345; BytesRefBlockPool.java:83-91 (MaxBytesLengthExceededException over 32,766 bytes)

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

Repro files

About the diagrams

A step-by-step picture of the repro's example, made in parallel with this doc.

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.