1use std::collections::HashMap;
11
12use crate::bm25::bm25_score;
13use crate::buckets::{BAND_MIN_DL, Buckets};
14use crate::token::tokenize;
15
16#[derive(Debug, Clone, PartialEq)]
18pub struct TextMatch {
19 pub key: Vec<u8>,
21 pub score: f64,
23}
24
25#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
27pub struct TextStats {
28 pub docs: u64,
30 pub tokens: u64,
32 pub postings: u64,
34 pub approx_bytes: u64,
36}
37
38type ScoredList<'s> = (&'s Buckets, f64, f64);
40
41struct QueryCtx {
43 n_docs: f64,
44 avgdl: f64,
45 limit: usize,
46}
47
48#[derive(Debug, Default)]
56pub struct TextSegment {
57 postings: HashMap<Vec<u8>, Buckets>,
58 docs: HashMap<Vec<u8>, (u32, u32, Vec<u8>)>,
60 id_key: Vec<Option<Vec<u8>>>,
62 id_dl: Vec<u32>,
64 free_ids: Vec<u32>,
65 total_len: u64,
66}
67
68impl TextSegment {
69 pub fn new() -> Self {
71 Self::default()
72 }
73
74 pub fn apply(&mut self, key: &[u8], text: Option<&[u8]>) {
76 if let Some((old_id, old_len, old_text)) = self.docs.remove(key) {
77 self.total_len -= u64::from(old_len);
78 for (t, tf) in tf_of(&tokenize(&old_text)) {
81 if let Some(list) = self.postings.get_mut(&t) {
82 list.remove(tf, old_len, old_id);
83 if list.is_empty() {
84 self.postings.remove(&t);
85 }
86 }
87 }
88 self.id_key[old_id as usize] = None;
89 self.free_ids.push(old_id);
90 }
91 let Some(text) = text else { return };
92 let toks = tokenize(text);
93 if toks.is_empty() {
94 return;
95 }
96 let dl = toks.len() as u32;
97 let id = if let Some(id) = self.free_ids.pop() {
98 self.id_key[id as usize] = Some(key.to_vec());
99 self.id_dl[id as usize] = dl;
100 id
101 } else {
102 self.id_key.push(Some(key.to_vec()));
103 self.id_dl.push(dl);
104 (self.id_key.len() - 1) as u32
105 };
106 self.docs.insert(key.to_vec(), (id, dl, text.to_vec()));
107 self.total_len += u64::from(dl);
108 for (t, tf) in tf_of(&toks) {
109 match self.postings.entry(t) {
110 std::collections::hash_map::Entry::Occupied(mut e) => {
111 e.get_mut().insert(tf, dl, id);
112 }
113 std::collections::hash_map::Entry::Vacant(v) => {
114 v.insert(Buckets::new_one(tf, dl, id));
115 }
116 }
117 }
118 }
119
120 pub fn matches(&self, query: &[u8], limit: usize) -> Vec<TextMatch> {
130 if limit == 0 {
134 return Vec::new();
135 }
136 let mut q_tokens = tokenize(query);
137 q_tokens.sort();
138 q_tokens.dedup();
139 if q_tokens.is_empty() || self.docs.is_empty() {
140 return Vec::new();
141 }
142 let n_docs = self.docs.len() as f64;
143 let avgdl = self.total_len as f64 / n_docs;
144 let lists = self.scored_lists(&q_tokens, n_docs);
145 if lists.is_empty() {
146 return Vec::new();
147 }
148 let tail_ub = tail_bounds(&lists);
149 let ctx = QueryCtx { n_docs, avgdl, limit };
150 let mut scores: HashMap<u32, f64> = HashMap::new();
151 let mut kth_threshold = 0.0_f64;
152 let mut walked = 0usize;
153 for (i, (list, df, _ub)) in lists.iter().enumerate() {
154 if i > 0 && scores.len() >= limit && tail_ub[i] < kth_threshold {
158 break;
159 }
160 walked = i + 1;
161 let tail_next = tail_ub.get(i + 1).copied().unwrap_or(0.0);
162 self.walk_list(list, *df, tail_next, lists.len() == 1, &ctx, &mut scores);
163 if scores.len() >= limit && i + 1 < lists.len() {
164 kth_threshold = kth_of(&scores, limit);
165 }
166 }
167 for (list, df, _) in &lists[walked..] {
172 self.probe_list(list, *df, &[], &ctx, &mut scores);
173 }
174 self.select_top(&scores, limit)
175 }
176
177 fn scored_lists<'s>(&'s self, q_tokens: &[Vec<u8>], n_docs: f64) -> Vec<ScoredList<'s>> {
181 let mut lists: Vec<ScoredList<'s>> = Vec::new();
182 for t in q_tokens {
183 let Some(list) = self.postings.get(t) else { continue };
184 let df = list.len() as f64;
185 let max_tf = f64::from(list.max_tf());
186 lists.push((list, df, crate::bm25::bm25_upper(max_tf, df, n_docs)));
187 }
188 lists.sort_by(|a, b| b.2.total_cmp(&a.2));
189 lists
190 }
191
192 fn walk_list(
195 &self,
196 list: &Buckets,
197 df: f64,
198 tail_next: f64,
199 single: bool,
200 ctx: &QueryCtx,
201 scores: &mut HashMap<u32, f64>,
202 ) {
203 let QueryCtx { n_docs, limit, .. } = *ctx;
204 let groups = list.tf_groups();
205 for (bi, (tf, bands)) in groups.iter().enumerate() {
206 if scores.len() >= limit {
215 let bound = crate::bm25::bm25_upper(f64::from(*tf), df, n_docs);
216 if bound + tail_next < kth_of(scores, limit) {
217 let walked_tfs: Vec<u32> =
218 groups[..bi].iter().map(|(t, _)| *t).collect();
219 self.probe_list(list, df, &walked_tfs, ctx, scores);
220 break;
221 }
222 }
223 self.walk_bucket(*tf, bands, df, single, ctx, scores);
224 }
225 }
226
227 fn walk_bucket(
237 &self,
238 tf: u32,
239 bands: &crate::buckets::BandsView<'_>,
240 df: f64,
241 single: bool,
242 ctx: &QueryCtx,
243 scores: &mut HashMap<u32, f64>,
244 ) {
245 let QueryCtx { n_docs, avgdl, limit } = *ctx;
246 for (b, band) in bands.iter() {
247 if band.is_empty() {
248 continue;
249 }
250 let bound = bm25_score(
251 f64::from(tf),
252 df,
253 n_docs,
254 f64::from(BAND_MIN_DL[b as usize]),
255 avgdl,
256 );
257 if single && scores.len() >= limit && bound < kth_of(scores, limit) {
258 break;
259 }
260 for &id in band {
261 let dl = f64::from(self.id_dl[id as usize]);
262 *scores.entry(id).or_insert(0.0) +=
263 bm25_score(f64::from(tf), df, n_docs, dl, avgdl);
264 }
265 }
266 }
267
268 fn probe_list(
273 &self,
274 list: &Buckets,
275 df: f64,
276 skip_tfs: &[u32],
277 ctx: &QueryCtx,
278 scores: &mut HashMap<u32, f64>,
279 ) {
280 let ids: Vec<u32> = scores.keys().copied().collect();
281 for &id in &ids {
282 if let Some(tf) = list.get(id)
283 && !skip_tfs.contains(&tf)
284 {
285 let dl = f64::from(self.id_dl[id as usize]);
286 *scores.get_mut(&id).expect("accumulated") +=
287 bm25_score(f64::from(tf), df, ctx.n_docs, dl, ctx.avgdl);
288 }
289 }
290 }
291
292 fn select_top(&self, scores: &HashMap<u32, f64>, limit: usize) -> Vec<TextMatch> {
295 let key_of = |id: u32| -> &[u8] {
296 self.id_key[id as usize].as_deref().expect("live posting id")
297 };
298 let mut top: Vec<(f64, &[u8])> = Vec::with_capacity(limit + 1);
299 for (id, score) in scores {
300 let cand = (*score, key_of(*id));
301 if top.len() < limit {
302 top.push(cand);
303 if top.len() == limit {
304 top.sort_by(|a, b| b.0.total_cmp(&a.0).then_with(|| a.1.cmp(b.1)));
305 }
306 } else if better(cand, top[limit - 1]) {
307 let pos = top.partition_point(|e| better(*e, cand));
308 top.insert(pos, cand);
309 top.pop();
310 }
311 }
312 if top.len() < limit {
313 top.sort_by(|a, b| b.0.total_cmp(&a.0).then_with(|| a.1.cmp(b.1)));
314 }
315 top.into_iter()
316 .map(|(score, k)| TextMatch { key: k.to_vec(), score })
317 .collect()
318 }
319
320 pub fn stats(&self) -> TextStats {
322 let postings: u64 = self.postings.values().map(|l| l.len() as u64).sum();
323 let many_postings: u64 = self
327 .postings
328 .values()
329 .map(|l| match l {
330 Buckets::One { .. } => 0,
331 Buckets::Many(m) => m.index.len() as u64,
332 })
333 .sum();
334 let token_bytes: u64 = self.postings.keys().map(|t| (t.len() + 48) as u64).sum();
335 let doc_bytes: u64 = self
337 .docs
338 .iter()
339 .map(|(k, (_, _, text))| (2 * k.len() + text.len() + 110) as u64)
340 .sum();
341 TextStats {
342 docs: self.docs.len() as u64,
343 tokens: self.postings.len() as u64,
344 postings,
345 approx_bytes: token_bytes + many_postings * 30 + doc_bytes,
350 }
351 }
352
353 pub fn contains(&self, key: &[u8]) -> bool {
355 self.docs.contains_key(key)
356 }
357}
358
359fn tf_of(toks: &[Vec<u8>]) -> HashMap<Vec<u8>, u32> {
361 let mut tf = HashMap::new();
362 for t in toks {
363 *tf.entry(t.clone()).or_insert(0) += 1;
364 }
365 tf
366}
367
368#[allow(clippy::float_cmp)]
373fn better(a: (f64, &[u8]), b: (f64, &[u8])) -> bool {
374 a.0 > b.0 || (a.0 == b.0 && a.1 < b.1)
375}
376
377fn kth_of(scores: &HashMap<u32, f64>, limit: usize) -> f64 {
380 let mut v: Vec<f64> = scores.values().copied().collect();
381 let idx = limit - 1;
382 v.select_nth_unstable_by(idx, |a, b| b.total_cmp(a));
383 v[idx]
384}
385
386fn tail_bounds(lists: &[ScoredList<'_>]) -> Vec<f64> {
388 let mut acc = 0.0;
389 let mut v: Vec<f64> = lists
390 .iter()
391 .rev()
392 .map(|l| {
393 acc += l.2;
394 acc
395 })
396 .collect();
397 v.reverse();
398 v
399}
400
401#[cfg(test)]
402#[path = "segment_tests.rs"]
403mod tests;