
Semantic Search at Scale: Hybrid, Rerank, and Filtering Done Right
Dense-only retrieval falls apart past a few hundred thousand documents. This is how the working systems combine BM25, vectors, filters, and rerankers.
Pure vector search collapses on real corpora. Production semantic search is hybrid (BM25 + dense) with structured filters and a cross-encoder reranker, tuned against a real eval set. Every layer is measurable and independently swappable.
- Dense embeddings excel at paraphrase; BM25 excels at rare terms, IDs, and names. You need both.
- Reciprocal Rank Fusion is a two-line fusion algorithm that beats most tuned hybrid weights.
- Cross-encoder rerankers turn 'top-50 messy' into 'top-5 great' at a fraction of retraining cost.
- Metadata filters are not free — pre-filter when selectivity is high, post-filter when low.
- Every retrieval system needs a golden set. Without it you're optimizing vibes.
Why vector-only fails past a few hundred thousand docs
On a demo corpus of 10k documents, vector search looks magical. Push it to 500k or 5M and the failure modes appear: exact identifiers (order numbers, error codes, product SKUs) never match reliably; rare terms get lost in embedding averages; near-duplicates crowd the top-K; and every query returns 'plausible' but not 'correct' results.
The fix is not a better embedding model. It's a stack: lexical retrieval for precision on tokens, dense retrieval for paraphrase, filters for structured constraints, and a reranker to sort out the mess before it hits the LLM.
BM25 + dense = hybrid
Run both retrievers, then fuse the results. The simplest and most robust fusion is Reciprocal Rank Fusion:
def rrf(rank_lists, k=60):
scores = {}
for ranking in rank_lists:
for rank, doc_id in enumerate(ranking, start=1):
scores[doc_id] = scores.get(doc_id, 0) + 1 / (k + rank)
return sorted(scores.items(), key=lambda x: -x[1])
bm25_hits = bm25.search(query, top_k=50)
dense_hits = index.query(embed(query), top_k=50)
fused = rrf([bm25_hits, dense_hits])[:20]RRF is parameter-light (only k), handles disparate score scales gracefully, and consistently outperforms weighted linear combinations tuned by hand. Ship it as the baseline and only replace it if you have a specific reason.
Filters: when to pre-filter vs post-filter
Every real search has metadata: tenant_id, language, date range, product line. Two ways to apply filters:
- Pre-filter — restrict the search space before ANN. Fast if the filter is selective (returns <10% of docs). Vector indexes like pgvector's HNSW and Qdrant support this well.
- Post-filter — run ANN over all docs, then drop non-matching results. Safer for low-selectivity filters but requires oversampling top_k or you lose recall.
Cross-encoder rerankers
A bi-encoder (what your embedding model does) scores query and doc independently — fast, but the scoring model never sees them together. A cross-encoder scores them jointly and produces much better rankings, at the cost of one forward pass per (query, doc) pair.
The pattern: retrieve top-50 with hybrid search, rerank with a cross-encoder, keep top-5 for the LLM. Modern rerankers (Cohere Rerank, Voyage Rerank, bge-reranker-v2) cost roughly $1 per 1000 rerank calls and add 100-300ms latency. Nearly every RAG system I've shipped in the last 18 months uses one.
import cohere
co = cohere.Client()
reranked = co.rerank(
query=query,
documents=[d.text for d in fused[:50]],
top_n=5,
model='rerank-english-v3.0',
)
final = [fused[r.index] for r in reranked.results]Evaluation without which none of this matters
You cannot tune what you cannot measure. Build a golden set:
- Collect 100-300 real queries from logs.
- For each query, mark the 1-5 documents that are actually relevant. Use two annotators when you can.
- Score every configuration on Recall@20, MRR, and nDCG@10.
- Track a leaderboard in a spreadsheet or MLflow; every change to the pipeline gets a row.
A team without a golden set will spend months debating chunk sizes. A team with a golden set will make measurable weekly progress and know exactly when a change hurts.
Operating cost breakdown
For a typical 1M-document, 100 QPS system in 2026:
- BM25 (OpenSearch or Postgres
tsvector): $80-200/mo compute. - Vector index (pgvector on managed Postgres, or Qdrant/Weaviate managed): $150-500/mo.
- Reranker API: ~$400/mo at 8M reranks/month (top-50 × 100 QPS × 8h/day × 22d).
- Embeddings for ingestion: one-time backfill (~$50 per 10M chunks with a small model) plus incremental.
Total: under $1500/mo for a production semantic search backing a mid-sized SaaS. The reranker is often the largest line — cache aggressively for repeated queries.
Frequently asked
Do I still need BM25 if I have a great embedding model?
Yes. Every embedding model fails on rare tokens, IDs, and precise names. BM25 costs almost nothing and prevents an entire class of embarrassing misses.
Is a reranker worth the added latency?
For RAG feeding an LLM, almost always yes — the LLM itself adds seconds and the reranker adds a fraction of one. For instant-search UIs, only if you can hit <200ms budgets.
Which vector DB should I choose?
For <10M vectors and mostly-structured filters, pgvector on managed Postgres is the pragmatic pick. Above that, Qdrant or Vespa. Chase specialized engines only when you've measured a specific bottleneck.
Building something similar?
I help teams ship production-grade AI agents, n8n workflows, and data platforms. Let's talk about what you're building.
Work with me
Islam Gamal
AI, Data & Automation Engineer. I design and ship production AI agents, n8n workflows, and cloud data platforms — with a focus on reliability, cost, and measurable business impact. Founder of Tashghil and Tek bil Arabi.
More from Islam Gamal
RAG Chunking Strategies That Actually Work in Production
Fixed-size, semantic, hierarchical, and late-chunking approaches — with benchmarks, failure modes, and a decision tree for picking the right one.
Vector Databases in 2026: pgvector vs Pinecone vs Qdrant vs Weaviate vs Milvus
A hands-on 2026 comparison of the five vector databases I actually ship — pricing, latency, hybrid search, filtering, sharding, re-embed cost, and the decision tree I use in client architecture reviews.
RAG That Actually Works: Chunking, Hybrid Search, Reranking, and Evals
The full production playbook: why naïve RAG demos die on the second question, how to chunk, index, retrieve, rerank, cite, evaluate, and cost-engineer a retrieval-augmented system that survives real users.
Browse every article by Islam Gamal on the author page.