use std::collections::HashMap;
use crate::bm25::bm25_score;
use crate::buckets::{BAND_MIN_DL, Buckets};
use crate::token::tokenize;
#[derive(Debug, Clone, PartialEq)]
pub struct TextMatch {
pub key: Vec<u8>,
pub score: f64,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct TextStats {
pub docs: u64,
pub tokens: u64,
pub postings: u64,
pub approx_bytes: u64,
}
type ScoredList<'s> = (&'s Buckets, f64, f64);
struct QueryCtx {
n_docs: f64,
avgdl: f64,
limit: usize,
}
#[derive(Debug, Default)]
pub struct TextSegment {
postings: HashMap<Vec<u8>, Buckets>,
docs: HashMap<Vec<u8>, (u32, u32, Vec<u8>)>,
id_key: Vec<Option<Vec<u8>>>,
id_dl: Vec<u32>,
free_ids: Vec<u32>,
total_len: u64,
}
impl TextSegment {
pub fn new() -> Self {
Self::default()
}
pub fn apply(&mut self, key: &[u8], text: Option<&[u8]>) {
if let Some((old_id, old_len, old_text)) = self.docs.remove(key) {
self.total_len -= u64::from(old_len);
for (t, tf) in tf_of(&tokenize(&old_text)) {
if let Some(list) = self.postings.get_mut(&t) {
list.remove(tf, old_len, old_id);
if list.is_empty() {
self.postings.remove(&t);
}
}
}
self.id_key[old_id as usize] = None;
self.free_ids.push(old_id);
}
let Some(text) = text else { return };
let toks = tokenize(text);
if toks.is_empty() {
return;
}
let dl = toks.len() as u32;
let id = if let Some(id) = self.free_ids.pop() {
self.id_key[id as usize] = Some(key.to_vec());
self.id_dl[id as usize] = dl;
id
} else {
self.id_key.push(Some(key.to_vec()));
self.id_dl.push(dl);
(self.id_key.len() - 1) as u32
};
self.docs.insert(key.to_vec(), (id, dl, text.to_vec()));
self.total_len += u64::from(dl);
for (t, tf) in tf_of(&toks) {
match self.postings.entry(t) {
std::collections::hash_map::Entry::Occupied(mut e) => {
e.get_mut().insert(tf, dl, id);
}
std::collections::hash_map::Entry::Vacant(v) => {
v.insert(Buckets::new_one(tf, dl, id));
}
}
}
}
pub fn matches(&self, query: &[u8], limit: usize) -> Vec<TextMatch> {
if limit == 0 {
return Vec::new();
}
let mut q_tokens = tokenize(query);
q_tokens.sort();
q_tokens.dedup();
if q_tokens.is_empty() || self.docs.is_empty() {
return Vec::new();
}
let n_docs = self.docs.len() as f64;
let avgdl = self.total_len as f64 / n_docs;
let lists = self.scored_lists(&q_tokens, n_docs);
if lists.is_empty() {
return Vec::new();
}
let tail_ub = tail_bounds(&lists);
let ctx = QueryCtx { n_docs, avgdl, limit };
let mut scores: HashMap<u32, f64> = HashMap::new();
let mut kth_threshold = 0.0_f64;
let mut walked = 0usize;
for (i, (list, df, _ub)) in lists.iter().enumerate() {
if i > 0 && scores.len() >= limit && tail_ub[i] < kth_threshold {
break;
}
walked = i + 1;
let tail_next = tail_ub.get(i + 1).copied().unwrap_or(0.0);
self.walk_list(list, *df, tail_next, lists.len() == 1, &ctx, &mut scores);
if scores.len() >= limit && i + 1 < lists.len() {
kth_threshold = kth_of(&scores, limit);
}
}
for (list, df, _) in &lists[walked..] {
self.probe_list(list, *df, &[], &ctx, &mut scores);
}
self.select_top(&scores, limit)
}
fn scored_lists<'s>(&'s self, q_tokens: &[Vec<u8>], n_docs: f64) -> Vec<ScoredList<'s>> {
let mut lists: Vec<ScoredList<'s>> = Vec::new();
for t in q_tokens {
let Some(list) = self.postings.get(t) else { continue };
let df = list.len() as f64;
let max_tf = f64::from(list.max_tf());
lists.push((list, df, crate::bm25::bm25_upper(max_tf, df, n_docs)));
}
lists.sort_by(|a, b| b.2.total_cmp(&a.2));
lists
}
fn walk_list(
&self,
list: &Buckets,
df: f64,
tail_next: f64,
single: bool,
ctx: &QueryCtx,
scores: &mut HashMap<u32, f64>,
) {
let QueryCtx { n_docs, limit, .. } = *ctx;
let groups = list.tf_groups();
for (bi, (tf, bands)) in groups.iter().enumerate() {
if scores.len() >= limit {
let bound = crate::bm25::bm25_upper(f64::from(*tf), df, n_docs);
if bound + tail_next < kth_of(scores, limit) {
let walked_tfs: Vec<u32> =
groups[..bi].iter().map(|(t, _)| *t).collect();
self.probe_list(list, df, &walked_tfs, ctx, scores);
break;
}
}
self.walk_bucket(*tf, bands, df, single, ctx, scores);
}
}
fn walk_bucket(
&self,
tf: u32,
bands: &crate::buckets::BandsView<'_>,
df: f64,
single: bool,
ctx: &QueryCtx,
scores: &mut HashMap<u32, f64>,
) {
let QueryCtx { n_docs, avgdl, limit } = *ctx;
for (b, band) in bands.iter() {
if band.is_empty() {
continue;
}
let bound = bm25_score(
f64::from(tf),
df,
n_docs,
f64::from(BAND_MIN_DL[b as usize]),
avgdl,
);
if single && scores.len() >= limit && bound < kth_of(scores, limit) {
break;
}
for &id in band {
let dl = f64::from(self.id_dl[id as usize]);
*scores.entry(id).or_insert(0.0) +=
bm25_score(f64::from(tf), df, n_docs, dl, avgdl);
}
}
}
fn probe_list(
&self,
list: &Buckets,
df: f64,
skip_tfs: &[u32],
ctx: &QueryCtx,
scores: &mut HashMap<u32, f64>,
) {
let ids: Vec<u32> = scores.keys().copied().collect();
for &id in &ids {
if let Some(tf) = list.get(id)
&& !skip_tfs.contains(&tf)
{
let dl = f64::from(self.id_dl[id as usize]);
*scores.get_mut(&id).expect("accumulated") +=
bm25_score(f64::from(tf), df, ctx.n_docs, dl, ctx.avgdl);
}
}
}
fn select_top(&self, scores: &HashMap<u32, f64>, limit: usize) -> Vec<TextMatch> {
let key_of = |id: u32| -> &[u8] {
self.id_key[id as usize].as_deref().expect("live posting id")
};
let mut top: Vec<(f64, &[u8])> = Vec::with_capacity(limit + 1);
for (id, score) in scores {
let cand = (*score, key_of(*id));
if top.len() < limit {
top.push(cand);
if top.len() == limit {
top.sort_by(|a, b| b.0.total_cmp(&a.0).then_with(|| a.1.cmp(b.1)));
}
} else if better(cand, top[limit - 1]) {
let pos = top.partition_point(|e| better(*e, cand));
top.insert(pos, cand);
top.pop();
}
}
if top.len() < limit {
top.sort_by(|a, b| b.0.total_cmp(&a.0).then_with(|| a.1.cmp(b.1)));
}
top.into_iter()
.map(|(score, k)| TextMatch { key: k.to_vec(), score })
.collect()
}
pub fn stats(&self) -> TextStats {
let postings: u64 = self.postings.values().map(|l| l.len() as u64).sum();
let many_postings: u64 = self
.postings
.values()
.map(|l| match l {
Buckets::One { .. } => 0,
Buckets::Many(m) => m.index.len() as u64,
})
.sum();
let token_bytes: u64 = self.postings.keys().map(|t| (t.len() + 48) as u64).sum();
let doc_bytes: u64 = self
.docs
.iter()
.map(|(k, (_, _, text))| (2 * k.len() + text.len() + 110) as u64)
.sum();
TextStats {
docs: self.docs.len() as u64,
tokens: self.postings.len() as u64,
postings,
approx_bytes: token_bytes + many_postings * 30 + doc_bytes,
}
}
pub fn contains(&self, key: &[u8]) -> bool {
self.docs.contains_key(key)
}
}
fn tf_of(toks: &[Vec<u8>]) -> HashMap<Vec<u8>, u32> {
let mut tf = HashMap::new();
for t in toks {
*tf.entry(t.clone()).or_insert(0) += 1;
}
tf
}
#[allow(clippy::float_cmp)]
fn better(a: (f64, &[u8]), b: (f64, &[u8])) -> bool {
a.0 > b.0 || (a.0 == b.0 && a.1 < b.1)
}
fn kth_of(scores: &HashMap<u32, f64>, limit: usize) -> f64 {
let mut v: Vec<f64> = scores.values().copied().collect();
let idx = limit - 1;
v.select_nth_unstable_by(idx, |a, b| b.total_cmp(a));
v[idx]
}
fn tail_bounds(lists: &[ScoredList<'_>]) -> Vec<f64> {
let mut acc = 0.0;
let mut v: Vec<f64> = lists
.iter()
.rev()
.map(|l| {
acc += l.2;
acc
})
.collect();
v.reverse();
v
}
#[cfg(test)]
#[path = "segment_tests.rs"]
mod tests;