"""RAG skeleton shared by all stacks. The pipeline is identical for the three architectures: retrieve the chunks, assemble the prompt, call the LLM. Only the retrieval strategy changes from one stack to another. So we factor the orchestration out here; a stack only provides its `retriever` (an object exposing `search(query, k) -> list[dict]`). """ import time from typing import Callable, Protocol from .prompts import DEFAULT_PROMPT_TEMPLATE, build_prompt class Retriever(Protocol): """Common retriever interface: return the k relevant chunks.""" def search(self, query: str, k: int = 5) -> list[dict]: ... class BaseRAG: """Generic RAG pipeline: retrieval -> prompt -> generation, with latencies.""" def __init__( self, retriever: Retriever, llm_fn: Callable[[str], str], prompt_template: str | None = None, ): self.retriever = retriever self.llm_fn = llm_fn self.prompt_template = prompt_template or DEFAULT_PROMPT_TEMPLATE def query(self, question: str, k: int = 5) -> dict: """Run the pipeline. Returns {answer, contexts, retrieval_ms, generation_ms, latency_ms}.""" start = time.perf_counter() contexts = self.retriever.search(question, k=k) retrieval_ms = (time.perf_counter() - start) * 1000 prompt = build_prompt(question, contexts, self.prompt_template) gen_start = time.perf_counter() answer = self.llm_fn(prompt) generation_ms = (time.perf_counter() - gen_start) * 1000 return { "answer": answer, "contexts": contexts, "retrieval_ms": round(retrieval_ms, 2), "generation_ms": round(generation_ms, 2), "latency_ms": round((time.perf_counter() - start) * 1000, 2), }