Module 4: Re-ranking — the second stage that turns mediocre retrieval into excellent retrieval
Capsule 06: Re-ranking trade-offs and operational optimizations
Capsule overview
The previous capsules covered which re-ranking technique to pick (cross-encoder, LLM, Cohere). But there are operational decisions that affect performance regardless of which technique you use: how many candidates to pass to the re-ranker, when to re-rank vs when to trust the direct retrieval, what to cache, how to parallelize.
These optimizations can improve latency by 30-50% or cut cost by 70% without changing the underlying technique. In production systems, they're what separates "re-ranking works but it's slow" from "re-ranking is fast and cheap". This capsule gives you the operational playbook for getting the most out of whichever technique you choose.
By the end of this capsule you'll be able to:
- ✅ Compute the optimal
n_resultsfor the initial retrieval based on the re-ranker type - ✅ Design a re-ranking result cache for repeated queries
- ✅ Apply score thresholds to discard low-relevance results
- ✅ Identify queries where re-ranking adds no value (dynamic skip)
- ✅ Parallelize correctly when the re-ranker allows it
- ✅ Anticipate the trap: optimizing latency while silently breaking accuracy
Estimated time: 25-30 minutes
Optimization 1: the right n_results for the initial retrieval
The key question: how many candidates do you pass from the retrieval to the re-ranker?
cosine retrieval re-ranker
[corpus] ────────────────> [top-N candidates] ───────> [final top-K]
N = ? K = 5
Too low (N=10): you lose good docs before the rerank
Too high (N=200): re-ranking becomes slow and expensive
Sweet spot: N = 20-50 (depending on the corpus and the technique)
How to find the optimal N empirically
# benchmark_n_results.py
import time
import statistics
from sentence_transformers import CrossEncoder
reranker = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-12-v2")
def benchmark_n_results(query, eval_set, n_values=[5, 10, 20, 50, 100, 200]):
"""
Measure precision@5 vs latency for different n_results values of the retrieval.
"""
results = []
for n in n_values:
latencies = []
precisions = []
for item in eval_set:
# Retrieval with N candidates
start = time.perf_counter()
candidates = collection.query(
query_texts=[item["query"]], n_results=n
)['documents'][0]
# Re-rank
pairs = [(item["query"], doc) for doc in candidates]
scores = reranker.predict(pairs)
# Final top 5
import numpy as np
top_5_idx = np.argsort(scores)[::-1][:5]
top_5_docs = [candidates[i] for i in top_5_idx]
latencies.append((time.perf_counter() - start) * 1000)
# Precision: are the expected_doc_ids in the top 5?
# (this assumes your eval set has a doc → id mapping)
hits = sum(1 for doc in top_5_docs if doc in item["expected_chunks"])
precisions.append(hits / 5)
results.append({
"n_results": n,
"p95_latency_ms": sorted(latencies)[int(len(latencies) * 0.95)],
"avg_precision_at_5": statistics.mean(precisions),
})
return results
Typical output (local cross-encoder, technical dataset):
n_results= 5: p95=210ms, precision@5=0.74
n_results= 10: p95=240ms, precision@5=0.85
n_results= 20: p95=320ms, precision@5=0.91 ← sweet spot
n_results= 50: p95=580ms, precision@5=0.92 ← diminishing returns
n_results=100: p95=1100ms, precision@5=0.92 ← wasteful
n_results=200: p95=2200ms, precision@5=0.92 ← unusable
How to read it:
- From N=5 to N=20: precision goes up 17 points. Every extra candidate pays for itself.
- From N=20 to N=50: precision goes up just 1 point. Latency almost doubles. A bad trade.
- N>50: zero returns, latency explodes.
The sweet spot per technique:
| Re-ranker | Typical optimal N | Reason |
|---|---|---|
| Local cross-encoder | 20-30 | Fast processing, it's worth having more candidates |
| Cohere Rerank | 25-50 | Linear cost per document; a precision/cost balance |
| LLM rerank (GPT-4o-mini) | 10-20 | Every pair costs money; be more selective |
| LLM rerank (GPT-4) | 5-10 | Expensive and slow, only for the most likely ones |
The rule: start at N=20, tune empirically on your eval set.
Optimization 2: caching results
In production, queries aren't random. There's a Pareto pattern: ~20% of unique queries account for ~80% of the traffic. Caching results for repeated queries is the optimization with the best gain/effort ratio.
Implementation with an LRU cache (in-memory)
# rerank_cache.py
import hashlib
from functools import lru_cache
from typing import Tuple
def query_cache_key(query: str, n_candidates: int) -> str:
"""A stable cache key based on the query and the configuration."""
raw = f"{query.strip().lower()}|{n_candidates}"
return hashlib.md5(raw.encode()).hexdigest()
# Cache with a configurable size
_RERANK_CACHE: dict[str, list] = {}
_MAX_CACHE_SIZE = 10_000
def cached_rerank_pipeline(query: str, top_k: int = 5):
"""The full pipeline with a cache for re-ranking results."""
cache_key = query_cache_key(query, n_candidates=20)
# Cache hit
if cache_key in _RERANK_CACHE:
return _RERANK_CACHE[cache_key][:top_k]
# Cache miss: run the full pipeline
candidates = collection.query(query_texts=[query], n_results=20)['documents'][0]
reranked = cross_encoder_rerank(query, candidates, top_k=top_k * 2)
# Store in the cache (up to the limit)
if len(_RERANK_CACHE) >= _MAX_CACHE_SIZE:
# Evict the oldest (simple FIFO; in production use a real LRU)
_RERANK_CACHE.pop(next(iter(_RERANK_CACHE)))
_RERANK_CACHE[cache_key] = reranked
return reranked[:top_k]
When to invalidate the cache
The cache becomes wrong if:
- The collection's content changes (you insert new documents): the old results may be omitting relevant new docs.
- The embedding model changes (a re-ingest): the whole rankings change.
- The re-ranking model changes (upgrading from Cohere v3 to v4, for example).
def invalidate_cache(reason: str):
global _RERANK_CACHE
print(f"Invalidating rerank cache: {reason}")
_RERANK_CACHE.clear()
# In the ingestion pipeline
def ingest_documents(...):
collection.add(...)
invalidate_cache(f"Added {n} new documents")
A distributed cache (Redis) for multiple instances
An in-memory LRU works for a single process. If you have multiple instances of your API, each one has its own cache and hits are rare. The solution: a distributed cache.
import redis
import json
redis_client = redis.Redis(host='localhost', port=6379, db=0)
def cached_rerank_redis(query: str, top_k: int = 5, ttl_seconds: int = 3600):
cache_key = f"rerank:{query_cache_key(query, n_candidates=20)}"
# Cache hit
cached = redis_client.get(cache_key)
if cached:
return json.loads(cached)[:top_k]
# Cache miss
candidates = collection.query(query_texts=[query], n_results=20)['documents'][0]
reranked = cross_encoder_rerank(query, candidates, top_k=top_k * 2)
# Store with a TTL (it auto-invalidates after ttl_seconds)
redis_client.setex(cache_key, ttl_seconds, json.dumps(reranked))
return reranked[:top_k]
The typical benefit of caching:
- Typical hit rate in systems with real traffic: 40-70%.
- Latency on hits: ~5ms (a Redis lookup) vs 320ms (the full pipeline).
- Cost reduction in systems with an LLM rerank: up to 70% fewer OpenAI calls.
Optimization 3: score thresholds to discard garbage
Re-ranking doesn't guarantee that every one of the top_k is relevant. Sometimes the top-5 includes 1-2 docs with very low scores that the LLM shouldn't see.
# Without a threshold (everything gets through)
top_5 = reranker.rerank(query, candidates, top_k=5)
# With a threshold (filters out low-relevance results)
all_reranked = reranker.rerank(query, candidates, top_k=20)
top_relevant = [doc for doc in all_reranked if doc.score > 0.5]
# If fewer than 5 survive the threshold, that's fine
# Better to pass the LLM 3 relevant docs than 5 with 2 pieces of garbage
How to pick the threshold:
- On your eval set, plot the score distribution of relevant vs irrelevant docs.
- Find the point where the two distributions clearly separate.
- Set the threshold there, and validate that precision goes up without recall dropping too much.
import matplotlib.pyplot as plt
def find_threshold(eval_set):
relevant_scores = []
irrelevant_scores = []
for item in eval_set:
candidates = collection.query(query_texts=[item["query"]], n_results=20)['documents'][0]
reranked = reranker.rerank(item["query"], candidates, top_k=20)
for doc in reranked:
if doc.document in item["expected_chunks"]:
relevant_scores.append(doc.score)
else:
irrelevant_scores.append(doc.score)
# Comparative histogram
plt.hist(relevant_scores, alpha=0.5, label="Relevant", bins=20)
plt.hist(irrelevant_scores, alpha=0.5, label="Irrelevant", bins=20)
plt.legend()
plt.show()
# Where the two distributions cross = the candidate threshold
Typical threshold (MS MARCO cross-encoder): 0.4-0.6 Typical threshold (Cohere relevance score): 0.5-0.7 Typical threshold (LLM rerank 0-10): 5.0-7.0
Optimization 4: the dynamic skip — when NOT to re-rank
Not every query needs re-ranking. If the initial cosine retrieval is already very confident (a top-3 with scores far above the rest), re-ranking is a waste.
def needs_reranking(retrieval_scores: list[float], threshold_gap: float = 0.15) -> bool:
"""
Decide whether re-ranking is worth it based on the score distribution.
If the gap between the top-3 and the top-10 is large, the retrieval is confident and
re-ranking won't change much. If the gap is small, there's ambiguity and
re-ranking is worth it.
"""
if len(retrieval_scores) < 10:
return False # not enough candidates
# Cosine distance: lower = more similar
top_3_avg = sum(retrieval_scores[:3]) / 3
top_10_avg = sum(retrieval_scores[:10]) / 10
gap = top_10_avg - top_3_avg
return gap < threshold_gap
def smart_rerank(query: str, top_k: int = 5):
results = collection.query(query_texts=[query], n_results=20)
candidates = results['documents'][0]
distances = results['distances'][0]
if needs_reranking(distances):
# Re-rank because there's ambiguity
return cross_encoder_rerank(query, candidates, top_k=top_k)
else:
# Skip the rerank — the retrieval is already confident
return [(doc, dist) for doc, dist in zip(candidates[:top_k], distances[:top_k])]
The benefit: ~20-30% of queries can skip re-ranking with no measurable quality loss. It saves latency and cost.
The risk: setting threshold_gap wrong can skip re-ranking on queries that did need it. Validate empirically on your eval set.
Optimization 5: parallelizing correctly
Local cross-encoder: use native batching
# ❌ Bad: sequential (a Python loop)
for doc in candidates:
score = reranker.predict([(query, doc)])
# ✅ Good: native batching
pairs = [(query, doc) for doc in candidates]
scores = reranker.predict(pairs, batch_size=32)
The internal batch_size in sentence-transformers exploits GPU/CPU vectorization. The difference between sequential and batch is 10-50x.
LLM rerank: ThreadPoolExecutor
from concurrent.futures import ThreadPoolExecutor
def parallel_llm_rerank(query, candidates, top_k=5, workers=5):
def score_one(doc):
return llm_rerank_pair(query, doc).score
with ThreadPoolExecutor(max_workers=workers) as executor:
scores = list(executor.map(score_one, candidates))
paired = sorted(zip(candidates, scores), key=lambda x: -x[1])
return paired[:top_k]
5-10 parallel workers turn 1500ms into ~300ms for reranking 20 candidates. The calls are I/O bound (they're waiting on OpenAI), so parallelizing is trivial.
Cohere Rerank: it already comes parallelized server-side
Cohere receives the N documents in a single API call and processes them in parallel internally. No client-side parallelization needed.
# A single call with all the candidates
response = co.rerank(query=query, documents=candidates, top_n=top_k)
The trap: optimizing latency while silently breaking accuracy
Goodhart's Law applied: when you tune n_results=10 to bring latency down, you may be bringing precision down without seeing it on the dashboard.
# Aggressive optimization
n_results = 5 # low, "to save"
top_k_rerank = 3 # low, "to save"
score_threshold = 0.7 # high, "to be strict"
# Result: latency improves 40%, precision drops 8% with no alert
Mitigation:
- A guardrail metric: continuous precision@K monitoring on the eval set. If it drops, alert.
- A/B test before deploying: compare the optimized version vs the current one on a fixed eval set.
- Composite metrics: "latency × (1 / precision)" — it gets worse if either of the two degrades.
Traps and common mistakes
Trap 1: caching without invalidating when new docs are ingested
The mistake: the re-ranking cache is on. Someone inserts 1000 new docs. The cache keeps returning results that don't take the new docs into account.
Symptom: relevant new docs never show up on cached queries. The system looks like it isn't taking advantage of the new information.
How to prevent it: invalidate the cache on every ingestion (or at least at the end of the batch). If the ingestion is continuous, use a short TTL (5-15 min) instead of a permanent cache.
Trap 2: a global threshold when scores have different ranges per query
The mistake: you set a global threshold=0.5. But some queries are ambiguous (all scores between 0.3 and 0.5) and others are specific (scores between 0.6 and 0.9).
Symptom: ambiguous queries return zero results (everything is below the threshold). Specific queries filter nothing (everything is above).
How to prevent it: use a relative threshold instead of an absolute one:
# Relative threshold: only discard docs significantly worse than the top-1
top_score = reranked[0].score
relevant = [doc for doc in reranked if doc.score > top_score * 0.7]
Trap 3: a rerank skip that becomes permanent
The mistake: you implement a "dynamic skip" with logic that drops the rerank when the retrieval is "confident". Because of a bug in the logic, it almost always considers the retrieval confident, and the rerank almost never runs.
Symptom: the operational metrics don't change (rerank latency and Cohere cost drop) but precision falls silently.
How to prevent it: instrument rerank_skipped_count and rerank_executed_count. Monitor that the ratio stays ~20-30%, not >70%.
Trap 4: an in-memory cache with no maximum size
The mistake:
_CACHE = {} # grows infinitely
Symptom: the app's memory grows without limit, and eventually OOMs.
How to prevent it: always with a MAX_SIZE and an eviction policy (typically LRU).
Trap 5: parallelizing a cross-encoder with threads
The mistake: you use a ThreadPoolExecutor to call cross_encoder.predict with one pair at a time.
Symptom: it doesn't speed anything up. A local cross-encoder is CPU-bound; threads don't help, the GIL serializes them.
How to prevent it: for a local cross-encoder, use the internal batch_size, not threads. For API calls (LLM, Cohere), threads do help (I/O bound).
Trap 6: measuring only the rerank's latency
The mistake: you measured that the cross-encoder rerank takes 150ms. You assume that's the total cost.
Symptom: in production, the latency is 400ms. You forgot to count the retrieval before it, the query's embedding and the generation.
How to prevent it: measure end-to-end, from the moment the user's query arrives until the answer is returned. Every component of the pipeline should log its latency.
Applied exercise
Scenario: a RAG system in production with these baseline metrics:
- Pipeline: cosine retrieval
n_results=50→ cross-encoder rerank → top-5 to the LLM - Current p95 latency: 850ms
- Precision@5: 89%
- Cost: ~$0/query (local cross-encoder)
- Volume: 100K queries/day
The product team asks: "let's get latency down to <500ms p95 without sacrificing precision."
Your job: propose three specific optimizations in priority order, estimate the impact of each, and specify how you'd validate them.
Solution
Optimization 1: lower n_results from 50 to 20
Hypothesis: according to typical benchmarks, n=50 vs n=20 gives severely diminishing returns. Precision probably stays almost the same.
Expected impact:
- Rerank latency: ~360ms → ~150ms (half the candidates)
- Total p95 latency: 850ms → ~640ms
- Precision: probably unchanged (<1% delta)
Validation: run the eval set with n=10, 20, 30, 50. If precision at n=20 is ≥ 88%, ship it. Cost: 1 hour of work.
Optimization 2: add an LRU cache for repeated queries
Hypothesis: a system with 100K queries/day probably has 30-50% repeated queries (the typical Pareto).
Expected impact:
- On cached queries (a hit): latency ~5ms
- Expected hit rate: 40%
- Weighted average latency: 0.6 * 640ms + 0.4 * 5ms = 386ms p50, ~640ms p95
- p95 does NOT improve directly (cache misses are unchanged), BUT the system's aggregate throughput improves 40%
A variant: prioritize p95 with a distributed cache + proactive warming
# Pre-warm the cache with the top queries from the last day
def warm_cache_with_top_queries():
top_queries = get_top_queries_from_logs(limit=1000)
for query in top_queries:
cached_rerank_pipeline(query) # fills the cache
That guarantees the most common queries are always cached, improving p95.
Validation: measure the real hit rate for 1 week. If it's >30%, it's worth the cost of Redis ($10-50/month managed).
Optimization 3: a dynamic skip for queries with a confident retrieval
Hypothesis: ~25% of queries have a retrieval where the top-3 is clearly better than the top-10. Re-ranking adds no value.
def should_skip_rerank(distances):
if len(distances) < 10:
return False
return distances[9] - distances[2] < 0.10 # a small gap = confident
Expected impact:
- 25% of queries skip the rerank → 0ms instead of ~150ms
- Average latency: a small improvement
- p95 of queries that DO get reranked: unchanged
Critical validation: verify that the skip doesn't affect precision. Set up a monitor for "precision on skipped queries" vs "precision on reranked queries". If the difference is >2%, tune the threshold.
Implementation plan (in order):
| Sprint | Optimization | Effort | Expected p95 impact |
|---|---|---|---|
| Day 1 | Lower n_results from 50 to 20 | 2 hours | 850ms → 640ms |
| Day 2-3 | Redis cache with TTL + warming | 1 day | (improves throughput, marginal p95) |
| Day 4-5 | Dynamic rerank skip | 1 day | 640ms → 580ms |
If after the 3 optimizations we're still over 500ms:
- Consider a lighter cross-encoder (
ms-marco-TinyBERT-L-2-v2): -50ms latency, -2% precision. - Optimize the LLM generation (typically 60% of the total time): cache answers for identical queries, use a smaller model for simple queries (Haiku/4o-mini instead of GPT-4).
- If nothing gets there, renegotiate the SLA with product: 600ms is reasonable for a technical chatbot, sub-500ms is strict.
Metrics to monitor during the rollout:
- Latency p50/p95/p99 (not just p95)
- Precision@5 on the daily eval set
- Cache hit rate
- Rerank skip rate (it should be 20-30%)
- Cost per query (it should drop marginally with the cache)
Rollback plan: a feature flag for each optimization. If the guardrail metric (precision) drops more than 2%, roll back automatically.
Recap and next step
What you learned:
- The initial retrieval's
n_resultsgives diminishing returns after 20-30 candidates. The empirical sweet spot depends on the re-ranker. - Caching results with LRU/Redis exploits the Pareto pattern of repeated queries (a typical 40-70% hit rate).
- Score thresholds let you discard low-relevance results. The optimal threshold is found empirically on your eval set.
- The dynamic skip (skipping re-ranking when the retrieval is already confident) saves 20-30% of the cost with no precision loss.
- Parallelization: native batching for a local cross-encoder, ThreadPoolExecutor for LLM/Cohere, none for Cohere (it does it server-side).
- Optimizing latency without measuring precision leads to Goodhart's Law: the metric improves but quality falls silently. Always use guardrail metrics.
Checkpoint: before moving on, you should be able to:
- Find the optimal
n_resultsfor your pipeline with a benchmark on your eval set. - Implement an LRU/Redis cache with correct invalidation.
- Design a dynamic re-ranking skip with guardrail metrics.
Next capsule: 07 — Comparing re-ranking techniques.
We covered the three techniques (cross-encoder, LLM, Cohere) and the operational optimizations. Capsule 07 is the consolidation: a decision framework for picking the right technique in a given context, benchmarks side by side, and an actionable flowchart you'll use whenever you start a new project. It's the capsule you come back to month after month.
Resources
- Caching Strategies for ML Systems (Uber Eng) — Caching patterns in ML production
- Redis Best Practices for Caching — Distributed caching patterns
- Speed up sentence-transformers inference — Batching and optimization tips
- Goodhart's Law (Wikipedia) — Why optimizing what you measure can break what you don't
- The Four Golden Signals (Google SRE) — Latency, traffic, errors, saturation
- Asyncio vs Threading vs Multiprocessing in Python — Choosing the right concurrency model
Estimated time: 25-30 minutes Next: 07-technique-comparison-2.md