Skip to content

Commit dd1c81d

Browse files
authored
Merge pull request #6 from Eilodon/ci-dual-layer-embedding-2l6da4
Add Layer-2 code-body chunk embeddings for semantic search
2 parents 2ef46c4 + 4e76708 commit dd1c81d

11 files changed

Lines changed: 1143 additions & 75 deletions

File tree

README.md

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,16 @@ tính graph metrics (coreness/hubs), và phục vụ qua SQLite FTS5 + semantic
2626
4. **Graph metrics**`coreness` (k-core, O(V+E)) và `is_hub` để AI biết đâu là lõi hệ thống.
2727
5. **Incremental watcher** — hash-diff chỉ re-parse file đổi; call graph rebuild từ `call_sites` đã lưu trong DB. Quá trình parse được song song hoá (`rayon`). Khi `ci serve` khởi động và đã có index cũ, tự động chạy **incremental reindex** thay vì full index — giảm thời gian warm-up. Debounce 500ms, lọc bỏ noise (`.codeindex/`, `target/`, v.v.).
2828
6. **FTS5 search** — full-text search native qua SQLite triggers, BM25 dual-column.
29-
7. **Semantic search (Bật theo mặc định trong config)** — static code embeddings (`model2vec-rs` + `sqlite-vec`),
30-
fuse với FTS bằng Reciprocal Rank Fusion (tỉ lệ 1.5x FTS / 1.0x Vector). Được kiểm soát bởi
31-
`semantic_search.enabled` trong `config.json` (mặc định `true`). Tự động cấu hình trên bản build native.
29+
7. **Semantic search — 2 tầng (Bật theo mặc định trong config)** — static code embeddings
30+
(`model2vec-rs` + `sqlite-vec`), fuse với FTS bằng Reciprocal Rank Fusion (tỉ lệ 1.5x FTS / 1.0x
31+
mỗi tầng semantic). Tầng 1 embed *symbol identity* (tên + signature + docstring); Tầng 2
32+
(`indexer::chunker`) embed *code body* thực tế — toàn bộ thân hàm nếu ≤30 dòng, sliding window
33+
30 dòng/stride 20 nếu dài hơn, cộng với các đoạn code nằm giữa các symbol (module scaffolding,
34+
field declarations) — nên một query chỉ khớp từ vựng *bên trong* thân hàm (một tên thư viện, một
35+
biến, một idiom) vẫn tìm ra kết quả dù tên/docstring của symbol không chứa từ đó. `kind=semantic`
36+
tự fuse 2 tầng nội bộ; `kind=hybrid` fuse cả 3 (FTS + Tầng 1 + Tầng 2) trong một lượt RRF phẳng.
37+
Được kiểm soát bởi `semantic_search.enabled` trong `config.json` (mặc định `true`). Tự động cấu
38+
hình trên bản build native.
3239
8. **`edges_ready` gating** — tool báo trung thực trạng thái index (`scanning → parsing →
3340
building_edges → ready`); agent không tin nhầm graph khi chưa build xong.
3441

@@ -71,8 +78,10 @@ cargo build -p ci-cli # Đã bao gồm embeddings
7178
```
7279

7380
Model mặc định `minishlab/potion-code-16M` (256-dim, static code embeddings, pure-Rust, không
74-
ONNX). `search(kind="semantic")``kind="hybrid"` (RRF: FTS + vector) sẽ hoạt động; khi tắt,
75-
chúng degrade về FTS.
81+
ONNX) — dùng chung cho cả 2 tầng. Tầng 1 (`embedding_vecs`) index tên/signature/docstring; Tầng 2
82+
(`code_chunk_vecs`) index các đoạn code body thực tế (bảng quan hệ `code_chunks` lưu text/dòng, luôn
83+
được tạo; chỉ được embed khi feature `embeddings` bật). `search(kind="semantic")``kind="hybrid"`
84+
(RRF: FTS + vector) sẽ hoạt động; khi tắt, chúng degrade về FTS.
7685

7786
> Lưu ý: feature `embeddings` kéo thêm dependency (tokenizers/TLS). Binary musl tĩnh phân phối
7887
> ở Phase IV build **không** bật feature này để giữ kích thước tối thiểu.

crates/ci-cli/src/main.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,12 @@ async fn main() -> Result<()> {
127127
Ok(embedder) => {
128128
ci_core::embedding::create_embedding_table(&conn, semantic.dimensions)?;
129129
let n = ci_core::embedding::embed_pending(&conn, &embedder)?;
130-
println!(" {n} symbols embedded.");
130+
ci_core::embedding::create_chunk_embedding_table(
131+
&conn,
132+
semantic.dimensions,
133+
)?;
134+
let nc = ci_core::embedding::embed_pending_chunks(&conn, &embedder)?;
135+
println!(" {n} symbols, {nc} code chunks embedded.");
131136
}
132137
Err(e) => eprintln!("\nEmbeddings skipped: {e}"),
133138
}

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

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,26 @@ CREATE TABLE IF NOT EXISTS call_sites (
8888
);
8989
CREATE INDEX IF NOT EXISTS idx_call_sites_from ON call_sites(from_path);
9090
CREATE INDEX IF NOT EXISTS idx_call_sites_callee ON call_sites(callee_name);
91+
92+
-- Semantic search Layer 2: raw code-body slices (whole short bodies, or a
93+
-- sliding window over longer ones — see indexer::chunker), embedded alongside
94+
-- Layer 1's symbol-identity (name+signature+docstring) vectors so a query
95+
-- matching only implementation vocabulary (e.g. a library name used inside a
96+
-- function body) still has something to match against. Always created —
97+
-- populated only when the `embeddings` feature is enabled at build time; the
98+
-- companion `code_chunk_vecs` vec0 table lives in embedding.rs (needs the
99+
-- sqlite-vec extension registered and a runtime-configured dimension, so it
100+
-- can't be part of this static schema).
101+
CREATE TABLE IF NOT EXISTS code_chunks (
102+
id INTEGER PRIMARY KEY AUTOINCREMENT,
103+
path TEXT NOT NULL,
104+
line_start INTEGER NOT NULL,
105+
line_end INTEGER NOT NULL,
106+
chunk_text TEXT NOT NULL,
107+
symbol_qn TEXT,
108+
file_hash TEXT NOT NULL DEFAULT ''
109+
);
110+
CREATE INDEX IF NOT EXISTS idx_code_chunks_path ON code_chunks(path);
91111
";
92112

93113
const FTS5_SQL: &str = "
@@ -206,6 +226,46 @@ mod tests {
206226
assert_eq!(count, 0);
207227
}
208228

229+
#[test]
230+
fn test_code_chunks_table() {
231+
let conn = Connection::open_in_memory().unwrap();
232+
init_db(&conn).unwrap();
233+
234+
let table_count: i64 = conn
235+
.query_row(
236+
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='code_chunks'",
237+
[],
238+
|r| r.get(0),
239+
)
240+
.unwrap();
241+
assert_eq!(table_count, 1);
242+
243+
conn.execute(
244+
"INSERT INTO code_chunks (path, line_start, line_end, chunk_text, symbol_qn, file_hash) \
245+
VALUES ('a.py', 1, 3, 'def f():\n pass', 'a.py::f', 'deadbeef')",
246+
[],
247+
)
248+
.unwrap();
249+
250+
let (path, symbol_qn): (String, Option<String>) = conn
251+
.query_row(
252+
"SELECT path, symbol_qn FROM code_chunks WHERE line_start = 1",
253+
[],
254+
|r| Ok((r.get(0)?, r.get(1)?)),
255+
)
256+
.unwrap();
257+
assert_eq!(path, "a.py");
258+
assert_eq!(symbol_qn.as_deref(), Some("a.py::f"));
259+
260+
// symbol_qn is nullable — gap chunks have no enclosing symbol.
261+
conn.execute(
262+
"INSERT INTO code_chunks (path, line_start, line_end, chunk_text, file_hash) \
263+
VALUES ('a.py', 4, 4, '', 'deadbeef')",
264+
[],
265+
)
266+
.unwrap();
267+
}
268+
209269
#[test]
210270
fn test_symbol_metrics_history_table() {
211271
let conn = Connection::open_in_memory().unwrap();

crates/ci-core/src/embedding.rs

Lines changed: 154 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,11 @@ use rusqlite::Connection;
1111
/// True when the crate was built with the `embeddings` feature.
1212
pub 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.
1519
pub 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

183286
pub 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

Comments
 (0)