use super::VectorStore;
use crate::math;
use crate::model::{Chunk, Document, Scored};
use crate::{RagError, Result};
use async_trait::async_trait;
use sqlx::sqlite::{SqliteConnectOptions, SqlitePool, SqlitePoolOptions};
use sqlx::Row;
use std::str::FromStr;
use std::sync::Once;
fn register_sqlite_vec() {
static REGISTER: Once = Once::new();
REGISTER.call_once(|| unsafe {
#[allow(clippy::missing_transmute_annotations)]
libsqlite3_sys::sqlite3_auto_extension(Some(std::mem::transmute(
sqlite_vec::sqlite3_vec_init as *const (),
)));
});
}
pub struct SqliteStore {
pool: SqlitePool,
dim: usize,
}
impl SqliteStore {
pub async fn connect(url: &str, dim: usize) -> Result<Self> {
register_sqlite_vec();
let opts = SqliteConnectOptions::from_str(url)
.map_err(|e| RagError::config(format!("invalid RAG_DATABASE_URL '{url}': {e}")))?
.create_if_missing(true);
let filename = opts.get_filename();
if let Some(parent) = filename.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent).ok();
}
}
let pool = SqlitePoolOptions::new()
.max_connections(4)
.connect_with(opts)
.await?;
Ok(SqliteStore { pool, dim })
}
}
fn row_to_chunk(row: &sqlx::sqlite::SqliteRow) -> Result<Chunk> {
let metadata: String = row.try_get("metadata")?;
Ok(Chunk {
id: row.try_get("id")?,
doc_id: row.try_get("doc_id")?,
ordinal: row.try_get("ordinal")?,
text: row.try_get("text")?,
token_count: row.try_get("token_count")?,
metadata: serde_json::from_str(&metadata).unwrap_or(serde_json::Value::Null),
embedding: None,
})
}
#[async_trait]
impl VectorStore for SqliteStore {
async fn migrate(&self) -> Result<()> {
sqlx::query(
"CREATE TABLE IF NOT EXISTS documents (
id TEXT PRIMARY KEY,
source_uri TEXT NOT NULL,
title TEXT NOT NULL,
hash TEXT NOT NULL,
metadata TEXT NOT NULL DEFAULT 'null',
created_at TEXT NOT NULL
)",
)
.execute(&self.pool)
.await?;
sqlx::query("CREATE INDEX IF NOT EXISTS idx_documents_hash ON documents(hash)")
.execute(&self.pool)
.await?;
sqlx::query(
"CREATE TABLE IF NOT EXISTS chunks (
id TEXT PRIMARY KEY,
doc_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
ordinal INTEGER NOT NULL,
text TEXT NOT NULL,
token_count INTEGER NOT NULL,
metadata TEXT NOT NULL DEFAULT 'null'
)",
)
.execute(&self.pool)
.await?;
sqlx::query("CREATE INDEX IF NOT EXISTS idx_chunks_doc ON chunks(doc_id)")
.execute(&self.pool)
.await?;
sqlx::query(&format!(
"CREATE VIRTUAL TABLE IF NOT EXISTS chunks_vec USING vec0(
embedding float[{}] distance_metric=cosine
)",
self.dim
))
.execute(&self.pool)
.await?;
Ok(())
}
async fn upsert_document(&self, doc: &Document) -> Result<()> {
sqlx::query(
"INSERT INTO documents (id, source_uri, title, hash, metadata, created_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)
ON CONFLICT(id) DO UPDATE SET
source_uri = excluded.source_uri,
title = excluded.title,
hash = excluded.hash,
metadata = excluded.metadata",
)
.bind(&doc.id)
.bind(&doc.source_uri)
.bind(&doc.title)
.bind(&doc.hash)
.bind(serde_json::to_string(&doc.metadata)?)
.bind(&doc.created_at)
.execute(&self.pool)
.await?;
Ok(())
}
async fn find_document_by_hash(&self, hash: &str) -> Result<Option<String>> {
let row = sqlx::query("SELECT id FROM documents WHERE hash = ?1 LIMIT 1")
.bind(hash)
.fetch_optional(&self.pool)
.await?;
Ok(row.map(|r| r.get::<String, _>("id")))
}
async fn insert_chunks(&self, chunks: &[Chunk]) -> Result<()> {
let mut tx = self.pool.begin().await?;
for c in chunks {
let emb = c
.embedding
.as_ref()
.ok_or_else(|| RagError::Store(format!("chunk {} has no embedding", c.id)))?;
if emb.len() != self.dim {
return Err(RagError::Store(format!(
"chunk {} embedding has dim {}, store expects {}",
c.id,
emb.len(),
self.dim
)));
}
let rowid: i64 = sqlx::query_scalar(
"INSERT INTO chunks (id, doc_id, ordinal, text, token_count, metadata)
VALUES (?1, ?2, ?3, ?4, ?5, ?6) RETURNING rowid",
)
.bind(&c.id)
.bind(&c.doc_id)
.bind(c.ordinal)
.bind(&c.text)
.bind(c.token_count)
.bind(serde_json::to_string(&c.metadata)?)
.fetch_one(&mut *tx)
.await?;
sqlx::query("INSERT INTO chunks_vec (rowid, embedding) VALUES (?1, ?2)")
.bind(rowid)
.bind(math::to_bytes(emb))
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
Ok(())
}
async fn vector_search(&self, query: &[f32], k: usize) -> Result<Vec<Scored>> {
if query.len() != self.dim {
return Err(RagError::Store(format!(
"query embedding has dim {}, store expects {}",
query.len(),
self.dim
)));
}
let rows = sqlx::query(
"SELECT c.id, c.doc_id, c.ordinal, c.text, c.token_count, c.metadata, v.distance
FROM (SELECT rowid, distance FROM chunks_vec
WHERE embedding MATCH ?1 AND k = ?2) v
JOIN chunks c ON c.rowid = v.rowid
ORDER BY v.distance",
)
.bind(math::to_bytes(query))
.bind(k as i64)
.fetch_all(&self.pool)
.await?;
let mut out = Vec::with_capacity(rows.len());
for row in &rows {
let distance: f64 = row.try_get("distance")?;
out.push(Scored::new(row_to_chunk(row)?, 1.0 - distance as f32));
}
Ok(out)
}
async fn all_chunks(&self) -> Result<Vec<Chunk>> {
let rows =
sqlx::query("SELECT id, doc_id, ordinal, text, token_count, metadata FROM chunks")
.fetch_all(&self.pool)
.await?;
rows.iter().map(row_to_chunk).collect()
}
async fn count_chunks(&self) -> Result<usize> {
let row = sqlx::query("SELECT COUNT(*) AS n FROM chunks")
.fetch_one(&self.pool)
.await?;
Ok(row.get::<i64, _>("n") as usize)
}
async fn count_chunks_for(&self, doc_id: &str) -> Result<usize> {
let row = sqlx::query("SELECT COUNT(*) AS n FROM chunks WHERE doc_id = ?")
.bind(doc_id)
.fetch_one(&self.pool)
.await?;
Ok(row.get::<i64, _>("n") as usize)
}
async fn chunk_neighborhood(&self, doc_id: &str, ordinal: i64) -> Result<Vec<Chunk>> {
let rows = sqlx::query(
"SELECT * FROM chunks WHERE doc_id = ? AND ordinal BETWEEN ? - 1 AND ? + 1 \
ORDER BY ordinal",
)
.bind(doc_id)
.bind(ordinal)
.bind(ordinal) .fetch_all(&self.pool)
.await?;
rows.iter().map(row_to_chunk).collect()
}
async fn count_documents(&self) -> Result<usize> {
let row = sqlx::query("SELECT COUNT(*) AS n FROM documents")
.fetch_one(&self.pool)
.await?;
Ok(row.get::<i64, _>("n") as usize)
}
async fn list_documents(&self) -> Result<Vec<Document>> {
let rows =
sqlx::query("SELECT id, source_uri, title, hash, metadata, created_at FROM documents")
.fetch_all(&self.pool)
.await?;
rows.iter()
.map(|row| {
let metadata: String = row.try_get("metadata")?;
Ok(Document {
id: row.try_get("id")?,
source_uri: row.try_get("source_uri")?,
title: row.try_get("title")?,
hash: row.try_get("hash")?,
metadata: serde_json::from_str(&metadata).unwrap_or(serde_json::Value::Null),
created_at: row.try_get("created_at")?,
})
})
.collect()
}
async fn delete_document(&self, doc_id: &str) -> Result<()> {
let mut tx = self.pool.begin().await?;
sqlx::query(
"DELETE FROM chunks_vec WHERE rowid IN (SELECT rowid FROM chunks WHERE doc_id = ?1)",
)
.bind(doc_id)
.execute(&mut *tx)
.await?;
sqlx::query("DELETE FROM chunks WHERE doc_id = ?1")
.bind(doc_id)
.execute(&mut *tx)
.await?;
sqlx::query("DELETE FROM documents WHERE id = ?1")
.bind(doc_id)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(())
}
async fn delete_documents_by_source(&self, source_uri: &str) -> Result<()> {
let mut tx = self.pool.begin().await?;
sqlx::query(
"DELETE FROM chunks_vec WHERE rowid IN (
SELECT c.rowid FROM chunks c
JOIN documents d ON c.doc_id = d.id
WHERE d.source_uri = ?1
)",
)
.bind(source_uri)
.execute(&mut *tx)
.await?;
sqlx::query(
"DELETE FROM chunks WHERE doc_id IN (SELECT id FROM documents WHERE source_uri = ?1)",
)
.bind(source_uri)
.execute(&mut *tx)
.await?;
sqlx::query("DELETE FROM documents WHERE source_uri = ?1")
.bind(source_uri)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(())
}
async fn clear(&self) -> Result<()> {
sqlx::query("DELETE FROM chunks_vec")
.execute(&self.pool)
.await?;
sqlx::query("DELETE FROM chunks")
.execute(&self.pool)
.await?;
sqlx::query("DELETE FROM documents")
.execute(&self.pool)
.await?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::embed::{Embedder, HashEmbedder};
use crate::model::Document;
#[tokio::test]
async fn knn_search_via_sqlite_vec() {
let store = SqliteStore::connect("sqlite::memory:", 64).await.unwrap();
store.migrate().await.unwrap();
let embedder = HashEmbedder::new(64);
let doc = Document::new("mem://t", "T", "h1");
store.upsert_document(&doc).await.unwrap();
let texts = [
"vector database semantic search",
"banana smoothie recipe",
"tokio async runtime",
];
let mut chunks = Vec::new();
for (i, t) in texts.iter().enumerate() {
let mut c = Chunk::new(&doc.id, i as i64, *t, 0);
c.embedding = Some(embedder.embed_one(t).await.unwrap());
chunks.push(c);
}
store.insert_chunks(&chunks).await.unwrap();
assert_eq!(store.count_chunks().await.unwrap(), 3);
let q = embedder
.embed_one("semantic search in a vector database")
.await
.unwrap();
let hits = store.vector_search(&q, 2).await.unwrap();
assert_eq!(hits.len(), 2);
assert!(
hits[0].chunk.text.contains("vector database"),
"got: {}",
hits[0].chunk.text
);
assert!(hits[0].score >= hits[1].score);
assert_eq!(
store.find_document_by_hash("h1").await.unwrap(),
Some(doc.id.clone())
);
store.clear().await.unwrap();
assert_eq!(store.count_chunks().await.unwrap(), 0);
assert!(store.vector_search(&q, 2).await.unwrap().is_empty());
}
#[tokio::test]
async fn rejects_wrong_dimension() {
let store = SqliteStore::connect("sqlite::memory:", 8).await.unwrap();
store.migrate().await.unwrap();
assert!(store.vector_search(&[0.0; 4], 1).await.is_err());
}
}