Skip to content

Commit c9327cc

Browse files
authored
fix hnsw delete reinsert rebuild (#142)
1 parent a31fb06 commit c9327cc

8 files changed

Lines changed: 216 additions & 131 deletions

File tree

docs/sql-engine.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@ Two specialized shortcuts in [`src/sql/executor.rs`](../src/sql/executor.rs) rec
133133
ORDER BY vec_distance_l2(<col>, <bracket-array literal>) ASC LIMIT k
134134
```
135135

136-
Returns top-k from the HNSW graph in `O(log N)` per probe. Mirrored shapes for `vec_distance_cosine` and `vec_distance_dot`.
136+
Returns top-k from the HNSW graph in `O(log N)` per probe. Mirrored shapes for `vec_distance_cosine` and `vec_distance_dot`. INSERT maintains HNSW incrementally. DELETE / UPDATE mark the graph dirty; the next INSERT on the indexed vector column rebuilds the in-memory graph from surviving rows before adding the new node, and save/COMMIT still rebuilds dirty graphs before serializing.
137137

138138
`try_fts_probe` (Phase 8b) — BM25 keyword:
139139

examples/nodejs-notes/README.md

Lines changed: 1 addition & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -247,17 +247,6 @@ Default is 0.5.
247247
time and won't notice newly-ingested files until you reload it
248248
anyway, so the gain from coexisting isn't worth the surprise.
249249

250-
- **HNSW after delete + re-insert (engine bug, SQLR-8).** The
251-
engine's HNSW chunk index panics when rows are deleted and re-
252-
inserted within the same connection lifetime — `index out of
253-
bounds` inside `DistanceMetric::compute`. The ingest pipeline
254-
works around it by splitting refresh into a delete-phase →
255-
close/reopen → insert-phase, which forces a clean index rebuild
256-
on next open. We pay the close/reopen hop only when there are
257-
actual deletions (so first-time `init` skips it). Once SQLR-8
258-
lands in the engine, `src/ingest.mjs` can drop the `db.reopen()`
259-
call.
260-
261250
- **Aggregates limited.** The engine supports `COUNT(*)`,
262251
`SUM`/`AVG`/`MIN`/`MAX` but not arbitrary expressions in `SELECT`
263252
projection beyond aggregates (see [`docs/supported-sql.md`](../../docs/supported-sql.md)).
@@ -319,7 +308,7 @@ examples/nodejs-notes/
319308
│ ├── db.mjs # schema, migrations, every SQL string
320309
│ ├── chunker.mjs # frontmatter + heading-aware chunking
321310
│ ├── embeddings.mjs # hash + OpenAI embedders
322-
│ ├── ingest.mjs # plan → delete → reopen → insert
311+
│ ├── ingest.mjs # plan → delete → insert
323312
│ ├── search.mjs # hybrid retrieval driver
324313
│ ├── serve.mjs # spawn sqlrite-mcp --read-only
325314
│ └── claude-config.mjs # Claude Desktop snippet renderer

examples/nodejs-notes/src/ingest.mjs

Lines changed: 1 addition & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,7 @@
1010
//
1111
// Both flow through `ingestImpl`, which splits the work into three
1212
// phases: PLAN (read-only diff against the current DB) → DELETE (drop
13-
// stale documents/chunks; close + reopen the DB) → INSERT (write new
14-
// rows). The close/reopen between DELETE and INSERT is a workaround
15-
// for an engine bug where the HNSW chunk index panics when rows are
16-
// deleted and re-inserted in the same connection lifetime — see the
17-
// "Known limitations" section of this example's README.
13+
// stale documents/chunks) → INSERT (write new rows).
1814

1915
import { readFile, readdir, stat } from 'node:fs/promises';
2016
import { createHash } from 'node:crypto';
@@ -192,23 +188,13 @@ async function ingestImpl({ db, root, embedder, logger, chunkOpts, mode }) {
192188

193189
// ----------------------------------------------------------------
194190
// PHASE 2 — deletes (and replacing-deletes).
195-
//
196-
// The engine's HNSW index has a bug where rows deleted and re-
197-
// inserted within the same connection lifetime can corrupt the
198-
// index's stored vectors (see ../README.md "Known limitations").
199-
// Closing + reopening the connection between the delete-pass and
200-
// the insert-pass forces a full index rebuild on next open,
201-
// sidestepping the issue. We only pay this cost when there's
202-
// actually something to delete; pure-INSERT runs (first `init`)
203-
// skip this hop entirely.
204191
if (hasMutations) {
205192
db.transaction(() => {
206193
for (const id of planDeletes) db.deleteDocument(id);
207194
for (const e of embedded) {
208195
if (e.plan.priorId !== null) db.deleteDocument(e.plan.priorId);
209196
}
210197
});
211-
db.reopen();
212198
}
213199

214200
// ----------------------------------------------------------------

src/sql/db/table.rs

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1237,7 +1237,7 @@ impl Table {
12371237
// pulls from the table's row storage (which we *just* updated
12381238
// with the new value).
12391239
if let Some(Value::Vector(new_vec)) = &typed_value {
1240-
self.maintain_hnsw_on_insert(key, next_rowid, new_vec);
1240+
self.maintain_hnsw_on_insert(key, next_rowid, new_vec)?;
12411241
}
12421242

12431243
// Step 4 (Phase 8b): maintain any FTS indexes on this column.
@@ -1257,7 +1257,9 @@ impl Table {
12571257
/// the borrowing dance — we need both `&self.rows` (read other
12581258
/// vectors) and `&mut self.hnsw_indexes` (insert into the graph) —
12591259
/// stays localized.
1260-
fn maintain_hnsw_on_insert(&mut self, column: &str, rowid: i64, new_vec: &[f32]) {
1260+
fn maintain_hnsw_on_insert(&mut self, column: &str, rowid: i64, new_vec: &[f32]) -> Result<()> {
1261+
self.rebuild_dirty_hnsw_indexes()?;
1262+
12611263
// Snapshot the current vector storage so the get_vec closure
12621264
// doesn't fight with `&mut self.hnsw_indexes`. For a typical
12631265
// HNSW insert we touch ef_construction × log(N) other vectors,
@@ -1279,9 +1281,52 @@ impl Table {
12791281
if entry.column_name == column {
12801282
entry.index.insert(rowid, new_vec, |id| {
12811283
vec_snapshot.get(&id).cloned().unwrap_or_default()
1282-
});
1284+
})?;
1285+
}
1286+
}
1287+
Ok(())
1288+
}
1289+
1290+
/// Rebuilds any dirty HNSW index on this table from the current
1291+
/// vector column storage. DELETE / UPDATE mark indexes dirty because
1292+
/// stale graph edges may still point at removed rowids; this makes
1293+
/// the next in-memory operation see a clean graph without requiring
1294+
/// a close/reopen or save round-trip.
1295+
pub fn rebuild_dirty_hnsw_indexes(&mut self) -> Result<()> {
1296+
let dirty: Vec<(String, String, DistanceMetric)> = self
1297+
.hnsw_indexes
1298+
.iter()
1299+
.filter(|e| e.needs_rebuild)
1300+
.map(|e| (e.name.clone(), e.column_name.clone(), e.metric))
1301+
.collect();
1302+
if dirty.is_empty() {
1303+
return Ok(());
1304+
}
1305+
1306+
for (idx_name, col_name, metric) in dirty {
1307+
let mut vectors: Vec<(i64, Vec<f32>)> = Vec::new();
1308+
{
1309+
let row_data = self.rows.lock().expect("rows mutex poisoned");
1310+
if let Some(Row::Vector(map)) = row_data.get(&col_name) {
1311+
for (id, v) in map.iter() {
1312+
vectors.push((*id, v.clone()));
1313+
}
1314+
}
1315+
}
1316+
1317+
let snapshot: HashMap<i64, Vec<f32>> = vectors.iter().cloned().collect();
1318+
let mut new_idx = HnswIndex::new(metric, 0xC0FFEE);
1319+
vectors.sort_by_key(|(id, _)| *id);
1320+
for (id, v) in &vectors {
1321+
new_idx.insert(*id, v, |q| snapshot.get(&q).cloned().unwrap_or_default())?;
1322+
}
1323+
1324+
if let Some(entry) = self.hnsw_indexes.iter_mut().find(|e| e.name == idx_name) {
1325+
entry.index = new_idx;
1326+
entry.needs_rebuild = false;
12831327
}
12841328
}
1329+
Ok(())
12851330
}
12861331

12871332
/// After a row insert, push the new (rowid, text) into every FTS

src/sql/executor.rs

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1468,7 +1468,7 @@ fn create_hnsw_index(
14681468
let v_clone = v.clone();
14691469
idx.insert(*rowid, &v_clone, |id| {
14701470
vec_map.get(&id).cloned().unwrap_or_default()
1471-
});
1471+
})?;
14721472
}
14731473
}
14741474

@@ -1863,12 +1863,15 @@ fn try_hnsw_probe(table: &Table, order_expr: &Expr, k: usize) -> Option<Vec<i64>
18631863
// module stays decoupled from the SQL types.
18641864
let column_for_closure = col_name.clone();
18651865
let table_ref = table;
1866-
let result = entry.index.search(&query_vec, k, |id| {
1867-
match table_ref.get_value(&column_for_closure, id) {
1868-
Some(Value::Vector(v)) => v,
1869-
_ => Vec::new(),
1870-
}
1871-
});
1866+
let result = entry
1867+
.index
1868+
.search(&query_vec, k, |id| {
1869+
match table_ref.get_value(&column_for_closure, id) {
1870+
Some(Value::Vector(v)) => v,
1871+
_ => Vec::new(),
1872+
}
1873+
})
1874+
.ok()?;
18721875
Some(result)
18731876
}
18741877

0 commit comments

Comments
 (0)