teachyou.ai academy
← All posts
RAGhybrid searchvector searchinformation retrievalsearch engineering

Hybrid Search with Reciprocal Rank Fusion: Combining Vector and Keyword Search in RAG

Pramod Dutta · Jun 21, 2026 · 11 min read

Reciprocal rank fusion (RRF) is the algorithm most production RAG systems use to combine vector search and keyword search into one ranked list. If you have ever shipped a retrieval-augmented generation pipeline that nails conceptual questions but whines out on exact product codes, error messages, or acronyms, you have hit the classic weakness of pure embedding search, and reciprocal rank fusion is the standard, low-effort fix. This article walks through why hybrid search beats either method alone, how RRF works mathematically, and how to implement it end to end with Postgres, Elasticsearch/OpenSearch, and a pure Python fallback.

Why vector search alone is not enough

Dense vector search, the kind backing most RAG stacks, embeds a query and a set of chunks into the same vector space and ranks chunks by cosine similarity or dot product. It is excellent at capturing meaning: a query about "reducing cloud spend" will surface a chunk about "cutting infrastructure costs" even though the two share almost no words. But that same strength is a weakness for anything where exact tokens matter.

Consider these query types where dense retrieval regularly underperforms:

  • Exact identifiers: SKU numbers, error codes like ECONNREFUSED, ticket IDs, model numbers such as gpt-5-mini.
  • Rare or out-of-vocabulary terms: a customer's own product name, an internal codename, a person's surname.
  • Acronyms and short queries: "RRF" itself is a good example, embedding models often blur it toward unrelated short strings.
  • Negation and precise phrasing: "not compatible with" versus "compatible with" can land close together in embedding space.

Keyword search (BM25 and its relatives) is the mirror image. It is precise on exact terms but blind to synonymy and paraphrase. A query for "how do I stop my API from throttling me" will not match a document that only says "rate limit handling" if BM25 is your only retriever.

The fix is not picking a winner, it is running both and merging the results. That merging step is where reciprocal rank fusion comes in.

What reciprocal rank fusion actually does

RRF was introduced by Cormack, Clarke, and Buettcher in a 2009 SIGIR paper as a way to combine ranked lists from multiple retrieval systems without needing to know or normalize their underlying scores. That is the key insight: BM25 scores and cosine similarity scores live on completely different scales, one might range from 0 to 40, the other from -1 to 1, and neither is calibrated in a way that lets you add them together meaningfully. RRF sidesteps the whole problem by ignoring scores and using rank position instead.

For each document d that appears in one or more ranked lists, RRF computes:

RRF_score(d) = sum over each ranking r of  1 / (k + rank_r(d))

Where rank_r(d) is the 1-indexed position of document d in ranking r, and k is a constant (commonly 60) that dampens the influence of top-ranked results and prevents rank 1 from completely dominating the sum. Documents that do not appear in a given ranking simply contribute 0 for that ranking.

Two properties make this useful in practice:

  • It needs no score normalization. You can combine BM25, dense cosine similarity, a graph-based retriever, or even a reranker's raw output, since only the rank order matters, not the score magnitude.
  • It rewards documents that show up near the top of multiple lists, which is usually a strong signal of genuine relevance, while still giving credit to a document that only one retriever found but ranked highly.

Here is a concrete example. Say a query returns these top-4 results from two retrievers:

Vector search ranks:      [doc_A, doc_C, doc_B, doc_D]
Keyword search ranks:     [doc_B, doc_A, doc_E, doc_C]

With k = 60:

RRF(doc_A) = 1/(60+1) + 1/(60+2) = 0.01639 + 0.01613 = 0.03252
RRF(doc_B) = 1/(60+3) + 1/(60+1) = 0.01587 + 0.01639 = 0.03226
RRF(doc_C) = 1/(60+2) + 1/(60+4) = 0.01613 + 0.01563 = 0.03175
RRF(doc_D) = 1/(60+4)            = 0.01563
RRF(doc_E) =            1/(60+3) = 0.01587

Fused order: doc_A > doc_B > doc_C > doc_E > doc_D. Notice doc_A wins even though it was rank 2 in keyword search, because it was consistently near the top of both lists. That consistency-rewarding behavior is exactly what you want in a RAG retrieval step feeding an LLM: documents both a semantic and a lexical signal agree on are more trustworthy context.

Implementing RRF in Python

Here is a retriever-agnostic implementation you can drop into any pipeline. It takes any number of ranked lists (as ordered lists of document IDs) and returns a fused ranking.

from collections import defaultdict

def reciprocal_rank_fusion(ranked_lists, k=60, weights=None):
    """
    ranked_lists: list of ranked lists, each a list of doc_ids
                  ordered from most to least relevant.
    k: dampening constant, 60 is the standard default.
    weights: optional list of floats, one per ranked_list, to
             upweight a stronger retriever. Defaults to all 1.0.
    Returns: list of (doc_id, rrf_score) sorted descending.
    """
    if weights is None:
        weights = [1.0] * len(ranked_lists)

    scores = defaultdict(float)
    for ranked_list, weight in zip(ranked_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 pair: pair[1], reverse=True)


vector_results = ["doc_A", "doc_C", "doc_B", "doc_D"]
keyword_results = ["doc_B", "doc_A", "doc_E", "doc_C"]

fused = reciprocal_rank_fusion([vector_results, keyword_results])
for doc_id, score in fused:
    print(f"{doc_id}: {score:.5f}")

This function is retriever-agnostic on purpose. Feed it a graph-search ranking, a sparse SPLADE ranking, or a reranker's output, and it composes the same way. The weights parameter lets you nudge the fusion toward one retriever when you have evidence it is more reliable for your domain, for example weighting keyword search higher for a codebase-search product where identifiers dominate queries.

Hybrid search in Postgres with pgvector and tsvector

If your RAG stack already runs on Postgres with pgvector, you do not need a separate search engine to get hybrid search. Postgres has built-in full-text search (tsvector/tsquery, which implements a BM25-like ranking via ts_rank) and, since pgvector 0.7, a native way to do this in a single query using a common table expression per retriever plus a fusion step.

Schema:

CREATE TABLE chunks (
    id BIGSERIAL PRIMARY KEY,
    content TEXT NOT NULL,
    embedding VECTOR(1536),
    content_tsv TSVECTOR GENERATED ALWAYS AS (to_tsvector('english', content)) STORED
);

CREATE INDEX ON chunks USING hnsw (embedding vector_cosine_ops);
CREATE INDEX ON chunks USING gin (content_tsv);

Fused query, run entirely in SQL:

WITH vector_search AS (
    SELECT id, RANK() OVER (ORDER BY embedding <=> $1) AS rank
    FROM chunks
    ORDER BY embedding <=> $1
    LIMIT 50
),
keyword_search AS (
    SELECT id, RANK() OVER (ORDER BY ts_rank(content_tsv, query) DESC) AS rank
    FROM chunks, plainto_tsquery('english', $2) AS query
    WHERE content_tsv @@ query
    ORDER BY ts_rank(content_tsv, query) DESC
    LIMIT 50
)
SELECT
    COALESCE(v.id, k.id) AS id,
    COALESCE(1.0 / (60 + v.rank), 0.0) + COALESCE(1.0 / (60 + k.rank), 0.0) AS rrf_score
FROM vector_search v
FULL OUTER JOIN keyword_search k ON v.id = k.id
ORDER BY rrf_score DESC
LIMIT 10;

Bind $1 to the query embedding and $2 to the raw query text. This runs both retrievers and the fusion in one round trip, which matters for RAG latency budgets where you are already paying for an embedding call and an LLM call downstream. Pull each candidate set wider than your final top-k (50 candidates for a top-10 result is a reasonable starting ratio) so the fusion step has enough overlap to actually do its job.

Hybrid search in Elasticsearch or OpenSearch

Both Elasticsearch and OpenSearch ship native RRF support, which removes the need to hand-roll the fusion logic. In OpenSearch, you configure a search pipeline once:

PUT /_search/pipeline/rrf-pipeline
{
  "description": "RRF fusion of BM25 and vector search",
  "phase_results_processors": [
    {
      "score-ranker-processor": {
        "combination": {
          "technique": "rrf",
          "rank_constant": 60
        }
      }
    }
  ]
}

Then issue a hybrid query referencing that pipeline:

POST /rag_chunks/_search?search_pipeline=rrf-pipeline
{
  "query": {
    "hybrid": {
      "queries": [
        { "match": { "content": "connection refused error handling" } },
        {
          "knn": {
            "embedding": {
              "vector": [0.021, -0.114, "...1536 floats..."],
              "k": 50
            }
          }
        }
      ]
    }
  },
  "size": 10
}

Elasticsearch's rrf retriever works the same way but is expressed directly in the _search body via a retriever block instead of a separate pipeline object. Check your cluster's version docs for the exact syntax, this API has moved around across releases. The advantage of using the engine's native RRF over rolling your own is that it happens inside the search node, so you are not shipping two full candidate sets back to your application just to merge them client-side.

Tuning the k constant and candidate depth

Two knobs matter in practice, and neither needs heavy tuning:

  • k (rank constant): 60 is the value from the original paper and it works well as a default across most corpora. Lowering k (say to 10-20) makes the fusion more aggressive about rewarding top-1 and top-2 results, which helps if your top-ranked results are usually trustworthy. Raising k flattens the curve and gives more weight to consistency across many mid-ranked results. Only move off 60 if you have a labeled eval set showing it helps, guessing here rarely pays off.
  • Candidate depth: how many results each retriever contributes before fusion. If you only fetch the top 10 from each retriever, documents that rank 11th in one list but 1st in the other never get a chance to combine. Fetch 3-5x your final top-k from each side.

One thing RRF explicitly does not fix: retriever quality. If your embedding model is weak for your domain, or your BM25 analyzer is stripping tokens you need (stemming a product code into garbage, for instance), fusion cannot rescue a retriever that never puts the right document in its candidate list at all. Fusion combines rankings, it does not invent relevance that was never surfaced.

When to add a reranker on top

RRF gets you a strong candidate list cheaply, but it is still a heuristic, not a learned relevance model. For RAG applications where retrieval quality directly gates answer quality, for example legal or medical document QA, a common pattern is: run vector search and keyword search, fuse with RRF to get a top 20-30 candidate set, then pass that set through a cross-encoder reranker to produce the final top-5 to 10 chunks that go into the LLM prompt. This three-stage pipeline (retrieve wide, fuse, rerank narrow) balances latency against quality: the expensive reranker only scores a couple dozen documents instead of the entire corpus, and it only sees documents that at least one retriever thought were plausible.

FAQ

What does RRF stand for and who invented it? Reciprocal rank fusion. It comes from a 2009 SIGIR paper by Gordon Cormack, Charles Clarke, and Stefan Buettcher, originally applied to combining results from multiple independent search engines for enterprise search.

Do I need to normalize scores before using RRF? No, and that is the entire point of the algorithm. RRF only looks at rank position within each list, not the underlying score, so it sidesteps the problem of BM25 and cosine similarity living on incompatible scales.

What value of k should I use? Start with 60, the value from the original paper. It is a reasonable default across most corpora and query types. Only adjust it if you have an evaluation set (recall@k or NDCG against labeled relevant documents) showing a different value measurably helps.

Can I combine more than two retrievers with RRF? Yes. The formula sums 1/(k+rank) across however many ranked lists you provide, so adding a third retriever, for example a graph-based or metadata-filtered ranking, is a matter of adding one more list to the sum. Some implementations also support per-list weights if one retriever is known to be stronger.

Does RRF replace the need for a reranker? No. RRF is a cheap way to merge multiple first-stage retrievers into a better candidate set. A cross-encoder reranker is a separate, more expensive step that re-scores a small candidate set using a model trained specifically on query-document relevance. Many production RAG pipelines use both: RRF to fuse retrievers, then a reranker to do final precision ranking.

Will hybrid search with RRF slow down my RAG pipeline? It adds one additional retrieval call (the keyword search) and a fusion step that is O(n) in the number of candidates, which is negligible compared to embedding generation and LLM inference time. If you use a search engine with native RRF support (OpenSearch, Elasticsearch), the fusion happens server-side and adds essentially no extra round trip.

Is RRF only useful for RAG, or does it apply elsewhere? RRF is a general-purpose rank fusion technique used anywhere you have multiple ranked lists to combine, including traditional web search, recommendation systems, and any ensemble of retrieval or ranking models. RAG is simply the context where most engineers encounter it first today, because combining a vector index with a keyword index is now the default architecture for serious retrieval systems.