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.
Starter file: evaluate.py
"""Scores search functions on questions.json. Run it with the method to score:
python3 evaluate.py bm25 python3 evaluate.py dense python3 evaluate.py hybrid
python3 evaluate.py rerank python3 evaluate.py shipped python3 evaluate.py pool
hit@1 = the right passage is first; recall@5 = it is anywhere in the top five. Scores are split by kind of
question: exact (error codes, model numbers, versions), paraphrase (the customer's own words), plain."""
import json
import sys
import time
from concurrent.futures import ThreadPoolExecutor
import search
KINDS = ["exact", "paraphrase", "plain"]
def ranked(method, question):
if method == "bm25":
return search.bm25_search(question, 5)
if method == "dense":
return search.dense_search(question, 5)
if method == "hybrid":
return search.hybrid_search(question, 5)
if method == "rerank":
return search.rerank(question, search.hybrid_search(question, 10))[:5]
if method == "shipped":
return search.answer_passages(question, 5)
raise SystemExit(f"unknown method {method!r}: use bm25, dense, hybrid, rerank or shipped")
def score(method, questions):
rows = []
with ThreadPoolExecutor(6) as pool: # rerank calls the model once per question; six at a time
results = list(pool.map(lambda q: ranked(method, q["question"]), questions))
for q, ids in zip(questions, results):
rows.append({"id": q["id"], "kind": q["kind"], "top": ids[:5],
"hit1": bool(ids) and ids[0] == q["answer"], "hit5": q["answer"] in ids[:5]})
out = {}
for kind in KINDS + ["all"]:
sel = [r for r in rows if kind == "all" or r["kind"] == kind]
out[kind] = {"n": len(sel), "hit@1": round(sum(r["hit1"] for r in sel) / len(sel), 3),
"recall@5": round(sum(r["hit5"] for r in sel) / len(sel), 3)}
return out, rows
def pool_table(questions):
print(f"rerank pool sizes (hybrid depth {search.SETTINGS['depth']})")
print(f" {'size':>4}{'recall':>8}{'avg prompt chars':>18}")
for r in search.pool_report(questions, range(4, 21), search.SETTINGS["depth"]):
print(f" {r['size']:>4}{r['recall']:>8.3f}{r['avg_prompt_chars']:>18}")
print(f" shipping rerank_top = {search.SETTINGS['rerank_top']}")
if __name__ == "__main__":
method = sys.argv[1] if len(sys.argv) > 1 else "bm25"
questions = json.load(open("questions.json", encoding="utf-8"))
if method == "pool":
pool_table(questions)
raise SystemExit
t = time.time()
table, rows = score(method, questions)
print(f"{method} ({len(questions)} questions, {time.time() - t:.1f}s)")
print(f" {'kind':<11}{'n':>4}{'hit@1':>8}{'recall@5':>10}")
for kind, s in table.items():
print(f" {kind:<11}{s['n']:>4}{s['hit@1']:>8.2f}{s['recall@5']:>10.2f}")
miss = [r for r in rows if not r["hit5"]]
if miss:
print(" not in top 5:", ", ".join(f"q{r['id']}" for r in miss))
json.dump({"method": method, "table": table, "rows": rows}, open(f".last_{method}.json", "w"), indent=1)