0Pricing
AI Engineering Academy · Lesson

Reciprocal Rank Fusion for Score Merging

Implement reciprocal rank fusion to merge ranked result lists from dense and sparse retrievers without needing to normalize incompatible similarity scores.

Reciprocal Rank Fusion for Score Merging is a free AI Engineering Academy lesson on CoddyKit — lesson 3 of 4. You can read the complete lesson below for free — then practise it hands-on in the browser with a built-in code editor and a 24/7 AI tutor. It is part of the AI Engineering Academy learning path, one of 4 lessons in the course, and your progress syncs across the web and the CoddyKit app.

The Score Incompatibility Problem

Dense retrievers produce cosine similarity scores between -1 and 1, while BM25 produces unbounded positive scores that depend on corpus size and term frequencies. You cannot add these numbers directly — a BM25 score of 3.7 and a cosine similarity of 0.85 mean completely different things. Simple score normalization (dividing by max score) is brittle because outlier documents distort the scale. We need a method that is agnostic to absolute score values.

The Core Idea Behind RRF

Reciprocal Rank Fusion (RRF) sidesteps the score incompatibility problem by converting each retriever's results into ranked positions and fusing those ranks rather than raw scores. A document ranked first gets a high RRF contribution, a document ranked tenth gets a much lower one, and the final score is the sum of RRF contributions across all retrievers. The formula is: RRF(d) = sum(1 / (k + rank_i(d))) where k is a smoothing constant (typically 60).

# RRF formula
# For each retriever i, document d receives:
#   contribution = 1 / (k + rank_i(d))
# Final RRF score = sum of contributions from all retrievers
# k = 60 is the standard constant from the original 2009 paper

# Example:
# Document A: rank 1 in BM25, rank 4 in dense
#   RRF(A) = 1/(60+1) + 1/(60+4) = 0.01639 + 0.01563 = 0.03202
# Document B: rank 2 in BM25, rank 2 in dense
#   RRF(B) = 1/(60+2) + 1/(60+2) = 0.01613 + 0.01613 = 0.03226
# Document B scores slightly higher due to consistent top-2 ranking

Implementing RRF from Scratch

The implementation is surprisingly simple. For each retriever, iterate over its ranked result list and accumulate RRF scores into a dictionary keyed by document ID. Documents that appear in multiple retrievers accumulate contributions from each. Finally, sort by total RRF score descending to produce the merged ranking.

from collections import defaultdict

def reciprocal_rank_fusion(
    result_lists: list[list[str]],
    k: int = 60,
) -> list[tuple[str, float]]:
    '''Merge multiple ranked result lists using RRF.
    result_lists: each inner list is a ranked list of document IDs
    Returns: sorted list of (doc_id, rrf_score) tuples
    '''
    rrf_scores = defaultdict(float)

    for ranked_list in result_lists:
        for rank, doc_id in enumerate(ranked_list, start=1):
            rrf_scores[doc_id] += 1.0 / (k + rank)

    sorted_results = sorted(rrf_scores.items(), key=lambda x: x[1], reverse=True)
    return sorted_results

End-to-End Hybrid Search with RRF

In practice, you run both retrievers, collect their ranked document ID lists, pass them to RRF, and look up the top-K document IDs in your document store. Notice that no score normalization is required — only the rank ordering of each retriever's results matters. This makes RRF extremely robust to distribution shifts and corpus size changes.

def hybrid_search_rrf(
    query: str,
    bm25_index,
    dense_retriever,
    documents: dict,  # id -> text
    top_k: int = 5,
) -> list[dict]:
    # Get ranked lists from each retriever
    bm25_ids = bm25_index.search(query, top_k=20)  # over-retrieve then fuse
    dense_ids = dense_retriever.search(query, top_k=20)

    # Run RRF
    fused = reciprocal_rank_fusion([bm25_ids, dense_ids])

    # Return top-K with scores
    results = []
    for doc_id, score in fused[:top_k]:
        results.append({
            'id': doc_id,
            'text': documents[doc_id],
            'rrf_score': round(score, 6),
        })
    return results

Why Over-Retrieve Before Fusing

Notice the pattern of retrieving 20 candidates from each retriever and then taking the top 5 after fusion. This over-retrieval strategy is important because a document that appears at rank 10 in BM25 but rank 1 in dense should bubble up in the fused list. If you only retrieve 5 from each, you miss such documents. A common practice is to retrieve top_k * 4 from each retriever before fusing and then return top_k final results.

The Smoothing Constant K

The constant k in the RRF formula controls how much weight is given to top-ranked versus lower-ranked documents. A small k amplifies the difference between ranks (rank 1 is much better than rank 2), while a large k flattens the distribution (all ranks matter roughly equally). The original paper used k=60 and empirical studies show values between 30 and 100 all perform similarly. k=60 is a robust default.

# Visualize effect of k on contribution weights
for k in [10, 60, 100]:
    weights = [1.0 / (k + rank) for rank in range(1, 11)]
    print(f'k={k}: rank1={weights[0]:.4f}, rank5={weights[4]:.4f}, rank10={weights[9]:.4f}')

# k=10:  rank1=0.0909, rank5=0.0667, rank10=0.0500  (steep)
# k=60:  rank1=0.0164, rank5=0.0154, rank10=0.0143  (flat)
# k=100: rank1=0.0099, rank5=0.0095, rank10=0.0091  (very flat)

Handling Documents Absent from One Retriever

RRF handles missing documents gracefully: a document that appears in only one retriever still accumulates that retriever's RRF contribution. It simply does not receive a second contribution. This means documents that both retrievers agree on will naturally score higher than documents found by only one. This consensus signal is exactly what you want in a hybrid system.

# Example showing consensus effect
example_docs = {
    'doc_A': [1, 3],   # ranks in [bm25, dense]
    'doc_B': [2, 2],   # both retrievers like it
    'doc_C': [3, None],  # only in BM25
    'doc_D': [None, 1],  # only in dense
}

k = 60
for doc, ranks in example_docs.items():
    score = sum(1/(k + r) for r in ranks if r is not None)
    print(f'{doc}: RRF = {score:.5f}')
# doc_B ranks highest because both retrievers agree
# doc_D (rank 1 in dense only) may outscore doc_A (1,3) despite lower consensus

RRF with More Than Two Retrievers

RRF scales naturally to three or more retrievers. You might combine BM25, a dense embedding retriever, and a sparse learned retriever (like SPLADE) that explicitly optimizes for keyword matching in a learned sparse space. Each additional retriever adds another RRF contribution per document, and documents that rank highly across all retrievers win by a clear margin.

# Three-way hybrid: BM25 + dense + SPLADE
bm25_ids = ['doc_B', 'doc_A', 'doc_C', 'doc_D']
dense_ids = ['doc_D', 'doc_A', 'doc_B', 'doc_E']
splade_ids = ['doc_B', 'doc_D', 'doc_A', 'doc_F']

fused = reciprocal_rank_fusion([bm25_ids, dense_ids, splade_ids])
print('Fused ranking:')
for doc_id, score in fused:
    print(f'  {doc_id}: {score:.5f}')
# doc_A and doc_B likely dominate because they appear in all three lists

Weighted RRF for Asymmetric Retrievers

Standard RRF treats all retrievers equally, but in practice one retriever may be more reliable than another for your specific domain. Weighted RRF multiplies each retriever's contribution by a weight before summing. A weight of 1.5 on the dense retriever and 0.5 on BM25 emphasizes semantic matching, while equal weights of 1.0 treat them symmetrically. Tune weights on a validation set.

def weighted_rrf(
    result_lists: list[list[str]],
    weights: list[float],
    k: int = 60,
) -> list[tuple[str, float]]:
    from collections import defaultdict
    scores = defaultdict(float)
    for ranked_list, weight in zip(result_lists, weights):
        for rank, doc_id in enumerate(ranked_list, start=1):
            scores[doc_id] += weight * (1.0 / (k + rank))
    return sorted(scores.items(), key=lambda x: x[1], reverse=True)

# Emphasize dense retrieval (0.7) over BM25 (0.3)
fused = weighted_rrf([bm25_ids, dense_ids], weights=[0.3, 0.7])

RRF vs Score Normalization: Why RRF Wins

Alternatives to RRF include min-max normalization (scale each retriever's scores to [0,1]) and z-score normalization. Both are sensitive to outlier documents that inflate the maximum score and collapse other scores toward zero. RRF is robust to outliers because it only uses rank positions, not raw scores. Empirical benchmarks consistently show RRF outperforms normalization-based fusion approaches.

Implementing RRF with LangChain

LangChain's EnsembleRetriever implements RRF fusion under the hood. It accepts a list of retrievers and optional weights, runs them in parallel, and returns RRF-merged results. This lets you integrate hybrid search into LCEL chains with minimal boilerplate and swap out individual retrievers without changing the fusion logic.

from langchain.retrievers import EnsembleRetriever
from langchain_community.retrievers import BM25Retriever
from langchain_community.vectorstores import FAISS
from langchain_openai import OpenAIEmbeddings

# Dense retriever
vectorstore = FAISS.from_documents(langchain_docs, OpenAIEmbeddings())
vector_retriever = vectorstore.as_retriever(search_kwargs={'k': 20})

# Sparse retriever
bm25_retriever = BM25Retriever.from_documents(langchain_docs, k=20)

# Hybrid ensemble using RRF internally
ensemble = EnsembleRetriever(
    retrievers=[bm25_retriever, vector_retriever],
    weights=[0.4, 0.6],
)
results = ensemble.invoke('hybrid search reciprocal rank fusion')

Quick Check

Test your understanding of reciprocal rank fusion from this lesson.

Lesson Recap

In this lesson you learned: RRF merges ranked lists without normalizing incompatible scores, the formula 1/(k + rank) gives each document a contribution from each retriever, and documents ranked highly by multiple retrievers earn the highest fused scores. The smoothing constant k=60 is a robust default. LangChain's EnsembleRetriever implements RRF natively. Next up we configure hybrid search directly in Pinecone and pgvector.

Frequently asked questions

Is the “Reciprocal Rank Fusion for Score Merging” lesson free?

Yes — the full text of “Reciprocal Rank Fusion for Score Merging” is free to read here on the web, and the AI Engineering Academy course includes 4 lessons in total. To practise it interactively (a built-in code editor and a 24/7 AI tutor) and unlock the rest of the AI Engineering Academy course, upgrade to CoddyKit PRO.

What will I learn in “Reciprocal Rank Fusion for Score Merging”?

Implement reciprocal rank fusion to merge ranked result lists from dense and sparse retrievers without needing to normalize incompatible similarity scores. You practise AI Engineering Academy with hands-on code you run directly in the browser, and a 24/7 AI tutor answers your questions as you work through the lesson.

Do I need any experience to start AI Engineering Academy?

No prior experience is required. AI Engineering Academy on CoddyKit is structured for beginners through advanced learners; this is — lesson 3 of 4, so you can start here or from the beginning and move at your own pace.

How long does the “Reciprocal Rank Fusion for Score Merging” lesson take?

Most CoddyKit lessons take about 5–10 minutes. Each one is bite-sized and interactive, so you make steady progress and pick up exactly where you left off across the web and the app.

Can I write and run code in this AI Engineering Academy lesson?

Yes. Every AI Engineering Academy lesson includes a built-in code editor, so you write and run real code right in your browser and get instant AI feedback — no local setup required.

All lessons in this course

  1. Dense vs Sparse Retrieval: Trade-offs
  2. Implementing BM25 Keyword Search
  3. Reciprocal Rank Fusion for Score Merging
  4. Hybrid Search in Pinecone and pgvector
← Back to AI Engineering Academy