A wrapper around ChromaDB supporting in-memory or persistent
storage, automatic batching for large upserts, optional multi-tenant isolation via a
tenant_id field injected into every document's metadata, and both a modern
(DocumentChunk-list) and legacy (explicit ids/documents/metadatas) calling convention
for add().
normalize_metadata (module-level helper)
Python
defnormalize_metadata(metadata: Any) -> Dict
Converts None → {}, passes a dict through unchanged, converts a dataclass instance via
asdict(), and raises TypeError for any other type. Not listed in __all__ /
vector_database/__init__.py, so it's an internal helper of this module rather than a
package-level public export — documented here because add() uses it directly on chunk
metadata before merging in doc_id.
(A dict rather than a set — keys are the metric names this wrapper accepts; values are what's
passed through to ChromaDB's hnsw:space collection setting.)
If given, uses chromadb.PersistentClient(path=...); otherwise chromadb.Client() (in-memory).
distance_metric
str
"cosine"
Must be a key of SUPPORTED_METRICS.
tenant_id
Optional[str]
None
If set, injected into every document's metadata and into every query's where filter.
batch_size
int
500
Clamped to at least 1 via max(1, batch_size).
strict_mode
bool
True
When True, add() validates that every ID is a non-empty string.
Raises:ImportError if chromadb isn't installed; ValueError for an unsupported
distance_metric; RuntimeError if client or collection initialization fails.
Initialization workflow
super().__init__(embedder).
Validate distance_metric, map it to self.metric_space.
_initialize_client(persist_directory) — persistent or in-memory client.
_initialize_collection() — tries get_collection(name=...); on any exception, falls back
to create_collection(name=..., metadata={"hnsw:space": self.metric_space}).
[!NOTE] Existing collections keep their original distance metric
Because _initialize_collection only sets hnsw:space when creating a new collection,
reopening an existing collection with a different distance_metric argument does not
change the collection's actual metric — Chroma keeps whatever metric it was created with.
self.distance_metric on the Python object reflects the constructor argument, which can
silently diverge from the collection's real configuration in that scenario.
Thread safety
Not addressed in source.
Properties
Python · 4 lines
@propertydefclient(self) -> Any# raises RuntimeError if not initialized@propertydefcollection(self) -> Collection # raises RuntimeError if not initialized
Both simply guard against None and return the underlying ChromaDB objects, giving escape-hatch
access to the native client/collection API for anything this wrapper doesn't expose.
[!DANGER] Legacy calling style is broken in this version of the source
In the legacy branch, the function body reads:
Python · 3 lines
ids = chunks # first positional arg treated as ids
documents = documents # references a name that is NOT a parameter of add()
metadatas = [sanitize_metadata(m) for m in (metadatas or [])] # same issue
documents and metadatas are not parameters of add() — there is no
documents=/metadatas= parameter in the signature
(def add(self, chunks, embeddings=None)), so referencing those names raises
NameError unless a variable with that exact name happens to already exist in an
enclosing scope. In practice, calling add() in the documented legacy style as shown in
the docstring will raise NameError, not perform an upsert. This is a defect in the
source as provided, not a documentation gap — see
Troubleshooting for a full
explanation and workaround.
Common workflow after ids/documents/metadatas are resolved:
_validate_inputs(ids, documents, metadatas, embeddings) — raises ValueError if any of
ids/documents/metadatas is empty, if their lengths mismatch, if embeddings length
mismatches, or (when strict_mode=True) if any ID is not a non-empty string.
If embeddings is None, requires self.embedder (else ValueError), then calls
self.embedder.encode(documents), wrapping failures in RuntimeError.
Cast embeddings to float32, run _validate_embeddings (checks 2-D shape and consistent
dimension across calls — the first call's dimension becomes self.embedding_dim).
Iterate in batches of self.batch_size, apply _enforce_tenant_isolation (injects
tenant_id into each batch's metadata copies), then self.collection.upsert(ids=..., documents=..., metadatas=..., embeddings=batch_emb.tolist()).
Raises:ValueError for invalid input shapes; RuntimeError wrapping upsert failures
(per-batch, naming the failing batch's start index).
Complexity
Upsert cost is whatever ChromaDB's own HNSW-backed collection charges per batch; this wrapper
adds only O(n) bookkeeping (validation, batching, tenant-tag injection) on top.
For each hit: convert Chroma's distance to a similarity score via
_distance_to_score; drop it if below score_threshold;
reconstruct a DocumentChunk (doc_id taken from metadata if present, else falls back to
the Chroma document ID itself; the returned chunk's metadata excludes the doc_id key
since it's promoted to the DocumentChunk.doc_id field).
Returns:List[Tuple[DocumentChunk, float]].
Raises:ValueError for bad top_k, missing embedder on a text query, or dimension
mismatch; RuntimeError wrapping query failures.
This means the numeric range and meaning of the returned "score" is metric-dependent — a
score_threshold tuned for cosine similarity ([0, 1]-ish) is not comparable to one tuned for
negated L2 distance.
Delete operations
delete_by_ids
Python
defdelete_by_ids(self, ids: List[str]) -> int
Empty list → returns 0 immediately. Otherwise self.collection.delete(ids=ids) and returns
len(ids) — note this is the requested count, not a confirmed-deleted count from Chroma.
delete_by_filter
Python
defdelete_by_filter(self, filters: Dict) -> int
Empty filters → warns and returns 0. Applies tenant isolation the same way search does,
fetches matching IDs via self.collection.get(where=filters), deletes them, and returns the
actual number of IDs that matched (this one is a confirmed count, unlike delete_by_ids).
clear_collection
Python
defclear_collection(self) -> int
Reads the current count, fetches every ID via collection.get(), deletes them all, and returns
the pre-deletion count. This cannot be undone (per the source's own docstring warning).
Returns (document_text, metadata) pairs (note: notDocumentChunk objects, unlike every
other cross-backend method — this one keeps Chroma's native shape).
remove_by_doc_id only works correctly if doc_id was actually stored as a metadata key —
which it is, whenever chunks were added via the unified DocumentChunk-list style (add()
injects "doc_id": c.doc_id into metadata). If chunks were added via the legacy style (setting
aside that it currently raises NameError — see above) with metadata that never included
doc_id, remove_by_doc_id would match nothing.
Context manager support
batch_operation
Python · 2 lines
@contextmanagerdefbatch_operation(self)
A thin try/except/finally wrapper around a yield self that logs start/success/failure/
finalization — it does not buffer or defer writes; calls made inside the with block still
hit ChromaDB immediately. Its value is centralized logging/error propagation around a group of
calls, not batching in the performance sense.
All are asyncio.to_thread(...) wrappers, same pattern as the other backends.
Common mistakes
Calling add() in the legacy keyword style as shown in its own docstring — currently raises
NameError. Use the DocumentChunk-list style instead. See
Troubleshooting.
Comparing score_threshold values across different distance_metric settings — the scale
and direction of "score" changes with the metric (see _distance_to_score).
Assuming get_by_ids returns DocumentChunk objects like other backends' equivalent
methods — it returns (text, metadata) tuples instead.
Expecting distance_metric passed to the constructor to retroactively change an
already-existing collection's actual similarity metric — it doesn't.