Source code for fastrag.benchmark.metrics.base
"""The :class:`Metric` contract, its input, and how scores aggregate.
Metrics consume the retrieval and query layers:
:class:`~fastrag.retrieval.base.Retriever` returns
:class:`~fastrag.models.Document` — there is no ``ScoredDocument`` any more —
so a metric reads a document's ``score``/``rank`` out of its ``metadata``
dict (stamped by ``VectorStore.search``) instead of off its ``.score``/
``.rank`` attributes.
"""
from __future__ import annotations
import math
import statistics
from abc import ABC, abstractmethod
from collections.abc import Sequence
from dataclasses import dataclass, field
from fastrag.benchmark.datasets import EvalSample
from fastrag.models import Document
PERCENTILES = (50, 75, 90, 95, 99)
[docs]
@dataclass(frozen=True)
class QueryOutcome:
"""What the pipeline produced for one sample — a metric's whole input.
``answer`` is ``""`` and ``input_tokens`` / ``output_tokens`` are ``None``
when :meth:`~fastrag.benchmark.benchmark.Benchmark.run` targets a bare
:class:`~fastrag.retrieval.base.Retriever` rather than a
:class:`~fastrag.query.engine.QueryEngine`.
"""
answer: str
sources: list[Document]
latency_ms: float
input_tokens: int | None = None
output_tokens: int | None = None
[docs]
class Metric(ABC):
"""One measurement, computed per sample and aggregated over a run.
A metric carries its own dependencies as class attributes, so
:class:`~fastrag.benchmark.benchmark.Benchmark` can refuse an
unsatisfiable request at construction rather than silently returning a
number that means nothing.
"""
needs_relevant_ids: bool = False
"""``True`` when :meth:`score` reads ``sample.relevant_ids``."""
needs_answer: bool = False
"""``True`` when :meth:`score` reads ``outcome.answer`` — never satisfied
by a bare :class:`~fastrag.retrieval.base.Retriever`."""
higher_is_better: bool = True
"""Which direction improves. ``False`` for ``latency_ms``, ``total_tokens``,
and ``cost_usd``."""
@property
@abstractmethod
def name(self) -> str:
"""Key this metric occupies in ``BenchmarkResult.summary``."""
[docs]
@abstractmethod
async def score(self, sample: EvalSample, outcome: QueryOutcome) -> float | None:
"""This sample's score, or ``None`` when it is not measurable."""
[docs]
def aggregate(self, scores: Sequence[float | None]) -> DistributionSummary:
"""Aggregate this metric's per-sample scores. Override to change how."""
return DistributionSummary.of(scores)
[docs]
@dataclass(frozen=True)
class DistributionSummary:
"""A run's scores for one metric, never collapsed to a single mean.
``missing`` counts the ``None`` scores, dropped before ``mean``/``median``/
``std``/``min``/``max``/``percentiles`` are computed over what is left. An
empty or all-``None`` input yields ``count=0`` and zeros everywhere, never
a ``ZeroDivisionError``.
"""
count: int
missing: int
mean: float
median: float
std: float
min: float
max: float
percentiles: dict[str, float] = field(default_factory=dict)
[docs]
@classmethod
def of(cls, scores: Sequence[float | None]) -> DistributionSummary:
"""Build a :class:`DistributionSummary` from raw per-sample scores."""
present = sorted(score for score in scores if score is not None)
missing = len(scores) - len(present)
if not present:
return cls(
count=0,
missing=missing,
mean=0.0,
median=0.0,
std=0.0,
min=0.0,
max=0.0,
percentiles={f"p{p}": 0.0 for p in PERCENTILES},
)
return cls(
count=len(present),
missing=missing,
mean=statistics.fmean(present),
median=statistics.median(present),
std=statistics.pstdev(present),
min=present[0],
max=present[-1],
percentiles={f"p{p}": _nearest_rank(present, p) for p in PERCENTILES},
)
def _nearest_rank(sorted_scores: Sequence[float], percentile: int) -> float:
"""Nearest-rank percentile over an already-sorted, non-empty sequence.
Exact, and defined for a single-element sequence — unlike
:func:`statistics.quantiles`, which needs at least two points.
"""
rank = math.ceil(percentile / 100 * len(sorted_scores))
index = min(max(rank - 1, 0), len(sorted_scores) - 1)
return sorted_scores[index]