Skip to content

Commit d9aa7cb

Browse files
jaepilbenwtrent
andauthored
Improve BayesianScoreQuery and LogOddsFusionQuery with base rate prior, weighted Log-OP, and parameter estimation (#15948)
* Improve BayesianScoreQuery and LogOddsFusionQuery for probabilistic hybrid search - Add BayesianScoreEstimator for auto-estimating sigmoid calibration parameters - Add base rate prior support to BayesianScoreQuery for log-odds shifting - Add per-signal weights to LogOddsFusionQuery for weighted Logarithmic Opinion Pooling - Add logit normalization support to LogOddsFusionScorer - Add comprehensive tests for BayesianScoreQuery and LogOddsFusionQuery * Move CHANGES.txt entry from 11.0.0 to 10.5.0 Improvements section * Use indexed terms for Bayesian score estimation --------- Co-authored-by: Benjamin Trent <ben.w.trent@gmail.com>
1 parent 9669101 commit d9aa7cb

7 files changed

Lines changed: 939 additions & 58 deletions

File tree

lucene/CHANGES.txt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,13 @@ New Features
326326

327327
Improvements
328328
---------------------
329+
* GITHUB#15948: Improve BayesianScoreQuery and LogOddsFusionQuery with base rate prior,
330+
weighted Logarithmic Opinion Pooling, and auto parameter estimation. Add
331+
BayesianScoreEstimator for estimating sigmoid calibration parameters from corpus
332+
statistics. Add base rate prior to BayesianScoreQuery for log-odds space shifting.
333+
Add per-signal weights and logit normalization to LogOddsFusionQuery.
334+
(Jaepil Jeong)
335+
329336
* GITHUB#15823: Implement method to add all stream elements into a PriorityQueue.
330337
Call PriorityQueue#addAll with mapped stream in DisjunctionMaxBulkScorer's constructor. (Zhou Hui)
331338

Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one or more
3+
* contributor license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright ownership.
5+
* The ASF licenses this file to You under the Apache License, Version 2.0
6+
* (the "License"); you may not use this file except in compliance with
7+
* the License. You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
package org.apache.lucene.search;
18+
19+
import java.io.IOException;
20+
import java.util.ArrayList;
21+
import java.util.Arrays;
22+
import java.util.List;
23+
import java.util.Random;
24+
import org.apache.lucene.index.IndexReader;
25+
import org.apache.lucene.index.MultiTerms;
26+
import org.apache.lucene.index.Term;
27+
import org.apache.lucene.index.Terms;
28+
import org.apache.lucene.index.TermsEnum;
29+
import org.apache.lucene.util.BytesRef;
30+
31+
/**
32+
* Estimates {@link BayesianScoreQuery} parameters (alpha, beta, base rate) from corpus statistics
33+
* via pseudo-query sampling.
34+
*
35+
* <p>The estimation algorithm:
36+
*
37+
* <ol>
38+
* <li>Reservoir-sample terms from the target field's indexed vocabulary
39+
* <li>Partition the sampled terms into pseudo-queries
40+
* <li>Run each pseudo-query via BM25 and collect the score distribution
41+
* <li>Estimate: beta = median(scores), alpha = 1 / std(scores)
42+
* <li>Estimate base rate: mean fraction of documents scoring above the 95th percentile
43+
* </ol>
44+
*
45+
* @lucene.experimental
46+
*/
47+
public class BayesianScoreEstimator {
48+
49+
/** Estimated parameters for {@link BayesianScoreQuery}. */
50+
public record Parameters(float alpha, float beta, float baseRate) {}
51+
52+
private static final int DEFAULT_N_SAMPLES = 50;
53+
private static final int DEFAULT_TOKENS_PER_QUERY = 5;
54+
private static final double PERCENTILE_THRESHOLD = 0.95;
55+
private static final float BASE_RATE_MIN = 1e-6f;
56+
private static final float BASE_RATE_MAX = 0.5f;
57+
58+
private BayesianScoreEstimator() {}
59+
60+
/**
61+
* Estimates BayesianScoreQuery parameters from the given index.
62+
*
63+
* @param searcher the index searcher to sample from
64+
* @param field the indexed text field to create pseudo-queries for
65+
* @param nSamples number of pseudo-queries to sample (default 50)
66+
* @param tokensPerQuery number of indexed terms per pseudo-query (default 5)
67+
* @param seed random seed for reproducible sampling
68+
* @return estimated alpha, beta, and base rate
69+
* @throws IOException if an I/O error occurs reading the index
70+
*/
71+
public static Parameters estimate(
72+
IndexSearcher searcher, String field, int nSamples, int tokensPerQuery, long seed)
73+
throws IOException {
74+
if (nSamples <= 0) {
75+
throw new IllegalArgumentException("nSamples must be positive, got " + nSamples);
76+
}
77+
if (tokensPerQuery <= 0) {
78+
throw new IllegalArgumentException("tokensPerQuery must be positive, got " + tokensPerQuery);
79+
}
80+
81+
IndexReader reader = searcher.getIndexReader();
82+
int maxDoc = reader.maxDoc();
83+
if (maxDoc == 0) {
84+
return new Parameters(1.0f, 0.0f, 0.01f);
85+
}
86+
87+
Random rng = new Random(seed);
88+
List<BytesRef> sampledTerms =
89+
sampleVocabularyTerms(reader, field, Math.multiplyExact(nSamples, tokensPerQuery), rng);
90+
if (sampledTerms.isEmpty()) {
91+
return new Parameters(1.0f, 0.0f, 0.01f);
92+
}
93+
94+
// Create pseudo-queries from indexed vocabulary terms and collect scores.
95+
List<float[]> allScoreArrays = new ArrayList<>();
96+
List<Float> baseRateFractions = new ArrayList<>();
97+
98+
for (int offset = 0; offset < sampledTerms.size(); offset += tokensPerQuery) {
99+
BooleanQuery.Builder builder = new BooleanQuery.Builder();
100+
int end = Math.min(offset + tokensPerQuery, sampledTerms.size());
101+
for (int i = offset; i < end; i++) {
102+
builder.add(
103+
new TermQuery(new Term(field, sampledTerms.get(i))), BooleanClause.Occur.SHOULD);
104+
}
105+
Query pseudoQuery = builder.build();
106+
107+
// Collect all scores
108+
float[] scores = collectScores(searcher, pseudoQuery, maxDoc);
109+
if (scores.length == 0) {
110+
continue;
111+
}
112+
allScoreArrays.add(scores);
113+
114+
// Base rate: fraction of docs above 95th percentile
115+
float[] sorted = scores.clone();
116+
Arrays.sort(sorted);
117+
int pIdx = (int) (sorted.length * PERCENTILE_THRESHOLD);
118+
pIdx = Math.min(pIdx, sorted.length - 1);
119+
float threshold = sorted[pIdx];
120+
int highCount = 0;
121+
for (float s : scores) {
122+
if (s >= threshold) {
123+
highCount++;
124+
}
125+
}
126+
baseRateFractions.add((float) highCount / maxDoc);
127+
}
128+
129+
if (allScoreArrays.isEmpty()) {
130+
return new Parameters(1.0f, 0.0f, 0.01f);
131+
}
132+
133+
// Flatten all scores for global statistics
134+
int totalScores = 0;
135+
for (float[] arr : allScoreArrays) {
136+
totalScores += arr.length;
137+
}
138+
float[] allScores = new float[totalScores];
139+
int offset = 0;
140+
for (float[] arr : allScoreArrays) {
141+
System.arraycopy(arr, 0, allScores, offset, arr.length);
142+
offset += arr.length;
143+
}
144+
145+
// beta = median
146+
Arrays.sort(allScores);
147+
float beta = allScores[allScores.length / 2];
148+
149+
// alpha = 1 / std
150+
double mean = 0;
151+
for (float s : allScores) {
152+
mean += s;
153+
}
154+
mean /= allScores.length;
155+
double variance = 0;
156+
for (float s : allScores) {
157+
double diff = s - mean;
158+
variance += diff * diff;
159+
}
160+
variance /= allScores.length;
161+
double std = Math.sqrt(variance);
162+
float alpha = std > 0 ? (float) (1.0 / std) : 1.0f;
163+
164+
// base rate = mean of per-query fractions, clamped
165+
float baseRate = 0;
166+
for (float f : baseRateFractions) {
167+
baseRate += f;
168+
}
169+
baseRate /= baseRateFractions.size();
170+
baseRate = Math.clamp(baseRate, BASE_RATE_MIN, BASE_RATE_MAX);
171+
172+
return new Parameters(alpha, beta, baseRate);
173+
}
174+
175+
/**
176+
* Estimates parameters with default settings (50 samples, 5 tokens per query, seed 42).
177+
*
178+
* @param searcher the index searcher
179+
* @param field the text field
180+
* @return estimated parameters
181+
* @throws IOException if an I/O error occurs
182+
*/
183+
public static Parameters estimate(IndexSearcher searcher, String field) throws IOException {
184+
return estimate(searcher, field, DEFAULT_N_SAMPLES, DEFAULT_TOKENS_PER_QUERY, 42);
185+
}
186+
187+
static List<BytesRef> sampleVocabularyTerms(
188+
IndexReader reader, String field, int sampleSize, Random rng) throws IOException {
189+
Terms terms = MultiTerms.getTerms(reader, field);
190+
if (terms == null) {
191+
return new ArrayList<>();
192+
}
193+
194+
List<BytesRef> reservoir = new ArrayList<>(sampleSize);
195+
TermsEnum termsEnum = terms.iterator();
196+
BytesRef term;
197+
long seen = 0;
198+
while ((term = termsEnum.next()) != null) {
199+
seen++;
200+
if (reservoir.size() < sampleSize) {
201+
reservoir.add(BytesRef.deepCopyOf(term));
202+
} else {
203+
long replacement = nextLong(rng, seen);
204+
if (replacement < sampleSize) {
205+
reservoir.set((int) replacement, BytesRef.deepCopyOf(term));
206+
}
207+
}
208+
}
209+
return reservoir;
210+
}
211+
212+
private static long nextLong(Random rng, long bound) {
213+
long bits;
214+
long value;
215+
do {
216+
bits = rng.nextLong() >>> 1;
217+
value = bits % bound;
218+
} while (bits - value + (bound - 1) < 0L);
219+
return value;
220+
}
221+
222+
private static float[] collectScores(IndexSearcher searcher, Query query, int maxDoc)
223+
throws IOException {
224+
int topN = Math.min(maxDoc, 10000);
225+
TopDocs topDocs = searcher.search(query, topN);
226+
float[] scores = new float[topDocs.scoreDocs.length];
227+
for (int i = 0; i < topDocs.scoreDocs.length; i++) {
228+
scores[i] = topDocs.scoreDocs[i].score;
229+
}
230+
return scores;
231+
}
232+
}

0 commit comments

Comments
 (0)