hands-on labon a real NVIDIA GPU

Advanced RAG: Hybrid Search + Cross-Encoder Reranking

Build a production-shape retrieval stack, dense bi-encoder plus from-scratch BM25, fused with Reciprocal Rank Fusion, then re-ordered by a BAAI cross-encoder.

time
40 min
checked steps
4
level
Advanced
setup
None

$29.99a month with Pro

Pro opens this lab and every other one. Cancel any time.

Lab cockpit40 min · 4 stepsSession running
3 / 4 steps passingCross-encoder reranking · step 4 of 4
lab.ipynb

Step 4 — Cross-encoder reranking

Bi-encoders (our BGE dense embedder) run in two phases: encode docs once at index time, encode query once at query time, compute one matmul. Fast, scales to billions of docs, but the query and passage never "meet" inside a model.

[ ]:
from transformers import AutoModelForSequenceClassification print('Loading BGE-reranker-base (~280 MB first run)...')RERANK_ID = 'BAAI/bge-reranker-base'rerank_tokenizer = AutoTokenizer.from_pretrained(RERANK_ID)reranker = AutoModelForSequenceClassification.from_pretrained(RERANK_ID).to(device)reranker.eval() def rerank(query, passages):    """Score each passage against the query using the cross-encoder. Returns logits (higher = more relevant)."""    pairs = [(query, p) for p in passages]    enc = rerank_tokenizer(        [q for q, _ in pairs], [p for _, p in pairs],        padding=True, truncation=True, max_length=256, return_tensors='pt',    ).to(device)    with torch.no_grad():        logits = reranker(**enc).logits.squeeze(-1)    return logits.cpu().tolist() # Rerank the top-5 hybrid candidatescandidates_idx = sorted(range(len(rrf_scores)), key=lambda i: -rrf_scores[i])[:5]candidates = [corpus[i] for i in candidates_idx]rerank_scores_for_candidates = rerank(failure_query, candidates) # Rebuild a full-corpus score list (non-candidates get -inf so they never win)rerank_scores = [float('-inf')] * len(corpus)for orig_idx, rs in zip(candidates_idx, rerank_scores_for_candidates):    rerank_scores[orig_idx] = rsreranked_top1_idx = max(range(len(rerank_scores)), key=lambda i: rerank_scores[i]) print(f'\nReranker scores for top-5 hybrid candidates:')for orig_idx, rs in sorted(zip(candidates_idx, rerank_scores_for_candidates), key=lambda x: -x[1]):    marker = ' <-- HALCYON doc' if orig_idx == TRUE_RELEVANT_IDX else ''    print(f'  [idx={orig_idx}, logit={rs:+.3f}] {corpus[orig_idx][:90]}...{marker}')
[ ]:
lab.check(4)
step 4 of 4, replayed: Cross-encoder reranking. The code, the output and the check are the lab's own.

4 steps, each checked when you finish it

A check runs your work at the end of every step. Hints and the full solution are there if you get stuck.

  1. 1

    Set up corpus + dense baseline, find a dense-failure case

    read this step below ↓

  2. 2

    BM25 keyword retrieval from scratch

  3. 3

    Reciprocal Rank Fusion: combine dense + BM25

  4. 4

    Cross-encoder reranking

Step 1 as it appears in the lab

The lab’s own text. The hint and the solution stay inside the lab.

Step 1: Set up corpus + dense baseline, find a dense-failure case

Lab 4 showed the basic RAG pipeline: one embedding model, cosine similarity, done. In production, naive dense retrieval leaves 10–30% of queries on the table — especially ones involving rare keywords, acronyms, product names, or proper nouns. The industry's answer is a two-stage retrieval stack:

query ┬─→ [dense]   ─┐
       │              ├─→ merge top-K ─→ [cross-encoder reranker] ─→ top-N → LLM
       └─→ [BM25]    ─┘      (via RRF)

Three upgrades over vanilla RAG, each earned by empirical pain:

  1. BM25 keyword retrieval — catches rare-term queries that dense embeddings sometimes miss
  2. Reciprocal Rank Fusion (RRF) — the canonical way to blend heterogeneous score distributions
  3. Cross-encoder reranker — a second, slower, more accurate model re-orders the top candidates

Why each piece exists

  • BM25 (Robertson & Zaragoza, 2009) is a 40-year-old TF-IDF descendant. In 2024, it's still competitive with dense retrieval — and strictly better on rare-token queries where embeddings' semantic smoothing hurts. Sparse retrieval's strength: it scores based on exact term overlap, weighted by how distinctive each term is.
  • RRF (Cormack et al., 2009) blends ranked lists by summing 1/(k + rank) across retrievers. Simple, parameter-light, beats most learned fusion methods. Default in Weaviate, Elasticsearch, Vespa.
  • Cross-encoder reranker is a model that takes both query AND passage together and outputs a single relevance score. Much slower than dense retrieval (no precomputed vectors), but far more accurate per pair. Used to re-score the top 20–100 candidates from first-stage retrieval. BAAI's bge-reranker-base is the go-to open option; Cohere Rerank is the go-to paid option.

References to read alongside


The best way to see why hybrid search matters is to find a query where dense retrieval fails. Our corpus will include a few entries with distinctive proper nouns / acronyms — the kind of content dense embeddings smooth away.

import torch
import torch.nn.functional as F
from transformers import AutoModel, AutoTokenizer

device = torch.device('cuda')

# Corpus includes rare proper nouns that test embeddings' generalization
corpus = [
    "Our internal CI/CD system is called NIMBUS-OPS. It auto-deploys every merged PR to staging within 90 seconds.",
    "The customer support portal is built on ZephyrDesk v3. It handles 50,000 tickets per day at 99.95% SLA.",
    "We use a custom observability stack combining Prometheus, Grafana, and Loki for logs aggregation.",
    "All employee badges are managed by the HALCYON access-control system, replaced in 2023 from the previous Mercury platform.",
    "Our data warehouse runs on Snowflake. Daily ETL batches process ~2 TB into fact tables for the BI team.",
    "Production Kubernetes clusters are managed through a GitOps layer built on ArgoCD + Kustomize.",
    "Engineering onboarding takes two weeks and includes a rotation through each of the six platform teams.",
    "Incident response follows the PagerDuty + Slack + Zoom bridge pattern. Ops rotations are weekly, 6 engineers per tier.",
    "Our internal ML platform, codenamed PRISM, runs on Ray and supports distributed training up to 128 GPUs.",
    "Employee expenses go through Concur Expense. Approval chains are based on manager org hierarchy with a 72-hour SLA.",
]
print(f'{len(corpus)} documents in corpus')

# Use BGE-small as the dense embedder (same as Lab 4)
embed_tokenizer = AutoTokenizer.from_pretrained('BAAI/bge-small-en-v1.5')
embed_model = AutoModel.from_pretrained('BAAI/bge-small-en-v1.5').to(device)
embed_model.eval()

def embed(texts, max_len=256):
    enc = embed_tokenizer(texts, padding=True, truncation=True, max_length=max_len, return_tensors='pt').to(device)
    with torch.no_grad():
        out = embed_model(**enc)
    return F.normalize(out.last_hidden_state[:, 0], dim=-1)

passage_embs = embed(corpus)
print(f'Embedded into {passage_embs.shape[1]}-dim vectors')
# Find a dense-retrieval failure case.
# Query contains a rare proper noun (HALCYON) that should clearly match doc 3.
# Dense embeddings tend to smooth away exact term identity in favor of general semantics — so they might
# retrieve a different access-control-ish doc first instead.

failure_query = 'Who provides our employee badge access system?'
q_vec = embed([failure_query])
scores = (q_vec @ passage_embs.T).squeeze(0)
dense_top1 = int(scores.argmax().item())

# Show top-3 for context
top_scores, top_idx = torch.topk(scores, k=3)
print(f'DENSE RETRIEVAL for query: {failure_query!r}\n')
for rank, (s, i) in enumerate(zip(top_scores.cpu().tolist(), top_idx.cpu().tolist()), 1):
    marker = ' <-- should be this one (HALCYON)' if i == 3 else ''
    print(f'  #{rank} [idx={i}, score={s:.3f}] {corpus[i][:100]}...{marker}')

TRUE_RELEVANT_IDX = 3  # The HALCYON doc
dense_failure = {
    'query': failure_query,
    'dense_top1_idx': dense_top1,
    'true_relevant_idx': TRUE_RELEVANT_IDX,
    'dense_top1_text': corpus[dense_top1],
}
if dense_top1 != TRUE_RELEVANT_IDX:
    print(f'\n DENSE MISSED: returned idx {dense_top1} instead of the true {TRUE_RELEVANT_IDX} (HALCYON doc).')
    print(' Rare proper nouns like HALCYON are exactly where keyword retrieval shines.')

what you get

  • A JupyterLab notebook on a real NVIDIA GPU
  • A check at the end of every step
  • Hints and the full solution
  • Nothing to install or configure

before you start

  • Familiarity with transformer embedding models (BGE, sentence-transformers)
  • Completed a basic RAG pipeline (dense retrieval + LLM)
  • Comfortable with PyTorch tensors on CUDA

Exam domains covered

Retrieval-Augmented GenerationLLM Application Development

Part of the RAG and search track

Lab 6 of 14. Chunking, hybrid search and reranking, permission-aware retrieval, GraphRAG and RAG evaluation.

Questions about this lab

Why build hybrid + reranking when a strong dense encoder like BGE gets most queries right?

Because 'most' isn't good enough for enterprise RAG. BGE-small is excellent on semantic paraphrase but still fragile on rare proper nouns, acronyms, product codes, and exact-match keyword queries, the exact patterns BM25 was designed for. Hybrid via RRF costs you one extra sparse retriever (milliseconds, no GPU) and strictly dominates either component alone. Reranking on top adds joint query-passage attention to the top-K and tightens precision where it matters most, the ranked list you actually pass to the LLM.

Why Reciprocal Rank Fusion instead of score averaging or min-max normalization?

Because dense cosine scores and BM25 scores live in incompatible distributions, cosine is bounded in roughly [-1, 1] while BM25 scales with IDF and document length and can easily exceed 20. Any normalization you pick (min-max, z-score, linear scaling) is a brittle hyperparameter that drifts as the corpus changes. RRF throws scores away entirely and fuses ranks with 1 / (k + rank), k=60. It is calibration-free, monotonic, and what every modern retrieval paper defaults to.

Why rerank only the top-5 and not the top-100?

Cost. A cross-encoder must run the full transformer over every (query, passage) pair with no caching, latency scales linearly with the candidate count. Top-5 gives you visible precision gains at essentially zero extra wall-clock budget; top-100 is where you start hurting the end-user. In production you tune K to your SLA: 10-50 is typical, 100+ only when recall matters more than latency and you can batch aggressively on the GPU.

Does BM25 really need to be written from scratch, isn't there a library?

There are several (rank_bm25, pyserini, Lucene bindings) and you absolutely use them in production. The lab implements it by hand because the formula, IDF weighting, term-frequency saturation, length normalization, is the entire reason BM25 beats naive keyword matching, and you should be able to read it off a page. Once you've written the 30-line version, swapping in a fast library is a trivial optimization, not a conceptual step.

What if my dense encoder already gets the rare-keyword query right?

The check script handles that case: BGE-small is strong enough that on a tiny corpus it sometimes succeeds on exactly the query you designed to break it. The pipeline still demonstrates the full hybrid + rerank path either way. At real corpus scale (hundreds of thousands of passages) and with naturally adversarial queries, dense-only regresses frequently enough that hybrid remains the production default, this lab gives you the instrumentation to see it.

How is the cross-encoder different from just a bigger bi-encoder?

Architecture, not size. A bi-encoder encodes query and passage independently and compares fixed vectors, attention never flows between them. A cross-encoder concatenates query and passage into a single sequence, so every query token attends to every passage token through all layers. That joint attention captures reasoning about negation, quantifiers, and co-reference that no amount of embedding-dimensionality can express. Cost: you cannot precompute passage vectors, so you only run it on a short candidate list.

What you'll build in this hybrid retrieval + reranking lab

Dense-only retrieval breaks on rare proper nouns, acronyms, and product codes, the exact queries that show up most in enterprise RAG. Hybrid search fused with cross-encoder reranking is the architecture every serious production system converges to, and in 40 minutes you'll build it end-to-end. You'll leave with a working two-stage retrieval stack (dense bi-encoder + BM25 fused via Reciprocal Rank Fusion, then reranked by a cross-encoder), a from-scratch BM25 implementation that makes the Robertson-Sparck-Jones IDF formula no longer feel like a black box, and a clear mental model of why bi-encoder vs cross-encoder is the trade-off that governs every modern RAG system. You'll also see the score gap between the correct doc and its runner-up widen after reranking, a visceral demo of what the cross-encoder is actually buying you over cheaper ranking methods.

The technical substance is the two asymmetries that force the two-stage shape. First asymmetry: bi-encoders like BAAI/bge-small-en-v1.5 encode query and passage independently, so passage vectors precompute offline and every query is one matmul, fast enough for a million-document corpus. Cross-encoders like BAAI/bge-reranker-base concatenate (query, passage) and run full attention across both, so every query token attends to every passage token, far more accurate, completely uncacheable, and linear in candidate count. You can't run a cross-encoder over a million docs; you can't trust a bi-encoder alone on rare-term queries. Second asymmetry: dense cosine scores live in [-1, 1] while BM25 scales with IDF and document length into the 20s. RRF sidesteps this with 1 / (k + rank), k=60, rank-based fusion that's calibration-free, monotonic, and the default in modern retrieval papers because any normalization you pick (min-max, z-score) is a brittle hyperparameter that drifts with the corpus. You'll also see the practical tuning lever: top-5 reranking gives visible precision gains at essentially zero latency cost, top-100 is where you start hurting the end-user SLA.

You should already be comfortable with transformer embeddings (BGE, sentence-transformers), have built at least one basic RAG pipeline (the rag-pipeline lab is the warm-up if not), and know your way around PyTorch CUDA tensors. The sandbox is a real NVIDIA GPU pod we provision per session with BGE, the BAAI reranker checkpoint, and dependencies preinstalled. Checks run strict against real retrieval outcomes, L2-normalized embeddings paired with a failure-case dict, BM25 actually retrieving the rare-keyword doc the dense encoder missed, RRF producing the correct top-1, and the reranker preserving the correct doc at top-1 with a widened score gap over the runner-up.