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.embed runs on one query as one call, never as a pipeline step.

  • RerankingRetriever — composes a vector_store, an embedder, and a Reranker (e.g. CohereReranker). Retrieves k candidates, then reranks down to top_k with 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.