Spaces:
Build error
Build error
File size: 4,948 Bytes
bb9cf7b 9f6648b bb9cf7b 9f6648b bb9cf7b 9f6648b bb9cf7b 9f6648b bb9cf7b 9f6648b bb9cf7b 9f6648b bb9cf7b 9f6648b bb9cf7b 9f6648b bb9cf7b 9f6648b bb9cf7b 9f6648b bb9cf7b 9f6648b bb9cf7b 9f6648b bb9cf7b 9f6648b bb9cf7b 9f6648b bb9cf7b 9f6648b bb9cf7b 9f6648b bb9cf7b 9f6648b bb9cf7b 9f6648b bb9cf7b 9f6648b bb9cf7b | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 | """
evaluation.py — Custom RAG evaluation metrics (no paid LLM required).
Three cosine-similarity-based metrics:
1. FAITHFULNESS
Does the answer contain only claims supported by the retrieved context?
Method: For each answer sentence, find its max cosine similarity to any
context chunk. Average these scores.
Low score = potential hallucination.
2. ANSWER RELEVANCE
Does the answer address what was actually asked?
Method: Cosine similarity between answer embedding and query embedding.
3. CONTEXT PRECISION
Are the retrieved chunks relevant to the query?
Method: Rank-weighted average of FAISS retrieval scores.
These are proxy metrics. They correlate well with quality and run in
milliseconds with zero API cost. For production, augment with periodic
human evaluation on a curated golden test set.
"""
import logging
import re
import numpy as np
from src.embeddings import embed_texts
logger = logging.getLogger("enterprise-rag.evaluation")
def compute_faithfulness(answer: str, context_chunks: list) -> float:
"""
Faithfulness score: how grounded is the answer in the retrieved context?
Range 0.0 (hallucinated) to 1.0 (fully grounded).
"""
if not answer or not context_chunks:
return 0.0
sentences = _split_sentences(answer)
if not sentences:
return 0.0
try:
answer_embs = embed_texts(sentences)
context_embs = embed_texts(context_chunks)
if answer_embs.shape[0] == 0 or context_embs.shape[0] == 0:
return 0.0
# shape: (n_sentences, n_chunks)
sim_matrix = np.dot(answer_embs, context_embs.T)
max_sims = sim_matrix.max(axis=1)
score = float(np.mean(max_sims))
return round(min(max(score, 0.0), 1.0), 4)
except Exception as e:
logger.warning(f"Faithfulness computation failed: {e}")
return 0.0
def compute_answer_relevance(answer: str, query: str) -> float:
"""
Answer relevance: does the answer address the question?
Range 0.0 (off-topic) to 1.0 (directly answers question).
"""
if not answer or not query:
return 0.0
try:
embs = embed_texts([query.strip(), answer.strip()])
if embs.shape[0] < 2:
return 0.0
score = float(np.dot(embs[0], embs[1]))
return round(min(max(score, 0.0), 1.0), 4)
except Exception as e:
logger.warning(f"Answer relevance computation failed: {e}")
return 0.0
def compute_context_precision(scores: list) -> float:
"""
Context precision: are the retrieved chunks relevant?
Uses rank-weighted average of retrieval similarity scores.
Range 0.0 (poor retrieval) to 1.0 (perfect retrieval).
"""
if not scores:
return 0.0
weighted = sum(
score * (1.0 / (rank + 1))
for rank, score in enumerate(scores)
)
normalizer = sum(1.0 / (rank + 1) for rank in range(len(scores)))
score = weighted / normalizer if normalizer > 0 else 0.0
return round(min(max(score, 0.0), 1.0), 4)
def run_evaluation(
query: str,
answer: str,
context_chunks: list,
retrieval_scores: list,
) -> dict:
"""
Run all three evaluation metrics and return a summary dict.
Called by app.py after each query to populate the metrics panel.
"""
# Skip evaluation for fallback/error responses
if not answer or answer.startswith("⚠️") or "unable to find" in answer.lower():
return {
"faithfulness": 0.0,
"answer_relevance": 0.0,
"context_precision": 0.0,
"overall": 0.0,
"quality_label": "N/A",
"note": "Evaluation skipped — fallback or error response.",
}
faithfulness = compute_faithfulness(answer, context_chunks)
answer_relevance = compute_answer_relevance(answer, query)
context_precision = compute_context_precision(retrieval_scores)
overall = round((faithfulness + answer_relevance + context_precision) / 3, 4)
quality_label = _quality_label(overall)
logger.info(
f"Eval scores — Faithfulness: {faithfulness} | "
f"Relevance: {answer_relevance} | "
f"Precision: {context_precision} | "
f"Overall: {overall}"
)
return {
"faithfulness": faithfulness,
"answer_relevance": answer_relevance,
"context_precision": context_precision,
"overall": overall,
"quality_label": quality_label,
"note": "",
}
def _quality_label(score: float) -> str:
if score >= 0.75:
return "✅ High Quality"
elif score >= 0.50:
return "🟡 Moderate Quality"
else:
return "🔴 Low Quality — Review Answer"
def _split_sentences(text: str) -> list:
"""Simple sentence splitter — no NLTK dependency required."""
sentences = re.split(r"(?<=[.!?])\s+", text.strip())
return [s.strip() for s in sentences if len(s.strip()) > 10] |