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 ;
811use crate :: math;
912use crate :: model:: { Chunk , Document , Scored } ;
1013use crate :: { RagError , Result } ;
1114use async_trait:: async_trait;
1215use sqlx:: sqlite:: { SqliteConnectOptions , SqlitePool , SqlitePoolOptions } ;
1316use sqlx:: Row ;
1417use 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.
1735pub struct SqliteStore {
1836 pool : SqlitePool ,
37+ dim : usize ,
1938}
2039
2140impl 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