Retrievers in depth
Similarity search, MMR, multi-query and contextual compression, hybrid search, reranking, and writing your own retriever.
Retrieval modes
from langchain_community.vectorstores import FAISS
from langchain_core.runnables import RunnableLambda
store = FAISS.from_documents(chunks, local_emb)
# 1. plain similarity: top-k nearest
basic = store.as_retriever(search_kwargs={"k": 6})
# 2. MMR: relevant but mutually diverse, good for summaries over a broad question
mmr = store.as_retriever(
search_type="mmr",
search_kwargs={"k": 6, "fetch_k": 30, "lambda_mult": 0.5},
)
# 3. score threshold: abstain rather than return irrelevant passages
thresholded = store.as_retriever(
search_type="similarity_score_threshold",
search_kwargs={"score_threshold": 0.25, "k": 6},
)
for name, retriever in [("basic", basic), ("mmr", mmr), ("threshold", thresholded)]:
docs = retriever.invoke("how do refunds work for annual plans?")
print(name, len(docs), [d.metadata.get("h2") for d in docs])| Mode | Behaviour | Use when |
|---|---|---|
| Similarity | Top-k nearest neighbours | Direct factual questions |
| MMR | Relevant and diverse | Broad questions spanning a document |
| Score threshold | Empty result if nothing is close | Abstention matters |
| Multi-query | Generates query variants, unions results | Vocabularies differ between query and document |
| Parent-document | Retrieve small, return large | Precise matching but long context needed |
| Contextual compression | Extract only relevant sentences | Context window is expensive |
- MMR's
fetch_kmust be larger thank. The point is to fetch a wide candidate set and then choose a diverse subset from it. - A score threshold turns a bad retrieval into an empty one. That is usually better than feeding an irrelevant passage to a model that will answer from it anyway.
- Parent-document retrieval is the practical fix for the chunk-size trade-off: embed small chunks for matching, return the parent section for context.
- Retrieval parameters are the highest-leverage knobs in a RAG system. Tune
k, the search type and the threshold before touching the prompt.
Multi-query, compression and hybrid
from langchain.retrievers.multi_query import MultiQueryRetriever
from langchain.retrievers import ContextualCompressionRetriever, EnsembleRetriever
from langchain.retrievers.document_compressors import LLMChainExtractor
from langchain_community.retrievers import BM25Retriever
# multi-query: the model rewrites the question several ways, results are unioned
multi = MultiQueryRetriever.from_llm(retriever=basic, llm=model)
# contextual compression: keep only the sentences that bear on the question
compressor = LLMChainExtractor.from_llm(model)
compressed = ContextualCompressionRetriever(base_compressor=compressor, base_retriever=basic)
# hybrid: lexical BM25 alongside the dense retriever, fused by rank
bm25 = BM25Retriever.from_documents(chunks)
bm25.k = 6
hybrid = EnsembleRetriever(retrievers=[bm25, basic], weights=[0.4, 0.6])
import logging
logging.getLogger("langchain.retrievers.multi_query").setLevel(logging.INFO)
for name, r in [("multi", multi), ("compressed", compressed), ("hybrid", hybrid)]:
docs = r.invoke("what happens if I exceed the API rate limit?")
print(name, len(docs), docs[0].page_content[:70])- Multi-query costs one extra model call per query and helps most when the user's words do not match the document's words.
- Contextual compression costs one model call per retrieved document. It shrinks the prompt but can be slower than simply passing more context, so measure both.
- Hybrid retrieval needs score normalisation before fusion; rank-based fusion (reciprocal rank fusion) is more robust than weighted score averaging.
- Every extra retriever stage adds latency. For an interactive product, budget the total: retrieval, reranking and generation should fit the response-time target together.
A custom retriever and its evaluation
from langchain_core.retrievers import BaseRetriever
from langchain_core.documents import Document
from typing import List
class SectionAwareRetriever(BaseRetriever):
"""Prefer chunks whose heading matches a section hinted at in the question."""
store: object
k: int = 6
boost: float = 1.4
def _get_relevant_documents(self, query: str, *, run_manager=None) -> List[Document]:
candidates = self.store.similarity_search_with_score(query, k=self.k * 4)
scored = []
for doc, score in candidates:
heading = (doc.metadata.get("h2") or "").lower()
bonus = self.boost if heading and heading in query.lower() else 1.0
scored.append((score * bonus, doc))
scored.sort(key=lambda pair: pair[0], reverse=True)
return [doc for _, doc in scored[: self.k]]
retriever = SectionAwareRetriever(store=store)
print(len(retriever.invoke("billing refund policy")))
# evaluate retrieval separately from generation
def recall_at_k(retriever, cases, k=6):
hits = 0
for case in cases:
docs = retriever.invoke(case["question"])[:k]
ids = {d.metadata.get("chunk_id") for d in docs}
hits += case["expected_chunk_id"] in ids
return hits / len(cases)
cases = [
{"question": "how do I request a refund?", "expected_chunk_id": "billing-03"},
{"question": "what is the SLA for enterprise?", "expected_chunk_id": "sla-11"},
]
print(round(recall_at_k(basic, cases), 3))💡
Measure retrieval recall before measuring answer quality. If the correct passage is not in the retrieved set, the generator cannot produce a correct grounded answer, and every prompt change you make afterwards is optimising the wrong component.
FAQ
How many documents should I retrieve?
Enough that the answer is likely present, few enough that the context stays focused: three to eight after reranking is a common range. Measure recall at k and stop increasing k when it plateaus.
Do I need a reranker?
If you can afford one extra forward pass per candidate, yes: a cross-encoder over twenty candidates typically improves precision substantially for a modest latency cost. It is usually the single best retrieval improvement.
Related
Retrieval-augmented generation Embeddings and vector stores
Last refreshed 2026-09-18.