Skip to main content

nodedb_fts/index/
analyzer_config.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Per-collection analyzer configuration stored in backend metadata.
4//!
5//! Uses structural meta blobs of the collection's whole-document index
6//! (`IndexScope::document(collection)`), shared by every field index:
7//! - `subkey = "analyzer"` → analyzer name (e.g. "german", "standard")
8//! - `subkey = "language"` → lang code (e.g. "de", "ja")
9//! - `subkey = "fuzzy"` → `"1"` when the collection defaults to fuzzy matching
10//!
11//! Applied automatically at both index time and query time.
12
13use crate::analyzer::language::stemmer::{LanguageAnalyzer, NoStemAnalyzer};
14use crate::analyzer::pipeline::{TextAnalyzer, analyze};
15use crate::analyzer::standard::StandardAnalyzer;
16use crate::backend::FtsBackend;
17use crate::index::FtsIndex;
18use crate::scope::IndexScope;
19
20impl<B: FtsBackend> FtsIndex<B> {
21    /// Set the analyzer for a collection. Persists to backend metadata.
22    pub fn set_collection_analyzer(
23        &self,
24        database_id: u64,
25        tid: u64,
26        collection: &str,
27        analyzer_name: &str,
28    ) -> Result<(), B::Error> {
29        self.backend.write_meta(
30            database_id,
31            tid,
32            IndexScope::document(collection),
33            "analyzer",
34            analyzer_name.as_bytes(),
35        )
36    }
37
38    /// Set the language for a collection. Persists to backend metadata.
39    pub fn set_collection_language(
40        &self,
41        database_id: u64,
42        tid: u64,
43        collection: &str,
44        lang_code: &str,
45    ) -> Result<(), B::Error> {
46        self.backend.write_meta(
47            database_id,
48            tid,
49            IndexScope::document(collection),
50            "language",
51            lang_code.as_bytes(),
52        )
53    }
54
55    /// Set whether searches over this collection fall back to fuzzy
56    /// (Levenshtein) matching by default. Persists to backend metadata.
57    pub fn set_collection_fuzzy(
58        &self,
59        database_id: u64,
60        tid: u64,
61        collection: &str,
62        fuzzy: bool,
63    ) -> Result<(), B::Error> {
64        self.backend.write_meta(
65            database_id,
66            tid,
67            IndexScope::document(collection),
68            "fuzzy",
69            if fuzzy { b"1" } else { b"0" },
70        )
71    }
72
73    /// Whether this collection defaults to fuzzy matching. `false` when unset.
74    pub fn get_collection_fuzzy(
75        &self,
76        database_id: u64,
77        tid: u64,
78        collection: &str,
79    ) -> Result<bool, B::Error> {
80        Ok(self
81            .backend
82            .read_meta(database_id, tid, IndexScope::document(collection), "fuzzy")?
83            .is_some_and(|bytes| bytes.as_slice() == b"1"))
84    }
85
86    /// Get the configured analyzer name for a collection.
87    pub fn get_collection_analyzer(
88        &self,
89        database_id: u64,
90        tid: u64,
91        collection: &str,
92    ) -> Result<Option<String>, B::Error> {
93        match self.backend.read_meta(
94            database_id,
95            tid,
96            IndexScope::document(collection),
97            "analyzer",
98        )? {
99            Some(bytes) => Ok(std::str::from_utf8(&bytes).ok().map(String::from)),
100            None => Ok(None),
101        }
102    }
103
104    /// Get the configured language for a collection.
105    pub fn get_collection_language(
106        &self,
107        database_id: u64,
108        tid: u64,
109        collection: &str,
110    ) -> Result<Option<String>, B::Error> {
111        match self.backend.read_meta(
112            database_id,
113            tid,
114            IndexScope::document(collection),
115            "language",
116        )? {
117            Some(bytes) => Ok(std::str::from_utf8(&bytes).ok().map(String::from)),
118            None => Ok(None),
119        }
120    }
121
122    /// Analyze text using the collection's configured analyzer.
123    ///
124    /// Falls back to the standard English analyzer if no analyzer is configured.
125    pub fn analyze_for_collection(
126        &self,
127        database_id: u64,
128        tid: u64,
129        collection: &str,
130        text: &str,
131    ) -> Result<Vec<String>, B::Error> {
132        let analyzer_name = self.get_collection_analyzer(database_id, tid, collection)?;
133        match analyzer_name.as_deref() {
134            Some(name) => Ok(resolve_analyzer(name).analyze(text)),
135            None => Ok(analyze(text)),
136        }
137    }
138
139    /// Tokenize text WITHOUT stemming for fuzzy matching.
140    pub fn tokenize_raw_for_collection(
141        &self,
142        database_id: u64,
143        tid: u64,
144        collection: &str,
145        text: &str,
146    ) -> Result<Vec<String>, B::Error> {
147        let lang = self.get_collection_language(database_id, tid, collection)?;
148        let lang_code = lang.as_deref().unwrap_or("en");
149        let stop_list = crate::analyzer::language::stop_words::stop_words(lang_code);
150        Ok(crate::analyzer::pipeline::tokenize_raw(
151            text, lang_code, stop_list,
152        ))
153    }
154}
155
156/// Whether `name` resolves to a real analyzer.
157///
158/// [`resolve_analyzer`] falls back to the standard analyzer for anything it
159/// does not know, which silently turns a misspelled analyzer name into a
160/// different analyzer. Callers that accept a name from a user — DDL, config —
161/// gate on this first so the fallback is only ever reached for a name that
162/// was already checked. The two must stay in step: every arm below has a
163/// matching arm there.
164pub fn analyzer_exists(name: &str) -> bool {
165    name == "standard"
166        || LanguageAnalyzer::new(name).is_some()
167        || NoStemAnalyzer::new(name).is_some()
168}
169
170/// Resolve an analyzer name to a `Box<dyn TextAnalyzer>`.
171fn resolve_analyzer(name: &str) -> Box<dyn TextAnalyzer> {
172    match name {
173        "standard" => Box::new(StandardAnalyzer),
174        _ => {
175            if let Some(a) = LanguageAnalyzer::new(name) {
176                Box::new(a)
177            } else if let Some(a) = NoStemAnalyzer::new(name) {
178                Box::new(a)
179            } else {
180                Box::new(StandardAnalyzer)
181            }
182        }
183    }
184}
185
186#[cfg(test)]
187mod tests {
188    use crate::backend::memory::MemoryBackend;
189    use crate::index::FtsIndex;
190    use crate::test_support::test_governor;
191
192    const DB: u64 = 0;
193    const T: u64 = 1;
194
195    #[test]
196    fn default_analyzer() {
197        let idx = FtsIndex::new(MemoryBackend::new(), test_governor());
198        let tokens = idx
199            .analyze_for_collection(DB, T, "col", "The quick brown fox")
200            .unwrap();
201        assert!(tokens.contains(&"quick".to_string()));
202        assert!(!tokens.contains(&"the".to_string()));
203    }
204
205    #[test]
206    fn configured_german_analyzer() {
207        let idx = FtsIndex::new(MemoryBackend::new(), test_governor());
208        idx.set_collection_analyzer(DB, T, "col", "german").unwrap();
209
210        let tokens = idx
211            .analyze_for_collection(DB, T, "col", "Die Datenbanken sind schnell")
212            .unwrap();
213        assert!(!tokens.iter().any(|t| t == "die" || t == "sind"));
214        assert!(!tokens.is_empty());
215    }
216
217    #[test]
218    fn configured_hindi_no_stem() {
219        let idx = FtsIndex::new(MemoryBackend::new(), test_governor());
220        idx.set_collection_analyzer(DB, T, "col", "hindi").unwrap();
221
222        let tokens = idx
223            .analyze_for_collection(DB, T, "col", "यह एक परीक्षा है")
224            .unwrap();
225        assert!(!tokens.iter().any(|t| t == "यह" || t == "है"));
226    }
227
228    #[test]
229    fn analyzer_persists() {
230        let idx = FtsIndex::new(MemoryBackend::new(), test_governor());
231        idx.set_collection_analyzer(DB, T, "col", "french").unwrap();
232        idx.set_collection_language(DB, T, "col", "fr").unwrap();
233
234        assert_eq!(
235            idx.get_collection_analyzer(DB, T, "col")
236                .unwrap()
237                .as_deref(),
238            Some("french")
239        );
240        assert_eq!(
241            idx.get_collection_language(DB, T, "col")
242                .unwrap()
243                .as_deref(),
244            Some("fr")
245        );
246    }
247}