llm_kernel/search/
provider.rs1use crate::search::SearchResult;
8
9pub trait SearchProvider: Send + Sync {
11 fn name(&self) -> &'static str;
13 fn search(&self, query: &str, limit: usize) -> crate::error::Result<Vec<SearchResult>>;
15}
16
17pub struct KeywordIndex {
24 docs: Vec<(String, String)>,
26}
27
28impl KeywordIndex {
29 pub fn new(docs: Vec<(String, String)>) -> Self {
31 Self { docs }
32 }
33}
34
35impl SearchProvider for KeywordIndex {
36 fn name(&self) -> &'static str {
37 "keyword"
38 }
39
40 fn search(&self, query: &str, limit: usize) -> crate::error::Result<Vec<SearchResult>> {
41 if query.is_empty() {
42 return Ok(Vec::new());
43 }
44 let query_lower = query.to_lowercase();
45 let terms: Vec<&str> = query_lower.split_ascii_whitespace().collect();
46
47 let mut scored: Vec<SearchResult> = self
48 .docs
49 .iter()
50 .filter_map(|(id, text)| {
51 let text_lower = text.to_lowercase();
52 let mut count: usize = 0;
53 for term in &terms {
54 count += text_lower.matches(term).count();
55 }
56 if count == 0 {
57 None
58 } else {
59 Some(SearchResult {
60 id: id.clone(),
61 score: count as f32,
62 text: text.clone(),
63 })
64 }
65 })
66 .collect();
67
68 scored.sort_by(|a, b| {
69 b.score
70 .partial_cmp(&a.score)
71 .unwrap_or(std::cmp::Ordering::Equal)
72 });
73 scored.truncate(limit);
74 Ok(scored)
75 }
76}
77
78#[cfg(test)]
79mod tests {
80 use super::*;
81
82 fn sample_docs() -> Vec<(String, String)> {
83 vec![
84 (
85 "d1".to_string(),
86 "the rust programming language is fast".to_string(),
87 ),
88 (
89 "d2".to_string(),
90 "python is a popular programming language".to_string(),
91 ),
92 (
93 "d3".to_string(),
94 "rust has zero cost abstractions".to_string(),
95 ),
96 ("d4".to_string(), "cooking recipes for dinner".to_string()),
97 ]
98 }
99
100 #[test]
101 fn all_terms_outranks_fewer() {
102 let index = KeywordIndex::new(sample_docs());
103 let results = index.search("rust programming language", 10).unwrap();
104 assert_eq!(results[0].id, "d1");
106 let d1 = results.iter().find(|r| r.id == "d1").unwrap().score;
107 let d2 = results.iter().find(|r| r.id == "d2").unwrap().score;
108 let d3 = results.iter().find(|r| r.id == "d3").unwrap().score;
109 assert!(d1 > d2);
110 assert!(d2 > d3);
111 }
112
113 #[test]
114 fn empty_query_returns_empty() {
115 let index = KeywordIndex::new(sample_docs());
116 let results = index.search("", 10).unwrap();
117 assert!(results.is_empty());
118 }
119
120 #[test]
121 fn limit_is_respected() {
122 let index = KeywordIndex::new(sample_docs());
123 let results = index.search("programming language", 1).unwrap();
124 assert_eq!(results.len(), 1);
125 }
126
127 #[test]
128 fn unknown_term_returns_only_matches() {
129 let index = KeywordIndex::new(sample_docs());
130 let results = index.search("rust xyzzy", 10).unwrap();
132 let ids: Vec<&str> = results.iter().map(|r| r.id.as_str()).collect();
133 assert!(ids.contains(&"d1"));
134 assert!(ids.contains(&"d3"));
135 assert!(!ids.contains(&"d2"));
136 assert!(!ids.contains(&"d4"));
137 }
138
139 #[test]
140 fn name_is_keyword() {
141 let index = KeywordIndex::new(vec![]);
142 assert_eq!(index.name(), "keyword");
143 }
144
145 #[test]
146 fn zero_score_docs_excluded() {
147 let index = KeywordIndex::new(sample_docs());
148 let results = index.search("rust", 10).unwrap();
149 assert!(results.iter().all(|r| r.id != "d4"));
151 }
152}