Component · L3
BM25
github.com/juanmmm21/bm25-ranking-engine
A from-scratch implementation of the BM25 ranking algorithm for lexical relevance scoring over an inverted index.
- Lines
- 1,274
- Files
- 25
- Test files
- 8
- Commits
- 16
- Started
- 9 Jul 2026
- Last commit
- 11 Jul 2026
- Python 100%
● Where it sits
Layer 3 of Beacon — ranking & relevance.
L1 Ingestion
L2 Indexing
L3 Ranking & relevance
L4 Query serving
↳ Flagship
README · 5 min read
Commits per day
Project 5/10 of the beacon-search-engine ecosystem — Ranking and Relevance category.
Repository: github.com/juanmmm21/bm25-ranking-engine
The industry-standard lexical relevance scorer — the same algorithm Elasticsearch
and Lucene use internally — implemented from scratch: term frequency saturation,
inverse document frequency, and document-length normalization, combined into a
single score for every candidate document against a query. No rank_bm25, no
gensim, no third-party ranking library: the formula, the IDF variant, and the
NumPy-vectorized batch scoring are all implemented here.
What problem it solves
An inverted index tells you which documents contain a query term. It says nothing about how relevant each of those documents actually is. A document that mentions a term once in ten thousand words is not as relevant as one where the term is central to a short, focused page — and a term that appears in every document in the corpus carries far less signal than one that appears in only a handful. BM25 is the classical answer to "how do I turn raw term occurrences into a relevance score that reflects both of these things at once," refined over decades of information retrieval research (it descends directly from the probabilistic ranking work behind the Okapi retrieval system) and still the lexical baseline every modern search engine either uses directly or measures itself against. This project is exactly that scoring layer: given a query and an inverted index, rank every candidate document by lexical relevance, with nothing borrowed from a ranking library.
Role in beacon-search-engine
inverted-index-builder ── index-compression-codec
│ documents.jsonl, stats.json,
│ postings (plain or compressed)
▼
┌───────────────────────────┐
│ bm25-ranking-engine │ (this project)
│ query terms → BM25 score │
└─────────────┬──────────────┘
│ ranked (doc_id, score) list
▼
learning-to-rank-reranker
│ ▲
│ │ pagerank-link-analysis
▼
(converges in beacon-search-console)
bm25-ranking-engine sits directly on top of the indexing stage: it reads
whichever index it is pointed at — the uncompressed output of
inverted-index-builder
or the compressed, randomly-addressable one from
index-compression-codec
— and produces one of the input signals
learning-to-rank-reranker
combines with page authority from
pagerank-link-analysis.
It never decides the final result order on its own: it is a lexical relevance
signal, not the whole ranking pipeline.
Goal and skills demonstrated
- The BM25 formula implemented directly from its probabilistic derivation,
not copied from a library: term frequency saturation via
k1, document-length normalization viab, and an IDF term with Lucene/Elasticsearch's +1 smoothing to keep it non-negative for very common terms — each piece parametrized and justified on its own, not hidden behind a single opaque.score()call. - NumPy-vectorized batch scoring: for every query term, the contribution to
every candidate document is computed as array operations over that term's
posting list —
doc_id,term_frequency, and document length all resolved and combined as vectors — never a Pythonforloop over documents, which is what actually makes scoring thousands of candidates per query practical. Combining scores across the handful of terms in a query uses a plain dictionary — the honest scope of "batch operations over documents," not "vectorize everything at any cost." - Independent re-implementation of a sibling repo's binary contract, not an
import of it: to read
index-compression-codec's compressed index, this project re-implements its documented varint and bitpacking decode logic from the README's own byte-layout description, entirely independently of that repo's Python package — the polyrepo integration contract ofbeacon-search-engine(data formats on disk, never cross-repo imports), applied to a real binary format, not just JSON. - Deterministic ranking with an explicit tie-break: equal scores are
ordered by ascending
doc_id, not by whatever order a term happened to be processed in — the same determinism guarantee the rest of the ecosystem's indexing and ranking modules hold themselves to.
How it works
- Opening the index (
index_reader.py):manifest.jsonis read first; if it declares aterms_file, the index is a compressedindex-compression-codecindex andCompressedIndexReaderis used — random-access lookup, decoding only the bytes of the term requested. If it doesn't, it's a plaininverted-index-builderindex andPlainIndexReaderloads all posting lists into memory once (that format has no offset directory to seek into, so there is no random access to preserve). Both readers precompute adocument_lengthsNumPy array indexed bydoc_id, built once at open time. - Tokenizing the query (
tokenizer.py,search_textonly): Unicode NFC normalization plus locale-awarecasefold(), matching the normalizationinverted-index-builderapplies at indexing time, so free-text query terms land on the same vocabulary. This is a CLI convenience, not the ecosystem's query parser —query-parser-autocompleteowns that role, including operators this project doesn't handle. - Scoring (
scoring.py): for each distinct query term, its posting list is resolved, converted todoc_id/term_frequencyNumPy arrays, and BM25's per-document contribution is computed as vector arithmetic — including resolving every matched document's length via the precomputeddocument_lengthsarray. Contributions from different query terms are summed into a per-document accumulator; ties are broken by ascendingdoc_idbefore truncating to the requested top-K. - Orchestration (
pipeline.py):BM25RankingPipelineties the reader, the scorer, and the tokenizer together behind two methods —search_tokens(pre-tokenized terms, the ecosystem contract) andsearch_text(raw text, for standalone use) — and owns closing the underlying reader's resources.
Architecture
src/bm25_ranking_engine/
├── models.py # Posting, PostingsList, DocumentRecord, BM25Params, ScoredDocument
├── protocols.py # IndexReader interface
├── compressed_postings_codec.py # decode-only re-implementation of index-compression-codec's binary format
├── index_reader.py # PlainIndexReader, CompressedIndexReader, open_index_reader()
├── tokenizer.py # minimal NFC + casefold tokenizer, for CLI convenience only
├── scoring.py # BM25Scorer: NumPy-vectorized scoring
├── pipeline.py # BM25RankingPipeline: ties reader + scorer + tokenizer together
└── __main__.py # CLI (`search`, `term-idf`)
Requirements and installation
- Python
>=3.11 - Runtime dependency:
numpy— the one exception to this ecosystem's usual "no runtime dependencies" default, required explicitly bybeacon-search-engine's engineering standards for vectorized scoring (../CLAUDE.md, section 1).
git clone https://github.com/juanmmm21/bm25-ranking-engine.git
cd bm25-ranking-engine
python3 -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"
Usage (CLI)
# Rank documents against a free-text query (tokenized internally)
bm25-ranking-engine search ./index "search engine ranking" --top-k 5
# Tune k1 (term frequency saturation) and b (length normalization)
bm25-ranking-engine search ./index "search engine" --k1 1.2 --b 0.6
# Inspect document_frequency and IDF for a single term
bm25-ranking-engine term-idf ./index search
Try it end-to-end against the tiny fixture checked into samples/tiny_example/
(a 3-document index already built by inverted-index-builder):
bm25-ranking-engine search samples/tiny_example/index "python search" --top-k 5
bm25-ranking-engine term-idf samples/tiny_example/index search
./index can be either an uncompressed index directory produced by
inverted-index-builder or a compressed one produced by
index-compression-codec — search/term-idf detect which from
manifest.json automatically. It can also be invoked as a module:
python -m bm25_ranking_engine search ....
Data formats
This project only reads existing index directories; see
inverted-index-builder
and
index-compression-codec
for the exact schemas of manifest.json, documents.jsonl, stats.json,
postings.jsonl / terms.jsonl + postings.bin. It produces no on-disk
format of its own — its output is an in-memory ranked list of
ScoredDocument(doc_id, score).
Programmatic usage
from pathlib import Path
from bm25_ranking_engine.models import BM25Params
from bm25_ranking_engine.pipeline import BM25RankingPipeline
with BM25RankingPipeline(Path("./index"), BM25Params(k1=1.5, b=0.75)) as pipeline:
# Primary contract: pre-tokenized terms (e.g. from query-parser-autocomplete)
results = pipeline.search_tokens(["search", "engine"], top_k=10)
for scored in results:
document = pipeline.document(scored.doc_id)
print(scored.doc_id, scored.score, document.title if document else None)
# Convenience for standalone / CLI use: raw text, tokenized internally
results = pipeline.search_text("search engine ranking", top_k=10)
Development
pytest
ruff check .
mypy --strict src/
Tests cover the domain models (BM25Params validation), the compressed
postings decoder (hand-built VARINT and PACKED-mode blobs, truncation and
consistency errors), both index readers (plain and compressed, against small
on-disk fixtures), the tokenizer (NFC equivalence between composed and
decomposed accented forms, locale-aware case folding), the BM25 scorer (a
small fixed corpus where every expected score is precomputed independently
from the formula, covering single- and multi-term queries, candidate
restriction, and the ascending-doc_id tie-break), the end-to-end pipeline,
and the CLI.
Troubleshooting
ValueError: average_document_length debe ser positiva ...: the index being queried has zero documents, or itsstats.jsonreports a zero average length — BM25's length normalization is undefined in that case; rebuild the index from a non-empty corpus.- A term I expect to match returns no results: this project's own
tokenize()is a minimal NFC + casefold splitter meant for CLI convenience, not the ecosystem's query parser — it doesn't support phrase queries, exclusion, or any operator. If you're integrating programmatically, prefersearch_tokenswith terms already tokenized the same wayinverted-index-buildertokenized the corpus. ValueError: inconsistencia de índice para '<term>'while querying a compressed index: thedocument_frequencyrecorded in that index'sterms.jsonldoesn't match thenum_postingsencoded inside the correspondingpostings.binblob — the compressed index directory is corrupted or was assembled from mismatched files. Recompress it withindex-compression-codec.FileNotFoundErroron an index directory that looks correct: every reader requiresmanifest.jsonto exist and declare aformat_versionthis project understands (via the file names it references) — verify the directory was produced by a compatible version ofinverted-index-builderorindex-compression-codec, not partially copied.mypyfails undertests/: expected — onlysrc/is type-checked in--strictmode; test fixtures use deliberately looser typing.
License
MIT — see LICENSE.