1use indexmap::IndexMap;
9
10use crate::lexer::{NormalizedToken, TokenLexer};
11use crate::query::{
12 QueryMatchScore, QueryResultScore, QuerySearchID, QuerySearchLimit, QuerySearchOffset,
13};
14use crate::store::StoreItem;
15use crate::store::fst::{StoreFSTActionBuilder, typo_factor};
16use crate::store::identifiers::{
17 StoreMetaKey, StoreMetaValue, StoreObjectIID, StoreTermHash, StoreTermHashed,
18};
19use crate::store::kv::{StoreKVAcquireMode, StoreKVAction, StoreKVActionBuilder};
20
21impl super::Executor {
22 pub fn search(
23 &self,
24 item: StoreItem,
25 _event_id: QuerySearchID,
26 lexer: TokenLexer,
27 limit: QuerySearchLimit,
28 offset: QuerySearchOffset,
29 ) -> Result<Vec<String>, ()> {
30 if let StoreItem(collection, Some(bucket), None) = item {
31 let _kv_read_guard = self.kv_pool.lock_read_access();
34 let _fst_read_guard = self.fst_pool.lock_read_access();
35
36 let (Ok(kv_store), Ok(fst_store)) = (
37 self.kv_pool
38 .acquire(StoreKVAcquireMode::OpenOnly, collection),
39 self.fst_pool.acquire(collection, bucket),
40 ) else {
41 return Err(());
42 };
43
44 let (higher_limit, mut alternates_try) = (
45 self.app_conf.store.kv.retain_word_objects,
46 self.app_conf.search.query_alternates_try,
47 );
48
49 let (mut minimum_idf, idf_min_doc_count) = (
50 self.app_conf.search.query_minimum_term_idf_default,
51 (self.app_conf.search).query_minimum_term_idf_minimum_object_count,
52 );
53
54 let (prefix_matching_enabled, fuzzy_matching_enabled) = (
55 self.fst_pool.fst_action_config.prefix_matching_enabled,
56 self.fst_pool.fst_action_config.fuzzy_matching_enabled,
57 );
58
59 executor_kv_lock_read!(kv_store);
61
62 let (kv_action, fst_action) = (
63 StoreKVActionBuilder::access(bucket, kv_store),
64 StoreFSTActionBuilder::access(fst_store),
65 );
66
67 let document_count = match kv_action.get_meta_to_value(StoreMetaKey::IIDIncr)? {
68 Some(StoreMetaValue::IIDIncr(last_iid)) => u64::from(last_iid) + 1,
69 None => 0,
70 };
71
72 if document_count < idf_min_doc_count {
73 tracing::debug!(
74 "ignoring minimum_term_idf ({minimum_idf}) as document_count is too low ({document_count}<{idf_min_doc_count})"
75 );
76 minimum_idf = 0.;
77 }
78
79 let tokens: Vec<(NormalizedToken, StoreTermHashed, usize)> = lexer.collect();
82 let term_count = tokens.len();
83
84 let mut scoring_matrix: IndexMap<StoreObjectIID, Vec<Option<QueryMatchScore>>> =
93 IndexMap::with_capacity(24usize.min(usize::from(limit)));
94
95 'matches: for (idx, (token, term_hash, _)) in tokens.iter().enumerate() {
97 let mut iids = kv_action
98 .get_term_to_iids(*term_hash)
99 .unwrap_or(None)
100 .unwrap_or_default();
101
102 if !token.is_special()
105 && self.app_conf.normalization.unicode_normalization.is_none()
106 {
107 use unicode_normalization::UnicodeNormalization as _;
108
109 let mut nfc = kv_action
110 .get_term_to_iids(StoreTermHash::from(
111 token.as_str().nfc().to_string().as_str(),
112 ))
113 .unwrap_or(None)
114 .unwrap_or_default();
115 iids.append(&mut nfc);
116
117 let mut nfd = kv_action
118 .get_term_to_iids(StoreTermHash::from(
119 token.as_str().nfd().to_string().as_str(),
120 ))
121 .unwrap_or(None)
122 .unwrap_or_default();
123 iids.append(&mut nfd);
124 };
125
126 tracing::debug!("got exact search executor iids: {iids:?} for term: {token:?}");
127
128 let document_frequency = document_frequency(*term_hash, &kv_action);
129
130 if minimum_idf > 0. {
133 let idf = (document_count as f32 / document_frequency as f32).ln();
134 if idf < minimum_idf {
135 tracing::debug!(
136 "skipping term {token:?} because idf too low ({idf}<{minimum_idf})"
137 );
138 continue;
139 }
140 }
141
142 let bm25_score = bm25_lite_idf(document_count, document_frequency);
143
144 for iid in iids.into_iter() {
145 let inserted =
147 update_score(&mut scoring_matrix, iid, 1. * bm25_score, idx, term_count);
148
149 if inserted {
150 if scoring_matrix.len() >= higher_limit {
153 tracing::trace!(?token, "got enough completed results for term");
154
155 break 'matches;
156 }
157 }
158 }
159 }
160
161 #[cfg(debug_assertions)]
162 tracing::debug!(?scoring_matrix);
163
164 if scoring_matrix.len() < higher_limit && alternates_try > 0 && prefix_matching_enabled
166 {
167 tracing::debug!(
168 "not enough iids were found ({}/{higher_limit}), looking for prefixes",
169 scoring_matrix.len(),
170 );
171
172 'terms: for (idx, (token, _, original_len)) in tokens.iter().enumerate() {
173 let Some(suggestions) = fst_action.lookup_begins(token, *original_len) else {
174 tracing::trace!("did not get any completed word for term {token:?}");
175 continue 'terms;
176 };
177
178 merge_suggestions(
179 suggestions.map(|(w, distance)| (w, prefix_score(distance, *original_len))),
180 &mut scoring_matrix,
181 token,
182 idx,
183 term_count,
184 &kv_action,
185 &mut alternates_try,
186 higher_limit,
187 document_count,
188 minimum_idf,
189 );
190 }
191 }
192
193 #[cfg(debug_assertions)]
194 tracing::debug!(?scoring_matrix);
195
196 if scoring_matrix.len() < higher_limit && alternates_try > 0 && fuzzy_matching_enabled {
198 tracing::debug!(
199 "not enough iids were found ({}/{higher_limit}), looking for fuzzy matches",
200 scoring_matrix.len(),
201 );
202
203 'terms: for (idx, (token, _, original_word_len)) in tokens.iter().enumerate() {
204 let term = match token {
205 NormalizedToken::Word(term) => term,
206 NormalizedToken::Special(term) => {
208 tracing::debug!("skipping fuzzy search for {term:?}: term is special");
209 continue 'terms;
210 }
211 };
212
213 let max_typo_factor = typo_factor(*original_word_len);
214 let mut typo_factor = 1u32;
215
216 while alternates_try > 0 && typo_factor <= max_typo_factor {
220 let Some(suggestions) = fst_action.lookup_typos(term, typo_factor) else {
221 tracing::trace!("did not get any completed word for term {term:?}");
222 continue 'terms;
223 };
224
225 merge_suggestions(
226 suggestions
227 .map(|(w, distance)| (w, typo_score(distance, *original_word_len))),
228 &mut scoring_matrix,
229 term,
230 idx,
231 term_count,
232 &kv_action,
233 &mut alternates_try,
234 higher_limit,
235 document_count,
236 minimum_idf,
237 );
238
239 typo_factor += 1;
240 }
241 }
242 }
243
244 #[cfg(debug_assertions)]
245 tracing::debug!(?scoring_matrix);
246
247 let one_term_is_special = tokens.iter().any(|(token, _, _)| token.is_special());
255 if one_term_is_special {
256 let mut to_remove = Vec::<StoreObjectIID>::new();
257
258 for (&iid, scores) in scoring_matrix.iter() {
259 if scores.iter().any(Option::is_none) {
260 to_remove.push(iid);
261 }
262 }
263
264 for iid in to_remove {
265 scoring_matrix.swap_remove(&iid);
266 }
267 }
268
269 let found_iids = scoring_matrix
272 .into_iter()
273 .map(|(iid, scores)| (iid, overall_score(&scores)));
274
275 let all_iids = {
277 let mut all_iids = found_iids.collect::<Vec<_>>();
278 all_iids.sort_by(|a, b| a.1.total_cmp(&b.1).reverse());
279 all_iids.into_iter().map(|(iid, _score)| iid)
280 };
281
282 let (limit_usize, offset_usize) = (limit as usize, offset as usize);
285 let mut result_oids = Vec::with_capacity(limit_usize);
286
287 'paging: for (index, found_iid) in all_iids.skip(offset_usize).enumerate() {
288 if index >= limit_usize {
290 break 'paging;
291 }
292
293 if let Ok(Some(oid)) = kv_action.get_iid_to_oid(found_iid) {
295 result_oids.push(oid);
296 } else {
297 tracing::error!("failed getting search executor iid-to-oid");
298 }
299 }
300
301 tracing::info!("got search executor final oids: {:?}", result_oids);
302
303 return Ok(result_oids);
304 }
305
306 Err(())
307 }
308}
309
310fn prefix_score(lev_distance: u16, word_len: usize) -> f32 {
313 let lev_ratio = lev_distance as f32 / word_len as f32;
315
316 20. / (20. + lev_ratio)
320}
321
322#[cfg(test)]
323#[test]
324fn test_prefix_score() {
325 for n in [2, 4, 8] {
327 assert_eq!(prefix_score(n, n as usize), 0.95238096);
328 }
329
330 for n in [2, 4, 8] {
332 assert_eq!(prefix_score(3 * n, n as usize), 0.8695652);
333 }
334
335 assert_eq!(prefix_score(1, 3), 0.9836065);
337 assert_eq!(prefix_score(1, 4), 0.9876543);
338 assert_eq!(prefix_score(1, 5), 0.99009895);
339 assert_eq!(prefix_score(1, 6), 0.9917356);
340
341 assert_eq!(prefix_score(2, 3), 0.96774197);
343 assert_eq!(prefix_score(2, 4), 0.9756098);
344 assert_eq!(prefix_score(2, 5), 0.98039216);
345 assert_eq!(prefix_score(2, 6), 0.9836065);
346
347 for n in [1, 2, 4, 8] {
349 for word_len in [2, 4, 8, 10] {
350 assert!(prefix_score(n + 1, word_len as usize) < prefix_score(n, word_len as usize));
351 }
352 }
353}
354
355fn typo_score(lev_distance: u16, word_len: usize) -> f32 {
357 debug_assert!(
358 (lev_distance as usize) < word_len,
359 "{lev_distance} >= {word_len}"
360 );
361
362 let lev_ratio = (lev_distance as f32 / word_len as f32).min(1.);
365
366 1. - lev_ratio
367}
368
369#[cfg(test)]
370#[test]
371fn test_typo_score() {
372 assert_eq!(typo_score(0, 1), 1.);
374 assert_eq!(typo_score(0, 2), 1.);
375
376 assert_eq!(typo_score(1, 3), 0.6666666);
378 assert_eq!(typo_score(1, 4), 0.75);
379 assert_eq!(typo_score(1, 5), 0.8);
380 for n in 2..=8 {
382 assert!(typo_score(1, n) < typo_score(0, n), "n={n}");
383 }
384
385 assert_eq!(typo_score(2, 5), 0.6);
387 assert_eq!(typo_score(2, 6), 0.6666666);
388 assert_eq!(typo_score(2, 7), 0.71428573);
389 for n in 3..=8 {
391 assert!(typo_score(2, n) < typo_score(1, n), "n={n}");
392 }
393
394 assert_eq!(typo_score(1 * 20, 2 * 20), typo_score(1, 2));
396 assert_eq!(typo_score(3 * 20, 7 * 20), typo_score(3, 7));
397}
398
399fn overall_score(scores: &[Option<QueryMatchScore>]) -> QueryResultScore {
400 let total = scores.iter().map(|opt| opt.unwrap_or(0f32)).sum::<f32>();
401 let count = scores.len() as f32;
402
403 #[allow(clippy::let_and_return)]
404 let average = total / count;
405
406 average
407}
408
409#[cfg(test)]
410#[test]
411fn test_overall_score() {
412 const MISSING: Option<QueryMatchScore> = None;
413 const EXACT_MATCH: Option<QueryMatchScore> = Some(1.);
414
415 assert_eq!(overall_score(&[EXACT_MATCH; 1]), 1.);
417 assert_eq!(overall_score(&[EXACT_MATCH; 2]), 1.);
418 assert_eq!(overall_score(&[EXACT_MATCH; 3]), 1.);
419 assert_eq!(overall_score(&[EXACT_MATCH; 4]), 1.);
420
421 assert_eq!(overall_score(&[MISSING; 1]), 0.);
423 assert_eq!(overall_score(&[MISSING; 2]), 0.);
424 assert_eq!(overall_score(&[MISSING; 3]), 0.);
425 assert_eq!(overall_score(&[MISSING; 4]), 0.);
426
427 assert!(overall_score(&[Some(prefix_score(5, 4))]) > overall_score(&[Some(typo_score(1, 10))]));
429
430 assert_eq!(overall_score(&[MISSING, EXACT_MATCH]), 1. / 2.);
432 assert_eq!(overall_score(&[MISSING, EXACT_MATCH, EXACT_MATCH]), 2. / 3.);
433 assert!(overall_score(&[MISSING, EXACT_MATCH]) > overall_score(&[MISSING]));
435
436 assert_eq!(
438 overall_score(&[
439 EXACT_MATCH,
440 Some(prefix_score(2, 3)),
441 Some(typo_score(2, 7))
442 ]),
443 overall_score(&[
444 Some(typo_score(2, 7)),
445 Some(prefix_score(2, 3)),
446 EXACT_MATCH
447 ])
448 );
449
450 assert_eq!(
453 overall_score(&[Some(typo_score(1, 7)); 2]),
454 overall_score(&[Some(typo_score(2, 7)), EXACT_MATCH])
455 );
456 assert_eq!(
457 overall_score(&[Some(typo_score(1, 7)); 3]),
458 overall_score(&[Some(typo_score(3, 7)), EXACT_MATCH, EXACT_MATCH])
459 );
460
461 assert_eq!(overall_score(&[EXACT_MATCH, EXACT_MATCH, EXACT_MATCH]), 1.);
464 assert_eq!(
466 overall_score(&[EXACT_MATCH, EXACT_MATCH, Some(prefix_score(2, 3))]),
467 0.9892473
468 );
469 assert_eq!(
471 overall_score(&[
472 Some(typo_score(1, 5)),
473 EXACT_MATCH,
474 Some(prefix_score(2, 3))
475 ]),
476 0.92258066
477 );
478 assert_eq!(overall_score(&[EXACT_MATCH, EXACT_MATCH,]), 1.);
480 assert_eq!(
482 overall_score(&[EXACT_MATCH, EXACT_MATCH, MISSING]),
483 0.6666667
484 ); }
486
487fn document_frequency(term_hash: StoreTermHashed, kv_action: &StoreKVAction<'_>) -> u64 {
488 kv_action
489 .get_term_to_iids(term_hash)
490 .inspect_err(|err| tracing::error!("{err:?}"))
491 .unwrap_or(None)
492 .map_or(0, |iids| iids.len()) as u64
493}
494
495fn bm25_lite_idf(document_count: u64, document_frequency: u64) -> f32 {
496 debug_assert!(
497 document_frequency <= document_count,
498 "{document_frequency} > {document_count}"
499 );
500
501 let document_count = document_count.max(document_frequency) as f64;
502 let df = document_frequency as f64;
503
504 (1.0 + (document_count - df + 0.5) / (df + 0.5)).ln() as f32
505}
506
507#[allow(clippy::too_many_arguments)] fn merge_suggestions(
509 suggestions: impl Iterator<Item = (String, QueryMatchScore)>,
510 scoring_matrix: &mut IndexMap<StoreObjectIID, Vec<Option<QueryMatchScore>>>,
511 term: &String,
512 term_idx: usize,
513 term_count: usize,
514 kv_action: &StoreKVAction<'_>,
515 alternates_try: &mut usize,
516 higher_limit: usize,
517 document_count: u64,
518 minimum_idf: f32,
519) {
520 'suggestions: for (suggested_word, base_score) in suggestions {
521 if suggested_word.eq(term) {
523 continue;
524 }
525
526 tracing::trace!(?term, ?suggested_word, "got completed word for term");
527
528 let suggested_term_hash = StoreTermHash::from(&suggested_word);
529 let suggested_iids = match kv_action.get_term_to_iids(suggested_term_hash) {
530 Ok(Some(suggested_iids)) => suggested_iids,
531 Ok(None) => continue,
532 Err(_) => continue,
533 };
534
535 let document_frequency = document_frequency(suggested_term_hash, kv_action);
536
537 if minimum_idf > 0. {
540 let idf = (document_count as f32 / document_frequency as f32).ln();
541 if idf < minimum_idf {
542 tracing::debug!(
543 "skipping term {suggested_word:?} because idf too low ({idf}<{minimum_idf})"
544 );
545 continue;
546 }
547 }
548
549 let bm25_score = bm25_lite_idf(document_count, document_frequency);
550
551 let suggestion_score = base_score * bm25_score;
552
553 for suggested_iid in suggested_iids.into_iter().take(*alternates_try) {
554 *alternates_try = unsafe { alternates_try.unchecked_sub(1) };
556
557 let inserted = update_score(
558 scoring_matrix,
559 suggested_iid,
560 suggestion_score,
561 term_idx,
562 term_count,
563 );
564
565 if inserted {
566 if scoring_matrix.len() >= higher_limit {
569 tracing::trace!(?term, "got enough completed results for term");
570
571 break 'suggestions;
572 }
573 }
574 }
575 }
576
577 tracing::trace!(
578 ?term,
579 "done completing results for term, now {} total results",
580 scoring_matrix.len()
581 );
582}
583
584fn update_score(
585 scoring_matrix: &mut IndexMap<StoreObjectIID, Vec<Option<QueryMatchScore>>>,
586 iid: StoreObjectIID,
587 score: QueryMatchScore,
588 term_idx: usize,
589 term_count: usize,
590) -> bool {
591 match scoring_matrix.entry(iid) {
592 indexmap::map::Entry::Occupied(mut occupied_entry) => {
594 let entry_score = unsafe { occupied_entry.get_mut().get_unchecked_mut(term_idx) };
596
597 let new_score = entry_score.map_or(score, |entry_score| score.min(entry_score));
598
599 tracing::trace!(entry_score, new_score, "Updating to min score");
600 *entry_score = Some(new_score);
601
602 false
603 }
604 indexmap::map::Entry::Vacant(vacant_entry) => {
606 let mut scores = vec![None; term_count];
607
608 tracing::trace!(new_score = score, "Inserting new score");
609 unsafe { *scores.get_unchecked_mut(term_idx) = Some(score) };
611
612 vacant_entry.insert(scores);
613
614 true
615 }
616 }
617}