1use crate::types::{ChunkMeta, DocumentChunk, IndexManifest, RankScore, SourceFile};
10use anyhow::Result;
11use async_trait::async_trait;
12use dyn_clone::DynClone;
13use std::collections::{HashMap, HashSet};
14use std::path::Path;
15#[cfg(any(feature = "internal", feature = "lancedb"))]
16use std::path::PathBuf;
17
18use crate::agents::Generator;
19use crate::embed::EmbeddingMetadata;
20
21#[derive(Clone, Debug)]
23#[cfg_attr(feature = "internal", derive(serde::Serialize, serde::Deserialize))]
24pub struct StoredChunk {
25 pub text: String,
27 pub source_file: SourceFile,
29 pub vector: Vec<f32>,
31 #[cfg_attr(feature = "internal", serde(default))]
33 pub meta: ChunkMeta,
34}
35
36#[derive(Clone, Debug)]
38pub struct ScoredChunk {
39 pub score: RankScore,
41 pub chunk: DocumentChunk,
43}
44
45#[async_trait]
54pub trait Ranker: Send + Sync + std::fmt::Debug + DynClone {
55 async fn rank(
59 &self,
60 chunks: &[StoredChunk],
61 query_vec: &[f32],
62 query_text: &str,
63 top_k: usize,
64 threshold: f64,
65 ) -> Vec<ScoredChunk>;
66
67 fn name(&self) -> &'static str;
69}
70
71dyn_clone::clone_trait_object!(Ranker);
72
73#[derive(Debug, Clone)]
81pub struct HybridRrfRanker {
82 pub k: f64,
85}
86
87impl Default for HybridRrfRanker {
88 fn default() -> Self {
89 Self { k: 60.0 }
90 }
91}
92
93#[async_trait]
94impl Ranker for HybridRrfRanker {
95 async fn rank(
96 &self,
97 chunks: &[StoredChunk],
98 query_vec: &[f32],
99 query_text: &str,
100 top_k: usize,
101 threshold: f64,
102 ) -> Vec<ScoredChunk> {
103 rank_hybrid_rrf(chunks, query_vec, query_text, top_k, threshold, self.k).await
104 }
105
106 fn name(&self) -> &'static str {
107 "RRFFusion"
108 }
109}
110
111#[derive(Debug, Clone)]
121pub struct WeightedFusionRanker {
122 pub alpha: f64,
124}
125
126impl Default for WeightedFusionRanker {
127 fn default() -> Self {
128 Self { alpha: 0.5 }
129 }
130}
131
132#[async_trait]
133impl Ranker for WeightedFusionRanker {
134 async fn rank(
135 &self,
136 chunks: &[StoredChunk],
137 query_vec: &[f32],
138 query_text: &str,
139 top_k: usize,
140 threshold: f64,
141 ) -> Vec<ScoredChunk> {
142 rank_weighted_fusion(chunks, query_vec, query_text, top_k, threshold, self.alpha).await
143 }
144
145 fn name(&self) -> &'static str {
146 if self.alpha >= 1.0 {
148 "Cosine"
149 } else if self.alpha <= 0.0 {
150 "BM25"
151 } else {
152 "Weighted"
153 }
154 }
155}
156
157#[derive(Debug)]
168pub struct MmrDiversityRanker {
169 pub lambda: f64,
171 pub inner: Box<dyn Ranker>,
173}
174
175impl Clone for MmrDiversityRanker {
176 fn clone(&self) -> Self {
177 Self {
178 lambda: self.lambda,
179 inner: self.inner.clone(),
180 }
181 }
182}
183
184#[async_trait]
185impl Ranker for MmrDiversityRanker {
186 async fn rank(
187 &self,
188 chunks: &[StoredChunk],
189 query_vec: &[f32],
190 query_text: &str,
191 top_k: usize,
192 threshold: f64,
193 ) -> Vec<ScoredChunk> {
194 mmr_rerank(
195 chunks,
196 query_vec,
197 query_text,
198 top_k,
199 threshold,
200 self.lambda,
201 &*self.inner,
202 )
203 .await
204 }
205
206 fn name(&self) -> &'static str {
207 "MMR"
208 }
209}
210
211#[derive(Debug)]
219pub struct LlmReranker {
220 pub generator: Box<dyn Generator>,
222 pub inner: Box<dyn Ranker>,
224 pub prompt_template: String,
226}
227
228impl Clone for LlmReranker {
229 fn clone(&self) -> Self {
230 Self {
231 generator: self.generator.clone(),
232 inner: self.inner.clone(),
233 prompt_template: self.prompt_template.clone(),
234 }
235 }
236}
237
238#[async_trait]
239impl Ranker for LlmReranker {
240 async fn rank(
241 &self,
242 chunks: &[StoredChunk],
243 query_vec: &[f32],
244 query_text: &str,
245 top_k: usize,
246 threshold: f64,
247 ) -> Vec<ScoredChunk> {
248 if chunks.is_empty() || top_k == 0 {
249 return Vec::new();
250 }
251
252 let pool_size = (top_k * 3).min(chunks.len()).max(top_k);
254 let mut candidates = self
255 .inner
256 .rank(chunks, query_vec, query_text, pool_size, threshold)
257 .await;
258 if candidates.len() <= 1 {
259 return candidates;
260 }
261
262 let mut passages = String::new();
264 for (i, sc) in candidates.iter().enumerate() {
265 let snippet: String = sc.chunk.text.chars().take(300).collect();
266 passages.push_str(&format!("[{i}] {snippet}\n\n"));
267 }
268 let template = if self.prompt_template.is_empty() {
269 LLM_DEFAULT_PROMPT
270 } else {
271 &self.prompt_template
272 };
273 let prompt = template
274 .replace("{query}", query_text)
275 .replace("{passages}", &passages);
276
277 log::debug!(
279 "LLM reranker: asking {} ({}) to re-rank {} candidates",
280 self.generator.backend_name(),
281 self.generator.model_name(),
282 candidates.len()
283 );
284 let response = match self.generator.generate(&prompt).await {
285 Ok(r) => r,
286 Err(e) => {
287 log::warn!("LLM reranker call failed: {e}; falling back to inner ranker");
288 candidates.truncate(top_k);
289 return candidates;
290 }
291 };
292
293 let order = parse_llm_ranking(&response, candidates.len());
295 let mut result: Vec<ScoredChunk> = order
296 .into_iter()
297 .filter_map(|i| candidates.get(i).cloned())
298 .collect();
299 result.truncate(top_k);
300 result
301 }
302
303 fn name(&self) -> &'static str {
304 "LLM"
305 }
306}
307
308#[async_trait]
331pub trait VectorStore: Send + Sync + std::fmt::Debug + DynClone {
332 async fn insert(&self, chunks: Vec<StoredChunk>) -> Result<()>;
334
335 async fn search(
338 &self,
339 query_vec: &[f32],
340 query_text: &str,
341 top_k: usize,
342 threshold: f64,
343 ) -> Result<Vec<ScoredChunk>>;
344
345 async fn delete_by_source(&self, source: &str) -> Result<()>;
347
348 fn len(&self) -> usize;
350
351 fn sources(&self) -> HashSet<SourceFile>;
353
354 fn is_empty(&self) -> bool {
356 self.len() == 0
357 }
358
359 fn set_ranker(&self, _ranker: Box<dyn Ranker>) -> Result<()> {
364 Err(anyhow::anyhow!(
365 "This store backend does not support swappable rankers"
366 ))
367 }
368
369 fn ranker_name(&self) -> Option<String> {
371 None
372 }
373
374 fn validate_embedder(&self, _meta: &EmbeddingMetadata) -> Result<()> {
381 Ok(())
382 }
383
384 fn flush(&self) -> Result<()> {
390 Ok(())
391 }
392
393 fn manifest(&self) -> Option<IndexManifest> {
399 None
400 }
401
402 fn record_manifest(&self, _manifest: IndexManifest) -> Result<()> {
407 Ok(())
408 }
409}
410
411dyn_clone::clone_trait_object!(VectorStore);
412
413fn cosine_similarity_public(a: &[f32], b: &[f32]) -> f64 {
420 let (dot, norm_a, norm_b) =
421 a.iter()
422 .zip(b.iter())
423 .fold((0.0f64, 0.0f64, 0.0f64), |(d, na, nb), (&x, &y)| {
424 let (x, y) = (x as f64, y as f64);
425 (d + x * y, na + x * x, nb + y * y)
426 });
427 let denom = (norm_a.sqrt() * norm_b.sqrt()).max(1e-12);
428 (dot / denom).clamp(-1.0, 1.0)
429}
430
431fn tokenize(text: &str) -> Vec<String> {
434 text.to_lowercase()
435 .split(|c: char| !c.is_alphanumeric())
436 .filter(|t| !t.is_empty() && t.len() >= 2)
437 .map(|t| t.to_string())
438 .collect()
439}
440
441struct Bm25Index {
446 doc_freqs: HashMap<String, usize>,
447 doc_tfs: Vec<HashMap<String, usize>>,
448 doc_lens: Vec<usize>,
449 avg_doc_len: f64,
450 total_docs: usize,
451}
452
453impl Bm25Index {
454 fn build(chunks: &[StoredChunk]) -> Self {
455 let total_docs = chunks.len();
456 let mut doc_freqs: HashMap<String, usize> = HashMap::new();
457 let mut doc_tfs: Vec<HashMap<String, usize>> = Vec::with_capacity(total_docs);
458 let mut doc_lens: Vec<usize> = Vec::with_capacity(total_docs);
459
460 for chunk in chunks {
461 let tokens = tokenize(&chunk.text);
462 doc_lens.push(tokens.len());
463 let mut tf: HashMap<String, usize> = HashMap::new();
464 for t in &tokens {
465 *tf.entry(t.clone()).or_insert(0) += 1;
466 }
467 for t in tf.keys() {
468 *doc_freqs.entry(t.clone()).or_insert(0) += 1;
469 }
470 doc_tfs.push(tf);
471 }
472
473 let avg_doc_len = if total_docs > 0 {
474 doc_lens.iter().sum::<usize>() as f64 / total_docs as f64
475 } else {
476 1.0
477 };
478
479 Self {
480 doc_freqs,
481 doc_tfs,
482 doc_lens,
483 avg_doc_len,
484 total_docs,
485 }
486 }
487
488 fn score_all(&self, query_tokens: &[String]) -> Vec<(usize, f64)> {
489 const K1: f64 = 1.5;
490 const B: f64 = 0.75;
491 const IDF_SMOOTH: f64 = 0.5;
492
493 let n = self.total_docs as f64;
494 let mut scores: Vec<(usize, f64)> = Vec::with_capacity(self.total_docs);
495
496 for (doc_idx, tf_map) in self.doc_tfs.iter().enumerate() {
497 let mut score = 0.0;
498 let doc_len = self.doc_lens[doc_idx] as f64;
499 for qt in query_tokens {
500 let df = *self.doc_freqs.get(qt).unwrap_or(&0) as f64;
501 if df == 0.0 {
502 continue;
503 }
504 let idf = ((n - df + IDF_SMOOTH) / (df + IDF_SMOOTH) + 1.0).ln();
505 let tf = *tf_map.get(qt).unwrap_or(&0) as f64;
506 let numerator = tf * (K1 + 1.0);
507 let denominator = tf + K1 * (1.0 - B + B * doc_len / self.avg_doc_len);
508 score += idf * numerator / denominator;
509 }
510 scores.push((doc_idx, score));
511 }
512 scores
513 }
514}
515
516fn rrf_fusion(
523 vec_ranked: &[(usize, f64)],
524 bm25_ranked: &[(usize, f64)],
525 k: f64,
526) -> Vec<(usize, f64)> {
527 let mut fusion: HashMap<usize, f64> = HashMap::new();
528 for (rank, (doc_idx, _)) in vec_ranked.iter().enumerate() {
529 *fusion.entry(*doc_idx).or_insert(0.0) += 1.0 / (k + rank as f64 + 1.0);
530 }
531 for (rank, (doc_idx, _)) in bm25_ranked.iter().enumerate() {
532 *fusion.entry(*doc_idx).or_insert(0.0) += 1.0 / (k + rank as f64 + 1.0);
533 }
534 let mut fused: Vec<(usize, f64)> = fusion.into_iter().collect();
535 fused.sort_by(|a, b| b.1.total_cmp(&a.1));
536 fused
537}
538
539async fn rank_hybrid_rrf(
540 chunks: &[StoredChunk],
541 query_vec: &[f32],
542 query_text: &str,
543 top_k: usize,
544 threshold: f64,
545 rrf_k: f64,
546) -> Vec<ScoredChunk> {
547 if chunks.is_empty() {
548 return Vec::new();
549 }
550
551 let mut vec_scores: Vec<(usize, f64)> = chunks
552 .iter()
553 .enumerate()
554 .map(|(i, c)| (i, cosine_similarity_public(query_vec, &c.vector)))
555 .filter(|(_, s)| *s >= threshold)
556 .collect();
557 vec_scores.sort_by(|a, b| b.1.total_cmp(&a.1));
558
559 let bm25 = Bm25Index::build(chunks);
560 let query_tokens = tokenize(query_text);
561 let mut bm25_scores = bm25.score_all(&query_tokens);
562 bm25_scores.sort_by(|a, b| b.1.total_cmp(&a.1));
563
564 let fused = rrf_fusion(&vec_scores, &bm25_scores, rrf_k);
565
566 fused
567 .into_iter()
568 .take(top_k)
569 .map(|(idx, score)| {
570 let chunk = &chunks[idx];
571 ScoredChunk {
572 score: RankScore::from(score),
573 chunk: DocumentChunk {
574 text: chunk.text.clone(),
575 source_file: chunk.source_file.clone(),
576 meta: chunk.meta.clone(),
577 },
578 }
579 })
580 .collect()
581}
582
583async fn rank_weighted_fusion(
584 chunks: &[StoredChunk],
585 query_vec: &[f32],
586 query_text: &str,
587 top_k: usize,
588 threshold: f64,
589 alpha: f64,
590) -> Vec<ScoredChunk> {
591 if chunks.is_empty() {
592 return Vec::new();
593 }
594
595 let vec_scores: Vec<(usize, f64)> = if alpha > 0.0 {
597 let mut vs: Vec<_> = chunks
598 .iter()
599 .enumerate()
600 .map(|(i, c)| (i, cosine_similarity_public(query_vec, &c.vector)))
601 .filter(|(_, s)| *s >= threshold)
602 .collect();
603 vs.sort_by(|a, b| b.1.total_cmp(&a.1));
604 vs
605 } else {
606 Vec::new()
607 };
608
609 let bm25_scores: Vec<(usize, f64)> = if alpha < 1.0 {
610 let bm25 = Bm25Index::build(chunks);
611 let query_tokens = tokenize(query_text);
612 let mut bs = bm25.score_all(&query_tokens);
613 bs.sort_by(|a, b| b.1.total_cmp(&a.1));
614 bs
615 } else {
616 Vec::new()
617 };
618
619 let mut fused: Vec<(usize, f64)> = if alpha >= 1.0 {
621 vec_scores
622 } else if alpha <= 0.0 {
623 bm25_scores
624 } else {
625 let norm_vec = min_max_normalise(&vec_scores);
626 let norm_bm25 = min_max_normalise(&bm25_scores);
627
628 let mut map: HashMap<usize, f64> = HashMap::new();
629 for (idx, score) in &norm_vec {
630 *map.entry(*idx).or_insert(0.0) += alpha * score;
631 }
632 for (idx, score) in &norm_bm25 {
633 *map.entry(*idx).or_insert(0.0) += (1.0 - alpha) * score;
634 }
635 let mut combined: Vec<_> = map.into_iter().collect();
636 combined.sort_by(|a, b| b.1.total_cmp(&a.1));
637 combined
638 };
639
640 fused.truncate(top_k);
641 fused
642 .into_iter()
643 .map(|(idx, score)| {
644 let chunk = &chunks[idx];
645 ScoredChunk {
646 score: RankScore::from(score),
647 chunk: DocumentChunk {
648 text: chunk.text.clone(),
649 source_file: chunk.source_file.clone(),
650 meta: chunk.meta.clone(),
651 },
652 }
653 })
654 .collect()
655}
656
657fn min_max_normalise(scores: &[(usize, f64)]) -> Vec<(usize, f64)> {
662 if scores.is_empty() {
663 return Vec::new();
664 }
665 let min = scores.iter().map(|(_, s)| *s).fold(f64::INFINITY, f64::min);
666 let max = scores
667 .iter()
668 .map(|(_, s)| *s)
669 .fold(f64::NEG_INFINITY, f64::max);
670 let range = max - min;
671 if range < 1e-12 {
672 return scores.to_vec();
674 }
675 scores
676 .iter()
677 .map(|(idx, s)| (*idx, (s - min) / range))
678 .collect()
679}
680
681async fn mmr_rerank(
684 chunks: &[StoredChunk],
685 query_vec: &[f32],
686 query_text: &str,
687 top_k: usize,
688 threshold: f64,
689 lambda: f64,
690 inner: &dyn Ranker,
691) -> Vec<ScoredChunk> {
692 if chunks.is_empty() || top_k == 0 {
693 return Vec::new();
694 }
695
696 let pool_size = (top_k * 3).min(chunks.len()).max(top_k);
698 let candidates = inner
699 .rank(chunks, query_vec, query_text, pool_size, threshold)
700 .await;
701 if candidates.is_empty() {
702 return Vec::new();
703 }
704
705 let mut pool: Vec<(usize, f64)> = Vec::with_capacity(candidates.len());
707 for sc in &candidates {
708 if let Some(idx) = chunks
709 .iter()
710 .position(|c| c.source_file == sc.chunk.source_file && c.text == sc.chunk.text)
711 {
712 pool.push((idx, sc.score.0));
713 }
714 }
715
716 let mut selected: Vec<usize> = Vec::with_capacity(top_k);
718
719 while !pool.is_empty() && selected.len() < top_k {
720 let mut best_idx: usize = 0;
722 let mut best_mmr: f64 = f64::NEG_INFINITY;
723
724 for (i, (chunk_idx, score)) in pool.iter().enumerate() {
725 let max_sim = if selected.is_empty() {
726 0.0
727 } else {
728 selected
729 .iter()
730 .map(|&si| {
731 cosine_similarity_public(&chunks[si].vector, &chunks[*chunk_idx].vector)
732 })
733 .fold(0.0f64, f64::max)
734 };
735 let mmr = score - lambda * max_sim;
736 if mmr > best_mmr {
737 best_mmr = mmr;
738 best_idx = i;
739 }
740 }
741
742 let (chunk_idx, _original_score) = pool.remove(best_idx);
743 selected.push(chunk_idx);
744
745 }
747
748 selected
749 .into_iter()
750 .map(|idx| {
751 let chunk = &chunks[idx];
752 ScoredChunk {
753 score: RankScore::from(0.0), chunk: DocumentChunk {
755 text: chunk.text.clone(),
756 source_file: chunk.source_file.clone(),
757 meta: chunk.meta.clone(),
758 },
759 }
760 })
761 .collect()
762}
763
764const LLM_DEFAULT_PROMPT: &str = "\
767You are a relevance ranking assistant. Given a user query and a \
768numbered list of passages, rank them by how well they answer the \
769query. Return only the passage numbers in order of relevance, one \
770per line, most relevant first.\n\n\
771Query: {query}\n\n\
772Passages:\n\
773{passages}\n\
774Ranked order (most relevant first):";
775
776fn parse_llm_ranking(response: &str, num_passages: usize) -> Vec<usize> {
782 let mut order = Vec::new();
783 let mut seen = HashSet::new();
784 for line in response.lines() {
785 let trimmed = line.trim();
786 if let Some(first_char) = trimmed.chars().next()
787 && first_char.is_ascii_digit()
788 {
789 let num_str: String = trimmed.chars().take_while(|c| c.is_ascii_digit()).collect();
790 if let Ok(idx) = num_str.parse::<usize>()
791 && idx < num_passages
792 && seen.insert(idx)
793 {
794 order.push(idx);
795 }
796 }
797 }
798 for i in 0..num_passages {
800 if !seen.contains(&i) {
801 order.push(i);
802 }
803 }
804 order
805}
806
807#[cfg(feature = "internal")]
810mod brute_force {
811 use super::*;
812 use std::path::Path;
813
814 #[derive(Debug)]
817 pub struct BruteForceStore {
818 pub(super) inner: std::sync::Mutex<BruteForceInner>,
819 pub(super) path: PathBuf,
820 ranker: std::sync::Mutex<Box<dyn Ranker>>,
821 }
822
823 #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
824 pub struct BruteForceInner {
825 pub chunks: Vec<StoredChunk>,
826 #[serde(default)]
829 pub embedding_metadata: Option<EmbeddingMetadata>,
830 #[serde(skip, default)]
833 pub dirty: bool,
834 #[serde(default)]
837 pub manifest: Option<IndexManifest>,
838 }
839
840 impl BruteForceStore {
841 fn store_path(folder: &Path) -> PathBuf {
842 folder.join(".ragrig_store")
843 }
844
845 fn check_and_record_metadata(
849 &self,
850 embedder_meta: &EmbeddingMetadata,
851 ) -> Result<()> {
852 let mut inner = self.inner.lock().unwrap();
853 match &inner.embedding_metadata {
854 Some(stored) => {
855 if !stored.is_compatible_with(embedder_meta) {
856 return Err(anyhow::anyhow!(
857 crate::RagrigError::EmbeddingMismatch {
858 stored_model: stored.model_name.clone(),
859 stored_dims: stored.dimensions,
860 current_model: embedder_meta.model_name.clone(),
861 current_dims: embedder_meta.dimensions,
862 }
863 ));
864 }
865 }
866 None => {
867 inner.embedding_metadata = Some(embedder_meta.clone());
868 inner.dirty = true;
869 }
870 }
871 Ok(())
872 }
873
874 pub fn open_or_create(folder: &Path) -> Result<BruteForceStore> {
876 Self::open_or_create_with_ranker(folder, Box::new(HybridRrfRanker::default()))
877 }
878
879 pub fn open_or_create_with_ranker(
881 folder: &Path,
882 ranker: Box<dyn Ranker>,
883 ) -> Result<BruteForceStore> {
884 let path = Self::store_path(folder);
885 let inner = if path.exists() {
886 let bytes = std::fs::read(&path)?;
887 rmp_serde::from_slice(&bytes).map_err(|_| {
888 anyhow::anyhow!(crate::RagrigError::StoreCorrupt {
889 path: path.to_string_lossy().into_owned(),
890 })
891 })?
892 } else {
893 BruteForceInner {
894 chunks: Vec::new(),
895 embedding_metadata: None,
896 dirty: false,
897 manifest: None,
898 }
899 };
900 Ok(BruteForceStore {
901 inner: std::sync::Mutex::new(inner),
902 path,
903 ranker: std::sync::Mutex::new(ranker),
904 })
905 }
906
907 pub fn save(&self) -> Result<()> {
909 let mut inner = self.inner.lock().unwrap();
910 let bytes = rmp_serde::to_vec(&*inner)?;
911 std::fs::write(&self.path, &bytes)?;
912 inner.dirty = false;
913 Ok(())
914 }
915
916 pub fn flush(&self) -> Result<()> {
921 if self.inner.lock().unwrap().dirty {
922 self.save()
923 } else {
924 Ok(())
925 }
926 }
927 }
928
929 impl Drop for BruteForceStore {
930 fn drop(&mut self) {
931 if let Err(e) = self.flush() {
932 log::error!("BruteForceStore: failed to flush on drop: {e}");
933 }
934 }
935 }
936
937 impl Clone for BruteForceStore {
938 fn clone(&self) -> Self {
939 Self {
940 inner: std::sync::Mutex::new(self.inner.lock().unwrap().clone()),
941 path: self.path.clone(),
942 ranker: std::sync::Mutex::new(Box::new(HybridRrfRanker::default())),
943 }
944 }
945 }
946
947 #[async_trait]
948 impl VectorStore for BruteForceStore {
949 async fn insert(&self, chunks: Vec<StoredChunk>) -> Result<()> {
950 let n = chunks.len();
951 {
952 let mut inner = self.inner.lock().unwrap();
953 let new_sources: HashSet<SourceFile> =
954 chunks.iter().map(|c| c.source_file.clone()).collect();
955 inner
956 .chunks
957 .retain(|c| !new_sources.contains(&c.source_file));
958 inner.chunks.extend(chunks);
959 inner.dirty = true;
960 }
961 log::info!("Inserted {} chunks into internal store.", n);
962 Ok(())
963 }
964
965 async fn search(
966 &self,
967 query_vec: &[f32],
968 query_text: &str,
969 top_k: usize,
970 threshold: f64,
971 ) -> Result<Vec<ScoredChunk>> {
972 let (chunks, ranker) = {
973 let inner = self.inner.lock().unwrap();
974 let r = self.ranker.lock().unwrap();
975 (inner.chunks.clone(), r.clone())
976 };
977 log::trace!(
978 "BruteForceStore: searching {} chunks with ranker '{}'",
979 chunks.len(),
980 ranker.name()
981 );
982 Ok(ranker
983 .rank(&chunks, query_vec, query_text, top_k, threshold)
984 .await)
985 }
986
987 async fn delete_by_source(&self, source: &str) -> Result<()> {
988 {
989 let mut inner = self.inner.lock().unwrap();
990 inner.chunks.retain(|c| c.source_file != source);
991 inner.dirty = true;
992 }
993 Ok(())
994 }
995
996 fn len(&self) -> usize {
997 self.inner.lock().unwrap().chunks.len()
998 }
999
1000 fn sources(&self) -> HashSet<SourceFile> {
1001 self.inner
1002 .lock()
1003 .unwrap()
1004 .chunks
1005 .iter()
1006 .map(|c| c.source_file.clone())
1007 .collect()
1008 }
1009
1010 fn set_ranker(&self, ranker: Box<dyn Ranker>) -> Result<()> {
1011 *self.ranker.lock().unwrap() = ranker;
1012 Ok(())
1013 }
1014
1015 fn ranker_name(&self) -> Option<String> {
1016 Some(self.ranker.lock().unwrap().name().to_string())
1017 }
1018
1019 fn validate_embedder(&self, meta: &EmbeddingMetadata) -> Result<()> {
1020 self.check_and_record_metadata(meta)
1021 }
1022
1023 fn flush(&self) -> Result<()> {
1024 BruteForceStore::flush(self)
1025 }
1026
1027 fn manifest(&self) -> Option<IndexManifest> {
1028 self.inner.lock().unwrap().manifest.clone()
1029 }
1030
1031 fn record_manifest(&self, manifest: IndexManifest) -> Result<()> {
1032 let mut inner = self.inner.lock().unwrap();
1033 inner.manifest = Some(manifest);
1034 inner.dirty = true;
1035 Ok(())
1036 }
1037 }
1038}
1039
1040#[cfg(feature = "internal")]
1041pub use brute_force::BruteForceStore;
1042
1043#[cfg(feature = "lancedb")]
1046pub mod lance_db_store {
1048 use super::*;
1049 use anyhow::anyhow;
1050 use futures_util::TryStreamExt;
1051 use lance_index::scalar::FullTextSearchQuery;
1052 use lancedb::arrow::arrow_array::builder::StringBuilder;
1053 use lancedb::arrow::arrow_array::{
1054 Array, FixedSizeListArray, Float32Array, RecordBatch, StringArray, types::Float32Type,
1055 };
1056 use lancedb::arrow::arrow_schema::{DataType, Field, Schema};
1057 use lancedb::index::Index;
1058 use lancedb::index::scalar::FtsIndexBuilder;
1059 use lancedb::query::{QueryBase, QueryExecutionOptions};
1060 use std::sync::Arc;
1061
1062 #[derive(Clone, Debug)]
1064 pub struct LanceDbStore {
1065 table: lancedb::Table,
1066 count: std::sync::Arc<std::sync::atomic::AtomicUsize>,
1068 }
1069
1070 impl LanceDbStore {
1071 pub fn table_path(folder: &Path) -> PathBuf {
1073 folder.join(".ragrig_lancedb")
1074 }
1075
1076 pub async fn open_or_create(folder: &Path) -> Result<Self> {
1078 use std::sync::atomic::AtomicUsize;
1079 let path = Self::table_path(folder);
1080 let db = lancedb::connect(&path.to_string_lossy()).execute().await?;
1081 let (table, count) = match db.open_table("rag_knowledge_base").execute().await {
1082 Ok(t) => {
1083 let c = t.count_rows(None).await.unwrap_or(0);
1084 (t, c)
1085 }
1086 Err(_) => {
1087 let schema = Schema::new(vec![
1088 Field::new("text", DataType::Utf8, false),
1089 Field::new("source_file", DataType::Utf8, false),
1090 Field::new(
1091 "vector",
1092 DataType::FixedSizeList(
1093 Arc::new(Field::new("item", DataType::Float32, true)),
1094 768,
1095 ),
1096 false,
1097 ),
1098 ]);
1099 let batch = RecordBatch::new_empty(Arc::new(schema));
1100 let t = db
1101 .create_table("rag_knowledge_base", batch)
1102 .execute()
1103 .await?;
1104 t.create_index(&["text"], Index::FTS(FtsIndexBuilder::default()))
1105 .execute()
1106 .await?;
1107 (t, 0)
1108 }
1109 };
1110 Ok(Self {
1111 table,
1112 count: std::sync::Arc::new(AtomicUsize::new(count)),
1113 })
1114 }
1115 }
1116
1117 #[async_trait]
1118 impl VectorStore for LanceDbStore {
1119 async fn insert(&self, chunks: Vec<StoredChunk>) -> Result<()> {
1120 if chunks.is_empty() {
1121 return Ok(());
1122 }
1123 let n = chunks.len();
1124 let dim = chunks[0].vector.len();
1125 let mut text_builder = StringBuilder::with_capacity(chunks.len(), chunks.len() * 256);
1126 let mut source_builder = StringBuilder::with_capacity(chunks.len(), chunks.len() * 128);
1127 let mut vec_flat: Vec<f32> = Vec::with_capacity(chunks.len() * dim);
1128
1129 for c in &chunks {
1130 text_builder.append_value(&c.text);
1131 source_builder.append_value(&c.source_file.0);
1132 vec_flat.extend_from_slice(&c.vector);
1133 }
1134
1135 let vector_array = FixedSizeListArray::from_iter_primitive::<Float32Type, _, _>(
1136 vec_flat
1137 .chunks(dim)
1138 .map(|chunk| Some(chunk.iter().map(|v| Some(*v)))),
1139 dim as i32,
1140 );
1141
1142 let schema = Schema::new(vec![
1143 Field::new("text", DataType::Utf8, false),
1144 Field::new("source_file", DataType::Utf8, false),
1145 Field::new(
1146 "vector",
1147 DataType::FixedSizeList(
1148 Arc::new(Field::new("item", DataType::Float32, true)),
1149 dim as i32,
1150 ),
1151 false,
1152 ),
1153 ]);
1154
1155 let batch = RecordBatch::try_new(
1156 Arc::new(schema),
1157 vec![
1158 Arc::new(text_builder.finish()),
1159 Arc::new(source_builder.finish()),
1160 Arc::new(vector_array),
1161 ],
1162 )?;
1163
1164 self.table.add(batch).execute().await?;
1165 self.count
1166 .fetch_add(n, std::sync::atomic::Ordering::Relaxed);
1167 Ok(())
1168 }
1169
1170 async fn search(
1171 &self,
1172 query_vec: &[f32],
1173 query_text: &str,
1174 top_k: usize,
1175 threshold: f64,
1176 ) -> Result<Vec<ScoredChunk>> {
1177 let stream = self
1178 .table
1179 .query()
1180 .nearest_to(query_vec)?
1181 .full_text_search(FullTextSearchQuery::new(query_text.to_string()))
1182 .limit(top_k)
1183 .execute_hybrid(QueryExecutionOptions::default())
1184 .await?;
1185
1186 let batches: Vec<RecordBatch> = stream.try_collect().await?;
1187 let mut results = Vec::new();
1188
1189 for batch in &batches {
1190 let text_col = batch
1191 .column_by_name("text")
1192 .and_then(|col| col.as_any().downcast_ref::<StringArray>())
1193 .ok_or_else(|| anyhow!("text column not found"))?;
1194 let source_col = batch
1195 .column_by_name("source_file")
1196 .and_then(|col| col.as_any().downcast_ref::<StringArray>())
1197 .ok_or_else(|| anyhow!("source_file column not found"))?;
1198
1199 let score_col: Option<&Float32Array> = batch
1200 .column_by_name("_score")
1201 .and_then(|col| col.as_any().downcast_ref::<Float32Array>())
1202 .or_else(|| {
1203 batch
1204 .column_by_name("_distance")
1205 .and_then(|col| col.as_any().downcast_ref::<Float32Array>())
1206 });
1207
1208 let has_score = batch.column_by_name("_score").is_some();
1209
1210 for i in 0..batch.num_rows() {
1211 let raw_score = match score_col {
1212 Some(col) => col.value(i) as f64,
1213 None => 1.0 / (1.0 + (results.len() + i) as f64),
1214 };
1215 if threshold > 0.0 {
1216 if has_score && raw_score < threshold {
1217 continue;
1218 }
1219 if !has_score && raw_score > threshold {
1220 continue;
1221 }
1222 }
1223 results.push(ScoredChunk {
1224 score: RankScore::from(raw_score),
1225 chunk: DocumentChunk {
1226 text: text_col.value(i).to_string(),
1227 source_file: SourceFile::from(source_col.value(i).to_string()),
1228 meta: ChunkMeta::default(),
1229 },
1230 });
1231 }
1232 }
1233
1234 Ok(results)
1235 }
1236
1237 async fn delete_by_source(&self, source: &str) -> Result<()> {
1238 self.table
1239 .delete(&format!("source_file = '{}'", source))
1240 .await?;
1241 Ok(())
1242 }
1243
1244 fn len(&self) -> usize {
1245 self.count.load(std::sync::atomic::Ordering::Relaxed)
1246 }
1247
1248 fn sources(&self) -> std::collections::HashSet<SourceFile> {
1249 std::collections::HashSet::new()
1252 }
1253 }
1254}
1255
1256#[cfg(feature = "lancedb")]
1261pub async fn open_store(folder: &Path) -> Result<Box<dyn VectorStore>> {
1262 lance_db_store::LanceDbStore::open_or_create(folder)
1263 .await
1264 .map(|s| Box::new(s) as Box<dyn VectorStore>)
1265}
1266
1267#[cfg(all(feature = "internal", not(feature = "lancedb")))]
1270pub async fn open_store(folder: &Path) -> Result<Box<dyn VectorStore>> {
1271 BruteForceStore::open_or_create(folder).map(|s| Box::new(s) as Box<dyn VectorStore>)
1272}
1273
1274#[cfg(not(any(feature = "lancedb", feature = "internal")))]
1278pub async fn open_store(_folder: &Path) -> Result<Box<dyn VectorStore>> {
1279 anyhow::bail!("No vector store backend enabled. Enable the 'internal' or 'lancedb' feature.")
1280}
1281
1282pub async fn embed_and_insert(
1285 store: &dyn VectorStore,
1286 embedded: Vec<(String, Vec<f32>)>,
1287 text_to_source: &HashMap<String, (String, ChunkMeta)>,
1288) -> Result<()> {
1289 let chunks: Vec<StoredChunk> = embedded
1290 .into_iter()
1291 .map(|(text, vector)| {
1292 let (source_file, meta) = text_to_source
1293 .get(&text)
1294 .cloned()
1295 .unwrap_or_else(|| ("unknown".to_string(), ChunkMeta::default()));
1296 StoredChunk {
1297 text,
1298 source_file: SourceFile::from(source_file),
1299 vector,
1300 meta,
1301 }
1302 })
1303 .collect();
1304 store.insert(chunks).await
1305}
1306
1307#[cfg(test)]
1308#[cfg(feature = "internal")]
1309mod tests {
1310 use super::*;
1311 use std::env;
1312
1313 fn temp_folder() -> PathBuf {
1314 use std::sync::atomic::{AtomicUsize, Ordering};
1315 static COUNTER: AtomicUsize = AtomicUsize::new(0);
1316 let n = COUNTER.fetch_add(1, Ordering::Relaxed);
1317 let mut dir = env::temp_dir();
1318 dir.push(format!("ragrig_test_{}_{}", std::process::id(), n));
1319 let _ = std::fs::create_dir_all(&dir);
1320 dir
1321 }
1322
1323 fn cleanup(dir: &Path) {
1324 let _ = std::fs::remove_dir_all(dir);
1325 }
1326
1327 fn chunk(text: &str, source: &str) -> StoredChunk {
1328 StoredChunk {
1329 text: text.into(),
1330 source_file: source.into(),
1331 vector: vec![1.0f32, 2.0, 3.0],
1332 meta: ChunkMeta::default(),
1333 }
1334 }
1335
1336 #[tokio::test]
1337 async fn insert_and_len() {
1338 let dir = temp_folder();
1339 let store = BruteForceStore::open_or_create(&dir).unwrap();
1340 assert_eq!(store.len(), 0);
1341 store.insert(vec![chunk("hello", "doc1")]).await.unwrap();
1342 assert_eq!(store.len(), 1);
1343 store.insert(vec![chunk("world", "doc2")]).await.unwrap();
1344 assert_eq!(store.len(), 2);
1345 cleanup(&dir);
1346 }
1347
1348 #[tokio::test]
1349 async fn insert_replaces_same_source() {
1350 let dir = temp_folder();
1351 let store = BruteForceStore::open_or_create(&dir).unwrap();
1352 store.insert(vec![chunk("old", "doc1")]).await.unwrap();
1353 store.insert(vec![chunk("new", "doc1")]).await.unwrap();
1354 assert_eq!(store.len(), 1);
1355 cleanup(&dir);
1356 }
1357
1358 #[tokio::test]
1359 async fn delete_by_source() {
1360 let dir = temp_folder();
1361 let store = BruteForceStore::open_or_create(&dir).unwrap();
1362 store
1363 .insert(vec![chunk("a", "src1"), chunk("b", "src2")])
1364 .await
1365 .unwrap();
1366 assert_eq!(store.len(), 2);
1367 store.delete_by_source("src1").await.unwrap();
1368 assert_eq!(store.len(), 1);
1369 let sources = store.sources();
1370 assert!(sources.contains("src2"));
1371 assert!(!sources.contains(&SourceFile::from("src1")));
1372 cleanup(&dir);
1373 }
1374
1375 #[tokio::test]
1376 async fn search_returns_scored_results() {
1377 let dir = temp_folder();
1378 let store = BruteForceStore::open_or_create(&dir).unwrap();
1379 let qv = vec![1.0f32, 2.0, 3.0];
1380 store
1381 .insert(vec![
1382 chunk("cat", "s1"),
1383 chunk("dog", "s2"),
1384 chunk("cat dog", "s3"),
1385 ])
1386 .await
1387 .unwrap();
1388 let hits = store.search(&qv, "cat", 3, 0.0).await.unwrap();
1389 assert!(!hits.is_empty());
1390 for h in &hits {
1392 assert!(h.score > 0.0);
1393 assert!(!h.chunk.text.is_empty());
1394 assert!(!h.chunk.source_file.0.is_empty());
1395 }
1396 cleanup(&dir);
1397 }
1398
1399 #[tokio::test]
1400 async fn persistence_round_trip() {
1401 let dir = temp_folder();
1402 let store = BruteForceStore::open_or_create(&dir).unwrap();
1403 store
1404 .insert(vec![chunk("persist me", "src")])
1405 .await
1406 .unwrap();
1407 drop(store);
1408
1409 let reopened = BruteForceStore::open_or_create(&dir).unwrap();
1410 assert_eq!(reopened.len(), 1);
1411 assert!(reopened.sources().contains(&SourceFile::from("src")));
1412 cleanup(&dir);
1413 }
1414
1415 #[tokio::test]
1423 async fn cosine_beats_bm25_on_synonym_query() {
1424 let query_vec = vec![1.0f32, 0.0, 0.0, 0.0];
1431
1432 let multi_level_chunks: Vec<StoredChunk> = (0..5)
1433 .map(|i| StoredChunk {
1434 text: format!(
1435 "Multi-level regression handles hierarchical data structures. \
1436 Section {} discusses random intercepts and variance components.",
1437 i
1438 ),
1439 source_file: SourceFile::from("mlm_chapter.pdf".to_string()),
1440 vector: vec![0.92, 0.15, 0.0, 0.05],
1442 meta: ChunkMeta::default(),
1443 })
1444 .collect();
1445
1446 let literal_chunk = StoredChunk {
1448 text: "These models have also been called hierarchical models or \
1449 mixed-effects models. The 'mixed' stands for a mixture of \
1450 fixed effects and random effects."
1451 .into(),
1452 source_file: SourceFile::from("mlm_chapter.pdf".to_string()),
1453 vector: vec![0.0, 0.0, 0.0, 1.0],
1455 meta: ChunkMeta::default(),
1456 };
1457
1458 let unrelated_chunks: Vec<StoredChunk> = (0..5)
1459 .map(|i| StoredChunk {
1460 text: format!(
1461 "The Gaussian distribution has mean μ and standard deviation σ. \
1462 Example {} illustrates the central limit theorem.",
1463 i
1464 ),
1465 source_file: SourceFile::from("stats_chapter.pdf".to_string()),
1466 vector: vec![0.0, 0.0, 1.0, 0.0],
1468 meta: ChunkMeta::default(),
1469 })
1470 .collect();
1471
1472 let all_chunks: Vec<StoredChunk> = multi_level_chunks
1473 .iter()
1474 .chain(std::iter::once(&literal_chunk))
1475 .chain(unrelated_chunks.iter())
1476 .cloned()
1477 .collect();
1478
1479 let query_text = "mixed-effects models";
1480 let top_k = 6;
1481 let threshold = 0.0;
1482
1483 let cosine = WeightedFusionRanker { alpha: 1.0 };
1485 let cos_hits = cosine
1486 .rank(&all_chunks, &query_vec, query_text, top_k, threshold)
1487 .await;
1488
1489 let bm25 = WeightedFusionRanker { alpha: 0.0 };
1491 let bm25_hits = bm25
1492 .rank(&all_chunks, &query_vec, query_text, top_k, threshold)
1493 .await;
1494
1495 let cos_ml_count = cos_hits
1499 .iter()
1500 .filter(|h| h.chunk.text.to_lowercase().contains("multi-level"))
1501 .count();
1502 assert!(
1503 cos_ml_count >= 2,
1504 "Cosine (alpha=1.0) should retrieve at least 2 multi-level chunks \
1505 via semantic similarity, but found {}",
1506 cos_ml_count
1507 );
1508
1509 let cos_literal_pos = cos_hits
1511 .iter()
1512 .position(|h| h.chunk.text.to_lowercase().contains("mixed-effects"));
1513 assert!(
1514 cos_literal_pos.unwrap_or(0) >= 2,
1515 "Cosine should prefer semantically-close chunks over the literal match"
1516 );
1517
1518 let bm25_literal_pos = bm25_hits
1521 .iter()
1522 .position(|h| h.chunk.text.to_lowercase().contains("mixed-effects"));
1523 assert!(
1524 bm25_literal_pos.is_some(),
1525 "BM25 (alpha=0.0) should find the literal 'mixed-effects' mention"
1526 );
1527 assert_eq!(
1528 bm25_literal_pos.unwrap(),
1529 0,
1530 "BM25 should rank the literal token match first, got position {}",
1531 bm25_literal_pos.unwrap()
1532 );
1533
1534 let bm25_positive = bm25_hits.iter().filter(|h| h.score > 0.0).count();
1535 assert_eq!(
1536 bm25_positive, 1,
1537 "BM25 should give a positive score to exactly 1 chunk (the literal match), got {}",
1538 bm25_positive
1539 );
1540
1541 assert!(
1543 cos_ml_count > 1,
1544 "Cosine retrieved {} multi-level chunks; BM25 got {} positive hits. \
1545 Expected Cosine to surface more relevant content on synonym queries.",
1546 cos_ml_count,
1547 bm25_positive
1548 );
1549
1550 for hits in &[&cos_hits, &bm25_hits] {
1552 let gauss_count = hits
1553 .iter()
1554 .filter(|h| h.chunk.text.to_lowercase().contains("gaussian"))
1555 .count();
1556 assert_eq!(
1557 gauss_count, 0,
1558 "Unrelated chunks should not appear in top-{} results",
1559 top_k
1560 );
1561 }
1562 }
1563
1564 #[tokio::test]
1568 async fn llm_reranker_surfaces_assumption_passages() {
1569 use crate::agents::Generator;
1570
1571 #[derive(Debug)]
1573 struct MockRanker {
1574 ranking: String,
1575 captured_prompt: std::sync::Mutex<Option<String>>,
1576 }
1577
1578 impl Clone for MockRanker {
1579 fn clone(&self) -> Self {
1580 Self {
1581 ranking: self.ranking.clone(),
1582 captured_prompt: std::sync::Mutex::new(None),
1583 }
1584 }
1585 }
1586
1587 #[async_trait]
1588 impl Generator for MockRanker {
1589 async fn generate_stream(
1590 &self,
1591 prompt: &str,
1592 on_token: &(dyn Fn(String) + Sync),
1593 ) -> anyhow::Result<()> {
1594 *self.captured_prompt.lock().unwrap() = Some(prompt.to_string());
1595 on_token(self.ranking.clone());
1596 Ok(())
1597 }
1598 fn backend_name(&self) -> &'static str {
1599 "mock"
1600 }
1601 fn model_name(&self) -> &str {
1602 "mock-ranker"
1603 }
1604 }
1605
1606 let query_text = "pre-conditions and limitations of linear models";
1609 let chunks = vec![
1610 StoredChunk {
1611 text: "Linear models assume independent, identically \
1612 distributed errors with constant variance."
1613 .into(),
1614 source_file: SourceFile::from("stats".to_string()),
1615 vector: vec![0.8, 0.0, 0.0, 0.0],
1616 meta: ChunkMeta::default(),
1617 },
1618 StoredChunk {
1619 text: "The Gauss-Markov theorem proves OLS is the best \
1620 linear unbiased estimator under homoscedasticity \
1621 and no autocorrelation."
1622 .into(),
1623 source_file: SourceFile::from("stats".to_string()),
1624 vector: vec![0.7, 0.0, 0.0, 0.1],
1625 meta: ChunkMeta::default(),
1626 },
1627 StoredChunk {
1628 text: "Multi-level models extend linear models by adding \
1629 random effects for grouped data structures."
1630 .into(),
1631 source_file: SourceFile::from("stats".to_string()),
1632 vector: vec![0.6, 0.0, 0.0, 0.2],
1633 meta: ChunkMeta::default(),
1634 },
1635 StoredChunk {
1636 text: "Violations of normality affect the validity of \
1637 t-tests and F-tests, especially in small samples."
1638 .into(),
1639 source_file: SourceFile::from("stats".to_string()),
1640 vector: vec![0.5, 0.0, 0.0, 0.3],
1641 meta: ChunkMeta::default(),
1642 },
1643 StoredChunk {
1644 text: "Perfect multicollinearity makes the design matrix \
1645 singular, preventing OLS estimation entirely."
1646 .into(),
1647 source_file: SourceFile::from("stats".to_string()),
1648 vector: vec![0.4, 0.0, 0.0, 0.4],
1649 meta: ChunkMeta::default(),
1650 },
1651 StoredChunk {
1652 text: "Bayesian hierarchical models use prior distributions \
1653 to regularize parameter estimates across groups."
1654 .into(),
1655 source_file: SourceFile::from("stats".to_string()),
1656 vector: vec![0.3, 0.0, 0.0, 0.5],
1657 meta: ChunkMeta::default(),
1658 },
1659 ];
1660 let query_vec = vec![1.0f32, 0.0, 0.0, 0.0];
1661
1662 let inner = WeightedFusionRanker { alpha: 1.0 };
1664
1665 let baseline = inner.rank(&chunks, &query_vec, query_text, 6, 0.0).await;
1667 let baseline_texts: Vec<&str> = baseline.iter().map(|h| h.chunk.text.as_str()).collect();
1672 assert!(
1673 baseline_texts[0].contains("independent"),
1674 "Cosine should rank the most similar vector first"
1675 );
1676
1677 let mock = MockRanker {
1681 ranking: "0\n1\n3\n4\n2\n5\n".into(),
1682 captured_prompt: std::sync::Mutex::new(None),
1683 };
1684
1685 let llm_ranker = LlmReranker {
1686 generator: Box::new(mock),
1687 inner: Box::new(inner.clone()),
1688 prompt_template: String::new(), };
1690
1691 let reranked = llm_ranker
1692 .rank(&chunks, &query_vec, query_text, 4, 0.0)
1693 .await;
1694
1695 assert_eq!(reranked.len(), 4);
1697
1698 let reranked_texts: Vec<&str> = reranked.iter().map(|h| h.chunk.text.as_str()).collect();
1701
1702 assert!(
1703 reranked_texts[0].contains("independent"),
1704 "LLM should rank the i.i.d. assumption passage first, got: {:?}",
1705 reranked_texts[0]
1706 );
1707 assert!(
1708 reranked_texts[1].contains("Gauss-Markov"),
1709 "LLM should rank Gauss-Markov second, got: {:?}",
1710 reranked_texts[1]
1711 );
1712
1713 for t in &reranked_texts {
1716 assert!(
1717 !t.contains("Multi-level") && !t.contains("Bayesian"),
1718 "LLM should exclude multi-level and Bayesian from top results, \
1719 but found: {}",
1720 t
1721 );
1722 }
1723 }
1724
1725 #[tokio::test]
1728 async fn empty_store_search_returns_empty_not_error() {
1729 let dir = temp_folder();
1730 let store = BruteForceStore::open_or_create(&dir).unwrap();
1731 assert!(store.is_empty());
1732 let results = store
1733 .search(&[1.0f32, 0.0, 0.0], "query", 5, 0.0)
1734 .await
1735 .unwrap();
1736 assert!(results.is_empty());
1737 cleanup(&dir);
1738 }
1739
1740 #[tokio::test]
1741 async fn empty_store_has_zero_len() {
1742 let dir = temp_folder();
1743 let store = BruteForceStore::open_or_create(&dir).unwrap();
1744 assert_eq!(store.len(), 0);
1745 assert!(store.is_empty());
1746 assert!(store.sources().is_empty());
1747 cleanup(&dir);
1748 }
1749}