mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
55d675e278
Signed-off-by: phernandez <paul@basicmachines.co> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
142 lines
4.4 KiB
Python
142 lines
4.4 KiB
Python
"""Quality metric computation and reporting for semantic search benchmarks.
|
|
|
|
Computes hit@1, recall@5, and MRR@10 from search results, plus timing
|
|
data for performance comparison across backends and providers.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from statistics import mean
|
|
|
|
from basic_memory.repository.search_index_row import SearchIndexRow
|
|
|
|
|
|
# --- Relevance helpers ---
|
|
|
|
|
|
def first_relevant_rank(results: list[SearchIndexRow], expected_topic: str, k: int) -> int | None:
|
|
"""Return the 1-based rank of the first result matching ``expected_topic``, or None."""
|
|
expected_prefix = f"bench/{expected_topic}-"
|
|
for rank, row in enumerate(results[:k], start=1):
|
|
if (row.permalink or "").startswith(expected_prefix):
|
|
return rank
|
|
return None
|
|
|
|
|
|
# --- Metric dataclass ---
|
|
|
|
|
|
@dataclass
|
|
class QualityMetrics:
|
|
"""Aggregated quality metrics for a (combo, suite, mode) triple."""
|
|
|
|
combo: str
|
|
suite: str
|
|
mode: str
|
|
cases: int = 0
|
|
hits_at_1: int = 0
|
|
hits_at_5: int = 0
|
|
reciprocal_rank_sum: float = 0.0
|
|
per_query: list[dict] = field(default_factory=list)
|
|
# Timing data: per-query latencies in seconds
|
|
latencies: list[float] = field(default_factory=list)
|
|
|
|
def record(
|
|
self, query_text: str, expected_topic: str, rank: int | None, latency: float = 0.0
|
|
) -> None:
|
|
self.cases += 1
|
|
entry = {"query": query_text, "expected": expected_topic, "rank": rank}
|
|
self.per_query.append(entry)
|
|
self.latencies.append(latency)
|
|
if rank is None:
|
|
return
|
|
self.reciprocal_rank_sum += 1.0 / rank
|
|
if rank == 1:
|
|
self.hits_at_1 += 1
|
|
if rank <= 5:
|
|
self.hits_at_5 += 1
|
|
|
|
@property
|
|
def hit_at_1(self) -> float:
|
|
return self.hits_at_1 / self.cases if self.cases else 0.0
|
|
|
|
@property
|
|
def recall_at_5(self) -> float:
|
|
return self.hits_at_5 / self.cases if self.cases else 0.0
|
|
|
|
@property
|
|
def mrr_at_10(self) -> float:
|
|
return self.reciprocal_rank_sum / self.cases if self.cases else 0.0
|
|
|
|
@property
|
|
def total_time_ms(self) -> float:
|
|
return sum(self.latencies) * 1000
|
|
|
|
@property
|
|
def avg_latency_ms(self) -> float:
|
|
return mean(self.latencies) * 1000 if self.latencies else 0.0
|
|
|
|
def as_dict(self) -> dict:
|
|
return {
|
|
"combo": self.combo,
|
|
"suite": self.suite,
|
|
"mode": self.mode,
|
|
"cases": self.cases,
|
|
"hit_at_1": round(self.hit_at_1, 4),
|
|
"recall_at_5": round(self.recall_at_5, 4),
|
|
"mrr_at_10": round(self.mrr_at_10, 4),
|
|
"avg_latency_ms": round(self.avg_latency_ms, 2),
|
|
"total_time_ms": round(self.total_time_ms, 2),
|
|
}
|
|
|
|
|
|
# --- Comparison table ---
|
|
|
|
|
|
def format_comparison_table(all_metrics: list[QualityMetrics]) -> str:
|
|
"""Format a list of QualityMetrics into an ASCII comparison table."""
|
|
header = (
|
|
f"{'Combo':<25} {'Suite':<12} {'Mode':<8} "
|
|
f"{'hit@1':>6} {'R@5':>6} {'MRR@10':>7} {'avg_ms':>8} {'total_ms':>9}"
|
|
)
|
|
separator = "-" * len(header)
|
|
lines = [separator, header, separator]
|
|
|
|
for m in sorted(all_metrics, key=lambda x: (x.suite, x.combo, x.mode)):
|
|
lines.append(
|
|
f"{m.combo:<25} {m.suite:<12} {m.mode:<8} "
|
|
f"{m.hit_at_1:>6.3f} {m.recall_at_5:>6.3f} {m.mrr_at_10:>7.3f} "
|
|
f"{m.avg_latency_ms:>8.1f} {m.total_time_ms:>9.1f}"
|
|
)
|
|
|
|
lines.append(separator)
|
|
return "\n".join(lines)
|
|
|
|
|
|
# --- Artifact output ---
|
|
|
|
|
|
def write_benchmark_artifact(all_metrics: list[QualityMetrics]) -> None:
|
|
"""Append JSON-lines benchmark artifact if BASIC_MEMORY_BENCHMARK_OUTPUT is set."""
|
|
output_path = os.getenv("BASIC_MEMORY_BENCHMARK_OUTPUT")
|
|
if not output_path:
|
|
return
|
|
|
|
artifact_path = Path(output_path).expanduser()
|
|
artifact_path.parent.mkdir(parents=True, exist_ok=True)
|
|
timestamp = datetime.now(timezone.utc).isoformat()
|
|
|
|
with artifact_path.open("a", encoding="utf-8") as f:
|
|
for m in all_metrics:
|
|
payload = {
|
|
"benchmark": f"semantic-quality-{m.combo}-{m.suite}-{m.mode}",
|
|
"timestamp_utc": timestamp,
|
|
"metrics": m.as_dict(),
|
|
}
|
|
f.write(json.dumps(payload, sort_keys=True) + "\n")
|