refactor(rag): extract RagProvider trait and add YamlProvider
Introduce a narrow `RagProvider` trait covering vector search and content retrieval, and make `Rag` delegate to a boxed provider instead of owning an HNSW index directly. `YamlProvider` is the sole implementation for now. The trait deliberately stays narrow: embeddings, chunking, BM25 keyword search, graph RAG, entity extraction, RRF merging and persistence all remain on `Rag`/`RagData`, so a new storage backend does not have to reimplement Coyote's indexing logic. Notable points: - `fetch_content`'s ordering contract is part of the trait, not an accident. Implementations must return results in input-`ids` order; `hybrid_search` passes an RRF-ranked list straight to the prompt builder, so a provider returning storage order would silently discard the ranking. - The content store is keyed on `data.files`, never `data.vectors`. Both the content map and BM25 now route through the new `RagData::iter_documents()` so the two key spaces match by construction. `RagData::add` zips document ids with embeddings and truncates silently, so ids in `files \ vectors` are genuinely reachable. - A provider keyword-search failure degrades to an empty ranker with a warning rather than failing the whole query; it is one of three RRF inputs. It deliberately does not fall back to the local BM25, which would be a silent ranking-algorithm swap once a provider with native FTS exists. - The rerank path builds its text and id vectors from a single `fetch_content` result in one pass, so the reranker's positional indices cannot desync. This is not a bit-for-bit no-op. `vector_search` now dedups by best score and sorts globally instead of concatenating per-chunk hit lists. Single-chunk queries (the common case) are unaffected. Multi-chunk queries get corrected rank assignment and no longer let a document that matched several query chunks accumulate multiple RRF contributions. There is no overall cap on the merged pool — truncation remains `reciprocal_rank_fusion`'s job. `RagData::get()` is removed: its only two callers were the content lookups replaced here, and an unused private-module method fails the build under `--deny warnings`. Its three tests were rewritten against `iter_documents()`, one of which now guards the files-vs-vectors keying directly. Implements Phase 2 of the RAG driver abstraction design (§6).
This commit is contained in:
+189
-76
@@ -5,10 +5,14 @@ use crate::config::*;
|
||||
use crate::utils::*;
|
||||
|
||||
mod graph;
|
||||
mod provider;
|
||||
mod providers;
|
||||
mod serde_vectors;
|
||||
mod splitter;
|
||||
|
||||
use self::graph::{KnowledgeGraph, extract_entities};
|
||||
use self::provider::RagProvider;
|
||||
use self::providers::YamlProvider;
|
||||
|
||||
use anyhow::{Context, Result, anyhow, bail};
|
||||
use bm25::{Language, SearchEngine, SearchEngineBuilder};
|
||||
@@ -56,8 +60,11 @@ pub struct Rag {
|
||||
name: String,
|
||||
path: String,
|
||||
embedding_model: Model,
|
||||
hnsw: Hnsw<'static, f32, DistCosine>,
|
||||
// Local BM25: keyword search + graph seeding. Always built from `data.files`
|
||||
// regardless of driver, and kept on `Rag` so the sync `graph_search` can use it.
|
||||
bm25: SearchEngine<DocumentId>,
|
||||
// Vector storage + content retrieval.
|
||||
provider: Box<dyn RagProvider>,
|
||||
data: RagData,
|
||||
last_sources: RwLock<Option<String>>,
|
||||
node_to_docs: IndexMap<u32, Vec<DocumentId>>,
|
||||
@@ -74,6 +81,19 @@ impl Debug for Rag {
|
||||
}
|
||||
}
|
||||
|
||||
// CLONING A `Rag` DOES NOT SNAPSHOT ITS BACKING STORE.
|
||||
//
|
||||
// `provider.duplicate(&self.data)` is a true snapshot for YamlProvider only.
|
||||
// DuckDbProvider Arc-clones one shared `Mutex<Connection>` over one file, and
|
||||
// QdrantProvider addresses the same remote collection. So for those drivers the
|
||||
// clone and the original are two views of ONE store.
|
||||
//
|
||||
// INVARIANT: after calling `rebuild_indexes` on a cloned `Rag`, the pre-clone
|
||||
// instance MUST be discarded immediately and MUST NOT serve further queries.
|
||||
// Cloning to READ is always fine; cloning to REBUILD makes the original a
|
||||
// half-truth (pre-rebuild `data`, post-rebuild store). Note that reassigning
|
||||
// `RequestContext.rag` drops only one holder of the old `Arc<Rag>` — forked
|
||||
// request contexts, agents, captured inputs and the RAG cache keep theirs.
|
||||
impl Clone for Rag {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
@@ -81,8 +101,8 @@ impl Clone for Rag {
|
||||
name: self.name.clone(),
|
||||
path: self.path.clone(),
|
||||
embedding_model: self.embedding_model.clone(),
|
||||
hnsw: self.data.build_hnsw(),
|
||||
bm25: self.data.build_bm25(),
|
||||
provider: self.provider.duplicate(&self.data),
|
||||
node_to_docs: self.data.knowledge_graph.build_node_to_docs(),
|
||||
data: self.data.clone(),
|
||||
last_sources: RwLock::new(None),
|
||||
@@ -296,8 +316,8 @@ impl Rag {
|
||||
}
|
||||
|
||||
pub fn create(app: &AppConfig, name: &str, path: &Path, data: RagData) -> Result<Self> {
|
||||
let hnsw = data.build_hnsw();
|
||||
let bm25 = data.build_bm25();
|
||||
let provider: Box<dyn RagProvider> = Box::new(YamlProvider::from_data(&data));
|
||||
let node_to_docs = data.knowledge_graph.build_node_to_docs();
|
||||
let embedding_model =
|
||||
Model::retrieve_model(app, &data.embedding_model, ModelType::Embedding)?;
|
||||
@@ -307,8 +327,8 @@ impl Rag {
|
||||
path: path.display().to_string(),
|
||||
data,
|
||||
embedding_model,
|
||||
hnsw,
|
||||
bm25,
|
||||
provider,
|
||||
node_to_docs,
|
||||
last_sources: RwLock::new(None),
|
||||
};
|
||||
@@ -821,7 +841,11 @@ impl Rag {
|
||||
}
|
||||
|
||||
progress(&spinner, "Building store".into());
|
||||
self.hnsw = self.data.build_hnsw();
|
||||
// `refresh` is true for a full re-index (.rebuild rag / --rebuild-rag /
|
||||
// initial build) and false for an incremental .edit rag-docs change.
|
||||
// Passing it through is what stops a remote provider from wiping its
|
||||
// collection on a one-file add.
|
||||
self.provider.rebuild_indexes(&self.data, refresh).await?;
|
||||
self.bm25 = self.data.build_bm25();
|
||||
self.node_to_docs = self.data.knowledge_graph.build_node_to_docs();
|
||||
|
||||
@@ -834,17 +858,30 @@ impl Rag {
|
||||
top_k: usize,
|
||||
rerank_model: Option<&str>,
|
||||
) -> Result<Vec<(DocumentId, String)>> {
|
||||
let (vector_search_results, keyword_search_results) = tokio::join!(
|
||||
self.vector_search(query, top_k, 0.0),
|
||||
self.keyword_search(query, top_k, 0.0),
|
||||
);
|
||||
|
||||
let vector_search_results = vector_search_results?;
|
||||
let vector_search_results = self.vector_search(query, top_k, 0.0).await?;
|
||||
debug!("vector_search_results: {vector_search_results:?}",);
|
||||
let vector_search_ids: Vec<DocumentId> =
|
||||
vector_search_results.into_iter().map(|(v, _)| v).collect();
|
||||
|
||||
let keyword_search_results = keyword_search_results?;
|
||||
let keyword_search_results: Vec<(DocumentId, f32)> =
|
||||
if self.provider.has_native_keyword_search() {
|
||||
// Keyword is ONE of three RRF rankers (vector + keyword + graph);
|
||||
// its absence is survivable and produces a slightly worse ranking,
|
||||
// whereas a `?` here turns a provider FTS fault into TOTAL query
|
||||
// failure — the user gets an error instead of the results the
|
||||
// vector and graph rankers already retrieved. Degrade, do not
|
||||
// propagate, and do not silently swap in the local BM25 either:
|
||||
// that would change the ranking algorithm mid-query.
|
||||
match self.provider.keyword_search(query, top_k).await {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
warn!("native keyword search failed, dropping the keyword ranker: {e}");
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.keyword_search(query, top_k, 0.0)
|
||||
};
|
||||
debug!("keyword_search_results: {keyword_search_results:?}",);
|
||||
let keyword_search_ids: Vec<DocumentId> =
|
||||
keyword_search_results.into_iter().map(|(v, _)| v).collect();
|
||||
@@ -857,13 +894,20 @@ impl Rag {
|
||||
.concat()
|
||||
.into_iter()
|
||||
.collect();
|
||||
let mut documents = vec![];
|
||||
let mut documents_ids = vec![];
|
||||
for id in ids {
|
||||
if let Some(document) = self.data.get(id) {
|
||||
documents_ids.push(id);
|
||||
documents.push(document.page_content.to_string());
|
||||
}
|
||||
// `ids` is an `IndexSet` here, not the `Vec` of the RRF branch below,
|
||||
// and `&IndexSet<_>` does not coerce to `&[DocumentId]`.
|
||||
let ids: Vec<DocumentId> = ids.into_iter().collect();
|
||||
let fetched = self.provider.fetch_content(&ids).await?;
|
||||
// Build both vectors from the SAME source in the SAME iteration —
|
||||
// never zip two independently-built lists. The reranker returns
|
||||
// positional indices into `documents`, so any drift between the two
|
||||
// resolves reranked hits to the wrong document's text. A partial
|
||||
// fetch simply yields a shorter pair, and both shrink together.
|
||||
let mut documents_ids = Vec::with_capacity(fetched.len());
|
||||
let mut documents = Vec::with_capacity(fetched.len());
|
||||
for (id, text) in fetched {
|
||||
documents_ids.push(id);
|
||||
documents.push(text);
|
||||
}
|
||||
let data = RerankData::new(query.to_string(), documents, top_k);
|
||||
let list = client.rerank(&data).await.context("Failed to rerank")?;
|
||||
@@ -895,13 +939,9 @@ impl Rag {
|
||||
ids
|
||||
}
|
||||
};
|
||||
let output = ids
|
||||
.into_iter()
|
||||
.filter_map(|id| {
|
||||
let document = self.data.get(id)?;
|
||||
Some((id, document.page_content.clone()))
|
||||
})
|
||||
.collect();
|
||||
// `ids` is the ranked list; `fetch_content` preserves that order per the
|
||||
// trait's ordering contract, so the result is returned as-is.
|
||||
let output = self.provider.fetch_content(&ids).await?;
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
@@ -918,35 +958,24 @@ impl Rag {
|
||||
);
|
||||
let texts = splitter.split_text(query);
|
||||
let embeddings_data = EmbeddingsData::new(texts, true);
|
||||
let embeddings = self.create_embeddings(embeddings_data, None).await?;
|
||||
let output = self
|
||||
.hnsw
|
||||
.parallel_search(&embeddings, top_k, 30)
|
||||
.into_iter()
|
||||
.flat_map(|list| {
|
||||
list.into_iter()
|
||||
.filter_map(|v| {
|
||||
let score = 1.0 - v.distance;
|
||||
if score > min_score {
|
||||
Some((DocumentId(v.d_id), score))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.collect();
|
||||
Ok(output)
|
||||
let query_embeddings = self.create_embeddings(embeddings_data, None).await?;
|
||||
|
||||
let mut results: Vec<(DocumentId, f32)> = vec![];
|
||||
for embedding in &query_embeddings {
|
||||
let batch = self
|
||||
.provider
|
||||
.vector_search(embedding, top_k, min_score)
|
||||
.await?;
|
||||
results.extend(batch);
|
||||
}
|
||||
Ok(merge_vector_results(results))
|
||||
}
|
||||
|
||||
async fn keyword_search(
|
||||
&self,
|
||||
query: &str,
|
||||
top_k: usize,
|
||||
min_score: f32,
|
||||
) -> Result<Vec<(DocumentId, f32)>> {
|
||||
/// Local in-memory BM25 over `data.files` — empty for attached RAGs, which is
|
||||
/// correct: they have no local text.
|
||||
fn keyword_search(&self, query: &str, top_k: usize, min_score: f32) -> Vec<(DocumentId, f32)> {
|
||||
let results = self.bm25.search(query, top_k);
|
||||
let output: Vec<(DocumentId, f32)> = results
|
||||
results
|
||||
.into_iter()
|
||||
.filter_map(|v| {
|
||||
let score = v.score;
|
||||
@@ -956,8 +985,7 @@ impl Rag {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
Ok(output)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn graph_search(&self, query: &str, top_k: usize) -> Vec<DocumentId> {
|
||||
@@ -1243,11 +1271,21 @@ impl RagData {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get(&self, id: DocumentId) -> Option<&RagDocument> {
|
||||
let (file_index, document_index) = id.split();
|
||||
let file = self.files.get(&file_index)?;
|
||||
let document = file.documents.get(document_index)?;
|
||||
Some(document)
|
||||
/// Every (DocumentId, &RagDocument) in the corpus, in `files` order.
|
||||
///
|
||||
/// This — NOT `vectors` — is the authoritative document id space. BM25, the
|
||||
/// knowledge graph and content lookup all key off it; `vectors` is a subset,
|
||||
/// since `add`'s zip truncates whenever fewer embeddings come back than
|
||||
/// document ids were sent.
|
||||
pub fn iter_documents(&self) -> impl Iterator<Item = (DocumentId, &RagDocument)> {
|
||||
self.files.iter().flat_map(|(file_index, file)| {
|
||||
file.documents
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(move |(document_index, document)| {
|
||||
(DocumentId::new(*file_index, document_index), document)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
pub fn del(&mut self, file_ids: Vec<FileId>) {
|
||||
@@ -1285,13 +1323,12 @@ impl RagData {
|
||||
}
|
||||
|
||||
pub fn build_bm25(&self) -> SearchEngine<DocumentId> {
|
||||
let mut documents = vec![];
|
||||
for (file_index, file) in self.files.iter() {
|
||||
for (document_index, document) in file.documents.iter().enumerate() {
|
||||
let id = DocumentId::new(*file_index, document_index);
|
||||
documents.push(bm25::Document::new(id, &document.page_content))
|
||||
}
|
||||
}
|
||||
// Shares `iter_documents` with the providers' content maps so the BM25 key
|
||||
// space and the content key space are identical by construction.
|
||||
let documents: Vec<_> = self
|
||||
.iter_documents()
|
||||
.map(|(id, doc)| bm25::Document::new(id, &doc.page_content))
|
||||
.collect();
|
||||
SearchEngineBuilder::<DocumentId>::with_documents(Language::English, documents)
|
||||
.k1(1.5)
|
||||
.b(0.75)
|
||||
@@ -1580,6 +1617,27 @@ fn find_hash_skip(
|
||||
.map(|(i, v)| (i, *v))
|
||||
}
|
||||
|
||||
/// Global score sort + dedup keeping the best score per document.
|
||||
///
|
||||
/// NO overall cap: each `provider.vector_search` call already returns <= top_k,
|
||||
/// so the pool is bounded by `top_k * query_chunks`, and `reciprocal_rank_fusion`
|
||||
/// truncates to `top_k` itself. Capping here would let whichever query chunk has
|
||||
/// the strongest absolute scores crowd out every other chunk's hits.
|
||||
///
|
||||
/// Free function (not a method) so it is unit-testable without an embeddings client.
|
||||
fn merge_vector_results(mut results: Vec<(DocumentId, f32)>) -> Vec<(DocumentId, f32)> {
|
||||
debug_assert!(
|
||||
results.iter().all(|(_, score)| score.is_finite()),
|
||||
"provider returned a non-finite score; NaN silently degrades sort order"
|
||||
);
|
||||
results.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(Ordering::Equal));
|
||||
let mut seen = IndexSet::new();
|
||||
results
|
||||
.into_iter()
|
||||
.filter(|(id, _)| seen.insert(*id))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn reciprocal_rank_fusion(
|
||||
list_of_document_ids: Vec<Vec<DocumentId>>,
|
||||
list_of_weights: Vec<f32>,
|
||||
@@ -1695,7 +1753,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rag_data_get_returns_document() {
|
||||
fn rag_data_iter_documents_yields_all_documents_in_file_order() {
|
||||
let mut data = RagData::new(
|
||||
"m".into(),
|
||||
100,
|
||||
@@ -1712,15 +1770,21 @@ mod tests {
|
||||
};
|
||||
data.files.insert(0, file);
|
||||
|
||||
let doc = data.get(DocumentId::new(0, 0)).unwrap();
|
||||
assert_eq!(doc.page_content, "first");
|
||||
|
||||
let doc = data.get(DocumentId::new(0, 1)).unwrap();
|
||||
assert_eq!(doc.page_content, "second");
|
||||
let documents: Vec<_> = data
|
||||
.iter_documents()
|
||||
.map(|(id, doc)| (id, doc.page_content.as_str()))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
documents,
|
||||
vec![
|
||||
(DocumentId::new(0, 0), "first"),
|
||||
(DocumentId::new(0, 1), "second"),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rag_data_get_returns_none_for_missing_file() {
|
||||
fn rag_data_iter_documents_is_empty_without_files() {
|
||||
let data = RagData::new(
|
||||
"m".into(),
|
||||
100,
|
||||
@@ -1730,11 +1794,14 @@ mod tests {
|
||||
None,
|
||||
GraphRagConfig::default(),
|
||||
);
|
||||
assert!(data.get(DocumentId::new(99, 0)).is_none());
|
||||
assert_eq!(data.iter_documents().count(), 0);
|
||||
}
|
||||
|
||||
/// The document id space is `files`, never `vectors`: `add`'s zip truncates
|
||||
/// silently, so a vector may exist for an id no file provides. Content lookup
|
||||
/// and BM25 both key off this iterator and must agree.
|
||||
#[test]
|
||||
fn rag_data_get_returns_none_for_missing_document() {
|
||||
fn rag_data_iter_documents_ignores_vector_only_ids() {
|
||||
let mut data = RagData::new(
|
||||
"m".into(),
|
||||
100,
|
||||
@@ -1750,7 +1817,10 @@ mod tests {
|
||||
documents: vec![RagDocument::new("only one")],
|
||||
};
|
||||
data.files.insert(0, file);
|
||||
assert!(data.get(DocumentId::new(0, 5)).is_none());
|
||||
data.vectors.insert(DocumentId::new(0, 5), vec![1.0]);
|
||||
|
||||
let ids: Vec<_> = data.iter_documents().map(|(id, _)| id).collect();
|
||||
assert_eq!(ids, vec![DocumentId::new(0, 0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1998,6 +2068,49 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_vector_results_empty_input() {
|
||||
let result = super::merge_vector_results(vec![]);
|
||||
assert!(result.is_empty(), "empty input should produce empty output");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_vector_results_keeps_best_score_per_document() {
|
||||
let doc = DocumentId::new(0, 0);
|
||||
let result = super::merge_vector_results(vec![(doc, 0.2), (doc, 0.9)]);
|
||||
assert_eq!(result.len(), 1, "a document must not be double-counted");
|
||||
assert_eq!(result[0].0, doc);
|
||||
assert_eq!(
|
||||
result[0].1, 0.9,
|
||||
"dedup must keep the highest score, not the first seen"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_vector_results_sorts_globally_by_descending_score() {
|
||||
let doc_a = DocumentId::new(0, 0);
|
||||
let doc_b = DocumentId::new(1, 0);
|
||||
let doc_c = DocumentId::new(2, 0);
|
||||
// Interleaved as two per-chunk hit lists would arrive: concatenating them
|
||||
// would yield a, c, b — only a global sort produces c, a, b.
|
||||
let result = super::merge_vector_results(vec![(doc_a, 0.5), (doc_c, 0.9), (doc_b, 0.1)]);
|
||||
let ids: Vec<DocumentId> = result.iter().map(|(id, _)| *id).collect();
|
||||
assert_eq!(ids, vec![doc_c, doc_a, doc_b]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_vector_results_does_not_truncate() {
|
||||
let input: Vec<(DocumentId, f32)> = (0..10)
|
||||
.map(|i| (DocumentId::new(i, 0), i as f32 / 10.0))
|
||||
.collect();
|
||||
let result = super::merge_vector_results(input);
|
||||
assert_eq!(
|
||||
result.len(),
|
||||
10,
|
||||
"merging must not cap the pool; truncation belongs to reciprocal_rank_fusion"
|
||||
);
|
||||
}
|
||||
|
||||
fn hash_skip_fixture() -> (IndexMap<FileId, RagFile>, IndexMap<String, Vec<FileId>>) {
|
||||
let mut files: IndexMap<FileId, RagFile> = Default::default();
|
||||
files.insert(
|
||||
|
||||
Reference in New Issue
Block a user