Lesson 6 — Hybrid search

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?” — rrf fuses by rank, no score calibration needed.
  • “Boost popular items, demote stale ones” — score_functions.
  • “An expensive second pass, only where it matters” — rescore over 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-retriever weights. 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 #

  • retrieve cannot currently combine with aggs, post_filter, collapse or rescore in the same body — the gateway rejects the mix with a clear 400.
  • score_functions and rescore compose with a plain query — 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.
  • rrf fuses by rank when scores are incomparable; linear/weights when they are calibrated; disjmax when a document should score by its best leg.
  • score_functions re-shapes scores per document (boost, decay, script); rescore spends an expensive query only on the top window.
  • The vector leg has a SQL twin — KNN(field, vector, k) ranked by SCORE() — one pipeline, two front doors.

Next: Lesson 7 — geographic search.

Calendar October 6, 2026
Edit Edit this page