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.
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.- 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 defaultSpellChecker. - You expect each word once:
indexDictionaryskips words that are already indexed, and the same words split over two calls are indexed once. SosuggestSimilar("recieve", 2)should be[relieve, receive](1 and 2 edits away). - You get
[relieve, relieve], and the spell index holds 2 documents forrelieve. No error. - 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
relieveis added again, the two copies fill both result slots, andreceiveis dropped. - More copies crowd out more: with 30 copies of
lanternamong five other words,suggestSimilar("lanterm", 3)gives[lantern, lantern, lantern]instead of[lanter, lantern, lanterns].
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.
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 fieldword, plus its n-grams.indexDictionary(Dictionary, IndexWriterConfig, boolean fullMerge)reads every entry of aDictionaryand 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 top10 * numSughits, scores each hit with theStringDistance(defaultLevenshteinDistance:1 - edits / max length), and keeps the bestnumSugin a queue.
- Before its loop,
indexDictionarytakes the current searcher and collects oneTermsEnumof fieldwordper segment (SpellChecker.java:497-508). - For each entry it skips the word if one of those enums finds it (
:525-531), else it adds a document to a newIndexWriter(:534-535). The searcher is replaced only afterwriter.close()(:544,:550). - 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). suggestSimilarinserts 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.
One call, default SpellChecker and IndexWriterConfig, query recieve, numSug 2:
| dictionary entries | spell-index docs for relieve | suggestSimilar |
|---|---|---|
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:
| N | query | numSug | with the bug | each word once |
|---|---|---|---|---|
| 2 | lanterm | 3 | [lanter, lantern, lantern] | [lanter, lantern, lanterns] |
| 30 | lanterm | 3 | [lantern, lantern, lantern] | [lanter, lantern, lanterns] |
| 50 | lanterm | 5 | five times lantern | [lanter, lantern, lanterns, latern, pattern] |
| 20 | lantern | 2 | [] | [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
SpellCheckerof lucene-suggest whose singleindexDictionarycall receives the same word twice: aPlainTextDictionaryorFileDictionaryover a word list with repeated lines (for example concatenated or merged lists), aDocumentDictionaryover a stored field whose values repeat across documents, or a custom dictionary. The class javadoc's example indexes "a file containing words" withPlainTextDictionary(through a one-argumentindexDictionarythat no longer exists). Default constructor andIndexWriterConfigsuffice;fullMergedoes not help. - Not affected.
LuceneDictionaryandHighFrequencyDictionaryenumerate 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
FileBasedSpellCheckerwithout afieldTypeclears the spell index and rebuilds it in one call over aPlainTextDictionaryof its source file, so repeated lines in that file trigger it; itsfieldTypepath andIndexBasedSpellCheckeruseHighFrequencyDictionaryand are not affected. GitHub code search (default branches only) found noindexDictionarycall 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).
SpellChecker.indexDictionary indexes a word twice when one dictionary repeats it, so suggestSimilar returns the same word twice and drops a distinct suggestion.
/*
* 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)));
}
}
}
# 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].
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:<[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)- 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
testBuildasserts 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.createDocumentsays thewordfield 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 assertsnum_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
Dictionaryto be duplicate-free: it is "a list of entries, where every entry consists of term, weight and payload" (Dictionary.java:23-24);PlainTextDictionarydocuments "1 word per line";DocumentDictionaryyields 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.
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.
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
addDocumenton purpose:BytesRefHashrejects entries over 32,766 bytes with its own exception. With the insert first (variant A0, also run: every suite passes), such an entry fails withMaxBytesLengthExceededExceptioninstead of theIllegalArgumentExceptionthatIndexWriterthrows for an immense term today. With A it still fails inaddDocument, as today. - Two limits that B does not have (by reading; not run through
indexDictionary):BytesRefHashstores 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.utf8ToStringdoes not fully validate UTF-8, so two byte sequences can decode to the same word (a small probe: the overlongC1 A1 62 63andabcboth give"abc", and the hash does not match them). A dictionary that yields such bytes, for example binary stored values throughDocumentDictionary, 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.
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.
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.
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.
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.
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.
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 readingindexDictionaryshowed that theTermsEnums 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 indexedabcd,abcd,abcein one call and got[abcd, abcd, abce]as the suggestions forabcf; the finder reduced it to the JUnit test, and an independent reproducer then wrote its own test with other words and aDocumentDictionarytrigger.
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.
| Where | When | How | Found |
|---|---|---|---|
| apache/lucene issues, PRs and commits; dev@, java-user@, issues@, commits@; LUCENE JIRA archive; downstream; web | 2026-10-26 | a 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 queries | no 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-26 | 19 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-26 | 10 JIRA REST queries and 7 list queries | no report; SOLR-5995 and SOLR-572 are different defects |
| Open PRs and git history | 2026-10-26 | changed files of all 408 open apache/lucene PRs (the first 100 files of each); git pickaxe over the full history | only #13951 touches SpellChecker.java (an Analyzer cast); no commit ever added a per-call set |
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.
| Version or commit | Verdict | Result |
|---|---|---|
main a9779ef3c193 | affected | 3 of 4 primary repro tests fail through gradle, assertions on and off; the control passes |
main ea57360912ed | affected | finder, independent reproducer, two more probes and adjudication: fail with -ea and -da, several seeds |
| 10.5.2, 10.0.0, 9.12.3 | affected | the 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) | affected | a 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 commit | the same probe reproduces |
| 1.4.3 and older | API absent | no spellchecker in the release |
fix A on a9779ef3c193 | not affected | the whole suggest module, repros included: 247 tests, 0 failures, assertions on and off |
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.
| Check | Verdict | What it found |
|---|---|---|
| finder (code reading, probe, JUnit) | candidate | JUnit 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 | real | Own 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, medium | Intended 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, medium | Built 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, high | Re-ran the independent test (-ea) and the finder's (-da, seed CAFEBABE): both fail. |
| security probe | security none | Crowding 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 pass | Stock 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. |
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 459 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,378 words
- 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 mainea57360912ed(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.
- 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-39a Lucene main checkout lucene/suggest/src/test/org/apache/lucene/search/spell/TestSpellChecker.java:113-124a 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)
- TestSpellCheckerDuplicateDictionaryWords.java (JUnit test):
repro/LFV-518/src/org/apache/lucene/search/spell/TestSpellCheckerDuplicateDictionaryWords.java(41 lines)
A step-by-step picture of the repro's example, made in parallel with this doc.
assets/LFV-518/bug.png(459 KB)
