Aditya
Deploy RAG benchmark dashboard
af383cf
Raw
History Blame Contribute Delete
7.58 kB
"""Real RAG pipeline using LangChain + FAISS + Groq.
This is what replaces mock_system() in the CLI. Instead of echoing back the
reference answer, this:
1. Loads your document (PDF or .txt)
2. Splits it into overlapping chunks
3. Embeds chunks locally using sentence-transformers (all-MiniLM-L6-v2)
4. Stores vectors in FAISS (in-memory, no server needed)
5. On each question: retrieves top-k chunks → sends to Groq LLM → returns answer
The answer and retrieved context are both returned so the faithfulness evaluator
can check whether the answer is grounded in what was actually retrieved.
"""
import asyncio
import logging
import time
from pathlib import Path
from langchain_community.document_loaders import PyPDFLoader, TextLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_community.vectorstores import FAISS
from langchain_huggingface import HuggingFaceEmbeddings
from langchain_groq import ChatGroq
from langchain_core.prompts import ChatPromptTemplate
from eval_framework.config import get_settings
from eval_framework.types import QAPair, SystemOutput
logger = logging.getLogger(__name__)
# Token cost estimate for llama-3.3-70b on Groq
# (Groq free tier is actually free, but we estimate for the cost metric)
_COST_PER_OUTPUT_TOKEN = 0.59 / 1_000_000
class RAGPipeline:
"""
Simple but real RAG system. Compatible with the EvaluationPipeline interface:
async def query(self, qa_pair: QAPair) -> SystemOutput
Usage:
rag = RAGPipeline("data/my_doc.pdf").build()
# Then pass rag.query as the system function:
report = await pipeline.run(system=rag.query, dataset=..., system_name="rag-v1")
"""
def __init__(
self,
doc_path: str | Path,
chunk_size: int = 500,
chunk_overlap: int = 75,
top_k: int = 3,
model_name: str = "llama-3.3-70b-versatile",
):
"""
Args:
doc_path: Path to a .pdf or .txt file to use as the knowledge base.
chunk_size: Characters per chunk. Smaller = more precise retrieval,
larger = more context per chunk.
chunk_overlap: Characters of overlap between adjacent chunks so
sentences don't get cut at boundaries.
top_k: How many chunks to retrieve per question.
model_name: Groq model to use for answer generation.
"""
self.doc_path = Path(doc_path)
self.chunk_size = chunk_size
self.chunk_overlap = chunk_overlap
self.top_k = top_k
self.model_name = model_name
self._llm = None
self._retriever = None
self._prompt = None
def build(self) -> "RAGPipeline":
"""
Load the document, embed chunks, and build the retrieval chain.
Call this once before evaluation starts.
"""
if not self.doc_path.exists():
raise FileNotFoundError(f"Document not found: {self.doc_path}")
logger.info(f"Loading document: {self.doc_path}")
# Step 1: Load document
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)")
# Step 2: Split into chunks
splitter = RecursiveCharacterTextSplitter(
chunk_size=self.chunk_size,
chunk_overlap=self.chunk_overlap,
separators=["\n\n", "\n", ". ", " ", ""],
)
chunks = splitter.split_documents(documents)
logger.info(f"Split into {len(chunks)} chunks")
# Step 3: Embed locally (no API key needed, ~80MB model downloads once)
print("Loading embedding model (downloads once ~80MB)...")
embeddings = HuggingFaceEmbeddings(
model_name="all-MiniLM-L6-v2",
model_kwargs={"device": "cpu"},
encode_kwargs={"normalize_embeddings": True},
)
# Step 4: Build FAISS vector store (in-memory, instant)
vectorstore = FAISS.from_documents(chunks, embeddings)
logger.info("FAISS index built")
# Step 5: Build Groq LLM
settings = get_settings()
self._llm = ChatGroq(
api_key=settings.groq_api_key,
model_name=self.model_name,
temperature=0.1, # Low temp = more factual, less creative
max_tokens=512,
)
# Step 6: Store retriever for use in query()
self._retriever = vectorstore.as_retriever(
search_type="similarity",
search_kwargs={"k": self.top_k},
)
# Step 7: RAG prompt — tells the LLM to stay grounded in context
self._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:"
)
print(f"RAG pipeline ready ({len(chunks)} chunks indexed)")
return self
async def query(self, qa_pair: QAPair) -> SystemOutput:
"""
Query the RAG system for a single QA pair.
This is the function you pass as `system=` to EvaluationPipeline.run().
It also sets qa_pair.context to the retrieved chunks so faithfulness
and context_precision evaluators can check the answer against them.
"""
if self._retriever is None:
raise RuntimeError("Call .build() before querying the RAG pipeline.")
start = time.time()
# Step 1: Retrieve relevant chunks (sync call in thread pool)
source_docs = await asyncio.get_event_loop().run_in_executor(
None,
lambda: self._retriever.invoke(qa_pair.question),
)
# Step 2: Format context from retrieved chunks
context = "\n\n---\n\n".join(doc.page_content for doc in source_docs)
# Step 3: Build prompt and call Groq LLM (async)
messages = await self._prompt.ainvoke({"context": context, "question": qa_pair.question})
response = await self._llm.ainvoke(messages)
answer = response.content
latency_ms = (time.time() - start) * 1000
# Inject retrieved context into the qa_pair so evaluators can use it.
# This is what makes faithfulness meaningful — we're checking the answer
# against what was actually retrieved, not some pre-written context.
if context:
qa_pair.context = context
# Rough cost estimate (output tokens × price)
output_tokens = len(answer.split()) * 1.3
estimated_cost = output_tokens * _COST_PER_OUTPUT_TOKEN
logger.info(
f"RAG answered in {latency_ms:.0f}ms | "
f"{len(source_docs)} chunks retrieved | "
f"answer length: {len(answer)} chars"
)
return SystemOutput(
answer=answer,
latency_ms=latency_ms,
cost_usd=estimated_cost,
model=self.model_name,
metadata={
"chunks_retrieved": len(source_docs),
"source_snippets": [doc.page_content[:100] for doc in source_docs],
},
)