const K1: f64 = 1.2;
const B: f64 = 0.75;
pub(crate) struct Entry {
pub id: String,
pub text: String,
}
pub(crate) struct Index {
documents: Vec<Document>,
average_length: f64,
total: usize,
}
struct Document {
id: String,
terms: Vec<String>,
length: f64,
}
impl Index {
pub(crate) fn build(entries: Vec<Entry>) -> Self {
let documents: Vec<Document> = entries
.into_iter()
.map(|entry| {
let terms = tokenize(&entry.text);
let length = terms.len() as f64;
Document { id: entry.id, terms, length }
})
.collect();
let total = documents.len();
let average_length = if total == 0 {
0.0
} else {
documents.iter().map(|d| d.length).sum::<f64>() / total as f64
};
Self { documents, average_length, total }
}
pub(crate) fn is_empty(&self) -> bool {
self.total == 0
}
pub(crate) fn search(&self, query: &str, limit: usize) -> Vec<&str> {
if self.is_empty() {
return Vec::new();
}
let terms = tokenize(query);
let mut scored: Vec<(f64, &str)> = self
.documents
.iter()
.map(|doc| (self.score(doc, &terms), doc.id.as_str()))
.filter(|(score, _)| *score > 0.0)
.collect();
scored.sort_by(|a, b| b.0.total_cmp(&a.0).then(a.1.cmp(b.1)));
scored.into_iter().take(limit).map(|(_, id)| id).collect()
}
fn score(&self, doc: &Document, terms: &[String]) -> f64 {
terms.iter().map(|term| self.term_score(doc, term)).sum()
}
fn term_score(&self, doc: &Document, term: &str) -> f64 {
let frequency = doc.terms.iter().filter(|t| *t == term).count() as f64;
if frequency == 0.0 {
return 0.0;
}
let containing = self.documents.iter().filter(|d| d.terms.iter().any(|t| t == term)).count() as f64;
let idf = (((self.total as f64 - containing + 0.5) / (containing + 0.5)) + 1.0).ln().max(0.0);
let normalised = doc.length / self.average_length.max(1.0);
idf * (frequency * (K1 + 1.0)) / (frequency + K1 * (1.0 - B + B * normalised))
}
}
fn tokenize(text: &str) -> Vec<String> {
text.split(|c: char| !c.is_alphanumeric())
.filter(|word| !word.is_empty())
.map(str::to_lowercase)
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn index() -> Index {
Index::build(vec![
Entry { id: "github_create_issue".into(), text: "github create issue open a new issue on a repository".into() },
Entry { id: "github_list_prs".into(), text: "github list pull requests for a repository".into() },
Entry { id: "slack_post".into(), text: "slack post a message to a channel".into() },
Entry { id: "db_query".into(), text: "run a read only sql query against the database".into() },
])
}
#[test]
fn a_query_finds_the_tool_it_describes() {
let index = index();
assert_eq!(index.search("file a bug on github", 3).first().copied(), Some("github_create_issue"));
assert_eq!(index.search("send a slack message", 3).first().copied(), Some("slack_post"));
assert_eq!(index.search("sql", 3), vec!["db_query"]);
}
#[test]
fn a_query_matching_nothing_returns_nothing() {
let index = index();
assert!(index.search("photosynthesis", 5).is_empty());
assert!(Index::build(Vec::new()).search("anything", 5).is_empty());
}
#[test]
fn a_term_common_to_everything_does_not_decide_the_ranking() {
let index = index();
let both = index.search("github", 5);
assert_eq!(both.len(), 2, "got {both:?}");
assert_eq!(index.search("github pull requests", 5).first().copied(), Some("github_list_prs"));
}
#[test]
fn results_are_capped_and_ordered_the_same_way_twice() {
let index = index();
assert_eq!(index.search("github repository issue", 1).len(), 1, "the limit is honoured");
assert_eq!(index.search("github repository", 5), index.search("github repository", 5));
}
#[test]
fn identifiers_split_so_a_plain_word_matches() {
let index = Index::build(vec![Entry { id: "x".into(), text: "read_file fetches a path".into() }]);
assert_eq!(index.search("read", 3), vec!["x"]);
assert_eq!(index.search("file", 3), vec!["x"]);
}
}