# Copyright Vespa.ai. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. """Query execution and ground truth computation.""" import asyncio import logging import time import numpy as np from vespa.application import Vespa from config import Config from recall import recall_at_k from tuning_request import TuningRequest log = logging.getLogger(__name__) # Per-query ground truth cache: (filter_key, query_index) -> list of doc IDs _exact_gt_cache: dict[tuple[float, int], list[int]] = {} async def compute_exact_ground_truth( app: Vespa, cfg: Config, query_vectors: np.ndarray, filter_threshold: float, num_queries: int, concurrency: int = 4, ) -> list[list[int]]: """Compute exact ground truth for queries at a given filter threshold using Vespa.""" filter_key = round(filter_threshold, 5) cached = [] to_compute = [] for idx in range(num_queries): key = (filter_key, idx) if key in _exact_gt_cache: cached.append((idx, _exact_gt_cache[key])) else: to_compute.append(idx) if not to_compute: return [r for _, r in sorted(cached)] log.info( "Computing exact ground truth for %d/%d queries (filter=%.5f, %d cached)", len(to_compute), num_queries, filter_threshold, len(cached), ) filter_clause = "" if filter_threshold < 0 else f" and random >= {filter_threshold}" sem = asyncio.Semaphore(concurrency) async def run_exact(idx: int) -> tuple[int, list[int]]: async with sem: vec = query_vectors[idx] yql = ( f"select id from {cfg.schema_name} where " f"{{targetHits:{cfg.target_hits},approximate:false}}" f"nearestNeighbor(embedding,q){filter_clause}" ) params = { "yql": yql, "ranking": "closeness", "input.query(q)": str(vec.tolist()), "timeout": "30s", } loop = asyncio.get_event_loop() response = await loop.run_in_executor(None, lambda: app.query(body=params)) if not response.is_successful(): return idx, [] return idx, [hit["fields"]["id"] for hit in response.hits] computed = await asyncio.gather(*[run_exact(idx) for idx in to_compute]) for idx, ids in computed: result = ids[: cfg.target_hits] _exact_gt_cache[(filter_key, idx)] = result cached.append((idx, result)) return [r for _, r in sorted(cached)] async def run_single_query( app: Vespa, cfg: Config, idx: int, query_vec: np.ndarray, gt_neighbors: list[int], explore_additional_hits: int, approximate_threshold: float, filter_threshold: float, post_filter_threshold: float, use_exact: bool, filter_first_threshold: float, filter_first_exploration: float, target_hits_max_adjustment_factor: float, exploration_slack: float, max_latency_ms: int, ) -> dict | None: """Execute a single query and return latency/recall.""" filter_clause = "" if filter_threshold < 0 else f" and random >= {filter_threshold}" if use_exact: yql = ( f"select id from {cfg.schema_name} where " f"{{targetHits:{cfg.target_hits},approximate:false}}" f"nearestNeighbor(embedding,q){filter_clause}" ) else: yql = ( f"select id from {cfg.schema_name} where " f"{{targetHits:{cfg.target_hits}," f"hnsw.exploreAdditionalHits:{explore_additional_hits}}}" f"nearestNeighbor(embedding,q){filter_clause}" ) query_params = { "yql": yql, "ranking": "closeness", "input.query(q)": str(query_vec.tolist()), "ranking.matching.approximateThreshold": approximate_threshold, "ranking.matching.postFilterThreshold": post_filter_threshold, "ranking.matching.filterFirstThreshold": filter_first_threshold, "ranking.matching.filterFirstExploration": filter_first_exploration, "ranking.matching.targetHitsMaxAdjustmentFactor": target_hits_max_adjustment_factor, "ranking.matching.explorationSlack": exploration_slack, "trace.level": 5, "presentation.timing": "true", "timeout": f"{max_latency_ms}ms", } loop = asyncio.get_event_loop() start = time.time() try: response = await loop.run_in_executor(None, lambda: app.query(body=query_params)) latency_ms = (time.time() - start) * 1000 # Check for timeout / degraded coverage if hasattr(response, "json") and "root" in response.json: root = response.json["root"] for err in root.get("errors", []): if err.get("code") == 12 or "Timed out" in err.get("summary", ""): return {"timeout": True} coverage = root.get("coverage", {}) if coverage.get("degraded", {}).get("timeout", False): return {"timeout": True} if not response.is_successful(): log.warning("Query %d failed: %s", idx, getattr(response, "json", "unknown")) return None retrieved_ids = [hit["fields"]["id"] for hit in response.hits] # Detect exact vs approximate from trace was_exact = False if hasattr(response, "json") and "trace" in response.json: trace_str = str(response.json["trace"]) if "approximate=false" in trace_str or "approximate = false" in trace_str: was_exact = True elif "Skip calculate global filter" in trace_str and "estimated_hit_ratio" in trace_str: # Vespa auto-switched to exact because estimated hit ratio fell # below the approximateThreshold (global_filter.lower_limit) was_exact = True # Internal Vespa timing internal_latency_ms = None if hasattr(response, "json") and "timing" in response.json: qt = response.json["timing"].get("querytime") if qt is not None: internal_latency_ms = qt * 1000 recall = recall_at_k(retrieved_ids, gt_neighbors, k=cfg.target_hits) result = { "latency_ms": latency_ms, "internal_latency_ms": internal_latency_ms, "recall": recall, "exact": was_exact, } if idx == 0: result["query_params"] = query_params return result except Exception as e: error_str = str(e).lower() if any(kw in error_str for kw in ("timed out", "timeout", "code': 12")): return {"timeout": True} log.warning("Query %d exception: %s", idx, e) return None async def run_queries( app: Vespa, cfg: Config, query_vectors: np.ndarray, ground_truth: list[list[int]], req: TuningRequest, ) -> dict: """Run a batch of queries and return aggregated results.""" num_available = min(len(query_vectors), len(ground_truth)) num_queries = min(req.num_queries, num_available) sem = asyncio.Semaphore(req.concurrency) async def run_with_sem(idx: int): async with sem: return await run_single_query( app, cfg, idx, query_vectors[idx], ground_truth[idx], req.explore_additional_hits, req.approximate_threshold, req.filter_threshold, req.post_filter_threshold, req.use_exact, req.filter_first_threshold, req.filter_first_exploration, req.target_hits_max_adjustment_factor, req.exploration_slack, req.max_latency_ms, ) start = time.time() results = await asyncio.gather(*[run_with_sem(i) for i in range(num_queries)]) total_time_ms = (time.time() - start) * 1000 successful = [] timeout_count = 0 failed_count = 0 for r in results: if r is None: failed_count += 1 elif r.get("timeout"): timeout_count += 1 else: successful.append(r) log.info( "Query results: %d successful, %d timeouts, %d failed / %d total", len(successful), timeout_count, failed_count, num_queries, ) if not successful: return { "error": "All queries failed", "timeout_count": timeout_count, "failed_count": failed_count, "total_requested": num_queries, } latencies = [r["latency_ms"] for r in successful] recalls = [r["recall"] for r in successful] example_query_params = None if successful and "query_params" in successful[0]: example_query_params = successful[0].pop("query_params") return { "points": successful, "stats": { "min_latency_ms": float(np.min(latencies)), "mean_latency_ms": float(np.mean(latencies)), "max_latency_ms": float(np.max(latencies)), "p50_latency_ms": float(np.percentile(latencies, 50)), "p95_latency_ms": float(np.percentile(latencies, 95)), "p99_latency_ms": float(np.percentile(latencies, 99)), "mean_recall": float(np.mean(recalls)), "num_queries": len(successful), "total_requested": num_queries, "total_time_ms": total_time_ms, "concurrency": req.concurrency, "timeout_count": timeout_count, "failed_count": failed_count, }, "example_query_params": example_query_params, }