Use Whoosh as a LangChain retriever

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

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.

What you'll build: a copy-paste 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
            ]
Whoosh's 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

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.

⭐ Star Whoosh on GitHub  ·  Ask a question