genAI-Project / src /generation /rag_pipeline.py
OGB2000's picture
Initial clean deployment
bf77be6
Raw
History Blame Contribute Delete
6.13 kB
"""
src/generation/rag_pipeline.py
--------------------------------
Scientific RAG pipeline with:
- grounded answer generation
- source citation
- hallucination control (out-of-corpus refusal)
- optional reranking stage
Compatible with lab_s6 rag_pipeline.py API.
"""
import time
from typing import Optional
# ---------------------------------------------------------------------------
# Prompt templates
# ---------------------------------------------------------------------------
SCIENTIFIC_RAG_PROMPT = """\
You are an expert scientific assistant. Answer ONLY from the provided context.
If the context does not contain the answer, reply EXACTLY:
"I cannot answer this question from the available scientific corpus."
Do NOT hallucinate or add information beyond the context.
### Context
{context}
### Question
{question}
### Answer (cite papers in [Title, Year] format when possible)
"""
NO_RAG_PROMPT = """\
You are a knowledgeable AI assistant. Answer the following question to the best of your ability.
### Question
{question}
### Answer
"""
# ---------------------------------------------------------------------------
# Citation helper
# ---------------------------------------------------------------------------
def format_sources(metadatas: list[dict], distances: list[float], rerank_scores: list[float] = None) -> list[dict]:
"""Format retrieved chunk metadata into source cards."""
sources = []
for i, (meta, dist) in enumerate(zip(metadatas, distances)):
score_label = f"{rerank_scores[i]:.3f}" if rerank_scores else f"{1 - dist:.3f}"
sources.append({
"rank": i + 1,
"paper_id": meta.get("paper_id", ""),
"title": meta.get("title", "Unknown"),
"authors": meta.get("authors_str", ""),
"year": meta.get("year", ""),
"section": meta.get("section", ""),
"score": score_label,
"chunk_preview": "", # filled below
})
return sources
# ---------------------------------------------------------------------------
# Core pipeline functions
# ---------------------------------------------------------------------------
def answer_without_rag(question: str, llm, max_new_tokens: int = 400) -> str:
"""Baseline: direct LLM generation with no retrieval context."""
prompt = NO_RAG_PROMPT.format(question=question)
return llm.generate(prompt, max_new_tokens=max_new_tokens)
def answer_with_rag(
question: str,
embedder,
store,
llm,
k: int = 5,
max_new_tokens: int = 400,
reranker=None,
reranker_k: int = 20,
metadata_filter: Optional[dict] = None,
) -> dict:
"""
Full scientific RAG pipeline.
Parameters
----------
question : user question
embedder : Embedder instance
store : ScientificChromaStore instance
llm : LLM backend (generate method)
k : final number of chunks in the context
max_new_tokens: generation budget
reranker : CrossEncoderReranker instance (optional)
reranker_k : first-stage retrieval size when reranking
metadata_filter: Chroma 'where' dict for filtering
Returns
-------
dict with keys:
question, retrieved, metadatas, distances, rerank_scores,
sources, prompt, answer, latency
"""
timings = {}
# Stage 1: Embed question
t0 = time.time()
q_vec = embedder.encode_one(question)
timings["embed_s"] = round(time.time() - t0, 3)
# Stage 2: Dense retrieval
t0 = time.time()
first_k = reranker_k if reranker else k
res = store.query(q_vec, k=first_k, where=metadata_filter)
timings["retrieve_s"] = round(time.time() - t0, 3)
rerank_scores = None
# Stage 3 (optional): Cross-encoder reranking
if reranker and res["documents"]:
t0 = time.time()
ranked = reranker.rerank(question, res, top_k=k)
timings["rerank_s"] = round(time.time() - t0, 3)
from src.retrieval.reranker import ranked_to_result
res = ranked_to_result(ranked)
rerank_scores = res.get("rerank_scores")
else:
# Keep only top-k from dense retrieval
for key in ["documents", "metadatas", "distances"]:
res[key] = res[key][:k]
# Stage 4: Build grounded prompt with context
context_parts = []
for i, (doc, meta) in enumerate(zip(res["documents"], res["metadatas"])):
citation = f"[{meta.get('title', 'Unknown')} ({meta.get('year', '?')})]"
context_parts.append(f"Source {i+1} {citation}:\n{doc}")
context = "\n\n---\n\n".join(context_parts)
prompt = SCIENTIFIC_RAG_PROMPT.format(context=context, question=question)
# Stage 5: Generate
t0 = time.time()
answer = llm.generate(prompt, max_new_tokens=max_new_tokens)
timings["generate_s"] = round(time.time() - t0, 3)
timings["total_s"] = round(sum(timings.values()), 3)
# Format source cards
sources = format_sources(res["metadatas"], res["distances"], rerank_scores)
for i, src in enumerate(sources):
src["chunk_preview"] = res["documents"][i][:200] + "..."
return {
"question": question,
"retrieved": res["documents"],
"metadatas": res["metadatas"],
"distances": res["distances"],
"rerank_scores": rerank_scores,
"sources": sources,
"prompt": prompt,
"answer": answer,
"latency": timings,
"used_reranker": reranker is not None,
}
def run_comparison(
question: str,
embedder,
store,
llm,
reranker=None,
k: int = 5,
) -> dict:
"""
Run all three experiment conditions for one question:
Exp1: No RAG, Exp2: RAG, Exp3: RAG + Reranker
Returns dict with keys: no_rag, rag, rag_reranker
"""
result = {
"question": question,
"no_rag": answer_without_rag(question, llm),
"rag": answer_with_rag(question, embedder, store, llm, k=k),
}
if reranker:
result["rag_reranker"] = answer_with_rag(
question, embedder, store, llm, k=k, reranker=reranker
)
return result