vecadvisor 0.1.0a1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- vecadvisor/__init__.py +5 -0
- vecadvisor/bench/__init__.py +1 -0
- vecadvisor/bench/calibrate.py +292 -0
- vecadvisor/bench/crossover.py +440 -0
- vecadvisor/bench/datasets.py +246 -0
- vecadvisor/bench/db_runner.py +409 -0
- vecadvisor/bench/groundtruth.py +220 -0
- vecadvisor/bench/plots.py +1034 -0
- vecadvisor/bench/proof.py +248 -0
- vecadvisor/bench/runner.py +456 -0
- vecadvisor/bench/sweep.py +724 -0
- vecadvisor/bench/validate.py +413 -0
- vecadvisor/calibration.py +196 -0
- vecadvisor/cli.py +2606 -0
- vecadvisor/costmodel.py +153 -0
- vecadvisor/diagnostics.py +406 -0
- vecadvisor/introspect.py +389 -0
- vecadvisor/local_cache.py +178 -0
- vecadvisor/local_probe.py +598 -0
- vecadvisor/models.py +246 -0
- vecadvisor/pgversion.py +102 -0
- vecadvisor/plan.py +240 -0
- vecadvisor/planner_observer.py +125 -0
- vecadvisor/py.typed +1 -0
- vecadvisor/query_spec.py +231 -0
- vecadvisor/recommend.py +818 -0
- vecadvisor/selectivity.py +205 -0
- vecadvisor/statistics_advisor.py +113 -0
- vecadvisor/stats_health.py +130 -0
- vecadvisor-0.1.0a1.dist-info/METADATA +460 -0
- vecadvisor-0.1.0a1.dist-info/RECORD +34 -0
- vecadvisor-0.1.0a1.dist-info/WHEEL +4 -0
- vecadvisor-0.1.0a1.dist-info/entry_points.txt +2 -0
- vecadvisor-0.1.0a1.dist-info/licenses/LICENSE +176 -0
vecadvisor/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Benchmark and ground-truth helpers for VecAdvisor."""
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
from psycopg import Connection
|
|
7
|
+
|
|
8
|
+
from ..models import CalibrationProfile
|
|
9
|
+
from .datasets import generate_synthetic_dataset, generate_synthetic_queries
|
|
10
|
+
from .db_runner import run_postgres_synthetic_benchmark
|
|
11
|
+
from .runner import (
|
|
12
|
+
STRATEGY_EXACT,
|
|
13
|
+
STRATEGY_POSTFILTER,
|
|
14
|
+
BenchmarkReport,
|
|
15
|
+
StrategyMetrics,
|
|
16
|
+
run_synthetic_benchmark,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
DEFAULT_EF_SWEEP = (10, 20, 40, 80, 160)
|
|
20
|
+
MIN_RECALL = 1e-6
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class CalibrationFit:
|
|
25
|
+
profile: CalibrationProfile
|
|
26
|
+
reports: tuple[BenchmarkReport, ...]
|
|
27
|
+
notes: tuple[str, ...]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def parse_ef_sweep(value: str | None) -> tuple[int, ...]:
|
|
31
|
+
if value is None or not value.strip():
|
|
32
|
+
return DEFAULT_EF_SWEEP
|
|
33
|
+
points: list[int] = []
|
|
34
|
+
for item in value.split(","):
|
|
35
|
+
stripped = item.strip()
|
|
36
|
+
if not stripped:
|
|
37
|
+
continue
|
|
38
|
+
try:
|
|
39
|
+
point = int(stripped)
|
|
40
|
+
except ValueError as exc:
|
|
41
|
+
raise ValueError("ef sweep values must be positive integers") from exc
|
|
42
|
+
if point <= 0:
|
|
43
|
+
raise ValueError("ef sweep values must be positive integers")
|
|
44
|
+
points.append(point)
|
|
45
|
+
if not points:
|
|
46
|
+
raise ValueError("ef sweep must contain at least one point")
|
|
47
|
+
return tuple(sorted(set(points)))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def run_synthetic_calibration(
|
|
51
|
+
*,
|
|
52
|
+
rows: int = 5_000,
|
|
53
|
+
dim: int = 64,
|
|
54
|
+
queries: int = 50,
|
|
55
|
+
clusters: int = 16,
|
|
56
|
+
filter_selectivity: float = 0.1,
|
|
57
|
+
correlation: float = 0.0,
|
|
58
|
+
limit: int = 10,
|
|
59
|
+
metric: str = "l2",
|
|
60
|
+
block_rows: int | None = None,
|
|
61
|
+
seed: int = 0,
|
|
62
|
+
ef_sweep: tuple[int, ...] = DEFAULT_EF_SWEEP,
|
|
63
|
+
dataset_id: str = "synthetic-simulated",
|
|
64
|
+
hardware_id: str = "local-synthetic-cpu",
|
|
65
|
+
index_method: str = "hnsw",
|
|
66
|
+
) -> CalibrationFit:
|
|
67
|
+
"""Calibrate a profile from the synthetic benchmark semantics runner."""
|
|
68
|
+
|
|
69
|
+
if not ef_sweep:
|
|
70
|
+
raise ValueError("ef_sweep must not be empty")
|
|
71
|
+
dataset = generate_synthetic_dataset(
|
|
72
|
+
n_rows=rows,
|
|
73
|
+
dim=dim,
|
|
74
|
+
n_clusters=clusters,
|
|
75
|
+
filter_selectivity=filter_selectivity,
|
|
76
|
+
correlation=correlation,
|
|
77
|
+
seed=seed,
|
|
78
|
+
)
|
|
79
|
+
query_set = generate_synthetic_queries(
|
|
80
|
+
dataset,
|
|
81
|
+
n_queries=queries,
|
|
82
|
+
seed=seed + 1,
|
|
83
|
+
cluster_policy="uniform",
|
|
84
|
+
)
|
|
85
|
+
reports = tuple(
|
|
86
|
+
run_synthetic_benchmark(
|
|
87
|
+
dataset=dataset,
|
|
88
|
+
queries=query_set,
|
|
89
|
+
k=limit,
|
|
90
|
+
metric=metric,
|
|
91
|
+
strategies=(STRATEGY_EXACT, STRATEGY_POSTFILTER),
|
|
92
|
+
ef_search=ef,
|
|
93
|
+
block_rows=block_rows,
|
|
94
|
+
)
|
|
95
|
+
for ef in ef_sweep
|
|
96
|
+
)
|
|
97
|
+
profile = fit_profile_from_reports(
|
|
98
|
+
reports,
|
|
99
|
+
dataset_id=dataset_id,
|
|
100
|
+
hardware_id=hardware_id,
|
|
101
|
+
index_method=index_method,
|
|
102
|
+
)
|
|
103
|
+
return CalibrationFit(
|
|
104
|
+
profile=profile,
|
|
105
|
+
reports=reports,
|
|
106
|
+
notes=(
|
|
107
|
+
"profile fitted from synthetic strategy-semantics benchmark measurements",
|
|
108
|
+
"c_h reflects exact-candidate simulation, not real pgvector HNSW latency",
|
|
109
|
+
"run DB-backed calibration before using this profile for production planning",
|
|
110
|
+
),
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def run_postgres_calibration(
|
|
115
|
+
conn: Connection[object],
|
|
116
|
+
*,
|
|
117
|
+
rows: int = 1_000,
|
|
118
|
+
dim: int = 32,
|
|
119
|
+
queries: int = 10,
|
|
120
|
+
clusters: int = 8,
|
|
121
|
+
filter_selectivity: float = 0.1,
|
|
122
|
+
correlation: float = 0.0,
|
|
123
|
+
limit: int = 10,
|
|
124
|
+
metric: str = "l2",
|
|
125
|
+
block_rows: int | None = None,
|
|
126
|
+
seed: int = 0,
|
|
127
|
+
ef_sweep: tuple[int, ...] = DEFAULT_EF_SWEEP,
|
|
128
|
+
hnsw_m: int = 8,
|
|
129
|
+
hnsw_ef_construction: int = 32,
|
|
130
|
+
statement_timeout_ms: int = 30_000,
|
|
131
|
+
dataset_id: str = "postgres-synthetic",
|
|
132
|
+
hardware_id: str = "local-postgres-pgvector",
|
|
133
|
+
index_method: str = "hnsw",
|
|
134
|
+
) -> CalibrationFit:
|
|
135
|
+
"""Calibrate a profile from actual PostgreSQL/pgvector benchmark measurements."""
|
|
136
|
+
|
|
137
|
+
if not ef_sweep:
|
|
138
|
+
raise ValueError("ef_sweep must not be empty")
|
|
139
|
+
dataset = generate_synthetic_dataset(
|
|
140
|
+
n_rows=rows,
|
|
141
|
+
dim=dim,
|
|
142
|
+
n_clusters=clusters,
|
|
143
|
+
filter_selectivity=filter_selectivity,
|
|
144
|
+
correlation=correlation,
|
|
145
|
+
seed=seed,
|
|
146
|
+
)
|
|
147
|
+
query_set = generate_synthetic_queries(
|
|
148
|
+
dataset,
|
|
149
|
+
n_queries=queries,
|
|
150
|
+
seed=seed + 1,
|
|
151
|
+
cluster_policy="uniform",
|
|
152
|
+
)
|
|
153
|
+
reports = tuple(
|
|
154
|
+
run_postgres_synthetic_benchmark(
|
|
155
|
+
conn,
|
|
156
|
+
dataset=dataset,
|
|
157
|
+
queries=query_set,
|
|
158
|
+
k=limit,
|
|
159
|
+
metric=metric,
|
|
160
|
+
strategies=(STRATEGY_EXACT, STRATEGY_POSTFILTER),
|
|
161
|
+
ef_search=ef,
|
|
162
|
+
hnsw_m=hnsw_m,
|
|
163
|
+
hnsw_ef_construction=hnsw_ef_construction,
|
|
164
|
+
block_rows=block_rows,
|
|
165
|
+
statement_timeout_ms=statement_timeout_ms,
|
|
166
|
+
)
|
|
167
|
+
for ef in ef_sweep
|
|
168
|
+
)
|
|
169
|
+
profile = fit_profile_from_reports(
|
|
170
|
+
reports,
|
|
171
|
+
dataset_id=dataset_id,
|
|
172
|
+
hardware_id=hardware_id,
|
|
173
|
+
index_method=index_method,
|
|
174
|
+
)
|
|
175
|
+
return CalibrationFit(
|
|
176
|
+
profile=profile,
|
|
177
|
+
reports=reports,
|
|
178
|
+
notes=(
|
|
179
|
+
"profile fitted from actual PostgreSQL/pgvector benchmark measurements",
|
|
180
|
+
"postfilter latency comes from real HNSW SQL with scalar post-filter",
|
|
181
|
+
"ground truth for recall still comes from bounded exact in-process computation",
|
|
182
|
+
),
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def fit_profile_from_reports(
|
|
187
|
+
reports: tuple[BenchmarkReport, ...],
|
|
188
|
+
*,
|
|
189
|
+
dataset_id: str,
|
|
190
|
+
hardware_id: str,
|
|
191
|
+
index_method: str = "hnsw",
|
|
192
|
+
) -> CalibrationProfile:
|
|
193
|
+
if not reports:
|
|
194
|
+
raise ValueError("at least one benchmark report is required")
|
|
195
|
+
first = reports[0]
|
|
196
|
+
exact = _strategy(first, STRATEGY_EXACT)
|
|
197
|
+
rows = _positive_float(first.dataset["rows"], "rows")
|
|
198
|
+
selectivity = max(
|
|
199
|
+
_positive_float(first.dataset["observed_filter_selectivity"], "selectivity"),
|
|
200
|
+
1 / rows,
|
|
201
|
+
)
|
|
202
|
+
k = _positive_int(first.ground_truth["k"], "k")
|
|
203
|
+
qualifying_rows = max(1.0, rows * selectivity)
|
|
204
|
+
exact_latency_us = max(exact.latency_ms_mean * 1000.0, MIN_RECALL)
|
|
205
|
+
exact_denominator = qualifying_rows * (2.0 + math.log2(max(k, 2)))
|
|
206
|
+
c_scan = max(MIN_RECALL, exact_latency_us / max(exact_denominator, MIN_RECALL))
|
|
207
|
+
c_d = c_scan
|
|
208
|
+
c_h_values: list[float] = []
|
|
209
|
+
recall_points: list[tuple[int, float]] = []
|
|
210
|
+
running_recall = 0.0
|
|
211
|
+
for report in reports:
|
|
212
|
+
postfilter = _strategy(report, STRATEGY_POSTFILTER)
|
|
213
|
+
ef = _positive_int(postfilter.params["ef_search"], "ef_search")
|
|
214
|
+
latency_us = max(postfilter.latency_ms_mean * 1000.0, MIN_RECALL)
|
|
215
|
+
denominator = c_d * ef * math.log(max(rows, 2.0))
|
|
216
|
+
c_h_values.append(max(MIN_RECALL, latency_us / max(denominator, MIN_RECALL)))
|
|
217
|
+
running_recall = max(running_recall, postfilter.recall_at_k)
|
|
218
|
+
recall_points.append((ef, min(1.0, max(MIN_RECALL, running_recall))))
|
|
219
|
+
|
|
220
|
+
return CalibrationProfile(
|
|
221
|
+
dataset_id=dataset_id,
|
|
222
|
+
hardware_id=hardware_id,
|
|
223
|
+
index_method=index_method,
|
|
224
|
+
c_d=c_d,
|
|
225
|
+
c_scan=c_scan,
|
|
226
|
+
c_h=_median(tuple(c_h_values)),
|
|
227
|
+
delta_strict=0.0,
|
|
228
|
+
recall_curve=tuple(sorted(recall_points)),
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def calibration_fit_to_json(fit: CalibrationFit) -> dict[str, object]:
|
|
233
|
+
from ..calibration import calibration_profile_to_json
|
|
234
|
+
|
|
235
|
+
return {
|
|
236
|
+
"profile": calibration_profile_to_json(fit.profile),
|
|
237
|
+
"ef_sweep": [ef for ef, _ in fit.profile.recall_curve],
|
|
238
|
+
"fit_reports": [
|
|
239
|
+
{
|
|
240
|
+
"ef_search": _strategy(report, STRATEGY_POSTFILTER).params["ef_search"],
|
|
241
|
+
"postfilter_recall_at_k": _strategy(report, STRATEGY_POSTFILTER).recall_at_k,
|
|
242
|
+
"postfilter_latency_ms_mean": _strategy(
|
|
243
|
+
report,
|
|
244
|
+
STRATEGY_POSTFILTER,
|
|
245
|
+
).latency_ms_mean,
|
|
246
|
+
"exact_latency_ms_mean": _strategy(report, STRATEGY_EXACT).latency_ms_mean,
|
|
247
|
+
}
|
|
248
|
+
for report in fit.reports
|
|
249
|
+
],
|
|
250
|
+
"notes": list(fit.notes),
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _strategy(report: BenchmarkReport, strategy: str) -> StrategyMetrics:
|
|
255
|
+
for metrics in report.strategies:
|
|
256
|
+
if metrics.strategy == strategy:
|
|
257
|
+
return metrics
|
|
258
|
+
raise ValueError(f"benchmark report does not contain strategy: {strategy}")
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _positive_float(value: object, name: str) -> float:
|
|
262
|
+
if isinstance(value, bool) or not isinstance(value, (int, float, str)):
|
|
263
|
+
raise ValueError(f"{name} must be numeric")
|
|
264
|
+
try:
|
|
265
|
+
number = float(value)
|
|
266
|
+
except ValueError as exc:
|
|
267
|
+
raise ValueError(f"{name} must be numeric") from exc
|
|
268
|
+
if not math.isfinite(number) or number <= 0.0:
|
|
269
|
+
raise ValueError(f"{name} must be positive")
|
|
270
|
+
return number
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _positive_int(value: object, name: str) -> int:
|
|
274
|
+
if isinstance(value, bool) or not isinstance(value, (int, str)):
|
|
275
|
+
raise ValueError(f"{name} must be a positive integer")
|
|
276
|
+
try:
|
|
277
|
+
number = int(value)
|
|
278
|
+
except ValueError as exc:
|
|
279
|
+
raise ValueError(f"{name} must be a positive integer") from exc
|
|
280
|
+
if number <= 0:
|
|
281
|
+
raise ValueError(f"{name} must be a positive integer")
|
|
282
|
+
return number
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _median(values: tuple[float, ...]) -> float:
|
|
286
|
+
if not values:
|
|
287
|
+
raise ValueError("cannot compute median of empty values")
|
|
288
|
+
ordered = sorted(values)
|
|
289
|
+
middle = len(ordered) // 2
|
|
290
|
+
if len(ordered) % 2:
|
|
291
|
+
return ordered[middle]
|
|
292
|
+
return (ordered[middle - 1] + ordered[middle]) / 2.0
|