Elasticsearch Hybrid Search Recipes – Benchmarked (2025)

softwaredoug1 pts0 comments

Elasticsearch Hybrid Search Recipes - Benchmarked

-->

Don't fill this out if you're human:

Doug's search blog and newsletter

Subscribe

Learn more

Check your email to confirm your subscription

back home

Elasticsearch Hybrid Search Recipes - Benchmarked

March<br>13th,

2025

Previously I wrote a blog article on Elasticsearch hybrid search. Let’s get actual numbers on different solutions.

It that previous post, I discussed how Elasticsearch’s KNN query can only retrieve some top N for subsequent boosting, etc. So we discussed a strategy of getting KNN candidates filtered to different buckets of lexical candidates. Then build out boosts on top of those candidates to tie break or pull up important documents. In this blog post (code here). I gradually build up this strategy using the WANDS Furniture e-commerce dataset from Wayfair. I embed the name and description with MiniLM and kick the tires to get some relevance stats on each hybrid search strategy.

So let’s go for a ride 🚗. And please, be critical, and try and find ways to improve upon what I’ve done here!

Baseline - naive BM25 crossfields search

Search all the fields in a cross-field search. Cross-field ensures that all the search terms have the same document frequency - a key component of the BM25 algorithm used to score lexical results. So it’s often a first stop shop.

def search_baseline(es: Elasticsearch, query: str):<br>body = {<br>"query": {<br>"multi_match": {<br>"query": query,<br>"fields": ["product_name^10", "product_description", "product_class"],<br>"type": "cross_fields"<br>hits = es.search(index="wands_products", body=body)<br>return hits

Evals - NDCG using WANDS queries + judgments

Running baseline<br>Mean NDCG: 0.6983108389843456<br>Median NDCG: 0.7799082337019198

Naive KNN

Search using Elasticsearch’s KNN query, embedding product name and description, then searching by cosine similarity to that embedding using Elasticsearch’s HNSW search.

def search_knn(es: Elasticsearch, query: str):<br>query_vector = minilm(query)<br>body = {<br>"query": {<br>"knn": {<br>"field": "product_name_description_minilm",<br>"query_vector": query_vector.tolist(),<br>},<br>"_source": ["product_name", "product_description"]<br>hits = es.search(index="wands_products", body=body)<br>return hits

Running knn<br>Mean NDCG: 0.6953041112365016<br>Median NDCG: 0.7723200343340987

Reciprocal Rank Fusion (RRF)

Strategy where we merge results from two systems using 1/rank from each retrieval source. Then sort by the sum of 1/rank for each document.

RRF is a common strategy when introducing vector search to an existing system. I’ve written how RRF is not enough, and based on this change, there really is no incremental gain.

def rrf(es: Elasticsearch, query: str, search_fn1=search_knn, search_fn2=search_baseline):<br>"""Implement reciprocal rank fusion using search_fn1 and search_fn2."""<br>hits1 = search_fn1(es, query)<br>df : list | pd.DataFrame = []<br>for idx, hit in enumerate(hits1['hits']['hits']):<br>df.append({<br>"product_id": hit['_id'],<br>"product_name": hit['_source']['product_name'],<br>"product_description": hit['_source']['product_description'],<br>"score": hit['_score'],<br>"rank": idx + 1,<br>"reciprocal_rank": 1 / (idx + 1)<br>})<br>hits2 = search_fn2(es, query)<br>for idx, hit in enumerate(hits2['hits']['hits']):<br>df.append({<br>"product_id": hit['_id'],<br>"product_name": hit['_source']['product_name'],<br>"product_description": hit['_source']['product_description'],<br>"score": hit['_score'],<br>"rank": idx + 1,<br>"reciprocal_rank": 1 / (idx + 1)<br>})<br>df = pd.DataFrame(df)<br>df = df.groupby('product_id').agg({<br>"product_name": "first",<br>"product_description": "first",<br>"score": "mean",<br>"rank": "mean",<br>"reciprocal_rank": "sum"<br>})<br>df = df.sort_values("reciprocal_rank", ascending=False)<br># Back to hits<br>hits = []<br>for idx, row in df.iterrows():<br>hits.append({<br>"_id": idx,<br>"_score": row['reciprocal_rank'],<br>"_source": {<br>"product_name": row['product_name'],<br>"product_description": row['product_description']<br>})<br>return {"hits": {"hits": hits}}

Running rrf<br>Mean NDCG: 0.7068035290192084<br>Median NDCG: 0.7663491917568945

Naive Hybrid

Remember Elasticsearch’s KNN retrieval can only get some top N set of candidates. Here we select vector candidates with some sort of lexical match, ignoring the others, by filtering to those candidates, but still rank on KNN similarity to minilm.

def search_hybrid(es: Elasticsearch, query: str):<br>query_vector = minilm(query)<br>body = {<br>"query": {<br>"knn": {<br>"field": "product_name_description_minilm",<br>"query_vector": query_vector.tolist(),<br>"filter": {<br>"multi_match": {<br>"query": query,<br>"fields": ["product_name", "product_description", "product_class"],<br>"type": "cross_fields"<br>},<br>"_source": ["product_name", "product_description"]<br>hits = es.search(index="wands_products", body=body)<br>return hits

Running hybrid<br>Mean NDCG: 0.7092796426405182<br>Median NDCG: 0.7799776597284811

Add pure vector fallback

We can begin to look at multiple buckets of candidates. Here there are two candidate sets wrapped by the dis_max query below (which just takes the max score per...

query search hits elasticsearch product_name product_description

Related Articles