1use std::collections::{HashMap, HashSet};
2use std::sync::{Mutex, MutexGuard, PoisonError};
3
4use super::tokenize::{parse_search_terms, SearchTerm};
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum SearchMode {
8 All,
9 Any,
10}
11
12#[derive(Debug, Default)]
13pub struct CorpusDelta {
14 pub added: HashMap<String, Vec<String>>,
15 pub removed: HashSet<String>,
16}
17
18struct StagedDocument {
19 length: usize,
20 frequencies: HashMap<String, u32>,
21}
22
23struct Corpus {
24 documents: i64,
25 total_tokens: i64,
26 staged: HashMap<String, StagedDocument>,
27 hidden: HashSet<String>,
28}
29
30struct Posting {
31 key: String,
32 frequency: u32,
33}
34
35const K1: f64 = 1.2;
36const B: f64 = 0.75;
37const SPLICED_TOKENS: usize = 16;
38
39fn distinct_terms(query: &str) -> Vec<SearchTerm> {
40 let mut seen = HashSet::new();
41 let mut terms = Vec::new();
42 for term in parse_search_terms(query) {
43 let identity = if term.prefix {
44 format!("{}*", term.token)
45 } else {
46 term.token.clone()
47 };
48 if seen.insert(identity) {
49 terms.push(term);
50 }
51 }
52 terms
53}
54
55#[allow(dead_code)]
61pub fn matches_terms(tokens: &[String], query: &str, mode: SearchMode) -> bool {
62 let terms = distinct_terms(query);
63 if terms.is_empty() {
64 return false;
65 }
66 let mut hits = 0;
67 for term in &terms {
68 let hit = tokens.iter().any(|token| {
69 if term.prefix {
70 token.starts_with(&term.token)
71 } else {
72 token == &term.token
73 }
74 });
75 if hit {
76 hits += 1;
77 } else if mode == SearchMode::All {
78 return false;
79 }
80 }
81 hits > 0
82}
83
84fn cached<T>(cell: &Mutex<T>) -> MutexGuard<'_, T> {
88 cell.lock().unwrap_or_else(PoisonError::into_inner)
89}
90
91#[derive(Default)]
93pub struct FulltextIndex {
94 postings: HashMap<String, HashMap<String, u32>>,
95 lengths: HashMap<String, usize>,
96 vocabulary: Mutex<Vec<String>>,
97 fresh_tokens: Mutex<Vec<String>>,
98 total_tokens: i64,
99}
100
101impl FulltextIndex {
102 pub fn new() -> Self {
103 FulltextIndex::default()
104 }
105
106 #[allow(dead_code)]
107 pub fn documents(&self) -> usize {
108 self.lengths.len()
109 }
110
111 pub fn index(&mut self, key: &str, tokens: &[String]) {
112 self.remove(key);
113 if tokens.is_empty() {
114 return;
115 }
116 for token in tokens {
117 match self.postings.get_mut(token) {
118 Some(bucket) => {
119 *bucket.entry(key.to_string()).or_insert(0) += 1;
120 }
121 None => {
122 let mut bucket = HashMap::new();
123 bucket.insert(key.to_string(), 1);
124 self.postings.insert(token.clone(), bucket);
125 cached(&self.fresh_tokens).push(token.clone());
126 }
127 }
128 }
129 self.lengths.insert(key.to_string(), tokens.len());
130 self.total_tokens += tokens.len() as i64;
131 }
132
133 pub fn remove(&mut self, key: &str) {
134 let Some(length) = self.lengths.remove(key) else { return };
135 self.total_tokens -= length as i64;
136 let mut emptied = HashSet::new();
137 self.postings.retain(|token, bucket| {
138 if bucket.remove(key).is_some() && bucket.is_empty() {
139 emptied.insert(token.clone());
140 false
141 } else {
142 true
143 }
144 });
145 if emptied.is_empty() {
146 return;
147 }
148 cached(&self.vocabulary).retain(|token| !emptied.contains(token));
149 let mut fresh = cached(&self.fresh_tokens);
150 if !fresh.is_empty() {
151 fresh.retain(|token| !emptied.contains(token));
152 }
153 }
154
155 pub fn search(&self, query: &str, mode: SearchMode, delta: Option<&CorpusDelta>) -> HashMap<String, f64> {
156 let terms = distinct_terms(query);
157 let mut scores = HashMap::new();
158 if terms.is_empty() {
159 return scores;
160 }
161
162 let corpus = self.corpus_with(delta);
163 if corpus.documents == 0 {
164 return scores;
165 }
166
167 let average_length = corpus.total_tokens as f64 / corpus.documents as f64;
168 let mut hits: HashMap<String, u32> = HashMap::new();
169
170 for term in &terms {
171 self.accumulate_term(term, &corpus, average_length, &mut scores, &mut hits);
172 }
173
174 if mode == SearchMode::All {
175 let terms_len = terms.len() as u32;
176 scores.retain(|key, _| hits.get(key).copied().unwrap_or(0) >= terms_len);
177 }
178 scores
179 }
180
181 fn accumulate_term(
182 &self,
183 term: &SearchTerm,
184 corpus: &Corpus,
185 average_length: f64,
186 scores: &mut HashMap<String, f64>,
187 hits: &mut HashMap<String, u32>,
188 ) {
189 let tokens = if term.prefix {
190 self.expand(&term.token, corpus)
191 } else {
192 vec![term.token.clone()]
193 };
194 if tokens.len() == 1 {
195 let mut add = |key: String, score: f64| {
196 *scores.entry(key.clone()).or_insert(0.0) += score;
197 *hits.entry(key).or_insert(0) += 1;
198 };
199 self.score_token(&tokens[0], corpus, average_length, &mut add);
200 return;
201 }
202
203 let mut best: HashMap<String, f64> = HashMap::new();
204 for token in &tokens {
205 let mut take = |key: String, score: f64| {
206 let current = best.get(&key).copied();
207 if current.is_none_or(|value| score > value) {
208 best.insert(key, score);
209 }
210 };
211 self.score_token(token, corpus, average_length, &mut take);
212 }
213 for (key, score) in best {
214 *scores.entry(key.clone()).or_insert(0.0) += score;
215 *hits.entry(key).or_insert(0) += 1;
216 }
217 }
218
219 fn score_token(&self, token: &str, corpus: &Corpus, average_length: f64, take: &mut dyn FnMut(String, f64)) {
220 let postings = self.postings_of(token, corpus);
221 if postings.is_empty() {
222 return;
223 }
224 let documents = corpus.documents as f64;
225 let df = postings.len() as f64;
226 let idf = (1.0 + (documents - df + 0.5) / (df + 0.5)).ln();
227 for posting in &postings {
228 let length = corpus
229 .staged
230 .get(&posting.key)
231 .map(|doc| doc.length as f64)
232 .or_else(|| self.lengths.get(&posting.key).map(|l| *l as f64))
233 .unwrap_or(average_length);
234 let frequency = posting.frequency as f64;
235 let denominator = frequency + K1 * (1.0 - B + (B * length) / average_length);
236 take(posting.key.clone(), (idf * (frequency * (K1 + 1.0))) / denominator);
237 }
238 }
239
240 fn expand(&self, prefix: &str, corpus: &Corpus) -> Vec<String> {
241 self.merge_fresh_tokens();
243 let mut tokens = HashSet::new();
244 let mut at = self.lower_bound(prefix);
245 let vocabulary = cached(&self.vocabulary);
246 while at < vocabulary.len() {
247 let token = &vocabulary[at];
248 if !token.starts_with(prefix) {
249 break;
250 }
251 tokens.insert(token.clone());
252 at += 1;
253 }
254 drop(vocabulary);
255 for document in corpus.staged.values() {
256 for token in document.frequencies.keys() {
257 if token.starts_with(prefix) {
258 tokens.insert(token.clone());
259 }
260 }
261 }
262 tokens.into_iter().collect()
263 }
264
265 fn merge_fresh_tokens(&self) {
266 let mut fresh = std::mem::take(&mut *cached(&self.fresh_tokens));
268 if fresh.is_empty() {
269 return;
270 }
271
272 if fresh.len() <= SPLICED_TOKENS {
273 for token in fresh {
274 let at = self.lower_bound(&token);
275 cached(&self.vocabulary).insert(at, token);
276 }
277 return;
278 }
279
280 fresh.sort();
281 let sorted = std::mem::take(&mut *cached(&self.vocabulary));
282 let mut merged = Vec::with_capacity(sorted.len() + fresh.len());
283 let mut left = 0;
284 let mut right = 0;
285 while left < sorted.len() || right < fresh.len() {
286 if right == fresh.len() || (left < sorted.len() && sorted[left] <= fresh[right]) {
287 merged.push(sorted[left].clone());
288 left += 1;
289 } else {
290 merged.push(fresh[right].clone());
291 right += 1;
292 }
293 }
294 *cached(&self.vocabulary) = merged;
295 }
296
297 fn lower_bound(&self, token: &str) -> usize {
298 let vocabulary = cached(&self.vocabulary);
299 let mut low = 0;
300 let mut high = vocabulary.len();
301 while low < high {
302 let middle = (low + high) / 2;
303 if vocabulary[middle].as_str() < token {
304 low = middle + 1;
305 } else {
306 high = middle;
307 }
308 }
309 low
310 }
311
312 fn corpus_with(&self, delta: Option<&CorpusDelta>) -> Corpus {
313 let mut staged = HashMap::new();
314 let mut hidden = HashSet::new();
315 let mut documents = self.lengths.len() as i64;
316 let mut total_tokens = self.total_tokens;
317 let Some(delta) = delta else {
318 return Corpus {
319 documents,
320 total_tokens,
321 staged,
322 hidden,
323 };
324 };
325
326 for key in &delta.removed {
327 hidden.insert(key.clone());
328 }
329 for (key, tokens) in &delta.added {
330 hidden.insert(key.clone());
331 if tokens.is_empty() {
332 continue;
333 }
334 let mut frequencies = HashMap::new();
335 for token in tokens {
336 *frequencies.entry(token.clone()).or_insert(0) += 1;
337 }
338 staged.insert(
339 key.clone(),
340 StagedDocument {
341 length: tokens.len(),
342 frequencies,
343 },
344 );
345 }
346
347 for key in &hidden {
348 if let Some(length) = self.lengths.get(key) {
349 documents -= 1;
350 total_tokens -= *length as i64;
351 }
352 }
353 for document in staged.values() {
354 documents += 1;
355 total_tokens += document.length as i64;
356 }
357 Corpus {
358 documents,
359 total_tokens,
360 staged,
361 hidden,
362 }
363 }
364
365 fn postings_of(&self, token: &str, corpus: &Corpus) -> Vec<Posting> {
366 let mut postings = Vec::new();
367 if let Some(bucket) = self.postings.get(token) {
368 for (key, frequency) in bucket {
369 if corpus.hidden.contains(key) {
370 continue;
371 }
372 postings.push(Posting {
373 key: key.clone(),
374 frequency: *frequency,
375 });
376 }
377 }
378 for (key, document) in &corpus.staged {
379 if let Some(frequency) = document.frequencies.get(token) {
380 postings.push(Posting {
381 key: key.clone(),
382 frequency: *frequency,
383 });
384 }
385 }
386 postings
387 }
388}
389
390#[cfg(test)]
391mod tests {
392 use super::*;
393 use crate::registry::tokenize::tokenize;
394
395 fn strings(tokens: &[&str]) -> Vec<String> {
396 tokens.iter().map(|t| t.to_string()).collect()
397 }
398
399 #[test]
400 fn requires_every_query_token_by_default_and_ranks_the_closer_document_higher() {
401 let mut index = FulltextIndex::new();
402 index.index("a", &tokenize("backend developer node"));
403 index.index("b", &tokenize("frontend developer"));
404
405 let hits: Vec<String> = index
406 .search("developer backend", SearchMode::All, None)
407 .into_keys()
408 .collect();
409 assert_eq!(hits, vec!["a".to_string()]);
410
411 let any = index.search("developer frontend", SearchMode::Any, None);
412 let mut keys: Vec<&String> = any.keys().collect();
413 keys.sort();
414 assert_eq!(keys, vec!["a", "b"]);
415 assert!(any["b"] > any["a"]);
416 }
417
418 #[test]
419 fn forgets_a_removed_document() {
420 let mut index = FulltextIndex::new();
421 index.index("a", &strings(&["backend"]));
422 index.remove("a");
423 assert_eq!(index.documents(), 0);
424 assert!(index.search("backend", SearchMode::All, None).is_empty());
425 }
426
427 #[test]
428 fn scores_a_staged_document_against_the_corpus_it_is_added_to() {
429 let mut index = FulltextIndex::new();
430 index.index("a", &strings(&["backend", "developer"]));
431 let alone = index.search("backend", SearchMode::All, None)["a"];
432
433 let delta = CorpusDelta {
434 added: HashMap::from([
435 ("b".to_string(), strings(&["backend", "developer"])),
436 ("c".to_string(), strings(&["backend", "architect"])),
437 ]),
438 removed: HashSet::new(),
439 };
440 let scores = index.search("backend", SearchMode::All, Some(&delta));
441
442 let mut keys: Vec<&String> = scores.keys().collect();
443 keys.sort();
444 assert_eq!(keys, vec!["a", "b", "c"]);
445 assert!(scores["a"] < alone);
446 }
447
448 #[test]
449 fn leaves_a_removed_document_out_of_the_scores_and_statistics() {
450 let mut index = FulltextIndex::new();
451 index.index("a", &strings(&["backend", "developer"]));
452 index.index("b", &strings(&["frontend", "developer"]));
453 let with_both = index.search("developer", SearchMode::All, None)["a"];
454
455 let delta = CorpusDelta {
456 added: HashMap::new(),
457 removed: HashSet::from(["b".to_string()]),
458 };
459 let scores = index.search("developer", SearchMode::All, Some(&delta));
460
461 let keys: Vec<&String> = scores.keys().collect();
462 assert_eq!(keys, vec!["a"]);
463 assert_ne!(scores["a"], with_both);
464 }
465
466 #[test]
467 fn matches_a_token_that_only_begins_with_the_query_term_when_marked() {
468 let mut index = FulltextIndex::new();
469 index.index("a", &strings(&["wybor", "silnika", "skladowania"]));
470 index.index("b", &strings(&["wybor", "formatu"]));
471
472 assert!(index.search("silnik", SearchMode::All, None).is_empty());
473 let hits: Vec<String> = index.search("silnik*", SearchMode::All, None).into_keys().collect();
474 assert_eq!(hits, vec!["a".to_string()]);
475 }
476
477 #[test]
478 fn scores_each_expanded_token_by_its_own_document_frequency() {
479 let mut index = FulltextIndex::new();
480 index.index("rare", &strings(&["silnikowego"]));
481 index.index("common", &strings(&["silnik"]));
482 for at in 0..20 {
483 index.index(&format!("filler_{at}"), &strings(&["silnik"]));
484 }
485
486 let scores = index.search("silnik*", SearchMode::All, None);
487 assert!(scores["rare"] > scores["common"]);
488 assert_eq!(scores["common"], scores["filler_0"]);
489 }
490
491 #[test]
492 fn judges_an_unindexed_document_by_the_same_membership_rule_as_the_postings() {
493 let mut index = FulltextIndex::new();
494 let tokens = tokenize("Backend architecture");
495 index.index("indexed", &tokens);
496
497 for (query, mode) in [
498 ("backend", SearchMode::All),
499 ("architec*", SearchMode::All),
500 ("architek* backend", SearchMode::All),
501 ("architek* backend", SearchMode::Any),
502 ("frontend", SearchMode::All),
503 ("", SearchMode::All),
504 ] {
505 let indexed = index.search(query, mode, None).contains_key("indexed");
506 assert_eq!(
507 matches_terms(&tokens, query, mode),
508 indexed,
509 "query \"{query}\" disagreed"
510 );
511 }
512 }
513
514 #[test]
515 fn forgets_a_token_from_the_vocabulary_when_its_last_document_goes() {
516 let mut index = FulltextIndex::new();
517 index.index("a", &strings(&["silnika"]));
518 index.index("b", &strings(&["silnikowego"]));
519 index.remove("a");
520
521 let hits: Vec<String> = index.search("silnik*", SearchMode::All, None).into_keys().collect();
522 assert_eq!(hits, vec!["b".to_string()]);
523 index.remove("b");
524 assert!(index.search("silnik*", SearchMode::All, None).is_empty());
525 }
526}