Spaces:
Running
Running
File size: 7,874 Bytes
086a913 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 | """
HNSW ef_search sweep β M2 concept checkpoint.
Reads pre-cached query vectors from disk (run scripts/cache_query_vectors.py
first) and sweeps the HNSW beam width to chart the recall/latency tradeoff.
For each ef we report:
Recall@100, MRR@10 β accuracy
server ms/q β Qdrant's reported time per query (from response.time)
wall ms/q β client-side wall time per query (server + network RTT)
The delta between server and wall ms/q is how much of "ms per query" is
actually network round-trip to Qdrant Cloud, not HNSW work.
Usage:
uv run python scripts/cache_query_vectors.py # one-time
uv run python scripts/ef_sweep.py
uv run python scripts/ef_sweep.py --ef-values 16,32,64,128,256,512
uv run python scripts/ef_sweep.py --max-queries 1000
"""
from __future__ import annotations
import argparse
import os
import pickle
import random
import sys
import time
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "src"))
import httpx
from tqdm import tqdm
from codesearch.config import (
EMBEDDING_MODEL,
QDRANT_API_KEY,
QDRANT_COLLECTION,
QDRANT_URL,
)
from codesearch.eval.metrics import mean_recall, mean_reciprocal_rank
_SAMPLE_SEED = 42 # match eval/harness.py
_SEARCH_BATCH = 50 # queries per HTTP request to Qdrant (matches dense.py)
_TOP_K = 100
CACHE_DIR = ".cache"
def cache_path(model_name: str) -> str:
safe = model_name.replace("/", "_")
return os.path.join(CACHE_DIR, f"query_vectors_{safe}.pkl")
def load_cached_queries() -> tuple[list[dict], list[list[float]]]:
path = cache_path(EMBEDDING_MODEL)
if not os.path.exists(path):
sys.exit(
f"[error] Cache not found at {path}.\n"
f" Run: uv run python scripts/cache_query_vectors.py"
)
print(f"[1/3] Loading cached query vectors from {path}...")
with open(path, "rb") as f:
data = pickle.load(f)
if data["model"] != EMBEDDING_MODEL:
sys.exit(
f"[error] Cache model mismatch:\n"
f" cache built for: {data['model']!r}\n"
f" current model: {EMBEDDING_MODEL!r}\n"
f" Run: uv run python scripts/cache_query_vectors.py --recompute"
)
queries = data["queries"]
vectors = data["vectors"].tolist()
print(f" Loaded {len(queries):,} queries and their vectors.")
return queries, vectors
def search_batch(
http: httpx.Client,
chunk: list[list[float]],
ef: int,
) -> tuple[list[list[str]], float]:
"""One HTTP call to Qdrant for a chunk of <=_SEARCH_BATCH queries.
Returns (hits_per_query, server_time_seconds)."""
payload = {
"searches": [
{
"query": qv,
"limit": _TOP_K,
"params": {"hnsw_ef": ef},
"with_payload": ["doc_id"],
}
for qv in chunk
]
}
r = http.post(
f"/collections/{QDRANT_COLLECTION}/points/query/batch",
json=payload,
timeout=60.0,
)
r.raise_for_status()
body = r.json()
server_time = float(body.get("time", 0.0))
hits = [
[p["payload"]["doc_id"] for p in resp["points"]]
for resp in body["result"]
]
return hits, server_time
def sweep_one_ef(
http: httpx.Client,
query_vectors: list[list[float]],
ef: int,
) -> tuple[list[list[str]], float, float]:
"""Run all batches at a given ef. Returns (hits, wall_seconds, server_seconds)."""
all_hits: list[list[str]] = []
server_total = 0.0
n_batches = (len(query_vectors) + _SEARCH_BATCH - 1) // _SEARCH_BATCH
wall_t0 = time.perf_counter()
for i in tqdm(
range(0, len(query_vectors), _SEARCH_BATCH),
total=n_batches,
desc=f" ef={ef:<4}",
leave=False,
):
chunk = query_vectors[i : i + _SEARCH_BATCH]
hits, server_t = search_batch(http, chunk, ef)
all_hits.extend(hits)
server_total += server_t
wall_elapsed = time.perf_counter() - wall_t0
return all_hits, wall_elapsed, server_total
def run_sweep(ef_values: list[int], max_queries: int) -> None:
# ------------------------------------------------------------------
# [1] Load cached queries + vectors
# ------------------------------------------------------------------
queries, vectors = load_cached_queries()
if max_queries and len(queries) > max_queries:
random.seed(_SAMPLE_SEED)
idx = random.sample(range(len(queries)), max_queries)
queries = [queries[i] for i in idx]
vectors = [vectors[i] for i in idx]
print(f" Sampled {len(queries):,} queries (seed={_SAMPLE_SEED}).")
# ------------------------------------------------------------------
# [2] Verify Qdrant collection is populated
# ------------------------------------------------------------------
print(f"[2/3] Connecting to Qdrant at {QDRANT_URL}")
http = httpx.Client(
base_url=QDRANT_URL,
headers={"api-key": QDRANT_API_KEY},
)
info = http.get(f"/collections/{QDRANT_COLLECTION}").json()
points_count = info.get("result", {}).get("points_count", 0)
if not points_count:
sys.exit(
f"[error] Collection '{QDRANT_COLLECTION}' is empty.\n"
f" Run: uv run python scripts/index_corpus.py"
)
print(f" Collection '{QDRANT_COLLECTION}' has {points_count:,} vectors.")
# ------------------------------------------------------------------
# [3] Warmup, then sweep
# ------------------------------------------------------------------
print(f"[3/3] Warming up (1 batch at ef={ef_values[0]})...")
search_batch(http, vectors[: min(_SEARCH_BATCH, len(vectors))], ef_values[0])
print(f" Sweeping ef across {ef_values}...")
relevant_ids = [q["relevant_id"] for q in queries]
rows: list[tuple[int, float, float, float, float]] = []
for ef in tqdm(ef_values, desc="Sweep"):
hits, wall_s, server_s = sweep_one_ef(http, vectors, ef)
query_results = list(zip(hits, relevant_ids))
mrr = mean_reciprocal_rank(query_results, k=10)
recall = mean_recall(query_results, k=100)
n = len(queries)
rows.append((ef, mrr, recall, server_s / n * 1000, wall_s / n * 1000))
http.close()
# ------------------------------------------------------------------
# Results table
# ------------------------------------------------------------------
print()
print(f"Dense retrieval HNSW ef_search sweep β n={len(queries)} queries, top-{_TOP_K}")
header = f"{'ef':>6} {'MRR@10':>9} {'Recall@100':>12} {'server ms/q':>14} {'wall ms/q':>12}"
print(header)
print("-" * len(header))
for ef, mrr, recall, server_ms, wall_ms in rows:
print(f"{ef:>6} {mrr:>9.4f} {recall:>12.4f} {server_ms:>14.2f} {wall_ms:>12.2f}")
print()
print("server ms/q = Qdrant's reported processing time per query (HNSW work only)")
print("wall ms/q = client-side wall time per query (server time + network RTT)")
print("(query embedding is cached and excluded from both)")
def main() -> None:
parser = argparse.ArgumentParser(
description="Sweep HNSW ef_search on the dense Qdrant index."
)
parser.add_argument(
"--ef-values",
default="32,128,512",
help="Comma-separated ef_search values to sweep (default: 32,128,512 per PLAN.md).",
)
parser.add_argument(
"--max-queries",
type=int,
default=500,
help="Sample size (default: 500 β ~Β±2pp stderr on Recall@100, fast runtime).",
)
args = parser.parse_args()
ef_values = [int(x) for x in args.ef_values.split(",")]
run_sweep(ef_values, args.max_queries)
if __name__ == "__main__":
main()
|