Library / SDK
xhluca/bm25s avatar
xhluca/bm25s

bm25s: eager sparse scoring for BM25 in pure Python

Fast BM25 search in Python, powered by Numpy and Numba

1,785 stars104 forksPythonMIT

At a glance

What is it?
bm25s precomputes BM25 impact weights into a sparse matrix at index time, so query scoring becomes a matrix operation in NumPy. It is a good fit for lexical retrieval inside Python pipelines, and a poor fit if you need incremental updates or a full search server.
Who is it for?
Adopt bm25s if you are building lexical retrieval inside a Python process and can afford to rebuild the index when the corpus changes. Do not adopt it if you need per-document updates, deletion, or a network-facing search service with analyzers and replication.
Can I use it commercially?
Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
Is it still maintained?
Yes. The repository last received commits 5 days ago.
What is it written in?
Mainly Python, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on September 15, 2026, and from our analysis. They are not legal advice.

DEEP OPEN-SOURCE ANALYSIS

The problem bm25s solves: BM25 without a JVM or a scoring loop

BM25 is a ranking function, not a search engine. In Python, the common way to get it is either rank-bm25, which scores documents by iterating in Python at query time, or Elasticsearch, which brings a JVM, a cluster, and an HTTP boundary. bm25s targets the space between those two. The README states the library is implemented in pure Python and uses sparse matrices to store eagerly computed scores for all document tokens, which it says allows fast scoring at query time. The intended user is someone who already has documents in memory in a Python process and wants ranked results back as arrays, not as JSON over a socket. The README frames BM25 as a core component of search services like Elasticsearch, and positions bm25s as a way to get that ranking function without the surrounding service. The comparison the README publishes is against rank-bm25, measured in queries per second on BEIR datasets in a single-threaded setting, with Elasticsearch included in the same chart. The repository also carries the topics bm25, bm25-l, bm25-plus, okapi-bm25, robertson, rag, and retrieval, so the maintainer is signalling both classic Okapi BM25 variants and retrieval-augmented generation pipelines as target uses.

How eager sparse scoring changes the cost profile

The mechanism named in the README is eager computation. Instead of computing term frequencies and document lengths per query, bm25s computes the per-token impact weights once, at index time, and stores them in a sparse matrix. At query time the work becomes a sparse lookup and aggregation over the query's terms rather than a scan over the corpus. That is the whole trade: you move cost from the query path to the index path. The README does not publish the memory overhead of that sparse matrix, so the size of your index in RAM is something you have to measure yourself. The optional Numba backend is described in the README as giving around a 2x speedup for larger datasets, with a link to a separate benchmarks repository and a discussion thread for version 0.2.0. That number is the maintainer's, not an independent measurement, and the README does not say at what dataset size the crossover happens. The retrieval API returns a tuple of document ids and scores, both arrays of shape (n_queries, k), which means batching queries is the natural call pattern rather than looping one query at a time.

Installation and the optional dependency split

The base install is a single command: pip install bm25s. The README labels the extras as recommended but optional. pip install "bm25s[core]" pulls in what the README lists as json loading, a progress bar, stemming, and JIT compilation. Stemming specifically can also be added on its own with pip install PyStemmer, and pip install "bm25s[full]" installs all extra dependencies. The README states there is no dependency on Java or PyTorch, and that NumPy is the requirement, with stemming and Numba compilation as optional lightweight additions. That split matters for deployment: if you skip the core extra you lose the progress bar and the JIT path, and if you skip PyStemmer you lose stemming, which the README ties to better results. The package is MIT licensed according to the repository metadata, and the version listed in the metadata at the time of writing is 0.3.11.

Tokenize, index, retrieve: the three calls that define a bm25s pipeline

The README's quickstart shows the sequence. You call bm25s.tokenize(corpus, stopwords="en", stemmer=stemmer) to get token ids, construct a retriever with bm25s.BM25(), then call retriever.index(corpus_tokens). Querying is bm25s.tokenize(query, stemmer=stemmer) followed by retriever.retrieve(query_tokens, k=2), which returns results and scores. The README notes that tokenizing to ids only is faster and saves memory. The stemmer in the example is Stemmer.Stemmer("english") from the PyStemmer package, imported separately. Persistence is retriever.save("animal_index_bm25") and bm25s.BM25.load("animal_index_bm25", load_corpus=True), with the README noting you can set load_corpus=False when you do not need the corpus back. One detail worth reading twice: the corpus you pass to BM25(...), retrieve(...), or save(...) is the list of values returned for matching document ids, and retrieval is position-based, so corpus[i] is returned for document id i. The README explicitly says to keep that corpus in the same order and length as the indexed documents. If you reorder one and not the other, you get silently wrong documents attached to correct scores.

The corpus format is deliberately loose, and that is a footgun

bm25s separates what you index from what you get back. You tokenize strings, and the corpus you attach to the retriever is a parallel list of return values. The README allows both plain strings and dictionaries, and says dictionaries have no required keys, suggesting id, title, text, or nested metadata as shapes that fit. For metadata corpora the documented pattern is to tokenize only the text field, for example bm25s.tokenize([doc["text"] for doc in metadata_corpus]), then pass the full dictionary list as the retriever's corpus. Serialization rules are stated: entries must be strings, dictionaries, lists, or tuples that can be serialized to JSON. String entries are written to corpus.jsonl as {"id": i, "text": doc}, while dictionaries, lists, and tuples are written as provided. The practical consequence is that if you want to index a title field and a body field jointly, the README does not show a field weighting mechanism here. You would concatenate them into one string before tokenizing, which loses the ability to weight title matches more heavily. That is a real gap for anyone coming from a search engine where field boosts are a config option.

Where bm25s is the wrong tool

The design that makes query scoring cheap is the same design that makes the index static. Because scores are eagerly computed and stored, adding or removing a single document is not a documented operation. The README shows index, save, load, and retrieve, and no update or delete call. If your corpus changes continuously, you rebuild. That is a different operational shape from Elasticsearch, where the README notes BM25 is a core component and where segment merges, incremental indexing, and replication are handled by the server. The README does not claim bm25s replaces Elasticsearch, and the comparison chart is about throughput, not about features. Beyond updates, the README does not document analyzers, custom tokenizers beyond the stemmer and stopwords options shown, faceting, filtering, or any query language. If you need to filter results by a metadata field before ranking, the README's position-based corpus does not describe a mechanism for that. You would retrieve a larger k and filter in Python. For a corpus that fits in memory and changes in batches, that is fine. For a corpus that changes per request, it is not.

Alternatives and the actual difference in approach

The README names rank-bm25 as the most popular Python implementation of BM25 and uses it as the baseline in its throughput chart. The difference in approach is where the arithmetic happens. rank-bm25 computes BM25 scores at query time by iterating over documents in Python, so the per-query cost scales with corpus size and the index is essentially just term statistics. bm25s computes the per-token impact weights once and stores them in a sparse matrix, so query time is a sparse aggregation and the index is larger. Rank-bm25 has no compiled extension to install and no sparse matrix in memory. The README also places Elasticsearch on the same chart. Elasticsearch is a different category: a server with its own process, its own index format, and its own update semantics, reached over HTTP. Choosing between bm25s and Elasticsearch is mostly a question of whether you want a library inside your Python process or a service next to it. Choosing between bm25s and rank-bm25 is a question of whether you can pay index build time and memory to buy query throughput.

Maintenance, versioning, and what the licence means in practice

The repository metadata shows releases on a roughly monthly cadence through 2026, with 0.3.11 dated 2026-08-25, 0.3.10 on 2026-07-22, and 0.3.9 on 2026-05-13, and the last push to main on 2026-09-10. The project is not archived. The version history in the README shows one breaking-ish milestone: 0.2.0 introduced the Numba backend, described as an addition rather than a replacement. The 0.x version line means the maintainer has not declared a stable API, so pinning a version in your requirements file is the reasonable default. The licence is MIT per the repository metadata and the LICENSE badge. MIT is permissive: it allows commercial use and modification with the copyright notice retained. That is a summary of the licence identifier, not legal advice, and if you redistribute bm25s inside a product you should read the LICENSE file in the repository. The README also cites an arXiv technical report, 2407.03618, titled BM25S: Orders of magnitude faster lexical search via eager sparse scoring, which is the place to look for the methodology behind the throughput claims rather than the chart alone.

Editorial conclusion

Adopt bm25s if you are building lexical retrieval inside a Python process and can afford to rebuild the index when the corpus changes. Do not adopt it if you need per-document updates, deletion, or a network-facing search service with analyzers and replication. Before committing, verify on your own corpus that the tokenization you pass to bm25s.tokenize() matches what you will pass at query time, and measure the index build cost at your document count, since that is where the eager scoring work is paid.

Official sources

  1. License: MIT
  2. Project website
  3. README
  4. Releases
  5. xhluca/bm25s on GitHub
Community notes

Community notes