Spaces:
Sleeping
Sleeping
| """ | |
| Advanced RAG System β The Best of All Worlds | |
| This system combines THREE techniques to give the highest quality answers: | |
| Step 1 β Query Rewriting: | |
| Instead of searching with just the original question, we ask the LLM to | |
| rephrase it N different ways. This helps catch relevant chunks that use | |
| different vocabulary than the user's exact wording. | |
| Example: "What is RAG?" might also be searched as | |
| "How does Retrieval-Augmented Generation work?" etc. | |
| Step 2 β Hybrid Retrieval (BM25 + FAISS): | |
| For EACH query variant, we run two searches in parallel: | |
| - BM25: keyword-based (good at exact word matches) | |
| - FAISS: vector/semantic (good at meaning matches) | |
| Then we merge all these ranked lists using Reciprocal Rank Fusion (RRF), | |
| which gives us the best of both search styles across all query variants. | |
| Step 3 β Cross-Encoder Reranking: | |
| After hybrid retrieval we have many candidate chunks. The cross-encoder | |
| reads the question + each chunk together (not separately) and assigns | |
| a relevance score. This is slower but far more accurate than the initial | |
| retrieval, so it filters out noise before the LLM sees any context. | |
| Step 4 β Answer Generation: | |
| The top-k reranked chunks are joined into a context block and passed to | |
| the LLM to generate a grounded answer. | |
| Why is this the "advanced" system? | |
| - More relevant context = more faithful, complete answers | |
| - Reranking filters out irrelevant chunks = less hallucination | |
| - Trade-off: ~3x more tokens and ~2x more latency vs naive RAG | |
| """ | |
| import asyncio | |
| import logging | |
| import time | |
| from langchain_groq import ChatGroq | |
| from langchain_core.prompts import ChatPromptTemplate | |
| from sentence_transformers import CrossEncoder | |
| from eval_framework.config import get_settings | |
| from eval_framework.systems.shared import SharedIndex, reciprocal_rank_fusion | |
| from eval_framework.systems.query_rewriting import _parse_query_variants | |
| from eval_framework.types import QAPair, SystemOutput | |
| logger = logging.getLogger(__name__) | |
| # Cost per output token for Llama models on Groq (used to estimate run cost) | |
| _COST_PER_OUTPUT_TOKEN = 0.59 / 1_000_000 | |
| # The cross-encoder model we use for reranking β small but very accurate | |
| _RERANKER_MODEL = "cross-encoder/ms-marco-MiniLM-L-6-v2" | |
| class AdvancedRAGSystem: | |
| """ | |
| The most powerful RAG system in this benchmark. | |
| Pipeline summary: | |
| User Question | |
| -> Query Rewriting (1 question becomes N) | |
| -> Hybrid Retrieval (BM25 + FAISS for every variant) | |
| -> RRF Merge (combine all ranked lists into one) | |
| -> Cross-Encoder Reranking (score each chunk more carefully) | |
| -> LLM Answer Generation (using only the top-k chunks) | |
| """ | |
| def __init__( | |
| self, | |
| index: SharedIndex, | |
| top_k: int = 3, # How many chunks to send to the LLM after reranking | |
| candidate_k: int = 10, # How many chunks to retrieve before reranking | |
| n_variants: int = 3, # How many query rewrites to generate | |
| model_name: str = "llama-3.3-70b-versatile", | |
| reranker: CrossEncoder | None = None, | |
| ): | |
| """ | |
| Args: | |
| index: The shared FAISS + BM25 index (built once, shared across systems). | |
| top_k: Final number of chunks fed to the LLM. Fewer = less noise. | |
| candidate_k: Retrieval pool size before reranking. Bigger = more recall. | |
| n_variants: Number of query rewrites to generate. More = better recall, | |
| but more LLM tokens spent on rewriting. | |
| model_name: Groq model used for both query rewriting and answer generation. | |
| reranker: Optional pre-loaded CrossEncoder. Pass this when running | |
| multiple systems in one script so the model isn't downloaded twice. | |
| """ | |
| self._index = index | |
| self.top_k = top_k | |
| self.candidate_k = candidate_k | |
| self.n_variants = n_variants | |
| self.model_name = model_name | |
| settings = get_settings() | |
| # LLM used for query rewriting β slightly higher temperature (0.4) so the | |
| # rephrased questions are diverse rather than near-identical copies. | |
| self._llm = ChatGroq( | |
| api_key=settings.groq_api_key, | |
| model_name=model_name, | |
| temperature=0.4, | |
| max_tokens=256, | |
| ) | |
| # LLM used for answer generation β low temperature (0.1) for factual, | |
| # consistent answers. More tokens allowed because answers can be detailed. | |
| self._llm_answer = ChatGroq( | |
| api_key=settings.groq_api_key, | |
| model_name=model_name, | |
| temperature=0.1, | |
| max_tokens=512, | |
| ) | |
| # Load the cross-encoder reranker. | |
| # If one was passed in from outside (e.g. compare_systems.py loads it once | |
| # and shares it), reuse it β no point downloading the model twice. | |
| if reranker is not None: | |
| self._reranker = reranker | |
| else: | |
| print(f"Loading cross-encoder reranker ({_RERANKER_MODEL})...") | |
| self._reranker = CrossEncoder(_RERANKER_MODEL) | |
| # --- Prompt Templates --- | |
| # Prompt that asks the LLM to rephrase the user's question N different ways. | |
| # The output is a JSON array of strings which we parse in _parse_query_variants(). | |
| self._rewrite_prompt = ChatPromptTemplate.from_template( | |
| f"Generate {n_variants} different ways to ask the following question. " | |
| "Use different wording but ask for the same information. " | |
| f"Return exactly {n_variants} variants as a JSON array of strings.\n\n" | |
| "Question: {question}\n\n" | |
| "JSON array:" | |
| ) | |
| # Prompt that instructs the LLM to answer using ONLY the provided context. | |
| # This grounding instruction is key to reducing hallucinations. | |
| self._answer_prompt = ChatPromptTemplate.from_template( | |
| "You are a precise question-answering assistant. " | |
| "Answer the question using ONLY the information in the context below. " | |
| "If the context does not contain enough information, say: " | |
| "'The document does not contain enough information to answer this question.'\n\n" | |
| "Context:\n{context}\n\n" | |
| "Question: {question}\n\n" | |
| "Answer:" | |
| ) | |
| async def query(self, qa_pair: QAPair) -> SystemOutput: | |
| """ | |
| Full 4-stage pipeline: rewrite -> retrieve -> rerank -> answer. | |
| Args: | |
| qa_pair: Contains the user's question (and optionally a reference answer). | |
| Returns: | |
| SystemOutput with the generated answer, latency, and cost info. | |
| """ | |
| start = time.time() | |
| chunks = self._index.chunks | |
| # Build a lookup table: chunk text -> its position (index) in the chunks list. | |
| # FAISS returns Document objects, not indices, so we need this to convert back. | |
| content_to_idx = {chunk.page_content: i for i, chunk in enumerate(chunks)} | |
| # ββ STAGE 1: Query Rewriting ββββββββββββββββββββββββββββββββββββββββββ | |
| # Ask the LLM to generate N alternative phrasings of the question. | |
| # This broadens the vocabulary we search with, improving recall. | |
| rw_messages = await self._rewrite_prompt.ainvoke({"question": qa_pair.question}) | |
| rw_response = await self._llm.ainvoke(rw_messages) | |
| # Parse the JSON array of rewritten questions (falls back to [] on error). | |
| variants = _parse_query_variants(rw_response.content, qa_pair.question) | |
| # Combine the original question with all its variants. | |
| # Example: ["What is RAG?", "How does RAG work?", "Explain RAG", ...] | |
| all_queries = [qa_pair.question] + variants | |
| # ββ STAGE 2: Hybrid Retrieval βββββββββββββββββββββββββββββββββββββββββ | |
| # For every query variant, run both BM25 and FAISS searches. | |
| # Then merge all the ranked result lists using Reciprocal Rank Fusion. | |
| # | |
| # We run this in a thread executor because both BM25 and FAISS are | |
| # CPU-bound / synchronous operations β running them in the async event | |
| # loop directly would block other coroutines. | |
| def _hybrid_retrieve_all(): | |
| # Collect ranked result lists from both search methods | |
| bm25_rankings = [] # List of lists: each inner list = chunk indices in BM25 rank order | |
| faiss_rankings = [] # Same for FAISS | |
| for query in all_queries: | |
| # BM25 search β returns (score, text, chunk_index) tuples | |
| bm25_results = self._index.bm25.retrieve(query, k=self.candidate_k) | |
| bm25_rankings.append([idx for _, _, idx in bm25_results]) | |
| # FAISS semantic search β returns Document objects | |
| faiss_docs = self._index.vectorstore.similarity_search(query, k=self.candidate_k) | |
| # Convert Document objects back to chunk indices using our lookup table. | |
| # Skip any docs not found in the table (shouldn't happen, but -1 is the sentinel). | |
| faiss_ranking = [content_to_idx.get(doc.page_content, -1) for doc in faiss_docs] | |
| faiss_rankings.append([i for i in faiss_ranking if i >= 0]) | |
| # Combine ALL ranked lists (BM25 + FAISS from every query variant) | |
| # and merge them with Reciprocal Rank Fusion. | |
| # RRF gives higher combined scores to chunks that rank highly across | |
| # multiple lists, naturally surfacing the most consistently relevant chunks. | |
| all_rankings = bm25_rankings + faiss_rankings | |
| merged_indices = reciprocal_rank_fusion(all_rankings) | |
| # Return the top candidate_k chunks as Document objects | |
| return [chunks[i] for i in merged_indices[:self.candidate_k]] | |
| candidates = await asyncio.get_event_loop().run_in_executor( | |
| None, _hybrid_retrieve_all | |
| ) | |
| # ββ STAGE 3: Cross-Encoder Reranking βββββββββββββββββββββββββββββββββ | |
| # The cross-encoder reads the question AND each candidate chunk together | |
| # in a single forward pass, giving it much more context than the retrieval | |
| # step (which embeds question and chunks separately). | |
| # | |
| # Result: a relevance score per candidate. We keep only the top_k. | |
| # This is also CPU-bound, so we run it in a thread executor. | |
| def _rerank(candidates): | |
| # Build (question, chunk_text) pairs for the cross-encoder to score | |
| pairs = [(qa_pair.question, doc.page_content) for doc in candidates] | |
| # Cross-encoder returns a score for each pair (higher = more relevant) | |
| scores = self._reranker.predict(pairs) | |
| # Sort by score descending and keep only the top_k chunks | |
| ranked = sorted(zip(scores, candidates), key=lambda x: x[0], reverse=True) | |
| return [doc for _, doc in ranked[:self.top_k]] | |
| source_docs = await asyncio.get_event_loop().run_in_executor( | |
| None, lambda: _rerank(candidates) | |
| ) | |
| # ββ STAGE 4: Answer Generation ββββββββββββββββββββββββββββββββββββββββ | |
| # Join the top-k reranked chunks into one context block. | |
| # The "---" separator makes it clear to the LLM where each chunk ends. | |
| context = "\n\n---\n\n".join(doc.page_content for doc in source_docs) | |
| messages = await self._answer_prompt.ainvoke( | |
| {"context": context, "question": qa_pair.question} | |
| ) | |
| response = await self._llm_answer.ainvoke(messages) | |
| answer = response.content | |
| # ββ Bookkeeping βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| latency_ms = (time.time() - start) * 1000 | |
| # Store the context back on the QAPair so evaluators can use it | |
| # (e.g. faithfulness checks that the answer is grounded in this context) | |
| if context: | |
| qa_pair.context = context | |
| # Rough cost estimate: word count * 1.3 approximates token count, | |
| # then multiply by the per-token price. | |
| rw_tokens = len(rw_response.content.split()) * 1.3 # tokens used for query rewriting | |
| ans_tokens = len(answer.split()) * 1.3 # tokens used for the answer | |
| estimated_cost = (rw_tokens + ans_tokens) * _COST_PER_OUTPUT_TOKEN | |
| logger.info( | |
| f"AdvancedRAG answered in {latency_ms:.0f}ms | " | |
| f"{len(all_queries)} queries -> {len(candidates)} candidates -> " | |
| f"{len(source_docs)} after reranking" | |
| ) | |
| return SystemOutput( | |
| answer=answer, | |
| latency_ms=latency_ms, | |
| cost_usd=estimated_cost, | |
| model=self.model_name, | |
| metadata={ | |
| "system": "advanced_rag", | |
| "n_query_variants": len(variants), # how many rewrites were generated | |
| "candidates_before_rerank": len(candidates), # pool size going into reranker | |
| "chunks_after_rerank": len(source_docs), # what the LLM actually sees | |
| }, | |
| ) | |