Files
chunking_strategies_evaluation/scripts/run_neighbor_sweep.py

375 lines
11 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Run a Benchmark Sweep: fixed_size × text-embedding-3-large × Neighbor ±0..±3.
Calls the running FastAPI app over HTTP (same path as the Dashboard).
Doc-major order; gap-fills Process (fixed_size only) when SQLite provenance
says the Model Corpus is not ready; retries each unit twice on failure.
Usage:
.venv/bin/python scripts/run_neighbor_sweep.py
.venv/bin/python scripts/run_neighbor_sweep.py --base-url http://127.0.0.1:8000
"""
from __future__ import annotations
import argparse
import json
import sys
import time
from dataclasses import dataclass, field
from typing import Any
import httpx
CORPUS_MODEL_ID = "text-embedding-3-large"
STRATEGY = "fixed_size"
TOP_K = 5
NEIGHBOR_LEVELS = (0, 1, 2, 3)
MAX_ATTEMPTS = 3 # 1 try + 2 retries
DEFAULT_BASE_URL = "http://127.0.0.1:8000"
# Benchmarks with many questions can take a long time (LLM + eval per Q).
REQUEST_TIMEOUT_S = 60 * 60 * 3 # 3 hours
# App-truth document ↔ questions map (filenames as stored in the app).
SWEEP_PAIRS: list[tuple[str, str]] = [
("bazresi.docx", "files/bazresi.json"),
("customer1.docx", "files/customer1.json"),
("fire.docx", "files/fire.json"),
("general-havades-individuals.doc", "files/general-havades-individuals.json"),
("havades.docx", "files/havades.json"),
("life-time-individual.docx", "files/life-time-individual.json"),
("moavenin.docx", "files/moavenin.json"),
("Refah.docx", "files/refah.json"),
("website.docx", "files/website.json"),
("lifetime-compensation.docx", "files/lifetime-compensation.json"),
]
@dataclass
class UnitResult:
document: str
neighbor: int
kind: str # "process" | "experiment"
ok: bool
detail: str
experiment_id: str | None = None
attempts: int = 1
@dataclass
class SweepState:
results: list[UnitResult] = field(default_factory=list)
@property
def failed(self) -> list[UnitResult]:
return [r for r in self.results if not r.ok]
def log(msg: str) -> None:
print(msg, flush=True)
def is_ready(doc: dict[str, Any]) -> bool:
corpus = doc.get("last_corpus_embedding_model_id")
counts = doc.get("chunk_counts") or {}
fixed = int(counts.get("fixed_size") or 0)
return corpus == CORPUS_MODEL_ID and fixed > 0
def fetch_documents(client: httpx.Client) -> dict[str, dict[str, Any]]:
"""Return filename → document dict (paginated)."""
by_name: dict[str, dict[str, Any]] = {}
offset = 0
limit = 100
while True:
r = client.get("/documents", params={"offset": offset, "limit": limit})
r.raise_for_status()
data = r.json()
items = data.get("items") or []
for doc in items:
by_name[doc["filename"]] = doc
total = int(data.get("total") or 0)
offset += len(items)
if offset >= total or not items:
break
return by_name
def error_detail(exc: BaseException) -> str:
if isinstance(exc, httpx.HTTPStatusError):
body = ""
try:
body = exc.response.text[:500]
except Exception:
pass
return f"HTTP {exc.response.status_code}: {body or exc}"
return str(exc)
def with_retries(
label: str,
fn,
*,
state: SweepState,
document: str,
neighbor: int,
kind: str,
) -> Any | None:
last_err = ""
for attempt in range(1, MAX_ATTEMPTS + 1):
try:
log(f" [{kind}] {label} (attempt {attempt}/{MAX_ATTEMPTS})")
value = fn()
return value
except Exception as exc:
last_err = error_detail(exc)
log(f" ! failed attempt {attempt}/{MAX_ATTEMPTS}: {last_err}")
if attempt < MAX_ATTEMPTS:
time.sleep(min(5 * attempt, 15))
state.results.append(
UnitResult(
document=document,
neighbor=neighbor,
kind=kind,
ok=False,
detail=last_err,
attempts=MAX_ATTEMPTS,
)
)
return None
def ensure_corpus(
client: httpx.Client,
doc: dict[str, Any],
*,
state: SweepState,
) -> dict[str, Any] | None:
"""Process fixed_size under CORPUS_MODEL_ID if not ready. Returns updated doc or None."""
filename = doc["filename"]
if is_ready(doc):
log(f" corpus ready ({CORPUS_MODEL_ID}, fixed_size={doc['chunk_counts'].get('fixed_size')})")
return doc
log(
f" corpus not ready "
f"(last_corpus={doc.get('last_corpus_embedding_model_id')!r}, "
f"fixed_size={ (doc.get('chunk_counts') or {}).get('fixed_size') }) — processing"
)
def _process() -> dict[str, Any]:
r = client.post(
f"/documents/{doc['id']}/process",
json={
"strategies": [STRATEGY],
"corpus_model_id": CORPUS_MODEL_ID,
},
)
r.raise_for_status()
body = r.json()
failed = body.get("strategies_failed") or []
if failed:
raise RuntimeError(f"process strategies_failed: {json.dumps(failed)}")
# Refresh document record
docs = fetch_documents(client)
updated = docs.get(filename)
if updated is None:
raise RuntimeError(f"document disappeared after process: {filename}")
if not is_ready(updated):
raise RuntimeError(
"process finished but provenance still not ready: "
f"last_corpus={updated.get('last_corpus_embedding_model_id')!r}, "
f"chunk_counts={updated.get('chunk_counts')}"
)
return updated
result = with_retries(
f"process {filename}",
_process,
state=state,
document=filename,
neighbor=-1,
kind="process",
)
if result is None:
return None
state.results.append(
UnitResult(
document=filename,
neighbor=-1,
kind="process",
ok=True,
detail="gap-filled fixed_size",
attempts=1,
)
)
return result
def run_experiment(
client: httpx.Client,
*,
doc: dict[str, Any],
questions_file: str,
neighbor: int,
state: SweepState,
) -> None:
filename = doc["filename"]
def _bench() -> dict[str, Any]:
r = client.post(
"/benchmarks",
json={
"document_id": doc["id"],
"strategies": [STRATEGY],
"questions_file": questions_file,
"top_k": TOP_K,
"neighbor_prev": neighbor,
"neighbor_next": neighbor,
"corpus_model_id": CORPUS_MODEL_ID,
},
)
r.raise_for_status()
return r.json()
body = with_retries(
f"±{neighbor}/{neighbor} on {filename}",
_bench,
state=state,
document=filename,
neighbor=neighbor,
kind="experiment",
)
if body is None:
return
exp_id = body.get("experiment_id", "")
state.results.append(
UnitResult(
document=filename,
neighbor=neighbor,
kind="experiment",
ok=True,
detail=(
f"best={body.get('best_strategy')} "
f"latency={body.get('total_latency_seconds')}s "
f"cost=${body.get('estimated_cost_usd')}"
),
experiment_id=exp_id,
)
)
log(f" ok experiment_id={exp_id}")
def print_summary(state: SweepState) -> None:
log("")
log("=" * 88)
log("BENCHMARK SWEEP SUMMARY")
log("=" * 88)
log(
f"{'document':<40} {'±N':>4} {'kind':<11} {'status':<6} detail"
)
log("-" * 88)
for r in state.results:
n = "—" if r.neighbor < 0 else f"±{r.neighbor}"
status = "ok" if r.ok else "FAIL"
detail = r.experiment_id or r.detail
if not r.ok and r.experiment_id is None:
detail = r.detail
elif r.ok and r.experiment_id:
detail = f"id={r.experiment_id} {r.detail}"
log(f"{r.document:<40} {n:>4} {r.kind:<11} {status:<6} {detail}")
failed = state.failed
log("-" * 88)
exp_ok = sum(1 for r in state.results if r.kind == "experiment" and r.ok)
exp_fail = sum(1 for r in state.results if r.kind == "experiment" and not r.ok)
log(f"Experiments: {exp_ok} ok, {exp_fail} failed")
if failed:
log("")
log("Manual redo hints (failed units):")
for r in failed:
if r.kind == "process":
log(
f" • Process {r.document}: "
f'POST /documents/{{id}}/process '
f'{{"strategies":["{STRATEGY}"],"corpus_model_id":"{CORPUS_MODEL_ID}"}}'
)
else:
log(
f" • Experiment {r.document} ±{r.neighbor}: "
f'POST /benchmarks with strategies=["{STRATEGY}"], '
f"neighbor_prev={r.neighbor}, neighbor_next={r.neighbor}, "
f"corpus_model_id={CORPUS_MODEL_ID}, questions_file from SWEEP_PAIRS"
)
log(f" error: {r.detail}")
def main() -> int:
parser = argparse.ArgumentParser(description="Run fixed_size Neighbor Expansion Benchmark Sweep")
parser.add_argument(
"--base-url",
default=DEFAULT_BASE_URL,
help=f"FastAPI base URL (default: {DEFAULT_BASE_URL})",
)
args = parser.parse_args()
base_url = args.base_url.rstrip("/")
state = SweepState()
log(f"Benchmark Sweep → {base_url}")
log(f"Strategy={STRATEGY} corpus={CORPUS_MODEL_ID} top_k={TOP_K} neighbors={list(NEIGHBOR_LEVELS)}")
log(f"Documents: {len(SWEEP_PAIRS)}")
timeout = httpx.Timeout(REQUEST_TIMEOUT_S, connect=30.0)
with httpx.Client(base_url=base_url, timeout=timeout) as client:
try:
health = client.get("/admin/health")
health.raise_for_status()
except Exception as exc:
log(f"Cannot reach app at {base_url}: {error_detail(exc)}")
return 2
docs = fetch_documents(client)
log(f"Loaded {len(docs)} documents from app")
for filename, questions_file in SWEEP_PAIRS:
log("")
log(f"=== {filename} ({questions_file}) ===")
doc = docs.get(filename)
if doc is None:
log(f" ! document not found in app — skipping")
state.results.append(
UnitResult(
document=filename,
neighbor=-1,
kind="process",
ok=False,
detail="document not found in GET /documents",
attempts=1,
)
)
continue
updated = ensure_corpus(client, doc, state=state)
if updated is None:
log(f" ! skipping experiments for {filename} (process failed)")
continue
docs[filename] = updated
for n in NEIGHBOR_LEVELS:
run_experiment(
client,
doc=updated,
questions_file=questions_file,
neighbor=n,
state=state,
)
print_summary(state)
return 1 if state.failed else 0
if __name__ == "__main__":
sys.exit(main())