THE SHORT ANSWER

Chroma's metadata filters (where) translate to almost any vector database. Its document filters (where_document) are the hard part: substring and regex matching on the text itself.

Many vector databases have no equivalent for document filters, which means redesigning queries mid-migration. TopK supports every Chroma operator, including regex.

Metadata filters (where)

In TopK, filters are expressions you build with field() and pass to .filter():

ChromaTopK
{"k": v} or {"k": {"$eq": v}}field("k") == v
{"k": {"$ne": v}}field("k") != v
{"k": {"$gt": v}}, $gte, $lt, $ltefield("k") > v, >=, <, <=
{"k": {"$in": [a, b]}}field("k").in_([a, b])
{"k": {"$nin": [a, b]}}not_(field("k").in_([a, b]))
{"tags": {"$contains": "x"}} (list)field("tags").contains("x")
{"tags": {"$not_contains": "x"}} (list)not_(field("tags").contains("x"))
{"$and": [a, b]}(a) & (b)
{"$or": [a, b]}(a) | (b)

Wrap each comparison in parentheses when you combine them. Python binds & and | tighter than ==, so field("a") == 1 & field("b") == 2 does not mean what it looks like.

Document filters (where_document)

ChromaTopK
{"$contains": "text"}field("document").contains("text")
{"$not_contains": "text"}not_(field("document").contains("text"))
{"$regex": "pattern"}field("document").regexp_match("pattern")
{"$not_regex": "pattern"}not_(field("document").regexp_match("pattern"))

Both $contains and contains() are case-sensitive. regexp_match also takes flags, such as "i" for case-insensitive. Regex at scale needs the right index; see how to index text for regex.

This is where migrations stall elsewhere. Databases built only for vectors often have no way to filter on the document text, so those queries need a redesign.

A full query, side by side

Chroma:

collection.query(
    query_embeddings=[q],
    n_results=10,
    where={"$and": [{"year": {"$gte": 2020}}, {"lang": "en"}]},
    where_document={"$contains": "refund"},
)

TopK:

from topk_sdk.query import select, field, fn

client.collection("docs").query(
    select("document", score=fn.vector_distance("embedding", q))
    .filter((field("year") >= 2020) & (field("lang") == "en"))
    .filter(field("document").contains("refund"))
    .sort(field("score"), asc=False)
    .limit(10)
)

Filters run inside the search, not after it, so a narrow filter never leaves you with fewer than 10 results. That's the failure filtering often causes in HNSW-based databases.

What changes in field names?

TopK field names can't start with _, and id, document and embedding are reserved. The migration script renames those keys with a meta_ prefix, so update matching filters. A key with mixed types becomes text, so numeric comparisons on it need a fix too.

Going further than Chroma

Beyond a straight mapping, you can rank by keyword relevance with BM25 instead of only filtering on it, or combine keyword and vector scores with true hybrid search. Per-tenant filters can become partitions. The full query syntax is in the query docs.

See everything that changes from Chroma to TopK, or book a migration call and we'll map your filters with you.