Hybrid search scoring, explained: Elasticsearch 9.5 vs OpenSearch 3.9
We ran the same hybrid queries on Elasticsearch 9.5.5 and OpenSearch 3.9.0 with identical data and vectors. Raw scores differ by exactly 2.2x, fused scores match to four decimals, and your page size can change the ranking.
Hybrid search combines keyword matching (BM25) with vector similarity, and on both the Elastic and OpenSearch forums the same questions come back again and again. Why do irrelevant documents get high hybrid scores? Why does BM25 seem to drown out the vectors? How can a "normalized" score be greater than 1?
Rather than answer from documentation, we ran the experiments. We indexed identical documents and identical vectors into Elasticsearch 9.5.5 and OpenSearch 3.9.0, ran every common way of combining scores, and read the explain output. Every number in this post comes from those runs, and the setup at the end lets you reproduce them.
What we found:
- Raw BM25 scores differ by exactly 2.2× between the engines on identical data, with identical rankings.
- Adding BM25 and vector scores together lets BM25 dominate. That is the cause of most "hybrid made it worse" reports.
- Once you use proper fusion, the engines agree to four decimal places. Their hybrid math is the same; the APIs and licensing are not.
- Your page size can change your ranking if the normalization window follows it.
- Hybrid scores are not relevance scores. An off-topic query still gets confident-looking results.
- On Elasticsearch's free Basic license, the fusion features are not available. On OpenSearch they are.
The setup
Both engines run the same Lucene version (10.5.1), so BM25 is computed by the same code. Both indices use plain HNSW with cosine similarity and no quantization, so vector search is like-for-like. Vectors come from the open-source all-MiniLM-L6-v2 model (384 dimensions), computed once and indexed into both engines.
The corpus is 40 short help-centre articles for a fictional cloud product, written for this example, so treat what follows as a worked demonstration of how scoring behaves, not a benchmark. We used four queries:
| Query | Type | Articles that answer it |
|---|---|---|
ERR_429 | Exact error code | "ERR_429: Too many requests" and "API rate limits by plan" |
why is the API throttling my requests | Paraphrase | The ERR_429 article, rate limits, retry advice, and an article on traffic spikes that never uses the word "throttling" |
how do I get my money back | Paraphrase | "Request a refund" and "Getting your money back after cancelling" |
best pizza dough recipe | Off-topic | None |
1. Raw scores live on different scales
For the throttling query, the top raw score from each retriever was:
xychart-beta
title "Top raw score for the throttling query"
x-axis ["ES BM25", "OpenSearch BM25", "Vector (both)"]
y-axis "Score" 0 --> 6
bar [5.5014, 2.5006, 0.8053]Two things stand out. BM25 scores are unbounded and depend on the query and the corpus, while the vector score here is a bounded similarity: both engines map cosine similarity to (1 + cosine) / 2, so it stays between 0 and 1. And the two engines' BM25 scores differ by exactly 2.2×. On the ERR_429 query, Elasticsearch scored the top document 3.1547 and OpenSearch 1.4339.
The explain output shows why. Both engines compute the same IDF (3.3081) and the same term-frequency component (0.4335). Elasticsearch then multiplies by one more factor:
Elasticsearch 9.5.5
3.1547 score(freq=1.0), computed as boost * idf * tf from:
2.2 boost
3.3081 idf
0.4335 tf
OpenSearch 3.9.0
1.4339 score(freq=1.0), computed as boost * idf * tf from:
3.3081 idf
0.4335 tf
2.2 is k1 + 1 with the default k1 = 1.2. The extra factor scales every BM25 score equally, so rankings are identical, but any absolute score threshold does not transfer between the engines. If you migrate between them, re-derive every threshold.
2. Adding scores lets BM25 swamp the vectors
The simplest way to "combine" the two is to send both in one request and let the engine add the scores: a bool query with both clauses on OpenSearch, or query plus knn in the same Elasticsearch request. Here is the throttling query on Elasticsearch:
| Rank | Vector search alone | Summed (BM25 + vector) |
|---|---|---|
| 1 | ERR_429: Too many requests (0.8053) | ERR_429: Too many requests (5.9229) |
| 2 | Why requests slow down during traffic spikes (0.8045) | Use the REST API (5.5014) |
| 3 | API rate limits by plan (0.7378) | ERR_403: Permission denied (5.4308) |
| 4 | Best practices for retries and backoff (0.6804) | Why requests slow down during traffic spikes (5.1907) |
| 5 | ERR_500: Internal error (0.6625) | ERR_401: Invalid API key (4.5891) |
Vector search alone found all four useful articles. The sum found two, and pushed "Use the REST API" to second place because it matches the words API and requests many times. A vector score that tops out around 0.8 cannot compete with BM25 scores around 5.
This is exactly the situation in the Elastic forum thread Hybrid Search high score on irrelevant documents, where the explanation given was that "BM25 (lexical) scores tend to be much higher than knn ones, thus dominating the final score."
3. Proper fusion fixes it, and the engines agree exactly
There are two standard ways to combine retrievers fairly:
- Reciprocal rank fusion (RRF) ignores the raw scores and uses only each document's rank in each list:
score = Σ 1 / (rank_constant + rank). - Linear combination with normalization rescales each retriever's scores to 0–1 (min-max) and then takes a weighted average.
flowchart LR
Q[Query] --> L[BM25 retriever]
Q --> V[Vector retriever]
L --> WL["Top N results<br/>(the window)"]
V --> WV["Top N results<br/>(the window)"]
WL --> F{Fusion}
WV --> F
F -- RRF --> R1["Score by rank<br/>1 / (60 + rank)"]
F -- Linear --> R2["Min-max to 0-1,<br/>then weighted average"]
R1 --> P[Requested page]
R2 --> PWe ran both on both engines with the same settings (rank constant 60; equal weights; window of 10). The results matched to four decimals. For the throttling query:
| Rank | RRF (ES and OpenSearch) | Min-max, equal weights (ES and OpenSearch) |
|---|---|---|
| 1 | ERR_429: Too many requests (0.0325) | ERR_429: Too many requests (0.9361) |
| 2 | Why requests slow down during traffic spikes (0.0318) | Why requests slow down during traffic spikes (0.8123) |
| 3 | ERR_403: Permission denied (0.0310) | ERR_403: Permission denied (0.5037) |
| 4 | Best practices for retries and backoff (0.0308) | Use the REST API (0.5000) |
| 5 | ERR_401: Invalid API key (0.0303) | ERR_401: Invalid API key (0.3558) |
The 2.2× BM25 difference disappears because RRF never looks at raw scores, and min-max normalization divides it out. Elasticsearch's explain output shows the RRF arithmetic for the top document:
0.0325 rrf score computed for initial ranks [2, 1] with rankConstant: [60]
rrf score: [0.016129032], for rank [2] in query at index [0] computed as [1 / (2 + 60)]
rrf score: [0.016393442], for rank [1] in query at index [1] computed as [1 / (1 + 60)]
The ERR_429 article was second for BM25 and first for vectors, so it scores 1/62 + 1/61 = 0.0325. That also answers another recurring forum question, "where are my RRF scores?": they are rank sums, so they are always small, and explain shows each contribution.
4. Your page size can change your ranking
Min-max normalization rescales each retriever using the best and worst scores in its window. If the window is the page you asked for, a different page size gives a different min and max, and different results.
On OpenSearch, the hybrid query's window follows size unless you set pagination_depth:
| Request | Results for the throttling query |
|---|---|
size: 2 | ERR_429 (0.5005), Use the REST API (0.5000) |
size: 5 | ERR_429 (0.8761), traffic spikes (0.6371), Use the REST API (0.5000), ERR_403 (0.2705) |
size: 10 | ERR_429 (0.9361), traffic spikes (0.8123), ERR_403 (0.5037), Use the REST API (0.5000) |
size: 2, pagination_depth: 10 | ERR_429 (0.9361), traffic spikes (0.8123) |
With size: 2, the irrelevant "Use the REST API" article took second place, because within a window of two the ERR_429 article was the lowest BM25 score and normalized to almost zero. The explain output shows it: its BM25 contribution was 0.001 min_max normalization of 2.3262. Setting pagination_depth fixes the window and the ranking stays stable.
Elasticsearch's linear retriever behaves the same way through rank_window_size. Setting it to 2 gave ERR_429 (0.5), Use the REST API (0.5); setting it to 10, or leaving it unset on our node, gave the stable ERR_429 (0.9361), traffic spikes (0.8123).
Set the window explicitly, independent of page size. A page of 10 should still be fused from the top 50 or 100 of each retriever.
5. "Normalized" scores above 1: the missing pipeline
The OpenSearch forum thread Hybrid Search Score Normalisation asks: "How even is this possible if score normalisation is done, and still max score is greater than 1.0?"
In OpenSearch, normalization is not part of the hybrid query itself. It happens in a search pipeline. Send a hybrid query without the pipeline and you get raw scores, plus internal marker entries, which are not real results:
| Request | max_score | First hits returned |
|---|---|---|
hybrid query, no pipeline | 2.5006 | Use the REST API (−9549512000), Use the REST API (−4422440400), Use the REST API (2.5006), ERR_429 (2.3262) |
| Same query with a min-max pipeline | 0.8279 | ERR_429 (0.8279), Use the REST API (0.5000), traffic spikes (0.4971), API rate limits by plan (0.2299) |
Set the pipeline as the index default with index.search.default_pipeline so no client can forget it.
6. Hybrid scores are not relevance scores
The off-topic pizza query still returned eleven results on both engines. BM25 matched one article on the single word best ("Best practices for retries and backoff"), and vector search always returns its nearest neighbours, however distant. After fusion, the top pizza result scored 0.5 with min-max and 0.0164 with RRF: numbers that look no different from a real match.
Fused scores only rank documents within one query. They cannot tell you whether anything relevant was found. The raw vector similarity is more useful as a signal: for the pizza query the best match scored 0.5435, while the three real queries' best matches scored 0.7131, 0.8053 and 0.7773. A similarity floor, calibrated on your own queries and model, or a reranker score, is the right tool for "no good answer".
7. Hybrid is not automatically better than its best part
Counting how many of the useful articles reached the top five:
| Method | ERR_429 | Throttling paraphrase | Refund paraphrase |
|---|---|---|---|
| BM25 only | 1 of 2 | 2 of 4 | 1 of 2 |
| Vector only | 1 of 2, but ERR_500 ranked first | 4 of 4 | 2 of 2 |
| RRF | 1 of 2, ERR_429 first | 3 of 4 | 2 of 2 |
| Min-max, equal weights | 1 of 2, ERR_429 first | 2 of 4 | 2 of 2 |
Vector search alone ranked the wrong error code first for ERR_429, which is the classic weakness of embeddings on identifiers, and every hybrid method fixed that. But on the throttling paraphrase, vectors alone beat every hybrid. Fusion is a compromise between its inputs, so the weights and method should be tuned against labeled queries of each type you serve, not left at defaults.
On Elasticsearch, check your license first
On our Elasticsearch 9.5.5 node with the default Basic license, every fusion method in this post failed:
| Feature | Response on Basic |
|---|---|
| RRF retriever | 403: current license is non-compliant for [Reciprocal Rank Fusion (RRF)] |
| Linear retriever | 403: current license is non-compliant for [linear retriever] |
ES|QL FUSE | current license is non-compliant for [FUSE] |
They all worked after we started Elasticsearch's free 30-day trial. On a Basic-licensed cluster you are left with score addition, which is the approach section 2 shows going wrong. OpenSearch includes RRF, normalization and the explain processor in its standard distribution.
Cheat sheet
| Elasticsearch 9.5 | OpenSearch 3.9 | |
|---|---|---|
| RRF | retriever.rrf or ES|QL FUSE (needs a paid license or trial) | hybrid query + score-ranker-processor pipeline |
| Weighted, normalized | retriever.linear with normalizer: minmax (paid or trial) | hybrid query + normalization-processor pipeline |
| Fusion window | rank_window_size | pagination_depth |
| See the arithmetic | explain: true | hybrid_score_explanation response processor + explain=true |
| Raw BM25 scale | Includes a k1 + 1 (2.2) boost | Without it |
Recommendations
- Never add raw BM25 and vector scores. Use RRF or normalized linear fusion.
- Start with RRF. It needs no tuning and is immune to score scales. Move to weighted min-max only when you have labeled queries to tune the weights against.
- Fix the fusion window (
rank_window_sizeorpagination_depth) at 50 to 100, independent of page size. - On OpenSearch, make the normalization pipeline the index default.
- Don't use fused scores as confidence. Use a calibrated similarity floor or a reranker to decide when nothing relevant was found.
- Evaluate by query type. Identifiers, paraphrases and off-topic queries behave differently; measure each before choosing a method.
- On Elasticsearch, confirm your license tier supports the fusion you plan to use.
Reproduce it
Everything ran locally on Docker at no cost:
docker run -d --name es -p 127.0.0.1:9201:9200 -e discovery.type=single-node \
-e xpack.security.enabled=false docker.elastic.co/elasticsearch/elasticsearch:9.5.5
docker run -d --name os -p 127.0.0.1:9202:9200 -e discovery.type=single-node \
-e DISABLE_SECURITY_PLUGIN=true -e DISABLE_INSTALL_DEMO_CONFIG=true \
opensearchproject/opensearch:3.9.0
The key requests, using the same BM25 query (multi_match on title and body) and vector query (k: 10) throughout:
// Elasticsearch: RRF retriever
POST help/_search
{
"size": 10,
"retriever": {
"rrf": {
"retrievers": [
{ "standard": { "query": { "multi_match": { "query": "why is the API throttling my requests", "fields": ["title", "body"] } } } },
{ "knn": { "field": "vector", "query_vector": [/* 384 floats */], "k": 10, "num_candidates": 40 } }
],
"rank_window_size": 10,
"rank_constant": 60
}
}
}
// OpenSearch: min-max pipeline, then a hybrid query with a fixed window
PUT _search/pipeline/minmax-mean
{
"phase_results_processors": [
{ "normalization-processor": {
"normalization": { "technique": "min_max" },
"combination": { "technique": "arithmetic_mean", "parameters": { "weights": [0.5, 0.5] } }
} }
]
}
POST help/_search?search_pipeline=minmax-mean
{
"size": 10,
"query": {
"hybrid": {
"pagination_depth": 10,
"queries": [
{ "multi_match": { "query": "why is the API throttling my requests", "fields": ["title", "body"] } },
{ "knn": { "vector": { "vector": [/* 384 floats */], "k": 10 } } }
]
}
}
}
Both indices used dense_vector / knn_vector fields with 384 dimensions, cosine similarity and HNSW (m: 16, ef_construction: 100), with quantization off. The full corpus, scripts and results are available on request.
Sources
- Hybrid Search high score on irrelevant documents, Elastic Discuss forum.
- Hybrid Search Score Normalisation, OpenSearch forum, 16 March 2024.
- All scores, explain output and license responses: our runs on Elasticsearch 9.5.5 and OpenSearch 3.9.0 (both on Lucene 10.5.1), 15 October 2026.
Senior Elasticsearch Engineer with 8+ years of experience in search optimization and distributed systems.
