Chroma#
pip install "wardowl[chroma]"
The permission filter becomes Chroma’s where clause, so Chroma filters inside the search.
Set up#
import chromadb
from wardowl.backends.chroma import ChromaBackend
client = chromadb.PersistentClient(path="./chroma")
collection = client.get_or_create_collection("docs", configuration={"hnsw": {"space": "cosine"}})
backend = ChromaBackend(collection) # uses the collection's embedding function
Pass embed= to embed queries and documents with your own model instead of the collection’s
embedding function. Scores are 1 - distance, so create the collection with cosine distance for
scores between -1 and 1. add(documents, embeddings=None) upserts by id and delete(ids) removes
chunks.
Chroma refuses a write of more records than its maximum batch size (about 5,400 for a local
client), so add, delete and set_metadata send at most batch_size records per call. It
defaults to the client’s own maximum (client.get_max_batch_size()), or 1,000 when the collection
does not report one; pass ChromaBackend(collection, batch_size=500) to choose. Each write is
checked whole before its first batch is sent, but the batches are separate calls: one that fails
part way leaves the batches before it written, so repeat the call.
How metadata is stored#
wardowl does not use Chroma’s array metadata, and the reason is a real leak. In chromadb 1.5.x
(local mode), deleting a collection leaves its array metadata behind, and because SQLite reuses
record ids, new records in other collections come back with those arrays and are matched by
where filters (chroma-core/chroma#7594).
For permission data, that means a chunk can appear to belong to groups it was never shared with.
So the backend stores metadata in a form that bug cannot affect:
Your metadata |
Stored in Chroma |
|---|---|
|
|
|
One boolean field per value: |
Everything |
An exact JSON copy under |
Search results are rebuilt from the JSON copy, never from the fields Chroma returns, so the retriever’s re-check always sees the true permissions. Records written by something other than wardowl have no copy and are treated as having no permissions: the re-check denies them.
Field names, in metadata and in filters, may not contain ::, and wardowl_metadata is
reserved: a filter on such a name would test a list field or the JSON copy, and filter_to_where
refuses it with ValueError. Values that are not strings or integers are kept in the JSON copy
only, which matches the reference semantics: no filter matches them.
set_metadata is an update, which Chroma applies to stored records only, so a chunk deleted
meanwhile is not recreated, and its text and vector are never read and written back. Fields of the
stored metadata that the new metadata lacks are removed in the same update, so no old list field
survives. Reading the stored fields and updating them are two calls, though, so an add of the same
chunk between them can leave list fields the update does not know of. Every search therefore
checks each hit’s JSON copy against the filter, and drops a hit that the filter matches only
through such a field.
How the filter becomes where#
In(field, values) becomes an $or of $in on the scalar field (one per value type, since
Chroma’s $in lists hold a single type) and an $eq: true test on each value’s list field.
And and Or become $and and $or. A filter that can match nothing, such as In(field, []),
skips the query entirely; filter_to_where raises NothingMatches for it rather than return {},
which Chroma reads as no filter at all.
Exact or approximate#
Chroma searches an HNSW index, and the backend has no switch for exact search, so treat a filtered
search as approximate, like an unfiltered one. In the benchmarks (local Chroma
1.5.9, default settings), pre-filtered searches found 0.996 to 1.000 of the true top 10 for users
who may read 0.06% to 20% of the collection, and 0.990 with everything readable, which is the
index’s own recall: high, but not exact. A result can then miss a chunk the user may read and still
say complete=True (Concepts). Chroma’s HNSW settings,
such as ef_search in the collection’s hnsw configuration, trade latency for recall.
Performance#
In the benchmarks (57,600 chunks, local Chroma 1.5.9), pre-filtered queries kept recall at 0.990 or better, but took 200 to 900 ms against about 18 ms unfiltered, rising with the number of groups in the filter. So:
When users may read a small part of the collection, keep the default (
auto, which pre-filters): it is the only strategy that finds their documents.When users may read most of it,
strategy="postfilter"returns the same results far faster (20 ms instead of 876 ms when everything was readable). That holds when post-filtering sends Chroma nowhere, as withMetadataACLAuthorizerin the benchmark. AnAllOfwhose parts all give filters sends its whole filter as a scope even withstrategy="postfilter"(Concepts), so post-filtering is then no faster.Grant access through a few broad groups (team, department, division) rather than hundreds of individual groups, which keeps the
wheredocument small.
Chroma’s own tenants and access control#
wardowl applies permissions itself, as a filter inside each query and a check of each result, so it never relies on Chroma’s tenants, databases or authorization providers. Do not rely on them to separate your tenants either: as of October 2026, Chroma 1.5.9 (the latest release) has unfixed advisories in which an authenticated user can reach any tenant’s collections (CVE-2026-45830, CVE-2026-45831), and two that let a request run code on a Chroma server (CVE-2026-45829, CVE-2026-45833). If you run a Chroma server, keep it on a private network, reachable only by your application.