All findings · Lucene finding · LFV-867 · word2vecmodel-repeated-term-shifts-later-vectors

Word2VecModel: a term listed twice shifts every later term onto another term's vector

If a word2vec model lists a term twice, every later term silently reads the previous term's vector: in the model a, a, b, c, vectorValue("b") returns a's vector, and Word2VecSynonymFilter expands banana with auto's synonyms. Affects every release since 9.7.0; stock Deeplearning4j models list each word once.

Summary

Priority
Low, rank 386Needs a model that repeats a term in the experimental Word2VecSynonymFilter.
Impact
silent wrong results
Since
9.7.0 (#12169; 10.0.0 on the 10.x line)introduced 2023-04-24 · about 3 years
Affected
9.7.0 to 9.12.3, 10.0.0 to 10.6.0 and main
Status
documented
Found by
code reading and a small probe

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

A word2vec model is a list of words, each with a vector. Word2VecModel keeps the vectors in an array, one slot per added line, and finds a word's slot through a hash that numbers distinct words 0, 1, 2. A repeated word takes a slot but gets no number, so the two numberings drift apart.

Diagram of the bug. The model a (1,0), a (1,0), b (0,1), c (-1,0) fills array slots 0 to 3, while the term hash numbers only distinct terms: a 0, b 1, c 2. vectorValue(b) reads slot 1, the second a, (1,0); vectorValue(c) reads slot 2, b's (0,1). Expected (0,1) and (-1,0). Affected: 9.7.0 to 10.5.2 and main.
The a, a, b, c model: array slots versus term-hash ids, and which vector each lookup returns after the repeat. Select the still to open it at full size. Generated with AI, checked against the code.
  1. You build a Word2VecModel from four lines, a (1,0), a (1,0) again, b (0,1) and c (-1,0), with addTermAndVector. It accepts the repeat without any error or warning; a model file with a repeated word, such as the zip in step 4, also loads silently.
  2. You expect each term to keep its own vector: vectorValue("b") = (0,1) and vectorValue("c") = (-1,0).
  3. Instead vectorValue("b") = (1,0), a's vector, and vectorValue("c") = (0,1), b's. Each line gets the next array slot (0, 1, 2, 3), but the term hash numbers only new terms (a 0, b 1, c 2), and the lookup uses that number as the slot, so every term after the repeat reads one slot too early.
  4. Through the filter with its defaults (5 synonyms, similarity 0.8), the model car, car, auto, banana, fruit expands banana to [auto, car, car] (cosines 0.20, 0 and 0 with banana) instead of [fruit] (cosine 0.995).
  5. Terms before the first repeat are fine, and a repeat on the last line shifts nothing; each further repeat shifts the later terms one more slot.

Who is affected

Only applications that opt in to the experimental Word2VecSynonymFilter, or use Word2VecModel directly, with a model that lists some term twice: a hand-built or converted model file, or code that calls addTermAndVector with a repeat. Stock Deeplearning4j output lists each word once. Every term after the repeat then gets another word's synonyms, silently.

Mechanism, terms and numbers715 words

Word2VecModel.addTermAndVector stores each entry at the next array slot (termsAndVectors[loadedCount++]) and calls BytesRefHash.add(term) without looking at its result (Word2VecModel.java:58-62 on main a9779ef3c193). vectorValue(BytesRef term) then reads the slot whose index is the term's hash id (:69-74). The two numbers agree only while every term is new: for a term that is already present, BytesRefHash.add allocates no id and returns -(id)-1, while the slot counter still advances. From then on, every later distinct term reads the slot one before its own line (one more slot back for each further repeat). For the model a (1,0), a (1,0), b (0,1), c (-1,0), vectorValue("b") returns (1,0) and vectorValue("c") returns (0,1). Through Word2VecSynonymFilterFactory with its defaults, a Deeplearning4j-format zip listing car, car, auto, banana, fruit loads without any error, and banana is expanded to [auto, car, car] instead of [fruit]. The trigger is a model that lists some term twice: a hand-built or converted model file, or code that calls addTermAndVector with a repeat; stock Deeplearning4j output lists each word once. Every release with the API is affected: 9.7.0 to 9.12.3, 10.0.0 to 10.6.0 and main.

Terms

  • Word2vec model: a list of words, each with a vector. Word2VecSynonymFilter looks up a token's vector and emits the words whose vectors are nearest, found with an HNSW graph built over the model.
  • Slot: the position of an entry in Word2VecModel's array termsAndVectors, one per added line.
  • Id: the number BytesRefHash gives a distinct term: 0, 1, 2, ... in the order terms are first added.

The bug

  1. addTermAndVector (Word2VecModel.java:58-62) normalizes the vector, stores the entry at termsAndVectors[loadedCount++] and calls word2Vec.add(term) without using the returned id.
  2. BytesRefHash.add (BytesRefHash.java:310-345) gives a new term the next id; for a term it already holds it allocates nothing and returns -(id)-1 (javadoc :314-316).
  3. vectorValue(BytesRef term) (Word2VecModel.java:69-74) reads termsAndVectors[word2Vec.find(term)]: it uses the id as the slot.
  4. Word2VecSynonymProvider builds its graph over all size() slots (Word2VecSynonymProvider.java:56-67; size() returns the header's dictionarySize, Word2VecModel.java:86-88), takes the query vector from vectorValue(term) (Word2VecSynonymProvider.java:78) and names each hit by slot with termValue(id) (Word2VecSynonymProvider.java:97). So a later term is searched with the previous term's vector, while its own vector stays in the graph and can still come back as a synonym of other words.
  5. Dl4jModelReader.read() (Dl4jModelReader.java:64-81) adds every line of the syn0 file with no repeat check. Its only deliberate check is the vector dimension (:99-109). Other malformed files fail loudly but incidentally, with low-level exceptions: more lines than the header declares, fewer lines, or a plain-text first term shorter than 3 characters (StringIndexOutOfBoundsException from the base64-prefix check, :73-75). So the a, a, b, c model loads from a file only with base64-encoded terms; the file example below uses car.

Trace for a, a, b, c

lineslot writtenadd() returnsslot read by vectorValue(term)
a (1,0)00a: slot 0, (1,0), correct
a (1,0)1-1 (a already has id 0)
b (0,1)21b: slot 1, the second a, (1,0)
c (-1,0)32c: slot 2, b's (0,1)

When the capacity counts every line, as here, every index stays in bounds, so nothing fails. Each further repeat shifts the later terms one more slot.

The numbers

modellookup or inputresult on mainexpected
a (1,0), a (1,0), b (0,1), c (-1,0)vectorValue("b"), vectorValue("c")(1,0), (0,1)(0,1), (-1,0)
zip car, car, auto (0.98,0.2), banana (0,1), fruit (0.1,1), factory defaults (5, 0.8)banana[banana, auto, car, car][banana, fruit]
red, red, green, lime, blue, navy through the filter (5, 0.8)green, blue[green, red, red], [blue, lime, green][green, lime], [blue, navy]
pear, quince, pear, rhubarbvectorValue("rhubarb")pear's vector (quince, before the repeat, is correct)rhubarb's

In the banana row the synonyms are auto's nearest neighbours: cosines 0.20, 0 and 0 with banana, while fruit's is 0.995. The result is the same whether the 0.8 threshold applies to the cosine or to Lucene's (1 + cos) / 2 score. car appears twice because both lines got their own graph node; that comes from storing the repeat at all, not from the shift.

Exposure and likelihood280 words
  • Who is hit. Users of the @lucene.experimental word2vec synonym support: the Word2VecSynonym filter factory, Word2VecSynonymFilter with a Word2VecSynonymProvider, or Word2VecModel directly. No default analyzer uses it.
  • The trigger. A model that lists some term twice, with at least one distinct term after it: a model file built or converted by hand or by a script (the reader accepts any syn0 file in Deeplearning4j's text layout; lower-casing a cased vocabulary while converting, which turns Car and car into two car lines, is one plausible way, not observed in a real file), or code that calls the public addTermAndVector with a repeat.
  • Not stock Deeplearning4j output. Its writer emits one line per vocabulary word, and Lucene's own sample model in the tests has 235 lines and 235 distinct words. Repeated words do occur in some word2vec-format files elsewhere: gensim's loader warns about them and keeps the first.
  • What goes wrong. Every distinct term after the first repeat is looked up with another term's vector, so wherever the filter is configured (index or query time) it is expanded with that term's synonyms (the visible change can be small when the two vectors happen to be close). With one repeat early in a large vocabulary, that is almost the whole vocabulary. No error or warning.
  • Not affected. Models with unique terms. Terms before the first repeat, and all terms when the only repeat is on the last line, keep their own vectors; the repeated line is still a graph node, so the repeated word can come back twice among other words' synonyms (the second car above). No index corruption and no exception; the only extra cost is one graph node per repeated line.

Minimal repro

The test shows that after a term is added twice to a Word2VecModel, vectorValue of every later term returns the previous entry's vector (the repeat takes an array slot but gets no new BytesRefHash id).

TestWord2VecModelRepeatedTerm.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.analysis.synonym.word2vec;

import java.util.Arrays;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.util.BytesRef;
import org.apache.lucene.util.TermAndVector;

public class TestWord2VecModelRepeatedTerm extends LuceneTestCase {

  public void testRepeatedTermKeepsLaterVectors() {
    Word2VecModel model = new Word2VecModel(3, 2);
    model.addTermAndVector(new TermAndVector(new BytesRef("a"), new float[] {1, 0}));
    try {
      model.addTermAndVector(new TermAndVector(new BytesRef("a"), new float[] {1, 0}));
    } catch (IllegalArgumentException rejectingTheRepeatIsAlsoCorrect) {
      return;
    }
    model.addTermAndVector(new TermAndVector(new BytesRef("b"), new float[] {0, 1}));
    float[] b = model.vectorValue(new BytesRef("b"));
    // expected b's own vector [0.0, 1.0]; actual a's vector [1.0, 0.0]
    assertTrue(
        "expected b's vector [0.0, 1.0], actual " + Arrays.toString(b),
        Arrays.equals(new float[] {0, 1}, b));
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-867/, which holds src/)
# Lucene checkout at the tested commit, built with: ./gradlew --no-daemon assemble   (JDK 25)
LUCENE=/path/to/lucene; V=12.0.0-SNAPSHOT
CP=$LUCENE/lucene/core/build/libs/lucene-core-$V.jar
CP=$CP:$LUCENE/lucene/codecs/build/libs/lucene-codecs-$V.jar
CP=$CP:$LUCENE/lucene/test-framework/build/libs/lucene-test-framework-$V.jar
CP=$CP:$LUCENE/lucene/analysis/common/build/libs/lucene-analysis-common-$V.jar
CP=$CP:randomizedtesting-runner-2.9.2.jar:junit-4.13.2.jar:hamcrest-3.0.jar   # test-framework dependencies (Maven Central)
CP=$CP:junit-platform-console-standalone-6.1.3.jar   # JUnit Platform console launcher (Maven Central)

javac -d classes -cp "$CP" $(find src -name '*.java')

# run 1: assertions on, seed DEADBEEF
java -ea -esa --add-modules jdk.incubator.vector --enable-native-access=ALL-UNNAMED -Dtests.seed=DEADBEEF \
     -cp "classes:$CP" org.junit.platform.console.ConsoleLauncher \
     execute --select-class org.apache.lucene.analysis.synonym.word2vec.TestWord2VecModelRepeatedTerm
Expected vs actual
For the model [a(1,0), a(1,0), b(0,1)] expected vectorValue("b") = [0.0, 1.0]; actual a's vector [1.0, 0.0], with -ea and -da (rejecting the repeat with IllegalArgumentException also passes).

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

More files, environment, full output37 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 b's vector [0.0, 1.0], actual [1.0, 0.0]
       org.junit.Assert.fail(Assert.java:89)
       org.junit.Assert.assertTrue(Assert.java:42)
       org.apache.lucene.analysis.synonym.word2vec.TestWord2VecModelRepeatedTerm.testRepeatedTermKeepsLaterVectors(TestWord2VecModelRepeatedTerm.java:37)

Why it looks unintended

  • The javadoc promises "the vectors for each word in dictionary", and an upstream test checks that vectorValue(term) is that term's own vector.
  • No uniqueness rule is written, the damage hits terms listed once, and other malformed model files fail loudly.
  • The introducing PR first used a HashMap, correct for lookups; the switch to BytesRefHash dropped the id add() returns.
The full argument268 words
  • The contract. Class javadoc: "Word2VecModel is a class representing the parsed Word2Vec model containing the vectors for each word in dictionary" (Word2VecModel.java:26-28). The upstream test TestWord2VecSynonymProvider.testModel_shouldReturnNormalizedVectors (TestWord2VecSynonymProvider.java:110-121) builds a model through the same public API and asserts that vectorValue(term) is that term's normalized vector. BytesRefHash.add documents the negative return for bytes already present (BytesRefHash.java:314-316).
  • No precondition. addTermAndVector and vectorValue(BytesRef) have no javadoc; the constructor, the package doc, the factories, CHANGES.txt and MIGRATE.md say nothing about unique terms.
  • The damage falls on other terms. A repeat does not decide which of the two a vectors wins; it silently changes b and c, which are listed once.
  • Other malformed files fail loudly. A wrong vector dimension (the reader's one deliberate check) gives "Word2Vec model file corrupted"; more lines than the header declares give ArrayIndexOutOfBoundsException and fewer give NullPointerException when the provider is built (both incidental, low-level failures). Of the cases probed, only a repeat is silent.
  • History. The first revision of #12169 (2abb1ea6de1, 2023-02-23) kept entries in a HashMap<BytesRef, TermAndVector> with put/get, so lookups stayed correct per term: a repeat replaced only that word's map entry (the last vector won), although both lines still took an array slot and a graph node. After a review question ("did you consider using BytesRefHash?"), PR commit bf712b90215 (2023-04-05) switched to BytesRefHash and used the id as the slot without using add's return value. No comment on the PR or on issue #12168 discusses repeated terms. On the 9.7.0 jars, putting back only the HashMap lookup makes the hunt's tests pass.
The devil's-advocate check188 words

The case for "intended": a word2vec model is a dictionary with one vector per word, and Dl4jModelReader is documented to read "the file generated by the library Deeplearning4j" (Dl4jModelReader.java:34-36), which never repeats a word. The model is trusted configuration and the package is @lucene.experimental, so a file with a repeated word is malformed input: garbage in, garbage out.

Why it fails: no precondition is written anywhere, and Word2VecModel has a public constructor and a public addTermAndVector that upstream tests call directly. @lucene.experimental means the API may change, not that results are undefined. "Garbage in" would allow a wrong vector for the repeated word, not for every unrelated word after it. Other malformed files do fail loudly in the reader, and the PR's first revision looked up every term correctly despite repeats, so the shift is an accident of the BytesRefHash switch, not a design. A reviewer from another model family (OpenAI GPT-5.6) rated the case "moderate" and rejected it; the devil's advocate and the adjudication rejected it too. What survives is a severity point: the documented producer writes unique words, so the reach is low.

Suggested fix

Recommended Reject a repeated term with IllegalArgumentException (recommended; validated)

Use the id that BytesRefHash.add returns and reject a repeated term with IllegalArgumentException, checking the capacity first so a failed call leaves the hash unchanged (+11/-1 in Word2VecModel.java). Validated: the repro passes with assertions on and off, and the whole analysis-common test task passes (1,911 tests, 0 failures).

  public void addTermAndVector(TermAndVector modelEntry) {
    modelEntry = modelEntry.normalizeVector();
    // check the capacity before add() changes the hash
    Objects.checkIndex(loadedCount, termsAndVectors.length);
    int termOrd = this.word2Vec.add(modelEntry.term());
    if (termOrd < 0) {
      // vectorValue(BytesRef) uses the hash id as the array slot, so a repeated term would shift
      // every later term onto the previous term's vector
      throw new IllegalArgumentException(
          "Duplicate term in word2vec model: " + modelEntry.term().utf8ToString());
    }
    assert termOrd == loadedCount;
    this.termsAndVectors[loadedCount++] = modelEntry;
  }
All options and trade-offs4 options · 771 words

Two options were built and validated in a scratch worktree of a9779ef3c193; A is recommended.

Reject a repeated term with IllegalArgumentException (recommended; validated)

Use the id that BytesRefHash.add returns and throw when it is negative, so today's numbering (slot = id) holds. The capacity check runs before add, so a model that is already full leaves the hash unchanged; it throws IndexOutOfBoundsException, the superclass of the ArrayIndexOutOfBoundsException thrown today for too many lines. +11/-1 in Word2VecModel.java, including the java.util.Objects import.

  public void addTermAndVector(TermAndVector modelEntry) {
    modelEntry = modelEntry.normalizeVector();
    // check the capacity before add() changes the hash
    Objects.checkIndex(loadedCount, termsAndVectors.length);
    int termOrd = this.word2Vec.add(modelEntry.term());
    if (termOrd < 0) {
      // vectorValue(BytesRef) uses the hash id as the array slot, so a repeated term would shift
      // every later term onto the previous term's vector
      throw new IllegalArgumentException(
          "Duplicate term in word2vec model: " + modelEntry.term().utf8ToString());
    }
    assert termOrd == loadedCount;
    this.termsAndVectors[loadedCount++] = modelEntry;
  }

Trade-offs

  • Loud and early, like the reader's handling of a wrong dimension; the message names the term. Through the factory the exception surfaces when the analyzer is built, and nothing is cached for that model name (Word2VecSynonymProviderFactory.java:43-51 caches only after a successful load).
  • Compatibility. A model file with a repeated word that loads today, with silently wrong synonyms, fails to load after the fix; the user has to remove the repeat. Direct callers of addTermAndVector that add a repeat also start getting the exception, and a repeat added once the model is already full throws the IndexOutOfBoundsException, not the duplicate IllegalArgumentException, because the capacity check comes first. Worth a CHANGES entry.

What the checks validated

  • stock: the repro fails 2 of 2 with assertions on and off; the word2vec test package (5 classes, 23 tests with the repro) fails only the repro's 2 tests;
  • fix A: the repro passes 2 of 2 with assertions on and off; the word2vec package passes (23 tests, 0 failures); the whole lucene/analysis/common test task, repro included, runs 340 classes, 1,911 tests, 0 failures, 18 skipped (13 in hunspell classes that need external dictionaries); ecjLint, checkGoogleJavaFormat, forbiddenApisMain and forbiddenApisTest pass; tidy changes nothing;
  • the hunt's 3-test repro with fix A shadowed passes 3 of 3; the independent reproducer's test fails its 5 repeat tests with "Duplicate term in word2vec model: red" (or pear), by design (they expect the first vector to be kept), and its 3 controls pass.

Keep the first vector of a repeated term and size the model by distinct terms (validated)

Store the entry at the id add returns, ignore a repeat, and let size() return word2Vec.size() so the HNSW graph and its ordinals cover exactly the distinct terms.

  public void addTermAndVector(TermAndVector modelEntry) {
    modelEntry = modelEntry.normalizeVector();
    int termOrd = this.word2Vec.add(modelEntry.term());
    if (termOrd < 0) {
      return; // repeated term: keep its first vector
    }
    this.termsAndVectors[termOrd] = modelEntry;
    loadedCount++;
  }

  @Override
  public int size() {
    return word2Vec.size();
  }

Trade-offs

  • Loads such files the way gensim does (it keeps the first and warns), but silently: a later, different vector for the same word is dropped without notice.
  • size() changes from the header's count to the number of distinct terms. It has to: with the header count, the graph builder would read empty slots.
  • A truncated file with unique words (fewer lines than the header declares), which fails today with NullPointerException when the provider is built, then loads silently as a smaller model.
  • No capacity check before add: one line too many still throws ArrayIndexOutOfBoundsException, but only after add has given the term an id that has no slot.

What the checks validated

  • fix B: the repro passes 2 of 2 (gradle, assertions on; classpath runner, assertions off); the word2vec package passes (23 tests, 0 failures, including TestDl4jModelReader's size() == 235 on Lucene's sample model); tidy changes nothing;
  • the hunt's 3-test repro, the independent reproducer's 8 tests and the second verifier's 3 tests pass with fix B shadowed. The whole module and the lint tasks were not run for B.

Keep the last vector instead (not validated)

Store a repeat at -(id)-1, overwriting the first entry, with size() as in the keep-first option. The PR's first revision (HashMap.put) also let the last vector win for lookups, but it is not the same: it still wrote every line to the next array slot, kept size() at the header's count and named hits by slot, so both lines stayed graph nodes.

Trade-offs

Same as keep-first.

Check only in Dl4jModelReader (not recommended)

Reject repeats while reading the model file.

Trade-offs

Leaves direct callers of the public addTermAndVector exposed.

Downstream note

Only whoever supplies the model can trigger this, not document or query text; a product that accepts word2vec model files from its users should reject repeated words itself until a fix lands.

How it was found

Code reading noticed that the array slot and the hash id can disagree, and a small probe on the stock jars confirmed it. An exhaustive comparison of the real classes with an exact reference over 321,075 small models then found 164,160 wrong lookups, all after a repeated term, and a public-API JUnit reproduced it.

The method in full195 words

Method: code reading plus a small executed probe (the hunt's non-formal tier; the hunt unit tagged it semi-formal because of the later exhaustive check).

  • The hunt's planning read the word2vec classes, named the lead "duplicate model terms shift vectors", and a probe on the stock main jars showed a small model with a repeated term returning the wrong vector for both terms after the repeat.
  • The hunt unit compared Word2VecModel, Word2VecSynonymProvider and Word2VecSynonymFilter on the real jar with an exact reference written from the javadoc, over 321,075 small models: 49,635 with distinct names (2-D and 3-D direction grids) and 271,440 with names from {a, b, c}, vectors from 4 directions and lengths 2 to 5. Oracle "vectorValue(term) is a vector that was added for that term": 164,160 of 1,525,824 checks over all 321,075 models fail, every failure in a model where a name repeats before a later distinct name.
  • It then wrote a 3-test public-API JUnit that fails on the stock jar. An independent reproducer, a second verifier and an intended-behaviour devil's advocate confirmed it with their own tests, and a reviewer from another model family (OpenAI GPT-5.6, reading only) rated it real.

Prior reports, history and checks

Prior reports

No prior report found (2026-10-28): the scripted prior-report search (51 queries over GitHub, the mailing lists, JIRA, downstream trackers and the web), 14 manual GitHub searches, the changed files of 150 open and recent PRs and 8 related threads read in full. The introducing PR #12169 never discusses repeated terms.

Searches4 searches

No report of this defect was found. Related items concern other parts of the word2vec package.

What was searched, when, and what turned up
WhereWhenHowFound
apache/lucene issues, PRs, commits; downstream repositories; dev@, java-user@, issues@, commits@; JIRA archive; web2026-10-28scripted prior-report search: 16 issue/PR, 3 commit, 2 downstream, 20 mailing-list, 4 JIRA-archive and 6 web queriesno report; 165 distinct hits, every word2vec-specific hit read
GitHub, manual2026-10-2814 searches: word2vec, Word2VecModel, Word2VecSynonymProvider, Word2VecSynonymFilter, dl4j, deeplearning4j, BytesRefHash "return value", ...; also apache/solr, OpenSearch and Elasticsearchno report (0 hits in the three downstream repositories)
Open and recent PRs2026-10-28changed-file lists of 129 open PRs and of 21 PRs closed or updated since 2026-10-24; issues created since 2026-10-24; refreshed at the registration check (PRs to #16926, issues to #16928)#16847 and #16815 make mechanical edits to Word2VecSynonymProvider.java only; #12433 (open draft) touches the filter and its factory; none touches Word2VecModel
Related threads (read in full)2026-10-28#12168, #12169, #12246, #12257, #12366, #12397, #12433, #15704; lists.apache.org (dev@, java-user@, users@solr); the feature authors' model-trainer issue tracker#12169 introduced the BytesRefHash mapping without discussing repeats; the others concern thread safety, class moves, boosts and the synonym buffer

History

Present since the word2vec package was added in #12169 (1f4f2bf5095, merged 2023-04-24; first release 9.7.0 through the re-applied backport 64b48b89b50). The tests fail on 9.7.0, 9.12.3, 10.0.0, 10.5.2, branch_10_6 and main; 9.6.0 has no word2vec classes. Not a regression; not fixed upstream.

Versions tested6 rows

Introduced by apache/lucene#12169 "Introduced the Word2VecSynonymFilter" (issue #12168; by @dantuzi, co-authored by Alessandro Benedetti), squash-merged to main on 2023-04-24 as 1f4f2bf5095. Inside the PR, commit bf712b90215 (2023-04-05) replaced the first revision's HashMap (2abb1ea6de1) with BytesRefHash. On branch_9x the first cherry-pick (53709ccaecc) was reverted the next day (7e15a390688), before 9.6.0, and re-applied as 64b48b89b50 (committed 2023-05-30), hence 9.7.0. CHANGES.txt lists GITHUB#12169 under 9.6.0, but the 9.6.0 release has no word2vec classes, so say "since 9.7.0 (10.0.0 on the 10.x line)". Later commits on Word2VecModel.java (3d86ff2e6a7, d8a81476262, 2f7da75b7d1, 6053e1e3137, the last on 2024-09-28) changed types and accessors, not this logic.

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affectedthis doc's test fails 2 of 2 (gradle and classpath runner, assertions on and off); the hunt's 3 tests fail 3 of 3; both fixes pass
branch_10_6 build (10.6.0-SNAPSHOT at 4ae946e5a9b, the revision released as 10.6.0 on 2026-10-29) affectedthe hunt's tests fail 3 of 3 (-ea only)
10.0.0 to 10.5.2 affectedthe hunt's tests fail 3 of 3 on 10.0.0 and 10.5.2 (-ea and -da); this doc's test fails 2 of 2 on 10.5.2; the file is the same blob in every 10.x tag
9.7.0 to 9.12.3 affectedthe hunt's tests fail 3 of 3 on 9.7.0 and 9.12.3 (-ea and -da); this doc's test fails 2 of 2 on 9.7.0; the two method bodies are identical in every 9.x release from 9.7.0
9.7.0 with only the PR's first-revision HashMap lookup counterfactualthe hunt's tests pass 3 of 3
9.6.0 and older no APIcompile error: no word2vec classes (0 files in the 9.6.0 tree)

Independent checks

An independent reproducer, a second verifier with its own controlled JUnit, a cross-model reviewer (OpenAI GPT-5.6) and an intended-behaviour devil's advocate rated it real; an adjudication and a credibility audit re-ran it; this doc's gradle runs reproduce it and validate two fixes. The cross-model reviews could only read the code, not run it.

Checks and verdicts376 words

Every check ran on main a9779ef3c193 with JDK 25.0.3 unless noted.

Each independent check and its verdict
CheckVerdictWhat it found
Independent reproducer realOwn 8-test JUnit (5 repeat tests, 3 controls) from a package outside Lucene: 5 fail and the controls pass on main (-ea, -da), 10.5.2, 9.12.3 and 9.7.0; a keep-first shadow fix passes 8 of 8.
Second verifier reproducedWrote and ran its own 3-test JUnit (TestWord2VecRepeat) on main with -ea and -da: the test with a repeat in the middle fails (every later term gets another entry's vector), and the two controls (distinct terms; a repeat on the last line) pass. It also re-ran the hunt's test (3 of 3 fail).
Cross-model review, intended lens (OpenAI GPT-5.6) real, mediumReading only (the reviewer could not run code and wrote no test): rated the bug real (medium) and the intended-behaviour case moderate, and rejected that case. A separate re-run of the hunt's test fails 3 of 3.
Cross-model review, refute lens (OpenAI GPT-5.6) procedural onlyNo command ran; the reviewer states that this is not a refutation. A separate re-run fails 3 of 3.
Intended-behaviour devil's advocate real, not intendedFound the PR history (HashMap, then BytesRefHash) and showed with a probe that every other malformed model fails loudly.
Adjudication confirmed, highRe-ran the independent reproduction's test with a new seed (-ea and -da: 8 run, 5 failed, controls pass).
Registration check and credibility audit keepRe-ran the hunt's and the independent reproduction's tests on main with fresh seeds (same failures) and the finder's patch (passes).
This doc reproduced; fixes A and B validatedGradle in a clean worktree: the new 2-test repro fails 2 of 2 on stock (assertions on and off) and passes with fix A and with fix B; with A, the word2vec package and the whole analysis-common test task (1,911 tests) pass and the lint tasks pass; with the fixes shadowed, the hunt's repro passes with either fix, and the independent reproducer's and the second verifier's tests pass with the keep-first fix (the reproducer's repeat tests expect the first vector to be kept, so they fail with the reject fix, by design).

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

Finding record

LFV-867, snapshot of 2026-11-06

Registry title
Word2VecModel: a term added twice silently shifts the vector of every later term, so Word2VecSynonymFilter returns synonyms computed from other terms' vectors
Area
analysis-common / synonym.word2vec (Word2VecModel.addTermAndVector term-to-vector mapping; Dl4jModelReader; Word2VecSynonymProvider and Word2VecSynonymFilter as consumers)
Priority
Low, rank 386: Needs a model that repeats a term in the experimental Word2VecSynonymFilter.
Impact class
silent wrong results
Impact note
Only applications that opt in to the @lucene.experimental Word2VecSynonymFilter (or use Word2VecModel directly) with a model that lists some term twice are hit: a hand-built or converted model zip loaded through Dl4jModelReader / Word2VecSynonymFilterFactory, or application code that calls the public addTermAndVector with a repeat (the Deeplearning4j writer that Dl4jModelReader targets writes each word once, so stock Deeplearning4j models are not affected); the model then loads without any error, and every distinct term listed after the first repeat gets another term's vector, so its synonyms at index and query time come from a different word; ordinary scale; every release since 9.7.0.
Summary
You build a Word2VecModel in which one term appears twice, through the public addTermAndVector or by loading a Deeplearning4j-format zip with Dl4jModelReader or Word2VecSynonymFilterFactory, and you expect every term to keep its own vector, as the class javadoc ('containing the vectors for each word in dictionary') and the upstream test of vectorValue(term) say. addTermAndVector stores each entry at the next array slot but ignores that BytesRefHash.add gives no new id to a term it already holds, while vectorValue(term) looks the vector up by that id, so after the first repeat every later distinct term reads the slot of the term before it. For the model [a(1,0), a(1,0), b(0,1), c(-1,0)] vectorValue(b) returns a's vector and vectorValue(c) returns b's, and with the model [car, car, auto, banana, fruit] and the factory defaults 'banana' is expanded with the neighbours of 'auto' ([auto, car, car]) instead of [fruit], with no error or warning.
Introduced
1f4f2bf50957ad1a13c32d5351c7de77154953f1 on main (apache/lucene#12169 'Introduced the Word2VecSynonymFilter', feature issue #12168; author dantuzi, co-author Alessandro Benedetti; squash-merged 2023-04-24; first in 10.0.0) and its branch_9x re-apply 64b48b89b501 (committed 2023-05-30, author date 2023-04-24; first in 9.7.0; an earlier cherry-pick 53709ccaecc was reverted by 7e15a390688 on 2023-04-25, before 9.6.0). Inside the PR the defect arrived with PR commit bf712b90215 (author date 2023-04-05), which replaced the first revision's HashMap<BytesRef,TermAndVector> put/get (2abb1ea6de1, correct per term) with BytesRefHash ids used as array slots while ignoring the return value of add() [read: git blame -w -M -C on the history clone, whose Word2VecModel.java is identical to a9779ef3c193's, attributes Word2VecModel.java:58, 60, 62, 69-72 and 74 to 1f4f2bf5095 and :59, 61, 66, 73 to 2f7da75b7d1 (record accessors only); exec: on the 9.7.0 jars, whose Word2VecModel.java is byte-identical to 1f4f2bf5095's, the finder's JUnit fails 3 of 3, and with only the term lookup restored to the PR's first-revision HashMap it passes 3 of 3, -ea and -da] (2023-04-24 (main 1f4f2bf5095); 2023-05-30 (branch_9x re-apply 64b48b89b50, author date 2023-04-24); PR-internal change bf712b90215 authored 2023-04-05)
First affected version
9.7.0 [exec] (the first release with the word2vec package; 9.6.0 has no word2vec classes and the repro does not compile there [exec], although CHANGES.txt lists GITHUB#12169 under 9.6.0); first on the main line: 10.0.0 [exec]
Affected versions
Every release that has the API: 9.7.0 to 9.12.3 and 10.0.0 to 10.5.2 [exec: the finder's JUnit fails 3 of 3 on 9.7.0, 9.12.3, 10.0.0 and 10.5.2 with -ea and with -da; the independent reproduction's JUnit fails its 5 repeat tests (controls pass) on 10.5.2, 9.12.3 and 9.7.0 with -da; the intended-behaviour check re-ran 10.5.2 -da; read: the addTermAndVector and vectorValue(BytesRef) bodies are identical in 9.7.0 to 9.12.3 and the Word2VecModel.java blob 68fd3b5884b0 is identical in every 10.x tag], the branch_10_6 head 4ae946e5a9b (10.6.0, released 2026-10-29) [exec, finder], branch_10x 1309534296e3 [read: same blob] and main a9779ef3c193 [exec: -ea and -da; re-run at the registration check]. Not fixed upstream: the last commit on Word2VecModel.java on upstream main is 6053e1e3137 (#13779, 2024-09-28, no logic change) [read 2026-10-28]. All runs on aarch64 Linux with JDK 25.
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; also reached by exhaustive check
Upstream and prior reports
none found (the prior-report record, verdict new, searched 2026-10-28, read-only; refreshed at the registration check 2026-10-28): a scripted prior-report search (16 GitHub issue/PR, 3 commit, 2 downstream Elasticsearch/OpenSearch/Solr, 20 mailing-list queries over dev@, java-user@, issues@ and commits@, 4 JIRA-archive and 6 web queries), 14 manual GitHub searches, the file lists of 129 open PRs (128 updated since 2026-10-14 plus #12433) and of 21 PRs closed or updated since 2026-10-24, issues since 2026-10-24, the full threads of #12168, #12169, #12246, #12257, #12366, #12397, #12433 and #15704, lists.apache.org (also users@solr), the issues of the authors' model-trainer repository, 8 web searches and 1 page read. Related, not this bug: #12169 (introducing PR; a reviewer's BytesRefHash suggestion was adopted without using add()'s return value, repeated terms were never discussed), #12433 (open draft, filter boost, does not touch Word2VecModel), #16847 and #16815 (open, mechanical edits to Word2VecSynonymProvider.java only), #12246 and #12257 (thread safety), #15704 (filter collection type). Refresh: the newest PRs (to #16926) and issues (to #16928) contain nothing on word2vec, and the last commit on Word2VecModel.java is still 6053e1e3137 (2024-09-28). Gaps: the refresh's two search-API queries hit the shared GitHub rate limit; keyword search reads only the first page per query; the JIRA archive cannot hold a report because the feature postdates the 2022 migration.
Lucene commit tested
main a9779ef3c193 (gradle in a clean scratch worktree, and the hunt's classpath runner with -ea and -da); through the classpath runner also 10.5.2 and 9.7.0. The hunt's tests ran on 9.7.0, 9.12.3, 10.0.0, 10.5.2, a branch_10_6 build (10.6.0-SNAPSHOT at 4ae946e5a9b, -ea only) and main; 9.6.0 has no word2vec classes. OpenJDK 25.0.3, Linux aarch64, 2026-10-28.

Lucene and upstream sources

Finding record
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/synonym/word2vec/Word2VecModel.java:26-30 (class javadoc 'containing the vectors for each word in dictionary', @lucene.experimental), :40-45 (public constructor), :58-62 (addTermAndVector: slot loadedCount++, add() result ignored), :69-74 (vectorValue(BytesRef): termsAndVectors[word2Vec.find(term)]), :86-88 (size() returns dictionarySize)
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/synonym/word2vec/Dl4jModelReader.java:34-43 (reads 'the file generated by the library Deeplearning4j', @lucene.experimental), :64-81 (header sizes the model; every line is added, no repeat check)
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/synonym/word2vec/Word2VecSynonymProvider.java:78 (query vector = vectorValue(term)), :97 (synonym = termValue(id))
  • lucene/core/src/java/org/apache/lucene/util/BytesRefHash.java:310-344 (add: returns the new id, or -(id)-1 when the bytes are already present, and allocates no id then)
  • lucene/analysis/common/src/test/org/apache/lucene/analysis/synonym/word2vec/TestWord2VecSynonymProvider.java:110-121 (testModel_shouldReturnNormalizedVectors: vectorValue(term) is that term's normalized vector)
The finding in 30 seconds
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/synonym/word2vec/Word2VecModel.java:26-30, (at a9779ef3c193) 58-62, 69-74, 76-78, 86-88
  • lucene/core/src/java/org/apache/lucene/util/BytesRefHash.java:310-345 (at a9779ef3c193)
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/synonym/word2vec/Word2VecSynonymProvider.java:56-68, (at a9779ef3c193) 78, 97-100; Dl4jModelReader.java:64-81, 93-115
Who is affected
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/synonym/word2vec/Word2VecModel.java:30; (at a9779ef3c193) Dl4jModelReader.java:34-44; Word2VecSynonymFilterFactory.java:32-42
  • lucene/analysis/common/src/test/org/apache/lucene/analysis/synonym/word2vec/word2vec-model.zip (at a9779ef3c193) (syn0.txt header 235, 235 lines, 0 repeated words; unzip -p | sort | uniq -d, 2026-10-28)
Why it looks unintended
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/synonym/word2vec/Word2VecModel.java:26-30, (at a9779ef3c193) 58-62, 69-74; Dl4jModelReader.java:34-44, 99-109
  • lucene/analysis/common/src/test/org/apache/lucene/analysis/synonym/word2vec/TestWord2VecSynonymProvider.java:110-121 (at a9779ef3c193)
  • lucene/core/src/java/org/apache/lucene/util/BytesRefHash.java:310-320 (at a9779ef3c193)
Suggested fix
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/synonym/word2vec/Word2VecSynonymProviderFactory.java:40-53 (at a9779ef3c193)
History
  • lucene/CHANGES.txt:3593 (at a9779ef3c193)

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 a, a, b, c example.

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.