File size: 10,082 Bytes
af383cf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
"""
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