hands-on labIDE in your browser

Hybrid Search and Reranking: BM25, Embeddings, RRF and an LLM Reranker

Build hybrid retrieval step by step and measure every stage: BM25 keyword search, dense embedding search, reciprocal rank fusion, and a listwise LLM reranker that orders the candidate pool.

time
55 min
checked steps
5
level
Intermediate
setup
None
NCP-AAINCA-GENLNCA-AMAD

$29.99a month with Pro

Pro opens all 5 steps of this lab and every other lab. Cancel any time.

no account needed · Pro opens all 5 steps

See Pro plans
Lab cockpit55 min · 5 stepsSession running
4 / 5 steps passingSize the pool and ship · step 5 of 5
search.py▶ Run✓ Check
# ---------- Step 5: the pipeline you ship ----------def pool_report(questions, sizes, depth=20):    """For each pool size: how often the right passage is in hybrid_search(question, size, depth) (recall), and the    average length in characters of the rerank prompt for that pool (avg_prompt_chars)."""          
TerminalOutput
step 5 of 5, replayed: Size the pool and ship. The code, the output and the check are the lab's own.

The job

Hearthly makes smart thermostats, and its help assistant answers from 56 help-centre articles. Customers ask in two very different ways: some quote an error code, a model number or a figure from the manual, others describe the problem in their own words. You build a search that handles both, measure each stage on 43 labelled questions, and ship settings you can defend.

5 steps, each checked when you finish it

A check runs your work at the end of every step. Hints and the full solution are there if you get stuck.

  1. 1

    Keyword search with BM25

    Hearthly's help centre has 56 short articles (kb.json).

    you writetokenize()bm25_search()read this step below ↓

  2. 2

    Dense search with embeddings

    BM25 found most exact-detail questions and missed half of the paraphrased ones: "I got a new broadband box" shares no words with the article about changing your router.

    you writedense_search()

  3. 3

    Fuse the rankings with RRF

    Each search finds answers the other misses.

    you writerrf()hybrid_search()

  4. 4

    Rerank the pool with a model

    Hybrid search almost always has the answer in its top ten, but it is often not first.

    you writererank_prompt()parse_ranking()rerank()

  5. 5

    Size the pool and ship

    The shipped pipeline, answer_passages(), reranks the top SETTINGS["rerank_top"] hybrid results.

    you writepool_report()

Step 1 as it appears in the lab

The lab’s own text. The hint and the solution stay inside the lab.

Step 1: Keyword search with BM25

Hearthly's help centre has 56 short articles (kb.json). questions.json holds 43 real-looking customer questions, each with the id of the article that answers it, split into three kinds:

  • exact: the question names a detail from the article: an error code, a model number, a terminal, a figure.
  • paraphrase: the customer describes the problem in their own words.
  • plain: ordinary questions that share words with their answer.

evaluate.py scores any search on them: hit@1 is how often the right article comes first, and recall@5 is how often it is anywhere in the top five.

BM25 is the classic keyword ranking: an article scores for every question word it contains, more for rare words, less when the article is long. It only ever sees tokens, so how you cut text into tokens decides what can match: "E41," and "e41" must become the same token.

do this

1. Write tokenize(text): lowercase the text and return its runs of letters and digits. "Error E41, HT-2000!" becomes ["error", "e41", "ht", "2000"].

2. Write bm25_search(question, k): build BM25Okapi from the tokenized TEXTS once (keep it in _bm25), score the tokenized question with get_scores(), and return the ids of the k best articles.

3. Run. Compare the exact and paraphrase rows before you check.

search.py, the file you edit90 lines
"""Hybrid search for the Hearthly help centre. Every search function takes a question and returns passage ids
("p01", "p02", ...), best first."""
import re

import numpy as np
from rank_bm25 import BM25Okapi

import rag

KB = rag.load_kb()
IDS = [p["id"] for p in KB]
BY_ID = {p["id"]: p for p in KB}
TEXTS = [rag.passage_text(p) for p in KB]


# ---------- Step 1: keyword search ----------
def tokenize(text):
    """Lowercase runs of letters and digits, punctuation dropped: "Error E41, HT-2000!" -> ["error", "e41", "ht", "2000"]."""
    # TODO (Step 1): lowercase the text and return its runs of letters and digits, e.g. re.findall(r"[a-z0-9]+", ...)
    raise NotImplementedError("Step 1: write tokenize()")


_bm25 = None


def bm25_search(question, k=5):
    """The k passage ids BM25 scores highest for the question."""
    # TODO (Step 1): build BM25Okapi once from [tokenize(t) for t in TEXTS] (keep it in the global _bm25),
    # score the tokenized question with get_scores(), and return the ids (IDS[i]) of the k highest scores, best first.
    raise NotImplementedError("Step 1: write bm25_search()")


# ---------- Step 2: dense search ----------
def dense_search(question, k=5):
    """The k passage ids whose embeddings are closest to the question's."""
    raise NotImplementedError("dense_search() arrives in Step 2")


# ---------- Step 3: fuse the two ----------
def rrf(rankings, k=60):
    """Reciprocal rank fusion: each list gives an id 1 / (k + rank), rank starting at 1. Ids sorted by total, best first."""
    raise NotImplementedError("rrf() arrives in Step 3")


def hybrid_search(question, k=5, depth=20):
    """Fuse the top `depth` of BM25 and of dense search with rrf() and keep the first k."""
    raise NotImplementedError("hybrid_search() arrives in Step 3")


# ---------- Step 4: rerank ----------
RERANK_PROMPT = """You rank help-centre passages for a customer question.

Question: {question}

Passages:
{passages}

Which passages answer the question? Reply with the passage numbers only, best answer first, separated by commas, for example: 3, 1, 2"""


def rerank_prompt(question, ids):
    """The prompt for rerank(): each passage numbered [1], [2], ... in the order given."""
    raise NotImplementedError("rerank_prompt() arrives in Step 4")


def parse_ranking(reply, ids):
    """Turn the model's reply ("3, 1") into ids, best first. Numbers outside 1..len(ids) and repeats are ignored;
    every id the reply leaves out is appended in its original order, so nothing is ever lost."""
    raise NotImplementedError("parse_ranking() arrives in Step 4")


def rerank(question, ids):
    """Ask the chat model to order the candidate ids. On any error keep the original order."""
    raise NotImplementedError("rerank() arrives in Step 4")


# ---------- Step 5: the pipeline you ship ----------
def pool_report(questions, sizes, depth=20):
    """For each pool size: how often the right passage is in hybrid_search(question, size, depth) (recall), and the
    average length in characters of the rerank prompt for that pool (avg_prompt_chars)."""
    raise NotImplementedError("pool_report() arrives in Step 5")


SETTINGS = {"depth": 20, "rerank_top": 5}


def answer_passages(question, k=3):
    """The shipped pipeline: hybrid search, rerank the top SETTINGS["rerank_top"], return the first k ids."""
    candidates = hybrid_search(question, k=SETTINGS["rerank_top"], depth=SETTINGS["depth"])
    return rerank(question, candidates)[:k]

provided for you:evaluate.pykb.jsonquestions.jsonrag.py

what you get

  • An editor and terminal in your browser
  • A check at the end of every step
  • Hints and the full solution
  • Nothing to install or configure

before you start

  • Python: functions, lists, dictionaries
  • Helpful: Embeddings You Can See, RAG Chunking Shoot-Out

Exam domains covered

RAGSearchEvaluation

Part of the RAG and search track

Lab 5 of 14. Chunking, hybrid search and reranking, permission-aware retrieval, GraphRAG and RAG evaluation.

Questions about this lab

What is hybrid search?

Running keyword search (such as BM25) and embedding search on the same query and merging the two rankings. Keyword search matches exact details like error codes and figures; embeddings match questions written in different words.

What is reciprocal rank fusion?

A way to merge rankings using positions only: each list gives a result 1 / (k + rank), usually with k = 60, and the totals set the final order. It needs no score normalisation, because BM25 scores and cosine similarities are on different scales.

What does a reranker add?

It reads the question together with each candidate and orders them by how well they answer it. In the lab, hybrid search has the answer in its top ten for every question but first for only about three in four; the reranker puts it first almost every time.

How many candidates should a reranker see?

Enough that the answer is almost always in the pool, and no more, because every candidate lengthens the prompt. The lab measures recall and prompt length for pool sizes 4 to 20 and picks the smallest size that reaches full recall.

Hybrid search with reranking, measured stage by stage

Hybrid search combines keyword ranking (BM25) with embedding search, and a reranker then orders the combined candidates. Each stage fixes a different failure, and the way to see that is to measure every stage on the same labelled questions. In this lab you write BM25 search, dense search, reciprocal rank fusion and a listwise reranker that asks a chat model to order a numbered pool, with a parser that never loses a candidate. You then size the rerank pool from a recall and prompt-length report and check the result on held-out questions.