Skip to content

Commit a40737a

Browse files
Your Nameclaude
andcommitted
Phase III: semantic embeddings (model2vec-rs + sqlite-vec, opt-in)
Wires the deferred semantic-search layer end to end, behind the `embeddings` Cargo feature so the default musl static binary stays lean. Model & storage: - model2vec-rs static code embeddings (default minishlab/potion-code-16M, 256-dim, distilled from nomic CodeRankEmbed) — pure Rust, no ONNX/C++ runtime. - sqlite-vec vec0 table (FLOAT[dim] distance_metric=cosine) for KNN. The extension is auto-registered before connections open. ci-core::embedding — one surface in both builds: - feature ON: register_extension, create_embedding_table(dim), Embedder (load/embed_one/embed_batch), embed_pending (embeds symbols lacking vectors), knn. feature OFF: identical signatures, all no-ops (Embedder::load fails → callers keep None and degrade). - search.rs: real search_semantic (embed query → KNN → join symbols, cosine similarity score) and hybrid RRF over FTS + semantic; gated by an Option<&Embedder> instead of a bare bool. Server/CLI: - CodeIntelligenceServer holds the loaded model + EmbedStatus; the background indexer loads it after the graph is built (downloading→embedding→ready), embeds all symbols, and the watcher re-embeds on incremental reindex. repo_overview / indexing_status now report the live embeddings_status; the search/locate/ understand tools pass the embedder for semantic + hybrid. - config default → potion-code-16M / 256-dim; schema dim is configurable (old hardcoded 768 table removed). - New CI job builds + tests the feature (vec0 KNN runs offline). Verified: default build/clippy/test green (135 tests); `--features embeddings` clippy + tests green (incl. a vec0 KNN test); Embedder is Send+Sync. Real `ci index --features embeddings` on a Python project downloaded the model and reported "Embedded 2 symbols". Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent e2a1530 commit a40737a

15 files changed

Lines changed: 475 additions & 60 deletions

File tree

.github/workflows/ci.yml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,3 +41,22 @@ jobs:
4141

4242
- name: Run Stack Graphs Regression Corpus
4343
run: cargo test --test parity_test test_formal_edges -- --nocapture
44+
45+
embeddings:
46+
runs-on: ubuntu-latest
47+
steps:
48+
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
49+
50+
- uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
51+
with:
52+
components: clippy
53+
54+
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
55+
56+
# The optional `embeddings` feature (model2vec-rs + sqlite-vec). The vec0
57+
# KNN test runs fully offline; model download is not exercised in CI.
58+
- name: Clippy (embeddings)
59+
run: cargo clippy -p ci-core --all-targets --features embeddings -- -D warnings
60+
61+
- name: Test (embeddings)
62+
run: cargo test -p ci-core --features embeddings

crates/ci-cli/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,6 @@ anyhow = { workspace = true }
1717
clap = { workspace = true }
1818
rusqlite = { workspace = true }
1919
serde_json = { workspace = true }
20+
21+
[features]
22+
embeddings = ["ci-core/embeddings", "ci-server/embeddings"]

crates/ci-cli/src/main.rs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,9 @@ async fn main() -> Result<()> {
8181
Commands::Index { project_root } => {
8282
let root = std::fs::canonicalize(&project_root)?;
8383
tracing::info!("Indexing {}", root.display());
84+
// Register sqlite-vec before opening the connection (no-op unless the
85+
// `embeddings` feature is built in).
86+
ci_core::embedding::register_extension();
8487
let db_path = ci_server::default_db_path(&root);
8588
if let Some(parent) = db_path.parent() {
8689
std::fs::create_dir_all(parent)?;
@@ -94,6 +97,21 @@ async fn main() -> Result<()> {
9497
conn.query_row("SELECT COUNT(*) FROM file_index", [], |r| r.get(0))?;
9598
tracing::info!("Indexing complete: {file_count} files, {symbol_count} symbols");
9699
println!("Indexed {file_count} files, {symbol_count} symbols.");
100+
101+
// Opt-in semantic embeddings.
102+
let semantic = ci_core::config::load_config(&root)
103+
.map(|c| c.semantic_search)
104+
.unwrap_or_default();
105+
if semantic.enabled {
106+
match ci_core::embedding::Embedder::load(&semantic.model, semantic.dimensions) {
107+
Ok(embedder) => {
108+
ci_core::embedding::create_embedding_table(&conn, semantic.dimensions)?;
109+
let n = ci_core::embedding::embed_pending(&conn, &embedder)?;
110+
println!("Embedded {n} symbols.");
111+
}
112+
Err(e) => eprintln!("Embeddings skipped: {e}"),
113+
}
114+
}
97115
}
98116
Commands::Doctor { project_root } => {
99117
let root = std::fs::canonicalize(&project_root)?;

crates/ci-core/Cargo.toml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,13 @@ tree-sitter-go = { workspace = true }
2222
stack-graphs = { workspace = true }
2323
tree-sitter-stack-graphs = { workspace = true }
2424
tree-sitter-stack-graphs-python = { workspace = true }
25+
model2vec-rs = { version = "0.2.1", optional = true }
26+
sqlite-vec = { version = "0.1.9", optional = true }
2527

2628
[dev-dependencies]
2729
tempfile = "3"
30+
31+
[features]
32+
# Opt-in semantic embeddings: pure-Rust static code embeddings (model2vec-rs)
33+
# stored in sqlite-vec. Off by default to keep the musl static binary lean.
34+
embeddings = ["dep:model2vec-rs", "dep:sqlite-vec"]

crates/ci-core/src/config.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,8 +101,10 @@ impl Default for SemanticSearchConfig {
101101
fn default() -> Self {
102102
Self {
103103
enabled: false,
104-
model: "BAAI/bge-base-en-v1.5".into(),
105-
dimensions: 768,
104+
// Pure-Rust static code embeddings (model2vec-rs); distilled from
105+
// nomic CodeRankEmbed, 256-dim. Keeps the musl static binary intact.
106+
model: "minishlab/potion-code-16M".into(),
107+
dimensions: 256,
106108
index_on_startup: false,
107109
}
108110
}

crates/ci-core/src/db/schema.rs

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -190,15 +190,6 @@ fn migrate_add_column(
190190
Ok(())
191191
}
192192

193-
pub fn create_embedding_table(conn: &Connection) -> rusqlite::Result<()> {
194-
conn.execute_batch(
195-
"CREATE VIRTUAL TABLE IF NOT EXISTS embedding_vecs USING vec0(
196-
symbol_id INTEGER,
197-
embedding FLOAT[768]
198-
);",
199-
)
200-
}
201-
202193
#[cfg(test)]
203194
mod tests {
204195
use super::*;

crates/ci-core/src/embedding.rs

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
//! Opt-in semantic embeddings (Cargo feature `embeddings`).
2+
//!
3+
//! Pure-Rust static code embeddings via `model2vec-rs` (default
4+
//! `minishlab/potion-code-16M`, 256-dim), stored and searched in `sqlite-vec`.
5+
//! The feature is off by default so the musl static binary stays lean; this
6+
//! module exposes the *same* surface in both builds — when the feature is off,
7+
//! every entry point is a no-op and semantic search degrades to FTS.
8+
9+
use rusqlite::Connection;
10+
11+
/// True when the crate was built with the `embeddings` feature.
12+
pub const ENABLED: bool = cfg!(feature = "embeddings");
13+
14+
/// The text embedded for a symbol: name + signature + docstring.
15+
pub fn symbol_doc(name: &str, signature: &str, docstring: &str) -> String {
16+
let mut s = String::with_capacity(name.len() + signature.len() + docstring.len() + 2);
17+
s.push_str(name);
18+
if !signature.is_empty() {
19+
s.push(' ');
20+
s.push_str(signature);
21+
}
22+
if !docstring.is_empty() {
23+
s.push(' ');
24+
s.push_str(docstring);
25+
}
26+
s
27+
}
28+
29+
// ---------------------------------------------------------------------------
30+
// Feature ON: real model2vec-rs + sqlite-vec implementation.
31+
// ---------------------------------------------------------------------------
32+
#[cfg(feature = "embeddings")]
33+
mod imp {
34+
use super::*;
35+
use model2vec_rs::model::StaticModel;
36+
37+
/// Register the sqlite-vec extension for every subsequent connection. Must be
38+
/// called once, before opening any connection that uses the `vec0` table.
39+
pub fn register_extension() {
40+
unsafe {
41+
#[allow(clippy::missing_transmute_annotations)]
42+
rusqlite::ffi::sqlite3_auto_extension(Some(std::mem::transmute(
43+
sqlite_vec::sqlite3_vec_init as *const (),
44+
)));
45+
}
46+
}
47+
48+
/// Create the KNN table for `dim`-dimensional cosine vectors (idempotent).
49+
pub fn create_embedding_table(conn: &Connection, dim: usize) -> rusqlite::Result<()> {
50+
conn.execute_batch(&format!(
51+
"CREATE VIRTUAL TABLE IF NOT EXISTS embedding_vecs USING vec0(
52+
symbol_id INTEGER PRIMARY KEY,
53+
embedding FLOAT[{dim}] distance_metric=cosine
54+
);"
55+
))
56+
}
57+
58+
/// A loaded static embedding model.
59+
pub struct Embedder {
60+
model: StaticModel,
61+
dim: usize,
62+
}
63+
64+
impl Embedder {
65+
/// Load `model_id` (a HuggingFace repo id or local path). Output is
66+
/// L2-normalised so cosine distance behaves well.
67+
pub fn load(model_id: &str, dim: usize) -> anyhow::Result<Self> {
68+
let model = StaticModel::from_pretrained(model_id, None, Some(true), None)
69+
.map_err(|e| anyhow::anyhow!("load embedding model '{model_id}': {e}"))?;
70+
Ok(Self { model, dim })
71+
}
72+
73+
pub fn dim(&self) -> usize {
74+
self.dim
75+
}
76+
77+
pub fn embed_one(&self, text: &str) -> Vec<f32> {
78+
self.model.encode_single(text)
79+
}
80+
81+
pub fn embed_batch(&self, texts: &[String]) -> Vec<Vec<f32>> {
82+
self.model.encode(texts)
83+
}
84+
}
85+
86+
fn vec_to_blob(v: &[f32]) -> Vec<u8> {
87+
let mut b = Vec::with_capacity(v.len() * 4);
88+
for f in v {
89+
b.extend_from_slice(&f.to_le_bytes());
90+
}
91+
b
92+
}
93+
94+
pub fn store_embedding(conn: &Connection, symbol_id: i64, vec: &[f32]) -> rusqlite::Result<()> {
95+
conn.execute(
96+
"INSERT OR REPLACE INTO embedding_vecs(symbol_id, embedding) VALUES (?1, ?2)",
97+
rusqlite::params![symbol_id, vec_to_blob(vec)],
98+
)?;
99+
Ok(())
100+
}
101+
102+
/// Embed every symbol that has no embedding yet; returns how many were added.
103+
pub fn embed_pending(conn: &Connection, embedder: &Embedder) -> rusqlite::Result<usize> {
104+
let rows: Vec<(i64, String, String, String)> = {
105+
let mut stmt = conn.prepare(
106+
"SELECT id, name, signature, docstring FROM symbols \
107+
WHERE id NOT IN (SELECT symbol_id FROM embedding_vecs)",
108+
)?;
109+
stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)))?
110+
.collect::<rusqlite::Result<Vec<_>>>()?
111+
};
112+
if rows.is_empty() {
113+
return Ok(0);
114+
}
115+
let docs: Vec<String> = rows
116+
.iter()
117+
.map(|(_, n, sig, doc)| symbol_doc(n, sig, doc))
118+
.collect();
119+
let vecs = embedder.embed_batch(&docs);
120+
for ((id, ..), v) in rows.iter().zip(vecs.iter()) {
121+
store_embedding(conn, *id, v)?;
122+
}
123+
Ok(rows.len())
124+
}
125+
126+
/// Nearest `k` symbol ids to `query` by cosine distance (ascending).
127+
pub fn knn(conn: &Connection, query: &[f32], k: usize) -> rusqlite::Result<Vec<(i64, f64)>> {
128+
let mut stmt = conn.prepare(
129+
"SELECT symbol_id, distance FROM embedding_vecs \
130+
WHERE embedding MATCH ?1 AND k = ?2 ORDER BY distance",
131+
)?;
132+
let rows = stmt
133+
.query_map(rusqlite::params![vec_to_blob(query), k as i64], |r| {
134+
Ok((r.get::<_, i64>(0)?, r.get::<_, f64>(1)?))
135+
})?
136+
.collect::<rusqlite::Result<Vec<_>>>()?;
137+
Ok(rows)
138+
}
139+
}
140+
141+
// ---------------------------------------------------------------------------
142+
// Feature OFF: identical surface, every operation a no-op.
143+
// ---------------------------------------------------------------------------
144+
#[cfg(not(feature = "embeddings"))]
145+
mod imp {
146+
use super::*;
147+
148+
pub fn register_extension() {}
149+
150+
pub fn create_embedding_table(_conn: &Connection, _dim: usize) -> rusqlite::Result<()> {
151+
Ok(())
152+
}
153+
154+
/// Stub embedder — `load` always fails, so callers keep `None` and degrade.
155+
pub struct Embedder;
156+
157+
impl Embedder {
158+
pub fn load(_model_id: &str, _dim: usize) -> anyhow::Result<Self> {
159+
anyhow::bail!("embeddings feature not enabled at build time")
160+
}
161+
pub fn dim(&self) -> usize {
162+
0
163+
}
164+
pub fn embed_one(&self, _text: &str) -> Vec<f32> {
165+
Vec::new()
166+
}
167+
pub fn embed_batch(&self, _texts: &[String]) -> Vec<Vec<f32>> {
168+
Vec::new()
169+
}
170+
}
171+
172+
pub fn store_embedding(_c: &Connection, _id: i64, _v: &[f32]) -> rusqlite::Result<()> {
173+
Ok(())
174+
}
175+
pub fn embed_pending(_c: &Connection, _e: &Embedder) -> rusqlite::Result<usize> {
176+
Ok(0)
177+
}
178+
pub fn knn(_c: &Connection, _q: &[f32], _k: usize) -> rusqlite::Result<Vec<(i64, f64)>> {
179+
Ok(Vec::new())
180+
}
181+
}
182+
183+
pub use imp::{
184+
Embedder, create_embedding_table, embed_pending, knn, register_extension, store_embedding,
185+
};
186+
187+
#[cfg(test)]
188+
mod tests {
189+
use super::*;
190+
191+
#[test]
192+
fn symbol_doc_joins_parts() {
193+
assert_eq!(
194+
symbol_doc("run", "fn run()", "does a thing"),
195+
"run fn run() does a thing"
196+
);
197+
assert_eq!(symbol_doc("run", "", ""), "run");
198+
}
199+
200+
#[cfg(feature = "embeddings")]
201+
#[test]
202+
fn vec0_knn_with_synthetic_vectors() {
203+
use rusqlite::Connection;
204+
register_extension();
205+
let conn = Connection::open_in_memory().unwrap();
206+
crate::db::schema::init_db(&conn).unwrap();
207+
create_embedding_table(&conn, 3).unwrap();
208+
209+
// Three unit-ish vectors; query is closest to id 2.
210+
store_embedding(&conn, 1, &[1.0, 0.0, 0.0]).unwrap();
211+
store_embedding(&conn, 2, &[0.0, 1.0, 0.0]).unwrap();
212+
store_embedding(&conn, 3, &[0.0, 0.0, 1.0]).unwrap();
213+
214+
let hits = knn(&conn, &[0.1, 0.9, 0.0], 2).unwrap();
215+
assert_eq!(hits.len(), 2);
216+
assert_eq!(hits[0].0, 2, "nearest should be id 2");
217+
}
218+
}

crates/ci-core/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
pub mod analysis;
22
pub mod config;
33
pub mod db;
4+
pub mod embedding;
45
pub mod fitness;
56
pub mod graph;
67
pub mod indexer;

0 commit comments

Comments
 (0)