Overview
Chroma filters on two axes at once: where on metadata and where_document on stored document text, both combined with vector similarity in query and without it in get. Loose filters fill the context window with off-topic chunks, and a filter that matches nothing returns an empty result that downstream code mistakes for “no answer”. These rules keep filters correct.
Always pass a where filter when a scope applies
An unfiltered query searches the whole collection. For multi-tenant collections that leaks rows across tenants, so filter by tenant_id on every query, ideally in one wrapper function that cannot be bypassed.
results = collection.query(
query_embeddings=[query_vec],
n_results=10,
where={"$and": [{"tenant_id": "acme"}, {"lang": "en"}]},
)n_results counts matches after filtering. A selective filter returns fewer than n_results rows rather than padding the list with non-matching ones.
Combine conditions with an explicit $and
A where dict must have exactly one top-level operator. {"tenant_id": "acme", "lang": "en"} raises ValueError; wrap the conditions in $and, or use $or for alternatives.
where={"created_at": {"$gte": 1736294400}} # range
where={"source": {"$in": ["zendesk", "confluence"]}} # set inclusion
where={"lang": {"$ne": "de"}} # exclusion
where={"$or": [{"source": "zendesk"}, {"source": "confluence"}]} # alternatives
where={"tags": {"$contains": "billing"}} # array metadataMetadata operators are $eq, $ne, $gt, $gte, $lt, $lte, $in, $nin, $and, $or, plus $contains and $not_contains for array values. Arrays hold strings, integers, floats, or booleans of one type; empty and nested arrays are not allowed. Use the operators instead of filtering in Python, which defeats the filter. Keep metadata types consistent so range filters do not skip rows; see chromadb-collections.
Use where_document for rare keywords, not as the retriever
where_document supports $contains, $not_contains, $regex, $not_regex, $and, and $or. Matching is case-sensitive and returns no ranking.
results = collection.query(
query_embeddings=[query_vec],
n_results=20,
where={"tenant_id": "acme"},
where_document={"$contains": "ERR_4012"},
)Use it to require an exact token the embedding may blur: error codes, SKUs, identifiers. Pair it with a where filter. Do not treat it as the primary retrieval path; it returns unranked matches.
Over-fetch and rerank in the application
When the filter is tight, or the question needs precise ordering, fetch more than you need and rerank before building the prompt. Fifty candidates reranked by a cross-encoder cost milliseconds; fifty chunks in the prompt cost tokens. See rag-reranking for the pattern and rag-retrieval for the retrieval loop.
Merge BM25 and vector results yourself
The local Chroma server has no BM25 index. Chroma’s search API with hybrid ranking is marked experimental and works only on Chroma Cloud. For a local deployment, run a keyword search alongside the vector query and merge the ranked id lists with reciprocal rank fusion.
def rrf(*ranked_id_lists, k=60):
scores = {}
for ids in ranked_id_lists:
for rank, doc_id in enumerate(ids):
scores[doc_id] = scores.get(doc_id, 0) + 1 / (k + rank + 1)
return sorted(scores, key=scores.get, reverse=True)Use the keyword path when queries contain rare tokens: product codes, error messages, proper nouns. See rag-retrieval for the full pattern.
Fail loudly on filters that match nothing
A filter that silently returns zero rows is worse than an error. In development and tests, assert that a filter matches at least one record.
def assert_filter_matches(collection, where):
if not collection.get(where=where, limit=1)["ids"]:
raise ValueError(f"filter {where} matches nothing in {collection.name}")Zero-match filters are the usual cause of “the model said it found nothing” bugs.