Aditya
Deploy RAG benchmark dashboard
af383cf
Raw
History Blame Contribute Delete
10.1 kB
"""
Shared Index β€” Built Once, Used by All RAG Systems
Why does this exist?
Every RAG system in this benchmark (Hybrid, Reranking, HyDE, QueryRewriting,
Advanced) needs to search the same knowledge base. Each search requires:
- A FAISS vectorstore (for semantic/embedding-based search)
- A BM25 index (for keyword-based search)
- The embedding model that was used to build the FAISS index
Building these from scratch for each system would mean:
- Loading the ~80MB embedding model 6 separate times
- Re-encoding all 35 chunks 6 separate times
That's slow and wasteful.
Instead, we build everything ONCE here and share it across all systems.
What is FAISS?
Facebook AI Similarity Search β€” a library for fast vector search.
We store each text chunk as a vector (list of numbers capturing its meaning).
When querying, we embed the question and find the chunks with the closest vectors.
What is BM25?
Best Match 25 β€” a classic information retrieval algorithm based on word frequency.
No neural network needed. It scores chunks based on how often the query words
appear in them (with adjustments for document length and word rarity).
Fast, interpretable, and great at exact keyword matches.
What is Reciprocal Rank Fusion (RRF)?
A formula for merging multiple ranked lists into one combined ranking.
Example: BM25 ranks chunk A at #1, FAISS ranks it at #3.
BM25 ranks chunk B at #5, FAISS ranks it at #1.
RRF combines these positions into a single score for each chunk.
The formula: score = 1/(60 + rank) β€” summed across all lists.
The constant 60 prevents any single top-ranked result from dominating.
"""
import logging
from pathlib import Path
import numpy as np
from langchain_community.document_loaders import PyPDFLoader, TextLoader
from langchain_community.vectorstores import FAISS
from langchain_huggingface import HuggingFaceEmbeddings
from langchain_text_splitters import RecursiveCharacterTextSplitter
from rank_bm25 import BM25Okapi
logger = logging.getLogger(__name__)
# Rough cost per output token for Groq-hosted Llama models
_COST_PER_OUTPUT_TOKEN = 0.59 / 1_000_000
class BM25Index:
"""
A simple wrapper around the rank_bm25 library to give it a consistent
interface that matches how we call FAISS (retrieve method with k parameter).
BM25 works by:
1. Splitting every chunk into individual words (tokenization)
2. When searching, splitting the query into words too
3. Scoring each chunk based on how often query words appear in it,
adjusted for document length (so longer docs don't get unfair advantage)
"""
def __init__(self, chunks):
"""
Build the BM25 index from a list of document chunks.
Args:
chunks: List of LangChain Document objects (the same chunks used by FAISS).
"""
self.chunks = chunks
# Tokenize: split each chunk's text into lowercase words
# BM25 compares word overlap, so we normalize to lowercase
tokenized = [doc.page_content.lower().split() for doc in chunks]
# Build the BM25 index from the tokenized chunks
self._bm25 = BM25Okapi(tokenized)
def retrieve(self, query: str, k: int) -> list:
"""
Find the top-k chunks most relevant to the query by keyword overlap.
Args:
query: The search query (will be tokenized the same way as chunks).
k: Number of top results to return.
Returns:
List of (Document, score, original_index) tuples, sorted by score descending.
The original_index lets callers map results back to the full chunks list.
"""
# Tokenize the query the same way we tokenized the chunks
tokens = query.lower().split()
# Get a BM25 relevance score for each chunk
scores = self._bm25.get_scores(tokens)
# argsort gives indices that would sort the array ascending,
# [::-1] reverses it to descending (highest score first), [:k] takes top-k
top_indices = np.argsort(scores)[::-1][:k]
return [(self.chunks[i], float(scores[i]), i) for i in top_indices]
def reciprocal_rank_fusion(rankings: list[list[int]], k: int = 60) -> list[int]:
"""
Combine multiple ranked lists of chunk indices into one merged ranking.
This is the "glue" that makes Hybrid RAG, Query Rewriting, and Advanced RAG
work β€” it takes rankings from different search methods/queries and produces
one unified ranking.
How it works:
Each chunk in each ranked list gets a score of: 1 / (k + rank)
where rank is its position in that list (0-based).
These scores are summed across all lists.
Chunks that rank well in multiple lists accumulate higher total scores.
Why k=60?
The constant k prevents the top-ranked item from scoring infinitely higher
than the second. It's an empirically validated default from the original paper.
Example:
BM25 ranking: [3, 7, 12, ...]
FAISS ranking: [7, 3, 21, ...]
Chunk 3: 1/(60+0) + 1/(60+1) = 0.01667 + 0.01639 = 0.03306
Chunk 7: 1/(60+1) + 1/(60+0) = 0.01639 + 0.01667 = 0.03306 (same β€” tied!)
Chunk 12: 1/(60+2) = 0.01613 (only in BM25)
Chunk 21: 1/(60+2) = 0.01613 (only in FAISS)
Args:
rankings: List of ranked lists. Each inner list contains chunk indices
sorted from most to least relevant according to one search method.
k: RRF smoothing constant (default 60 is standard).
Returns:
Single list of chunk indices sorted by combined RRF score (best first).
"""
scores: dict[int, float] = {}
for ranking in rankings:
for rank, chunk_idx in enumerate(ranking):
# Add this chunk's RRF contribution for this ranked list
# rank+1 because RRF is conventionally 1-based (rank=0 -> position 1)
scores[chunk_idx] = scores.get(chunk_idx, 0.0) + 1.0 / (k + rank + 1)
# Sort chunks by their total accumulated RRF score, highest first
return sorted(scores, key=lambda i: scores[i], reverse=True)
class SharedIndex:
"""
Loads a document, splits it into chunks, and builds both FAISS and BM25 indexes.
Build this ONCE and pass it to every RAG system. All systems will search
the exact same index, ensuring a fair comparison.
Usage:
index = SharedIndex("data/knowledge_base.txt").build()
naive_rag = NaiveRAGSystem(index, ...)
hybrid_rag = HybridRAGSystem(index, ...)
advanced_rag = AdvancedRAGSystem(index, ...)
"""
def __init__(
self,
doc_path: str | Path,
chunk_size: int = 500, # Each chunk is at most 500 characters
chunk_overlap: int = 75, # Chunks overlap by 75 chars so context isn't cut off at boundaries
):
"""
Args:
doc_path: Path to the knowledge base file (.txt or .pdf).
chunk_size: Maximum size of each text chunk in characters.
Smaller = more precise retrieval, larger = more context per chunk.
chunk_overlap: How many characters each chunk shares with the previous one.
Prevents important sentences from being split across chunks.
"""
self.doc_path = Path(doc_path)
self.chunk_size = chunk_size
self.chunk_overlap = chunk_overlap
# These are populated when build() is called
self.chunks = []
self.embeddings: HuggingFaceEmbeddings | None = None
self.vectorstore: FAISS | None = None
self.bm25: BM25Index | None = None
def build(self) -> "SharedIndex":
"""
Load the document, split it, embed it, and build both indexes.
Returns:
self β€” so you can chain: index = SharedIndex(...).build()
"""
if not self.doc_path.exists():
raise FileNotFoundError(f"Document not found: {self.doc_path}")
# --- Step 1: Load the document ---
# Support both PDF (multi-page) and plain text files
if self.doc_path.suffix.lower() == ".pdf":
loader = PyPDFLoader(str(self.doc_path))
else:
loader = TextLoader(str(self.doc_path), encoding="utf-8")
documents = loader.load()
logger.info(f"Loaded {len(documents)} page(s) from {self.doc_path}")
# --- Step 2: Split into chunks ---
# RecursiveCharacterTextSplitter tries to split on paragraph breaks (\n\n)
# first, then line breaks, then sentences, etc. β€” preserving natural boundaries.
splitter = RecursiveCharacterTextSplitter(
chunk_size=self.chunk_size,
chunk_overlap=self.chunk_overlap,
separators=["\n\n", "\n", ". ", " ", ""],
)
self.chunks = splitter.split_documents(documents)
logger.info(f"Split into {len(self.chunks)} chunks")
# --- Step 3: Build the FAISS vectorstore ---
# This downloads the embedding model (~80MB) on first run, then caches it.
# The model converts text chunks into 384-dimensional vectors.
print("Loading embedding model (downloads once ~80MB)...")
self.embeddings = HuggingFaceEmbeddings(
model_name="all-MiniLM-L6-v2",
model_kwargs={"device": "cpu"},
encode_kwargs={"normalize_embeddings": True}, # normalize for cosine similarity
)
# Embed all chunks and store them in a FAISS index for fast vector search
self.vectorstore = FAISS.from_documents(self.chunks, self.embeddings)
logger.info("FAISS index built")
# --- Step 4: Build the BM25 index ---
# BM25 doesn't need embeddings β€” it works directly on the text
self.bm25 = BM25Index(self.chunks)
logger.info("BM25 index built")
print(f"Shared index ready β€” {len(self.chunks)} chunks, FAISS + BM25")
return self