IndiaFinBench / rag /bm25_index.py
Rajveer Singh Pall
Deploy IndiaFinBench research site
8f41246
Raw
History Blame Contribute Delete
3.32 kB
"""
rag/bm25_index.py
-----------------
BM25Okapi index for lexical retrieval over chunk texts.
Tokenisation strategy:
Lowercase + strip punctuation EXCEPT hyphens and parentheses.
Rationale: Indian regulatory text contains tokens like "91-day", "4(2)(b)",
"Section-51A" whose internal punctuation carries semantic meaning.
Standard whitespace tokenisation after these targeted strips preserves them.
BM25 parameters:
k1=1.5 β€” term frequency saturation (standard Okapi default)
b=0.75 β€” document length normalisation (standard Okapi default)
These are not tuned; the corpus is too small to warrant grid search.
"""
import pickle
import re
from pathlib import Path
from rank_bm25 import BM25Okapi
from rag.models import ChunkRecord
# Strip punctuation that is NOT part of regulatory term identifiers
_STRIP_RE = re.compile(r"[^\w\s\-()]")
class BM25Index:
def __init__(self) -> None:
self._bm25: BM25Okapi | None = None
self._chunks: list[ChunkRecord] = []
# ── Tokenisation ──────────────────────────────────────────────────────────
@staticmethod
def tokenise(text: str) -> list[str]:
text = text.lower()
text = _STRIP_RE.sub(" ", text)
return text.split()
# ── Build ─────────────────────────────────────────────────────────────────
def build(self, chunks: list[ChunkRecord]) -> None:
self._chunks = list(chunks)
tokenised = [self.tokenise(c.text) for c in chunks]
self._bm25 = BM25Okapi(tokenised, k1=1.5, b=0.75)
# ── Query ─────────────────────────────────────────────────────────────────
def search(self, query: str, k: int) -> list[tuple[ChunkRecord, float]]:
"""Return up to k (chunk, bm25_score) pairs, descending by score."""
if self._bm25 is None:
raise RuntimeError("BM25Index not built. Call build() or load() first.")
tokens = self.tokenise(query)
scores = self._bm25.get_scores(tokens)
top_k = min(k, len(self._chunks))
ranked = sorted(range(len(scores)), key=lambda i: scores[i], reverse=True)[:top_k]
return [(self._chunks[i], float(scores[i])) for i in ranked]
# ── Persistence ───────────────────────────────────────────────────────────
def save(self, index_dir: Path) -> None:
index_dir = Path(index_dir)
index_dir.mkdir(parents=True, exist_ok=True)
with open(index_dir / "bm25.pkl", "wb") as fh:
pickle.dump((self._bm25, self._chunks), fh)
@classmethod
def load(cls, index_dir: Path) -> "BM25Index":
obj = cls()
with open(Path(index_dir) / "bm25.pkl", "rb") as fh:
obj._bm25, obj._chunks = pickle.load(fh)
return obj
@property
def size(self) -> int:
return len(self._chunks)