Skip to main content

nodedb_fts/search/
bm25_search.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! BM25 search over the FtsIndex with AND-first OR-fallback, phrase boost,
4//! and NOT-term exclusion.
5//!
6//! ## AND-first / OR-fallback
7//!
8//! A multi-term query `rust programming` first attempts AND (all terms must
9//! match). If no document satisfies all terms, results fall back to OR with
10//! coverage-penalised scores.
11//!
12//! ## NOT operator
13//!
14//! `rust NOT python` and `rust -python` are equivalent. The query parser
15//! splits the input into positive and negative term lists. BM25 scoring runs
16//! on positive terms only; a bitmap of doc IDs that match **any** negative
17//! term is built separately and used to filter final results. Negative terms
18//! do not affect BM25 scores.
19//!
20//! Synonym expansion applies to both positive and negative term lists, so
21//! `rust NOT db` also excludes documents that contain synonym expansions of
22//! `db` (e.g. `database`, `datastore`).
23
24use 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
36/// Query and tuning parameters for a BM25 search.
37///
38/// The `(database_id, tid, collection)` scope is passed separately so the
39/// same struct can be shared by callers that hold the tenant id as either a
40/// raw `u64` (this crate) or a strongly-typed `TenantId` (the Origin wrapper).
41pub struct FtsSearchParams<'a> {
42    /// Raw query string (may contain `NOT <term>` / `-<term>` negation).
43    pub query: &'a str,
44    /// Maximum number of results to return.
45    pub top_k: usize,
46    /// When `true`, unmatched terms fall back to fuzzy (Levenshtein) lookup.
47    pub fuzzy_enabled: bool,
48    /// Boolean combination mode for multi-term queries (AND or OR).
49    pub mode: QueryMode,
50    /// Optional surrogate bitmap restricting the candidate set before scoring.
51    pub prefilter: Option<&'a SurrogateBitmap>,
52}
53
54/// Inputs to the AND-mode post-filter that drops BMW candidates which do not
55/// match at least `num_terms` of the analyzed query tokens.
56struct 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    /// Search the index with explicit boolean mode, fuzzy, and optional prefilter.
67    ///
68    /// Supports `NOT <term>` and `-<term>` negation in the query string.
69    /// Returns `Err(FtsIndexError::InvalidQuery)` for ill-formed queries such
70    /// as NOT-only queries or unsupported parenthesised groups.
71    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        // Parse the query for NOT / - negation operators before analysis.
86        let parsed = parse_query(query)?;
87
88        // Reconstruct the positive-only query string for the existing analyzer path.
89        // Each raw positive token is passed to the analyzer individually rather than
90        // joining them, because some analyzers are sensitive to token boundaries.
91        // Joining with a space is safe for the standard/language analyzers.
92        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        // Build the negative-term exclusion set before scoring.
124        // Negative terms are analyzed and synonym-expanded just like positive
125        // terms. The result is a set of doc IDs that match any negative term.
126        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        // Fallback: exhaustive BM25 scoring reading directly from the backend.
203        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                // Prefilter: skip surrogates not present in the bitmap.
240                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        // Apply negative filter to final fallback results.
307        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    /// Build a set of doc IDs that match any of the given raw negative terms.
316    ///
317    /// Each raw negative term is analyzed and synonym-expanded before posting
318    /// lookup, matching the same pipeline as positive terms.
319    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        // Analyze all negative terms together (join is safe for standard analyzer).
331        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        // Synonym-expand negative tokens so negating 'db' also excludes 'database'.
341        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        // Collect postings from memtable + segments for each negative token.
348        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        // Also check the backend postings directly (covers the exhaustive path).
368        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    // ── NOT operator tests ────────────────────────────────────────────────────
922
923    #[test]
924    fn not_keyword_excludes_documents() {
925        let idx = FtsIndex::new(MemoryBackend::new());
926        // D1: rust + python, D2: rust + ruby, D3: python + ruby
927        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        // Must include D2 (rust, no python), must not include D1 (has python).
949        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        // Only D3 has neither python nor ruby.
1012        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}