Files
onyx/backend/scripts/benchmark_csv_retrieval.py
2026-10-16 01:26:47 +00:00

275 lines
8.7 KiB
Python

"""
Benchmark document retrieval quality against Onyx's search endpoint.
Usage:
cd backend && uv run python -m scripts.benchmark_csv_retrieval \
--input benchmark.csv \
[--base-url http://localhost:3000] \
[--email admin@example.com] \
[--password ...] \
[--top-k 10] \
[--num-hits 30] \
[--output results.txt]
Input CSV format (has header row):
question,expected_file
"What is the Q1 revenue?",financials.xlsx
"Who manages the West region?",sales_territories.csv
Matches expected_file against each result's semantic_identifier by exact
file name, ignoring case.
Credentials default to $ONYX_BENCH_EMAIL / $ONYX_BENCH_PASSWORD.
"""
import argparse
import csv
import os
import sys
import time
from dataclasses import dataclass
from datetime import datetime
from pathlib import PurePosixPath
import requests
from scripts.benchmark_api import REQUEST_TIMEOUT_S, SearchDoc, login
@dataclass
class QueryResult:
query: str
expected_file: str
rank: int | None # 1-indexed, None if not found
score: float | None # score of the matched doc, None if not found
num_results: int
top_results: list[
tuple[str, float | None]
] # (semantic_identifier, score) for top 3
MAX_QUERY_ATTEMPTS = 3
RETRY_BACKOFF_S = 2
def search(
base_url: str, headers: dict[str, str], query: str, num_hits: int
) -> list[SearchDoc]:
"""Run one search query, retrying transient failures.
Retries up to MAX_QUERY_ATTEMPTS total on any request error or
non-2xx response. If every attempt fails, returns an empty result
list so the caller records the query as a miss instead of aborting
the whole benchmark.
"""
last_error: Exception | None = None
for attempt in range(1, MAX_QUERY_ATTEMPTS + 1):
try:
resp = requests.post(
f"{base_url}/api/search/send-search-message",
json={
"search_query": query,
"num_hits": num_hits,
"stream": False,
},
headers=headers,
timeout=REQUEST_TIMEOUT_S,
)
resp.raise_for_status()
docs: list[SearchDoc] = resp.json()["search_docs"]
return docs
except (requests.RequestException, ValueError, KeyError) as e:
last_error = e
if attempt < MAX_QUERY_ATTEMPTS:
print(
f" attempt {attempt}/{MAX_QUERY_ATTEMPTS} failed: {e!r}; "
f"retrying in {RETRY_BACKOFF_S}s..."
)
time.sleep(RETRY_BACKOFF_S)
else:
print(
f" attempt {attempt}/{MAX_QUERY_ATTEMPTS} failed: {e!r}; "
"giving up on this query"
)
print(
f" SKIPPED after {MAX_QUERY_ATTEMPTS} attempts — last error: {last_error!r}"
)
return []
def find_rank(
documents: list[SearchDoc], expected_file: str, top_k: int
) -> tuple[int | None, float | None]:
"""Rank of the expected file among the hits, matched by exact name.
A substring match would score wholesale_sales.csv as a hit for sales.csv,
inflating recall and hiding a regression.
"""
expected = PurePosixPath(expected_file).name.lower()
for i, doc in enumerate(documents[:top_k]):
name = PurePosixPath(doc.get("semantic_identifier", "")).name.lower()
if name == expected:
return i + 1, doc.get("score")
return None, None
def run_benchmark(
base_url: str,
headers: dict[str, str],
queries: list[tuple[str, str]],
top_k: int,
num_hits: int,
) -> list[QueryResult]:
results: list[QueryResult] = []
for i, (query, expected_file) in enumerate(queries, 1):
print(f" [{i}/{len(queries)}] {query[:60]}...")
documents: list[SearchDoc] = search(base_url, headers, query, num_hits)
rank, score = find_rank(documents, expected_file, top_k)
top_results = [
(doc.get("semantic_identifier", "???"), doc.get("score"))
for doc in documents[:3]
]
results.append(
QueryResult(
query=query,
expected_file=expected_file,
rank=rank,
score=score,
num_results=len(documents),
top_results=top_results,
)
)
return results
def build_report(results: list[QueryResult], top_k: int) -> str:
lines: list[str] = []
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
n = len(results)
# Header
lines.append(f"Retrieval Benchmark — {timestamp}")
lines.append(f"Queries: {n}, Top-K: {top_k}")
lines.append("")
# Aggregate metrics
k_values = sorted({1, 3, 5, top_k})
recall: dict[int, float] = {}
for k in k_values:
if k > top_k:
break
found = sum(1 for r in results if r.rank is not None and r.rank <= k)
recall[k] = found / n if n else 0
lines.append(f"Recall@{k}: {found}/{n} ({recall[k]:.1%})")
reciprocal_ranks = [1.0 / r.rank if r.rank else 0.0 for r in results]
mrr = sum(reciprocal_ranks) / n if n else 0
lines.append(f"MRR: {mrr:.4f}")
misses = sum(1 for r in results if r.rank is None)
lines.append(f"Misses: {misses}/{n}")
lines.append("")
# Per-query detail
lines.append("Per-query results:")
lines.append("-" * 70)
for i, r in enumerate(results, 1):
if r.rank is not None:
score_str = f"{r.score:.4f}" if r.score is not None else "N/A"
status = f"rank {r.rank}, score {score_str}"
else:
status = f"NOT FOUND (searched {r.num_results} results)"
lines.append(f"[{i}] {status}")
lines.append(f" Q: {r.query}")
lines.append(f" Expected: {r.expected_file}")
if r.rank is None and r.top_results:
lines.append(" Top results returned:")
for j, (name, s) in enumerate(r.top_results, 1):
s_str = f"{s:.4f}" if s is not None else "N/A"
lines.append(f" {j}. {name} (score: {s_str})")
lines.append("")
return "\n".join(lines)
def main() -> None:
parser = argparse.ArgumentParser(
description="Benchmark retrieval quality against Onyx search"
)
parser.add_argument(
"--input", required=True, help="CSV file with (question, expected_file) rows"
)
parser.add_argument(
"--base-url", default="http://localhost:3000", help="Onyx base URL"
)
parser.add_argument(
"--email",
default=os.environ.get("ONYX_BENCH_EMAIL"),
help="Admin email (default: $ONYX_BENCH_EMAIL)",
)
parser.add_argument(
"--password",
default=os.environ.get("ONYX_BENCH_PASSWORD"),
help="Admin password (default: $ONYX_BENCH_PASSWORD)",
)
parser.add_argument(
"--top-k", type=int, default=10, help="Number of results to consider"
)
parser.add_argument(
"--num-hits", type=int, default=30, help="Number of hits to request from search"
)
parser.add_argument(
"--output", default="benchmark_results.txt", help="Output file for full report"
)
args = parser.parse_args()
if not args.email or not args.password:
print(
"Set ONYX_BENCH_EMAIL and ONYX_BENCH_PASSWORD "
"(or pass --email/--password).",
file=sys.stderr,
)
sys.exit(1)
# Load queries
queries: list[tuple[str, str]] = []
with open(args.input, newline="") as f:
reader = csv.reader(f)
next(reader, None) # skip header row
for row in reader:
if len(row) < 2:
continue
question, expected_file = row[0].strip(), row[1].strip()
if question and expected_file:
queries.append((question, expected_file))
if not queries:
print("No valid queries found in input CSV.", file=sys.stderr)
sys.exit(1)
print(f"Loaded {len(queries)} queries from {args.input}")
# Login
headers = login(args.base_url, args.email, args.password)
# Run
print("Running queries...")
results = run_benchmark(args.base_url, headers, queries, args.top_k, args.num_hits)
# Report
report = build_report(results, args.top_k)
with open(args.output, "w") as f:
f.write(report)
# Print summary
n = len(results)
found = sum(1 for r in results if r.rank is not None and r.rank <= args.top_k)
mrr = sum(1.0 / r.rank if r.rank else 0.0 for r in results) / n
print(f"\nRecall@{args.top_k}: {found}/{n} ({found / n:.1%}), MRR: {mrr:.4f}")
print(f"Full report written to {args.output}")
if __name__ == "__main__":
main()