Retrieval#
Turn a question into the documents that answer it, by searching a collection
you already indexed. fastrag.retrieval sits on top of a VectorStore
opened by you: a VectorRetriever embeds the query with the same model the
collection was indexed with and searches k nearest neighbours; a
RerankingRetriever over-fetches on similarity and narrows on relevance with
a Reranker. examples/10_retrieving.py runs this end to end:
import asyncio
from _runtime import config_from_env
from fastrag.ingestion.steps.embedding import Model2VecEmbedder
from fastrag.vector_store import MilvusVectorStore
from fastrag.retrieval import VectorRetriever
async def main():
cfg = config_from_env()
async with MilvusVectorStore(
url=cfg.milvus_uri, collection=cfg.collection
) as vector_store:
retriever = VectorRetriever(
vector_store=vector_store,
embedder=Model2VecEmbedder(model=cfg.embedding_model),
)
documents = await retriever.retrieve(cfg.question, k=5)
if not documents:
raise RuntimeError("No matching documents; run 05_indexing.py first")
print(f"Top 5 documents for query '{cfg.question}':")
for doc in documents:
source_uri = doc.metadata.get("source_uri", "Unknown")
print(f"Source URI: {source_uri}")
print(f"Content: {doc.content}")
print("-" * 80)
if __name__ == "__main__":
asyncio.run(main())
A retrieved Document is the shared fastrag.models.Document: its text is
doc.content, and its provenance for a citation lives in
doc.metadata["source_uri"] (stamped by the parser before chunking and
embedded). The relevance a store assigned it is in the reserved metadata keys
score and rank, which VectorStore.search stamps.
Retrievers#
Retriever.retrieve(query, *, k=...) returns list[Document], best first.
VectorRetriever— dense similarity search.embedder.embedruns on one query as one call, never as a pipeline step.RerankingRetriever— composes avector_store, anembedder, and aReranker(e.g.CohereReranker). Retrieveskcandidates, then reranks down totop_kwith a cross-encoder that scores each chunk against the query directly.
A Retriever is what Querying’s QueryEngine consumes — pass any
retriever in and the engine knows retrieval only by its contract, not by its
strategy.
See also#
Querying for retrieval plus generation; Indexing for how the
collection is written; examples/10_retrieving.py and
examples/11_reranking.py for both retrievers end to end.
BM25, dense retrieval and RRF#
BM25Retriever(store=...) takes any LexicalStore. Both
OpenSearchVectorStore and MilvusVectorStore(enable_bm25=True) implement it
alongside VectorStore, so one open store can serve both branches. With a
vector store that has no BM25, such as ChromaVectorStore, pass a separate
LexicalStore to BM25Retriever instead. The retrievers and fusion strategy are SDK abstractions;
switching stores leaves the retrieval and reranking composition unchanged:
from fastrag.retrieval import (
BM25Retriever,
VectorRetriever,
HybridRetriever,
ReciprocalRankFusion,
RerankingRetriever,
)
hybrid = HybridRetriever(
retrievers=[
BM25Retriever(store=store),
VectorRetriever(vector_store=store, embedder=embedder),
],
fusion=ReciprocalRankFusion(rank_constant=60),
candidate_k=40,
)
retriever = RerankingRetriever(
retriever=hybrid,
reranker=reranker,
candidate_k=40,
)
documents = await retriever.retrieve("What is AgrospAI?", k=5)
Branches run concurrently. Fusion deduplicates by document id and sums
1 / (rank_constant + one_based_rank) for each ranking. It uses positions,
not backend scores, which are on different scales. Implement RankFusion.fuse
to replace RRF, or supply any Retriever as a branch. Each stage returns
Document objects with copied score and zero-based rank metadata.
RerankingRetriever continues to accept the original vector_store /
embedder constructor pair. With retriever=... it wraps any retrieval
strategy; candidate_k sets the over-fetch size independently of the final
k consumed by QueryEngine.
Multilingual sites#
A site that serves each page in several languages gets every page indexed
once per language, and a multilingual embedder and reranker score its
translations alike. The top results then repeat one passage in several
languages. LanguageAwareRetriever wraps the ranking and fixes both:
from fastrag.retrieval import LanguageAwareRetriever, LinguaLanguageDetector
retriever = LanguageAwareRetriever(
retriever=RerankingRetriever(retriever=hybrid, reranker=reranker),
detector=LinguaLanguageDetector(["en", "es", "ca", "zh"]),
candidate_k=40,
)
It fetches candidate_k candidates and detects the language of the question
and of each document (metadata["language"], when set, wins). Documents whose
source_uri differs only by a leading locale segment (/docs/a, /en/docs/a)
are one page, and only one translation is kept: the question’s, when it was
retrieved. Documents in other languages then have their score scaled by
other_language_weight (0.8), so they yield to results of similar relevance
in the question’s language, but not to much weaker ones. Each returned
document carries metadata["language"], which the default prompt shows next
to its title. LinguaLanguageDetector needs the language extra; naming only
the corpus’s languages keeps short questions reliable.
OpenSearch filters are JSON-encoded Query DSL objects. Milvus filters are
native expression strings. Chroma filters are JSON-encoded where objects.
BM25Retriever(filter=...) passes the filter
through to lexical search. OpenSearch stores arbitrary metadata in _source
without indexing it; filter on explicit fields such as source_uri and
source_id.
Backend APIs: OpenSearch k-NN and Milvus full-text search.
Resolve original sections#
For structural hits, keep normalized parent documents in SectionIndex and
call resolve(hit, ancestors=True) to obtain original retained source sections
with document-scoped provenance. Merged chunks can resolve several sections;
missing parents raise unless you select missing="skip". This database-independent
API does not alter vectors or prompts and needs application-owned parent
persistence and context budgeting. See Structure-aware ingestion for a runnable recipe.