Hybrid search and scoring pipeline #
Pizza runs lexical (BM25) and vector retrieval side by side and gives you explicit, composable stages to combine and re-shape scores:
graph LR
B("bm25 leg") --> F("fuse
rrf / linear / disjmax")
V("vector leg") --> F
F --> SC("score_functions
per-document function scoring")
P["a plain query,
no retrieve"] --> SC
SC --> RS("rescore
second pass on the top window")
RS --> H["one ranked list"]
Every stage is optional and combines with a plain query: use
score_functions alone on a keyword search, or the full chain for
hybrid retrieval.
POST /<collection>/_search
Stage 1: retrieve — parallel retrievers
#
Replace the single query with a list of retrievers whose results are
fused. BM25 and vector legs side by side over the embeddings dataset:
{
"retrieve": [
{ "bm25": { "query": { "match": { "field": "text", "query": "vector search" } } } },
{ "vector": { "field": "embedding", "query_vector": [0.98, -0.05, 0.1, 0, -0.1, 0.05, -0.05, 0.1], "k": 5 } }
],
"fuse": { "method": "rrf", "k": 60 },
"size": 5
}
The fused answer includes every document found by EITHER leg (the match alone finds 24; the vector leg re-orders the top by similarity).
When both a bm25 and a vector retriever are present together with a
pre_filter, the engine’s strategy optimizer picks the execution plan
from the filter’s selectivity instead of always running the legs
blindly: no filter (or an unselective one) runs the legs in parallel
and fuses; a filter under ~5% pre-filters and runs the vector leg only
on survivors; mid-selectivity filters on an HNSW field lead the text leg
and re-rank; a filter matching over ~80% leads with the vector leg.
You do not choose this — the planner estimates and decides
(lib/engine/src/search/mod.rs, hybrid strategy table).
Retriever types #
bm25
(object) Carries aqueryin the normal query DSL — any leaf or boolean query.vector
(object) Carriesfield,query_vector(orquerytext /query_sparse),k,num_candidates— same options as thevectorquery.
Stage 2: fuse — combine the result lists
#
method
(Required,rrf|linear|disjmax) How lists are merged.rrf(reciprocal rank fusion) needs no comparable scores — it ranks by positions;lineartakes a weighted sum of normalized scores;disjmaxkeeps the max score per document.k
(Optional, integer, default:60) RRF rank constant — larger dampens the influence of top ranks.weights
(Optional, array of floats) Per-retriever weights forlinear.
The three methods are exact, not approximate
(lib/engine/src/search/pipeline/mod.rs, built-in Fuser set):
rrf—score(d) = Σ 1 / (k + rank_r(d))over every retrieverr, withrankcounted from 1. Purely rank-based: BM25 and cosine scores never meet, only their positions do. A document found high on both legs outranks a document found first on one leg only.linear— each leg’s scores are min-max normalized to[0, 1]independently, then summed per document asΣ weight_r × score_r(d). A document absent from a leg contributes nothing from that leg.disjmax—score(d) = max over legs of score_r(d), no normalization; ties keep the single best leg’s score.
For per-document multi-vector fusion inside one query see
multi_vector; the gateway also translates the
Elasticsearch top-level knn and retriever forms into these stages (see
API conventions).
Stage 3: score_functions — function scoring
#
Re-shape the score per document (demote stale docs, boost by
popularity). Composes with a plain query — no retrieve needed:
products matching “apparel”, scored by price with a log1p
modifier and a constant weight:
Compare _score against the plain match (≈1.4 per hit): the price
factor lifts expensive products; weight multiplies through.
Function types #
functions
(Required, array) One or more of:field_value_factor—{field, factor, modifier (none|log1p|log2p|sqrt|reciprocal|ln|square), missing}decay—{origin, scale, decay, offset, function (linear|exp|gauss)}— score falls off with distance fromorigin(date or numeric distance)script—{source, params}weight— constant multiplier
score_mode
(Optional,multiply|sum|avg|first|max|min, default:multiply) How the functions’ values combine with each other.boost_mode
(Optional,multiply|replace|sum|avg|max|min, default:multiply) How the combined function value combines with the query score.
A script_score function form is available too — it nests the
source under "script", and the script sees doc, _score and params
(see
scripted queries):
Stage 4: rescore — second pass on the top window
#
Re-score only the top candidates with an expensive query (typically a vector or phrase pass over the top-K). Text match first, vector re-score over the top 10:
{
"query": { "match": { "field": "text", "query": "vector search" } },
"rescore": {
"window": 10,
"query": { "vector": { "field": "embedding", "query_vector": [0.98, -0.05, 0.1, 0, -0.1, 0.05, -0.05, 0.1], "k": 10 } },
"query_weight": 0.4,
"rescore_query_weight": 0.6
},
"size": 5
}
window
(Optional, integer, default:10) Number of top documents re-scored.query
(Required, object) The rescoring query.query_weight/rescore_query_weight
(Optional, float) Blend weights of original and rescored scores.
What composes with what #
retrievecannot currently combine withaggs,post_filter,collapseorrescorein the same body — the gateway rejects the mix with a clear 400 (verified live).score_functionsandrescorecompose with a plainquery— and with each other — without restriction.rerank(cross-encoder style re-ranking) is recognized but not implemented: requests fail fast with an explicit error naming the missing reranker-model serving layer.
Use cases #
- Lexical + semantic recall. Exact-match reliability (BM25 on
keywords, SKUs, hostnames) plus meaning-match recall (vector on
prose) in one ranked list — the
embeddingsdataset’s search / storage / vector topics fuse cleanly with RRF. - Cheap two-stage relevance. Skip
retrieveentirely: a plainqueryplusscore_functions(freshness decay, popularityfield_value_factor) covers most “boost good documents” needs with one stage. - Precision pass on the top window. Broad text match, then a
vector or phrase
rescoreover the top 10 — expensive similarity computed only where it changes the answer (Stage 4). - Multi-source retrieval without score wars. When the legs'
scores are not comparable (different models, different scales), RRF
sidesteps calibration entirely;
disjmaxwhen a document should rank by its best single source.
Choosing a fusion method #
- Prefer
rrfwhen BM25 and cosine scores must not be compared directly — it works from ranks alone. - Prefer
linear/weightswhen you have calibrated, comparable scores. disjmaxwhen a document should score by its BEST leg (dedup-like behavior for multi-source retrieval).
Related #
vectorquery — the vector leg’s full parameter surface (k,num_candidates, similarity); a standalonevectorquery composes like any leaf for hybrid search without the retriever DSL.multi_vector— per-document multi-vector fusion inside one query, the query-level counterpart of request-levelfuse.- Rescore — Stage 4 in depth: window semantics, blend
weights, and where the re-scored list meets
from/size. - Script fields —
_scriptsorts and script score functions share the same sandboxed script engine anddocbinding. - API conventions — request envelope and wire-form tolerance at the gateway.