graphrag-eval 6.3.0__tar.gz → 6.5.0__tar.gz
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.
- {graphrag_eval-6.3.0 → graphrag_eval-6.5.0}/PKG-INFO +2 -2
- {graphrag_eval-6.3.0 → graphrag_eval-6.5.0}/graphrag_eval/aggregation.py +4 -4
- graphrag_eval-6.5.0/graphrag_eval/answer_correctness.py +172 -0
- graphrag_eval-6.5.0/graphrag_eval/answer_relevance.py +61 -0
- {graphrag_eval-6.3.0 → graphrag_eval-6.5.0}/graphrag_eval/cli/answer_correctness.py +47 -22
- {graphrag_eval-6.3.0 → graphrag_eval-6.5.0}/graphrag_eval/custom_evaluation.py +34 -18
- graphrag_eval-6.5.0/graphrag_eval/evaluation.py +154 -0
- graphrag_eval-6.5.0/graphrag_eval/evaluator.py +14 -0
- {graphrag_eval-6.3.0 → graphrag_eval-6.5.0}/graphrag_eval/llm_factory.py +18 -8
- graphrag_eval-6.5.0/graphrag_eval/prompts/template.md +31 -0
- {graphrag_eval-6.3.0 → graphrag_eval-6.5.0}/graphrag_eval/steps/evaluation.py +11 -3
- {graphrag_eval-6.3.0 → graphrag_eval-6.5.0}/pyproject.toml +6 -6
- graphrag_eval-6.3.0/graphrag_eval/answer_correctness.py +0 -139
- graphrag_eval-6.3.0/graphrag_eval/answer_relevance.py +0 -29
- graphrag_eval-6.3.0/graphrag_eval/evaluation.py +0 -104
- graphrag_eval-6.3.0/graphrag_eval/prompts/template.md +0 -26
- {graphrag_eval-6.3.0 → graphrag_eval-6.5.0}/LICENSE +0 -0
- {graphrag_eval-6.3.0 → graphrag_eval-6.5.0}/README.md +0 -0
- {graphrag_eval-6.3.0 → graphrag_eval-6.5.0}/graphrag_eval/__init__.py +0 -0
- {graphrag_eval-6.3.0 → graphrag_eval-6.5.0}/graphrag_eval/cli/__init__.py +0 -0
- {graphrag_eval-6.3.0 → graphrag_eval-6.5.0}/graphrag_eval/steps/__init__.py +0 -0
- {graphrag_eval-6.3.0 → graphrag_eval-6.5.0}/graphrag_eval/steps/iri_discovery.py +0 -0
- {graphrag_eval-6.3.0 → graphrag_eval-6.5.0}/graphrag_eval/steps/retrieval_answer.py +0 -0
- {graphrag_eval-6.3.0 → graphrag_eval-6.5.0}/graphrag_eval/steps/retrieval_context_ids.py +0 -0
- {graphrag_eval-6.3.0 → graphrag_eval-6.5.0}/graphrag_eval/steps/retrieval_context_texts.py +0 -0
- {graphrag_eval-6.3.0 → graphrag_eval-6.5.0}/graphrag_eval/steps/sparql.py +0 -0
- {graphrag_eval-6.3.0 → graphrag_eval-6.5.0}/graphrag_eval/steps/timeseries.py +0 -0
- {graphrag_eval-6.3.0 → graphrag_eval-6.5.0}/graphrag_eval/util.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: graphrag-eval
|
|
3
|
-
Version: 6.
|
|
3
|
+
Version: 6.5.0
|
|
4
4
|
Summary: For assessing question answering systems' final answers and intermediate steps, against a given set of questions, reference answers and steps.
|
|
5
5
|
License: Apache-2.0
|
|
6
6
|
Author: Philip Ganchev
|
|
@@ -10,7 +10,7 @@ Classifier: License :: OSI Approved :: Apache Software License
|
|
|
10
10
|
Classifier: Programming Language :: Python :: 3
|
|
11
11
|
Classifier: Programming Language :: Python :: 3.12
|
|
12
12
|
Provides-Extra: llm
|
|
13
|
-
Requires-Dist: pydantic (==2.
|
|
13
|
+
Requires-Dist: pydantic (==2.14.1)
|
|
14
14
|
Requires-Dist: python-dateutil (==2.9.0.post0)
|
|
15
15
|
Requires-Dist: ragas (==0.4.3) ; extra == "llm"
|
|
16
16
|
Project-URL: Repository, https://github.com/Ontotext-AD/graphrag-eval
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import json
|
|
2
|
-
import yaml
|
|
3
2
|
from collections import defaultdict
|
|
4
3
|
from collections.abc import Sequence
|
|
5
4
|
from pathlib import Path
|
|
6
5
|
from statistics import mean, median
|
|
7
6
|
from typing import Any, Collection, Iterable
|
|
8
7
|
|
|
9
|
-
|
|
8
|
+
import yaml
|
|
10
9
|
|
|
10
|
+
from . import evaluation
|
|
11
11
|
|
|
12
12
|
METRICS = [
|
|
13
13
|
"answer_recall",
|
|
@@ -155,7 +155,7 @@ def compute_micro_stats(
|
|
|
155
155
|
) -> dict:
|
|
156
156
|
if custom_metrics is None:
|
|
157
157
|
custom_metrics = []
|
|
158
|
-
|
|
158
|
+
|
|
159
159
|
values = number_of_samples_per_template_by_status.values()
|
|
160
160
|
micro_summary = defaultdict(dict, {
|
|
161
161
|
"number_of_error_samples": sum(v["error"] for v in values),
|
|
@@ -197,7 +197,7 @@ def compute_macro_stats(
|
|
|
197
197
|
) -> dict:
|
|
198
198
|
if custom_metrics is None:
|
|
199
199
|
custom_metrics = []
|
|
200
|
-
|
|
200
|
+
|
|
201
201
|
macro_summary = defaultdict(dict)
|
|
202
202
|
for metric in METRICS + custom_metrics:
|
|
203
203
|
means = [
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any, Self, TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
7
|
+
|
|
8
|
+
from graphrag_eval.util import compute_f1
|
|
9
|
+
from .evaluator import Evaluator
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from ragas.llms.base import InstructorBaseRagasLLM
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def load_default_prompt() -> str:
|
|
16
|
+
with open(
|
|
17
|
+
Path(__file__).parent / "prompts" / "template.md",
|
|
18
|
+
encoding="utf-8"
|
|
19
|
+
) as f:
|
|
20
|
+
return f.read()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class AnswerCorrectnessConfig(BaseModel):
|
|
24
|
+
enabled: bool = Field(default=True)
|
|
25
|
+
prompt: str = Field(default_factory=load_default_prompt)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class AnswerCorrectnessResult(BaseModel):
|
|
29
|
+
model_config = ConfigDict(extra="forbid")
|
|
30
|
+
|
|
31
|
+
reference_claims_count: int = Field(ge=1)
|
|
32
|
+
actual_claims_count: int = Field(ge=1)
|
|
33
|
+
matching_claims_count: int = Field(ge=0)
|
|
34
|
+
reason: str = Field(min_length=1)
|
|
35
|
+
|
|
36
|
+
@model_validator(mode="after")
|
|
37
|
+
def validate_claim_counts(self) -> "AnswerCorrectnessResult":
|
|
38
|
+
if (
|
|
39
|
+
self.matching_claims_count > self.reference_claims_count or
|
|
40
|
+
self.matching_claims_count > self.actual_claims_count
|
|
41
|
+
):
|
|
42
|
+
raise ValueError(
|
|
43
|
+
"matching_claims_count must be less than or equal to both "
|
|
44
|
+
"reference_claims_count and actual_claims_count"
|
|
45
|
+
)
|
|
46
|
+
return self
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class InvalidPromptException(Exception):
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
message="The prompt template is invalid and cannot be "
|
|
53
|
+
"formatted."
|
|
54
|
+
):
|
|
55
|
+
self.message = message
|
|
56
|
+
super().__init__(self.message)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class AnswerCorrectnessEvaluator:
|
|
60
|
+
def __init__(
|
|
61
|
+
self,
|
|
62
|
+
ragas_llm: InstructorBaseRagasLLM,
|
|
63
|
+
config: AnswerCorrectnessConfig | None = None,
|
|
64
|
+
):
|
|
65
|
+
self.config = config or AnswerCorrectnessConfig()
|
|
66
|
+
self.__validate_prompt_template(self.config.prompt)
|
|
67
|
+
self.prompt_template = self.config.prompt
|
|
68
|
+
self.ragas_llm = ragas_llm
|
|
69
|
+
|
|
70
|
+
@classmethod
|
|
71
|
+
def from_config(
|
|
72
|
+
cls,
|
|
73
|
+
ragas_llm: InstructorBaseRagasLLM | None,
|
|
74
|
+
config: AnswerCorrectnessConfig | None
|
|
75
|
+
) -> Self | None:
|
|
76
|
+
if ragas_llm is None:
|
|
77
|
+
return None
|
|
78
|
+
if config is None or not config.enabled:
|
|
79
|
+
return None
|
|
80
|
+
return cls(ragas_llm=ragas_llm, config=config)
|
|
81
|
+
|
|
82
|
+
@staticmethod
|
|
83
|
+
def __validate_prompt_template(prompt_template: str):
|
|
84
|
+
try:
|
|
85
|
+
prompt_template.format(
|
|
86
|
+
question="Q?",
|
|
87
|
+
reference_answer="R",
|
|
88
|
+
actual_answer="A",
|
|
89
|
+
)
|
|
90
|
+
except Exception as exc:
|
|
91
|
+
raise InvalidPromptException(
|
|
92
|
+
"Invalid prompt template. Must only contain placeholders: "
|
|
93
|
+
"{question}, {reference_answer}, and {actual_answer}. "
|
|
94
|
+
f"Original error: {exc}"
|
|
95
|
+
) from exc
|
|
96
|
+
|
|
97
|
+
async def _agenerate(self, prompt: str) -> AnswerCorrectnessResult:
|
|
98
|
+
"""Wrapper method for easier testing"""
|
|
99
|
+
response = await self.ragas_llm.agenerate(prompt, AnswerCorrectnessResult)
|
|
100
|
+
return AnswerCorrectnessResult.model_validate(response)
|
|
101
|
+
|
|
102
|
+
async def evaluate_answer(
|
|
103
|
+
self,
|
|
104
|
+
question: str,
|
|
105
|
+
reference_answer: str,
|
|
106
|
+
actual_answer: str
|
|
107
|
+
) -> AnswerCorrectnessResult:
|
|
108
|
+
if any(
|
|
109
|
+
not s.strip() for s in [question, reference_answer, actual_answer]
|
|
110
|
+
):
|
|
111
|
+
raise ValueError(
|
|
112
|
+
"The question of the reference or the actual answer is a blank "
|
|
113
|
+
"string!"
|
|
114
|
+
)
|
|
115
|
+
prompt = self.prompt_template.format(
|
|
116
|
+
question=question,
|
|
117
|
+
reference_answer=reference_answer,
|
|
118
|
+
actual_answer=actual_answer,
|
|
119
|
+
)
|
|
120
|
+
return await self._agenerate(prompt)
|
|
121
|
+
|
|
122
|
+
async def evaluate(
|
|
123
|
+
self,
|
|
124
|
+
reference: dict[str, Any],
|
|
125
|
+
actual: dict[str, Any]
|
|
126
|
+
) -> dict[str, Any]:
|
|
127
|
+
if "actual_answer" not in actual or "reference_answer" not in reference:
|
|
128
|
+
return {}
|
|
129
|
+
result = {}
|
|
130
|
+
try:
|
|
131
|
+
correctness = await self.evaluate_answer(
|
|
132
|
+
reference["question_text"],
|
|
133
|
+
reference["reference_answer"],
|
|
134
|
+
actual["actual_answer"],
|
|
135
|
+
)
|
|
136
|
+
result.update({
|
|
137
|
+
"answer_reference_claims_count": correctness.reference_claims_count,
|
|
138
|
+
"answer_actual_claims_count": correctness.actual_claims_count,
|
|
139
|
+
"answer_matching_claims_count": correctness.matching_claims_count,
|
|
140
|
+
"answer_correctness_reason": correctness.reason,
|
|
141
|
+
})
|
|
142
|
+
recall, precision, f1 = self.compute_recall_precision_f1(
|
|
143
|
+
correctness.reference_claims_count,
|
|
144
|
+
correctness.actual_claims_count,
|
|
145
|
+
correctness.matching_claims_count,
|
|
146
|
+
)
|
|
147
|
+
if recall is not None:
|
|
148
|
+
result["answer_recall"] = recall
|
|
149
|
+
if precision is not None:
|
|
150
|
+
result["answer_precision"] = precision
|
|
151
|
+
if f1 is not None:
|
|
152
|
+
result["answer_f1"] = f1
|
|
153
|
+
except Exception as exc:
|
|
154
|
+
result["answer_correctness_error"] = str(exc)
|
|
155
|
+
return result
|
|
156
|
+
|
|
157
|
+
@staticmethod
|
|
158
|
+
def compute_recall_precision_f1(
|
|
159
|
+
n_pos: int,
|
|
160
|
+
n_pred_pos: int,
|
|
161
|
+
n_true_pos: int,
|
|
162
|
+
) -> tuple[float | None, float | None, float | None]:
|
|
163
|
+
recall = None
|
|
164
|
+
precision = None
|
|
165
|
+
if n_pos:
|
|
166
|
+
recall = n_true_pos / n_pos
|
|
167
|
+
if n_pred_pos:
|
|
168
|
+
precision = n_true_pos / n_pred_pos
|
|
169
|
+
return recall, precision, compute_f1(recall, precision)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
_: Evaluator = AnswerCorrectnessEvaluator
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Self, TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
from .evaluator import Evaluator
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from ragas.llms.base import InstructorBaseRagasLLM
|
|
11
|
+
from ragas.embeddings.base import BaseRagasEmbeddings, BaseRagasEmbedding
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class AnswerRelevanceConfig(BaseModel):
|
|
15
|
+
enabled: bool = Field(default=True)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class AnswerRelevanceEvaluator:
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
ragas_llm: InstructorBaseRagasLLM,
|
|
22
|
+
ragas_embedder: BaseRagasEmbeddings | BaseRagasEmbedding
|
|
23
|
+
):
|
|
24
|
+
from ragas.metrics.collections import AnswerRelevancy
|
|
25
|
+
self.scorer = AnswerRelevancy(llm=ragas_llm, embeddings=ragas_embedder)
|
|
26
|
+
|
|
27
|
+
@classmethod
|
|
28
|
+
def from_config(
|
|
29
|
+
cls,
|
|
30
|
+
ragas_llm: InstructorBaseRagasLLM | None,
|
|
31
|
+
ragas_embedder: BaseRagasEmbeddings | BaseRagasEmbedding | None,
|
|
32
|
+
config: AnswerRelevanceConfig | None
|
|
33
|
+
) -> Self | None:
|
|
34
|
+
if ragas_llm is None or ragas_embedder is None:
|
|
35
|
+
return None
|
|
36
|
+
if config is None or not config.enabled:
|
|
37
|
+
return None
|
|
38
|
+
return cls(ragas_llm=ragas_llm, ragas_embedder=ragas_embedder)
|
|
39
|
+
|
|
40
|
+
async def evaluate(
|
|
41
|
+
self,
|
|
42
|
+
reference: dict[str, Any],
|
|
43
|
+
actual: dict[str, Any]
|
|
44
|
+
) -> dict[str, Any]:
|
|
45
|
+
if "actual_answer" not in actual:
|
|
46
|
+
return {}
|
|
47
|
+
try:
|
|
48
|
+
result = await self.scorer.ascore(
|
|
49
|
+
user_input=reference["question_text"],
|
|
50
|
+
response=actual["actual_answer"]
|
|
51
|
+
)
|
|
52
|
+
return {
|
|
53
|
+
"answer_relevance": result.value
|
|
54
|
+
}
|
|
55
|
+
except Exception as e:
|
|
56
|
+
return {
|
|
57
|
+
"answer_relevance_error": str(e)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
_: Evaluator = AnswerRelevanceEvaluator
|
|
@@ -1,8 +1,12 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
1
3
|
import argparse
|
|
2
4
|
import asyncio
|
|
3
5
|
import csv
|
|
6
|
+
import sys
|
|
4
7
|
from argparse import ArgumentParser
|
|
5
8
|
from pathlib import Path
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
6
10
|
|
|
7
11
|
from tqdm import tqdm
|
|
8
12
|
|
|
@@ -10,34 +14,39 @@ from graphrag_eval import llm_factory
|
|
|
10
14
|
from graphrag_eval.answer_correctness import AnswerCorrectnessEvaluator
|
|
11
15
|
from graphrag_eval.evaluation import Config
|
|
12
16
|
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from ragas.llms.base import InstructorBaseRagasLLM
|
|
19
|
+
|
|
13
20
|
|
|
14
21
|
def parse_args() -> argparse.Namespace:
|
|
15
22
|
parser = ArgumentParser(
|
|
16
|
-
description="Calculates answer correctness over the entries from the
|
|
17
|
-
"stores the output in the output tsv
|
|
23
|
+
description="Calculates answer correctness over the entries from the "
|
|
24
|
+
"input tsv file and stores the output in the output tsv "
|
|
25
|
+
"file.",
|
|
18
26
|
)
|
|
19
27
|
parser.add_argument(
|
|
20
28
|
"-i",
|
|
21
29
|
"--input-tsv-file-path",
|
|
22
30
|
type=Path,
|
|
23
31
|
required=True,
|
|
24
|
-
help="Input tsv file path with columns `Question`, `Reference answer`
|
|
32
|
+
help="Input tsv file path with columns `Question`, `Reference answer` "
|
|
33
|
+
"and `Actual answer`",
|
|
25
34
|
)
|
|
26
35
|
parser.add_argument(
|
|
27
36
|
"-o",
|
|
28
37
|
"--output-tsv-file-path",
|
|
29
38
|
type=Path,
|
|
30
39
|
required=True,
|
|
31
|
-
help="Output tsv file path with columns `#Reference`, `#PTarget`,
|
|
32
|
-
"`Reasoning`, `Error`",
|
|
40
|
+
help="Output tsv file path with columns `#Reference`, `#PTarget`, "
|
|
41
|
+
"`#Matching`, `Reasoning`, `Error`",
|
|
33
42
|
)
|
|
34
43
|
parser.add_argument(
|
|
35
44
|
"-c",
|
|
36
45
|
"--config-yaml-file-path",
|
|
37
46
|
type=Path,
|
|
38
47
|
required=True,
|
|
39
|
-
help="Config yaml file path with definition of the LLM to use and
|
|
40
|
-
"prompt.",
|
|
48
|
+
help="Config yaml file path with definition of the LLM to use and "
|
|
49
|
+
"optionally a custom prompt.",
|
|
41
50
|
)
|
|
42
51
|
return parser.parse_args()
|
|
43
52
|
|
|
@@ -47,6 +56,8 @@ async def evaluate_and_write(
|
|
|
47
56
|
output_tsv_file_path: Path,
|
|
48
57
|
evaluator: AnswerCorrectnessEvaluator,
|
|
49
58
|
) -> None:
|
|
59
|
+
csv.field_size_limit(sys.maxsize)
|
|
60
|
+
|
|
50
61
|
with open(input_tsv_file_path, encoding="utf-8") as f:
|
|
51
62
|
reader = csv.DictReader(f, delimiter="\t")
|
|
52
63
|
rows = [row for row in reader]
|
|
@@ -54,7 +65,9 @@ async def evaluate_and_write(
|
|
|
54
65
|
output_tsv_file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
55
66
|
with open(output_tsv_file_path, "w", encoding="utf-8") as f:
|
|
56
67
|
writer = csv.writer(f, delimiter="\t")
|
|
57
|
-
writer.writerow(
|
|
68
|
+
writer.writerow(
|
|
69
|
+
["#Reference", "#PTarget", "#Matching", "Reasoning", "Error"]
|
|
70
|
+
)
|
|
58
71
|
|
|
59
72
|
for row in tqdm(rows):
|
|
60
73
|
if "Question" not in row or \
|
|
@@ -63,13 +76,18 @@ async def evaluate_and_write(
|
|
|
63
76
|
raise ValueError("Unexpected input format!")
|
|
64
77
|
|
|
65
78
|
try:
|
|
66
|
-
|
|
79
|
+
correctness = await evaluator.evaluate_answer(
|
|
67
80
|
row["Question"],
|
|
68
81
|
row["Reference answer"],
|
|
69
82
|
row["Actual answer"]
|
|
70
83
|
)
|
|
71
|
-
|
|
72
|
-
|
|
84
|
+
writer.writerow([
|
|
85
|
+
correctness.reference_claims_count,
|
|
86
|
+
correctness.actual_claims_count,
|
|
87
|
+
correctness.matching_claims_count,
|
|
88
|
+
correctness.reason,
|
|
89
|
+
"",
|
|
90
|
+
])
|
|
73
91
|
except Exception as exc:
|
|
74
92
|
writer.writerow(["", "", "", "", str(exc)])
|
|
75
93
|
f.flush()
|
|
@@ -81,19 +99,26 @@ def run(
|
|
|
81
99
|
output_tsv_file_path: Path,
|
|
82
100
|
):
|
|
83
101
|
config = Config.parse(config_yaml_file_path)
|
|
84
|
-
ragas_llm = llm_factory.create_llm(
|
|
102
|
+
ragas_llm: InstructorBaseRagasLLM | None = llm_factory.create_llm(
|
|
103
|
+
config.llm
|
|
104
|
+
)
|
|
85
105
|
if ragas_llm is None:
|
|
86
|
-
raise ValueError(
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
106
|
+
raise ValueError(
|
|
107
|
+
"LLM must be configured to calculate the answer correctness!"
|
|
108
|
+
)
|
|
109
|
+
if config.answer_correctness and not config.answer_correctness.enabled:
|
|
110
|
+
raise ValueError(
|
|
111
|
+
"Can't disable answer correctness, when running this script!"
|
|
91
112
|
)
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
113
|
+
evaluator = AnswerCorrectnessEvaluator(
|
|
114
|
+
ragas_llm=ragas_llm,
|
|
115
|
+
config=config.answer_correctness,
|
|
116
|
+
)
|
|
117
|
+
asyncio.run(evaluate_and_write(
|
|
118
|
+
input_tsv_file_path,
|
|
119
|
+
output_tsv_file_path,
|
|
120
|
+
evaluator,
|
|
121
|
+
))
|
|
97
122
|
|
|
98
123
|
|
|
99
124
|
def main():
|
|
@@ -1,9 +1,14 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
1
3
|
import json
|
|
2
|
-
from typing import Literal
|
|
4
|
+
from typing import Literal, Self, TYPE_CHECKING, Any
|
|
3
5
|
|
|
4
6
|
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
5
7
|
|
|
6
|
-
from
|
|
8
|
+
from .evaluator import Evaluator
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from ragas.llms.base import InstructorBaseRagasLLM
|
|
7
12
|
|
|
8
13
|
RESERVED_KEYS = {
|
|
9
14
|
"template_id",
|
|
@@ -43,7 +48,7 @@ Inputs = Literal[
|
|
|
43
48
|
StepsKey = Literal["args", "output"]
|
|
44
49
|
|
|
45
50
|
|
|
46
|
-
class
|
|
51
|
+
class EvaluatorConfig(BaseModel):
|
|
47
52
|
model_config = ConfigDict(extra='forbid')
|
|
48
53
|
name: str
|
|
49
54
|
inputs: list[Inputs] = Field(..., min_length=1)
|
|
@@ -53,7 +58,7 @@ class Config(BaseModel):
|
|
|
53
58
|
steps_keys: set[StepsKey] | None = Field(default=None, min_length=1)
|
|
54
59
|
|
|
55
60
|
@model_validator(mode='after')
|
|
56
|
-
def validate_step_dependencies(self) ->
|
|
61
|
+
def validate_step_dependencies(self) -> Self:
|
|
57
62
|
if set(self.inputs) & {"reference_steps", "actual_steps"}:
|
|
58
63
|
suffix = "is required when steps are in inputs"
|
|
59
64
|
for var_name in ["steps_name", "steps_keys"]:
|
|
@@ -62,7 +67,7 @@ class Config(BaseModel):
|
|
|
62
67
|
return self
|
|
63
68
|
|
|
64
69
|
@model_validator(mode='after')
|
|
65
|
-
def validate_name_and_outputs(self) ->
|
|
70
|
+
def validate_name_and_outputs(self) -> Self:
|
|
66
71
|
if self.name + "_error" in RESERVED_KEYS:
|
|
67
72
|
raise ValueError(f"Name {self.name} is reserved")
|
|
68
73
|
conflicting_keys = set(self.outputs.keys()) & RESERVED_KEYS
|
|
@@ -76,7 +81,7 @@ def create_input_template(input_key: str) -> str:
|
|
|
76
81
|
return f"# {header}\n{{{input_key}}}"
|
|
77
82
|
|
|
78
83
|
|
|
79
|
-
def create_prompt_template(config:
|
|
84
|
+
def create_prompt_template(config: EvaluatorConfig, output_variables: list[str]) -> str:
|
|
80
85
|
"""
|
|
81
86
|
Return a template for the LLM prompt, with placeholders for the inputs,
|
|
82
87
|
instructions, outputs etc. We use this template at evaluation time to
|
|
@@ -99,8 +104,8 @@ def create_prompt_template(config: Config, output_variables: list[str]) -> str:
|
|
|
99
104
|
class CustomEvaluator:
|
|
100
105
|
def __init__(
|
|
101
106
|
self,
|
|
102
|
-
|
|
103
|
-
|
|
107
|
+
ragas_llm: InstructorBaseRagasLLM,
|
|
108
|
+
config: EvaluatorConfig,
|
|
104
109
|
):
|
|
105
110
|
self.name = config.name
|
|
106
111
|
self.input_variables = config.inputs
|
|
@@ -111,11 +116,24 @@ class CustomEvaluator:
|
|
|
111
116
|
config,
|
|
112
117
|
self.output_variables
|
|
113
118
|
)
|
|
114
|
-
self.
|
|
119
|
+
self.ragas_llm = ragas_llm
|
|
120
|
+
|
|
121
|
+
@classmethod
|
|
122
|
+
def from_config(
|
|
123
|
+
cls,
|
|
124
|
+
ragas_llm: InstructorBaseRagasLLM | None,
|
|
125
|
+
evaluation_configs: list[EvaluatorConfig] | None
|
|
126
|
+
) -> list[Self]:
|
|
127
|
+
if ragas_llm and evaluation_configs:
|
|
128
|
+
return [
|
|
129
|
+
cls(ragas_llm, evaluation_config)
|
|
130
|
+
for evaluation_config in evaluation_configs
|
|
131
|
+
]
|
|
132
|
+
return []
|
|
115
133
|
|
|
116
134
|
async def _agenerate(self, prompt: str) -> str:
|
|
117
135
|
"""Wrapper method for easier testing"""
|
|
118
|
-
return (await self.
|
|
136
|
+
return (await self.ragas_llm.agenerate(prompt, None)).choices[0].message.content
|
|
119
137
|
|
|
120
138
|
def format_steps(self, steps: list) -> str:
|
|
121
139
|
steps_formatted = []
|
|
@@ -157,7 +175,11 @@ class CustomEvaluator:
|
|
|
157
175
|
return result
|
|
158
176
|
return self.error(f"Expected {n_exp} tab-separated values, got: {response}")
|
|
159
177
|
|
|
160
|
-
async def evaluate(
|
|
178
|
+
async def evaluate(
|
|
179
|
+
self,
|
|
180
|
+
reference: dict[str, Any],
|
|
181
|
+
actual: dict[str, Any]
|
|
182
|
+
) -> dict[str, Any]:
|
|
161
183
|
inputs = {}
|
|
162
184
|
if "question" in self.input_variables:
|
|
163
185
|
if "question_text" not in reference:
|
|
@@ -195,10 +217,4 @@ class CustomEvaluator:
|
|
|
195
217
|
return self.parse_outputs(response)
|
|
196
218
|
|
|
197
219
|
|
|
198
|
-
|
|
199
|
-
if config.custom_evaluations and config.llm:
|
|
200
|
-
return [
|
|
201
|
-
CustomEvaluator(custom_evaluation_config, config)
|
|
202
|
-
for custom_evaluation_config in config.custom_evaluations
|
|
203
|
-
]
|
|
204
|
-
return []
|
|
220
|
+
_: Evaluator = CustomEvaluator
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Self, TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
import yaml
|
|
7
|
+
from pydantic import BaseModel, Field, model_validator
|
|
8
|
+
|
|
9
|
+
from .answer_correctness import (
|
|
10
|
+
AnswerCorrectnessConfig,
|
|
11
|
+
AnswerCorrectnessEvaluator,
|
|
12
|
+
)
|
|
13
|
+
from .answer_relevance import AnswerRelevanceConfig, AnswerRelevanceEvaluator
|
|
14
|
+
from .custom_evaluation import EvaluatorConfig, CustomEvaluator
|
|
15
|
+
from .evaluator import Evaluator
|
|
16
|
+
from .llm_factory import LLMConfig, create_llm, create_embedder
|
|
17
|
+
from .steps.evaluation import evaluate_steps
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from ragas.llms.base import InstructorBaseRagasLLM
|
|
21
|
+
from ragas.embeddings.base import BaseRagasEmbeddings, BaseRagasEmbedding
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Config(BaseModel):
|
|
25
|
+
llm: LLMConfig | None = None
|
|
26
|
+
custom_evaluations: list[EvaluatorConfig] | None = Field(
|
|
27
|
+
default=None,
|
|
28
|
+
min_length=1
|
|
29
|
+
)
|
|
30
|
+
answer_correctness: AnswerCorrectnessConfig | None = None
|
|
31
|
+
answer_relevance: AnswerRelevanceConfig | None = None
|
|
32
|
+
|
|
33
|
+
@model_validator(mode="after")
|
|
34
|
+
def validate_config_and_set_defaults(self) -> Self:
|
|
35
|
+
has_llm = self.llm is not None
|
|
36
|
+
has_embedding = has_llm and self.llm.embedding is not None
|
|
37
|
+
|
|
38
|
+
if self.answer_correctness is None and has_llm:
|
|
39
|
+
self.answer_correctness = AnswerCorrectnessConfig()
|
|
40
|
+
|
|
41
|
+
if self.answer_relevance is None and has_embedding:
|
|
42
|
+
self.answer_relevance = AnswerRelevanceConfig()
|
|
43
|
+
|
|
44
|
+
if self.custom_evaluations and not has_llm:
|
|
45
|
+
raise ValueError(
|
|
46
|
+
"llm config is required if custom_evaluations are provided"
|
|
47
|
+
)
|
|
48
|
+
if (
|
|
49
|
+
self.answer_correctness
|
|
50
|
+
and self.answer_correctness.enabled
|
|
51
|
+
and not has_llm
|
|
52
|
+
):
|
|
53
|
+
raise ValueError(
|
|
54
|
+
"llm config is required if answer correctness is enabled"
|
|
55
|
+
)
|
|
56
|
+
if (
|
|
57
|
+
self.answer_relevance
|
|
58
|
+
and self.answer_relevance.enabled
|
|
59
|
+
and not has_embedding
|
|
60
|
+
):
|
|
61
|
+
raise ValueError(
|
|
62
|
+
"llm config including embedding is required if answer "
|
|
63
|
+
"relevance is enabled"
|
|
64
|
+
)
|
|
65
|
+
return self
|
|
66
|
+
|
|
67
|
+
@classmethod
|
|
68
|
+
def parse(cls, config_file_path: str | Path | None) -> Self:
|
|
69
|
+
if config_file_path:
|
|
70
|
+
with open(config_file_path, encoding="utf-8") as f:
|
|
71
|
+
config_dict = yaml.safe_load(f)
|
|
72
|
+
return cls(**config_dict)
|
|
73
|
+
return cls()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
async def run_evaluation(
|
|
77
|
+
qa_dataset: list[dict],
|
|
78
|
+
responses_dict: dict,
|
|
79
|
+
config_file_path: str | Path | None = None,
|
|
80
|
+
) -> list[dict]:
|
|
81
|
+
evaluators, ragas_llm = parse_config_and_init_evaluators(config_file_path)
|
|
82
|
+
|
|
83
|
+
# Output metrics are not nested, for simpler aggregation
|
|
84
|
+
evaluation_results = []
|
|
85
|
+
for template in qa_dataset:
|
|
86
|
+
template_id = template["template_id"]
|
|
87
|
+
for question in template["questions"]:
|
|
88
|
+
actual_result = responses_dict[question["id"]]
|
|
89
|
+
eval_result = {
|
|
90
|
+
"template_id": template_id,
|
|
91
|
+
"question_id": actual_result["question_id"],
|
|
92
|
+
"question_text": question["question_text"]
|
|
93
|
+
}
|
|
94
|
+
for key in ("input_tokens", "output_tokens", "total_tokens",
|
|
95
|
+
"elapsed_sec"):
|
|
96
|
+
if key in actual_result:
|
|
97
|
+
eval_result[key] = actual_result[key]
|
|
98
|
+
if "actual_answer" in actual_result:
|
|
99
|
+
eval_result["actual_answer"] = actual_result["actual_answer"]
|
|
100
|
+
if "reference_answer" in question:
|
|
101
|
+
eval_result["reference_answer"] = question["reference_answer"]
|
|
102
|
+
if "reference_steps" in question:
|
|
103
|
+
eval_result["reference_steps"] = question["reference_steps"]
|
|
104
|
+
if "error" in actual_result:
|
|
105
|
+
eval_result.update({
|
|
106
|
+
"status": "error",
|
|
107
|
+
"error": actual_result["error"],
|
|
108
|
+
})
|
|
109
|
+
else:
|
|
110
|
+
eval_result["status"] = "success"
|
|
111
|
+
|
|
112
|
+
eval_result.update(
|
|
113
|
+
await evaluate_steps(question, actual_result, ragas_llm)
|
|
114
|
+
)
|
|
115
|
+
for evaluator in evaluators:
|
|
116
|
+
eval_result.update(
|
|
117
|
+
await evaluator.evaluate(question, actual_result)
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
evaluation_results.append(eval_result)
|
|
121
|
+
return evaluation_results
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def parse_config_and_init_evaluators(
|
|
125
|
+
config_file_path: str | Path | None
|
|
126
|
+
) -> tuple[
|
|
127
|
+
list[Evaluator],
|
|
128
|
+
InstructorBaseRagasLLM | None,
|
|
129
|
+
]:
|
|
130
|
+
config = Config.parse(config_file_path)
|
|
131
|
+
ragas_llm: InstructorBaseRagasLLM | None = create_llm(config.llm)
|
|
132
|
+
ragas_embedder: BaseRagasEmbeddings | BaseRagasEmbedding | None = (
|
|
133
|
+
create_embedder(config.llm)
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
evaluators: list[Evaluator] = []
|
|
137
|
+
|
|
138
|
+
answer_relevance_evaluator = AnswerRelevanceEvaluator.from_config(
|
|
139
|
+
ragas_llm, ragas_embedder, config.answer_relevance
|
|
140
|
+
)
|
|
141
|
+
if answer_relevance_evaluator:
|
|
142
|
+
evaluators.append(answer_relevance_evaluator)
|
|
143
|
+
|
|
144
|
+
answer_correctness_evaluator = AnswerCorrectnessEvaluator.from_config(
|
|
145
|
+
ragas_llm, config.answer_correctness
|
|
146
|
+
)
|
|
147
|
+
if answer_correctness_evaluator:
|
|
148
|
+
evaluators.append(answer_correctness_evaluator)
|
|
149
|
+
|
|
150
|
+
evaluators.extend(
|
|
151
|
+
CustomEvaluator.from_config(ragas_llm, config.custom_evaluations)
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
return evaluators, ragas_llm
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from typing import Protocol, Any
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class Evaluator(Protocol):
|
|
5
|
+
async def evaluate(
|
|
6
|
+
self,
|
|
7
|
+
reference: dict[str, Any],
|
|
8
|
+
actual: dict[str, Any]
|
|
9
|
+
) -> dict[str, Any]:
|
|
10
|
+
"""
|
|
11
|
+
Evaluate the actual output against the reference.
|
|
12
|
+
Returns a flat dictionary containing scores or error tracking logs.
|
|
13
|
+
"""
|
|
14
|
+
...
|
|
@@ -1,7 +1,13 @@
|
|
|
1
|
-
from
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
2
4
|
|
|
3
5
|
from pydantic import BaseModel, ConfigDict, Field
|
|
4
6
|
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from ragas.llms.base import InstructorBaseRagasLLM
|
|
9
|
+
from ragas.embeddings.base import BaseRagasEmbeddings, BaseRagasEmbedding
|
|
10
|
+
|
|
5
11
|
|
|
6
12
|
class GenerationConfig(BaseModel):
|
|
7
13
|
provider: str
|
|
@@ -17,18 +23,20 @@ class EmbeddingConfig(BaseModel):
|
|
|
17
23
|
model_config = ConfigDict(extra='allow')
|
|
18
24
|
|
|
19
25
|
|
|
20
|
-
class
|
|
26
|
+
class LLMConfig(BaseModel):
|
|
21
27
|
generation: GenerationConfig
|
|
22
28
|
embedding: EmbeddingConfig | None = None
|
|
23
29
|
|
|
24
30
|
|
|
25
|
-
def create_llm(
|
|
26
|
-
|
|
31
|
+
def create_llm(
|
|
32
|
+
config: LLMConfig | None
|
|
33
|
+
) -> InstructorBaseRagasLLM | None:
|
|
34
|
+
if config:
|
|
27
35
|
import litellm
|
|
28
36
|
from ragas.llms import llm_factory
|
|
29
37
|
|
|
30
38
|
litellm.drop_params = True # Remove unsupported params from requests
|
|
31
|
-
params = config.
|
|
39
|
+
params = config.generation.model_dump()
|
|
32
40
|
ragas_llm = llm_factory(
|
|
33
41
|
provider="litellm",
|
|
34
42
|
model=f"{params.pop('provider')}/{params.pop('model')}",
|
|
@@ -40,13 +48,15 @@ def create_llm(config: "evaluation.Config") -> Optional["InstructorBaseRagasLLM"
|
|
|
40
48
|
return None
|
|
41
49
|
|
|
42
50
|
|
|
43
|
-
def create_embedder(
|
|
44
|
-
|
|
51
|
+
def create_embedder(
|
|
52
|
+
config: LLMConfig | None
|
|
53
|
+
) -> BaseRagasEmbeddings | BaseRagasEmbedding | None:
|
|
54
|
+
if config and config.embedding:
|
|
45
55
|
import litellm
|
|
46
56
|
from ragas.embeddings.base import embedding_factory
|
|
47
57
|
|
|
48
58
|
litellm.drop_params = True # Remove unsupported params from requests
|
|
49
|
-
params = config.
|
|
59
|
+
params = config.embedding.model_dump()
|
|
50
60
|
ragas_embedder = embedding_factory(
|
|
51
61
|
provider="litellm",
|
|
52
62
|
model=f"{params.pop('provider')}/{params.pop('model')}",
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
Below are a query, a reference response and a candidate response to it.
|
|
2
|
+
1. Extract all claims from each response.
|
|
3
|
+
2. Find matching claims between responses. Matching claims have the same meaning and details such as subjects, names, locations, amounts, IDs, commands and paths.
|
|
4
|
+
3. Output one JSON object only. Do not include Markdown, code fences, or any text outside the JSON object.
|
|
5
|
+
|
|
6
|
+
# Query
|
|
7
|
+
{question}
|
|
8
|
+
|
|
9
|
+
# Reference response
|
|
10
|
+
{reference_answer}
|
|
11
|
+
|
|
12
|
+
# Candidate response
|
|
13
|
+
{actual_answer}
|
|
14
|
+
|
|
15
|
+
# JSON fields
|
|
16
|
+
* reference_claims_count: Count of reference response claims
|
|
17
|
+
* actual_claims_count: Count of candidate response claims
|
|
18
|
+
* matching_claims_count: Count of matching claims
|
|
19
|
+
* reason: Explanation of the counts in English
|
|
20
|
+
|
|
21
|
+
# Value checks
|
|
22
|
+
* 1 <= reference_claims_count, actual_claims_count
|
|
23
|
+
* 0 <= matching_claims_count <= reference_claims_count, actual_claims_count
|
|
24
|
+
|
|
25
|
+
# Output format
|
|
26
|
+
{{
|
|
27
|
+
"reference_claims_count": 1,
|
|
28
|
+
"actual_claims_count": 1,
|
|
29
|
+
"matching_claims_count": 1,
|
|
30
|
+
"reason": "The reference and candidate responses contain the same single claim."
|
|
31
|
+
}}
|
|
@@ -1,13 +1,21 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
1
3
|
import json
|
|
2
4
|
import logging
|
|
3
5
|
from collections import defaultdict
|
|
4
6
|
from collections.abc import Sequence
|
|
5
|
-
from typing import Any
|
|
7
|
+
from typing import Any, TYPE_CHECKING
|
|
6
8
|
|
|
7
9
|
from .iri_discovery import do_iri_discovery_steps_equal
|
|
8
10
|
from .retrieval_context_ids import recall_at_k
|
|
9
11
|
from .sparql import compare_sparql_results
|
|
10
|
-
from .timeseries import
|
|
12
|
+
from .timeseries import (
|
|
13
|
+
do_retrieve_time_series_steps_equal,
|
|
14
|
+
do_retrieve_data_points_steps_equal,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from ragas.llms.base import InstructorBaseRagasLLM
|
|
11
19
|
|
|
12
20
|
logger = logging.getLogger(__name__)
|
|
13
21
|
|
|
@@ -140,7 +148,7 @@ def calculate_steps_score(
|
|
|
140
148
|
async def evaluate_steps(
|
|
141
149
|
reference: dict,
|
|
142
150
|
actual: dict,
|
|
143
|
-
ragas_llm:
|
|
151
|
+
ragas_llm: InstructorBaseRagasLLM | None,
|
|
144
152
|
) -> dict:
|
|
145
153
|
eval_result = {}
|
|
146
154
|
actual_steps = actual.get("actual_steps", [])
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "graphrag-eval"
|
|
3
|
-
version = "6.
|
|
3
|
+
version = "6.5.0"
|
|
4
4
|
description = "For assessing question answering systems' final answers and intermediate steps, against a given set of questions, reference answers and steps."
|
|
5
5
|
authors = [
|
|
6
6
|
{ name = "Philip Ganchev", email = "philip.ganchev@graphwise.ai" },
|
|
@@ -17,23 +17,23 @@ repository = "https://github.com/Ontotext-AD/graphrag-eval"
|
|
|
17
17
|
[tool.poetry.dependencies]
|
|
18
18
|
python-dateutil = "2.9.0.post0"
|
|
19
19
|
ragas = { version = "0.4.3", optional = true }
|
|
20
|
-
pydantic = "2.
|
|
20
|
+
pydantic = "2.14.1"
|
|
21
21
|
|
|
22
22
|
[tool.poetry.extras]
|
|
23
23
|
llm = ["ragas", "litellm", "pyyaml"]
|
|
24
24
|
|
|
25
25
|
[tool.poetry.group.llm.dependencies]
|
|
26
26
|
ragas = "0.4.3"
|
|
27
|
-
litellm = "1.
|
|
27
|
+
litellm = "1.105.1"
|
|
28
28
|
pyyaml = "6.0.3"
|
|
29
29
|
|
|
30
30
|
[tool.poetry.group.llm]
|
|
31
31
|
optional = true
|
|
32
32
|
|
|
33
33
|
[tool.poetry.group.test.dependencies]
|
|
34
|
-
pytest = "<10,>=9.
|
|
35
|
-
pytest-asyncio = "1.
|
|
36
|
-
pytest-cov = "<8,>=7"
|
|
34
|
+
pytest = "<10,>=9.1.1"
|
|
35
|
+
pytest-asyncio = "<2,>=1.4.0"
|
|
36
|
+
pytest-cov = "<8,>=7.1.0"
|
|
37
37
|
jsonlines = "4.0.0"
|
|
38
38
|
pyyaml = "6.0.3"
|
|
39
39
|
|
|
@@ -1,139 +0,0 @@
|
|
|
1
|
-
from pathlib import Path
|
|
2
|
-
|
|
3
|
-
from pydantic import BaseModel, Field
|
|
4
|
-
|
|
5
|
-
from graphrag_eval.util import compute_f1
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
def load_default_prompt() -> str:
|
|
9
|
-
with open(Path(__file__).parent / "prompts" / "template.md", "r", encoding="utf-8") as f:
|
|
10
|
-
return f.read()
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
class AnswerCorrectnessConfig(BaseModel):
|
|
14
|
-
prompt: str = Field(default_factory=load_default_prompt)
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
class InvalidPromptException(Exception):
|
|
18
|
-
def __init__(self, message="The prompt template is invalid and cannot be formatted."):
|
|
19
|
-
self.message = message
|
|
20
|
-
super().__init__(self.message)
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
class AnswerCorrectnessEvaluator:
|
|
24
|
-
def __init__(
|
|
25
|
-
self,
|
|
26
|
-
llm: "InstructorBaseRagasLLM",
|
|
27
|
-
config: AnswerCorrectnessConfig | None = None,
|
|
28
|
-
):
|
|
29
|
-
self.config = config or AnswerCorrectnessConfig()
|
|
30
|
-
self.__validate_prompt_template(self.config.prompt)
|
|
31
|
-
self.prompt_template = self.config.prompt
|
|
32
|
-
self.llm = llm
|
|
33
|
-
|
|
34
|
-
@staticmethod
|
|
35
|
-
def __validate_prompt_template(prompt_template: str):
|
|
36
|
-
try:
|
|
37
|
-
prompt_template.format(
|
|
38
|
-
question="Q?",
|
|
39
|
-
reference_answer="R",
|
|
40
|
-
actual_answer="A",
|
|
41
|
-
)
|
|
42
|
-
except Exception as exc:
|
|
43
|
-
raise InvalidPromptException(
|
|
44
|
-
"Invalid prompt template. Must only contain placeholders: "
|
|
45
|
-
"{question}, {reference_answer}, and {actual_answer}. "
|
|
46
|
-
f"Original error: {exc}"
|
|
47
|
-
) from exc
|
|
48
|
-
|
|
49
|
-
async def _agenerate(self, prompt):
|
|
50
|
-
"""Wrapper method for easier testing"""
|
|
51
|
-
return (await self.llm.agenerate(prompt, None)).choices[0].message.content
|
|
52
|
-
|
|
53
|
-
async def evaluate_answer(
|
|
54
|
-
self,
|
|
55
|
-
question: str,
|
|
56
|
-
reference_answer: str,
|
|
57
|
-
actual_answer: str
|
|
58
|
-
) -> tuple[int, int, int, str]:
|
|
59
|
-
if any(not s.strip() for s in [question, reference_answer, actual_answer]):
|
|
60
|
-
raise ValueError("The question of the reference or the actual answer is a blank "
|
|
61
|
-
"string!")
|
|
62
|
-
prompt = self.prompt_template.format(
|
|
63
|
-
question=question,
|
|
64
|
-
reference_answer=reference_answer,
|
|
65
|
-
actual_answer=actual_answer,
|
|
66
|
-
)
|
|
67
|
-
response_str = await self._agenerate(prompt)
|
|
68
|
-
return self.extract_response_values(response_str)
|
|
69
|
-
|
|
70
|
-
async def get_correctness_dict(
|
|
71
|
-
self,
|
|
72
|
-
reference: dict,
|
|
73
|
-
actual: dict,
|
|
74
|
-
):
|
|
75
|
-
result = {"reference_answer": reference["reference_answer"]}
|
|
76
|
-
try:
|
|
77
|
-
num_ref_claims, num_actual_claims, num_matching_claims, reason = \
|
|
78
|
-
await self.evaluate_answer(
|
|
79
|
-
reference["question_text"],
|
|
80
|
-
reference["reference_answer"],
|
|
81
|
-
actual["actual_answer"],
|
|
82
|
-
)
|
|
83
|
-
result.update({
|
|
84
|
-
"answer_reference_claims_count": num_ref_claims,
|
|
85
|
-
"answer_actual_claims_count": num_actual_claims,
|
|
86
|
-
"answer_matching_claims_count": num_matching_claims,
|
|
87
|
-
"answer_correctness_reason": reason,
|
|
88
|
-
})
|
|
89
|
-
recall, precision, f1 = self.compute_recall_precision_f1(
|
|
90
|
-
num_ref_claims, num_actual_claims, num_matching_claims
|
|
91
|
-
)
|
|
92
|
-
if recall is not None:
|
|
93
|
-
result["answer_recall"] = recall
|
|
94
|
-
if precision is not None:
|
|
95
|
-
result["answer_precision"] = precision
|
|
96
|
-
if f1 is not None:
|
|
97
|
-
result["answer_f1"] = f1
|
|
98
|
-
except Exception as exc:
|
|
99
|
-
result["answer_eval_error"] = str(exc)
|
|
100
|
-
return result
|
|
101
|
-
|
|
102
|
-
@staticmethod
|
|
103
|
-
def compute_recall_precision_f1(
|
|
104
|
-
n_pos: int,
|
|
105
|
-
n_pred_pos: int,
|
|
106
|
-
n_true_pos: int,
|
|
107
|
-
) -> tuple[float | None, float | None, float | None]:
|
|
108
|
-
recall = None
|
|
109
|
-
precision = None
|
|
110
|
-
if n_pos:
|
|
111
|
-
recall = n_true_pos / n_pos
|
|
112
|
-
if n_pred_pos:
|
|
113
|
-
precision = n_true_pos / n_pred_pos
|
|
114
|
-
return recall, precision, compute_f1(recall, precision)
|
|
115
|
-
|
|
116
|
-
@staticmethod
|
|
117
|
-
def extract_response_values(
|
|
118
|
-
response: str
|
|
119
|
-
) -> tuple[int, int, int, str]:
|
|
120
|
-
vals = response.split("\t")
|
|
121
|
-
n = len(vals)
|
|
122
|
-
if n < 4:
|
|
123
|
-
raise ValueError(f"Expected 4 tab-separated values: {response}")
|
|
124
|
-
vals = vals[:4]
|
|
125
|
-
try:
|
|
126
|
-
n_ref, n_actual, n_matching = map(int, vals[:3])
|
|
127
|
-
except ValueError:
|
|
128
|
-
raise ValueError(f"Claims counts should be ints: {vals}")
|
|
129
|
-
if any([
|
|
130
|
-
n_ref < 1,
|
|
131
|
-
n_actual < 1,
|
|
132
|
-
n_matching < 0,
|
|
133
|
-
n_matching > n_ref,
|
|
134
|
-
n_matching > n_actual
|
|
135
|
-
]):
|
|
136
|
-
raise ValueError(
|
|
137
|
-
f"Invalid claims counts combination: {n_ref}\t{n_actual}\t{n_matching}"
|
|
138
|
-
)
|
|
139
|
-
return n_ref, n_actual, n_matching, vals[3]
|
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
from ragas.embeddings.base import BaseRagasEmbedding
|
|
2
|
-
from ragas.llms.base import InstructorBaseRagasLLM
|
|
3
|
-
from ragas.metrics.collections import AnswerRelevancy
|
|
4
|
-
|
|
5
|
-
from graphrag_eval.util import singleton
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
@singleton
|
|
9
|
-
class Evaluator:
|
|
10
|
-
def __init__(self, ragas_llm: InstructorBaseRagasLLM, ragas_embedder: BaseRagasEmbedding):
|
|
11
|
-
self.scorer = AnswerRelevancy(llm=ragas_llm, embeddings=ragas_embedder)
|
|
12
|
-
|
|
13
|
-
async def get_relevance_dict(
|
|
14
|
-
self,
|
|
15
|
-
question_text: str,
|
|
16
|
-
actual_answer: str,
|
|
17
|
-
) -> dict:
|
|
18
|
-
try:
|
|
19
|
-
result = await self.scorer.ascore(
|
|
20
|
-
user_input=question_text,
|
|
21
|
-
response=actual_answer
|
|
22
|
-
)
|
|
23
|
-
return {
|
|
24
|
-
"answer_relevance": result.value
|
|
25
|
-
}
|
|
26
|
-
except Exception as e:
|
|
27
|
-
return {
|
|
28
|
-
"answer_relevance_error": str(e)
|
|
29
|
-
}
|
|
@@ -1,104 +0,0 @@
|
|
|
1
|
-
from pathlib import Path
|
|
2
|
-
|
|
3
|
-
import yaml
|
|
4
|
-
from pydantic import BaseModel, Field, model_validator
|
|
5
|
-
|
|
6
|
-
from . import custom_evaluation
|
|
7
|
-
from .answer_correctness import AnswerCorrectnessConfig
|
|
8
|
-
from .llm_factory import Config as LLMConfig, create_llm, create_embedder
|
|
9
|
-
from .steps.evaluation import evaluate_steps
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
class Config(BaseModel):
|
|
13
|
-
llm: LLMConfig | None = None
|
|
14
|
-
custom_evaluations: list[custom_evaluation.Config] | None \
|
|
15
|
-
= Field(default=None, min_length=1)
|
|
16
|
-
answer_correctness: AnswerCorrectnessConfig | None = None
|
|
17
|
-
|
|
18
|
-
@model_validator(mode="after")
|
|
19
|
-
def validate_config(self) -> "Config":
|
|
20
|
-
if self.custom_evaluations and not self.llm:
|
|
21
|
-
msg = "llm config is required if custom_evaluations are provided"
|
|
22
|
-
raise ValueError(msg)
|
|
23
|
-
return self
|
|
24
|
-
|
|
25
|
-
@classmethod
|
|
26
|
-
def parse(cls, config_file_path: str | Path | None) -> "Config":
|
|
27
|
-
if config_file_path:
|
|
28
|
-
with open(config_file_path, encoding="utf-8") as f:
|
|
29
|
-
config_dict = yaml.safe_load(f)
|
|
30
|
-
return cls(**config_dict)
|
|
31
|
-
return cls()
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
async def run_evaluation(
|
|
35
|
-
qa_dataset: list[dict],
|
|
36
|
-
responses_dict: dict,
|
|
37
|
-
config_file_path: str | Path | None = None,
|
|
38
|
-
) -> list[dict]:
|
|
39
|
-
# Output metrics are not nested, for simpler aggregation
|
|
40
|
-
evaluation_results = []
|
|
41
|
-
config = Config.parse(config_file_path)
|
|
42
|
-
ragas_llm = create_llm(config)
|
|
43
|
-
ragas_embedder = create_embedder(config)
|
|
44
|
-
custom_evaluators = custom_evaluation.create_evaluators(config)
|
|
45
|
-
for template in qa_dataset:
|
|
46
|
-
template_id = template["template_id"]
|
|
47
|
-
for question in template["questions"]:
|
|
48
|
-
actual_result = responses_dict[question["id"]]
|
|
49
|
-
eval_result = {
|
|
50
|
-
"template_id": template_id,
|
|
51
|
-
"question_id": actual_result["question_id"],
|
|
52
|
-
"question_text": question["question_text"]
|
|
53
|
-
}
|
|
54
|
-
if "reference_answer" in question:
|
|
55
|
-
eval_result["reference_answer"] = question["reference_answer"]
|
|
56
|
-
if "reference_steps" in question:
|
|
57
|
-
eval_result["reference_steps"] = question["reference_steps"]
|
|
58
|
-
if "error" in actual_result:
|
|
59
|
-
eval_result.update({
|
|
60
|
-
"status": "error",
|
|
61
|
-
"error": actual_result["error"],
|
|
62
|
-
})
|
|
63
|
-
else:
|
|
64
|
-
eval_result["status"] = "success"
|
|
65
|
-
|
|
66
|
-
if "actual_answer" in actual_result:
|
|
67
|
-
eval_result["actual_answer"] = actual_result["actual_answer"]
|
|
68
|
-
if ragas_llm:
|
|
69
|
-
from graphrag_eval.answer_relevance import Evaluator
|
|
70
|
-
relevance_evaluator = Evaluator(ragas_llm, ragas_embedder)
|
|
71
|
-
eval_result.update(
|
|
72
|
-
await relevance_evaluator.get_relevance_dict(
|
|
73
|
-
question["question_text"],
|
|
74
|
-
actual_result["actual_answer"],
|
|
75
|
-
)
|
|
76
|
-
)
|
|
77
|
-
if "reference_answer" in question and ragas_llm:
|
|
78
|
-
from graphrag_eval.answer_correctness import AnswerCorrectnessEvaluator
|
|
79
|
-
answer_correctness_evaluator = AnswerCorrectnessEvaluator(
|
|
80
|
-
llm=ragas_llm,
|
|
81
|
-
config=config.answer_correctness,
|
|
82
|
-
)
|
|
83
|
-
eval_result.update(
|
|
84
|
-
await answer_correctness_evaluator.get_correctness_dict(
|
|
85
|
-
question,
|
|
86
|
-
actual_result,
|
|
87
|
-
)
|
|
88
|
-
)
|
|
89
|
-
eval_result.update(
|
|
90
|
-
await evaluate_steps(
|
|
91
|
-
question,
|
|
92
|
-
actual_result,
|
|
93
|
-
ragas_llm,
|
|
94
|
-
)
|
|
95
|
-
)
|
|
96
|
-
for custom_evaluator in custom_evaluators:
|
|
97
|
-
custom_metrics = await custom_evaluator.evaluate(question, actual_result)
|
|
98
|
-
eval_result.update(**custom_metrics)
|
|
99
|
-
for key in "input_tokens", "output_tokens", "total_tokens", "elapsed_sec":
|
|
100
|
-
if key in actual_result:
|
|
101
|
-
eval_result[key] = actual_result[key]
|
|
102
|
-
|
|
103
|
-
evaluation_results.append(eval_result)
|
|
104
|
-
return evaluation_results
|
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
Below are a query, a reference response and a candidate response to it.
|
|
2
|
-
1. Extract all claims from each response
|
|
3
|
-
2. Find matching claims between responses. Matching claims have the same meaning and details such as subjects, names, locations, amounts, IDs, commands and paths.
|
|
4
|
-
3. Output the values listed below (all and only those).
|
|
5
|
-
|
|
6
|
-
# Query
|
|
7
|
-
{question}
|
|
8
|
-
|
|
9
|
-
# Reference response
|
|
10
|
-
{reference_answer}
|
|
11
|
-
|
|
12
|
-
# Candidate response
|
|
13
|
-
{actual_answer}
|
|
14
|
-
|
|
15
|
-
# Output values
|
|
16
|
-
* v1: Count of reference response claims
|
|
17
|
-
* v2: Count of candidate response claims
|
|
18
|
-
* v3: Count of matching claims
|
|
19
|
-
* v4: Explanation of v1-v3 in English
|
|
20
|
-
|
|
21
|
-
# Value checks
|
|
22
|
-
* 1 <= v1, v2
|
|
23
|
-
* 0 <= v3 <= v1, v2
|
|
24
|
-
|
|
25
|
-
# Output format
|
|
26
|
-
<v1><tab><v2><tab><v3><tab><v4>
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|