This episode covers full-text search for precise keyword matching and relevance scoring, then hybrid search strategies that combine vector, full-text, and metadata, including fusion and rerank approaches for accurate RAG retrieval.

Episode 7 introduced where_document as a simple text filter. Now it's time to take it further: full-text search and hybrid search. This is where ChromaDB proves its claim as "search infrastructure for AI" — not just a vector database, but an engine that combines four types of search at once.
We will discuss when full-text outperforms semantic search, how to combine both, relevance scoring, and the fusion and rerank strategies that make RAG accurate. Many teams assume semantic search is always the best — this episode will correct that assumption.
Semantic search excels at capturing meaning but fails in cases that require character-level precision. Real examples: error codes, product names, software versions, or UUIDs. A query like "error code E404" could semantically pull in documents about other errors with similar meaning — when what you need is exactly E404.
hasil = collection.query(
query_texts=["error E404"],
n_results=5,
where_document={"$contains": "E404"},
)where_document={"$contains": "E404"} ensures only documents that truly contain the string E404 are returned. Character-level precision like this cannot be guaranteed by semantic search.
Let's put both side by side:
| Characteristic | Semantic Search | Full-Text Search |
|---|---|---|
| Basis | Meaning (embedding) | Exact words |
| Strong at | Paraphrase, synonyms | Codes, names, versions |
| Weak at | Character precision | Meaning, synonyms |
| Speed | Depends on index | Very fast |
Full-text in ChromaDB searches for words in documents, and results are filtered by the presence of those words. For RAG serving diverse questions, both must run together — and that is exactly hybrid search.
Hybrid search runs vector and full-text searches against the same query, then combines the results. ChromaDB makes this easy: you can run query with where_document, or run two separate searches and combine them in your application.
The most common approach in ChromaDB:
hasil_vector = collection.query(
query_texts=["cara deploy aplikasi"],
n_results=10,
)
hasil_fulltext = collection.query(
query_texts=["cara deploy aplikasi"],
n_results=10,
where_document={"$contains": "deploy"},
)The two collection.query(...) calls above produce two candidate sets of a different character: the first is based on vector similarity, the second is constrained to the word "deploy". Your job is to combine them into one final list.
Hybrid actually has three dimensions in ChromaDB: vector, full-text, and metadata. The metadata filter from episode 7 can be added to either search above:
hasil = collection.query(
query_texts=["deploy"],
n_results=10,
where={"env": "production"},
where_document={"$contains": "Docker"},
)collection.query(query_texts=["deploy"], where={"env": "production"}, where_document={"$contains": "Docker"}) runs a semantic search constrained to the production environment and documents containing the word Docker. Three filters working in a single call.
Info
It is important to understand: where_document in ChromaDB is a strict filter — results that do not contain the word are discarded. It is not a continuous scoring search like BM25. For full relevance scoring, combine results manually or use a fusion tool.
The main hybrid problem: two result lists with two different scales cannot be mixed directly. The classic solution is Reciprocal Rank Fusion (RRF): each document is scored based on its position in each list.
def rrf(daftar_dok, k=60):
skor = {}
for daftar in daftar_dok:
for posisi, dok in enumerate(daftar):
skor[dok] = skor.get(dok, 0) + 1 / (k + posisi + 1)
return sorted(skor, key=skor.get, reverse=True)
final = rrf([hasil_vector["ids"][0], hasil_fulltext["ids"][0]])The rrf([...], k=60) function gives high scores to documents that appear near the top of both lists. This is a simple fusion pattern, parameter-free, and strong enough for many cases.
For the highest accuracy, the final step is reranking with a specialized model — for example a cross-encoder that computes query-document relevance directly:
from sentence_transformers import CrossEncoder
reranker = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2")
kandidat = hasil_vector["documents"][0] + hasil_fulltext["documents"][0]
skor = reranker.predict([(query, d) for d in kandidat])reranker.predict([(query, d) for d in kandidat]) returns a relevance score for each query-document pair. Sort descending, take the top-N, and you have results far more accurate than relying on embeddings alone.
Summarizing this episode into one production pattern:
where_document retrieving parallel results.This pattern balances recall (semantic captures meaning, full-text captures precision) and precision (rerank filters out what is truly irrelevant). You will see the full implementation in episode 18 when we cover LLM framework integration.
Episode 8 raised your retrieval level: understanding when full-text beats semantic, running hybrid search with three dimensions (vector, full-text, metadata), combining rankings with RRF, and refining results with cross-encoder reranking. ChromaDB is not just a vector store — it is a search engine.
Key takeaways:
where_document + where.In the next episode, episode 9, we will discuss distance metrics and the HNSW index — the differences between L2, cosine, and inner product, choosing a metric to match your embedding model, and tuning the ef_construction, M, and search ef parameters with the recall-versus-latency-and-memory trade-off. This is where retrieval performance starts to be tuned.