Guide

Hybrid search: BM25 and vectors, fused by rank

Vector search finds what a query means; keyword search finds what it says. Agents and support bots need both, because real queries carry error codes, ids and product names next to plain-language questions. Hybrid search runs both rankings and merges them into one list.

Which ranking a query needs

Embeddings compress text into meaning, which is their strength and their blind spot: two error codes one digit apart look almost identical. BM25 scores documents by the query’s words, weighting rare words more, so an exact code or name wins. Neither is right for every query.

Query hasUseWhy
Error codes, ids, SKUs, namesKeywordsEmbeddings blur “E504” and “E503”; BM25 and exact search don’t
Questions in the user’s own wordsVectors“can’t sign in” should find “login fails”
Support, docs and agent memoryHybridQueries mix both: “webhook retries E504”
Must contain a term, nearest firstVector + text filterA filter is exact; nothing without the term comes back

How reciprocal rank fusion works

  • Rank, don’t score. BM25 scores and cosine similarities are on different scales, so adding them needs a weight you’d have to tune. RRF uses only each document’s place in each list.
  • The formula. A document scores 1 / (60 + place) in each list it is in, and the scores add up. Place 1 is worth 1/61.
  • Agreement wins. A document high in both lists beats one that tops only one, but a document only one ranking finds can still make the top.

The alternatives

  • Weighted scores. Normalise both scores and blend with an alpha. Tunable, but the best alpha changes with your data and queries.
  • Rerank. Take the union of both lists and score it with a cross-encoder model. Usually the most accurate and the slowest; it pairs well with RRF as a first stage.
  • Filter, then rank. Require the term, then order by vector distance. Exact, but drops documents that use a synonym.

A worked example you can check by hand

Five support tickets with hand-made four-number vectors, from our vectors guide. The query vector [1, 0, 0, 0] stands for “webhook problems”; the keyword is “retries”. The comments are the answers the API returned when we ran it (October 23, 2026).

vectors only, then hybrid
B=https://api.skimmerdb.com/v1
H=(-H "Authorization: Bearer $KEY" -H "Content-Type: application/json")

curl -fsS "$B/namespaces" "${H[@]}" -d '{"name":"tickets","vector":{"dims":4,"metric":"cosine"}}' >/dev/null
curl -fsS "$B/namespaces/tickets/docs" "${H[@]}" -d '{"wait":true,"docs":[
  {"id":"t1","text":"Webhook timeout after 30 seconds","meta":{"team":"api"},"vector":[0.9,0.1,0,0]},
  {"id":"t2","text":"Webhook retries exhausted with E504","meta":{"team":"api"},"vector":[0.8,0.3,0.1,0]},
  {"id":"t3","text":"Payment webhook timeout on renewal","meta":{"team":"billing"},"vector":[0.95,0,0,0.2]},
  {"id":"t4","text":"Login page slow to load","meta":{"team":"web"},"vector":[0.1,0.9,0.2,0]},
  {"id":"t5","text":"Avatar upload timeout","meta":{"team":"web"},"vector":[0.3,0.3,0.9,0]}]}'

# vectors only: [1,0,0,0] stands for "webhook problems"
curl -fsS "$B/namespaces/tickets/search" "${H[@]}" \
  -d '{"vector":[1,0,0,0],"limit":3}' | jq -c '[.hits[].id]'
# ["t1","t3","t2"]

# hybrid: the same vector, fused with BM25 for "retries"
curl -fsS "$B/namespaces/tickets/search" "${H[@]}" \
  -d '{"vector":[1,0,0,0],"q":"retries","rank":"bm25","limit":3}' \
  | jq -c '[.hits[] | [.id, .vector_rank, .text_rank]]'
# [["t2",3,1],["t1",1,0],["t3",2,0]]

Why t2 comes first

By vector alone, t2 is third. It is the only ticket that says “retries”, so it is first for BM25. Fused, it scores 1/63 + 1/61 and beats t1, which is nearest by vector but absent from the keyword list.

HitTextvector_ranktext_rankFused score
t2Webhook retries exhausted with E504310.0323
t1Webhook timeout after 30 seconds1—0.0164
t3Payment webhook timeout on renewal2—0.0161

The API reports a missing rank as 0. Each list is max(4 × limit, 40) documents deep, under the same filter, and the whole search counts as one query.

From Python

The same call through the Python client, and with a metadata filter, which both rankings respect. semantic text works in place of vector when you let SkimmerDB embed the query.

Python
from skimmerdb import SkimmerDB

tickets = SkimmerDB(KEY).namespace("tickets")
res = tickets.search("retries", vector=[1, 0, 0, 0], rank="bm25", limit=3)
print([h.id for h in res.hits])   # ['t2', 't1', 't3']

# with a filter, both rankings see only the matching documents
res = tickets.search("timeout retry", vector=query_embedding, rank="bm25",
                     filter={"team": "api"}, limit=10)

Hybrid search in Postgres

  • pgvector gives you the vector half. Postgres’s built-in full text ranks with ts_rank, which is not BM25; an extension such as ParadeDB’s pg_search adds BM25.
  • Fusion is yours to write: two subqueries, a rank column each, and an RRF sum in SQL, keeping the same tenant and metadata filters in both halves.
  • That is a good fit if your data already lives in Postgres. See SkimmerDB vs pgvector for when a separate search store helps.

Testing it on your data

  • Collect 30–50 real queries with the documents that should come back, including some with ids or codes.
  • Run all three: vector only, BM25 only, hybrid. Compare recall@10 and where the right answer lands.
  • Look at the misses by vector_rank and text_rank: they tell you which half failed.

Questions

What is hybrid search?

Search that ranks documents two ways, by keyword relevance (usually BM25) and by vector similarity, and combines the two rankings into one list. It finds exact terms such as error codes and product names, which embeddings often miss, and paraphrases, which keyword search misses.

What is reciprocal rank fusion (RRF)?

A way to merge ranked lists using only each document’s position. A document scores 1 / (k + its place) in each list it appears in, usually with k = 60, and the scores are added. It needs no tuning of BM25 scores against cosine similarities, which are on different scales.

Is hybrid search better than vector search?

For most retrieval over real text, yes, especially when queries contain ids, codes or rare names. Pure vector search is fine when queries and documents are both natural-language paraphrases. Test both on your own queries with known right answers.

Does pgvector support BM25?

No. pgvector adds vector types and indexes to Postgres. Postgres’s built-in full-text search ranks with ts_rank and ts_rank_cd, which are not BM25; for BM25 you need an extension such as ParadeDB’s pg_search. Either way you fuse the keyword and vector results yourself in SQL.

How do I do hybrid search in SkimmerDB?

Send a vector (or semantic text) and q in the same search with "rank": "bm25". SkimmerDB runs both rankings under the same filter and fuses them by reciprocal rank on the server. Each hit reports its fused score, vector_rank and text_rank, and it counts as one query.

Related