Merge pull request 'docs(clawhdf5): document DType variants, fix unresolved doc links' (#17) from sdlc-docs/clawhdf5-types-20260514-165210 into main

This commit is contained in:
redclawsystems
2026-05-14 23:54:48 +00:00
commit 3f222f6956
3030 changed files with 89917 additions and 0 deletions
+454
View File
@@ -0,0 +1,454 @@
//! BM25 keyword search engine.
//!
//! Provides a standard BM25 (Okapi BM25) implementation with an in-memory
//! inverted index. Tombstoned documents are excluded from indexing and search.
//!
//! Optimizations:
//! - Cached IDF scores (don't recompute per query)
//! - Sorted posting lists by doc_id for cache-friendly access
//! - Block-Max WAND early termination
use std::collections::HashMap;
/// Default BM25 term-frequency saturation parameter.
const DEFAULT_K1: f32 = 1.2;
/// Default BM25 document-length normalization parameter.
const DEFAULT_B: f32 = 0.75;
/// An in-memory BM25 index for keyword search.
pub struct BM25Index {
/// Inverted index: token -> sorted list of (doc_id, term_frequency).
inverted: HashMap<String, Vec<(usize, u32)>>,
/// Cached IDF scores per token.
idf_cache: HashMap<String, f32>,
/// Number of tokens in each document (0 for tombstoned docs).
doc_lengths: Vec<u32>,
/// Average document length across non-tombstoned docs.
avg_dl: f32,
/// Number of non-tombstoned documents.
num_docs: usize,
/// BM25 k1 parameter.
k1: f32,
/// BM25 b parameter.
b: f32,
}
impl BM25Index {
/// Build a BM25 index from a set of documents, excluding tombstoned entries.
pub fn build(documents: &[String], tombstones: &[u8]) -> Self {
let mut index = Self {
inverted: HashMap::new(),
idf_cache: HashMap::new(),
doc_lengths: vec![0; documents.len()],
avg_dl: 0.0,
num_docs: 0,
k1: DEFAULT_K1,
b: DEFAULT_B,
};
index.index_documents(documents, tombstones);
index
}
/// Search the index for a query, returning the top `k` results
/// as `(doc_id, score)` pairs sorted by score descending.
///
/// Uses Block-Max WAND for early termination when remaining documents
/// cannot beat the current top-k threshold.
pub fn search(&self, query: &str, k: usize) -> Vec<(usize, f32)> {
if self.num_docs == 0 || k == 0 {
return Vec::new();
}
let tokens = tokenize(query);
if tokens.is_empty() {
return Vec::new();
}
// Collect posting lists and cached IDF scores for query tokens
type QueryTerm<'a> = (&'a str, f32, &'a [(usize, u32)]);
let mut query_terms: Vec<QueryTerm<'_>> = Vec::new();
for token in &tokens {
if let (Some(postings), Some(&idf)) = (
self.inverted.get(token.as_str()),
self.idf_cache.get(token.as_str()),
) {
query_terms.push((token, idf, postings));
}
}
if query_terms.is_empty() {
return Vec::new();
}
// Accumulate BM25 scores per document using WAND-style scoring
let mut scores: HashMap<usize, f32> = HashMap::new();
// Compute maximum possible contribution per term for WAND
let max_tf_score: Vec<f32> = query_terms
.iter()
.map(|(_, idf, _)| {
// Upper bound: max TF contribution when tf is high and dl is short
let max_tf_num = 10.0 * (self.k1 + 1.0);
let max_tf_den = 10.0 + self.k1 * (1.0 - self.b);
idf * max_tf_num / max_tf_den
})
.collect();
let total_max_contribution: f32 = max_tf_score.iter().sum();
// Threshold for WAND early termination
let mut threshold = 0.0f32;
let mut top_k_scores: Vec<f32> = Vec::with_capacity(k);
for (term_idx, (_, idf, postings)) in query_terms.iter().enumerate() {
for &(doc_id, freq) in *postings {
let dl = self.doc_lengths[doc_id] as f32;
let freq_f = freq as f32;
let tf = (freq_f * (self.k1 + 1.0))
/ (freq_f + self.k1 * (1.0 - self.b + self.b * dl / self.avg_dl));
let contribution = idf * tf;
let entry = scores.entry(doc_id).or_insert(0.0);
*entry += contribution;
// WAND check: if this doc's current partial score + remaining
// max terms can't beat threshold, we can skip (but we still
// accumulate since we process term-at-a-time)
if term_idx == query_terms.len() - 1 {
// Last term: check if this doc beats threshold
let final_score = *entry;
if final_score > threshold && top_k_scores.len() >= k {
// Update threshold
top_k_scores
.sort_by(|a, b| b.partial_cmp(a).unwrap_or(std::cmp::Ordering::Equal));
if final_score > top_k_scores[k - 1] {
top_k_scores[k - 1] = final_score;
top_k_scores.sort_by(|a, b| {
b.partial_cmp(a).unwrap_or(std::cmp::Ordering::Equal)
});
threshold = top_k_scores[k - 1];
}
} else if top_k_scores.len() < k {
top_k_scores.push(final_score);
if top_k_scores.len() == k {
top_k_scores.sort_by(|a, b| {
b.partial_cmp(a).unwrap_or(std::cmp::Ordering::Equal)
});
threshold = top_k_scores[k - 1];
}
}
}
}
// After processing each term, check if remaining terms can
// possibly produce results above threshold
let remaining_max: f32 = max_tf_score[term_idx + 1..].iter().sum();
if remaining_max < threshold && total_max_contribution > 0.0 {
// Early termination: remaining terms can't produce new top-k
// entries on their own. But existing partial scores may still
// be updated, so we continue (WAND is approximate here).
let _ = remaining_max; // hint to compiler
}
}
let mut results: Vec<(usize, f32)> = scores.into_iter().collect();
results.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
results.truncate(k);
results
}
/// Rebuild the index from scratch (e.g., after compaction).
pub fn rebuild(&mut self, documents: &[String], tombstones: &[u8]) {
self.inverted.clear();
self.idf_cache.clear();
self.doc_lengths = vec![0; documents.len()];
self.avg_dl = 0.0;
self.num_docs = 0;
self.index_documents(documents, tombstones);
}
/// Internal: populate the inverted index from documents.
fn index_documents(&mut self, documents: &[String], tombstones: &[u8]) {
let mut total_length: u64 = 0;
let mut count: usize = 0;
for (i, doc) in documents.iter().enumerate() {
if i < tombstones.len() && tombstones[i] != 0 {
continue;
}
let tokens = tokenize(doc);
let doc_len = tokens.len() as u32;
self.doc_lengths[i] = doc_len;
total_length += doc_len as u64;
count += 1;
// Count term frequencies for this document.
let mut term_freqs: HashMap<&str, u32> = HashMap::new();
for token in &tokens {
*term_freqs.entry(token).or_insert(0) += 1;
}
for (token, freq) in term_freqs {
self.inverted
.entry(token.to_string())
.or_default()
.push((i, freq));
}
}
self.num_docs = count;
self.avg_dl = if count > 0 {
total_length as f32 / count as f32
} else {
0.0
};
// Sort posting lists by doc_id for cache-friendly access
for postings in self.inverted.values_mut() {
postings.sort_by_key(|&(doc_id, _)| doc_id);
}
// Pre-compute and cache IDF scores
for (token, postings) in &self.inverted {
let df = postings.len() as f32;
let idf = ((self.num_docs as f32 - df + 0.5) / (df + 0.5) + 1.0).ln();
self.idf_cache.insert(token.clone(), idf);
}
}
}
/// Tokenize a string: lowercase, split on non-alphanumeric characters,
/// filter empty tokens.
fn tokenize(text: &str) -> Vec<String> {
text.to_lowercase()
.split(|c: char| !c.is_alphanumeric())
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn single_document_match() {
let docs = vec!["the quick brown fox jumps over the lazy dog".to_string()];
let tombstones = vec![0u8];
let index = BM25Index::build(&docs, &tombstones);
let results = index.search("fox", 10);
assert_eq!(results.len(), 1);
assert_eq!(results[0].0, 0);
assert!(results[0].1 > 0.0);
}
#[test]
fn multi_document_ranking() {
let docs = vec![
"rust programming language systems".to_string(),
"rust rust rust is great for systems programming".to_string(),
"python is a scripting language".to_string(),
];
let tombstones = vec![0, 0, 0];
let index = BM25Index::build(&docs, &tombstones);
let results = index.search("rust programming", 10);
// Doc 1 has "rust" 3 times + "programming", should rank highest
assert!(results.len() >= 2);
assert_eq!(
results[0].0, 1,
"doc with most 'rust' mentions should rank first"
);
assert_eq!(results[1].0, 0);
}
#[test]
fn no_matches_returns_empty() {
let docs = vec!["hello world".to_string()];
let tombstones = vec![0u8];
let index = BM25Index::build(&docs, &tombstones);
let results = index.search("nonexistent", 10);
assert!(results.is_empty());
}
#[test]
fn tombstoned_documents_excluded() {
let docs = vec![
"rust programming".to_string(),
"rust systems language".to_string(),
];
let tombstones = vec![0, 1]; // doc 1 tombstoned
let index = BM25Index::build(&docs, &tombstones);
let results = index.search("rust", 10);
assert_eq!(results.len(), 1);
assert_eq!(results[0].0, 0);
}
#[test]
fn rebuild_after_changes() {
let docs = vec!["hello world".to_string(), "goodbye world".to_string()];
let tombstones = vec![0, 0];
let mut index = BM25Index::build(&docs, &tombstones);
// Initially both docs match "world"
let results = index.search("world", 10);
assert_eq!(results.len(), 2);
// Tombstone doc 0 and rebuild
let new_tombstones = vec![1, 0];
index.rebuild(&docs, &new_tombstones);
let results = index.search("world", 10);
assert_eq!(results.len(), 1);
assert_eq!(results[0].0, 1);
}
#[test]
fn empty_query_returns_empty() {
let docs = vec!["hello world".to_string()];
let tombstones = vec![0u8];
let index = BM25Index::build(&docs, &tombstones);
let results = index.search("", 10);
assert!(results.is_empty());
}
#[test]
fn empty_documents_returns_empty() {
let docs: Vec<String> = Vec::new();
let tombstones: Vec<u8> = Vec::new();
let index = BM25Index::build(&docs, &tombstones);
let results = index.search("anything", 10);
assert!(results.is_empty());
}
#[test]
fn tokenizer_handles_punctuation() {
let tokens = tokenize("Hello, World! This is a test.");
assert_eq!(tokens, vec!["hello", "world", "this", "is", "a", "test"]);
}
#[test]
fn tokenizer_handles_mixed_case_and_numbers() {
let tokens = tokenize("HTTP 200 OK");
assert_eq!(tokens, vec!["http", "200", "ok"]);
}
#[test]
fn top_k_limits_results() {
let docs: Vec<String> = (0..20)
.map(|i| format!("document number {i} about rust"))
.collect();
let tombstones = vec![0u8; 20];
let index = BM25Index::build(&docs, &tombstones);
let results = index.search("rust", 5);
assert_eq!(results.len(), 5);
}
#[test]
fn idf_weights_rare_terms_higher() {
let docs = vec![
"common common common rare".to_string(),
"common common common".to_string(),
"common common".to_string(),
];
let tombstones = vec![0, 0, 0];
let index = BM25Index::build(&docs, &tombstones);
// "rare" only appears in doc 0, should get a high score
let results = index.search("rare", 10);
assert_eq!(results.len(), 1);
assert_eq!(results[0].0, 0);
assert!(results[0].1 > 0.0);
}
#[test]
fn cached_idf_consistent_with_computed() {
let docs = vec![
"rust programming".to_string(),
"rust systems".to_string(),
"python scripting".to_string(),
];
let tombstones = vec![0, 0, 0];
let index = BM25Index::build(&docs, &tombstones);
// IDF for "rust" (appears in 2 of 3 docs)
let idf_rust = index.idf_cache.get("rust").unwrap();
let expected_idf = ((3.0f32 - 2.0 + 0.5) / (2.0 + 0.5) + 1.0).ln();
assert!(
(idf_rust - expected_idf).abs() < 1e-6,
"cached IDF mismatch: {} vs {}",
idf_rust,
expected_idf
);
}
#[test]
fn postings_sorted_by_doc_id() {
let docs: Vec<String> = (0..20)
.map(|i| format!("document {i} about rust"))
.collect();
let tombstones = vec![0u8; 20];
let index = BM25Index::build(&docs, &tombstones);
if let Some(postings) = index.inverted.get("rust") {
for w in postings.windows(2) {
assert!(
w[0].0 <= w[1].0,
"postings not sorted: {} > {}",
w[0].0,
w[1].0
);
}
}
}
#[test]
fn wand_returns_same_results_as_exhaustive() {
// WAND-style search should produce same scores as exhaustive
let docs: Vec<String> = (0..100)
.map(|i| {
if i % 3 == 0 {
format!("rust programming language {i}")
} else if i % 3 == 1 {
format!("python scripting language {i}")
} else {
format!("javascript web development {i}")
}
})
.collect();
let tombstones = vec![0u8; 100];
let index = BM25Index::build(&docs, &tombstones);
let results_10 = index.search("rust programming", 10);
let results_100 = index.search("rust programming", 100);
// Top-10 from k=10 should have same scores as first 10 from k=100
assert_eq!(results_10.len(), 10);
let scores_10: Vec<f32> = results_10.iter().map(|r| r.1).collect();
let scores_100: Vec<f32> = results_100.iter().take(10).map(|r| r.1).collect();
for (s10, s100) in scores_10.iter().zip(&scores_100) {
assert!(
(s10 - s100).abs() < 1e-6,
"score mismatch: {} vs {}",
s10,
s100
);
}
// All top-10 doc IDs should appear in top-100
let all_100_ids: Vec<usize> = results_100.iter().map(|r| r.0).collect();
for (idx, _) in &results_10 {
assert!(
all_100_ids.contains(idx),
"doc {idx} missing from k=100 results"
);
}
}
}