use std::collections::HashMap;
use semtree_core::Chunk;
const K1: f32 = 1.2;
const B: f32 = 0.75;
const NAME_BOOST: usize = 3;
pub struct LexicalIndex {
ids: Vec<String>,
doc_len: Vec<f32>,
avgdl: f32,
postings: HashMap<String, Vec<(usize, u32)>>,
}
impl LexicalIndex {
pub fn from_chunks<'a>(chunks: impl Iterator<Item = &'a Chunk>) -> Self {
let mut ids = Vec::new();
let mut doc_len = Vec::new();
let mut postings: HashMap<String, Vec<(usize, u32)>> = HashMap::new();
for chunk in chunks {
let doc_idx = ids.len();
ids.push(chunk.id.clone());
let mut text = String::new();
if let Some(name) = &chunk.name {
for _ in 0..NAME_BOOST {
text.push_str(name);
text.push(' ');
}
}
text.push_str(&chunk.content);
let tokens = tokenize(&text);
doc_len.push(tokens.len() as f32);
let mut tf: HashMap<String, u32> = HashMap::new();
for t in tokens {
*tf.entry(t).or_insert(0) += 1;
}
for (term, count) in tf {
postings.entry(term).or_default().push((doc_idx, count));
}
}
let n = ids.len().max(1) as f32;
let avgdl = doc_len.iter().sum::<f32>() / n;
let avgdl = if avgdl == 0.0 { 1.0 } else { avgdl };
Self {
ids,
doc_len,
avgdl,
postings,
}
}
pub fn len(&self) -> usize {
self.ids.len()
}
pub fn is_empty(&self) -> bool {
self.ids.is_empty()
}
pub fn search(&self, query: &str, limit: usize) -> Vec<(String, f32)> {
let n = self.ids.len() as f32;
let mut q_terms = tokenize(query);
q_terms.sort();
q_terms.dedup();
let mut scores: HashMap<usize, f32> = HashMap::new();
for term in q_terms {
let Some(postings) = self.postings.get(&term) else {
continue;
};
let df = postings.len() as f32;
let idf = (1.0 + (n - df + 0.5) / (df + 0.5)).ln();
for &(doc, tf) in postings {
let tf = tf as f32;
let dl = self.doc_len[doc];
let denom = tf + K1 * (1.0 - B + B * dl / self.avgdl);
*scores.entry(doc).or_insert(0.0) += idf * (tf * (K1 + 1.0)) / denom;
}
}
let mut ranked: Vec<(usize, f32)> = scores.into_iter().collect();
ranked.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
ranked.truncate(limit);
ranked
.into_iter()
.map(|(doc, score)| (self.ids[doc].clone(), score))
.collect()
}
}
fn tokenize(text: &str) -> Vec<String> {
let mut tokens = Vec::new();
let mut buf = String::new();
for ch in text.chars() {
if ch.is_alphanumeric() {
buf.push(ch);
} else if !buf.is_empty() {
split_camel(&buf, &mut tokens);
buf.clear();
}
}
if !buf.is_empty() {
split_camel(&buf, &mut tokens);
}
tokens
}
fn split_camel(word: &str, out: &mut Vec<String>) {
let chars: Vec<char> = word.chars().collect();
let mut start = 0;
for i in 1..chars.len() {
if chars[i - 1].is_lowercase() && chars[i].is_uppercase() {
push_token(&chars[start..i], out);
start = i;
}
}
push_token(&chars[start..], out);
}
fn push_token(chars: &[char], out: &mut Vec<String>) {
if chars.len() >= 2 {
out.push(chars.iter().collect::<String>().to_lowercase());
}
}
#[cfg(test)]
mod tests {
use super::*;
use semtree_core::{ChunkKind, Language, Span};
use std::path::PathBuf;
fn chunk(id: &str, name: &str, content: &str) -> Chunk {
Chunk {
id: id.to_string(),
path: PathBuf::from(format!("{id}.rs")),
language: Language::Rust,
kind: ChunkKind::Function,
name: Some(name.to_string()),
content: content.to_string(),
span: Span::new(0, content.len(), 0, 0),
doc: None,
}
}
#[test]
fn tokenizer_splits_camel_and_snake() {
let toks = tokenize("TokenBucket rate_limit HTTPServer");
assert!(toks.contains(&"token".to_string()));
assert!(toks.contains(&"bucket".to_string()));
assert!(toks.contains(&"rate".to_string()));
assert!(toks.contains(&"limit".to_string()));
}
#[test]
fn bm25_ranks_identifier_match_first() {
let chunks = [
chunk(
"a",
"TokenBucket",
"fn refill(&mut self) { self.tokens += 1; }",
),
chunk("b", "parse_config", "fn parse_config() { read_file(); }"),
chunk(
"c",
"log_message",
"fn log_message(msg: &str) { println!(); }",
),
];
let idx = LexicalIndex::from_chunks(chunks.iter());
let hits = idx.search("token bucket throttling", 3);
assert_eq!(hits.first().map(|(id, _)| id.as_str()), Some("a"));
}
#[test]
fn empty_query_returns_nothing() {
let chunks = [chunk("a", "foo", "bar")];
let idx = LexicalIndex::from_chunks(chunks.iter());
assert!(idx.search("", 5).is_empty());
}
}