style: Cleaned up some minor styling issues
This commit is contained in:
+56
-134
@@ -1,9 +1,11 @@
|
||||
use crate::rag::provider::RagProvider;
|
||||
use crate::rag::{DocumentId, RagData};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use anyhow::{Context, Result, anyhow, bail};
|
||||
use async_trait::async_trait;
|
||||
use duckdb::Connection;
|
||||
use duckdb::types::Value;
|
||||
use indexmap::IndexMap;
|
||||
use log::warn;
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -136,9 +138,6 @@ impl DuckDbProvider {
|
||||
pub(crate) fn read_all_vectors(&self) -> Result<IndexMap<DocumentId, Vec<f32>>> {
|
||||
let conn = self.lock_conn()?;
|
||||
let mut stmt = conn.prepare("SELECT doc_id, embedding FROM vectors")?;
|
||||
// Collect into a Result, NOT a filter_map. `.filter_map(|r| r.ok())` here would
|
||||
// turn a systematic decode failure (e.g. a schema written by a different duckdb
|
||||
// version) into a silently short map, indistinguishable from an empty store.
|
||||
let raw: Vec<(u64, Vec<f32>)> = stmt
|
||||
.query_map([], |row| {
|
||||
let id: u64 = row.get(0)?;
|
||||
@@ -146,12 +145,12 @@ impl DuckDbProvider {
|
||||
// column is read as a `Value` and destructured. A FLOAT[N] column yields
|
||||
// `Value::Array`, a FLOAT[] column yields `Value::List`; match both so
|
||||
// the reader survives a file written under either schema.
|
||||
let embedding: Vec<f32> = match row.get::<_, duckdb::types::Value>(1)? {
|
||||
duckdb::types::Value::Array(vals) | duckdb::types::Value::List(vals) => vals
|
||||
let embedding: Vec<f32> = match row.get::<_, Value>(1)? {
|
||||
Value::Array(vals) | Value::List(vals) => vals
|
||||
.into_iter()
|
||||
.map(|v| match v {
|
||||
duckdb::types::Value::Float(f) => f,
|
||||
duckdb::types::Value::Double(d) => d as f32,
|
||||
Value::Float(f) => f,
|
||||
Value::Double(d) => d as f32,
|
||||
_ => f32::NAN,
|
||||
})
|
||||
.collect(),
|
||||
@@ -184,6 +183,7 @@ impl DuckDbProvider {
|
||||
}
|
||||
out.insert(DocumentId(id as usize), embedding);
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
@@ -192,11 +192,10 @@ impl DuckDbProvider {
|
||||
///
|
||||
/// A schema-existence check alone is NOT sufficient. After
|
||||
/// `CREATE OR REPLACE TABLE documents`, the `fts_main_documents` schema still
|
||||
/// exists and `match_bm25` still SUCCEEDS — but returns zero rows for every term.
|
||||
/// exists and `match_bm25` still SUCCEEDS, but returns zero rows for every term.
|
||||
/// The index is silently dead. The probe therefore asserts that a KNOWN row comes
|
||||
/// back rather than merely that the call did not error.
|
||||
fn probe_fts_index(conn: &Connection) -> bool {
|
||||
// 1. Structural check — cheap, and short-circuits a never-built index.
|
||||
let schema_exists = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM duckdb_schemas() WHERE schema_name = 'fts_main_documents'",
|
||||
@@ -208,11 +207,6 @@ impl DuckDbProvider {
|
||||
if !schema_exists {
|
||||
return false;
|
||||
}
|
||||
// 2. Liveness check — pull a real token out of a real row and confirm the index
|
||||
// scores that same row. A live index returns >= 1; a stale one returns 0
|
||||
// without erroring. An empty `documents` table cannot be probed, and
|
||||
// reporting false is correct: there is nothing to keyword-search, and the
|
||||
// next rebuild_indexes sets the flag directly.
|
||||
conn.query_row(
|
||||
"SELECT COUNT(*) FROM documents d
|
||||
WHERE fts_main_documents.match_bm25(
|
||||
@@ -250,7 +244,6 @@ impl RagProvider for DuckDbProvider {
|
||||
top_k: usize,
|
||||
min_score: f32,
|
||||
) -> Result<Vec<(DocumentId, f32)>> {
|
||||
// Validate before building the SQL literal — a NaN would produce malformed SQL.
|
||||
if embedding.iter().any(|f| !f.is_finite()) {
|
||||
bail!("Query embedding contains a non-finite value (NaN or infinity)");
|
||||
}
|
||||
@@ -276,11 +269,11 @@ impl RagProvider for DuckDbProvider {
|
||||
let id: u64 = row.get(0)?;
|
||||
// `array_cosine_distance` on a FLOAT[N] column returns FLOAT (f32), NOT
|
||||
// DOUBLE. Reading it as f64 raises InvalidColumnType INSIDE the closure,
|
||||
// which a bare `.filter_map(|r| r.ok())` would silently discard —
|
||||
// which a bare `.filter_map(|r| r.ok())` would silently discard,
|
||||
// yielding ZERO results with no error and no log line.
|
||||
let distance: f32 = match row.get::<_, duckdb::types::Value>(1)? {
|
||||
duckdb::types::Value::Float(f) => f,
|
||||
duckdb::types::Value::Double(d) => d as f32,
|
||||
let distance: f32 = match row.get::<_, Value>(1)? {
|
||||
Value::Float(f) => f,
|
||||
Value::Double(d) => d as f32,
|
||||
other => {
|
||||
warn!("unexpected distance type from DuckDB: {other:?}");
|
||||
return Err(duckdb::Error::InvalidQuery);
|
||||
@@ -299,6 +292,7 @@ impl RagProvider for DuckDbProvider {
|
||||
})
|
||||
.filter(|(_, score)| *score > min_score)
|
||||
.collect();
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
@@ -310,10 +304,7 @@ impl RagProvider for DuckDbProvider {
|
||||
let placeholders = ids.iter().map(|_| "?").collect::<Vec<_>>().join(", ");
|
||||
let sql =
|
||||
format!("SELECT doc_id, page_content FROM documents WHERE doc_id IN ({placeholders})");
|
||||
let params: Vec<duckdb::types::Value> = ids
|
||||
.iter()
|
||||
.map(|id| duckdb::types::Value::UBigInt(id.0 as u64))
|
||||
.collect();
|
||||
let params: Vec<Value> = ids.iter().map(|id| Value::UBigInt(id.0 as u64)).collect();
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let mut rows: Vec<(DocumentId, String)> = stmt
|
||||
.query_map(duckdb::params_from_iter(params.iter()), |row| {
|
||||
@@ -329,22 +320,21 @@ impl RagProvider for DuckDbProvider {
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
// Ordering contract: `WHERE doc_id IN (...)` returns rows in storage order, NOT
|
||||
// in the order of `ids`. Since `ids` is the RRF-ranked list, returning storage
|
||||
// order would silently discard the ranking. Re-sort by input position.
|
||||
let position: std::collections::HashMap<DocumentId, usize> =
|
||||
let position: HashMap<DocumentId, usize> =
|
||||
ids.iter().enumerate().map(|(i, id)| (*id, i)).collect();
|
||||
|
||||
rows.sort_by_key(|(id, _)| position.get(id).copied().unwrap_or(usize::MAX));
|
||||
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
async fn rebuild_indexes(&mut self, data: &RagData, _full_rebuild: bool) -> Result<()> {
|
||||
// Local on-disk state — a wholesale table reset plus re-INSERT is always
|
||||
// Local on-disk state. A wholesale table reset plus re-INSERT is always
|
||||
// correct, so the incremental/full distinction is ignored.
|
||||
//
|
||||
// 🔴 ANTI-WIPE GUARD. The CREATE OR REPLACE TABLE below writes exactly what
|
||||
// `data.vectors` holds. An empty map on a RAG that HAS indexed files, against a
|
||||
// store that ALREADY holds vectors, is always a bug — a failed/skipped
|
||||
// store that ALREADY holds vectors, is always a bug; a failed/skipped
|
||||
// hydration, or a caller that emptied the live map. Refuse rather than commit
|
||||
// the loss. Every legitimate empty-vector rebuild also has an empty `files`
|
||||
// (a fresh RAG; the zero-document tests), so this cannot fire on a correct call.
|
||||
@@ -353,7 +343,7 @@ impl RagProvider for DuckDbProvider {
|
||||
if data.vectors.is_empty() && !data.files.is_empty() {
|
||||
let existing: i64 = {
|
||||
// Scoped: the guard MUST be dropped before `lock_conn()` is taken again
|
||||
// below. `Mutex` is not reentrant — holding both self-deadlocks at
|
||||
// below. `Mutex` is not reentrant; holding both self-deadlocks at
|
||||
// runtime, with no compile error.
|
||||
let conn = self.lock_conn()?;
|
||||
conn.query_row("SELECT count(*) FROM vectors", [], |r| r.get(0))
|
||||
@@ -382,7 +372,6 @@ impl RagProvider for DuckDbProvider {
|
||||
}
|
||||
}
|
||||
let dim = self.dim;
|
||||
// `Connection::transaction()` takes `&mut self`, so this binding must be `mut`.
|
||||
let mut conn = self.lock_conn()?;
|
||||
let tx = conn
|
||||
.transaction()
|
||||
@@ -408,7 +397,7 @@ impl RagProvider for DuckDbProvider {
|
||||
|
||||
{
|
||||
// Plain INSERT: tables are empty after CREATE OR REPLACE, so no PK conflict.
|
||||
// The embedding is bound as TEXT and cast in SQL — the duckdb crate's
|
||||
// The embedding is bound as TEXT and cast in SQL. The duckdb crate's
|
||||
// `bind_parameter` has no arm for List/Array, so binding a vector directly
|
||||
// fails at runtime with "binding List parameters is not yet supported".
|
||||
let mut vstmt = tx.prepare(&format!(
|
||||
@@ -418,12 +407,12 @@ impl RagProvider for DuckDbProvider {
|
||||
tx.prepare("INSERT INTO documents (doc_id, page_content) VALUES (?, ?)")?;
|
||||
|
||||
// 🔴 TWO INDEPENDENT LOOPS. `documents` is keyed on `files`, `vectors` on
|
||||
// `vectors` — they are DIFFERENT key sets and neither is a subset of the
|
||||
// `vectors`. They are DIFFERENT key sets and neither is a subset of the
|
||||
// other. Do not merge these into one loop over `&data.vectors` gated on a
|
||||
// lookup into `files`: that produces keys(documents) ⊆ keys(vectors), and
|
||||
// every id in `files \ vectors` (which `RagData::add`'s zip truncation
|
||||
// really does produce) becomes a live, rankable id from BM25 and
|
||||
// graph_search that resolves to nothing — no error, no log line.
|
||||
// graph_search that resolves to nothing; no error, no log line.
|
||||
for (id, doc) in data.iter_documents() {
|
||||
dstmt.execute(duckdb::params![id.0 as u64, doc.page_content.as_str()])?;
|
||||
}
|
||||
@@ -461,7 +450,7 @@ impl RagProvider for DuckDbProvider {
|
||||
|
||||
// Rebuild the FTS index (must be outside the transaction). This rebuild is
|
||||
// MANDATORY on every pass, not an optimization: CREATE OR REPLACE TABLE above
|
||||
// leaves the old fts_main_documents schema in place but DEAD — match_bm25 keeps
|
||||
// leaves the old fts_main_documents schema in place but DEAD; match_bm25 keeps
|
||||
// succeeding while returning zero rows for every term.
|
||||
// GUARDED on a non-empty table: FTS cannot index an empty table, and
|
||||
// rebuild_indexes is legitimately called with zero documents (a fresh RAG, and
|
||||
@@ -480,7 +469,7 @@ impl RagProvider for DuckDbProvider {
|
||||
|
||||
// Live only if an index was actually built. With zero documents there is no FTS
|
||||
// index, so `has_native_keyword_search()` must stay false and hybrid_search must
|
||||
// fall back to local BM25 — which is also empty, and therefore correct.
|
||||
// fall back to local BM25, which is also empty, and therefore correct.
|
||||
self.fts_ready.store(doc_count > 0, Ordering::Relaxed);
|
||||
|
||||
Ok(())
|
||||
@@ -501,11 +490,11 @@ impl RagProvider for DuckDbProvider {
|
||||
let id: u64 = row.get(0)?;
|
||||
// Same hazard as vector_search's distance column: guessing the width
|
||||
// wrong raises InvalidColumnType INSIDE the closure, which a bare
|
||||
// `.filter_map(|r| r.ok())` silently discards — yielding ZERO keyword
|
||||
// `.filter_map(|r| r.ok())` silently discards, yielding ZERO keyword
|
||||
// hits, indistinguishable from "the query matched nothing".
|
||||
let score: f32 = match row.get::<_, duckdb::types::Value>(1)? {
|
||||
duckdb::types::Value::Double(d) => d as f32,
|
||||
duckdb::types::Value::Float(f) => f,
|
||||
let score: f32 = match row.get::<_, Value>(1)? {
|
||||
Value::Double(d) => d as f32,
|
||||
Value::Float(f) => f,
|
||||
other => {
|
||||
warn!("unexpected match_bm25 score type from DuckDB: {other:?}");
|
||||
return Err(duckdb::Error::InvalidQuery);
|
||||
@@ -543,7 +532,7 @@ impl RagProvider for DuckDbProvider {
|
||||
// This means DuckDbProvider clones are NOT independent snapshots: state lives on
|
||||
// disk and a rebuild through one handle is immediately visible to the other.
|
||||
// That is unavoidable for any on-disk store and is handled by the discipline
|
||||
// documented on `Rag`'s Clone impl — the pre-clone instance must be discarded.
|
||||
// documented on `Rag`'s Clone impl, the pre-clone instance must be discarded.
|
||||
Box::new(DuckDbProvider {
|
||||
path: self.path.clone(),
|
||||
conn: Arc::clone(&self.conn),
|
||||
@@ -556,44 +545,37 @@ impl RagProvider for DuckDbProvider {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
// Trait methods are only callable with the trait in scope.
|
||||
use crate::rag::provider::RagProvider;
|
||||
// `RagFile` / `RagDocument` have private fields; struct-literal construction
|
||||
// compiles only because this module is a descendant of `crate::rag`. They are
|
||||
// deliberately not in the non-test `use` block — unused there, and `--deny warnings`
|
||||
// rejects that.
|
||||
use crate::rag::{RagDocument, RagFile};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use std::{env, fs};
|
||||
|
||||
/// Unique temp path per test. `tempfile` is not a dev-dependency; this mirrors the
|
||||
/// house pattern used elsewhere in the tree.
|
||||
struct TempDb {
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl TempDb {
|
||||
fn new(tag: &str) -> Self {
|
||||
let unique = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
let unique = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let path = std::env::temp_dir().join(format!("coyote-duckdb-{tag}-{unique}.duckdb"));
|
||||
let path = env::temp_dir().join(format!("coyote-duckdb-{tag}-{unique}.duckdb"));
|
||||
Self { path }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TempDb {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_file(&self.path);
|
||||
// DuckDB writes a `<path>.wal` sidecar; remove it too or /tmp accumulates
|
||||
// one per test run.
|
||||
let _ = std::fs::remove_file(self.path.with_extension("duckdb.wal"));
|
||||
let _ = fs::remove_file(&self.path);
|
||||
let _ = fs::remove_file(self.path.with_extension("duckdb.wal"));
|
||||
}
|
||||
}
|
||||
|
||||
/// ⚠️ NOTE THE EMPTY `files`. This fixture describes a RAG with ZERO documents.
|
||||
/// Since `rebuild_indexes` populates the `documents` table from
|
||||
/// `data.iter_documents()` (keyed on `files`), any test built on this helper alone
|
||||
/// exercises the `vectors` table and NOTHING ELSE — no `documents` rows, no FTS
|
||||
/// exercises the `vectors` table and NOTHING ELSE. No `documents` rows, no FTS
|
||||
/// index. That is correct for the rebuild tests below, and is exactly why the
|
||||
/// FTS-flag test and the `fetch_content` tests use `populated_rag_data()` instead.
|
||||
/// Do not "simplify" them back onto this helper.
|
||||
@@ -609,7 +591,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Two files, one document each — the minimum fixture that produces `documents`
|
||||
/// Two files, one document each; the minimum fixture that produces `documents`
|
||||
/// rows.
|
||||
fn populated_rag_data() -> RagData {
|
||||
let mut data = minimal_rag_data();
|
||||
@@ -644,12 +626,14 @@ mod tests {
|
||||
let db = TempDb::new("schema");
|
||||
let provider = DuckDbProvider::open(&db.path, 3).unwrap();
|
||||
let conn = provider.conn.lock().unwrap();
|
||||
|
||||
let v: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM vectors", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
let d: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM documents", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(v, 0);
|
||||
assert_eq!(d, 0);
|
||||
}
|
||||
@@ -668,10 +652,12 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let results = provider
|
||||
.vector_search(&[0.1, 0.2, 0.3], 5, 0.0)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0].0.0, 0);
|
||||
assert!(results[0].1 > 0.99);
|
||||
@@ -689,7 +675,9 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let results = provider.fetch_content(&[DocumentId(42)]).await.unwrap();
|
||||
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0].1, "hello world");
|
||||
}
|
||||
@@ -700,19 +688,15 @@ mod tests {
|
||||
let mut provider = DuckDbProvider::open(&db.path, 3).unwrap();
|
||||
let data = minimal_rag_data();
|
||||
provider.rebuild_indexes(&data, true).await.unwrap();
|
||||
|
||||
let results = provider
|
||||
.vector_search(&[0.1, 0.2, 0.3], 5, 0.0)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(results.is_empty()); // no vectors in empty data
|
||||
|
||||
assert!(results.is_empty());
|
||||
}
|
||||
|
||||
/// Rebuilding TWICE must succeed.
|
||||
///
|
||||
/// The first rebuild always passes because the tables start empty. The bug this
|
||||
/// guards against — `DELETE FROM` plus re-INSERT of the same PKs inside one
|
||||
/// transaction — only fires on the SECOND rebuild, with "Duplicate key ... violates
|
||||
/// primary key constraint". A single-rebuild test cannot catch it.
|
||||
#[tokio::test]
|
||||
async fn rebuild_indexes_is_idempotent_across_two_passes() {
|
||||
let db = TempDb::new("idempotent");
|
||||
@@ -722,7 +706,7 @@ mod tests {
|
||||
data.vectors.insert(DocumentId(1), vec![0.4, 0.5, 0.6]);
|
||||
|
||||
provider.rebuild_indexes(&data, true).await.unwrap();
|
||||
// Second pass over the SAME doc_ids — this is the assertion that matters.
|
||||
|
||||
provider
|
||||
.rebuild_indexes(&data, true)
|
||||
.await
|
||||
@@ -735,27 +719,16 @@ mod tests {
|
||||
assert_eq!(count, 2, "rebuild must replace rows, not duplicate them");
|
||||
}
|
||||
|
||||
/// Guards the incremental data-loss bug: `sync_documents` hash-skips unchanged
|
||||
/// files, so on an incremental pass `data.vectors` holds ONLY the newly embedded
|
||||
/// chunks. If the live map is ever emptied, the `CREATE OR REPLACE TABLE` in
|
||||
/// rebuild_indexes writes only those, destroying every previously indexed vector.
|
||||
/// Hydration via `read_all_vectors()` is what prevents it.
|
||||
///
|
||||
/// This simulates the real restart-then-edit sequence: build, drop the in-memory
|
||||
/// map, rehydrate from disk, add one vector, rebuild incrementally.
|
||||
#[tokio::test]
|
||||
async fn rebuild_indexes_preserves_prior_vectors_on_incremental_pass() {
|
||||
let db = TempDb::new("incremental");
|
||||
let mut provider = DuckDbProvider::open(&db.path, 3).unwrap();
|
||||
|
||||
// Pass 1 — initial full build with two vectors.
|
||||
let mut data = minimal_rag_data();
|
||||
data.vectors.insert(DocumentId(0), vec![0.1, 0.2, 0.3]);
|
||||
data.vectors.insert(DocumentId(1), vec![0.4, 0.5, 0.6]);
|
||||
provider.rebuild_indexes(&data, true).await.unwrap();
|
||||
|
||||
// Simulate a restart: the YAML on disk carries NO vectors, so a fresh RagData
|
||||
// starts empty. create() hydrates it back from the sidecar.
|
||||
let mut reloaded = minimal_rag_data();
|
||||
assert!(
|
||||
reloaded.vectors.is_empty(),
|
||||
@@ -768,7 +741,6 @@ mod tests {
|
||||
"hydration must restore both vectors"
|
||||
);
|
||||
|
||||
// Pass 2 — incremental add of ONE new chunk.
|
||||
reloaded.vectors.insert(DocumentId(2), vec![0.7, 0.8, 0.9]);
|
||||
provider.rebuild_indexes(&reloaded, false).await.unwrap();
|
||||
|
||||
@@ -783,14 +755,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// `has_native_keyword_search()` must be false before any FTS index exists,
|
||||
/// otherwise hybrid_search routes into keyword_search and the `?` turns a DuckDB
|
||||
/// catalog error into a total query failure.
|
||||
///
|
||||
/// 🔴 THE FIXTURE MUST CARRY DOCUMENTS. `rebuild_indexes` builds the FTS index only
|
||||
/// when the `documents` table is non-empty, and that table is filled from
|
||||
/// `data.iter_documents()` (keyed on `files`), never from `vectors`. With `files`
|
||||
/// empty the pragma is skipped and `fts_ready` stores `false`.
|
||||
#[tokio::test]
|
||||
async fn keyword_search_is_not_advertised_before_first_rebuild() {
|
||||
let db = TempDb::new("ftsflag");
|
||||
@@ -800,13 +764,10 @@ mod tests {
|
||||
"a freshly opened DB has no FTS index yet"
|
||||
);
|
||||
|
||||
// 2 files × 1 document → 2 `documents` rows → doc_count > 0 → pragma runs.
|
||||
let mut data = populated_rag_data();
|
||||
data.vectors.insert(DocumentId(0), vec![0.1, 0.2, 0.3]);
|
||||
provider.rebuild_indexes(&data, true).await.unwrap();
|
||||
{
|
||||
// Pin the precondition explicitly. If this ever reads 0 the assertion below
|
||||
// is vacuous and the FTS hazard is unguarded again.
|
||||
let conn = provider.conn.lock().unwrap();
|
||||
let docs: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM documents", [], |r| r.get(0))
|
||||
@@ -819,23 +780,11 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The `documents` table is keyed on `files`, NOT on `vectors`.
|
||||
///
|
||||
/// `fetch_content` is the sink for BOTH the BM25 and graph_search paths, and both
|
||||
/// enumerate `files` via `iter_documents()`. If `rebuild_indexes` only inserts a
|
||||
/// documents row for ids that also appear in `data.vectors`, every id in
|
||||
/// `files \ vectors` resolves to nothing: healthy BM25 scores, healthy graph hits,
|
||||
/// zero results, no error.
|
||||
///
|
||||
/// The incremental test CANNOT catch this — its fixture has an empty `files`, so the
|
||||
/// documents table is empty in every assertion either way.
|
||||
#[tokio::test]
|
||||
async fn fetch_content_resolves_documents_without_vectors() {
|
||||
let db = TempDb::new("docsnovec");
|
||||
let mut provider = DuckDbProvider::open(&db.path, 3).unwrap();
|
||||
|
||||
// Two files, one document each — but a vector for ONLY THE FIRST. This is the
|
||||
// `RagData::add` zip-truncation shape.
|
||||
let mut data = populated_rag_data();
|
||||
let id_a = DocumentId::new(0, 0);
|
||||
let id_b = DocumentId::new(1, 0);
|
||||
@@ -858,13 +807,6 @@ mod tests {
|
||||
assert_eq!(got[1].1, "beta keyword");
|
||||
}
|
||||
|
||||
/// `fetch_content` must return rows in INPUT order, not storage order.
|
||||
///
|
||||
/// `YamlProvider` satisfies this contract structurally — it maps over `ids` — so the
|
||||
/// existing yaml test proves nothing about DuckDB. DuckDB's `WHERE doc_id IN (...)`
|
||||
/// returns storage order and relies on an explicit positional re-sort, which is what
|
||||
/// can actually regress. `ids` is the RRF-ranked list, so losing the order silently
|
||||
/// discards the ranking while still returning the right documents.
|
||||
#[tokio::test]
|
||||
async fn duckdb_fetch_content_preserves_input_order() {
|
||||
let db = TempDb::new("order");
|
||||
@@ -872,11 +814,9 @@ mod tests {
|
||||
let data = populated_rag_data();
|
||||
provider.rebuild_indexes(&data, true).await.unwrap();
|
||||
|
||||
let id_a = DocumentId::new(0, 0); // inserted first → storage order 0
|
||||
let id_b = DocumentId::new(1, 0); // inserted second → storage order 1
|
||||
let id_a = DocumentId::new(0, 0);
|
||||
let id_b = DocumentId::new(1, 0);
|
||||
|
||||
// REVERSED relative to storage order. Without the positional re-sort this
|
||||
// returns ["alpha keyword", "beta keyword"] and the assertion fails.
|
||||
let got = provider.fetch_content(&[id_b, id_a]).await.unwrap();
|
||||
assert_eq!(got.len(), 2);
|
||||
assert_eq!(got[0].0, id_b, "input order must win over storage order");
|
||||
@@ -885,19 +825,11 @@ mod tests {
|
||||
assert_eq!(got[1].1, "alpha keyword");
|
||||
}
|
||||
|
||||
/// The anti-wipe guard.
|
||||
///
|
||||
/// `rebuild_indexes` does `CREATE OR REPLACE TABLE` and writes exactly what
|
||||
/// `data.vectors` holds. An empty map on a RAG that HAS indexed files, against a
|
||||
/// store that already holds vectors, is always a bug (failed hydration, or a caller
|
||||
/// that emptied the live map) and must be refused rather than committed. The loss
|
||||
/// would otherwise be permanent: nothing re-embeds it back.
|
||||
#[tokio::test]
|
||||
async fn rebuild_indexes_refuses_to_wipe_when_vectors_are_empty_but_files_are_not() {
|
||||
let db = TempDb::new("nowipe");
|
||||
let mut provider = DuckDbProvider::open(&db.path, 3).unwrap();
|
||||
|
||||
// A healthy store: two files with documents, two vectors.
|
||||
let mut data = populated_rag_data();
|
||||
data.vectors
|
||||
.insert(DocumentId::new(0, 0), vec![0.1, 0.2, 0.3]);
|
||||
@@ -905,8 +837,7 @@ mod tests {
|
||||
.insert(DocumentId::new(1, 0), vec![0.4, 0.5, 0.6]);
|
||||
provider.rebuild_indexes(&data, true).await.unwrap();
|
||||
|
||||
// Now the failure state: vectors lost in memory, files intact.
|
||||
let broken = populated_rag_data(); // same files, NO vectors
|
||||
let broken = populated_rag_data();
|
||||
assert!(
|
||||
broken.vectors.is_empty() && !broken.files.is_empty(),
|
||||
"precondition"
|
||||
@@ -918,7 +849,6 @@ mod tests {
|
||||
"got: {err}"
|
||||
);
|
||||
|
||||
// The store must be untouched — this is the whole point.
|
||||
let conn = provider.conn.lock().unwrap();
|
||||
let count: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM vectors", [], |r| r.get(0))
|
||||
@@ -929,31 +859,23 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The guard must NOT fire on a legitimately empty RAG — otherwise a fresh
|
||||
/// `Rag::init()` cannot complete. Empty `vectors` AND empty `files` is fine.
|
||||
#[tokio::test]
|
||||
async fn rebuild_indexes_allows_empty_vectors_when_files_are_also_empty() {
|
||||
let db = TempDb::new("emptyok");
|
||||
let mut provider = DuckDbProvider::open(&db.path, 3).unwrap();
|
||||
let data = minimal_rag_data(); // no files, no vectors
|
||||
let data = minimal_rag_data();
|
||||
provider
|
||||
.rebuild_indexes(&data, true)
|
||||
.await
|
||||
.expect("a fresh RAG with nothing indexed must rebuild cleanly");
|
||||
}
|
||||
|
||||
/// `duplicate()` must clone the Arc, NOT call `open()` again.
|
||||
///
|
||||
/// This asserts SHARED state, which is the actual contract. It deliberately does NOT
|
||||
/// use `fetch_content(&[])` — that early-returns before ever touching the
|
||||
/// connection, so it would pass even against a broken double-opening implementation.
|
||||
#[tokio::test]
|
||||
async fn duplicate_shares_the_same_connection() {
|
||||
let db = TempDb::new("dup");
|
||||
let provider = DuckDbProvider::open(&db.path, 3).unwrap();
|
||||
let dup = provider.duplicate(&minimal_rag_data());
|
||||
|
||||
// Write through the ORIGINAL...
|
||||
{
|
||||
let conn = provider.conn.lock().unwrap();
|
||||
conn.execute(
|
||||
@@ -962,8 +884,9 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
// ...and read it back through the DUPLICATE. Only possible if they share state.
|
||||
|
||||
let via_dup = dup.fetch_content(&[DocumentId(7)]).await.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
via_dup.len(),
|
||||
1,
|
||||
@@ -972,8 +895,6 @@ mod tests {
|
||||
assert_eq!(via_dup[0].1, "shared row");
|
||||
}
|
||||
|
||||
/// A decode failure must abort hydration rather than yield a thinned map: a short
|
||||
/// map is what the next CREATE OR REPLACE commits as the new truth.
|
||||
#[tokio::test]
|
||||
async fn read_all_vectors_rejects_non_finite_embeddings() {
|
||||
let db = TempDb::new("nonfinite");
|
||||
@@ -1005,6 +926,7 @@ mod tests {
|
||||
#[test]
|
||||
fn duckdb_path_from_yaml_swaps_extension() {
|
||||
let p = duckdb_path_from_yaml(Path::new("/tmp/rags/docs.yaml"));
|
||||
|
||||
assert_eq!(p, PathBuf::from("/tmp/rags/docs.duckdb"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,8 @@
|
||||
mod yaml;
|
||||
// Use `self::` on every re-export in this file. Once a `mod duckdb;` sits here
|
||||
// alongside a dependency on the `duckdb` CRATE, a bare `pub use duckdb::...`
|
||||
// is ambiguous (E0659) — `use` paths resolve against both this module's items
|
||||
// and the extern prelude, and `use` declarations may not shadow.
|
||||
pub use self::yaml::YamlProvider;
|
||||
|
||||
mod duckdb;
|
||||
pub use self::duckdb::DuckDbProvider;
|
||||
// `create()` in rag/mod.rs derives the sidecar path through this. It is `pub(crate)`
|
||||
// in providers/duckdb.rs, and the re-export must be `pub(crate)` too — a `pub use` of
|
||||
// a `pub(crate)` item is E0364/E0365.
|
||||
pub(crate) use self::duckdb::duckdb_path_from_yaml;
|
||||
|
||||
mod qdrant;
|
||||
|
||||
+46
-45
@@ -3,6 +3,9 @@ use crate::rag::{DocumentId, RagData};
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use async_trait::async_trait;
|
||||
use reqwest::header::{HeaderMap, HeaderValue};
|
||||
use reqwest::{Client, Response, StatusCode};
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Render Qdrant's error envelope into a human-readable message.
|
||||
@@ -15,22 +18,18 @@ use std::collections::HashMap;
|
||||
/// * routing-level 404s (a wrong HTTP verb) return an EMPTY body with no JSON at
|
||||
/// all, which without the length check surfaces as "EOF while parsing a value"
|
||||
/// instead of the actual 404.
|
||||
fn format_error_body(status: reqwest::StatusCode, body: &str) -> String {
|
||||
fn format_error_body(status: StatusCode, body: &str) -> String {
|
||||
if body.is_empty() {
|
||||
return format!("HTTP {status} (empty body — check the HTTP verb and path)");
|
||||
}
|
||||
serde_json::from_str::<serde_json::Value>(body)
|
||||
serde_json::from_str::<Value>(body)
|
||||
.ok()
|
||||
.and_then(|v| v["status"]["error"].as_str().map(str::to_string))
|
||||
.unwrap_or_else(|| format!("HTTP {status}: {body}"))
|
||||
}
|
||||
|
||||
/// Read the vector dimension out of a parsed `GET /collections/{name}` response.
|
||||
///
|
||||
/// Unnamed collections put `size` directly under `vectors`; named ones nest it
|
||||
/// under the vector's name. Both shapes occur in the wild, so try the flat one
|
||||
/// first and fall back to the first named entry.
|
||||
fn vector_dimension_from_collection(body: &serde_json::Value) -> Result<u64> {
|
||||
fn vector_dimension_from_collection(body: &Value) -> Result<u64> {
|
||||
let params = &body["result"]["config"]["params"];
|
||||
params["vectors"]["size"]
|
||||
.as_u64()
|
||||
@@ -47,12 +46,12 @@ fn vector_dimension_from_collection(body: &serde_json::Value) -> Result<u64> {
|
||||
/// (multi-vector) collection.
|
||||
///
|
||||
/// `vector_search` posts an unnamed vector, which a named-vector collection
|
||||
/// rejects with HTTP 400 on every query — so attaching one yields a RAG that is
|
||||
/// rejects with HTTP 400 on every query, so attaching one yields a RAG that is
|
||||
/// silently 100% broken. A named collection holding a SINGLE vector is
|
||||
/// structurally a map, identical in kind to the multi-named case, and rejects
|
||||
/// the same way; testing for a numeric `size` directly under `vectors` catches
|
||||
/// it, whereas counting keys (`len() > 1`) would wrongly accept it.
|
||||
fn is_multi_vector_config(body: &serde_json::Value) -> bool {
|
||||
fn is_multi_vector_config(body: &Value) -> bool {
|
||||
body["result"]["config"]["params"]["vectors"]["size"]
|
||||
.as_u64()
|
||||
.is_none()
|
||||
@@ -63,27 +62,21 @@ fn is_multi_vector_config(body: &serde_json::Value) -> bool {
|
||||
/// Attach-only: this provider never writes to the remote collection. Coyote does
|
||||
/// not own the data, and `rebuild_indexes` refuses rather than pretending to.
|
||||
pub struct QdrantProvider {
|
||||
/// `reqwest::Client` is Arc-backed, so `clone()` is O(1) and shares both the
|
||||
/// connection pool and the `api-key` default header injected at build time.
|
||||
client: reqwest::Client,
|
||||
/// Includes the scheme, e.g. `http://qdrant.example.com:6333`.
|
||||
client: Client,
|
||||
base_url: String,
|
||||
collection: String,
|
||||
}
|
||||
|
||||
impl QdrantProvider {
|
||||
/// The resolved API key is injected as a default header here and is
|
||||
/// deliberately NOT stored on the struct: the plaintext value stays a local
|
||||
/// of the caller and never outlives it.
|
||||
fn make_client(api_key: Option<&str>) -> Result<reqwest::Client> {
|
||||
let mut headers = reqwest::header::HeaderMap::new();
|
||||
fn make_client(api_key: Option<&str>) -> Result<Client> {
|
||||
let mut headers = HeaderMap::new();
|
||||
if let Some(key) = api_key {
|
||||
let mut value = reqwest::header::HeaderValue::from_str(key)
|
||||
.context("api-key header value is not valid ASCII")?;
|
||||
let mut value =
|
||||
HeaderValue::from_str(key).context("api-key header value is not valid ASCII")?;
|
||||
value.set_sensitive(true);
|
||||
headers.insert("api-key", value);
|
||||
}
|
||||
reqwest::Client::builder()
|
||||
Client::builder()
|
||||
.default_headers(headers)
|
||||
.build()
|
||||
.context("Failed to build reqwest client")
|
||||
@@ -97,7 +90,7 @@ impl QdrantProvider {
|
||||
}
|
||||
}
|
||||
|
||||
async fn error_message(resp: reqwest::Response) -> String {
|
||||
async fn error_message(resp: Response) -> String {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
format_error_body(status, &body)
|
||||
@@ -109,7 +102,7 @@ impl QdrantProvider {
|
||||
host: &str,
|
||||
collection: &str,
|
||||
api_key: Option<&str>,
|
||||
) -> Result<serde_json::Value> {
|
||||
) -> Result<Value> {
|
||||
let base_url = Self::normalize_base_url(host);
|
||||
let client = Self::make_client(api_key)?;
|
||||
let resp = client
|
||||
@@ -123,13 +116,13 @@ impl QdrantProvider {
|
||||
Self::error_message(resp).await
|
||||
);
|
||||
}
|
||||
|
||||
Ok(resp.json().await?)
|
||||
}
|
||||
|
||||
pub async fn new(host: &str, collection: &str, api_key: Option<&str>) -> Result<Self> {
|
||||
let base_url = Self::normalize_base_url(host);
|
||||
let client = Self::make_client(api_key)?;
|
||||
// Preflight: confirm the collection exists and we may read it.
|
||||
let resp = client
|
||||
.get(format!("{base_url}/collections/{collection}"))
|
||||
.send()
|
||||
@@ -141,6 +134,7 @@ impl QdrantProvider {
|
||||
Self::error_message(resp).await
|
||||
);
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
client,
|
||||
base_url,
|
||||
@@ -162,13 +156,15 @@ impl QdrantProvider {
|
||||
Self::error_message(resp).await
|
||||
);
|
||||
}
|
||||
let body: serde_json::Value = resp.json().await?;
|
||||
|
||||
let body: Value = resp.json().await?;
|
||||
let names = body["result"]["collections"]
|
||||
.as_array()
|
||||
.context("Unexpected /collections response shape")?
|
||||
.iter()
|
||||
.filter_map(|v| v["name"].as_str().map(str::to_string))
|
||||
.collect();
|
||||
|
||||
Ok(names)
|
||||
}
|
||||
|
||||
@@ -178,6 +174,7 @@ impl QdrantProvider {
|
||||
api_key: Option<&str>,
|
||||
) -> Result<u64> {
|
||||
let body = Self::fetch_collection(host, collection, api_key).await?;
|
||||
|
||||
vector_dimension_from_collection(&body)
|
||||
}
|
||||
|
||||
@@ -187,11 +184,10 @@ impl QdrantProvider {
|
||||
api_key: Option<&str>,
|
||||
) -> Result<bool> {
|
||||
let body = Self::fetch_collection(host, collection, api_key).await?;
|
||||
|
||||
Ok(is_multi_vector_config(&body))
|
||||
}
|
||||
|
||||
/// Peek at one point to learn how its ID is typed. Returns the raw JSON
|
||||
/// rendering, so a string ID comes back quoted and an integer one bare.
|
||||
pub async fn sample_point_id(
|
||||
host: &str,
|
||||
collection: &str,
|
||||
@@ -201,23 +197,27 @@ impl QdrantProvider {
|
||||
let client = Self::make_client(api_key)?;
|
||||
let url = format!("{base_url}/collections/{collection}/points/scroll");
|
||||
let body = serde_json::json!({ "limit": 1, "with_payload": false });
|
||||
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| format!("Failed to connect to {host}"))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
bail!(
|
||||
"Failed to sample a point from '{collection}': {}",
|
||||
Self::error_message(resp).await
|
||||
);
|
||||
}
|
||||
let data: serde_json::Value = resp.json().await?;
|
||||
|
||||
let data: Value = resp.json().await?;
|
||||
let id_val = data["result"]["points"]
|
||||
.as_array()
|
||||
.and_then(|pts| pts.first())
|
||||
.map(|pt| pt["id"].to_string());
|
||||
|
||||
Ok(id_val)
|
||||
}
|
||||
}
|
||||
@@ -251,7 +251,7 @@ impl RagProvider for QdrantProvider {
|
||||
Self::error_message(resp).await
|
||||
);
|
||||
}
|
||||
let data: serde_json::Value = resp.json().await?;
|
||||
let data: Value = resp.json().await?;
|
||||
let results = data["result"]
|
||||
.as_array()
|
||||
.context("Unexpected /points/search response shape")?
|
||||
@@ -266,6 +266,7 @@ impl RagProvider for QdrantProvider {
|
||||
})
|
||||
.filter(|(_, score)| *score > min_score)
|
||||
.collect();
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
@@ -279,7 +280,9 @@ impl RagProvider for QdrantProvider {
|
||||
"ids": id_list,
|
||||
"with_payload": true,
|
||||
});
|
||||
|
||||
let resp = self.client.post(&url).json(&body).send().await?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
bail!(
|
||||
"Qdrant point fetch on '{}' failed: {}",
|
||||
@@ -287,7 +290,7 @@ impl RagProvider for QdrantProvider {
|
||||
Self::error_message(resp).await
|
||||
);
|
||||
}
|
||||
let data: serde_json::Value = resp.json().await?;
|
||||
let data: Value = resp.json().await?;
|
||||
let mut rows: Vec<(DocumentId, String)> = data["result"]
|
||||
.as_array()
|
||||
.context("Unexpected /points response shape")?
|
||||
@@ -303,13 +306,14 @@ impl RagProvider for QdrantProvider {
|
||||
let position: HashMap<DocumentId, usize> =
|
||||
ids.iter().enumerate().map(|(i, id)| (*id, i)).collect();
|
||||
rows.sort_by_key(|(id, _)| position.get(id).copied().unwrap_or(usize::MAX));
|
||||
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
async fn rebuild_indexes(&mut self, data: &RagData, _full_rebuild: bool) -> Result<()> {
|
||||
// Both arms refuse. A silent `Ok(())` would make `.rebuild rag` and
|
||||
// `.edit rag-docs` look like they worked while writing nothing to the
|
||||
// remote — leaving the user believing the collection was updated.
|
||||
// remote, leaving the user believing the collection was updated.
|
||||
if data.attached {
|
||||
bail!(
|
||||
"This RAG is attached to an external Qdrant collection. Coyote does not own \
|
||||
@@ -340,7 +344,7 @@ mod tests {
|
||||
fn error_message_reads_the_object_status_envelope() {
|
||||
let body =
|
||||
r#"{"status": {"error": "Wrong input: Not existing vector name error:"}, "time": 0.0}"#;
|
||||
let msg = format_error_body(reqwest::StatusCode::BAD_REQUEST, body);
|
||||
let msg = format_error_body(StatusCode::BAD_REQUEST, body);
|
||||
assert!(msg.contains("Not existing vector name"), "got: {msg}");
|
||||
assert!(
|
||||
!msg.contains("EOF"),
|
||||
@@ -350,15 +354,14 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn error_message_survives_the_string_status_and_the_empty_body() {
|
||||
// Success envelope: `status` is a bare string, so the object lookup misses
|
||||
// and we must fall back rather than panic or invent an error text.
|
||||
let ok = format_error_body(reqwest::StatusCode::OK, r#"{"status": "ok", "time": 0.0}"#);
|
||||
let ok = format_error_body(StatusCode::OK, r#"{"status": "ok", "time": 0.0}"#);
|
||||
assert!(
|
||||
ok.contains("200"),
|
||||
"no `status.error` present → fall back to status+body: {ok}"
|
||||
);
|
||||
// Routing-level 404 from a wrong HTTP verb: empty body, no JSON at all.
|
||||
let empty = format_error_body(reqwest::StatusCode::NOT_FOUND, "");
|
||||
|
||||
let empty = format_error_body(StatusCode::NOT_FOUND, "");
|
||||
|
||||
assert!(empty.contains("empty body"), "got: {empty}");
|
||||
assert!(
|
||||
empty.contains("verb"),
|
||||
@@ -384,15 +387,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn is_multi_vector_rejects_the_named_single_collection() {
|
||||
// The only supported shape: a single unnamed vector.
|
||||
let unnamed = serde_json::json!({
|
||||
"result": {"config": {"params": {"vectors": {"size": 1536, "distance": "Cosine"}}}}
|
||||
});
|
||||
assert!(!is_multi_vector_config(&unnamed));
|
||||
|
||||
// Named but SINGLE — structurally a map, and writes to it fail with
|
||||
// `400 "Wrong input: Not existing vector name error:"`. A `len() > 1` check
|
||||
// would wrongly accept this one; that is the bug this case exists to catch.
|
||||
let named_single = serde_json::json!({
|
||||
"result": {"config": {"params": {"vectors": {"text": {"size": 1536}}}}}
|
||||
});
|
||||
@@ -426,7 +425,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn rebuild_indexes_refuses_for_attached_and_unattached_alike() {
|
||||
let mut provider = QdrantProvider {
|
||||
client: reqwest::Client::new(),
|
||||
client: Client::new(),
|
||||
base_url: "http://localhost:6333".to_string(),
|
||||
collection: "c".to_string(),
|
||||
};
|
||||
@@ -459,13 +458,12 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn fetch_content_short_circuits_on_an_empty_id_list() {
|
||||
// No network is touched: the early return happens before any request, which
|
||||
// is why this can assert against an unreachable host.
|
||||
let provider = QdrantProvider {
|
||||
client: reqwest::Client::new(),
|
||||
client: Client::new(),
|
||||
base_url: "http://127.0.0.1:1".to_string(),
|
||||
collection: "c".to_string(),
|
||||
};
|
||||
|
||||
assert!(provider.fetch_content(&[]).await.unwrap().is_empty());
|
||||
}
|
||||
|
||||
@@ -475,6 +473,7 @@ mod tests {
|
||||
let collections = QdrantProvider::list_collections("http://localhost:6333", None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(!collections.is_empty());
|
||||
}
|
||||
|
||||
@@ -485,7 +484,9 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
let embedding = vec![0.0f32; 1536];
|
||||
|
||||
let results = provider.vector_search(&embedding, 5, 0.0).await.unwrap();
|
||||
|
||||
assert!(results.len() <= 5);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,9 +20,6 @@ impl YamlProvider {
|
||||
}
|
||||
|
||||
fn build_content_map(data: &RagData) -> IndexMap<DocumentId, String> {
|
||||
// Keyed on `files`, NOT `vectors`: this is the exact replacement for the
|
||||
// per-id document lookup it supersedes, and it must resolve every id that
|
||||
// BM25 or graph_search can produce — both of which enumerate `files`.
|
||||
data.iter_documents()
|
||||
.map(|(id, doc)| (id, doc.page_content.clone()))
|
||||
.collect()
|
||||
@@ -52,12 +49,11 @@ impl RagProvider for YamlProvider {
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
async fn fetch_content(&self, ids: &[DocumentId]) -> Result<Vec<(DocumentId, String)>> {
|
||||
// Iterating `ids` (not `content_map`) satisfies the trait's ordering
|
||||
// contract for free — output order mirrors input order.
|
||||
Ok(ids
|
||||
.iter()
|
||||
.filter_map(|id| self.content_map.get(id).map(|text| (*id, text.clone())))
|
||||
@@ -65,10 +61,10 @@ impl RagProvider for YamlProvider {
|
||||
}
|
||||
|
||||
async fn rebuild_indexes(&mut self, data: &RagData, _full_rebuild: bool) -> Result<()> {
|
||||
// Local in-memory state — a wholesale rebuild is fast and always correct,
|
||||
// so the incremental/full distinction is irrelevant here.
|
||||
self.hnsw = data.build_hnsw();
|
||||
|
||||
self.content_map = Self::build_content_map(data);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -80,14 +76,9 @@ impl RagProvider for YamlProvider {
|
||||
#[cfg(test)]
|
||||
mod provider_tests {
|
||||
use super::*;
|
||||
// `RagFile` and `RagDocument` are not used by the impl above, so they are
|
||||
// imported here rather than at module scope. Both have private fields, which
|
||||
// is why these tests must live in-crate rather than under `tests/`.
|
||||
use crate::rag::{RagDocument, RagFile};
|
||||
|
||||
fn minimal_rag_data() -> RagData {
|
||||
// `..Default::default()` rather than an exhaustive struct literal so that
|
||||
// later additions to `RagData` do not break this helper.
|
||||
RagData {
|
||||
embedding_model: "text-embedding-3-small".to_string(),
|
||||
chunk_size: 1024,
|
||||
@@ -99,7 +90,7 @@ mod provider_tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Two files, one chunk each, with vectors — the minimum needed to exercise
|
||||
/// Two files, one chunk each, with vectors, the minimum needed to exercise
|
||||
/// `build_content_map` and the `fetch_content` ordering contract.
|
||||
/// `DocumentId::new(f, d)` packs (file_index, document_index); `RagData::add`
|
||||
/// is the real insertion path but a direct literal is sufficient and avoids
|
||||
@@ -143,13 +134,12 @@ mod provider_tests {
|
||||
async fn yaml_provider_empty_data_returns_nothing() {
|
||||
let data = minimal_rag_data();
|
||||
let provider = YamlProvider::from_data(&data);
|
||||
|
||||
let results = provider.fetch_content(&[]).await.unwrap();
|
||||
|
||||
assert!(results.is_empty());
|
||||
}
|
||||
|
||||
/// `fetch_content` MUST return results in the same relative order as the input
|
||||
/// ids. The reversed-input case is the one that fails if an implementation ever
|
||||
/// iterates its own map instead of `ids`.
|
||||
#[tokio::test]
|
||||
async fn yaml_provider_fetch_content_preserves_input_order() {
|
||||
let data = populated_rag_data();
|
||||
@@ -163,8 +153,8 @@ mod provider_tests {
|
||||
assert_eq!(forward[0].1, "alpha");
|
||||
assert_eq!(forward[1].1, "beta");
|
||||
|
||||
// Reversed input must produce reversed output — NOT storage order.
|
||||
let reversed = provider.fetch_content(&[b, a]).await.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
reversed[0].1, "beta",
|
||||
"fetch_content must honor input order"
|
||||
@@ -172,8 +162,6 @@ mod provider_tests {
|
||||
assert_eq!(reversed[1].1, "alpha");
|
||||
}
|
||||
|
||||
/// A missing id is skipped, not an error, and does not disturb the order of
|
||||
/// the ids that DO resolve.
|
||||
#[tokio::test]
|
||||
async fn yaml_provider_fetch_content_skips_missing_ids() {
|
||||
let data = populated_rag_data();
|
||||
@@ -189,23 +177,16 @@ mod provider_tests {
|
||||
assert_eq!(out[1].1, "beta");
|
||||
}
|
||||
|
||||
/// `YamlProvider::duplicate()` rebuilds from `data`, so the clone is a genuine
|
||||
/// independent snapshot. Providers backed by a shared store deliberately are not.
|
||||
#[tokio::test]
|
||||
async fn yaml_provider_duplicate_returns_equivalent_content() {
|
||||
// MUST be populated_rag_data(): on minimal_rag_data() both providers hold an
|
||||
// EMPTY content map, so `assert_eq!(r1, r2)` compares two empty vectors and
|
||||
// passes against a duplicate() that returns nothing at all.
|
||||
let data = populated_rag_data();
|
||||
let provider = YamlProvider::from_data(&data);
|
||||
let dup = provider.duplicate(&data);
|
||||
let ids = [DocumentId::new(0, 0), DocumentId::new(1, 0)];
|
||||
// Query with REAL ids, not `&[]` — an empty slice is answered without ever
|
||||
// touching the content map, so it would pass against a broken duplicate().
|
||||
|
||||
let r1 = provider.fetch_content(&ids).await.unwrap();
|
||||
let r2 = dup.fetch_content(&ids).await.unwrap();
|
||||
// Guard against the vacuous case: if both sides resolved nothing, the equality
|
||||
// below proves nothing. Assert the fixture actually produced content first.
|
||||
|
||||
assert_eq!(r1.len(), 2, "fixture must resolve both documents");
|
||||
assert_eq!(
|
||||
r1, r2,
|
||||
@@ -213,11 +194,6 @@ mod provider_tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The content store is keyed on `files`, never on `vectors`. A vector may exist
|
||||
/// for an id with no backing file (a stale entry, or a file dropped mid-sync);
|
||||
/// keying on `vectors` would surface such an id with empty text instead of
|
||||
/// dropping it. The shared fixture only ever inserts vectors for ids that also
|
||||
/// have files, so this case has to be constructed here.
|
||||
#[tokio::test]
|
||||
async fn yaml_provider_content_is_keyed_on_files_not_vectors() {
|
||||
let mut data = populated_rag_data();
|
||||
@@ -232,7 +208,6 @@ mod provider_tests {
|
||||
"an id present only in `vectors` must not resolve to content"
|
||||
);
|
||||
|
||||
// The file-backed ids still resolve, so the assertion above is not vacuous.
|
||||
let real = provider
|
||||
.fetch_content(&[DocumentId::new(0, 0), DocumentId::new(1, 0)])
|
||||
.await
|
||||
|
||||
Reference in New Issue
Block a user