← all writing
┌─cat blog/rrf-reciprocal-rank-fusion.md┐

2026-05-22 · 1 min read

RAGSearchAlgorithmsAI

RRF is a simple, elegant way to combine multiple ranked result lists into a single ranking — without caring about the actual scores.

The Problem with Raw Scores

Hybrid search typically involves two retrievers running in parallel:

  • Vector search returns a cosine similarity score between 0.0 and 1.0 — a well-defined, bounded value.
  • Lexical search (e.g. BM25) returns an arbitrary relevance score — the ordering is meaningful, but the score itself has no meaning outside of that specific result set. A score of 42.7 from BM25 tells you nothing on its own.

Since the two score ranges are completely incompatible, you can't just add them together or average them. You need a score-agnostic merging strategy.

The Idea

RRF throws away the raw scores entirely and works only with rank positions. For each document, its RRF score is the sum of a reciprocal rank contribution from every retriever that returned it:

RRF(d) = Σ  1 / (k + rank(d))

Where:

  • rank(d) is the position of document d in a given retriever's result list (1-indexed)
  • k is a smoothing constant (typically 60) that prevents top-ranked results from dominating too heavily
  • The sum is taken over all retrievers that returned d

A document that ranks #1 in both vector and lexical search gets a much higher combined score than one that ranks #1 in only one. Documents that don't appear in a retriever's results simply don't contribute to the sum from that retriever.

RRF Implementation in Nexus

func mergeResults(lex, vec []store.ChunkResult) ([]MergedResult, error) {
	const k = 60.0 // smoothing constant
	rrfScores := make(map[string]float64)
	chunkMap := make(map[string]store.ChunkResult)

	for rank, item := range lex {
		rrfScores[item.Id] += 1.0 / (float64(rank+1) + k)
		chunkMap[item.Id] = item
	}

	for rank, item := range vec {
		rrfScores[item.Id] += 1.0 / (float64(rank+1) + k)
		chunkMap[item.Id] = item
	}

	var results []MergedResult

	for id, score := range rrfScores {
		results = append(results, MergedResult{
			ChunkResult: chunkMap[id],
			RRFScore:    score,
		})
	}

	sort.Slice(results, func(i, j int) bool {
		return results[i].RRFScore > results[j].RRFScore
	})

	if len(results) > 10 {
		results = results[:10]
	}
	return results, nil
}

Both result lists are iterated independently. Each chunk accumulates score contributions from whichever retrievers found it. The final list is sorted by RRF score descending and capped at the top 10 results.

└────────────────────────────────────────┘