Source code for klea_utils.stores.retrieval.bm25
#!/usr/bin/env python3
"""
BM25 keyword retriever manager
File: klea_utils/stores/retrieval/bm25.py
Copyright 2026 Ankur Sinha
Author: Ankur Sinha <sanjay DOT ankur AT gmail DOT com>
"""
import logging
import pickle
from pathlib import Path
from typing import Any, override
from langchain_core.documents import Document
from klea_utils.stores.retrieval.base import BaseKleaRetriever
from ..config import PerDomainConfig, RetrieverConfig
[docs]
class BM25RetrieverManager(BaseKleaRetriever):
"""Manages domain-specific BM25 keyword stores.
Each BM25 store is a pickled corpus of chunked documents written by
:class:`~klea_utils.stores.ingestion.StoresBuilder.write_bm25_store`.
Stores are loaded lazily per domain: the corpus is unpickled and used
to build a :class:`langchain_community.retrievers.BM25Retriever`, which
is queried with BM25 keyword scoring.
A store whose corpus file is missing is skipped with a warning, so a
misconfigured domain degrades gracefully instead of failing retrieval.
Scalability note: this is a pure-Python in-memory index
(``rank_bm25``). Building and querying stay fast to well over
~100k chunks, but memory grows roughly with the total number of
unique terms (one Python dict entry per term per chunk, ~100-150
bytes each), so a single collection becomes heavy in the ~50-100k
chunk range. That is far beyond current corpora, but if large
platformed deployments are ever planned, consider a proper keyword
backend (e.g. Elasticsearch, Qdrant sparse vectors, or Postgres
full-text search) instead.
"""
source_label = "BM25"
def __init__(
self,
config: RetrieverConfig,
logger: logging.Logger,
default_k: int = 5,
k_max: int = 10,
k_inc: int = 1,
):
"""Initialise the BM25 retriever manager.
:param config: Retriever configuration for all domains
:param logger: Logger instance (injected from orchestrator)
:param default_k: Fallback number of documents to retrieve
:param k_max: Fallback maximum number of documents to retrieve
:param k_inc: Fallback amount to increase ``k`` by per ``inc_k``
"""
super().__init__(
config=config,
logger=logger,
default_k=default_k,
k_max=k_max,
k_inc=k_inc,
)
@override
def _stores_of(self, domain: PerDomainConfig) -> list[Any]:
"""Return the BM25 stores configured for *domain*."""
return domain.bm25_stores
@override
def _instantiate_store(self, path: str, name: str):
"""Load a BM25 corpus pickle and build a BM25Retriever from it.
:param path: Path to the pickled document corpus
:param name: Store name from the configuration
:returns: A :class:`langchain_community.retrievers.BM25Retriever`,
or ``None`` if the corpus file is missing
"""
# Lazy: importing langchain_community pulls in the whole integration
# package, which is heavy. Only needed when a BM25 store is actually
# configured, so defer the import to first load.
from langchain_community.retrievers import BM25Retriever
corpus_path = Path(path)
if not corpus_path.is_file():
self.logger.warning(
f"BM25 corpus not found, skipping store '{name}': {corpus_path}"
)
return None
with open(corpus_path, "rb") as f:
docs = pickle.load(f)
self.logger.debug(f"Loaded {len(docs)} chunks for BM25 store '{name}'")
return BM25Retriever.from_documents(docs)
@override
def _retrieve_from_store(
self, store: Any, query: str, k: int
) -> list[tuple[Document, float]]:
"""Run BM25 keyword search on a single store.
:param store: Loaded BM25 store to query
:param query: User query string
:param k: Number of documents to retrieve from this store
:returns: List of (document, relevance_score) tuples. Documents
with a non-positive score (no term overlap with the query)
are dropped.
"""
retriever = store.loaded_object
processed = retriever.preprocess_func(query)
top_docs = retriever.vectorizer.get_top_n(processed, retriever.docs, n=k)
scores = retriever.vectorizer.get_scores(processed)
# get_top_n returns the same Document objects, so map scores by id.
score_by_id = {id(doc): score for doc, score in zip(retriever.docs, scores)}
result = [(doc, score_by_id[id(doc)]) for doc in top_docs]
return [(doc, score) for doc, score in result if score > 0]