Spaces:
Runtime error
Runtime error
| """ | |
| RAG evaluation harness β RAGAS-inspired metrics, fully local (no paid API). | |
| Metrics implemented: | |
| - Recall@K : did the correct source appear in top-k retrieved chunks? | |
| - Faithfulness : LLM-as-judge (1-5 scale): is the answer grounded in context? | |
| - Answer Relevancy : cosine similarity between answer embedding and question embedding | |
| - Context Precision : fraction of retrieved chunks that are genuinely relevant | |
| - Latency : end-to-end response time | |
| All metrics run without external APIs β only the local LLM backend + sentence-transformers. | |
| """ | |
| from __future__ import annotations | |
| import logging | |
| import statistics | |
| import time | |
| from collections.abc import Callable | |
| from rich.console import Console | |
| from rich.table import Table | |
| from config import settings | |
| from core.generation import answer_question, get_backend | |
| from core.ingestion import get_embedding_model | |
| from models import EvalResult, EvalSample, EvalSummary, QueryMode, QueryRequest | |
| logger = logging.getLogger(__name__) | |
| console = Console() | |
| # ββ Metric: Recall@K βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def recall_at_k(retrieved_sources: list[str], relevant_sources: list[str]) -> float: | |
| """ | |
| Fraction of relevant sources that were retrieved. | |
| recall@k = |relevant β© retrieved| / |relevant| | |
| Args: | |
| retrieved_sources: filenames/URLs of retrieved chunks | |
| relevant_sources: expected source filenames from the test case | |
| Returns: | |
| Float in [0, 1]. 1.0 = all relevant sources found. | |
| """ | |
| if not relevant_sources: | |
| return 1.0 # no ground truth = can't penalise | |
| # Normalize to basename so full paths match bare filenames | |
| import os | |
| retrieved_set = {os.path.basename(s).lower() for s in retrieved_sources} | |
| relevant_set = {os.path.basename(s).lower() for s in relevant_sources} | |
| hits = len(relevant_set & retrieved_set) | |
| return hits / len(relevant_set) | |
| # ββ Metric: Faithfulness (LLM-as-judge) ββββββββββββββββββββββββββββββββββββββ | |
| def faithfulness_score( | |
| question: str, | |
| answer: str, | |
| context_chunks: list[str], | |
| llm_fn: Callable[[str], str], | |
| ) -> float: | |
| """ | |
| Ask the LLM to score whether the answer is faithful to the retrieved context. | |
| Faithfulness = is every claim in the answer directly supported by context? | |
| Score: 1 (not faithful) β 5 (perfectly faithful, no hallucinations) | |
| This is the RAGAS faithfulness metric implemented with a zero-shot LLM judge. | |
| """ | |
| context_str = "\n\n".join(context_chunks[:5])[:2000] | |
| prompt = ( | |
| "You are an expert evaluator for RAG (Retrieval-Augmented Generation) systems.\n\n" | |
| "Evaluate the following answer for FAITHFULNESS β whether every claim in the answer " | |
| "is directly supported by the provided context. Do not consider factual accuracy " | |
| "against world knowledge; only judge whether the answer stays within the context.\n\n" | |
| f"QUESTION: {question}\n\n" | |
| f"CONTEXT:\n{context_str}\n\n" | |
| f"ANSWER:\n{answer}\n\n" | |
| "Score the faithfulness from 1 to 5:\n" | |
| " 1 = Answer contains significant hallucinations not in context\n" | |
| " 2 = Answer has some claims not in context\n" | |
| " 3 = Mostly faithful with minor stretches\n" | |
| " 4 = Almost entirely faithful to context\n" | |
| " 5 = Perfectly faithful β every claim is directly supported\n\n" | |
| "Reply with ONLY the integer score (1-5):" | |
| ) | |
| try: | |
| raw = llm_fn(prompt).strip() | |
| score = float(raw.split()[0].rstrip(".,")) | |
| return max(1.0, min(5.0, score)) | |
| except (ValueError, IndexError): | |
| logger.warning( | |
| "Could not parse faithfulness score from: '%s'", raw if "raw" in dir() else "?" | |
| ) | |
| return 3.0 | |
| # ββ Metric: Answer Relevancy ββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def answer_relevancy_score(question: str, answer: str) -> float: | |
| """ | |
| Cosine similarity between question embedding and answer embedding. | |
| A good answer should be topically aligned with the question. | |
| Higher = more relevant. This mirrors the RAGAS answer relevancy metric. | |
| """ | |
| model = get_embedding_model() | |
| embeddings = model.encode([question, answer], normalize_embeddings=True) | |
| q_emb, a_emb = embeddings[0], embeddings[1] | |
| import numpy as np | |
| return float(np.dot(q_emb, a_emb)) | |
| # ββ Metric: Context Precision βββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def context_precision_score( | |
| question: str, | |
| context_chunks: list[str], | |
| llm_fn: Callable[[str], str], | |
| ) -> float: | |
| """ | |
| Fraction of retrieved chunks that were actually useful for answering. | |
| For each chunk, ask LLM: "Is this relevant to answering the question?" | |
| precision = (useful chunks) / (total chunks) | |
| """ | |
| if not context_chunks: | |
| return 0.0 | |
| useful = 0 | |
| for chunk in context_chunks: | |
| prompt = ( | |
| f"Is the following text relevant to answering this question?\n\n" | |
| f"Question: {question}\n\n" | |
| f"Text: {chunk[:500]}\n\n" | |
| "Reply with ONLY 'yes' or 'no':" | |
| ) | |
| try: | |
| answer = llm_fn(prompt).strip().lower() | |
| if "yes" in answer: | |
| useful += 1 | |
| except Exception: | |
| useful += 1 # assume relevant on error | |
| return useful / len(context_chunks) | |
| # ββ Single-sample evaluator βββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def evaluate_sample(sample: EvalSample) -> EvalResult: | |
| """ | |
| Run the full RAG pipeline on a single test case and compute all metrics. | |
| Args: | |
| sample: (question, expected_answer, relevant_sources, collection) | |
| Returns: | |
| EvalResult with all metric scores | |
| """ | |
| start = time.perf_counter() | |
| backend = get_backend() | |
| request = QueryRequest( | |
| question=sample.question, | |
| collection=sample.collection, | |
| top_k=settings.top_k, | |
| mode=QueryMode.HYBRID, | |
| ) | |
| try: | |
| response = answer_question(request) | |
| except Exception as e: | |
| logger.error("Generation failed for sample '%s': %s", sample.question[:60], e) | |
| return EvalResult( | |
| question=sample.question, | |
| generated_answer=f"ERROR: {e}", | |
| expected_answer=sample.expected_answer, | |
| sources_retrieved=[], | |
| relevant_sources=sample.relevant_sources, | |
| recall_at_k=0.0, | |
| faithfulness_score=1.0, | |
| answer_relevancy=0.0, | |
| latency_ms=(time.perf_counter() - start) * 1000, | |
| ) | |
| retrieved_sources = [s.source for s in response.sources] | |
| context_chunks = [s.excerpt for s in response.sources] | |
| # Compute metrics | |
| r_at_k = recall_at_k(retrieved_sources, sample.relevant_sources) | |
| faith = faithfulness_score( | |
| question=sample.question, | |
| answer=response.answer, | |
| context_chunks=context_chunks, | |
| llm_fn=backend.complete_raw, | |
| ) | |
| relevancy = answer_relevancy_score(sample.question, response.answer) | |
| return EvalResult( | |
| question=sample.question, | |
| generated_answer=response.answer, | |
| expected_answer=sample.expected_answer, | |
| sources_retrieved=retrieved_sources, | |
| relevant_sources=sample.relevant_sources, | |
| recall_at_k=round(r_at_k, 4), | |
| faithfulness_score=round(faith, 2), | |
| answer_relevancy=round(relevancy, 4), | |
| latency_ms=round(response.latency_ms, 2), | |
| ) | |
| # ββ Full eval harness βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def run_evaluation(samples: list[EvalSample]) -> EvalSummary: | |
| """ | |
| Run the evaluation harness over all samples and return an aggregated summary. | |
| Args: | |
| samples: list of test cases | |
| Returns: | |
| EvalSummary with per-sample results and aggregate stats | |
| """ | |
| results: list[EvalResult] = [] | |
| console.print(f"\n[bold cyan]Running evaluation on {len(samples)} samplesβ¦[/bold cyan]\n") | |
| for i, sample in enumerate(samples, start=1): | |
| console.print(f"[dim]Sample {i}/{len(samples)}:[/dim] {sample.question[:70]}β¦") | |
| result = evaluate_sample(sample) | |
| results.append(result) | |
| console.print( | |
| f" recall@k={result.recall_at_k:.2f} " | |
| f"faithfulness={result.faithfulness_score:.1f}/5 " | |
| f"relevancy={result.answer_relevancy:.2f} " | |
| f"latency={result.latency_ms:.0f}ms" | |
| ) | |
| summary = EvalSummary( | |
| total_samples=len(results), | |
| mean_recall_at_k=round(statistics.mean(r.recall_at_k for r in results), 4) | |
| if results | |
| else 0.0, | |
| mean_faithfulness=round(statistics.mean(r.faithfulness_score for r in results), 2) | |
| if results | |
| else 1.0, | |
| mean_answer_relevancy=round(statistics.mean(r.answer_relevancy for r in results), 4) | |
| if results | |
| else 0.0, | |
| mean_latency_ms=round(statistics.mean(r.latency_ms for r in results), 2) | |
| if results | |
| else 0.0, | |
| results=results, | |
| ) | |
| return summary | |
| def print_eval_summary(summary: EvalSummary) -> None: | |
| """Render evaluation summary as a Rich table.""" | |
| # Aggregate table | |
| agg_table = Table(title="Evaluation Summary", show_header=True, header_style="bold magenta") | |
| agg_table.add_column("Metric", style="cyan", no_wrap=True) | |
| agg_table.add_column("Score", justify="right") | |
| agg_table.add_column("Interpretation") | |
| agg_table.add_row( | |
| "Recall@K", f"{summary.mean_recall_at_k:.3f}", "Fraction of relevant sources retrieved" | |
| ) | |
| agg_table.add_row( | |
| "Faithfulness", f"{summary.mean_faithfulness:.2f}/5.0", "LLM-judged groundedness in context" | |
| ) | |
| agg_table.add_row( | |
| "Answer Relevancy", | |
| f"{summary.mean_answer_relevancy:.3f}", | |
| "Semantic alignment with question", | |
| ) | |
| agg_table.add_row("Avg Latency", f"{summary.mean_latency_ms:.0f}ms", "End-to-end response time") | |
| agg_table.add_row("Samples", str(summary.total_samples), "Total evaluated") | |
| console.print("\n") | |
| console.print(agg_table) | |
| # Per-sample table | |
| detail_table = Table( | |
| title="Per-Sample Results", show_header=True, header_style="bold blue", show_lines=True | |
| ) | |
| detail_table.add_column("#", style="dim", width=4) | |
| detail_table.add_column("Question", max_width=40) | |
| detail_table.add_column("Recall@K", justify="right", width=10) | |
| detail_table.add_column("Faith.", justify="right", width=8) | |
| detail_table.add_column("Relev.", justify="right", width=8) | |
| detail_table.add_column("Latency", justify="right", width=10) | |
| for i, r in enumerate(summary.results, start=1): | |
| faith_color = ( | |
| "green" | |
| if r.faithfulness_score >= 4 | |
| else ("yellow" if r.faithfulness_score >= 3 else "red") | |
| ) | |
| recall_color = ( | |
| "green" if r.recall_at_k >= 0.8 else ("yellow" if r.recall_at_k >= 0.5 else "red") | |
| ) | |
| detail_table.add_row( | |
| str(i), | |
| r.question[:40] + ("β¦" if len(r.question) > 40 else ""), | |
| f"[{recall_color}]{r.recall_at_k:.2f}[/{recall_color}]", | |
| f"[{faith_color}]{r.faithfulness_score:.1f}[/{faith_color}]", | |
| f"{r.answer_relevancy:.2f}", | |
| f"{r.latency_ms:.0f}ms", | |
| ) | |
| console.print("\n") | |
| console.print(detail_table) | |