All findings · Lucene finding · LFV-862 · word2vec-provider-returns-max-synonyms-plus-one

Word2VecSynonymProvider returns maxSynonymsPerTerm + 1 synonyms when the query word is missing from its own search hits

Word2VecSynonymFilter promises at most maxSynonymsPerTerm synonyms, but the provider asks for k + 1 neighbours, expecting the query word among them, and never caps the result: when it is missing, all k + 1 above the threshold stay. With the defaults, w138 of a 500-word model gets 6, not 5.

Summary

Priority
Low, rank 447The experimental, opt-in Word2VecSynonymFilter adds one extra above-threshold synonym for a small fixed set of terms, a quality-only drift like the opt-in WordDelimiterGraphFilterFactory types row LFV-851.
Impact
silent wrong results
Since
9.7.0 (2023), when the filter was added (#12169)introduced 2023-04-24 · about 3 years
Affected
9.7.0 to 10.6.0 and main; 24 releases and 10.6.0 run
Status
documented
Found by
bounded-exhaustive comparison with an exact-cosine reference

AI-assisted page: this finding was first signalled by a semi-formal check (an exact check of the real jar, or a modelling hypothesis that a tool then witnessed) and then checked with AI tools; the checks it went through, and what each found, are under Prior reports, history and checks. Not yet triaged by a human. Snapshot of 2026-11-06.

The finding in 30 seconds

Word2VecSynonymFilter adds, for each word, the nearest words of a word2vec model as synonyms, found by an HNSW search over the model's vectors. maxSynonymsPerTerm caps how many. The provider asks for one neighbour more, as room for the query word itself, and gives that room back only when the query word is among the hits.

Diagram of the bug: Word2VecSynonymProvider asks the HNSW graph for maxSynonymsPerTerm + 1 neighbours, removes only the query word, and returns all k + 1 neighbours when the query word is not among the hits, so Word2VecSynonymFilter emits one synonym more than maxSynonymsPerTerm.
How the provider's one spare search slot, meant for the query word itself, is kept when the query word is missing from the hits. Select the still to open it at full size. Generated with AI, checked against the code.
  1. Build a Word2VecSynonymFilter with maxSynonymsPerTerm = 1 over a model in which the words a, b and c have the same vector. The javadoc calls the value the "maximum number of result returned by the synonym search", so you expect at most 1 synonym per word.
  2. a and b get 1 synonym each, but c gets 2: [a, b]. The provider asks the HNSW graph for k + 1 = 2 neighbours, expecting c itself to be one of them and dropping it. Ties go to the lower ordinals here, so the 2 hits are a and b; both pass the threshold, nothing is dropped and both are kept.
  3. No ties are needed. In a 500-word model of distinct vectors with the factory defaults (5 synonyms, threshold 0.8), the search for 6 hits stops before it reaches w138 itself, although w138 is its own nearest neighbour (cosine 1.000; the best other word 0.923). All 6 hits pass the threshold, so w138 gets 6 synonyms.
  4. No error: the sixth synonym is the next-best hit (score 0.937 against a threshold of 0.8), so it silently joins the indexed terms or the query expansion on every occurrence of the word. A one-line cap on the result fixes it.

Who is affected

Applications that opt in to the experimental Word2VecSynonymFilter, with any model and the factory defaults. A small fixed set of words gets one extra synonym on every occurrence: about 0 to 0.2% of words at the defaults in synthetic clustered models, up to about 0.8% at maxSynonymsPerTerm = 1. Nothing fails.

Mechanism, terms and numbers733 words

Word2VecSynonymFilter (analysis-common, @lucene.experimental) expands each token with the nearest words of a word2vec model, found by Word2VecSynonymProvider.getSynonyms through an HNSW search over the model. The filter documents maxSynonymsPerTerm as the "maximum number of result returned by the synonym search". The provider searches for maxSynonymsPerTerm + 1 neighbours, because the query word is "always the nearest neighbour of itself", then drops the query word and every hit below minAcceptedSimilarity, and never caps the result. When the query word is not among the k + 1 hits and all of them pass the threshold, all of them are returned and emitted: three words with the same vector at k = 1 (c gets [a, b]), or, with distinct vectors and the factory defaults, a word that the search for k + 1 hits stops before reaching, although it is its own strict nearest neighbour (w138 of a 500-word clustered model gets 6 synonyms). The extra synonym is the next-best hit and passes the threshold; nothing fails. It has been there since the filter was added (#12169, first released in 9.7.0), fails on all 24 releases run and on main, and stopping the loop at k fixes it.

Terms

  • Word2VecSynonymFilter (org.apache.lucene.analysis.synonym.word2vec, analysis-common, @lucene.experimental; factory SPI name Word2VecSynonym, defaults maxSynonymsPerTerm = 5, minAcceptedSimilarity = 0.8). For every input token it asks a Word2VecSynonymProvider for synonyms and emits each as a SYNONYM token at the same position.
  • Word2VecSynonymProvider builds an HNSW graph over the model's normalised vectors once, then getSynonyms(term, maxSynonymsPerTerm, minAcceptedSimilarity) searches it with the query word's own vector. Each hit has a score (1 + cosine) / 2.
  • HNSW search is approximate: it keeps the best topK hits found so far and stops when no unexplored candidate can beat the worst of them (HnswGraphSearcher.java:177-186, :304-355), so a larger topK can make it explore further (it is not a fixed-size beam), and it can miss true neighbours. That is accepted; returning more than the maximum is a different property.

The bug

  1. getSynonyms calls HnswGraphSearcher.search(scorer, maxSynonymsPerTerm + 1, hnswGraph, null, Integer.MAX_VALUE) (Word2VecSynonymProvider.java:82-90), with the comment "When looking for the top-k it's always the nearest neighbour of itself so, we look for the top-k+1" (:85-86).
  2. The loop over the hits keeps every hit that is not the query word and whose score is at least minAcceptedSimilarity (:93-102). There is no cap at maxSynonymsPerTerm.
  3. When the query word is among the hits, at most k others remain. When it is not, all k + 1 remain if they pass the threshold, and Word2VecSynonymFilter emits all of them (Word2VecSynonymFilter.java:95-99, :79-89).
  4. Two legal inputs leave the query word out. Ties: at least k + 2 words with the same normalised vector; equal scores break towards the smaller ordinal (NeighborQueue.java:95-96), but which tied word loses its own slot depends on the order in which the search collects them (in the 3-word repro at k = 1 it is c). Distinct vectors: the search for k + 1 hits stops before it reaches the query node.

The numbers (stock main, score (1 + cosine) / 2)

model, k, thresholdresult
a, b, c with vector {1, 2}; k = 1; 0.5a -> [b], b -> [a], c -> [a, b] (both score 1.0)
500 distinct 16-dim vectors, 25 clusters; k = 5; 0.8 (factory defaults)499 words get at most 5; w138 -> [w136, w121, w127, w139, w135, w133]
w138, exact top-7 by brute forcew138 1.0000 (itself), w131 0.9616, w136 0.9552, w121 0.9500, w127 0.9442, w139 0.9385, w135 0.9385
w138 with the fix[w136, w121, w127, w139, w135]
same 500-word model, threshold 0.8, by koverrunning words: 4 at k = 1, 3 at k = 2, 2 at k = 3, 1 at k = 4 and 5, 0 at k = 6 and 7

The search also misses w131, the true nearest other word; that is ordinary approximate recall. The bug is the sixth word, w133 (score 0.9366). The search for 7 hits (k = 6) finds w138 itself, so the search for 6 stops too early; w138 is not unreachable. The 500 vectors are pairwise distinct: every word's cosine with itself beats its best cosine with any other word by at least 0.0209.

Exposure and likelihood314 words
  • Who. Applications that use Word2VecSynonymFilter (directly, through its factory, or by SPI name, for example with CustomAnalyzer) or call Word2VecSynonymProvider.getSynonyms. The classes are @lucene.experimental and opt-in. With any model and any maxSynonymsPerTerm, the factory defaults included; which words are hit depends on the model.
  • Effect. For an affected word, every occurrence gets one synonym more than configured: the (k + 1)-th best hit of the search, a model word that still passes minAcceptedSimilarity. Indexed terms or query expansions differ from what the setting promises; what that does to relevance depends on the application. No exception and no extra search work.
  • Scale. Deterministic per model as long as HnswGraphBuilder.randSeed keeps its default 42 (a public, mutable static field "public to expose for testing"; Lucene's core HNSW tests change it, no analysis-common test does), so the same words are affected every time. Measured on synthetic clustered models only (8 to 64 dimensions, 500 to 10,000 words; no real word2vec model was measured):
    • factory defaults (5, 0.8): 1 of 500 words (finder), 4 of 4,000 (independent reproducer), 0 to 2 of 10,000 in four 10,000-word models; 0 to 4 per model in 27 models of 1,000 to 4,000 words, non-zero in 8 of them;
    • maxSynonymsPerTerm = 1 (threshold 0.8): 0.1% to 0.8% of words in the 27 models (at most 33 of 4,000), 0.2% to 0.55% in the finder's 2,000- and 10,000-word models.
  • Ties. The tie trigger needs at least k + 2 words with one normalised vector (seven at the default k = 5); how often real models have that was not measured. The tie case is the smallest repro, the distinct-vector case the realistic one.
  • How likely. Plausible within the opt-in feature: no special input is needed, but only a small fraction of words is affected, and the extra word looks like a reasonable synonym, so it is easy to miss.

Minimal repro

The test shows that with three terms that share one vector, Word2VecSynonymProvider.getSynonyms(c, maxSynonymsPerTerm=1, 0.5f) returns two synonyms.

TestWord2VecSynonymProviderMaxSynonyms.java (JUnit test)38 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.Collection;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.util.BytesRef;
import org.apache.lucene.util.TermAndVector;

public class TestWord2VecSynonymProviderMaxSynonyms extends LuceneTestCase {

  public void testTiedVectorsMaxOneSynonym() throws Exception {
    Word2VecModel model = new Word2VecModel(3, 2);
    for (String term : new String[] {"a", "b", "c"}) {
      model.addTermAndVector(new TermAndVector(new BytesRef(term), new float[] {1f, 0f}));
    }
    Word2VecSynonymProvider provider = new Word2VecSynonymProvider(model);
    Collection<TermAndBoost> synonyms = provider.getSynonyms(new BytesRef("c"), 1, 0.5f);
    assertTrue(
        "maxSynonymsPerTerm=1: expected at most 1 synonym of c, got "
            + synonyms.stream().map(s -> s.term().utf8ToString()).toList(),
        synonyms.size() <= 1);
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-862/, 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.TestWord2VecSynonymProviderMaxSynonyms
Expected vs actual
Expected: at most 1 synonym (maxSynonymsPerTerm is the "maximum number of result returned by the synonym search"); actual (-ea and -da): [a, b], because the k+1 HNSW search does not return the query term itself and nothing caps the result.

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

More files, environment, full output40 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: maxSynonymsPerTerm=1: expected at most 1 synonym of c, got [a, b]
       org.junit.Assert.fail(Assert.java:89)
       org.junit.Assert.assertTrue(Assert.java:42)
       org.apache.lucene.analysis.synonym.word2vec.TestWord2VecSynonymProviderMaxSynonyms.testTiedVectorsMaxOneSynonym(TestWord2VecSynonymProviderMaxSynonyms.java:33)

Why it looks unintended

  • The filter's javadoc calls maxSynonymsPerTerm the "maximum number of result returned by the synonym search"; the PR's first draft said it of getSynonyms.
  • An upstream test sets 2 and expects exactly 2 synonyms, although 3 hits are searched.
  • The code's comment says the extra hit is room for the query word. Missing neighbours is approximation; exceeding the maximum is not.
The full argument218 words
  • Contract. Word2VecSynonymFilter.java:58: "@param maxSynonymsPerTerm maximum number of result returned by the synonym search". The filter passes the value to getSynonyms (:95-96) and emits everything it gets back.
  • The provider's own contract, originally. In the first revision of #12169 (2abb1ea6de1), a SynonymProvider interface documented getSynonyms(BytesRef term, int maxSynonymsPerTerm, float minAcceptedSimilarity) with exactly this @param. The interface was removed in review as a single-implementation interface, and the sentence survived only on the filter constructor.
  • Upstream test. TestWord2VecSynonymFilter.java:57-81: maxSynonymPerTerm = 2, expected output a plus exactly the two SYNONYM tokens d and e. The query word is among its hits there, so the test supports the bound but does not cover the self-miss case. It passes on stock and with both fixes.
  • The + 1 is only room for the query word. The comment at Word2VecSynonymProvider.java:85-86 says so; the factory validates the value as a positive count (Word2VecSynonymFilterFactory.java:72-76).
  • History. The #12169 review (33 review comments, 38 conversation comments) discussed thread safety, the visited limit, BoostAttribute and the model reader, never the cap or a missing self-hit. No later commit, test, CHANGES or MIGRATE entry accepts k + 1 results. Silence in a review is weak evidence of intent; the contract, the code comment and the original javadoc carry the argument.
The devil's-advocate check190 words

The strongest not-a-bug case (the intended-behaviour devil's advocate): HNSW is approximate by design, so one more approximation artifact is acceptable; the @param may describe the kNN search size rather than the synonym count; getSynonyms has no javadoc and the package is experimental; equal vectors are rare in trained models; and the extra synonym passes the threshold, so it is harmless.

Why it fails: missing some true neighbours is accepted approximation, but returning more than an explicit maximum is a different property, and the code shows the + 1 exists only as room for the query word. Read as "the search size", the sentence is still broken, because the search is called with k + 1. The original interface javadoc ties the sentence to getSynonyms, and the upstream test treats it as the output count (in a case where the query word is among the hits). A read-only reviewer from another model family (OpenAI GPT-5.6) put it as: "Experimental status and approximate-neighbor semantics do not authorize violating an explicit output bound." Only a severity argument survives (one extra, plausible synonym), which is why the bug ranks low. Verdict: not intended.

Suggested fix

Recommended A: stop the loop at maxSynonymsPerTerm

Stop the result loop once maxSynonymsPerTerm synonyms are collected (one condition plus a corrected comment). Hits come in score order, so the best k are kept, and nothing changes when the query word is among the hits. With it the word2vec and repro tests pass with assertions on and off, and the whole analysis-common test task passes.

              // The query vector is in the model, so it is usually its own nearest neighbour:
              // look for the top-k+1 and skip it below. It can be missing from the hits (tied
              // vectors, approximate search), so the loop below also stops after k synonyms.
              maxSynonymsPerTerm + 1,
              ...
      for (int i = 0; i < topDocs.scoreDocs.length && result.size() < maxSynonymsPerTerm; i++) {
All options and trade-offs3 options · 524 words

Two equivalent code fixes were built and run in a scratch worktree at a9779ef3c193; a larger alternative is listed without a run. A is recommended: the smallest change, and it does no extra work.

A: stop the loop at maxSynonymsPerTerm (validated by the checks) (recommended)

Add result.size() < maxSynonymsPerTerm to the loop condition in getSynonyms (Word2VecSynonymProvider.java:93) and correct the comment that says the query word is always among the hits.

              // The query vector is in the model, so it is usually its own nearest neighbour:
              // look for the top-k+1 and skip it below. It can be missing from the hits (tied
              // vectors, approximate search), so the loop below also stops after k synonyms.
              maxSynonymsPerTerm + 1,
              ...
      for (int i = 0; i < topDocs.scoreDocs.length && result.size() < maxSynonymsPerTerm; i++) {

Trade-offs

  • For positive k (the factory rejects k <= 0; the filter constructor and getSynonyms do not check k), output changes only when the query word is missing from the hits; otherwise at most k others pass anyway.
  • With it, c gets [a], w138 gets [w136, w121, w127, w139, w135], and no word of the 500-word model overruns for any k from 1 to 7.
  • It does not touch the + 1 itself, which overflows at Integer.MAX_VALUE (a separate bug with a search-size clamp as its fix); the two fixes compose.

What the checks validated

Gradle in a scratch worktree at a9779ef3c193, seed DEADBEEF: the word2vec test package plus the three repro classes, 7 classes, 30 tests, 0 failures, with assertions on and off (the 9 repro tests that fail on stock pass; the 21 upstream tests pass); the whole :lucene:analysis:common:test task with the three repro classes added, assertions on: 342 classes, 1,918 tests, 0 failures (18 skipped by the module itself); :lucene:analysis:common:check -x test with the fix and this doc's test added passes (google-java-format, ecj lint, forbidden APIs). Through the repro runner with the fixed file shadowed, this doc's test passes 2 of 2 with assertions on and off.

B: trim the result after the loop

After the loop, while (result.size() > maxSynonymsPerTerm) { result.removeLast(); }. Same output as A; it builds one extra entry in the overrun case. Run: the word2vec package plus the three repro classes, assertions on, 30 tests, 0 failures. The same idea in the 9.7.0 code shape (the 9.7.0 file shadowed onto the 9.7.0 jars) passes the finder's test.

      while (result.size() > maxSynonymsPerTerm) {
        result.removeLast();
      }

Trade-offs

Equivalent to A; slightly more work in the overrun case. Not run on the whole module.

C: exclude the query's own node from the search and ask for exactly k

Pass acceptOrds that rejects the query word's ordinal to HnswGraphSearcher.search(scorer, k, ...) and drop the term comparison.

Trade-offs

States the intent directly, but Word2VecModel has no public term-to-ordinal lookup (it needs a package-private accessor), and the kept hits shrink from k + 1 to k, so the search stops earlier and results can change for every word, not only the overrunning ones. Not run. A wider search alone is not a fix: ties can still push the query word out.

Downstream note

Analyzers built by SPI name (for example with CustomAnalyzer) reach this filter as Word2VecSynonym and inherit the extra synonym; which words get it depends only on the configured model.

How it was found

Semi-formal: a bounded-exhaustive comparison of the real provider with an exact-cosine reference, over every small model on a grid of vector directions (about 11 million checks), flagged results longer than maxSynonymsPerTerm, all with tied vectors. A second harness on clustered models of distinct vectors showed the overrun without ties; both were reduced to a public-API JUnit test.

The method in full175 words

Method: bounded-exhaustive translation validation (the hunt's "semi-formal" tier): the stock analysis-common jar against an exact-cosine reference of getSynonyms, with the oracles "getSynonyms returns at most maxSynonymsPerTerm" and "the filter emits at most maxSynonymsPerTerm SYNONYM tokens".

  • Scope: every 2-D model of 2 to 4 words on a 30-degree direction grid in all insertion orders, 3-D multisets of 3 to 4 of 26 directions, k = 1 to 5, several thresholds.
  • Result: 385,120 of 11,048,256 provider checks and 144 of 328,320 filter checks fail, all with tied vectors. Mutants of the other oracles were killed, so the comparison was not vacuous.
  • A second harness on clustered models of 2,000 and 10,000 distinct random vectors showed the same overrun without ties: the search for k + 1 hits stops before it reaches the query word itself.
  • Both were reduced to a public-API JUnit test on the stock main jar; an independent reproducer wrote its own test from the claim alone and checked by brute force that every overrunning word is its own strict nearest neighbour.

Prior reports, history and checks

Prior reports

No prior report found (two prior-report tool runs plus manual searches of Lucene issues, PRs, commits, mailing lists, the JIRA archive, downstream projects and the web, 2026-10-28). The introducing PR #12169 and the open draft #12433 touch this code but never discuss the cap.

Searches5 searches

Registry verdict new: no issue, PR, review comment, commit, list post, downstream issue or web page reports that getSynonyms or the filter returns or emits maxSynonymsPerTerm + 1 synonyms (best-effort: first-page GitHub results, keyword-based list and JIRA search; one downstream query hit the search quota). Nearest items: #12169 (introduced the k + 1 search; its reviews covered thread safety, the visited limit and BoostAttribute); #12433 (open draft, similarity as boost in the filter, does not touch the provider loop); #12246 and #12257 (thread safety); #12366 (TermAndBoost location); #12529 (scorer refactor that kept the loop); #15704 (collection type). Open PRs #16847 and #16815 touch the provider file for other reasons. Three sibling bugs in the same package are tracked separately with their own fix sites: the unclamped maxSynonymsPerTerm + 1 search size, minAcceptedSimilarity compared with a score instead of a cosine, and a repeated model term that shifts later vectors.

What was searched, when, and what turned up
WhereWhenHowFound
apache/lucene issues, PRs and commits; dev@, java-user@, issues@, commits@; JIRA archive; downstream; web2026-10-28prior-report tool, two runs: 24 GitHub issue/PR, 3 commit, 2 downstream, 20 list (with commits@) and 4 JIRA queries plus the 6 planned web queries; then 10 GitHub, 2 downstream, 9 list and 4 JIRA queriesno report; #12169 and #12433 are related only
web2026-10-288 general web searches: the 6 planned queries plus "Word2VecSynonymProvider" and a duplicate-vectors phrasing (raw results saved for the 6)the #12168 issue page, the contributors' blog and slides, outside word2vec filters; no report of the defect
GitHub (all repositories; apache/lucene open and recent closed PRs)2026-10-28global search for Word2VecSynonymProvider; titles of all 410 open PRs; files of the 42 updated since 2026-10-25; 200 most recently updated closed PRs; issues since 2026-10-24only #12169, #12366, #12529 and draft #12433
Review threads and history2026-10-28comments of #12168, #12169, #12246, #12257, #12366, #12433, #15704; the dev@ thread on moving TermAndBoost (a reply to the #12169 commit); the package's commits on main, branch_10x and branch_10_6; CHANGES.txtnobody discusses the cap, the self-exclusion or tied vectors
apache/lucene PRs and issues, newest2026-10-28refresh: the 30 newest PRs (to #16926) and issues (to #16928)nothing on word2vec or synonyms

History

Present since the filter was added by #12169 (1f4f2bf5095, 2023-04-24); first released in 9.7.0, because the first backport was reverted before 9.6.0. The finder's test fails on all 24 releases from 9.7.0 to 10.5.2, on 10.6.0 (branch_10_6 4ae946e5a9b9) and on main; 9.6.0 has no such API.

Versions tested7 rows

Introduced by 1f4f2bf50957ad1a13c32d5351c7de77154953f1 "Introduced the Word2VecSynonymFilter (#12169)" (author @dantuzi, co-author Alessandro Benedetti, merged by @alessandrobenedetti, 2023-04-24; feature issue #12168). The + 1, the "nearest neighbour of itself" comment and the uncapped loop are original lines of that commit. Not a regression.

The branch_9x backport 53709ccaecc (2023-04-24) was reverted by 7e15a390688 the next day over thread safety, so 9.6.0 has no word2vec package, although CHANGES.txt lists GITHUB#12169 under 9.6.0. The re-backport 64b48b89b50 (2023-05-30) is first in 9.7.0; on the main line the first release is 10.0.0. Later commits on the provider (#12235, #12246, #12366, #12434, #12529, #12651, #13288, #15704) changed the search call, visited limit, collector, imports or result container, never the cap. The provider file is byte-identical in 9.12.3, 10.0.0, 10.5.2 and branch_10_6; main differs only by #15704. No commit on the package's main sources after #15704 (2026-02-26), up to upstream main (fetched 2026-10-28); two later commits only tidy its tests (#16028, #16255). The provider file as 1f4f2bf5095 added it, shadowed onto the 9.7.0 jars, fails the finder's test 3 of 3.

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affectedthis doc's test fails 2 of 2 through gradle and the repro runner, assertions on and off; the finder's and the independent tests fail too
branch_10_6 4ae946e5a9b9 finder's JUnitfails 3 of 3 (4ae946e5a9b9 is newer than the local clone's origin/branch_10_6 2763db11b0a, its ancestor; the provider file is byte-identical in both)
10.5.2, 9.12.3, 9.7.0 affectedthis doc's test fails 2 of 2 on the published jars (10.5.2 also with assertions off); the independent test fails 4 of 4
24 releases, 9.7.0 to 10.5.2 finder's JUnitfails 3 of 3 on every release, with the same messages (9.x with a one-accessor copy)
1f4f2bf5095 (#12169, 2023) introducing commitits provider file, shadowed onto the 9.7.0 jars, fails 3 of 3; the 9.7.0 file plus a cap passes
9.6.0 API absentno word2vec package; the test does not compile
fix A on a9779ef3c193 not affectedthe word2vec and repro tests 30 of 30, assertions on and off; the whole analysis-common test task: 342 classes, 1,918 tests, 0 failures (18 skipped by the module itself)

Independent checks

An independent reproducer, an intended-behaviour devil's advocate, a read-only cross-model review and an adjudication all found it real; the other cross-model review could not run any command and returned a procedural verdict. Every executed re-run failed on main with assertions on and off and passed with the fix.

Checks and verdicts299 words

Each check worked on its own; the registration check and a credibility audit 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 (exhaustive comparison, JUnit) candidateJUnit fails 3 of 3 on main (-ea, -da, scalar vector code), branch_10_6 and 24 releases; with the fix 3 of 3 pass and the 21 upstream word2vec tests pass.
cross-model review, refute lens not real, low (procedural)A reviewer from another model family (OpenAI GPT-5.6) could not run any command and says the verdict is not a technical refutation; a separate re-run of the repro fails 3 of 3.
independent reproducer realOwn test fails 4 of 4 on main (-ea, -da, scalar), 10.5.2, 9.12.3 and 9.7.0; a brute-force check shows each overrunning word is its own strict nearest neighbour; its own cap fix passes.
cross-model review, intended lens real, low (read-only)Read only (no Java run, low confidence; it read main ea57360912ed): the intended argument is weak. Separate re-runs on a9779ef3c193 fail 3 of 3 and pass 3 of 3 with the fix.
intended-behaviour devil's advocate realFound the original getSynonyms javadoc in #12169's first revision and the cap-asserting upstream test; its probe shows w138 overruns for k = 1 to 5 and is found from k = 6.
adjudication confirmed, highRe-ran the independent test with a new seed: 4 of 4 fail with -ea and -da.
security screen not security-sensitiveThe model is trusted configuration (SECURITY.md line 42); the effect is one extra above-threshold synonym.
registry re-run and credibility audit keepFinder's test fails with -ea, the independent test with -da, on fresh seeds; the finder's test passes with the fix.

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

Finding record

LFV-862, snapshot of 2026-11-06

Registry title
Word2VecSynonymProvider returns maxSynonymsPerTerm + 1 synonyms when the query term is not among its own k+1 HNSW hits (tied vectors or an approximate-search miss), so Word2VecSynonymFilter emits one synonym more than the documented maximum
Area
analysis-common / synonym.word2vec (Word2VecSynonymProvider.getSynonyms result cap; Word2VecSynonymFilter)
Priority
Low, rank 447: The experimental, opt-in Word2VecSynonymFilter adds one extra above-threshold synonym for a small fixed set of terms, a quality-only drift like the opt-in WordDelimiterGraphFilterFactory types row LFV-851.
Impact class
silent wrong results
Impact note
Only applications that opt in to the @lucene.experimental Word2VecSynonymFilter (or call Word2VecSynonymProvider.getSynonyms directly) are hit, with any model and the factory defaults (maxSynonymsPerTerm=5, minAcceptedSimilarity=0.8): for the terms whose own vector the k+1 HNSW search does not return (about 0-0.2% of terms at the defaults and 0.1-1.1% at maxSynonymsPerTerm=1 in the synthetic clustered models measured; no real model was measured), or that share their normalized vector with two or more other terms, every occurrence at index or query time silently gets exactly one extra synonym, the (k+1)-th best hit, which still passes minAcceptedSimilarity; ordinary scale; every release since 9.7.0.
Summary
You configure Word2VecSynonymFilter with maxSynonymsPerTerm=k, whose javadoc calls it the 'maximum number of result returned by the synonym search', so you expect at most k synonyms per token. Word2VecSynonymProvider.getSynonyms searches the HNSW graph for k+1 neighbours on the assumption that the query term is always its own nearest neighbour, drops only the query term and never caps the result, so when the term is not among the hits it returns all k+1. With three terms that share one vector and k=1, getSynonyms(c) returns [a, b] and the filter emits 2 SYNONYM tokens; with 4000 distinct clustered vectors and the factory defaults, 4 terms get 6 synonyms because the approximate search with a beam of 6 does not reach the query term itself, although it is its own strict nearest neighbour by a margin of more than 0.03.
Introduced
1f4f2bf50957ad1a13c32d5351c7de77154953f1 on main (apache/lucene#12169 'Introduced the Word2VecSynonymFilter', feature issue #12168; author dantuzi, co-author Alessandro Benedetti, merged by alessandrobenedetti; first in 10.0.0) and its branch_9x backport 64b48b89b501 (committed 2023-05-30, author date 2023-04-24; first in 9.7.0; an earlier backport 53709ccaecc was reverted by 7e15a390688 on 2023-04-25) [read: git blame on main a9779ef3c193 attributes the comment 'it's always the nearest neighbour of itself', the '+ 1' (Word2VecSynonymProvider.java:85-87) and the result condition (:99) to 1f4f2bf5095; exec: the provider file exactly as 1f4f2bf5095 added it (one import line dropped) shadowed onto the 9.7.0 jars fails 3 of 3, and the 9.7.0 provider plus a final cap passes 3 of 3] (2023-04-24 (main 1f4f2bf5095); 2023-05-30 (branch_9x re-backport 64b48b89b50, author date 2023-04-24))
First affected version
9.7.0 [exec] (the first release with the API; 9.6.0 has no synonym.word2vec package, 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, 24 releases [exec: the finder's JUnit fails 3 of 3 with the same messages on each with -ea, also with -da on 9.7.0, 9.12.3, 10.0.0 and 10.5.2; 9.x runs use an import-adapted copy because TermAndBoost has public fields there; the independent reproduction's JUnit compiles unchanged and fails 4 of 4 on 10.5.2, 9.12.3 and 9.7.0], the branch_10_6 head 4ae946e5a9b9 (10.6.0, released 2026-10-29) [exec] and main a9779ef3c193 [exec: -ea and -da, Panama and scalar vector providers; re-run at the registration check]. Not fixed upstream: the provider file is byte-identical in 9.12.3, 10.0.0, 10.5.2 and branch_10_6, main differs only by #15704 (collection type), and no commit on the package follows 7335eb0ad96 (#15704, author date 2026-02-26) on upstream main c5ab3e57b9b (2026-10-28) [read]. 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
semi-formal: 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): scripted prior-report search specs v1 and v2 (GitHub issue/PR, commit-message, downstream Elasticsearch/OpenSearch/Solr, dev@/java-user@/issues@/commits@ list, JIRA-archive and web queries), global GitHub search for Word2VecSynonymProvider (only apache/lucene #12169, #12366, #12529), titles of all 410 open apache/lucene PRs, file lists of the 42 open PRs updated since 2026-10-25, the 200 most recently updated closed PRs, issues created since 2026-10-24, the commit history of the package on main, branch_10x and branch_10_6, CHANGES.txt, the comments of #12168, #12169, #12246, #12257, #12366, #12433 and #15704, the dev@ thread on #12169, and 8 general web searches. Related, not this bug: #12169 (introduced the k+1 search; reviews discussed thread safety, visitedLimit and BoostAttribute, not the cap), #12433 (open draft, filter boost, does not touch the provider loop), #12246/#12257 (thread safety), #15704 (collection type). Refresh: the 30 newest PRs (to #16926) and issues (to #16928) contain nothing on word2vec or synonyms, and the last commit on the package is still 7335eb0ad96. Gaps: one downstream GitHub query (elastic/elasticsearch Word2VecSynonymFilter) and the search query hit GitHub's search rate limit; keyword search reads only the first page per query.
Lucene commit tested
main a9779ef3c193 (gradle in a scratch worktree: the word2vec test package plus three repro classes, stock and with fix A, assertions on and off; fix A on the whole analysis-common test task and its static checks; fix B on the package with assertions on); this doc's test through the repro runner on main (stock and with fix A shadowed, assertions on and off) and on the published 10.5.2, 9.12.3 and 9.7.0 jars; the finder's test on all 24 releases from 9.7.0 to 10.5.2, on branch_10_6 4ae946e5a9b9 and on main (the hunt, 2026-10-28). JDK 25.0.3, Linux aarch64.

Lucene and upstream sources

Finding record
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/synonym/word2vec/Word2VecSynonymProvider.java:41 (@lucene.experimental), :70-104 (getSynonyms; '+ 1' at :87, comment :85-86, self-removal and threshold :98-100, no cap)
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/synonym/word2vec/Word2VecSynonymFilter.java:37 (@lucene.experimental), :58 ('@param maxSynonymsPerTerm maximum number of result returned by the synonym search'), :95-99 (every returned synonym is buffered and emitted)
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/synonym/word2vec/Word2VecSynonymFilterFactory.java:41-42 (defaults 5 and 0.8), :72-76 (maxSynonymsPerTerm must be positive)
  • lucene/analysis/common/src/test/org/apache/lucene/analysis/synonym/word2vec/TestWord2VecSynonymFilter.java:58-80 (synonymExpansion_oneCandidate_shouldBeExpandedWithTopKSynonyms: k=2, exactly 2 SYNONYM tokens)
The finding in 30 seconds
  • a Lucene main checkout lucene/analysis/common/src/java/org/apache/lucene/analysis/synonym/word2vec/Word2VecSynonymProvider.java:41, 56-68, 70-104
  • a Lucene main checkout lucene/analysis/common/src/java/org/apache/lucene/analysis/synonym/word2vec/Word2VecSynonymFilter.java:37, 58, 79-89, 95-99
  • a Lucene main checkout lucene/analysis/common/src/java/org/apache/lucene/analysis/synonym/word2vec/Word2VecSynonymFilterFactory.java:32-42
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/util/hnsw/HnswGraphSearcher.java:165-186, 304-355; NeighborQueue.java:95-96
Who is affected
  • a Lucene main checkout lucene/analysis/common/src/java/org/apache/lucene/analysis/synonym/word2vec/Word2VecSynonymProvider.java:41, 60-67 (experimental; HnswGraphBuilder.randSeed); lucene/core/src/java/org/apache/lucene/util/hnsw/HnswGraphBuilder.java:60, 65-67 (default seed 42, public mutable static)
  • a Lucene main checkout lucene/analysis/common/src/java/org/apache/lucene/analysis/synonym/word2vec/Word2VecSynonymFilterFactory.java:32-42 (experimental, SPI name, defaults)
Why it looks unintended
  • a Lucene main checkout lucene/analysis/common/src/java/org/apache/lucene/analysis/synonym/word2vec/Word2VecSynonymFilter.java:58, 95-96
  • a Lucene main checkout lucene/analysis/common/src/java/org/apache/lucene/analysis/synonym/word2vec/Word2VecSynonymProvider.java:85-86
  • a Lucene main checkout lucene/analysis/common/src/java/org/apache/lucene/analysis/synonym/word2vec/Word2VecSynonymFilterFactory.java:72-76
  • a Lucene main checkout lucene/analysis/common/src/test/org/apache/lucene/analysis/synonym/word2vec/TestWord2VecSynonymFilter.java:57-81
Suggested fix
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/util/hnsw/HnswGraphSearcher.java:165-186, 348-350 (acceptOrds: explored but not collected)
History
  • a Lucene main checkout lucene/CHANGES.txt:3564, 3593

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 examples, made in parallel with this doc from the code at a9779ef3c193 and the executed repro output. The PNG is its summary frame.

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.