Skip to main content

ragrig/
store.rs

1//! Vector store abstraction for chunk persistence and hybrid search.
2//!
3//! The [`VectorStore`] trait decouples the RAG pipeline from any specific
4//! storage backend.  Two implementations are provided behind feature flags:
5//!
6//! - `internal` (default) — pure Rust, zero native deps, MessagePack on disk
7//! - `lancedb` — LanceDB-backed hybrid BM25 + vector search
8
9use 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/// A single chunk with its embedding, ready to be stored.
22#[derive(Clone, Debug)]
23#[cfg_attr(feature = "internal", derive(serde::Serialize, serde::Deserialize))]
24pub struct StoredChunk {
25    /// The chunk's text content.
26    pub text: String,
27    /// Which document this chunk came from.
28    pub source_file: SourceFile,
29    /// The embedding vector (dimensionality varies by embedder).
30    pub vector: Vec<f32>,
31    /// Positional metadata for source citation.
32    #[cfg_attr(feature = "internal", serde(default))]
33    pub meta: ChunkMeta,
34}
35
36/// Result of a hybrid search.
37#[derive(Clone, Debug)]
38pub struct ScoredChunk {
39    /// Relevance score assigned by the active ranker.
40    pub score: RankScore,
41    /// The matching document chunk.
42    pub chunk: DocumentChunk,
43}
44
45// ── Ranker trait ───────────────────────────────────────────────────────────
46
47/// Pluggable chunk ranking strategy.
48///
49/// Implementations define how candidate chunks are scored and ordered.
50/// Stores that support swappable rankers (e.g. `BruteForceStore`)
51/// delegate scoring to the ranker; opaque backends (e.g. LanceDB) use
52/// their own internal ranking and ignore this trait.
53#[async_trait]
54pub trait Ranker: Send + Sync + std::fmt::Debug + DynClone {
55    /// Score every chunk against the query and return the top `top_k`
56    /// results, filtering out any whose vector-similarity score falls
57    /// below `threshold`.
58    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    /// Human-readable name used in the REPL (`/search rank <name>`).
68    fn name(&self) -> &'static str;
69}
70
71dyn_clone::clone_trait_object!(Ranker);
72
73// ── Built-in rankers ───────────────────────────────────────────────────────
74
75/// Cosine-similarity + BM25 fused via Reciprocal Rank Fusion (k = 60).
76///
77/// This is the default ranker and preserves the behaviour of all previous
78/// ragrig releases.  The `k` parameter controls how sharply rank position
79/// decays in the fusion formula: `1 / (k + rank + 1)`.
80#[derive(Debug, Clone)]
81pub struct HybridRrfRanker {
82    /// RRF fusion constant (default: 60.0).  Higher values flatten the
83    /// rank decay; lower values give more weight to top positions.
84    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/// Weighted linear fusion of cosine similarity and BM25.
112///
113/// `alpha` controls the vector-vs-keyword trade-off:
114/// - `1.0` — pure cosine (identical to the old `CosineOnlyRanker`)
115/// - `0.0` — pure BM25 keyword search
116/// - `0.5` — equal weight (default)
117///
118/// Both score vectors are min-max normalised to [0, 1] before fusion,
119/// so `alpha` behaves predictably regardless of score distribution.
120#[derive(Debug, Clone)]
121pub struct WeightedFusionRanker {
122    /// Weight for cosine similarity (0.0–1.0).  The BM25 weight is `1.0 - alpha`.
123    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        // Report the specific mode when alpha is at an extreme.
147        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/// Maximal Marginal Relevance (MMR) diversity re-ranker.
158///
159/// Wraps an inner ranker and greedily re-selects chunks to maximise
160/// `relevance − λ × max_similarity(already_selected)`.  This penalises
161/// chunks that are too similar to ones already picked, reducing redundant
162/// context in the prompt.
163///
164/// Carbonell & Goldstein (1998).  "The Use of MMR, Diversity-Based
165/// Reranking for Reordering Documents and Producing Summaries."
166/// *Proceedings of SIGIR '98*, pp. 335–336.
167#[derive(Debug)]
168pub struct MmrDiversityRanker {
169    /// Diversity penalty (0.0 = no re-ranking, 1.0 = max diversity).
170    pub lambda: f64,
171    /// The underlying ranker whose top results are re-ranked for diversity.
172    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/// LLM-based re-ranker: delegates candidate retrieval to an inner ranker,
212/// then asks an LLM to re-order the candidates by relevance to the query.
213///
214/// This decorator retrieves a larger pool (3× top_k) from the inner ranker,
215/// formats them as a numbered list in a prompt, and parses the LLM's ranking
216/// response.  The LLM can reason about paraphrases, implications, and
217/// relevance in ways that pure vector or keyword scoring cannot.
218#[derive(Debug)]
219pub struct LlmReranker {
220    /// Generator used for the re-ranking call.
221    pub generator: Box<dyn Generator>,
222    /// Underlying ranker producing the candidate pool.
223    pub inner: Box<dyn Ranker>,
224    /// Prompt template with `{query}` and `{passages}` placeholders.
225    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        // Retrieve a larger candidate pool from the inner ranker.
253        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        // Format prompt: numbered list of passages.
263        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        // Call the LLM.
278        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        // Parse the ranking and reorder.
294        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// ── VectorStore trait ─────────────────────────────────────────────────────
309
310/// Backend-agnostic chunk storage with hybrid BM25 + vector search.
311///
312/// Methods use `#[async_trait]` which expands to `Pin<Box<dyn Future>>` in
313/// the rendered docs — just call them with `.await` as normal.
314///
315/// # Example
316///
317/// ```rust,no_run
318/// use ragrig::store::{open_store, VectorStore};
319/// use std::path::Path;
320///
321/// # async fn example() -> anyhow::Result<()> {
322/// let store = open_store(Path::new("./my_docs")).await?;
323/// println!("{} chunks indexed", store.len());
324///
325/// // Search requires an embedding vector (produced by an Embedder):
326/// // let results = store.search(&query_vec, "quantum computing", 5, 0.0).await?;
327/// # Ok(())
328/// # }
329/// ```
330#[async_trait]
331pub trait VectorStore: Send + Sync + std::fmt::Debug + DynClone {
332    /// Insert chunks along with their pre-computed embedding vectors.
333    async fn insert(&self, chunks: Vec<StoredChunk>) -> Result<()>;
334
335    /// Hybrid search: delegates to the store's active ranker if supported,
336    /// otherwise uses the backend's built-in ranking.
337    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    /// Remove all chunks belonging to `source_file`.
346    async fn delete_by_source(&self, source: &str) -> Result<()>;
347
348    /// Total number of stored chunks.
349    fn len(&self) -> usize;
350
351    /// All unique source file names currently in the store.
352    fn sources(&self) -> HashSet<SourceFile>;
353
354    /// Returns `true` when the store contains no chunks.
355    fn is_empty(&self) -> bool {
356        self.len() == 0
357    }
358
359    /// Replace the active ranking strategy.
360    ///
361    /// Returns `Ok(())` on success, or an error if this store backend
362    /// does not support swappable rankers (e.g. LanceDB).
363    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    /// Return the name of the active ranker, if accessible.
370    fn ranker_name(&self) -> Option<String> {
371        None
372    }
373
374    /// Validate that `meta` (from the current [`Embedder`](crate::embed::Embedder))
375    /// is compatible with this store's index.
376    ///
377    /// Returns `Err(EmbeddingMismatch)` when the embedding model has changed
378    /// since the index was created.  The default implementation accepts any
379    /// embedder; backends that persist embedding metadata should override this.
380    fn validate_embedder(&self, _meta: &EmbeddingMetadata) -> Result<()> {
381        Ok(())
382    }
383
384    /// Persist any buffered writes to stable storage.
385    ///
386    /// Backends that defer writes (like [`BruteForceStore`](crate::store::BruteForceStore))
387    /// flush on this call; backends that write immediately (like LanceDB) treat
388    /// this as a no-op.
389    fn flush(&self) -> Result<()> {
390        Ok(())
391    }
392
393    /// Return the reproducibility manifest for this index, if available.
394    ///
395    /// The manifest records the embedding model, chunking parameters, and
396    /// source file hashes at the time the index was created.  `None` for
397    /// stores that do not support manifests (or pre-v0.9.8 stores).
398    fn manifest(&self) -> Option<IndexManifest> {
399        None
400    }
401
402    /// Record an [`IndexManifest`] for reproducibility.
403    ///
404    /// Called automatically after `collect_documents` completes.
405    /// Stores that do not support manifests treat this as a no-op.
406    fn record_manifest(&self, _manifest: IndexManifest) -> Result<()> {
407        Ok(())
408    }
409}
410
411dyn_clone::clone_trait_object!(VectorStore);
412
413// ── Shared ranking helpers (used by built-in rankers) ─────────────────────
414
415/// Cosine similarity between two vectors.
416///
417/// Returns a value in [−1.0, 1.0].  Identical vectors yield 1.0;
418/// orthogonal vectors yield 0.0.
419fn 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
431/// Tokenize text for BM25: lowercase, split on non-alphanumeric,
432/// keep tokens with length ≥ 2.
433fn 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
441/// Okapi BM25 index with standard parameters (k1 = 1.5, b = 0.75).
442///
443/// Robertson, Walker, Jones, Hancock-Beaulieu & Gatford (1994).
444/// "Okapi at TREC-3."  *Proceedings of TREC-3*, NIST.
445struct 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
516/// Reciprocal Rank Fusion: combines two ranked lists into one.
517///
518/// For each rank `r`, the contribution is `1 / (k + r + 1)`.
519/// Documents appearing in both lists accumulate contributions from each.
520///
521/// Cormack, Clarke & Buettcher (2009).  *SIGIR '09*.
522fn 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    // Build and score both ranking lists.
596    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    // Special-case pure modes to avoid normalisation overhead.
620    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
657/// Min-max normalise a list of (index, score) pairs to [0.0, 1.0].
658///
659/// Returns the input unchanged if all scores are identical (avoiding
660/// division by zero).
661fn 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        // All scores identical — return as-is (each would normalise to 0.5).
673        return scores.to_vec();
674    }
675    scores
676        .iter()
677        .map(|(idx, s)| (*idx, (s - min) / range))
678        .collect()
679}
680
681/// MMR diversity re-ranking: greedily select chunks maximising
682/// `relevance - lambda * max_similarity(already_selected)`.
683async 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    // Retrieve a larger candidate pool from the inner ranker.
697    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    // Map each candidate back to its chunk index and original score.
706    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    // Greedy MMR selection.
717    let mut selected: Vec<usize> = Vec::with_capacity(top_k);
718
719    while !pool.is_empty() && selected.len() < top_k {
720        // Find the best remaining candidate under MMR.
721        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        // Preserve the original relevance score in the output.
746    }
747
748    selected
749        .into_iter()
750        .map(|idx| {
751            let chunk = &chunks[idx];
752            ScoredChunk {
753                score: RankScore::from(0.0), // MMR doesn't produce meaningful absolute scores
754                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
764/// LLM-based re-ranking: retrieve candidates with the inner ranker, ask the
765/// LLM to order them by relevance, and return the top_k.
766const 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
776/// Parse an LLM ranking response into an ordered list of passage indices.
777///
778/// Extracts numbers from the beginning of each line, deduplicates, and
779/// appends any missing indices at the end (so every passage appears exactly
780/// once).
781fn 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    // Append any indices the LLM omitted.
799    for i in 0..num_passages {
800        if !seen.contains(&i) {
801            order.push(i);
802        }
803    }
804    order
805}
806
807// ── Internal store (feature = "internal") ─────────────────────────────────
808
809#[cfg(feature = "internal")]
810mod brute_force {
811    use super::*;
812    use std::path::Path;
813
814    /// Pure‑Rust brute‑force vector store backed by MessagePack on disk.
815    /// Enabled by the `internal` feature (on by default).
816    #[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        /// Metadata of the embedding model that created this index.
827        /// `None` for stores created before v0.9.8.
828        #[serde(default)]
829        pub embedding_metadata: Option<EmbeddingMetadata>,
830        /// Whether in-memory state has changed since the last [`save`](BruteForceStore::save).
831        /// Not persisted — always `false` after deserialisation.
832        #[serde(skip, default)]
833        pub dirty: bool,
834        /// Reproducibility manifest recorded at index creation time.
835        /// `None` for stores created before v0.9.8.
836        #[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        /// Check that the given embedder is compatible with the stored index
846        /// metadata.  If the store has no metadata yet (pre-v0.9.8), writes it
847        /// on first insert so subsequent embedder changes are detected.
848        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        /// Open an existing store or create a new one with the default RRF ranker.
875        pub fn open_or_create(folder: &Path) -> Result<BruteForceStore> {
876            Self::open_or_create_with_ranker(folder, Box::new(HybridRrfRanker::default()))
877        }
878
879        /// Open an existing store or create a new one with a custom ranker.
880        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        /// Serialise the current state to the on‑disk MessagePack file.
908        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        /// Persist in-memory changes to disk if the store is dirty.
917        ///
918        /// No-op when nothing has changed since the last [`save`](Self::save)
919        /// or [`flush`](Self::flush).  Called automatically on [`Drop`].
920        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// ── LanceDB store (behind "lancedb" feature) ──────────────────────────────
1044
1045#[cfg(feature = "lancedb")]
1046/// LanceDB-backed vector store with native hybrid BM25 + vector search.
1047pub 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    /// LanceDB-backed vector store — handles BM25 + vector hybrid search natively.
1063    #[derive(Clone, Debug)]
1064    pub struct LanceDbStore {
1065        table: lancedb::Table,
1066        /// Cached row count (avoid async query in sync `len()`).
1067        count: std::sync::Arc<std::sync::atomic::AtomicUsize>,
1068    }
1069
1070    impl LanceDbStore {
1071        /// Default on‑disk path for the LanceDB store within a folder.
1072        pub fn table_path(folder: &Path) -> PathBuf {
1073            folder.join(".ragrig_lancedb")
1074        }
1075
1076        /// Open an existing LanceDB store or create a new one with the ragrig schema.
1077        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            // LanceDB doesn't expose a sync source list; user should
1250            // call search_similar or rely on delete_by_source.
1251            std::collections::HashSet::new()
1252        }
1253    }
1254}
1255
1256// ── Factory ───────────────────────────────────────────────────────────────
1257
1258/// Open or create a vector store in the given folder.
1259/// Uses LanceDB (hybrid BM25 + vector search).
1260#[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/// Open or create a vector store in the given folder.
1268/// Uses the built‑in brute‑force store (MessagePack on disk).
1269#[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/// Open or create a vector store in the given folder.
1275/// Uses LanceDB when the `lancedb` feature is enabled, otherwise the built‑in brute‑force store.
1276/// No vector store backend is enabled — always returns an error.
1277#[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
1282/// Helper: convert embedded `(text, Vec<f32>)` pairs into `StoredChunk`s
1283/// keyed by source file, then insert into the store.
1284pub 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        // Exact vector match is highest score, then BM25-boosted.
1391        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    /// Cosine similarity retrieves semantically-related content that BM25
1416    /// misses because the query and document use different terminology.
1417    ///
1418    /// The book uses "multi-level models" throughout but the synonym
1419    /// "mixed-effects models" appears only in a glossary sentence and a
1420    /// bibliographic reference.  Cosine embeddings capture the semantic
1421    /// relationship; BM25's token matching cannot.
1422    #[tokio::test]
1423    async fn cosine_beats_bm25_on_synonym_query() {
1424        // ── Build mock chunks with semantic vector relationships ──────
1425        //
1426        // Query vector represents "mixed-effects models".
1427        // "multi-level" chunks get vectors close to the query (simulating
1428        // what a real embedding model would produce for synonyms).
1429        // Unrelated chunks get orthogonal vectors.
1430        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                // Semantically close to the query vector.
1441                vector: vec![0.92, 0.15, 0.0, 0.05],
1442                meta: ChunkMeta::default(),
1443            })
1444            .collect();
1445
1446        // A chunk where "mixed-effects" appears literally — BM25's only hit.
1447        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            // Not close to query vector — BM25 should still find it via tokens.
1454            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                // Orthogonal to query — neither Cosine nor BM25 should rank these.
1467                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        // ── Cosine ranker (alpha = 1.0) ──────────────────────────────
1484        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        // ── BM25 ranker (alpha = 0.0) ────────────────────────────────
1490        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        // ── Assertions ───────────────────────────────────────────────
1496
1497        // Cosine must return multiple semantically-related chunks.
1498        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        // Cosine should rank semantically-close chunks above the literal one.
1510        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        // BM25: only the literal chunk gets a positive score; all others tie at
1519        // zero, so their order is insertion-order noise — not meaningful retrieval.
1520        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        // Cosine retrieves multiple relevant chunks; BM25 retrieves exactly one.
1542        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        // Unrelated Gaussian-distribution chunks should not appear in either.
1551        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    /// An LLM reranker re-orders candidates based on conceptual relevance
1565    /// to the query, surfacing passages that discuss assumptions and
1566    /// limitations even when the inner ranker didn't rank them highest.
1567    #[tokio::test]
1568    async fn llm_reranker_surfaces_assumption_passages() {
1569        use crate::agents::Generator;
1570
1571        // ── Mock Generator: returns a fixed ranking ──────────────────
1572        #[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        // ── Chunks: mixed relevance to "pre-conditions and limitations
1607        //    of linear models" ────────────────────────────────────────
1608        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        // ── Inner ranker: cosine similarity ──────────────────────────
1663        let inner = WeightedFusionRanker { alpha: 1.0 };
1664
1665        // ── Baseline: inner ranker alone ────────────────────────────
1666        let baseline = inner.rank(&chunks, &query_vec, query_text, 6, 0.0).await;
1667        // Cosine orders by vector proximity: R0, R1, R2, R3, R4, R5.
1668        // R2 (multi-level) ranks above R3 (normality) because its vector
1669        // happens to be closer — even though it's less relevant to the
1670        // query about assumptions.
1671        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        // ── Mock LLM returns a domain-aware ranking ──────────────────
1678        // It correctly demotes R2 (multi-level) and R5 (Bayesian) and
1679        // promotes R3 (normality violations) and R4 (multicollinearity).
1680        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(), // use default
1689        };
1690
1691        let reranked = llm_ranker
1692            .rank(&chunks, &query_vec, query_text, 4, 0.0)
1693            .await;
1694
1695        // ── Assertions ───────────────────────────────────────────────
1696        assert_eq!(reranked.len(), 4);
1697
1698        // The assumption/limitation chunks (0, 1, 3, 4) should appear
1699        // before the unrelated chunks (2=multi-level, 5=Bayesian).
1700        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        // Neither multi-level nor Bayesian should be in the top 4 after
1714        // the LLM demoted them.
1715        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    // ── Empty-corpus behaviour (#12) ─────────────────────────────────
1726
1727    #[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}