Repository navigation
fix(index): update and delete spatial and vector index entries (#164) - #172
mrdevrobot merged 4 commits into
Conversation
…Db#164) CollectionSecondaryIndex.Update and Delete only maintained B-Tree indexes, and DynamicCollection skipped vector and spatial indexes on delete. Neither RTreeIndex nor VectorSearchIndex could remove an entry. A moved or deleted document stayed at its old position or embedding, and once its slot was reused, searches returned the unrelated document now stored there. - RTreeIndex.Delete removes the leaf entry and shrinks the bounding boxes above it. Emptied nodes stay in place, since page frees are not transactional. - VectorSearchIndex.Delete marks the node deleted by clearing its document location (no document lives on page 0). The node keeps routing, and search skips it. Relocate re-points the node of a document that moved with an unchanged vector, which DynamicCollection uses since its updates always move the document. - CollectionSecondaryIndex routes insert, update and delete through per-type helpers, and compares vectors by value so an update that leaves the vector alone does not touch the index. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
mrdevrobot
left a comment
There was a problem hiding this comment.
Thanks, this is careful work: the R-Tree delete (boundary points, exact (mbr, PageId, SlotIndex) match, MBR shrink via CalculateMBR, GeoBox.Empty never intersecting), the tombstone choice (page 0 is the header, never a document page), the by-value vector comparison and the transactional writes all check out, and the page format is unchanged. 73/73 in the targeted tests, full suite green apart from the known .wal-shm failures, netstandard2.1 builds, no conflicts with main (5.2.3). One blocker.
1. HNSW Search returns fewer than k, down to zero, live results after deletes (VectorSearchIndex.cs:482-503).
SearchLayer(..., Math.Max(efSearch, k), 0) fills its result heap with the ef nearest nodes including tombstones, and only afterwards the loop skips loc.PageId == 0. Nothing over-fetches or keeps exploring. Repro (DynamicCollection, L2, 3 dims): insert v0..v299 at [i,i,i], delete v0..v199, query [0,0,0] with k=10 → 0 results, expected v200..v209. Vector_DeleteMost_SearchReturnsOnlySurvivors passes only because ef=100 ≥ 40 nodes, so it never exercises this. Fix as hnswlib does: keep expanding through deleted nodes in SearchLayer (they stay in visited/candidates for routing) but only push live nodes into the result heap, so ef counts live results. Please bump the test to more nodes than efSearch (e.g. 300 inserted / 200 deleted).
2. Tombstones grow without a reclaim path on typed collections. A relocating update on DocumentCollection is DeleteCore + InsertIntoAll (DocumentCollection.cs:2812-2829), so every update that outgrows its slot tombstones a node even when the vector is unchanged; Relocate is only used by DynamicCollection. Please route the typed relocating update through Relocate as well, and state in the docs that a rebuild (once #170 lands) is the way to drop tombstones.
3. RewriteLocation fallback is a full scan of the node pages (VectorSearchIndex.cs:566-577), hit deterministically when more than EfConstruction documents share an identical vector (zero/default embeddings are common). Not a correctness issue; a comment or a cap would help.
Nits: Delete/Relocate return bool but every caller discards it (a debug log when the entry is missing would help); CollectionSecondaryIndex.Delete still decrements _documentCount when nothing was found (pre-existing); vector Search/RewriteLocation run without the index-level lock that RTreeIndex takes, consistent with Insert but worth a comment.
Posted from the wrong account by mistake; the maintainer review from @mrdevrobot supersedes this.
…tor-index-maintenance
…Db#164) - HNSW search counts only live nodes: SearchLayer (in query mode) still expands deleted nodes for routing but keeps them out of the result and keeps searching until it holds ef live nodes, as hnswlib does. Before, a query near many deleted nodes could return fewer than k results, down to none. Graph construction still treats deleted nodes as neighbours. - Typed relocating updates move secondary-index entries instead of deleting and reinserting them: DeleteCore can leave the indexes alone, and CollectionSecondaryIndex.Update re-points an unchanged key at its new location; for an unchanged vector that is Relocate, so no deleted node is left behind (with delete + insert as the fallback). - Comments on the RewriteLocation page-scan fallback (hit when more than EfConstruction documents share one vector) and on the missing index-level lock. Tests: 300 vectors with 200 deleted near the query (more than efSearch) return exactly the next 10; three relocating updates with an unchanged vector leave the node count unchanged. Both fail without these changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Thanks for the repro. Addressed in the latest commit; the branch also merges 1. Fewer than k results.
The test is now 2. Tombstones on typed relocating updates.
Rebuilding the index with 3. Nits:
Full suite on macOS: 2405 passed, 0 failed, 4 skipped. 🤖 Generated with Claude Code |
mrdevrobot
left a comment
There was a problem hiding this comment.
All three points are fixed and both new tests fail with their fixes reverted. The query mode matches hnswlib: Bound() is infinite until a live node is in the result, the upper-layer descent still routes through deleted nodes, and construction and RewriteLocation are unchanged. I ran a recall probe with 2,000 random 16-dim vectors, k = 10, 50 queries against brute force over the survivors:
| deleted | recall@10 now | recall@10 before | ms/query now |
|---|---|---|---|
| 50% | 1.000 | 1.000 | 1.4 |
| 90% | 1.000 | 0.918 | 2.1 |
| 99% | 1.000 | 0.094 | 3.1 |
The relocating update path is correct for B-Tree, spatial and vector indexes, and the other DeleteCore callers keep the default. Full suite 2405 / 0 / 4 skipped. Approving.
Small follow-ups:
- The XML doc on
CollectionSecondaryIndex.Update(~L111-121) still says relocating callers must useDeleteFromAll+InsertInAlland that the locations must match. It's now wrong. - Relocating updates no longer adjust
_documentCountwhen a key goes from null to non-null or back. The in-place path already had this gap; statistics only. - Query cost now grows with tombstones when fewer than
eflive nodes are reachable. That's expected; a sentence in theDeletedocs pointing at the rebuild would help.
…ntglDb#164) - CollectionSecondaryIndex.Update: the XML doc now describes the in-place and relocating cases it handles. - Update adjusts the index's document count when the key goes from null to non-null or back, on both paths. - VectorSearchIndex.Delete: note that query cost grows with deleted nodes when fewer than ef live nodes are reachable, and point at the index rebuild. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Thanks. Addressed in 58d9c71.
|
mrdevrobot
left a comment
There was a problem hiding this comment.
Verified 58d9c71: the Update doc, the null ↔ non-null _documentCount adjustment and the Delete docs are all correct, and the commit changes nothing else. On main with #170 merged, a vacuum rebuild does drop tombstones: 100 nodes after insert, 100 after deleting 60, 40 after VacuumAsync(RebuildIndexes = true), still 40 after reopen. Full suite on the merged state 2425 / 0 / 4 skipped.
One low-severity follow-up for an issue, not blocking: with several vectors on one key, all &= Relocate(...) re-points every vector it can; if one fails, the fallback deletes at the old location (missing the re-pointed ones) and re-inserts all of them, leaving duplicate live nodes (CollectionSecondaryIndex.cs:142-145, :227-233). It needs an already inconsistent index to trigger.
Approving and merging.
Fixes #164.
Problem
CollectionSecondaryIndex.UpdateandDeleteonly maintained B-Tree indexes (_btreeIndex?.).DynamicCollection.IndexDeleteskipped vector and spatial indexes outright. NeitherRTreeIndexnorVectorSearchIndexcould remove an entry. A moved or deleted document stayed findable at its old position or embedding, and once its slot was reused, searches returned the unrelated document stored there.Fix
RTreeIndex.Delete(GeoBox, DocumentLocation)UpdateMBRUpwards.GeoBox.Empty, which matches no search. Freeing would need a transactional page free; a rollback would otherwise leave the parent pointing at a freed page.VectorSearchIndex.Delete(float[], DocumentLocation)default. No document lives on page 0.Searchskips deleted nodes and fills the top-k from the rest of its candidate list.VectorSearchIndex.Relocate(float[], DocumentLocation, DocumentLocation)re-points the node of a document that moved without changing its vector.DynamicCollectionupdates always move the document to a new slot. Without this, every update would leave a deleted node behind.CollectionSecondaryIndexroutes insert, update and delete through per-type helpers. Vectors are now compared by value when deciding whether an update touches the index. Every load creates a new array, so reference equality would have rewritten the HNSW graph on every update of any field.DynamicCollectiondeletes vector and spatial entries. Its update paths use a newIndexUpdatehelper that relocates an unchanged vector.DeleteandRelocateare new public members on the publicVectorSearchIndexclass.Tests
SpatialVectorIndexMaintenanceTests, 12 tests. 10 fail onmain:Withinreturns exactly the survivors.DynamicCollection: spatial update/delete, and vector update/delete/rename.The other two (spatial and vector delete rolled back) guard the new transactional deletes; on
mainthey pass because delete did nothing.The existing
HnswGraphQualityTests(recall) still pass. Full suite on macOS: 2391 passed, 3 failed, 4 skipped. The 3 failures are the knownMultiProcessWalSharedMemoryTests.wal-shmlock timeouts.Limitations
kresults may come back. Rebuilding the index drops them:VacuumAsync(RebuildIndexes = true)once fix(vacuum): rebuild typed-collection indexes into fresh trees (#167) #170 is in, or dropping and recreating the index.CollectAllPages(unchanged).Near/ non-indexedNear/Withincorrectness issues fromNear/Withinalways returntrueoutside the index: non-indexed spatial queries match every document, and indexedNearreturns the whole bounding box #165 are separate.🤖 Generated with Claude Code