Hybrid Search with RRF in Elasticsearch: A Complete Guide

August 26, 2026guides

Keyword search and vector search each solve a different slice of the retrieval problem. BM25 excels when the user's words match the document's words exactly — it is fast, interpretable, and tuned by decades of information retrieval research. Dense vector search captures meaning across vocabulary gaps: a query for "machine learning" can surface a document that never uses those words but talks entirely about "training neural networks." Neither modality dominates the other across all query types. When BM25 misses a paraphrase and the vector model misses a rare keyword, both rankings are individually wrong — but the correct document is often present in at least one of them. Reciprocal Rank Fusion (RRF) exploits precisely that redundancy: it combines the rank lists rather than the raw scores, so a document near the top of either list is promoted, and one buried in both is suppressed.

This matters most in production RAG systems, where retrieval quality directly gates generation quality. If your retriever misses the relevant chunk, no amount of prompt engineering rescues the answer. Hybrid search with RRF is now a baseline expectation in serious RAG deployments, and Elasticsearch's retriever API makes it available with zero additional configuration. This guide walks through a complete, runnable implementation — from package installation to a live hybrid query — so you can drop the pattern straight into your own index. You need an Elastic Cloud trial cluster, a Python environment, and roughly fifteen minutes. There are no GPU requirements; the embedding model runs on CPU.

This guide is adapted from Elasticsearch Labs' 02-hybrid-search.ipynb, published under the Apache-2.0 licence. Code blocks are reproduced exactly from that source.


Prerequisites

Requirement Minimum version / details Notes
Elasticsearch cluster 8.x (Elastic Cloud trial is free) RRF retriever API requires 8.8+
elasticsearch Python client <9 Installed in Step 1
sentence-transformers 2.7.0 Pin the version; 3.x changes the API
Python 3.8+ Standard library getpass is used for secrets
book_index dataset Pre-populated from the quickstart notebook Must exist before Step 4; see note below

The book_index dataset — including its title_vector dense field — is created in the Elasticsearch Labs quickstart notebook (00-quick-start.ipynb). If you have not run that notebook yet, do so first. The hybrid query in Step 4 will fail with an index-not-found error without it.


Step 1: Install packages

The source notebook pins sentence-transformers at 2.7.0 deliberately. Later releases restructure the public API in ways that break the .encode() call used in Step 4, so do not drop the version pin.

!pip install -qU "elasticsearch<9" sentence-transformers==2.7.0

The -q flag suppresses pip output; -U upgrades if an older version is already present. Inside a Colab or Jupyter cell this runs as-is — in a plain terminal, drop the leading !.


Step 2: Initialise the embedding model and connect to Elasticsearch

Two things happen here in sequence. First, SentenceTransformer downloads and caches all-MiniLM-L6-v2, a 22 million-parameter model that produces 384-dimensional embeddings. It is deliberately small — inference is fast on CPU, and the quality is sufficient for many production use cases. Second, the Elasticsearch client is instantiated using your Cloud ID and an API key. Both are read via getpass so they are never echoed to the terminal or persisted in notebook output.

from elasticsearch import Elasticsearch
from sentence_transformers import SentenceTransformer
from getpass import getpass

model = SentenceTransformer("all-MiniLM-L6-v2")
# https://www.elastic.co/search-labs/tutorials/install-elasticsearch/elastic-cloud#finding-your-cloud-id
ELASTIC_CLOUD_ID = getpass("Elastic Cloud ID: ")

# https://www.elastic.co/search-labs/tutorials/install-elasticsearch/elastic-cloud#creating-an-api-key
ELASTIC_API_KEY = getpass("Elastic Api Key: ")

# Create the client instance
client = Elasticsearch(
    # For local development
    # hosts=["http://localhost:9200"]
    cloud_id=ELASTIC_CLOUD_ID,
    api_key=ELASTIC_API_KEY,
)

If you are running a self-managed cluster instead of Elastic Cloud, uncomment the hosts line and remove the cloud_id argument. The rest of the guide is identical either way.


Step 3: Enable telemetry and verify the connection

The telemetry step is optional but recommended if you are following the Elasticsearch Labs tutorial path — it helps the maintainers understand which notebooks are actively used.

!curl -O -s https://raw.githubusercontent.com/elastic/elasticsearch-labs/main/telemetry/telemetry.py
from telemetry import enable_telemetry

client = enable_telemetry(client, "02-hybrid-search")

After enabling telemetry, confirm the client is connected before proceeding. A failed connection surfaces as an exception here rather than a confusing error inside the search call.

print(client.info())

A successful response prints a JSON blob containing your cluster name, version, and tagline. If you see a ConnectionError, double-check that your Cloud ID is the full string from the Elastic Cloud console — it is long and easy to truncate accidentally.


Step 4: Define a response formatter and run the hybrid query

A small helper function makes the raw Elasticsearch response readable. It iterates the hits in ranked order and prints the fields that matter for evaluating retrieval quality: title, summary, rank position, and score.

def pretty_response(response):
    if len(response["hits"]["hits"]) == 0:
        print("Your search returned no results.")
    else:
        for idx, hit in enumerate(response["hits"]["hits"], start=1):
            id = hit["_id"]
            publication_date = hit["_source"]["publish_date"]
            score = hit["_score"]
            title = hit["_source"]["title"]
            summary = hit["_source"]["summary"]
            pretty_output = f"\nID: {id}\nPublication date: {publication_date}\nTitle: {title}\nSummary: {summary}\nRank: {idx}\nScore: {score}"
            print(pretty_output)

Now the centrepiece: a single query that simultaneously fires a BM25 match query against the summary field and a kNN approximate-nearest-neighbour search against title_vector, then fuses their rank lists with RRF.

response = client.search(
    index="book_index",
    size=5,
    retriever={
        "rrf": {
            "retrievers": [
                {"standard": {"query": {"match": {"summary": "python programming"}}}},
                {
                    "knn": {
                        "field": "title_vector",
                        "query_vector": model.encode("python programming").tolist(),
                        "k": 5,
                        "num_candidates": 10,
                    }
                },
            ]
        }
    },
)
pretty_response(response)

Walk through the structure from the inside out. The standard retriever wraps a classic BM25 match query — it scores documents by term frequency and inverse document frequency against the literal tokens in "python programming". The knn retriever encodes that same string into a 384-dimensional vector via model.encode(...), then retrieves the five nearest neighbours from the pre-indexed title_vector field using HNSW approximate search. num_candidates controls how many candidate vectors HNSW considers before returning k results; raising it improves recall at the cost of latency. The outer rrf retriever receives both rank lists and applies the RRF formula — each document's fused score is the sum of 1 / (rank + k) contributions across lists, where the rank constant k defaults to 60 in Elasticsearch. The result is a single merged ranking where a document strong in either modality is rewarded, and one weak in both is buried.

The _score values printed by pretty_response are RRF scores, not BM25 or cosine similarities — they are not comparable across different queries or indices, but they are correctly ordinal within a single result set.


What to watch out for

The book_index must have a title_vector mapping before indexing. Dense vector fields require an explicit dense_vector mapping with a matching dims value — 384 for all-MiniLM-L6-v2. If the field was indexed without that mapping, the kNN retriever throws a mapping exception. There is no way to add a dense vector mapping retroactively without reindexing.

num_candidates is a latency-recall dial, not a free lunch. Setting it below k silently degrades vector recall without raising any error. A value of num_candidates = 10 * k is a reasonable starting point for most datasets; on very large shards you may need to go higher. Monitor kNN recall as data volume grows.

RRF score values are not human-interpretable in isolation. Thresholding on _score to filter low-confidence results does not work with RRF — the numerical range depends on how many retrievers contributed and what ranks they assigned, not on any intrinsic relevance signal. Use rank position (idx in the helper function) as your confidence proxy instead.

Model version drift breaks the vector index. Vector similarity is computed in the embedding space of whichever model produced the index-time vectors. Swapping to a newer checkpoint or a different model produces query vectors in a different space, and results silently degrade or become nonsensical. Treat the model as a fixed dependency of the index schema and version both together.

Dense vector fields are storage-heavy. A 384-dimension float32 vector costs 1.5 KB per document before HNSW graph overhead. Plan your Elastic Cloud tier accordingly before moving to production.

Hybrid search does not fix bad chunking. RRF can only rank what the retrievers surface. If documents were chunked in a way that splits relevant context across boundaries, neither BM25 nor kNN retrieves the right passage, and fusing two wrong rank lists still gives the wrong answer. Retrieval strategy and chunking strategy are tightly coupled — see our guide on row-level chunking for structured data retrieval for a concrete example of how chunking decisions reshape retrieval quality.


Where to go next

The natural next step is tuning the RRF rank_constant parameter and measuring its effect on your dataset using an offline evaluation set — Elasticsearch's Rank Evaluation API is the right tool for this. Beyond parameter tuning, consider whether your use case benefits from query-time field boosting inside the standard retriever, which lets you weight exact-match signals more heavily for keyword-skewed queries. If latency is a constraint, profile num_candidates against a representative query log to find the recall-latency frontier for your data. The retriever composition pattern shown here also generalises: you can nest more than two retrievers under rrf, so adding a sparse vector retriever using ELSER follows exactly the same structure as what you have already built.

Related Guides