If you run an agent's retrieval on Chroma, the single biggest quality lever you have is hybrid search — dense vectors to catch meaning, sparse keyword matching to catch the exact acronym, part number, or error code your embeddings smear away. Chroma Cloud now does this with a purpose-built API, and the shape is different enough from the old query()/get() calls that copy-pasting last year's code won't work. Here's the version that actually runs.
The new Search API, in one shape#
Chroma Cloud replaced the query()/get() split with a single fluent builder:
from chromadb import Search, Knn, K
results = collection.search(
Search()
.where(K("category") == "animals") # optional metadata pre-filter
.rank(Knn(query="a fast fox")) # dense vector search, auto-embedded
.limit(10)
.select(K.DOCUMENT, K.SCORE) # without this you get IDs only
)
Two things bite newcomers here. First, every method returns a new immutable Search — you chain, you don't mutate. Second, an empty Search() returns document IDs and nothing else; the fields come back only for what you .select(). Selectable tokens are K.DOCUMENT, K.EMBEDDING, K.METADATA, K.SCORE, K.ID, or any metadata key by name. Passing query=<text> to Knn auto-embeds using the collection's configured embedding function, and results come back ordered by score ascending — for a Knn, that score is distance, so lower is closer.
Step 1: put a sparse index in the schema#
Hybrid search needs two indexes on the collection: the default dense one, and a sparse one you declare explicitly. You can't add keyword search as a query-time flag — it's a property of the collection.
import chromadb
from chromadb import Schema, SparseVectorIndexConfig, K
from chromadb.utils.embedding_functions import ChromaCloudSpladeEmbeddingFunction
client = chromadb.CloudClient(tenant="your-tenant", database="your-db", api_key="…")
schema = Schema()
schema.create_index(
config=SparseVectorIndexConfig(
source_key=K.DOCUMENT, # embed the document text
embedding_function=ChromaCloudSpladeEmbeddingFunction(),
),
key="sparse_embedding", # the key you'll query later
)
collection = client.create_collection(name="docs", schema=schema)
collection.add(
ids=["d1", "d2", "d3"],
documents=[
"The quick brown fox jumps over the lazy dog",
"A fast auburn fox leaps over a sleepy canine",
"Machine learning is a subset of artificial intelligence",
],
metadatas=[{"category": "animals"}, {"category": "animals"}, {"category": "technology"}],
) # sparse vectors are generated automatically from source_key
ChromaCloudSpladeEmbeddingFunction is the managed SPLADE option; HuggingFaceSparseEmbeddingFunction and FastembedSparseEmbeddingFunction are there if you'd rather bring your own. The important part is that the sparse index exists before you query key="sparse_embedding" — query a key with no index config and it errors.
Step 2: fuse dense and sparse with RRF#
Now the payoff. Rrf — Reciprocal Rank Fusion — takes a list of Knn rankings and combines them by each document's rank position in each list rather than by its raw distance, which is exactly what lets you blend a semantic ranking and a keyword ranking that otherwise live on completely different, incomparable scales. You hand it the rankings, an optional per-ranking weight, and the constant k, and it returns one fused order:
from chromadb import Search, Knn, Rrf, K
hybrid = Rrf(
ranks=[
Knn(query="fox animal", return_rank=True), # dense
Knn(query="fox animal", key="sparse_embedding", return_rank=True), # sparse
],
weights=[0.7, 0.3], # 70% semantic, 30% keyword
k=60, # the RRF constant; default is 60
)
results = collection.search(
Search().where(K("category") == "animals").rank(hybrid).limit(10).select(K.DOCUMENT, K.SCORE)
)
for row in results.rows()[0]:
print(f"{row['score']:.3f} {row['document']}")
The one line that silently breaks hybrid search: a Knn inside an Rrf without return_rank=True. It won't error — it'll fuse raw distances instead of ranks, and your results just quietly get worse.
That flag is the whole game. RRF's formula is score = -Σ wᵢ / (k + rᵢ), where rᵢ is a document's rank in each list — so each Knn has to hand back ranks, not distances. Set return_rank=True on all of them. The fused score is negative and ascending, so lower is still better, same as a plain Knn.
One more knob worth knowing: by default RRF only scores documents that appear in every ranking (an intersection). Pass a default=<rank> to a Knn and documents in any ranking get scored, with missing ones assigned that default rank — the difference between "must match both" and "match either, prefer both."
Why it's built this way#
Under the hood, distributed Chroma is five services — a Gateway (auth, quotas, routing), a Log (the write-ahead log), a Query Executor (reads), a Compactor (builds the vector, full-text, and metadata indexes from the log), and a System Database (the SQL catalog of tenants and collections). Indexes and the log live in object storage with local SSD caches, and reads route by rendezvous hashing on the collection ID. That architecture is the object-storage bet we wrote about earlier: cheap to keep parked, and it's why the pricing meters queried and returned bytes rather than a running instance.
That pricing is the reason to design queries deliberately. Reads bill per TiB queried plus per GiB returned, and each metadata or full-text predicate counts as an additional query — so .select() only the fields you need and don't stack filters you can live without. It's the same discipline that makes the dense-vs-sparse-vs-RRF tradeoff worth thinking through before you wire it into an agent that fires thousands of retrievals a day.
If you're still choosing an engine rather than tuning one, we put Chroma head-to-head with LanceDB and with Weaviate and Milvus, and walked through the same hybrid setup on LanceDB for the self-hosted crowd. But if you're already on Chroma Cloud, the upgrade is worth an afternoon: declare the sparse index, set return_rank=True, and let RRF do the fusion the old two-call dance never could.



