@@ -11,7 +11,11 @@ use rusqlite::Connection;
1111/// True when the crate was built with the `embeddings` feature.
1212pub const ENABLED : bool = cfg ! ( feature = "embeddings" ) ;
1313
14- /// The text embedded for a symbol: name + signature + docstring.
14+ /// The text embedded for a symbol: name + signature + docstring. This is
15+ /// Layer 1 of semantic search — *symbol identity*. Layer 2 (`code_chunks` /
16+ /// `code_chunk_vecs`, populated by `indexer::chunker`) embeds the raw code
17+ /// body instead, so a query that only matches implementation vocabulary (not
18+ /// reflected in the name/signature/docstring) still has something to match.
1519pub fn symbol_doc ( name : & str , signature : & str , docstring : & str ) -> String {
1620 let mut s = String :: with_capacity ( name. len ( ) + signature. len ( ) + docstring. len ( ) + 2 ) ;
1721 s. push_str ( name) ;
@@ -55,6 +59,18 @@ mod imp {
5559 ) )
5660 }
5761
62+ /// Create the Layer-2 KNN table for `dim`-dimensional code-chunk vectors
63+ /// (idempotent). Separate from `embedding_vecs` — chunk ids and symbol ids
64+ /// are unrelated key spaces.
65+ pub fn create_chunk_embedding_table ( conn : & Connection , dim : usize ) -> rusqlite:: Result < ( ) > {
66+ conn. execute_batch ( & format ! (
67+ "CREATE VIRTUAL TABLE IF NOT EXISTS code_chunk_vecs USING vec0(
68+ chunk_id INTEGER PRIMARY KEY,
69+ embedding FLOAT[{dim}] distance_metric=cosine
70+ );"
71+ ) )
72+ }
73+
5874 /// A loaded static embedding model.
5975 pub struct Embedder {
6076 model : StaticModel ,
@@ -99,6 +115,18 @@ mod imp {
99115 Ok ( ( ) )
100116 }
101117
118+ pub fn store_chunk_embedding (
119+ conn : & Connection ,
120+ chunk_id : i64 ,
121+ vec : & [ f32 ] ,
122+ ) -> rusqlite:: Result < ( ) > {
123+ conn. execute (
124+ "INSERT OR REPLACE INTO code_chunk_vecs(chunk_id, embedding) VALUES (?1, ?2)" ,
125+ rusqlite:: params![ chunk_id, vec_to_blob( vec) ] ,
126+ ) ?;
127+ Ok ( ( ) )
128+ }
129+
102130 /// Embed every symbol that has no embedding yet; returns how many were added.
103131 pub fn embed_pending ( conn : & Connection , embedder : & Embedder ) -> rusqlite:: Result < usize > {
104132 let rows: Vec < ( i64 , String , String , String ) > = {
@@ -136,6 +164,65 @@ mod imp {
136164 . collect :: < rusqlite:: Result < Vec < _ > > > ( ) ?;
137165 Ok ( rows)
138166 }
167+
168+ /// Remove `code_chunk_vecs` rows whose chunk no longer exists in
169+ /// `code_chunks` (file changed or was deleted since the vector was
170+ /// written). Returns how many rows were pruned. Unlike `symbols` —
171+ /// `code_chunks` rows are always deleted-and-reinserted as a unit per file
172+ /// (see `indexer::pipeline::remove_file_rows`), so their ids never survive
173+ /// a reindex of that file; without this, stale orphans would accumulate
174+ /// forever and could crowd out real matches in `knn_chunks` (a KNN query
175+ /// has no way to know a returned id is dangling before doing this exact
176+ /// lookup).
177+ pub fn prune_orphaned_chunk_vecs ( conn : & Connection ) -> rusqlite:: Result < usize > {
178+ conn. execute (
179+ "DELETE FROM code_chunk_vecs WHERE chunk_id NOT IN (SELECT id FROM code_chunks)" ,
180+ [ ] ,
181+ )
182+ }
183+
184+ /// Embed every Layer-2 code chunk that has no embedding yet; returns how
185+ /// many were added. Prunes orphaned vectors first — see
186+ /// `prune_orphaned_chunk_vecs`.
187+ pub fn embed_pending_chunks ( conn : & Connection , embedder : & Embedder ) -> rusqlite:: Result < usize > {
188+ prune_orphaned_chunk_vecs ( conn) ?;
189+
190+ let rows: Vec < ( i64 , String ) > = {
191+ let mut stmt = conn. prepare (
192+ "SELECT id, chunk_text FROM code_chunks \
193+ WHERE id NOT IN (SELECT chunk_id FROM code_chunk_vecs)",
194+ ) ?;
195+ stmt. query_map ( [ ] , |r| Ok ( ( r. get ( 0 ) ?, r. get ( 1 ) ?) ) ) ?
196+ . collect :: < rusqlite:: Result < Vec < _ > > > ( ) ?
197+ } ;
198+ if rows. is_empty ( ) {
199+ return Ok ( 0 ) ;
200+ }
201+ let texts: Vec < String > = rows. iter ( ) . map ( |( _, text) | text. clone ( ) ) . collect ( ) ;
202+ let vecs = embedder. embed_batch ( & texts) ;
203+ for ( ( id, _) , v) in rows. iter ( ) . zip ( vecs. iter ( ) ) {
204+ store_chunk_embedding ( conn, * id, v) ?;
205+ }
206+ Ok ( rows. len ( ) )
207+ }
208+
209+ /// Nearest `k` chunk ids to `query` by cosine distance (ascending).
210+ pub fn knn_chunks (
211+ conn : & Connection ,
212+ query : & [ f32 ] ,
213+ k : usize ,
214+ ) -> rusqlite:: Result < Vec < ( i64 , f64 ) > > {
215+ let mut stmt = conn. prepare (
216+ "SELECT chunk_id, distance FROM code_chunk_vecs \
217+ WHERE embedding MATCH ?1 AND k = ?2 ORDER BY distance",
218+ ) ?;
219+ let rows = stmt
220+ . query_map ( rusqlite:: params![ vec_to_blob( query) , k as i64 ] , |r| {
221+ Ok ( ( r. get :: < _ , i64 > ( 0 ) ?, r. get :: < _ , f64 > ( 1 ) ?) )
222+ } ) ?
223+ . collect :: < rusqlite:: Result < Vec < _ > > > ( ) ?;
224+ Ok ( rows)
225+ }
139226}
140227
141228// ---------------------------------------------------------------------------
@@ -151,6 +238,10 @@ mod imp {
151238 Ok ( ( ) )
152239 }
153240
241+ pub fn create_chunk_embedding_table ( _conn : & Connection , _dim : usize ) -> rusqlite:: Result < ( ) > {
242+ Ok ( ( ) )
243+ }
244+
154245 /// Stub embedder — `load` always fails, so callers keep `None` and degrade.
155246 pub struct Embedder ;
156247
@@ -178,10 +269,24 @@ mod imp {
178269 pub fn knn ( _c : & Connection , _q : & [ f32 ] , _k : usize ) -> rusqlite:: Result < Vec < ( i64 , f64 ) > > {
179270 Ok ( Vec :: new ( ) )
180271 }
272+ pub fn store_chunk_embedding ( _c : & Connection , _id : i64 , _v : & [ f32 ] ) -> rusqlite:: Result < ( ) > {
273+ Ok ( ( ) )
274+ }
275+ pub fn prune_orphaned_chunk_vecs ( _c : & Connection ) -> rusqlite:: Result < usize > {
276+ Ok ( 0 )
277+ }
278+ pub fn embed_pending_chunks ( _c : & Connection , _e : & Embedder ) -> rusqlite:: Result < usize > {
279+ Ok ( 0 )
280+ }
281+ pub fn knn_chunks ( _c : & Connection , _q : & [ f32 ] , _k : usize ) -> rusqlite:: Result < Vec < ( i64 , f64 ) > > {
282+ Ok ( Vec :: new ( ) )
283+ }
181284}
182285
183286pub use imp:: {
184- Embedder , create_embedding_table, embed_pending, knn, register_extension, store_embedding,
287+ Embedder , create_chunk_embedding_table, create_embedding_table, embed_pending,
288+ embed_pending_chunks, knn, knn_chunks, prune_orphaned_chunk_vecs, register_extension,
289+ store_chunk_embedding, store_embedding,
185290} ;
186291
187292#[ cfg( test) ]
@@ -216,6 +321,53 @@ mod tests {
216321 assert_eq ! ( hits[ 0 ] . 0 , 2 , "nearest should be id 2" ) ;
217322 }
218323
324+ #[ cfg( feature = "embeddings" ) ]
325+ #[ test]
326+ fn vec0_knn_chunks_with_synthetic_vectors ( ) {
327+ use rusqlite:: Connection ;
328+ register_extension ( ) ;
329+ let conn = Connection :: open_in_memory ( ) . unwrap ( ) ;
330+ crate :: db:: schema:: init_db ( & conn) . unwrap ( ) ;
331+ create_chunk_embedding_table ( & conn, 3 ) . unwrap ( ) ;
332+
333+ // Layer-2 chunk vectors live in their own table/key-space from symbols.
334+ store_chunk_embedding ( & conn, 10 , & [ 1.0 , 0.0 , 0.0 ] ) . unwrap ( ) ;
335+ store_chunk_embedding ( & conn, 20 , & [ 0.0 , 1.0 , 0.0 ] ) . unwrap ( ) ;
336+ store_chunk_embedding ( & conn, 30 , & [ 0.0 , 0.0 , 1.0 ] ) . unwrap ( ) ;
337+
338+ let hits = knn_chunks ( & conn, & [ 0.0 , 0.0 , 0.9 ] , 2 ) . unwrap ( ) ;
339+ assert_eq ! ( hits. len( ) , 2 ) ;
340+ assert_eq ! ( hits[ 0 ] . 0 , 30 , "nearest should be chunk id 30" ) ;
341+ }
342+
343+ #[ cfg( feature = "embeddings" ) ]
344+ #[ test]
345+ fn prune_orphaned_chunk_vecs_removes_only_dangling_rows ( ) {
346+ use rusqlite:: Connection ;
347+ register_extension ( ) ;
348+ let conn = Connection :: open_in_memory ( ) . unwrap ( ) ;
349+ crate :: db:: schema:: init_db ( & conn) . unwrap ( ) ;
350+ create_chunk_embedding_table ( & conn, 3 ) . unwrap ( ) ;
351+
352+ // id 2 has a matching code_chunks row; id 1 is an orphan (e.g. left
353+ // over from a file that was since reindexed with new chunk ids).
354+ conn. execute (
355+ "INSERT INTO code_chunks (id, path, line_start, line_end, chunk_text, file_hash) \
356+ VALUES (2, 'a.py', 1, 1, 'pass', '')",
357+ [ ] ,
358+ )
359+ . unwrap ( ) ;
360+ store_chunk_embedding ( & conn, 1 , & [ 1.0 , 0.0 , 0.0 ] ) . unwrap ( ) ;
361+ store_chunk_embedding ( & conn, 2 , & [ 0.0 , 1.0 , 0.0 ] ) . unwrap ( ) ;
362+
363+ let pruned = prune_orphaned_chunk_vecs ( & conn) . unwrap ( ) ;
364+ assert_eq ! ( pruned, 1 , "exactly the dangling id-1 row must be pruned" ) ;
365+
366+ let hits = knn_chunks ( & conn, & [ 0.0 , 1.0 , 0.0 ] , 10 ) . unwrap ( ) ;
367+ assert_eq ! ( hits. len( ) , 1 ) ;
368+ assert_eq ! ( hits[ 0 ] . 0 , 2 ) ;
369+ }
370+
219371 /// KNN latency benchmark: 100k synthetic 256-dim vectors, topK=10.
220372 /// Run with: cargo test -p ci-core --features embeddings -- --ignored --nocapture bench_knn_latency
221373 #[ cfg( feature = "embeddings" ) ]
0 commit comments