haiku.rag/evaluations/evaluations/benchmark.py
Yiorgis Gozadinos 1d09b4e31b
Find a document in whichever database holds it
`get_document_by_id`, `get_document_by_uri` and `get_chunk_by_id` read
through repositories a client covering a set does not have, so a lookup by
identifier raised AttributeError and `resolve_document` with it. An
identifier says nothing about which database holds it, so every database is
asked at once and the first that has it, in configured order, answers.

On the evaluation side, `--db` overrides the configured set as the CLI
documents, and population refuses a set rather than ingesting into a
database the run would not read. A case filter matching nothing raises
instead of reporting 0.0000 as though it were a score.
2026-08-24 10:03:47 +03:00

266 lines
9 KiB
Python

import asyncio
from pathlib import Path
from typing import cast
import typer
from dotenv import find_dotenv, load_dotenv
from rich.console import Console
from evaluations.artifacts import download_dataset_db, upload_dataset_db
from evaluations.config import DatasetSpec
from evaluations.population import populate_db
from evaluations.qa import TARGETS, Target, run_live_qa_benchmark, run_qa_benchmark
from evaluations.retrieval import run_retrieval_benchmark
from evaluations.datasets import DATASETS
from haiku.rag.config import AppConfig, find_config_file, load_yaml_config
from haiku.rag.config.models import ModelConfig
from haiku.rag.logging import configure_cli_logging
from haiku.rag.telemetry import configure as configure_telemetry
from haiku.rag.utils import parse_model_option
load_dotenv(find_dotenv(usecwd=True))
# Scrubbing off: eval outputs are financial answers with words like "authorized"
# that trip Logfire's secret scrubber and redact the model's answer text.
configure_telemetry(service_name="evals", scrubbing=False)
configure_cli_logging()
console = Console()
async def evaluate_dataset(
spec: DatasetSpec,
config: AppConfig,
skip_db: bool,
skip_retrieval: bool,
skip_qa: bool,
limit: int | None,
name: str | None,
db_path: Path | None,
vacuum_interval: int = 100,
multimodal_only: bool = False,
judge_model: ModelConfig | None = None,
target: Target = "rag-capability",
capability_model: ModelConfig | None = None,
case_ids: set[str] | None = None,
document_filter: str | None = None,
) -> None:
if document_filter is not None:
console.print(f"Document filter: {document_filter}", style="dim")
if not skip_db:
if spec.covers_a_set(config, db_path):
raise ValueError(
"lancedb.databases names several databases and population writes "
"to one, so it would ingest into a database the run does not "
"read. Pass --skip-db to evaluate the configured set, or --db "
"PATH to populate and evaluate one database."
)
console.print(f"Using dataset: {spec.key}", style="bold magenta")
await populate_db(
spec, config, db_path=db_path, vacuum_interval=vacuum_interval
)
if not skip_retrieval:
console.print("Running retrieval benchmarks...", style="bold blue")
await run_retrieval_benchmark(
spec,
config,
limit=limit,
name=name,
db_path=db_path,
multimodal_only=multimodal_only,
document_filter=document_filter,
)
if not skip_qa:
console.print(
f"\nRunning QA benchmarks (target={target})...", style="bold yellow"
)
qa_benchmark = run_live_qa_benchmark if spec.live else run_qa_benchmark
await qa_benchmark(
spec,
config,
limit=limit,
name=name,
db_path=db_path,
judge_model=judge_model,
target=target,
capability_model=capability_model,
case_ids=case_ids,
document_filter=document_filter,
)
app = typer.Typer(help="Run retrieval and QA benchmarks for configured datasets.")
def _load_config(config_path: Path | None) -> AppConfig:
"""Load AppConfig from a file path or standard search path."""
if config_path:
if not config_path.exists():
raise typer.BadParameter(f"Config file not found: {config_path}")
console.print(f"Loading config from: {config_path}", style="dim")
yaml_data = load_yaml_config(config_path)
return AppConfig.model_validate(yaml_data)
found = find_config_file(None)
if found:
console.print(f"Loading config from: {found}", style="dim")
yaml_data = load_yaml_config(found)
return AppConfig.model_validate(yaml_data)
console.print("No config file found, using defaults", style="dim")
return AppConfig()
def _load_case_ids(path: Path | None) -> set[str] | None:
"""Read a newline-delimited case-id file into a set (None when no path)."""
if path is None:
return None
return {line.strip() for line in path.read_text().splitlines() if line.strip()}
def _resolve_dataset(dataset: str) -> DatasetSpec:
"""Resolve a dataset key to a DatasetSpec or raise BadParameter."""
spec = DATASETS.get(dataset.lower())
if spec is None:
valid_datasets = ", ".join(sorted(DATASETS))
raise typer.BadParameter(
f"Unknown dataset '{dataset}'. Choose from: {valid_datasets}"
)
return spec
def _resolve_datasets(dataset: str) -> list[DatasetSpec]:
"""Resolve 'all' or a single dataset key to a list of DatasetSpecs.
'all' yields one spec per database: query variants sharing a db_filename
would otherwise be downloaded/uploaded twice.
"""
if dataset.lower() == "all":
seen: set[str] = set()
specs: list[DatasetSpec] = []
for spec in DATASETS.values():
if spec.db_filename in seen:
continue
seen.add(spec.db_filename)
specs.append(spec)
return specs
return [_resolve_dataset(dataset)]
@app.command()
def run(
dataset: str = typer.Argument(..., help="Dataset key to evaluate."),
config: Path | None = typer.Option(
None, "--config", help="Path to haiku.rag YAML config file."
),
db: Path | None = typer.Option(None, "--db", help="Override the database path."),
skip_db: bool = typer.Option(
False, "--skip-db", help="Skip updating the evaluation db."
),
skip_retrieval: bool = typer.Option(
False, "--skip-retrieval", help="Skip retrieval benchmark."
),
skip_qa: bool = typer.Option(False, "--skip-qa", help="Skip QA benchmark."),
limit: int | None = typer.Option(
None, "--limit", help="Limit number of test cases for both retrieval and QA."
),
name: str | None = typer.Option(None, "--name", help="Override evaluation name."),
vacuum_interval: int = typer.Option(
100, "--vacuum-interval", help="Vacuum every N documents during DB population."
),
multimodal_only: bool = typer.Option(
False,
"--multimodal-only",
help="Only evaluate queries requiring image understanding.",
),
target: str = typer.Option(
"rag-capability",
"--target",
help="What to benchmark: rag-capability | analysis-capability.",
),
capability_model: str | None = typer.Option(
None,
"--capability-model",
help=(
"Capability model as 'provider:name'. Defaults to qa.model (or "
"analysis.model when --target is analysis-capability) from the config."
),
),
document_filter: str | None = typer.Option(
None,
"--filter",
"-f",
help=(
"SQL WHERE clause over document columns (id, uri, title, "
"created_at, updated_at, metadata) restricting every benchmark "
"search, e.g. \"uri LIKE '%arxiv%'\". metadata is stored as a "
"string, so match it with LIKE."
),
),
filter_ids: Path | None = typer.Option(
None,
"--filter-ids",
help=(
"Path to a newline-delimited file of QA case ids to run "
"(failure-subset rerun). Filters QA only; retrieval is unaffected."
),
),
) -> None:
spec = _resolve_dataset(dataset)
app_config = _load_config(config)
if target not in TARGETS:
raise typer.BadParameter(
f"Unknown target {target!r}. Choose from: {', '.join(TARGETS)}"
)
target_value = cast(Target, target)
judge_model_config = app_config.evaluations.judge
capability_model_config = (
parse_model_option(capability_model) if capability_model else None
)
asyncio.run(
evaluate_dataset(
spec=spec,
config=app_config,
skip_db=skip_db,
skip_retrieval=skip_retrieval,
skip_qa=skip_qa,
limit=limit,
name=name,
db_path=db,
vacuum_interval=vacuum_interval,
multimodal_only=multimodal_only,
judge_model=judge_model_config,
target=target_value,
capability_model=capability_model_config,
case_ids=_load_case_ids(filter_ids),
document_filter=document_filter,
)
)
@app.command()
def download(
dataset: str = typer.Argument(..., help="Dataset key or 'all' to download all."),
force: bool = typer.Option(False, "--force", help="Overwrite existing database."),
) -> None:
"""Download pre-built evaluation database from HuggingFace."""
for spec in _resolve_datasets(dataset):
download_dataset_db(spec, force=force)
@app.command()
def upload(
dataset: str = typer.Argument(..., help="Dataset key or 'all' to upload all."),
) -> None:
"""Upload evaluation database to HuggingFace (maintainer only)."""
for spec in _resolve_datasets(dataset):
upload_dataset_db(spec)
if __name__ == "__main__":
app()