uqa_scoring/
multi_field.rs1use std::sync::Arc;
20
21use uqa_core::IndexStats;
22
23use crate::bayesian_bm25::{BayesianBM25Params, BayesianBM25Scorer};
24use crate::error::invalid_input;
25use crate::prob::sigmoid;
26use crate::ScoringResult;
27
28#[derive(Debug, Clone)]
30pub struct FieldConfig {
31 pub field: String,
32 pub params: BayesianBM25Params,
33 pub weight: f64,
34}
35
36pub struct MultiFieldBayesianScorer {
37 fields: Vec<String>,
38 scorers: Vec<BayesianBM25Scorer>,
39 weights: Vec<f64>,
40}
41
42impl MultiFieldBayesianScorer {
43 pub fn new(field_configs: Vec<FieldConfig>, stats: &Arc<IndexStats>) -> ScoringResult<Self> {
44 let total_weight = field_configs.iter().try_fold(0.0, |total, config| {
45 if config.field.is_empty() {
46 return Err(invalid_input("multi-field field name must not be empty"));
47 }
48 if !config.weight.is_finite() || config.weight < 0.0 {
49 return Err(invalid_input(format!(
50 "multi-field weight for {:?} must be finite and non-negative, got {}",
51 config.field, config.weight
52 )));
53 }
54 let next = total + config.weight;
55 if next.is_finite() {
56 Ok(next)
57 } else {
58 Err(invalid_input("multi-field weight sum overflowed"))
59 }
60 })?;
61 if !field_configs.is_empty() && total_weight <= 0.0 {
62 return Err(invalid_input(
63 "multi-field weights must have a positive finite sum",
64 ));
65 }
66 let mut fields = Vec::with_capacity(field_configs.len());
67 let mut scorers = Vec::with_capacity(field_configs.len());
68 let mut weights = Vec::with_capacity(field_configs.len());
69 for cfg in field_configs {
70 fields.push(cfg.field);
71 scorers.push(BayesianBM25Scorer::new(cfg.params, stats.clone())?);
72 weights.push(cfg.weight / total_weight);
73 }
74 Ok(Self {
75 fields,
76 scorers,
77 weights,
78 })
79 }
80
81 pub fn score_document(
86 &self,
87 term_freq_per_field: &std::collections::BTreeMap<String, u64>,
88 doc_length_per_field: &std::collections::BTreeMap<String, u64>,
89 doc_freq_per_field: &std::collections::BTreeMap<String, u64>,
90 ) -> f64 {
91 if self.fields.is_empty() {
92 return 0.5;
93 }
94 let mut probabilities: Vec<Option<f64>> = Vec::with_capacity(self.fields.len());
95 for (i, name) in self.fields.iter().enumerate() {
96 let tf = *term_freq_per_field.get(name).unwrap_or(&0);
97 let dl = *doc_length_per_field.get(name).unwrap_or(&1);
98 let df = *doc_freq_per_field.get(name).unwrap_or(&1);
99 if tf == 0 {
100 probabilities.push(None);
101 } else {
102 probabilities.push(Some(self.scorers[i].score(tf, dl, df)));
103 }
104 }
105 if probabilities.len() == 1 {
106 return probabilities[0].unwrap_or(0.5);
107 }
108 let evidence_sum: f64 = probabilities
109 .iter()
110 .zip(&self.weights)
111 .zip(&self.scorers)
112 .filter_map(|((probability, weight), scorer)| {
113 probability.map(|probability| {
114 weight * softplus(lucene_logit(probability) - field_prior_logit(scorer))
115 })
116 })
117 .sum();
118 let prior_logit: f64 = self
119 .weights
120 .iter()
121 .zip(&self.scorers)
122 .map(|(weight, scorer)| weight * field_prior_logit(scorer))
123 .sum();
124 sigmoid(evidence_sum * (probabilities.len() as f64).sqrt() + prior_logit)
125 }
126}
127
128fn softplus(value: f64) -> f64 {
129 if value > 20.0 {
130 value
131 } else {
132 value.exp().ln_1p()
133 }
134}
135
136fn field_prior_logit(scorer: &BayesianBM25Scorer) -> f64 {
140 let base_rate = scorer.params.base_rate;
141 if base_rate > 0.0 {
142 lucene_logit(base_rate)
143 } else {
144 0.0
145 }
146}
147
148fn lucene_logit(probability: f64) -> f64 {
149 let clamped = probability.clamp(1e-7, 1.0 - 1e-7);
150 (clamped / (1.0 - clamped)).ln()
151}
152
153#[cfg(test)]
154mod tests {
155 use super::*;
156
157 #[test]
158 fn single_field_returns_scorer_output() {
159 let mut base = IndexStats::default();
160 base.total_docs = 100;
161 base.avg_doc_length = 50.0;
162 let stats = Arc::new(base);
163 let stats = &stats;
164 let scorer = MultiFieldBayesianScorer::new(
165 vec![FieldConfig {
166 field: "title".into(),
167 params: BayesianBM25Params::default(),
168 weight: 1.0,
169 }],
170 stats,
171 )
172 .unwrap();
173 let tf = std::collections::BTreeMap::from([("title".into(), 5u64)]);
174 let dl = std::collections::BTreeMap::from([("title".into(), 50u64)]);
175 let df = std::collections::BTreeMap::from([("title".into(), 10u64)]);
176 let s = scorer.score_document(&tf, &dl, &df);
177 assert!((0.0..=1.0).contains(&s));
178 assert!(s > 0.5);
179 }
180
181 #[test]
182 fn missing_field_contributes_sparse_absence() {
183 let mut base = IndexStats::default();
184 base.total_docs = 100;
185 base.avg_doc_length = 50.0;
186 let stats = Arc::new(base);
187 let stats = &stats;
188 let scorer = MultiFieldBayesianScorer::new(
189 vec![
190 FieldConfig {
191 field: "title".into(),
192 params: BayesianBM25Params::default(),
193 weight: 1.0,
194 },
195 FieldConfig {
196 field: "body".into(),
197 params: BayesianBM25Params::default(),
198 weight: 1.0,
199 },
200 ],
201 stats,
202 )
203 .unwrap();
204 let tf = std::collections::BTreeMap::from([("title".into(), 5u64)]);
206 let dl = std::collections::BTreeMap::from([("title".into(), 50u64)]);
207 let df = std::collections::BTreeMap::from([("title".into(), 10u64)]);
208 let s = scorer.score_document(&tf, &dl, &df);
209 assert!((0.0..=1.0).contains(&s));
210 }
211
212 #[test]
213 fn constructor_rejects_invalid_field_weights() {
214 let stats = Arc::new(IndexStats::default());
215 let config = |field: &str, weight: f64| FieldConfig {
216 field: field.to_string(),
217 params: BayesianBM25Params::default(),
218 weight,
219 };
220 assert!(MultiFieldBayesianScorer::new(vec![config("", 1.0)], &stats).is_err());
221 assert!(MultiFieldBayesianScorer::new(vec![config("title", f64::NAN)], &stats).is_err());
222 assert!(MultiFieldBayesianScorer::new(vec![config("title", -1.0)], &stats).is_err());
223 assert!(MultiFieldBayesianScorer::new(
224 vec![config("title", 0.0), config("body", 0.0)],
225 &stats,
226 )
227 .is_err());
228 }
229}