Spaces:
Running
Running
File size: 16,221 Bytes
d5a6f3e | 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 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 | """
Comprehensive benchmark & diagnostics:
1. Collection config (BQ, HNSW, quantization)
2. Latency benchmarks for every pipeline stage
3. Latest paper in the collection
"""
import asyncio
import time
import uuid
import re
import numpy as np
from fastapi.testclient import TestClient
from bs4 import BeautifulSoup
from app.main import app
from app import qdrant_svc, arxiv_svc, db, config
from app.recommend import profiles
from app.recommend.clustering import compute_clusters
from app.recommend.reranker import rerank_candidates
from app.recommend.diversity import mmr_rerank, inject_exploration
def _timer():
"""Simple context-manager-like timer."""
class T:
def __init__(self): self.start = time.perf_counter()
def elapsed_ms(self): return (time.perf_counter() - self.start) * 1000
return T()
def run():
loop = asyncio.get_event_loop()
client_api = TestClient(app)
# =================================================================
# SECTION 1: QDRANT COLLECTION CONFIG (BQ, HNSW, Quantization)
# =================================================================
print("=" * 70)
print("SECTION 1: QDRANT COLLECTION CONFIG")
print("=" * 70)
from qdrant_client import QdrantClient
qclient = QdrantClient(
url=config.QDRANT_URL,
api_key=config.QDRANT_API_KEY,
timeout=15,
check_compatibility=False,
)
info = qclient.get_collection(config.QDRANT_COLLECTION)
print(f"\n Collection: {config.QDRANT_COLLECTION}")
print(f" Points count: {info.points_count:,}")
print(f" Status: {info.status}")
# Vector config
vc = info.config.params.vectors
if hasattr(vc, 'size'):
print(f" Vector dim: {vc.size}")
print(f" Distance: {vc.distance}")
elif isinstance(vc, dict):
for name, v in vc.items():
print(f" Vector '{name}': dim={v.size}, distance={v.distance}")
else:
print(f" Vectors config: {vc}")
# HNSW config
hnsw = info.config.hnsw_config
print(f"\n HNSW m: {hnsw.m}")
print(f" HNSW ef_construct: {hnsw.ef_construct}")
print(f" HNSW on_disk: {hnsw.on_disk}")
# Quantization config
quant = info.config.quantization_config
if quant is not None:
print(f"\n Quantization: YES")
if hasattr(quant, 'binary'):
bq = quant.binary
print(f" Type: Binary Quantization (BQ)")
print(f" Always RAM: {bq.always_ram if hasattr(bq, 'always_ram') else 'N/A'}")
elif hasattr(quant, 'scalar'):
sq = quant.scalar
print(f" Type: Scalar Quantization")
print(f" Scalar type: {sq.type}")
print(f" Quantile: {sq.quantile}")
print(f" Always RAM: {sq.always_ram}")
elif hasattr(quant, 'product'):
pq = quant.product
print(f" Type: Product Quantization (PQ)")
print(f" Compression: {pq.compression}")
print(f" Always RAM: {pq.always_ram}")
else:
print(f" Type: {quant}")
else:
print(f"\n Quantization: NONE (full float32 vectors)")
# Optimizer config
opt = info.config.optimizer_config
print(f"\n Indexing threshold: {opt.indexing_threshold}")
print(f" Memmap threshold: {opt.memmap_threshold}")
# =================================================================
# SECTION 2: LATENCY BENCHMARKS
# =================================================================
print("\n\n" + "=" * 70)
print("SECTION 2: LATENCY BENCHMARKS (per pipeline stage)")
print("=" * 70)
# Setup: save papers to create a realistic user
user_id = f"bench-{uuid.uuid4().hex[:8]}"
cookies = {"arxiv_user_id": user_id}
# Search and save papers
res = client_api.get("/search?q=Machine+Learning", cookies=cookies)
soup = BeautifulSoup(res.text, "html.parser")
ml_ids = []
for btn in soup.find_all("button", attrs={"hx-post": re.compile(r"/api/papers/.+/save")}):
m = re.search(r"/api/papers/([^/]+)/save", btn["hx-post"])
if m: ml_ids.append(m.group(1))
ml_ids = ml_ids[:4]
res2 = client_api.get("/search?q=Neural+Networks", cookies=cookies)
soup2 = BeautifulSoup(res2.text, "html.parser")
nn_ids = []
for btn in soup2.find_all("button", attrs={"hx-post": re.compile(r"/api/papers/.+/save")}):
m = re.search(r"/api/papers/([^/]+)/save", btn["hx-post"])
if m: nn_ids.append(m.group(1))
nn_ids = nn_ids[:3]
all_saved = ml_ids + nn_ids
for pid in all_saved:
client_api.post(f"/api/papers/{pid}/save", cookies=cookies)
# Force EWMA inline
async def setup():
for pid in all_saved:
vecs = await qdrant_svc.get_paper_vectors([pid])
if pid in vecs:
emb = np.array(vecs[pid], dtype=np.float32)
await profiles.update_on_save(user_id, emb)
loop.run_until_complete(setup())
print(f"\n Setup: saved {len(all_saved)} papers, EWMA profiles computed")
# --- Benchmark each stage ---
timings = {}
# Stage 1: Fetch vectors from Qdrant
async def bench_fetch_vectors():
t = _timer()
vecs = await qdrant_svc.get_paper_vectors(all_saved)
elapsed = t.elapsed_ms()
return vecs, elapsed
vecs, t1 = loop.run_until_complete(bench_fetch_vectors())
timings["1. Fetch vectors from Qdrant"] = t1
print(f"\n 1. Fetch {len(all_saved)} vectors from Qdrant: {t1:.1f} ms")
# Stage 2: Ward clustering
aligned_ids = [pid for pid in all_saved if pid in vecs]
aligned_embs = np.array([vecs[pid] for pid in aligned_ids], dtype=np.float32)
t = _timer()
clusters = compute_clusters(aligned_ids, aligned_embs)
t2 = t.elapsed_ms()
timings["2. Ward clustering"] = t2
print(f" 2. Ward clustering ({len(aligned_ids)} papers -> {len(clusters)} clusters): {t2:.2f} ms")
# Stage 3: Multi-interest retrieval (Qdrant prefetch + RRF)
async def bench_retrieval():
interest_vectors = []
limits = [40, 30, 25]
for i, c in enumerate(clusters):
lim = limits[i] if i < len(limits) else 15
interest_vectors.append((c.medoid_embedding.tolist(), lim))
lt = await profiles.load_profile(user_id, "long_term")
st = await profiles.load_profile(user_id, "short_term")
st_list = st.tolist() if st is not None else None
seen = set(all_saved)
t = _timer()
candidates = await qdrant_svc.multi_interest_search(
interest_vectors=interest_vectors,
short_term_vector=st_list,
exclude_ids=seen,
total_limit=100,
)
elapsed = t.elapsed_ms()
return candidates, lt, st, elapsed
candidates, lt_vec, st_vec, t3 = loop.run_until_complete(bench_retrieval())
timings["3. Prefetch + RRF retrieval"] = t3
print(f" 3. Prefetch + RRF ({len(clusters)} clusters + session): {t3:.1f} ms ({len(candidates)} candidates)")
# Stage 4: Fetch candidate vectors + metadata for re-ranking
async def bench_cand_fetch():
t = _timer()
cand_vecs = await qdrant_svc.get_paper_vectors(candidates[:50])
cand_meta = await arxiv_svc.fetch_metadata_batch(candidates[:50])
elapsed = t.elapsed_ms()
return cand_vecs, cand_meta, elapsed
cand_vecs, cand_meta, t4 = loop.run_until_complete(bench_cand_fetch())
timings["4. Fetch candidate vectors+meta"] = t4
print(f" 4. Fetch candidate vectors + metadata: {t4:.1f} ms ({len(cand_vecs)} vectors, {len(cand_meta)} metadata)")
# Stage 5: Heuristic re-ranking
valid_ids = [cid for cid in candidates if cid in cand_vecs and cid in cand_meta]
valid_embs = np.array([cand_vecs[cid] for cid in valid_ids], dtype=np.float32)
valid_meta = [cand_meta[cid] for cid in valid_ids]
t = _timer()
reranked_ids, reranked_scores, reranked_embs = rerank_candidates(
candidate_ids=valid_ids,
candidate_embeddings=valid_embs,
candidate_metadata=valid_meta,
long_term_vec=lt_vec,
short_term_vec=st_vec,
)
t5 = t.elapsed_ms()
timings["5. Heuristic re-ranking"] = t5
print(f" 5. Heuristic re-ranking ({len(valid_ids)} candidates): {t5:.2f} ms")
# Stage 6: MMR diversity
query_vec = lt_vec if lt_vec is not None else aligned_embs.mean(axis=0)
t = _timer()
mmr_selected = mmr_rerank(
query_embedding=query_vec,
candidate_embeddings=reranked_embs,
candidate_ids=reranked_ids,
scores=reranked_scores,
lambda_param=0.6,
top_k=10,
)
t6 = t.elapsed_ms()
timings["6. MMR diversity selection"] = t6
print(f" 6. MMR diversity selection (top 10): {t6:.2f} ms")
# Stage 7: Exploration injection
t = _timer()
final = inject_exploration(mmr_selected, reranked_ids, n_explore=2)
t7 = t.elapsed_ms()
timings["7. Exploration injection"] = t7
print(f" 7. Exploration injection: {t7:.3f} ms")
# Stage 8: Template rendering (end-to-end HTTP)
t = _timer()
rec_res = client_api.get("/api/recommendations", cookies=cookies)
t8 = t.elapsed_ms()
timings["8. Full HTTP request (/api/recommendations)"] = t8
print(f"\n 8. FULL HTTP /api/recommendations: {t8:.1f} ms (status={rec_res.status_code})")
# Totals
compute_total = t2 + t5 + t6 + t7
network_total = t1 + t3 + t4
print(f"\n --- TOTALS ---")
print(f" Pure compute (clustering + rerank + MMR + explore): {compute_total:.2f} ms")
print(f" Network I/O (Qdrant + arXiv metadata): {network_total:.1f} ms")
print(f" Full end-to-end HTTP request: {t8:.1f} ms")
# =================================================================
# SECTION 3: HOW THE PIPELINE WORKS (Step by step)
# =================================================================
print("\n\n" + "=" * 70)
print("SECTION 3: HOW THE PIPELINE WORKS")
print("=" * 70)
print("""
User saves papers -> events.py fires background EWMA update
(fetches paper's 1024-dim BGE-M3 vector from Qdrant,
blends into user's long-term/short-term profile)
User loads home page -> GET /api/recommendations fires
Step 1: Load user_state (in-memory deque of saved/dismissed IDs)
Step 2: Check tier eligibility:
>= 5 saves? -> Tier 1 (clustering + RRF)
>= 3 saves? -> Tier 2 (EWMA vector search)
>= 1 save? -> Tier 3 (Qdrant BEST_SCORE with raw IDs)
TIER 1 PIPELINE:
a) Fetch BGE-M3 embeddings for all saved papers from Qdrant
b) Run Ward hierarchical clustering on those embeddings
-> Finds 1-7 interest groups automatically (adaptive gap method)
-> Each cluster's "medoid" = the real paper closest to cluster center
c) Send medoid embeddings as parallel ANN queries to Qdrant
(Prefetch API: single network call, server runs them in parallel)
d) Qdrant fuses results via Reciprocal Rank Fusion (RRF, k=60)
-> Papers appearing in multiple cluster results get boosted
e) Fetch candidate embeddings + arXiv metadata
f) Heuristic re-ranking: score = 0.45*cos_sim_LT + 0.25*cos_sim_ST
+ 0.20*recency + 0.10*rrf_rank
g) MMR diversity: greedily select top-10 maximizing
lambda*relevance - (1-lambda)*max_similarity_to_already_selected
h) Inject 1-2 random "exploration" papers from the candidate pool
i) Fetch arXiv metadata, render Jinja2 HTML, return via HTMX
""")
# =================================================================
# SECTION 4: LATEST PAPER IN QDRANT
# =================================================================
print("=" * 70)
print("SECTION 4: LATEST PAPER IN QDRANT")
print("=" * 70)
# Strategy: scroll with ordering by a date field, or sample recent IDs
# Qdrant doesn't have a "sort by payload" for scroll, so we'll sample
# papers with high point IDs (usually later additions) and check dates
print("\n Sampling papers with highest point IDs (latest additions)...")
# Get collection info to find point ID range
try:
# Scroll from the end (highest IDs)
# Use reverse scroll by getting points near the max count
max_id = info.points_count
sample_ids = list(range(max(1, max_id - 20), max_id + 1))
points = qclient.retrieve(
collection_name=config.QDRANT_COLLECTION,
ids=sample_ids,
with_payload=True,
with_vectors=False,
)
if points:
# Sort by published date
dated_papers = []
for p in points:
pub = p.payload.get("published", "")
arxiv_id = p.payload.get("arxiv_id", "?")
title = p.payload.get("title", "?")
cats = p.payload.get("categories", p.payload.get("category", "?"))
dated_papers.append((pub, arxiv_id, title, cats, p.id))
dated_papers.sort(key=lambda x: x[0], reverse=True)
print(f"\n Top 10 most recent papers (by published date) from high-ID sample:\n")
for i, (pub, aid, title, cats, pid) in enumerate(dated_papers[:10], 1):
t_short = (title[:65] + "...") if len(str(title)) > 65 else title
print(f" {i:2d}. [{pub}] {aid}")
print(f" {t_short}")
print(f" Categories: {cats} (Qdrant ID: {pid})")
print()
latest = dated_papers[0]
print(f" LATEST PAPER: {latest[1]} published {latest[0]}")
else:
print(" Could not retrieve high-ID points")
except Exception as e:
print(f" Error sampling latest papers: {e}")
# Also try searching for very recent papers by scrolling with a filter
print("\n Also checking for 2025+ papers across the collection...")
try:
from qdrant_client.models import Filter, FieldCondition, Range
pts_2025, _ = qclient.scroll(
collection_name=config.QDRANT_COLLECTION,
scroll_filter=Filter(must=[
FieldCondition(key="year", range=Range(gte=2025))
]),
limit=10,
with_payload=True,
with_vectors=False,
)
if pts_2025:
latest_2025 = []
for p in pts_2025:
pub = p.payload.get("published", "")
aid = p.payload.get("arxiv_id", "?")
title = p.payload.get("title", "?")
latest_2025.append((pub, aid, title))
latest_2025.sort(key=lambda x: x[0], reverse=True)
print(f" Found {len(pts_2025)} papers from 2025+:\n")
for pub, aid, title in latest_2025[:5]:
t_short = (str(title)[:65] + "...") if len(str(title)) > 65 else title
print(f" [{pub}] {aid}: {t_short}")
else:
print(" No papers with year >= 2025 found (or 'year' field not indexed)")
# Try without year filter, just get random sample
print(" Trying broader search...")
except Exception as e:
print(f" Year filter failed ({e}), trying published date range...")
try:
from qdrant_client.models import Filter, FieldCondition, MatchValue
# Just scroll a few and show dates
pts, _ = qclient.scroll(
collection_name=config.QDRANT_COLLECTION,
limit=5,
with_payload=True,
with_vectors=False,
)
if pts:
for p in pts:
print(f" Sample: {p.payload.get('arxiv_id','?')} "
f"published={p.payload.get('published','?')}")
except Exception as e2:
print(f" Could not query: {e2}")
print("\n" + "=" * 70)
print("DONE")
print("=" * 70)
if __name__ == "__main__":
run()
|