mempalace.backends.chroma
Source: mempalace/backends/chroma.py
ChromaDB-backed MemPalace storage backend (RFC 001 reference implementation).
Classes
class ChromaCollection(BaseCollection)
Thin adapter translating ChromaDB dict returns into typed results.
When palace_path is set, all write methods (add, upsert, update, delete) acquire mine_palace_lock(palace_path) for the duration of the underlying chromadb call. This serializes MCP and other direct-backend writers against mempalace mine and against each other, closing the race between concurrent writers that triggers ChromaDB's multi-threaded HNSW corruption (#974/#965).
The lock is the same primitive used by miner.mine() so re-entrant acquisition from inside the mine pipeline (mine -> _mine_body -> collection.upsert) is short-circuited by the per-thread guard inside mine_palace_lock — no self-deadlock.
palace_path=None disables the wrapping, preserving the legacy no-lock behaviour for callers that construct a ChromaCollection directly without going through ChromaBackend.
__init__
def __init__(self, collection, palace_path: Optional[str] = None, backend = None)add
def add(self, *, documents, ids, metadatas = None, embeddings = None)upsert
def upsert(self, *, documents, ids, metadatas = None, embeddings = None)update
def update(self, *, ids, documents = None, metadatas = None, embeddings = None)query
def query(self, *, query_texts = None, query_embeddings = None, n_results = 10, where = None, where_document = None, include = None) -> QueryResultget
def get(self, *, ids = None, where = None, where_document = None, limit = None, offset = None, include = None) -> GetResultdelete
def delete(self, *, ids = None, where = None)count
def count(self)lexical_search
def lexical_search(self, *, query: str, n_results: int = 10, where: Optional[dict] = None) -> LexicalResultReturn lexical BM25 candidates for this collection.
This is the normal healthy-Chroma implementation behind the optional backend capability. The HNSW-disabled fallback in searcher.py still reads chroma.sqlite3 directly and remains Chroma-only.
metadata
def metadata(self) -> dictPass-through to the underlying ChromaDB collection's metadata.
Used by the searcher to detect legacy palaces that were created without hnsw:space=cosine and therefore silently use L2 distance, which breaks cosine-based similarity interpretation. Returns {} when metadata is absent so callers can do a plain .get("hnsw:space") without None-checks.
distance_metric
def distance_metric(self) -> strReport this collection's actual space from hnsw:space.
MemPalace sets hnsw:space=cosine on every creation path, so a healthy palace reports "cosine". When the key is absent, empty, or an unrecognized value, the collection is genuinely using Chroma's HNSW default — L2 (Euclidean) — because cosine was never set on it. We report "l2" in that case so core ranking maps the distances correctly; reporting "cosine" here would reintroduce the floor-every-result-to-zero misranking this property exists to fix.
get_stored_embedder_identity
def get_stored_embedder_identity(self)set_embedder_identity
def set_embedder_identity(self, identity) -> Noneclass ChromaBackend(BaseBackend)
MemPalace's default ChromaDB backend.
Maintains two caches:
self._clients—palace_path -> PersistentClientfor callers using thePalaceRef/ :meth:get_collectionpath.- An inode+mtime freshness check absorbed from
mcp_server._get_client(merged via #757) ensuring a palace rebuild on disk is detected on the next :meth:get_collectioncall.
__init__
def __init__(self)make_client
def make_client(palace_path: str)Create a fresh PersistentClient (runs pre-open safety pass first).
Deprecated-ish: exposed for legacy long-lived callers that manage their own client cache. New code should obtain a collection through :meth:get_collection which manages caching internally.
Quarantines HNSW segments on first open and after any detected disk change. See :attr:_quarantined_paths for the gate logic.
backend_version
def backend_version() -> strReturn the installed chromadb package version string.
get_collection
def get_collection(self, *args, **kwargs) -> ChromaCollectionObtain a collection for a palace.
Supports two calling conventions during the RFC 001 transition:
- New (preferred):
get_collection(palace=PalaceRef, collection_name=..., create=False, options=None). - Legacy:
get_collection(palace_path, collection_name, create=False)— still used by callers not yet migrated.
close_palace
def close_palace(self, palace) -> NoneDrop cached handles for palace and release its SQLite file lock.
Accepts PalaceRef or legacy path str. chromadb's rust-side file lock is held until PersistentClient.close() is called, so plain dict eviction would leave the palace path unreopenable and unremovable in the same process.
close
def close(self) -> Nonehealth
def health(self, palace: Optional[PalaceRef] = None) -> HealthStatusdetect
def detect(cls, path: str) -> boolReturn True when path looks like a chroma palace.
Verifies the SQLite magic header rather than file presence alone. Bare sqlite3.connect() against a missing path leaves a 0-byte file behind (the SQLite header is written on the first statement, not on connection), so file-presence alone treats those artifacts as real chroma palaces and breaks multi-backend resolution. The 16-byte SQLite format 3\x00 magic prefix is written as soon as chromadb's PersistentClient does any work, so this check accepts every real chroma palace while rejecting empty / garbage files. See #1893.
get_or_create_collection
def get_or_create_collection(self, palace_path: str, collection_name: str) -> ChromaCollectionLegacy shim for get_collection(..., create=True) by path string.
delete_collection
def delete_collection(self, palace_path: str, collection_name: str) -> NoneDelete collection_name from the palace at palace_path.
create_collection
def create_collection(self, palace_path: str, collection_name: str, hnsw_space: str = 'cosine') -> ChromaCollectionCreate (not get-or-create) collection_name with the given HNSW space.
Functions
quarantine_stale_hnsw
def quarantine_stale_hnsw(palace_path: str, stale_seconds: float = 300.0) -> list[str]Rename HNSW segment dirs that look unsafe to open.
This catches two classes of HNSW corruption before ChromaDB opens the native segment reader:
- stale-by-mtime segments whose
index_metadata.picklefails the existing format sniff-test; - structurally impossible HNSW payloads where
link_lists.binis much larger thandata_level0.bin.
The second check is intentionally not gated by mtime. A segment with a 300x link/data ratio is unsafe regardless of whether its mtime is recent; letting Chroma open it can SIGSEGV before Python fallback code runs.
The original directory is renamed, not deleted, so recovery remains possible if the heuristic ever misfires.
reset_hnsw_capacity_cache
def reset_hnsw_capacity_cache() -> NoneForget every cached capacity verdict.
The signature check already picks up on-disk changes on its own; this is for callers that drop all cached palace state at once (tool_reconnect, _force_chroma_cache_reset) and for tests that want a probe to run unconditionally. Bumps the generation so a probe already running cannot re-store the entry this call just dropped.
hnsw_capacity_status
def hnsw_capacity_status(palace_path: str, collection_name: str = 'mempalace_drawers') -> dictCompare sqlite embedding count against HNSW element count.
The #1222 failure mode: max_elements froze at 16 384 while sqlite accumulated 192 997 embeddings. Every subsequent tool call segfaulted when chromadb tried to load the undersized HNSW. This probe runs before anything touches the segment so we can warn (or fall back to BM25) instead of crashing.
Returns a dict with:
segment_id— VECTOR segment UUID, orNoneif no palacesqlite_count— embeddings present in chroma.sqlite3hnsw_count— elements chromadb's pickle knows aboutdivergence—sqlite_count - hnsw_countwhen both knowndiverged— True when divergence exceeds the thresholdstatus—"ok"|"diverged"|"unknown"message— human-readable summary
Never raises — a probe that throws would defeat the point.
A fully-measured verdict is cached per (palace_path, collection_name) and reused while every file the probe reads is unchanged on disk (#1471). Each call otherwise costs a COUNT(*) over the embeddings table and a full unpickle of the segment metadata — the two dominant costs — plus a few small sqlite reads, on a path every search, duplicate check and status call runs through. A verdict the probe could not fully measure (sqlite_count is None from a locked database, or there is no palace yet) is returned but never cached, so a transient failure cannot pin a false reading.
Freshness comes from an (inode, mtime_ns, size) signature rather than a wall-clock TTL, so an external writer — mempalace repair, a peer mine, another process — invalidates the verdict as soon as it touches the files, instead of leaving the #1222 guard blind for a fixed window. _CAPACITY_CACHE_MAX_AGE_SECONDS caps how long one verdict may be reused, but only as a backstop for filesystems with coarse timestamps; the signature is what makes the verdict fresh.
Unlike :meth:ChromaBackend._client, which tolerates a 0.01 s mtime epsilon to avoid rebuilding an expensive client, this compares exactly: re-running the probe costs milliseconds, whereas serving one stale verdict can route a query into a diverged segment.
sqlite_room_wing_hall_counts
def sqlite_room_wing_hall_counts(palace_path: str, collection_name: str) -> Optional[list[tuple]]Grouped (room, wing, hall, n, last_date) from chroma.sqlite3.
last_date is the newest date metadata value in the group, so find_tunnels can still report recent without paging every drawer (build_graph only ever uses the maximum). Returns None when sqlite cannot be trusted, so the caller falls back to the client path.
sqlite_list_id_metadata
def sqlite_list_id_metadata(palace_path: str, collection_name: str, where: Optional[dict] = None) -> Optional[tuple[list[str], list[dict]]]All matching drawer ids + metadata from sqlite, without opening HNSW.
Documents are deliberately excluded: chroma:document lives in the same embedding_metadata table, and joining it in would materialize the whole palace's verbatim text (hundreds of MB on a six-figure palace) just to render one page of previews. Callers hydrate the page they display via :func:sqlite_documents_for_ids.
where supports equality on wing/room and $and of those, matching tool_list_drawers; it is applied in SQL. Returns None when sqlite cannot be trusted so the caller can fall back to col.get paging.
sqlite_documents_for_ids
def sqlite_documents_for_ids(palace_path: str, collection_name: str, ids: list) -> Optional[dict]{drawer_id: document} for ids only, straight from sqlite.
Hydrates previews for the page being displayed without opening HNSW and without reading the rest of the palace's text.
Resolved in two indexed steps rather than one join: embedding_id is only indexed as part of UNIQUE (segment_id, embedding_id), so a join that filters on it alone degenerates into a full scan of embedding_metadata — 5.7s for a 20-row page on a 165k-drawer palace. Seeking the segment first, then embedding_metadata's (id, key) primary key, keeps both steps on an index.
quarantine_invalid_hnsw_metadata
def quarantine_invalid_hnsw_metadata(palace_path: str) -> list[str]Quarantine segment dirs whose index_metadata.pickle is unreadable or invalid.
Chroma's persisted HNSW metadata is untrusted disk state. If a segment has labels but invalid or partial metadata, current Chroma versions can accept the pickle and crash later in the Rust loader. We rename the entire segment out of the way before PersistentClient opens so Chroma can rebuild cleanly instead of touching known-bad metadata.
