Two retrievers with opposite blind spots, and the two-line formula that combines them.
| Dense (embeddings) | Sparse (BM25) | |
|---|---|---|
| Scores by | Vector similarity between query and chunk embeddings | Weighted overlap of exact terms, rare terms weighing more[1] |
| Finds | Paraphrase and meaning: "cosy flat with outdoor space" matches "apartment with terrace" | Exact tokens: "OMN-4812", "Kortrijksesteenweg 12", "EPC label B" |
| Misses | Identifiers, codes, names - embeddings smear rare exact tokens | Any wording the document does not literally use |
Real-estate queries arrive in both kinds, often in the same sentence. So run both.
Reciprocal rank fusion merges ranked lists using only ranks - no fragile score normalisation:[2]
RRF(d) = sum over lists L of 1 / (k + rank_L(d)) # k = 60 by convention
dense_list = vector_search(embed(query), top=100)
sparse_list = bm25_search(terms(query), top=100)
candidates = top_by_rrf(dense_list, sparse_list, top=100)
A document ranked 1st in one list and absent in the other scores 1/61; ranked ~30th in both scores ~2/90 - agreement between retrievers beats a single strong showing at depth. The constant k=60 flattens the curve so rank 1 does not drown everything; the paper found it deliberately insensitive.[2]
From memory, before scrolling up.
Read the RRF paper - two pages, and you will never need to look the formula up again. BM25 background, when wanted: Robertson & Zaragoza via the free mirror in RESOURCES.md.
Want to sanity-check a fusion design against your actual query log? Bring examples to your teaching agent.