Aditya
Deploy RAG benchmark dashboard
af383cf
Raw
History Blame Contribute Delete
13.6 kB
"""
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
},
)