use std::cmp::Ordering;
use ahash::{AHashMap, AHashSet};
use crate::chunk::CodeChunk;
use crate::index::{IndexDb, keys};
const MAX_TERM_LEN: usize = 80;
pub const BM25_K1: f32 = 1.2;
pub const BM25_B: f32 = 0.75;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ChunkPosting {
pub chunk_id: String,
pub doclen: u32,
pub terms: Vec<(String, u32)>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Bm25Hit {
pub chunk_id: String,
pub score: f32,
}
fn for_each_token(text: &str, mut f: impl FnMut(&str)) {
let mut current = String::new();
for ch in text.chars() {
if ch.is_alphanumeric() {
current.extend(ch.to_lowercase());
} else if !current.is_empty() {
if current.len() <= MAX_TERM_LEN {
f(¤t);
}
current.clear();
}
}
if !current.is_empty() && current.len() <= MAX_TERM_LEN {
f(¤t);
}
}
fn tokenize_counts(text: &str) -> AHashMap<String, u32> {
let mut counts: AHashMap<String, u32> = AHashMap::new();
for_each_token(text, |tok| {
if let Some(count) = counts.get_mut(tok) {
*count += 1;
} else {
counts.insert(tok.to_string(), 1);
}
});
counts
}
fn tokenize_query(query: &str) -> Vec<String> {
let mut seen: AHashSet<String> = AHashSet::new();
for_each_token(query, |tok| {
seen.insert(tok.to_string());
});
seen.into_iter().collect()
}
pub fn build_chunk_postings(chunks: &[CodeChunk]) -> Vec<ChunkPosting> {
chunks
.iter()
.map(|c| {
let counts = tokenize_counts(&c.searchable_text);
let doclen: u32 = counts.values().copied().sum();
let terms: Vec<(String, u32)> = counts.into_iter().collect();
ChunkPosting {
chunk_id: c.chunk_id.clone(),
doclen,
terms,
}
})
.collect()
}
pub fn bm25_idf(n: u64, df: u64) -> f32 {
let numerator = (n as f32) - (df as f32) + 0.5;
let denominator = (df as f32) + 0.5;
(1.0 + numerator / denominator).ln().max(0.0)
}
pub fn bm25_term_score(tf: u32, doclen: u32, avgdl: f32, idf: f32) -> f32 {
let tf = tf as f32;
let norm = 1.0 - BM25_B + BM25_B * (doclen as f32) / avgdl;
idf * (tf * (BM25_K1 + 1.0)) / (tf + BM25_K1 * norm)
}
pub fn bm25_search(db: &IndexDb, query: &str, limit: usize) -> Vec<Bm25Hit> {
let terms = tokenize_query(query);
if terms.is_empty() || limit == 0 {
return Vec::new();
}
let Some((n, total_len)) = db.bm25_stats() else {
return Vec::new();
};
if n == 0 {
return Vec::new();
}
let avgdl = (total_len as f32 / n as f32).max(1.0);
let mut scores: AHashMap<String, f32> = AHashMap::new();
for term in &terms {
let mut postings: Vec<(String, u32, u32)> = Vec::new();
for guard in db.code_bm25_postings.prefix(keys::code_bm25_postings_prefix(term)) {
let (k, v) = match guard.into_inner() {
Ok(kv) => kv,
Err(error) => {
tracing::warn!(%term, ?error, "bm25: posting read failed; skipping entry");
continue;
}
};
if let (Some(chunk_id), Some((tf, doclen))) = (
keys::parse_code_bm25_posting_chunk_id(&k),
keys::parse_code_bm25_posting_value(&v),
) {
postings.push((chunk_id.to_string(), tf, doclen));
}
}
if postings.is_empty() {
continue;
}
let idf = bm25_idf(n, postings.len() as u64);
for (chunk_id, tf, doclen) in postings {
*scores.entry(chunk_id).or_insert(0.0) += bm25_term_score(tf, doclen, avgdl, idf);
}
}
let mut hits: Vec<Bm25Hit> = scores
.into_iter()
.map(|(chunk_id, score)| Bm25Hit { chunk_id, score })
.collect();
hits.sort_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(Ordering::Equal)
.then_with(|| a.chunk_id.cmp(&b.chunk_id))
});
hits.truncate(limit);
hits
}
#[cfg(test)]
mod tests {
use super::*;
fn chunk(chunk_id: &str, searchable_text: &str) -> CodeChunk {
CodeChunk {
chunk_id: chunk_id.to_string(),
path: "src/lib.rs".to_string(),
lang: "rust".to_string(),
kind: None,
symbol: None,
signature: None,
doc: None,
byte_start: 0,
byte_end: 0,
line_start: 1,
line_end: 1,
text: searchable_text.to_string(),
searchable_text: searchable_text.to_string(),
}
}
#[test]
fn tokenizer_splits_snake_case_and_lowercases() {
let counts = tokenize_counts("fn find_references(Spawn) spawn spawn");
assert_eq!(counts.get("find"), Some(&1));
assert_eq!(counts.get("references"), Some(&1));
assert_eq!(counts.get("spawn"), Some(&3)); assert_eq!(counts.get("fn"), Some(&1));
}
#[test]
fn tokenizer_drops_oversized_tokens() {
let huge = "x".repeat(MAX_TERM_LEN + 1);
let counts = tokenize_counts(&format!("keep {huge} keep"));
assert_eq!(counts.get("keep"), Some(&2));
assert!(counts.get(huge.as_str()).is_none(), "oversized token must be dropped");
}
#[test]
fn build_postings_reports_doclen_as_total_token_count() {
let postings = build_chunk_postings(&[chunk("h:0", "alpha beta alpha")]);
assert_eq!(postings.len(), 1);
assert_eq!(postings[0].chunk_id, "h:0");
assert_eq!(postings[0].doclen, 3, "three tokens total incl. the repeat");
let alpha = postings[0].terms.iter().find(|(t, _)| t == "alpha").unwrap();
assert_eq!(alpha.1, 2, "alpha appears twice");
}
#[test]
fn idf_is_non_negative_even_for_ubiquitous_terms() {
assert!(bm25_idf(100, 100) >= 0.0);
assert!(bm25_idf(100, 1) > bm25_idf(100, 50));
}
#[test]
fn term_score_rewards_higher_tf_and_penalizes_length() {
let idf = bm25_idf(100, 10);
assert!(bm25_term_score(5, 100, 100.0, idf) > bm25_term_score(1, 100, 100.0, idf));
assert!(bm25_term_score(3, 400, 100.0, idf) < bm25_term_score(3, 50, 100.0, idf));
}
}