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.
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.
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.
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.
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.
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.
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
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)
]
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
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,
)
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]
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.
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.
limit results before fusion, RRF has too few candidates to produce a good ranking. Always over-fetch by 2–3x and then trim the fused list to the desired size.k=60 works well for general web search, but domain-specific corpora (legal documents, code, medical records) may benefit from different values. Run A/B tests with k in the range [10, 100] against labeled relevance judgments.