The idea: two ways to find a document, and a way to merge them
A search box, or the retrieval step of a RAG system, has to turn a short query into the few documents that answer it. There are two classic ways. Keyword search with BM25 looks for the query's words in the documents. Dense vector search turns the query and the documents into embedding vectors and looks for the closest ones, so it matches meaning instead of spelling. Hybrid search runs both and merges the two ranked lists, most often with reciprocal rank fusion (RRF).
The page has a help centre of 8 one-line articles (d1 to d8) and five queries. Pick a query and press Search: the query runs through BM25 (blue), then vector search (purple), then RRF (yellow). The article that really answers the query turns green, and each result box says whether its list found it at #1, lower down, or not at all.
BM25: words, rare words, and length
BM25 is the default ranking of Lucene, Elasticsearch, OpenSearch and Solr. The query is tokenized the same way as the documents: lowercase, punctuation removed, stop words such as "my", "how", "the" dropped. An inverted index maps each term to the documents that contain it, so only those documents are scored at all (the page "Inverted Index" shows how that index is built). Each matching term adds:
idf(t) · tf · (k1 + 1) / (tf + k1 · (1 − b + b · |d| / avgdl)) idf(t) = ln(1 + (N − df + 0.5) / (df + 0.5))
- idf: a term that few documents contain is worth more. With N = 8, a term in 1 article has idf 1.79; a term in no article has 2.89 but matches nothing.
- tf saturation (
k1 = 1.2): the second and third time a term appears count less than the first, so repeating a word does not win. - length normalisation (
b = 0.75): a match in a short document counts more than the same match in a long one. In Demo 4 "phone" in d5 (7 terms) beats "battery" in d6 (10 terms), 1.87 to 1.60, though both terms have the same idf.
A document with none of the query's terms scores exactly 0 and is not returned. That is BM25's strength (it never returns something unrelated) and its weakness (it cannot find "end your subscription" for "cancel my plan").
Vector search: embeddings and cosine
An embedding model maps a text to a vector so that texts with similar meaning point in similar directions. Search computes the cosine similarity q·d / (|q| |d|) between the query vector and every document vector and keeps the k nearest. Real models have hundreds of unlabelled dimensions; the page uses 8 named ones (account, billing, cancel, problem, device, power, startup, shipping) and counts the words of each topic, so you can read the vectors. In Demo 3 "how do I cancel my plan" becomes billing 1, cancel 1, and d2 "To end your subscription … Stop membership" has billing 3, cancel 2: cosine 0.98.
A real vector database does not compare against every document: it uses an approximate nearest-neighbour index such as HNSW (see HNSW Vector Search). The ranking idea is the same.
Where each one fails
| query type | BM25 | vector search | demo |
|---|---|---|---|
| same words as the answer | ✓ | ✓ | Demo 1 |
| exact code, ID, product name, rare term | ✓ high idf | ✗ the model has no meaning for "E1042" | Demo 2 |
| synonyms, paraphrase, other wording | ✗ no shared word, score 0 | ✓ | Demo 3 |
| half right each | answer at #2 | answer at #2 | Demo 4: RRF puts it first |
| nothing in the corpus answers it | ✓ returns nothing | ✗ always returns k results, even at cosine 1.00 | Demo 5 |
Reciprocal rank fusion
The two scores cannot be added: BM25 is unbounded (4.40 for d1 in Demo 1) and cosine is between −1 and 1. RRF (Cormack, Clarke and Büttcher, 2009) throws the scores away and keeps only the ranks:
RRF(d) = Σ over lists 1 / (k + rank of d in that list), k = 60
A document missing from a list adds nothing for it. The constant k = 60 flattens the curve, so #1 is worth only a little more than #2 (1/61 = 0.0164 vs 1/62 = 0.0161) and being in both lists matters most. In Demo 4 the answer d6 is #2 in both lists: 1/62 + 1/62 = 0.0323. BM25's #1, d5, is #4 for vectors: 1/61 + 1/64 = 0.0320. Vectors' #1, d7, is not in the BM25 list: 0.0164. The document both retrievers agree on wins.
The same rule works against you in Demo 3: the pricing page d3 is BM25's only match and vector #2, so it beats the real answer d2, which only vectors found. Hybrid keeps d2 in the top 3, but someone still has to pick it. Production systems add a reranker (a cross-encoder that reads the query and each candidate together) over the top 20–50 fused results, or let the LLM read all of them.
Hybrid search in practice
| system | how hybrid is done |
|---|---|
| Elasticsearch / OpenSearch | a bool or hybrid query with a match and a knn clause, fused by the rrf retriever or a normalisation processor |
| PostgreSQL | full-text search (tsvector, ts_rank) plus pgvector, fused in SQL with 1/(60 + rank) |
| vector databases (Weaviate, Qdrant, Milvus, Pinecone) | sparse BM25-style vectors next to dense ones, fused by RRF or a weighted sum |
Hybrid search is the usual first fix when a RAG system misses facts: see Retrieval-Augmented Generation, where retrieval is vectors only and the warranty question can lose to an off-topic marketing chunk.
See also Word2Vec: where embedding vectors come from (in the vector-search section).
What the page leaves out
A learned embedding model (8 hand-made dimensions here), subword tokenizers and stemming ("refunds" vs "refund"), BM25F field weights, approximate nearest-neighbour indexes, weighted-sum fusion with score normalisation, learned sparse models such as SPLADE, rerankers, query rewriting, and how to measure retrieval quality over many queries (recall@k, MRR, nDCG).