Mattral's picture
Add Multimodal Financial RAG app
2fe3a9e
Raw
History Blame Contribute Delete
7.56 kB
"""utils/retriever.py — Embedding, FAISS indexing, and hybrid BM25+dense retrieval.
Mirrors src/rag_system/components/retriever/__init__.py's HybridRetriever:
RRF fusion (k=60, dense x 0.7 + BM25 x 0.3).
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from typing import Dict, List, Optional, Tuple
import numpy as np
from utils.pdf_processor import DocumentChunk
@dataclass
class RetrievedChunk:
chunk: DocumentChunk
dense_score: float
bm25_score: float
rrf_score: float
rank: int
@property
def text(self) -> str:
return self.chunk.text
@property
def source(self) -> str:
return f"{self.chunk.source_filename}, page {self.chunk.page_number}"
class EmbeddingModel:
"""Lazy-loaded sentence-transformers embedder with in-memory cache."""
def __init__(self, model_name: str = "BAAI/bge-small-en-v1.5") -> None:
self._model_name = model_name
self._model = None
self._cache: Dict[str, List[float]] = {}
def _get_model(self):
if self._model is None:
from sentence_transformers import SentenceTransformer
self._model = SentenceTransformer(self._model_name)
return self._model
def embed(self, texts: List[str]) -> np.ndarray:
model = self._get_model()
uncached = [t for t in texts if t not in self._cache]
if uncached:
vecs = model.encode(uncached, normalize_embeddings=True, show_progress_bar=False)
for text, vec in zip(uncached, vecs, strict=True):
self._cache[text] = vec.tolist()
return np.array([self._cache[t] for t in texts], dtype=np.float32)
def embed_query(self, query: str) -> np.ndarray:
return self.embed([query])[0]
class BM25Index:
def __init__(self) -> None:
self._bm25 = None
self._corpus: List[List[str]] = []
def _tokenize(self, text: str) -> List[str]:
return re.sub(r"[^a-zA-Z0-9$%.,]", " ", text.lower()).split()
def build(self, chunks: List[DocumentChunk]) -> None:
self._corpus = [self._tokenize(c.text) for c in chunks]
try:
from rank_bm25 import BM25Okapi
self._bm25 = BM25Okapi(self._corpus)
except ImportError:
self._bm25 = None
def score(self, query: str) -> np.ndarray:
tokens = self._tokenize(query)
if not self._corpus:
return np.array([])
if self._bm25 is not None:
scores = np.array(self._bm25.get_scores(tokens))
else:
scores = np.array([
sum(doc.count(t) for t in tokens) / max(len(doc), 1)
for doc in self._corpus
], dtype=np.float32)
max_score = scores.max()
if max_score > 0:
scores = scores / max_score
return scores
def reciprocal_rank_fusion(
dense_ranks: List[int],
bm25_ranks: List[int],
k: int = 60,
dense_weight: float = 0.7,
bm25_weight: float = 0.3,
) -> List[float]:
"""RRF(d) = sum(weight_i / (k + rank_i(d))). k=60 per Cormack et al. 2009."""
n = len(dense_ranks)
scores = []
for i in range(n):
rrf = (dense_weight / (k + dense_ranks[i] + 1)) + (bm25_weight / (k + bm25_ranks[i] + 1))
scores.append(rrf)
return scores
class VectorIndex:
"""FAISS-backed dense vector index with BM25 hybrid retrieval."""
def __init__(self, embedding_model: Optional[EmbeddingModel] = None) -> None:
self._embedder = embedding_model or EmbeddingModel()
self._chunks: List[DocumentChunk] = []
self._index = None
self._bm25 = BM25Index()
def build(self, chunks: List[DocumentChunk]) -> List[str]:
if not chunks:
return ["No chunks to index"]
steps = []
self._chunks = chunks
steps.append(f"Embedding {len(chunks)} chunks with BAAI/bge-small-en-v1.5...")
texts = [c.text for c in chunks]
embeddings = self._embedder.embed(texts)
try:
import faiss
dim = embeddings.shape[1]
self._index = faiss.IndexFlatIP(dim)
self._index.add(embeddings)
steps.append(f"Built FAISS IndexFlatIP (dim={dim}, {len(chunks)} vectors)")
except ImportError:
self._index = embeddings
steps.append("Built NumPy cosine index (FAISS not installed)")
self._bm25.build(chunks)
steps.append("Built BM25 keyword index for hybrid retrieval")
steps.append(f"Index ready - {len(chunks)} chunks searchable")
return steps
def search(
self, query: str, top_k: int = 5, chunk_type_filter: Optional[str] = None,
) -> Tuple[List[RetrievedChunk], List[str]]:
steps = []
if not self._chunks:
return [], ["Index is empty - ingest a document first"]
steps.append("Embedding query for dense retrieval...")
query_vec = self._embedder.embed_query(query).astype(np.float32)
candidate_k = min(len(self._chunks), top_k * 4)
if hasattr(self._index, "search"):
scores, indices = self._index.search(query_vec.reshape(1, -1), candidate_k)
dense_scores = scores[0].tolist()
dense_indices = indices[0].tolist()
else:
sims = self._index @ query_vec
ranked = np.argsort(-sims)[:candidate_k]
dense_indices = ranked.tolist()
dense_scores = sims[ranked].tolist()
steps.append(f"Dense retrieval: top-{candidate_k} candidates via cosine similarity")
bm25_all_scores = self._bm25.score(query)
if len(bm25_all_scores) > 0:
bm25_ranked = np.argsort(-bm25_all_scores)[:candidate_k].tolist()
else:
bm25_ranked = list(range(min(candidate_k, len(self._chunks))))
steps.append(f"BM25 keyword retrieval: top-{len(bm25_ranked)} candidates")
all_candidate_indices = list(dict.fromkeys(dense_indices + bm25_ranked))
def _dense_rank(idx: int) -> int:
try:
return dense_indices.index(idx)
except ValueError:
return candidate_k
def _bm25_rank(idx: int) -> int:
try:
return bm25_ranked.index(idx)
except ValueError:
return candidate_k
rrf_scores = []
for idx in all_candidate_indices:
rrf = (0.7 / (60 + _dense_rank(idx) + 1)) + (0.3 / (60 + _bm25_rank(idx) + 1))
rrf_scores.append((idx, rrf))
rrf_scores.sort(key=lambda x: x[1], reverse=True)
steps.append(f"RRF fusion (k=60, dense x0.7 + BM25 x0.3): merged {len(rrf_scores)} candidates")
results = []
for rank, (idx, rrf_score) in enumerate(rrf_scores):
if idx >= len(self._chunks):
continue
chunk = self._chunks[idx]
if chunk_type_filter and chunk.chunk_type != chunk_type_filter:
continue
d_score = dense_scores[dense_indices.index(idx)] if idx in dense_indices else 0.0
b_score = float(bm25_all_scores[idx]) if len(bm25_all_scores) > idx else 0.0
results.append(RetrievedChunk(
chunk=chunk, dense_score=float(d_score), bm25_score=b_score,
rrf_score=rrf_score, rank=rank + 1,
))
if len(results) >= top_k:
break
steps.append(f"Retrieved {len(results)} chunks (top-k={top_k})")
return results, steps