All findings · Lucene finding · LFV-259 · perfield-wrapper-ignores-k3

PerFieldSimilarityWrapper does not forward computeQueryTermWeight, so a BM25 or StableTfl k3 setting has no effect behind a per-field similarity

On unreleased main, BM25's opt-in k3 makes a word repeated in a query count less than linearly, but behind a PerFieldSimilarityWrapper, the documented per-field route, it is silently ignored: apple apple gets boost 2.0 instead of 1.78, so one document scores 0.2615 instead of 0.2325.

Summary

Priority
Low, rank 726In the unreleased opt-in k3 setting, PerFieldSimilarityWrapper silently drops k3, the same feature family as LFV-103.
Impact
silent wrong results
Since
#15818 (main, 2026-03-30)introduced 2026-03-30
Affected
main and branch_11x (unreleased 11.0.0); no release
Status
documented
Found by
non-formal: code reading of the k3 change, then a differential JUnit

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

BooleanQuery turns a repeated clause into one boosted clause and asks the searcher's similarity for the boost. With k3, BM25Similarity answers 1.78 for a term repeated twice. A PerFieldSimilarityWrapper never passes the question on to the field's similarity, because the hook has no field parameter, so it answers 2.0.

Background

  1. k3 (new, unreleased). new BM25Similarity(k1, b, discountOverlaps, k3) with k3 ≥ 0 weighs a term that appears qtf times in a query ((k3 + 1) × qtf) / (k3 + qtf) instead of qtf: with k3 = 7, two repeats weigh 1.78 instead of 2. It is off by default (k3 = -1). StableTflSimilarity has the same parameter.
  2. Where k3 is applied. BooleanQuery.rewrite merges equal unboosted SHOULD or MUST clauses into one clause and gets its boost from searcher.getSimilarity().computeQueryTermWeight(qtf). That hook has no field parameter; its default returns qtf.
  3. Per-field similarities. PerFieldSimilarityWrapper is how Lucene uses a different similarity, or different parameter values, per field: a subclass implements get(field). Search servers built on Lucene install such a subclass as the searcher's similarity.
Diagram of the bug: the query body:apple OR body:apple is rewritten by BooleanQuery into one clause whose boost comes from the searcher's similarity. With BM25Similarity(k3 = 7) set directly, computeQueryTermWeight(2) returns 16/9 = 1.78 and the one document scores 0.2325. With a PerFieldSimilarityWrapper whose get(field) returns that same BM25, the wrapper does not forward computeQueryTermWeight, its inherited default returns 2.0, and the document scores 0.2615.
The minimal repro: the same BM25 (k3 = 7) set directly gives the merged clause boost 1.78 and the score 0.2325; behind the per-field wrapper the boost question never reaches BM25, the default answers 2.0, and the score is 0.2615. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Create new BM25Similarity(1.2f, 0.75f, true, 7f) (k3 = 7) and install it through a PerFieldSimilarityWrapper whose get(field) returns it for every field. Index one document body: apple and search apple OR apple (two equal SHOULD clauses).
  2. You expect the score of the bare similarity: with k3 = 7 a term repeated twice weighs (7 + 1) × 2 / (7 + 2) = 16/9 = 1.78, and the document scores 0.2325.
  3. Instead it scores 0.2615: the merged clause got the linear boost 2.0, exactly as with k3 off. No error, with assertions on or off.
  4. Why: BooleanQuery merges the duplicates and asks the searcher's similarity, here the wrapper, for the weight via computeQueryTermWeight(2). That hook has no field parameter, and the wrapper forwards only computeNorm and scorer to get(field), so it answers with the linear default.
  5. Rankings change too: in a five-document index, apple repeated 5 times plus banana puts an apple document on top instead of the banana one. Which documents match never changes.

Who is affected

Applications on main or branch_11x (unreleased 11.0.0) that opt into k3 on BM25Similarity or StableTflSimilarity and install it through a PerFieldSimilarityWrapper. Queries that repeat an unboosted term on one field, such as QueryParser text w w w, are scored as if k3 were off. A bare similarity on the searcher is not affected.

Mechanism, terms and numbers495 words

#15818 (unreleased, 11.0.0) added query-side term frequency saturation: BM25Similarity built with k3 >= 0 weighs a term repeated qtf times in a query ((k3 + 1) * qtf) / (k3 + qtf) instead of qtf; #16243 gave StableTflSimilarity the same k3. The weighting is applied in BooleanQuery.rewrite, which merges equal unboosted clauses and takes the merged clause's boost from indexSearcher.getSimilarity().computeQueryTermWeight(qtf).

That hook has no field parameter and defaults to linear. PerFieldSimilarityWrapper forwards only computeNorm and scorer to get(field) and inherits the default. So behind a wrapper, even one that returns the same k3 similarity for every field, k3 does nothing: scores and ranking are exactly those of k3 disabled, with no error or warning. The matching set is unchanged. No release has the bug; main and branch_11x do.

The code (main 645d4fe14a20)

  1. Duplicates are merged. BooleanQuery.java:438-447 deduplicates SHOULD clauses when minimumNumberShouldMatch <= 1, :449-455 MUST clauses; both call deduplicateClauses (:714-758).
  2. The searcher's similarity is asked. :734 Similarity similarity = indexSearcher.getSimilarity();. When no occurrence has an explicit boost, the merged clause's boost is similarity.computeQueryTermWeight(Math.toIntExact(Math.round(boostSum))) (:743-746); with explicit boosts it stays the linear sum, by design. This is the hook's only production caller.
  3. The hook. Similarity.java:163-177: computeQueryTermWeight(int queryTermFrequency), no field parameter, @lucene.experimental, default return (float) queryTermFrequency; (:175-177), "Subclasses may override this to apply saturation (e.g. BM25's k3 parameter)".
  4. The overrides. BM25Similarity.java:130-136 and StableTflSimilarity.java:158-164 return ((k3 + 1f) * qtf) / (k3 + qtf) when k3 >= 0, else qtf. k3 is off by default (BM25Similarity.java:79-81 passes -1f; StableTflSimilarity.java:65-66 DEFAULT_K3 = -1f).
  5. The wrapper. PerFieldSimilarityWrapper.java:31-48 overrides only computeNorm (:36-39) and scorer (:41-44), both final delegations to get(field). It does not override computeQueryTermWeight, so step 2 lands on the linear default and never reaches the per-field BM25Similarity.

The numbers of the minimal repro

One document, so idf = ln(1 + 0.5 / 1.5) = 0.2877 and the tf part is 1 / (1 + 1.2) = 0.4545: base score 0.13076. Bare k3 similarity: × 16/9 = 0.23247036. Behind the wrapper: × 2 = 0.26152915, a ratio of 1.125.

The same defect elsewhere (executed by the verifiers)

  • Duplicate MUST clauses: +(f:w)^2.0 +f:x instead of +(f:w)^1.8 +f:x (k3 = 8).
  • Classic QueryParser text w w w: (f:w)^3.0 instead of (f:w)^2.4545455 (k3 = 8).
  • StableTflSimilarity with k3: (f:w)^2.0 instead of (f:w)^1.7777778.
  • 50 repeats of one term plus two other terms (k3 = 8): 10.250272 behind the wrapper versus 2.1188598 bare, a factor of 4.84; the repeated term weighs 50 instead of 9 × 50 / 58 = 7.76.
  • Ranking: documents A apple, B banana and three fillers apple filler (k3 = 7). For apple repeated n times plus banana, the bare similarity and a wrapper that forwards the hook rank B first for n = 2 to 6; the stock wrapper ranks A first for n = 5 and 6.
Exposure and likelihood248 words
  • Who. Users of main or branch_11x who build BM25Similarity or StableTflSimilarity with k3 >= 0 (off by default) and set it through a PerFieldSimilarityWrapper, the documented route for per-field similarities and parameters (Similarity.java:62-66; CHANGES.txt:16128-16130, 4.0.0-ALPHA: "implement PerFieldSimilarityWrapper with your per-field logic").
  • Trigger. A BooleanQuery with a repeated unboosted SHOULD clause (with minimumNumberShouldMatch <= 1) or MUST clause on one field that reaches the duplicate-merging step (earlier rewrite rules can replace the query first; for example a MUST clause that is also MUST_NOT becomes MatchNoDocsQuery): query-parser text that repeats a word, or programmatic queries built from verbose text, the queries k3 is meant for. Any index size; one document is enough.
  • Effect. Silent wrong results: scores, and so ranking, equal the k3-disabled ones. Nothing throws. The matching set does not change, and no score goes above the k3-disabled score, because k3 can only lower a weight. The repeated term is over-weighted by qtf / w(qtf): 1.125 for 2 repeats with k3 = 7, 6.44 for 50 repeats with k3 = 8.
  • Not claimed. A repeated clause that spans several fields (for example a duplicated DisjunctionMaxQuery) has no single correct delegate. MultiSimilarity and the test framework's AssertingSimilarity and RandomSimilarity also inherit the linear default; they are related, but what MultiSimilarity should return is unclear and the others are test-only.
  • How likely: plausible once k3 is on: the wrapper is the standard per-field route, while users who set a bare similarity never hit it.

Minimal repro

One JUnit test, one document, public API: the query apple OR apple on a searcher with the bare k3 similarity and on one with a per-field wrapper around that same instance. The two scores must be equal.

TestPerFieldWrapperK3.java (one test, public API)68 lines
/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.apache.lucene.search;

import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.TextField;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.index.Term;
import org.apache.lucene.search.similarities.BM25Similarity;
import org.apache.lucene.search.similarities.PerFieldSimilarityWrapper;
import org.apache.lucene.search.similarities.Similarity;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestPerFieldWrapperK3 extends LuceneTestCase {

  public void testWrappedBM25AppliesK3() throws Exception {
    BM25Similarity bm25 = new BM25Similarity(1.2f, 0.75f, true, 7f);
    Similarity perField =
        new PerFieldSimilarityWrapper() {
          @Override
          public Similarity get(String name) {
            return bm25;
          }
        };
    try (Directory dir = new ByteBuffersDirectory()) {
      try (IndexWriter w = new IndexWriter(dir, new IndexWriterConfig().setSimilarity(bm25))) {
        Document doc = new Document();
        doc.add(new TextField("body", "apple", Field.Store.NO));
        w.addDocument(doc);
      }
      try (DirectoryReader reader = DirectoryReader.open(dir)) {
        Query query =
            new BooleanQuery.Builder()
                .add(new TermQuery(new Term("body", "apple")), BooleanClause.Occur.SHOULD)
                .add(new TermQuery(new Term("body", "apple")), BooleanClause.Occur.SHOULD)
                .build();
        IndexSearcher direct = new IndexSearcher(reader);
        direct.setSimilarity(bm25);
        IndexSearcher wrapped = new IndexSearcher(reader);
        wrapped.setSimilarity(perField);
        // expected the bare BM25(k3=7) score (boost 1.78 for "apple apple"); actual: linear boost 2
        assertEquals(
            direct.search(query, 1).scoreDocs[0].score,
            wrapped.search(query, 1).scoreDocs[0].score,
            0f);
      }
    }
  }
}
Command
cd $LUCENE && ./gradlew :lucene:core:test --tests TestPerFieldWrapperK3 -Ptests.seed=DEADBEEF -Ptests.asserts=true
Expected
Both searchers score the document 0.23247036 (duplicate boost 16/9 = 1.78).
Observed
Behind the wrapper it scores 0.26152915 (linear boost 2.0): AssertionError: expected:<0.23247036> but was:<0.26152915>, assertions on and off.

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

More files, environment, full output194 words

ByteBuffersDirectory, plain IndexSearchers, every similarity set explicitly. The test itself draws nothing random; the test framework still picks a random default codec, locale and time zone, and seeds DEADBEEF and 5EED1234 give identical numbers. The class sits in org.apache.lucene.search only to be next to Lucene's own tests.

The verifiers' longer tests show the same defect for MUST clauses, StableTflSimilarity and QueryParser input; they are not part of this repro.

Regression test: this same test; it passes with fix A (and with fix C).

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT): fails; with fix A or C: passes. 10.6.0 and earlier: no k3 API (the test does not compile)
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64 (the only platform run; the code path is pure Java)
Command (environment)
# $REPRO = the directory holding TestPerFieldWrapperK3.java (this bug's repro-minimal/src/org/apache/lucene/search/)
cp $REPRO/TestPerFieldWrapperK3.java $LUCENE/lucene/core/src/test/org/apache/lucene/search/
cd $LUCENE && ./gradlew :lucene:core:test --tests TestPerFieldWrapperK3 -Ptests.seed=DEADBEEF -Ptests.asserts=true
# assertions off fails the same way: -Ptests.asserts=false

Observed output

# gradle, stock 645d4fe14a20, -Ptests.seed=DEADBEEF -Ptests.asserts=true (stack trimmed; -Ptests.asserts=false identical)
> Task :lucene:core:test FAILED

TestPerFieldWrapperK3 > testWrappedBM25AppliesK3 FAILED
    java.lang.AssertionError: expected:<0.23247036> but was:<0.26152915>
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:E003A562A2B2A7D3]:0)
        at org.junit.Assert.assertEquals(Assert.java:701)
        at org.apache.lucene.search.TestPerFieldWrapperK3.testWrappedBM25AppliesK3(TestPerFieldWrapperK3.java:61)

1 test completed, 1 failed

Why it looks unintended

  • The k3 javadoc unconditionally promises the saturated weight for repeated query terms; the wrapper is documented for "field-specific parameter values".
  • Lucene fixed the same trap as a bug before: LUCENE-7395 (6.2) made the wrapper forward the field-less queryNorm and coord.
  • No discussion in #15818 or #16243 and no test chose this; IDFValueSource already unwraps the wrapper per field.
The full argument261 words
  • The k3 contract. BM25Similarity.java:44-46: "When duplicate terms appear in a query, their boost is computed as ((k3 + 1) * qtf) / (k3 + qtf) instead of linear summing." StableTflSimilarity.java:103-105 says the same. No javadoc, CHANGES or MIGRATE text says k3 needs a bare similarity on the searcher.
  • The wrapper's contract. PerFieldSimilarityWrapper.java:24-27: subclasses implement get(String) "to return an appropriate Similarity (for example, using field-specific parameter values) for the field". A wrapper whose get() returns S for every field should behave like S, the argument of LUCENE-4559 (#5625).
  • Precedent. In 6.x the wrapper did not forward the field-less queryNorm and coord; LUCENE-7395 / SOLR-9315 fixed it under 6.2.0 Bug Fixes (CHANGES.txt:9006-9008, "Fix PerFieldSimilarityWrapper to also delegate query norm and coordination factor using a default similarity added as ctor param", Uwe Schindler and Sascha Markus); jpountz called the old default "trappy" on #8448. Those hooks were removed in 7.0; computeQueryTermWeight is the first field-less hook added to Similarity since.
  • In-tree convention. IDFValueSource.asTFIDF (lucene/queries/.../IDFValueSource.java:62-72) loops while (sim instanceof PerFieldSimilarityWrapper pfsw) sim = pfsw.get(field); to reach the field's similarity.
  • No decision, no test. The #15818 thread (9 comments, 13 inline review comments) and #16243 never mention wrappers or per-field configuration. The k3 tests use a bare similarity (TestBooleanRewrites.java:1137-1171). testDefaultSimilarityPreservesLinearDedup (:1192) runs through the test framework's wrapper chain but passes either way, because no RandomSimilarity member enables k3.
  • Context. On #14433 (2025), rmuir wrote in review that a field-less Similarity method is "impossible for someone to implement correctly if they customize just one field" (a different, unmerged method).
The devil's-advocate check160 words

The strongest case: the hook is documented as field-less with a linear default and "Subclasses may override this"; PerFieldSimilarityWrapper only delegates the methods that carry a field; the hook is not final, so a wrapper subclass can forward it itself (that workaround works in the verifiers' controls); and both APIs are @lucene.experimental on unreleased code. A maintainer could answer "document that wrappers must override it".

Why it fails: the k3 promise has no condition and nothing documents the override requirement; the project treated the same non-forwarding of field-less hooks as a bug in LUCENE-7395; a field-less override cannot express the per-field parameters the wrapper exists for, so it only works when every field shares one k3; and no discussion or test shows the gap was considered. The evidence supports raising it as a pre-release bug, but it cannot show what the maintainers intend: they may still decide that a javadoc note or a wrapper override is the intended resolution.

Suggested fix

Recommended A field-aware hook that the wrapper forwards

Give the hook a field: add Similarity#computeQueryTermWeight(String field, int qtf) (default: the field-less method), forward it in PerFieldSimilarityWrapper to get(field), and let BooleanQuery pass the field when a clause it weighs targets one field. The repro passes, and so do core's search tests (2,339) and lint.

// PerFieldSimilarityWrapper
@Override
public final float computeQueryTermWeight(String field, int queryTermFrequency) {
  return get(field).computeQueryTermWeight(field, queryTermFrequency);
}

// BooleanQuery.deduplicateClauses: one field -> the field-aware method
return fields.size() == 1
    ? similarity.computeQueryTermWeight(fields.iterator().next(), queryTermFrequency)
    : similarity.computeQueryTermWeight(queryTermFrequency);
All options and trade-offs4 options · 818 words

Options A and C were run through gradle on 645d4fe14a20. A keeps per-field k3 values and is preferred; C needs no new API but special-cases one similarity class in BooleanQuery; B follows the 6.2 precedent but allows one k3 only; D only documents the trap. The hook is @lucene.experimental and unreleased, so maintainers may also prefer another API shape.

A field-aware hook that the wrapper forwards (validated by the checks) (recommended)

Similarity gets computeQueryTermWeight(String field, int queryTermFrequency), which by default returns the field-less method, so BM25Similarity and StableTflSimilarity need no change. PerFieldSimilarityWrapper overrides it as a final delegation to get(field), like computeNorm and scorer. BooleanQuery.deduplicateClauses, wherever it calls the hook today, collects that clause's fields with a QueryVisitor and calls the field-aware method when there is exactly one (3 files, +44/-1). As on stock main, once an occur type has any duplicate, every distinct unboosted clause of that type gets its weight from the hook (singletons with qtf = 1 included), so this covers all of them, not only the merged ones.

// PerFieldSimilarityWrapper
@Override
public final float computeQueryTermWeight(String field, int queryTermFrequency) {
  return get(field).computeQueryTermWeight(field, queryTermFrequency);
}

// BooleanQuery.deduplicateClauses: one field -> the field-aware method
return fields.size() == 1
    ? similarity.computeQueryTermWeight(fields.iterator().next(), queryTermFrequency)
    : similarity.computeQueryTermWeight(queryTermFrequency);

Trade-offs

  • Per-field k3. Different fields can use different k3 values, which is what the wrapper is for.
  • API. One new @lucene.experimental method. Since the hook is unreleased, a variant replaces the field-less signature instead of adding an overload; the multi-field case then needs a defined argument. That variant was not run.
  • Multi-field clauses. A merged clause over several fields keeps the field-less path, as today: linear behind the stock wrapper, or whatever a wrapper subclass's own override of computeQueryTermWeight(int) returns.
  • Field detection. The field comes from QueryVisitor: TermQuery reports it, but a query that reports no field (such as MatchAllDocsQuery) or a custom query with incomplete visitor information falls back to the field-less path.
  • Existing overrides. A wrapper subclass that already overrides the field-less hook (the workaround below) is bypassed for single-field clauses, because the new wrapper method is final and goes to get(field); with the usual workaround body the result is the same.
  • Cost. Once an occur type has duplicates, one QueryVisitor walk per distinct unboosted clause of that type, only on the dedup path; no benchmark was run.

What the checks validated

Applied to a scratch worktree of 645d4fe14a20 and run through gradle with OpenJDK 25.0.3, seed DEADBEEF (2026-10-31):

  • stock: the minimal test fails with assertions on and off;
  • with the fix: the minimal test passes with assertions on and off;
  • TestBooleanRewrites, TestBM25Similarity, TestStableTflSimilarity, TestSimilarityProvider, TestSimilarity2 and TestBooleanQuery pass (6 classes, 89 tests, 0 failures);
  • every test class of core's org.apache.lucene.search packages passes (214 classes, 2,339 tests, 0 failures, 49 skipped); the copied regression test was removed before this run and the closest-suites run, so it is not in these counts (it passed on its own above);
  • checkGoogleJavaFormat, ecjLintMain, forbiddenApisMain and renderJavadoc for core pass;
  • Earlier, the finder's equivalent sketch passed the finder's test, TestBooleanRewrites + TestBM25Similarity (37 tests) and the security probe (wrapped scores equal the bare k3 scores, match sets unchanged).

Resolve the wrapper in BooleanQuery, no new API (validated by the checks)

For each clause whose weight comes from the hook and that targets a single field (found with a QueryVisitor, as in A), BooleanQuery does what IDFValueSource.asTFIDF does, while (similarity instanceof PerFieldSimilarityWrapper perField) similarity = perField.get(field);, before calling computeQueryTermWeight(qtf) (BooleanQuery.java only, +25/-1).

Trade-offs

No public API change and the smallest diff, but BooleanQuery special-cases one Similarity subclass, and a custom delegating similarity that does not extend PerFieldSimilarityWrapper is not resolved. It also unwraps past a wrapper subclass's own override of the field-less hook, so the workaround below is bypassed for single-field clauses (same result with the usual workaround body), and the QueryVisitor fallback of A applies.

What the checks validated

Applied to the same scratch worktree (BooleanQuery.java only, +25/-1), gradle, seed DEADBEEF (2026-10-31):

  • the minimal test passes with assertions on and off;
  • the same 6 closest suites pass (89 tests, 0 failures);
  • checkGoogleJavaFormat, ecjLintMain and forbiddenApisMain for core pass. The wider package run was done for option A only.

A default similarity, as in LUCENE-7395 (not run)

Give PerFieldSimilarityWrapper a default similarity (constructor argument) and forward the field-less computeQueryTermWeight(int) to it, as 6.2 did for queryNorm and coord.

Trade-offs

Simple and precedented, but one k3 for all fields, and every subclass must supply the default or keep today's behaviour.

Documentation only, plus the workaround (not a fix)

A javadoc note that wrappers must forward the hook. Until a fix lands, users can override it in their wrapper subclass: public float computeQueryTermWeight(int qtf) { return bm25.computeQueryTermWeight(qtf); } (the verifiers' forwarding controls pass).

Trade-offs

Works only when every field shares one k3, and leaves the trap for the next user.

Downstream note

Elasticsearch, OpenSearch and Solr each install a PerFieldSimilarityWrapper subclass as the searcher's similarity, so a k3 setting they expose on Lucene 11 would be inert unless the wrapper forwards the hook (read from their source, not tested).

How it was found

While modelling how BooleanQuery boosts duplicate clauses, the finder read the #15818 change and saw that the new hook has no field and that PerFieldSimilarityWrapper does not forward it. A differential JUnit, wrapped versus bare similarity on the stock jars, confirmed it.

The method in full109 words

Method: non-formal (code reading plus a differential test). The hunt unit covered the similarities, with the #15818 change as its main target.

  • Reading. The duplicate-clause boost path: deduplicateClauses, its only call of computeQueryTermWeight, and every Similarity subclass that overrides or inherits it.
  • Differential. The same index and query on a searcher with the bare BM25Similarity(k3 = 7) and on one with a PerFieldSimilarityWrapper returning that instance: rewrites ^1.7777778 versus ^2.0, scores 0.5601189 versus 0.63013375 (two documents), failing with assertions on and off, and 10 of 10 iterations with assertions on.
  • Minimal repro. The test above is that test cut down to one document and one assertion.

Prior reports, history and checks

Prior reports

No prior report found in the documented searches (2026-10-26, re-checked 2026-10-31): no issue, PR, commit or list post describes it. Precedent for the same kind of gap on the removed queryNorm/coord hooks: LUCENE-7395 (#8448, fixed in 6.2) and LUCENE-4559 (#5625, open).

Searches3 searches
  • #15818 (the origin): the design discussion covered qtf versus maxQtf and the -1 sentinel; nobody mentioned wrappers or per-field configuration. #15768 (the open feature request), #16243 / #16322 (StableTfl; "k3 is not supported on 10x"), #15893 (moved the call into the shared dedup helper).
  • Precedent, not duplicates: #8448 / LUCENE-7395 and #5625 / LUCENE-4559 (same defect class for queryNorm/coord); rmuir's review comment on #14433 (context).
  • Same feature (the hunt's own finding): LFV-103, k3 skipped when minimumNumberShouldMatch >= 2; same introducing commit, different fix site.
  • Limits: GitHub search does not index inline review comments (the relevant threads were read directly); private channels cannot be searched; the feature has existed only since 2026-03-30, on unreleased code.
What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene incl. the migrated JIRA archive; Elasticsearch, OpenSearch, Solr, Lucene.NET; all of GitHub)2026-10-2641 logged read-only queries (32 issue and PR, 5 commit, 4 downstream) plus 34 manual GraphQL searches (PerFieldSimilarityWrapper k3, computeQueryTermWeight, k3 per-field, query-term frequency saturation, ...); changed files of all 413 open PRs and of 827 PRs merged or closed since 2026-03-01; all 191 issues created since 2026-03-01; full reads of 12 threadsonly #15818 and its follow-ups discuss this code; none reports the bug
Mailing lists (dev@, java-user@, issues@, commits@), LUCENE and SOLR JIRA, web2026-10-2632 list queries, 8 JQL queries plus direct SOLR/LUCENE project queries, 8 web searchesno report; only notification mails of #15818, #15768, #16243, #16322 and #15893
GitHub issues and PRs (apache/lucene)2026-10-31computeQueryTermWeight (all time); PerFieldSimilarityWrapper, k3 saturation, BM25 k3, StableTflSimilarity, query term frequency saturation (updated since 2026-10-20); open PRs mentioning PerFieldSimilarityWrappernothing new (only #15818)

History

Introduced by #15818 (05a788f0a4eb, merged 2026-03-30), which added the field-less hook but did not touch the wrapper; StableTfl's k3 (#16243, 2026-07-01) has the same gap. On main and branch_11x; no release has it (10.6.0 and earlier lack the k3 API).

Versions tested6 rows

Introduced by 05a788f0a4eb7ca44ca881689b2a10c58e22585a, #15818 "Add BM25 k3 query-term frequency saturation" (sgup432; reviewed by rmuir and msfroh; merged by msfroh on 2026-03-30; related open issue #15768). It added the hook with its linear default, the BM25 override and the BooleanQuery call; git log -S computeQueryTermWeight finds only this commit and e3a7a733c8a (#16243, StableTfl k3). The parent 5af15dc4be5b has no k3 API; at 05a788f0a4eb the finder's test fails with the same numbers as on main (0.5601189 vs 0.63013375; it indexes two documents, so these differ from the minimal repro's).

Now (refs fetched 2026-10-30): branch_11x, the 11.0.0 branch, was cut from main, and main became 12.0.0-SNAPSHOT; the five files involved are identical to 645d4fe14a20 on both. 10.6.0 (tagged 2026-10-29) has no computeQueryTermWeight and no k3: its StableTfl backport (#16322) left k3 out.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedminimal test fails, assertions on and off (gradle and standalone); passes with fix A or C
main ea57360912ed, a9779ef3c193 affectedthe finder's test fails, assertions on and off
05a788f0a4eb (#15818) affectedthe finder's test fails with the same numbers as on main, assertions on and off
main 2f424fd428d, branch_11x 603af870375 same codethe five files are identical to 645d4fe14a20 (read, not run)
10.6.0, branch_10_6 2763db11b0a7 no k3 APIbranch head: the repro does not compile, a version-neutral probe passes; the 10.6.0 tag has no computeQueryTermWeight (read)
10.5.2, 10.5.0, 10.4.0, 9.12.3, parent 5af15dc4be5b no k3 APIthe repro does not compile; a version-neutral probe shows wrapped and bare scoring agree

Independent checks

Reproduced by an independent reproducer (with a forwarding-wrapper control), a devil's-advocate verifier, an adjudication with a fresh seed, a credibility audit that showed a top-hit change, and this doc's gradle run. Reviews by OpenAI GPT-5.6 could only read code; one rated it real, the other agreed on the root cause.

Checks and verdicts318 words

Every executed check ran on stock jars; the cross-model reviews ran with reading-only tools, so their verdicts rest on reading.

Each independent check and its verdict
CheckVerdictWhat it found
Finder (code reading, differential JUnit) candidateWrapped versus bare similarity on stock main: fails 1/1 with assertions on and off, 10/10 iterations.
Intended lens (OpenAI GPT-5.6, reading only) real (medium)Intended argument moderate (field-less experimental hook); pointed to the LUCENE-7395 precedent. Could not run code.
Independent reproducer realOwn test, 4 methods: both k3 tests fail (BM25 0.91686136 expected, 0.99021024 actual; StableTfl 3.8780565 vs 4.187965); k3-disabled and forwarding-wrapper controls pass; 10.5.2 and 9.12.3 lack the API.
Refute lens (OpenAI GPT-5.6, reading only) not real (low), proceduralCould not run Java, so its required own repro did not run; it agreed on the root cause and that the inputs are legal. A separate run's own test for this review fails 3 of 4 (MUST, StableTfl, QueryParser w w w); its control passes.
Intended-behaviour devil's advocate realBuilt the strongest intended case and rejected it; rewrite-level probe fails 1 of 2, the override workaround passes.
Adjudication confirmed (high)Re-ran the independent reproduction's test with seed 5EED1234 (2 of 4 fail, assertions on and off) and the finder's; limited the claim to single-field clauses.
Credibility audit keepFinder's test fails with seed A0D17004; own probe: the top hit changes for apple repeated 5 or 6 times plus banana, assertions on and off.
Re-verification and minimal repro still reproducesStill fails on a9779ef3c193; the one-document test fails on 645d4fe14a20 with two seeds, assertions on and off.
This doc's fix validation (gradle) real; fixes validatedScratch worktree of 645d4fe14a20: stock fails with assertions on and off; fix A passes the test, the closest suites and core's search packages (2,339 tests); fix C passes the test and the closest suites.

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

Finding record

LFV-259, snapshot of 2026-11-06

Registry title
PerFieldSimilarityWrapper does not forward Similarity#computeQueryTermWeight, so a BM25Similarity or StableTflSimilarity k3 (query-term saturation) setting silently has no effect behind a per-field wrapper
Area
search / similarities (PerFieldSimilarityWrapper, Similarity#computeQueryTermWeight) and BooleanQuery duplicate-clause rewrite
Priority
Low, rank 726: In the unreleased opt-in k3 setting, PerFieldSimilarityWrapper silently drops k3, the same feature family as LFV-103.
Impact class
silent wrong results
Impact note
Only applications on unreleased main (11.0.0) that opt into k3 >= 0 on BM25Similarity or StableTflSimilarity and install it through a PerFieldSimilarityWrapper (the documented way to set per-field similarity parameters; Elasticsearch, OpenSearch and Solr each install a PerFieldSimilarityWrapper subclass as the searcher similarity [read]) are hit, on every BooleanQuery with a repeated unboosted SHOULD or MUST clause (for example classic QueryParser text 'w w w' or two equal TermQuery clauses), at any scale; the repeated term is weighted linearly, exactly as with k3 disabled, so k3 is a silent no-op: scores change (by a factor of 4.8 for a term repeated 50 times), never beyond the k3-disabled score, the set of matching documents does not change, and no verifier showed two documents swapping rank.
Summary
You configure BM25Similarity with its opt-in k3 parameter (new BM25Similarity(1.2f, 0.75f, true, 7f)), whose javadoc promises that a term repeated in a query is weighted ((k3 + 1) * qtf) / (k3 + qtf) instead of linearly, and you install it through a PerFieldSimilarityWrapper, the documented way to use per-field similarities, even one whose get() returns that same instance for every field. You expect the query 'apple apple' to score as it does with the bare BM25Similarity, where BooleanQuery's duplicate-clause rewrite gives (body:apple)^1.7777778. Instead the rewrite asks the wrapper, which does not forward Similarity#computeQueryTermWeight and inherits its linear default, so the query becomes (body:apple)^2.0 and the document scores 0.63013375 instead of 0.5601189; StableTflSimilarity's k3 is ignored the same way.
Introduced
05a788f0a4eb (#15818 'Add BM25 k3 query-term frequency saturation', author sgup432, merged by msfroh; related issue #15768) [read: git log -S computeQueryTermWeight finds only this commit and e3a7a733c8a; it added the field-less hook with a linear default, the BM25 k3 override and the BooleanQuery call, and did not touch PerFieldSimilarityWrapper, so the bug is an omission present from the first commit that has the hook; exec: the finder repro fails at 05a788f0a4eb with -ea and -da with the same numbers as on main, and does not compile at its parent 5af15dc4be5b (no k3 API), where a version-neutral probe passes]; StableTflSimilarity's k3 override has the same gap from its first commit e3a7a733c8a (#16243) [read: git blame; executed on main by the independent reproduction and the cross-model review] (2026-03-30 (#15818 merged; StableTflSimilarity k3 since 2026-07-01, #16243))
First affected version
unreleased (11.0.0, main only; no release tag contains 05a788f0a4eb; CHANGES.txt lists #15818 under 11.0.0)
Affected versions
main from 05a788f0a4eb (2026-03-30) through ea57360912ed for BM25Similarity with k3 >= 0, and from e3a7a733c8a (2026-07-01) for StableTflSimilarity with k3 >= 0 [exec at 05a788f0a4eb (-ea and -da) and on main (-ea and -da, 10 of 10 iterations); StableTfl executed on main]; not affected: every release through 10.5.2, including 9.12.3, 10.4.0 and 10.5.0 (no k3 API: the repro does not compile, and a version-neutral probe shows wrapped and direct scoring agree) [exec]; branch_10_6 head 2763db11b0a7 (the branch of 10.6.0, released 2026-10-29) [exec: compile error, probe passes] and branch_10x [read] have no computeQueryTermWeight, so 10.6.0 is not affected; not fixed upstream as of main ea57360912ed, the upstream main HEAD on 2026-10-26 [read]
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 (2026-10-26, read-only: a scripted prior-report search with 32 GitHub issue/PR, 5 commit, 4 downstream-repository, 32 mailing-list (dev@, java-user@, issues@, commits@), 8 JIRA-archive and 8 web queries; manual GitHub GraphQL searches including Elasticsearch, OpenSearch, Solr and Lucene.NET; changed-file lists of all 413 open PRs and of the 827 merged or closed PRs updated since 2026-03-01; all 191 issues created since 2026-03-01 grepped; full reads of #15818 (all comments, reviews and inline comments), #15768, #15893, #16243, #16322, #15929, #16667, #14433, #5625, #8448, #7648 and #4822); related prior items: #15818 (introduced the hook; no comment mentions wrappers), #15768 (open feature request), #16243 and #16322 (StableTfl; 'k3 is not supported on 10x'), #15893 (dedup refactor); precedent, the same defect class for the removed field-less hooks queryNorm and coord, not this bug: #8448 / LUCENE-7395 (fixed in 6.2 under Bug Fixes) and #5625 / LUCENE-4559 (open since 2012); context: #14433 (a 2025 review comment by rmuir that a field-less Similarity method is impossible to implement correctly when only one field is customized); no open PR touches the code
Lucene commit tested
main 645d4fe14a20 (2026-10-28): the minimal test fails with assertions on and off, through gradle in a scratch worktree and through a standalone runner (seeds DEADBEEF and 5EED1234); with fix A it passes, and so do the closest suites (89 tests) and every test class of core's org.apache.lucene.search packages (2,339 tests); fix C also passes the test and the closest suites. Upstream main 2f424fd428d (12.0.0-SNAPSHOT) and branch_11x 603af870375 carry the same five files (read, not run).

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/search/similarities/PerFieldSimilarityWrapper.java:23-48 (class javadoc :24-27; delegates only computeNorm :37 and scorer :42; computeQueryTermWeight inherited)
  • lucene/core/src/java/org/apache/lucene/search/similarities/Similarity.java:163-177 (computeQueryTermWeight(int), no field parameter, linear default :175-177, @lucene.experimental)
  • lucene/core/src/java/org/apache/lucene/search/BooleanQuery.java:438-455 (SHOULD dedup when minimumNumberShouldMatch <= 1 at :442, MUST dedup at :449-455), :714-758 (deduplicateClauses; similarity from indexSearcher.getSimilarity(), call at :746)
  • lucene/core/src/java/org/apache/lucene/search/similarities/BM25Similarity.java:44-46 (k3 @param), :131-136 (computeQueryTermWeight override)
  • lucene/core/src/java/org/apache/lucene/search/similarities/StableTflSimilarity.java:103-105 (k3 @param), :159-164 (computeQueryTermWeight override)
  • lucene/queries/src/java/org/apache/lucene/queries/function/valuesource/IDFValueSource.java:62-72 (asTFIDF unwraps PerFieldSimilarityWrapper via get(field))
  • lucene/CHANGES.txt:166 (GITHUB#15818 under 11.0.0), :8949-8951 (LUCENE-7395, 6.2.0 New Features) and :8973-8975 (LUCENE-7395 / SOLR-9315, 6.2.0 Bug Fixes: 'Fix PerFieldSimilarityWrapper to also delegate query norm and coordination factor')
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/search/BooleanQuery.java:438-455, (at 645d4fe14a20) :714-758 (:734, :740-746)
  • lucene/core/src/java/org/apache/lucene/search/similarities/Similarity.java:163-177 (at 645d4fe14a20)
  • lucene/core/src/java/org/apache/lucene/search/similarities/BM25Similarity.java:44-46, (at 645d4fe14a20) :51, :79-81, :125-136
  • lucene/core/src/java/org/apache/lucene/search/similarities/StableTflSimilarity.java:65-66, (at 645d4fe14a20) :103-105, :158-164
  • lucene/core/src/java/org/apache/lucene/search/similarities/PerFieldSimilarityWrapper.java:23-48 (at 645d4fe14a20)
Who is affected
  • lucene/core/src/java/org/apache/lucene/search/similarities/Similarity.java:62-66 (at 645d4fe14a20)
  • lucene/CHANGES.txt:16128-16130 (at 645d4fe14a20) (4.0.0-ALPHA: per-field similarities via PerFieldSimilarityWrapper)
  • lucene/core/src/java/org/apache/lucene/search/BooleanQuery.java:364-385 (at 645d4fe14a20) (MUST clause also in MUST_NOT -> MatchNoDocsQuery, before the dedup at :438-455)
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/search/similarities/BM25Similarity.java:44-46; (at 645d4fe14a20) StableTflSimilarity.java:103-105; PerFieldSimilarityWrapper.java:24-27; Similarity.java:163-177
  • lucene/CHANGES.txt:8982-8984 (at 645d4fe14a20) (6.2.0 New Features) and :9006-9008 (6.2.0 Bug Fixes: LUCENE-7395, SOLR-9315)
  • lucene/queries/src/java/org/apache/lucene/queries/function/valuesource/IDFValueSource.java:62-72 (at 645d4fe14a20)
  • lucene/core/src/test/org/apache/lucene/search/TestBooleanRewrites.java:1137-1171, (at 645d4fe14a20) :1192
History
  • a Lucene clone refs fetched 2026-10-30: origin/main 2f424fd428d (Version.LATEST = LUCENE_12_0_0), origin/branch_11x 603af870375 (LUCENE_11_0_0); git diff 645d4fe14a20 against both for BooleanQuery.java, Similarity.java, PerFieldSimilarityWrapper.java, BM25Similarity.java, StableTflSimilarity.java: empty; tag releases/lucene/10.6.0 (2026-10-29): Similarity.java has no computeQueryTermWeight, BM25Similarity.java and StableTflSimilarity.java contain no k3

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 code at 645d4fe14a20 and the minimal repro, with its own probe of the numbers.

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.