Hybrid Search in LanceDB with Vector + BM25 (Python 2026)
Hybrid Search in LanceDB with Vector + BM25 (Python 2026) — native dense vectors + BM25 full-text search fused with RRFReranker, not just a vector query plus a scalar .where(...) filter.
Our broader guides — Polars + LanceDB + vLLM RAG and production RAG pipelines — mention hybrid retrieval at architecture level. This tutorial is the missing deep-dive: create_fts_index, query_type="hybrid", and Reciprocal Rank Fusion (RRF) in working Python.
TL;DR
- Hybrid in LanceDB = vector ANN + BM25 FTS, then a reranker merges ranked lists
- Default reranker is
RRFReranker(rank-based; no extra model or API key) - Always call
.limit(...)so top-k is an explicit contract - Demo below runs with fake vectors (no OpenAI key); production snippet uses the embedding registry
What “hybrid” means here (and what it is not)
On many blogs — and in some high-level RAG posts — “hybrid” means “vector search filtered by metadata.” That is useful, but it is still one ranked list. Native LanceDB hybrid search runs two retrievers (semantic + keyword), then fuses them. Keyword hits rescue exact IDs, error codes, and product names that embeddings blur; vectors catch paraphrase and synonyms BM25 misses.
If you only need metadata gates, keep using .where(...) on a vector query. If you need keyword recall and semantic recall, use query_type="hybrid" after building an FTS index.
Install
# Core (enough for the no-key hybrid demo)
uv add lancedb pandas pyarrow
# or: pip install lancedb pandas pyarrow
# Optional: local embeddings for the production snippet (no OpenAI key)
uv add sentence-transformers
# or: pip install sentence-transformers
Demo 1 — Hybrid without an API key (explicit vector + text)
This path matches the docs’ explicit pattern: you pass a query vector and a text query yourself. Fake unit vectors are enough to exercise FTS + RRF wiring. Swap in real embeddings later without changing the search call shape.
import lancedb
import numpy as np
from lancedb.rerankers import RRFReranker
# Local on-disk LanceDB (no cloud, no keys)
db = lancedb.connect("./.lancedb-hybrid-demo")
DIM = 8
rng = np.random.default_rng(42)
rows = [
{"id": 1, "text": "rebel spaceships striking from a hidden base"},
{"id": 2, "text": "have won their first victory against the evil Galactic Empire"},
{"id": 3, "text": "during the battle rebel spies managed to steal secret plans"},
{"id": 4, "text": "to the Empire's ultimate weapon the Death Star"},
{"id": 5, "text": "BM25 keyword search finds exact tokens like Death Star"},
]
# Deterministic fake embeddings so the demo is reproducible offline
data = []
for r in rows:
v = rng.normal(size=DIM).astype("float32")
v = v / (np.linalg.norm(v) + 1e-9)
data.append({**r, "vector": v.tolist()})
table = db.create_table("hybrid_docs", data=data, mode="overwrite")
# Required before keyword / hybrid FTS half works
table.create_fts_index("text")
# Query: keyword wants "Death Star"; vector is a small random probe
query_text = "Death Star plans"
query_vector = rng.normal(size=DIM).astype("float32")
query_vector = (query_vector / (np.linalg.norm(query_vector) + 1e-9)).tolist()
reranker = RRFReranker(K=60) # default K=60; return_score="relevance" by default
results = (
table.search(query_type="hybrid")
.vector(query_vector)
.text(query_text)
.rerank(reranker)
.limit(5) # always set limit on hybrid queries
.to_pandas()
)
print(results[["id", "text", "_relevance_score"]])
RRF scores each document as 1 / (rank + K) in each list, then sums contributions. Documents that rank well in both vector and FTS rise. Because RRF uses ranks—not raw cosine vs BM25 scores—you avoid fragile score normalization.
Demo 2 — Production shape with embedding registry
For real RAG, let LanceDB embed SourceField text on add and at query time. sentence-transformers runs locally; swap to openai in the registry when you want hosted embeddings.
import lancedb
from lancedb.embeddings import get_registry
from lancedb.pydantic import LanceModel, Vector
from lancedb.rerankers import RRFReranker
db = lancedb.connect("./.lancedb-hybrid-prod")
# Local model — no OpenAI API key required
embeddings = get_registry().get("sentence-transformers").create(
name="sentence-transformers/all-MiniLM-L6-v2"
)
class Documents(LanceModel):
text: str = embeddings.SourceField()
vector: Vector(embeddings.ndims()) = embeddings.VectorField()
table = db.create_table("hybrid_search_example", schema=Documents, mode="overwrite")
table.add(
[
{"text": "rebel spaceships striking from a hidden base"},
{"text": "have won their first victory against the evil Galactic Empire"},
{"text": "during the battle rebel spies managed to steal secret plans"},
{"text": "to the Empire's ultimate weapon the Death Star"},
]
)
table.create_fts_index("text")
results = (
table.search(
"flower moon",
query_type="hybrid",
vector_column_name="vector",
fts_columns="text",
)
.rerank(RRFReranker())
.limit(10)
.to_pandas()
)
print(results)
Ingest PDFs to Markdown first with Docling, then chunk and embed into this table. For embedding ops and index hygiene across stores, see our vector databases & embeddings guide.
Rerankers: RRF default vs LinearCombination
RRFReranker— default for hybrid; model-free; best starting pointLinearCombinationReranker— weighted blend of normalized vector/FTS scores when you want an explicit weight knob (e.g. lean keyword-heavy for SKUs)- Model rerankers (CrossEncoder, Cohere, …) — higher quality, extra latency/cost; use after RRF candidates if needed
from lancedb.rerankers import LinearCombinationReranker
# Brief alternative: weight the FTS/vector score mix instead of pure RRF
alt = LinearCombinationReranker(weight=0.7) # see current docs for weight semantics
(
table.search("Death Star", query_type="hybrid")
.rerank(alt)
.limit(10)
.to_pandas()
)
Query controls worth knowing
- Always
.limit(k)— default is 10; make top-k explicit before you tune reranking .where("category = 'film'", prefilter=True)— metadata filter on both halves (prefilter is usually what you want).with_row_id(True)— keep_rowidfor joins / dedupe.distance_range(...)— bound how far the vector half may drift before fusionreturn_score="all"onRRFReranker— keep vector distance + FTS score beside_relevance_scorewhile debugging
Where this sits in a 2026 RAG stack
Typical flow: Docling (or similar) → chunk/enrich → LanceDB hybrid retrieve → generate. Trace retrieval and generation with Langfuse; validate structured answers with Instructor; route models via LiteLLM. For analytical prep of chunk tables, DuckDB vs Polars remains the local data layer choice.
Production tips
- Create the FTS index on the column you pass as
fts_columns(here:"text") - Prefer RRF first; only add a cross-encoder if online metrics justify the cost
- Do not confuse scalar prefilters with hybrid fusion — you can (and often should) use both
- Pin embedding model names; changing dims means a new table or re-embed
- Evaluate recall with keyword-heavy and paraphrase-heavy query sets separately
Wrap-up
In 2026, “hybrid” for LanceDB should mean vector + BM25 FTS fused by RRF—not only ANN plus .where. Start with the explicit no-key demo, then flip on the embedding registry for production RAG. Pair this retrieve step with the architecture guides linked above when you wire ingestion, generation, and observability.