BM25 Ranking
debt(d9/e3/b5/t5)
Closest to 'silent in production until users hit it' (d9). Misconfigured BM25 parameters (wrong k1/b values, using FTS4 instead of FTS5, comparing scores cross-query) produce no errors or warnings — the search engine runs and returns results. Only degraded search relevance experienced by real users reveals the problem, and even then it requires deliberate measurement with a relevance evaluation dataset to confirm. No detection_hints.tools are specified; no tooling in the search ecosystem flags suboptimal BM25 parameter choices automatically.
Closest to 'simple parameterised fix' (e3). The quick_fix confirms Elasticsearch uses BM25 by default (no config needed) and parameter tuning is a matter of adjusting k1 and b values. Switching SQLite from FTS4 to FTS5 is a schema migration touching the table definition and index creation, slightly more than a one-liner but still localised to one component. The remediation pattern is clear and bounded — hence e3 rather than e1.
Closest to 'persistent productivity tax' (b5). BM25 is the relevance backbone of every search feature in the application. Choosing wrong parameter defaults or the wrong FTS version affects every search query across the system. However, it doesn't reshape every unrelated change the way an ORM or auth system would — it stays localised to the search subsystem. Teams building search features persistently feel the tax of needing a relevance evaluation dataset and measurement discipline, placing this at b5.
Closest to 'notable trap — a documented gotcha most devs eventually learn' (t5). The misconception field explicitly states developers believe BM25 and TF-IDF are interchangeable and produce equivalent results. Additionally, common_mistakes highlight that BM25 scores are not comparable across queries (a non-obvious behaviour), and that defaults are assumed optimal when they are not. These are well-documented gotchas in search engineering that most developers learn after encountering degraded relevance, fitting t5.
Also Known As
TL;DR
Explanation
BM25 (Okapi BM25) improves on TF-IDF by adding two tuning parameters: k1 controls term frequency saturation (how much additional occurrences of a term increase the score — typically 1.2–2.0), and b controls length normalisation (how strongly document length affects scoring — 0.75 is standard). The key insight over TF-IDF: in BM25, each additional occurrence of a term contributes diminishing returns to the score. A term appearing 100 times in a document does not score 100× higher than a term appearing once — there is a saturation ceiling. This makes BM25 less susceptible to term-stuffing and more accurate on documents of varying lengths. BM25 is the default ranking function in Elasticsearch 5+, Lucene, Solr, SQLite FTS5, and PostgreSQL's ts_rank_cd variant.
Watch Out
Common Misconception
Why It Matters
Common Mistakes
- Assuming BM25 scores are directly comparable across different indices or document collections — scores are relative within a corpus and depend on the corpus statistics, not absolute.
- Tuning k1 and b parameters without understanding the trade-off: lowering b aggressively to ignore document length can hurt ranking quality if documents legitimately vary in relevant content density.
- Confusing BM25 with vector embeddings or semantic search — BM25 is lexical and keyword-based, not semantic, so it fails on synonyms and conceptual relevance without query expansion.
- Setting k1 too high (> 2.5) expecting more term-frequency sensitivity, then wondering why repeated keyword spam still ranks too high — the point of k1 is to cap that effect, not amplify it.
Code Examples
// ❌ Hand-rolling relevance scoring with raw LIKE — no IDF weighting
function search(string $query, PDO $db): array {
$words = explode(' ', $query);
$sql = "SELECT *, 0 AS score FROM documents WHERE ";
$conditions = [];
foreach ($words as $word) {
$conditions[] = "content LIKE '%$word%'";
}
$sql .= implode(' OR ', $conditions);
// Counts nothing, ranks nothing, vulnerable to SQL injection
return $db->query($sql)->fetchAll();
}
// ✅ Use Elasticsearch (BM25 by default since v5) or PostgreSQL FTS
// Elasticsearch — BM25 automatic, no config needed
$results = $es->search([
'index' => 'articles',
'body' => [
'query' => [
'multi_match' => [
'query' => $userQuery,
'fields' => ['title^3', 'body'], // ^3 boosts title matches
]
]
]
]);
// PostgreSQL FTS — ts_rank uses BM25-like IDF weighting
$stmt = $pdo->prepare("
SELECT *, ts_rank(search_vector, plainto_tsquery('english', :q)) AS rank
FROM articles
WHERE search_vector @@ plainto_tsquery('english', :q)
ORDER BY rank DESC
LIMIT 20
");
$stmt->execute([':q' => $userQuery]);