Qdrant hybrid retrieval with RRF

Qdrant can hold dense and sparse vectors in a single collection. At query time, you run both searches in parallel and ask Qdrant to fuse the ranked lists with Reciprocal Rank Fusion. RRF is simple, parameter-free, and famously effective.

document_utils.py
python
from qdrant_client import QdrantClient, models

class RAGPipeline:
    def __init__(self, llm_provider):
        self.client = QdrantClient(
            host=os.getenv("QDRANT_HOST", "qdrant"),
            port=int(os.getenv("QDRANT_PORT", 6333)),
        )

    def upsert_documents(self, document_id, chunks):
        collection_name = f"doc_{document_id}"

        # Collection holds dense + sparse in one place
        self.client.recreate_collection(
            collection_name=collection_name,
            vectors_config={
                "dense": models.VectorParams(
                    size=384,
                    distance=models.Distance.COSINE,
                ),
            },
            sparse_vectors_config={
                "sparse": models.SparseVectorParams(
                    index=models.SparseIndexParams(on_disk=False),
                ),
            },
        )

Dense vectors are declared under vectors_config, sparse vectors under sparse_vectors_config. The named vectors pattern lets a single point carry both.

Reciprocal Rank Fusion at query time

Dense and sparse each produce a ranked list. RRF fuses them by reciprocal ranks.

document_utils.py
python
def retrieve_relevant_chunks(
    self,
    query_text,
    query_vector,
    query_sparse_vector,
    document_id,
    max_chunks=5,
):
    collection_name = f"doc_{document_id}"
    limit = max(20, max_chunks * 4)

    # Hybrid search with RRF fusion
    search_result = self.client.query_points(
        collection_name=collection_name,
        prefetch=[
            models.Prefetch(
                query=query_vector,
                using="dense",
                limit=limit,
            ),
            models.Prefetch(
                query=query_sparse_vector,
                using="sparse",
                limit=limit,
            ),
        ],
        query=models.FusionQuery(fusion=models.Fusion.RRF),
        limit=limit,
        with_payload=True,
    )
    return search_result.points

The prefetch list runs both searches. FusionQuery with Fusion.RRF tells Qdrant to blend the two ranked lists. You get candidates back, ready for reranking.

Dense cosine scores and sparse BM25 scores live on completely different scales. Averaging them forces you to pick weights by hand, and the right weights change per query. RRF ignores raw scores and uses ranks. A document gets a bump for showing up near the top of either list. It is parameter-free, holds up across domains, and used by every serious hybrid search system.

Quiz: Quiz

Loading practice…

AI prompt: Try it: explain RRF to a teammate

Loading practice…