LlamaIndex makes dense vector retrieval the default path, and for good
reason — embeddings are excellent at paraphrase and semantic recall. But they
have a well-known blind spot: they quietly miss the exact tokens users
type when they know what they want — product SKUs, error codes like
ERR_2043, function names, gene symbols, ticket IDs. A lexical BM25
retriever is the classic complement, and you don't need a search server to run
one.
Whoosh is a fast,
pure-Python full-text search library with BM25 ranking. It has no server
process and no C extensions, so the index is just a folder on disk in the same
Python process as your LlamaIndex app. As of whoosh3 3.35.0,
the integration is first-class: whoosh.llamaindex
gives you a drop-in BaseRetriever you can hand to any query engine
or a QueryFusionRetriever.
NodeWithScore objects, ready to use on its own or as the
lexical half of a QueryFusionRetriever hybrid setup — with zero
extra services to run.
Install
pip install "whoosh3[llamaindex]"
The PyPI package is whoosh3 (the classic whoosh
name is a stale build); the import is still import whoosh. The
[llamaindex] extra pulls in llama-index-core.
Importing whoosh.llamaindex itself never requires it — the
LlamaIndex adapter is built lazily — so the dependency-free
WhooshSearch core is safe to import anywhere.
1. Build the BM25 index from your chunks
The shared WhooshSearch core takes parallel lists of texts,
ids, and metadata. Omit path to keep the index in memory (handy
for notebooks and tests) or pass one to persist it to disk.
from whoosh.llamaindex import WhooshSearch, make_whoosh_llamaindex_retriever
core = WhooshSearch.from_texts(
texts=[
"Whoosh is a fast pure-Python full-text search library "
"with BM25F ranking and no external dependencies.",
"Embeddings capture semantic similarity but can miss exact "
"keyword matches like error codes or SKUs.",
"Hybrid retrieval fuses BM25 keyword search with vector "
"search to improve RAG recall and precision.",
],
ids=["intro", "vectors", "hybrid"],
metadatas=[{"source": "intro.md"},
{"source": "vectors.md"},
{"source": "hybrid.md"}],
# path="rag_index", # uncomment to persist to disk
)
2. Wrap it as a LlamaIndex retriever
One factory call returns a real
llama_index.core.retrievers.BaseRetriever. Each Whoosh hit becomes
a NodeWithScore carrying the BM25 score and your metadata, so it
composes into LlamaIndex query engines exactly like a vector retriever.
retriever = make_whoosh_llamaindex_retriever(core, k=3)
for nws in retriever.retrieve("keyword search for RAG"):
print(round(nws.score, 3), nws.node.metadata["source"])
print(" ", nws.node.text[:60], "...")
# 3.854 hybrid.md
# Hybrid retrieval fuses BM25 keyword search with vector ...
# 0.989 intro.md
# Whoosh is a fast pure-Python full-text search library ...
# 0.989 vectors.md
# Embeddings capture semantic similarity but can miss exact ...
BM25 floats the chunk that actually contains the query terms (keyword, search, RAG) to the top and rewards rarer, more discriminating terms — the behaviour dense retrieval tends to smooth over.
3. Plug it into a query engine
Hand the retriever to RetrieverQueryEngine and it drives
synthesis like any other LlamaIndex retriever:
from llama_index.core.query_engine import RetrieverQueryEngine
query_engine = RetrieverQueryEngine.from_args(retriever)
response = query_engine.query("How does hybrid retrieval help RAG?")
print(response)
4. Hybrid retrieval with QueryFusionRetriever
The real payoff: fuse Whoosh's keyword precision with a vector index's
semantic recall. LlamaIndex's QueryFusionRetriever does Reciprocal
Rank Fusion for you, so you never have to reconcile BM25 and cosine score
scales by hand.
from llama_index.core.retrievers import QueryFusionRetriever
# vector_retriever = vector_index.as_retriever(similarity_top_k=4)
keyword_retriever = make_whoosh_llamaindex_retriever(core, k=4)
hybrid = QueryFusionRetriever(
[keyword_retriever, vector_retriever],
similarity_top_k=4,
num_queries=1, # set >1 to also fuse LLM-generated query variants
mode="reciprocal_rerank",
)
nodes = hybrid.retrieve("BM25 vs embeddings for exact matches")
Because Whoosh runs in-process, the keyword half adds no network hop and no extra service to operate — it's just Python and a folder of index files.
Why Whoosh for the keyword half
- Zero infrastructure. No Elasticsearch/OpenSearch cluster, no separate BM25 service — the index lives in the same process as your LlamaIndex app.
- Pure Python. No C extensions to compile; the same wheel runs on Python 3.10–3.14, in AWS Lambda, and in slim containers.
- Real BM25F. Field-weighted BM25, exact phrase and prefix queries, and metadata filtering built in.
- Importable anywhere. The
WhooshSearchcore depends only on Whoosh and the standard library, so importingwhoosh.llamaindexnever drags inllama-index-coreuntil you actually build the adapter.
Prefer LangChain? There's a matching LangChain retriever built on the same shared core. For a from-scratch walkthrough of the BM25 and RRF math — with no framework dependency at all — see the RAG & hybrid search guide.
Next steps
· LangChain retriever — the same core, for LangChain.
· RAG & hybrid search — the scoring and RRF fundamentals.
· Autocomplete & prefix search — add type-ahead to your app.
Building RAG with Whoosh, or hit a snag? Whoosh is actively maintained again — issues and PRs are welcome, and I aim to respond promptly and kindly.