mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-10-21 11:00:28 -05:00
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
275 lines
8.7 KiB
Python
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()
|