Chapter 04
Retrieval & reranking
Basic vector search is a solid start, but it misses exact terms and returns “close-ish” results. Two techniques close most of the gap: hybrid search and reranking.
Dense vs sparse retrieval
Dense (semantic)
Embedding similarity. Understands meaning and paraphrase — “can't sign in” matches “login failure”. But it can miss exact identifiers, product codes, or rare terms.
Sparse (lexical / BM25)
Classic keyword scoring. Nails exact matches — error codes, names, SKUs, function names — but is blind to synonyms and rephrasing.
Hybrid search fuses both
Run dense and sparse in parallel, then combine their rankings (commonly with Reciprocal Rank Fusion). Hybrid consistently beats either method alone — it captures conceptual meaning and exact keywords.
🔢 Dense
meaning
🔤 Sparse
keywords
🔀 Fuse (RRF)
merged ranking
🎯 Rerank
top-N by relevance
Reranking: the biggest single lever
Fast retrieval optimises for recall — cast a wide net, grab ~20 candidates. A reranker (a cross-encoder that reads the query and each passage together) then re-scores them for true relevance and keeps the best few. It's slower per item, so you only run it on the shortlist — but the top-5 after reranking are far better than the top-5 from search alone.
Watch it work. These 6 passages came back from vector search, ranked by embedding score. Hit rerank — a cross-encoder re-reads each against the query and the genuinely relevant ones rise.
Query “How do I get a refund on an annual enterprise plan?”
Hybrid + rerank, in code
import numpy as np, voyageai
from rank_bm25 import BM25Okapi
vo = voyageai.Client()
def hybrid_retrieve(query, chunks, doc_vecs, bm25, k=20):
# dense: cosine similarity ranking
q_vec = np.array(vo.embed([query], model="voyage-3", input_type="query").embeddings)[0]
dense = np.argsort(doc_vecs @ q_vec)[::-1]
# sparse: BM25 keyword ranking
sparse = np.argsort(bm25.get_scores(query.split()))[::-1]
# Reciprocal Rank Fusion — reward chunks ranked high by EITHER method
scores, C = {}, 60
for rank, i in enumerate(dense[:k]): scores[i] = scores.get(i, 0) + 1 / (C + rank)
for rank, i in enumerate(sparse[:k]): scores[i] = scores.get(i, 0) + 1 / (C + rank)
fused = sorted(scores, key=scores.get, reverse=True)[:k]
return [chunks[i] for i in fused]
def rerank(query, candidates, top_n=5):
# cross-encoder reads (query, passage) pairs together — high precision
r = vo.rerank(query, candidates, model="rerank-2", top_k=top_n)
return [candidates[res.index] for res in r.results]
candidates = hybrid_retrieve(query, chunks, doc_vecs, bm25) # ~20, high recall
context = rerank(query, candidates, top_n=5) # 5, high precision
Other retrieval tricks
Query rewriting
Have an LLM expand or rephrase the question (or generate several variants) before retrieving — catches more relevant chunks.
Metadata filtering
Attach metadata (date, source, product) to chunks and filter before or during search — “only the 2026 docs”.
Small-to-big
Retrieve on small precise chunks, then feed the model their larger parent sections for full context.
HyDE
Generate a hypothetical answer, embed that, and search with it — often closer to the target docs than the raw question.