Skip to main content

flams_search/
lib.rs

1#![allow(unexpected_cfgs)]
2#![cfg_attr(all(doc, CHANNEL_NIGHTLY), feature(doc_cfg))]
3#![doc = include_str!("../README.md")]
4/*!
5 * ## Feature flags
6 */
7#![cfg_attr(doc,doc = document_features::document_features!())]
8
9use crate::index::SearchIndex;
10use flams_backend_types::search::FragmentQueryFilter;
11use flams_backend_types::search::SearchResult;
12use flams_math_archives::{
13    Archive, LocallyBuilt,
14    artifacts::{Artifact, ContentResult, FileOrString},
15    backend::{AnyBackend, GlobalBackend, LocalBackend},
16    build_target,
17    formats::BuildResult,
18    utils::errors::{ArtifactSaveError, FileError},
19};
20use flams_system::FlamsExtension;
21use ftml_uris::DocumentElementUri;
22use ftml_uris::{DocumentUri, SymbolUri, UriPath, UriWithArchive};
23
24#[cfg(all(feature = "tantivy", not(feature = "vectorsearch")))]
25use crate::schema::SearchSchema;
26
27pub mod index;
28pub mod query;
29#[cfg(all(feature = "tantivy", not(feature = "vectorsearch")))]
30pub mod schema;
31pub mod textify;
32
33#[cfg(feature = "tantivy")]
34const MEMORY_SIZE: usize = 150_000_000;
35
36flams_system::register_exension!(FlamsExtension {
37    name: "search",
38    on_start: initialize,
39    on_build_result: |b, uri, rel_path, a| {
40        //println!("Here");
41        if let Some(content) = a.as_any().downcast_ref::<ContentResult>() {
42            index(b, uri, rel_path, content);
43        }
44    },
45    on_reload: initialize
46});
47
48#[cfg(feature = "tantivy")]
49build_target!(TANTIVY {
50    name: "tantivy_search",
51    description: "search index",
52    run: |_| BuildResult::default()
53});
54
55#[cfg(feature = "vectorsearch")]
56build_target!(VECTORSEARCH {
57    name: "vector_search",
58    description: "search index",
59    run: |_| BuildResult::default()
60});
61
62// -------------------------------------------------
63
64#[cfg(feature = "vectorsearch")]
65pub(crate) struct Embedder;
66
67#[cfg(feature = "vectorsearch")]
68impl Embedder {
69    pub fn embed<S: AsRef<str> + Send + Sync>(
70        texts: impl AsRef<[S]>,
71    ) -> Result<Vec<flams_backend_types::search::Embedding>, String> {
72        MODEL.as_ref().map_or_else(
73            || Err("No model".to_string()),
74            |lock| {
75                let mut model = lock.lock();
76                model.embed(texts, None).map_or_else(
77                    |e| Err(format!("Error embedding texts: {e}")),
78                    |r| {
79                        Ok(r.into_iter()
80                            .map(|v| {
81                                // SAFETY: invariant
82                                let boxed = unsafe { v.try_into().unwrap_unchecked() };
83                                flams_backend_types::search::Embedding::new(boxed)
84                            })
85                            .collect())
86                    },
87                )
88            },
89        )
90    }
91}
92
93#[cfg(feature = "vectorsearch")]
94pub(crate) static MODEL: std::sync::LazyLock<Option<parking_lot::Mutex<fastembed::TextEmbedding>>> =
95    std::sync::LazyLock::new(|| {
96        tracing::info_span!("initializing vector search model").in_scope(|| {
97            use flams_system::settings::CONFIG_DIR;
98            // https://ort.pyke.io/backends/candle
99            //ort::set_api(ort_candle::api());
100            let model_path = flams_system::settings::Settings::get()
101                .embedding_dir
102                .as_ref()
103                .map_or_else(
104                    || {
105                        CONFIG_DIR
106                            .as_ref()
107                            .expect("no default directory")
108                            .join("embedding")
109                    },
110                    |d| (*d).to_path_buf(),
111                );
112
113            match fastembed::TextEmbedding::try_new(
114                fastembed::InitOptions::new(fastembed::EmbeddingModel::ParaphraseMLMiniLML12V2Q)
115                    .with_show_download_progress(false)
116                    .with_cache_dir(model_path),
117            ) {
118                Ok(m) => {
119                    tracing::info!("Model initialized");
120                    Some(parking_lot::Mutex::new(m))
121                }
122                Err(e) => {
123                    tracing::error!("Error downloading embedding model: {e}");
124                    None
125                }
126            }
127        })
128    });
129
130// -------------------------------------------------
131
132static SEARCHER: std::sync::LazyLock<Searcher> = std::sync::LazyLock::new(Searcher::new);
133static SPAN: std::sync::LazyLock<tracing::Span> =
134    std::sync::LazyLock::new(|| tracing::info_span!(target:"search",parent:None,"search"));
135
136#[cfg(all(feature = "tantivy", not(feature = "vectorsearch")))]
137pub struct Searcher {
138    index: parking_lot::RwLock<tantivy::index::Index>,
139    reader: parking_lot::RwLock<tantivy::IndexReader>,
140    writer: parking_lot::Mutex<()>,
141}
142
143#[cfg(feature = "vectorsearch")]
144pub struct Searcher {
145    index: parking_lot::RwLock<Vec<SearchIndex>>,
146}
147
148impl Searcher {
149    #[inline]
150    #[must_use]
151    pub fn get() -> &'static Self {
152        &SEARCHER
153    }
154
155    #[cfg(feature = "vectorsearch")]
156    pub fn size(&self) -> (usize, usize) {
157        let slf = self.index.read();
158        (slf.len(), slf.len() * std::mem::size_of::<SearchIndex>())
159    }
160
161    #[cfg(all(feature = "tantivy", not(feature = "vectorsearch")))]
162    pub fn size(&self) -> (usize, usize) {
163        let reader = self.reader.read();
164        (
165            reader.searcher().num_docs() as usize,
166            reader
167                .searcher()
168                .space_usage()
169                .expect("test")
170                .total()
171                .get_bytes() as usize,
172        )
173    }
174
175    #[cfg(all(feature = "tantivy", not(feature = "vectorsearch")))]
176    fn new() -> Self {
177        let index =
178            tantivy::index::Index::create_in_ram(schema::SearchSchema::get().schema.clone());
179        Self {
180            reader: parking_lot::RwLock::new(index.reader().expect("Failed to build reader")),
181            index: parking_lot::RwLock::new(index),
182            writer: parking_lot::Mutex::new(()),
183        }
184    }
185
186    #[cfg(feature = "vectorsearch")]
187    #[inline]
188    const fn new() -> Self {
189        Self {
190            index: parking_lot::RwLock::new(Vec::new()),
191        }
192    }
193
194    #[cfg(feature = "vectorsearch")]
195    #[inline]
196    pub fn add_one(&self, index: SearchIndex) {
197        self.index.write().push(index);
198    }
199
200    #[cfg(feature = "vectorsearch")]
201    #[inline]
202    pub fn add(&self, iter: impl IntoIterator<Item = SearchIndex>) {
203        self.index.write().extend(iter);
204    }
205
206    #[cfg(all(feature = "tantivy", not(feature = "vectorsearch")))]
207    pub fn query(
208        &self,
209        s: &str,
210        mut opts: FragmentQueryFilter,
211        num_results: usize,
212    ) -> Result<Vec<(f32, SearchResult)>, String> {
213        SPAN.in_scope(move || {
214            let searcher = self.reader.read().searcher();
215            let in_documents = std::mem::take(&mut opts.in_documents)
216                .into_iter()
217                .map(|u| u.to_string())
218                .collect::<Vec<_>>();
219            let query = query::build_query(s, &self.index.read(), opts)
220                .ok_or_else(|| "erro building query".to_string())?;
221            let top_num = if num_results == 0 {
222                usize::MAX / 2
223            } else {
224                num_results
225            };
226            let mut ret = Vec::new();
227            let iter = if in_documents.is_empty() {
228                searcher
229                    .search(
230                        &*query,
231                        &tantivy::collector::TopDocs::with_limit(top_num).order_by_score(),
232                    )
233                    .map_err(|e| e.to_string())?
234            } else {
235                searcher
236                    .search(
237                        &*query,
238                        &tantivy::collector::BytesFilterCollector::new(
239                            "uri".to_string(),
240                            move |u: &[u8]| {
241                                in_documents.iter().any(|d| u.starts_with(d.as_bytes()))
242                            },
243                            tantivy::collector::TopDocs::with_limit(top_num).order_by_score(),
244                        ),
245                    )
246                    .map_err(|e| e.to_string())?
247            };
248            for (s, a) in iter {
249                let Ok(doc) = searcher.doc::<tantivy::schema::TantivyDocument>(a) else {
250                    continue;
251                };
252                if let Some(doc) = SearchIndex::from_document(doc) {
253                    ret.push((s, doc));
254                };
255            }
256            Ok(ret)
257        })
258    }
259
260    #[cfg(all(feature = "tantivy", not(feature = "vectorsearch")))]
261    #[allow(clippy::type_complexity)]
262    pub fn query_symbols(
263        &self,
264        s: &str,
265        num_results: usize,
266    ) -> Option<Vec<(f32, SymbolUri, DocumentElementUri)>> {
267        SPAN.in_scope(move || {
268            const FILTER: FragmentQueryFilter = {
269                use flams_backend_types::search::QueryFilterFlags;
270
271                let mut f = FragmentQueryFilter::new();
272                f.flags = QueryFilterFlags::definition_like_only();
273                f
274            };
275            let searcher = self.reader.read().searcher();
276
277            let query = query::build_query(s, &self.index.read(), FILTER)?;
278            let top_num = if num_results == 0 {
279                usize::MAX / 2
280            } else {
281                num_results
282            };
283            let mut ret: Vec<(f32, SymbolUri, DocumentElementUri)> = Vec::new();
284            for (score, a) in searcher
285                .search(
286                    &*query,
287                    &tantivy::collector::TopDocs::with_limit(top_num * 3).order_by_score(),
288                )
289                .map_err(|e| tracing::error!("Search Error A: {e}"))
290                .ok()?
291            {
292                let Ok(doc) = searcher
293                    .doc::<tantivy::schema::TantivyDocument>(a)
294                    .map_err(|e| tracing::error!("Search Error: {e}"))
295                else {
296                    continue;
297                };
298                if let Some(doc) = SearchIndex::from_document(doc) {
299                    if let SearchResult::Paragraph { fors, uri, .. } = doc {
300                        for sym in fors {
301                            if let Some((r, _, e)) = ret.iter_mut().find(|(_, k, _)| *k == sym) {
302                                if score > *r {
303                                    *e = uri.clone();
304                                }
305                            } else {
306                                ret.push((score, sym, uri.clone()));
307                            }
308                        }
309                    }
310                }
311            }
312            ret.sort_by_key(|(s, _, _)| ordered_float::OrderedFloat(-*s));
313            ret.truncate(num_results);
314            Some(ret)
315        })
316    }
317
318    #[cfg(feature = "vectorsearch")]
319    #[allow(clippy::cast_possible_truncation)]
320    pub fn query_symbols(
321        &self,
322        s: &str,
323        num_results: usize,
324    ) -> Option<Vec<(f32, SymbolUri, DocumentElementUri)>> {
325        // SAFETY: invariant: input.len() == output.len()
326        let query = unsafe { crate::Embedder::embed([s]).ok()?.pop().unwrap_unchecked() };
327        let top_num = if num_results == 0 {
328            usize::MAX / 2
329        } else {
330            num_results
331        };
332        let mut ret: Vec<(f32, SymbolUri, DocumentElementUri)> =
333            Vec::with_capacity(if num_results == 0 { 1 } else { num_results + 1 });
334        let searcher = self.index.read();
335        for par in searcher.iter().filter(|e| {
336            if let SearchIndex::Paragraph {
337                definition_like: true,
338                fors,
339                ..
340            } = e
341                && !fors.is_empty()
342            {
343                true
344            } else {
345                false
346            }
347        }) {
348            let SearchIndex::Paragraph {
349                title,
350                fors,
351                body,
352                uri: elem_uri,
353                ..
354            } = par
355            else {
356                // SAFETY: filter_map above
357                unsafe {
358                    use std::hint::unreachable_unchecked;
359                    unreachable_unchecked()
360                }
361            };
362            let title_score = title.as_ref().map(|t| (t % &query) as f32);
363            let body_score = (body % &query) as f32;
364            let neg_score = ordered_float::OrderedFloat(
365                -title_score.map_or(body_score, |t| t.mul_add(2.0, body_score) / 3.0),
366            );
367            let index = ret
368                .binary_search_by_key(&neg_score, |(e, _, _)| ordered_float::OrderedFloat(-*e))
369                .unwrap_or_else(|i| i);
370
371            // this could be optimized to iterate less
372            for f in fors {
373                if let Some((i, (_, _, _))) =
374                    ret.iter().enumerate().find(|(_, (_, uri, _))| uri == f)
375                {
376                    if i >= index {
377                        let (_, uri, _) = ret.remove(i);
378                        ret.insert(index, (-neg_score.0, uri, elem_uri.clone()));
379                    }
380                } else {
381                    ret.insert(index, (-neg_score.0, f.clone(), elem_uri.clone()));
382                }
383            }
384            ret.truncate(top_num);
385        }
386        drop(searcher);
387        Some(ret)
388    }
389
390    #[cfg(feature = "vectorsearch")]
391    #[allow(clippy::cast_possible_truncation)]
392    pub fn query(
393        &self,
394        s: &str,
395        opts: FragmentQueryFilter,
396        num_results: usize,
397    ) -> Result<Vec<(f32, SearchResult)>, String> {
398        // SAFETY: invariant: input.len() == output.len()
399        let query = unsafe { crate::Embedder::embed([s])?.pop().unwrap_unchecked() };
400        let top_num = if num_results == 0 {
401            usize::MAX / 2
402        } else {
403            num_results
404        };
405        let mut ret: Vec<(f32, SearchResult)> =
406            Vec::with_capacity(if num_results == 0 { 1 } else { num_results + 1 });
407        let searcher = self.index.read();
408        for e in searcher.iter().filter(|e| filter(&opts, e)) {
409            match e {
410                SearchIndex::Document { uri, title, body } => {
411                    let title_score = title.as_ref().map(|t| (t % &query) as f32);
412                    let body_score = (body % &query) as f32;
413                    let neg_score = ordered_float::OrderedFloat(
414                        -title_score.map_or(body_score, |t| t.mul_add(2.0, body_score) / 3.0),
415                    );
416                    let i = ret
417                        .binary_search_by_key(&neg_score, |(e, _)| ordered_float::OrderedFloat(-*e))
418                        .unwrap_or_else(|i| i);
419                    ret.insert(i, (-neg_score.0, SearchResult::Document(uri.clone())));
420                    if ret.len() > top_num {
421                        let _ = ret.pop();
422                    }
423                }
424                SearchIndex::Paragraph {
425                    uri,
426                    kind,
427                    definition_like,
428                    title,
429                    fors,
430                    body,
431                } => {
432                    let title_score = title.as_ref().map(|t| (t % &query) as f32);
433                    let body_score = (body % &query) as f32;
434                    let neg_score = ordered_float::OrderedFloat(
435                        -title_score.map_or(body_score, |t| t.mul_add(2.0, body_score) / 3.0),
436                    );
437                    let i = ret
438                        .binary_search_by_key(&neg_score, |(e, _)| ordered_float::OrderedFloat(-*e))
439                        .unwrap_or_else(|i| i);
440                    ret.insert(
441                        i,
442                        (
443                            -neg_score.0,
444                            SearchResult::Paragraph {
445                                uri: uri.clone(),
446                                fors: fors.clone(),
447                                def_like: *definition_like,
448                                kind: *kind,
449                            },
450                        ),
451                    );
452                    if ret.len() > top_num {
453                        let _ = ret.pop();
454                    }
455                }
456            }
457        }
458        drop(searcher);
459        Ok(ret)
460    }
461}
462
463pub fn index(backend: &AnyBackend, uri: &DocumentUri, rel_path: &UriPath, result: &ContentResult) {
464    backend.with_buildable_archive(uri.archive_id(), |a| {
465        if let Some(a) = a {
466            let it = index::index_document(&result.document, &result.ftml);
467            let _ = a.save(
468                uri,
469                Some(rel_path),
470                FileOrString::Str(String::new().into_boxed_str()),
471                #[cfg(all(feature = "tantivy", not(feature = "vectorsearch")))]
472                TANTIVY.id(),
473                #[cfg(feature = "vectorsearch")]
474                VECTORSEARCH.id(),
475                Some(Box::new(IndexFile(it)) as _),
476                GlobalBackend.triple_store(),
477                false,
478            );
479        } else {
480            tracing::error!("Archive not found! {}", uri.archive_id());
481        }
482    });
483}
484
485struct IndexFile(Vec<SearchIndex>);
486impl Artifact for IndexFile {
487    fn as_any(&self) -> &dyn std::any::Any {
488        self as _
489    }
490    fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
491        self as _
492    }
493    fn into_any(self: Box<Self>) -> Box<dyn std::any::Any> {
494        self as _
495    }
496
497    #[cfg(all(not(feature = "tantivy"), feature = "vectorsearch"))]
498    fn kind(&self) -> &'static str {
499        "vectorsearch"
500    }
501
502    #[cfg(feature = "tantivy")]
503    fn kind(&self) -> &'static str {
504        "tantivy"
505    }
506
507    fn write(&self, into: &std::path::Path) -> Result<(), ArtifactSaveError> {
508        let file = std::fs::File::create(into)
509            .map_err(|e| ArtifactSaveError::Fs(FileError::Creation(into.to_path_buf(), e)))?;
510        #[cfg(all(feature = "tantivy", not(feature = "vectorsearch")))]
511        {
512            bincode::encode_into_std_write(
513                &self.0,
514                &mut std::io::BufWriter::new(file),
515                bincode::config::standard(),
516            )?;
517        }
518        #[cfg(feature = "vectorsearch")]
519        {
520            bincode::encode_into_std_write(
521                &self.0,
522                &mut std::io::BufWriter::new(file),
523                bincode::config::standard(),
524            )?;
525        }
526        Ok(())
527    }
528}
529
530#[allow(clippy::too_many_lines)]
531fn initialize() {
532    #[cfg(feature = "vectorsearch")]
533    SPAN.in_scope(|| {
534        use rayon::iter::{IntoParallelRefIterator, ParallelIterator};
535
536        let _ = std::thread::spawn(|| {
537            std::sync::LazyLock::force(&MODEL);
538        });
539        let mut index = SEARCHER.index.write();
540        let nidx = tracing::info_span!("Loading search indices").in_scope(move || {
541            GlobalBackend
542                .all_archives()
543                .par_iter()
544                .filter_map(|a| match a {
545                    Archive::Local(a) => Some(a),
546                    Archive::Ext(_, _) => None,
547                })
548                .flat_map(|a| {
549                    let out = a.out_dir();
550                    if out.exists() && out.is_dir() {
551                        Some(
552                            walkdir::WalkDir::new(out)
553                                .into_iter()
554                                .filter_map(Result::ok)
555                                .filter(|entry| entry.file_name() == "vectorsearch")
556                                .filter_map(|e| {
557                                    let Ok(f) = std::fs::File::open(e.path()) else {
558                                        tracing::error!(
559                                            "error reading file {}",
560                                            e.path().display()
561                                        );
562                                        return None;
563                                    };
564                                    let file = std::io::BufReader::new(f);
565
566                                    let Ok(v): Result<Vec<SearchIndex>, _> =
567                                        bincode::decode_from_reader(
568                                            file,
569                                            bincode::config::standard(),
570                                        )
571                                    else {
572                                        tracing::error!(
573                                            "error deserializing file {}",
574                                            e.path().display()
575                                        );
576                                        return None;
577                                    };
578                                    Some(v)
579                                })
580                                .collect::<Vec<_>>(),
581                        )
582                    } else {
583                        None
584                    }
585                })
586                .flatten()
587                .flatten()
588                .collect::<Vec<_>>()
589        });
590        *index = nidx;
591    });
592
593    #[cfg(all(feature = "tantivy", not(feature = "vectorsearch")))]
594    SPAN.in_scope(|| {
595        use rayon::iter::{IntoParallelRefIterator, ParallelIterator};
596        let index = tantivy::index::Index::create_in_ram(SearchSchema::get().schema.clone());
597        let mut writer = index
598            .writer(MEMORY_SIZE)
599            .expect("Failed to instantiate search writer");
600        let wr = &writer;
601        tracing::info_span!("Loading search indices").in_scope(move || {
602            GlobalBackend
603                .all_archives()
604                .par_iter()
605                .filter_map(|a| match a {
606                    Archive::Local(a) => Some(a),
607                    Archive::Ext(_, _) => None,
608                })
609                .for_each(|a| {
610                    let out = a.out_dir();
611                    if out.exists() && out.is_dir() {
612                        for e in walkdir::WalkDir::new(out)
613                            .into_iter()
614                            .filter_map(Result::ok)
615                            .filter(|entry| entry.file_name() == "tantivy")
616                        {
617                            let Ok(f) = std::fs::File::open(e.path()) else {
618                                tracing::error!("error reading file {}", e.path().display());
619                                return;
620                            };
621                            let file = std::io::BufReader::new(f);
622
623                            let Ok(v): Result<Vec<SearchIndex>, _> =
624                                bincode::decode_from_reader(file, bincode::config::standard())
625                            else {
626                                tracing::error!("error deserializing file {}", e.path().display());
627                                return;
628                            };
629                            for d in v {
630                                use tantivy::schema::Value;
631
632                                let d: tantivy::TantivyDocument = d.to_document();
633                                if let Err(e) = wr.add_document(d) {
634                                    tracing::error!("{e}");
635                                }
636                            }
637                        }
638                    }
639                });
640        });
641        match writer.commit() {
642            Ok(i) => tracing::info!("Loaded {i} entries"),
643            Err(e) => tracing::error!("Error: {e}"),
644        }
645        let slf = Searcher::get();
646        let writer = slf.writer.lock();
647        let mut old_index = slf.index.write();
648        let mut reader = slf.reader.write();
649        let Ok(r) = index.reader() else {
650            tracing::error!("Failed to instantiate search reader");
651            return;
652        };
653        *reader = r;
654        *old_index = index;
655        drop(reader);
656        drop(old_index);
657        drop(writer);
658    });
659}
660
661#[cfg(feature = "vectorsearch")]
662fn filter(cplx: &FragmentQueryFilter, idx: &SearchIndex) -> bool {
663    use flams_backend_types::search::SearchResultKind;
664    use ftml_uris::IsNarrativeUri;
665
666    match idx {
667        SearchIndex::Document { uri, .. } => {
668            cplx.flags.allow_documents()
669                && (cplx.languages.is_empty() || cplx.languages.contains(&uri.language))
670        }
671        SearchIndex::Paragraph {
672            definition_like: true,
673            uri,
674            ..
675        } => {
676            cplx.flags.allow_definitions()
677                && (cplx.languages.is_empty() || cplx.languages.contains(&uri.language()))
678        }
679        SearchIndex::Paragraph {
680            kind: SearchResultKind::Assertion,
681            uri,
682            ..
683        } => {
684            cplx.flags.allow_assertions()
685                && (cplx.languages.is_empty() || cplx.languages.contains(&uri.language()))
686        }
687        SearchIndex::Paragraph {
688            kind: SearchResultKind::Example,
689            uri,
690            ..
691        } => {
692            cplx.flags.allow_examples()
693                && (cplx.languages.is_empty() || cplx.languages.contains(&uri.language()))
694        }
695        SearchIndex::Paragraph {
696            kind: SearchResultKind::Paragraph,
697            uri,
698            ..
699        } => {
700            cplx.flags.allow_paragraphs()
701                && (cplx.languages.is_empty() || cplx.languages.contains(&uri.language()))
702        }
703        SearchIndex::Paragraph {
704            kind: SearchResultKind::Problem,
705            uri,
706            ..
707        } => {
708            cplx.flags.allow_problems()
709                && (cplx.languages.is_empty() || cplx.languages.contains(&uri.language()))
710        }
711        SearchIndex::Paragraph { .. } => false,
712    }
713}