Pure vector search has one failure mode that shows up the moment real users touch your agent: it's bad at exact tokens. Ask a retrieval-augmented agent for error code E4012, the SKU RTX-4090-OC, or the function parse_mandate, and dense embeddings return something semantically nearby — a paragraph about error handling, a different GPU, a neighboring function — because a rare literal string has no meaningful neighborhood in embedding space. It gets outranked by text that's merely on the same topic.
Hybrid search fixes this by running keyword search next to vector search and fusing the results — so the semantic recall you already have gains keyword precision. In LanceDB it's three small additions to a table you already have. Here's the whole thing.
Start with a table and vectors#
Assume the ordinary setup: a table with text, a vector column, and some rows. (Using an embedding function so LanceDB vectorizes queries for you; you can also pass raw vectors.)
import lancedb
from lancedb.embeddings import get_registry
from lancedb.pydantic import LanceModel, Vector
db = lancedb.connect("./agent-memory")
embed = get_registry().get("openai").create(name="text-embedding-3-small")
class Doc(LanceModel):
text: str = embed.SourceField()
vector: Vector(embed.ndims()) = embed.VectorField()
table = db.create_table("docs", schema=Doc, mode="overwrite")
table.add([
{"text": "Mandate error E4012: the spend cap was exceeded before settlement."},
{"text": "Agentic checkout registers an agent identity with a sponsor email."},
{"text": "Webhooks fire on mandate.charge.pending with a 30-second veto window."},
])
A vector search over this already works. It just won't reliably surface the E4012 row when someone searches that exact code — which is precisely the query where being wrong is most obvious.
Step 1: build the full-text index#
Hybrid search needs a keyword index alongside the vector index. Build it on the text column:
table.create_fts_index("text")
One line — with a catch that bites in production. The FTS build is asynchronous: create_fts_index() returns before the index is ready, so a query fired immediately afterward can see an empty index and silently return no keyword hits. In a script, add a short wait or verify the index exists before your first hybrid query; don't assume "the call returned" means "the index is live."
Step 2: query with query_type="hybrid"#
Now the actual change. Instead of a plain vector search, ask for hybrid:
results = (
table.search("mandate error E4012", query_type="hybrid")
.limit(5)
.to_pandas()
)
Under the hood LanceDB runs two queries in parallel — a vector search for meaning and a full-text search for the literal tokens — then merges and re-orders them. The E4012 row now surfaces because the full-text side matched the exact code, even though the vector side alone would have buried it. You changed one argument and got keyword precision on top of semantic recall.
Hybrid search isn't a smarter embedding. It's admitting that "meaning" and "exact string" are two different retrieval problems, and running one query for each instead of pretending a single vector can do both.
Step 3: the reranker (and why the default is fine)#
The fusion step is a reranker. By default LanceDB uses RRFReranker() — reciprocal rank fusion — which combines the two ranked lists by rank position, needs no model, and adds essentially no latency. It's a genuinely strong baseline; do not reach past it reflexively.
When you have measured that RRF's ordering is leaving relevance on the table, swap in a stronger reranker with .rerank():
from lancedb.rerankers import CrossEncoderReranker
reranker = CrossEncoderReranker() # a model that scores (query, doc) pairs directly
results = (
table.search("how do I stop an agent overspending?", query_type="hybrid")
.rerank(reranker=reranker)
.limit(5)
.to_pandas()
)
A cross-encoder reads each query–document pair and scores it directly, which is more accurate than rank fusion and meaningfully slower — you're running a model per candidate. That trade is worth it for the top-k of a high-value query and wasteful for everything else. LanceDB also ships Cohere and other rerankers, and you can write your own; the decision is empirical, not aesthetic.
When to actually use it#
Hybrid isn't free — two searches and a merge cost more than one vector lookup. The rule is about your queries, not your data: reach for hybrid when exact tokens carry meaning — code, error IDs, product names, API symbols — the retrieval an agent does over logs, docs, and tickets. Stay on pure vector search for natural-language questions over prose, where the literal words barely matter and the extra keyword pass just adds cost. (If you're weighing the retrieval math itself, the BM25 vs dense vs RRF breakdown is the theory under this how-to; if you haven't picked a store yet, LanceDB vs sqlite-vec vs DuckDB covers that call.)
The whole change is three lines against a table you already have: index the text, ask for hybrid, keep the default reranker until a measurement tells you otherwise. That's the cheapest large win available in agent retrieval right now.



