A learner searches for “money back policy RF-204.” Dense retrieval may recognize “money back” as related to refunds. Lexical retrieval may preserve the exact policy ID. How can you use both signals without adding incomparable raw scores?
Before you begin: Understand lexical retrieval, embeddings, and ranked search results.
Keep the two retrievers' roles clear
Lexical methods score matches between query terms and document text, often accounting for frequency and document length. Dense methods compare learned representations. Each can miss cases the other finds, and each can return irrelevant results.
Hybrid search combines their evidence. This can be done with calibrated score combinations, rank fusion, or a later reranker. The combination should be evaluated on your task rather than assumed to be an automatic improvement.
Avoid adding arbitrary score scales
A lexical score of 12 and a cosine similarity of 0.8 are not directly comparable measurements. Adding them without calibration can make one retriever dominate just because of its numerical scale.
Reciprocal rank fusion, or RRF, combines ranks instead. For each result list, a document at rank r receives a contribution 1 / (c + r), where ranks start at one and c is a positive smoothing constant. Contributions are summed across lists. The constant controls how sharply top ranks differ; it is not a probability parameter.
Why are two good scores not automatically additive?
Lexical and embedding systems produce scores through different calculations. Adding them directly gives the numerically larger scale more influence, even when that scale does not represent greater relevance. Rank-based fusion offers one way to combine orderings without pretending the score units match.
Inspect the candidates each method contributes before tuning the fusion. A lexical match can rescue an exact identifier, while semantic retrieval can rescue a paraphrase. The challenge asks you to make the combination rule explicit and evaluate the result on both types of query.
Lexical scores are 14.2 and 9.8; vector scores are 0.83 and 0.79.
Add the raw values without calibration or a defined weighting rule.
The lexical scale dominates the arithmetic, regardless of the intended importance of each signal.
Work through two lists
Suppose lexical search returns A, B, C and dense search returns B, D, A. With c = 10, A receives 1/11 + 1/13, while B receives 1/12 + 1/11. B ranks ahead because both retrievers place it near the top.
This is an invented example. It shows agreement between rank lists, not proof that B answers the question. If both retrievers share a blind spot, fusion can reinforce it.
Run the fusion
This complete Python 3 program deduplicates each input list so a repeated ID within one retriever does not earn extra votes. It uses stable ID ordering to break ties.
# Runnable: Python 3, standard library.
def rrf(rankings, constant=10):
if constant <= 0:
raise ValueError('Use a positive smoothing constant')
scores = {}
for ranking in rankings:
unique = list(dict.fromkeys(ranking))
for rank, doc_id in enumerate(unique, start=1):
scores[doc_id] = scores.get(doc_id, 0) + 1/(constant + rank)
return sorted(scores, key=lambda doc_id: (-scores[doc_id], doc_id))
assert rrf([['A', 'B', 'C'], ['B', 'D', 'A']])[0] == 'B'
assert rrf([['A', 'A'], ['B']]) == ['A', 'B']
print(rrf([['A', 'B', 'C'], ['B', 'D', 'A']]))
Apply permissions consistently
Both retrievers must search the authorized collection or apply equivalent trusted filtering. Fusion should not reintroduce a record excluded by one path for access reasons. Keep source IDs consistent across indexes so the same passage is recognized as the same result.
Updates and deletions must reach both paths. A current dense index combined with a stale lexical index can surface superseded evidence. Test version handling in the combined result, not only in each retriever separately.
Evaluate the added complexity
Compare lexical-only, dense-only, and fused retrieval on exact IDs, paraphrases, rare terms, ambiguous wording, and missing-answer questions. Measure task relevance and latency. If fusion improves paraphrases but pushes exact policy matches down, investigate result depths, weights, or a dedicated exact-ID rule.
A reranker can inspect the fused candidate set, but it cannot recover a document absent from every candidate list. Retrieval depth and reranking cost should be chosen together.
Predict a failure
An irrelevant welcome page ranks first in both lists because every notice repeats its generic event wording. RRF ranks it first too. Is the fusion implementation necessarily broken?
Inspect the candidate evidence
Not necessarily. RRF is rewarding agreement, which is what it is designed to do. Improve the retrievers, source preprocessing, or task-specific reranking and evaluate again. Rank fusion does not understand relevance independently of its input rankings.
Practice with feedback
Fuse two result lists without inventing a score scale
BM25 returns scores like 14.2 and 9.8. Your embedding search returns 0.83 and 0.79. Someone proposes adding them together.
Combine lexical and semantic retrieval by rank, and keep permissions consistent.
Check your understanding
Your task
Implement RRF over two lists, with the permission filter applied consistently.
These notes stay on this page. Download them before leaving. Code in this field is not executed.
What to include
- Rank starts at 1, not 0
- A document appearing in only one list still gets a score
- The permission filter is applied to both inputs before fusion
- You show a concrete case where fusion beats either list alone
Compare with a worked answer
Here is one way to answer. Check how it uses the information in the task.
def rrf(lists, k=60):
scores = {}
for ranked in lists:
for rank, doc in enumerate(ranked, start=1):
scores[doc] = scores.get(doc, 0.0) + 1.0 / (k + rank)
return sorted(scores, key=scores.get, reverse=True)
lex = ['n12', 'n03', 'n44', 'n07'] # exact term matches
vec = ['n44', 'n21', 'n12', 'n09'] # semantic matches
print(rrf([lex, vec]))
# ['n12', 'n44', 'n03', 'n21', 'n07', 'n09']
#
# n12 and n44 rise because both retrievers found them, which neither list
# expresses on its own: lexical had n44 third, semantic had n12 third.
# Documents found by only one retriever are kept, just lower.
# Permissions: filter identically, before fusion.
def search(query, viewer):
allowed = visibility_for(viewer) # e.g. {'public','members'}
lex = bm25_search(query, visibility__in=allowed, k=50)
vec = vector_search(query, visibility__in=allowed, k=50)
return rrf([lex, vec])
# The bug this prevents: our first version filtered only the vector side,
# because the BM25 index had no visibility column. A members-only notice
# reached a signed-out visitor at fused rank 2. The fix was in the index, not
# in the fusion, which is the point: fusion cannot repair an unfiltered input.When you are signed in, opening the challenge carries your edited working notes into its draft in this browser. The challenge has its own completion record. Practising here does not award points or mark it complete.
Next, you will build a search project that displays evidence directly and measures ranking before adding generated answers.
Reference
Elasticsearch's RRF reference documents the rank-fusion approach and its parameters. The local code above is a small explanatory implementation.