iris-vector-rag 0.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. adapters/rag_templates_bridge.py +578 -0
  2. common/__init__.py +1 -0
  3. common/batch_utils.py +294 -0
  4. common/config.py +58 -0
  5. common/connection_manager.py +242 -0
  6. common/connection_pool.py +445 -0
  7. common/database.py +141 -0
  8. common/database_schema_manager.py +289 -0
  9. common/db_init_with_indexes.py +181 -0
  10. common/db_vector_utils.py +181 -0
  11. common/environment_manager.py +220 -0
  12. common/huggingface_utils.py +206 -0
  13. common/iris_client.py +92 -0
  14. common/iris_connection_manager.py +411 -0
  15. common/iris_connection_pool.py +360 -0
  16. common/iris_connector.py +71 -0
  17. common/iris_dbapi_connector.py +323 -0
  18. common/iris_index_utils.py +195 -0
  19. common/iris_sql_utils.py +704 -0
  20. common/iris_stream_reader.py +166 -0
  21. common/llm_cache_config.py +308 -0
  22. common/llm_cache_iris.py +671 -0
  23. common/llm_cache_manager.py +911 -0
  24. common/utils.py +559 -0
  25. common/vector_sql_utils.py +584 -0
  26. evaluation_framework/__init__.py +66 -0
  27. evaluation_framework/biomedical_question_generator.py +826 -0
  28. evaluation_framework/comparative_analysis_system.py +1173 -0
  29. evaluation_framework/empirical_reporting.py +1298 -0
  30. evaluation_framework/evaluation_orchestrator.py +1123 -0
  31. evaluation_framework/execute_production_evaluation.py +382 -0
  32. evaluation_framework/pmc_data_pipeline.py +1332 -0
  33. evaluation_framework/production_evaluation_config.py +353 -0
  34. evaluation_framework/ragas_metrics_framework.py +1013 -0
  35. evaluation_framework/real_production_evaluation.py +544 -0
  36. evaluation_framework/run_10k_evaluation.py +175 -0
  37. evaluation_framework/simple_test.py +364 -0
  38. evaluation_framework/statistical_evaluation_methodology.py +1016 -0
  39. evaluation_framework/test_framework.py +281 -0
  40. evaluation_framework/test_iris_connectivity.py +244 -0
  41. evaluation_framework/test_small_scale_evaluation.py +432 -0
  42. evaluation_framework/true_e2e_evaluation.py +639 -0
  43. evaluation_framework/visualization_engine.py +585 -0
  44. iris_rag/__init__.py +284 -0
  45. iris_rag/config/default_config.yaml +295 -0
  46. iris_vector_rag-0.2.1.dist-info/METADATA +499 -0
  47. iris_vector_rag-0.2.1.dist-info/RECORD +50 -0
  48. iris_vector_rag-0.2.1.dist-info/WHEEL +5 -0
  49. iris_vector_rag-0.2.1.dist-info/entry_points.txt +2 -0
  50. iris_vector_rag-0.2.1.dist-info/top_level.txt +4 -0
@@ -0,0 +1,1013 @@
1
+ """
2
+ Comprehensive RAGAS Metrics Framework for Biomedical RAG Evaluation
3
+
4
+ This module implements all key RAGAS metrics with biomedical domain adaptations,
5
+ statistical rigor, and robust error handling for systematic pipeline comparison.
6
+
7
+ Key Features:
8
+ 1. All 7 core RAGAS metrics implementation
9
+ 2. Biomedical domain-specific adaptations
10
+ 3. Statistical significance testing
11
+ 4. Confidence interval calculation
12
+ 5. Batch processing for scalability
13
+ 6. Comprehensive error handling and fallbacks
14
+ """
15
+
16
+ import json
17
+ import logging
18
+ import time
19
+ import warnings
20
+ from collections import defaultdict
21
+ from dataclasses import dataclass, field
22
+ from pathlib import Path
23
+ from typing import Any, Dict, List, Optional, Tuple, Union
24
+
25
+ import numpy as np
26
+ import pandas as pd
27
+
28
+ # Statistical analysis
29
+ from scipy import stats
30
+ from scipy.stats import bootstrap
31
+
32
+ warnings.filterwarnings("ignore")
33
+
34
+ import torch
35
+
36
+ # NLP and ML libraries
37
+ from sentence_transformers import SentenceTransformer, util
38
+ from sklearn.feature_extraction.text import TfidfVectorizer
39
+ from sklearn.metrics.pairwise import cosine_similarity
40
+ from transformers import AutoModelForSequenceClassification, AutoTokenizer, pipeline
41
+
42
+ # SpaCy import with fallback
43
+ try:
44
+ import spacy
45
+
46
+ SPACY_AVAILABLE = True
47
+ except (ImportError, ValueError) as e:
48
+ SPACY_AVAILABLE = False
49
+ warnings.warn(f"SpaCy not available: {e}. Some NLP features will be disabled.")
50
+
51
+ # RAGAS specific imports
52
+ try:
53
+ from ragas import evaluate
54
+ from ragas.metrics import (
55
+ answer_correctness,
56
+ answer_relevancy,
57
+ answer_similarity,
58
+ context_precision,
59
+ context_recall,
60
+ context_utilization,
61
+ faithfulness,
62
+ )
63
+
64
+ RAGAS_AVAILABLE = True
65
+ except ImportError:
66
+ RAGAS_AVAILABLE = False
67
+ logging.warning("RAGAS library not available, using custom implementations")
68
+
69
+ logger = logging.getLogger(__name__)
70
+
71
+
72
+ @dataclass
73
+ class RAGASResult:
74
+ """Structured RAGAS evaluation result with statistical measures."""
75
+
76
+ metric_name: str
77
+ value: float
78
+ confidence_interval: Tuple[float, float]
79
+ sample_size: int
80
+ standard_error: float
81
+ computation_time: float
82
+ error_message: Optional[str] = None
83
+ individual_scores: List[float] = field(default_factory=list)
84
+
85
+
86
+ @dataclass
87
+ class ComprehensiveRAGASResults:
88
+ """Complete RAGAS evaluation results for a pipeline."""
89
+
90
+ pipeline_name: str
91
+ timestamp: str
92
+ total_queries: int
93
+ successful_evaluations: int
94
+
95
+ # Core RAGAS metrics
96
+ faithfulness: Optional[RAGASResult] = None
97
+ answer_relevancy: Optional[RAGASResult] = None
98
+ context_precision: Optional[RAGASResult] = None
99
+ context_recall: Optional[RAGASResult] = None
100
+ context_utilization: Optional[RAGASResult] = None
101
+ answer_correctness: Optional[RAGASResult] = None
102
+ answer_similarity: Optional[RAGASResult] = None
103
+
104
+ # Aggregate scores
105
+ overall_score: Optional[float] = None
106
+ weighted_score: Optional[float] = None
107
+
108
+ def to_dict(self) -> Dict[str, Any]:
109
+ """Convert to dictionary for serialization."""
110
+ result = {
111
+ "pipeline_name": self.pipeline_name,
112
+ "timestamp": self.timestamp,
113
+ "total_queries": self.total_queries,
114
+ "successful_evaluations": self.successful_evaluations,
115
+ "overall_score": self.overall_score,
116
+ "weighted_score": self.weighted_score,
117
+ }
118
+
119
+ # Add metric results
120
+ for metric_name in [
121
+ "faithfulness",
122
+ "answer_relevancy",
123
+ "context_precision",
124
+ "context_recall",
125
+ "context_utilization",
126
+ "answer_correctness",
127
+ "answer_similarity",
128
+ ]:
129
+ metric_result = getattr(self, metric_name)
130
+ if metric_result:
131
+ result[metric_name] = {
132
+ "value": metric_result.value,
133
+ "confidence_interval": metric_result.confidence_interval,
134
+ "sample_size": metric_result.sample_size,
135
+ "standard_error": metric_result.standard_error,
136
+ "computation_time": metric_result.computation_time,
137
+ "error_message": metric_result.error_message,
138
+ }
139
+
140
+ return result
141
+
142
+
143
+ class BiomedicalRAGASFramework:
144
+ """
145
+ Comprehensive RAGAS evaluation framework adapted for biomedical domain
146
+ with statistical rigor and scalability for enterprise evaluation.
147
+ """
148
+
149
+ def __init__(self, config: Optional[Dict[str, Any]] = None):
150
+ """
151
+ Initialize the RAGAS framework with biomedical adaptations.
152
+
153
+ Args:
154
+ config: Configuration dictionary for customizing evaluation
155
+ """
156
+ self.config = config or self._get_default_config()
157
+ self.nlp = None
158
+ self.embedding_model = None
159
+ self.biomedical_qa_model = None
160
+ self.entailment_model = None
161
+
162
+ self._initialize_models()
163
+
164
+ # Metric weights for biomedical domain
165
+ self.metric_weights = {
166
+ "faithfulness": 0.25, # Critical for medical accuracy
167
+ "answer_relevancy": 0.20, # Important for user satisfaction
168
+ "context_precision": 0.15, # Efficiency metric
169
+ "context_recall": 0.15, # Completeness metric
170
+ "answer_correctness": 0.20, # Critical for medical accuracy
171
+ "answer_similarity": 0.05, # Less critical than correctness
172
+ }
173
+
174
+ logger.info("Biomedical RAGAS Framework initialized successfully")
175
+
176
+ def _get_default_config(self) -> Dict[str, Any]:
177
+ """Get default configuration for biomedical RAGAS evaluation."""
178
+ return {
179
+ "embedding_model": "sentence-transformers/all-MiniLM-L6-v2",
180
+ "biomedical_qa_model": "dmis-lab/biobert-base-cased-v1.1-squad",
181
+ "entailment_model": "microsoft/deberta-large-mnli",
182
+ "batch_size": 32,
183
+ "max_context_length": 2048,
184
+ "confidence_level": 0.95,
185
+ "bootstrap_samples": 1000,
186
+ "similarity_threshold": 0.7,
187
+ "device": "cuda" if torch.cuda.is_available() else "cpu",
188
+ }
189
+
190
+ def _initialize_models(self):
191
+ """Initialize all required models for RAGAS evaluation."""
192
+ logger.info("Initializing biomedical NLP models for RAGAS evaluation...")
193
+
194
+ try:
195
+ # Embedding model for semantic similarity
196
+ self.embedding_model = SentenceTransformer(
197
+ self.config["embedding_model"], device=self.config["device"]
198
+ )
199
+ logger.info(f"Loaded embedding model: {self.config['embedding_model']}")
200
+
201
+ # Biomedical QA model for answer validation
202
+ self.biomedical_qa_model = pipeline(
203
+ "question-answering",
204
+ model=self.config["biomedical_qa_model"],
205
+ device=0 if self.config["device"] == "cuda" else -1,
206
+ )
207
+ logger.info(
208
+ f"Loaded biomedical QA model: {self.config['biomedical_qa_model']}"
209
+ )
210
+
211
+ # Entailment model for faithfulness assessment
212
+ self.entailment_model = pipeline(
213
+ "text-classification",
214
+ model=self.config["entailment_model"],
215
+ device=0 if self.config["device"] == "cuda" else -1,
216
+ )
217
+ logger.info(f"Loaded entailment model: {self.config['entailment_model']}")
218
+
219
+ # SpaCy for text processing
220
+ try:
221
+ self.nlp = spacy.load("en_core_sci_sm") # Biomedical spaCy model
222
+ except OSError:
223
+ self.nlp = spacy.load("en_core_web_sm")
224
+ logger.warning("Using standard spaCy model instead of biomedical")
225
+
226
+ except Exception as e:
227
+ logger.error(f"Failed to initialize models: {e}")
228
+ raise
229
+
230
+ def evaluate_pipeline(
231
+ self,
232
+ pipeline_results: List[Dict[str, Any]],
233
+ ground_truth_answers: List[str],
234
+ pipeline_name: str,
235
+ ) -> ComprehensiveRAGASResults:
236
+ """
237
+ Comprehensive RAGAS evaluation of a RAG pipeline.
238
+
239
+ Args:
240
+ pipeline_results: List of pipeline query results with 'query', 'answer', 'contexts'
241
+ ground_truth_answers: List of ground truth answers
242
+ pipeline_name: Name of the pipeline being evaluated
243
+
244
+ Returns:
245
+ Comprehensive RAGAS evaluation results
246
+ """
247
+ logger.info(f"Starting comprehensive RAGAS evaluation for {pipeline_name}")
248
+ start_time = time.time()
249
+
250
+ # Validate input data
251
+ self._validate_evaluation_inputs(pipeline_results, ground_truth_answers)
252
+
253
+ # Initialize results
254
+ results = ComprehensiveRAGASResults(
255
+ pipeline_name=pipeline_name,
256
+ timestamp=time.strftime("%Y-%m-%d %H:%M:%S"),
257
+ total_queries=len(pipeline_results),
258
+ successful_evaluations=0,
259
+ )
260
+
261
+ # Prepare evaluation data
262
+ evaluation_data = self._prepare_evaluation_data(
263
+ pipeline_results, ground_truth_answers
264
+ )
265
+
266
+ # Evaluate each metric
267
+ metrics_to_evaluate = [
268
+ ("faithfulness", self._evaluate_faithfulness),
269
+ ("answer_relevancy", self._evaluate_answer_relevancy),
270
+ ("context_precision", self._evaluate_context_precision),
271
+ ("context_recall", self._evaluate_context_recall),
272
+ ("context_utilization", self._evaluate_context_utilization),
273
+ ("answer_correctness", self._evaluate_answer_correctness),
274
+ ("answer_similarity", self._evaluate_answer_similarity),
275
+ ]
276
+
277
+ successful_metrics = 0
278
+ for metric_name, metric_func in metrics_to_evaluate:
279
+ try:
280
+ logger.info(f"Evaluating {metric_name}...")
281
+ metric_result = metric_func(evaluation_data)
282
+ setattr(results, metric_name, metric_result)
283
+ if metric_result.error_message is None:
284
+ successful_metrics += 1
285
+ logger.info(
286
+ f"{metric_name}: {metric_result.value:.4f} ± {metric_result.standard_error:.4f}"
287
+ )
288
+ except Exception as e:
289
+ logger.error(f"Failed to evaluate {metric_name}: {e}")
290
+ error_result = RAGASResult(
291
+ metric_name=metric_name,
292
+ value=0.0,
293
+ confidence_interval=(0.0, 0.0),
294
+ sample_size=0,
295
+ standard_error=0.0,
296
+ computation_time=0.0,
297
+ error_message=str(e),
298
+ )
299
+ setattr(results, metric_name, error_result)
300
+
301
+ # Calculate aggregate scores
302
+ results.overall_score = self._calculate_overall_score(results)
303
+ results.weighted_score = self._calculate_weighted_score(results)
304
+ results.successful_evaluations = successful_metrics
305
+
306
+ total_time = time.time() - start_time
307
+ logger.info(
308
+ f"RAGAS evaluation completed in {total_time:.2f}s. "
309
+ f"Overall score: {results.overall_score:.4f}"
310
+ )
311
+
312
+ return results
313
+
314
+ def _validate_evaluation_inputs(
315
+ self, pipeline_results: List[Dict[str, Any]], ground_truth_answers: List[str]
316
+ ):
317
+ """Validate inputs for RAGAS evaluation."""
318
+ if len(pipeline_results) != len(ground_truth_answers):
319
+ raise ValueError(
320
+ "Mismatch between pipeline results and ground truth answers"
321
+ )
322
+
323
+ required_keys = ["query", "answer", "contexts"]
324
+ for i, result in enumerate(pipeline_results):
325
+ for key in required_keys:
326
+ if key not in result:
327
+ raise ValueError(f"Missing key '{key}' in pipeline result {i}")
328
+ if not result[key]:
329
+ logger.warning(f"Empty {key} in pipeline result {i}")
330
+
331
+ def _prepare_evaluation_data(
332
+ self, pipeline_results: List[Dict[str, Any]], ground_truth_answers: List[str]
333
+ ) -> List[Dict[str, Any]]:
334
+ """Prepare data for RAGAS evaluation."""
335
+ evaluation_data = []
336
+
337
+ for i, (result, ground_truth) in enumerate(
338
+ zip(pipeline_results, ground_truth_answers)
339
+ ):
340
+ # Ensure contexts is a list of strings
341
+ contexts = result["contexts"]
342
+ if isinstance(contexts, str):
343
+ contexts = [contexts]
344
+ elif not isinstance(contexts, list):
345
+ contexts = [str(contexts)]
346
+
347
+ # Clean and validate data
348
+ evaluation_item = {
349
+ "question": str(result["query"]).strip(),
350
+ "answer": str(result["answer"]).strip() if result["answer"] else "",
351
+ "contexts": [str(ctx).strip() for ctx in contexts if str(ctx).strip()],
352
+ "ground_truth": str(ground_truth).strip(),
353
+ "index": i,
354
+ }
355
+
356
+ # Skip if critical data is missing
357
+ if (
358
+ evaluation_item["question"]
359
+ and evaluation_item["answer"]
360
+ and evaluation_item["contexts"]
361
+ and evaluation_item["ground_truth"]
362
+ ):
363
+ evaluation_data.append(evaluation_item)
364
+ else:
365
+ logger.warning(f"Skipping evaluation item {i} due to missing data")
366
+
367
+ logger.info(f"Prepared {len(evaluation_data)} valid evaluation items")
368
+ return evaluation_data
369
+
370
+ def _evaluate_faithfulness(
371
+ self, evaluation_data: List[Dict[str, Any]]
372
+ ) -> RAGASResult:
373
+ """
374
+ Evaluate faithfulness: How factually accurate is the answer based on the context.
375
+
376
+ Uses entailment models to check if the answer is supported by the retrieved context.
377
+ """
378
+ start_time = time.time()
379
+ scores = []
380
+
381
+ for item in evaluation_data:
382
+ try:
383
+ answer = item["answer"]
384
+ contexts = item["contexts"]
385
+
386
+ # Combine contexts
387
+ combined_context = " ".join(contexts)
388
+
389
+ # Check entailment between answer and context
390
+ entailment_result = self.entailment_model(
391
+ f"Premise: {combined_context} Hypothesis: {answer}"
392
+ )
393
+
394
+ # Extract faithfulness score
395
+ if entailment_result[0]["label"] == "ENTAILMENT":
396
+ score = entailment_result[0]["score"]
397
+ elif entailment_result[0]["label"] == "NEUTRAL":
398
+ score = 0.5
399
+ else: # CONTRADICTION
400
+ score = 1.0 - entailment_result[0]["score"]
401
+
402
+ scores.append(score)
403
+
404
+ except Exception as e:
405
+ logger.warning(f"Failed to evaluate faithfulness for item: {e}")
406
+ scores.append(0.0)
407
+
408
+ return self._compute_metric_statistics(
409
+ "faithfulness", scores, time.time() - start_time
410
+ )
411
+
412
+ def _evaluate_answer_relevancy(
413
+ self, evaluation_data: List[Dict[str, Any]]
414
+ ) -> RAGASResult:
415
+ """
416
+ Evaluate answer relevancy: How relevant is the answer to the question.
417
+
418
+ Uses semantic similarity between question and answer embeddings.
419
+ """
420
+ start_time = time.time()
421
+ scores = []
422
+
423
+ # Batch encode questions and answers
424
+ questions = [item["question"] for item in evaluation_data]
425
+ answers = [item["answer"] for item in evaluation_data]
426
+
427
+ try:
428
+ question_embeddings = self.embedding_model.encode(
429
+ questions, batch_size=self.config["batch_size"]
430
+ )
431
+ answer_embeddings = self.embedding_model.encode(
432
+ answers, batch_size=self.config["batch_size"]
433
+ )
434
+
435
+ # Calculate cosine similarities
436
+ similarities = util.cos_sim(question_embeddings, answer_embeddings)
437
+ scores = [similarities[i][i].item() for i in range(len(questions))]
438
+
439
+ except Exception as e:
440
+ logger.error(
441
+ f"Batch processing failed, falling back to individual processing: {e}"
442
+ )
443
+ for item in evaluation_data:
444
+ try:
445
+ question_emb = self.embedding_model.encode([item["question"]])
446
+ answer_emb = self.embedding_model.encode([item["answer"]])
447
+ similarity = cosine_similarity(question_emb, answer_emb)[0][0]
448
+ scores.append(similarity)
449
+ except Exception:
450
+ scores.append(0.0)
451
+
452
+ return self._compute_metric_statistics(
453
+ "answer_relevancy", scores, time.time() - start_time
454
+ )
455
+
456
+ def _evaluate_context_precision(
457
+ self, evaluation_data: List[Dict[str, Any]]
458
+ ) -> RAGASResult:
459
+ """
460
+ Evaluate context precision: What fraction of retrieved context is relevant.
461
+
462
+ Measures how much of the retrieved context is actually useful for answering the question.
463
+ """
464
+ start_time = time.time()
465
+ scores = []
466
+
467
+ for item in evaluation_data:
468
+ try:
469
+ question = item["question"]
470
+ contexts = item["contexts"]
471
+
472
+ relevant_contexts = 0
473
+ total_contexts = len(contexts)
474
+
475
+ if total_contexts == 0:
476
+ scores.append(0.0)
477
+ continue
478
+
479
+ for context in contexts:
480
+ # Use QA model to check if context can answer the question
481
+ try:
482
+ qa_result = self.biomedical_qa_model(
483
+ question=question, context=context
484
+ )
485
+
486
+ # Consider context relevant if QA confidence is high
487
+ if qa_result["score"] > self.config["similarity_threshold"]:
488
+ relevant_contexts += 1
489
+
490
+ except Exception:
491
+ # Fallback to embedding similarity
492
+ question_emb = self.embedding_model.encode([question])
493
+ context_emb = self.embedding_model.encode([context])
494
+ similarity = cosine_similarity(question_emb, context_emb)[0][0]
495
+
496
+ if similarity > self.config["similarity_threshold"]:
497
+ relevant_contexts += 1
498
+
499
+ precision = relevant_contexts / total_contexts
500
+ scores.append(precision)
501
+
502
+ except Exception as e:
503
+ logger.warning(f"Failed to evaluate context precision for item: {e}")
504
+ scores.append(0.0)
505
+
506
+ return self._compute_metric_statistics(
507
+ "context_precision", scores, time.time() - start_time
508
+ )
509
+
510
+ def _evaluate_context_recall(
511
+ self, evaluation_data: List[Dict[str, Any]]
512
+ ) -> RAGASResult:
513
+ """
514
+ Evaluate context recall: How much of the relevant information was retrieved.
515
+
516
+ Measures if the retrieved context contains information present in ground truth.
517
+ """
518
+ start_time = time.time()
519
+ scores = []
520
+
521
+ for item in evaluation_data:
522
+ try:
523
+ ground_truth = item["ground_truth"]
524
+ contexts = item["contexts"]
525
+
526
+ if not contexts:
527
+ scores.append(0.0)
528
+ continue
529
+
530
+ # Combine all contexts
531
+ combined_context = " ".join(contexts)
532
+
533
+ # Use embedding similarity between ground truth and contexts
534
+ gt_embedding = self.embedding_model.encode([ground_truth])
535
+ context_embedding = self.embedding_model.encode([combined_context])
536
+
537
+ similarity = cosine_similarity(gt_embedding, context_embedding)[0][0]
538
+ scores.append(similarity)
539
+
540
+ except Exception as e:
541
+ logger.warning(f"Failed to evaluate context recall for item: {e}")
542
+ scores.append(0.0)
543
+
544
+ return self._compute_metric_statistics(
545
+ "context_recall", scores, time.time() - start_time
546
+ )
547
+
548
+ def _evaluate_context_utilization(
549
+ self, evaluation_data: List[Dict[str, Any]]
550
+ ) -> RAGASResult:
551
+ """
552
+ Evaluate context utilization: How effectively was the retrieved context used.
553
+
554
+ Measures how much of the provided context is reflected in the generated answer.
555
+ """
556
+ start_time = time.time()
557
+ scores = []
558
+
559
+ for item in evaluation_data:
560
+ try:
561
+ answer = item["answer"]
562
+ contexts = item["contexts"]
563
+
564
+ if not contexts:
565
+ scores.append(0.0)
566
+ continue
567
+
568
+ # Calculate semantic overlap between answer and contexts
569
+ combined_context = " ".join(contexts)
570
+
571
+ answer_embedding = self.embedding_model.encode([answer])
572
+ context_embedding = self.embedding_model.encode([combined_context])
573
+
574
+ utilization = cosine_similarity(answer_embedding, context_embedding)[0][
575
+ 0
576
+ ]
577
+ scores.append(utilization)
578
+
579
+ except Exception as e:
580
+ logger.warning(f"Failed to evaluate context utilization for item: {e}")
581
+ scores.append(0.0)
582
+
583
+ return self._compute_metric_statistics(
584
+ "context_utilization", scores, time.time() - start_time
585
+ )
586
+
587
+ def _evaluate_answer_correctness(
588
+ self, evaluation_data: List[Dict[str, Any]]
589
+ ) -> RAGASResult:
590
+ """
591
+ Evaluate answer correctness: How factually correct is the answer.
592
+
593
+ Combines semantic similarity with factual accuracy assessment.
594
+ """
595
+ start_time = time.time()
596
+ scores = []
597
+
598
+ # Batch processing for efficiency
599
+ answers = [item["answer"] for item in evaluation_data]
600
+ ground_truths = [item["ground_truth"] for item in evaluation_data]
601
+
602
+ try:
603
+ # Semantic similarity component
604
+ answer_embeddings = self.embedding_model.encode(
605
+ answers, batch_size=self.config["batch_size"]
606
+ )
607
+ gt_embeddings = self.embedding_model.encode(
608
+ ground_truths, batch_size=self.config["batch_size"]
609
+ )
610
+
611
+ similarities = util.cos_sim(answer_embeddings, gt_embeddings)
612
+ semantic_scores = [similarities[i][i].item() for i in range(len(answers))]
613
+
614
+ # Factual accuracy component using entailment
615
+ for i, (answer, gt) in enumerate(zip(answers, ground_truths)):
616
+ try:
617
+ # Check if answer entails ground truth and vice versa
618
+ entailment1 = self.entailment_model(
619
+ f"Premise: {answer} Hypothesis: {gt}"
620
+ )
621
+ entailment2 = self.entailment_model(
622
+ f"Premise: {gt} Hypothesis: {answer}"
623
+ )
624
+
625
+ # Calculate factual score
626
+ factual_score = 0.0
627
+ if entailment1[0]["label"] == "ENTAILMENT":
628
+ factual_score += entailment1[0]["score"] * 0.5
629
+ if entailment2[0]["label"] == "ENTAILMENT":
630
+ factual_score += entailment2[0]["score"] * 0.5
631
+
632
+ # Combine semantic and factual scores
633
+ combined_score = 0.6 * semantic_scores[i] + 0.4 * factual_score
634
+ scores.append(combined_score)
635
+
636
+ except Exception:
637
+ # Fallback to semantic similarity only
638
+ scores.append(semantic_scores[i])
639
+
640
+ except Exception as e:
641
+ logger.error(f"Batch processing failed: {e}")
642
+ # Fallback to individual processing
643
+ for item in evaluation_data:
644
+ try:
645
+ answer_emb = self.embedding_model.encode([item["answer"]])
646
+ gt_emb = self.embedding_model.encode([item["ground_truth"]])
647
+ similarity = cosine_similarity(answer_emb, gt_emb)[0][0]
648
+ scores.append(similarity)
649
+ except Exception:
650
+ scores.append(0.0)
651
+
652
+ return self._compute_metric_statistics(
653
+ "answer_correctness", scores, time.time() - start_time
654
+ )
655
+
656
+ def _evaluate_answer_similarity(
657
+ self, evaluation_data: List[Dict[str, Any]]
658
+ ) -> RAGASResult:
659
+ """
660
+ Evaluate answer similarity: Semantic similarity between answer and ground truth.
661
+
662
+ Pure semantic similarity measure using embeddings.
663
+ """
664
+ start_time = time.time()
665
+ scores = []
666
+
667
+ # Batch encode for efficiency
668
+ answers = [item["answer"] for item in evaluation_data]
669
+ ground_truths = [item["ground_truth"] for item in evaluation_data]
670
+
671
+ try:
672
+ answer_embeddings = self.embedding_model.encode(
673
+ answers, batch_size=self.config["batch_size"]
674
+ )
675
+ gt_embeddings = self.embedding_model.encode(
676
+ ground_truths, batch_size=self.config["batch_size"]
677
+ )
678
+
679
+ similarities = util.cos_sim(answer_embeddings, gt_embeddings)
680
+ scores = [similarities[i][i].item() for i in range(len(answers))]
681
+
682
+ except Exception as e:
683
+ logger.error(f"Batch processing failed: {e}")
684
+ for item in evaluation_data:
685
+ try:
686
+ answer_emb = self.embedding_model.encode([item["answer"]])
687
+ gt_emb = self.embedding_model.encode([item["ground_truth"]])
688
+ similarity = cosine_similarity(answer_emb, gt_emb)[0][0]
689
+ scores.append(similarity)
690
+ except Exception:
691
+ scores.append(0.0)
692
+
693
+ return self._compute_metric_statistics(
694
+ "answer_similarity", scores, time.time() - start_time
695
+ )
696
+
697
+ def _compute_metric_statistics(
698
+ self, metric_name: str, scores: List[float], computation_time: float
699
+ ) -> RAGASResult:
700
+ """Compute comprehensive statistics for a metric."""
701
+ if not scores:
702
+ return RAGASResult(
703
+ metric_name=metric_name,
704
+ value=0.0,
705
+ confidence_interval=(0.0, 0.0),
706
+ sample_size=0,
707
+ standard_error=0.0,
708
+ computation_time=computation_time,
709
+ error_message="No valid scores computed",
710
+ )
711
+
712
+ # Remove any NaN or infinite values
713
+ valid_scores = [s for s in scores if np.isfinite(s)]
714
+
715
+ if not valid_scores:
716
+ return RAGASResult(
717
+ metric_name=metric_name,
718
+ value=0.0,
719
+ confidence_interval=(0.0, 0.0),
720
+ sample_size=0,
721
+ standard_error=0.0,
722
+ computation_time=computation_time,
723
+ error_message="No valid finite scores",
724
+ )
725
+
726
+ # Basic statistics
727
+ mean_score = np.mean(valid_scores)
728
+ std_score = np.std(valid_scores, ddof=1) if len(valid_scores) > 1 else 0.0
729
+ standard_error = (
730
+ std_score / np.sqrt(len(valid_scores)) if len(valid_scores) > 0 else 0.0
731
+ )
732
+
733
+ # Confidence interval using bootstrap
734
+ confidence_interval = self._compute_confidence_interval(valid_scores)
735
+
736
+ return RAGASResult(
737
+ metric_name=metric_name,
738
+ value=mean_score,
739
+ confidence_interval=confidence_interval,
740
+ sample_size=len(valid_scores),
741
+ standard_error=standard_error,
742
+ computation_time=computation_time,
743
+ individual_scores=valid_scores,
744
+ )
745
+
746
+ def _compute_confidence_interval(self, scores: List[float]) -> Tuple[float, float]:
747
+ """Compute confidence interval using bootstrap resampling."""
748
+ if len(scores) < 2:
749
+ return (scores[0], scores[0]) if scores else (0.0, 0.0)
750
+
751
+ try:
752
+ # Bootstrap resampling
753
+ data = np.array(scores)
754
+
755
+ def bootstrap_mean(data):
756
+ return np.mean(np.random.choice(data, size=len(data), replace=True))
757
+
758
+ bootstrap_means = [
759
+ bootstrap_mean(data) for _ in range(self.config["bootstrap_samples"])
760
+ ]
761
+
762
+ # Calculate confidence interval
763
+ alpha = 1 - self.config["confidence_level"]
764
+ lower_percentile = (alpha / 2) * 100
765
+ upper_percentile = (1 - alpha / 2) * 100
766
+
767
+ ci_lower = np.percentile(bootstrap_means, lower_percentile)
768
+ ci_upper = np.percentile(bootstrap_means, upper_percentile)
769
+
770
+ return (ci_lower, ci_upper)
771
+
772
+ except Exception as e:
773
+ logger.warning(f"Bootstrap confidence interval failed: {e}")
774
+ # Fallback to normal approximation
775
+ mean_val = np.mean(scores)
776
+ se = np.std(scores) / np.sqrt(len(scores))
777
+ z_score = stats.norm.ppf(1 - (1 - self.config["confidence_level"]) / 2)
778
+ margin = z_score * se
779
+ return (mean_val - margin, mean_val + margin)
780
+
781
+ def _calculate_overall_score(self, results: ComprehensiveRAGASResults) -> float:
782
+ """Calculate overall score as simple average of available metrics."""
783
+ metric_values = []
784
+
785
+ for metric_name in [
786
+ "faithfulness",
787
+ "answer_relevancy",
788
+ "context_precision",
789
+ "context_recall",
790
+ "context_utilization",
791
+ "answer_correctness",
792
+ "answer_similarity",
793
+ ]:
794
+ metric_result = getattr(results, metric_name)
795
+ if metric_result and metric_result.error_message is None:
796
+ metric_values.append(metric_result.value)
797
+
798
+ return np.mean(metric_values) if metric_values else 0.0
799
+
800
+ def _calculate_weighted_score(self, results: ComprehensiveRAGASResults) -> float:
801
+ """Calculate weighted score using biomedical domain-specific weights."""
802
+ weighted_sum = 0.0
803
+ total_weight = 0.0
804
+
805
+ for metric_name, weight in self.metric_weights.items():
806
+ metric_result = getattr(results, metric_name)
807
+ if metric_result and metric_result.error_message is None:
808
+ weighted_sum += metric_result.value * weight
809
+ total_weight += weight
810
+
811
+ return weighted_sum / total_weight if total_weight > 0 else 0.0
812
+
813
+ def compare_pipelines(
814
+ self, pipeline_results: Dict[str, ComprehensiveRAGASResults]
815
+ ) -> Dict[str, Any]:
816
+ """
817
+ Statistical comparison of multiple pipeline results.
818
+
819
+ Args:
820
+ pipeline_results: Dictionary mapping pipeline names to their RAGAS results
821
+
822
+ Returns:
823
+ Comprehensive comparison analysis with statistical significance tests
824
+ """
825
+ logger.info(f"Comparing {len(pipeline_results)} pipelines...")
826
+
827
+ comparison = {
828
+ "timestamp": time.strftime("%Y-%m-%d %H:%M:%S"),
829
+ "pipelines_compared": list(pipeline_results.keys()),
830
+ "metric_comparisons": {},
831
+ "overall_ranking": [],
832
+ "statistical_significance": {},
833
+ "best_pipeline_per_metric": {},
834
+ }
835
+
836
+ # Compare each metric across pipelines
837
+ for metric_name in [
838
+ "faithfulness",
839
+ "answer_relevancy",
840
+ "context_precision",
841
+ "context_recall",
842
+ "context_utilization",
843
+ "answer_correctness",
844
+ "answer_similarity",
845
+ ]:
846
+
847
+ metric_data = {}
848
+ individual_scores = {}
849
+
850
+ for pipeline_name, results in pipeline_results.items():
851
+ metric_result = getattr(results, metric_name)
852
+ if metric_result and metric_result.error_message is None:
853
+ metric_data[pipeline_name] = metric_result.value
854
+ individual_scores[pipeline_name] = metric_result.individual_scores
855
+
856
+ if len(metric_data) > 1:
857
+ # Statistical comparison
858
+ comparison["metric_comparisons"][metric_name] = metric_data
859
+
860
+ # Find best pipeline for this metric
861
+ best_pipeline = max(metric_data, key=metric_data.get)
862
+ comparison["best_pipeline_per_metric"][metric_name] = {
863
+ "pipeline": best_pipeline,
864
+ "score": metric_data[best_pipeline],
865
+ }
866
+
867
+ # Statistical significance testing
868
+ if len(individual_scores) >= 2:
869
+ significance_results = self._test_statistical_significance(
870
+ individual_scores
871
+ )
872
+ comparison["statistical_significance"][
873
+ metric_name
874
+ ] = significance_results
875
+
876
+ # Overall ranking
877
+ overall_scores = {}
878
+ for pipeline_name, results in pipeline_results.items():
879
+ if results.weighted_score is not None:
880
+ overall_scores[pipeline_name] = results.weighted_score
881
+
882
+ comparison["overall_ranking"] = sorted(
883
+ overall_scores.items(), key=lambda x: x[1], reverse=True
884
+ )
885
+
886
+ return comparison
887
+
888
+ def _test_statistical_significance(
889
+ self, score_groups: Dict[str, List[float]]
890
+ ) -> Dict[str, Any]:
891
+ """Test statistical significance between pipeline performance."""
892
+ pipeline_names = list(score_groups.keys())
893
+ significance_results = {
894
+ "test_type": "mannwhitneyu", # Non-parametric test
895
+ "pairwise_comparisons": {},
896
+ "overall_p_value": None,
897
+ "significant_differences": [],
898
+ }
899
+
900
+ # Pairwise comparisons
901
+ for i in range(len(pipeline_names)):
902
+ for j in range(i + 1, len(pipeline_names)):
903
+ pipeline1, pipeline2 = pipeline_names[i], pipeline_names[j]
904
+ scores1, scores2 = score_groups[pipeline1], score_groups[pipeline2]
905
+
906
+ try:
907
+ # Mann-Whitney U test (non-parametric)
908
+ statistic, p_value = stats.mannwhitneyu(
909
+ scores1, scores2, alternative="two-sided"
910
+ )
911
+
912
+ comparison_key = f"{pipeline1}_vs_{pipeline2}"
913
+ significance_results["pairwise_comparisons"][comparison_key] = {
914
+ "statistic": statistic,
915
+ "p_value": p_value,
916
+ "significant": p_value < 0.05,
917
+ "effect_size": self._calculate_effect_size(scores1, scores2),
918
+ }
919
+
920
+ if p_value < 0.05:
921
+ better_pipeline = (
922
+ pipeline1
923
+ if np.mean(scores1) > np.mean(scores2)
924
+ else pipeline2
925
+ )
926
+ significance_results["significant_differences"].append(
927
+ {
928
+ "comparison": comparison_key,
929
+ "p_value": p_value,
930
+ "better_pipeline": better_pipeline,
931
+ }
932
+ )
933
+
934
+ except Exception as e:
935
+ logger.warning(
936
+ f"Statistical test failed for {pipeline1} vs {pipeline2}: {e}"
937
+ )
938
+
939
+ return significance_results
940
+
941
+ def _calculate_effect_size(
942
+ self, scores1: List[float], scores2: List[float]
943
+ ) -> float:
944
+ """Calculate Cohen's d effect size."""
945
+ try:
946
+ mean1, mean2 = np.mean(scores1), np.mean(scores2)
947
+ pooled_std = np.sqrt(
948
+ (
949
+ (len(scores1) - 1) * np.var(scores1, ddof=1)
950
+ + (len(scores2) - 1) * np.var(scores2, ddof=1)
951
+ )
952
+ / (len(scores1) + len(scores2) - 2)
953
+ )
954
+ return (mean1 - mean2) / pooled_std if pooled_std > 0 else 0.0
955
+ except Exception:
956
+ return 0.0
957
+
958
+ def save_results(
959
+ self,
960
+ results: Union[ComprehensiveRAGASResults, Dict[str, Any]],
961
+ output_path: str,
962
+ ):
963
+ """Save RAGAS evaluation results to file."""
964
+ output_dir = Path(output_path)
965
+ output_dir.mkdir(parents=True, exist_ok=True)
966
+
967
+ if isinstance(results, ComprehensiveRAGASResults):
968
+ # Single pipeline results
969
+ filename = f"ragas_results_{results.pipeline_name}_{results.timestamp.replace(':', '-').replace(' ', '_')}.json"
970
+ data = results.to_dict()
971
+ else:
972
+ # Comparison results
973
+ filename = f"ragas_comparison_{results['timestamp'].replace(':', '-').replace(' ', '_')}.json"
974
+ data = results
975
+
976
+ filepath = output_dir / filename
977
+ with open(filepath, "w") as f:
978
+ json.dump(data, f, indent=2, default=str)
979
+
980
+ logger.info(f"RAGAS results saved to {filepath}")
981
+
982
+
983
+ def create_biomedical_ragas_framework(
984
+ config: Optional[Dict[str, Any]] = None,
985
+ ) -> BiomedicalRAGASFramework:
986
+ """Factory function to create a configured biomedical RAGAS framework."""
987
+ return BiomedicalRAGASFramework(config)
988
+
989
+
990
+ if __name__ == "__main__":
991
+ # Example usage
992
+ framework = create_biomedical_ragas_framework()
993
+
994
+ # Mock evaluation data
995
+ sample_pipeline_results = [
996
+ {
997
+ "query": "What are the symptoms of diabetes?",
998
+ "answer": "Common symptoms include increased thirst, frequent urination, and fatigue.",
999
+ "contexts": [
1000
+ "Diabetes mellitus symptoms include polydipsia, polyuria, and general weakness."
1001
+ ],
1002
+ }
1003
+ ]
1004
+
1005
+ sample_ground_truth = [
1006
+ "Diabetes symptoms include excessive thirst, frequent urination, fatigue, and blurred vision."
1007
+ ]
1008
+
1009
+ results = framework.evaluate_pipeline(
1010
+ sample_pipeline_results, sample_ground_truth, "sample_pipeline"
1011
+ )
1012
+
1013
+ framework.save_results(results, "output/ragas_results")