| """BM25 sparse index over exactly the chunks in the dense index. |
| |
| Why a keyword index at all, next to a perfectly good vector index: statutes are full of |
| tokens whose *identity* matters and whose *meaning* is nil. "Article 51" and "Article 15" |
| sit in almost the same place in embedding space; to BM25 they are different terms. The |
| reverse is equally true — "end-of-service money" retrieves Article 51 by meaning while |
| sharing no term with it. Hybrid retrieval exists because neither failure mode is rare. |
| |
| Deliberately **not** stemmed. A stemmer folds "leave"/"leaves" together, which is fine, |
| but it also mangles the defined terms a statute turns on. Exact-term recall is the entire |
| reason this index exists, so tokens are kept as written. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import json |
| import logging |
| import re |
| from dataclasses import dataclass |
| from pathlib import Path |
| from typing import Any, Final, Self |
|
|
| from rank_bm25 import BM25Okapi |
|
|
| from app.core.models import Chunk |
|
|
| logger = logging.getLogger(__name__) |
|
|
| |
| _TOKEN_RE: Final = re.compile(r"[a-z0-9]+") |
|
|
| |
| |
| _STOPWORDS: Final[frozenset[str]] = frozenset( |
| { |
| "a", |
| "an", |
| "and", |
| "any", |
| "are", |
| "as", |
| "at", |
| "be", |
| "been", |
| "by", |
| "for", |
| "from", |
| "has", |
| "have", |
| "in", |
| "is", |
| "it", |
| "its", |
| "of", |
| "on", |
| "or", |
| "that", |
| "the", |
| "their", |
| "there", |
| "these", |
| "this", |
| "those", |
| "to", |
| "was", |
| "were", |
| "which", |
| "will", |
| "with", |
| } |
| ) |
|
|
|
|
| def tokenize(text: str) -> list[str]: |
| """Lowercase, split on non-alphanumerics, drop only very common function words.""" |
| return [token for token in _TOKEN_RE.findall(text.lower()) if token not in _STOPWORDS] |
|
|
|
|
| @dataclass(slots=True) |
| class BM25Index: |
| """A persisted BM25 index keyed by chunk id.""" |
|
|
| chunk_ids: list[str] |
| corpus: list[list[str]] |
| _bm25: BM25Okapi | None = None |
|
|
| @classmethod |
| def build(cls, chunks: list[Chunk]) -> Self: |
| if not chunks: |
| raise ValueError("cannot build a sparse index over zero chunks") |
| return cls( |
| chunk_ids=[chunk.chunk_id for chunk in chunks], |
| corpus=[tokenize(chunk.text) for chunk in chunks], |
| ) |
|
|
| def save(self, path: Path) -> None: |
| path.parent.mkdir(parents=True, exist_ok=True) |
| payload: dict[str, Any] = { |
| "version": 1, |
| "chunk_ids": self.chunk_ids, |
| "corpus": self.corpus, |
| } |
| path.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8") |
| logger.info("wrote sparse index: %d documents -> %s", len(self.chunk_ids), path) |
|
|
| @classmethod |
| def load(cls, path: Path) -> Self: |
| if not path.exists(): |
| raise FileNotFoundError(f"{path} not found. Run `make index` to build the index.") |
| payload: dict[str, Any] = json.loads(path.read_text(encoding="utf-8")) |
| return cls( |
| chunk_ids=list(payload["chunk_ids"]), corpus=[list(d) for d in payload["corpus"]] |
| ) |
|
|
| @property |
| def bm25(self) -> BM25Okapi: |
| """Scorer, constructed lazily. |
| |
| Only the tokenised corpus is persisted, not a pickled scorer: unpickling is |
| arbitrary code execution, and an index file is exactly the kind of artefact that |
| travels between machines. Rebuilding over a few hundred documents is immediate. |
| """ |
| if self._bm25 is None: |
| self._bm25 = BM25Okapi(self.corpus) |
| return self._bm25 |
|
|
| def search(self, query: str, limit: int) -> list[tuple[str, float]]: |
| """Top ``limit`` ``(chunk_id, score)`` pairs, best first. |
| |
| Chunks scoring zero share no term with the query and are dropped rather than |
| padded in: a zero-score document at rank 8 still earns Reciprocal Rank Fusion |
| weight, which would let an unrelated passage ride into the reranker. |
| """ |
| tokens = tokenize(query) |
| if not tokens: |
| return [] |
| scores = self.bm25.get_scores(tokens) |
| ranked = sorted( |
| ((self.chunk_ids[i], float(score)) for i, score in enumerate(scores) if score > 0.0), |
| key=lambda pair: (-pair[1], pair[0]), |
| ) |
| return ranked[:limit] |
|
|
| def __len__(self) -> int: |
| return len(self.chunk_ids) |
|
|