Use Whoosh as a LlamaIndex retriever

A pure-Python BM25 retriever for RAG — by Priya Sundaram

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.

What you'll build: a Whoosh-backed LlamaIndex retriever that returns 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

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.

⭐ Star Whoosh on GitHub  ·  Ask a question