Put a BM25 list and a vector list side by side, pick a fusion method, and see the fused ranking the way Elasticsearch or OpenSearch computes it, including how the fusion window changes the result. The companion to hybrid search scoring, explained.
Example data: a fictional outdoor-gear store. The scores are made up to show the mechanics, not measured; paste your own from explain output.
Rank comes from the score. Documents are matched across lists by name.
Rank comes from the score. Documents are matched across lists by name.
OpenSearch, linear, Min-max normalization, arithmetic mean, weights BM25 0.5 / vector 0.5. Window 10 per list; the first 5 are the page you get back.
Same lists and settings. When the window follows the page size, min and max (or mean and spread) are taken over fewer hits, so the top results can change. A fixed window keeps them stable.
| Request | Top results |
|---|---|
| size 2, window 2 | Storm waterproof running jacket (0.5005), Running jacket size guide (0.5000) |
| size 5, window 5 | Storm waterproof running jacket (0.9177), Running jacket size guide (0.5000), Featherlite packable rain shell (0.4545), Waterproof jacket care and re-proofing (0.3790), Breathable hardshell for wet runs (0.3017) |
| size 10, window 10 | Storm waterproof running jacket (0.9589), Waterproof jacket care and re-proofing (0.7050), Running jacket size guide (0.6970), Featherlite packable rain shell (0.5508), Trailhead insulated jacket (0.5212), Breathable hardshell for wet runs (0.4091), Waterproof trail shoes (0.3141), Running jacket returns policy (0.3132), Reflective running vest (0.3106), Windproof cycling gilet (0.0625) |
| size 2, window 10 | Storm waterproof running jacket (0.9589), Waterproof jacket care and re-proofing (0.7050) |
PUT /_search/pipeline/hybrid-minmax-arithmetic
{
"phase_results_processors": [
{
"normalization-processor": {
"normalization": {
"technique": "min_max"
},
"combination": {
"technique": "arithmetic_mean",
"parameters": {
"weights": [
0.5,
0.5
]
}
}
}
}
]
}
POST /my-index/_search?search_pipeline=hybrid-minmax-arithmetic
{
"size": 5,
"query": {
"hybrid": {
"pagination_depth": 10,
"queries": [
{
"multi_match": {
"query": "waterproof running jacket",
"fields": [
"title",
"body"
]
}
},
{
"knn": {
"vector": {
"vector": "REPLACE_WITH_QUERY_VECTOR",
"k": 10
}
}
}
]
}
}
}Replace my-index, the field names and REPLACE_WITH_QUERY_VECTOR with your own. Set the pipeline as index.search.default_pipeline so no client forgets it. Syntax from normalization processor and hybrid query docs.
We tune hybrid relevance against labeled queries: see Elasticsearch consulting and OpenSearch consulting.
Windows and ranks. Each list is sorted by raw score and cut to its top N hits (the window: Elasticsearch rank_window_size, OpenSearch pagination_depth, which defaults to size). Only hits inside a window take part in fusion. We assume one shard; OpenSearch applies pagination_depth per shard (hybrid query docs).
Reciprocal rank fusion (both engines). score(d) = Σ over lists containing d of 1 / (k + rank), rank starting at 1, k = rank_constant (default 60). A document missing from a list adds nothing for it (Elastic RRF; OpenSearch score ranker). Raw scores only decide the order within each list.
Min-max. n = (score − min) / (max − min) over the window. If all scores in the window are equal (or there is one hit), every hit gets 1. OpenSearch replaces a normalized 0 with 0.001; Elasticsearch keeps 0 (OpenSearch normalization processor; Elastic linear retriever, source).
L2. n = score / √(Σ score²) over the window (Elastic l2_norm, OpenSearch l2).
Z-score (OpenSearch only). n = (score − mean) / sd with the sample standard deviation; results ≤ 0 become 0.001. Only combinable with the arithmetic mean (docs).
Combination. Elasticsearch linear: score = Σ weight × n (a document missing from a list contributes 0). OpenSearch, with weights that must sum to 1: arithmetic mean = Σ w·n / Σ w, where a missing document contributes 0 but its weight still counts; geometric mean = exp(Σ w·ln n / Σ w) and harmonic mean = Σ w / Σ (w / n), both skipping lists where the document is missing, so a document found by only one retriever is not penalized (docs; weighted formulas from the neural-search source). With weights summing to 1, Elasticsearch's weighted sum equals OpenSearch's arithmetic mean apart from the 0.001 floor.
Bars and movement. For RRF and sums, each bar segment is that list's exact contribution. Geometric and harmonic means are not additive, so the bar splits the score in proportion to w·n. Arrows compare the fused rank with the document's rank in the full input list. Ties are broken arbitrarily here; engines break them by internal doc id.
Limits. Not modelled: OpenSearch lower_bounds/upper_bounds, per-retriever normalizers that differ, weighted RRF, more than two retrievers, and multi-shard effects. Fused scores rank documents within one query; they are not relevance or confidence scores. Which method works best depends on your queries: measure against labeled queries of each type you serve.
It ignores raw scores and adds 1 / (k + rank) for every list the document appears in, with k = 60 by default on both Elasticsearch and OpenSearch. A document ranked 2nd by BM25 and 1st by vectors scores 1/62 + 1/61 = 0.0325.
Because fusion only sees each retriever's top N hits, and min-max takes its min and max from that window. If the window follows the page size (OpenSearch without pagination_depth, or a small rank_window_size), a different size gives different results. Fix the window independently of size.
They live on different scales: BM25 is unbounded while vector similarity is usually between 0 and 1, so BM25 dominates the sum. RRF uses ranks instead, and linear fusion normalizes each list before weighting.
Elasticsearch's linear retriever offers minmax and l2_norm normalizers with a weighted sum. OpenSearch's normalization processor offers min_max, l2 and z_score, combined by arithmetic, geometric or harmonic mean; z_score works only with the arithmetic mean.
Not in general. Fusion is a compromise between its inputs, so the right method and weights depend on your queries. Measure each option against labeled queries of every type you serve, such as identifiers and paraphrases.
We tune BM25, vectors and fusion against your own labeled queries on Elasticsearch or OpenSearch, and show you the before and after.