vecshift 0.1.0__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.
- vecshift/__init__.py +15 -0
- vecshift/assets/bench.css +39 -0
- vecshift/assets/eval.css +91 -0
- vecshift/assets/report.css +252 -0
- vecshift/assets/report.js +50 -0
- vecshift/bench/__init__.py +18 -0
- vecshift/bench/corpus.py +175 -0
- vecshift/bench/generate.py +106 -0
- vecshift/bench/html.py +325 -0
- vecshift/bench/metrics.py +50 -0
- vecshift/bench/runner.py +183 -0
- vecshift/cli.py +236 -0
- vecshift/cli_apply.py +283 -0
- vecshift/cli_bench.py +354 -0
- vecshift/cli_cutover.py +431 -0
- vecshift/cli_eval.py +591 -0
- vecshift/cli_plan.py +335 -0
- vecshift/cli_style.py +57 -0
- vecshift/connectors/__init__.py +1 -0
- vecshift/connectors/pgvector/__init__.py +29 -0
- vecshift/connectors/pgvector/connection.py +155 -0
- vecshift/connectors/pgvector/documents.py +96 -0
- vecshift/connectors/pgvector/inspect.py +427 -0
- vecshift/connectors/pgvector/search.py +240 -0
- vecshift/connectors/pgvector/switch.py +481 -0
- vecshift/connectors/pgvector/target.py +195 -0
- vecshift/connectors/pgvector/writer.py +431 -0
- vecshift/core/__init__.py +4 -0
- vecshift/core/capabilities.py +33 -0
- vecshift/core/contracts.py +57 -0
- vecshift/core/fingerprint.py +57 -0
- vecshift/core/record.py +75 -0
- vecshift/doctor/__init__.py +15 -0
- vecshift/doctor/checks.py +490 -0
- vecshift/doctor/findings.py +78 -0
- vecshift/doctor/html.py +493 -0
- vecshift/doctor/profile.py +69 -0
- vecshift/embeddings/__init__.py +22 -0
- vecshift/embeddings/cache.py +86 -0
- vecshift/embeddings/providers.py +244 -0
- vecshift/embeddings/spec.py +240 -0
- vecshift/eval/__init__.py +20 -0
- vecshift/eval/html.py +444 -0
- vecshift/eval/metrics.py +81 -0
- vecshift/eval/queries.py +97 -0
- vecshift/eval/runner.py +394 -0
- vecshift/html_kit.py +143 -0
- vecshift/jobs/__init__.py +5 -0
- vecshift/jobs/spec.py +202 -0
- vecshift/migrate/__init__.py +6 -0
- vecshift/migrate/engine.py +272 -0
- vecshift/migrate/state.py +50 -0
- vecshift/planning/__init__.py +14 -0
- vecshift/planning/plan.py +87 -0
- vecshift/planning/planner.py +493 -0
- vecshift/py.typed +0 -0
- vecshift-0.1.0.dist-info/METADATA +264 -0
- vecshift-0.1.0.dist-info/RECORD +62 -0
- vecshift-0.1.0.dist-info/WHEEL +4 -0
- vecshift-0.1.0.dist-info/entry_points.txt +2 -0
- vecshift-0.1.0.dist-info/licenses/LICENSE +202 -0
- vecshift-0.1.0.dist-info/licenses/NOTICE +4 -0
vecshift/cli_plan.py
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
"""The ``vecshift init`` and ``vecshift plan`` commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import shutil
|
|
9
|
+
import sys
|
|
10
|
+
import textwrap
|
|
11
|
+
import time
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import TYPE_CHECKING, Annotated
|
|
15
|
+
|
|
16
|
+
import typer
|
|
17
|
+
|
|
18
|
+
from vecshift.cli_style import finding_lines
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from vecshift.connectors.pgvector import ConnectionSettings
|
|
22
|
+
from vecshift.connectors.pgvector.target import TargetState
|
|
23
|
+
from vecshift.doctor import IndexProfile
|
|
24
|
+
from vecshift.embeddings import ModelSpec
|
|
25
|
+
from vecshift.jobs import JobSpec
|
|
26
|
+
from vecshift.planning import Plan, ProbeResult
|
|
27
|
+
|
|
28
|
+
DEFAULT_JOB = Path("vecshift.yaml")
|
|
29
|
+
PROBE_DOCUMENTS = 16
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _fail(message: str, hint: str | None = None) -> typer.Exit:
|
|
33
|
+
typer.secho(message, err=True, fg=typer.colors.RED)
|
|
34
|
+
if hint:
|
|
35
|
+
typer.echo(f"→ {hint}", err=True)
|
|
36
|
+
return typer.Exit(2)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def init(
|
|
40
|
+
table: Annotated[
|
|
41
|
+
str, typer.Option(help="Table holding the vectors, as table or schema.table.")
|
|
42
|
+
],
|
|
43
|
+
model: Annotated[
|
|
44
|
+
str, typer.Option(help="The new model, e.g. openai/text-embedding-3-large,dims=1024.")
|
|
45
|
+
],
|
|
46
|
+
column: Annotated[str, typer.Option(help="Column for the new vectors.")] = "embedding_v2",
|
|
47
|
+
output: Annotated[Path, typer.Option("--output", "-o", help="Where to write the job.")] = (
|
|
48
|
+
DEFAULT_JOB
|
|
49
|
+
),
|
|
50
|
+
force: Annotated[bool, typer.Option(help="Overwrite an existing file.")] = False,
|
|
51
|
+
) -> None:
|
|
52
|
+
"""Write a job file to start a migration from."""
|
|
53
|
+
import yaml
|
|
54
|
+
from pydantic import ValidationError
|
|
55
|
+
|
|
56
|
+
from vecshift.jobs.spec import JobSpec, format_errors, template
|
|
57
|
+
|
|
58
|
+
text = template(table, model, column)
|
|
59
|
+
try:
|
|
60
|
+
JobSpec.model_validate(yaml.safe_load(text))
|
|
61
|
+
except ValidationError as exc:
|
|
62
|
+
raise _fail(f"Those settings aren't valid:\n{format_errors(exc)}") from exc
|
|
63
|
+
if output.exists() and not force:
|
|
64
|
+
raise _fail(f"{output} already exists.", "Pass --force to overwrite it.")
|
|
65
|
+
try:
|
|
66
|
+
output.write_text(text, encoding="utf-8")
|
|
67
|
+
except OSError as exc:
|
|
68
|
+
raise _fail(f"Couldn't write {output}: {exc.strerror}") from exc
|
|
69
|
+
typer.echo(f"Wrote {output}. Review it, then run: vecshift plan {output}")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _size(n: int | None) -> str:
|
|
73
|
+
if n is None:
|
|
74
|
+
return "—"
|
|
75
|
+
for unit, size in (("TB", 1024**4), ("GB", 1024**3), ("MB", 1024**2), ("KB", 1024)):
|
|
76
|
+
if n >= size:
|
|
77
|
+
return f"{n / size:,.1f} {unit}"
|
|
78
|
+
return f"{n} B"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _duration(seconds: float | None) -> str:
|
|
82
|
+
if seconds is None:
|
|
83
|
+
return "unknown"
|
|
84
|
+
if seconds < 90:
|
|
85
|
+
return f"~{max(1, round(seconds))} s"
|
|
86
|
+
if seconds < 90 * 60:
|
|
87
|
+
return f"~{round(seconds / 60)} min"
|
|
88
|
+
if seconds < 48 * 3600:
|
|
89
|
+
return f"~{seconds / 3600:.1f} h"
|
|
90
|
+
return f"~{seconds / 86400:.1f} days"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _compact(n: int | None) -> str:
|
|
94
|
+
if n is None:
|
|
95
|
+
return "—"
|
|
96
|
+
for size, suffix in ((1_000_000_000, "B"), (1_000_000, "M"), (1_000, "K")):
|
|
97
|
+
if n >= size * 10:
|
|
98
|
+
return f"{n / size:,.1f}".rstrip("0").rstrip(".") + suffix
|
|
99
|
+
return f"{n:,}"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _note(text: str) -> str:
|
|
103
|
+
width = min(100, shutil.get_terminal_size((100, 24)).columns)
|
|
104
|
+
return textwrap.fill(text, width=width, initial_indent=" " * 6, subsequent_indent=" " * 6)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _money(value: float | None) -> str:
|
|
108
|
+
if value is None:
|
|
109
|
+
return "unknown"
|
|
110
|
+
if value == 0:
|
|
111
|
+
return "$0.00"
|
|
112
|
+
return "<$0.01" if value < 0.01 else f"~${value:,.2f}"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _render(plan: Plan) -> None:
|
|
116
|
+
e = plan.estimates
|
|
117
|
+
dims = f"{plan.dimensions:,}" if plan.dimensions else "?"
|
|
118
|
+
source_note = {
|
|
119
|
+
"spec": "set in the spec",
|
|
120
|
+
"known": "this model's size",
|
|
121
|
+
"probe": "measured",
|
|
122
|
+
None: "unknown",
|
|
123
|
+
}[plan.dimensions_source]
|
|
124
|
+
typer.secho(f"vecshift plan · {plan.job}", bold=True)
|
|
125
|
+
typer.echo(f"Source {plan.source}")
|
|
126
|
+
typer.echo(f"Model {plan.model} → {dims} dimensions ({source_note})")
|
|
127
|
+
|
|
128
|
+
typer.secho("\nChanges", bold=True)
|
|
129
|
+
marks = {"add_column": "+", "trigger": "+", "embed": "~", "index": "+", "cutover": "⇄"}
|
|
130
|
+
for change in plan.changes:
|
|
131
|
+
typer.echo(f" {marks.get(change.kind, '·')} {change.summary}")
|
|
132
|
+
if change.sql:
|
|
133
|
+
typer.secho(f" {change.sql}", fg=typer.colors.CYAN)
|
|
134
|
+
if change.note:
|
|
135
|
+
typer.secho(_note(change.note), dim=True)
|
|
136
|
+
|
|
137
|
+
typer.secho("\nEstimates", bold=True)
|
|
138
|
+
rows = "—" if e.rows is None else (f"{e.rows:,}" if e.rows_exact else f"~{e.rows:,}")
|
|
139
|
+
lines = [
|
|
140
|
+
("Rows to embed", rows, "exact" if e.rows_exact else "from table statistics"),
|
|
141
|
+
("Tokens", "~" + _compact(e.tokens) if e.tokens else "—", e.tokens_method or ""),
|
|
142
|
+
(
|
|
143
|
+
"Cost",
|
|
144
|
+
_money(e.cost_usd),
|
|
145
|
+
"",
|
|
146
|
+
),
|
|
147
|
+
("Requests", f"{e.requests:,}" if e.requests is not None else "—", ""),
|
|
148
|
+
("Duration", _duration(e.seconds), e.seconds_method or "add a rate limit or --probe"),
|
|
149
|
+
(
|
|
150
|
+
"New column",
|
|
151
|
+
_size(e.new_bytes),
|
|
152
|
+
f"old column {_size(e.old_bytes)}" if e.old_bytes else "",
|
|
153
|
+
),
|
|
154
|
+
]
|
|
155
|
+
if e.index_memory_bytes:
|
|
156
|
+
lines.append(
|
|
157
|
+
(
|
|
158
|
+
"Index build",
|
|
159
|
+
f"~{_size(e.index_memory_bytes)}",
|
|
160
|
+
f"maintenance_work_mem is {_size(e.maintenance_work_mem)}",
|
|
161
|
+
)
|
|
162
|
+
)
|
|
163
|
+
width = max(len(k) for k, _, _ in lines)
|
|
164
|
+
for key, value, note in lines:
|
|
165
|
+
typer.echo(f" {key:<{width}} {value:<10} " + typer.style(note, dim=True))
|
|
166
|
+
|
|
167
|
+
if plan.findings:
|
|
168
|
+
typer.secho("\nChecks", bold=True)
|
|
169
|
+
finding_lines(plan.findings)
|
|
170
|
+
typer.echo()
|
|
171
|
+
if plan.ok:
|
|
172
|
+
typer.secho("Ready to apply.", fg=typer.colors.GREEN, bold=True)
|
|
173
|
+
else:
|
|
174
|
+
noun = "error" if plan.errors == 1 else "errors"
|
|
175
|
+
typer.secho(f"Fix {plan.errors} {noun} before applying.", fg=typer.colors.RED, bold=True)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
async def _probe(spec: ModelSpec, texts: list[str]) -> ProbeResult:
|
|
179
|
+
from vecshift.embeddings import create_embedder
|
|
180
|
+
from vecshift.planning import ProbeResult
|
|
181
|
+
|
|
182
|
+
embedder = create_embedder(spec)
|
|
183
|
+
try:
|
|
184
|
+
start = time.perf_counter()
|
|
185
|
+
vectors = await embedder.embed(texts, "document")
|
|
186
|
+
elapsed = time.perf_counter() - start
|
|
187
|
+
finally:
|
|
188
|
+
await embedder.aclose()
|
|
189
|
+
chars = sum(len(spec.doc_prefix) + len(t) for t in texts)
|
|
190
|
+
return ProbeResult(
|
|
191
|
+
documents=len(texts),
|
|
192
|
+
dimensions=len(vectors[0]),
|
|
193
|
+
tokens_per_char=embedder.tokens / chars if chars else 0.25,
|
|
194
|
+
docs_per_second=len(texts) / elapsed if elapsed > 0 else 0.0,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
@dataclass(slots=True)
|
|
199
|
+
class Prepared:
|
|
200
|
+
"""Everything known about a job before anything changes."""
|
|
201
|
+
|
|
202
|
+
job: JobSpec
|
|
203
|
+
settings: ConnectionSettings
|
|
204
|
+
spec: ModelSpec
|
|
205
|
+
profile: IndexProfile
|
|
206
|
+
target: TargetState
|
|
207
|
+
plan: Plan
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def load(
|
|
211
|
+
job_file: Path, dsn_env: str | None = None
|
|
212
|
+
) -> tuple[JobSpec, ConnectionSettings, ModelSpec]:
|
|
213
|
+
"""The job, its connection settings (from the environment), and its model.
|
|
214
|
+
|
|
215
|
+
``dsn_env`` reads the connection string from another variable, such as a replica's.
|
|
216
|
+
"""
|
|
217
|
+
from vecshift.connectors import pgvector
|
|
218
|
+
from vecshift.embeddings import parse_spec
|
|
219
|
+
from vecshift.jobs import JobError, load_job
|
|
220
|
+
|
|
221
|
+
try:
|
|
222
|
+
job = load_job(job_file)
|
|
223
|
+
except JobError as exc:
|
|
224
|
+
hint = (
|
|
225
|
+
"Create one with: vecshift init --table ... --model ..."
|
|
226
|
+
if not job_file.exists()
|
|
227
|
+
else None
|
|
228
|
+
)
|
|
229
|
+
raise _fail(str(exc), hint) from exc
|
|
230
|
+
variable = dsn_env or job.source.dsn_env
|
|
231
|
+
dsn = os.environ.get(variable)
|
|
232
|
+
if not dsn:
|
|
233
|
+
raise _fail(
|
|
234
|
+
f"{variable} isn't set.",
|
|
235
|
+
f"Export the database connection string as {variable}.",
|
|
236
|
+
)
|
|
237
|
+
try:
|
|
238
|
+
settings = pgvector.prepare(dsn)
|
|
239
|
+
except pgvector.ConnectError as exc:
|
|
240
|
+
raise _fail(f"Couldn't connect: {exc}", exc.hint) from exc
|
|
241
|
+
return job, settings, parse_spec(job.model)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def prepare(
|
|
245
|
+
job_file: Path, *, sample_size: int = 2000, probe: bool = False, yes: bool = False
|
|
246
|
+
) -> Prepared:
|
|
247
|
+
"""Load the job, inspect the database read-only, and build the plan."""
|
|
248
|
+
from vecshift.connectors import pgvector
|
|
249
|
+
from vecshift.connectors.pgvector.documents import sample_documents
|
|
250
|
+
from vecshift.connectors.pgvector.target import inspect_target, resolve_source_column
|
|
251
|
+
from vecshift.embeddings import EmbeddingError
|
|
252
|
+
from vecshift.planning import SampleStats, build_plan
|
|
253
|
+
|
|
254
|
+
job, settings, spec = load(job_file)
|
|
255
|
+
try:
|
|
256
|
+
conn = pgvector.connect(settings)
|
|
257
|
+
except pgvector.ConnectError as exc:
|
|
258
|
+
raise _fail(f"Couldn't connect: {exc}", exc.hint) from exc
|
|
259
|
+
try:
|
|
260
|
+
source_column = resolve_source_column(
|
|
261
|
+
conn, job.source.table, job.source.vector_column, job.target.column
|
|
262
|
+
)
|
|
263
|
+
profile = pgvector.inspect(
|
|
264
|
+
conn,
|
|
265
|
+
table=job.source.table,
|
|
266
|
+
column=source_column,
|
|
267
|
+
text_column=job.source.text_column,
|
|
268
|
+
sample_size=sample_size,
|
|
269
|
+
)
|
|
270
|
+
target = inspect_target(conn, job.source.table, source_column, job.target.column)
|
|
271
|
+
texts: list[str] = []
|
|
272
|
+
if profile.text_field:
|
|
273
|
+
rows, _ = sample_documents(
|
|
274
|
+
conn,
|
|
275
|
+
job.source.table,
|
|
276
|
+
source_column,
|
|
277
|
+
profile.text_field,
|
|
278
|
+
min(sample_size, 500),
|
|
279
|
+
)
|
|
280
|
+
texts = [t for _, t in rows]
|
|
281
|
+
except pgvector.TargetSelectionError as exc:
|
|
282
|
+
raise _fail(str(exc)) from exc
|
|
283
|
+
finally:
|
|
284
|
+
conn.rollback()
|
|
285
|
+
conn.close()
|
|
286
|
+
|
|
287
|
+
stats = SampleStats(
|
|
288
|
+
documents=len(texts), average_chars=sum(map(len, texts)) / len(texts) if texts else 0.0
|
|
289
|
+
)
|
|
290
|
+
measured = None
|
|
291
|
+
if probe and texts:
|
|
292
|
+
chosen = texts[:PROBE_DOCUMENTS]
|
|
293
|
+
if not spec.is_local and not yes:
|
|
294
|
+
typer.echo(
|
|
295
|
+
f"The probe sends {len(chosen)} sample rows to {spec.url} to measure {spec.name}.",
|
|
296
|
+
err=True,
|
|
297
|
+
)
|
|
298
|
+
if not sys.stdin.isatty():
|
|
299
|
+
raise _fail("Not sending data without confirmation.", "Pass --yes to proceed.")
|
|
300
|
+
if not typer.confirm("Continue?", err=True):
|
|
301
|
+
raise typer.Exit(1)
|
|
302
|
+
try:
|
|
303
|
+
measured = asyncio.run(_probe(spec, chosen))
|
|
304
|
+
except EmbeddingError as exc:
|
|
305
|
+
raise _fail(f"The probe failed: {exc}") from exc
|
|
306
|
+
|
|
307
|
+
result = build_plan(job, profile, target, stats, measured)
|
|
308
|
+
return Prepared(job, settings, spec, profile, target, result)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def plan(
|
|
312
|
+
job_file: Annotated[
|
|
313
|
+
Path, typer.Argument(help="The job file.", show_default=True)
|
|
314
|
+
] = DEFAULT_JOB,
|
|
315
|
+
probe: Annotated[
|
|
316
|
+
bool,
|
|
317
|
+
typer.Option(
|
|
318
|
+
help=f"Embed {PROBE_DOCUMENTS} sample rows with the real model to measure its "
|
|
319
|
+
"size, token counts, and speed."
|
|
320
|
+
),
|
|
321
|
+
] = False,
|
|
322
|
+
yes: Annotated[bool, typer.Option("--yes", "-y", help="Don't ask before probing.")] = False,
|
|
323
|
+
sample_size: Annotated[
|
|
324
|
+
int, typer.Option("--sample", min=50, max=100_000, help="Rows to sample.")
|
|
325
|
+
] = 2000,
|
|
326
|
+
output_json: Annotated[bool, typer.Option("--json", help="Print the plan as JSON.")] = False,
|
|
327
|
+
) -> None:
|
|
328
|
+
"""Check a migration job and estimate it, without changing anything."""
|
|
329
|
+
result = prepare(job_file, sample_size=sample_size, probe=probe, yes=yes).plan
|
|
330
|
+
if output_json:
|
|
331
|
+
typer.echo(json.dumps(result.to_dict(), indent=2))
|
|
332
|
+
else:
|
|
333
|
+
_render(result)
|
|
334
|
+
if not result.ok:
|
|
335
|
+
raise typer.Exit(1)
|
vecshift/cli_style.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Terminal styling shared by the CLI commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shutil
|
|
6
|
+
import textwrap
|
|
7
|
+
from collections.abc import Sequence
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from vecshift.doctor import Finding, Severity
|
|
12
|
+
|
|
13
|
+
STYLE = {
|
|
14
|
+
Severity.ERROR: ("✖ ERROR ", typer.colors.RED, "error"),
|
|
15
|
+
Severity.WARNING: ("⚠ WARNING", typer.colors.YELLOW, "warning"),
|
|
16
|
+
Severity.INFO: ("● INFO ", typer.colors.BLUE, "info"),
|
|
17
|
+
Severity.OK: ("✔ OK ", typer.colors.GREEN, "ok"),
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
INDENT = " " * 11
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def wrap(text: str) -> str:
|
|
25
|
+
width = min(100, shutil.get_terminal_size((100, 24)).columns)
|
|
26
|
+
return textwrap.fill(text, width=width, initial_indent=INDENT, subsequent_indent=INDENT)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def finding_lines(findings: Sequence[Finding]) -> None:
|
|
30
|
+
"""Print findings, most severe first, in the doctor style."""
|
|
31
|
+
for finding in sorted(findings, key=lambda f: -f.severity.rank):
|
|
32
|
+
label, color, _ = STYLE[finding.severity]
|
|
33
|
+
typer.secho(f"{label} ", fg=color, bold=True, nl=False)
|
|
34
|
+
typer.secho(finding.title.replace("`", ""), bold=True)
|
|
35
|
+
typer.echo(wrap(finding.detail.replace("`", "")))
|
|
36
|
+
if finding.hint:
|
|
37
|
+
typer.secho(wrap(f"→ {finding.hint.replace('`', '')}"), dim=True)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def warn_if_password_on_command_line(ctx: typer.Context, dsn: str | None) -> None:
|
|
41
|
+
"""Passwords given as arguments are visible to other users through ps and shell history."""
|
|
42
|
+
source = ctx.get_parameter_source("dsn")
|
|
43
|
+
# Compared by name: typer may bundle its own copy of click's ParameterSource enum.
|
|
44
|
+
if dsn and source is not None and source.name == "COMMANDLINE":
|
|
45
|
+
from psycopg.conninfo import conninfo_to_dict
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
has_password = bool(conninfo_to_dict(dsn).get("password"))
|
|
49
|
+
except Exception: # an invalid string is reported later, where it's parsed
|
|
50
|
+
return
|
|
51
|
+
if has_password:
|
|
52
|
+
typer.secho(
|
|
53
|
+
"Warning: the --dsn password is visible to other users of this machine and "
|
|
54
|
+
"saved in shell history. Prefer the VECSHIFT_DSN environment variable.",
|
|
55
|
+
err=True,
|
|
56
|
+
fg=typer.colors.YELLOW,
|
|
57
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Connectors for vector stores."""
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""PostgreSQL with the pgvector extension, including Supabase."""
|
|
2
|
+
|
|
3
|
+
from vecshift.connectors.pgvector.connection import (
|
|
4
|
+
ConnectError,
|
|
5
|
+
ConnectionSettings,
|
|
6
|
+
SupabaseMode,
|
|
7
|
+
connect,
|
|
8
|
+
connect_writer,
|
|
9
|
+
prepare,
|
|
10
|
+
)
|
|
11
|
+
from vecshift.connectors.pgvector.inspect import (
|
|
12
|
+
TargetSelectionError,
|
|
13
|
+
VectorColumn,
|
|
14
|
+
find_vector_columns,
|
|
15
|
+
inspect,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"ConnectError",
|
|
20
|
+
"ConnectionSettings",
|
|
21
|
+
"SupabaseMode",
|
|
22
|
+
"TargetSelectionError",
|
|
23
|
+
"VectorColumn",
|
|
24
|
+
"connect",
|
|
25
|
+
"connect_writer",
|
|
26
|
+
"find_vector_columns",
|
|
27
|
+
"inspect",
|
|
28
|
+
"prepare",
|
|
29
|
+
]
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Connecting to PostgreSQL safely, with Supabase's connection modes in mind.
|
|
2
|
+
|
|
3
|
+
Supabase offers a direct connection and Supavisor poolers. The ones that matter here:
|
|
4
|
+
|
|
5
|
+
- ``db.<ref>.supabase.co:5432``: direct. IPv6 only unless the project has the IPv4 add-on.
|
|
6
|
+
- ``*.pooler.supabase.com:5432``: session pooler. Works over IPv4.
|
|
7
|
+
- ``*.pooler.supabase.com:6543`` and ``db.<ref>.supabase.co:6543``: transaction poolers,
|
|
8
|
+
which don't support prepared statements.
|
|
9
|
+
|
|
10
|
+
VecShift always disables prepared statements and runs inside one read-only transaction,
|
|
11
|
+
so every mode works.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from enum import StrEnum
|
|
18
|
+
|
|
19
|
+
import psycopg
|
|
20
|
+
from psycopg import sql
|
|
21
|
+
from psycopg.conninfo import conninfo_to_dict, make_conninfo
|
|
22
|
+
|
|
23
|
+
APPLICATION_NAME = "vecshift"
|
|
24
|
+
_TRANSACTION_POOLER_PORT = "6543"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ConnectError(Exception):
|
|
28
|
+
"""A connection failed. The message is safe to show: it never includes a password."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, message: str, hint: str | None = None) -> None:
|
|
31
|
+
super().__init__(message)
|
|
32
|
+
self.hint = hint
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class SupabaseMode(StrEnum):
|
|
36
|
+
DIRECT = "direct"
|
|
37
|
+
SESSION_POOLER = "session pooler"
|
|
38
|
+
TRANSACTION_POOLER = "transaction pooler"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True, slots=True)
|
|
42
|
+
class ConnectionSettings:
|
|
43
|
+
"""A validated connection string plus what we learned about where it points."""
|
|
44
|
+
|
|
45
|
+
conninfo: str
|
|
46
|
+
display: str
|
|
47
|
+
"""The connection string with the password removed, safe to print."""
|
|
48
|
+
supabase: SupabaseMode | None
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def description(self) -> str:
|
|
52
|
+
return f"Supabase {self.supabase}" if self.supabase else "PostgreSQL"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _supabase_mode(host: str, port: str) -> SupabaseMode | None:
|
|
56
|
+
host = host.lower().rstrip(".")
|
|
57
|
+
if host.endswith(".pooler.supabase.com"):
|
|
58
|
+
if port == _TRANSACTION_POOLER_PORT:
|
|
59
|
+
return SupabaseMode.TRANSACTION_POOLER
|
|
60
|
+
return SupabaseMode.SESSION_POOLER
|
|
61
|
+
if host.endswith(".supabase.co"):
|
|
62
|
+
if port == _TRANSACTION_POOLER_PORT:
|
|
63
|
+
return SupabaseMode.TRANSACTION_POOLER
|
|
64
|
+
return SupabaseMode.DIRECT
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def prepare(dsn: str) -> ConnectionSettings:
|
|
69
|
+
"""Parse and harden a connection string (a ``postgresql://`` URL or ``key=value`` pairs).
|
|
70
|
+
|
|
71
|
+
For Supabase hosts, TLS is required unless the string already sets ``sslmode``.
|
|
72
|
+
"""
|
|
73
|
+
try:
|
|
74
|
+
params = conninfo_to_dict(dsn)
|
|
75
|
+
except psycopg.ProgrammingError as exc:
|
|
76
|
+
raise ConnectError(
|
|
77
|
+
"That doesn't look like a valid PostgreSQL connection string.",
|
|
78
|
+
hint="Expected something like postgresql://user:password@host:5432/dbname",
|
|
79
|
+
) from exc
|
|
80
|
+
|
|
81
|
+
host = str(params.get("host") or "")
|
|
82
|
+
port = str(params.get("port") or "5432")
|
|
83
|
+
mode = _supabase_mode(host, port)
|
|
84
|
+
if mode is not None:
|
|
85
|
+
params.setdefault("sslmode", "require")
|
|
86
|
+
params.setdefault("application_name", APPLICATION_NAME)
|
|
87
|
+
params.setdefault("connect_timeout", "10")
|
|
88
|
+
|
|
89
|
+
shown = {k: v for k, v in params.items() if k == "user" or k == "dbname"}
|
|
90
|
+
user = f"{shown['user']}@" if shown.get("user") else ""
|
|
91
|
+
display = f"postgresql://{user}{host or 'localhost'}:{port}/{shown.get('dbname', '')}"
|
|
92
|
+
|
|
93
|
+
return ConnectionSettings(conninfo=make_conninfo("", **params), display=display, supabase=mode)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _hint_for(settings: ConnectionSettings, error: str) -> str | None:
|
|
97
|
+
lowered = error.lower()
|
|
98
|
+
if settings.supabase is SupabaseMode.DIRECT and any(
|
|
99
|
+
s in lowered
|
|
100
|
+
for s in ("network is unreachable", "cannot assign requested address", "translate host")
|
|
101
|
+
):
|
|
102
|
+
return (
|
|
103
|
+
"Supabase's direct connection is IPv6 only unless your project has the IPv4 "
|
|
104
|
+
"add-on. Use the session pooler string from the dashboard's Connect button "
|
|
105
|
+
"instead (host ending in .pooler.supabase.com, port 5432)."
|
|
106
|
+
)
|
|
107
|
+
if "password authentication failed" in lowered and settings.supabase in (
|
|
108
|
+
SupabaseMode.SESSION_POOLER,
|
|
109
|
+
SupabaseMode.TRANSACTION_POOLER,
|
|
110
|
+
):
|
|
111
|
+
return (
|
|
112
|
+
"Supabase poolers expect the user as postgres.<project-ref>, not just postgres. "
|
|
113
|
+
"Copy the exact string from the dashboard's Connect button."
|
|
114
|
+
)
|
|
115
|
+
if "timeout" in lowered:
|
|
116
|
+
return "Check the host and port, and that your network allows outbound connections."
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def connect(settings: ConnectionSettings, statement_timeout_s: int = 60) -> psycopg.Connection:
|
|
121
|
+
"""Open a read-only connection with a statement timeout.
|
|
122
|
+
|
|
123
|
+
Prepared statements are disabled so transaction poolers work. The caller should
|
|
124
|
+
roll back and close the connection when done.
|
|
125
|
+
"""
|
|
126
|
+
try:
|
|
127
|
+
conn = psycopg.connect(settings.conninfo, prepare_threshold=None)
|
|
128
|
+
except psycopg.OperationalError as exc:
|
|
129
|
+
message = str(exc).strip().splitlines()[0] if str(exc).strip() else "Connection failed."
|
|
130
|
+
raise ConnectError(message, hint=_hint_for(settings, str(exc))) from exc
|
|
131
|
+
|
|
132
|
+
conn.read_only = True
|
|
133
|
+
# SET LOCAL lasts for the current transaction only, which also suits transaction poolers.
|
|
134
|
+
conn.execute(
|
|
135
|
+
sql.SQL("SET LOCAL statement_timeout = {}").format(sql.Literal(statement_timeout_s * 1000))
|
|
136
|
+
)
|
|
137
|
+
return conn
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def connect_writer(settings: ConnectionSettings) -> psycopg.Connection:
|
|
141
|
+
"""An autocommit connection for apply, which manages its own short transactions.
|
|
142
|
+
|
|
143
|
+
Apply holds a session-level advisory lock and builds indexes concurrently, so it needs a
|
|
144
|
+
real session: a transaction pooler won't do.
|
|
145
|
+
"""
|
|
146
|
+
if settings.supabase is SupabaseMode.TRANSACTION_POOLER:
|
|
147
|
+
raise ConnectError(
|
|
148
|
+
"apply can't run through a transaction pooler.",
|
|
149
|
+
hint="Use the session pooler (port 5432) or the direct connection string.",
|
|
150
|
+
)
|
|
151
|
+
try:
|
|
152
|
+
return psycopg.connect(settings.conninfo, prepare_threshold=None, autocommit=True)
|
|
153
|
+
except psycopg.OperationalError as exc:
|
|
154
|
+
message = str(exc).strip().splitlines()[0] if str(exc).strip() else "Connection failed."
|
|
155
|
+
raise ConnectError(message, hint=_hint_for(settings, str(exc))) from exc
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Read a sample of documents (ID and text) from a pgvector table, for benchmarks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Collection
|
|
6
|
+
|
|
7
|
+
import psycopg
|
|
8
|
+
from psycopg import sql
|
|
9
|
+
|
|
10
|
+
from vecshift.connectors.pgvector.inspect import (
|
|
11
|
+
TEXT_COLUMNS,
|
|
12
|
+
TEXT_TYPES,
|
|
13
|
+
TargetSelectionError,
|
|
14
|
+
_columns,
|
|
15
|
+
_pick,
|
|
16
|
+
find_vector_columns,
|
|
17
|
+
select_column,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _id_column(conn: psycopg.Connection, relid: int) -> str | None:
|
|
22
|
+
row = conn.execute(
|
|
23
|
+
"""
|
|
24
|
+
SELECT a.attname FROM pg_index i
|
|
25
|
+
JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = i.indkey[0]
|
|
26
|
+
WHERE i.indrelid = %s AND i.indisprimary AND i.indnatts = 1
|
|
27
|
+
""",
|
|
28
|
+
(relid,),
|
|
29
|
+
).fetchone()
|
|
30
|
+
return str(row[0]) if row else None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def sample_documents(
|
|
34
|
+
conn: psycopg.Connection,
|
|
35
|
+
table: str | None,
|
|
36
|
+
column: str | None,
|
|
37
|
+
text_column: str | None,
|
|
38
|
+
size: int,
|
|
39
|
+
include_ids: Collection[str] = (),
|
|
40
|
+
) -> tuple[list[tuple[str, str]], str]:
|
|
41
|
+
"""Up to ``size`` (id, text) rows spread across the table, plus any ``include_ids``.
|
|
42
|
+
|
|
43
|
+
Returns the rows and the table's qualified name.
|
|
44
|
+
"""
|
|
45
|
+
col = select_column(find_vector_columns(conn), table, column)
|
|
46
|
+
columns = _columns(conn, col.relid)
|
|
47
|
+
text = text_column or _pick(columns, TEXT_COLUMNS, TEXT_TYPES)
|
|
48
|
+
if text is None or text not in columns:
|
|
49
|
+
raise TargetSelectionError(
|
|
50
|
+
f"No text column found in {col.schema}.{col.table}. Pass --text-column."
|
|
51
|
+
)
|
|
52
|
+
id_col = _id_column(conn, col.relid)
|
|
53
|
+
id_expr = sql.Identifier(id_col) if id_col else sql.SQL("ctid")
|
|
54
|
+
if include_ids and not id_col:
|
|
55
|
+
raise TargetSelectionError(
|
|
56
|
+
"Labeled queries need a single-column primary key to match document IDs."
|
|
57
|
+
)
|
|
58
|
+
table_id = sql.Identifier(col.schema, col.table)
|
|
59
|
+
text_id = sql.Identifier(text)
|
|
60
|
+
nonempty = sql.SQL("{t} IS NOT NULL AND btrim({t}::text) <> ''").format(t=text_id)
|
|
61
|
+
|
|
62
|
+
est_row = conn.execute(
|
|
63
|
+
"SELECT reltuples::bigint FROM pg_class WHERE oid = %s", (col.relid,)
|
|
64
|
+
).fetchone()
|
|
65
|
+
estimated = int(est_row[0]) if est_row and est_row[0] > 0 else 0
|
|
66
|
+
tablesample: sql.Composable = sql.SQL("")
|
|
67
|
+
if estimated > size * 2:
|
|
68
|
+
percent = min(100.0, 100.0 * size * 1.5 / estimated)
|
|
69
|
+
tablesample = sql.SQL("TABLESAMPLE SYSTEM ({}) REPEATABLE (7)").format(sql.Literal(percent))
|
|
70
|
+
|
|
71
|
+
rows = conn.execute(
|
|
72
|
+
sql.SQL(
|
|
73
|
+
"SELECT {id}::text, {t}::text FROM {table} {ts} WHERE {nonempty} "
|
|
74
|
+
"ORDER BY md5(ctid::text) LIMIT {n}"
|
|
75
|
+
).format(
|
|
76
|
+
id=id_expr,
|
|
77
|
+
t=text_id,
|
|
78
|
+
table=table_id,
|
|
79
|
+
ts=tablesample,
|
|
80
|
+
nonempty=nonempty,
|
|
81
|
+
n=sql.Literal(size),
|
|
82
|
+
)
|
|
83
|
+
).fetchall()
|
|
84
|
+
docs: dict[str, str] = {r[0]: r[1] for r in rows}
|
|
85
|
+
|
|
86
|
+
wanted = [i for i in include_ids if i not in docs]
|
|
87
|
+
if wanted:
|
|
88
|
+
extra = conn.execute(
|
|
89
|
+
sql.SQL("SELECT {id}::text, {t}::text FROM {table} WHERE {id}::text = ANY(%s)").format(
|
|
90
|
+
id=id_expr, t=text_id, table=table_id
|
|
91
|
+
),
|
|
92
|
+
(wanted,),
|
|
93
|
+
).fetchall()
|
|
94
|
+
for doc_id, body in extra:
|
|
95
|
+
docs[doc_id] = body or ""
|
|
96
|
+
return list(docs.items()), f"{col.schema}.{col.table}"
|