Most LangChain RAG tutorials reach for a vector store and stop there. But embeddings alone quietly drop exact matches — product SKUs, error codes, function names, rare proper nouns — the very tokens users type when they know what they want. A keyword retriever fixes that, 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 it drops straight into the same Python process
as your LangChain app. This guide wraps it in a custom
BaseRetriever so it plugs into chains, ensemble retrievers, and
LCEL like any other LangChain retriever.
WhooshRetriever
class returning LangChain Document objects, with optional
metadata filtering, ready to use on its own or as the lexical half of an
EnsembleRetriever hybrid setup.
Install
pip install whoosh3 langchain-core
The PyPI package is whoosh3 (the classic whoosh
name is a stale build); the import is still import whoosh. You
only need langchain-core for the retriever base class and the
Document type — no heavy dependency tree.
1. Build a Whoosh index
Give each document a stable id, store the text so
we can hand it back to the LLM, and store any metadata you'll want to filter
or cite on. Storing fields is what lets the retriever reconstruct a full
Document from a hit.
from whoosh.fields import Schema, TEXT, ID, KEYWORD
from whoosh.index import create_in, open_dir
import os
def build_index(dir_path, docs):
"""docs: iterable of dicts with keys id, text, and optional source, category."""
os.makedirs(dir_path, exist_ok=True)
schema = Schema(
id=ID(stored=True, unique=True),
text=TEXT(stored=True), # stored so we can return page_content
source=ID(stored=True), # e.g. filename or URL, for citations
category=KEYWORD(stored=True), # exact-match metadata for filtering
)
ix = create_in(dir_path, schema)
writer = ix.writer()
for d in docs:
writer.update_document(
id=str(d["id"]),
text=d["text"],
source=d.get("source", ""),
category=d.get("category", ""),
)
writer.commit()
return ix
docs = [
{"id": 1, "text": "Whoosh is a fast pure-Python full-text search library "
"with BM25F ranking and no external dependencies.",
"source": "intro.md", "category": "docs"},
{"id": 2, "text": "Embeddings capture semantic similarity but can miss exact "
"keyword matches like error codes or SKUs.",
"source": "vectors.md", "category": "concepts"},
{"id": 3, "text": "Hybrid retrieval fuses BM25 keyword search with vector "
"search to improve RAG recall and precision.",
"source": "hybrid.md", "category": "concepts"},
]
build_index("rag_index", docs)
2. The WhooshRetriever class
LangChain retrievers subclass BaseRetriever and implement
_get_relevant_documents. Because BaseRetriever is a
Pydantic model, declare your fields as class attributes. This version opens a
searcher per call (safe and simple), parses the query across the
text field, and maps each Whoosh hit to a LangChain
Document with its BM25 score attached in metadata.
from typing import List, Optional, Dict, Any
from langchain_core.documents import Document
from langchain_core.retrievers import BaseRetriever
from langchain_core.callbacks import CallbackManagerForRetrieverRun
from whoosh.index import open_dir
from whoosh.qparser import MultifieldParser, OrGroup
from whoosh import scoring
from whoosh.query import Term, And
class WhooshRetriever(BaseRetriever):
"""A LangChain retriever backed by a pure-Python Whoosh BM25 index."""
index_dir: str
k: int = 4
search_fields: List[str] = ["text"]
filter: Optional[Dict[str, str]] = None # exact-match metadata filter
def _get_relevant_documents(
self, query: str, *, run_manager: CallbackManagerForRetrieverRun
) -> List[Document]:
ix = open_dir(self.index_dir)
with ix.searcher(weighting=scoring.BM25F()) as searcher:
# OrGroup: any query term can match (good recall for RAG queries)
parser = MultifieldParser(
self.search_fields, schema=ix.schema, group=OrGroup
)
q = parser.parse(query)
# Optional exact-match metadata filter (e.g. {"category": "docs"})
mask = None
if self.filter:
mask = And([Term(f, v) for f, v in self.filter.items()])
hits = searcher.search(q, limit=self.k, filter=mask)
return [
Document(
page_content=h.get("text", ""),
metadata={
"id": h.get("id"),
"source": h.get("source", ""),
"category": h.get("category", ""),
"score": float(h.score),
},
)
for h in hits
]
searcher must be used inside a with block
so its file handles close cleanly — that's why we materialise the
Document list before leaving the block. Reading the stored
fields (h.get(...)) inside the block is what makes that work.
3. Retrieve
retriever = WhooshRetriever(index_dir="rag_index", k=3)
for doc in retriever.invoke("keyword search for RAG"):
print(round(doc.metadata["score"], 3), doc.metadata["source"])
print(" ", doc.page_content[:70], "...")
# 3.x hybrid.md
# Hybrid retrieval fuses BM25 keyword search with vector search ...
.invoke() is the standard LangChain runnable call, so this
retriever composes into LCEL chains exactly like a vector-store retriever. To
scope a query, pass a filter: retriever.invoke("...",
config={"configurable": {}}) — or set
WhooshRetriever(index_dir=..., filter={"category": "docs"}).
4. Hybrid retrieval with EnsembleRetriever
The real payoff: combine Whoosh's keyword precision with a vector store's
semantic recall. LangChain's EnsembleRetriever fuses ranked lists
with Reciprocal Rank Fusion (RRF), so you don't have to reconcile score scales
yourself.
from langchain.retrievers import EnsembleRetriever
# vector_retriever = your_vectorstore.as_retriever(search_kwargs={"k": 4})
keyword_retriever = WhooshRetriever(index_dir="rag_index", k=4)
hybrid = EnsembleRetriever(
retrievers=[keyword_retriever, vector_retriever],
weights=[0.4, 0.6], # tune to your corpus; keyword-heavy data -> raise 0.4
)
results = hybrid.invoke("BM25 vs embeddings for exact matches")
Start at [0.4, 0.6] and adjust: bump the keyword weight for
corpora full of identifiers, codes, or names; bump the vector weight for
paraphrase-heavy, conversational queries. Because Whoosh runs in-process, the
keyword half adds no network hop and no extra service to operate.
Why Whoosh for the keyword half
- Zero infrastructure. No Elasticsearch/OpenSearch cluster, no separate BM25 service — the index is just files on disk in the same process as your chain.
- Pure Python. No C extensions to compile; the same wheel runs on Python 3.9–3.14, in Lambda, and in slim containers.
- Real BM25F. Field-weighted BM25, exact phrase and prefix queries, and metadata filtering built in.
- Incremental updates.
update_documentlets you add or replace chunks without rebuilding the whole index.
For a from-scratch walkthrough of the scoring and RRF math — including a version with no LangChain dependency at all — see the RAG & hybrid search guide.
Next steps
· RAG & hybrid search — the scoring and RRF fundamentals.
· Whoosh quickstart — the 5-minute API tour.
· 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.