Skip to content

Commit 05a788f

Browse files
authored
Add BM25 k3 query-term frequency saturation (#15818)
1 parent 5af15dc commit 05a788f

6 files changed

Lines changed: 225 additions & 20 deletions

File tree

lucene/CHANGES.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,8 @@ New Features
8585

8686
* GITHUB#15508: Use native vectorization in Lucene. (Ankur Goel, Shubham Chaudhary, Dawid Weiss)
8787

88+
* GITHUB#15818: Add BM25 k3 query-term frequency saturation to BM25Similarity. (Sagar Upadhyaya)
89+
8890
Improvements
8991
---------------------
9092

lucene/core/src/java/org/apache/lucene/search/BooleanQuery.java

Lines changed: 44 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
import java.util.function.Predicate;
3232
import org.apache.lucene.index.TermStates;
3333
import org.apache.lucene.search.BooleanClause.Occur;
34+
import org.apache.lucene.search.similarities.Similarity;
3435

3536
/**
3637
* A Query that matches documents matching boolean combinations of other queries, e.g. {@link
@@ -424,24 +425,41 @@ public Query rewrite(IndexSearcher indexSearcher) throws IOException {
424425
}
425426
}
426427

427-
// Deduplicate SHOULD clauses by summing up their boosts
428+
// Deduplicate SHOULD clauses. Tracks total boost per query and which clauses have
429+
// explicit boosts. Uses Similarity.computeQueryTermWeight for unweighted duplicates
430+
// (e.g. "a a a"), falls back to linear boost sum when explicit boosts are present
431+
// (e.g. "a^2 a^3").
428432
if (clauseSets.get(Occur.SHOULD).size() > 0 && minimumNumberShouldMatch <= 1) {
429-
Map<Query, Double> shouldClauses = new HashMap<>();
433+
Map<Query, Double> shouldBoosts = new HashMap<>();
434+
Set<Query> boostedClauses = new HashSet<>();
430435
for (Query query : clauseSets.get(Occur.SHOULD)) {
431436
double boost = 1;
437+
boolean hasBoosted = false;
432438
while (query instanceof BoostQuery) {
433439
BoostQuery bq = (BoostQuery) query;
434440
boost *= bq.getBoost();
435441
query = bq.getQuery();
442+
hasBoosted = true;
443+
}
444+
shouldBoosts.merge(query, boost, Double::sum);
445+
if (hasBoosted) {
446+
boostedClauses.add(query);
436447
}
437-
shouldClauses.put(query, shouldClauses.getOrDefault(query, 0d) + boost);
438448
}
439-
if (shouldClauses.size() != clauseSets.get(Occur.SHOULD).size()) {
449+
if (shouldBoosts.size() != clauseSets.get(Occur.SHOULD).size()) {
450+
Similarity similarity = indexSearcher.getSimilarity();
440451
BooleanQuery.Builder builder =
441452
new BooleanQuery.Builder().setMinimumNumberShouldMatch(minimumNumberShouldMatch);
442-
for (Map.Entry<Query, Double> entry : shouldClauses.entrySet()) {
453+
for (Map.Entry<Query, Double> entry : shouldBoosts.entrySet()) {
443454
Query query = entry.getKey();
444-
float boost = entry.getValue().floatValue();
455+
double boostSum = entry.getValue();
456+
// Only apply similarity-based query term weighting when all occurrences have the
457+
// default boost of 1.0 (the common case from query parsers). When explicit boosts
458+
// are present (e.g. programmatic BoostQuery), preserve the original linear sum.
459+
float boost =
460+
boostedClauses.contains(query)
461+
? (float) boostSum
462+
: similarity.computeQueryTermWeight(Math.toIntExact(Math.round(boostSum)));
445463
if (boost != 1f) {
446464
query = new BoostQuery(query, boost);
447465
}
@@ -456,24 +474,38 @@ public Query rewrite(IndexSearcher indexSearcher) throws IOException {
456474
}
457475
}
458476

459-
// Deduplicate MUST clauses by summing up their boosts
477+
// Deduplicate MUST clauses — same approach as SHOULD above.
460478
if (clauseSets.get(Occur.MUST).size() > 0) {
461-
Map<Query, Double> mustClauses = new HashMap<>();
479+
Map<Query, Double> mustBoosts = new HashMap<>();
480+
Set<Query> boostedClauses = new HashSet<>();
462481
for (Query query : clauseSets.get(Occur.MUST)) {
463482
double boost = 1;
483+
boolean hasBoosted = false;
464484
while (query instanceof BoostQuery) {
465485
BoostQuery bq = (BoostQuery) query;
466486
boost *= bq.getBoost();
467487
query = bq.getQuery();
488+
hasBoosted = true;
489+
}
490+
mustBoosts.merge(query, boost, Double::sum);
491+
if (hasBoosted) {
492+
boostedClauses.add(query);
468493
}
469-
mustClauses.put(query, mustClauses.getOrDefault(query, 0d) + boost);
470494
}
471-
if (mustClauses.size() != clauseSets.get(Occur.MUST).size()) {
495+
if (mustBoosts.size() != clauseSets.get(Occur.MUST).size()) {
496+
Similarity similarity = indexSearcher.getSimilarity();
472497
BooleanQuery.Builder builder =
473498
new BooleanQuery.Builder().setMinimumNumberShouldMatch(minimumNumberShouldMatch);
474-
for (Map.Entry<Query, Double> entry : mustClauses.entrySet()) {
499+
for (Map.Entry<Query, Double> entry : mustBoosts.entrySet()) {
475500
Query query = entry.getKey();
476-
float boost = entry.getValue().floatValue();
501+
// Only apply similarity-based query term weighting when all occurrences have the
502+
// default boost of 1.0 (the common case from query parsers). When explicit boosts
503+
// are present (e.g. programmatic BoostQuery), preserve the original linear sum.
504+
double boostSum = entry.getValue();
505+
float boost =
506+
boostedClauses.contains(query)
507+
? (float) boostSum
508+
: similarity.computeQueryTermWeight(Math.toIntExact(Math.round(boostSum)));
477509
if (boost != 1f) {
478510
query = new BoostQuery(query, boost);
479511
}

lucene/core/src/java/org/apache/lucene/search/similarities/BM25Similarity.java

Lines changed: 52 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
public class BM25Similarity extends Similarity {
3333
private final float k1;
3434
private final float b;
35+
private final float k3;
3536

3637
/**
3738
* BM25 with the supplied parameter values.
@@ -40,10 +41,14 @@ public class BM25Similarity extends Similarity {
4041
* @param b Controls to what degree document length normalizes tf values.
4142
* @param discountOverlaps True if overlap tokens (tokens with a position of increment of zero)
4243
* are discounted from the document's length.
43-
* @throws IllegalArgumentException if {@code k1} is infinite or negative, or if {@code b} is not
44-
* within the range {@code [0..1]}
44+
* @param k3 Controls query-side term frequency saturation. When duplicate terms appear in a
45+
* query, their boost is computed as ((k3 + 1) * qtf) / (k3 + qtf) instead of linear summing.
46+
* A negative value disables saturation (linear behavior). Common values are 7 or 8.
47+
* @throws IllegalArgumentException if {@code k1} is infinite or negative
48+
* @throws IllegalArgumentException if {@code b} is not within the range {@code [0..1]}
49+
* @throws IllegalArgumentException if {@code k3} is NaN or infinite
4550
*/
46-
public BM25Similarity(float k1, float b, boolean discountOverlaps) {
51+
public BM25Similarity(float k1, float b, boolean discountOverlaps, float k3) {
4752
super(discountOverlaps);
4853
if (Float.isFinite(k1) == false || k1 < 0) {
4954
throw new IllegalArgumentException(
@@ -52,8 +57,27 @@ public BM25Similarity(float k1, float b, boolean discountOverlaps) {
5257
if (Float.isNaN(b) || b < 0 || b > 1) {
5358
throw new IllegalArgumentException("illegal b value: " + b + ", must be between 0 and 1");
5459
}
60+
if (Float.isNaN(k3) || Float.isInfinite(k3)) {
61+
throw new IllegalArgumentException(
62+
"illegal k3 value: " + k3 + ", must be a finite value (negative to disable)");
63+
}
5564
this.k1 = k1;
5665
this.b = b;
66+
this.k3 = k3;
67+
}
68+
69+
/**
70+
* BM25 with the supplied parameter values.
71+
*
72+
* @param k1 Controls non-linear term frequency normalization (saturation).
73+
* @param b Controls to what degree document length normalizes tf values.
74+
* @param discountOverlaps True if overlap tokens (tokens with a position of increment of zero)
75+
* are discounted from the document's length.
76+
* @throws IllegalArgumentException if {@code k1} is infinite or negative, or if {@code b} is not
77+
* within the range {@code [0..1]}
78+
*/
79+
public BM25Similarity(float k1, float b, boolean discountOverlaps) {
80+
this(k1, b, discountOverlaps, -1f);
5781
}
5882

5983
/**
@@ -65,7 +89,7 @@ public BM25Similarity(float k1, float b, boolean discountOverlaps) {
6589
* within the range {@code [0..1]}
6690
*/
6791
public BM25Similarity(float k1, float b) {
68-
this(k1, b, true);
92+
this(k1, b, true, -1f);
6993
}
7094

7195
/**
@@ -82,7 +106,7 @@ public BM25Similarity(float k1, float b) {
82106
* are discounted from the document's length.
83107
*/
84108
public BM25Similarity(boolean discountOverlaps) {
85-
this(1.2f, 0.75f, discountOverlaps);
109+
this(1.2f, 0.75f, discountOverlaps, -1f);
86110
}
87111

88112
/**
@@ -95,7 +119,20 @@ public BM25Similarity(boolean discountOverlaps) {
95119
* </ul>
96120
*/
97121
public BM25Similarity() {
98-
this(1.2f, 0.75f, true);
122+
this(1.2f, 0.75f, true, -1f);
123+
}
124+
125+
/**
126+
* Computes the query-term weight using BM25's k3 saturation formula: ((k3 + 1) * qtf) / (k3 +
127+
* qtf). When k3 is negative (disabled), falls back to linear weighting where the weight equals
128+
* the query term frequency.
129+
*/
130+
@Override
131+
public float computeQueryTermWeight(int queryTermFrequency) {
132+
if (k3 < 0) {
133+
return (float) queryTermFrequency;
134+
}
135+
return ((k3 + 1f) * queryTermFrequency) / (k3 + queryTermFrequency);
99136
}
100137

101138
/** Implemented as <code>log(1 + (docCount - docFreq + 0.5)/(docFreq + 0.5))</code>. */
@@ -307,7 +344,7 @@ private List<Explanation> explainConstantFactors() {
307344

308345
@Override
309346
public String toString() {
310-
return "BM25(k1=" + k1 + ",b=" + b + ")";
347+
return "BM25(k1=" + k1 + ",b=" + b + (k3 >= 0 ? ",k3=" + k3 : "") + ")";
311348
}
312349

313350
/**
@@ -327,4 +364,12 @@ public final float getK1() {
327364
public final float getB() {
328365
return b;
329366
}
367+
368+
/**
369+
* Returns the <code>k3</code> parameter for query-side term frequency saturation. A negative
370+
* value means saturation is disabled.
371+
*/
372+
public final float getK3() {
373+
return k3;
374+
}
330375
}

lucene/core/src/java/org/apache/lucene/search/similarities/Similarity.java

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,22 @@ public long computeNorm(FieldInvertState state) {
162162
return SmallFloat.intToByte4(numTerms);
163163
}
164164

165+
/**
166+
* Computes the weight for a query term based on how many times it appears in the query. This is
167+
* used during query rewriting to compute the boost for duplicate query terms.
168+
*
169+
* <p>The default implementation returns {@code queryTermFrequency} as a float, which preserves
170+
* the existing linear boost behavior. Subclasses may override this to apply saturation (e.g.
171+
* BM25's k3 parameter).
172+
*
173+
* @param queryTermFrequency the number of times a term appears in the query
174+
* @return the computed weight for this query term frequency
175+
* @lucene.experimental
176+
*/
177+
public float computeQueryTermWeight(int queryTermFrequency) {
178+
return (float) queryTermFrequency;
179+
}
180+
165181
/**
166182
* Compute any collection-level weight (e.g. IDF, average document length, etc) needed for scoring
167183
* a query.

lucene/core/src/test/org/apache/lucene/search/TestBooleanRewrites.java

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
import org.apache.lucene.index.MultiReader;
3333
import org.apache.lucene.index.Term;
3434
import org.apache.lucene.search.BooleanClause.Occur;
35+
import org.apache.lucene.search.similarities.BM25Similarity;
3536
import org.apache.lucene.store.Directory;
3637
import org.apache.lucene.tests.index.RandomIndexWriter;
3738
import org.apache.lucene.tests.search.RandomApproximationQuery;
@@ -1097,6 +1098,75 @@ public void testShouldClausesLessThanOrEqualToMinimumNumberShouldMatch() throws
10971098
assertEquals(MatchNoDocsQuery.INSTANCE, searcher.rewrite(query));
10981099
}
10991100

1101+
public void testDeduplicateShouldClausesWithK3Saturation() throws IOException {
1102+
IndexSearcher searcher = newSearcher(new MultiReader());
1103+
searcher.setSimilarity(new BM25Similarity(1.2f, 0.75f, true, 8f));
1104+
1105+
// 3 duplicate unweighted SHOULD clauses: similarity applies k3 saturation
1106+
Query query =
1107+
new BooleanQuery.Builder()
1108+
.add(new TermQuery(new Term("foo", "bar")), Occur.SHOULD)
1109+
.add(new TermQuery(new Term("foo", "bar")), Occur.SHOULD)
1110+
.add(new TermQuery(new Term("foo", "bar")), Occur.SHOULD)
1111+
.build();
1112+
float expectedWeight = ((8f + 1f) * 3f) / (8f + 3f); // 27/11 ≈ 2.4545
1113+
Query expected = new BoostQuery(new TermQuery(new Term("foo", "bar")), expectedWeight);
1114+
assertEquals(expected, searcher.rewrite(query));
1115+
}
1116+
1117+
public void testDeduplicateMustClausesWithK3Saturation() throws IOException {
1118+
IndexSearcher searcher = newSearcher(new MultiReader());
1119+
searcher.setSimilarity(new BM25Similarity(1.2f, 0.75f, true, 7f));
1120+
1121+
// 2 duplicate unweighted MUST clauses
1122+
Query query =
1123+
new BooleanQuery.Builder()
1124+
.add(new TermQuery(new Term("foo", "bar")), Occur.MUST)
1125+
.add(new TermQuery(new Term("foo", "bar")), Occur.MUST)
1126+
.add(new TermQuery(new Term("foo", "quux")), Occur.MUST)
1127+
.build();
1128+
float expectedWeight = ((7f + 1f) * 2f) / (7f + 2f); // 16/9 ≈ 1.7778
1129+
Query expected =
1130+
new BooleanQuery.Builder()
1131+
.add(new BoostQuery(new TermQuery(new Term("foo", "bar")), expectedWeight), Occur.MUST)
1132+
.add(new TermQuery(new Term("foo", "quux")), Occur.MUST)
1133+
.build();
1134+
assertEquals(expected, searcher.rewrite(query));
1135+
}
1136+
1137+
public void testDeduplicateWithExplicitBoostsFallsBackToLinearSum() throws IOException {
1138+
IndexSearcher searcher = newSearcher(new MultiReader());
1139+
searcher.setSimilarity(new BM25Similarity(1.2f, 0.75f, true, 8f));
1140+
1141+
// Explicit boosts: should fall back to linear sum (3.0), not k3 saturation
1142+
Query query =
1143+
new BooleanQuery.Builder()
1144+
.add(new TermQuery(new Term("foo", "bar")), Occur.SHOULD)
1145+
.add(new BoostQuery(new TermQuery(new Term("foo", "bar")), 2), Occur.SHOULD)
1146+
.add(new TermQuery(new Term("foo", "quux")), Occur.SHOULD)
1147+
.build();
1148+
Query expected =
1149+
new BooleanQuery.Builder()
1150+
.add(new BoostQuery(new TermQuery(new Term("foo", "bar")), 3), Occur.SHOULD)
1151+
.add(new TermQuery(new Term("foo", "quux")), Occur.SHOULD)
1152+
.build();
1153+
assertEquals(expected, searcher.rewrite(query));
1154+
}
1155+
1156+
public void testDefaultSimilarityPreservesLinearDedup() throws IOException {
1157+
// Default similarity: computeQueryTermWeight returns qtf linearly
1158+
IndexSearcher searcher = newSearcher(new MultiReader());
1159+
1160+
Query query =
1161+
new BooleanQuery.Builder()
1162+
.add(new TermQuery(new Term("foo", "bar")), Occur.SHOULD)
1163+
.add(new TermQuery(new Term("foo", "bar")), Occur.SHOULD)
1164+
.add(new TermQuery(new Term("foo", "bar")), Occur.SHOULD)
1165+
.build();
1166+
Query expected = new BoostQuery(new TermQuery(new Term("foo", "bar")), 3);
1167+
assertEquals(expected, searcher.rewrite(query));
1168+
}
1169+
11001170
// Test query to count number of rewrites for its lifetime.
11011171
private static final class TestRewriteQuery extends Query {
11021172

lucene/core/src/test/org/apache/lucene/search/similarities/TestBM25Similarity.java

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,46 @@ public void testIllegalK1() {
4747
assertTrue(expected.getMessage().contains("illegal k1 value"));
4848
}
4949

50+
public void testIllegalK3() {
51+
IllegalArgumentException expected =
52+
expectThrows(
53+
IllegalArgumentException.class,
54+
() -> {
55+
new BM25Similarity(1.2f, 0.75f, true, Float.POSITIVE_INFINITY);
56+
});
57+
assertTrue(expected.getMessage().contains("illegal k3 value"));
58+
59+
expected =
60+
expectThrows(
61+
IllegalArgumentException.class,
62+
() -> {
63+
new BM25Similarity(1.2f, 0.75f, true, Float.NEGATIVE_INFINITY);
64+
});
65+
assertTrue(expected.getMessage().contains("illegal k3 value"));
66+
67+
expected =
68+
expectThrows(
69+
IllegalArgumentException.class,
70+
() -> {
71+
new BM25Similarity(1.2f, 0.75f, true, Float.NaN);
72+
});
73+
assertTrue(expected.getMessage().contains("illegal k3 value"));
74+
}
75+
76+
public void testValidK3() {
77+
// negative k3 disables saturation — should be allowed
78+
BM25Similarity sim = new BM25Similarity(1.2f, 0.75f, true, -1f);
79+
assertEquals(-1f, sim.getK3(), 0f);
80+
81+
// zero k3 — maximum saturation
82+
sim = new BM25Similarity(1.2f, 0.75f, true, 0f);
83+
assertEquals(0f, sim.getK3(), 0f);
84+
85+
// typical k3 value
86+
sim = new BM25Similarity(1.2f, 0.75f, true, 8f);
87+
assertEquals(8f, sim.getK3(), 0f);
88+
}
89+
5090
public void testIllegalB() {
5191
IllegalArgumentException expected =
5292
expectThrows(
@@ -126,6 +166,6 @@ protected Similarity getSimilarity(Random random) {
126166
b = random.nextFloat();
127167
break;
128168
}
129-
return new BM25Similarity(k1, b);
169+
return new BM25Similarity(k1, b, true, random.nextBoolean() ? -1f : random.nextFloat() * 20f);
130170
}
131171
}

0 commit comments

Comments
 (0)