Skip to content

feat: add mempalace repair --mode hnsw --segment <uuid> (#1046) - #1126

Open
sha2fiddy wants to merge 8 commits into
MemPalace:developfrom
sha2fiddy:feature/1046/repair-hnsw-mode
Open

feat: add mempalace repair --mode hnsw --segment <uuid> (#1046)#1126
sha2fiddy wants to merge 8 commits into
MemPalace:developfrom
sha2fiddy:feature/1046/repair-hnsw-mode

Conversation

@sha2fiddy

@sha2fiddy sha2fiddy commented Apr 23, 2026

Copy link
Copy Markdown
Contributor

What this does

Adds mempalace repair --mode hnsw --segment <uuid> for in-place rescue of a corrupt HNSW segment. Default --mode legacy is unchanged.

mempalace repair --mode hnsw --segment <uuid>
                 [--max-elements N] [--backup/--no-backup]
                 [--purge-queue] [--quarantine-orphans] [--dry-run]

Why

mempalace migrate on a chromadb 1.5.x palace (2026-04-19) produced a 5 TB sparse link_lists.bin and a truncated index_metadata.pickle. repair rebuild reproduced it (122 GB). Upstream classifies this "by design" (chroma-core/chroma#2594), so the workaround sits in mempalace.

How it works

  1. Parse the hnswlib header from header.bin (or first 100 bytes of data_level0.bin).
  2. Extract vectors directly from data_level0.bin via struct (no chromadb in the read path).
  3. Reconcile labels against index_metadata.pickle; dedupe / drop zero-labels.
  4. Detect space (l2 / ip / cosine) from collection_metadata; fall back to l2.
  5. Build a fresh persistent index via chroma-hnswlib (is_persistent_index=True).
  6. Self-query verify on a small sample. A mismatch raises RebuildVerificationError and aborts before the swap.
  7. Atomic swap via os.replace. Optional timestamped backup. Rollback via os.rename.
  8. Optional cleanup: purge stuck embeddings_queue rows, quarantine orphans to quarantined_orphans.json.

Dependencies

numpy and chroma-hnswlib are imported lazily. Missing deps print an install hint and abort cleanly. No pyproject.toml changes (per CONTRIBUTING.md).

Note: chroma-hnswlib, not upstream hnswlib. The persistent-mode kwargs (is_persistent_index=, persistence_location=) are fork additions. Both packages install under the same hnswlib Python name.

Tests

20 new tests in tests/test_repair.py (synthetic-segment fixture reproduces the legacy-format incident on disk), 5 in tests/test_cli.py. Full non-benchmark suite: 1092/1092. Ruff clean. repair.py coverage: 90%.

Known coverage gap

ChromaDB 1.5.7+ with rust bindings doesn't write index_metadata.pickle and leaves header.bin cur_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_pickle and the total_elements_added write at the end of rebuild_hnsw_segment now handle index_metadata.pickle whether 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 raises AttributeError on a palace whose pickle was originally written by 0.6.x.

448c83d — Verify loosened to top-10 with wider ef. _self_query_verify previously 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 default ef≈10 is too narrow for ~500k-element indexes with M=16. Now requires "self appears in top-10" with index.set_ef(max(200, k*4)), and clamps k to healthy_n so small palaces still pass.

Related

Checklist

  • pytest tests/ -v --ignore=tests/benchmarks passes (1092 / 1092)
  • ruff check . clean
  • ruff format --check . clean
  • No new dependencies added to pyproject.toml
  • --help text documents chroma-hnswlib install hint
  • Conventional-commits messages
  • Branch off develop, PR targets develop

@sha2fiddy
sha2fiddy marked this pull request as ready for review April 23, 2026 14:02
@igorls igorls added enhancement New feature or request area/cli CLI commands storage labels Apr 24, 2026
@sha2fiddy

Copy link
Copy Markdown
Contributor Author

Pushed two follow-up commits with details in the PR description: pickle-format compat (dc9dc92) and verify loosening (448c83d). Both turned up running this PR's code against a ~500k-drawer live palace.

@sha2fiddy
sha2fiddy force-pushed the feature/1046/repair-hnsw-mode branch from 448c83d to 6c4a16a Compare April 26, 2026 19:37
@bleathem

Copy link
Copy Markdown

Hit this on a 730k-drawer palace last week (3.3.2, chromadb 1.5.8, macOS ARM). mempalace repair --yes exited 0 saying "Repair complete. 730531 drawers rebuilt" but had created a brand-new collection with an 857GB sparse link_lists.bin instead of rebuilding the existing one. The bug isn't migrate/repair specific either — once I rolled back, every subsequent add_drawer kept extending link_lists, hit 73TB apparent before I disabled the auto-save hooks.

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.

@sha2fiddy

Copy link
Copy Markdown
Contributor Author

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.

@bleathem

bleathem commented May 2, 2026

Copy link
Copy Markdown

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

_extract_vectors in repair.py rejects the file:

ValueError: data_level0.bin is 1784301444 bytes, expected >= 1970976000

The check is len(data) >= max_elements * stride, but the read loop only walks cur_count * stride. Our data_level0.bin was sized exactly to cur_count * stride (1,064,619 × 1,676 = 1,784,301,444), not to the pre-allocated max_elements. So files captured mid-write — or wherever the corruption left things — fail to extract even though every byte the loop wants is present.

Locally patched the comparison to n * stride and everything below worked. One-line change in _extract_vectors.

Run results (with that one-line patch)

Header:  dim=384, cur_count=1,064,619, max_elements=1,176,000, size_per_element=1676

Report
  raw labels           1,064,619
  after dedup/zeros    1,063,946
  healthy (in pickle)    729,121
  orphan HNSW labels     334,825
  stale pickle ids           608
  new max_elements       947,857

Built:   729,121 vectors in 312.7s
Peak RSS ~4 GB

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:

  • link_lists.bin = 5.9 MB (vs 73 TB sparse before this PR's repair)
  • Collection.count() returns 712,288 — no segfault
  • Direct coll.query(...) returns relevant hits with sensible L2 distances on the test palace

mempalace search CLI itself returns "Error executing plan: Internal error: Error finding id", but that's coming from the BM25/search-wrapper layer, not the HNSW segment this PR rebuilt — coll.query against the rebuilt collection works fine.

Backup at <seg>.hnsw-backup-<ts> and quarantined_orphans.json both got written as advertised.

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.

@sha2fiddy

Copy link
Copy Markdown
Contributor Author

Thanks @bleathem — pushed the fix as f739378 with a regression test. Targeted suite 58/58, ruff clean.

@sha2fiddy
sha2fiddy force-pushed the feature/1046/repair-hnsw-mode branch 3 times, most recently from 8940e3b to 636a6fb Compare May 15, 2026 18:32
@sha2fiddy
sha2fiddy force-pushed the feature/1046/repair-hnsw-mode branch 4 times, most recently from e1fed16 to 5362761 Compare May 25, 2026 14:39
@sha2fiddy
sha2fiddy force-pushed the feature/1046/repair-hnsw-mode branch 2 times, most recently from bd1088d to 8aeb8d1 Compare May 30, 2026 15:51
sha2fiddy added 2 commits June 3, 2026 09:08
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.
sha2fiddy added 6 commits June 3, 2026 09:08
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.
@sha2fiddy
sha2fiddy force-pushed the feature/1046/repair-hnsw-mode branch from 8aeb8d1 to a313ac6 Compare June 3, 2026 13:09
@igorls

igorls commented Aug 15, 2026

Copy link
Copy Markdown
Member

Thanks for this contribution, and apologies for the slow turnaround.

develop has moved a fair way since this was opened and the branch no longer merges cleanly. If you're still interested in landing it, could you rebase onto current develop? Once it merges cleanly and CI is green I'll get it reviewed for the 3.8.0 cycle.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/cli CLI commands enhancement New feature or request storage

Projects

None yet

Development

Successfully merging this pull request may close these issues.

migrate and repair rebuild trigger unbounded link_lists.bin growth on large palaces

3 participants