Hybrid Search in LanceDB with Vector + BM25 (Python 2026)

Llm And Generative Ai Sep 20, 2026

Watch on YouTube

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 point
  • LinearCombinationReranker — 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 _rowid for joins / dedupe
  • .distance_range(...) — bound how far the vector half may drift before fusion
  • return_score="all" on RRFReranker — keep vector distance + FTS score beside _relevance_score while 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.

Share:
Last updated: September 2026
Built-in Functions in Python 2026 str() in Python 2026: String Conversion + Modern Formatting & Best Practices memoryview with JAX in Python 2026: Zero-Copy NumPy → JAX Array Interop + Efficient ML Examples memoryview with TensorFlow in Python 2026: Zero-Copy NumPy → Tensor Interop + GPU Pinning & ML Examples memoryview with TensorFlow in Python 2026: Zero-Copy NumPy → Tensor Interop + ML Examples memoryview with NumPy & PyTorch in Python 2026: Zero-Copy Views, Efficient Slicing & ML Interop Examples memoryview with NumPy in Python 2026: Zero-Copy Views, Efficient Slicing & Real ML Examples memoryview() in Python 2026: Zero-Copy Magic for Large Binary Data + Real Examples import() in Python 2026: How It Works, Security Risks & Modern Alternatives (importlib) zip() in Python 2026: How to Use It, strict=True Behavior & Real-World Examples vars() in Python 2026: Accessing Object Namespace + Modern Introspection Patterns type() in Python 2026: Dynamic Type Inspection & Object Creation + Modern Patterns tuple() in Python 2026: Immutable Sequences + Modern Patterns & Best Practices super() in Python 2026: Method Resolution & Modern Inheritance Patterns sum() in Python 2026: Summing Iterables + Modern Numeric Patterns & Best Practices staticmethod() in Python 2026: Static Methods, Utility Functions & Modern Best Practices sorted() in Python 2026: Sorting Iterables + Modern Patterns & Best Practices slice() in Python 2026: Creating Slice Objects + Modern Patterns & Best Practices setattr() in Python 2026: Dynamic Attribute Setting + Modern Patterns & Safety set() in Python 2026: Mutable Sets Creation + Modern Patterns & Best Practices