Files
siemens_ragas/rag_eval/metrics/factory.py
T

218 lines
8.2 KiB
Python
Raw Normal View History

2026-06-12 14:02:15 +08:00
"""Factories for OpenAI-backed RAGAS models and metric pipelines."""
from __future__ import annotations
import logging
2026-06-12 14:02:15 +08:00
from typing import Any
import httpx
2026-06-12 14:02:15 +08:00
from openai import AsyncOpenAI
from rag_eval.compat import ensure_ragas_import_compat
from rag_eval.settings import EvaluationSettings
from rag_eval.shared.models import Scenario
from .token_tracker import get_current_tracker
2026-06-12 14:02:15 +08:00
ensure_ragas_import_compat()
from ragas.embeddings.base import embedding_factory
from ragas.llms import llm_factory
from ragas.metrics.collections import (
AnswerRelevancy,
ContextPrecision,
ContextRecall,
FactualCorrectness,
2026-06-12 14:02:15 +08:00
Faithfulness,
NoiseSensitivity,
SemanticSimilarity,
2026-06-12 14:02:15 +08:00
)
from .judge_prompts import localize_pipeline_prompts
2026-06-12 14:02:15 +08:00
from .pipeline import MetricPipeline
logger = logging.getLogger("rag_eval.metrics.factory")
async def _usage_response_hook(response: httpx.Response) -> None:
"""Record token usage from an OpenAI-compatible HTTP response, if a tracker is active.
Applies to both chat-completions and embeddings responses since both
return top-level `model` and `usage` fields in OpenAI-compatible APIs.
Never raises — a broken/incompatible gateway response must not affect scoring.
"""
tracker = get_current_tracker()
if tracker is None:
return
try:
await response.aread()
data = response.json()
usage = data.get("usage")
if not usage:
# Gateway did not report usage at all — skip rather than record a
# misleading 0/0 call.
return
model = data.get("model") or "unknown"
tracker.record(
model,
int(usage.get("prompt_tokens", 0) or 0),
int(usage.get("completion_tokens", 0) or 0),
)
except Exception: # noqa: BLE001
logger.debug("[factory] usage hook failed to parse response", exc_info=True)
def attach_usage_hook(client: AsyncOpenAI) -> None:
"""Attach the token-usage response hook to an AsyncOpenAI client (idempotent).
Safe to call multiple times on the same client (e.g. when judge and
embedding models share one client) — the hook is only appended once.
"""
httpx_client = getattr(client, "_client", None)
if httpx_client is None or not hasattr(httpx_client, "event_hooks"):
return
hooks = httpx_client.event_hooks.setdefault("response", [])
if _usage_response_hook not in hooks:
hooks.append(_usage_response_hook)
def _resolve_openai_client_kwargs(
model: str,
settings: EvaluationSettings,
) -> dict[str, Any]:
"""Return AsyncOpenAI kwargs, preferring a matching LLM Profile over .env settings.
Lookup order:
1. LLM Profile whose model name equals `model` (exact match)
2. Fall back to EvaluationSettings (.env)
"""
try:
# Lazy import to avoid circular dependency (webapp -> rag_eval is one-way).
from webapp.services.profile_manager import profile_manager
profiles = profile_manager.list_all()
for profile in profiles:
if profile.model == model:
kwargs: dict[str, Any] = {
"api_key": profile.api_key or "sk-placeholder",
"timeout": float(profile.timeout_seconds or 30),
}
if profile.base_url and profile.base_url.strip():
kwargs["base_url"] = profile.base_url.strip()
logger.debug(
"[factory] model=%s source=profile base_url=%s",
model, kwargs.get("base_url", "(not set, using default)")
)
return kwargs
except Exception as exc: # noqa: BLE001
# If profile lookup fails for any reason, fall through to .env settings.
logger.warning("[factory] profile lookup failed for model=%s: %s", model, exc)
fallback = settings.openai_client_kwargs
logger.debug(
"[factory] model=%s source=env base_url=%s",
model, fallback.get("base_url", "(not set)")
)
return fallback
def resolve_openai_client_kwargs(
judge_model: str,
settings: EvaluationSettings,
) -> dict[str, Any]:
"""Public accessor for profile-aware AsyncOpenAI kwargs (matched by model name).
Exposed so other components (e.g. the optimization advisor's direct LLM call)
can build a client that honors the same saved-profile/.env resolution used by
the scoring pipeline, instead of duplicating the lookup logic.
"""
return _resolve_openai_client_kwargs(judge_model, settings)
def build_metric_registry(llm: Any, embeddings: Any) -> dict[str, Any]:
"""Instantiate the full set of supported RAGAS metrics keyed by canonical name.
Shared by the scenario pipeline, the inline scorer, and the prompt-cache
bootstrap so the metric set is defined in exactly one place.
"""
return {
"faithfulness": Faithfulness(llm=llm),
"answer_relevancy": AnswerRelevancy(llm=llm, embeddings=embeddings),
"context_recall": ContextRecall(llm=llm),
"context_precision": ContextPrecision(llm=llm),
# NoiseSensitivity mode='relevant': sensitivity to noise from relevant contexts.
"noise_sensitivity": NoiseSensitivity(llm=llm),
# FactualCorrectness mode='f1': balances claim precision and recall vs. ground truth.
"factual_correctness": FactualCorrectness(llm=llm),
# SemanticSimilarity: embedding cosine between answer and ground truth (no LLM call).
"semantic_similarity": SemanticSimilarity(embeddings=embeddings),
}
2026-06-12 14:02:15 +08:00
def build_models(
judge_model: str,
embedding_model: str,
settings: EvaluationSettings,
) -> tuple[Any, Any]:
"""Create the LLM and embedding clients required by the selected RAGAS metrics.
Resolves connection settings independently for the judge LLM and the embedding
model by looking up each in the stored LLM Profiles (matched by model name).
This allows judge_model and embedding_model to use different gateways / API keys.
Falls back to .env settings when no matching profile is found.
"""
llm_kwargs = _resolve_openai_client_kwargs(judge_model, settings)
emb_kwargs = _resolve_openai_client_kwargs(embedding_model, settings)
logger.info(
"[factory] build_models judge=%s%s embedding=%s%s",
judge_model, llm_kwargs.get("base_url", "(env default)"),
embedding_model, emb_kwargs.get("base_url", "(env default)"),
)
llm_client = AsyncOpenAI(**llm_kwargs)
attach_usage_hook(llm_client)
# Only allocate a second client when the embedding model needs different settings.
emb_client = AsyncOpenAI(**emb_kwargs) if emb_kwargs != llm_kwargs else llm_client
attach_usage_hook(emb_client)
2026-06-27 14:31:45 +08:00
# RAGAS structured-output judge calls can be truncated by the upstream default
# 1024 completion budget, especially for faithfulness and GPT-5 family models.
llm = llm_factory(
judge_model,
client=llm_client,
2026-06-27 14:31:45 +08:00
max_tokens=max(1, int(settings.ragas_llm_max_tokens)),
)
embeddings = embedding_factory(provider="openai", model=embedding_model, client=emb_client)
2026-06-12 14:02:15 +08:00
return llm, embeddings
def build_metric_pipeline(
scenario: Scenario,
settings: EvaluationSettings,
llm: Any | None = None,
embeddings: Any | None = None,
2026-06-12 14:02:15 +08:00
) -> MetricPipeline:
"""Build a metric pipeline containing only the metrics requested by the scenario.
If llm and embeddings are provided (pre-built by the caller), they are reused.
Otherwise, new instances are created from scenario + settings.
"""
if llm is None or embeddings is None:
llm, embeddings = build_models(
scenario.judge_model,
scenario.embedding_model,
settings,
)
# Build the full registry once using the shared factory, then slice by requested metrics.
registry = build_metric_registry(llm, embeddings)
selected = {name: registry[name] for name in scenario.metrics}
# Apply language-specific judge prompt localization (no-op for 'en').
language = scenario.judge_language or settings.ragas_judge_language or "en"
localize_pipeline_prompts(selected, language)
2026-06-12 14:02:15 +08:00
return MetricPipeline(
metrics=selected,
2026-06-12 14:02:15 +08:00
metric_timeout_seconds=settings.ragas_metric_timeout_seconds,
)