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.
- 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).StableTflSimilarityhas the same parameter. - Where k3 is applied.
BooleanQuery.rewritemerges equal unboosted SHOULD or MUST clauses into one clause and gets its boost fromsearcher.getSimilarity().computeQueryTermWeight(qtf). That hook has no field parameter; its default returns qtf. - Per-field similarities.
PerFieldSimilarityWrapperis how Lucene uses a different similarity, or different parameter values, per field: a subclass implementsget(field). Search servers built on Lucene install such a subclass as the searcher's similarity.
- Create
new BM25Similarity(1.2f, 0.75f, true, 7f)(k3 = 7) and install it through aPerFieldSimilarityWrapperwhoseget(field)returns it for every field. Index one documentbody: appleand searchappleORapple(two equal SHOULD clauses). - 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.
- 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.
- Why:
BooleanQuerymerges the duplicates and asks the searcher's similarity, here the wrapper, for the weight viacomputeQueryTermWeight(2). That hook has no field parameter, and the wrapper forwards onlycomputeNormandscorertoget(field), so it answers with the linear default. - Rankings change too: in a five-document index,
applerepeated 5 times plusbananaputs an apple document on top instead of the banana one. Which documents match never changes.
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.
- Duplicates are merged.
BooleanQuery.java:438-447deduplicates SHOULD clauses whenminimumNumberShouldMatch <= 1,:449-455MUST clauses; both calldeduplicateClauses(:714-758). - The searcher's similarity is asked.
:734Similarity similarity = indexSearcher.getSimilarity();. When no occurrence has an explicit boost, the merged clause's boost issimilarity.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. - The hook.
Similarity.java:163-177:computeQueryTermWeight(int queryTermFrequency), no field parameter,@lucene.experimental, defaultreturn (float) queryTermFrequency;(:175-177), "Subclasses may override this to apply saturation (e.g. BM25's k3 parameter)". - The overrides.
BM25Similarity.java:130-136andStableTflSimilarity.java:158-164return((k3 + 1f) * qtf) / (k3 + qtf)whenk3 >= 0, elseqtf. k3 is off by default (BM25Similarity.java:79-81passes-1f;StableTflSimilarity.java:65-66DEFAULT_K3 = -1f). - The wrapper.
PerFieldSimilarityWrapper.java:31-48overrides onlycomputeNorm(:36-39) andscorer(:41-44), bothfinaldelegations toget(field). It does not overridecomputeQueryTermWeight, so step 2 lands on the linear default and never reaches the per-fieldBM25Similarity.
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.
- Duplicate MUST clauses:
+(f:w)^2.0 +f:xinstead of+(f:w)^1.8 +f:x(k3 = 8). - Classic QueryParser text
w w w:(f:w)^3.0instead of(f:w)^2.4545455(k3 = 8). StableTflSimilaritywith k3:(f:w)^2.0instead 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, Bbananaand three fillersapple filler(k3 = 7). Forapplerepeated n times plusbanana, 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_11xwho buildBM25SimilarityorStableTflSimilaritywithk3 >= 0(off by default) and set it through aPerFieldSimilarityWrapper, 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
BooleanQuerywith a repeated unboosted SHOULD clause (withminimumNumberShouldMatch <= 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 becomesMatchNoDocsQuery): 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.MultiSimilarityand the test framework'sAssertingSimilarityandRandomSimilarityalso inherit the linear default; they are related, but whatMultiSimilarityshould 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.
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.
/*
* 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);
}
}
}
}
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.
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).
| lucene | main 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) |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux, aarch64 (the only platform run; the code path is pure Java) |
# $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
# 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- 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
queryNormandcoord. - No discussion in #15818 or #16243 and no test chose this;
IDFValueSourcealready 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-105says 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 implementget(String)"to return an appropriate Similarity (for example, using field-specific parameter values) for the field". A wrapper whoseget()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
queryNormandcoord; 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;computeQueryTermWeightis the first field-less hook added toSimilaritysince. - In-tree convention.
IDFValueSource.asTFIDF(lucene/queries/.../IDFValueSource.java:62-72) loopswhile (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 noRandomSimilaritymember enables k3. - Context. On #14433 (2025), rmuir wrote in review that a field-less
Similaritymethod 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.
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.
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.experimentalmethod. 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:TermQueryreports it, but a query that reports no field (such asMatchAllDocsQuery) 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
finaland goes toget(field); with the usual workaround body the result is the same. - Cost. Once an occur type has duplicates, one
QueryVisitorwalk 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,TestSimilarity2andTestBooleanQuerypass (6 classes, 89 tests, 0 failures);- every test class of core's
org.apache.lucene.searchpackages 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,forbiddenApisMainandrenderJavadocfor 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).
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,ecjLintMainandforbiddenApisMainfor core pass. The wider package run was done for option A only.
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.
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.
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).
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 ofcomputeQueryTermWeight, and everySimilaritysubclass that overrides or inherits it. - Differential. The same index and query on a searcher with the bare
BM25Similarity(k3 = 7)and on one with aPerFieldSimilarityWrapperreturning that instance: rewrites^1.7777778versus^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.
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
-1sentinel; 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.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene incl. the migrated JIRA archive; Elasticsearch, OpenSearch, Solr, Lucene.NET; all of GitHub) | 2026-10-26 | 41 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 threads | only #15818 and its follow-ups discuss this code; none reports the bug |
| Mailing lists (dev@, java-user@, issues@, commits@), LUCENE and SOLR JIRA, web | 2026-10-26 | 32 list queries, 8 JQL queries plus direct SOLR/LUCENE project queries, 8 web searches | no report; only notification mails of #15818, #15768, #16243, #16322 and #15893 |
| GitHub issues and PRs (apache/lucene) | 2026-10-31 | computeQueryTermWeight (all time); PerFieldSimilarityWrapper, k3 saturation, BM25 k3, StableTflSimilarity, query term frequency saturation (updated since 2026-10-20); open PRs mentioning PerFieldSimilarityWrapper | nothing new (only #15818) |
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.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | minimal test fails, assertions on and off (gradle and standalone); passes with fix A or C |
main ea57360912ed, a9779ef3c193 | affected | the finder's test fails, assertions on and off |
05a788f0a4eb (#15818) | affected | the finder's test fails with the same numbers as on main, assertions on and off |
main 2f424fd428d, branch_11x 603af870375 | same code | the five files are identical to 645d4fe14a20 (read, not run) |
10.6.0, branch_10_6 2763db11b0a7 | no k3 API | branch 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 API | the repro does not compile; a version-neutral probe shows wrapped and bare scoring agree |
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.
| Check | Verdict | What it found |
|---|---|---|
| Finder (code reading, differential JUnit) | candidate | Wrapped 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 | real | Own 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), procedural | Could 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 | real | Built 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 | keep | Finder'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 reproduces | Still 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 validated | Scratch 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. |
The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.
- PNG 390 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,193 words
- 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'sorg.apache.lucene.searchpackages (2,339 tests); fix C also passes the test and the closest suites. Upstream main2f424fd428d(12.0.0-SNAPSHOT) andbranch_11x603af870375carry the same five files (read, not run).
- 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-136lucene/core/src/java/org/apache/lucene/search/similarities/StableTflSimilarity.java:65-66, (at 645d4fe14a20) :103-105, :158-164lucene/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-177lucene/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
- TestPerFieldWrapperK3.java (one test, public API):
repro/LFV-259/src/org/apache/lucene/search/TestPerFieldWrapperK3.java(68 lines)
Made in parallel with this doc from the code at 645d4fe14a20 and the minimal repro, with its own probe of the numbers.
assets/LFV-259/bug.png(390 KB)
