Skip to content

Commit 9262c63

Browse files
author
Kyle Zheng
committed
migrate eval framework to graph pipeline and also added new changes with better scoring naming, more generic env naming for api keys for multi model support
1 parent d176f39 commit 9262c63

36 files changed

Lines changed: 2604 additions & 1653 deletions

nemo_retriever/pyproject.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,10 @@ dependencies = [
8383
svg = [
8484
"cairosvg>=2.7.0",
8585
]
86+
eval = [
87+
"litellm>=1.40.0",
88+
"pyyaml>=6.0",
89+
]
8690
dev = [
8791
"build>=1.2.2",
8892
"pytest>=8.0.2",
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2024-25, NVIDIA CORPORATION & AFFILIATES.
2+
# All rights reserved.
3+
# SPDX-License-Identifier: Apache-2.0
4+
5+
"""QA evaluation framework for nemo_retriever.
6+
7+
Provides pluggable retrieval, generation, judging, and orchestration
8+
components for measuring LLM answer quality given retrieved context.
9+
10+
The ``EvalOperator`` base class bridges ``graph.AbstractOperator`` into the
11+
evaluation domain, enabling ``>>`` chaining, ``Graph.execute()``, and
12+
executor compatibility for all evaluation operators.
13+
14+
Types, scoring, and ``EvalOperator`` are always available.
15+
Modules that depend on ``litellm`` (generators, judges, generation,
16+
judging, orchestrator, config) are lazy-loaded so that lightweight
17+
consumers can use scoring without installing the ``[eval]`` extra::
18+
19+
pip install nemo-retriever[eval]
20+
"""
21+
22+
from nemo_retriever.evaluation.eval_operator import EvalOperator
23+
from nemo_retriever.evaluation.scoring import score_dataframe
24+
from nemo_retriever.evaluation.types import (
25+
AnswerJudge,
26+
GenerationResult,
27+
JudgeResult,
28+
LLMClient,
29+
QAQueryResult,
30+
RetrievalResult,
31+
RetrieverStrategy,
32+
)
33+
34+
_LAZY_IMPORTS = {
35+
"QAGenerationOperator": "nemo_retriever.evaluation.generation",
36+
"JudgingOperator": "nemo_retriever.evaluation.judging",
37+
"ScoringOperator": "nemo_retriever.evaluation.scoring_operator",
38+
"RetrievalLoaderOperator": "nemo_retriever.evaluation.retrieval_loader",
39+
"LiteLLMClient": "nemo_retriever.evaluation.generators",
40+
"LLMJudge": "nemo_retriever.evaluation.judges",
41+
"QAEvalPipeline": "nemo_retriever.evaluation.orchestrator",
42+
"load_eval_config": "nemo_retriever.evaluation.config",
43+
"build_eval_chain": "nemo_retriever.evaluation.config",
44+
"build_eval_pipeline": "nemo_retriever.evaluation.config",
45+
}
46+
47+
48+
def __getattr__(name: str):
49+
if name in _LAZY_IMPORTS:
50+
import importlib
51+
52+
module = importlib.import_module(_LAZY_IMPORTS[name])
53+
return getattr(module, name)
54+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
55+
56+
57+
__all__ = [
58+
"AnswerJudge",
59+
"EvalOperator",
60+
"GenerationResult",
61+
"JudgeResult",
62+
"JudgingOperator",
63+
"LLMClient",
64+
"LLMJudge",
65+
"LiteLLMClient",
66+
"QAEvalPipeline",
67+
"QAGenerationOperator",
68+
"QAQueryResult",
69+
"RetrievalLoaderOperator",
70+
"RetrievalResult",
71+
"RetrieverStrategy",
72+
"ScoringOperator",
73+
"build_eval_chain",
74+
"build_eval_pipeline",
75+
"load_eval_config",
76+
"score_dataframe",
77+
]
Lines changed: 296 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,296 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2024-25, NVIDIA CORPORATION & AFFILIATES.
2+
# All rights reserved.
3+
# SPDX-License-Identifier: Apache-2.0
4+
5+
"""
6+
Pluggable configuration layer for evaluation pipelines.
7+
8+
Provides a single YAML/JSON file as the source of truth for an entire
9+
evaluation run -- datasets, models, retrieval sources, scoring, and
10+
execution parameters.
11+
12+
Primary entry points:
13+
``load_eval_config(path)`` -- read and validate a config file
14+
``build_eval_chain(config)`` -- single-model ``>>`` graph chain
15+
``build_eval_pipeline(config)`` -- multi-model ``QAEvalPipeline``
16+
17+
Config schema (YAML example)::
18+
19+
dataset:
20+
source: "csv:data/bo767_annotations.csv"
21+
ground_truth_dir: "tools/harness/data"
22+
query_column: "query"
23+
answer_column: "answer"
24+
limit: 0
25+
26+
retrieval:
27+
type: "file"
28+
file_path: "data/retrieval_bo767_run_1"
29+
30+
generators:
31+
- name: "nemotron"
32+
model: "nvidia_nim/nvidia/llama-3.3-nemotron-super-49b-v1.5"
33+
api_key: "${NVIDIA_API_KEY}"
34+
temperature: 0.0
35+
max_tokens: 4096
36+
37+
judge:
38+
model: "nvidia_nim/mistralai/mixtral-8x22b-instruct-v0.1"
39+
api_key: "${NVIDIA_API_KEY}"
40+
41+
execution:
42+
top_k: 5
43+
max_workers: 8
44+
chunk_char_limit: 500
45+
include_chunks_in_results: true
46+
47+
output:
48+
results_file: "data/qa_results.json"
49+
50+
Environment variables are expanded in string values: ``${VAR}`` resolves
51+
to ``os.environ["VAR"]`` at load time. Secrets never live in the config.
52+
"""
53+
54+
from __future__ import annotations
55+
56+
import json
57+
import logging
58+
import os
59+
import re
60+
from pathlib import Path
61+
from typing import Any, TYPE_CHECKING
62+
63+
if TYPE_CHECKING:
64+
from nemo_retriever.evaluation.orchestrator import QAEvalPipeline
65+
from nemo_retriever.graph.pipeline_graph import Graph
66+
67+
logger = logging.getLogger(__name__)
68+
69+
_ENV_VAR_RE = re.compile(r"\$\{([^}]+)\}")
70+
71+
_REQUIRED_SECTIONS = ("generators", "judge")
72+
73+
74+
def _expand_env_vars(value: Any) -> Any:
75+
"""Recursively expand ``${VAR}`` in string values."""
76+
if isinstance(value, str):
77+
78+
def _replace(match: re.Match) -> str:
79+
var_name = match.group(1)
80+
env_val = os.environ.get(var_name)
81+
if env_val is None:
82+
logger.warning("Environment variable %s is not set", var_name)
83+
return match.group(0)
84+
return env_val
85+
86+
return _ENV_VAR_RE.sub(_replace, value)
87+
if isinstance(value, dict):
88+
return {k: _expand_env_vars(v) for k, v in value.items()}
89+
if isinstance(value, list):
90+
return [_expand_env_vars(item) for item in value]
91+
return value
92+
93+
94+
def load_eval_config(path: str) -> dict:
95+
"""Load eval config from YAML (``.yaml``/``.yml``) or JSON (``.json``) file.
96+
97+
Supports ``${VAR}`` env var expansion in string values (recursive).
98+
YAML requires ``pyyaml`` (in ``[eval]`` extras). JSON uses stdlib.
99+
100+
Parameters
101+
----------
102+
path : str
103+
Path to the configuration file.
104+
105+
Returns
106+
-------
107+
dict
108+
Parsed and env-var-expanded configuration dictionary.
109+
110+
Raises
111+
------
112+
FileNotFoundError
113+
If *path* does not exist.
114+
ValueError
115+
If the file extension is unsupported or required sections are missing.
116+
"""
117+
config_path = Path(path)
118+
if not config_path.exists():
119+
raise FileNotFoundError(f"Config file not found: {config_path}")
120+
121+
suffix = config_path.suffix.lower()
122+
if suffix in (".yaml", ".yml"):
123+
try:
124+
import yaml
125+
except ImportError as exc:
126+
raise ImportError(
127+
"pyyaml is required for YAML config files. " "Install it: pip install nemo-retriever[eval]"
128+
) from exc
129+
with open(config_path, encoding="utf-8") as f:
130+
raw = yaml.safe_load(f)
131+
elif suffix == ".json":
132+
with open(config_path, encoding="utf-8") as f:
133+
raw = json.load(f)
134+
else:
135+
raise ValueError(f"Unsupported config file extension: {suffix!r}. " "Use .yaml, .yml, or .json.")
136+
137+
if not isinstance(raw, dict):
138+
raise ValueError(f"Config file must contain a mapping, got {type(raw).__name__}")
139+
140+
config = _expand_env_vars(raw)
141+
142+
missing = [s for s in _REQUIRED_SECTIONS if s not in config]
143+
if missing:
144+
raise ValueError(f"Config is missing required sections: {missing}")
145+
146+
return config
147+
148+
149+
def build_eval_chain(
150+
config: dict,
151+
model_name: str | None = None,
152+
) -> "Graph":
153+
"""Construct a ``>>`` chain from config for single-model evaluation.
154+
155+
If *model_name* is specified, uses that generator from config.
156+
If ``None``, uses the first generator listed.
157+
158+
Returns a :class:`Graph`::
159+
160+
RetrievalLoaderOperator >> QAGenerationOperator >> JudgingOperator >> ScoringOperator
161+
162+
Parameters
163+
----------
164+
config : dict
165+
Parsed config from :func:`load_eval_config`.
166+
model_name : str, optional
167+
Generator name to use. Defaults to the first in ``config["generators"]``.
168+
169+
Returns
170+
-------
171+
Graph
172+
A chainable graph ready for ``.execute(None)``.
173+
"""
174+
from nemo_retriever.evaluation.generation import QAGenerationOperator
175+
from nemo_retriever.evaluation.judging import JudgingOperator
176+
from nemo_retriever.evaluation.retrieval_loader import RetrievalLoaderOperator
177+
from nemo_retriever.evaluation.scoring_operator import ScoringOperator
178+
179+
generators = config["generators"]
180+
if not generators:
181+
raise ValueError("Config must have at least one generator")
182+
183+
if model_name is not None:
184+
gen_cfg = next((g for g in generators if g.get("name") == model_name), None)
185+
if gen_cfg is None:
186+
available = [g.get("name", "?") for g in generators]
187+
raise ValueError(f"Generator {model_name!r} not found. Available: {available}")
188+
else:
189+
gen_cfg = generators[0]
190+
191+
execution = config.get("execution", {})
192+
retrieval = config.get("retrieval", {})
193+
dataset = config.get("dataset", {})
194+
judge_cfg = config["judge"]
195+
196+
retrieval_json = retrieval.get("file_path", "")
197+
ground_truth_csv = dataset.get("source", "")
198+
if ground_truth_csv.startswith("csv:"):
199+
ground_truth_csv = ground_truth_csv[4:]
200+
201+
loader = RetrievalLoaderOperator(
202+
retrieval_json=retrieval_json,
203+
ground_truth_csv=ground_truth_csv,
204+
query_column=dataset.get("query_column", "query"),
205+
answer_column=dataset.get("answer_column", "answer"),
206+
top_k=execution.get("top_k", 5),
207+
)
208+
209+
gen_op = QAGenerationOperator(
210+
model=gen_cfg["model"],
211+
api_base=gen_cfg.get("api_base"),
212+
api_key=gen_cfg.get("api_key"),
213+
temperature=gen_cfg.get("temperature", 0.0),
214+
max_tokens=gen_cfg.get("max_tokens", 4096),
215+
extra_params=gen_cfg.get("extra_params"),
216+
num_retries=gen_cfg.get("num_retries", 3),
217+
max_workers=execution.get("max_workers", 8),
218+
)
219+
220+
judge_op = JudgingOperator(
221+
model=judge_cfg["model"],
222+
api_base=judge_cfg.get("api_base"),
223+
api_key=judge_cfg.get("api_key"),
224+
extra_params=judge_cfg.get("extra_params"),
225+
max_workers=execution.get("max_workers", 8),
226+
)
227+
228+
scoring_op = ScoringOperator()
229+
230+
return loader >> gen_op >> judge_op >> scoring_op
231+
232+
233+
def build_eval_pipeline(config: dict) -> "QAEvalPipeline":
234+
"""Construct a multi-model ``QAEvalPipeline`` from config.
235+
236+
Uses all generators listed in config for multi-model sweeps.
237+
238+
Parameters
239+
----------
240+
config : dict
241+
Parsed config from :func:`load_eval_config`.
242+
243+
Returns
244+
-------
245+
QAEvalPipeline
246+
A fully configured pipeline ready for ``.evaluate(qa_pairs)``
247+
or ``.process(df)``.
248+
"""
249+
from nemo_retriever.evaluation.generators import LiteLLMClient
250+
from nemo_retriever.evaluation.judges import LLMJudge
251+
from nemo_retriever.evaluation.orchestrator import QAEvalPipeline
252+
from nemo_retriever.evaluation.retrievers import FileRetriever
253+
254+
generators = config["generators"]
255+
if not generators:
256+
raise ValueError("Config must have at least one generator")
257+
258+
execution = config.get("execution", {})
259+
retrieval = config.get("retrieval", {})
260+
judge_cfg = config["judge"]
261+
262+
retrieval_type = retrieval.get("type", "file")
263+
if retrieval_type == "file":
264+
retriever = FileRetriever(file_path=retrieval["file_path"])
265+
else:
266+
raise ValueError(f"Unsupported retrieval type: {retrieval_type!r}. " "Currently only 'file' is supported.")
267+
268+
llm_clients: dict[str, LiteLLMClient] = {}
269+
for gen_cfg in generators:
270+
name = gen_cfg.get("name", gen_cfg["model"])
271+
llm_clients[name] = LiteLLMClient(
272+
model=gen_cfg["model"],
273+
api_base=gen_cfg.get("api_base"),
274+
api_key=gen_cfg.get("api_key"),
275+
temperature=gen_cfg.get("temperature", 0.0),
276+
max_tokens=gen_cfg.get("max_tokens", 4096),
277+
extra_params=gen_cfg.get("extra_params"),
278+
num_retries=gen_cfg.get("num_retries", 3),
279+
)
280+
281+
judge = LLMJudge(
282+
model=judge_cfg["model"],
283+
api_base=judge_cfg.get("api_base"),
284+
api_key=judge_cfg.get("api_key"),
285+
extra_params=judge_cfg.get("extra_params"),
286+
)
287+
288+
return QAEvalPipeline(
289+
retriever=retriever,
290+
llm_clients=llm_clients,
291+
judge=judge,
292+
top_k=execution.get("top_k", 5),
293+
max_workers=execution.get("max_workers", 8),
294+
include_chunks_in_results=execution.get("include_chunks_in_results", True),
295+
chunk_char_limit=execution.get("chunk_char_limit", 500),
296+
)

0 commit comments

Comments
 (0)