From 9dfe36c52a3718527b53fb83ce3d9e8223aededf Mon Sep 17 00:00:00 2001 From: Harvey Tseng Date: Fri, 4 Sep 2026 10:15:08 -0400 Subject: [PATCH 1/5] test: tie-aware recall and recall@10 in refiner_curve - 1,510 of 22,581 live chunk vectors are exact duplicates, so id-based recall read tie reordering as a 4% loss; distance-based recall does not --- crates/scry-core/examples/refiner_curve.rs | 97 +++++++++++++++------- 1 file changed, 68 insertions(+), 29 deletions(-) diff --git a/crates/scry-core/examples/refiner_curve.rs b/crates/scry-core/examples/refiner_curve.rs index 3b2e4a4..1d4aa41 100644 --- a/crates/scry-core/examples/refiner_curve.rs +++ b/crates/scry-core/examples/refiner_curve.rs @@ -4,13 +4,13 @@ //! Copy the live DB first (`sqlite3 index.db "VACUUM INTO 'copy.db'"`) so //! WAL pages are included and the server is never touched. -use std::collections::HashSet; use std::time::{Duration, Instant}; use scry_core::config::Config; use scry_core::store::Store; const K: usize = 50; +const K_SHOWN: usize = 10; const SAMPLES: usize = 200; const COARSE_KS: &[usize] = &[100, 200, 400, 800, 1600]; @@ -19,57 +19,96 @@ fn main() { let db = args.next().expect("index.db path"); let repo_key = args.next(); let config = Config::load(None).unwrap(); + let opened = Instant::now(); let store = Store::open( std::path::Path::new(&db), &config.embedding.model, config.embedding.dim, ) .unwrap(); + println!( + "open (with any migration) {:.1}ms", + opened.elapsed().as_secs_f64() * 1000.0 + ); let repo_id = repo_key.and_then(|key| store.repo_id(&key).unwrap()); let samples = store.sample_chunk_vectors(repo_id, SAMPLES).unwrap(); println!("repo {:?} samples {} k {}", repo_id, samples.len(), K); let mut exact_time = Duration::ZERO; - let exact: Vec> = samples + let exact: Vec = samples .iter() .map(|(id, query)| { let started = Instant::now(); let hits = store.dense_search_exact(repo_id, query, K + 1).unwrap(); exact_time += started.elapsed(); - hits.iter() - .map(|h| h.chunk_id) - .filter(|c| c != id) + let others: Vec = hits + .iter() + .filter(|h| h.chunk_id != *id) + .map(|h| h.distance) .take(K) - .collect() + .collect(); + Truth { + at_shown: others[K_SHOWN - 1], + at_k: others[K - 1], + } }) .collect(); println!( - "exact recall 1.000 {:>6.1}ms/query", + "exact recall@50 1.000 recall@10 1.000 {:>6.1}ms/query", exact_time.as_secs_f64() * 1000.0 / samples.len() as f64 ); - for &coarse_k in COARSE_KS { - let mut time = Duration::ZERO; - let mut overlap = 0usize; - for ((id, query), truth) in samples.iter().zip(&exact) { - let started = Instant::now(); - let hits = store - .dense_search_coarse(repo_id, query, K + 1, coarse_k + 1) - .unwrap(); - time += started.elapsed(); - overlap += hits - .iter() - .map(|h| h.chunk_id) - .filter(|c| c != id) - .take(K) - .filter(|c| truth.contains(c)) - .count(); - } - println!( - "coarse {:>5} recall {:.3} {:>6.1}ms/query", - coarse_k, - overlap as f64 / (samples.len() * K) as f64, - time.as_secs_f64() * 1000.0 / samples.len() as f64 + report( + &format!("coarse {coarse_k:>5}"), + &samples, + &exact, + |query| { + store + .dense_search_coarse(repo_id, query, K + 1, coarse_k + 1) + .unwrap() + }, ); } } + +/// Recall counts a hit when its distance is within the exact k-th distance, +/// so ties between duplicate vectors do not read as misses. +struct Truth { + at_shown: f64, + at_k: f64, +} + +const TIE: f64 = 1e-6; + +fn report( + label: &str, + samples: &[(i64, Vec)], + exact: &[Truth], + mut search: impl FnMut(&[f32]) -> Vec, +) { + let mut time = Duration::ZERO; + let (mut overlap, mut overlap_shown) = (0usize, 0usize); + for ((id, query), truth) in samples.iter().zip(exact) { + let started = Instant::now(); + let hits = search(query); + time += started.elapsed(); + let got: Vec = hits + .iter() + .filter(|h| h.chunk_id != *id) + .map(|h| h.distance) + .take(K) + .collect(); + overlap += got.iter().filter(|d| **d <= truth.at_k + TIE).count(); + overlap_shown += got + .iter() + .take(K_SHOWN) + .filter(|d| **d <= truth.at_shown + TIE) + .count(); + } + println!( + "{label} recall@50 {:.3} recall@10 {:.3} {:>6.1}ms/query", + overlap as f64 / (samples.len() * K) as f64, + overlap_shown as f64 / (samples.len() * K_SHOWN) as f64, + time.as_secs_f64() * 1000.0 / samples.len() as f64 + ); +} From c986c2e5794fde015775fcd75c9604875ee4ebca Mon Sep 17 00:00:00 2001 From: Harvey Tseng Date: Fri, 4 Sep 2026 10:15:08 -0400 Subject: [PATCH 2/5] perf: float vectors in a plain table, coarse pass rescored from it - vec0 point lookups and IN-filtered KNN both read the whole 4 MB vector block per chunk, so the rescore could never beat exact search - With floats in a rowid table the rescore is 400 4 KB reads: at 22k chunks coarse_k 400 takes 29ms for recall@10 1.000 / recall@50 0.994, against 98ms exact; exact below the threshold is a Rust scan over the same table at parity with vec0 - repos.chunk_count replaces the per-query count(*) join; v2->v3 migration copies 22k vectors in 8.8s once, file is 151 MB after VACUUM --- crates/scry-core/src/store/chunks.rs | 126 ++++++++++++++------------ crates/scry-core/src/store/files.rs | 9 +- crates/scry-core/src/store/mod.rs | 100 +++++++++++++------- crates/scry-core/src/store/schema.sql | 10 +- 4 files changed, 150 insertions(+), 95 deletions(-) diff --git a/crates/scry-core/src/store/chunks.rs b/crates/scry-core/src/store/chunks.rs index c461975..c0e2699 100644 --- a/crates/scry-core/src/store/chunks.rs +++ b/crates/scry-core/src/store/chunks.rs @@ -54,6 +54,19 @@ fn bytes_to_vector(bytes: &[u8]) -> Vec { .collect() } +fn cosine_distance(a: &[f32], b: &[f32]) -> f64 { + let (mut dot, mut na, mut nb) = (0f64, 0f64, 0f64); + for (x, y) in a.iter().zip(b) { + dot += f64::from(*x) * f64::from(*y); + na += f64::from(*x) * f64::from(*x); + nb += f64::from(*y) * f64::from(*y); + } + if na == 0.0 || nb == 0.0 { + return 1.0; + } + 1.0 - dot / (na.sqrt() * nb.sqrt()) +} + fn json_id_list(ids: &[i64]) -> String { let inner: Vec = ids.iter().map(i64::to_string).collect(); format!("[{}]", inner.join(",")) @@ -64,7 +77,7 @@ impl Store { let bytes: Option> = self .conn .query_row( - "SELECT v.embedding FROM vec_chunks v + "SELECT v.embedding FROM chunk_vectors v JOIN chunks c ON c.id = v.chunk_id WHERE c.content_hash = ?1 LIMIT 1", [content_hash], @@ -86,12 +99,17 @@ impl Store { ) -> Result<()> { let tx = self.conn.transaction()?; tx.execute( - "DELETE FROM vec_chunks WHERE chunk_id IN + "UPDATE repos SET chunk_count = chunk_count + ?3 - + (SELECT count(*) FROM chunks WHERE file_id = ?2) WHERE id = ?1", + params![repo_id, file_id, chunks.len() as i64], + )?; + tx.execute( + "DELETE FROM vec_chunks_bit WHERE chunk_id IN (SELECT id FROM chunks WHERE file_id = ?1)", [file_id], )?; tx.execute( - "DELETE FROM vec_chunks_bit WHERE chunk_id IN + "DELETE FROM chunk_vectors WHERE chunk_id IN (SELECT id FROM chunks WHERE file_id = ?1)", [file_id], )?; @@ -111,15 +129,15 @@ impl Store { )?; let chunk_id = tx.last_insert_rowid(); let bytes = vector_bytes(&chunk.embedding); - tx.execute( - "INSERT INTO vec_chunks (chunk_id, repo_id, embedding) VALUES (?1, ?2, ?3)", - params![chunk_id, repo_id, bytes], - )?; tx.execute( "INSERT INTO vec_chunks_bit (chunk_id, repo_id, embedding) VALUES (?1, ?2, vec_quantize_binary(?3))", params![chunk_id, repo_id, bytes], )?; + tx.execute( + "INSERT INTO chunk_vectors (chunk_id, embedding) VALUES (?1, ?2)", + params![chunk_id, bytes], + )?; } tx.commit()?; Ok(()) @@ -132,8 +150,7 @@ impl Store { k: usize, ) -> Result> { let chunk_count: i64 = self.conn.query_row( - "SELECT count(*) FROM chunks c JOIN files f ON f.id = c.file_id - WHERE ?1 IS NULL OR f.repo_id = ?1", + "SELECT coalesce(sum(chunk_count), 0) FROM repos WHERE ?1 IS NULL OR id = ?1", [repo_id], |row| row.get(0), )?; @@ -144,42 +161,6 @@ impl Store { } } - pub fn dense_search_exact( - &self, - repo_id: Option, - query: &[f32], - k: usize, - ) -> Result> { - // vec0 KNN cannot take an optional partition constraint in one - // statement; the filter must be present or absent in the SQL. - let sql = match repo_id { - Some(_) => { - "SELECT chunk_id, distance FROM vec_chunks - WHERE embedding MATCH ?1 AND k = ?2 AND repo_id = ?3 - ORDER BY distance" - } - None => { - "SELECT chunk_id, distance FROM vec_chunks - WHERE embedding MATCH ?1 AND k = ?2 - ORDER BY distance" - } - }; - let mut stmt = self.conn.prepare(sql)?; - let map = |row: &rusqlite::Row<'_>| { - Ok(DenseHit { - chunk_id: row.get(0)?, - distance: row.get(1)?, - }) - }; - let rows = match repo_id { - Some(repo_id) => { - stmt.query_map(params![vector_bytes(query), k as i64, repo_id], map)? - } - None => stmt.query_map(params![vector_bytes(query), k as i64], map)?, - }; - Ok(rows.collect::>()?) - } - /// Hamming pass over `vec_chunks_bit` keeps `coarse_k` candidates, /// then the float table rescores them by cosine and keeps `k`. pub fn dense_search_coarse( @@ -214,20 +195,49 @@ impl Store { .collect::>()?, }; - let ids = json_id_list(&candidates); let mut rescore = self.conn.prepare( - "SELECT chunk_id, distance FROM vec_chunks - WHERE embedding MATCH ?1 AND k = ?2 - AND chunk_id IN (SELECT value FROM json_each(?3)) - ORDER BY distance", + "SELECT chunk_id, embedding FROM chunk_vectors + WHERE chunk_id IN (SELECT value FROM json_each(?1))", )?; - let rows = rescore.query_map(params![vector_bytes(query), k as i64, ids], |row| { - Ok(DenseHit { - chunk_id: row.get(0)?, - distance: row.get(1)?, - }) - })?; - Ok(rows.collect::>()?) + let mut hits: Vec = rescore + .query_map([json_id_list(&candidates)], |row| { + let bytes: Vec = row.get(1)?; + Ok(DenseHit { + chunk_id: row.get(0)?, + distance: cosine_distance(query, &bytes_to_vector(&bytes)), + }) + })? + .collect::>()?; + hits.sort_by(|a, b| a.distance.total_cmp(&b.distance)); + hits.truncate(k); + Ok(hits) + } + + /// Exact cosine KNN as a scan over the plain float table. + pub fn dense_search_exact( + &self, + repo_id: Option, + query: &[f32], + k: usize, + ) -> Result> { + let mut stmt = self.conn.prepare( + "SELECT v.chunk_id, v.embedding FROM chunk_vectors v + JOIN chunks c ON c.id = v.chunk_id + JOIN files f ON f.id = c.file_id + WHERE ?1 IS NULL OR f.repo_id = ?1", + )?; + let mut hits: Vec = stmt + .query_map([repo_id], |row| { + let bytes: Vec = row.get(1)?; + Ok(DenseHit { + chunk_id: row.get(0)?, + distance: cosine_distance(query, &bytes_to_vector(&bytes)), + }) + })? + .collect::>()?; + hits.sort_by(|a, b| a.distance.total_cmp(&b.distance)); + hits.truncate(k); + Ok(hits) } pub fn sample_chunk_vectors( @@ -236,7 +246,7 @@ impl Store { n: usize, ) -> Result)>> { let mut stmt = self.conn.prepare( - "SELECT v.chunk_id, v.embedding FROM vec_chunks v + "SELECT v.chunk_id, v.embedding FROM chunk_vectors v JOIN chunks c ON c.id = v.chunk_id JOIN files f ON f.id = c.file_id WHERE ?1 IS NULL OR f.repo_id = ?1 diff --git a/crates/scry-core/src/store/files.rs b/crates/scry-core/src/store/files.rs index bbfe391..30e6b96 100644 --- a/crates/scry-core/src/store/files.rs +++ b/crates/scry-core/src/store/files.rs @@ -111,12 +111,17 @@ impl Store { return Ok(()); }; tx.execute( - "DELETE FROM vec_chunks WHERE chunk_id IN + "UPDATE repos SET chunk_count = chunk_count - + (SELECT count(*) FROM chunks WHERE file_id = ?2) WHERE id = ?1", + params![repo_id, file_id], + )?; + tx.execute( + "DELETE FROM vec_chunks_bit WHERE chunk_id IN (SELECT id FROM chunks WHERE file_id = ?1)", [file_id], )?; tx.execute( - "DELETE FROM vec_chunks_bit WHERE chunk_id IN + "DELETE FROM chunk_vectors WHERE chunk_id IN (SELECT id FROM chunks WHERE file_id = ?1)", [file_id], )?; diff --git a/crates/scry-core/src/store/mod.rs b/crates/scry-core/src/store/mod.rs index 4ee98a7..7d1c7c6 100644 --- a/crates/scry-core/src/store/mod.rs +++ b/crates/scry-core/src/store/mod.rs @@ -72,12 +72,7 @@ impl Store { if !initialized { conn.execute_batch(SCHEMA)?; conn.execute_batch(&format!( - "CREATE VIRTUAL TABLE vec_chunks USING vec0( - chunk_id INTEGER PRIMARY KEY, - repo_id INTEGER PARTITION KEY, - embedding float[{dim}] distance_metric=cosine - ); - CREATE VIRTUAL TABLE vec_chunks_bit USING vec0( + "CREATE VIRTUAL TABLE vec_chunks_bit USING vec0( chunk_id INTEGER PRIMARY KEY, repo_id INTEGER PARTITION KEY, embedding bit[{dim}] @@ -89,7 +84,7 @@ impl Store { ))?; conn.execute( "INSERT INTO meta (key, value) VALUES - ('schema_version', '2'), ('embedding_model', ?1), ('embedding_dim', ?2)", + ('schema_version', '3'), ('embedding_model', ?1), ('embedding_dim', ?2)", rusqlite::params![embedding_model, dim.to_string()], )?; } @@ -171,41 +166,60 @@ impl Store { /// v1 -> v2: the FTS index gains a relpath column so path words rank in /// BM25; rebuilt in place from chunks + files, vectors untouched. +/// v2 -> v3: float vectors move from vec0 into the plain `chunk_vectors` +/// table and repos gain a maintained chunk_count. fn migrate(conn: &Connection) -> Result<()> { let version: String = conn.query_row( "SELECT value FROM meta WHERE key = 'schema_version'", [], |row| row.get(0), )?; - if version != "1" { - return Ok(()); + if version == "1" { + conn.execute_batch(MIGRATE_V1_TO_V2)?; + } + if version == "1" || version == "2" { + conn.execute_batch(MIGRATE_V2_TO_V3)?; } - conn.execute_batch( - "BEGIN; - DROP TRIGGER IF EXISTS chunks_ai; - DROP TRIGGER IF EXISTS chunks_ad; - DROP TABLE IF EXISTS chunks_fts; - CREATE VIRTUAL TABLE chunks_fts USING fts5( - content, symbol, relpath, - content='', contentless_delete=1 - ); - CREATE TRIGGER chunks_ai AFTER INSERT ON chunks BEGIN - INSERT INTO chunks_fts(rowid, content, symbol, relpath) - VALUES (new.id, new.content, new.symbol, - (SELECT relpath FROM files WHERE id = new.file_id)); - END; - CREATE TRIGGER chunks_ad AFTER DELETE ON chunks BEGIN - DELETE FROM chunks_fts WHERE rowid = old.id; - END; - INSERT INTO chunks_fts(rowid, content, symbol, relpath) - SELECT c.id, c.content, c.symbol, f.relpath - FROM chunks c JOIN files f ON f.id = c.file_id; - UPDATE meta SET value = '2' WHERE key = 'schema_version'; - COMMIT;", - )?; Ok(()) } +const MIGRATE_V1_TO_V2: &str = "BEGIN; + DROP TRIGGER IF EXISTS chunks_ai; + DROP TRIGGER IF EXISTS chunks_ad; + DROP TABLE IF EXISTS chunks_fts; + CREATE VIRTUAL TABLE chunks_fts USING fts5( + content, symbol, relpath, + content='', contentless_delete=1 + ); + CREATE TRIGGER chunks_ai AFTER INSERT ON chunks BEGIN + INSERT INTO chunks_fts(rowid, content, symbol, relpath) + VALUES (new.id, new.content, new.symbol, + (SELECT relpath FROM files WHERE id = new.file_id)); + END; + CREATE TRIGGER chunks_ad AFTER DELETE ON chunks BEGIN + DELETE FROM chunks_fts WHERE rowid = old.id; + END; + INSERT INTO chunks_fts(rowid, content, symbol, relpath) + SELECT c.id, c.content, c.symbol, f.relpath + FROM chunks c JOIN files f ON f.id = c.file_id; + UPDATE meta SET value = '2' WHERE key = 'schema_version'; + COMMIT;"; + +const MIGRATE_V2_TO_V3: &str = "BEGIN; + CREATE TABLE chunk_vectors ( + chunk_id INTEGER PRIMARY KEY REFERENCES chunks(id), + embedding BLOB NOT NULL + ); + INSERT INTO chunk_vectors (chunk_id, embedding) + SELECT chunk_id, embedding FROM vec_chunks; + DROP TABLE vec_chunks; + ALTER TABLE repos ADD COLUMN chunk_count INTEGER NOT NULL DEFAULT 0; + UPDATE repos SET chunk_count = ( + SELECT count(*) FROM chunks c JOIN files f ON f.id = c.file_id + WHERE f.repo_id = repos.id); + UPDATE meta SET value = '3' WHERE key = 'schema_version'; + COMMIT;"; + pub(crate) fn vector_bytes(vector: &[f32]) -> Vec { vector.iter().flat_map(|v| v.to_le_bytes()).collect() } @@ -234,7 +248,7 @@ mod tests { } #[test] - fn migrates_v1_fts_to_path_aware_v2() { + fn migrates_v1_through_v3() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("index.db"); { @@ -266,6 +280,14 @@ mod tests { content, symbol, content='chunks', content_rowid='id'); INSERT INTO chunks_fts(rowid, content, symbol) SELECT id, content, symbol FROM chunks; + CREATE VIRTUAL TABLE vec_chunks USING vec0( + chunk_id INTEGER PRIMARY KEY, + repo_id INTEGER PARTITION KEY, + embedding float[8] distance_metric=cosine); + INSERT INTO vec_chunks (chunk_id, repo_id, embedding) + SELECT v.chunk_id, 1, v.embedding FROM chunk_vectors v; + DROP TABLE chunk_vectors; + ALTER TABLE repos DROP COLUMN chunk_count; UPDATE meta SET value = '1' WHERE key = 'schema_version';", ) .unwrap(); @@ -279,9 +301,19 @@ mod tests { |row| row.get(0), ) .unwrap(); - assert_eq!(version, "2"); + assert_eq!(version, "3"); let hits = store.lexical_search(None, "\"domains\"", 5, None).unwrap(); assert_eq!(hits.len(), 1); + let copied: i64 = store + .conn + .query_row("SELECT count(*) FROM chunk_vectors", [], |row| row.get(0)) + .unwrap(); + assert_eq!(copied, 1); + let count: i64 = store + .conn + .query_row("SELECT chunk_count FROM repos", [], |row| row.get(0)) + .unwrap(); + assert_eq!(count, 1); } #[test] diff --git a/crates/scry-core/src/store/schema.sql b/crates/scry-core/src/store/schema.sql index a92b2df..697a0dd 100644 --- a/crates/scry-core/src/store/schema.sql +++ b/crates/scry-core/src/store/schema.sql @@ -2,7 +2,8 @@ CREATE TABLE repos ( id INTEGER PRIMARY KEY, key TEXT NOT NULL UNIQUE, display_name TEXT, - created_at INTEGER NOT NULL DEFAULT (unixepoch()) + created_at INTEGER NOT NULL DEFAULT (unixepoch()), + chunk_count INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE files ( @@ -27,6 +28,13 @@ CREATE TABLE chunks ( CREATE INDEX chunks_by_file ON chunks(file_id); CREATE INDEX chunks_by_hash ON chunks(content_hash); +-- Float vectors as plain rows: point lookups by id cost one 4 KB read, +-- where a vec0 point lookup pulls the whole 4 MB vector block. +CREATE TABLE chunk_vectors ( + chunk_id INTEGER PRIMARY KEY REFERENCES chunks(id), + embedding BLOB NOT NULL +); + -- Contentless: rows are fed by the triggers so relpath can be copied in -- from files and path words rank in BM25 alongside content and symbols. CREATE VIRTUAL TABLE chunks_fts USING fts5( From ef33d5330eae08f1b0aa7cb59f876fd5889011c5 Mon Sep 17 00:00:00 2001 From: Harvey Tseng Date: Fri, 4 Sep 2026 10:26:42 -0400 Subject: [PATCH 3/5] perf: allocation-free exact scan and schema guards - Cosine runs on the blob in place with a bounded top-k, so a full scan allocates nothing per row: exact at 22k chunks 98ms -> 70ms under the same load - chunk_vectors cascades from chunks and repos.chunk_count is kept by triggers, so no Rust path can drift the count or leave a vector behind - A file newer than this binary's schema is refused at open, since v3 drops a table an older binary would still query --- crates/scry-core/src/store/chunks.rs | 93 +++++++++++++++------------ crates/scry-core/src/store/files.rs | 10 --- crates/scry-core/src/store/mod.rs | 28 ++++++-- crates/scry-core/src/store/schema.sql | 10 ++- 4 files changed, 83 insertions(+), 58 deletions(-) diff --git a/crates/scry-core/src/store/chunks.rs b/crates/scry-core/src/store/chunks.rs index c0e2699..041f68c 100644 --- a/crates/scry-core/src/store/chunks.rs +++ b/crates/scry-core/src/store/chunks.rs @@ -54,17 +54,58 @@ fn bytes_to_vector(bytes: &[u8]) -> Vec { .collect() } -fn cosine_distance(a: &[f32], b: &[f32]) -> f64 { - let (mut dot, mut na, mut nb) = (0f64, 0f64, 0f64); - for (x, y) in a.iter().zip(b) { - dot += f64::from(*x) * f64::from(*y); - na += f64::from(*x) * f64::from(*x); - nb += f64::from(*y) * f64::from(*y); +/// Cosine distance between the query and a little-endian f32 blob, +/// computed in place so a full scan allocates nothing per row. +fn cosine_distance(query: &[f32], query_norm: f64, blob: &[u8]) -> f64 { + let (mut dot, mut norm) = (0f64, 0f64); + for (x, bytes) in query.iter().zip(blob.as_chunks::<4>().0) { + let y = f64::from(f32::from_le_bytes(*bytes)); + dot += f64::from(*x) * y; + norm += y * y; } - if na == 0.0 || nb == 0.0 { + if query_norm == 0.0 || norm == 0.0 { return 1.0; } - 1.0 - dot / (na.sqrt() * nb.sqrt()) + 1.0 - dot / (query_norm * norm.sqrt()) +} + +fn norm(vector: &[f32]) -> f64 { + vector + .iter() + .map(|x| f64::from(*x) * f64::from(*x)) + .sum::() + .sqrt() +} + +/// Keeps `top` sorted by distance and at most `k` long. +fn keep_best(top: &mut Vec, hit: DenseHit, k: usize) { + if top.len() == k && hit.distance >= top[k - 1].distance { + return; + } + let at = top.partition_point(|h| h.distance <= hit.distance); + top.insert(at, hit); + top.truncate(k); +} + +/// Scans `stmt` rows of (chunk_id, embedding blob) and keeps the k nearest. +fn nearest( + stmt: &mut rusqlite::Statement<'_>, + params: impl rusqlite::Params, + query: &[f32], + k: usize, +) -> Result> { + let query_norm = norm(query); + let mut top = Vec::with_capacity(k + 1); + let mut rows = stmt.query(params)?; + while let Some(row) = rows.next()? { + let blob = row.get_ref(1)?.as_blob().map_err(rusqlite::Error::from)?; + let hit = DenseHit { + chunk_id: row.get(0)?, + distance: cosine_distance(query, query_norm, blob), + }; + keep_best(&mut top, hit, k); + } + Ok(top) } fn json_id_list(ids: &[i64]) -> String { @@ -98,21 +139,11 @@ impl Store { chunks: &[NewChunk], ) -> Result<()> { let tx = self.conn.transaction()?; - tx.execute( - "UPDATE repos SET chunk_count = chunk_count + ?3 - - (SELECT count(*) FROM chunks WHERE file_id = ?2) WHERE id = ?1", - params![repo_id, file_id, chunks.len() as i64], - )?; tx.execute( "DELETE FROM vec_chunks_bit WHERE chunk_id IN (SELECT id FROM chunks WHERE file_id = ?1)", [file_id], )?; - tx.execute( - "DELETE FROM chunk_vectors WHERE chunk_id IN - (SELECT id FROM chunks WHERE file_id = ?1)", - [file_id], - )?; tx.execute("DELETE FROM chunks WHERE file_id = ?1", [file_id])?; for chunk in chunks { tx.execute( @@ -199,18 +230,7 @@ impl Store { "SELECT chunk_id, embedding FROM chunk_vectors WHERE chunk_id IN (SELECT value FROM json_each(?1))", )?; - let mut hits: Vec = rescore - .query_map([json_id_list(&candidates)], |row| { - let bytes: Vec = row.get(1)?; - Ok(DenseHit { - chunk_id: row.get(0)?, - distance: cosine_distance(query, &bytes_to_vector(&bytes)), - }) - })? - .collect::>()?; - hits.sort_by(|a, b| a.distance.total_cmp(&b.distance)); - hits.truncate(k); - Ok(hits) + nearest(&mut rescore, [json_id_list(&candidates)], query, k) } /// Exact cosine KNN as a scan over the plain float table. @@ -226,18 +246,7 @@ impl Store { JOIN files f ON f.id = c.file_id WHERE ?1 IS NULL OR f.repo_id = ?1", )?; - let mut hits: Vec = stmt - .query_map([repo_id], |row| { - let bytes: Vec = row.get(1)?; - Ok(DenseHit { - chunk_id: row.get(0)?, - distance: cosine_distance(query, &bytes_to_vector(&bytes)), - }) - })? - .collect::>()?; - hits.sort_by(|a, b| a.distance.total_cmp(&b.distance)); - hits.truncate(k); - Ok(hits) + nearest(&mut stmt, [repo_id], query, k) } pub fn sample_chunk_vectors( diff --git a/crates/scry-core/src/store/files.rs b/crates/scry-core/src/store/files.rs index 30e6b96..be90cdb 100644 --- a/crates/scry-core/src/store/files.rs +++ b/crates/scry-core/src/store/files.rs @@ -110,21 +110,11 @@ impl Store { let Some(file_id) = file_id else { return Ok(()); }; - tx.execute( - "UPDATE repos SET chunk_count = chunk_count - - (SELECT count(*) FROM chunks WHERE file_id = ?2) WHERE id = ?1", - params![repo_id, file_id], - )?; tx.execute( "DELETE FROM vec_chunks_bit WHERE chunk_id IN (SELECT id FROM chunks WHERE file_id = ?1)", [file_id], )?; - tx.execute( - "DELETE FROM chunk_vectors WHERE chunk_id IN - (SELECT id FROM chunks WHERE file_id = ?1)", - [file_id], - )?; tx.execute("DELETE FROM chunks WHERE file_id = ?1", [file_id])?; tx.execute("DELETE FROM files WHERE id = ?1", [file_id])?; tx.commit()?; diff --git a/crates/scry-core/src/store/mod.rs b/crates/scry-core/src/store/mod.rs index 7d1c7c6..84c5665 100644 --- a/crates/scry-core/src/store/mod.rs +++ b/crates/scry-core/src/store/mod.rs @@ -19,6 +19,7 @@ use rusqlite::ffi::sqlite3_auto_extension; use crate::{Error, Result}; const SCHEMA: &str = include_str!("schema.sql"); +const SCHEMA_VERSION: u32 = 3; static VEC_EXTENSION: Once = Once::new(); @@ -84,8 +85,8 @@ impl Store { ))?; conn.execute( "INSERT INTO meta (key, value) VALUES - ('schema_version', '3'), ('embedding_model', ?1), ('embedding_dim', ?2)", - rusqlite::params![embedding_model, dim.to_string()], + ('schema_version', ?3), ('embedding_model', ?1), ('embedding_dim', ?2)", + rusqlite::params![embedding_model, dim.to_string(), SCHEMA_VERSION.to_string()], )?; } let store = Self { conn }; @@ -174,10 +175,18 @@ fn migrate(conn: &Connection) -> Result<()> { [], |row| row.get(0), )?; - if version == "1" { + let version: u32 = version + .parse() + .map_err(|_| Error::Config(format!("db schema_version {version} is not a number")))?; + if version > SCHEMA_VERSION { + return Err(Error::Config(format!( + "db schema_version {version} is newer than this binary; upgrade scry" + ))); + } + if version < 2 { conn.execute_batch(MIGRATE_V1_TO_V2)?; } - if version == "1" || version == "2" { + if version < 3 { conn.execute_batch(MIGRATE_V2_TO_V3)?; } Ok(()) @@ -207,7 +216,7 @@ const MIGRATE_V1_TO_V2: &str = "BEGIN; const MIGRATE_V2_TO_V3: &str = "BEGIN; CREATE TABLE chunk_vectors ( - chunk_id INTEGER PRIMARY KEY REFERENCES chunks(id), + chunk_id INTEGER PRIMARY KEY REFERENCES chunks(id) ON DELETE CASCADE, embedding BLOB NOT NULL ); INSERT INTO chunk_vectors (chunk_id, embedding) @@ -217,6 +226,14 @@ const MIGRATE_V2_TO_V3: &str = "BEGIN; UPDATE repos SET chunk_count = ( SELECT count(*) FROM chunks c JOIN files f ON f.id = c.file_id WHERE f.repo_id = repos.id); + CREATE TRIGGER chunks_count_ai AFTER INSERT ON chunks BEGIN + UPDATE repos SET chunk_count = chunk_count + 1 + WHERE id = (SELECT repo_id FROM files WHERE id = new.file_id); + END; + CREATE TRIGGER chunks_count_ad AFTER DELETE ON chunks BEGIN + UPDATE repos SET chunk_count = chunk_count - 1 + WHERE id = (SELECT repo_id FROM files WHERE id = old.file_id); + END; UPDATE meta SET value = '3' WHERE key = 'schema_version'; COMMIT;"; @@ -287,6 +304,7 @@ mod tests { INSERT INTO vec_chunks (chunk_id, repo_id, embedding) SELECT v.chunk_id, 1, v.embedding FROM chunk_vectors v; DROP TABLE chunk_vectors; + DROP TRIGGER chunks_count_ai; DROP TRIGGER chunks_count_ad; ALTER TABLE repos DROP COLUMN chunk_count; UPDATE meta SET value = '1' WHERE key = 'schema_version';", ) diff --git a/crates/scry-core/src/store/schema.sql b/crates/scry-core/src/store/schema.sql index 697a0dd..512c5ea 100644 --- a/crates/scry-core/src/store/schema.sql +++ b/crates/scry-core/src/store/schema.sql @@ -31,9 +31,17 @@ CREATE INDEX chunks_by_hash ON chunks(content_hash); -- Float vectors as plain rows: point lookups by id cost one 4 KB read, -- where a vec0 point lookup pulls the whole 4 MB vector block. CREATE TABLE chunk_vectors ( - chunk_id INTEGER PRIMARY KEY REFERENCES chunks(id), + chunk_id INTEGER PRIMARY KEY REFERENCES chunks(id) ON DELETE CASCADE, embedding BLOB NOT NULL ); +CREATE TRIGGER chunks_count_ai AFTER INSERT ON chunks BEGIN + UPDATE repos SET chunk_count = chunk_count + 1 + WHERE id = (SELECT repo_id FROM files WHERE id = new.file_id); +END; +CREATE TRIGGER chunks_count_ad AFTER DELETE ON chunks BEGIN + UPDATE repos SET chunk_count = chunk_count - 1 + WHERE id = (SELECT repo_id FROM files WHERE id = old.file_id); +END; -- Contentless: rows are fed by the triggers so relpath can be copied in -- from files and path words rank in BM25 alongside content and symbols. From 8681ffe20648d6764b79fd197e52b5904b198341 Mon Sep 17 00:00:00 2001 From: Harvey Tseng Date: Fri, 4 Sep 2026 16:12:02 -0400 Subject: [PATCH 4/5] docs: allocation claim for the dense search path --- docs/search.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/search.md b/docs/search.md index 326c098..10aadf1 100644 --- a/docs/search.md +++ b/docs/search.md @@ -29,6 +29,11 @@ Hashing file content for the sync diff allocates nothing. Deriving a repo key from a remote URL costs at most five allocations. + +A dense query over 16k chunks allocates fewer than 512 times in total: +neither the exact scan nor the coarse rescore allocates per row. + + Retrieval quality is measured with `scry eval `: a golden set of queries with expected `path` or `path:line` answers, reported as Recall@10 and MRR. Run it before and after touching anything in the From 1600c35741a652f04cf53b12890006acbf20c98f Mon Sep 17 00:00:00 2001 From: Harvey Tseng Date: Fri, 4 Sep 2026 16:16:18 -0400 Subject: [PATCH 5/5] perf: one allocation for the rescore id list, exact alloc claim - The JSON id list allocated one String per candidate, ~400 of the 415 allocations in a dense query at 16k chunks; it is now 11 - Alloc claims are exact, so the docs claim states 11, not a ceiling --- crates/scry-core/src/store/chunks.rs | 13 +++++++++++-- docs/search.md | 4 ++-- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/crates/scry-core/src/store/chunks.rs b/crates/scry-core/src/store/chunks.rs index 041f68c..c6b8ee0 100644 --- a/crates/scry-core/src/store/chunks.rs +++ b/crates/scry-core/src/store/chunks.rs @@ -109,8 +109,17 @@ fn nearest( } fn json_id_list(ids: &[i64]) -> String { - let inner: Vec = ids.iter().map(i64::to_string).collect(); - format!("[{}]", inner.join(",")) + use std::fmt::Write; + let mut list = String::with_capacity(ids.len() * 8 + 2); + list.push('['); + for (i, id) in ids.iter().enumerate() { + if i > 0 { + list.push(','); + } + write!(list, "{id}").unwrap(); + } + list.push(']'); + list } impl Store { diff --git a/docs/search.md b/docs/search.md index 10aadf1..ed41be2 100644 --- a/docs/search.md +++ b/docs/search.md @@ -29,8 +29,8 @@ Hashing file content for the sync diff allocates nothing. Deriving a repo key from a remote URL costs at most five allocations. - -A dense query over 16k chunks allocates fewer than 512 times in total: + +A dense query over 16k chunks allocates at most eleven times in total: neither the exact scan nor the coarse rescore allocates per row.