1use std::collections::HashMap;
25
26use nodedb_types::{Surrogate, SurrogateBitmap};
27
28use crate::backend::FtsBackend;
29use crate::bm25::bm25_score;
30use crate::index::FtsIndex;
31use crate::index::error::FtsIndexError;
32use crate::posting::{Posting, QueryMode, TextSearchResult};
33use crate::search::phrase;
34use crate::search::query_parser::parse_query;
35
36pub struct FtsSearchParams<'a> {
42 pub query: &'a str,
44 pub top_k: usize,
46 pub fuzzy_enabled: bool,
48 pub mode: QueryMode,
50 pub prefilter: Option<&'a SurrogateBitmap>,
52}
53
54struct FilterAndModeParams<'a> {
57 database_id: u64,
58 tid: u64,
59 collection: &'a str,
60 query_tokens: &'a [String],
61 candidates: &'a [TextSearchResult],
62 num_terms: usize,
63}
64
65impl<B: FtsBackend> FtsIndex<B> {
66 pub fn search(
72 &self,
73 database_id: u64,
74 tid: u64,
75 collection: &str,
76 params: FtsSearchParams<'_>,
77 ) -> Result<Vec<TextSearchResult>, FtsIndexError<B::Error>> {
78 let FtsSearchParams {
79 query,
80 top_k,
81 fuzzy_enabled,
82 mode,
83 prefilter,
84 } = params;
85 let parsed = parse_query(query)?;
87
88 let positive_raw = parsed.positive.join(" ");
93 let negative_raw_terms = parsed.negative;
94
95 let base_tokens = self
96 .analyze_for_collection(database_id, tid, collection, &positive_raw)
97 .map_err(FtsIndexError::backend)?;
98 if base_tokens.is_empty() {
99 return Ok(Vec::new());
100 }
101
102 let base_token_count = base_tokens.len();
103 let query_tokens = self
104 .expand_query_with_synonyms(database_id, tid, base_tokens)
105 .map_err(FtsIndexError::backend)?;
106 let num_query_terms = query_tokens.len();
107 let and_threshold = base_token_count;
108
109 let raw_tokens = if fuzzy_enabled {
110 self.tokenize_raw_for_collection(database_id, tid, collection, &positive_raw)
111 .map_err(FtsIndexError::backend)?
112 } else {
113 Vec::new()
114 };
115
116 let (total_docs, avg_doc_len) = self
117 .index_stats(database_id, tid, collection)
118 .map_err(FtsIndexError::backend)?;
119 if total_docs == 0 {
120 return Ok(Vec::new());
121 }
122
123 let negative_set =
127 self.build_negative_set(database_id, tid, collection, &negative_raw_terms)?;
128
129 let bmw_params = super::bmw::query::BmwParams {
130 query_tokens: &query_tokens,
131 raw_tokens: &raw_tokens,
132 fuzzy_enabled,
133 top_k: if mode == QueryMode::And && and_threshold > 1 {
134 top_k.saturating_mul(3).max(20)
135 } else {
136 top_k
137 },
138 total_docs,
139 avg_doc_len,
140 bm25: &self.bm25_params,
141 prefilter,
142 };
143 if let Ok(Some(bmw_results)) =
144 super::bmw::query::bmw_search(self, database_id, tid, collection, &bmw_params)
145 {
146 if mode == QueryMode::Or || and_threshold == 1 {
147 let mut results: Vec<TextSearchResult> = bmw_results
148 .into_iter()
149 .filter(|r| !negative_set.contains(&r.doc_id))
150 .take(top_k)
151 .collect();
152 results.truncate(top_k);
153 return Ok(results);
154 }
155
156 let and_results = self
157 .filter_and_mode(FilterAndModeParams {
158 database_id,
159 tid,
160 collection,
161 query_tokens: &query_tokens,
162 candidates: &bmw_results,
163 num_terms: and_threshold,
164 })
165 .map_err(FtsIndexError::backend)?;
166
167 if !and_results.is_empty() {
168 let filtered: Vec<TextSearchResult> = and_results
169 .into_iter()
170 .filter(|r| !negative_set.contains(&r.doc_id))
171 .take(top_k)
172 .collect();
173 return Ok(filtered);
174 }
175
176 let penalized: Vec<TextSearchResult> = bmw_results
177 .into_iter()
178 .filter(|r| !negative_set.contains(&r.doc_id))
179 .map(|mut r| {
180 let matched = self.count_term_matches(
181 database_id,
182 tid,
183 collection,
184 &query_tokens,
185 r.doc_id,
186 );
187 let coverage = matched as f32 / and_threshold as f32;
188 r.score *= coverage;
189 r
190 })
191 .collect();
192 let mut sorted = penalized;
193 sorted.sort_by(|a, b| {
194 b.score
195 .partial_cmp(&a.score)
196 .unwrap_or(std::cmp::Ordering::Equal)
197 });
198 sorted.truncate(top_k);
199 return Ok(sorted);
200 }
201
202 let _term_postings_guard = self.governor.as_ref().and_then(|gov| {
204 let bytes = num_query_terms
205 * (std::mem::size_of::<Vec<Posting>>() + std::mem::size_of::<bool>());
206 gov.reserve(nodedb_mem::EngineId::Fts, bytes).ok()
207 });
208 let mut term_postings: Vec<(Vec<Posting>, bool)> = Vec::with_capacity(num_query_terms);
209 for (i, token) in query_tokens.iter().enumerate() {
210 let postings = self
211 .backend
212 .read_postings(database_id, tid, collection, token)
213 .map_err(FtsIndexError::backend)?;
214 if !postings.is_empty() {
215 term_postings.push((postings, false));
216 } else if fuzzy_enabled {
217 let raw = raw_tokens
218 .get(i)
219 .map(String::as_str)
220 .unwrap_or(token.as_str());
221 let (fuzzy_posts, is_fuzzy) = self
222 .fuzzy_lookup(database_id, tid, collection, raw)
223 .map_err(FtsIndexError::backend)?;
224 term_postings.push((fuzzy_posts, is_fuzzy));
225 } else {
226 term_postings.push((Vec::new(), false));
227 }
228 }
229
230 let mut doc_scores: HashMap<Surrogate, (f32, bool, usize)> = HashMap::new();
231
232 for (token_idx, (postings, is_fuzzy)) in term_postings.iter().enumerate() {
233 if postings.is_empty() {
234 continue;
235 }
236 let df = postings.len() as u32;
237
238 for posting in postings {
239 if let Some(bm) = prefilter
241 && !bm.contains(posting.doc_id)
242 {
243 continue;
244 }
245
246 let doc_len = self
247 .backend
248 .read_doc_length(database_id, tid, collection, posting.doc_id)
249 .map_err(FtsIndexError::backend)?
250 .unwrap_or(1);
251
252 let mut score = bm25_score(
253 posting.term_freq,
254 df,
255 doc_len,
256 total_docs,
257 avg_doc_len,
258 &self.bm25_params,
259 );
260
261 if *is_fuzzy {
262 score *= crate::fuzzy::fuzzy_discount(1);
263 }
264
265 let entry = doc_scores.entry(posting.doc_id).or_insert((0.0, false, 0));
266 entry.0 += score;
267 if *is_fuzzy {
268 entry.1 = true;
269 }
270 entry.2 += 1;
271 }
272 let _ = token_idx;
273 }
274
275 if num_query_terms >= 2 {
276 let doc_postings_map = phrase::collect_doc_postings(&query_tokens, &term_postings);
277 for (doc_id, token_postings) in &doc_postings_map {
278 if let Some(entry) = doc_scores.get_mut(doc_id) {
279 let boost = phrase::phrase_boost(&query_tokens, token_postings);
280 entry.0 *= boost;
281 }
282 }
283 }
284
285 if mode == QueryMode::And && and_threshold > 1 {
286 let and_results: HashMap<Surrogate, (f32, bool, usize)> = doc_scores
287 .iter()
288 .filter(|(_, (_, _, match_count))| *match_count >= and_threshold)
289 .map(|(k, v)| (*k, *v))
290 .collect();
291
292 if !and_results.is_empty() {
293 let filtered = and_results
294 .into_iter()
295 .filter(|(doc_id, _)| !negative_set.contains(doc_id))
296 .collect();
297 return Ok(Self::to_sorted_results(filtered, top_k));
298 }
299
300 for (score, _, match_count) in doc_scores.values_mut() {
301 let coverage = *match_count as f32 / and_threshold as f32;
302 *score *= coverage;
303 }
304 }
305
306 let filtered: HashMap<Surrogate, (f32, bool, usize)> = doc_scores
308 .into_iter()
309 .filter(|(doc_id, _)| !negative_set.contains(doc_id))
310 .collect();
311
312 Ok(Self::to_sorted_results(filtered, top_k))
313 }
314
315 fn build_negative_set(
320 &self,
321 database_id: u64,
322 tid: u64,
323 collection: &str,
324 raw_negative_terms: &[String],
325 ) -> Result<std::collections::HashSet<Surrogate>, FtsIndexError<B::Error>> {
326 if raw_negative_terms.is_empty() {
327 return Ok(std::collections::HashSet::new());
328 }
329
330 let neg_raw = raw_negative_terms.join(" ");
332 let neg_base_tokens = self
333 .analyze_for_collection(database_id, tid, collection, &neg_raw)
334 .map_err(FtsIndexError::backend)?;
335
336 if neg_base_tokens.is_empty() {
337 return Ok(std::collections::HashSet::new());
338 }
339
340 let neg_tokens = self
342 .expand_query_with_synonyms(database_id, tid, neg_base_tokens)
343 .map_err(FtsIndexError::backend)?;
344
345 let mut excluded: std::collections::HashSet<Surrogate> = std::collections::HashSet::new();
346
347 let term_blocks = crate::lsm::query::collect_merged_term_blocks(
349 &self.backend,
350 database_id,
351 tid,
352 collection,
353 self.memtable(),
354 &neg_tokens,
355 self.governor.as_ref(),
356 )
357 .map_err(FtsIndexError::backend)?;
358
359 for tb in &term_blocks {
360 for block in &tb.blocks {
361 for doc_id in &block.doc_ids {
362 excluded.insert(*doc_id);
363 }
364 }
365 }
366
367 for token in &neg_tokens {
369 let postings = self
370 .backend
371 .read_postings(database_id, tid, collection, token)
372 .map_err(FtsIndexError::backend)?;
373 for posting in postings {
374 excluded.insert(posting.doc_id);
375 }
376 }
377
378 Ok(excluded)
379 }
380
381 fn filter_and_mode(
382 &self,
383 params: FilterAndModeParams<'_>,
384 ) -> Result<Vec<TextSearchResult>, B::Error> {
385 let FilterAndModeParams {
386 database_id,
387 tid,
388 collection,
389 query_tokens,
390 candidates,
391 num_terms,
392 } = params;
393 let term_blocks = crate::lsm::query::collect_merged_term_blocks(
394 &self.backend,
395 database_id,
396 tid,
397 collection,
398 self.memtable(),
399 query_tokens,
400 self.governor.as_ref(),
401 )?;
402
403 let mut results = Vec::new();
404 for candidate in candidates {
405 let surrogate = candidate.doc_id;
406 let matched = term_blocks
407 .iter()
408 .filter(|tb| tb.blocks.iter().any(|b| b.doc_ids.contains(&surrogate)))
409 .count();
410 if matched >= num_terms {
411 results.push(candidate.clone());
412 }
413 }
414 Ok(results)
415 }
416
417 fn count_term_matches(
418 &self,
419 database_id: u64,
420 tid: u64,
421 collection: &str,
422 query_tokens: &[String],
423 doc_id: Surrogate,
424 ) -> usize {
425 let term_blocks = match crate::lsm::query::collect_merged_term_blocks(
426 &self.backend,
427 database_id,
428 tid,
429 collection,
430 self.memtable(),
431 query_tokens,
432 self.governor.as_ref(),
433 ) {
434 Ok(tb) => tb,
435 Err(_) => return 0,
436 };
437 term_blocks
438 .iter()
439 .filter(|tb| tb.blocks.iter().any(|b| b.doc_ids.contains(&doc_id)))
440 .count()
441 }
442
443 fn to_sorted_results(
444 doc_scores: HashMap<Surrogate, (f32, bool, usize)>,
445 top_k: usize,
446 ) -> Vec<TextSearchResult> {
447 let mut results: Vec<TextSearchResult> = doc_scores
448 .into_iter()
449 .map(|(doc_id, (score, fuzzy_flag, _))| TextSearchResult {
450 doc_id,
451 score,
452 fuzzy: fuzzy_flag,
453 })
454 .collect();
455 results.sort_by(|a, b| {
456 b.score
457 .partial_cmp(&a.score)
458 .unwrap_or(std::cmp::Ordering::Equal)
459 });
460 results.truncate(top_k);
461 results
462 }
463}
464
465#[cfg(test)]
466mod tests {
467 use nodedb_types::{Surrogate, SurrogateBitmap};
468
469 use super::FtsSearchParams;
470 use crate::backend::memory::MemoryBackend;
471 use crate::index::FtsIndex;
472 use crate::index::error::FtsIndexError;
473 use crate::posting::QueryMode;
474 use crate::search::query_parser::InvalidQuery;
475
476 const DB: u64 = 0;
477 const T: u64 = 1;
478 const D1: Surrogate = Surrogate(1);
479 const D2: Surrogate = Surrogate(2);
480 const D3: Surrogate = Surrogate(3);
481
482 fn make_index() -> FtsIndex<MemoryBackend> {
483 let idx = FtsIndex::new(MemoryBackend::new());
484 idx.index_document(
485 DB,
486 T,
487 "docs",
488 D1,
489 "The quick brown fox jumps over the lazy dog",
490 )
491 .unwrap();
492 idx.index_document(DB, T, "docs", D2, "A fast brown dog runs across the field")
493 .unwrap();
494 idx.index_document(DB, T, "docs", D3, "Rust programming language for systems")
495 .unwrap();
496 idx
497 }
498
499 #[test]
500 fn basic_search() {
501 let idx = make_index();
502 let results = idx
503 .search(
504 DB,
505 T,
506 "docs",
507 FtsSearchParams {
508 query: "brown fox",
509 top_k: 10,
510 fuzzy_enabled: false,
511 mode: QueryMode::And,
512 prefilter: None,
513 },
514 )
515 .unwrap();
516 assert!(!results.is_empty());
517 assert_eq!(results[0].doc_id, D1);
518 }
519
520 #[test]
521 fn search_with_stemming() {
522 let idx = FtsIndex::new(MemoryBackend::new());
523 idx.index_document(DB, T, "docs", D1, "running distributed databases")
524 .unwrap();
525 idx.index_document(DB, T, "docs", D2, "the cat sat on a mat")
526 .unwrap();
527
528 let results = idx
529 .search(
530 DB,
531 T,
532 "docs",
533 FtsSearchParams {
534 query: "database distribution",
535 top_k: 10,
536 fuzzy_enabled: false,
537 mode: QueryMode::And,
538 prefilter: None,
539 },
540 )
541 .unwrap();
542 assert!(!results.is_empty());
543 assert_eq!(results[0].doc_id, D1);
544 }
545
546 #[test]
547 fn or_mode() {
548 let idx = make_index();
549 let results = idx
550 .search(
551 DB,
552 T,
553 "docs",
554 FtsSearchParams {
555 query: "brown fox",
556 top_k: 10,
557 fuzzy_enabled: false,
558 mode: QueryMode::Or,
559 prefilter: None,
560 },
561 )
562 .unwrap();
563 assert!(results.len() >= 2);
564 }
565
566 #[test]
567 fn and_mode_filters() {
568 let idx = FtsIndex::new(MemoryBackend::new());
569 idx.index_document(DB, T, "docs", D1, "Rust programming language")
570 .unwrap();
571 idx.index_document(DB, T, "docs", D2, "Python programming language")
572 .unwrap();
573
574 let results = idx
575 .search(
576 DB,
577 T,
578 "docs",
579 FtsSearchParams {
580 query: "rust programming",
581 top_k: 10,
582 fuzzy_enabled: false,
583 mode: QueryMode::And,
584 prefilter: None,
585 },
586 )
587 .unwrap();
588 assert_eq!(results.len(), 1);
589 assert_eq!(results[0].doc_id, D1);
590 }
591
592 #[test]
593 fn and_fallback_to_or() {
594 let idx = FtsIndex::new(MemoryBackend::new());
595 idx.index_document(DB, T, "docs", D1, "rust programming language")
596 .unwrap();
597 idx.index_document(DB, T, "docs", D2, "python programming language")
598 .unwrap();
599
600 let results = idx
601 .search(
602 DB,
603 T,
604 "docs",
605 FtsSearchParams {
606 query: "rust python",
607 top_k: 10,
608 fuzzy_enabled: false,
609 mode: QueryMode::And,
610 prefilter: None,
611 },
612 )
613 .unwrap();
614 assert_eq!(results.len(), 2);
615 for r in &results {
616 assert!(r.score > 0.0);
617 }
618 }
619
620 #[test]
621 fn and_no_fallback_when_results_exist() {
622 let idx = FtsIndex::new(MemoryBackend::new());
623 idx.index_document(DB, T, "docs", D1, "rust programming language")
624 .unwrap();
625 idx.index_document(DB, T, "docs", D2, "python programming language")
626 .unwrap();
627
628 let results = idx
629 .search(
630 DB,
631 T,
632 "docs",
633 FtsSearchParams {
634 query: "rust programming",
635 top_k: 10,
636 fuzzy_enabled: false,
637 mode: QueryMode::And,
638 prefilter: None,
639 },
640 )
641 .unwrap();
642 assert_eq!(results.len(), 1);
643 assert_eq!(results[0].doc_id, D1);
644 }
645
646 #[test]
647 fn empty_query() {
648 let idx = make_index();
649 let results = idx
650 .search(
651 DB,
652 T,
653 "docs",
654 FtsSearchParams {
655 query: "the a is",
656 top_k: 10,
657 fuzzy_enabled: false,
658 mode: QueryMode::And,
659 prefilter: None,
660 },
661 )
662 .unwrap();
663 assert!(results.is_empty());
664 }
665
666 #[test]
667 fn collections_isolated() {
668 let idx = FtsIndex::new(MemoryBackend::new());
669 idx.index_document(DB, T, "col_a", D1, "alpha bravo charlie")
670 .unwrap();
671 idx.index_document(DB, T, "col_b", D1, "delta echo foxtrot")
672 .unwrap();
673
674 assert_eq!(
675 idx.search(
676 DB,
677 T,
678 "col_a",
679 FtsSearchParams {
680 query: "alpha",
681 top_k: 10,
682 fuzzy_enabled: false,
683 mode: QueryMode::And,
684 prefilter: None
685 }
686 )
687 .unwrap()
688 .len(),
689 1
690 );
691 assert!(
692 idx.search(
693 DB,
694 T,
695 "col_b",
696 FtsSearchParams {
697 query: "alpha",
698 top_k: 10,
699 fuzzy_enabled: false,
700 mode: QueryMode::And,
701 prefilter: None
702 }
703 )
704 .unwrap()
705 .is_empty()
706 );
707 }
708
709 #[test]
710 fn fuzzy_search() {
711 let idx = FtsIndex::new(MemoryBackend::new());
712 idx.index_document(DB, T, "docs", D1, "distributed database systems")
713 .unwrap();
714
715 let results = idx
716 .search(
717 DB,
718 T,
719 "docs",
720 FtsSearchParams {
721 query: "databse",
722 top_k: 10,
723 fuzzy_enabled: true,
724 mode: QueryMode::And,
725 prefilter: None,
726 },
727 )
728 .unwrap();
729 assert!(!results.is_empty());
730 assert!(results[0].fuzzy);
731 }
732
733 #[test]
734 fn phrase_boost_consecutive() {
735 let idx = FtsIndex::new(MemoryBackend::new());
736 idx.index_document(DB, T, "docs", D1, "the quick brown fox jumped")
737 .unwrap();
738 idx.index_document(DB, T, "docs", D2, "a brown dog chased a fox")
739 .unwrap();
740
741 let results = idx
742 .search(
743 DB,
744 T,
745 "docs",
746 FtsSearchParams {
747 query: "brown fox",
748 top_k: 10,
749 fuzzy_enabled: false,
750 mode: QueryMode::Or,
751 prefilter: None,
752 },
753 )
754 .unwrap();
755 assert!(results.len() >= 2);
756 assert_eq!(results[0].doc_id, D1);
757 }
758
759 #[test]
760 fn phrase_boost_no_effect_single_term() {
761 let idx = FtsIndex::new(MemoryBackend::new());
762 idx.index_document(DB, T, "docs", D1, "hello world")
763 .unwrap();
764
765 let results = idx
766 .search(
767 DB,
768 T,
769 "docs",
770 FtsSearchParams {
771 query: "hello",
772 top_k: 10,
773 fuzzy_enabled: false,
774 mode: QueryMode::And,
775 prefilter: None,
776 },
777 )
778 .unwrap();
779 assert_eq!(results.len(), 1);
780 }
781
782 #[test]
783 fn tenants_isolated() {
784 let idx = FtsIndex::new(MemoryBackend::new());
785 idx.index_document(DB, 1, "docs", D1, "alpha bravo")
786 .unwrap();
787 idx.index_document(DB, 2, "docs", D1, "charlie delta")
788 .unwrap();
789
790 let r1 = idx
791 .search(
792 DB,
793 1,
794 "docs",
795 FtsSearchParams {
796 query: "alpha",
797 top_k: 10,
798 fuzzy_enabled: false,
799 mode: QueryMode::And,
800 prefilter: None,
801 },
802 )
803 .unwrap();
804 let r2 = idx
805 .search(
806 DB,
807 2,
808 "docs",
809 FtsSearchParams {
810 query: "alpha",
811 top_k: 10,
812 fuzzy_enabled: false,
813 mode: QueryMode::And,
814 prefilter: None,
815 },
816 )
817 .unwrap();
818 assert_eq!(r1.len(), 1);
819 assert!(r2.is_empty());
820 }
821
822 #[test]
823 fn prefilter_excludes_non_member_surrogates() {
824 let idx = FtsIndex::new(MemoryBackend::new());
825
826 idx.index_document(DB, T, "docs", D1, "rust language system")
827 .unwrap();
828 idx.index_document(DB, T, "docs", D2, "rust rust rust rust rust")
829 .unwrap();
830 idx.index_document(DB, T, "docs", D3, "rust rust rust rust rust rust")
831 .unwrap();
832
833 let mut bm = SurrogateBitmap::new();
834 bm.insert(D1);
835
836 let results = idx
837 .search(
838 DB,
839 T,
840 "docs",
841 FtsSearchParams {
842 query: "rust",
843 top_k: 10,
844 fuzzy_enabled: false,
845 mode: QueryMode::And,
846 prefilter: Some(&bm),
847 },
848 )
849 .unwrap();
850
851 assert_eq!(results.len(), 1, "only D1 should be returned");
852 assert_eq!(results[0].doc_id, D1);
853
854 assert!(
855 !results.iter().any(|r| r.doc_id == D2),
856 "D2 must be excluded"
857 );
858 assert!(
859 !results.iter().any(|r| r.doc_id == D3),
860 "D3 must be excluded"
861 );
862
863 let all_results = idx
864 .search(
865 DB,
866 T,
867 "docs",
868 FtsSearchParams {
869 query: "rust",
870 top_k: 10,
871 fuzzy_enabled: false,
872 mode: QueryMode::And,
873 prefilter: None,
874 },
875 )
876 .unwrap();
877 assert_eq!(all_results.len(), 3, "all docs returned without prefilter");
878 assert!(
879 all_results[0].doc_id == D2 || all_results[0].doc_id == D3,
880 "D2 or D3 should lead without prefilter (higher tf)"
881 );
882
883 let empty_bm = SurrogateBitmap::new();
884 let empty_results = idx
885 .search(
886 DB,
887 T,
888 "docs",
889 FtsSearchParams {
890 query: "rust",
891 top_k: 10,
892 fuzzy_enabled: false,
893 mode: QueryMode::And,
894 prefilter: Some(&empty_bm),
895 },
896 )
897 .unwrap();
898 assert!(empty_results.is_empty(), "empty prefilter → no results");
899
900 let mut bm23 = SurrogateBitmap::new();
901 bm23.insert(D2);
902 bm23.insert(D3);
903 let results23 = idx
904 .search(
905 DB,
906 T,
907 "docs",
908 FtsSearchParams {
909 query: "rust",
910 top_k: 10,
911 fuzzy_enabled: false,
912 mode: QueryMode::And,
913 prefilter: Some(&bm23),
914 },
915 )
916 .unwrap();
917 assert_eq!(results23.len(), 2);
918 assert!(!results23.iter().any(|r| r.doc_id == D1));
919 }
920
921 #[test]
924 fn not_keyword_excludes_documents() {
925 let idx = FtsIndex::new(MemoryBackend::new());
926 idx.index_document(DB, T, "docs", D1, "rust python programming")
928 .unwrap();
929 idx.index_document(DB, T, "docs", D2, "rust ruby programming")
930 .unwrap();
931 idx.index_document(DB, T, "docs", D3, "python ruby programming")
932 .unwrap();
933
934 let results = idx
935 .search(
936 DB,
937 T,
938 "docs",
939 FtsSearchParams {
940 query: "rust NOT python",
941 top_k: 10,
942 fuzzy_enabled: false,
943 mode: QueryMode::And,
944 prefilter: None,
945 },
946 )
947 .unwrap();
948 assert!(
950 results.iter().any(|r| r.doc_id == D2),
951 "D2 (rust+ruby) must be in results"
952 );
953 assert!(
954 !results.iter().any(|r| r.doc_id == D1),
955 "D1 (rust+python) must be excluded"
956 );
957 }
958
959 #[test]
960 fn dash_prefix_excludes_documents() {
961 let idx = FtsIndex::new(MemoryBackend::new());
962 idx.index_document(DB, T, "docs", D1, "rust python programming")
963 .unwrap();
964 idx.index_document(DB, T, "docs", D2, "rust ruby programming")
965 .unwrap();
966 idx.index_document(DB, T, "docs", D3, "python ruby programming")
967 .unwrap();
968
969 let results = idx
970 .search(
971 DB,
972 T,
973 "docs",
974 FtsSearchParams {
975 query: "rust -python",
976 top_k: 10,
977 fuzzy_enabled: false,
978 mode: QueryMode::And,
979 prefilter: None,
980 },
981 )
982 .unwrap();
983 assert!(results.iter().any(|r| r.doc_id == D2));
984 assert!(!results.iter().any(|r| r.doc_id == D1));
985 }
986
987 #[test]
988 fn multiple_not_excludes_all_negated() {
989 let idx = FtsIndex::new(MemoryBackend::new());
990 idx.index_document(DB, T, "docs", D1, "rust python programming")
991 .unwrap();
992 idx.index_document(DB, T, "docs", D2, "rust ruby programming")
993 .unwrap();
994 idx.index_document(DB, T, "docs", D3, "rust systems programming")
995 .unwrap();
996
997 let results = idx
998 .search(
999 DB,
1000 T,
1001 "docs",
1002 FtsSearchParams {
1003 query: "rust NOT python NOT ruby",
1004 top_k: 10,
1005 fuzzy_enabled: false,
1006 mode: QueryMode::And,
1007 prefilter: None,
1008 },
1009 )
1010 .unwrap();
1011 assert!(results.iter().any(|r| r.doc_id == D3));
1013 assert!(!results.iter().any(|r| r.doc_id == D1));
1014 assert!(!results.iter().any(|r| r.doc_id == D2));
1015 }
1016
1017 #[test]
1018 fn not_nonexistent_term_returns_all_positives() {
1019 let idx = FtsIndex::new(MemoryBackend::new());
1020 idx.index_document(DB, T, "docs", D1, "rust programming")
1021 .unwrap();
1022 idx.index_document(DB, T, "docs", D2, "rust systems")
1023 .unwrap();
1024
1025 let results_plain = idx
1026 .search(
1027 DB,
1028 T,
1029 "docs",
1030 FtsSearchParams {
1031 query: "rust",
1032 top_k: 10,
1033 fuzzy_enabled: false,
1034 mode: QueryMode::And,
1035 prefilter: None,
1036 },
1037 )
1038 .unwrap();
1039 let results_not = idx
1040 .search(
1041 DB,
1042 T,
1043 "docs",
1044 FtsSearchParams {
1045 query: "rust NOT nonexistentxyz",
1046 top_k: 10,
1047 fuzzy_enabled: false,
1048 mode: QueryMode::And,
1049 prefilter: None,
1050 },
1051 )
1052 .unwrap();
1053
1054 let plain_ids: std::collections::HashSet<Surrogate> =
1055 results_plain.iter().map(|r| r.doc_id).collect();
1056 let not_ids: std::collections::HashSet<Surrogate> =
1057 results_not.iter().map(|r| r.doc_id).collect();
1058 assert_eq!(
1059 plain_ids, not_ids,
1060 "NOT with nonexistent term must not remove any docs"
1061 );
1062 }
1063
1064 #[test]
1065 fn negative_only_returns_invalid_query_error() {
1066 let idx = FtsIndex::new(MemoryBackend::new());
1067 idx.index_document(DB, T, "docs", D1, "python programming")
1068 .unwrap();
1069
1070 let err = idx
1071 .search(
1072 DB,
1073 T,
1074 "docs",
1075 FtsSearchParams {
1076 query: "NOT python",
1077 top_k: 10,
1078 fuzzy_enabled: false,
1079 mode: QueryMode::And,
1080 prefilter: None,
1081 },
1082 )
1083 .unwrap_err();
1084 assert!(
1085 matches!(err, FtsIndexError::InvalidQuery(InvalidQuery::NegativeOnly)),
1086 "expected InvalidQuery(NegativeOnly), got {err:?}"
1087 );
1088 }
1089
1090 #[test]
1091 fn parentheses_after_not_returns_invalid_query_error() {
1092 let idx = FtsIndex::new(MemoryBackend::new());
1093 idx.index_document(DB, T, "docs", D1, "rust programming")
1094 .unwrap();
1095
1096 let err = idx
1097 .search(
1098 DB,
1099 T,
1100 "docs",
1101 FtsSearchParams {
1102 query: "rust NOT (python OR ruby)",
1103 top_k: 10,
1104 fuzzy_enabled: false,
1105 mode: QueryMode::And,
1106 prefilter: None,
1107 },
1108 )
1109 .unwrap_err();
1110 assert!(
1111 matches!(
1112 err,
1113 FtsIndexError::InvalidQuery(InvalidQuery::ParenthesesNotSupported)
1114 ),
1115 "expected InvalidQuery(ParenthesesNotSupported), got {err:?}"
1116 );
1117 }
1118}