1use indexmap::IndexMap;
9use std::collections::BTreeMap;
10
11use crate::lexer::TokenLexer;
12use crate::query::{QueryMatchScore, QuerySearchID, QuerySearchLimit, QuerySearchOffset};
13use crate::store::StoreItem;
14use crate::store::fst::{StoreFSTActionBuilder, typo_factor};
15use crate::store::identifiers::{StoreObjectIID, StoreTermHash, StoreTermHashed};
16use crate::store::kv::{StoreKVAcquireMode, StoreKVAction, StoreKVActionBuilder};
17
18const MISSING_MATCH_SCORE: u16 = 100;
19
20impl super::Executor {
21 pub fn search(
22 &self,
23 item: StoreItem,
24 _event_id: QuerySearchID,
25 lexer: TokenLexer,
26 limit: QuerySearchLimit,
27 offset: QuerySearchOffset,
28 ) -> Result<Vec<String>, ()> {
29 if let StoreItem(collection, Some(bucket), None) = item {
30 let _kv_read_guard = self.kv_pool.lock_read_access();
33 let _fst_read_guard = self.fst_pool.lock_read_access();
34
35 let (Ok(kv_store), Ok(fst_store)) = (
36 self.kv_pool
37 .acquire(StoreKVAcquireMode::OpenOnly, collection),
38 self.fst_pool.acquire(collection, bucket),
39 ) else {
40 return Err(());
41 };
42
43 let (higher_limit, mut alternates_try) = (
44 self.app_conf.store.kv.retain_word_objects,
45 self.app_conf.search.query_alternates_try,
46 );
47
48 let (prefix_matching_enabled, fuzzy_matching_enabled) = (
49 self.fst_pool.fst_action_config.prefix_matching_enabled,
50 self.fst_pool.fst_action_config.fuzzy_matching_enabled,
51 );
52
53 executor_kv_lock_read!(kv_store);
55
56 let (kv_action, fst_action) = (
57 StoreKVActionBuilder::access(bucket, kv_store),
58 StoreFSTActionBuilder::access(fst_store),
59 );
60
61 let terms: Vec<(String, StoreTermHashed, usize)> = lexer.collect();
64 let term_count = terms.len();
65
66 let mut scoring_matrix: IndexMap<StoreObjectIID, Vec<QueryMatchScore>> =
75 IndexMap::with_capacity(24usize.min(usize::from(limit)));
76
77 'matches: for (idx, (term, term_hash, _)) in terms.iter().enumerate() {
79 let iids = kv_action
80 .get_term_to_iids(*term_hash)
81 .unwrap_or(None)
82 .unwrap_or_default();
83
84 tracing::debug!("got exact search executor iids: {iids:?} for term: {term:?}");
85
86 for iid in iids.into_iter() {
87 let inserted = update_score(&mut scoring_matrix, iid, 0, idx, term_count);
89
90 if inserted {
91 if scoring_matrix.len() >= higher_limit {
94 tracing::trace!(?term, "got enough completed results for term");
95
96 break 'matches;
97 }
98 }
99 }
100 }
101
102 #[cfg(debug_assertions)]
103 tracing::debug!(?scoring_matrix);
104
105 if scoring_matrix.len() < higher_limit && alternates_try > 0 && prefix_matching_enabled
107 {
108 tracing::debug!(
109 "not enough iids were found ({}/{higher_limit}), looking for prefixes",
110 scoring_matrix.len(),
111 );
112
113 'terms: for (idx, (term, _, original_len)) in terms.iter().enumerate() {
114 if is_considered_id(&term) {
118 tracing::debug!(
119 "skipping prefix search for {term:?}: term is considered an ID"
120 );
121 continue 'terms;
122 };
123
124 let Some(suggestions) = fst_action.lookup_begins(term, *original_len) else {
125 tracing::trace!("did not get any completed word for term {term:?}");
126 continue 'terms;
127 };
128
129 merge_suggestions(
130 suggestions,
131 &mut scoring_matrix,
132 term,
133 idx,
134 term_count,
135 &kv_action,
136 &mut alternates_try,
137 higher_limit,
138 );
139 }
140 }
141
142 #[cfg(debug_assertions)]
143 tracing::debug!(?scoring_matrix);
144
145 if scoring_matrix.len() < higher_limit && alternates_try > 0 && fuzzy_matching_enabled {
147 tracing::debug!(
148 "not enough iids were found ({}/{higher_limit}), looking for fuzzy matches",
149 scoring_matrix.len(),
150 );
151
152 'terms: for (idx, (term, _, original_word_len)) in terms.iter().enumerate() {
153 if is_considered_id(&term) {
155 tracing::debug!(
156 "skipping fuzzy matching for {term:?}: term is considered an ID"
157 );
158 continue 'terms;
159 };
160
161 let max_typo_factor = typo_factor(*original_word_len);
162 let mut typo_factor = 1u32;
163
164 while alternates_try > 0 && typo_factor <= max_typo_factor {
168 let Some(suggestions) = fst_action.lookup_typos(term, typo_factor) else {
169 tracing::trace!("did not get any completed word for term {term:?}");
170 continue 'terms;
171 };
172
173 merge_suggestions(
174 suggestions,
175 &mut scoring_matrix,
176 term,
177 idx,
178 term_count,
179 &kv_action,
180 &mut alternates_try,
181 higher_limit,
182 );
183
184 typo_factor += 1;
185 }
186 }
187 }
188
189 #[cfg(debug_assertions)]
190 tracing::debug!(?scoring_matrix);
191
192 let one_term_is_an_id = terms.iter().any(|(term, _, _)| is_considered_id(term));
199 if one_term_is_an_id {
200 let mut to_remove = Vec::<StoreObjectIID>::new();
201
202 for (&iid, scores) in scoring_matrix.iter() {
203 if scores.contains(&MISSING_MATCH_SCORE) {
204 to_remove.push(iid);
205 }
206 }
207
208 for iid in to_remove {
209 scoring_matrix.swap_remove(&iid);
210 }
211 }
212
213 let found_iids = scoring_matrix
216 .into_iter()
217 .map(|(iid, scores)| (iid, scores.into_iter().sum()));
218
219 let all_iids = sorted_groups(found_iids).flat_map(|(_, v)| v);
221
222 let (limit_usize, offset_usize) = (limit as usize, offset as usize);
225 let mut result_oids = Vec::with_capacity(limit_usize);
226
227 'paging: for (index, found_iid) in all_iids.skip(offset_usize).enumerate() {
228 if index >= limit_usize {
230 break 'paging;
231 }
232
233 if let Ok(Some(oid)) = kv_action.get_iid_to_oid(found_iid) {
235 result_oids.push(oid);
236 } else {
237 tracing::error!("failed getting search executor iid-to-oid");
238 }
239 }
240
241 tracing::info!("got search executor final oids: {:?}", result_oids);
242
243 return Ok(result_oids);
244 }
245
246 Err(())
247 }
248}
249
250fn is_considered_id(s: &str) -> bool {
251 s.chars().any(|c| c.is_ascii_digit() || c == '@')
252 || s.chars()
253 .skip(1)
255 .take(s.len().saturating_sub(2))
257 .any(is_non_prose_char)
258}
259
260fn is_non_prose_char(c: char) -> bool {
261 c == '.' || c == '_' || c == ':' || c == '\\' || c == '+' || c == '=' || c == '&'
262}
263
264#[cfg(test)]
267#[test]
268fn test_is_considered_id() {
269 assert!(!is_considered_id("Hello"));
271
272 assert!(is_considered_id("1234-567890-12"));
274 assert!(is_considered_id("0123456789"));
275
276 assert!(is_considered_id("6db14cb4-b82e-4e49-8016-ef76c4290a2f"));
278
279 assert!(is_considered_id("b244423d417369795292e9f4530d0c0e6fa07625"));
281 assert!(is_considered_id("b244423"));
282
283 assert!(is_considered_id("is_considered_id"));
285
286 assert!(is_considered_id("example.org"));
288 assert!(!is_considered_id("endofsentence."));
289 assert!(!is_considered_id(".startofsentence"));
290
291 assert!(is_considered_id("https://example.org/foo?id=123"));
293
294 assert!(is_considered_id("alice@example.org"));
296 assert!(is_considered_id("alice+foo@example.org"));
297
298 assert!(is_considered_id("192.168.1.0"));
300 assert!(is_considered_id("0.0.0.0"));
301 assert!(is_considered_id("2606:4700::6812:1c68"));
302 assert!(is_considered_id("::1"));
303 assert!(!is_considered_id("example:"));
304
305 assert!(is_considered_id("@example"));
307}
308
309#[allow(clippy::too_many_arguments)] fn merge_suggestions(
311 suggestions: impl Iterator<Item = (String, QueryMatchScore)>,
312 scoring_matrix: &mut IndexMap<StoreObjectIID, Vec<QueryMatchScore>>,
313 term: &String,
314 term_idx: usize,
315 term_count: usize,
316 kv_action: &StoreKVAction<'_>,
317 alternates_try: &mut usize,
318 higher_limit: usize,
319) {
320 'suggestions: for (suggested_word, suggestion_score) in suggestions {
321 if suggested_word.eq(term) {
323 continue;
324 }
325
326 tracing::trace!(?term, ?suggested_word, "got completed word for term");
327
328 let suggested_term_hash = StoreTermHash::from(&suggested_word);
329 let suggested_iids = match kv_action.get_term_to_iids(suggested_term_hash) {
330 Ok(Some(suggested_iids)) => suggested_iids,
331 Ok(None) => continue,
332 Err(_) => continue,
333 };
334
335 for suggested_iid in suggested_iids.into_iter().take(*alternates_try) {
336 *alternates_try = unsafe { alternates_try.unchecked_sub(1) };
338
339 let inserted = update_score(
340 scoring_matrix,
341 suggested_iid,
342 suggestion_score,
343 term_idx,
344 term_count,
345 );
346
347 if inserted {
348 if scoring_matrix.len() >= higher_limit {
351 tracing::trace!(?term, "got enough completed results for term");
352
353 break 'suggestions;
354 }
355 }
356 }
357 }
358
359 tracing::trace!(
360 ?term,
361 "done completing results for term, now {} total results",
362 scoring_matrix.len()
363 );
364}
365
366fn update_score(
367 scoring_matrix: &mut IndexMap<StoreObjectIID, Vec<QueryMatchScore>>,
368 iid: StoreObjectIID,
369 score: QueryMatchScore,
370 term_idx: usize,
371 term_count: usize,
372) -> bool {
373 match scoring_matrix.entry(iid) {
374 indexmap::map::Entry::Occupied(mut occupied_entry) => {
376 let entry_score = unsafe { occupied_entry.get_mut().get_unchecked_mut(term_idx) };
378
379 let new_score = score.min(*entry_score);
380
381 tracing::trace!(entry_score, new_score, "Updating to min score");
382 *entry_score = new_score;
383
384 false
385 }
386 indexmap::map::Entry::Vacant(vacant_entry) => {
388 let mut scores = vec![MISSING_MATCH_SCORE; term_count];
389
390 tracing::trace!(new_score = score, "Inserting new score");
391 unsafe { *scores.get_unchecked_mut(term_idx) = score };
393
394 vacant_entry.insert(scores);
395
396 true
397 }
398 }
399}
400
401fn sorted_groups(
402 map: impl ExactSizeIterator<Item = (StoreObjectIID, QueryMatchScore)>,
403) -> impl ExactSizeIterator<Item = (QueryMatchScore, Vec<StoreObjectIID>)> {
404 let mut btree: BTreeMap<QueryMatchScore, Vec<StoreObjectIID>> = BTreeMap::new();
405
406 for (k, v) in map.into_iter() {
407 btree.entry(v).or_default().push(k);
408 }
409
410 btree.into_iter()
411}