Retrievers
Retrievers are the “R” in RAG. Each implements a single method — get_relevant_documents(query) — backed by a vector store, keyword index, or hybrid. Mastering the retriever layer is what makes RAG answer with citations instead of hallucinations.
Vector, BM25, hybrid, MMR, reranking
EXAMPLE
from langchain_openai import OpenAIEmbeddings, ChatOpenAI
from langchain_community.vectorstores import FAISS
from langchain_community.retrievers import BM25Retriever
from langchain.retrievers import EnsembleRetriever, ContextualCompressionRetriever
from langchain.retrievers.document_compressors import LLMChainExtractor
from langchain_core.documents import Document
# 1) The contract — every retriever exposes a single method
class Retriever:
def get_relevant_documents(self, query: str) -> list[Document]: ...
async def aget_relevant_documents(self, query: str) -> list[Document]: ...
# 2) Build a small corpus
docs = [
Document(page_content='Kubernetes uses ReplicaSets to keep N pods running.', metadata={'src': 'k8s.md'}),
Document(page_content='Postgres MVCC means writers never block readers.', metadata={'src': 'pg.md'}),
Document(page_content='Redis is single-threaded; long-running commands block.', metadata={'src': 'redis.md'}),
Document(page_content='Django ORM uses lazy querysets — evaluated on iteration.', metadata={'src': 'django.md'}),
# ... pretend there are 10,000 of these
]
# 3) Vector retriever — semantic similarity
emb = OpenAIEmbeddings(model='text-embedding-3-small')
vs = FAISS.from_documents(docs, emb)
vector_retriever = vs.as_retriever(search_kwargs={'k': 4})
vector_retriever.invoke('How does Postgres handle concurrent writes?')
# 4) Keyword retriever — BM25, no embeddings, no GPU
bm25 = BM25Retriever.from_documents(docs)
bm25.k = 4
bm25.invoke('replicaset pod')
# 5) MMR — diverse results, not just nearest neighbours
mmr = vs.as_retriever(
search_type='mmr',
search_kwargs={'k': 4, 'fetch_k': 20, 'lambda_mult': 0.5},
)
# fetch_k candidates → re-rank for diversity → return k.
# lambda_mult 1.0 = pure relevance, 0.0 = pure diversity.
# 6) Threshold filter — drop weak matches
threshold = vs.as_retriever(
search_type='similarity_score_threshold',
search_kwargs={'score_threshold': 0.78, 'k': 4},
)
# Returns at most k docs whose similarity >= threshold; empty list is allowed.
# 7) Metadata filter — combine semantic + structured
meta_filter = vs.as_retriever(
search_kwargs={'k': 4, 'filter': {'src': 'pg.md'}},
)
# Pre-filter the vector index by metadata before ANN search. Saves cost and improves relevance.
# 8) Ensemble (hybrid) — combine vector + BM25
ensemble = EnsembleRetriever(retrievers=[vector_retriever, bm25], weights=[0.7, 0.3])
ensemble.invoke('does Redis block?')
# Hybrid retrieval reliably beats either alone for production search.
# 9) Contextual compression — re-rank or summarise with an LLM after fetch
llm = ChatOpenAI(model='gpt-4o-mini', temperature=0)
extractor = LLMChainExtractor.from_llm(llm)
compressed = ContextualCompressionRetriever(
base_compressor=extractor,
base_retriever=ensemble,
)
compressed.invoke('How does Postgres handle concurrent writes?')
# Returns only the SENTENCES from each retrieved doc that actually answer the question.
# Slower (extra LLM call), much higher signal-to-noise in the prompt.
# 10) Cross-encoder reranker — Cohere, Voyage, or a local bge-reranker
from langchain.retrievers.document_compressors import CrossEncoderReranker
from langchain_community.cross_encoders import HuggingFaceCrossEncoder
ce = HuggingFaceCrossEncoder(model_name='BAAI/bge-reranker-v2-m3')
reranked = ContextualCompressionRetriever(
base_compressor=CrossEncoderReranker(model=ce, top_n=4),
base_retriever=ensemble.with_config(search_kwargs={'k': 20}),
)
# Strategy: fetch 20 candidates from cheap retrievers, rerank to 4 with a strong model.
# Big quality gains; tiny extra latency.
# 11) MultiQueryRetriever — let the LLM generate query variations
from langchain.retrievers import MultiQueryRetriever
mq = MultiQueryRetriever.from_llm(retriever=vector_retriever, llm=llm)
mq.invoke('How does Postgres handle simultaneous writers?')
# Generates 3 paraphrases, retrieves for each, dedupes — catches phrasing mismatches.
# 12) ParentDocumentRetriever — small chunks for matching, large chunks for context
from langchain_community.storage import InMemoryStore
from langchain.retrievers import ParentDocumentRetriever
from langchain_text_splitters import RecursiveCharacterTextSplitter
parent = ParentDocumentRetriever(
vectorstore=FAISS.from_documents([], emb),
docstore=InMemoryStore(),
child_splitter=RecursiveCharacterTextSplitter(chunk_size=400, chunk_overlap=40),
parent_splitter=RecursiveCharacterTextSplitter(chunk_size=2000),
)
parent.add_documents(docs)
# Search uses tight 400-char windows; downstream gets the 2000-char parent for context.
# 13) Self-query retriever — LLM writes its own metadata filter
from langchain.retrievers.self_query.base import SelfQueryRetriever
from langchain.chains.query_constructor.base import AttributeInfo
field_info = [
AttributeInfo(name='src', description='Source file', type='string'),
AttributeInfo(name='year', description='Year of publication', type='integer'),
]
self_q = SelfQueryRetriever.from_llm(
llm=llm,
vectorstore=vs,
document_contents='technical docs',
metadata_field_info=field_info,
)
self_q.invoke('Postgres concurrency, from 2023') # generates filter: { year: 2023 } automatically
# 14) Build it into a RAG chain
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_core.runnables import RunnablePassthrough
prompt = ChatPromptTemplate.from_messages([
('system', 'Answer based ONLY on the context.\nIf the context does not contain the answer, say so.\n\nContext:\n{context}'),
('user', '{question}'),
])
def fmt(docs):
return '\n\n'.join(f'[{i+1}] {d.page_content}' for i, d in enumerate(docs))
rag = (
{'context': reranked | fmt, 'question': RunnablePassthrough()}
| prompt | llm | StrOutputParser()
)
rag.invoke('How does Postgres handle concurrent writes?')
# 15) Evaluation
# Metrics:
# • Hit@K — does the gold doc appear in the top K?
# • MRR / nDCG — ranking quality
# • Latency p50 / p95 — what users feel
# • End-to-end answer quality with eval LLM (ragas, evals frameworks)
# Always evaluate against a curated query set, not synthetic data.
# 16) Common bugs
# • k=4 default is often too small; production tuned values are 8-20
# • Pure vector retrieval misses exact-keyword queries (model numbers, error codes)
# • No metadata filter → retrieving last year's deprecated docs
# • Chunk size too small → answers fragmented; too big → retrieval mixes topics
# • Forgetting to dedupe across retrievers in an ensemble → repeated context wastes prompt budget
# • Reranker fed only 4 results → no headroom to improve ordering
Why it matters
Retrievers reward hybridisation: vector for semantic recall, BM25 for exact keywords, an ensemble to combine them, and a cross-encoder reranker at the end. Add a metadata filter for “recent docs only” or per-tenant scoping; tune k on a real eval set instead of trusting the default of 4.
Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.
Example
Example
retriever = store.as_retriever(
search_type='mmr', search_kwargs={'k': 4, 'fetch_k': 20},
)
hits = retriever.invoke('return policy')
Try it Yourself »
Exercise
Turn a vector store into a retriever.
retr = store.
()
Snake case.
Discussion
Loading…