Skip to content

Commit 72bbb0a

Browse files
committed
fix(chroma): adopt chromadb's own HNSW write defaults
`_HNSW_BLOAT_GUARD` pinned `hnsw:batch_size=2` / `hnsw:sync_threshold=2` on every collection this backend creates, to answer #1579. Three findings on chromadb 1.5.9 (PersistentClient, Rust bindings, single writer, zero concurrency) retire it. **2 sits outside chroma's own declared valid range.** `hnsw_params.py:21-22` validates both knobs as `isinstance(p, int) and p > 2`. **The cost is write amplification, measured.** Bytes written (`/proc/self/io`) for one mine of N records, 64-dim, `num_threads=1`, identical corpus and seed: N 2/2 100/1000 ratio 10,000 47.1 MB 28.1 MB 1.67x 20,000 127.4 MB 60.0 MB 2.12x 40,000 390.3 MB 134.0 MB 2.91x Two runs at N=20,000 reproduced within 0.1%. `sync_threshold` dominates — 3/3 measured identical to 2/2, and 1000/1000 identical to 100/1000, so `batch_size` barely registers. The ratio grows with collection size, so the largest palaces pay the most. **It buys nothing.** A 5-record collection at 100/1000 — far below the threshold, so no persist ever fires — reads back whole from a fresh process: `count()` and the vector query both answer. On that artifact `link_lists.bin` is 0 bytes with no `index_metadata.pickle`, which is #1579's trigger shape exactly, and `quarantine_stale_hnsw()` creates no drift directory. The knobs move into `_hnsw_creation_metadata()`, which both `create_collection` paths now call, so per-collection tuning has one home. Legacy `metadata=` is kept deliberately. Measured on 1.5.9: creating with `configuration=` leaves `collection.metadata` as `None` and the sqlite `collection_metadata` table empty, which blinds `_read_sync_threshold` and `ChromaCollection.distance_metric` — both read that table. (`collection.configuration` does read back under both paths; migrating those two readers to it is the prerequisite for ever moving the writer, and belongs in its own change.) Not claimed: that a small `sync_threshold` is a known chroma failure mode. No such report was found in chroma's issues or docs, and chroma's own guidance runs the other way. The Python persist path that #1579 and chroma#6975 describe does not execute under the Rust bindings — `hnswlib` is not a dependency of this line.
1 parent aa89bd8 commit 72bbb0a

4 files changed

Lines changed: 176 additions & 78 deletions

File tree

mempalace/backends/chroma.py

Lines changed: 95 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -160,34 +160,101 @@ def _hnsw_payload_appears_sane(seg_dir: str) -> bool:
160160
return ratio is None or ratio <= _HNSW_LINK_TO_DATA_MAX_RATIO
161161

162162

163-
# HNSW batch/sync thresholds applied at collection creation.
163+
# HNSW batch/sync thresholds applied at collection creation — chromadb's own
164+
# documented defaults (chromadb/api/configuration.py:263-273,
165+
# chromadb/api/collection_configuration.py:451-454,
166+
# chromadb/segment/impl/vector/hnsw_params.py:79-80, and
167+
# https://docs.trychroma.com/docs/collections/configure).
164168
#
165-
# chromadb's Rust HNSW segment writes index_metadata.pickle and
166-
# link_lists.bin only when internal counters cross both thresholds
167-
# (batch_size gates _apply_batch; sync_threshold gates _persist).
168-
# Records below both thresholds stay in memory and are lost on exit.
169+
# Both ran at 2 to answer #1579 (a sub-threshold mine left index_metadata.pickle
170+
# absent, and quarantine_stale_hnsw renamed the segment away). Three findings on
171+
# chromadb 1.5.9 (PersistentClient / Rust bindings, single writer) retire that:
169172
#
170-
# Previously 50k/50k to work around link_lists.bin sparse-file bloat
171-
# in pre-1.5.x Python chromadb (#344). chromadb >=1.5.4 Rust bindings
172-
# (the minimum mempalace supports) do not exhibit that bloat; verified
173-
# at batch_size=2 with 20k records: link_lists.bin = 171 KB, no
174-
# sparse-file inflation.
173+
# * 2 SITS OUTSIDE CHROMA'S OWN DECLARED VALID RANGE. hnsw_params.py:21-22
174+
# validates both knobs as `isinstance(p, int) and p > 2`. Not an inequality
175+
# between the two — a floor on each.
175176
#
176-
# The 50k guard caused #1579: mines under 50k drawers never triggered
177-
# _persist(), leaving index_metadata.pickle absent and link_lists.bin
178-
# empty. quarantine_stale_hnsw then renamed the segment on every cold
179-
# open after a 300s mtime gap, accumulating .drift-* directories.
177+
# * IT COSTS WRITE AMPLIFICATION, measured. Bytes written (/proc/self/io) for
178+
# one mine of N records, 64-dim, num_threads=1, identical corpus and seed:
180179
#
181-
# Lowered to 2 (empirical Rust-side minimum for chromadb >=1.5.4; the
182-
# Rust bindings reject 1 with InvalidArgumentError) so any mine of 2+
183-
# drawers triggers a natural persist. Existing palaces created under
184-
# the old 50k guard keep those thresholds in their collection metadata
185-
# until the user runs repair --mode from-sqlite --archive-existing.
186-
_HNSW_BLOAT_GUARD = {
187-
"hnsw:batch_size": 2,
188-
"hnsw:sync_threshold": 2,
180+
# N 2/2 100/1000 ratio
181+
# 10,000 47.1 MB 28.1 MB 1.67x
182+
# 20,000 127.4 MB 60.0 MB 2.12x
183+
# 40,000 390.3 MB 134.0 MB 2.91x
184+
#
185+
# Two runs at N=20,000 reproduced within 0.1%. sync_threshold dominates:
186+
# 3/3 measured identical to 2/2, and 1000/1000 identical to 100/1000. The
187+
# ratio grows with collection size, so the cost is worst on the largest
188+
# palaces.
189+
#
190+
# * IT BUYS NOTHING. A 5-record collection at 100/1000 — far below the
191+
# threshold, so no persist ever fires — reads back whole from a FRESH
192+
# PROCESS (count and vector query both answer). On that artifact
193+
# link_lists.bin is 0 bytes with no index_metadata.pickle, which is #1579's
194+
# trigger shape exactly, and quarantine_stale_hnsw() creates no drift dir:
195+
# _segment_appears_healthy reads it as never-persisted rather than as a torn
196+
# persist.
197+
#
198+
# NOT claimed here: that a small sync_threshold is a known chroma failure mode.
199+
# No such report was found in chroma's issues or docs, and chroma's own guidance
200+
# runs the other way (raise sync_threshold for bulk inserts). The Python
201+
# persist path that #1579 and chroma#6975 describe does not execute under the
202+
# Rust bindings at all — hnswlib is not a dependency of this line.
203+
#
204+
# A palace keeps whatever thresholds it was created under;
205+
# `repair --mode from-sqlite --archive-existing` re-creates it under these.
206+
_HNSW_WRITE_DEFAULTS = {
207+
"hnsw:batch_size": 100,
208+
"hnsw:sync_threshold": 1000,
189209
}
190210

211+
212+
def _hnsw_creation_metadata(options: Optional[dict]) -> dict:
213+
"""Build the ``metadata=`` dict for a fresh collection from caller options.
214+
215+
Centralizes the HNSW knobs so a multi-collection palace can tune each
216+
collection at creation while the base keeps every config value in the
217+
``collection_metadata`` table where the divergence guard, the cosine-space
218+
detector (``ChromaCollection.distance_metric``), and ``_read_sync_threshold``
219+
already read it. The legacy ``metadata=`` keys are kept deliberately: the
220+
modern ``configuration=`` API stores the same parameters in
221+
``configuration_json`` instead, leaving ``collection.metadata`` empty and
222+
silently blinding all of that existing tooling.
223+
224+
Caller option -> chromadb metadata key:
225+
226+
* ``hnsw_space`` -> ``hnsw:space`` (default ``"cosine"``)
227+
* ``num_threads`` -> ``hnsw:num_threads`` (default 1; serializes inserts)
228+
* ``ef_construction`` -> ``hnsw:construction_ef``
229+
* ``max_neighbors`` -> ``hnsw:M``
230+
* ``sync_threshold`` -> ``hnsw:sync_threshold``
231+
* ``batch_size`` -> ``hnsw:batch_size``
232+
233+
``sync_threshold``/``batch_size`` default to chromadb's own values
234+
(:data:`_HNSW_WRITE_DEFAULTS`), which amortize the index flush across a
235+
mine. A caller tunes them per collection; a caller writing far fewer
236+
records than the threshold still keeps them (the Rust writer holds the
237+
sub-threshold tail durable — see :data:`_HNSW_WRITE_DEFAULTS`).
238+
``ef_construction``/``max_neighbors`` are omitted when the caller does not
239+
set them, so chromadb applies its own defaults.
240+
"""
241+
opts = options if isinstance(options, dict) else {}
242+
md: dict[str, Any] = {
243+
"hnsw:space": opts.get("hnsw_space", "cosine"),
244+
"hnsw:num_threads": int(opts.get("num_threads", 1)),
245+
**_HNSW_WRITE_DEFAULTS,
246+
}
247+
if "ef_construction" in opts and opts["ef_construction"] is not None:
248+
md["hnsw:construction_ef"] = int(opts["ef_construction"])
249+
if "max_neighbors" in opts and opts["max_neighbors"] is not None:
250+
md["hnsw:M"] = int(opts["max_neighbors"])
251+
if "sync_threshold" in opts and opts["sync_threshold"] is not None:
252+
md["hnsw:sync_threshold"] = int(opts["sync_threshold"])
253+
if "batch_size" in opts and opts["batch_size"] is not None:
254+
md["hnsw:batch_size"] = int(opts["batch_size"])
255+
return md
256+
257+
191258
# Below this size, data_level0.bin is too small for a meaningful HNSW graph.
192259
# Used by _hnsw_link_lists_is_usable_for_payload (empty link_lists is fine
193260
# when data is trivially small) and _missing_dimensionality_appears_recoverable
@@ -633,9 +700,10 @@ def _hnsw_element_count(palace_path: str, segment_id: str) -> Optional[int]:
633700
# read the collection metadata (older palaces missing the row, sqlite
634701
# unreadable). 2000 = 2 × chromadb's default sync_threshold of 1000.
635702
#
636-
# Why dynamic: legacy palaces may still carry ``sync_threshold = 50_000``
637-
# (the pre-#1579 guard), so flush-lag can grow up to 50K on those palaces.
638-
# New palaces use sync_threshold=2 (#1579) and flush almost immediately.
703+
# Why dynamic: a palace carries whatever ``sync_threshold`` it was created
704+
# under, and flush-lag grows to that threshold before a persist fires — up
705+
# to 50K on a palace created under an old large guard, and 2 on one created
706+
# under the small guard that #1308 traces back to.
639707
# A fixed 2000 floor would flag actively-written legacy palaces as
640708
# DIVERGED the moment their queue exceeded 10% of sqlite_count, even
641709
# though chromadb is behaving correctly. The floor must scale with the
@@ -2355,11 +2423,7 @@ def get_collection(
23552423
except _ChromaNotFoundError:
23562424
collection = client.create_collection(
23572425
collection_name,
2358-
metadata={
2359-
"hnsw:space": hnsw_space,
2360-
"hnsw:num_threads": 1,
2361-
**_HNSW_BLOAT_GUARD,
2362-
},
2426+
metadata=_hnsw_creation_metadata(options),
23632427
**ef_kwargs,
23642428
)
23652429
except ValueError as e:
@@ -2449,11 +2513,7 @@ def create_collection(
24492513
ef_kwargs = {"embedding_function": ef} if ef is not None else {}
24502514
collection = self._client(palace_path).create_collection(
24512515
collection_name,
2452-
metadata={
2453-
"hnsw:space": hnsw_space,
2454-
"hnsw:num_threads": 1,
2455-
**_HNSW_BLOAT_GUARD,
2456-
},
2516+
metadata=_hnsw_creation_metadata({"hnsw_space": hnsw_space}),
24572517
**ef_kwargs,
24582518
)
24592519
return ChromaCollection(collection, palace_path=palace_path)

mempalace/mcp_server.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@
7171
from .backends.chroma import ( # noqa: E402
7272
ChromaBackend,
7373
ChromaCollection,
74-
_HNSW_BLOAT_GUARD,
74+
_HNSW_WRITE_DEFAULTS,
7575
_pin_hnsw_threads,
7676
hnsw_capacity_status,
7777
reset_hnsw_capacity_cache,
@@ -1176,7 +1176,7 @@ def _get_collection(create=False):
11761176
metadata={
11771177
"hnsw:space": "cosine",
11781178
"hnsw:num_threads": 1,
1179-
**_HNSW_BLOAT_GUARD,
1179+
**_HNSW_WRITE_DEFAULTS,
11801180
},
11811181
**ef_kwargs,
11821182
)

tests/test_backends.py

Lines changed: 76 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -523,13 +523,12 @@ def test_chroma_backend_creates_collection_with_cosine_distance(tmp_path):
523523
assert col.metadata.get("hnsw:space") == "cosine"
524524

525525

526-
def test_chroma_backend_sets_hnsw_bloat_guard_on_creation(tmp_path):
526+
def test_chroma_backend_sets_hnsw_write_defaults_on_creation(tmp_path):
527527
"""HNSW batch/sync thresholds must land on freshly-created collection metadata.
528528
529-
Low thresholds (2/2 per #1579) make chromadb's Rust HNSW segment
530-
persist index_metadata and link_lists after any mine of 2+ drawers.
531-
Asserting both keys land on the persisted metadata also covers the
532-
#1161 "config silently dropped" concern at CI time.
529+
That both keys land covers #1161 (config silently dropped). The VALUES guard
530+
#1308: sync_threshold sets a mine's write amplification, and batch_size must
531+
stay strictly below it (#2526).
533532
"""
534533
palace_path = tmp_path / "palace"
535534

@@ -541,35 +540,43 @@ def test_chroma_backend_sets_hnsw_bloat_guard_on_creation(tmp_path):
541540

542541
client = chromadb.PersistentClient(path=str(palace_path))
543542
col = client.get_collection("mempalace_drawers")
544-
assert col.metadata.get("hnsw:batch_size") == 2
545-
assert col.metadata.get("hnsw:sync_threshold") == 2
543+
batch = col.metadata.get("hnsw:batch_size")
544+
sync = col.metadata.get("hnsw:sync_threshold")
545+
assert batch == 100
546+
assert sync == 1000
547+
assert batch < sync, "chroma requires batch_size < sync_threshold (#2526)"
546548

547549

548-
def test_chroma_backend_create_collection_sets_hnsw_bloat_guard(tmp_path):
549-
"""Same guard must apply via the legacy create_collection() path."""
550+
def test_chroma_backend_create_collection_sets_hnsw_write_defaults(tmp_path):
551+
"""The same defaults must apply via the legacy create_collection() path."""
550552
palace_path = tmp_path / "palace"
551553

552554
ChromaBackend().create_collection(str(palace_path), "mempalace_drawers")
553555

554556
client = chromadb.PersistentClient(path=str(palace_path))
555557
col = client.get_collection("mempalace_drawers")
556-
assert col.metadata.get("hnsw:batch_size") == 2
557-
assert col.metadata.get("hnsw:sync_threshold") == 2
558+
assert col.metadata.get("hnsw:batch_size") == 100
559+
assert col.metadata.get("hnsw:sync_threshold") == 1000
558560

559561

560-
def test_sub_threshold_mine_persists_hnsw_metadata(tmp_path):
561-
"""Regression for #1579: small mines must persist HNSW metadata.
562+
def test_sub_threshold_mine_survives_reopen_and_escapes_quarantine(tmp_path):
563+
"""Regression for #1579, asserted as the PROPERTY, not the mechanism.
562564
563-
_HNSW_BLOAT_GUARD sets batch_size=2 and sync_threshold=2 so that any
564-
upsert of 2+ records crosses both thresholds, triggering chromadb's
565-
_apply_batch and _persist. Without this, index_metadata and link_lists
566-
stay empty and quarantine_stale_hnsw renames the segment on cold open.
565+
#1579 is a durability bug: a sub-threshold mine lost its drawers when
566+
quarantine_stale_hnsw renamed the segment away on cold open. So assert what
567+
the user needs — the drawers read back from a FRESH backend, and quarantine
568+
leaves the segment alone.
569+
570+
Asserting the mechanism instead (index_metadata.pickle present after 3
571+
records) only holds at sync_threshold=2, which buys #1308. Under chromadb's
572+
own thresholds the Rust writer keeps the tail durable WITHOUT a pickle, so
573+
the pickle is rightly absent here and asserting on it would fail a healthy
574+
palace.
567575
"""
568576
palace_path = str(tmp_path / "palace")
569577
backend = ChromaBackend()
570578
try:
571579
col = backend.get_collection(palace_path, "mempalace_drawers", create=True)
572-
573580
col.upsert(
574581
ids=["a", "b", "c"],
575582
documents=["doc a", "doc b", "doc c"],
@@ -579,34 +586,65 @@ def test_sub_threshold_mine_persists_hnsw_metadata(tmp_path):
579586
finally:
580587
backend.close()
581588

582-
found_healthy_segment = False
589+
# Quarantine runs on cold open; stale_seconds=0.0 forces the integrity gate
590+
# onto every segment regardless of mtime delta, so the gate itself is proven.
591+
moved = quarantine_stale_hnsw(palace_path, stale_seconds=0.0)
592+
assert moved == [], f"quarantine fired on a healthy sub-threshold segment: {moved}"
593+
583594
for entry in (tmp_path / "palace").iterdir():
584-
if not entry.is_dir() or entry.name.startswith("."):
585-
continue
586-
meta = entry / "index_metadata.pickle"
587-
link = entry / "link_lists.bin"
588-
data = entry / "data_level0.bin"
589-
if data.exists() and data.stat().st_size > _HNSW_MISSING_METADATA_DATA_FLOOR:
590-
assert meta.exists(), "index_metadata missing after sub-threshold upsert"
591-
assert link.exists() and link.stat().st_size > 0, "link_lists empty"
595+
if entry.is_dir() and not entry.name.startswith("."):
592596
assert _segment_appears_healthy(str(entry))
593-
found_healthy_segment = True
594597

595-
assert found_healthy_segment, "no VECTOR segment with data found"
598+
# The durability claim: a FRESH backend still finds every drawer, and the
599+
# vector index still answers.
600+
reopened = ChromaBackend()
601+
try:
602+
col = reopened.get_collection(palace_path, "mempalace_drawers")
603+
assert col.count() == 3, "sub-threshold mine lost drawers across reopen"
604+
hits = col.query(query_embeddings=[[0.1] * _TEST_EMBED_DIM], n_results=3)
605+
assert len(hits["ids"][0]) == 3, "vector index empty after reopen"
606+
finally:
607+
reopened.close()
608+
596609

597-
# stale_seconds=0.0 forces the stage-2 integrity gate (_segment_appears_healthy)
598-
# to run on every segment regardless of mtime delta, proving the fix directly.
599-
moved = quarantine_stale_hnsw(palace_path, stale_seconds=0.0)
600-
assert moved == [], f"quarantine fired on freshly-persisted segment: {moved}"
610+
def test_hnsw_write_defaults_bound_index_rewrites(tmp_path):
611+
"""Regression for #1308: the thresholds must bound write amplification.
612+
613+
chromadb rewrites the ENTIRE on-disk index once sync_threshold records
614+
accumulate, so a mine of N drawers costs N/sync_threshold full rewrites of a
615+
multi-megabyte segment. At 2, a 26k-drawer mine fires ~13,000 — enough to
616+
wedge the compactor and to tear a persist mid-pickle.
617+
618+
Arithmetic, not a live mine: a realistic corpus must cost rewrites in the
619+
tens, never the thousands.
620+
"""
621+
palace_path = tmp_path / "palace"
622+
ChromaBackend().get_collection(
623+
str(palace_path), collection_name="mempalace_drawers", create=True
624+
)
625+
client = chromadb.PersistentClient(path=str(palace_path))
626+
col = client.get_collection("mempalace_drawers")
627+
628+
sync = col.metadata.get("hnsw:sync_threshold")
629+
batch = col.metadata.get("hnsw:batch_size")
630+
631+
assert batch < sync, "chroma requires batch_size < sync_threshold (#2526)"
632+
633+
realistic_corpus = 26_000
634+
rewrites = realistic_corpus / sync
635+
assert rewrites <= 100, (
636+
f"sync_threshold={sync} costs ~{rewrites:.0f} full index rewrites over a "
637+
f"{realistic_corpus}-drawer mine — that is the #1308 corruption path"
638+
)
601639

602640

603641
def test_single_record_upsert_not_quarantined(tmp_path):
604642
"""A single-record upsert must not trigger quarantine.
605643
606-
With batch_size=2 chromadb only persists HNSW metadata after the second
607-
record. A one-record segment has no index_metadata.pickle and no
608-
link_lists.bin data; _segment_appears_healthy must treat that combination
609-
as sub-threshold (never persisted), not as corruption.
644+
A one-record segment sits below the HNSW thresholds, so chromadb never
645+
persists: no index_metadata.pickle, no link_lists.bin data.
646+
_segment_appears_healthy must read that combination as sub-threshold
647+
(never persisted), not as corruption.
610648
"""
611649
palace_path = str(tmp_path / "palace")
612650
backend = ChromaBackend()
@@ -657,7 +695,7 @@ def test_get_collection_create_true_preserves_existing_metadata(tmp_path):
657695
backend.get_collection(palace, collection_name="mempalace_drawers", create=True)
658696
col = backend.get_collection(palace, collection_name="mempalace_drawers", create=True)
659697
assert col._collection.metadata["hnsw:space"] == "cosine"
660-
assert col._collection.metadata.get("hnsw:batch_size") == 2
698+
assert col._collection.metadata.get("hnsw:batch_size") == 100
661699

662700

663701
def test_fix_blob_seq_ids_converts_blobs_to_integers(tmp_path):

tests/test_hnsw_capacity.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -289,9 +289,9 @@ def test_capacity_status_quiet_for_empty_palace(tmp_path):
289289
def test_capacity_status_tolerates_lag_under_large_sync_threshold(tmp_path):
290290
"""Regression for the PR #1191 / PR #1227 conflict.
291291
292-
Palaces created via mempalace's _HNSW_BLOAT_GUARD (sync_threshold=
293-
50_000) naturally accumulate up to ~50K queued entries between
294-
flushes. The pickle-vs-sqlite probe must scale its tolerance to
292+
A palace carrying a large sync_threshold (50_000) naturally accumulates
293+
up to ~50K queued entries between flushes. The pickle-vs-sqlite probe
294+
must scale its tolerance to
295295
``2 × sync_threshold`` so this expected lag is not flagged as
296296
corruption — otherwise vector search disables for ~80% of the
297297
write cycle on any actively-mined ≥100K palace.

0 commit comments

Comments
 (0)