Lesson 6 — Hybrid search #
Most engines hide their fusion behind a magic flag. Pizza exposes the
retrieval pipeline as explicit request stages —
retrieve runs retrievers in
parallel, fuse merges them, and two more stages re-shape scores
afterwards:
retrieve → fuse → score_functions → rescore
(parallel (merge (function (second pass
retrievers) the lists) scoring) on the top window)
Every stage is optional and combines with a plain query — you can
use score_functions alone on a keyword search, or the full chain for
hybrid retrieval.
The dataset for this lesson is the playground’s embeddings
collection: 24 documents in three well-separated 8-dim clusters
(search, storage, vector) — small enough to stay deterministic,
real enough to feel like embeddings.
The use case #
Users ask in words; documents answer in different words. “How do I store vectors?” is asked by a human and answered by a page titled dense_vector field options — pure keyword search misses it, and pure vector search misses the exact identifier someone pasted in. Hybrid retrieval runs both legs and merges them; the scoring stages then fix what matching alone cannot. The questions this lesson answers:
- “Docs about vector storage that never say ‘vector storage’” — the vector leg, by similarity.
- “Exact wording AND semantic neighbors, one ranked list” —
retrieve+fuse. - “BM25 and cosine scores are not comparable — which wins?” —
rrffuses by rank, no score calibration needed. - “Boost popular items, demote stale ones” —
score_functions. - “An expensive second pass, only where it matters” —
rescoreover the top window.
The vector leg, alone #
A kNN query with the first cluster’s direction returns exactly that cluster:
Vector search has a direct SQL spelling — KNN(field, vector, k),
ranked by SCORE():
Both return exactly the eight search documents — one pipeline, two
front doors.
The lexical leg, alone #
The same collection answers BM25 queries — a match on the body
text. The words “vector search” appear across all three clusters’
documents, so the lexical leg alone finds all 24:
Two retrievers, two orderings of the same corpus. Neither is the right answer — which is why fusion is a stage you control.
retrieve + fuse: the pipeline itself
#
Replace the single query with a list of retrievers whose results are
fused — the BM25 leg and the vector leg side by side:
graph LR
Q["one request"] --> B("bm25 leg
match on text")
Q --> V("vector leg
kNN on embedding")
B -->|"ranked list"| F("fuse — rrf")
V -->|"ranked list"| F
F --> R["one ranked result list
the union of both legs"]
The fused answer includes every document found by EITHER leg — the
match alone finds 24, and the vector leg re-orders the top by
similarity. A retriever is bm25 (carrying any query-DSL query) or
vector (same options as the
vector query).
fuse.method picks how the lists merge:
rrf(reciprocal rank fusion) — ranks by positions alone, no comparable scores needed. The default choice when BM25 and cosine scores must not be compared directly.k(default 60) is the rank constant; larger dampens the influence of top ranks.linear— weighted sum of normalized scores, with per-retrieverweights. Use it when the scores are calibrated and comparable.disjmax— keeps each document’s BEST leg score, dedup-like behavior for multi-source retrieval.
score_functions: re-shape scores per document
#
After retrieval (or on a plain query, with no retrieve at all),
function scoring re-shapes the score per document — demote stale docs,
boost by popularity. Products matching “apparel”, scored by price
with a log1p modifier and a constant weight:
Compare _score against the plain match: the price factor lifts
expensive products; weight multiplies through. The function
vocabulary is field_value_factor, decay (score falls off with
distance from an origin — date or numeric), script, and weight;
score_mode combines the functions with each other, boost_mode
combines the result with the query score.
rescore: an expensive second pass on the top window
#
When the expensive query only matters for the top candidates, run it as a second pass over a window instead of the whole corpus — 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 bounds how many top documents are re-scored (default 10);
query_weight / rescore_query_weight blend the original and
re-scored scores. This body needs a collection with a dense_vector
field — see
vector search —
run against a live node.
What composes with what #
retrievecannot currently combine withaggs,post_filter,collapseorrescorein the same body — the gateway rejects the mix with a clear400.score_functionsandrescorecompose with a plainquery— and with each other — without restriction.- Of this lesson, the vector half speaks SQL (
KNN+SCORE()); the pipeline stages stay DSL in the SQL M1 subset.
Each stage has its own section in the hybrid reference.
What you learned #
- Hybrid search in Pizza is an explicit retrieve → fuse → score pipeline, not a magic flag — every stage is a request key you can use alone or composed.
rrffuses by rank when scores are incomparable;linear/weightswhen they are calibrated;disjmaxwhen a document should score by its best leg.score_functionsre-shapes scores per document (boost, decay, script);rescorespends an expensive query only on the top window.- The vector leg has a SQL twin —
KNN(field, vector, k)ranked bySCORE()— one pipeline, two front doors.
Next: Lesson 7 — geographic search.