Rank-BM25 Quick Start with Stable Document IDs#

A useful lexical-search result should name the document it belongs to, not ask its caller to remember which list happened to occupy the same row order as the index. This example builds BM25Okapi with doc_ids and retrieves (document_id, score) pairs with BM25.get_top_ids.

It also checks two small but important contracts: an out-of-vocabulary query returns no sparse hits, and equal scores are returned in original corpus order.

# Authors: The scikit-plots developers
# SPDX-License-Identifier: BSD-3-Clause

from __future__ import annotations

from scikitplot.rank_bm25 import BM25Okapi

1. Tokenize the corpus once#

BM25 consumes token sequences. Keep the same normalization policy for the corpus and every query; here that policy is deliberately just lowercase plus whitespace splitting so the example stays transparent.

documents = [
    "A search index maps terms to documents",
    "BM25 is a lexical ranking function",
    "Vector search ranks documents by embedding similarity",
    "Hybrid retrieval can combine lexical and vector evidence",
]
doc_ids = ["indexing", "bm25", "vectors", "hybrid"]


def analyze(text: str) -> list[str]:
    """Apply the same tiny analysis policy to documents and queries."""
    return text.lower().split()


corpus = [analyze(text) for text in documents]
index = BM25Okapi(corpus, doc_ids=doc_ids)

print("corpus rows:", index.corpus_size)
print("bound document IDs:", index.doc_ids)
corpus rows: 4
bound document IDs: ('indexing', 'bm25', 'vectors', 'hybrid')

2. Retrieve by identity#

get_top_ids only considers rows containing at least one query term and returns identities held by the index itself.

query = analyze("lexical BM25 ranking")
hits = index.get_top_ids(query, n=3)

for position, (doc_id, score) in enumerate(hits, start=1):
    print(f"{position}. {doc_id:8s} score={score:.6f}")

assert hits
assert hits[0][0] == "bm25"
1. bm25     score=1.811018
2. hybrid   score=0.000000

3. Unknown terms do not manufacture results#

A dense score vector has a zero for every non-match, but sparse retrieval has no reason to return arbitrary zero-score rows. A query with no indexed term therefore returns an empty list.

missing = index.get_top_ids(analyze("quasar-neutrino"), n=3)
print("out-of-vocabulary hits:", missing)
assert missing == []
out-of-vocabulary hits: []

4. Ties are deterministic#

Equal scores preserve corpus row order. This makes repeated runs stable and keeps the identity path aligned with the positional path.

tied = BM25Okapi(
    [["same"], ["same"], ["same"]],
    doc_ids=["first", "second", "third"],
)
tied_ids = [doc_id for doc_id, _score in tied.get_top_ids(["same"], n=3)]

print("tie order:", tied_ids)
assert tied_ids == ["first", "second", "third"]
tie order: ['first', 'second', 'third']

The next examples compare scoring recipes, explain sparse versus dense paths, and persist an index. The full contracts and failure modes live in BM25 lexical ranking.

Tags: model-workflow: retrieval plot-type: text level: beginner purpose: tutorial

Total running time of the script: (0 minutes 0.003 seconds)

Related examples

Sparse Retrieval and Dense BM25 Diagnostics

Sparse Retrieval and Dense BM25 Diagnostics

Persist and Reload a Rank-BM25 Index

Persist and Reload a Rank-BM25 Index

Compare the Three Rank-BM25 Scoring Recipes

Compare the Three Rank-BM25 Scoring Recipes

Use Levenshtein distance with Corpus retrieval

Use Levenshtein distance with Corpus retrieval

Gallery generated by Sphinx-Gallery