Skip to content

Commit 9dba623

Browse files
committed
feat(rag): use sqlite-vec for SQLite vector search
Replace the brute-force cosine scan in the SQLite store with the sqlite-vec extension, statically compiled into the bundled SQLite and registered via sqlite3_auto_extension so every pooled connection sees it. Embeddings move out of a BLOB column on chunks into a vec0 virtual table (chunks_vec, cosine metric, dimension fixed at migrate time) sharing the chunks rowid, and vector_search becomes a KNN `embedding MATCH ? AND k = ?` query joined back to chunks. The store now takes the embedding dimension at connect time and validates it on insert and search. Adds vec0-backed unit tests (in-memory SQLite KNN, dimension mismatch); CLI smoke test returns identical cosine scores to the previous brute-force implementation. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PxznqztpBUiRN4FH6pKGun
1 parent e144344 commit 9dba623

5 files changed

Lines changed: 148 additions & 38 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ Retrieval-Augmented-Generation layer on top of the converter: it turns documents
5151
into Markdown, chunks them (configurable size / overlap), embeds the chunks, and
5252
stores them in a vector database for semantic search. Every external dependency is
5353
a swappable trait — embedders (**Ollama**/Gemini/local-ONNX), vector stores
54-
(**SQLite**/PostgreSQL+pgvector), LLM (**OpenRouter**, DeepSeek-V3 by default),
54+
(**SQLite+sqlite-vec**/PostgreSQL+pgvector), LLM (**OpenRouter**, DeepSeek-V3 by default),
5555
document sources (**folder**/FTP/SFTP), and message queues
5656
(**in-process**/RabbitMQ/Redis). It ships Hybrid, Multi-Query fusion and HyDE
5757
retrieval plus an evaluation harness to compare configurations. Configure it via

crates/fleischwolf-rag/Cargo.toml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ path = "src/bin/fleischwolf-rag.rs"
2020
[features]
2121
# The default build is fully offline-testable: bundled SQLite + HTTP embedders.
2222
default = ["sqlite", "ollama"]
23-
sqlite = ["sqlx/sqlite"]
23+
sqlite = ["sqlx/sqlite", "dep:sqlite-vec", "dep:libsqlite3-sys"]
2424
postgres = ["sqlx/postgres"]
2525
# HTTP-only backends ride on reqwest (no native deps) and always compile; the
2626
# feature flags below just gate the small amount of provider-specific code.
@@ -47,6 +47,11 @@ clap = { version = "4", features = ["derive", "env"] }
4747
# `ort` (tls-rustls) and sqlx below — no OpenSSL system dependency.
4848
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
4949
sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio", "tls-rustls"] }
50+
# sqlite-vec: the `vec0` ANN virtual table, statically compiled into the bundled
51+
# SQLite. libsqlite3-sys must match sqlx's (cargo enforces one copy via `links`);
52+
# we need it directly for sqlite3_auto_extension to register the extension.
53+
sqlite-vec = { version = "0.1.6", optional = true }
54+
libsqlite3-sys = { version = "0.30", optional = true }
5055
pulldown-cmark = { version = "0.13", default-features = false }
5156
uuid = { version = "1", features = ["v4"] }
5257
sha2 = "0.10"

crates/fleischwolf-rag/README.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ touching the pipeline.
1919
|--------------|------------------|-----------------------------------------------------------------|
2020
| Chunking | `Chunker` | Markdown-aware, configurable size (300) + overlap (5%) |
2121
| Embeddings | `Embedder` | **Ollama** (default, bge-m3, 1024-d), Gemini, local ONNX, hash |
22-
| Vector store | `VectorStore` | **SQLite** (default), PostgreSQL + pgvector, in-memory |
22+
| Vector store | `VectorStore` | **SQLite + sqlite-vec** (default), PostgreSQL + pgvector, in-memory |
2323
| Retrieval | `Retriever` | vector, BM25, **Hybrid** (RRF), Multi-Query fusion, HyDE |
2424
| LLM | `ChatModel` | OpenRouter (default model DeepSeek-V3) |
2525
| Sources | `DocumentSource` | **folder** (default), FTP, SFTP |
@@ -90,7 +90,10 @@ let hits = pipeline.query(RetrievalMode::Hybrid, "how does chunking work?", 5).a
9090

9191
The default feature set is fully self-contained and offline-testable
9292
(`cargo test -p fleischwolf-rag`) using the bundled SQLite store and the
93-
deterministic hashing embedder. The other backends are real client
93+
deterministic hashing embedder. The SQLite backend statically compiles the
94+
[sqlite-vec](https://github.com/asg017/sqlite-vec) extension: embeddings live in
95+
a `vec0` virtual table (cosine metric) and `vector_search` is a KNN `MATCH`
96+
query, not a full-table scan. The other backends are real client
9497
implementations that require the corresponding service (Postgres, an AMQP broker,
9598
Redis, an FTP/SSH server, an Ollama/Gemini endpoint, or local ONNX model files) to
9699
exercise end-to-end.

crates/fleischwolf-rag/src/store/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ pub async fn from_config(cfg: &RagConfig) -> Result<Arc<dyn VectorStore>> {
5959
DbBackend::Sqlite => {
6060
#[cfg(feature = "sqlite")]
6161
{
62-
Arc::new(sqlite::SqliteStore::connect(&cfg.database_url).await?)
62+
Arc::new(sqlite::SqliteStore::connect(&cfg.database_url, cfg.embed_dim).await?)
6363
}
6464
#[cfg(not(feature = "sqlite"))]
6565
{

crates/fleischwolf-rag/src/store/sqlite.rs

Lines changed: 135 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,48 @@
11
//! SQLite vector store (feature `sqlite`, on by default).
22
//!
3-
//! Uses the bundled SQLite that ships with `sqlx`. Embeddings are stored as a
4-
//! little-endian `f32` BLOB; `vector_search` loads candidates and ranks them by
5-
//! cosine in Rust — plenty for the eval-scale corpora this crate targets.
3+
//! Uses the bundled SQLite that ships with `sqlx`, plus the statically-compiled
4+
//! [`sqlite-vec`](https://github.com/asg017/sqlite-vec) extension: embeddings live
5+
//! in a `vec0` virtual table (`chunks_vec`, cosine metric) keyed by the `chunks`
6+
//! rowid, and `vector_search` is a real KNN `MATCH` query instead of a full-table
7+
//! scan. The extension is registered process-wide with `sqlite3_auto_extension`
8+
//! before the first connection, so every pooled connection sees `vec0`.
69
7-
use super::{top_k_by_cosine, VectorStore};
10+
use super::VectorStore;
811
use crate::math;
912
use crate::model::{Chunk, Document, Scored};
1013
use crate::{RagError, Result};
1114
use async_trait::async_trait;
1215
use sqlx::sqlite::{SqliteConnectOptions, SqlitePool, SqlitePoolOptions};
1316
use sqlx::Row;
1417
use std::str::FromStr;
18+
use std::sync::Once;
1519

16-
/// SQLite-backed store.
20+
/// Register sqlite-vec for every SQLite connection opened by this process.
21+
/// Safe to call repeatedly; the registration itself happens once.
22+
fn register_sqlite_vec() {
23+
static REGISTER: Once = Once::new();
24+
REGISTER.call_once(|| unsafe {
25+
// sqlite3_auto_extension expects `fn()`; sqlite3_vec_init's real signature
26+
// (db, err, api) is what SQLite actually calls it with.
27+
#[allow(clippy::missing_transmute_annotations)]
28+
libsqlite3_sys::sqlite3_auto_extension(Some(std::mem::transmute(
29+
sqlite_vec::sqlite3_vec_init as *const (),
30+
)));
31+
});
32+
}
33+
34+
/// SQLite-backed store with sqlite-vec KNN search.
1735
pub struct SqliteStore {
1836
pool: SqlitePool,
37+
dim: usize,
1938
}
2039

2140
impl SqliteStore {
2241
/// Connect to (creating if missing) the SQLite database at `url`
23-
/// (e.g. `sqlite://data/rag.db` or `sqlite::memory:`).
24-
pub async fn connect(url: &str) -> Result<Self> {
42+
/// (e.g. `sqlite://data/rag.db` or `sqlite::memory:`), expecting
43+
/// `dim`-dimensional embeddings.
44+
pub async fn connect(url: &str, dim: usize) -> Result<Self> {
45+
register_sqlite_vec();
2546
let opts = SqliteConnectOptions::from_str(url)
2647
.map_err(|e| RagError::config(format!("invalid RAG_DATABASE_URL '{url}': {e}")))?
2748
.create_if_missing(true);
@@ -36,24 +57,21 @@ impl SqliteStore {
3657
.max_connections(4)
3758
.connect_with(opts)
3859
.await?;
39-
Ok(SqliteStore { pool })
60+
Ok(SqliteStore { pool, dim })
4061
}
4162
}
4263

43-
fn row_to_chunk(row: &sqlx::sqlite::SqliteRow, with_embedding: bool) -> Result<(Chunk, Vec<f32>)> {
64+
fn row_to_chunk(row: &sqlx::sqlite::SqliteRow) -> Result<Chunk> {
4465
let metadata: String = row.try_get("metadata")?;
45-
let emb_bytes: Vec<u8> = row.try_get("embedding")?;
46-
let embedding = math::from_bytes(&emb_bytes);
47-
let chunk = Chunk {
66+
Ok(Chunk {
4867
id: row.try_get("id")?,
4968
doc_id: row.try_get("doc_id")?,
5069
ordinal: row.try_get("ordinal")?,
5170
text: row.try_get("text")?,
5271
token_count: row.try_get("token_count")?,
5372
metadata: serde_json::from_str(&metadata).unwrap_or(serde_json::Value::Null),
54-
embedding: if with_embedding { Some(embedding.clone()) } else { None },
55-
};
56-
Ok((chunk, embedding))
73+
embedding: None,
74+
})
5775
}
5876

5977
#[async_trait]
@@ -81,15 +99,24 @@ impl VectorStore for SqliteStore {
8199
ordinal INTEGER NOT NULL,
82100
text TEXT NOT NULL,
83101
token_count INTEGER NOT NULL,
84-
metadata TEXT NOT NULL DEFAULT 'null',
85-
embedding BLOB NOT NULL
102+
metadata TEXT NOT NULL DEFAULT 'null'
86103
)",
87104
)
88105
.execute(&self.pool)
89106
.await?;
90107
sqlx::query("CREATE INDEX IF NOT EXISTS idx_chunks_doc ON chunks(doc_id)")
91108
.execute(&self.pool)
92109
.await?;
110+
// The vec0 virtual table holds one embedding per chunk, sharing the chunks
111+
// table's rowid. Dimension and metric are fixed at creation time.
112+
sqlx::query(&format!(
113+
"CREATE VIRTUAL TABLE IF NOT EXISTS chunks_vec USING vec0(
114+
embedding float[{}] distance_metric=cosine
115+
)",
116+
self.dim
117+
))
118+
.execute(&self.pool)
119+
.await?;
93120
Ok(())
94121
}
95122

@@ -129,45 +156,72 @@ impl VectorStore for SqliteStore {
129156
.embedding
130157
.as_ref()
131158
.ok_or_else(|| RagError::Store(format!("chunk {} has no embedding", c.id)))?;
132-
sqlx::query(
133-
"INSERT INTO chunks (id, doc_id, ordinal, text, token_count, metadata, embedding)
134-
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
159+
if emb.len() != self.dim {
160+
return Err(RagError::Store(format!(
161+
"chunk {} embedding has dim {}, store expects {}",
162+
c.id,
163+
emb.len(),
164+
self.dim
165+
)));
166+
}
167+
let rowid: i64 = sqlx::query_scalar(
168+
"INSERT INTO chunks (id, doc_id, ordinal, text, token_count, metadata)
169+
VALUES (?1, ?2, ?3, ?4, ?5, ?6) RETURNING rowid",
135170
)
136171
.bind(&c.id)
137172
.bind(&c.doc_id)
138173
.bind(c.ordinal)
139174
.bind(&c.text)
140175
.bind(c.token_count)
141176
.bind(serde_json::to_string(&c.metadata)?)
142-
.bind(math::to_bytes(emb))
143-
.execute(&mut *tx)
177+
.fetch_one(&mut *tx)
144178
.await?;
179+
// vec0 accepts a raw little-endian f32 blob as the vector value.
180+
sqlx::query("INSERT INTO chunks_vec (rowid, embedding) VALUES (?1, ?2)")
181+
.bind(rowid)
182+
.bind(math::to_bytes(emb))
183+
.execute(&mut *tx)
184+
.await?;
145185
}
146186
tx.commit().await?;
147187
Ok(())
148188
}
149189

150190
async fn vector_search(&self, query: &[f32], k: usize) -> Result<Vec<Scored>> {
191+
if query.len() != self.dim {
192+
return Err(RagError::Store(format!(
193+
"query embedding has dim {}, store expects {}",
194+
query.len(),
195+
self.dim
196+
)));
197+
}
198+
// KNN via the vec0 MATCH operator; distance is cosine distance in [0, 2],
199+
// so similarity = 1 - distance.
151200
let rows = sqlx::query(
152-
"SELECT id, doc_id, ordinal, text, token_count, metadata, embedding FROM chunks",
201+
"SELECT c.id, c.doc_id, c.ordinal, c.text, c.token_count, c.metadata, v.distance
202+
FROM (SELECT rowid, distance FROM chunks_vec
203+
WHERE embedding MATCH ?1 AND k = ?2) v
204+
JOIN chunks c ON c.rowid = v.rowid
205+
ORDER BY v.distance",
153206
)
207+
.bind(math::to_bytes(query))
208+
.bind(k as i64)
154209
.fetch_all(&self.pool)
155210
.await?;
156-
let mut candidates = Vec::with_capacity(rows.len());
211+
let mut out = Vec::with_capacity(rows.len());
157212
for row in &rows {
158-
let (chunk, emb) = row_to_chunk(row, false)?;
159-
candidates.push((chunk, emb));
213+
let distance: f64 = row.try_get("distance")?;
214+
out.push(Scored::new(row_to_chunk(row)?, 1.0 - distance as f32));
160215
}
161-
Ok(top_k_by_cosine(query, candidates, k))
216+
Ok(out)
162217
}
163218

164219
async fn all_chunks(&self) -> Result<Vec<Chunk>> {
165-
let rows = sqlx::query(
166-
"SELECT id, doc_id, ordinal, text, token_count, metadata, embedding FROM chunks",
167-
)
168-
.fetch_all(&self.pool)
169-
.await?;
170-
rows.iter().map(|r| row_to_chunk(r, false).map(|(c, _)| c)).collect()
220+
let rows =
221+
sqlx::query("SELECT id, doc_id, ordinal, text, token_count, metadata FROM chunks")
222+
.fetch_all(&self.pool)
223+
.await?;
224+
rows.iter().map(row_to_chunk).collect()
171225
}
172226

173227
async fn count_chunks(&self) -> Result<usize> {
@@ -181,8 +235,56 @@ impl VectorStore for SqliteStore {
181235
}
182236

183237
async fn clear(&self) -> Result<()> {
238+
sqlx::query("DELETE FROM chunks_vec").execute(&self.pool).await?;
184239
sqlx::query("DELETE FROM chunks").execute(&self.pool).await?;
185240
sqlx::query("DELETE FROM documents").execute(&self.pool).await?;
186241
Ok(())
187242
}
188243
}
244+
245+
#[cfg(test)]
246+
mod tests {
247+
use super::*;
248+
use crate::embed::{Embedder, HashEmbedder};
249+
use crate::model::Document;
250+
251+
#[tokio::test]
252+
async fn knn_search_via_sqlite_vec() {
253+
let store = SqliteStore::connect("sqlite::memory:", 64).await.unwrap();
254+
store.migrate().await.unwrap();
255+
256+
let embedder = HashEmbedder::new(64);
257+
let doc = Document::new("mem://t", "T", "h1");
258+
store.upsert_document(&doc).await.unwrap();
259+
260+
let texts =
261+
["vector database semantic search", "banana smoothie recipe", "tokio async runtime"];
262+
let mut chunks = Vec::new();
263+
for (i, t) in texts.iter().enumerate() {
264+
let mut c = Chunk::new(&doc.id, i as i64, *t, 0);
265+
c.embedding = Some(embedder.embed_one(t).await.unwrap());
266+
chunks.push(c);
267+
}
268+
store.insert_chunks(&chunks).await.unwrap();
269+
assert_eq!(store.count_chunks().await.unwrap(), 3);
270+
271+
let q = embedder.embed_one("semantic search in a vector database").await.unwrap();
272+
let hits = store.vector_search(&q, 2).await.unwrap();
273+
assert_eq!(hits.len(), 2);
274+
assert!(hits[0].chunk.text.contains("vector database"), "got: {}", hits[0].chunk.text);
275+
assert!(hits[0].score >= hits[1].score);
276+
277+
// Dedup lookup and clear.
278+
assert_eq!(store.find_document_by_hash("h1").await.unwrap(), Some(doc.id.clone()));
279+
store.clear().await.unwrap();
280+
assert_eq!(store.count_chunks().await.unwrap(), 0);
281+
assert!(store.vector_search(&q, 2).await.unwrap().is_empty());
282+
}
283+
284+
#[tokio::test]
285+
async fn rejects_wrong_dimension() {
286+
let store = SqliteStore::connect("sqlite::memory:", 8).await.unwrap();
287+
store.migrate().await.unwrap();
288+
assert!(store.vector_search(&[0.0; 4], 1).await.is_err());
289+
}
290+
}

0 commit comments

Comments
 (0)