feat: add mempalace repair --mode hnsw --segment <uuid> (#1046) - #1126
feat: add mempalace repair --mode hnsw --segment <uuid> (#1046)#1126sha2fiddy wants to merge 8 commits into
Conversation
|
Pushed two follow-up commits with details in the PR description: pickle-format compat ( |
448c83d to
6c4a16a
Compare
|
Hit this on a 730k-drawer palace last week (3.3.2, chromadb 1.5.8, macOS ARM). The Rust HNSW count() segfaults on the corrupt segment so quarantine_stale_hnsw can't fire even after #1108. Recovery for me was manually quarantining the bins and accepting BM25-only — the surgical extract from data_level0.bin you have here would've saved me a lot of hand-rolling. |
6c4a16a to
c074a28
Compare
c074a28 to
f1f5a37
Compare
|
Rebased on upstream/develop @fdfaf01. How the conflicts shook out:
Targeted tests 170/170 (test_repair, test_cli, test_backends, test_hnsw_capacity). Full non-benchmark suite 1480/1480. Ruff clean. |
|
Tested this branch against the 730k-drawer palace I described earlier. Two things to share — one bug in the size check, then a successful end-to-end run. Size-check too strict
The check is Locally patched the comparison to Run results (with that one-line patch)The 335k orphan-HNSW labels lines up with what I described before — HNSW had inflated to 1M entries during the writer corruption, well beyond the ~730k drawers that actually exist in sqlite. Quarantine wrote them out cleanly. After rebuild:
Backup at This PR + that one size-check fix is the cleanest path off the corrupted state I've seen for this failure mode. Thanks for sticking with it. |
8940e3b to
636a6fb
Compare
e1fed16 to
5362761
Compare
bd1088d to
8aeb8d1
Compare
Targeted HNSW segment rebuild for palaces corrupted by the chromadb migrate/repair-rebuild resize-drift bug (MemPalace#1046). Rebuilds a single segment in place from data_level0.bin without re-embedding, without touching the rest of the palace, and without invoking the buggy chromadb rebuild path that produced the original corruption. New CLI: mempalace repair --mode hnsw --segment <uuid> [--max-elements N] [--backup/--no-backup] [--purge-queue] [--quarantine-orphans] [--dry-run] Legacy repair path (--mode legacy, default) unchanged except for a one-line offset fix matching the shape already fixed at repair.py:252. Implementation: - Reads vectors directly from data_level0.bin via struct parsing - Reconciles with index_metadata.pickle to drop stale IDs - Rebuilds via chroma-hnswlib in persistent mode - Self-query verifies top-1 match on all vectors before swap - Atomic swap via os.replace with optional timestamped backup - Optional: purge stuck embeddings_queue rows, quarantine orphan metadata rows to a JSON sidecar Dependencies: numpy and chroma-hnswlib imported lazily per CONTRIBUTING.md. Missing deps print install hint and abort gracefully — no changes to pyproject.toml. Tests: 20 new in test_repair.py (synthetic segment fixture covers header parse, vector extract, dedup, space detect, rebuild, verify, swap, rollback, queue purge, orphan quarantine, dry-run). 5 new in test_cli.py for argparse wiring and dispatch. Full non-benchmark suite: 1092/1092. Ruff clean. Coverage on repair.py: 90%. End-to-end validation on synthetic palace: 10 MB bloated link_lists.bin rebuilt to 16 bytes in <0.1s, self-query top-1 match on all vectors, queue purged, orphans quarantined, backup written.
…index_metadata.pickle ChromaDB 0.6.x wrote index_metadata.pickle as an attribute-style object (meta.label_to_id); chromadb 1.5.x writes it as a dict (meta["label_to_id"]). A palace whose pickle was originally created on the older version raises AttributeError in _reconcile_with_pickle and at the end of rebuild_hnsw_segment. Adds two small helpers _meta_get / _meta_set that read and write either shape transparently, and routes the four field accesses (label_to_id, id_to_label, id_to_seq_id, total_elements_added) through them. repair --mode hnsw now works on palaces that have lived through both chromadb versions, not just freshly-created 1.5.x ones.
Two false-positive failure modes on healthy rebuilt indexes: 1. Mined corpora regularly contain byte-identical near-duplicate vectors (e.g. the same code snippet pasted across multiple transcripts). On a correctly rebuilt index, a duplicate can legitimately rank MemPalace#1 instead of the original — verify fails on a rebuild that's actually fine. 2. hnswlib's default ef (~10) is too tight a search beam for ~500k-element indexes with M=16; even a byte-identical self-match can be missed because its neighborhood in the HNSW graph is sparse. Loosens the assertion to "self appears in top-k=10" and bumps index.set_ef(max(200, k*4)) before querying. Both narrow false positives; neither weakens detection of real corruption (a truly broken index still misses its own labels everywhere, not just outside top-1). ChromaDB sets its own ef at query time, so this only affects the verify step. Found running --mode hnsw against a real ~500k-drawer palace.
Verifies status() prints the actionable `--mode hnsw --segment <uuid>` recovery command (with the segment UUID inline) when hnsw_capacity_status flags divergence, alongside the legacy full-rebuild option. Closes the design-integration loop with MemPalace#1222.
The _extract_vectors size guard expected `max_elements * stride` bytes, but the loop only walks `cur_count * stride`. Files sized to `cur_count * stride` (which is what chroma-hnswlib persists in 1.5.x when cur_count < max_elements) failed extraction with a spurious "data_level0.bin is N bytes, expected >= M" error. bleathem reproduced this on a 730k-drawer palace where the file was 1,784,301,444 bytes but max_elements*stride wanted 1,970,976,000. With this fix, end-to-end repair on his palace rebuilt 729,121 vectors in 312.7s, quarantined 334,825 orphan HNSW labels, dropped link_lists.bin from 73 TB sparse to 5.9 MB, and Collection.count() returned 712,288 with no segfault. Add a regression test exercising the cur_count < max_elements case.
status() now bails early when sqlite_drawer_count returns 0 (added in the develop merge for the empty-palace path). Add the synthetic count patch so the diverged path runs end-to-end.
8aeb8d1 to
a313ac6
Compare
|
Thanks for this contribution, and apologies for the slow turnaround.
If you'd rather not pick it back up, no problem at all — just say so and I'll close it out, and thanks either way for taking the time to send it. |
What this does
Adds
mempalace repair --mode hnsw --segment <uuid>for in-place rescue of a corrupt HNSW segment. Default--mode legacyis unchanged.Why
mempalace migrateon a chromadb 1.5.x palace (2026-04-19) produced a 5 TB sparselink_lists.binand a truncatedindex_metadata.pickle.repair rebuildreproduced it (122 GB). Upstream classifies this "by design" (chroma-core/chroma#2594), so the workaround sits in mempalace.How it works
header.bin(or first 100 bytes ofdata_level0.bin).data_level0.binviastruct(no chromadb in the read path).index_metadata.pickle; dedupe / drop zero-labels.l2/ip/cosine) fromcollection_metadata; fall back tol2.chroma-hnswlib(is_persistent_index=True).RebuildVerificationErrorand aborts before the swap.os.replace. Optional timestamped backup. Rollback viaos.rename.embeddings_queuerows, quarantine orphans toquarantined_orphans.json.Dependencies
numpyandchroma-hnswlibare imported lazily. Missing deps print an install hint and abort cleanly. Nopyproject.tomlchanges (per CONTRIBUTING.md).Note:
chroma-hnswlib, not upstreamhnswlib. The persistent-mode kwargs (is_persistent_index=,persistence_location=) are fork additions. Both packages install under the samehnswlibPython name.Tests
20 new tests in
tests/test_repair.py(synthetic-segment fixture reproduces the legacy-format incident on disk), 5 intests/test_cli.py. Full non-benchmark suite: 1092/1092. Ruff clean.repair.pycoverage: 90%.Known coverage gap
ChromaDB 1.5.7+ with rust bindings doesn't write
index_metadata.pickleand leavesheader.bincur_count=0. The rescue path's input files don't exist on a 1.5.x-born palace. The synthetic fixture covers the 0.5.x / 0.6.x-origin case (which is the actual incident).Follow-up commits (2026-04-26)
Two compat fixes, both turned up running this PR's code against a ~500k-drawer live palace:
dc9dc92— Pickle-format compat._reconcile_with_pickleand thetotal_elements_addedwrite at the end ofrebuild_hnsw_segmentnow handleindex_metadata.picklewhether it's the chromadb 0.6.x attribute-style object shape (meta.label_to_id) or the 1.5.x dict shape (meta["label_to_id"]). Without this, repair raisesAttributeErroron a palace whose pickle was originally written by 0.6.x.448c83d— Verify loosened to top-10 with wideref._self_query_verifypreviously required top-1. Two false positives on healthy indexes: byte-identical near-duplicate vectors (the same code snippet appearing across multiple transcripts) can legitimately rank #1, and hnswlib's defaultef≈10is too narrow for ~500k-element indexes withM=16. Now requires "self appears in top-10" withindex.set_ef(max(200, k*4)), and clampsktohealthy_nso small palaces still pass.Related
_fix_blob_seq_ids+ addrepair --mode max-seq-id#1135 adds--mode max-seq-idto the same dispatcher; whichever lands second takes a smallcli.pyrebase.--mode hnswunblocks.Checklist
pytest tests/ -v --ignore=tests/benchmarkspasses (1092 / 1092)ruff check .cleanruff format --check .cleanpyproject.toml--helptext documentschroma-hnswlibinstall hintdevelop, PR targetsdevelop