CoolFace
Apppublic

Harshavard21/FinRAG

sourceHugging Faceupdated 19d agoView on Hugging Face
2likes
bm25_index.py258 linesDownload Raw Back to vectorstore
1"""2src/vectorstore/bm25_index.py3==============================4BM25 sparse index for keyword-based retrieval.5 6Why BM25 alongside dense vectors?7    Dense vectors capture SEMANTIC meaning ("revenue growth" ≈ "sales increase")8    BM25 captures EXACT TERM matching ("₹4,234 crore" or "NPA 2.3%")9 10Financial documents are full of exact numbers and acronyms that11dense retrieval may miss. BM25 ensures we always catch exact matches.12 13This index is combined with Qdrant dense search via RRF in hybrid_retriever.py.14 15Storage:16    - BM25 index serialized to ./data/bm25_index/bm25.pkl17    - Document list serialized to ./data/bm25_index/docs.json18    - Both files must be regenerated when documents change19"""20 21from __future__ import annotations22 23import json24import pickle25import re26from dataclasses import dataclass27from pathlib import Path28from typing import Optional29 30from rank_bm25 import BM25Okapi31 32from config.settings import settings33from src.utils.logger import logger34from src.chunking.hierarchical_chunker import Chunk35 36 37@dataclass38class BM25Result:39    """A single result from BM25 keyword search."""40    chunk_id: str41    content: str42    bm25_score: float43    company: str44    ticker: str45    source_file: str46    fiscal_year: str47    page_number: int48    section: str49    subsection: str50    content_type: str51    chunk_level: str52    parent_chunk_id: Optional[str] = None53 54 55class BM25Index:56    """57    BM25 sparse index over all indexed chunks.58 59    Build once → persist to disk → reload on startup.60    Supports company/year filtering (post-filter after search).61    """62 63    INDEX_FILE = "bm25.pkl"64    DOCS_FILE = "docs.json"65 66    def __init__(self):67        self.index_dir = settings.bm25_index_path68        self.index_dir.mkdir(parents=True, exist_ok=True)69        self._bm25: Optional[BM25Okapi] = None70        self._docs: list[dict] = []   # stores chunk metadata + content71 72        # Try to load existing index73        if self._index_exists():74            self._load()75 76    # ---------------------------------------------------------------- #77    # Build78    # ---------------------------------------------------------------- #79 80    def build(self, chunks: list[Chunk]) -> None:81        """82        Build BM25 index from a list of chunks.83        Indexes the same chunks that go into Qdrant (child + atomic).84 85        Args:86            chunks: List of Chunk objects to index87        """88        logger.info(f"Building BM25 index over {len(chunks)} chunks...")89 90        self._docs = []91        tokenized_corpus = []92 93        for chunk in chunks:94            tokens = self._tokenize(chunk.content)95            tokenized_corpus.append(tokens)96 97            # Store chunk metadata for result reconstruction98            self._docs.append({99                "chunk_id": chunk.chunk_id,100                "content": chunk.content,101                "company": chunk.company,102                "ticker": chunk.ticker,103                "source_file": chunk.source_file,104                "fiscal_year": chunk.fiscal_year,105                "page_number": chunk.page_number,106                "section": chunk.section,107                "subsection": chunk.subsection,108                "content_type": chunk.content_type,109                "chunk_level": chunk.chunk_level,110                "parent_chunk_id": chunk.parent_chunk_id or "",111            })112 113        self._bm25 = BM25Okapi(tokenized_corpus)114        self._save()115        logger.info(f"BM25 index built and saved | {len(chunks)} documents")116 117    # ---------------------------------------------------------------- #118    # Search119    # ---------------------------------------------------------------- #120 121    def search(122        self,123        query: str,124        top_k: int = 30,125        company_filter: Optional[str | list[str]] = None,126        fiscal_year_filter: Optional[str] = None,127    ) -> list[BM25Result]:128        """129        BM25 keyword search with optional post-filtering.130 131        Args:132            query: Search query string133            top_k: Number of results to return134            company_filter: Filter by company name or list of companies135            fiscal_year_filter: Filter by fiscal year136 137        Returns:138            List of BM25Result sorted by score descending139        """140        if self._bm25 is None:141            logger.warning("BM25 index not loaded — call build() first")142            return []143 144        query_tokens = self._tokenize(query)145        scores = self._bm25.get_scores(query_tokens)146 147        # Pair scores with doc metadata and sort148        scored_docs = sorted(149            enumerate(scores),150            key=lambda x: x[1],151            reverse=True,152        )153 154        results = []155        for idx, score in scored_docs:156            if score <= 0:157                continue158            if len(results) >= top_k * 3:   # over-retrieve for post-filtering159                break160 161            doc = self._docs[idx]162 163            # Post-filter by company164            if company_filter:165                if isinstance(company_filter, list):166                    if doc["company"] not in company_filter:167                        continue168                else:169                    if doc["company"] != company_filter:170                        continue171 172            # Post-filter by fiscal year173            if fiscal_year_filter and doc["fiscal_year"] != fiscal_year_filter:174                continue175 176            results.append(BM25Result(177                chunk_id=doc["chunk_id"],178                content=doc["content"],179                bm25_score=float(score),180                company=doc["company"],181                ticker=doc["ticker"],182                source_file=doc["source_file"],183                fiscal_year=doc["fiscal_year"],184                page_number=doc["page_number"],185                section=doc["section"],186                subsection=doc["subsection"],187                content_type=doc["content_type"],188                chunk_level=doc["chunk_level"],189                parent_chunk_id=doc["parent_chunk_id"] or None,190            ))191 192            if len(results) >= top_k:193                break194 195        return results196 197    # ---------------------------------------------------------------- #198    # Tokenizer199    # ---------------------------------------------------------------- #200 201    def _tokenize(self, text: str) -> list[str]:202        """203        Tokenize text for BM25.204 205        Financial document specific:206        - Preserve numbers (4234, 2.3%) as tokens207        - Preserve ₹ symbol208        - Lowercase everything209        - Remove punctuation except . % ₹ (financial symbols)210        - Preserve common financial acronyms as single tokens211        """212        text = text.lower()213 214        # Keep numbers with decimals and % intact215        text = re.sub(r'[^\w\s₹.%,/-]', ' ', text)216 217        # Split on whitespace218        tokens = text.split()219 220        # Remove very short tokens (< 2 chars) except numbers221        tokens = [t for t in tokens if len(t) >= 2 or t.isdigit()]222 223        # Remove pure punctuation tokens224        tokens = [t for t in tokens if re.search(r'[a-zA-Z0-9]', t)]225 226        return tokens227 228    # ---------------------------------------------------------------- #229    # Persistence230    # ---------------------------------------------------------------- #231 232    def _index_exists(self) -> bool:233        return (234            (self.index_dir / self.INDEX_FILE).exists() and235            (self.index_dir / self.DOCS_FILE).exists()236        )237 238    def _save(self) -> None:239        """Persist index to disk."""240        with open(self.index_dir / self.INDEX_FILE, "wb") as f:241            pickle.dump(self._bm25, f)242        with open(self.index_dir / self.DOCS_FILE, "w", encoding="utf-8") as f:243            json.dump(self._docs, f, ensure_ascii=False)244        logger.debug("BM25 index saved to disk")245 246    def _load(self) -> None:247        """Load index from disk."""248        try:249            with open(self.index_dir / self.INDEX_FILE, "rb") as f:250                self._bm25 = pickle.load(f)251            with open(self.index_dir / self.DOCS_FILE, "r", encoding="utf-8") as f:252                self._docs = json.load(f)253            logger.info(f"BM25 index loaded | {len(self._docs)} documents")254        except Exception as e:255            logger.warning(f"Failed to load BM25 index: {e} — will rebuild on next ingest")256            self._bm25 = None257            self._docs = []258