Reciprocal Rank Fusion (RRF) for Hybrid Search

Agentic AI Systems intermediate Building with AI ~20 min read

Overview

Modern information retrieval systems rarely rely on a single search strategy. Semantic vector search excels at understanding intent and finding conceptually related documents, but it can miss exact keyword matches — identifiers, error codes, and domain-specific terms that embedding models compress away. Full-text keyword search (BM25) handles precise terminology beautifully but fails when users paraphrase or express the same idea with different words. Hybrid search combines both approaches, but the question becomes: how do you merge two ranked lists that use incomparable scoring scales?

Reciprocal Rank Fusion (RRF) answers this question elegantly. Proposed by Cormack, Clarke, and Butt in their 2009 SIGIR paper, RRF is a rank aggregation method that produces a unified ranking from multiple result lists using only rank positions — not raw scores. Each document receives a score of 1 / (k + rank) from each list it appears in, and these scores are summed. The constant k (typically 60) dampens the advantage of top-ranked items, preventing any single retrieval system from dominating the fused results.

In the context of agent memory systems, RRF is critical. An agent storing thousands of memories needs to retrieve the most relevant ones at query time. Some memories match semantically (similar meaning), others match lexically (same keywords), and the best results often appear in both lists. RRF naturally promotes documents that rank well across multiple retrieval methods while preserving results found by only one method — giving agents a significantly more reliable retrieval layer than either approach alone.

Core Concepts

Rank Aggregation Problem

Given N ranked lists of documents produced by different retrieval methods, rank aggregation seeks a single consensus ranking that reflects the combined evidence from all lists. This is fundamentally a voting theory problem — each retrieval method “votes” for documents by ranking them. Unlike score-based fusion (which requires normalizing incomparable scores), rank-based methods like RRF operate solely on ordinal positions, making them robust and parameter-light.

The RRF Formula

For a document d appearing at rank r in list i, its RRF contribution from that list is 1 / (k + r). The final RRF score is the sum across all lists: RRF(d) = Σ 1/(k + r_i). Documents not present in a list contribute zero. The constant k controls how quickly rank positions lose influence — higher k flattens the distribution, lower k amplifies top-rank advantages. The default k=60 was empirically validated across diverse TREC benchmarks.

Semantic Search (Dense Retrieval)

Dense retrieval encodes queries and documents into fixed-dimensional vectors using transformer-based models. Similarity is measured via cosine distance or dot product in the embedding space. Models like text-embedding-3-small, BGE-M3, and all-MiniLM-L6-v2 produce these embeddings. Dense retrieval excels at finding paraphrased or conceptually equivalent content but struggles with rare terms, out-of-vocabulary tokens, and exact string matching.

Sparse Retrieval (BM25)

BM25 is a bag-of-words retrieval function that scores documents based on term frequency (TF), inverse document frequency (IDF), and document length normalization. It assigns higher scores to documents containing query terms that are rare in the overall corpus. BM25 is fast (inverted index lookup), interpretable (scores decompose per term), and excellent at matching specific identifiers, acronyms, and technical vocabulary that embedding models may not represent faithfully.

Hybrid Search Architecture

A hybrid search system runs dense and sparse retrieval in parallel, then fuses results. The architecture typically involves: (1) a vector index (HNSW, IVF) for dense retrieval, (2) an inverted index for sparse retrieval, (3) a fusion layer (RRF, linear combination, or learned) that merges results, and (4) optional re-ranking with a cross-encoder for final precision. Modern vector databases like Qdrant, Weaviate, and Milvus support both index types natively.

How It Works

Parallel Retrieval Pipeline

The hybrid search pipeline runs semantic and keyword searches concurrently, then applies RRF to merge results. Over-fetching from each retriever (requesting 2–3x the desired result count) ensures that the fusion step has enough candidates to produce a high-quality final ranking.

import numpy as np
from dataclasses import dataclass, field

@dataclass
class SearchResult:
    doc_id: str
    content: str
    score: float
    metadata: dict = field(default_factory=dict)

def reciprocal_rank_fusion(
    ranked_lists: list[list[SearchResult]],
    k: int = 60
) -> list[SearchResult]:
    """Merge multiple ranked result lists using RRF."""
    score_map: dict[str, float] = {}
    result_map: dict[str, SearchResult] = {}

    for ranked_list in ranked_lists:
        for rank, result in enumerate(ranked_list, start=1):
            rrf_score = 1.0 / (k + rank)
            score_map[result.doc_id] = score_map.get(result.doc_id, 0.0) + rrf_score
            if result.doc_id not in result_map:
                result_map[result.doc_id] = result

    fused = []
    for doc_id, score in sorted(score_map.items(), key=lambda x: -x[1]):
        r = result_map[doc_id]
        fused.append(SearchResult(
            doc_id=r.doc_id,
            content=r.content,
            score=score,
            metadata=r.metadata,
        ))
    return fused

Integration with Vector Database

In practice, both retrieval modes are run against the same vector database. Qdrant, for example, supports both vector similarity search and full-text payload search within a single collection, enabling true hybrid queries without maintaining separate indices in different systems.

from qdrant_client import QdrantClient, models
import asyncio

class HybridMemorySearch:
    def __init__(self, client: QdrantClient, collection: str, embedder):
        self.client = client
        self.collection = collection
        self.embedder = embedder

    async def search(
        self, query: str, limit: int = 10, tags: list[str] | None = None
    ) -> list[SearchResult]:
        query_vector = await self.embedder.embed(query)

        tag_filter = None
        if tags:
            tag_filter = models.Filter(
                must=[models.FieldCondition(
                    key="tags",
                    match=models.MatchAny(any=tags),
                )]
            )

        # Run both retrievals concurrently
        semantic_task = self._semantic_search(query_vector, tag_filter, limit * 3)
        keyword_task = self._keyword_search(query, tag_filter, limit * 3)
        semantic_results, keyword_results = await asyncio.gather(
            semantic_task, keyword_task
        )

        # Fuse with RRF
        fused = reciprocal_rank_fusion(
            [semantic_results, keyword_results], k=60
        )
        return fused[:limit]

    async def _semantic_search(
        self, vector: list[float], filt, limit: int
    ) -> list[SearchResult]:
        hits = self.client.search(
            collection_name=self.collection,
            query_vector=vector,
            query_filter=filt,
            limit=limit,
            with_payload=True,
        )
        return [
            SearchResult(
                doc_id=str(h.id),
                content=h.payload["content"],
                score=h.score,
                metadata=h.payload,
            )
            for h in hits
        ]

    async def _keyword_search(
        self, query: str, filt, limit: int
    ) -> list[SearchResult]:
        must_conditions = [
            models.FieldCondition(
                key="content",
                match=models.MatchText(text=query),
            )
        ]
        if filt and filt.must:
            must_conditions.extend(filt.must)

        hits, _ = self.client.scroll(
            collection_name=self.collection,
            scroll_filter=models.Filter(must=must_conditions),
            limit=limit,
            with_payload=True,
        )
        return [
            SearchResult(
                doc_id=str(h.id),
                content=h.payload["content"],
                score=1.0 / (i + 1),
                metadata=h.payload,
            )
            for i, h in enumerate(hits)
        ]

Tuning the k Parameter

The RRF constant k controls the trade-off between trusting top-ranked results and spreading influence across ranks. Visualizing the score contribution at different values of k helps build intuition for how the parameter shapes the fused ranking.

import matplotlib.pyplot as plt

def plot_rrf_curves():
    ranks = list(range(1, 101))
    for k_val in [1, 10, 60, 200]:
        scores = [1.0 / (k_val + r) for r in ranks]
        plt.plot(ranks, scores, label=f"k={k_val}")

    plt.xlabel("Rank Position")
    plt.ylabel("RRF Score Contribution")
    plt.title("RRF Score Decay by Rank Position")
    plt.legend()
    plt.grid(True, alpha=0.3)
    plt.show()

# k=1:  top ranks dominate heavily (rank 1 gets 0.5, rank 10 gets 0.09)
# k=60: gentle decay (rank 1 gets 0.016, rank 10 gets 0.014) — default
# k=200: very flat (rank 1 gets 0.005, rank 10 gets 0.0048) — almost uniform

Weighted RRF for Asymmetric Retrievers

When one retrieval method is known to be more reliable for your domain, you can weight the RRF contributions accordingly. This is sometimes called Weighted Reciprocal Rank Fusion (WRRF).

def weighted_rrf(
    ranked_lists: list[list[SearchResult]],
    weights: list[float],
    k: int = 60,
) -> list[SearchResult]:
    """RRF with per-list weights for asymmetric trust."""
    assert len(ranked_lists) == len(weights)

    score_map: dict[str, float] = {}
    result_map: dict[str, SearchResult] = {}

    for weight, ranked_list in zip(weights, ranked_lists):
        for rank, result in enumerate(ranked_list, start=1):
            rrf_score = weight * (1.0 / (k + rank))
            score_map[result.doc_id] = score_map.get(result.doc_id, 0.0) + rrf_score
            if result.doc_id not in result_map:
                result_map[result.doc_id] = result

    fused = []
    for doc_id, score in sorted(score_map.items(), key=lambda x: -x[1]):
        r = result_map[doc_id]
        fused.append(SearchResult(
            doc_id=r.doc_id, content=r.content,
            score=score, metadata=r.metadata,
        ))
    return fused

# Example: trust semantic search 70%, keyword search 30%
results = weighted_rrf(
    [semantic_results, keyword_results],
    weights=[0.7, 0.3],
    k=60,
)

Implementation Patterns

Multi-Stage Retrieval with Cross-Encoder Reranking

For maximum precision, use RRF as a first-stage fusion and follow it with a cross-encoder reranker. The cross-encoder processes query-document pairs jointly (rather than independently encoding them), giving it much higher accuracy at the cost of being too slow to run over the full corpus. RRF narrows candidates from thousands to dozens, making cross-encoder reranking feasible.

from sentence_transformers import CrossEncoder

class RerankedHybridSearch:
    def __init__(self, hybrid_search: HybridMemorySearch):
        self.hybrid = hybrid_search
        self.reranker = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2")

    async def search(self, query: str, limit: int = 10) -> list[SearchResult]:
        # Stage 1: RRF fusion over-fetches candidates
        candidates = await self.hybrid.search(query, limit=limit * 5)

        # Stage 2: Cross-encoder reranks the top candidates
        pairs = [(query, c.content) for c in candidates]
        rerank_scores = self.reranker.predict(pairs)

        for candidate, score in zip(candidates, rerank_scores):
            candidate.score = float(score)

        candidates.sort(key=lambda c: c.score, reverse=True)
        return candidates[:limit]

Extending RRF to N Retrievers

RRF generalizes naturally beyond two lists. You might add a third retriever — for example, a graph-based neighbor lookup that finds memories connected by entity relationships. The same fusion logic handles three, four, or more ranked lists without modification.

Evaluation with NDCG and MAP

Measure hybrid search quality using standard IR metrics: Normalized Discounted Cumulative Gain (NDCG) for graded relevance and Mean Average Precision (MAP) for binary relevance. Compare RRF against each individual retriever and against naive score-based fusion to validate that RRF actually improves your retrieval quality for your specific corpus.

Common Pitfalls

Further Reading