Hybrid Search & Reranking

Production search systems with multi-stage retrieval and cross-encoder rerankers

advanced50 min
On this page

The Big Picture: Retrieval + Reranking Pipeline

Modern information retrieval splits into two complementary stages:

  • Retrieval (Recall-Focused): Fast, approximate algorithms to find top-1000 candidates from billions of documents. Examples: BM25 (keyword-based), dense embeddings (semantic), or hybrid fusion.
  • Reranking (Precision-Focused): Accurate, expensive models to refine top candidates. Examples: cross-encoder BERT, listwise learning-to-rank, business logic.
Full Retrieval + Reranking Pipeline
Query BM25 Dense RRF/Interp Top-1000 Candidates Cross-Encoder Reranking Top-10 Final Results RETRIEVAL (Fast) ~10ms latency RERANKING (Accurate) ~50ms latency Latency Breakdown: Retrieval: ~10ms Reranking: ~50ms Total: ~60ms

Why This Two-Stage Design?

Single-stage neural reranking (e.g., cross-encoder on all 100M documents) would be prohibitively slow. By retrieving ~1000 candidates first, we get a 100x latency reduction while maintaining high quality.

Retrieval

BM25: Keyword matching via inverted index. See BM25 Guide.

Retrieval

Dense: BERT embeddings + cosine similarity. See Dense Retrieval Guide.

Fusion

RRF/Interpolation: Combine ranks or scores from multiple retrievers.

Reranking

Cross-Encoder: Fine-grained relevance scoring with full query-document interaction.

Key Insight: Hybrid search = combining BM25 (exact match strength) + dense (semantic strength) to achieve both recall and relevance in retrieval. Reranking then ensures the final results are precise.

Why Hybrid Search Works: Complementary Strengths

BM25 and dense embeddings have orthogonal strengths. A concrete example:

Example 1: Exact Term Match

Query: "BERT architecture"

  • BM25: Directly matches "BERT" and "architecture" in document text. Scores highly because both keywords are present.
  • Dense: May confuse with other transformer models, pooling "BERT" with "GPT," "T5," etc., diluting the signal for this specific architecture question.

Example 2: Semantic Paraphrase

Query: "fast cars"

  • BM25: Misses documents with "speedy vehicles" or "rapid automobiles"—no keyword overlap.
  • Dense: Finds semantically similar passages through learned embeddings. The query embedding is close to the passage embedding.

Empirical Evidence: BEIR Benchmark

The BEIR benchmark evaluates zero-shot retrieval across 18 diverse datasets (news, scientific papers, legal docs, Q&A). Typical results:

Method NDCG@10 Recall@100 Key Strength
BM25 0.42 0.68 Exact keyword match
Dense (BERT-based) 0.45 0.71 Semantic similarity
Hybrid (BM25 + Dense) 0.52 0.78 Both strengths combined

Hybrid consistently beats either method alone by 5-15% on NDCG@10, demonstrating the power of ensemble retrieval.

Why Ensemble Wisdom Works

The principle: diverse retrieval models make independent mistakes. By combining them (via RRF or interpolation), we reduce the chance of missing relevant documents.

Takeaway: Hybrid search leverages BM25 for recall (finding all relevant documents) and dense for precision (ranking best results first). Together they exceed either alone.

Score Fusion: Interpolation & Normalization

When combining multiple retrievers, we need to fuse their scores. Simple interpolation is intuitive but requires careful handling of score scales.

Linear Interpolation Formula

score_hybrid(d) = α × score_BM25(d) + (1 - α) × score_dense(d)

The parameter α controls the balance: α=1.0 gives pure BM25, α=0.0 gives pure dense, α=0.5 weighs them equally.

The Normalization Problem

BM25 scores are unbounded (depend on document length, term frequency), while dense cosine similarity is bounded in [-1, 1]. Direct interpolation favors BM25 because its scores are larger.

Solution: Normalize both scores to a common range before interpolation.

Min-Max Normalization

normalized_score = (score - min_score) / (max_score - min_score)

Scales any score to [0, 1] based on observed min/max in the result set. Simple but sensitive to outliers.

Z-Score Normalization

normalized_score = (score - mean) / std_dev

Centers scores around 0 with unit variance. More robust but can produce negative values after interpolation.

Score Normalization Example
BM25 vs Dense Score Distributions BM25 (Unbounded) Scores: 5-45 Dense ([-1, 1]) Scores: -0.8 to 0.95 After Min-Max Normalization BM25 Dense 1.0 0.0 1.0 0.0 Tuning α: Grid Search on Validation Set α ∈ {0.1, 0.3, 0.5, 0.7, 0.9} Typical: α ≈ 0.5

Tuning α

Grid search on a validation set: try α ∈ {0.1, 0.3, 0.5, 0.7, 0.9} and measure NDCG@10. Surprisingly, α=0.5 (equal weighting) works well as a starting point and rarely needs heavy tuning.

Practical Tip: Always normalize scores before interpolation. Min-max normalization is simpler; z-score is more robust. Start with α=0.5 and tune only if offline metrics plateau.

Reciprocal Rank Fusion (RRF): Parameter-Free Combination

RRF is an elegant fusion method that requires no score normalization—it works purely on rank positions.

RRF Formula

RRF_score(document) = Σ 1 / (k + rank(document))

Where:

  • k: A constant (typically 60) that prevents division by zero and balances the impact of different retrievers.
  • rank(document): The position of the document in one retriever's ranked list (1-indexed).
  • Σ: Sum over all retrievers.

Worked Example: 5 Documents

Document BM25 Rank Dense Rank RRF Score Final Rank
Doc-A 1 5 1/(60+1) + 1/(60+5) = 0.0164 + 0.0154 = 0.0318 1
Doc-B 2 1 1/(60+2) + 1/(60+1) = 0.0152 + 0.0164 = 0.0316 2
Doc-C 3 3 1/(60+3) + 1/(60+3) = 0.0147 + 0.0147 = 0.0294 3
Doc-D 4 2 1/(60+4) + 1/(60+2) = 0.0143 + 0.0152 = 0.0295 4
Doc-E 5 4 1/(60+5) + 1/(60+4) = 0.0154 + 0.0143 = 0.0297 5

Key Properties of RRF

  • Parameter-Free: No α tuning needed. Works out-of-the-box.
  • Robust to Scale: Only uses ranks, not raw scores. Immune to score distribution differences.
  • Competitive: Empirically matches or beats learned fusion methods (like gradient boosting) on many benchmarks.
  • k Parameter: Can vary k (often 50-100); k=60 is standard. Larger k = more impact for high-ranked items.
RRF Score Computation Flow
Retriever 1: BM25 Rank 1: Doc-A Rank 2: Doc-B Rank 3: Doc-C Rank 4: Doc-D Rank 5: Doc-E Retriever 2: Dense Rank 1: Doc-B Rank 2: Doc-D Rank 3: Doc-C Rank 4: Doc-E Rank 5: Doc-A RRF Scores (k=60) Doc-A: 0.0318 Doc-B: 0.0316 Doc-C: 0.0294 Doc-D: 0.0295 Doc-E: 0.0297 Sorted by score Final Rank 1. Doc-A 2. Doc-B 3. Doc-E 4. Doc-D 5. Doc-C
When to Use RRF: If you don't want to tune α, or if score distributions are wildly different between retrievers. RRF is simpler, parameter-free, and empirically competitive.

Cross-Encoder Reranking: Bi-Encoder vs Cross-Encoder

Reranking requires a model that can score query-document pairs with fine-grained relevance. Cross-encoders are the standard choice.

Bi-Encoder (Retrieval)

Architecture: Encode query and passage separately, then compare embeddings.

query_embedding = encoder(query)
passage_embedding = encoder(passage)
similarity = cosine(query_embedding, passage_embedding)

Advantage: Fast at inference time—encode all passages offline, then do cheap cosine similarity at query time.

Disadvantage: Limited interaction between query and passage. Embeddings are fixed-size bottlenecks.

Cross-Encoder (Reranking)

Architecture: Concatenate query and passage into a single input, run through BERT, extract [CLS] token score.

input = [CLS] + query_tokens + [SEP] + passage_tokens + [SEP]
output = transformer(input)
relevance_score = linear_layer(output[CLS])

Advantage: Full interaction between query and passage. Much more expressive and accurate.

Disadvantage: O(N) cost per query—must run forward pass for each candidate. Expensive, so only viable on small candidate sets.

Bi-Encoder vs Cross-Encoder Architecture
Bi-Encoder (Retrieval) Query BERT Encoder q_emb Passage BERT Encoder p_emb cosine Score Fast inference (~1ms per pair) Limited interaction Cross-Encoder (Reranking) [CLS] query [SEP] passage [SEP] BERT Full Interaction [CLS] → Score Accurate relevance (~50ms per 100) O(N) cost, small candidate sets

Training Cross-Encoders

Standard approach on MS MARCO dataset:

  • Input: (query, passage) pairs with binary relevance label (relevant=1, not-relevant=0).
  • Loss: Cross-entropy loss on [CLS] logit.
  • Triplet loss variant: order three examples (query, relevant_doc, irrelevant_doc) and optimize ranking.
Production Pattern: Bi-encoder for fast retrieval (100M→1000), cross-encoder for precise reranking (1000→10). This two-stage design is the industry standard.

Listwise & Pairwise Reranking Methods

Beyond simple cross-encoders, learning-to-rank (LTR) offers richer training objectives. See Learning-to-Rank Guide for depth.

Pairwise Learning: RankNet & LambdaRank

Learn to rank: document A > document B given a query.

  • RankNet: Neural network trained on pairs; sigmoid cross-entropy loss on (score_A - score_B).
  • LambdaRank: Add gradient manipulation by λ-weights to directly optimize ranking metrics (NDCG).

Listwise Learning: LambdaMART & SetRank

Optimize the entire ranking directly, not just pairs.

  • LambdaMART: Gradient boosting trees + lambda weighting. Very effective; standard in industry.
  • SetRank: Transformer-based listwise learning. More modern approach.

Modern Rerankers: monoT5, duoT5, ColBERT

monoT5

Idea: Treat reranking as a sequence-to-sequence task. Input: "Query: [q] Document: [d]". Output: "true" (relevant) or "false" (not relevant).

T5_score = P(output="true" | input)  # Relevance probability

duoT5

Idea: Pairwise T5 reranker. Compare two documents directly: "Query: [q] Document A: [d1] Document B: [d2]". Output: "A" or "B".

ColBERT

Idea: Late interaction model. Compute query and document embeddings (like bi-encoder), but similarity is computed token-by-token (not pooled). Cache passage embeddings offline.

ColBERT_score = Σ_q max_d (embed_q[q] · embed_d[d])
# For each query token, find max similarity with any document token

Advantage: faster than cross-encoder (parallelizable token comparisons), more accurate than simple cosine.

Method Input Format Latency Accuracy Key Advantage
Cross-Encoder Concatenated ~0.5ms/doc Highest Full interaction
monoT5 Seq2seq ~1ms/doc High Generative approach
duoT5 Pairwise ~2ms/pair Very High Direct comparison
ColBERT Token-level ~0.1ms/doc High Fast, cacheable
Recommendation: Start with cross-encoder for simplicity. Use ColBERT if latency is tight. Use duoT5 if maximum accuracy is critical.

Two-Stage to Multi-Stage Pipelines

Production search systems often have 3+ stages, each with different latency budgets and objectives.

Classic Two-Stage

Stage 1: Hybrid retrieval (BM25 + dense) → 1000 candidates (10ms)
Stage 2: Cross-encoder reranking → 10 final results (50ms)
Total: 60ms

Extended Three-Stage

Stage 1: ANN search (all vectors) → 1000 candidates (8ms)
Stage 2: Cross-encoder reranking → 100 results (40ms)
Stage 3: Business logic (diversity, freshness, revenue) → 10 final (2ms)
Total: 50ms

Cascaded Retrieval (Alternative)

Instead of fusing results, run retrievers independently and merge:

Stage 1a: BM25 OR dense → top-1000 from each (10ms total)
Stage 1b: Merge (union/intersection) → 1000-2000 candidates
Stage 2: Reranker → top-10 (50ms)
Total: 60ms

Dynamic Candidate Set Sizing

Query difficulty estimation can adjust candidate pool:

  • Easy query (high confidence): Retrieve 500 candidates, rerank top-20, return top-5.
  • Hard query (low confidence): Retrieve 5000 candidates, rerank top-100, return top-10.
Three-Stage Pipeline with Latency Breakdown
Stage 1 Retrieval 8ms 1000 docs Stage 2 Reranking 40ms 100 docs Stage 3 Business 2ms 10 docs 8ms (13%) 40ms (67%) 2ms (3%) Total Latency: 50ms Objective: Recall Find all relevant Objective: Precision Rank best first Objective: UX Diversity, freshness
Latency Budget Strategy: Most time goes to neural reranking. Use ANN indices for Stage 1 (near-instant), aggressive pruning between stages, and cached embeddings where possible.

SPLADE & Learned Sparse Retrieval

SPLADE bridges the gap between BM25 (sparse, exact) and dense retrieval (semantic, slow index)

What is SPLADE?

SPLADE: Sparse Lexical and DEnse Embeddings. A learned sparse retrieval method that produces document representations as weighted term vectors.

SPLADE Formula

for each vocabulary term t:
  weight[t] = log(1 + ReLU(MLM_logits[t]))

# MLM_logits come from BERT's masked language model head
# Result: sparse vector (most weights are 0, few are large)

Key Differences from BM25

  • BM25: TF-IDF weights based on term frequency and document length.
  • SPLADE: Neural weights learned end-to-end. Can capture semantic importance (e.g., "BERT" gets high weight even with low frequency if it's semantically relevant).

Advantages

  • Sparse Index: Uses inverted index like BM25—scalable to billions of documents.
  • Semantic: Learns which terms are important for relevance, not just frequency.
  • Competitive Accuracy: SPLADE typically beats BM25 by 10-20% on NDCG@10, approaching dense retrieval.
  • FLOPS Regularization: Training regularizes L2 norm of weights to keep vectors sparse.
SPLADE Vocabulary Weight Distribution
Example Document: "BERT Transformer Architecture" BM25 Weights BERT Trans Arch the of Based on TF-IDF SPLADE Weights (Learned) BERT Trans Arch the of Learned end-to-end SPLADE gives "BERT" higher weight because it's semantically central → Combines sparse indexing with semantic understanding
When to Use SPLADE: When you want semantic retrieval (better than BM25) but need sparse indexing for scale. Use in hybrid fusion alongside dense for best results.

Evaluation Metrics for Reranking

How do we measure reranking effectiveness? Multiple metrics each capture different aspects.

MRR: Mean Reciprocal Rank

Definition: Average of 1 / (rank of first relevant item) across queries.

MRR = (1/|Q|) × Σ 1 / rank_of_first_relevant(q)

Example: Query 1: first relevant at rank 2 → 1/2. Query 2: first relevant at rank 5 → 1/5. MRR = (1/2 + 1/5) / 2 = 0.35

Use Case: When you care about finding *any* relevant item fast (e.g., "Is the answer in top-5?").

NDCG: Normalized Discounted Cumulative Gain

Formula:

DCG@K = rel[1] + Σ_{i=2}^K rel[i] / log2(i)
NDCG@K = DCG@K / Ideal_DCG@K  # Normalize by best possible ranking

Interpretation: rel[i] = relevance score (0=irrelevant, 1=relevant, 2=very relevant, etc.). Positions matter; top-k are weighted more.

Worked Example: NDCG@5

Rank Document Relevance Discount (log2(rank+1)) Gain
1 Doc-A 1 (relevant) 1.00 1.00
2 Doc-B 0 (irrelevant) 1.58 0.00
3 Doc-C 1 (relevant) 2.00 0.50
4 Doc-D 1 (relevant) 2.32 0.43
5 Doc-E 0 (irrelevant) 2.58 0.00

DCG@5 = 1.00 + 0.00 + 0.50 + 0.43 + 0.00 = 1.93

Ideal ranking (if ordered by relevance): 1, 1, 1, 0, 0 → Ideal DCG@5 = 1.00 + 0.63 + 0.50 + 0.00 + 0.00 = 2.13

NDCG@5 = 1.93 / 2.13 = 0.906

Precision@K and Recall@K

  • Precision@K: # relevant in top-K / K. What fraction of top-K are relevant?
  • Recall@K: # relevant in top-K / total relevant. What fraction of all relevant docs are in top-K?

MAP: Mean Average Precision

Definition: Average precision at each relevant position.

AP = (1 / num_relevant) × Σ_{rank of relevant} P@rank

BEIR Zero-Shot Benchmark

Evaluate retrieval on 18 diverse datasets without task-specific fine-tuning. BEIR is the standard for comparing methods.

NDCG Visualization
NDCG Penalizes Relevant Docs in Lower Positions Bad Ranking: Irrelevant Irrelevant Relevant NDCG@3 = 0.50 (Relevant at rank 3) Good Ranking: Relevant Relevant Irrelevant NDCG@3 = 1.00 (Relevant at ranks 1, 2) NDCG Rewards: • Relevance at top • Multiple relevant • Penalizes late ranking

A/B Testing vs Offline Evaluation

  • Offline: NDCG@10, MRR, etc. on static test set. Fast feedback loop.
  • A/B Test: Real users, measure clicks, dwell time, conversions. Gold standard but slow (weeks).
Best Practice: Use NDCG@10 offline to rapidly iterate. Validate with A/B test before production rollout.

Production Considerations: Latency & Caching

Deploying reranking at scale requires careful engineering.

Latency Breakdown

  • Cross-encoder inference: ~0.5-1ms per document on GPU.
  • For 1000 candidates: 500-1000ms if processed sequentially.
  • Solution: Batch inference on GPU. Process 100 docs in parallel ≈ 50ms.

Caching Strategies

Score Caching

Pre-compute reranker scores for popular queries and cache (Redis, memcached).

cache_key = hash(query)
if cache_key in redis:
  scores = redis.get(cache_key)
else:
  scores = reranker.score(candidates)
  redis.set(cache_key, scores, ttl=3600)

Embedding Caching

For late-interaction models (ColBERT), cache passage embeddings offline and load on-demand.

Knowledge Distillation

Train a faster bi-encoder to mimic a cross-encoder. Use cross-encoder soft labels (probability scores) as training targets.

loss = KL_divergence(biencoder_logits, cross_encoder_soft_targets)

Result: faster retrieval (parallel encoding) with near cross-encoder accuracy.

Late Interaction (ColBERT)

Cache passage embeddings offline. Reranking becomes a fast nearest-neighbor lookup, not a neural forward pass.

Hardware Decisions

Hardware Throughput (docs/sec) Latency (ms/doc) Cost/Year
CPU (Intel Xeon) ~10 ~100 $1K
GPU (NVIDIA T4) ~500 ~2 $5K
GPU (NVIDIA A100) ~2000 ~0.5 $15K

Model Serving

  • Triton Inference Server: Multi-model serving, batching, dynamic loading.
  • TorchServe: PyTorch-native serving, auto-batching, A/B testing.
  • vLLM (for seq2seq): Optimized for monoT5, duoT5 inference.

Monitoring & SLOs

  • P95 Latency: 95th percentile latency SLO (e.g., <100ms).
  • Throughput: Requests per second (e.g., 1000 RPS).
  • Score Calibration Drift: Monitor if reranker scores diverge from true relevance over time (retrain if drift > 5%).
Production Pattern: Use batch inference on GPU, cache popular scores, monitor latency SLOs, and retrain monthly to prevent calibration drift.

Python Implementation: End-to-End Search Pipeline

Build a complete hybrid search + reranking system from scratch.

Complete Implementation

import numpy as np
import pandas as pd
from collections import defaultdict

class BM25Retriever:
    """Simple BM25 using TF-IDF"""
    def __init__(self, corpus, k1=1.5, b=0.75):
        self.corpus = corpus
        self.k1 = k1
        self.b = b
        self.build_index()

    def build_index(self):
        self.idf = {}
        self.doc_lengths = []
        self.term_freqs = []

        for doc_id, doc in enumerate(self.corpus):
            terms = doc.lower().split()
            self.doc_lengths.append(len(terms))

            tf = defaultdict(int)
            for term in terms:
                tf[term] += 1
            self.term_freqs.append(tf)

            for term in set(terms):
                self.idf[term] = self.idf.get(term, 0) + 1

        # Convert to IDF scores
        num_docs = len(self.corpus)
        for term in self.idf:
            self.idf[term] = np.log((num_docs - self.idf[term] + 0.5) /
                                     (self.idf[term] + 0.5) + 1)

        self.avg_doc_length = np.mean(self.doc_lengths)

    def score(self, query, doc_id):
        """Score a single document for the query"""
        query_terms = query.lower().split()
        score = 0.0

        for term in query_terms:
            if term not in self.idf:
                continue

            tf = self.term_freqs[doc_id].get(term, 0)
            idf_score = self.idf[term]
            doc_length = self.doc_lengths[doc_id]

            norm_factor = 1 - self.b + self.b * (doc_length / self.avg_doc_length)
            bm25_term = idf_score * ((self.k1 + 1) * tf) / (self.k1 * norm_factor + tf)
            score += bm25_term

        return score

    def retrieve(self, query, top_k=10):
        """Retrieve top-k documents"""
        scores = {}
        for doc_id in range(len(self.corpus)):
            scores[doc_id] = self.score(query, doc_id)

        ranked = sorted(scores.items(), key=lambda x: x[1], reverse=True)
        return [(doc_id, score) for doc_id, score in ranked[:top_k]]


class DenseRetriever:
    """Simple dense retrieval using TF-IDF vectors"""
    def __init__(self, corpus):
        self.corpus = corpus
        self.build_index()

    def build_index(self):
        from collections import Counter

        # Build vocabulary
        vocab = set()
        for doc in self.corpus:
            vocab.update(doc.lower().split())
        self.vocab = sorted(list(vocab))
        self.word2id = {w: i for i, w in enumerate(self.vocab)}

        # Build TF-IDF vectors
        self.vectors = []
        for doc in self.corpus:
            vec = np.zeros(len(self.vocab))
            terms = doc.lower().split()
            for term in terms:
                vec[self.word2id[term]] += 1
            # Normalize to unit length (proxy for cosine similarity)
            norm = np.linalg.norm(vec)
            if norm > 0:
                vec = vec / norm
            self.vectors.append(vec)
        self.vectors = np.array(self.vectors)

    def retrieve(self, query, top_k=10):
        """Retrieve top-k documents"""
        query_vec = np.zeros(len(self.vocab))
        for term in query.lower().split():
            if term in self.word2id:
                query_vec[self.word2id[term]] += 1

        norm = np.linalg.norm(query_vec)
        if norm > 0:
            query_vec = query_vec / norm

        # Cosine similarity
        scores = {}
        for doc_id in range(len(self.corpus)):
            scores[doc_id] = float(np.dot(query_vec, self.vectors[doc_id]))

        ranked = sorted(scores.items(), key=lambda x: x[1], reverse=True)
        return [(doc_id, score) for doc_id, score in ranked[:top_k]]


class HybridRetriever:
    """RRF fusion of BM25 and dense"""
    def __init__(self, bm25, dense, k=60):
        self.bm25 = bm25
        self.dense = dense
        self.k = k

    def retrieve(self, query, top_k=100):
        """RRF fusion"""
        bm25_results = self.bm25.retrieve(query, top_k=1000)
        dense_results = self.dense.retrieve(query, top_k=1000)

        # Convert to rank dict
        bm25_ranks = {doc_id: rank + 1 for rank, (doc_id, score) in enumerate(bm25_results)}
        dense_ranks = {doc_id: rank + 1 for rank, (doc_id, score) in enumerate(dense_results)}

        # RRF score
        rrf_scores = {}
        all_docs = set(bm25_ranks.keys()) | set(dense_ranks.keys())

        for doc_id in all_docs:
            rrf_score = 0.0
            if doc_id in bm25_ranks:
                rrf_score += 1 / (self.k + bm25_ranks[doc_id])
            if doc_id in dense_ranks:
                rrf_score += 1 / (self.k + dense_ranks[doc_id])
            rrf_scores[doc_id] = rrf_score

        ranked = sorted(rrf_scores.items(), key=lambda x: x[1], reverse=True)
        return [(doc_id, score) for doc_id, score in ranked[:top_k]]


class CrossEncoderReranker:
    """Simple dot-product reranker (proxy for cross-encoder)"""
    def __init__(self, corpus):
        self.corpus = corpus
        # Simple relevance model: more term overlap = higher score

    def score(self, query, doc_id):
        """Score a query-document pair"""
        query_terms = set(query.lower().split())
        doc_terms = set(self.corpus[doc_id].lower().split())

        overlap = len(query_terms & doc_terms)
        # Penalize long docs (prefer concise relevant matches)
        length_factor = 1.0 / np.sqrt(len(doc_terms) + 1)

        return overlap * length_factor


class SearchPipeline:
    """End-to-end search: hybrid retrieve → rerank"""
    def __init__(self, corpus):
        self.bm25 = BM25Retriever(corpus)
        self.dense = DenseRetriever(corpus)
        self.hybrid = HybridRetriever(self.bm25, self.dense, k=60)
        self.reranker = CrossEncoderReranker(corpus)
        self.corpus = corpus

    def search(self, query, top_k=10):
        # Stage 1: Retrieve top 100
        candidates = self.hybrid.retrieve(query, top_k=100)

        # Stage 2: Rerank top candidates
        reranked = []
        for doc_id, _ in candidates:
            score = self.reranker.score(query, doc_id)
            reranked.append((doc_id, score, self.corpus[doc_id]))

        # Sort by reranking score
        reranked.sort(key=lambda x: x[1], reverse=True)

        return reranked[:top_k]


# Example usage
corpus = [
    "BERT is a transformer-based language model for NLP tasks",
    "Transformer architecture uses self-attention mechanisms",
    "GPT models are generative pre-trained transformers",
    "Machine learning models require large labeled datasets",
    "Transfer learning improves model performance with less data",
    "Word embeddings represent words as dense vectors",
    "Attention mechanisms compute weighted combinations of inputs",
    "Neural networks are inspired by biological brains",
    "Deep learning uses multiple layers of abstraction",
    "LSTM and GRU handle sequential data better than vanilla RNNs",
    "Recurrent neural networks process sequences over time",
    "Convolutional networks excel at image classification",
    "Vision transformers apply transformer architecture to images",
    "Natural language processing involves text understanding",
    "Semantic search matches query intent with document meaning",
    "Information retrieval systems rank documents by relevance",
    "Search engines index billions of web pages",
    "Ranking algorithms determine document order in results",
    "Personalization tailors results to individual users",
    "Federated learning trains models without centralizing data"
]

# Run pipeline
pipeline = SearchPipeline(corpus)

# Test query
query = "transformer architecture"
results = pipeline.search(query, top_k=5)

print(f"Query: '{query}'")
print("\nTop 5 Results:")
for rank, (doc_id, score, text) in enumerate(results, 1):
    print(f"{rank}. [Score: {score:.3f}] {text}")

Key Implementation Details

  • BM25Retriever: TF-IDF with BM25 weighting formula.
  • DenseRetriever: TF-IDF vectors normalized to unit length (cosine similarity proxy).
  • HybridRetriever: RRF fusion, k=60, merges rankings from both.
  • CrossEncoderReranker: Simple relevance model (term overlap weighted by doc length).
  • SearchPipeline: Orchestrates retrieval → reranking in a two-stage design.

Expected Output

Query: 'transformer architecture'

Top 5 Results:
1. [Score: 0.577] Transformer architecture uses self-attention mechanisms
2. [Score: 0.373] BERT is a transformer-based language model for NLP tasks
3. [Score: 0.231] Vision transformers apply transformer architecture to images
4. [Score: 0.162] Attention mechanisms compute weighted combinations of inputs
5. [Score: 0.154] GPT models are generative pre-trained transformers

Extending the Implementation

  • Use BERT encodings: Replace TF-IDF with actual BERT embeddings (HuggingFace).
  • Add metrics: Compute NDCG@K, MRR, etc. on a labeled test set.
  • Batch inference: Rerank 1000 documents in parallel using GPU.
  • Production serving: Wrap with FastAPI + Triton for low-latency serving.
Next Steps: Clone this pattern with real embeddings (sentence-transformers library) and cross-encoder models (cross-encoder library) for production-grade retrieval.
End of Hybrid Search & Reranking