Skip to content

qmd_py.bench

The marq bench harness: fixture loading, per-backend scoring (precision@k/recall/MRR/F1), and the four search backends compared. See Bench & doctor for the user-facing reference and fixture format.

bench

Search-quality benchmark harness: precision@k/recall/MRR/F1 across backends (bm25/vector/hybrid/full) against a fixture file - port of the TS reference's src/bench/{bench,score,types}.ts. Framework-agnostic (no click/CLI dependency here, matching search/*.py's convention) - progress is reported via an optional on_progress callback the CLI layer supplies.

BenchmarkQuery dataclass

BenchmarkQuery(
    id: str,
    query: str,
    type: str,
    description: str,
    expected_files: list[str],
    expected_in_top_k: int,
)

One query in a benchmark fixture, with its expected results.

Attributes:

  • id (str) –

    Stable identifier, used to label result rows.

  • query (str) –

    The query text. May use the multi-line typed syntax.

  • type (str) –

    Free-form label for grouping (e.g. "lexical", "semantic").

  • description (str) –

    What this query is meant to probe.

  • expected_files (list[str]) –

    Paths that should be retrieved. Matched loosely by paths_match(), so collection prefixes need not agree.

  • expected_in_top_k (int) –

    The k used for precision@k and hits@k.

BenchmarkFixture dataclass

BenchmarkFixture(
    description: str,
    version: int,
    queries: list[BenchmarkQuery],
    collection: str | None = None,
)

A parsed benchmark fixture file.

Attributes:

  • description (str) –

    What this fixture set covers.

  • version (int) –

    Fixture format version.

  • queries (list[BenchmarkQuery]) –

    The queries to run.

  • collection (str | None) –

    Restrict every query to this collection, or None to search all of them. A per-file default, overridable at the call.

ScoreMetrics dataclass

ScoreMetrics(
    precision_at_k: float,
    recall: float,
    recall_at_1: float,
    recall_at_3: float,
    recall_at_5: float,
    mrr: float,
    f1: float,
    hits_at_k: int,
    matched_files: list[str],
    unmatched_expected_files: list[str],
)

Retrieval quality for one query against one backend.

Attributes:

  • precision_at_k (float) –

    Hits in the top k over min(k, len(expected)), so a query expecting fewer files than k can still reach 1.0.

  • recall (float) –

    Fraction of expected files found anywhere in the results.

  • recall_at_1 (float) –

    Recall counting only the top result.

  • recall_at_3 (float) –

    Recall counting the top three.

  • recall_at_5 (float) –

    Recall counting the top five.

  • mrr (float) –

    Reciprocal rank of the first correct hit; 0.0 if none.

  • f1 (float) –

    Harmonic mean of precision_at_k and recall.

  • hits_at_k (int) –

    Expected files found within the top k.

  • matched_files (list[str]) –

    Expected files that were found, anywhere.

  • unmatched_expected_files (list[str]) –

    Expected files that were missed - the useful column when diagnosing a regression.

BackendResult dataclass

BackendResult(
    precision_at_k: float,
    recall: float,
    recall_at_1: float,
    recall_at_3: float,
    recall_at_5: float,
    mrr: float,
    f1: float,
    hits_at_k: int,
    total_expected: int,
    latency_ms: float,
    top_files: list[str],
    matched_files: list[str],
    unmatched_expected_files: list[str],
)

One backend's ScoreMetrics for one query, plus what it returned.

Attributes:

  • precision_at_k (float) –

    See ScoreMetrics.

  • recall (float) –

    See ScoreMetrics.

  • recall_at_1 (float) –

    See ScoreMetrics.

  • recall_at_3 (float) –

    See ScoreMetrics.

  • recall_at_5 (float) –

    See ScoreMetrics.

  • mrr (float) –

    See ScoreMetrics.

  • f1 (float) –

    See ScoreMetrics.

  • hits_at_k (int) –

    See ScoreMetrics.

  • total_expected (int) –

    How many files the fixture expected, for reading the ratios above in context.

  • latency_ms (float) –

    Wall-clock time for this backend's run. Includes LLM round trips, so hybrid backends are not comparable with bm25 on latency alone.

  • top_files (list[str]) –

    Retrieved paths, best first, truncated for display.

  • matched_files (list[str]) –

    Expected files found.

  • unmatched_expected_files (list[str]) –

    Expected files missed.

QueryResult dataclass

QueryResult(
    id: str,
    query: str,
    type: str,
    backends: dict[str, BackendResult],
)

Every backend's outcome for a single fixture query.

Attributes:

  • id (str) –

    The fixture query's identifier.

  • query (str) –

    Its query text.

  • type (str) –

    Its grouping label.

  • backends (dict[str, BackendResult]) –

    Backend name to result - the comparison the benchmark exists to produce.

BenchmarkResult dataclass

BenchmarkResult(
    timestamp: str,
    fixture: str,
    results: list[QueryResult],
    summary: dict[str, dict[str, float]],
)

A complete benchmark run.

Attributes:

  • timestamp (str) –

    ISO-8601 UTC time the run finished, for comparing runs.

  • fixture (str) –

    Path of the fixture used.

  • results (list[QueryResult]) –

    Per-query results, in fixture order.

  • summary (dict[str, dict[str, float]]) –

    Backend name to averaged metrics across all queries - the headline table.

load_fixture

load_fixture(path: str | Path) -> BenchmarkFixture

Load and validate a benchmark fixture from JSON.

Raises:

  • ValueError

    The file has no queries array.

  • KeyError

    A query is missing a required field.

  • JSONDecodeError

    The file is not valid JSON.

Source code in src/qmd_py/bench.py
def load_fixture(path: str | Path) -> BenchmarkFixture:
    """Load and validate a benchmark fixture from JSON.

    Raises:
        ValueError: The file has no `queries` array.
        KeyError: A query is missing a required field.
        json.JSONDecodeError: The file is not valid JSON.
    """
    data = json.loads(Path(path).read_text())
    if not isinstance(data.get("queries"), list):
        raise ValueError("Invalid fixture: missing 'queries' array")
    queries = [
        BenchmarkQuery(
            id=q["id"],
            query=q["query"],
            type=q.get("type", ""),
            description=q.get("description", ""),
            expected_files=q["expected_files"],
            expected_in_top_k=q["expected_in_top_k"],
        )
        for q in data["queries"]
    ]
    return BenchmarkFixture(
        description=data.get("description", ""),
        version=data.get("version", 1),
        queries=queries,
        collection=data.get("collection"),
    )

normalize_path

normalize_path(path: str) -> str

Reduce a path to a comparable form for scoring.

marq://collection/docs/readme.md becomes docs/readme.md: lowercased, stripped of the scheme, collection prefix and surrounding slashes, so a fixture can name files without knowing which collection they were indexed into.

Source code in src/qmd_py/bench.py
def normalize_path(path: str) -> str:
    """Reduce a path to a comparable form for scoring.

    `marq://collection/docs/readme.md` becomes `docs/readme.md`:
    lowercased, stripped of the scheme, collection prefix and surrounding
    slashes, so a fixture can name files without knowing which
    collection they were indexed into.
    """
    if path.startswith("marq://"):
        without_scheme = path[len("marq://") :]
        slash_idx = without_scheme.find("/")
        path = without_scheme[slash_idx + 1 :] if slash_idx >= 0 else without_scheme
    return path.lower().strip("/")

paths_match

paths_match(result: str, expected: str) -> bool

Whether a retrieved path satisfies an expected one.

Deliberately loose: equal after normalization, or either being a suffix of the other. That lets a fixture write readme.md for a document actually stored at docs/readme.md - convenient for hand-written fixtures, but it can credit a near-miss, so treat scores as comparative rather than absolute.

Source code in src/qmd_py/bench.py
def paths_match(result: str, expected: str) -> bool:
    """Whether a retrieved path satisfies an expected one.

    Deliberately loose: equal after normalization, or either being a
    suffix of the other. That lets a fixture write `readme.md` for a
    document actually stored at `docs/readme.md` - convenient for
    hand-written fixtures, but it can credit a near-miss, so treat scores
    as comparative rather than absolute.
    """
    nr, ne = normalize_path(result), normalize_path(expected)
    return nr == ne or nr.endswith(ne) or ne.endswith(nr)

score_results

score_results(
    result_files: list[str],
    expected_files: list[str],
    top_k: int,
) -> ScoreMetrics

Score one ranked result list against its expected files.

Parameters:

  • result_files (list[str]) –

    Retrieved paths, best first.

  • expected_files (list[str]) –

    Paths that should have been retrieved.

  • top_k (int) –

    Cutoff for the @k metrics.

Returns:

  • ScoreMetrics

    All metrics for this query. An empty expected_files yields

  • ScoreMetrics

    zeros rather than dividing by zero.

Source code in src/qmd_py/bench.py
def score_results(result_files: list[str], expected_files: list[str], top_k: int) -> ScoreMetrics:
    """Score one ranked result list against its expected files.

    Args:
        result_files: Retrieved paths, best first.
        expected_files: Paths that should have been retrieved.
        top_k: Cutoff for the @k metrics.

    Returns:
        All metrics for this query. An empty `expected_files` yields
        zeros rather than dividing by zero.
    """
    hits_at_k = _hits_within(result_files, expected_files, top_k)
    matched = [e for e in expected_files if any(paths_match(r, e) for r in result_files)]
    matched_set = set(matched)
    unmatched = [e for e in expected_files if e not in matched_set]

    mrr = 0.0
    for i, r in enumerate(result_files):
        if any(paths_match(r, e) for e in expected_files):
            mrr = 1 / (i + 1)
            break

    n_expected = len(expected_files)

    def recall_at(k: int) -> float:
        return _hits_within(result_files, expected_files, k) / n_expected if n_expected else 0.0

    denominator = min(top_k, len(expected_files))
    precision_at_k = hits_at_k / denominator if denominator > 0 else 0.0
    recall = len(matched) / n_expected if n_expected else 0.0
    recall_at_1 = recall_at(1)
    recall_at_3 = recall_at(3)
    recall_at_5 = recall_at(5)
    f1 = (
        2 * (precision_at_k * recall) / (precision_at_k + recall)
        if (precision_at_k + recall) > 0
        else 0.0
    )
    return ScoreMetrics(
        precision_at_k=precision_at_k,
        recall=recall,
        recall_at_1=recall_at_1,
        recall_at_3=recall_at_3,
        recall_at_5=recall_at_5,
        mrr=mrr,
        f1=f1,
        hits_at_k=hits_at_k,
        matched_files=matched,
        unmatched_expected_files=unmatched,
    )

run_benchmark async

run_benchmark(
    session: AsyncSession,
    user: CurrentUser,
    llm_client: LlmClient,
    settings: Settings,
    fixture_path: str | Path,
    *,
    collection: str | None = None,
    backend_names: list[str] | None = None,
    on_progress: Callable[[str, str, float], None]
    | None = None,
) -> BenchmarkResult

Run every fixture query against every selected backend.

Framework-agnostic on purpose - no click dependency - so the CLI layer supplies progress reporting rather than this owning output.

Parameters:

  • fixture_path (str | Path) –

    JSON fixture to load.

  • collection (str | None, default: None ) –

    Restrict all queries to one collection, overriding the fixture's own setting.

  • backend_names (list[str] | None, default: None ) –

    Which backends to run (bm25, vector, hybrid, full). None runs all of them.

  • on_progress (Callable[[str, str, float], None] | None, default: None ) –

    Called as (query_id, backend_name, latency_ms) after each backend finishes, for incremental output.

Returns:

Note

Runs real searches against real infrastructure, LLM calls included - a full fixture against the hybrid backends takes minutes, not seconds.

Source code in src/qmd_py/bench.py
async def run_benchmark(
    session: AsyncSession,
    user: CurrentUser,
    llm_client: LlmClient,
    settings: Settings,
    fixture_path: str | Path,
    *,
    collection: str | None = None,
    backend_names: list[str] | None = None,
    on_progress: Callable[[str, str, float], None] | None = None,
) -> BenchmarkResult:
    """Run every fixture query against every selected backend.

    Framework-agnostic on purpose - no click dependency - so the CLI
    layer supplies progress reporting rather than this owning output.

    Args:
        fixture_path: JSON fixture to load.
        collection: Restrict all queries to one collection, overriding
            the fixture's own setting.
        backend_names: Which backends to run (`bm25`, `vector`,
            `hybrid`, `full`). None runs all of them.
        on_progress: Called as `(query_id, backend_name, latency_ms)`
            after each backend finishes, for incremental output.

    Returns:
        Per-query results plus the averaged summary.

    Note:
        Runs real searches against real infrastructure, LLM calls
        included - a full fixture against the hybrid backends takes
        minutes, not seconds.
    """
    fixture = load_fixture(fixture_path)
    active = backend_names or list(BACKENDS.keys())
    effective_collection = collection or fixture.collection

    results: list[QueryResult] = []
    for query in fixture.queries:
        backend_results: dict[str, BackendResult] = {}
        for name in active:
            backend_result = await _run_query(
                session, user, llm_client, settings, BACKENDS[name], query, effective_collection
            )
            backend_results[name] = backend_result
            if on_progress:
                on_progress(query.id, name, backend_result.latency_ms)
        results.append(
            QueryResult(id=query.id, query=query.query, type=query.type, backends=backend_results)
        )

    summary = _compute_summary(results)
    timestamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%S")
    return BenchmarkResult(
        timestamp=timestamp, fixture=str(fixture_path), results=results, summary=summary
    )

bench_result_to_json

bench_result_to_json(result: BenchmarkResult) -> str

Serialize a whole run as indented JSON, for diffing runs.

Source code in src/qmd_py/bench.py
def bench_result_to_json(result: BenchmarkResult) -> str:
    """Serialize a whole run as indented JSON, for diffing runs."""
    return json.dumps(asdict(result), indent=2)

format_bench_table

format_bench_table(results: list[QueryResult]) -> str

Render per-query, per-backend metrics as a fixed-width table.

Source code in src/qmd_py/bench.py
def format_bench_table(results: list[QueryResult]) -> str:
    """Render per-query, per-backend metrics as a fixed-width table."""
    def pad(s: str, n: int) -> str:
        return s[:n].ljust(n)

    def num(n: float) -> str:
        return f"{n:.2f}".rjust(5)

    lines = [
        f"{pad('Query', 25)} {pad('Backend', 8)} {pad('P@k', 6)} {pad('R@1', 6)} "
        f"{pad('R@3', 6)} {pad('R@5', 6)} {pad('MRR', 6)} {pad('F1', 6)} {pad('ms', 8)}",
        "-" * 88,
    ]
    for r in results:
        for backend, br in r.backends.items():
            lines.append(
                f"{pad(r.id, 25)} {pad(backend, 8)} {num(br.precision_at_k)} {num(br.recall_at_1)} "
                f"{num(br.recall_at_3)} {num(br.recall_at_5)} {num(br.mrr)} {num(br.f1)} "
                f"{str(round(br.latency_ms)).rjust(7)}ms"
            )
        lines.append("")
    return "\n".join(lines)

format_bench_summary

format_bench_summary(
    summary: dict[str, dict[str, float]],
) -> str

Render the averaged per-backend summary as a fixed-width table.

Source code in src/qmd_py/bench.py
def format_bench_summary(summary: dict[str, dict[str, float]]) -> str:
    """Render the averaged per-backend summary as a fixed-width table."""
    def pad(s: str, n: int) -> str:
        return s[:n].ljust(n)

    def num(n: float) -> str:
        return f"{n:.3f}".rjust(6)

    lines = []
    for name, s in summary.items():
        lines.append(
            f"  {pad(name, 8)} P@k={num(s['avg_precision'])} R@1={num(s['avg_recall_at_1'])} "
            f"R@3={num(s['avg_recall_at_3'])} R@5={num(s['avg_recall_at_5'])} "
            f"MRR={num(s['avg_mrr'])} F1={num(s['avg_f1'])} Avg={round(s['avg_latency_ms'])}ms"
        )
    return "\n".join(lines)