DEV Community

Cover image for Reciprocal Rerank Fusion (RRF): The Simple, Powerful Way to Combine Keyword + Semantic Search in RAG
Christopher S. Aondona
Christopher S. Aondona

Posted on

Reciprocal Rerank Fusion (RRF): The Simple, Powerful Way to Combine Keyword + Semantic Search in RAG

If you've built a RAG application, you’ve probably hit this wall:

  • Keyword search (Best Match 25 [BM25]) is great at exact matches.
  • Vector search captures semantic meaning.
  • But combining the two ranked lists is painful because their scores live on completely different scales.

Manually normalizing scores is fragile. Reciprocal Rank Fusion (RRF) solves this elegantly by ignoring raw scores entirely and only looking at rank positions.

RRF has become the standard fusion method in MemoFS, Elasticsearch, OpenSearch, Azure AI Search, Weaviate, MongoDB Atlas, and modern RAG pipelines.

In this post, I’ll show you exactly how RRF works and give you clean, production-ready TypeScript implementations.

How RRF Works

RRF assigns a score to each document using this simple formula:

RRF(d)=rR1k+rankr(d)\text{RRF}(d) = \sum_{r \in R} \frac{1}{k + \text{rank}_r(d)}

Where:

  • dd = document
  • RR = list of ranked result sets
  • rankr(d)\text{rank}_r(d) = position of the document in that result set (starting at 1)
  • kk = smoothing constant (default: 60)

Documents are then sorted by their final RRF score (highest first).

TypeScript Implementation

Here’s a clean, generic, and reusable TypeScript version:

/**
 * Reciprocal Rank Fusion (RRF)
 * Fuses multiple ranked lists into a single ranked list
 */
export function reciprocalRankFusion<T>(
  rankedLists: T[][],
  options: {
    k?: number;      // Smoothing constant (default: 60)
    topK?: number;   // Limit number of results returned
  } = {}
): Array<{ item: T; score: number }> {
  const { k = 60, topK } = options;
  const rrfScores = new Map<T, number>();

  for (const rankedList of rankedLists) {
    rankedList.forEach((item, index) => {
      const rank = index + 1;
      const currentScore = rrfScores.get(item) ?? 0;
      rrfScores.set(item, currentScore + 1 / (k + rank));
    });
  }

  // Convert to array and sort by score (descending)
  let results = Array.from(rrfScores.entries()).map(([item, score]) => ({
    item,
    score,
  }));

  results.sort((a, b) => b.score - a.score);

  if (topK !== undefined) {
    results = results.slice(0, topK);
  }

  return results;
}
Enter fullscreen mode Exit fullscreen mode

Usage example:

const bm25Results = ["doc_B", "doc_A", "doc_D", "doc_C"];
const vectorResults = ["doc_B", "doc_E", "doc_A", "doc_F"];

const fusedResults = reciprocalRankFusion([bm25Results, vectorResults], {
  k: 60,
  topK: 10,
});

console.log(fusedResults);
// [
//   { item: 'doc_B', score: 0.03125 },
//   { item: 'doc_A', score: 0.02548 },
//   ...
// ]
Enter fullscreen mode Exit fullscreen mode

Note: This implementation works best when items are primitives (string, number) or the same object references. For complex objects, consider using document IDs or a Map with string keys.

Working with LangChain.js

You can easily integrate RRF into LangChain.js workflows. Here’s a practical pattern:

import { EnsembleRetriever } from "langchain/retrievers/ensemble"; // or implement manually
import { BM25Retriever } from "@langchain/community/retrievers/bm25";
import { VectorStoreRetriever } from "langchain/vectorstores/base";

// Example: Combine BM25 + Vector retriever
const bm25Retriever = BM25Retriever.fromDocuments(docs);
const vectorRetriever = vectorStore.asRetriever({ k: 20 });

const ensembleRetriever = new EnsembleRetriever({
  retrievers: [bm25Retriever, vectorRetriever],
  weights: [0.5, 0.5],
  // Internally uses weighted RRF
});

const results = await ensembleRetriever.invoke("your query");
Enter fullscreen mode Exit fullscreen mode

If you prefer full control, just use the reciprocalRankFusion function above after fetching results from multiple retrievers.
MemoFS handles this automatically for you out of the box.

Why RRF is So Effective

  • No score normalization required
  • Rewards documents that rank well across multiple methods
  • Extremely robust (proven in the original 2009 SIGIR paper)
  • Only one parameter (k), and 60 works great in most cases

Best Practices in TypeScript Projects

  1. Fetch more candidates than you need (k: 20–50 per retriever)
  2. Fuse and then take the top 5–10 results
  3. Use document IDs when working with objects
  4. Consider adding light weighting when one retrieval method is consistently stronger

Are you a developer using coding agents, building RAG systems, or implementing AI memories?

Check out the full documentation and ready-to-use AI agents/apps memory at:

https://docs.memofs.dev

Whether you're deploying to a Node environment or Cloudflare workers, you'll find practical guides to take your retrieval quality to the next level.


What’s your current approach to hybrid search? Are you still normalizing scores, or have you switched to RRF? Let me know in the comments!

Top comments (0)