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...