Production consensus is stable: the two biggest retrieval-quality levers after chunking are hybrid search (BM25 + dense, fused) and cross-encoder reranking — with query-side transforms (rewriting, decomposition, HyDE, multi-turn condensation) as the third lever for conversational and multi-part questions. This playbook installs all three as measured upgrades over the pb-21 baseline.
Tier: Core · Workflow area: RAG & Retrieval Engineering
rank_bm25 alongside your dense index.BAAI/bge-reranker-v2-m3 (open, runs on CPU/free GPU) as default.Add the sparse leg and fuse with RRF. Run BM25 and dense retrieval in parallel, k=50 each, and fuse with Reciprocal Rank Fusion — rank-based, so no score-scale tuning:
def rrf(rankings: list[list[str]], k: int = 60) -> list[str]:
scores = defaultdict(float)
for ranking in rankings:
for rank, doc_id in enumerate(ranking):
scores[doc_id] += 1.0 / (k + rank + 1)
return [d for d, _ in sorted(scores.items(), key=lambda x: -x[1])]
Measure hybrid vs dense-only on the pb-21 set before adding the reranker — you want each lever's contribution attributable. Expect the biggest wins on keyword-ish queries; slice accordingly.
Rerank the fused candidates. The retrieve-wide-then-rerank shape: fetch ~50–100 candidates cheaply, cross-encode the top set, keep 3–5 for generation:
from sentence_transformers import CrossEncoder
reranker = CrossEncoder("BAAI/bge-reranker-v2-m3", max_length=512)
pairs = [(query, chunk_text[c]) for c in fused[:50]]
ranked = [c for _, c in sorted(zip(reranker.predict(pairs), fused[:50]), reverse=True)]
context = ranked[:4]
Cross-encoders read query and chunk together — they fix precisely the "right chunk at rank 12" failure. Measure recall@k of the final context set and nDCG@5; also record added latency (batch the pairs; a T4 reranks 50 pairs in tens of ms, CPU in the low hundreds).
Condense multi-turn queries. Before retrieval, rewrite the conversational turn into a standalone query with a cheap-tier structured call:
class CondensedQuery(BaseModel):
standalone_query: str
needs_retrieval: bool # small talk / meta-questions skip retrieval entirely
Evaluate on a multi-turn slice of your eval set (build 10–15 conversation snippets if you have none — this failure mode is invisible to single-turn evals).
Decompose compound questions. "Compare X's and Y's refund policies and their 2025 changes" → sub-queries, retrieved independently, contexts merged (dedup by chunk ID) with per-sub-query attribution. Gate decomposition behind a classifier field (is_compound: bool) — decomposing simple questions adds latency and noise.
HyDE — test, don't assume. Generate a hypothetical answer, embed it, retrieve by similarity to the hypothetical. It shines when queries and documents live in different registers (layman questions ↔ technical docs); it hurts when the model hallucinates domain specifics. It's one flag in your harness — let the eval decide per corpus.
Ablate and ship the stack. Final table: baseline → +hybrid → +rerank → +query-understanding, each row with recall@5, nDCG@5, RAGAS answer metrics, p95 latency, cost/query. Ship the config where marginal quality justifies marginal latency; it is common and legitimate to ship hybrid+rerank always, and query transforms only on the chat surface.
Retrieve wide and cheap, then rank narrow and smart — 50–100 candidates into a cross-encoder, 3–5 into the generator — is the single most reliable retrieval upgrade pattern in production RAG. Everything else in this playbook is corpus-dependent; measure it.
tsvector/BM25 extension and fuse in app code.MultiQueryRetriever / LlamaIndex QueryTransform classes package these; the structured-output versions above keep you framework-independent.needs_retrieval=false and is_compound gates fire correctly on ≥90% of a 20-case spot check.