#!/usr/bin/env python3 """Lance IVF_PQ parameter sweep — find the best recall config. Mirrors the HNSW autotune pattern: try different configs, measure recall against brute-force ground truth, pick the Pareto winner. Parameters to tune: - num_partitions: more partitions = finer-grained search = higher recall, slower build - num_sub_vectors: more = better quantization = higher recall, more disk - num_bits: 8 is standard; 4 is smaller but lower recall We keep the harness (resumes_100k_smoke) constant — same 20 queries, same ground truth computed from brute-force cosine. """ import json, time, sys from urllib.request import Request, urlopen from urllib.error import HTTPError BASE = "http://localhost:3100" def post(path, body=None, timeout=600): data = json.dumps(body).encode() if body else None req = Request(f"{BASE}{path}", data=data, headers={"Content-Type": "application/json"}) try: resp = urlopen(req, timeout=timeout) return json.loads(resp.read()) except HTTPError as e: return {"error": e.read().decode()[:300]} except Exception as e: return {"error": str(e)} # Configs to sweep. Key insight from IVF_PQ theory: # - More partitions + more probes = higher recall (sqrt(N) is a starting point) # - More sub_vectors = finer quantization = higher recall but bigger index # - 768 dims must be evenly divisible by num_sub_vectors # Valid: 48 (768/48=16), 96 (768/96=8), 192 (768/192=4), 384 (768/384=2) # Also: 64 (768/64=12), 128 (768/128=6), 256 (768/256=3) configs = [ # (partitions, bits, sub_vectors, label) (100, 8, 48, "fewer partitions"), (316, 8, 48, "baseline (√N)"), (500, 8, 48, "more partitions"), (316, 8, 96, "more sub_vectors (96)"), (316, 8, 192, "fine quantization (192)"), (500, 8, 96, "more parts + more subs"), (500, 8, 192, "max config"), (200, 8, 128, "balanced mid"), ] print("=" * 70) print("LANCE IVF_PQ PARAMETER SWEEP — find the recall sweet spot") print("=" * 70) print(f"Index: resumes_100k_v2 (100K × 768d)") print(f"Harness: resumes_100k_smoke (20 queries, brute-force ground truth)") print(f"Configs to test: {len(configs)}") print() results = [] for i, (parts, bits, subs, label) in enumerate(configs): print(f"[{i+1}/{len(configs)}] {label}: partitions={parts} bits={bits} sub_vectors={subs}") # Rebuild index with these params t0 = time.time() r = post(f"/vectors/lance/index/resumes_100k_v2", { "num_partitions": parts, "num_bits": bits, "num_sub_vectors": subs, }) build_ms = (time.time() - t0) * 1000 if "error" in r: print(f" ✗ build failed: {r['error'][:80]}") results.append({"label": label, "parts": parts, "bits": bits, "subs": subs, "recall": 0, "p50": 0, "build_ms": build_ms, "error": r["error"][:80]}) continue build_secs = r.get("build_time_secs", 0) print(f" built in {build_secs:.1f}s") # Measure recall r = post(f"/vectors/lance/recall/resumes_100k_v2", { "harness": "resumes_100k_smoke", "top_k": 10, }) if "error" in r: print(f" ✗ recall failed: {r['error'][:80]}") results.append({"label": label, "parts": parts, "bits": bits, "subs": subs, "recall": 0, "p50": 0, "build_ms": build_ms, "error": r["error"][:80]}) continue recall = r.get("mean_recall", 0) p50 = r.get("latency_p50_us", 0) p95 = r.get("latency_p95_us", 0) print(f" recall@10={recall:.4f} p50={p50:.0f}us p95={p95:.0f}us build={build_secs:.1f}s") results.append({ "label": label, "parts": parts, "bits": bits, "subs": subs, "recall": recall, "p50": p50, "p95": p95, "build_secs": build_secs, "build_ms": build_ms, }) # Pick the winner: highest recall, then lowest p50 on ties print("\n" + "=" * 70) print("RESULTS") print("=" * 70) print(f"\n{'Config':<30} {'Parts':>6} {'Subs':>6} {'Recall':>8} {'p50 us':>8} {'Build':>7}") print("-" * 70) results.sort(key=lambda r: (-r["recall"], r.get("p50", 99999))) for r in results: if "error" in r: print(f"{r['label']:<30} {r['parts']:>6} {r['subs']:>6} {'FAIL':>8} {'—':>8} {'—':>7}") else: print(f"{r['label']:<30} {r['parts']:>6} {r['subs']:>6} {r['recall']:>8.4f} {r['p50']:>8.0f} {r['build_secs']:>6.1f}s") winner = results[0] if results else None if winner and "error" not in winner: print(f"\n★ WINNER: {winner['label']}") print(f" recall@10={winner['recall']:.4f} p50={winner['p50']:.0f}us build={winner['build_secs']:.1f}s") print(f" Config: num_partitions={winner['parts']} num_bits={winner['bits']} num_sub_vectors={winner['subs']}") # Rebuild with the winner config so it stays active if winner != results[-1]: # only if the last build wasn't already the winner print(f"\n Rebuilding index with winner config...") post(f"/vectors/lance/index/resumes_100k_v2", { "num_partitions": winner["parts"], "num_bits": winner["bits"], "num_sub_vectors": winner["subs"], }) print(" Done — winner config is now active.")