How to Use BM25S: Fast Lexical Search in Python (Index, Retrieve, and the Hybrid RAG Arc)
CodexFuse Team · 2026-11-06T14:42:09.146Z · 25 min read min read
How to Use BM25S: Fast Lexical Search in Python (Index, Retrieve, and the Hybrid RAG Arc)
Learning how to use BM25S takes about an hour the first time, from the install to a working lexical search index over your own documents. BM25S is an open-source Python library for fast BM25 lexical search, and it gives you the exact-match retrieval that embedding-only search keeps missing. This bm25s tutorial follows the full arc in one guide: install it, tokenize and index a real corpus, retrieve ranked results with scores, use the command-line interface, save and share the index on the Hugging Face Hub, and plug BM25 into a hybrid RAG retriever.
To use BM25S, install the BM25 wrapper with pip install bm25, tokenize your corpus with bm25s.tokenize, create a retriever with bm25s.BM25(), index the tokens with retriever.index(), and call retriever.retrieve() with your tokenized query to get ranked document ids and relevance scores. You need no server, no Java, and no separate search engine, because BM25S runs in plain Python on top of NumPy. The whole first index fits in seven steps, and the install section below walks through them in order.
All facts in this guide are as of November 2026. CodexFuse offers no BM25S tool, so this guide teaches the open-source project itself, step by step, with every version, command, and limit taken from the official GitHub repository, the Hugging Face blog post, the PyPI pages, and the arXiv paper.
What Is BM25S, and What Does It Do?
BM25S is a Python library for bm25 lexical search python developers use when they need ranked keyword search without running a search server. The name expands to BM25-Sparse, and the idea is simple: BM25 ranks documents by how often the query words appear in each document, adjusted by how rare those words are across the whole corpus. That adjustment is the term frequency times inverse document frequency idea, and it is the reason a rare word like a product code counts for far more than a common word like "the".
BM25 itself was developed by Stephen Robertson and collaborators in the 1990s, and it is the ranking function behind Elasticsearch. BM25S brings that same ranking to plain Python by computing every word-level relevance score once at index time and storing the scores in a sparse matrix. At query time it only has to add up the scores for the words in your query, which is why it stays fast even as the corpus grows.
The project is 940 days old, counted from its repository creation on 10 April 2024 to today, 6 November 2026.
The practical difference from vector search is what each one can see. Vector search understands meaning, so it finds documents about a topic even when they use different words. Lexical search understands exact words, so it finds the document that literally contains the product code, the error string, the person name, or the section title you typed.
A retrieval system that uses only vectors misses those exact matches, and a system that uses only BM25 misses synonyms. That gap is why the hybrid section at the end of this guide exists. If you build retrieval chains, our LangChain beginner guide is the natural companion to this one, because the hybrid section shows BM25S as the lexical leg beside a vector retriever.
How Do You Install BM25S?
Install the beginner wrapper with pip install bm25. That one command is the pinned install for this guide, and it follows the recommendation in the March 2026 edit of the official Hugging Face blog post and in the bm25s README itself. The bm25 package is a beginner-friendly wrapper around bm25s, not a rename of it: both packages are live at version 0.3.16, released 28 October 2026, and bm25 pins bm25s[cli,core]==0.3.16 as its backend.
Installing the wrapper gives you the optimized engine plus the dependencies a beginner needs, including PyStemmer for stemming and rich for the command-line interface.
The other install command is pip install bm25s, and you should know what it buys you. That command installs the underlying engine only, and its base install needs nothing but NumPy. Use it when you want the advanced surface: the Hugging Face Hub integration, memory-mapped loading, custom BM25 variants, or custom tokenization over millions of documents.
The README says the same thing in the other direction: advanced use still goes through bm25s directly.
| Install command | What you get |
|---|---|
| pip install bm25 | The beginner wrapper: engine plus stemming and CLI support, and the high-level API |
| pip install bm25s | The engine only, with NumPy as its sole required dependency |
| pip install "bm25s[core]" | Recommended extra: JSON loading, progress bars, stemming, and the Numba backend |
| pip install "bm25s[full]" | Every optional extra at once |
| pip install "bm25s[cli]" | The interactive index picker for the command-line interface |
| pip install "bm25s[mcp]" | The built-in MCP server, so AI tools can query your index |
Check the install with a one-line import in your terminal, then confirm the version matches 0.3.16 before you go further. The project needs Python 3.8 or newer. The previous release, 0.3.15, landed only 6 days before 0.3.16, and the six releases from 0.3.11 to 0.3.16 span 64 days, so pinning the version matters: a guide written against 0.3.11 in August 2026 is already five releases behind.
pip install bm25 pip install bm25s pip install "bm25s[core]" pip install "bm25s[cli]" pip install "bm25s[mcp]" python -c "import bm25s; print(bm25s.__version__)"
How Do You Index Your First Corpus in 7 Steps?
Indexing a corpus is the heart of this guide, and it takes seven steps from an empty file to a searchable index. The example below is the official quickstart from the bm25s README, with a four-document animal corpus, and every step after it explains one line of that code. Run the code as written first, then read the steps to understand what each line did.
- Install the BM25 wrapper with pip install bm25, as the previous section shows.
- Write your corpus as a Python list of plain strings, one document per string.
- Tokenize the corpus with bm25s.tokenize, passing stopwords="en" to drop English stopwords and an optional stemmer to reduce words to their roots.
- Create a retriever with bm25s.BM25(), which uses the Lucene variant by default.
- Index the tokenized corpus with retriever.index(), which computes and stores every word-level score.
- Tokenize your query with the same tokenizer settings you used for the corpus.
- Retrieve with retriever.retrieve(query_tokens, k=2) and read the ranked document ids and scores.
import bm25s
import Stemmer # optional: for stemming
corpus = [
"a cat is a feline and likes to purr",
"a dog is the human's best friend and loves to play",
"a bird is a beautiful animal that can fly",
"a fish is a creature that lives in water and swims",
]
stemmer = Stemmer.Stemmer("english") # optional
corpus_tokens = bm25s.tokenize(corpus, stopwords="en", stemmer=stemmer)
retriever = bm25s.BM25()
retriever.index(corpus_tokens)
query = "does the fish purr like a cat?"
query_tokens = bm25s.tokenize(query, stemmer=stemmer)
results, scores = retriever.retrieve(query_tokens, k=2)
# results/scores are arrays of shape (n_queries, k)
# To return docs instead of IDs, set corpus=corpus on BM25(...), retrieve(...), or save(...)
Step 3 deserves a pause, because tokenization decides what BM25 can ever match. The tokenizer splits text into tokens, drops the stopwords you asked it to drop, and optionally stems each word, so "swims" and "swimming" become the same token. The corpus above returns token ids by default, and passing return_ids=False returns plain strings instead.
Keep one rule in mind for the rest of this guide: tokenize the query exactly the way you tokenized the corpus, with the same stopwords and the same stemmer, or the query words will not line up with the indexed words.
Step 5 is where BM25S earns its speed. The index call pre-computes the relevance score of every word in every document and stores the result sparsely, so the retrieve call only adds up the scores for the handful of words in your query. The README calls this eager sparse scoring, and it is the whole design: pay once at index time, then answer queries by addition.
That trade is also why the parameters are fixed at index time, a limit the comparison section comes back to.
How Do You Retrieve Ranked Results and Read the Scores?
Retrieval returns two arrays of shape (n_queries, k): the ranked document ids and their relevance scores. The ids are positions in your corpus, so corpus[i] is the document behind id i. The scores are BM25 relevance scores, and they are only meaningful next to each other inside one query: the top result outscores the second result, but a score of 12.4 in one query and 3.1 in another query says nothing about which query matched better.
BM25 scores are relevance scores, not probabilities, so never present them as percentages or confidence values.
Most beginners want documents back, not ids, and there are three places to ask for them. Pass corpus=corpus when you build the retriever, when you call retrieve, or when you save the index, and the retriever will hand you the text. You can also pass return_as="documents" to retrieve to get documents only, with no scores attached.
The k argument sets how many results come back, and the default in the command-line search is 10.
The high-level API is the shortest path for a local file. It wraps the same engine behind three calls: load a file, index it, and search it, with results coming back as a list of dictionaries. It reads CSV, JSON, JSONL, and plain text files, and the document_column argument tells it which column or key holds the text.
This is the API the bm25 wrapper package exposes as BM25.load, BM25.index, and retriever.search, and it is the one to teach a teammate first.
import bm25s.high_level as bm25
# Load a file: csv, json, jsonl, or txt
retriever = bm25.load("file.csv", document_column="text")
retriever.index()
results = retriever.search(["what is BM25?"], k=5)
print(results)
How Do the Five BM25 Variants Differ?
BM25 is not one formula. The research literature produced several variants that tune how term frequency saturates and how document length is normalized, and BM25S ships five of them behind one method argument. The default is lucene, which matches the variant Elasticsearch and Lucene use, and most beginners should leave it alone.
Change the variant only when you have a reason, such as matching another library exactly or testing which variant ranks your corpus best.
| Variant | method value | What it is |
|---|---|---|
| Lucene | lucene | The default, matching the Elasticsearch and Lucene behavior |
| Robertson | robertson | The original BM25, with inverse document frequency floored at zero |
| ATIRE | atire | A variant tuned for the ATIRE search system |
| BM25L | bm25l | Adds a delta term so long documents are not over-penalized |
| BM25+ | bm25+ | Adds a delta term so term frequency never fully saturates |
Two settings control the variants in practice. Pass method="robertson" with k1=1.5 and b=0.75 for the classic configuration, and pass a delta value, default 0.5, when you choose BM25L or BM25+. You can check what you are running at any time with retriever.method.
One equivalence is worth knowing for the comparison section: method="atire" with idf_method="robertson" and epsilon set to zero reproduces rank-bm25 exactly, which is how the project proves both libraries compute the same BM25 math.
How Do You Save, Reload, and Memory-Map an Index?
An index you cannot save is an index you rebuild every morning, so BM25S treats saving as a first-class step. Call retriever.save with a directory name to store the index, and pass corpus=corpus in the same call if you want the documents stored alongside it. Reload with bm25s.BM25.load, passing load_corpus=True when you need the documents back, or leave it off when you only need the ids.
The save call writes string entries to corpus.jsonl as id and text pairs, one per line.
retriever.save("animal_index_bm25") # index only
retriever.save("animal_index_bm25", corpus=corpus) # index + corpus
reloaded = bm25s.BM25.load("animal_index_bm25", load_corpus=True)
retriever = bm25s.BM25.load("bm25s_very_big_index", mmap=True) # memory-mapped load
The mmap=True option is the one that matters at scale. It loads the index without pulling it fully into memory, so a large index stays cheap in RAM and starts fast. The README benchmarks this on MS MARCO, a corpus of 8.8 million documents with more than 300 million tokens, and the numbers are stark: the memory-mapped load takes 0.62 seconds and 0.90 GB of RAM, against 11.41 seconds and 10.56 GB for the in-memory load.
That is 18.4x faster to load and 11.73x less RAM, and retrieval itself drops from 0.74 seconds to 0.18 seconds.
| MS MARCO, 8.8M documents | Load time | Retrieval time | RAM used |
|---|---|---|---|
| Memory-mapped load (mmap=True) | 0.62 seconds | 0.18 seconds | 0.90 GB |
| In-memory load | 11.41 seconds | 0.74 seconds | 10.56 GB |
One detail catches beginners: the tokenizer vocabulary and stopwords are saved separately from the index. Use tokenizer.save_vocab and tokenizer.save_stopwords to persist them, and the matching load_vocab and load_stopwords calls to bring them back. If you reload an index but not its vocabulary, a new query can tokenize against a different vocabulary than the corpus was indexed with, and the mismatch silently weakens your results.
How Do You Use the BM25S CLI Without Writing Code?
The bm25s cli is the no-code path: index a file and search it from your terminal, with no Python script at all. The workflow is three commands. First write a few lines of text to a file, then index that file into a directory, then search the index with a quoted query.
The index command takes a CSV, TXT, JSON, or JSONL file, writes the index to the directory you name with -o, and defaults to a directory named after the file with _index appended. The search command takes the index with -i, the query in quotes, and an optional -k for how many results to return, defaulting to 10.
echo -e "Machine learning is a subset of AI\nDeep learning uses neural networks\nNatural language processing handles text" > docs.txt bm25 index docs.txt -o my_index bm25 search -i my_index "what is AI?" bm25 search -i my_index "neural networks" -k 5 -s results.json
Three more flags round out the interface. The -c flag picks which column or key holds the text when your file has more than one, and the -u flag saves the index into your user directory at ~/.bm25s/indices/ so you can find it again later. Running bm25 search with -u and a query, and no -i flag, opens an interactive picker over the indices in that user directory, which needs the bm25s[cli] extra installed.
The -s flag saves the search results to a JSON file instead of only printing them.
The CLI also launches the built-in MCP server, which exposes the index to AI tools as two callable tools: retrieve, which takes a query and a k value, and get_info, which reports the vocabulary size, the document count, and the backend. Launch it with bm25 mcp launch, a port, and the index directory, and a tool like Claude Desktop can query your index directly. If the words API and MCP are new to you, read what an API is first, because the MCP server is an API over your search index.
The server is one more reason to keep the index saved on disk rather than rebuilt in memory.
How Do You Share an Index on the Hugging Face Hub?
The bm25s hugging face hub flow lets you publish an index once and load it anywhere with one line. The BM25HF class wraps the same retriever with two extra methods: save_to_hub uploads the index to a repository under your Hugging Face username, and load_from_hub downloads it again. You need the huggingface_hub package installed and a valid Hugging Face access token, which the library reads from the HUGGING_FACE_HUB_TOKEN environment variable or from the token argument.
Pass corpus=corpus to save_to_hub when the people loading the index should get the documents too, and pass private=False when the index should be public.
pip install huggingface_hub
import bm25s
from bm25s.hf import BM25HF
retriever = BM25HF(corpus=corpus)
retriever.index(bm25s.tokenize(corpus))
retriever.save_to_hub("username/bm25s-animals") # needs a HF access token
retriever = BM25HF.load_from_hub("username/bm25s-animals", load_corpus=True)
docs, scores = retriever.retrieve(bm25s.tokenize("a cat is a feline"), k=2)
Loading supports the same options as the local loader. Pass revision="main" to pin a branch or revision, and pass mmap=True to memory-map the downloaded index instead of loading it fully into RAM. The author also publishes pre-built indices for the standard BEIR benchmark collections on the Hub, eleven index repositories covering collections such as Natural Questions, SciFact, HotpotQA, and Quora, all published in June and July 2024.
Loading one of those instead of building your own is the fastest way to benchmark a retriever or to test a ranking idea against a known corpus.
How Does BM25S Compare With Rank-BM25?
Rank-bm25 is the library most Python developers meet first, and this bm25s vs rank-bm25 comparison is the fairest one for BM25S because both compute the same BM25 math. The differences are in speed, tooling, and maintenance. Rank-bm25 is a small package that implements BM25Okapi, BM25L, and BM25+ on top of NumPy, and it does no preprocessing at all: you tokenize, lowercase, remove stopwords, and stem the text yourself, then hand it token lists.
Its last release, version 0.2.2, shipped on 16 February 2022, which is 1,724 days before today, and it has not been released since. The releases and the star counts behind this section live on GitHub, and if the release workflow is unfamiliar, our GitHub beginner guide walks through it.
The speed gap is the headline, and it comes from the author's own benchmark on a single-threaded Intel Xeon CPU at 2.70 GHz, run over the BEIR collections. BM25S answers 287x more queries per second than rank-bm25 on arguana, 20x more on scifact, 418.5x more on Natural Questions, 522x more on HotpotQA, and 436.3x more on climate-fever. The paper behind the library states the headline as over 100x higher throughput in 10 out of 14 datasets, with one dataset reaching a 500x speedup.
Treat these as the author's numbers, not an independent measurement: no third-party reproduction was found while researching this guide.
| BEIR dataset | BM25S queries per second | Rank-BM25 queries per second |
|---|---|---|
| Arguana | 573.91 | 2 |
| SciFact | 952.92 | 47.6 |
| NFCorpus | 1196.16 | 224.66 |
| Quora | 183.53 | 1.18 |
| FiQA | 507.03 | 4.46 |
| SciDocs | 767.05 | 9.01 |
| Natural Questions | 41.85 | 0.1 |
| HotpotQA | 20.88 | 0.04 |
Speed is not the whole story, and the smaller differences decide real projects. BM25S ships a built-in tokenizer with stopwords and optional stemming, five BM25 variants, save and load with memory mapping, a Hugging Face Hub integration, a command-line interface, a high-level API, and an MCP server. Rank-bm25 ships none of those, and its one structural advantage is that you can change BM25 parameters at search time without re-indexing, because it scores lazily instead of eagerly.
Pick BM25S when the corpus is large, when you need tokenization and persistence, or when you want the CLI and the Hub. Pick rank-bm25 for a tiny teaching snippet with no extra machinery, or when you need to retune parameters at search time.
# BM25S by the numbers (facts fetched 2026-11-06; ages computed from those dates)
from datetime import date
today = date(2026, 11, 6)
repo_created = date(2024, 4, 10)
v0316 = date(2026, 10, 28)
v0311 = date(2026, 8, 25)
rank_last = date(2022, 2, 16)
stars, forks = 1822, 121
print("repo age (days):", (today - repo_created).days)
print("days since 0.3.16:", (today - v0316).days)
print("days 0.3.11 -> 0.3.16:", (v0316 - v0311).days)
print("stars per fork:", round(stars / forks, 2))
print("arguana speedup:", round(573.91 / 2, 1))
print("scifact speedup:", round(952.92 / 47.6, 1))
print("nq speedup:", round(41.85 / 0.1, 1))
print("hotpotqa speedup:", round(20.88 / 0.04, 1))
print("climate-fever speedup:", round(13.09 / 0.03, 1))
print("mmap RAM saving:", round(10.56 / 0.90, 2))
print("mmap load speedup:", round(11.41 / 0.62, 1))
print("rank-bm25 stale (days):", (today - rank_last).days)
# Output:
# repo age (days): 940
# days since 0.3.16: 9
# days 0.3.11 -> 0.3.16: 64
# stars per fork: 15.06
# arguana speedup: 287.0
# scifact speedup: 20.0
# nq speedup: 418.5
# hotpotqa speedup: 522.0
# climate-fever speedup: 436.3
# mmap RAM saving: 11.73
# mmap load speedup: 18.4
# rank-bm25 stale (days): 1724
How Do You Add BM25S to a Hybrid RAG Retriever?
Hybrid search runs a lexical retriever and a vector retriever in parallel over the same query, then merges the two ranked lists into one, and this hybrid search rag bm25 pattern is the standard way to get both exact matching and semantic matching in one retriever. The reason is the blind spot each side carries alone: pure vector search misses exact matches such as product codes, names, error strings, and ids, while pure BM25 misses meaning, so it never matches a synonym or a paraphrase. Running both and fusing the results covers both failure modes, and the merge step is where the design lives.
One first-person account of switching a RAG system to hybrid retrieval reports accuracy on 50 questions rising from about 60 percent with vectors alone to about 85 percent with the hybrid setup, and that is one author's self-reported test, not a controlled benchmark.
The merge most systems use is reciprocal rank fusion, which needs no score normalization because BM25 scores and cosine scores live on different scales. Each document gets a fused score of the sum over retrievers of 1 divided by k plus its rank in that retriever, with k set to 60 by convention. A document ranked first by BM25 and tenth by vectors still scores well, and a document ranked nowhere in one leg is not punished for it.
The formula is RRF(d) = sum 1 / (k + rank), with k = 60. An alternative is a weighted sum of normalized scores, and an optional second stage reranks the merged pool with a cross-encoder.
One honest warning belongs here before any code. No native bm25s integration exists in LangChain or LlamaIndex today: both frameworks ship a BM25Retriever built on rank-bm25, so wiring bm25s into either framework is custom glue code, not a supported integration. The glue is small, because bm25s.retrieve already returns ranked ids and scores as NumPy arrays, so a thin wrapper class can call it and hand the documents to the fusion step.
The example below shows that pattern with a dependency-free reciprocal rank fusion function over a BM25S leg and a vector leg.
import bm25s
# Lexical leg: BM25S over your corpus
retriever = bm25s.BM25(corpus=corpus)
retriever.index(bm25s.tokenize(corpus, stopwords="en"))
bm25_ids, bm25_scores = retriever.retrieve(
bm25s.tokenize("refund policy for damaged goods"), k=10
)
# Dense leg: your vector retriever returns ranked ids the same way
vector_ids = vector_search("refund policy for damaged goods", k=10)
def rrf(rankings, k=60):
fused = {}
for ranking in rankings:
for rank, doc_id in enumerate(ranking):
fused[doc_id] = fused.get(doc_id, 0) + 1 / (k + rank)
return sorted(fused, key=fused.get, reverse=True)
merged = rrf([bm25_ids[0].tolist(), vector_ids])
The dense leg in that sketch is whatever vector store you already run, and a managed option keeps the setup small. If you want the vector side handled by a dedicated database, our Qdrant beginner guide covers the setup. LangChain offers a second fusion style worth knowing: its EnsembleRetriever combines retrievers with weights, such as 0.4 for the lexical leg and 0.6 for the vector leg, which tunes their relative influence instead of fusing by rank.
Either way, keep the two legs independent and fuse late, so you can swap the vector store or the BM25 variant without rewriting the other side.
When Should You Pick BM25S, and When Should You Not?
Pick BM25S when exact matching is the job. That means corpora where the query words must appear in the answer, such as documentation search, support-article search, code search, and product catalogs keyed by names and codes. Pick it when the corpus is large enough for rank-bm25 to crawl, when you need to save and reload the index, when you want the CLI for a no-code workflow, and when you want to publish the index on the Hugging Face Hub for others to load.
The eager scoring design makes the lexical leg fast enough to run beside a vector search without dominating latency, which is the property the hybrid section relies on.
Do not pick it when the job needs something it was never built for. It does not understand meaning, so a semantic-only question with no shared words will rank poorly. It does not scale across machines, so a multi-node deployment still belongs to Elasticsearch, which the Hugging Face blog post names as the system that scales that way.
It does not let you change parameters at search time, so a workflow that retunes k1 and b per query should stay with rank-bm25. And it is a CPU library on NumPy and Numba, so GPU dense search belongs to a vector library instead. When you do ship BM25S beside your application, run it in a container so the index, the tokenizer files, and the MCP server travel together, and our Docker beginner guide covers that step.
What Mistakes Do Beginners Make With BM25S?
The most common mistake is tokenizing the query differently from the corpus. If the corpus was stemmed and stopwords were removed, but the query was tokenized raw, the query words will not match the indexed tokens, and the results will look random. Keep one tokenizer configuration for the whole project and reuse it for every query.
The second mistake is installing bm25s and then wondering where the bm25 command went: the command-line interface and the interactive picker come with the bm25 wrapper and the cli extra, so install those when you want the terminal workflow.
The third mistake is reading the scores as probabilities or comparing scores across different queries. Scores rank documents inside one query only, and a high score on a short query says nothing next to a low score on a long one. The fourth mistake is expecting semantic matches from a lexical index: BM25S will not match "feline" to "cat" unless the word "cat" appears in the document, and that is a property of lexical search, not a bug.
The fifth mistake is saving an index without its corpus and then wondering why load returns ids instead of documents: pass corpus=corpus to save, or load_corpus=True to load, when you need the text back.
Sources
The version numbers, the quickstart code, the CLI commands, the high-level API, the save and load calls, the Hugging Face Hub methods, the MCP server, and the benchmark tables all come from the official bm25s repository on GitHub at github.com/xhluca/bm25s, including its README and its releases page, where version 0.3.16 is dated 28 October 2026. The repository reports 1,822 stars and 121 forks as fetched on 6 November 2026, which works out to 15.06 stars per fork. The bm25s homepage at bm25s.github.io carries the same quickstart and comparison material.
The install recommendation and the 500x speedup claim come from the Hugging Face blog post by xhluca, "BM25 for Python: Achieving high performance while simplifying dependencies with BM25S", published 9 July 2024 and updated 20 March 2026 at huggingface.co/blog/xhluca/bm25s. The technical report behind the library is "BM25S: Orders of magnitude faster lexical search via eager sparse scoring" by Xing Han Lù, published 4 July 2024 at arxiv.org/abs/2407.03618. The pre-built BEIR indices live in the xhluca collection at huggingface.co/collections/xhluca/bm25s-indices-6670f65d459aa2d3b967cbb8.
The package versions and dependencies come from the PyPI JSON API: pypi.org/pypi/bm25s/json and pypi.org/pypi/bm25s/0.3.16/json for the engine, pypi.org/pypi/BM25/json for the wrapper, and pypi.org/pypi/rank-bm25/json for rank-bm25, whose latest release is 0.2.2 from 16 February 2022. The wrapper homepage at bm25-python.github.io documents its one-line API, its CLI, and its user directory. The rank-bm25 API details, including BM25Okapi and get_top_n, come from its PyPI page.
The hybrid retrieval material comes from the LangChain rank_bm25 integration documentation at docs.langchain.com, which shows its BM25Retriever built on rank_bm25, and from AI Letters issue 19, "The BM25 Test", which documents the same across frameworks. Reciprocal rank fusion with k=60, the LlamaIndex QueryFusionRetriever, and the LangChain EnsembleRetriever come from the hybrid search pieces at aitechconnect.in, engineersofai.substack.com issue 20, itsourcecode.com, dev.to, medium.com, and c-sharpcorner.com. The 60 percent to 85 percent accuracy account is the first-person hybrid retrieval report on dev.to.
Frequently Asked Questions
Is BM25S free to use?
Yes. BM25S is open source under the MIT license, and the bm25 wrapper package is free as well. You can use both in commercial projects, self-host them, and publish indices built with them, with no paid tier involved.
Should I install bm25 or bm25s?
Install bm25 if you are starting out, because it is the beginner wrapper that bundles the engine with stemming and the command-line interface. Install bm25s directly if you need the advanced surface, such as the Hugging Face Hub integration, memory-mapped loading, custom BM25 variants, or custom tokenization. Both packages are current at version 0.3.16, and bm25 is a wrapper around bm25s, not a replacement for it.
Is BM25S faster than Elasticsearch?
On a single node, the project's benchmark numbers put BM25S on par with Elasticsearch for the tested BEIR datasets, running single-threaded and with four threads. Elasticsearch still wins where BM25S cannot go, namely multi-node scaling, Java-based deployment, and incremental index updates. Pick BM25S for a fast single-machine lexical index, not for a distributed search cluster.
Can BM25S replace a vector database in RAG?
No, and that is the wrong way to frame it. BM25S matches exact words, while a vector database matches meaning, and a RAG system needs both, which is why the hybrid section pairs them and fuses the results. Use BM25S as the lexical leg that catches product codes, names, and error strings your vectors would miss.
Does BM25S work with LangChain?
There is no built-in bm25s retriever in LangChain today, and the same holds for LlamaIndex. Both frameworks ship a BM25Retriever built on rank-bm25, so using bm25s inside them means writing a small custom wrapper class that calls bm25s.retrieve and hands the documents to your chain. The hybrid section of this guide shows that wrapper pattern and the reciprocal rank fusion step that merges its results with a vector retriever.




