#!/usr/bin/env python3 """Benchmark a running Studio's RAG over its real HTTP API: indexing latency, scaling, and retrieval accuracy. Same script drives baseline and improved. Usage: python rag_studio_bench.py --base http://127.0.0.1:8901 --label baseline \ --password "" --corpus ../data/rag_corpus --out ../logs/bench_baseline.json """ import argparse import json import time from pathlib import Path import httpx API = "/api/rag" # Gold queries: (query, filename_substring, [accepted answer phrases any-of]). # A hit counts when a retrieved chunk is FROM the right document AND contains an # accepted phrase, so retrieval is scored independently of the chat model. GOLD = [ ("What are the sinusoidal positional encodings based on?", "attention", ["sine", "sinusoid", "wavelength"]), ("How many attention heads does the base Transformer use?", "attention", ["eight", "h = 8", "8 parallel", "8 attention", "h=8"]), ("What are BERT's two pre-training objectives?", "bert", ["masked", "next sentence"]), ("How many Transformer layers does BERT-large have?", "bert", ["24", "l = 24", "l=24"]), ("Difference between RAG-Sequence and RAG-Token models?", "rag", ["rag-token", "rag-sequence"]), ("Which retriever does RAG use to fetch passages?", "rag", ["dpr", "dense passage", "bi-encoder", "mips"]), ("What does the HTTP GET method do?", "rfc9110", ["transfer a current representation", "retrieve", "selector"]), ("Which HTTP status code means the resource was not found?", "rfc9110", ["404"]), ] def login(c, base, user, pw): new = pw + "Aa1!" # Re-run safe: bootstrap pw works first time; after we change it, the changed # pw works on later runs. r = c.post(f"{base}/api/auth/login", json={"username": user, "password": pw}) if r.status_code == 401: r = c.post(f"{base}/api/auth/login", json={"username": user, "password": new}) r.raise_for_status() body = r.json() tok = body["access_token"] if body.get("must_change_password"): r2 = c.post(f"{base}/api/auth/change-password", headers=H(tok), json={"current_password": pw, "new_password": new}) r2.raise_for_status() tok = r2.json()["access_token"] return tok def H(tok): return {"Authorization": f"Bearer {tok}"} def warmup(c, base, tok): t = time.perf_counter() try: c.post(f"{base}{API}/warmup", headers=H(tok), timeout=600) except Exception as e: print("warmup err:", e) return time.perf_counter() - t def create_kb(c, base, tok, name): r = c.post(f"{base}{API}/knowledge-bases", headers=H(tok), json={"name": name, "mode": "text", "chunking_strategy": "standard"}) r.raise_for_status() return r.json()["kb_id" if "kb_id" in r.json() else "id"] def upload(c, base, tok, kb_id, path: Path): with open(path, "rb") as f: r = c.post(f"{base}{API}/knowledge-bases/{kb_id}/documents", headers=H(tok), files={"file": (path.name, f, "application/octet-stream")}, timeout=600) r.raise_for_status() return r.json() def wait_indexed(c, base, tok, kb_id, document_id, timeout=600): t0 = time.perf_counter() while time.perf_counter() - t0 < timeout: r = c.get(f"{base}{API}/knowledge-bases/{kb_id}/documents", headers=H(tok)) r.raise_for_status() for d in r.json()["documents"]: if d["id"] == document_id: if d["status"] == "completed": return time.perf_counter() - t0, d["num_chunks"], "completed" if d["status"] == "failed": return time.perf_counter() - t0, 0, "failed" time.sleep(0.1) return timeout, 0, "timeout" def index_doc(c, base, tok, kb_id, path): t0 = time.perf_counter() up = upload(c, base, tok, kb_id, path) if up.get("already_indexed"): return {"file": path.name, "elapsed_s": 0.0, "chunks": 0, "status": "dup"} elapsed, chunks, status = wait_indexed(c, base, tok, kb_id, up["document_id"]) total = time.perf_counter() - t0 return {"file": path.name, "elapsed_s": round(total, 3), "chunks": chunks, "status": status, "document_id": up["document_id"]} def search(c, base, tok, kb_id, query, mode, top_k=10): r = c.post(f"{base}{API}/search", headers=H(tok), json={"query": query, "kb_id": kb_id, "mode": mode, "top_k": top_k}) r.raise_for_status() return r.json()["hits"] def score_mode(c, base, tok, kb_id, mode): r1 = r3 = r5 = 0 mrr = 0.0 lat = [] for query, fsub, phrases in GOLD: t = time.perf_counter() hits = search(c, base, tok, kb_id, query, mode, top_k=10) lat.append((time.perf_counter() - t) * 1000) rank = None for i, h in enumerate(hits): fn = (h.get("filename") or "").lower() txt = (h.get("text") or "").lower() if fsub in fn and any(p in txt for p in phrases): rank = i break if rank is not None: if rank == 0: r1 += 1 if rank < 3: r3 += 1 if rank < 5: r5 += 1 mrr += 1.0 / (rank + 1) n = len(GOLD) lat.sort() return {"recall@1": round(r1 / n, 3), "recall@3": round(r3 / n, 3), "recall@5": round(r5 / n, 3), "mrr": round(mrr / n, 3), "search_ms_median": round(lat[len(lat) // 2], 2)} def make_synthetic(dirpath: Path, n: int): dirpath.mkdir(parents=True, exist_ok=True) paths = [] for i in range(n): p = dirpath / f"syn_{i:02d}.txt" body = (f"Synthetic document number {i}. Project codename Orbit-{i} concerns " f"widget {i} calibration at {100 + i} hertz. Unique token zglyph{i} marks " f"this file. " * 8) p.write_text(body) paths.append(p) return paths def main(): ap = argparse.ArgumentParser() ap.add_argument("--base", required=True) ap.add_argument("--label", required=True) ap.add_argument("--password", required=True) ap.add_argument("--username", default="unsloth") ap.add_argument("--corpus", required=True) ap.add_argument("--out", required=True) ap.add_argument("--scaling-n", type=int, default=8) args = ap.parse_args() res = {"label": args.label, "base": args.base} with httpx.Client(timeout=120) as c: tok = login(c, args.base, args.username, args.password) res["warmup_s"] = round(warmup(c, args.base, tok), 2) # --- Real corpus: index timing (first = cold, rest = warm) + accuracy --- corpus = sorted(Path(args.corpus).glob("*")) corpus = [p for p in corpus if p.suffix.lower() in (".pdf", ".txt", ".md", ".html", ".htm", ".docx")] kb = create_kb(c, args.base, tok, f"{args.label}-corpus") res["kb"] = kb res["corpus_index"] = [] for i, p in enumerate(corpus): r = index_doc(c, args.base, tok, kb, p) r["which"] = "cold" if i == 0 else "warm" res["corpus_index"].append(r) print(f"[{args.label}] index {p.name}: {r['elapsed_s']}s ({r['chunks']} chunks) {r['which']}") res["accuracy"] = {m: score_mode(c, args.base, tok, kb, m) for m in ("bm25", "dense", "hybrid")} for m, s in res["accuracy"].items(): print(f"[{args.label}] {m}: R@1={s['recall@1']} R@5={s['recall@5']} MRR={s['mrr']}") # --- Scaling: N small docs into one fresh KB, per-doc index time --- kb2 = create_kb(c, args.base, tok, f"{args.label}-scaling") syn = make_synthetic(Path(args.corpus).parent / "rag_synthetic", args.scaling_n) res["scaling"] = [] for i, p in enumerate(syn): r = index_doc(c, args.base, tok, kb2, p) res["scaling"].append({"n": i + 1, "elapsed_s": r["elapsed_s"]}) print(f"[{args.label}] scaling doc {i+1}/{len(syn)}: {r['elapsed_s']}s") Path(args.out).write_text(json.dumps(res, indent=2)) print(f"[{args.label}] wrote {args.out}") if __name__ == "__main__": main()