#[macro_export]
macro_rules! collect_docs {
($e:expr, $p:expr, $f:expr, $c:expr, $s:expr $(,)?) => {
$crate::vector::collect_documents(
&*$e,
&$p,
&$crate::chunker::MarkdownChunker,
&$f,
&$c,
&*$s,
)
};
}
#[macro_export]
macro_rules! embed_docs {
($e:expr, $p:expr, $c:expr, $d:expr, $s:expr $(,)?) => {
$crate::vector::embed_documents(&*$e, &$p, &$crate::chunker::MarkdownChunker, &$c, $d, &*$s)
};
}
#[macro_export]
macro_rules! search_sim {
($e:expr, $k:expr, $t:expr, $s:expr, $q:expr $(,)?) => {
$crate::vector::search_similar(&*$e, $k, $t, &*$s, $q)
};
}
#[macro_export]
macro_rules! search_by_doc {
($e:expr, $s:expr, $p:expr, $f:expr, $c:expr, $k:expr, $t:expr $(,)?) => {
$crate::vector::search_by_document(&*$e, &*$s, &$p, &$f, &$c, $k, $t)
};
}
#[macro_export]
macro_rules! remove_del {
($s:expr, $f:expr $(,)?) => {
$crate::vector::remove_deleted_embeddings(&*$s, &$f)
};
}
#[macro_export]
macro_rules! download_get {
($e:expr, $p:expr, $f:expr, $c:expr, $h:expr, $s:expr, $u:expr, $m:expr $(,)?) => {
$crate::download_and_ingest_url(&*$e, &$p, &$f, &$c, &$h, &*$s, $u, $m)
};
}
use crate::sync_util::lock_or_recover;
use crate::types::{ChunkMeta, DocumentChunk, DocumentId, IndexManifest, RankScore, ScoreKind};
use anyhow::Result;
use async_trait::async_trait;
use dyn_clone::DynClone;
use std::collections::{HashMap, HashSet};
use std::path::Path;
#[cfg(any(feature = "internal", feature = "lancedb"))]
use std::path::PathBuf;
use crate::agents::Generator;
use crate::documents::ChunkCorpus;
use crate::embed::EmbeddingMetadata;
use crate::types::{PipelineFilter, PipelineProvenance};
#[derive(Clone, Debug)]
#[cfg_attr(feature = "internal", derive(serde::Serialize, serde::Deserialize))]
pub struct StoredChunk {
pub text: String,
pub document: DocumentId,
pub vector: Vec<f32>,
#[cfg_attr(feature = "internal", serde(default))]
pub meta: ChunkMeta,
#[cfg_attr(feature = "internal", serde(default))]
pub provenance: PipelineProvenance,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct StoredDocument {
pub corpus: String,
pub document: DocumentId,
pub parser: String,
pub chunker: String,
pub pipeline: String,
pub document_id: Option<String>,
}
#[derive(Clone, Debug)]
pub struct ScoredChunk {
pub score: RankScore,
pub chunk: DocumentChunk,
}
#[async_trait]
pub trait Ranker: Send + Sync + std::fmt::Debug + DynClone {
async fn rank(
&self,
chunks: &[StoredChunk],
query_vec: &[f32],
query_text: &str,
top_k: usize,
threshold: f64,
) -> Vec<ScoredChunk>;
fn name(&self) -> &'static str;
}
dyn_clone::clone_trait_object!(Ranker);
#[derive(Debug, Clone)]
pub struct HybridRrfRanker {
k: f64,
}
impl HybridRrfRanker {
pub fn new(k: f64) -> Result<Self> {
anyhow::ensure!(
k.is_finite() && k > 0.0,
"RRF fusion constant k must be finite and > 0, got {k}"
);
Ok(Self { k })
}
pub fn k(&self) -> f64 {
self.k
}
}
impl Default for HybridRrfRanker {
fn default() -> Self {
Self { k: 60.0 }
}
}
#[async_trait]
impl Ranker for HybridRrfRanker {
async fn rank(
&self,
chunks: &[StoredChunk],
query_vec: &[f32],
query_text: &str,
top_k: usize,
threshold: f64,
) -> Vec<ScoredChunk> {
rank_hybrid_rrf(chunks, query_vec, query_text, top_k, threshold, self.k).await
}
fn name(&self) -> &'static str {
"RRFFusion"
}
}
#[derive(Debug, Clone)]
pub struct WeightedFusionRanker {
alpha: f64,
}
impl WeightedFusionRanker {
pub fn new(alpha: f64) -> Result<Self> {
anyhow::ensure!(
alpha.is_finite() && (0.0..=1.0).contains(&alpha),
"fusion alpha must be in [0.0, 1.0], got {alpha}"
);
Ok(Self { alpha })
}
pub fn alpha(&self) -> f64 {
self.alpha
}
}
impl Default for WeightedFusionRanker {
fn default() -> Self {
Self { alpha: 0.5 }
}
}
#[async_trait]
impl Ranker for WeightedFusionRanker {
async fn rank(
&self,
chunks: &[StoredChunk],
query_vec: &[f32],
query_text: &str,
top_k: usize,
threshold: f64,
) -> Vec<ScoredChunk> {
rank_weighted_fusion(chunks, query_vec, query_text, top_k, threshold, self.alpha).await
}
fn name(&self) -> &'static str {
if self.alpha >= 1.0 {
"Cosine"
} else if self.alpha <= 0.0 {
"BM25"
} else {
"Weighted"
}
}
}
#[derive(Debug)]
pub struct MmrDiversityRanker {
lambda: f64,
inner: Box<dyn Ranker>,
}
impl MmrDiversityRanker {
pub fn new(lambda: f64, inner: Box<dyn Ranker>) -> Result<Self> {
anyhow::ensure!(
lambda.is_finite() && (0.0..=1.0).contains(&lambda),
"MMR lambda must be in [0.0, 1.0], got {lambda}"
);
Ok(Self { lambda, inner })
}
pub fn lambda(&self) -> f64 {
self.lambda
}
pub fn inner(&self) -> &dyn Ranker {
&*self.inner
}
}
impl Clone for MmrDiversityRanker {
fn clone(&self) -> Self {
Self {
lambda: self.lambda,
inner: self.inner.clone(),
}
}
}
#[async_trait]
impl Ranker for MmrDiversityRanker {
async fn rank(
&self,
chunks: &[StoredChunk],
query_vec: &[f32],
query_text: &str,
top_k: usize,
threshold: f64,
) -> Vec<ScoredChunk> {
mmr_rerank(
chunks,
query_vec,
query_text,
top_k,
threshold,
self.lambda,
&*self.inner,
)
.await
}
fn name(&self) -> &'static str {
"MMR"
}
}
#[derive(Debug)]
pub struct LlmReranker {
pub generator: Box<dyn Generator>,
pub inner: Box<dyn Ranker>,
pub prompt_template: String,
}
impl Clone for LlmReranker {
fn clone(&self) -> Self {
Self {
generator: self.generator.clone(),
inner: self.inner.clone(),
prompt_template: self.prompt_template.clone(),
}
}
}
#[async_trait]
impl Ranker for LlmReranker {
async fn rank(
&self,
chunks: &[StoredChunk],
query_vec: &[f32],
query_text: &str,
top_k: usize,
threshold: f64,
) -> Vec<ScoredChunk> {
if chunks.is_empty() || top_k == 0 {
return Vec::new();
}
let pool_size = (top_k * 3).min(chunks.len()).max(top_k);
let mut candidates = self
.inner
.rank(chunks, query_vec, query_text, pool_size, threshold)
.await;
if candidates.len() <= 1 {
return candidates;
}
let mut passages = String::new();
for (i, sc) in candidates.iter().enumerate() {
let snippet: String = sc.chunk.text.chars().take(300).collect();
passages.push_str(&format!("[{i}] {snippet}\n\n"));
}
let template = if self.prompt_template.is_empty() {
LLM_DEFAULT_PROMPT
} else {
&self.prompt_template
};
let prompt = template
.replace("{query}", query_text)
.replace("{passages}", &passages);
log::debug!(
"LLM reranker: asking {} ({}) to re-rank {} candidates",
self.generator.backend_name(),
self.generator.model_name(),
candidates.len()
);
let response = match self.generator.generate(&prompt).await {
Ok(r) => r,
Err(e) => {
log::warn!("LLM reranker call failed: {e}; falling back to inner ranker");
candidates.truncate(top_k);
return candidates;
}
};
let order = parse_llm_ranking(&response, candidates.len());
let mut result: Vec<ScoredChunk> = order
.into_iter()
.filter_map(|i| candidates.get(i).cloned())
.collect();
result.truncate(top_k);
result
}
fn name(&self) -> &'static str {
"LLM"
}
}
#[async_trait]
pub trait VectorStore: Send + Sync + std::fmt::Debug + DynClone {
async fn insert(&self, chunks: Vec<StoredChunk>) -> Result<()>;
async fn search(
&self,
query_vec: &[f32],
query_text: &str,
top_k: usize,
threshold: f64,
) -> Result<Vec<ScoredChunk>>;
async fn search_filtered(
&self,
query_vec: &[f32],
query_text: &str,
top_k: usize,
threshold: f64,
filter: &PipelineFilter,
) -> Result<Vec<ScoredChunk>> {
let mut results = self
.search(
query_vec,
query_text,
top_k.saturating_mul(8).max(64),
threshold,
)
.await?;
results.retain(|sc| filter.matches(&sc.chunk.provenance));
results.truncate(top_k);
Ok(results)
}
async fn count_matching(&self, _filter: &PipelineFilter) -> usize {
0
}
async fn delete_by_document(&self, document: &str) -> Result<()>;
async fn delete_document(&self, _corpus: &str, document: &str) -> Result<()> {
self.delete_by_document(document).await
}
async fn delete_corpus(&self, corpus: &str) -> Result<()> {
for doc in self.stored_documents() {
if doc.corpus == corpus {
self.delete_document(corpus, &doc.document.0).await?;
}
}
Ok(())
}
fn stored_documents(&self) -> Vec<StoredDocument> {
Vec::new()
}
fn len(&self) -> usize;
fn document_ids(&self) -> HashSet<DocumentId>;
fn is_empty(&self) -> bool {
self.len() == 0
}
fn set_ranker(&self, _ranker: Box<dyn Ranker>) -> Result<()> {
Err(anyhow::anyhow!(
"This store backend does not support swappable rankers"
))
}
fn ranker_name(&self) -> Option<String> {
None
}
fn validate_embedder(&self, _meta: &EmbeddingMetadata) -> Result<()> {
Ok(())
}
fn flush(&self) -> Result<()> {
Ok(())
}
fn manifest(&self) -> Option<IndexManifest> {
None
}
fn record_manifest(&self, _manifest: IndexManifest) -> Result<()> {
Ok(())
}
}
dyn_clone::clone_trait_object!(VectorStore);
fn cosine_similarity_public(a: &[f32], b: &[f32]) -> f64 {
let (dot, norm_a, norm_b) =
a.iter()
.zip(b.iter())
.fold((0.0f64, 0.0f64, 0.0f64), |(d, na, nb), (&x, &y)| {
let (x, y) = (x as f64, y as f64);
(d + x * y, na + x * x, nb + y * y)
});
let denom = (norm_a.sqrt() * norm_b.sqrt()).max(1e-12);
(dot / denom).clamp(-1.0, 1.0)
}
fn tokenize(text: &str) -> Vec<String> {
text.to_lowercase()
.split(|c: char| !c.is_alphanumeric())
.filter(|t| !t.is_empty() && t.len() >= 2)
.map(|t| t.to_string())
.collect()
}
struct Bm25Index {
doc_freqs: HashMap<String, usize>,
doc_tfs: Vec<HashMap<String, usize>>,
doc_lens: Vec<usize>,
avg_doc_len: f64,
total_docs: usize,
}
impl Bm25Index {
fn build(chunks: &[StoredChunk]) -> Self {
let total_docs = chunks.len();
let mut doc_freqs: HashMap<String, usize> = HashMap::new();
let mut doc_tfs: Vec<HashMap<String, usize>> = Vec::with_capacity(total_docs);
let mut doc_lens: Vec<usize> = Vec::with_capacity(total_docs);
for chunk in chunks {
let tokens = tokenize(&chunk.text);
doc_lens.push(tokens.len());
let mut tf: HashMap<String, usize> = HashMap::new();
for t in &tokens {
*tf.entry(t.clone()).or_insert(0) += 1;
}
for t in tf.keys() {
*doc_freqs.entry(t.clone()).or_insert(0) += 1;
}
doc_tfs.push(tf);
}
let avg_doc_len = if total_docs > 0 {
doc_lens.iter().sum::<usize>() as f64 / total_docs as f64
} else {
1.0
};
Self {
doc_freqs,
doc_tfs,
doc_lens,
avg_doc_len,
total_docs,
}
}
fn score_all(&self, query_tokens: &[String]) -> Vec<(usize, f64)> {
const K1: f64 = 1.5;
const B: f64 = 0.75;
const IDF_SMOOTH: f64 = 0.5;
let n = self.total_docs as f64;
let mut scores: Vec<(usize, f64)> = Vec::with_capacity(self.total_docs);
for (doc_idx, tf_map) in self.doc_tfs.iter().enumerate() {
let mut score = 0.0;
let doc_len = self.doc_lens[doc_idx] as f64;
for qt in query_tokens {
let df = *self.doc_freqs.get(qt).unwrap_or(&0) as f64;
if df == 0.0 {
continue;
}
let idf = ((n - df + IDF_SMOOTH) / (df + IDF_SMOOTH) + 1.0).ln();
let tf = *tf_map.get(qt).unwrap_or(&0) as f64;
let numerator = tf * (K1 + 1.0);
let denominator = tf + K1 * (1.0 - B + B * doc_len / self.avg_doc_len);
score += idf * numerator / denominator;
}
scores.push((doc_idx, score));
}
scores
}
}
fn rrf_fusion(
vec_ranked: &[(usize, f64)],
bm25_ranked: &[(usize, f64)],
k: f64,
) -> Vec<(usize, f64)> {
let mut fusion: HashMap<usize, f64> = HashMap::new();
for (rank, (doc_idx, _)) in vec_ranked.iter().enumerate() {
*fusion.entry(*doc_idx).or_insert(0.0) += 1.0 / (k + rank as f64 + 1.0);
}
for (rank, (doc_idx, _)) in bm25_ranked.iter().enumerate() {
*fusion.entry(*doc_idx).or_insert(0.0) += 1.0 / (k + rank as f64 + 1.0);
}
let mut fused: Vec<(usize, f64)> = fusion.into_iter().collect();
fused.sort_by(|a, b| b.1.total_cmp(&a.1));
fused
}
async fn rank_hybrid_rrf(
chunks: &[StoredChunk],
query_vec: &[f32],
query_text: &str,
top_k: usize,
threshold: f64,
rrf_k: f64,
) -> Vec<ScoredChunk> {
if chunks.is_empty() {
return Vec::new();
}
let mut vec_scores: Vec<(usize, f64)> = chunks
.iter()
.enumerate()
.map(|(i, c)| (i, cosine_similarity_public(query_vec, &c.vector)))
.filter(|(_, s)| *s >= threshold)
.collect();
vec_scores.sort_by(|a, b| b.1.total_cmp(&a.1));
let bm25 = Bm25Index::build(chunks);
let query_tokens = tokenize(query_text);
let mut bm25_scores = bm25.score_all(&query_tokens);
bm25_scores.sort_by(|a, b| b.1.total_cmp(&a.1));
let fused = rrf_fusion(&vec_scores, &bm25_scores, rrf_k);
fused
.into_iter()
.take(top_k)
.map(|(idx, score)| {
let chunk = &chunks[idx];
ScoredChunk {
score: RankScore::rrf(score),
chunk: DocumentChunk {
text: chunk.text.clone(),
document: chunk.document.clone(),
meta: chunk.meta.clone(),
provenance: chunk.provenance.clone(),
},
}
})
.collect()
}
async fn rank_weighted_fusion(
chunks: &[StoredChunk],
query_vec: &[f32],
query_text: &str,
top_k: usize,
threshold: f64,
alpha: f64,
) -> Vec<ScoredChunk> {
if chunks.is_empty() {
return Vec::new();
}
let vec_scores: Vec<(usize, f64)> = if alpha > 0.0 {
let mut vs: Vec<_> = chunks
.iter()
.enumerate()
.map(|(i, c)| (i, cosine_similarity_public(query_vec, &c.vector)))
.filter(|(_, s)| *s >= threshold)
.collect();
vs.sort_by(|a, b| b.1.total_cmp(&a.1));
vs
} else {
Vec::new()
};
let bm25_scores: Vec<(usize, f64)> = if alpha < 1.0 {
let bm25 = Bm25Index::build(chunks);
let query_tokens = tokenize(query_text);
let mut bs = bm25.score_all(&query_tokens);
bs.sort_by(|a, b| b.1.total_cmp(&a.1));
bs
} else {
Vec::new()
};
let mut fused: Vec<(usize, f64)> = if alpha >= 1.0 {
vec_scores
} else if alpha <= 0.0 {
bm25_scores
} else {
let norm_vec = min_max_normalise(&vec_scores);
let norm_bm25 = min_max_normalise(&bm25_scores);
let mut map: HashMap<usize, f64> = HashMap::new();
for (idx, score) in &norm_vec {
*map.entry(*idx).or_insert(0.0) += alpha * score;
}
for (idx, score) in &norm_bm25 {
*map.entry(*idx).or_insert(0.0) += (1.0 - alpha) * score;
}
let mut combined: Vec<_> = map.into_iter().collect();
combined.sort_by(|a, b| b.1.total_cmp(&a.1));
combined
};
fused.truncate(top_k);
let kind = if alpha >= 1.0 {
ScoreKind::Cosine
} else if alpha <= 0.0 {
ScoreKind::Bm25
} else {
ScoreKind::Weighted
};
fused
.into_iter()
.map(|(idx, score)| {
let chunk = &chunks[idx];
ScoredChunk {
score: RankScore::new(score, kind),
chunk: DocumentChunk {
text: chunk.text.clone(),
document: chunk.document.clone(),
meta: chunk.meta.clone(),
provenance: chunk.provenance.clone(),
},
}
})
.collect()
}
fn min_max_normalise(scores: &[(usize, f64)]) -> Vec<(usize, f64)> {
if scores.is_empty() {
return Vec::new();
}
let min = scores.iter().map(|(_, s)| *s).fold(f64::INFINITY, f64::min);
let max = scores
.iter()
.map(|(_, s)| *s)
.fold(f64::NEG_INFINITY, f64::max);
let range = max - min;
if range < 1e-12 {
return scores.to_vec();
}
scores
.iter()
.map(|(idx, s)| (*idx, (s - min) / range))
.collect()
}
async fn mmr_rerank(
chunks: &[StoredChunk],
query_vec: &[f32],
query_text: &str,
top_k: usize,
threshold: f64,
lambda: f64,
inner: &dyn Ranker,
) -> Vec<ScoredChunk> {
if chunks.is_empty() || top_k == 0 {
return Vec::new();
}
let pool_size = (top_k * 3).min(chunks.len()).max(top_k);
let candidates = inner
.rank(chunks, query_vec, query_text, pool_size, threshold)
.await;
if candidates.is_empty() {
return Vec::new();
}
let mut pool: Vec<(usize, f64)> = Vec::with_capacity(candidates.len());
for sc in &candidates {
if let Some(idx) = chunks
.iter()
.position(|c| c.document == sc.chunk.document && c.text == sc.chunk.text)
{
pool.push((idx, sc.score.value));
}
}
let mut selected: Vec<usize> = Vec::with_capacity(top_k);
while !pool.is_empty() && selected.len() < top_k {
let mut best_idx: usize = 0;
let mut best_mmr: f64 = f64::NEG_INFINITY;
for (i, (chunk_idx, score)) in pool.iter().enumerate() {
let max_sim = if selected.is_empty() {
0.0
} else {
selected
.iter()
.map(|&si| {
cosine_similarity_public(&chunks[si].vector, &chunks[*chunk_idx].vector)
})
.fold(0.0f64, f64::max)
};
let mmr = score - lambda * max_sim;
if mmr > best_mmr {
best_mmr = mmr;
best_idx = i;
}
}
let (chunk_idx, _original_score) = pool.remove(best_idx);
selected.push(chunk_idx);
}
selected
.into_iter()
.map(|idx| {
let chunk = &chunks[idx];
ScoredChunk {
score: RankScore::mmr(0.0), chunk: DocumentChunk {
text: chunk.text.clone(),
document: chunk.document.clone(),
meta: chunk.meta.clone(),
provenance: chunk.provenance.clone(),
},
}
})
.collect()
}
const LLM_DEFAULT_PROMPT: &str = "\
You are a relevance ranking assistant. Given a user query and a \
numbered list of passages, rank them by how well they answer the \
query. Return only the passage numbers in order of relevance, one \
per line, most relevant first.\n\n\
Query: {query}\n\n\
Passages:\n\
{passages}\n\
Ranked order (most relevant first):";
fn parse_llm_ranking(response: &str, num_passages: usize) -> Vec<usize> {
let mut order = Vec::new();
let mut seen = HashSet::new();
for line in response.lines() {
let trimmed = line.trim();
if let Some(first_char) = trimmed.chars().next()
&& first_char.is_ascii_digit()
{
let num_str: String = trimmed.chars().take_while(|c| c.is_ascii_digit()).collect();
if let Ok(idx) = num_str.parse::<usize>()
&& idx < num_passages
&& seen.insert(idx)
{
order.push(idx);
}
}
}
for i in 0..num_passages {
if !seen.contains(&i) {
order.push(i);
}
}
order
}
#[cfg(feature = "internal")]
mod brute_force {
use super::*;
use std::path::Path;
#[derive(Debug)]
pub struct BruteForceStore {
pub(super) inner: std::sync::Mutex<BruteForceInner>,
pub(super) path: PathBuf,
ranker: std::sync::Mutex<Box<dyn Ranker>>,
}
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct BruteForceInner {
pub chunks: Vec<StoredChunk>,
#[serde(default)]
pub embedding_metadata: Option<EmbeddingMetadata>,
#[serde(skip, default)]
pub dirty: bool,
#[serde(default)]
pub manifest: Option<IndexManifest>,
}
impl BruteForceStore {
fn store_path(folder: &Path) -> PathBuf {
folder.join(".ragrig_store")
}
fn check_and_record_metadata(&self, embedder_meta: &EmbeddingMetadata) -> Result<()> {
let mut inner = lock_or_recover(&self.inner);
match &inner.embedding_metadata {
Some(stored) => {
if !stored.is_compatible_with(embedder_meta) {
return Err(anyhow::anyhow!(crate::RagrigError::EmbeddingMismatch {
stored_model: stored.model_name.clone(),
stored_dims: stored.dimensions,
current_model: embedder_meta.model_name.clone(),
current_dims: embedder_meta.dimensions,
}));
}
}
None => {
inner.embedding_metadata = Some(embedder_meta.clone());
inner.dirty = true;
}
}
Ok(())
}
pub fn open_or_create(folder: &Path) -> Result<BruteForceStore> {
Self::open_or_create_with_ranker(folder, Box::new(HybridRrfRanker::default()))
}
pub fn open_or_create_with_ranker(
folder: &Path,
ranker: Box<dyn Ranker>,
) -> Result<BruteForceStore> {
let path = Self::store_path(folder);
let inner = if path.exists() {
let bytes = std::fs::read(&path)?;
rmp_serde::from_slice(&bytes).map_err(|_| {
anyhow::anyhow!(crate::RagrigError::StoreCorrupt {
path: path.to_string_lossy().into_owned(),
})
})?
} else {
BruteForceInner {
chunks: Vec::new(),
embedding_metadata: None,
dirty: false,
manifest: None,
}
};
Ok(BruteForceStore {
inner: std::sync::Mutex::new(inner),
path,
ranker: std::sync::Mutex::new(ranker),
})
}
pub fn save(&self) -> Result<()> {
let mut inner = lock_or_recover(&self.inner);
let bytes = rmp_serde::to_vec(&*inner)?;
std::fs::write(&self.path, &bytes)?;
inner.dirty = false;
Ok(())
}
pub fn flush(&self) -> Result<()> {
if lock_or_recover(&self.inner).dirty {
self.save()
} else {
Ok(())
}
}
}
impl Drop for BruteForceStore {
fn drop(&mut self) {
if let Err(e) = self.flush() {
log::error!("BruteForceStore: failed to flush on drop: {e}");
}
}
}
impl Clone for BruteForceStore {
fn clone(&self) -> Self {
Self {
inner: std::sync::Mutex::new(lock_or_recover(&self.inner).clone()),
path: self.path.clone(),
ranker: std::sync::Mutex::new(Box::new(HybridRrfRanker::default())),
}
}
}
#[async_trait]
impl VectorStore for BruteForceStore {
async fn insert(&self, chunks: Vec<StoredChunk>) -> Result<()> {
let n = chunks.len();
{
let mut inner = lock_or_recover(&self.inner);
let new_keys: HashSet<(String, DocumentId, String, String, String)> = chunks
.iter()
.map(|c| {
(
c.provenance.corpus.clone(),
c.document.clone(),
c.provenance.parser.clone(),
c.provenance.chunker.clone(),
c.provenance.pipeline.clone(),
)
})
.collect();
let new_documents: HashSet<DocumentId> =
chunks.iter().map(|c| c.document.clone()).collect();
let new_file_keys: HashSet<(DocumentId, String, String)> = chunks
.iter()
.map(|c| {
(
c.document.clone(),
c.provenance.parser.clone(),
c.provenance.chunker.clone(),
)
})
.collect();
inner.chunks.retain(|c| {
let old_key = (
c.provenance.corpus.clone(),
c.document.clone(),
c.provenance.parser.clone(),
c.provenance.chunker.clone(),
c.provenance.pipeline.clone(),
);
if new_keys.contains(&old_key) {
return false;
}
if c.provenance.parser.is_empty()
&& c.provenance.chunker.is_empty()
&& new_documents.contains(&c.document)
{
return false;
}
if c.provenance.corpus.is_empty()
&& new_file_keys.contains(&(
c.document.clone(),
c.provenance.parser.clone(),
c.provenance.chunker.clone(),
))
{
return false;
}
true
});
inner.chunks.extend(chunks);
inner.dirty = true;
}
log::info!("Inserted {} chunks into internal store.", n);
Ok(())
}
async fn search(
&self,
query_vec: &[f32],
query_text: &str,
top_k: usize,
threshold: f64,
) -> Result<Vec<ScoredChunk>> {
let (chunks, ranker) = {
let inner = lock_or_recover(&self.inner);
let r = lock_or_recover(&self.ranker);
(inner.chunks.clone(), r.clone())
};
if let Some(first) = chunks.first()
&& first.vector.len() != query_vec.len()
{
let inner = lock_or_recover(&self.inner);
let stored_dims = inner
.embedding_metadata
.as_ref()
.map(|m| m.dimensions)
.unwrap_or(first.vector.len());
let stored_model = inner
.embedding_metadata
.as_ref()
.map(|m| m.model_name.clone())
.unwrap_or_default();
return Err(anyhow::anyhow!(crate::RagrigError::EmbeddingMismatch {
stored_model,
stored_dims,
current_model: "(direct search query)".into(),
current_dims: query_vec.len(),
}));
}
log::trace!(
"BruteForceStore: searching {} chunks with ranker '{}'",
chunks.len(),
ranker.name()
);
Ok(ranker
.rank(&chunks, query_vec, query_text, top_k, threshold)
.await)
}
async fn search_filtered(
&self,
query_vec: &[f32],
query_text: &str,
top_k: usize,
threshold: f64,
filter: &PipelineFilter,
) -> Result<Vec<ScoredChunk>> {
let (chunks, ranker) = {
let inner = lock_or_recover(&self.inner);
let r = lock_or_recover(&self.ranker);
(inner.chunks.clone(), r.clone())
};
let filtered: Vec<StoredChunk> = chunks
.into_iter()
.filter(|c| filter.matches(&c.provenance))
.collect();
log::trace!(
"BruteForceStore: filtered search over {}/{} chunks with ranker '{}'",
filtered.len(),
lock_or_recover(&self.inner).chunks.len(),
ranker.name()
);
Ok(ranker
.rank(&filtered, query_vec, query_text, top_k, threshold)
.await)
}
async fn count_matching(&self, filter: &PipelineFilter) -> usize {
self.inner
.lock()
.unwrap()
.chunks
.iter()
.filter(|c| filter.matches(&c.provenance))
.count()
}
async fn delete_by_document(&self, document: &str) -> Result<()> {
{
let mut inner = lock_or_recover(&self.inner);
inner.chunks.retain(|c| c.document != document);
inner.manifest = None;
inner.dirty = true;
}
Ok(())
}
async fn delete_document(&self, corpus: &str, document: &str) -> Result<()> {
{
let mut inner = lock_or_recover(&self.inner);
inner
.chunks
.retain(|c| !(c.provenance.corpus == corpus && c.document == document));
inner.manifest = None;
inner.dirty = true;
}
Ok(())
}
async fn delete_corpus(&self, corpus: &str) -> Result<()> {
{
let mut inner = lock_or_recover(&self.inner);
inner.chunks.retain(|c| c.provenance.corpus != corpus);
inner.manifest = None;
inner.dirty = true;
}
Ok(())
}
fn stored_documents(&self) -> Vec<StoredDocument> {
let inner = lock_or_recover(&self.inner);
let mut seen: HashSet<(String, DocumentId, String, String, String)> = HashSet::new();
let mut out = Vec::new();
for c in &inner.chunks {
let key = (
c.provenance.corpus.clone(),
c.document.clone(),
c.provenance.parser.clone(),
c.provenance.chunker.clone(),
c.provenance.pipeline.clone(),
);
if seen.insert(key) {
out.push(StoredDocument {
corpus: c.provenance.corpus.clone(),
document: c.document.clone(),
parser: c.provenance.parser.clone(),
chunker: c.provenance.chunker.clone(),
pipeline: c.provenance.pipeline.clone(),
document_id: c.meta.document_id.clone(),
});
}
}
out
}
fn len(&self) -> usize {
lock_or_recover(&self.inner).chunks.len()
}
fn document_ids(&self) -> HashSet<DocumentId> {
self.inner
.lock()
.unwrap()
.chunks
.iter()
.map(|c| c.document.clone())
.collect()
}
fn set_ranker(&self, ranker: Box<dyn Ranker>) -> Result<()> {
*lock_or_recover(&self.ranker) = ranker;
Ok(())
}
fn ranker_name(&self) -> Option<String> {
Some(lock_or_recover(&self.ranker).name().to_string())
}
fn validate_embedder(&self, meta: &EmbeddingMetadata) -> Result<()> {
self.check_and_record_metadata(meta)
}
fn flush(&self) -> Result<()> {
BruteForceStore::flush(self)
}
fn manifest(&self) -> Option<IndexManifest> {
lock_or_recover(&self.inner).manifest.clone()
}
fn record_manifest(&self, manifest: IndexManifest) -> Result<()> {
let mut inner = lock_or_recover(&self.inner);
inner.manifest = Some(manifest);
inner.dirty = true;
Ok(())
}
}
}
#[cfg(feature = "internal")]
pub use brute_force::BruteForceStore;
#[cfg(feature = "lancedb")]
pub mod lance_db_store {
use super::*;
use anyhow::anyhow;
use futures_util::TryStreamExt;
use lance_index::scalar::FullTextSearchQuery;
use lancedb::arrow::arrow_array::builder::StringBuilder;
use lancedb::arrow::arrow_array::{
Array, FixedSizeListArray, Float32Array, RecordBatch, StringArray, UInt64Array,
types::Float32Type,
};
use lancedb::arrow::arrow_schema::{DataType, Field, Schema};
use lancedb::index::Index;
use lancedb::index::scalar::FtsIndexBuilder;
use lancedb::query::{ExecutableQuery, QueryBase, QueryExecutionOptions, Select};
use std::sync::{Arc, Mutex};
#[derive(Clone)]
pub struct LanceDbStore {
db: lancedb::Connection,
table: Arc<Mutex<Option<lancedb::Table>>>,
count: std::sync::Arc<std::sync::atomic::AtomicUsize>,
stored: Arc<Mutex<Vec<StoredDocument>>>,
corpus_column: bool,
manifest_path: PathBuf,
}
impl std::fmt::Debug for LanceDbStore {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("LanceDbStore")
.field("table_created", &lock_or_recover(&self.table).is_some())
.field("count", &self.count)
.field("stored", &self.stored)
.field("corpus_column", &self.corpus_column)
.field("manifest_path", &self.manifest_path)
.finish()
}
}
impl LanceDbStore {
pub fn table_path(folder: &Path) -> PathBuf {
folder.join(".ragrig_lancedb")
}
pub async fn open_or_create(folder: &Path) -> Result<Self> {
use std::sync::atomic::AtomicUsize;
let path = Self::table_path(folder);
std::fs::create_dir_all(&path).ok(); let db = lancedb::connect(&path.to_string_lossy()).execute().await?;
let (table, corpus_column, stored, count) = match db
.open_table("rag_knowledge_base")
.execute()
.await
{
Ok(t) => {
let corpus_column = probe_column(&t, "corpus").await;
let hash_column = probe_column(&t, "hash").await;
let provenance_column = probe_column(&t, "provenance").await;
let stored =
scan_stored_documents(&t, corpus_column, hash_column, provenance_column)
.await;
let count = t.count_rows(None).await.unwrap_or(0);
(Some(t), corpus_column, stored, count)
}
Err(_) => (None, true, Vec::new(), 0),
};
Ok(Self {
db,
table: Arc::new(Mutex::new(table)),
count: std::sync::Arc::new(AtomicUsize::new(count)),
stored: Arc::new(Mutex::new(stored)),
corpus_column,
manifest_path: path.join("manifest.json"),
})
}
async fn ensure_table(&self, dim: usize) -> Result<lancedb::Table> {
if let Some(table) = lock_or_recover(&self.table).clone() {
return Ok(table);
}
let schema = Schema::new(vec![
Field::new("text", DataType::Utf8, false),
Field::new("document", DataType::Utf8, false),
Field::new("corpus", DataType::Utf8, false),
Field::new("hash", DataType::Utf8, true),
Field::new("provenance", DataType::Utf8, false),
Field::new(
"vector",
DataType::FixedSizeList(
Arc::new(Field::new("item", DataType::Float32, true)),
dim as i32,
),
false,
),
]);
let batch = RecordBatch::new_empty(Arc::new(schema));
let table = match self
.db
.create_table("rag_knowledge_base", batch)
.execute()
.await
{
Ok(t) => t,
Err(_) => self.db.open_table("rag_knowledge_base").execute().await?,
};
table
.create_index(&["text"], Index::FTS(FtsIndexBuilder::default()))
.execute()
.await?;
*lock_or_recover(&self.table) = Some(table.clone());
Ok(table)
}
fn invalidate_manifest(&self) {
let _ = std::fs::remove_file(&self.manifest_path);
}
fn apply_deletion(&self, deleted: u64) {
self.count
.fetch_sub(deleted as usize, std::sync::atomic::Ordering::Relaxed);
}
async fn replace_existing_pipeline_chunks(
&self,
table: &lancedb::Table,
chunks: &[StoredChunk],
) -> Result<()> {
if !self.corpus_column {
return Ok(());
}
let mut keys: HashSet<(String, String, String, String)> = HashSet::new();
for c in chunks {
keys.insert((
c.provenance.corpus.clone(),
c.document.0.clone(),
c.provenance.parser.clone(),
c.provenance.chunker.clone(),
));
}
for (corpus, document, parser, chunker) in keys {
let filter = format!(
"corpus = {} AND document = {}",
sql_literal(&corpus),
sql_literal(&document)
);
let Ok(stream) = table
.query()
.only_if(&filter)
.with_row_id()
.select(Select::columns(&["provenance"]))
.execute()
.await
else {
continue;
};
let Ok(batches) = stream.try_collect::<Vec<_>>().await else {
continue;
};
let mut row_ids: Vec<u64> = Vec::new();
for batch in &batches {
let prov_col = batch
.column_by_name("provenance")
.and_then(|c| c.as_any().downcast_ref::<StringArray>());
let id_col = batch
.column_by_name("_rowid")
.and_then(|c| c.as_any().downcast_ref::<UInt64Array>());
for i in 0..batch.num_rows() {
let provenance: PipelineProvenance = prov_col
.map(|c| serde_json::from_str(c.value(i)).unwrap_or_default())
.unwrap_or_default();
if provenance.parser == parser
&& provenance.chunker == chunker
&& let Some(id_col) = id_col
{
row_ids.push(id_col.value(i));
}
}
}
if !row_ids.is_empty() {
let ids = row_ids
.iter()
.map(|r| r.to_string())
.collect::<Vec<_>>()
.join(",");
let deleted = table.delete(&format!("_rowid IN ({ids})")).await?;
self.apply_deletion(deleted.num_deleted_rows);
}
}
Ok(())
}
}
async fn probe_column(table: &lancedb::Table, column: &str) -> bool {
table
.query()
.select(Select::columns(&[column]))
.limit(1)
.execute()
.await
.is_ok()
}
async fn scan_stored_documents(
table: &lancedb::Table,
corpus_column: bool,
hash_column: bool,
provenance_column: bool,
) -> Vec<StoredDocument> {
let mut cols = vec!["document".to_string()];
if corpus_column {
cols.push("corpus".into());
}
if hash_column {
cols.push("hash".into());
}
if provenance_column {
cols.push("provenance".into());
}
let Ok(stream) = table.query().select(Select::columns(&cols)).execute().await else {
return Vec::new();
};
let Ok(batches) = stream.try_collect::<Vec<_>>().await else {
return Vec::new();
};
let mut seen: HashSet<(String, DocumentId, String, String, String)> = HashSet::new();
let mut out = Vec::new();
for batch in &batches {
let Some(doc_col) = batch
.column_by_name("document")
.and_then(|c| c.as_any().downcast_ref::<StringArray>())
else {
continue;
};
let corpus_col = batch
.column_by_name("corpus")
.and_then(|c| c.as_any().downcast_ref::<StringArray>());
let hash_col = batch
.column_by_name("hash")
.and_then(|c| c.as_any().downcast_ref::<StringArray>());
let prov_col = batch
.column_by_name("provenance")
.and_then(|c| c.as_any().downcast_ref::<StringArray>());
for i in 0..batch.num_rows() {
let provenance: PipelineProvenance = prov_col
.map(|c| serde_json::from_str(c.value(i)).unwrap_or_default())
.unwrap_or_default();
let corpus = corpus_col
.map(|c| c.value(i).to_string())
.unwrap_or_else(|| provenance.corpus.clone());
let document = DocumentId::from(doc_col.value(i).to_string());
let key = (
corpus.clone(),
document.clone(),
provenance.parser.clone(),
provenance.chunker.clone(),
provenance.pipeline.clone(),
);
if seen.insert(key) {
out.push(StoredDocument {
corpus,
document,
parser: provenance.parser.clone(),
chunker: provenance.chunker.clone(),
pipeline: provenance.pipeline.clone(),
document_id: hash_col
.map(|c| c.value(i).to_string())
.filter(|h| !h.is_empty()),
});
}
}
}
out
}
fn sql_literal(s: &str) -> String {
format!("'{}'", s.replace('\'', "''"))
}
fn build_batch(chunks: &[StoredChunk], dim: usize, full: bool) -> Result<RecordBatch> {
let mut text_builder = StringBuilder::with_capacity(chunks.len(), chunks.len() * 256);
let mut source_builder = StringBuilder::with_capacity(chunks.len(), chunks.len() * 128);
let mut corpus_builder = StringBuilder::with_capacity(chunks.len(), chunks.len() * 128);
let mut hash_builder = StringBuilder::with_capacity(chunks.len(), chunks.len() * 64);
let mut provenance_builder = StringBuilder::with_capacity(chunks.len(), chunks.len() * 128);
let mut vec_flat: Vec<f32> = Vec::with_capacity(chunks.len() * dim);
for c in chunks {
text_builder.append_value(&c.text);
source_builder.append_value(&c.document.0);
if full {
corpus_builder.append_value(&c.provenance.corpus);
match &c.meta.document_id {
Some(hash) => hash_builder.append_value(hash),
None => hash_builder.append_null(),
}
provenance_builder.append_value(&serde_json::to_string(&c.provenance)?);
}
vec_flat.extend_from_slice(&c.vector);
}
let vector_array = FixedSizeListArray::from_iter_primitive::<Float32Type, _, _>(
vec_flat
.chunks(dim)
.map(|chunk| Some(chunk.iter().map(|v| Some(*v)))),
dim as i32,
);
let fields: Vec<Field> = if full {
vec![
Field::new("text", DataType::Utf8, false),
Field::new("document", DataType::Utf8, false),
Field::new("corpus", DataType::Utf8, false),
Field::new("hash", DataType::Utf8, true),
Field::new("provenance", DataType::Utf8, false),
Field::new(
"vector",
DataType::FixedSizeList(
Arc::new(Field::new("item", DataType::Float32, true)),
dim as i32,
),
false,
),
]
} else {
vec![
Field::new("text", DataType::Utf8, false),
Field::new("document", DataType::Utf8, false),
Field::new(
"vector",
DataType::FixedSizeList(
Arc::new(Field::new("item", DataType::Float32, true)),
dim as i32,
),
false,
),
]
};
let columns: Vec<Arc<dyn Array>> = if full {
vec![
Arc::new(text_builder.finish()),
Arc::new(source_builder.finish()),
Arc::new(corpus_builder.finish()),
Arc::new(hash_builder.finish()),
Arc::new(provenance_builder.finish()),
Arc::new(vector_array),
]
} else {
vec![
Arc::new(text_builder.finish()),
Arc::new(source_builder.finish()),
Arc::new(vector_array),
]
};
Ok(RecordBatch::try_new(
Arc::new(Schema::new(fields)),
columns,
)?)
}
#[async_trait]
impl VectorStore for LanceDbStore {
async fn insert(&self, chunks: Vec<StoredChunk>) -> Result<()> {
if chunks.is_empty() {
return Ok(());
}
let n = chunks.len();
let dim = chunks[0].vector.len();
let table = self.ensure_table(dim).await?;
self.replace_existing_pipeline_chunks(&table, &chunks)
.await?;
let batch = build_batch(&chunks, dim, true)?;
let add_result = table.add(batch).execute().await;
match add_result {
Ok(_) => {}
Err(e) => {
log::warn!(
"LanceDB insert with corpus/provenance failed ({e}); \
retrying with the legacy schema (pre-v0.9.12 table?)"
);
let legacy_batch = build_batch(&chunks, dim, false)?;
table.add(legacy_batch).execute().await?;
}
}
self.count
.fetch_add(n, std::sync::atomic::Ordering::Relaxed);
{
let mut stored = lock_or_recover(&self.stored);
for c in &chunks {
stored.retain(|s| {
!(s.corpus == c.provenance.corpus
&& s.document == c.document
&& s.parser == c.provenance.parser
&& s.chunker == c.provenance.chunker
&& s.pipeline == c.provenance.pipeline)
});
stored.push(StoredDocument {
corpus: c.provenance.corpus.clone(),
document: c.document.clone(),
parser: c.provenance.parser.clone(),
chunker: c.provenance.chunker.clone(),
pipeline: c.provenance.pipeline.clone(),
document_id: c.meta.document_id.clone(),
});
}
}
Ok(())
}
async fn search(
&self,
query_vec: &[f32],
query_text: &str,
top_k: usize,
threshold: f64,
) -> Result<Vec<ScoredChunk>> {
let Some(table) = lock_or_recover(&self.table).clone() else {
return Ok(Vec::new());
};
let stream = table
.query()
.nearest_to(query_vec)?
.full_text_search(FullTextSearchQuery::new(query_text.to_string()))
.limit(top_k)
.execute_hybrid(QueryExecutionOptions::default())
.await?;
let batches: Vec<RecordBatch> = stream.try_collect().await?;
let mut results = Vec::new();
for batch in &batches {
let text_col = batch
.column_by_name("text")
.and_then(|col| col.as_any().downcast_ref::<StringArray>())
.ok_or_else(|| anyhow!("text column not found"))?;
let source_col = batch
.column_by_name("document")
.and_then(|col| col.as_any().downcast_ref::<StringArray>())
.ok_or_else(|| anyhow!("document column not found"))?;
let provenance_col = batch
.column_by_name("provenance")
.and_then(|col| col.as_any().downcast_ref::<StringArray>());
let score_col: Option<&Float32Array> = batch
.column_by_name("_score")
.and_then(|col| col.as_any().downcast_ref::<Float32Array>())
.or_else(|| {
batch
.column_by_name("_distance")
.and_then(|col| col.as_any().downcast_ref::<Float32Array>())
});
let has_score = batch.column_by_name("_score").is_some();
for i in 0..batch.num_rows() {
let raw_score = match score_col {
Some(col) => col.value(i) as f64,
None => 1.0 / (1.0 + (results.len() + i) as f64),
};
if threshold > 0.0 {
if has_score && raw_score < threshold {
continue;
}
if !has_score && raw_score > threshold {
continue;
}
}
results.push(ScoredChunk {
score: RankScore::cosine(raw_score),
chunk: DocumentChunk {
text: text_col.value(i).to_string(),
document: DocumentId::from(source_col.value(i).to_string()),
meta: ChunkMeta::default(),
provenance: provenance_col
.map(|col| serde_json::from_str(col.value(i)).unwrap_or_default())
.unwrap_or_default(),
},
});
}
}
Ok(results)
}
async fn delete_by_document(&self, document: &str) -> Result<()> {
let table = lock_or_recover(&self.table).clone();
if let Some(table) = table {
let deleted = table
.delete(&format!("document = {}", sql_literal(document)))
.await?;
self.apply_deletion(deleted.num_deleted_rows);
}
self.stored
.lock()
.unwrap()
.retain(|s| s.document != document);
self.invalidate_manifest();
Ok(())
}
async fn delete_document(&self, corpus: &str, document: &str) -> Result<()> {
if self.corpus_column {
let table = lock_or_recover(&self.table).clone();
if let Some(table) = table {
let deleted = table
.delete(&format!(
"corpus = {} AND document = {}",
sql_literal(corpus),
sql_literal(document)
))
.await?;
self.apply_deletion(deleted.num_deleted_rows);
}
self.stored
.lock()
.unwrap()
.retain(|s| !(s.corpus == corpus && s.document == document));
} else {
self.delete_by_document(document).await?;
}
self.invalidate_manifest();
Ok(())
}
async fn delete_corpus(&self, corpus: &str) -> Result<()> {
if self.corpus_column {
let table = lock_or_recover(&self.table).clone();
if let Some(table) = table {
let deleted = table
.delete(&format!("corpus = {}", sql_literal(corpus)))
.await?;
self.apply_deletion(deleted.num_deleted_rows);
}
lock_or_recover(&self.stored).retain(|s| s.corpus != corpus);
} else {
let docs: Vec<(String, String)> = {
let stored = lock_or_recover(&self.stored);
stored
.iter()
.filter(|s| s.corpus == corpus)
.map(|s| (s.corpus.clone(), s.document.0.clone()))
.collect()
};
for (c, d) in docs {
self.delete_document(&c, &d).await?;
}
}
self.invalidate_manifest();
Ok(())
}
fn stored_documents(&self) -> Vec<StoredDocument> {
lock_or_recover(&self.stored).clone()
}
fn manifest(&self) -> Option<IndexManifest> {
let bytes = std::fs::read(&self.manifest_path).ok()?;
serde_json::from_slice(&bytes).ok()
}
fn record_manifest(&self, manifest: IndexManifest) -> Result<()> {
let json = serde_json::to_string_pretty(&manifest)?;
std::fs::write(&self.manifest_path, json)?;
Ok(())
}
async fn count_matching(&self, filter: &PipelineFilter) -> usize {
let Some(table) = lock_or_recover(&self.table).clone() else {
return 0;
};
let Ok(stream) = table
.query()
.select(Select::columns(&["provenance"]))
.execute()
.await
else {
return 0;
};
let mut count = 0;
if let Ok(batches) = stream.try_collect::<Vec<_>>().await {
for batch in &batches {
let Some(col) = batch
.column_by_name("provenance")
.and_then(|c| c.as_any().downcast_ref::<StringArray>())
else {
continue;
};
for i in 0..batch.num_rows() {
let prov: PipelineProvenance =
serde_json::from_str(col.value(i)).unwrap_or_default();
if filter.matches(&prov) {
count += 1;
}
}
}
}
count
}
fn len(&self) -> usize {
self.count.load(std::sync::atomic::Ordering::Relaxed)
}
fn document_ids(&self) -> std::collections::HashSet<DocumentId> {
self.stored
.lock()
.unwrap()
.iter()
.map(|s| s.document.clone())
.collect()
}
}
}
#[cfg(feature = "lancedb")]
pub async fn open_store(folder: &Path) -> Result<Box<dyn VectorStore>> {
lance_db_store::LanceDbStore::open_or_create(folder)
.await
.map(|s| Box::new(s) as Box<dyn VectorStore>)
}
#[cfg(all(feature = "internal", not(feature = "lancedb")))]
pub async fn open_store(folder: &Path) -> Result<Box<dyn VectorStore>> {
BruteForceStore::open_or_create(folder).map(|s| Box::new(s) as Box<dyn VectorStore>)
}
#[cfg(not(any(feature = "lancedb", feature = "internal")))]
pub async fn open_store(_folder: &Path) -> Result<Box<dyn VectorStore>> {
anyhow::bail!("No vector store backend enabled. Enable the 'internal' or 'lancedb' feature.")
}
pub async fn embed_and_insert(
store: &dyn VectorStore,
embedded: Vec<crate::embed::Embedding>,
text_to_source: &HashMap<String, ChunkCorpus>,
chunker: &str,
embedder: &EmbeddingMetadata,
) -> Result<()> {
embed_and_insert_with_pipeline(store, embedded, text_to_source, chunker, embedder, "").await
}
pub async fn embed_and_insert_with_pipeline(
store: &dyn VectorStore,
embedded: Vec<crate::embed::Embedding>,
text_to_source: &HashMap<String, ChunkCorpus>,
chunker: &str,
embedder: &EmbeddingMetadata,
pipeline: &str,
) -> Result<()> {
let embedder_id = embedder.id();
let chunks: Vec<StoredChunk> = embedded
.into_iter()
.map(|embedding| {
let corpus = text_to_source
.get(&embedding.text)
.cloned()
.unwrap_or_else(|| ChunkCorpus {
corpus: String::new(),
document: DocumentId::from("unknown"),
meta: ChunkMeta::default(),
parser: String::new(),
});
StoredChunk {
text: embedding.text,
document: corpus.document,
vector: embedding.vector,
meta: corpus.meta,
provenance: PipelineProvenance::new_with_corpus_and_pipeline(
corpus.corpus,
corpus.parser,
chunker,
&embedder_id,
pipeline,
),
}
})
.collect();
store.insert(chunks).await
}
#[cfg(test)]
#[cfg(feature = "internal")]
mod tests {
use super::*;
use std::env;
fn temp_folder() -> PathBuf {
use std::sync::atomic::{AtomicUsize, Ordering};
static COUNTER: AtomicUsize = AtomicUsize::new(0);
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
let mut dir = env::temp_dir();
dir.push(format!("ragrig_test_{}_{}", std::process::id(), n));
let _ = std::fs::create_dir_all(&dir);
dir
}
fn cleanup(dir: &Path) {
let _ = std::fs::remove_dir_all(dir);
}
fn chunk(text: &str, corpus: &str) -> StoredChunk {
StoredChunk {
text: text.into(),
document: corpus.into(),
vector: vec![1.0f32, 2.0, 3.0],
meta: ChunkMeta::default(),
provenance: PipelineProvenance::default(),
}
}
fn chunk_with_provenance(text: &str, corpus: &str, chunker: &str) -> StoredChunk {
StoredChunk {
text: text.into(),
document: corpus.into(),
vector: vec![1.0f32, 2.0, 3.0],
meta: ChunkMeta::default(),
provenance: PipelineProvenance::new("unpdf", chunker, "Ollama/nomic-embed-text"),
}
}
fn chunk_with_corpus(
text: &str,
corpus: &str,
provenance_corpus: &str,
chunker: &str,
) -> StoredChunk {
StoredChunk {
text: text.into(),
document: corpus.into(),
vector: vec![1.0f32, 2.0, 3.0],
meta: ChunkMeta::default(),
provenance: PipelineProvenance::new_with_corpus(
provenance_corpus,
"unpdf",
chunker,
"Ollama/nomic-embed-text",
),
}
}
#[tokio::test]
async fn insert_coexists_across_corpora() {
let dir = temp_folder();
let store = BruteForceStore::open_or_create(&dir).unwrap();
store
.insert(vec![chunk_with_corpus(
"alpha", "doc.pdf", "/docs/a", "markdown",
)])
.await
.unwrap();
store
.insert(vec![chunk_with_corpus(
"beta", "doc.pdf", "/docs/b", "markdown",
)])
.await
.unwrap();
assert_eq!(store.len(), 2);
store
.insert(vec![chunk_with_corpus(
"gamma", "doc.pdf", "/docs/b", "markdown",
)])
.await
.unwrap();
assert_eq!(store.len(), 2);
let hits = store
.search(&[1.0, 2.0, 3.0], "doc", 10, 0.0)
.await
.unwrap();
assert!(hits.iter().any(|h| h.chunk.text == "gamma"));
assert!(hits.iter().any(|h| h.chunk.text == "alpha"));
assert!(!hits.iter().any(|h| h.chunk.text == "beta"));
cleanup(&dir);
}
#[tokio::test]
async fn insert_replaces_pres_source_era_chunks() {
let dir = temp_folder();
let store = BruteForceStore::open_or_create(&dir).unwrap();
let old = StoredChunk {
text: "old".into(),
document: "doc.pdf".into(),
vector: vec![1.0f32, 2.0, 3.0],
meta: ChunkMeta::default(),
provenance: PipelineProvenance::new("unpdf", "markdown", "Ollama/nomic-embed-text"),
};
store.insert(vec![old]).await.unwrap();
assert_eq!(store.len(), 1);
store
.insert(vec![chunk_with_corpus(
"new", "doc.pdf", "/docs/a", "markdown",
)])
.await
.unwrap();
assert_eq!(store.len(), 1);
let hits = store
.search(&[1.0, 2.0, 3.0], "doc", 10, 0.0)
.await
.unwrap();
assert_eq!(hits[0].chunk.text, "new");
cleanup(&dir);
}
#[tokio::test]
async fn delete_document_is_scoped_to_corpus() {
let dir = temp_folder();
let store = BruteForceStore::open_or_create(&dir).unwrap();
store
.insert(vec![
chunk_with_corpus("a1", "doc.pdf", "/docs/a", "markdown"),
chunk_with_corpus("b1", "doc.pdf", "/docs/b", "markdown"),
])
.await
.unwrap();
store.delete_document("/docs/a", "doc.pdf").await.unwrap();
assert_eq!(store.len(), 1);
assert!(store.document_ids().iter().all(|s| s == "doc.pdf")); let hits = store
.search(&[1.0, 2.0, 3.0], "doc", 10, 0.0)
.await
.unwrap();
assert!(hits.iter().any(|h| h.chunk.text == "b1"));
assert!(!hits.iter().any(|h| h.chunk.text == "a1"));
cleanup(&dir);
}
#[tokio::test]
async fn delete_corpus_removes_whole_corpus() {
let dir = temp_folder();
let store = BruteForceStore::open_or_create(&dir).unwrap();
store
.insert(vec![
chunk_with_corpus("a1", "doc.pdf", "/docs/a", "markdown"),
chunk_with_corpus("a2", "other.md", "/docs/a", "token"),
chunk_with_corpus("b1", "doc.pdf", "/docs/b", "markdown"),
])
.await
.unwrap();
store.delete_corpus("/docs/a").await.unwrap();
assert_eq!(store.len(), 1);
let hits = store
.search(&[1.0, 2.0, 3.0], "doc", 10, 0.0)
.await
.unwrap();
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].chunk.text, "b1");
cleanup(&dir);
}
#[tokio::test]
async fn stored_documents_reports_identities() {
let dir = temp_folder();
let store = BruteForceStore::open_or_create(&dir).unwrap();
store
.insert(vec![
chunk_with_corpus("a1", "doc.pdf", "/docs/a", "markdown"),
chunk_with_corpus("a2", "doc.pdf", "/docs/a", "markdown"),
chunk_with_corpus("b1", "doc.pdf", "/docs/b", "markdown"),
])
.await
.unwrap();
let stored = store.stored_documents();
assert_eq!(stored.len(), 2);
assert!(
stored
.iter()
.any(|s| s.corpus == "/docs/a" && s.document == "doc.pdf")
);
assert!(
stored
.iter()
.any(|s| s.corpus == "/docs/b" && s.document == "doc.pdf")
);
cleanup(&dir);
}
#[tokio::test]
async fn insert_coexists_across_pipelines() {
let dir = temp_folder();
let store = BruteForceStore::open_or_create(&dir).unwrap();
store
.insert(vec![chunk_with_provenance("alpha", "doc.pdf", "markdown")])
.await
.unwrap();
store
.insert(vec![chunk_with_provenance("beta", "doc.pdf", "token")])
.await
.unwrap();
assert_eq!(store.len(), 2);
store
.insert(vec![chunk_with_provenance("gamma", "doc.pdf", "token")])
.await
.unwrap();
assert_eq!(store.len(), 2);
let hits = store
.search(&[1.0, 2.0, 3.0], "token", 10, 0.0)
.await
.unwrap();
assert!(hits.iter().any(|h| h.chunk.text == "gamma"));
assert!(!hits.iter().any(|h| h.chunk.text == "beta"));
cleanup(&dir);
}
#[tokio::test]
async fn pipeline_id_coexists_and_filters() {
let dir = temp_folder();
let store = BruteForceStore::open_or_create(&dir).unwrap();
let chunk = |text: &str, pipeline: &str| StoredChunk {
text: text.into(),
document: DocumentId::from("doc.html"),
vector: vec![1.0f32, 2.0, 3.0],
meta: ChunkMeta::default(),
provenance: PipelineProvenance::new_with_corpus_and_pipeline(
"book",
"html",
"markdown",
"Ollama/nomic-embed-text",
pipeline,
),
};
store
.insert(vec![chunk("p1-shared", "pipe-a")])
.await
.unwrap();
store
.insert(vec![chunk("p2-shared", "pipe-b")])
.await
.unwrap();
assert_eq!(store.len(), 2);
let a = PipelineFilter::for_pipeline("pipe-a");
let b = PipelineFilter::for_pipeline("pipe-b");
assert_eq!(store.count_matching(&a).await, 1);
assert_eq!(store.count_matching(&b).await, 1);
let hits_a = store
.search_filtered(&[1.0, 2.0, 3.0], "p1", 10, 0.0, &a)
.await
.unwrap();
assert_eq!(hits_a.len(), 1);
assert_eq!(hits_a[0].chunk.text, "p1-shared");
store
.insert(vec![chunk("p1-replaced", "pipe-a")])
.await
.unwrap();
assert_eq!(store.len(), 2);
let hits_a = store
.search_filtered(&[1.0, 2.0, 3.0], "p1", 10, 0.0, &a)
.await
.unwrap();
assert_eq!(hits_a[0].chunk.text, "p1-replaced");
assert_eq!(
store
.search_filtered(&[1.0, 2.0, 3.0], "p2", 10, 0.0, &b)
.await
.unwrap()[0]
.chunk
.text,
"p2-shared"
);
cleanup(&dir);
}
#[tokio::test]
async fn count_matching_counts_pipeline_chunks() {
let dir = temp_folder();
let store = BruteForceStore::open_or_create(&dir).unwrap();
store
.insert(vec![
chunk_with_provenance("a", "doc.pdf", "markdown"),
chunk_with_provenance("b", "doc.pdf", "markdown"),
chunk_with_provenance("c", "doc.pdf", "token"),
])
.await
.unwrap();
let markdown = PipelineFilter::for_chunker("markdown");
assert_eq!(store.count_matching(&markdown).await, 2);
let token = PipelineFilter::for_chunker("token");
assert_eq!(store.count_matching(&token).await, 1);
let full = PipelineFilter {
corpus: None,
parser: Some("unpdf".into()),
chunker: Some("token".into()),
embedder: Some("Ollama/nomic-embed-text".into()),
pipeline: None,
};
assert_eq!(store.count_matching(&full).await, 1);
let missing = PipelineFilter::for_chunker("nope");
assert_eq!(store.count_matching(&missing).await, 0);
cleanup(&dir);
}
#[tokio::test]
async fn search_filtered_returns_only_matching_pipeline() {
let dir = temp_folder();
let store = BruteForceStore::open_or_create(&dir).unwrap();
store
.insert(vec![chunk_with_provenance(
"markdown chunk",
"doc.pdf",
"markdown",
)])
.await
.unwrap();
store
.insert(vec![chunk_with_provenance(
"token chunk",
"doc.pdf",
"token",
)])
.await
.unwrap();
let filter = PipelineFilter::for_chunker("markdown");
let hits = store
.search_filtered(&[1.0, 2.0, 3.0], "chunk", 10, 0.0, &filter)
.await
.unwrap();
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].chunk.provenance.chunker, "markdown");
assert!(filter.matches(&hits[0].chunk.provenance));
let missing = PipelineFilter::for_chunker("nope");
let hits = store
.search_filtered(&[1.0, 2.0, 3.0], "chunk", 10, 0.0, &missing)
.await
.unwrap();
assert!(hits.is_empty());
cleanup(&dir);
}
#[tokio::test]
async fn provenance_persists_round_trip() {
let dir = temp_folder();
let store = BruteForceStore::open_or_create(&dir).unwrap();
store
.insert(vec![chunk_with_provenance("alpha", "doc.pdf", "token")])
.await
.unwrap();
store.flush().unwrap();
drop(store);
let reopened = BruteForceStore::open_or_create(&dir).unwrap();
let hits = reopened
.search(&[1.0, 2.0, 3.0], "alpha", 5, 0.0)
.await
.unwrap();
assert_eq!(hits.len(), 1);
let prov = &hits[0].chunk.provenance;
assert_eq!(prov.parser, "unpdf");
assert_eq!(prov.chunker, "token");
assert_eq!(prov.embedder, "Ollama/nomic-embed-text");
cleanup(&dir);
}
#[tokio::test]
async fn insert_and_len() {
let dir = temp_folder();
let store = BruteForceStore::open_or_create(&dir).unwrap();
assert_eq!(store.len(), 0);
store.insert(vec![chunk("hello", "doc1")]).await.unwrap();
assert_eq!(store.len(), 1);
store.insert(vec![chunk("world", "doc2")]).await.unwrap();
assert_eq!(store.len(), 2);
cleanup(&dir);
}
#[tokio::test]
async fn insert_replaces_same_corpus() {
let dir = temp_folder();
let store = BruteForceStore::open_or_create(&dir).unwrap();
store.insert(vec![chunk("old", "doc1")]).await.unwrap();
store.insert(vec![chunk("new", "doc1")]).await.unwrap();
assert_eq!(store.len(), 1);
cleanup(&dir);
}
#[tokio::test]
async fn delete_by_document() {
let dir = temp_folder();
let store = BruteForceStore::open_or_create(&dir).unwrap();
store
.insert(vec![chunk("a", "src1"), chunk("b", "src2")])
.await
.unwrap();
assert_eq!(store.len(), 2);
store.delete_by_document("src1").await.unwrap();
assert_eq!(store.len(), 1);
let ids = store.document_ids();
assert!(ids.contains("src2"));
assert!(!ids.contains(&DocumentId::from("src1")));
cleanup(&dir);
}
#[tokio::test]
async fn search_returns_scored_results() {
let dir = temp_folder();
let store = BruteForceStore::open_or_create(&dir).unwrap();
let qv = vec![1.0f32, 2.0, 3.0];
store
.insert(vec![
chunk("cat", "s1"),
chunk("dog", "s2"),
chunk("cat dog", "s3"),
])
.await
.unwrap();
let hits = store.search(&qv, "cat", 3, 0.0).await.unwrap();
assert!(!hits.is_empty());
for h in &hits {
assert!(h.score > 0.0);
assert!(!h.chunk.text.is_empty());
assert!(!h.chunk.document.0.is_empty());
}
cleanup(&dir);
}
#[tokio::test]
async fn persistence_round_trip() {
let dir = temp_folder();
let store = BruteForceStore::open_or_create(&dir).unwrap();
store
.insert(vec![chunk("persist me", "src")])
.await
.unwrap();
drop(store);
let reopened = BruteForceStore::open_or_create(&dir).unwrap();
assert_eq!(reopened.len(), 1);
assert!(reopened.document_ids().contains(&DocumentId::from("src")));
cleanup(&dir);
}
#[tokio::test]
async fn cosine_beats_bm25_on_synonym_query() {
let query_vec = vec![1.0f32, 0.0, 0.0, 0.0];
let multi_level_chunks: Vec<StoredChunk> = (0..5)
.map(|i| StoredChunk {
text: format!(
"Multi-level regression handles hierarchical data structures. \
Section {} discusses random intercepts and variance components.",
i
),
document: DocumentId::from("mlm_chapter.pdf".to_string()),
vector: vec![0.92, 0.15, 0.0, 0.05],
meta: ChunkMeta::default(),
provenance: PipelineProvenance::default(),
})
.collect();
let literal_chunk = StoredChunk {
text: "These models have also been called hierarchical models or \
mixed-effects models. The 'mixed' stands for a mixture of \
fixed effects and random effects."
.into(),
document: DocumentId::from("mlm_chapter.pdf".to_string()),
vector: vec![0.0, 0.0, 0.0, 1.0],
meta: ChunkMeta::default(),
provenance: PipelineProvenance::default(),
};
let unrelated_chunks: Vec<StoredChunk> = (0..5)
.map(|i| StoredChunk {
text: format!(
"The Gaussian distribution has mean μ and standard deviation σ. \
Example {} illustrates the central limit theorem.",
i
),
document: DocumentId::from("stats_chapter.pdf".to_string()),
vector: vec![0.0, 0.0, 1.0, 0.0],
meta: ChunkMeta::default(),
provenance: PipelineProvenance::default(),
})
.collect();
let all_chunks: Vec<StoredChunk> = multi_level_chunks
.iter()
.chain(std::iter::once(&literal_chunk))
.chain(unrelated_chunks.iter())
.cloned()
.collect();
let query_text = "mixed-effects models";
let top_k = 6;
let threshold = 0.0;
let cosine = WeightedFusionRanker::new(1.0).unwrap();
let cos_hits = cosine
.rank(&all_chunks, &query_vec, query_text, top_k, threshold)
.await;
let bm25 = WeightedFusionRanker::new(0.0).unwrap();
let bm25_hits = bm25
.rank(&all_chunks, &query_vec, query_text, top_k, threshold)
.await;
let cos_ml_count = cos_hits
.iter()
.filter(|h| h.chunk.text.to_lowercase().contains("multi-level"))
.count();
assert!(
cos_ml_count >= 2,
"Cosine (alpha=1.0) should retrieve at least 2 multi-level chunks \
via semantic similarity, but found {}",
cos_ml_count
);
let cos_literal_pos = cos_hits
.iter()
.position(|h| h.chunk.text.to_lowercase().contains("mixed-effects"));
assert!(
cos_literal_pos.unwrap_or(0) >= 2,
"Cosine should prefer semantically-close chunks over the literal match"
);
let bm25_literal_pos = bm25_hits
.iter()
.position(|h| h.chunk.text.to_lowercase().contains("mixed-effects"));
assert!(
bm25_literal_pos.is_some(),
"BM25 (alpha=0.0) should find the literal 'mixed-effects' mention"
);
assert_eq!(
bm25_literal_pos.unwrap(),
0,
"BM25 should rank the literal token match first, got position {}",
bm25_literal_pos.unwrap()
);
let bm25_positive = bm25_hits.iter().filter(|h| h.score > 0.0).count();
assert_eq!(
bm25_positive, 1,
"BM25 should give a positive score to exactly 1 chunk (the literal match), got {}",
bm25_positive
);
assert!(
cos_ml_count > 1,
"Cosine retrieved {} multi-level chunks; BM25 got {} positive hits. \
Expected Cosine to surface more relevant content on synonym queries.",
cos_ml_count,
bm25_positive
);
for hits in &[&cos_hits, &bm25_hits] {
let gauss_count = hits
.iter()
.filter(|h| h.chunk.text.to_lowercase().contains("gaussian"))
.count();
assert_eq!(
gauss_count, 0,
"Unrelated chunks should not appear in top-{} results",
top_k
);
}
}
#[tokio::test]
async fn llm_reranker_surfaces_assumption_passages() {
use crate::agents::Generator;
#[derive(Debug)]
struct MockRanker {
ranking: String,
captured_prompt: std::sync::Mutex<Option<String>>,
}
impl Clone for MockRanker {
fn clone(&self) -> Self {
Self {
ranking: self.ranking.clone(),
captured_prompt: std::sync::Mutex::new(None),
}
}
}
#[async_trait]
impl Generator for MockRanker {
async fn generate_stream(
&self,
prompt: &str,
on_token: &(dyn Fn(String) + Sync),
) -> anyhow::Result<()> {
*self.captured_prompt.lock().unwrap() = Some(prompt.to_string());
on_token(self.ranking.clone());
Ok(())
}
fn backend_name(&self) -> &'static str {
"mock"
}
fn model_name(&self) -> &str {
"mock-ranker"
}
}
let query_text = "pre-conditions and limitations of linear models";
let chunks = vec![
StoredChunk {
text: "Linear models assume independent, identically \
distributed errors with constant variance."
.into(),
document: DocumentId::from("stats".to_string()),
vector: vec![0.8, 0.0, 0.0, 0.0],
meta: ChunkMeta::default(),
provenance: PipelineProvenance::default(),
},
StoredChunk {
text: "The Gauss-Markov theorem proves OLS is the best \
linear unbiased estimator under homoscedasticity \
and no autocorrelation."
.into(),
document: DocumentId::from("stats".to_string()),
vector: vec![0.7, 0.0, 0.0, 0.1],
meta: ChunkMeta::default(),
provenance: PipelineProvenance::default(),
},
StoredChunk {
text: "Multi-level models extend linear models by adding \
random effects for grouped data structures."
.into(),
document: DocumentId::from("stats".to_string()),
vector: vec![0.6, 0.0, 0.0, 0.2],
meta: ChunkMeta::default(),
provenance: PipelineProvenance::default(),
},
StoredChunk {
text: "Violations of normality affect the validity of \
t-tests and F-tests, especially in small samples."
.into(),
document: DocumentId::from("stats".to_string()),
vector: vec![0.5, 0.0, 0.0, 0.3],
meta: ChunkMeta::default(),
provenance: PipelineProvenance::default(),
},
StoredChunk {
text: "Perfect multicollinearity makes the design matrix \
singular, preventing OLS estimation entirely."
.into(),
document: DocumentId::from("stats".to_string()),
vector: vec![0.4, 0.0, 0.0, 0.4],
meta: ChunkMeta::default(),
provenance: PipelineProvenance::default(),
},
StoredChunk {
text: "Bayesian hierarchical models use prior distributions \
to regularize parameter estimates across groups."
.into(),
document: DocumentId::from("stats".to_string()),
vector: vec![0.3, 0.0, 0.0, 0.5],
meta: ChunkMeta::default(),
provenance: PipelineProvenance::default(),
},
];
let query_vec = vec![1.0f32, 0.0, 0.0, 0.0];
let inner = WeightedFusionRanker::new(1.0).unwrap();
let baseline = inner.rank(&chunks, &query_vec, query_text, 6, 0.0).await;
let baseline_texts: Vec<&str> = baseline.iter().map(|h| h.chunk.text.as_str()).collect();
assert!(
baseline_texts[0].contains("independent"),
"Cosine should rank the most similar vector first"
);
let mock = MockRanker {
ranking: "0\n1\n3\n4\n2\n5\n".into(),
captured_prompt: std::sync::Mutex::new(None),
};
let llm_ranker = LlmReranker {
generator: Box::new(mock),
inner: Box::new(inner.clone()),
prompt_template: String::new(), };
let reranked = llm_ranker
.rank(&chunks, &query_vec, query_text, 4, 0.0)
.await;
assert_eq!(reranked.len(), 4);
let reranked_texts: Vec<&str> = reranked.iter().map(|h| h.chunk.text.as_str()).collect();
assert!(
reranked_texts[0].contains("independent"),
"LLM should rank the i.i.d. assumption passage first, got: {:?}",
reranked_texts[0]
);
assert!(
reranked_texts[1].contains("Gauss-Markov"),
"LLM should rank Gauss-Markov second, got: {:?}",
reranked_texts[1]
);
for t in &reranked_texts {
assert!(
!t.contains("Multi-level") && !t.contains("Bayesian"),
"LLM should exclude multi-level and Bayesian from top results, \
but found: {}",
t
);
}
}
#[tokio::test]
async fn empty_store_search_returns_empty_not_error() {
let dir = temp_folder();
let store = BruteForceStore::open_or_create(&dir).unwrap();
assert!(store.is_empty());
let results = store
.search(&[1.0f32, 0.0, 0.0], "query", 5, 0.0)
.await
.unwrap();
assert!(results.is_empty());
cleanup(&dir);
}
#[tokio::test]
async fn empty_store_has_zero_len() {
let dir = temp_folder();
let store = BruteForceStore::open_or_create(&dir).unwrap();
assert_eq!(store.len(), 0);
assert!(store.is_empty());
assert!(store.document_ids().is_empty());
cleanup(&dir);
}
#[tokio::test]
async fn search_with_mismatched_dimension_is_an_error() {
let dir = temp_folder();
let store = BruteForceStore::open_or_create(&dir).unwrap();
store.insert(vec![chunk("text", "doc")]).await.unwrap();
let err = store
.search(&[1.0f32, 0.0, 0.0, 0.0], "text", 5, 0.0)
.await
.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("dims"), "unexpected error: {msg}");
let hits = store
.search(&[1.0f32, 0.0, 0.0], "text", 5, 0.0)
.await
.unwrap();
assert!(!hits.is_empty());
cleanup(&dir);
}
#[derive(Debug, Clone)]
struct InsertionOrderRanker;
#[async_trait]
impl Ranker for InsertionOrderRanker {
async fn rank(
&self,
chunks: &[StoredChunk],
_query_vec: &[f32],
_query_text: &str,
top_k: usize,
_threshold: f64,
) -> Vec<ScoredChunk> {
chunks
.iter()
.take(top_k)
.map(|c| ScoredChunk {
score: RankScore::new(1.0f64, ScoreKind::Cosine),
chunk: DocumentChunk {
text: c.text.clone(),
document: c.document.clone(),
meta: c.meta.clone(),
provenance: c.provenance.clone(),
},
})
.collect()
}
fn name(&self) -> &'static str {
"InsertionOrder"
}
}
#[tokio::test]
async fn swapped_ranker_drives_search_ordering() {
let dir = temp_folder();
let store = BruteForceStore::open_or_create(&dir).unwrap();
store
.insert(vec![
chunk_with_provenance("first chunk", "doc-a", "markdown"),
chunk_with_provenance("second chunk", "doc-b", "markdown"),
])
.await
.unwrap();
store.set_ranker(Box::new(InsertionOrderRanker)).unwrap();
assert_eq!(store.ranker_name().as_deref(), Some("InsertionOrder"));
let results = store
.search(&[1.0f32, 0.0, 0.0], "query", 5, 0.0)
.await
.unwrap();
assert_eq!(results.len(), 2);
assert_eq!(results[0].chunk.text, "first chunk");
assert_eq!(results[1].chunk.text, "second chunk");
cleanup(&dir);
}
#[tokio::test]
async fn mmr_ranker_prefers_distinct_over_duplicate() {
let dup_a = StoredChunk {
text: "quantum entanglement is weird".into(),
document: DocumentId::from("d1"),
vector: vec![1.0f32, 0.0, 0.0, 0.0],
meta: ChunkMeta::default(),
provenance: PipelineProvenance::default(),
};
let dup_b = StoredChunk {
text: "quantum entanglement is weird and counterintuitive".into(),
document: DocumentId::from("d2"),
vector: vec![1.0f32, 0.0, 0.0, 0.0],
meta: ChunkMeta::default(),
provenance: PipelineProvenance::default(),
};
let distinct = StoredChunk {
text: "quantum entanglement for gardeners".into(),
document: DocumentId::from("d3"),
vector: vec![0.0f32, 1.0, 0.0, 0.0],
meta: ChunkMeta::default(),
provenance: PipelineProvenance::default(),
};
let ranker = MmrDiversityRanker::new(0.9, Box::new(HybridRrfRanker::default())).unwrap();
let hits = ranker
.rank(
&[dup_a.clone(), dup_b.clone(), distinct.clone()],
&[1.0f32, 0.0, 0.0, 0.0],
"quantum entanglement",
3,
0.0,
)
.await;
assert_eq!(hits.len(), 3);
assert!(hits[0].chunk.document == "d1" || hits[0].chunk.document == "d2");
assert_eq!(hits[1].chunk.document, DocumentId::from("d3"));
let last = &hits[2].chunk.document;
assert!(last == &DocumentId::from("d1") || last == &DocumentId::from("d2"));
assert_ne!(&hits[0].chunk.document, last);
}
}
#[cfg(test)]
#[cfg(feature = "lancedb")]
mod lance_tests {
use super::*;
use lance_db_store::LanceDbStore;
fn lance_chunk(text: &str, document: &str, corpus: &str, chunker: &str) -> StoredChunk {
StoredChunk {
text: text.into(),
document: document.into(),
vector: vec![0.0f32; 768],
meta: ChunkMeta {
document_id: Some(format!("{corpus}:{document}")),
..Default::default()
},
provenance: PipelineProvenance::new_with_corpus(
corpus,
"unpdf",
chunker,
"Ollama/nomic-embed-text",
),
}
}
fn manifest() -> IndexManifest {
IndexManifest {
created: "2026-08-21T00:00:00Z".into(),
chunk_size: 1024,
chunk_overlap: 128,
chunker: "markdown".into(),
embedding_model: "nomic-embed-text".into(),
embedding_dimensions: 768,
document_count: 1,
total_chunks: 1,
file_hashes: vec![],
}
}
#[tokio::test]
async fn reopen_preserves_identity_manifest_and_count() {
let dir = tempfile::tempdir().unwrap();
let store = LanceDbStore::open_or_create(dir.path()).await.unwrap();
store
.insert(vec![
lance_chunk("chunk a", "a.md", "papers", "markdown"),
lance_chunk("chunk b", "a.md", "papers", "markdown"),
])
.await
.unwrap();
store.record_manifest(manifest()).unwrap();
let reopened = LanceDbStore::open_or_create(dir.path()).await.unwrap();
assert_eq!(reopened.len(), 2);
let docs = reopened.stored_documents();
assert_eq!(docs.len(), 1);
assert_eq!(docs[0].corpus, "papers");
assert_eq!(docs[0].document, "a.md");
assert_eq!(docs[0].chunker, "markdown");
assert_eq!(docs[0].document_id.as_deref(), Some("papers:a.md"));
assert!(reopened.manifest().is_some());
}
#[tokio::test]
async fn delete_document_is_scoped_to_corpus() {
let dir = tempfile::tempdir().unwrap();
let store = LanceDbStore::open_or_create(dir.path()).await.unwrap();
store
.insert(vec![
lance_chunk("papers copy", "shared.md", "papers", "markdown"),
lance_chunk("books copy", "shared.md", "books", "markdown"),
])
.await
.unwrap();
assert_eq!(store.len(), 2);
store.delete_document("papers", "shared.md").await.unwrap();
assert_eq!(store.len(), 1);
let docs = store.stored_documents();
assert_eq!(docs.len(), 1);
assert_eq!(docs[0].corpus, "books");
}
#[tokio::test]
async fn delete_corpus_removes_only_that_corpus_and_invalidates_manifest() {
let dir = tempfile::tempdir().unwrap();
let store = LanceDbStore::open_or_create(dir.path()).await.unwrap();
store
.insert(vec![
lance_chunk("p", "a.md", "papers", "markdown"),
lance_chunk("b", "b.md", "books", "markdown"),
])
.await
.unwrap();
store.record_manifest(manifest()).unwrap();
assert!(store.manifest().is_some());
store.delete_corpus("papers").await.unwrap();
assert_eq!(store.len(), 1);
let docs = store.stored_documents();
assert_eq!(docs.len(), 1);
assert_eq!(docs[0].corpus, "books");
assert!(store.manifest().is_none());
}
#[tokio::test]
async fn reinsert_replaces_same_pipeline_only() {
let dir = tempfile::tempdir().unwrap();
let store = LanceDbStore::open_or_create(dir.path()).await.unwrap();
store
.insert(vec![lance_chunk("v1", "a.md", "papers", "markdown")])
.await
.unwrap();
assert_eq!(store.len(), 1);
store
.insert(vec![lance_chunk("v2", "a.md", "papers", "markdown")])
.await
.unwrap();
assert_eq!(store.len(), 1);
store
.insert(vec![lance_chunk("v2", "a.md", "papers", "token")])
.await
.unwrap();
assert_eq!(store.len(), 2);
let docs = store.stored_documents();
assert_eq!(docs.len(), 2);
}
#[tokio::test]
async fn table_dimension_follows_first_insert() {
let dir = tempfile::tempdir().unwrap();
let store = LanceDbStore::open_or_create(dir.path()).await.unwrap();
assert!(
store
.search(&vec![0.0f32; 384], "anything", 5, 0.0)
.await
.unwrap()
.is_empty()
);
assert_eq!(store.count_matching(&PipelineFilter::any()).await, 0);
let mut chunk = lance_chunk("quantum", "a.md", "papers", "markdown");
chunk.vector = vec![0.0f32; 384];
store.insert(vec![chunk]).await.unwrap();
assert_eq!(store.len(), 1);
let hits = store
.search(&vec![0.0f32; 384], "quantum", 5, 0.0)
.await
.unwrap();
assert!(!hits.is_empty());
let mut other = lance_chunk("garden", "b.md", "papers", "markdown");
other.vector = vec![0.0f32; 1024];
assert!(store.insert(vec![other]).await.is_err());
assert_eq!(store.len(), 1);
}
}