1#![allow(unexpected_cfgs)]
2#![cfg_attr(all(doc, CHANNEL_NIGHTLY), feature(doc_cfg))]
3#![doc = include_str!("../README.md")]
4#![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 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#[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 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 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
130static 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 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 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 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 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}