use std::collections::HashMap;
use urna_format::encoding::{pack_u64s, unpack_u64s};
use urna_format::error::UrnaError;
use super::index::{BM25_PAYLOAD_VERSION, Bm25Index, Posting, TermEntry};
use crate::error::RuntimeError;
use urna_format::bytes::{le_f32, le_u32};
impl Bm25Index {
pub fn to_bytes(&self) -> Vec<u8> {
let mut out = Vec::new();
out.extend_from_slice(&BM25_PAYLOAD_VERSION.to_le_bytes());
out.extend_from_slice(&self.k1.to_le_bytes());
out.extend_from_slice(&self.b.to_le_bytes());
out.extend_from_slice(&self.avgdl.to_le_bytes());
out.extend_from_slice(&(self.n_docs as u32).to_le_bytes());
out.extend_from_slice(&(self.n_terms as u32).to_le_bytes());
let dls: Vec<u64> = self.doc_lengths.iter().map(|&x| x as u64).collect();
write_blob(&mut out, &pack_u64s(&dls));
let mut terms: Vec<(&String, &TermEntry)> = self.terms.iter().collect();
terms.sort_by(|a, b| a.0.cmp(b.0));
let mut doc_gaps: Vec<u64> = Vec::new();
let mut tfs: Vec<u64> = Vec::new();
for (term, entry) in &terms {
let bs = term.as_bytes();
out.extend_from_slice(&(bs.len() as u32).to_le_bytes());
out.extend_from_slice(bs);
out.extend_from_slice(&entry.df.to_le_bytes());
let mut prev = 0u32;
for p in &entry.postings {
doc_gaps.push(p.doc.wrapping_sub(prev) as u64);
prev = p.doc;
tfs.push(p.tf as u64);
}
}
write_blob(&mut out, &pack_u64s(&doc_gaps));
write_blob(&mut out, &pack_u64s(&tfs));
out
}
pub fn from_bytes(bytes: &[u8]) -> Result<Self, RuntimeError> {
let mut cur = ByteCursor::new(bytes);
let version = cur.u32()?;
let k1 = cur.f32()?;
let b = cur.f32()?;
let avgdl = cur.f32()?;
let n_docs = cur.u32()? as usize;
let n_terms = cur.u32()? as usize;
let (doc_lengths, terms) = match version {
1 => decode_v1(&mut cur, n_docs, n_terms)?,
2 => decode_v2(&mut cur, n_docs, n_terms)?,
other => {
return Err(RuntimeError::Format(UrnaError::UnsupportedSectionVersion {
section_id: urna_format::layout::SECTION_BM25_INDEX,
version: other,
}));
}
};
if !(k1.is_finite() && b.is_finite() && avgdl.is_finite()) {
return Err(malformed("bm25: non-finite k1 / b / avgdl"));
}
if doc_lengths.len() != n_docs {
return Err(malformed("bm25: doc-length count != n_docs"));
}
for entry in terms.values() {
if entry.postings.iter().any(|p| p.doc as usize >= n_docs) {
return Err(malformed("bm25: posting doc id >= n_docs"));
}
}
Ok(Self {
k1,
b,
avgdl,
n_docs,
n_terms,
doc_lengths,
terms,
})
}
}
fn malformed(reason: impl Into<String>) -> RuntimeError {
RuntimeError::Format(UrnaError::MalformedSectionPayload {
section_id: urna_format::layout::SECTION_BM25_INDEX,
reason: reason.into(),
})
}
fn write_blob(out: &mut Vec<u8>, blob: &[u8]) {
out.extend_from_slice(&(blob.len() as u32).to_le_bytes());
out.extend_from_slice(blob);
}
const MAX_PREALLOC: usize = 1 << 20;
type Decoded = (Vec<u32>, HashMap<String, TermEntry>);
fn decode_v1(cur: &mut ByteCursor, n_docs: usize, n_terms: usize) -> Result<Decoded, RuntimeError> {
let mut doc_lengths = Vec::with_capacity(n_docs.min(MAX_PREALLOC));
for _ in 0..n_docs {
doc_lengths.push(cur.u32()?);
}
let mut terms: HashMap<String, TermEntry> = HashMap::with_capacity(n_terms.min(MAX_PREALLOC));
for _ in 0..n_terms {
let term = cur.term()?;
let df = cur.u32()?;
let mut postings = Vec::with_capacity((df as usize).min(MAX_PREALLOC));
for _ in 0..df {
let doc = cur.u32()?;
let tf = cur.u32()?;
postings.push(Posting { doc, tf });
}
terms.insert(term, TermEntry { df, postings });
}
Ok((doc_lengths, terms))
}
fn decode_v2(cur: &mut ByteCursor, n_docs: usize, n_terms: usize) -> Result<Decoded, RuntimeError> {
let dls = cur.intpack_column()?;
if dls.len() != n_docs {
return Err(malformed("bm25 v2: doc-length column mismatch"));
}
let doc_lengths: Vec<u32> = dls.iter().map(|&x| x as u32).collect();
let mut dict: Vec<(String, u32)> = Vec::with_capacity(n_terms.min(MAX_PREALLOC));
let mut total: usize = 0;
for _ in 0..n_terms {
let term = cur.term()?;
let df = cur.u32()?;
total = total.saturating_add(df as usize);
dict.push((term, df));
}
let gaps = cur.intpack_column()?;
let tfs = cur.intpack_column()?;
if gaps.len() != total || tfs.len() != total {
return Err(malformed("bm25 v2: posting column mismatch"));
}
let mut terms: HashMap<String, TermEntry> = HashMap::with_capacity(n_terms.min(MAX_PREALLOC));
let mut k = 0usize;
for (term, df) in dict {
let mut postings = Vec::with_capacity((df as usize).min(MAX_PREALLOC));
let mut prev = 0u32;
for _ in 0..df {
let doc = prev.wrapping_add(gaps[k] as u32);
prev = doc;
postings.push(Posting {
doc,
tf: tfs[k] as u32,
});
k += 1;
}
terms.insert(term, TermEntry { df, postings });
}
Ok((doc_lengths, terms))
}
struct ByteCursor<'a> {
buf: &'a [u8],
pos: usize,
}
impl<'a> ByteCursor<'a> {
fn new(buf: &'a [u8]) -> Self {
Self { buf, pos: 0 }
}
fn u32(&mut self) -> Result<u32, RuntimeError> {
Ok(le_u32(self.bytes(4)?)?)
}
fn f32(&mut self) -> Result<f32, RuntimeError> {
Ok(le_f32(self.bytes(4)?)?)
}
fn bytes(&mut self, n: usize) -> Result<&'a [u8], RuntimeError> {
if n > self.buf.len() - self.pos {
return Err(malformed(format!("bm25: unexpected EOF (need {})", n)));
}
let out = &self.buf[self.pos..self.pos + n];
self.pos += n;
Ok(out)
}
fn term(&mut self) -> Result<String, RuntimeError> {
let len = self.u32()? as usize;
let b = self.bytes(len)?;
std::str::from_utf8(b)
.map(|s| s.to_string())
.map_err(|e| malformed(format!("term utf-8: {}", e)))
}
fn intpack_column(&mut self) -> Result<Vec<u64>, RuntimeError> {
let len = self.u32()? as usize;
let blob = self.bytes(len)?;
unpack_u64s(blob).map_err(RuntimeError::Format)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn v1_header(n_docs: u32, n_terms: u32) -> Vec<u8> {
let mut out = 1u32.to_le_bytes().to_vec(); out.extend_from_slice(&1.2f32.to_le_bytes()); out.extend_from_slice(&0.75f32.to_le_bytes()); out.extend_from_slice(&1.0f32.to_le_bytes()); out.extend_from_slice(&n_docs.to_le_bytes());
out.extend_from_slice(&n_terms.to_le_bytes());
out
}
#[test]
fn v1_hostile_n_docs_errors_without_ooming() {
let p = v1_header(u32::MAX, 0); assert!(Bm25Index::from_bytes(&p).is_err());
}
#[test]
fn v1_hostile_df_errors_without_ooming() {
let mut p = v1_header(0, 1);
p.extend_from_slice(&1u32.to_le_bytes()); p.extend_from_slice(b"a"); p.extend_from_slice(&u32::MAX.to_le_bytes()); assert!(Bm25Index::from_bytes(&p).is_err());
}
}