Skip to main content
Pinecone Docs

Search documentation

Type to search this documentation.

On this pageOverview

Reciprocal rank fusion

Combine results from separate Pinecone searches into a single ranking with reciprocal rank fusion (RRF), a client-side method that fuses rankings, not scores.

When you rank the same set of documents two different ways, for example, a full-text (BM25) search and a semantic (dense-vector) search, the scores live on different, incomparable scales. Adding or averaging the raw scores isn't meaningful, and one signal usually dominates. Reciprocal rank fusion (RRF) combines them by fusing the rankings instead of the scores, so no normalization or per-signal weighting is required.

You run each search separately, then apply RRF to their results in your client.

Reach for RRF whenever you combine results from separate searches and want each to contribute to the final ranking, most commonly full-text (BM25) with semantic (dense-vector). It's a strong default that works without normalizing scores or tuning per-signal weights. Combining a keyword search with a dense search this way is the client-side fusion approach to hybrid search: RRF is the fusion method, applied in your client after the searches run.

RRF scores each document by summing 1 / (k + rank) across every ranking it appears in, where rank is the document's 1-based position in that ranking and k is a constant (the original RRF paper uses k=60). A document ranked highly in multiple searches accumulates the highest fused score. A document absent from a ranking gets nothing from it. Because only rank position matters, scores never need to be normalized.

Python
def reciprocal_rank_fusion(rankings, k=60):
    """Fuse ranked lists of document IDs into one RRF-scored ranking.

    Each ranking is a list of `_id`s in rank order (best first).
    Returns a dict mapping `_id` to its fused score.
    """
    scores = {}
    for ranking in rankings:
        for rank, doc_id in enumerate(ranking, start=1):
            scores[doc_id] = scores.get(doc_id, 0.0) + 1.0 / (k + rank)
    return scores

This assumes an index whose schema declares an FTS-enabled string field (body) and a dense_vector field (embedding).

Run each search independently, pass the ranked _ids to reciprocal_rank_fusion, then sort by the fused score. Here, a full-text search and a semantic search over the same index are fused into one top-10 ranking:

Python
import os
from pinecone import Pinecone

pc = Pinecone(api_key=os.environ["PINECONE_API_KEY"])
index = pc.Index(name="docs-example")

query = "how do I reset my password?"
fetch_k = 50  # fetch deeper than the final top_k so weaker-but-present signals still count

# 1. Full-text (BM25) search.
text_hits = index.documents.search(
    namespace="__default__",
    top_k=fetch_k,
    score_by=[{"type": "text", "fields": ["body"], "query": query}],
    include_fields=["*"],
).matches

# 2. Semantic (dense-vector) search. Embed the query with the same model as your documents.
query_embedding = pc.inference.embed(
    model="llama-text-embed-v2",
    inputs=[query],
    parameters={"input_type": "query"},
)
dense_hits = index.documents.search(
    namespace="__default__",
    top_k=fetch_k,
    score_by=[{"type": "dense_vector", "fields": ["embedding"], "values": query_embedding[0]["values"]}],
    include_fields=["*"],
).matches

# 3. Fuse the two rankings client-side (reciprocal_rank_fusion is defined in "How it works" above).
fused = reciprocal_rank_fusion([
    [m._id for m in text_hits],
    [m._id for m in dense_hits],
])

# 4. Sort by fused score and keep the top results.
by_id = {m._id: m for m in text_hits}
for m in dense_hits:
    by_id.setdefault(m._id, m)

top_k = 10
for doc_id in sorted(fused, key=fused.get, reverse=True)[:top_k]:
    print(doc_id, fused[doc_id], getattr(by_id[doc_id], "body", ""))
  • fetch_k (a variable in this example, not an API parameter) controls how deep each ranking is fetched before fusing. Raise it so a document that ranks well in one search but outside the top of the other still contributes. It should be at least your final top_k.
  • k (default 60) controls how much a single ranking's top positions dominate the fused order. A larger k flattens the influence of any single top-ranked result.
  • Latency. Run the searches in parallel (for example with asyncio or threads) so you pay the slowest search's latency, not the sum.
  • Weighting. To favor one signal over another, weight each ranking's contribution (multiply its 1 / (k + rank) terms by a per-ranking weight). It's a powerful relevance-tuning lever and can warrant its own guide; this page uses the unweighted default.

RRF extends to any number of rankings. Pass more lists to reciprocal_rank_fusion; each additional search contributes another ranked list of _ids:

Python
fused = reciprocal_rank_fusion([
    [m._id for m in text_hits],
    [m._id for m in dense_hits],
    [m._id for m in sparse_hits],  # e.g., a sparse-vector search
])
Suggest an edit

Propose a replacement for this page. The site team reviews it before applying any changes.

Export
Documentation menu