""" RAGAS Evaluation Script Runs the RAG pipeline against the ground truth dataset and computes RAGAS metrics. """ import json import os import sys import time from typing import Any # Ensure src is in path sys.path.insert(0, os.getcwd()) from src.reasoning.pipeline import ReasoningPipeline STOPWORDS: set[str] = { "the", "a", "an", "is", "are", "was", "were", "be", "been", "being", "have", "has", "had", "do", "does", "did", "will", "would", "could", "should", "may", "might", "must", "shall", "can", "need", "dare", "ought", "used", "to", "of", "in", "for", "on", "with", "at", "by", "from", "as", "into", "through", "during", "before", "after", "above", "below", "between", "under", "again", "further", "then", "once", "here", "there", "when", "where", "why", "how", "all", "each", "few", "more", "most", "other", "some", "such", "no", "nor", "not", "only", "own", "same", "so", "than", "too", "very", "s", "t", "just", "don", "now", "what", "which", "who", "whom", "this", "that", "these", "those", } def compute_context_precision(question: str, contexts: list[str]) -> float: if not contexts: return 0.0 question_words = set(question.lower().split()) question_keywords = question_words - STOPWORDS if not question_keywords: return 0.0 relevant_count = 0 for ctx in contexts: ctx_words = set(ctx.lower().split()) overlap = len(question_keywords & ctx_words) if overlap >= 2: relevant_count += 1 return float(relevant_count / len(contexts)) def compute_answer_completeness(answer: str) -> float: length = len(answer.split()) if length < 20: return 0.3 if length < 50: return 0.6 if length < 100: return 0.8 return 1.0 def load_ground_truth(path: str) -> list[dict[str, Any]]: """Load ground truth dataset from JSON file.""" with open(path, encoding="utf-8") as f: data: list[dict[str, Any]] = json.load(f) return data def run_pipeline_for_question(pipeline: ReasoningPipeline, question: str) -> dict[str, Any]: """Run pipeline and extract answer + contexts.""" result = pipeline.run(question) # Extract retrieved context chunks contexts: list[str] = [] if "retrieved_context" in result: for ctx in result["retrieved_context"]: if isinstance(ctx, dict): text = ctx.get("text", ctx.get("content", "")) if text: contexts.append(str(text)) elif isinstance(ctx, str) and ctx: contexts.append(ctx) return { "answer": str(result.get("generated_answer", "")), "contexts": contexts, "latency_ms": float(result.get("total_latency_ms", 0)), "validation_passed": bool(result.get("validation_passed", False)), "error": str(result.get("error_message")), } def main() -> None: import argparse parser = argparse.ArgumentParser() parser.add_argument("--limit", type=int, default=0, help="Limit number of queries (0 = all)") args = parser.parse_args() # Paths - auto-append chunker type suffix for Phase 6 iteration comparison ground_truth_path = "data/ground_truth/ground_truth.json" # Determine chunker type from config for output filename config_path = "config/settings.yaml" if os.path.exists(config_path): import yaml with open(config_path) as f: config = yaml.safe_load(f) chunker_type = config.get("ingestion", {}).get("chunker_type", "structure_aware") else: chunker_type = "structure_aware" output_path = f"data/ground_truth/evaluation_results_{chunker_type}.json" print(f"Evaluation results will be saved to: {output_path}") # Load ground truth print("Loading ground truth dataset...") ground_truth = load_ground_truth(ground_truth_path) print(f"Loaded {len(ground_truth)} QA pairs") # Initialize pipeline print("\nInitializing Reasoning Pipeline...") pipeline = ReasoningPipeline() # Limit queries if specified if args.limit > 0: ground_truth = ground_truth[: args.limit] print(f"Limited to {args.limit} queries for testing") # Run evaluation results: list[dict[str, Any]] = [] failed_queries: list[dict[str, Any]] = [] print(f"\nRunning pipeline on {len(ground_truth)} queries...") for i, item in enumerate(ground_truth): question_id = item.get("question_id", f"unknown_{i}") question = item.get("question", "") print( f"[{i + 1}/{len(ground_truth)}] {question_id}: {question[:60]}...", end=" ", flush=True, ) try: start_time = time.time() result = run_pipeline_for_question(pipeline, question) elapsed = time.time() - start_time results.append( { "question_id": question_id, "question": question, "ground_truth": item.get("ground_truth_answer", ""), "generated_answer": result["answer"], "contexts": result["contexts"], "latency_ms": result["latency_ms"], "wall_clock_s": elapsed, "validation_passed": result["validation_passed"], "error": result["error"], } ) print(f"[OK] ({elapsed:.1f}s)") # Rate limit handling - small delay between queries time.sleep(1) except Exception as e: print(f"[ERR] {e}") failed_queries.append( { "question_id": question_id, "question": question, "error": str(e), } ) print("\n--- Evaluation Complete ---") print(f"Successful: {len(results)}/{len(ground_truth)}") print(f"Failed: {len(failed_queries)}") if failed_queries: print("\nFailed queries:") for fq in failed_queries: print(f" - {fq['question_id']}: {fq['question'][:50]}...") # Manual evaluation - compute RAGAS metrics without external LLM calls # This avoids rate limits and API credential issues print("\nComputing RAGAS evaluation metrics (manual computation)...") # Common stopwords for keyword filtering stopwords = { "the", "a", "an", "is", "are", "was", "were", "be", "been", "being", "have", "has", "had", "do", "does", "did", "will", "would", "could", "should", "may", "might", "must", "shall", "can", "need", "dare", "ought", "used", "to", "of", "in", "for", "on", "with", "at", "by", "from", "as", "into", "through", "during", "before", "after", "above", "below", "between", "under", "again", "further", "then", "once", "here", "there", "when", "where", "why", "how", "all", "each", "few", "more", "most", "other", "some", "such", "no", "nor", "not", "only", "own", "same", "so", "than", "too", "very", "s", "t", "just", "don", "now", "what", "which", "who", "whom", "this", "that", "these", "those", } # Compute context precision: relevance of retrieved chunks to question def compute_context_precision(question: str, contexts: list[str]) -> float: if not contexts: return 0.0 question_words = set(question.lower().split()) question_keywords = question_words - stopwords if not question_keywords: return 0.0 relevant_count = 0 for ctx in contexts: ctx_words = set(ctx.lower().split()) overlap = len(question_keywords & ctx_words) if overlap >= 2: relevant_count += 1 return float(relevant_count / len(contexts)) # Compute faithfulness: whether answer is grounded in retrieved contexts def compute_faithfulness(ground_truth_answer: str, generated_answer: str, contexts: list[str]) -> float: if not generated_answer or not contexts: return 0.0 import re sentences = re.split(r"[.!?]+", ground_truth_answer) verifiable_claims: list[int] = [] combined_context = " ".join(contexts).lower() for sentence in sentences: sentence = sentence.strip() if len(sentence) < 10: continue words = set(sentence.lower().split()) - stopwords if words: overlap = len(words & set(combined_context.split())) if overlap / len(words) > 0.3: verifiable_claims.append(1) else: verifiable_claims.append(0) if not verifiable_claims: return 0.5 return sum(verifiable_claims) / len(verifiable_claims) # Compute context recall: whether retrieved contexts contain the ground truth info def compute_context_recall(ground_truth_answer: str, contexts: list[str]) -> float: if not contexts or not ground_truth_answer: return 0.0 combined_context = " ".join(contexts).lower() gt_words = set(ground_truth_answer.lower().split()) - stopwords if not gt_words: return 0.0 matched_terms = 0 for word in gt_words: if len(word) < 4: continue if word in combined_context: matched_terms += 1 return matched_terms / len(gt_words) # Compute answer relevancy: keyword overlap ratio between question and answer. # No length multiplier — answer length is already captured by answer_completeness. def compute_answer_relevancy(question: str, generated_answer: str) -> float: if not generated_answer: return 0.0 q_words = set(question.lower().split()) - stopwords a_words = set(generated_answer.lower().split()) - stopwords if not q_words: return 0.0 overlap = len(q_words & a_words) return float(overlap / len(q_words)) # Compute answer completeness: length-based proxy for answer thoroughness def compute_answer_completeness(answer: str) -> float: length = len(answer.split()) if length < 20: return 0.3 elif length < 50: return 0.6 elif length < 100: return 0.8 else: return 1.0 # Compute metrics for each result precisions = [] faithfulness_scores = [] context_recalls = [] relevancies = [] completeness_scores = [] for r in results: prec = compute_context_precision(r["question"], r["contexts"]) faith = compute_faithfulness( r.get("ground_truth", ""), r.get("generated_answer", ""), r.get("contexts", []), ) recall = compute_context_recall(r.get("ground_truth", ""), r.get("contexts", [])) relev = compute_answer_relevancy(r["question"], r.get("generated_answer", "")) compl = compute_answer_completeness(r.get("generated_answer", "")) precisions.append(prec) faithfulness_scores.append(faith) context_recalls.append(recall) relevancies.append(relev) completeness_scores.append(compl) # Calculate RAGAS-style metrics (4 core RAGAS metrics) # Plus answer_completeness as custom internal metric metrics_dict: dict[str, float] = { "context_precision": (sum(precisions) / len(precisions) if precisions else 0), "faithfulness": (sum(faithfulness_scores) / len(faithfulness_scores) if faithfulness_scores else 0), "context_recall": (sum(context_recalls) / len(context_recalls) if context_recalls else 0), "answer_relevancy": (sum(relevancies) / len(relevancies) if relevancies else 0), "answer_completeness": (sum(completeness_scores) / len(completeness_scores) if completeness_scores else 0), } # Store per-query results for i, r in enumerate(results): r["context_precision"] = precisions[i] r["faithfulness"] = faithfulness_scores[i] r["context_recall"] = context_recalls[i] r["answer_relevancy"] = relevancies[i] r["answer_completeness"] = completeness_scores[i] # Display RAGAS results print("\n" + "=" * 50) print("RAGAS EVALUATION RESULTS (Manual Computation)") print("=" * 50) for metric_name, value in metrics_dict.items(): print(f"{metric_name:25s}: {value:.4f}") # Target comparison (RAGAS targets from project spec) print("\n" + "-" * 50) print("RAGAS TARGET COMPARISON") print("-" * 50) targets = { "faithfulness": 0.80, "answer_relevancy": 0.75, "context_precision": 0.61, "context_recall": 0.75, # Custom internal metric target "answer_completeness": 0.80, } for metric, target in targets.items(): actual = metrics_dict.get(metric, 0) status = "[PASS]" if actual >= target else "[BELOW TARGET]" print(f"{metric:25s}: {actual:.4f} (target: {target}) - {status}") # Latency stats latencies = [r["latency_ms"] / 1000 for r in results if r["latency_ms"] > 0] if latencies: avg_latency = sum(latencies) / len(latencies) max_latency = max(latencies) print(f"\nLatency: avg={avg_latency:.2f}s, max={max_latency:.2f}s (target: <=180s)") # Save results output = { "metrics": metrics_dict, "per_query": results, "failed_queries": failed_queries, } with open(output_path, "w", encoding="utf-8") as f: json.dump(output, f, indent=2, ensure_ascii=False) print(f"\nResults saved to: {output_path}") if __name__ == "__main__": main()