RECIPE · 06

Hybrid search

The problem

You run a help-desk search box. A user types "reset password". Two things are true at once:

Keyword search (BM25) is precise on exact terms and useless on synonyms. Vector search (kNN) captures meaning but drops exact-term matches whose embeddings drift. The usual "fix" is to run two systems — a lexical engine and a vector DB — then stitch and re-rank the results in your application. That's two deployments, two writes on every ingest, and a fusion step you have to build and maintain yourself.

Why XERJ

XERJ indexes the text field and the dense_vector field in the same index, and fuses BM25 and kNN in a single hybrid query. One request goes out, one ranked list comes back — already fused with Reciprocal Rank Fusion (RRF). No second datastore, no client-side re-ranking.

The solution

1. One index, two modalities

A text field for BM25 and a dense_vector field for kNN:

PUT /helpdesk
{
  "mappings": {
    "properties": {
      "title": { "type": "text" },
      "vec":   { "type": "dense_vector", "dims": 4, "similarity": "cosine" }
    }
  }
}

The vectors in this recipe are hand-authored 4-dim topic vectors so the demo is deterministic and you can see exactly why each result ranks where it does. In production you fill vec with a real embedding model — or skip the vectors entirely and use a semantic_text field, which auto-embeds on ingest. (XERJ's built-in embedder is lexical, good for demos and keyword-semantic overlap; point inference_id at an external /v1/embeddings endpoint for neural-quality vectors.)

2. The corpus

Each doc has a title (for BM25) and a topic vector (for kNN). Topic axes are [auth_recovery, security_policy, cooking, generic]:

idtitlevectorkeyword "password"?near query vector?
d1Reset your password[1.00, 0.15, 0, 0.10]yesyes
d2Regain entry to a locked-out account[0.95, 0.10, 0, 0.15]noyes
d3Password complexity and rotation policy[0.15, 1.00, 0, 0.10]yesno
d4How to bake sourdough bread[0.00, 0.00, 1.0, 0.10]nono
d5Change your account password[0.85, 0.25, 0, 0.20]yesyes

The user's intent in both modalities: text "reset password", vector [1.0, 0.20, 0.0, 0.10] (an "auth recovery" direction).

3. Watch each modality fail on its own

Pure BM25{"match": {"title": "reset password"}}:

d1  score=0.5754  Reset your password
d3  score=0.2877  Password complexity and rotation policy
d5  score=0.2877  Change your account password

It misses d2 entirely — "Regain entry to a locked-out account" has no shared keywords, so it can never be a BM25 hit.

Pure kNN{"knn": {"field": "vec", "query_vector": [...], "k": 3}}, showing the 3 nearest:

d1  score=0.9994  Reset your password
d2  score=0.9971  Regain entry to a locked-out account
d5  score=0.9942  Change your account password

It surfaces d2 (great!) but misses d3 — the password-policy article's topic vector points at "security_policy," far from the query, so it falls below the top-3 cutoff.

4. Hybrid recovers both — one query

POST /helpdesk/_search
{
  "size": 5,
  "query": {
    "hybrid": {
      "queries": [
        { "query": { "match": { "title": "reset password" } }, "weight": 1.0 },
        { "query": { "knn": { "field": "vec",
                              "query_vector": [1.0, 0.2, 0.0, 0.1],
                              "k": 3 } }, "weight": 1.0 }
      ],
      "fusion": "rrf"
    }
  }
}

Real response (trimmed to title, printed verbatim by the script):

{
  "hits": {
    "total": { "value": 4, "relation": "eq" },
    "max_score": 0.03279,
    "hits": [
      { "_id": "d1", "_score": 0.03279, "_source": { "title": "Reset your password" } },
      { "_id": "d5", "_score": 0.03175, "_source": { "title": "Change your account password" } },
      { "_id": "d3", "_score": 0.01613, "_source": { "title": "Password complexity and rotation policy" } },
      { "_id": "d2", "_score": 0.01613, "_source": { "title": "Regain entry to a locked-out account" } }
    ]
  }
}

d2 (only kNN found it) and d3 (only BM25 found it) are now both on the page, and d1 — the doc that matched on both keyword and vector — is still ranked #1. Watch d5: neither BM25 nor kNN ranks it better than #3, yet because both signals agree on it, fusion promotes it to #2 — ahead of d3 and d2, each of which is strong in only one modality. That's the whole point: fusion rewards agreement between the two signals while still admitting the strong single-signal hits that either method alone would have dropped.

Only four docs come back, not five: d4 ("How to bake sourdough bread") is in neither sub-query's result set — it shares no keywords and its topic vector falls outside the kNN k:3 cutoff — so RRF never sees it. Fusion ranks the union of the sub-query hits, nothing more.

How the fusion works

"fusion": "rrf" uses Reciprocal Rank Fusion: each sub-query contributes weight / (k + rank) for a doc (default k = 60, rank is 1-based), summed across sub-queries. It ranks by position, not raw score, so BM25's ~0.5 scores and kNN's ~0.99 cosines never have to be normalized onto a common scale — a persistent headache when you fuse two systems yourself. The scores above fall straight out of this formula:

Other fusion options:

Per-query weight lets you bias the blend, e.g. 1.5 on the match sub-query to favor exact terms.

Reproduce it yourself

No dependencies beyond the Python 3 standard library — no pip install, no API keys, no external service. Point it at a running XERJ:

# 1. start a throwaway XERJ (insecure, local data dir) on the default port 9200
xerj --insecure --data-dir /tmp/xerj-hybrid \
     --config <(printf '[server]\nes_compat_port = 9200\n')

# 2. in another shell, run the example (defaults to http://localhost:9200)
python3 docs/examples/hybrid-search/hybrid_search.py

The script reads its target from XERJ_URL (default http://localhost:9200); the legacy BASE alias still works. To hit a server on another port:

XERJ_URL=http://localhost:9485 python3 docs/examples/hybrid-search/hybrid_search.py

Expected tail (verified live):

--- assertions ---
PASS  BM25 alone MISSES d2 (locked-out account: no shared keywords)
PASS  kNN alone MISSES d3 (password policy: topic vector too far)
PASS  Hybrid surfaces BOTH d2 and d3 in a single query
PASS  d1 (keyword + vector match) still ranks #1 under fusion

BM25 ids  : ['d1', 'd3', 'd5']
kNN  ids  : ['d1', 'd2', 'd5']
Hybrid ids: ['d1', 'd5', 'd2', 'd3']

OK

The four assertions and the scores are deterministic; the BM25 and kNN id lists are stable, and the hybrid list always starts ['d1', 'd5', …]. The only run-to-run wobble is the last two ids — d3 and d2 tie at exactly 0.01613, so they swap freely between positions #3 and #4 (across a dozen runs we saw both d2, d3 and d3, d2). The recipe's point — that d2 and d3 both appear and d1 stays #1 — holds every time.

See the full runnable example — docs/examples/hybrid-search/hybrid_search.py on GitHub.

Notes & limits