rag-system / core /evaluation.py
joshsears's picture
Polish: BGE-large embeddings, contextual retrieval, 142 tests passing, lint clean
21ca2ea
Raw
History Blame Contribute Delete
12 kB
"""
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)