January 17, 2026
Hybrid Search RAG That Actually Works: BM25 + Vectors + Reranking in Python
Fix “dumb RAG” using hybrid retrieval and a lightweight reranker pipeline.

By Tarun Singh
7 min read
If your RAG app is "kind of okay" but randomly wrong, you don't have an LLM problem.
You have a retrieval problem.
Most "dumb RAG" fails for the same reason: it retrieves the most similar-looking text, not the most useful text. That tiny difference is why your answers feel confident… and still miss the point.
The fix is simple and powerful:
Hybrid Search RAG = BM25 (keywords) + Vectors (semantic) + Reranking (precision).
This single upgrade can make your RAG system:
- more accurate on real questions,
- faster under load,
- less hallucination-prone,
- and dramatically better with messy enterprise docs.
In this article, we'll build a Hybrid Search RAG pipeline in Python that you can actually ship.
Why "Dumb RAG" Breaks in the Real World
A typical basic pipeline looks like this:
- chunk documents
- embed chunks
- vector search top-k
- feed to LLM
This fails when:
- the user query contains rare keywords (IDs, error codes, product names)
- the user needs exact phrasing from a policy or contract
- the relevant doc is short, formal, or uses different wording
- the top vector matches are similar but not answering the question
Vector search is great at meaning. But production questions often require exact tokens, names, and constraints.
That's where BM25 dominates.
So we do both. Then we rerank.
The Winning Retrieval Stack
1) BM25 (Lexical Search)
BM25 is keyword-based ranking. It shines when:
- query contains exact terms ("E1127", "40102", "Form 26AS")
- user wants specific facts
- you need compliance/policy text precisely
2) Vector Search (Semantic Search)
Embeddings shine when:
- user asks in natural language
- documents use different wording
- you need conceptual similarity ("cancel subscription" vs "terminate plan")
3) Reranking (Final Precision)
Reranking takes a short list of candidates and answers:
"Which chunk is most relevant to this question_, really?"_
This is the step that turns "pretty good" into "reliably correct".
Hybrid Search RAG Architecture
Here's the exact flow you want:
Query → BM25 topN + Vector topN → Merge → Rerank topM → Context → LLM
Key idea:
- Retrieval should be broad (high recall)
- Reranking should be strict (high precision)
That combo is what makes Hybrid Search RAG work.
The Hybrid Search RAG Python Implementation
Below is a complete, clean, copy-paste-ready implementation you can use as a base. It includes:
- chunking
- BM25 indexing
- vector indexing (Sentence Transformers)
- hybrid merging with weights
- lightweight reranking (cross-encoder)
- final context builder
Install (you can run locally)
pip install rank-bm25 sentence-transformers numpypip install rank-bm25 sentence-transformers numpyThe reranker uses a cross-encoder model from
sentence-transformers.
Full Code: Hybrid Search + Reranking in Python
from __future__ import annotations
import re
from dataclasses import dataclass
from typing import List, Dict, Tuple
import numpy as np
from rank_bm25 import BM25Okapi
from sentence_transformers import SentenceTransformer, CrossEncoder
# =========================
# Data contracts
# =========================
@dataclass(frozen=True)
class DocChunk:
chunk_id: str
text: str
meta: Dict[str, str]
@dataclass(frozen=True)
class SearchHit:
chunk: DocChunk
score: float
source: str # "bm25" | "vector" | "hybrid" | "rerank"
# =========================
# Utilities
# =========================
def normalize_text(s: str) -> str:
s = s.strip()
s = re.sub(r"\s+", " ", s)
return s
def tokenize_for_bm25(text: str) -> List[str]:
# Simple tokenization: lowercase + alphanumeric words
text = text.lower()
return re.findall(r"[a-z0-9]+", text)
def cosine_sim_matrix(a: np.ndarray, b: np.ndarray) -> np.ndarray:
# a: (n, d), b: (m, d)
a_norm = a / (np.linalg.norm(a, axis=1, keepdims=True) + 1e-12)
b_norm = b / (np.linalg.norm(b, axis=1, keepdims=True) + 1e-12)
return a_norm @ b_norm.T
# =========================
# Hybrid Search RAG Engine
# =========================
class HybridSearchRAG:
"""
Hybrid Search RAG:
- BM25 for lexical retrieval
- Vector search for semantic retrieval
- Merge & score normalization
- Cross-encoder reranking for precision
"""
def __init__(
self,
embedding_model: str = "sentence-transformers/all-MiniLM-L6-v2",
rerank_model: str = "cross-encoder/ms-marco-MiniLM-L-6-v2",
) -> None:
self.embedder = SentenceTransformer(embedding_model)
self.reranker = CrossEncoder(rerank_model)
self.chunks: List[DocChunk] = []
self._bm25: BM25Okapi | None = None
self._bm25_tokens: List[List[str]] = []
self._embeddings: np.ndarray | None = None
# ---------
# Indexing
# ---------
def index(self, chunks: List[DocChunk]) -> None:
self.chunks = [DocChunk(c.chunk_id, normalize_text(c.text), c.meta) for c in chunks]
# BM25 index
self._bm25_tokens = [tokenize_for_bm25(c.text) for c in self.chunks]
self._bm25 = BM25Okapi(self._bm25_tokens)
# Vector index
texts = [c.text for c in self.chunks]
self._embeddings = np.array(self.embedder.encode(texts, normalize_embeddings=True))
# ------------
# Retrieval
# ------------
def bm25_search(self, query: str, top_n: int = 12) -> List[SearchHit]:
if not self._bm25:
raise RuntimeError("BM25 index not built. Call index() first.")
q_tokens = tokenize_for_bm25(query)
scores = self._bm25.get_scores(q_tokens)
idxs = np.argsort(scores)[::-1][:top_n]
hits = []
for i in idxs:
hits.append(SearchHit(self.chunks[int(i)], float(scores[int(i)]), "bm25"))
return hits
def vector_search(self, query: str, top_n: int = 12) -> List[SearchHit]:
if self._embeddings is None:
raise RuntimeError("Vector index not built. Call index() first.")
q_emb = np.array(self.embedder.encode([query], normalize_embeddings=True))
sims = cosine_sim_matrix(q_emb, self._embeddings)[0] # (num_chunks,)
idxs = np.argsort(sims)[::-1][:top_n]
hits = []
for i in idxs:
hits.append(SearchHit(self.chunks[int(i)], float(sims[int(i)]), "vector"))
return hits
# ------------
# Hybrid merge
# ------------
@staticmethod
def _minmax_norm(scores: List[float]) -> List[float]:
if not scores:
return scores
mn, mx = min(scores), max(scores)
if abs(mx - mn) < 1e-12:
return [1.0 for _ in scores]
return [(s - mn) / (mx - mn) for s in scores]
def hybrid_search(
self,
query: str,
bm25_top_n: int = 12,
vec_top_n: int = 12,
hybrid_top_m: int = 16,
bm25_weight: float = 0.45,
vec_weight: float = 0.55,
) -> List[SearchHit]:
"""
Returns merged candidates with normalized weighted scores.
"""
bm25_hits = self.bm25_search(query, top_n=bm25_top_n)
vec_hits = self.vector_search(query, top_n=vec_top_n)
# Normalize within each channel
bm25_norm = self._minmax_norm([h.score for h in bm25_hits])
vec_norm = self._minmax_norm([h.score for h in vec_hits])
merged: Dict[str, Tuple[DocChunk, float]] = {}
for h, ns in zip(bm25_hits, bm25_norm):
merged[h.chunk.chunk_id] = (h.chunk, merged.get(h.chunk.chunk_id, (h.chunk, 0.0))[1] + ns * bm25_weight)
for h, ns in zip(vec_hits, vec_norm):
merged[h.chunk.chunk_id] = (h.chunk, merged.get(h.chunk.chunk_id, (h.chunk, 0.0))[1] + ns * vec_weight)
# Sort merged candidates
merged_hits = [
SearchHit(chunk=c, score=s, source="hybrid")
for (c, s) in merged.values()
]
merged_hits.sort(key=lambda x: x.score, reverse=True)
return merged_hits[:hybrid_top_m]
# ------------
# Reranking
# ------------
def rerank(self, query: str, candidates: List[SearchHit], top_k: int = 6) -> List[SearchHit]:
"""
Cross-encoder reranking: (query, chunk_text) -> relevance score
"""
pairs = [(query, h.chunk.text) for h in candidates]
rerank_scores = self.reranker.predict(pairs)
reranked = []
for h, rs in zip(candidates, rerank_scores):
reranked.append(SearchHit(h.chunk, float(rs), "rerank"))
reranked.sort(key=lambda x: x.score, reverse=True)
return reranked[:top_k]
# ------------
# Context
# ------------
def build_context(self, hits: List[SearchHit], max_chars: int = 4500) -> str:
"""
Builds an LLM-ready context string.
Keeps it simple: top chunks concatenated with separators.
"""
parts = []
total = 0
for h in hits:
header = f"[{h.chunk.meta.get('source','doc')} | {h.chunk.chunk_id}]"
block = f"{header}\n{h.chunk.text}\n"
if total + len(block) > max_chars:
break
parts.append(block)
total += len(block)
return "\n---\n".join(parts)
# ------------
# End-to-end
# ------------
def retrieve(
self,
query: str,
bm25_top_n: int = 12,
vec_top_n: int = 12,
hybrid_top_m: int = 16,
rerank_top_k: int = 6,
) -> List[SearchHit]:
candidates = self.hybrid_search(
query=query,
bm25_top_n=bm25_top_n,
vec_top_n=vec_top_n,
hybrid_top_m=hybrid_top_m,
)
return self.rerank(query, candidates, top_k=rerank_top_k)
# =========================
# Example usage (drop-in)
# =========================
if __name__ == "__main__":
# Replace with your own chunks from PDFs/web/pages/db
chunks = [
DocChunk("c1", "Reset password: go to Settings > Security > Reset.", {"source": "help_center"}),
DocChunk("c2", "Error E1127 occurs when token is expired. Refresh the token.", {"source": "runbook"}),
DocChunk("c3", "To terminate plan, contact billing at least 7 days before renewal.", {"source": "policy"}),
DocChunk("c4", "Refunds are only issued within 14 days of purchase for annual plans.", {"source": "policy"}),
]
rag = HybridSearchRAG()
rag.index(chunks)
query = "How do I fix E1127 and why is it happening?"
hits = rag.retrieve(query)
print("\nTop reranked hits:\n")
for h in hits:
print(f"- {h.score:.4f} | {h.chunk.chunk_id} | {h.chunk.meta.get('source')}")
context = rag.build_context(hits)
print("\n\nLLM Context:\n")
print(context)from __future__ import annotations
import re
from dataclasses import dataclass
from typing import List, Dict, Tuple
import numpy as np
from rank_bm25 import BM25Okapi
from sentence_transformers import SentenceTransformer, CrossEncoder
# =========================
# Data contracts
# =========================
@dataclass(frozen=True)
class DocChunk:
chunk_id: str
text: str
meta: Dict[str, str]
@dataclass(frozen=True)
class SearchHit:
chunk: DocChunk
score: float
source: str # "bm25" | "vector" | "hybrid" | "rerank"
# =========================
# Utilities
# =========================
def normalize_text(s: str) -> str:
s = s.strip()
s = re.sub(r"\s+", " ", s)
return s
def tokenize_for_bm25(text: str) -> List[str]:
# Simple tokenization: lowercase + alphanumeric words
text = text.lower()
return re.findall(r"[a-z0-9]+", text)
def cosine_sim_matrix(a: np.ndarray, b: np.ndarray) -> np.ndarray:
# a: (n, d), b: (m, d)
a_norm = a / (np.linalg.norm(a, axis=1, keepdims=True) + 1e-12)
b_norm = b / (np.linalg.norm(b, axis=1, keepdims=True) + 1e-12)
return a_norm @ b_norm.T
# =========================
# Hybrid Search RAG Engine
# =========================
class HybridSearchRAG:
"""
Hybrid Search RAG:
- BM25 for lexical retrieval
- Vector search for semantic retrieval
- Merge & score normalization
- Cross-encoder reranking for precision
"""
def __init__(
self,
embedding_model: str = "sentence-transformers/all-MiniLM-L6-v2",
rerank_model: str = "cross-encoder/ms-marco-MiniLM-L-6-v2",
) -> None:
self.embedder = SentenceTransformer(embedding_model)
self.reranker = CrossEncoder(rerank_model)
self.chunks: List[DocChunk] = []
self._bm25: BM25Okapi | None = None
self._bm25_tokens: List[List[str]] = []
self._embeddings: np.ndarray | None = None
# ---------
# Indexing
# ---------
def index(self, chunks: List[DocChunk]) -> None:
self.chunks = [DocChunk(c.chunk_id, normalize_text(c.text), c.meta) for c in chunks]
# BM25 index
self._bm25_tokens = [tokenize_for_bm25(c.text) for c in self.chunks]
self._bm25 = BM25Okapi(self._bm25_tokens)
# Vector index
texts = [c.text for c in self.chunks]
self._embeddings = np.array(self.embedder.encode(texts, normalize_embeddings=True))
# ------------
# Retrieval
# ------------
def bm25_search(self, query: str, top_n: int = 12) -> List[SearchHit]:
if not self._bm25:
raise RuntimeError("BM25 index not built. Call index() first.")
q_tokens = tokenize_for_bm25(query)
scores = self._bm25.get_scores(q_tokens)
idxs = np.argsort(scores)[::-1][:top_n]
hits = []
for i in idxs:
hits.append(SearchHit(self.chunks[int(i)], float(scores[int(i)]), "bm25"))
return hits
def vector_search(self, query: str, top_n: int = 12) -> List[SearchHit]:
if self._embeddings is None:
raise RuntimeError("Vector index not built. Call index() first.")
q_emb = np.array(self.embedder.encode([query], normalize_embeddings=True))
sims = cosine_sim_matrix(q_emb, self._embeddings)[0] # (num_chunks,)
idxs = np.argsort(sims)[::-1][:top_n]
hits = []
for i in idxs:
hits.append(SearchHit(self.chunks[int(i)], float(sims[int(i)]), "vector"))
return hits
# ------------
# Hybrid merge
# ------------
@staticmethod
def _minmax_norm(scores: List[float]) -> List[float]:
if not scores:
return scores
mn, mx = min(scores), max(scores)
if abs(mx - mn) < 1e-12:
return [1.0 for _ in scores]
return [(s - mn) / (mx - mn) for s in scores]
def hybrid_search(
self,
query: str,
bm25_top_n: int = 12,
vec_top_n: int = 12,
hybrid_top_m: int = 16,
bm25_weight: float = 0.45,
vec_weight: float = 0.55,
) -> List[SearchHit]:
"""
Returns merged candidates with normalized weighted scores.
"""
bm25_hits = self.bm25_search(query, top_n=bm25_top_n)
vec_hits = self.vector_search(query, top_n=vec_top_n)
# Normalize within each channel
bm25_norm = self._minmax_norm([h.score for h in bm25_hits])
vec_norm = self._minmax_norm([h.score for h in vec_hits])
merged: Dict[str, Tuple[DocChunk, float]] = {}
for h, ns in zip(bm25_hits, bm25_norm):
merged[h.chunk.chunk_id] = (h.chunk, merged.get(h.chunk.chunk_id, (h.chunk, 0.0))[1] + ns * bm25_weight)
for h, ns in zip(vec_hits, vec_norm):
merged[h.chunk.chunk_id] = (h.chunk, merged.get(h.chunk.chunk_id, (h.chunk, 0.0))[1] + ns * vec_weight)
# Sort merged candidates
merged_hits = [
SearchHit(chunk=c, score=s, source="hybrid")
for (c, s) in merged.values()
]
merged_hits.sort(key=lambda x: x.score, reverse=True)
return merged_hits[:hybrid_top_m]
# ------------
# Reranking
# ------------
def rerank(self, query: str, candidates: List[SearchHit], top_k: int = 6) -> List[SearchHit]:
"""
Cross-encoder reranking: (query, chunk_text) -> relevance score
"""
pairs = [(query, h.chunk.text) for h in candidates]
rerank_scores = self.reranker.predict(pairs)
reranked = []
for h, rs in zip(candidates, rerank_scores):
reranked.append(SearchHit(h.chunk, float(rs), "rerank"))
reranked.sort(key=lambda x: x.score, reverse=True)
return reranked[:top_k]
# ------------
# Context
# ------------
def build_context(self, hits: List[SearchHit], max_chars: int = 4500) -> str:
"""
Builds an LLM-ready context string.
Keeps it simple: top chunks concatenated with separators.
"""
parts = []
total = 0
for h in hits:
header = f"[{h.chunk.meta.get('source','doc')} | {h.chunk.chunk_id}]"
block = f"{header}\n{h.chunk.text}\n"
if total + len(block) > max_chars:
break
parts.append(block)
total += len(block)
return "\n---\n".join(parts)
# ------------
# End-to-end
# ------------
def retrieve(
self,
query: str,
bm25_top_n: int = 12,
vec_top_n: int = 12,
hybrid_top_m: int = 16,
rerank_top_k: int = 6,
) -> List[SearchHit]:
candidates = self.hybrid_search(
query=query,
bm25_top_n=bm25_top_n,
vec_top_n=vec_top_n,
hybrid_top_m=hybrid_top_m,
)
return self.rerank(query, candidates, top_k=rerank_top_k)
# =========================
# Example usage (drop-in)
# =========================
if __name__ == "__main__":
# Replace with your own chunks from PDFs/web/pages/db
chunks = [
DocChunk("c1", "Reset password: go to Settings > Security > Reset.", {"source": "help_center"}),
DocChunk("c2", "Error E1127 occurs when token is expired. Refresh the token.", {"source": "runbook"}),
DocChunk("c3", "To terminate plan, contact billing at least 7 days before renewal.", {"source": "policy"}),
DocChunk("c4", "Refunds are only issued within 14 days of purchase for annual plans.", {"source": "policy"}),
]
rag = HybridSearchRAG()
rag.index(chunks)
query = "How do I fix E1127 and why is it happening?"
hits = rag.retrieve(query)
print("\nTop reranked hits:\n")
for h in hits:
print(f"- {h.score:.4f} | {h.chunk.chunk_id} | {h.chunk.meta.get('source')}")
context = rag.build_context(hits)
print("\n\nLLM Context:\n")
print(context)Why This Hybrid Search RAG Works So Well
BM25 gives you exactness
When users paste:
- invoice IDs
- server errors
- configuration keys
- internal product names
BM25 wins, because embeddings sometimes ignore "boring" tokens.
Vectors give you meaning
When users ask:
- "Why can't I log in?"
- "How do I cancel my plan?"
- "What is the policy for refunds?"
Vector retrieval wins, because it matches intent even when words differ.
Reranking gives you correctness
Your merged list is broad. Your reranker makes it strict.
That's how you get:
- fewer irrelevant chunks
- better grounding
- less hallucination
- better citations
- higher answer confidence
The "Top-K" Values That Usually Win
In Hybrid Search RAG, your top-k choices matter a lot.
A solid default:
- BM25 topN: 12
- Vector topN: 12
- Hybrid merged topM: 16
- Rerank topK: 6
Why this works:
- you pull enough to avoid missing the answer (high recall)
- you rerank aggressively to keep only the best (high precision)
Common Mistakes That Kill Hybrid Search RAG
1) No score normalization
BM25 scores and cosine scores live in different worlds. Normalize before merging or your weighting is fake.
2) Too few candidates
If you merge only top-3 + top-3, your reranker can't save you.
3) Chunking too large
Huge chunks reduce precision. Aim for chunks that can answer one question well.
4) Context stuffing
More chunks doesn't mean better answers. It often means more confusion.
Hybrid Search RAG is not "retrieve more". It's "retrieve better".
A Simple Mental Model That Never Fails
If you remember one thing, remember this:
- BM25 = exact match
- Vectors = intent match
- Rerank = answer match
That "answer match" step is what most RAG tutorials skip. That's why most RAG apps feel like demos.
The Real Result: Better Answers, Fewer Hallucinations
When your retrieval is precise, your LLM becomes calmer. It stops inventing. It stops guessing. It starts quoting the truth.
Hybrid Search RAG is the difference between:
- "cool prototype" and
- "reliable product"
Final Takeaway
If you want a RAG system that actually works in production, stop betting everything on vector search.
Build Hybrid Search RAG: BM25 + Vectors + Reranking
It's faster, more accurate, and far more robust for real user queries.
And once you ship it, you'll never go back to "dumb RAG" again.
Bonus resources: — YouTube ▶️ https://youtu.be/GHy73SBxFLs — Book ▶️ https://www.amazon.com/dp/B0CKGWZ8JT
Let's Connect
Email: krtarunsingh@gmail.com LinkedIn: Tarun Singh GitHub: github.com/krtarunsingh Buy Me a Coffee: https://buymeacoffee.com/krtarunsingh YouTube: @tarunaihacks
👉 If you found value here, clap, share, and leave a comment — it helps more devs discover practical guides like this.
More Builds You'll Love
On-Device AI Is Finally Real — Build a Copilot+ PC App That Runs 100% Offline On-device AI, explained in plain English — with a full working project you can run today.
Laptop-Only LLM: Tune Google Gemma 3 in Minutes (Code Inside) A clean, from-scratch walkthrough (with code) to tune a 270M-param LLM on chess — no cloud required.
Build an AI PDF Search Engine in a Weekend (Python, FAISS, RAG — Full Code) Turn messy folders of PDFs into a blazing-fast, AI-assisted knowledge base you can actually talk to.
The Next AI Boom: What Comes After AI Agents and Agentic AI? Artificial Intelligence is no longer science fiction. It's a living, breathing force that's already transforming how we…
AI-Powered OCR with Phi-3-Vision-128K: The Future of Document Processing In the fast-evolving world of artificial intelligence, multimodal models are setting new standards for integrating…
RAG Frameworks Explored: LlamaIndex vs. LangChain for Next-Gen LLMs The Rise of Large Language Models (LLMs)
Mastering RAG Chunking Techniques for Enhanced Document Processing Dividing large documents into smaller parts is a crucial yet intricate task that significantly impacts the performance…
📖 Ready to rebuild with me?
Rebuild: Your next version is only one decision away
Rebuild: Your next version is only one decision away Rebuild: Your next version is only one decision away [Singh, Tarun] on Amazon.com. FREE shipping on qualifying…