use std::collections::HashMap;
use std::path::{Path, PathBuf};
pub struct Bm25Index {
k1: f64,
b: f64,
paths: Vec<String>,
doc_len: Vec<f64>,
avgdl: f64,
postings: HashMap<String, Vec<(u32, u32)>>,
}
impl Bm25Index {
pub fn build_from_files(root: &Path, walked: &[PathBuf], k1: f64, b: f64) -> Self {
let docs = walked.iter().filter_map(|abs| {
let rel = abs
.strip_prefix(root)
.unwrap_or(abs)
.to_string_lossy()
.into_owned();
match std::fs::read_to_string(abs) {
Ok(content) => Some((rel, content)),
Err(_) => {
eprintln!("jevr: skipping unreadable {rel}");
None
}
}
});
Self::build(docs, k1, b)
}
pub fn build(docs: impl Iterator<Item = (String, String)>, k1: f64, b: f64) -> Self {
let mut paths = Vec::new();
let mut doc_len = Vec::new();
let mut postings: HashMap<String, Vec<(u32, u32)>> = HashMap::new();
for (path, content) in docs {
let doc = paths.len() as u32;
let mut tf: HashMap<String, u32> = HashMap::new();
let mut len = 0u64;
for token in tokenize(&content).into_iter().chain(tokenize(&path)) {
*tf.entry(token).or_insert(0) += 1;
len += 1;
}
for (term, count) in tf {
postings.entry(term).or_default().push((doc, count));
}
paths.push(path);
doc_len.push(len as f64);
}
let n = doc_len.len().max(1) as f64;
let avgdl = (doc_len.iter().sum::<f64>() / n).max(1.0);
Self {
k1,
b,
paths,
doc_len,
avgdl,
postings,
}
}
pub fn top(&self, query: &str, cap: usize) -> Vec<String> {
let n = self.paths.len() as f64;
let mut scores: HashMap<u32, f64> = HashMap::new();
let mut terms = tokenize(query);
terms.sort_unstable();
terms.dedup();
for term in &terms {
let Some(list) = self.postings.get(term) else {
continue;
};
let df = list.len() as f64;
let idf = (1.0 + (n - df + 0.5) / (df + 0.5)).ln();
for &(doc, tf) in list {
let tf = tf as f64;
let norm = 1.0 - self.b + self.b * self.doc_len[doc as usize] / self.avgdl;
*scores.entry(doc).or_insert(0.0) +=
idf * tf * (self.k1 + 1.0) / (tf + self.k1 * norm);
}
}
let mut ranked: Vec<(f64, u32)> = scores
.into_iter()
.filter(|(_, s)| *s > 0.0)
.map(|(doc, s)| (s, doc))
.collect();
ranked.sort_by(|a, b| b.0.total_cmp(&a.0).then(a.1.cmp(&b.1)));
ranked
.into_iter()
.take(cap)
.map(|(_, doc)| self.paths[doc as usize].clone())
.collect()
}
}
pub fn tokenize(text: &str) -> Vec<String> {
let mut tokens = Vec::new();
for word in text.split(|c: char| !c.is_alphanumeric() && c != '_') {
if word.is_empty() || word.len() > 40 {
continue;
}
let before = tokens.len();
for part in word.split('_') {
camel_split(part, &mut tokens);
}
if tokens.len() > before + 1 && word.chars().any(char::is_alphabetic) {
tokens.push(word.to_lowercase());
}
}
tokens
}
fn camel_split(part: &str, out: &mut Vec<String>) {
let chars: Vec<char> = part.chars().collect();
let mut start = 0;
let mut index = 0;
while index < chars.len() {
let c = chars[index];
let boundary = index > start
&& (
(c.is_ascii_uppercase() && !chars[index - 1].is_ascii_uppercase())
|| (c.is_ascii_lowercase()
&& chars[index - 1].is_ascii_uppercase()
&& index - 1 > start)
|| (c.is_ascii_digit() != chars[index - 1].is_ascii_digit())
);
if boundary {
let end = if c.is_ascii_lowercase() && chars[index - 1].is_ascii_uppercase() {
index - 1
} else {
index
};
push_run(&chars[start..end], out);
start = end;
}
index += 1;
}
push_run(&chars[start..], out);
}
fn push_run(run: &[char], out: &mut Vec<String>) {
if run.is_empty() || run.iter().all(|c| c.is_ascii_digit()) {
return;
}
out.push(run.iter().collect::<String>().to_lowercase());
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tokenizer_splits_snake_camel_and_acronyms() {
assert_eq!(
tokenize("sendCampaign snake_case HTTPResponse"),
vec![
"send",
"campaign",
"sendcampaign",
"snake",
"case",
"snake_case",
"http",
"response",
"httpresponse",
]
);
}
#[test]
fn tokenizer_keeps_non_ascii_prose_words_whole() {
assert_eq!(tokenize("Café in Zürich"), vec!["café", "in", "zürich"]);
}
#[test]
fn tokenizer_drops_numbers_and_oversized_blobs() {
let blob = "x".repeat(41);
assert_eq!(
tokenize(&format!("42 {blob} v2ray")),
vec!["v", "ray", "v2ray"]
);
}
#[test]
fn top_ranks_matching_file_first_and_respects_cap() {
let docs = vec![
(
"kelly.py".into(),
"def kelly_criterion(bankroll): pass".into(),
),
("fees.py".into(), "def taker_fee(): pass".into()),
("misc.py".into(), "unrelated content entirely".into()),
];
let index = Bm25Index::build(docs.into_iter(), 1.2, 0.75);
assert_eq!(index.top("kelly criterion bet size", 2), vec!["kelly.py"]);
assert!(index.top("zzz-no-such-term", 30).is_empty());
}
#[test]
fn path_tokens_match_even_without_content_hits() {
let docs = vec![
("src/circuit_breaker.py".into(), "x = 1".into()),
("src/other.py".into(), "y = 2".into()),
];
let index = Bm25Index::build(docs.into_iter(), 1.2, 0.75);
assert_eq!(
index.top("circuit breaker", 30),
vec!["src/circuit_breaker.py"]
);
}
}