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.
Files changed (62) hide show
  1. vecshift/__init__.py +15 -0
  2. vecshift/assets/bench.css +39 -0
  3. vecshift/assets/eval.css +91 -0
  4. vecshift/assets/report.css +252 -0
  5. vecshift/assets/report.js +50 -0
  6. vecshift/bench/__init__.py +18 -0
  7. vecshift/bench/corpus.py +175 -0
  8. vecshift/bench/generate.py +106 -0
  9. vecshift/bench/html.py +325 -0
  10. vecshift/bench/metrics.py +50 -0
  11. vecshift/bench/runner.py +183 -0
  12. vecshift/cli.py +236 -0
  13. vecshift/cli_apply.py +283 -0
  14. vecshift/cli_bench.py +354 -0
  15. vecshift/cli_cutover.py +431 -0
  16. vecshift/cli_eval.py +591 -0
  17. vecshift/cli_plan.py +335 -0
  18. vecshift/cli_style.py +57 -0
  19. vecshift/connectors/__init__.py +1 -0
  20. vecshift/connectors/pgvector/__init__.py +29 -0
  21. vecshift/connectors/pgvector/connection.py +155 -0
  22. vecshift/connectors/pgvector/documents.py +96 -0
  23. vecshift/connectors/pgvector/inspect.py +427 -0
  24. vecshift/connectors/pgvector/search.py +240 -0
  25. vecshift/connectors/pgvector/switch.py +481 -0
  26. vecshift/connectors/pgvector/target.py +195 -0
  27. vecshift/connectors/pgvector/writer.py +431 -0
  28. vecshift/core/__init__.py +4 -0
  29. vecshift/core/capabilities.py +33 -0
  30. vecshift/core/contracts.py +57 -0
  31. vecshift/core/fingerprint.py +57 -0
  32. vecshift/core/record.py +75 -0
  33. vecshift/doctor/__init__.py +15 -0
  34. vecshift/doctor/checks.py +490 -0
  35. vecshift/doctor/findings.py +78 -0
  36. vecshift/doctor/html.py +493 -0
  37. vecshift/doctor/profile.py +69 -0
  38. vecshift/embeddings/__init__.py +22 -0
  39. vecshift/embeddings/cache.py +86 -0
  40. vecshift/embeddings/providers.py +244 -0
  41. vecshift/embeddings/spec.py +240 -0
  42. vecshift/eval/__init__.py +20 -0
  43. vecshift/eval/html.py +444 -0
  44. vecshift/eval/metrics.py +81 -0
  45. vecshift/eval/queries.py +97 -0
  46. vecshift/eval/runner.py +394 -0
  47. vecshift/html_kit.py +143 -0
  48. vecshift/jobs/__init__.py +5 -0
  49. vecshift/jobs/spec.py +202 -0
  50. vecshift/migrate/__init__.py +6 -0
  51. vecshift/migrate/engine.py +272 -0
  52. vecshift/migrate/state.py +50 -0
  53. vecshift/planning/__init__.py +14 -0
  54. vecshift/planning/plan.py +87 -0
  55. vecshift/planning/planner.py +493 -0
  56. vecshift/py.typed +0 -0
  57. vecshift-0.1.0.dist-info/METADATA +264 -0
  58. vecshift-0.1.0.dist-info/RECORD +62 -0
  59. vecshift-0.1.0.dist-info/WHEEL +4 -0
  60. vecshift-0.1.0.dist-info/entry_points.txt +2 -0
  61. vecshift-0.1.0.dist-info/licenses/LICENSE +202 -0
  62. 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}"