1"""RAG benchmarking harness for comparing pipeline configurations."""
2
3from __future__ import annotations
4
5import asyncio
6from dataclasses import dataclass, field
7from datetime import UTC, datetime
8import time
9from typing import Any
10
11from lexigram.ai.rag.evaluation.evaluator import RAGEvaluator
12from lexigram.ai.rag.evaluation.retrieval import (
13 RetrievalPrecisionEvaluator,
14 RetrievalRecallEvaluator,
15)
16from lexigram.ai.rag.evaluation.types import MetricType, RAGEvaluationReport
17from lexigram.contracts.ai.rag import RAGContext, RAGPipelineProtocol
18from lexigram.logging import (
19 get_logger,
20)
21
22logger = get_logger(__name__)
23
24
25@dataclass
26class EvalExample:
27 """A single benchmark example with ground truth.
28
29 Attributes:
30 query: The query to evaluate.
31 relevant_doc_ids: Ground-truth relevant document identifiers.
32 reference_answer: Optional reference answer for answer-quality metrics.
33 metadata: Arbitrary metadata attached to this example.
34 """
35
36 query: str
37 relevant_doc_ids: list[str]
38 reference_answer: str | None = None
39 metadata: dict[str, Any] = field(default_factory=dict)
40
41
42@dataclass
43class PipelineResult:
44 """Result of running a single pipeline on a single example.
45
46 Attributes:
47 pipeline_name: Identifier of the pipeline that produced this result.
48 query: The query that was evaluated.
49 retrieved_doc_ids: Identifiers of documents returned by the pipeline.
50 answer: Generated answer.
51 latency_ms: End-to-end latency in milliseconds.
52 error: Error message if the pipeline failed.
53 """
54
55 pipeline_name: str
56 query: str
57 retrieved_doc_ids: list[str]
58 answer: str
59 latency_ms: float
60 error: str | None = None
61
62
63@dataclass
64class BenchmarkReport:
65 """Comparative benchmark report across multiple RAG pipelines.
66
67 Attributes:
68 pipeline_names: Names of all benchmarked pipelines.
69 total_examples: Number of evaluation examples used.
70 per_pipeline: Mapping of pipeline name → metric name → mean score.
71 best_pipeline: Name of the pipeline with the highest overall score.
72 generated_at: When this report was produced.
73 raw_results: Per-pipeline per-example evaluation reports,
74 keyed by pipeline name.
75 """
76
77 pipeline_names: list[str]
78 total_examples: int
79 per_pipeline: dict[str, dict[str, float]]
80 best_pipeline: str | None = None
81 generated_at: datetime = field(default_factory=lambda: datetime.now(UTC))
82 raw_results: dict[str, list[RAGEvaluationReport]] = field(default_factory=dict)
83
84 def summary_table(self) -> list[dict[str, Any]]:
85 """Return per-pipeline metric scores as a list of row dicts.
86
87 Each row has a ``pipeline`` key plus one key per metric.
88
89 Returns:
90 List of dicts suitable for tabular display.
91 """
92 rows = []
93 for name in self.pipeline_names:
94 row: dict[str, Any] = {"pipeline": name}
95 row.update(self.per_pipeline.get(name, {}))
96 rows.append(row)
97 return rows
98
99
100class RAGBenchmark:
101 """Compare RAG pipeline configurations on a labelled dataset.
102
103 Runs every pipeline against every example in the dataset, evaluates
104 retrieval and (optionally) answer quality, then aggregates scores
105 per pipeline.
106
107 Example:
108 >>> dataset = [EvalExample(query="...", relevant_doc_ids=["doc1"])]
109 >>> report = await RAGBenchmark().run({"pipe_a": pipeline_a}, dataset)
110 >>> print(report.best_pipeline)
111 """
112
113 def __init__(
114 self,
115 evaluator: RAGEvaluator | None = None,
116 *,
117 max_concurrency: int = 5,
118 ) -> None:
119 """Initialise the benchmark harness.
120
121 Args:
122 evaluator: Optional pre-configured :class:`RAGEvaluator`. When
123 ``None`` a default evaluator with retrieval-precision and
124 retrieval-recall is used.
125 max_concurrency: Maximum number of pipeline-example pairs that are
126 evaluated simultaneously.
127 """
128 self._evaluator = evaluator or RAGEvaluator(
129 evaluators=[
130 RetrievalPrecisionEvaluator(),
131 RetrievalRecallEvaluator(),
132 ]
133 )
134 self._max_concurrency = max_concurrency
135
136 async def run(
137 self,
138 pipelines: dict[str, RAGPipelineProtocol],
139 dataset: list[EvalExample],
140 metrics: list[MetricType] | None = None,
141 ) -> BenchmarkReport:
142 """Run the benchmark and return a comparative report.
143
144 For each (pipeline, example) pair the pipeline is executed, latency
145 is recorded, retrieval metrics are computed, and (when a
146 reference answer is provided) answer-quality metrics are computed too.
147
148 Args:
149 pipelines: Mapping of display-name → pipeline instance.
150 dataset: List of evaluation examples with ground-truth labels.
151 metrics: Subset of :class:`MetricType` to include in the report.
152 Defaults to all metrics produced by the evaluator.
153
154 Returns:
155 :class:`BenchmarkReport` with per-pipeline mean scores.
156 """
157 if not pipelines:
158 return BenchmarkReport(
159 pipeline_names=[],
160 total_examples=len(dataset),
161 per_pipeline={},
162 )
163
164 semaphore = asyncio.Semaphore(self._max_concurrency)
165 pipeline_names = list(pipelines.keys())
166
167 # raw_eval_results[pipeline_name] = list of RAGEvaluationReport
168 raw_eval_results: dict[str, list[RAGEvaluationReport]] = {
169 name: [] for name in pipeline_names
170 }
171
172 # Build all (pipeline_name, pipeline, example) tasks
173 tasks = [
174 (name, pipelines[name], example)
175 for name in pipeline_names
176 for example in dataset
177 ]
178
179 async def _run_one(
180 pipeline_name: str,
181 pipeline: RAGPipelineProtocol,
182 example: EvalExample,
183 ) -> tuple[str, RAGEvaluationReport | None]:
184 async with semaphore:
185 return await self._evaluate_one(pipeline_name, pipeline, example)
186
187 coros = [_run_one(name, pipeline, example) for name, pipeline, example in tasks]
188 results = await asyncio.gather(*coros, return_exceptions=False)
189
190 for pipeline_name, eval_report in results:
191 if eval_report is not None:
192 raw_eval_results[pipeline_name].append(eval_report)
193
194 # Aggregate per pipeline
195 per_pipeline = self._aggregate(raw_eval_results, metrics)
196
197 # Determine best pipeline by overall score
198 best = self._best_pipeline(per_pipeline)
199
200 return BenchmarkReport(
201 pipeline_names=pipeline_names,
202 total_examples=len(dataset),
203 per_pipeline=per_pipeline,
204 best_pipeline=best,
205 raw_results=raw_eval_results,
206 )
207
208 async def _evaluate_one(
209 self,
210 pipeline_name: str,
211 pipeline: RAGPipelineProtocol,
212 example: EvalExample,
213 ) -> tuple[str, RAGEvaluationReport | None]:
214 """Execute the pipeline and evaluate a single example.
215
216 Args:
217 pipeline_name: Display name of the pipeline.
218 pipeline: Pipeline instance to execute.
219 example: Evaluation example.
220
221 Returns:
222 Tuple of (pipeline_name, evaluation_report_or_None).
223 """
224 t0 = time.monotonic()
225 context = RAGContext(query=example.query)
226
227 try:
228 pipeline_result = await pipeline.execute(context)
229 except (OSError, RuntimeError, ValueError, TypeError) as exc:
230 logger.warning(
231 "benchmark: pipeline error",
232 pipeline=pipeline_name,
233 query=example.query,
234 error=str(exc),
235 )
236 return pipeline_name, None
237
238 latency_ms = (time.monotonic() - t0) * 1000.0
239
240 if pipeline_result.is_err():
241 logger.warning(
242 "benchmark: pipeline returned error",
243 pipeline=pipeline_name,
244 query=example.query,
245 error=str(pipeline_result.unwrap_err()),
246 )
247 return pipeline_name, None
248
249 rag_response = pipeline_result.unwrap()
250
251 # Extract retrieved doc IDs from sources
252 retrieved_doc_ids: list[str] = []
253 for source in rag_response.sources:
254 doc_id = getattr(source, "id", None) or getattr(
255 getattr(source, "document", None), "id", None
256 )
257 if doc_id is not None:
258 retrieved_doc_ids.append(str(doc_id))
259
260 eval_report = await self._evaluator.evaluate(
261 query=example.query,
262 retrieved_docs=retrieved_doc_ids,
263 generated_answer=rag_response.answer,
264 reference_answer=example.reference_answer,
265 relevant_doc_ids=example.relevant_doc_ids,
266 metadata={
267 "pipeline": pipeline_name,
268 "latency_ms": latency_ms,
269 **example.metadata,
270 },
271 )
272
273 # Inject latency as a metric result if not already present
274 if eval_report.get_metric(MetricType.LATENCY) is None:
275 from lexigram.ai.rag.evaluation.types import EvaluationResult
276
277 eval_report.results.append(
278 EvaluationResult(
279 metric_type=MetricType.LATENCY,
280 score=latency_ms,
281 details={"latency_ms": latency_ms},
282 )
283 )
284
285 return pipeline_name, eval_report
286
287 def _aggregate(
288 self,
289 raw: dict[str, list[RAGEvaluationReport]],
290 metrics: list[MetricType] | None,
291 ) -> dict[str, dict[str, float]]:
292 """Compute mean score per metric per pipeline.
293
294 Args:
295 raw: Per-pipeline list of evaluation reports.
296 metrics: Optional metric subset filter.
297
298 Returns:
299 Mapping of pipeline_name → metric_name → mean_score.
300 """
301 per_pipeline: dict[str, dict[str, float]] = {}
302
303 for pipeline_name, reports in raw.items():
304 if not reports:
305 per_pipeline[pipeline_name] = {}
306 continue
307
308 # Accumulate scores per metric
309 sums: dict[str, float] = {}
310 counts: dict[str, int] = {}
311
312 for report in reports:
313 for result in report.results:
314 if metrics and result.metric_type not in metrics:
315 continue
316 key = result.metric_type.value
317 sums[key] = sums.get(key, 0.0) + result.score
318 counts[key] = counts.get(key, 0) + 1
319
320 per_pipeline[pipeline_name] = {
321 key: sums[key] / counts[key] for key in sums if counts[key] > 0
322 }
323
324 return per_pipeline
325
326 def _best_pipeline(
327 self,
328 per_pipeline: dict[str, dict[str, float]],
329 ) -> str | None:
330 """Identify the pipeline with the highest mean overall score.
331
332 Latency and cost metrics are excluded from the ranking since lower
333 values are better for those and they are on a different scale.
334
335 Args:
336 per_pipeline: Aggregated per-pipeline metric scores.
337
338 Returns:
339 Name of the best pipeline, or ``None`` if no data is available.
340 """
341 _excluded = {MetricType.LATENCY.value, MetricType.COST.value}
342
343 best_name: str | None = None
344 best_score = -1.0
345
346 for pipeline_name, metrics in per_pipeline.items():
347 quality_scores = [
348 score
349 for key, score in metrics.items()
350 if key not in _excluded and not key.startswith(MetricType.LATENCY.value)
351 ]
352 if not quality_scores:
353 continue
354 mean = sum(quality_scores) / len(quality_scores)
355 if mean > best_score:
356 best_score = mean
357 best_name = pipeline_name
358
359 return best_name