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/jobs/spec.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""The job spec, usually ``vecshift.yaml``.
|
|
2
|
+
|
|
3
|
+
One file describes a migration: where the vectors are, where the new ones go, which model
|
|
4
|
+
makes them, and the limits to respect. The CLI reads it today; the API and UI will read and
|
|
5
|
+
write the same file. It never holds credentials: the database connection string comes
|
|
6
|
+
from an environment variable the file names.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
from enum import StrEnum
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Literal
|
|
16
|
+
|
|
17
|
+
import yaml
|
|
18
|
+
from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator
|
|
19
|
+
|
|
20
|
+
from vecshift.embeddings.spec import SpecError, parse_spec
|
|
21
|
+
|
|
22
|
+
_IDENT = re.compile(r"^[A-Za-z_][A-Za-z0-9_$]*$")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class JobError(ValueError):
|
|
26
|
+
"""The job file couldn't be read or isn't valid."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class _Strict(BaseModel):
|
|
30
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _identifier(value: str | None, what: str) -> str | None:
|
|
34
|
+
if value is not None and not _IDENT.match(value):
|
|
35
|
+
raise ValueError(f"{what} must be a plain identifier, got {value!r}")
|
|
36
|
+
return value
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Source(_Strict):
|
|
40
|
+
type: Literal["pgvector"] = "pgvector"
|
|
41
|
+
dsn_env: str = "VECSHIFT_DSN"
|
|
42
|
+
"""Environment variable holding the connection string. The file never stores it."""
|
|
43
|
+
table: str
|
|
44
|
+
"""``table`` or ``schema.table``."""
|
|
45
|
+
vector_column: str | None = None
|
|
46
|
+
text_column: str | None = None
|
|
47
|
+
model: str | None = None
|
|
48
|
+
"""The model that made the current vectors, so ``eval`` can embed queries for them."""
|
|
49
|
+
|
|
50
|
+
@field_validator("table")
|
|
51
|
+
@classmethod
|
|
52
|
+
def _table(cls, value: str) -> str:
|
|
53
|
+
for part in value.split("."):
|
|
54
|
+
_identifier(part, "table")
|
|
55
|
+
if value.count(".") > 1:
|
|
56
|
+
raise ValueError("table must be table or schema.table")
|
|
57
|
+
return value
|
|
58
|
+
|
|
59
|
+
@field_validator("vector_column", "text_column")
|
|
60
|
+
@classmethod
|
|
61
|
+
def _columns(cls, value: str | None) -> str | None:
|
|
62
|
+
return _identifier(value, "column")
|
|
63
|
+
|
|
64
|
+
@field_validator("model")
|
|
65
|
+
@classmethod
|
|
66
|
+
def _old_model(cls, value: str | None) -> str | None:
|
|
67
|
+
if value is not None:
|
|
68
|
+
try:
|
|
69
|
+
parse_spec(value)
|
|
70
|
+
except SpecError as exc:
|
|
71
|
+
raise ValueError(str(exc)) from None
|
|
72
|
+
return value
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class VectorType(StrEnum):
|
|
76
|
+
VECTOR = "vector"
|
|
77
|
+
HALFVEC = "halfvec"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class IndexMethod(StrEnum):
|
|
81
|
+
HNSW = "hnsw"
|
|
82
|
+
IVFFLAT = "ivfflat"
|
|
83
|
+
NONE = "none"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class Metric(StrEnum):
|
|
87
|
+
COSINE = "cosine"
|
|
88
|
+
INNER_PRODUCT = "inner_product"
|
|
89
|
+
L2 = "l2"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class Target(_Strict):
|
|
93
|
+
column: str = "embedding_v2"
|
|
94
|
+
"""New column on the source table. Cutover swaps it with the old one by renaming."""
|
|
95
|
+
vector_type: VectorType = VectorType.VECTOR
|
|
96
|
+
index: IndexMethod = IndexMethod.HNSW
|
|
97
|
+
metric: Metric | None = None
|
|
98
|
+
"""Distance for the index. Defaults to the existing index's metric, or cosine."""
|
|
99
|
+
|
|
100
|
+
@field_validator("column")
|
|
101
|
+
@classmethod
|
|
102
|
+
def _column(cls, value: str) -> str:
|
|
103
|
+
_identifier(value, "column")
|
|
104
|
+
return value
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class Limits(_Strict):
|
|
108
|
+
budget_usd: float | None = Field(default=None, gt=0)
|
|
109
|
+
"""Stop before spending more than this. Plan fails when the estimate exceeds it."""
|
|
110
|
+
tokens_per_minute: int | None = Field(default=None, gt=0)
|
|
111
|
+
"""The provider's rate limit, used to estimate duration."""
|
|
112
|
+
requests_per_minute: int | None = Field(default=None, gt=0)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class JobSpec(_Strict):
|
|
116
|
+
version: Literal[1] = 1
|
|
117
|
+
name: str = "migration"
|
|
118
|
+
source: Source
|
|
119
|
+
target: Target = Target()
|
|
120
|
+
model: str
|
|
121
|
+
"""A model spec, as for ``vecshift bench``: ``provider/model[,option=value]``."""
|
|
122
|
+
limits: Limits = Limits()
|
|
123
|
+
|
|
124
|
+
@field_validator("target", "limits", mode="before")
|
|
125
|
+
@classmethod
|
|
126
|
+
def _empty_section(cls, value: object) -> object:
|
|
127
|
+
# A section whose settings are all commented out reads as null: use the defaults.
|
|
128
|
+
return {} if value is None else value
|
|
129
|
+
|
|
130
|
+
@field_validator("model")
|
|
131
|
+
@classmethod
|
|
132
|
+
def _model(cls, value: str) -> str:
|
|
133
|
+
try:
|
|
134
|
+
parse_spec(value)
|
|
135
|
+
except SpecError as exc:
|
|
136
|
+
raise ValueError(str(exc)) from None
|
|
137
|
+
return value
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def format_errors(error: ValidationError) -> str:
|
|
141
|
+
lines = []
|
|
142
|
+
for item in error.errors():
|
|
143
|
+
where = ".".join(str(p) for p in item["loc"]) or "file"
|
|
144
|
+
message = item["msg"].removeprefix("Value error, ")
|
|
145
|
+
if item["type"] == "extra_forbidden":
|
|
146
|
+
message = "unknown setting"
|
|
147
|
+
elif item["type"] == "missing":
|
|
148
|
+
message = "required"
|
|
149
|
+
lines.append(f" {where}: {message}")
|
|
150
|
+
return "\n".join(lines)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def load_job(path: Path) -> JobSpec:
|
|
154
|
+
try:
|
|
155
|
+
raw = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
156
|
+
except OSError as exc:
|
|
157
|
+
raise JobError(f"Couldn't read {path}: {exc.strerror}") from exc
|
|
158
|
+
except yaml.YAMLError as exc:
|
|
159
|
+
mark = getattr(exc, "problem_mark", None)
|
|
160
|
+
where = f" at line {mark.line + 1}" if mark else ""
|
|
161
|
+
raise JobError(f"{path} isn't valid YAML{where}") from exc
|
|
162
|
+
if not isinstance(raw, dict):
|
|
163
|
+
raise JobError(f"{path} should contain settings, starting with source: and model:")
|
|
164
|
+
try:
|
|
165
|
+
return JobSpec.model_validate(raw)
|
|
166
|
+
except ValidationError as exc:
|
|
167
|
+
raise JobError(f"{path} has problems:\n{format_errors(exc)}") from exc
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def template(table: str, model: str, column: str = "embedding_v2") -> str:
|
|
171
|
+
"""A commented job file to start from. Values are quoted, so any input stays valid YAML."""
|
|
172
|
+
name = json.dumps(table.split(".")[-1] + "-reembed")
|
|
173
|
+
return f"""# vecshift job: re-embed a pgvector column with a new model, side by side.
|
|
174
|
+
# Check it with `vecshift plan`. Nothing is changed until `vecshift apply`.
|
|
175
|
+
version: 1
|
|
176
|
+
name: {name}
|
|
177
|
+
|
|
178
|
+
source:
|
|
179
|
+
type: pgvector
|
|
180
|
+
# The connection string is read from this environment variable, never from this file.
|
|
181
|
+
dsn_env: VECSHIFT_DSN
|
|
182
|
+
table: {json.dumps(table)}
|
|
183
|
+
# vector_column: embedding # needed only if the table has several
|
|
184
|
+
# text_column: content # detected automatically when it's a common name
|
|
185
|
+
# model: openai/text-embedding-3-small # the model that made the current vectors, for eval
|
|
186
|
+
|
|
187
|
+
target:
|
|
188
|
+
# New vectors go in this column next to the old ones. Cutover renames the two columns
|
|
189
|
+
# in one transaction, and rollback renames them back.
|
|
190
|
+
column: {json.dumps(column)}
|
|
191
|
+
vector_type: vector # or halfvec: half the storage, indexable up to 4,000 dims
|
|
192
|
+
index: hnsw # hnsw, ivfflat, or none
|
|
193
|
+
# metric: cosine # defaults to the current index's metric, or cosine
|
|
194
|
+
|
|
195
|
+
# The new model, written as for `vecshift bench`, e.g. openai/text-embedding-3-large,dims=1024
|
|
196
|
+
model: {json.dumps(model)}
|
|
197
|
+
|
|
198
|
+
limits:
|
|
199
|
+
# budget_usd: 50 # plan fails if the estimate is higher; apply stops there
|
|
200
|
+
# tokens_per_minute: 1000000 # your provider rate limit, for the duration estimate
|
|
201
|
+
# requests_per_minute: 3000
|
|
202
|
+
"""
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"""The apply loop: fill the new column, keep up with writes, then index and verify.
|
|
2
|
+
|
|
3
|
+
It talks to the database through a small writer interface and to the model through the
|
|
4
|
+
embedding contract, so it can be tested with fakes and reused for other stores.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import math
|
|
10
|
+
import time
|
|
11
|
+
from collections.abc import Callable, Sequence
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import Any, Literal, Protocol
|
|
14
|
+
|
|
15
|
+
from vecshift.embeddings.providers import EmbeddingError
|
|
16
|
+
from vecshift.migrate.state import JobState
|
|
17
|
+
|
|
18
|
+
CHARS_PER_TOKEN = 4
|
|
19
|
+
MAX_PASSES = 5
|
|
20
|
+
SETTLED_ROWS = 100
|
|
21
|
+
SETTLED_SHARE = 0.001
|
|
22
|
+
"""On a busy table some rows always change while a pass runs. Once no more than this many
|
|
23
|
+
remain (at least ``SETTLED_ROWS``), apply builds the index and finishes; the stragglers get
|
|
24
|
+
their vector on the next run, and cutover embeds them before it switches."""
|
|
25
|
+
"""Catch-up passes after the first, for rows that changed or arrived during the run."""
|
|
26
|
+
MAX_CHARS = 24_000
|
|
27
|
+
"""Longer texts are cut to this length, inside common model input limits (about 6K tokens)."""
|
|
28
|
+
|
|
29
|
+
Status = Literal["complete", "stopped", "budget", "failed"]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Row(Protocol):
|
|
33
|
+
@property
|
|
34
|
+
def key(self) -> Any: ...
|
|
35
|
+
@property
|
|
36
|
+
def text(self) -> str: ...
|
|
37
|
+
@property
|
|
38
|
+
def id(self) -> str: ...
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Writer(Protocol):
|
|
42
|
+
def acquire(self) -> None: ...
|
|
43
|
+
def release(self) -> None: ...
|
|
44
|
+
def ensure_column(self) -> bool: ...
|
|
45
|
+
def ensure_trigger(self) -> bool: ...
|
|
46
|
+
def total_count(self) -> int: ...
|
|
47
|
+
def pending_count(self, exclude: Sequence[str] = ()) -> int: ...
|
|
48
|
+
def fetch(self, after: Any, limit: int, exclude: Sequence[str] = ()) -> Sequence[Row]: ...
|
|
49
|
+
def write(self, items: Sequence[tuple[Any, Sequence[float]]]) -> int: ...
|
|
50
|
+
def build_index(self, method: str, metric: str, rows: int) -> str: ...
|
|
51
|
+
def dimensions_in_use(self) -> set[int]: ...
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class Embedder(Protocol):
|
|
55
|
+
tokens: int
|
|
56
|
+
|
|
57
|
+
async def embed(
|
|
58
|
+
self, texts: Sequence[str], mode: Literal["document", "query"] = ...
|
|
59
|
+
) -> list[list[float]]: ...
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True, slots=True)
|
|
63
|
+
class Event:
|
|
64
|
+
kind: str
|
|
65
|
+
"""``start``, ``column``, ``trigger``, ``batch``, ``pass``, ``index``, or ``done``."""
|
|
66
|
+
data: dict[str, Any] = field(default_factory=dict)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(slots=True)
|
|
70
|
+
class ApplyResult:
|
|
71
|
+
status: Status
|
|
72
|
+
rows_written: int = 0
|
|
73
|
+
rows_failed: int = 0
|
|
74
|
+
remaining: int = 0
|
|
75
|
+
tokens: int = 0
|
|
76
|
+
spent_usd: float | None = 0.0
|
|
77
|
+
"""This run's spend, or ``None`` when the model has no price."""
|
|
78
|
+
total_spent_usd: float | None = 0.0
|
|
79
|
+
seconds: float = 0.0
|
|
80
|
+
index: str = "pending"
|
|
81
|
+
"""``built``, ``rebuilt``, ``exists``, ``skipped``, or ``pending``."""
|
|
82
|
+
message: str | None = None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class DimensionMismatch(Exception):
|
|
86
|
+
pass
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
async def _embed_isolating(
|
|
90
|
+
embedder: Embedder, rows: Sequence[Row], dims: int
|
|
91
|
+
) -> tuple[list[tuple[Row, list[float]]], dict[str, str]]:
|
|
92
|
+
"""Embed rows; if the provider rejects a request, split it to find the bad rows."""
|
|
93
|
+
texts = [r.text[:MAX_CHARS] for r in rows]
|
|
94
|
+
try:
|
|
95
|
+
vectors = await embedder.embed(texts, "document")
|
|
96
|
+
except EmbeddingError as exc:
|
|
97
|
+
if len(rows) == 1:
|
|
98
|
+
return [], {rows[0].id: str(exc)}
|
|
99
|
+
mid = len(rows) // 2
|
|
100
|
+
left, lf = await _embed_isolating(embedder, rows[:mid], dims)
|
|
101
|
+
right, rf = await _embed_isolating(embedder, rows[mid:], dims)
|
|
102
|
+
return left + right, {**lf, **rf}
|
|
103
|
+
for vector in vectors:
|
|
104
|
+
if len(vector) != dims:
|
|
105
|
+
raise DimensionMismatch(
|
|
106
|
+
f"The model returned {len(vector)} dimensions, but the column holds {dims}."
|
|
107
|
+
)
|
|
108
|
+
return list(zip(rows, vectors, strict=True)), {}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
async def apply(
|
|
112
|
+
writer: Writer,
|
|
113
|
+
embedder: Embedder,
|
|
114
|
+
state: JobState,
|
|
115
|
+
*,
|
|
116
|
+
dims: int,
|
|
117
|
+
price_per_million: float | None,
|
|
118
|
+
budget_usd: float | None,
|
|
119
|
+
chunk_rows: int,
|
|
120
|
+
index: tuple[str, str] | None,
|
|
121
|
+
on_event: Callable[[Event], None] = lambda e: None,
|
|
122
|
+
should_stop: Callable[[], bool] = lambda: False,
|
|
123
|
+
until: float | None = None,
|
|
124
|
+
) -> ApplyResult:
|
|
125
|
+
"""Run (or resume) a migration. Safe to run again at any time to catch up.
|
|
126
|
+
|
|
127
|
+
``until`` (a fraction, such as 0.5) stops once that share of the rows with text has a
|
|
128
|
+
new vector, so a migration can be checked part way and continued later.
|
|
129
|
+
"""
|
|
130
|
+
started = time.monotonic()
|
|
131
|
+
result = ApplyResult(status="complete")
|
|
132
|
+
tokens_before = embedder.tokens
|
|
133
|
+
spent_before = state.spent_usd
|
|
134
|
+
state.runs += 1
|
|
135
|
+
|
|
136
|
+
def spent() -> float:
|
|
137
|
+
if price_per_million is None:
|
|
138
|
+
return 0.0
|
|
139
|
+
return (embedder.tokens - tokens_before) * price_per_million / 1_000_000
|
|
140
|
+
|
|
141
|
+
def finish(status: Status, message: str | None = None) -> ApplyResult:
|
|
142
|
+
result.status = status
|
|
143
|
+
result.message = message
|
|
144
|
+
result.tokens = embedder.tokens - tokens_before
|
|
145
|
+
run_spend = spent()
|
|
146
|
+
result.spent_usd = run_spend if price_per_million is not None else None
|
|
147
|
+
state.spent_usd = spent_before + run_spend
|
|
148
|
+
state.tokens += result.tokens
|
|
149
|
+
state.save()
|
|
150
|
+
result.total_spent_usd = state.spent_usd if price_per_million is not None else None
|
|
151
|
+
result.rows_failed = len(state.failed)
|
|
152
|
+
result.seconds = time.monotonic() - started
|
|
153
|
+
on_event(Event("done", {"status": status, "message": message}))
|
|
154
|
+
return result
|
|
155
|
+
|
|
156
|
+
writer.acquire()
|
|
157
|
+
try:
|
|
158
|
+
# The column has to exist before rows can be counted against it.
|
|
159
|
+
on_event(Event("column", {"added": writer.ensure_column()}))
|
|
160
|
+
on_event(Event("trigger", {"added": writer.ensure_trigger()}))
|
|
161
|
+
pending = writer.pending_count(tuple(state.failed))
|
|
162
|
+
quota: int | None = None
|
|
163
|
+
if until is not None:
|
|
164
|
+
total = writer.total_count()
|
|
165
|
+
done = total - writer.pending_count()
|
|
166
|
+
quota = max(0, math.ceil(total * until) - done)
|
|
167
|
+
pending = min(pending, quota)
|
|
168
|
+
on_event(Event("start", {"pending": pending, "spent_before": spent_before}))
|
|
169
|
+
settled = max(SETTLED_ROWS, math.ceil(SETTLED_SHARE * max(pending, state.rows_written)))
|
|
170
|
+
|
|
171
|
+
backlog = pending
|
|
172
|
+
for number in range(1, MAX_PASSES + 2):
|
|
173
|
+
after: Any = None
|
|
174
|
+
progress = 0
|
|
175
|
+
# A pass covers what was pending when it began; rows that arrive meanwhile wait
|
|
176
|
+
# for the next pass, so a pass ends even if inserts outpace embedding.
|
|
177
|
+
budget = backlog + chunk_rows
|
|
178
|
+
fetched = 0
|
|
179
|
+
while fetched < budget:
|
|
180
|
+
if should_stop():
|
|
181
|
+
result.remaining = writer.pending_count(tuple(state.failed))
|
|
182
|
+
return finish("stopped", "Stopped. Run apply again to continue.")
|
|
183
|
+
limit = chunk_rows
|
|
184
|
+
if quota is not None and result.rows_written >= quota:
|
|
185
|
+
result.remaining = writer.pending_count(tuple(state.failed))
|
|
186
|
+
if result.remaining:
|
|
187
|
+
return finish(
|
|
188
|
+
"stopped",
|
|
189
|
+
f"Reached {until or 0:.0%} of rows. Check the new vectors, then "
|
|
190
|
+
"run apply again to continue.",
|
|
191
|
+
)
|
|
192
|
+
break
|
|
193
|
+
if quota is not None:
|
|
194
|
+
limit = min(chunk_rows, quota - result.rows_written)
|
|
195
|
+
rows = writer.fetch(after, limit, tuple(state.failed))
|
|
196
|
+
if not rows:
|
|
197
|
+
break
|
|
198
|
+
fetched += len(rows)
|
|
199
|
+
after = rows[-1].key
|
|
200
|
+
if budget_usd is not None and price_per_million is not None:
|
|
201
|
+
estimate = sum(len(r.text[:MAX_CHARS]) for r in rows) / CHARS_PER_TOKEN
|
|
202
|
+
projected = spent_before + spent() + estimate * price_per_million / 1e6
|
|
203
|
+
if projected > budget_usd:
|
|
204
|
+
result.remaining = writer.pending_count(tuple(state.failed))
|
|
205
|
+
return finish(
|
|
206
|
+
"budget",
|
|
207
|
+
f"Stopped before the next batch would pass the ${budget_usd:,.2f} "
|
|
208
|
+
"budget. Raise limits.budget_usd and run apply again to continue.",
|
|
209
|
+
)
|
|
210
|
+
try:
|
|
211
|
+
pairs, failed = await _embed_isolating(embedder, rows, dims)
|
|
212
|
+
except DimensionMismatch as exc:
|
|
213
|
+
return finish("failed", str(exc))
|
|
214
|
+
except EmbeddingError as exc: # pragma: no cover - isolation catches these
|
|
215
|
+
return finish("failed", str(exc))
|
|
216
|
+
state.failed.update(failed)
|
|
217
|
+
written = writer.write(pairs)
|
|
218
|
+
progress += written
|
|
219
|
+
result.rows_written += written
|
|
220
|
+
state.rows_written += written
|
|
221
|
+
state.spent_usd = spent_before + spent()
|
|
222
|
+
state.save()
|
|
223
|
+
on_event(
|
|
224
|
+
Event(
|
|
225
|
+
"batch",
|
|
226
|
+
{
|
|
227
|
+
"pass": number,
|
|
228
|
+
"written": written,
|
|
229
|
+
"skipped": len(pairs) - written,
|
|
230
|
+
"failed": len(failed),
|
|
231
|
+
"rows_written": result.rows_written,
|
|
232
|
+
"spent_usd": state.spent_usd if price_per_million is not None else None,
|
|
233
|
+
},
|
|
234
|
+
)
|
|
235
|
+
)
|
|
236
|
+
remaining = writer.pending_count(tuple(state.failed))
|
|
237
|
+
on_event(Event("pass", {"number": number, "remaining": remaining}))
|
|
238
|
+
result.remaining = backlog = remaining
|
|
239
|
+
if remaining == 0 or progress == 0:
|
|
240
|
+
break
|
|
241
|
+
|
|
242
|
+
if result.remaining > settled:
|
|
243
|
+
return finish(
|
|
244
|
+
"stopped",
|
|
245
|
+
f"{result.remaining:,} rows kept changing during the run, faster than they "
|
|
246
|
+
"could be embedded. Run apply again to catch up.",
|
|
247
|
+
)
|
|
248
|
+
if index is None:
|
|
249
|
+
result.index = "skipped"
|
|
250
|
+
else:
|
|
251
|
+
on_event(Event("index", {"state": "building", "method": index[0]}))
|
|
252
|
+
result.index = writer.build_index(index[0], index[1], state.rows_written)
|
|
253
|
+
on_event(Event("index", {"state": result.index}))
|
|
254
|
+
# A big index takes minutes, and a busy table keeps changing meanwhile.
|
|
255
|
+
result.remaining = writer.pending_count(tuple(state.failed))
|
|
256
|
+
sizes = writer.dimensions_in_use()
|
|
257
|
+
if sizes and sizes != {dims}:
|
|
258
|
+
return finish("failed", f"Found vectors of sizes {sorted(sizes)} in the new column.")
|
|
259
|
+
failed_note = (
|
|
260
|
+
f" {len(state.failed):,} rows were rejected by the provider; see the state file."
|
|
261
|
+
if state.failed
|
|
262
|
+
else ""
|
|
263
|
+
)
|
|
264
|
+
if result.remaining:
|
|
265
|
+
return finish(
|
|
266
|
+
"complete",
|
|
267
|
+
f"Every row has a new vector except {result.remaining:,} that changed during "
|
|
268
|
+
f"the run; cutover (or the next apply) embeds them.{failed_note}",
|
|
269
|
+
)
|
|
270
|
+
return finish("complete", f"Every row with text has a new vector.{failed_note}")
|
|
271
|
+
finally:
|
|
272
|
+
writer.release()
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Progress that has to survive between runs: spend so far and failed rows.
|
|
2
|
+
|
|
3
|
+
The database itself records which rows are done (their new vector is filled in), so this
|
|
4
|
+
file only holds what the database can't: money spent across runs, for the budget, and rows
|
|
5
|
+
the provider rejected. It lives in ``.vecshift/`` next to the job file, readable by its owner
|
|
6
|
+
only, and never holds document text.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
from dataclasses import asdict, dataclass, field
|
|
14
|
+
from datetime import UTC, datetime
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(slots=True)
|
|
19
|
+
class JobState:
|
|
20
|
+
path: Path
|
|
21
|
+
spent_usd: float = 0.0
|
|
22
|
+
tokens: int = 0
|
|
23
|
+
rows_written: int = 0
|
|
24
|
+
runs: int = 0
|
|
25
|
+
failed: dict[str, str] = field(default_factory=dict)
|
|
26
|
+
"""Row ID → error, for rows the provider rejected. Text is never stored."""
|
|
27
|
+
history: list[dict[str, str]] = field(default_factory=list)
|
|
28
|
+
"""Cutovers and rollbacks: what happened, when, and to which columns."""
|
|
29
|
+
updated_at: str | None = None
|
|
30
|
+
|
|
31
|
+
@classmethod
|
|
32
|
+
def for_job(cls, job_file: Path, name: str) -> JobState:
|
|
33
|
+
safe = "".join(c if c.isalnum() or c in "-_" else "_" for c in name) or "job"
|
|
34
|
+
path = job_file.resolve().parent / ".vecshift" / f"{safe}.state.json"
|
|
35
|
+
if not path.exists():
|
|
36
|
+
return cls(path=path)
|
|
37
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
38
|
+
data.pop("path", None)
|
|
39
|
+
return cls(path=path, **data)
|
|
40
|
+
|
|
41
|
+
def save(self) -> None:
|
|
42
|
+
self.updated_at = datetime.now(UTC).isoformat(timespec="seconds")
|
|
43
|
+
self.path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
44
|
+
data = {k: v for k, v in asdict(self).items() if k != "path"}
|
|
45
|
+
tmp = self.path.with_suffix(".tmp")
|
|
46
|
+
# Write then rename, so a crash never leaves a half-written state file.
|
|
47
|
+
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
48
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
49
|
+
json.dump(data, fh, indent=2)
|
|
50
|
+
os.replace(tmp, self.path)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""``vecshift plan``: validate a migration job and estimate it before anything runs."""
|
|
2
|
+
|
|
3
|
+
from vecshift.planning.plan import Change, Estimates, Plan, ProbeResult
|
|
4
|
+
from vecshift.planning.planner import CHARS_PER_TOKEN, SampleStats, build_plan
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"CHARS_PER_TOKEN",
|
|
8
|
+
"Change",
|
|
9
|
+
"Estimates",
|
|
10
|
+
"Plan",
|
|
11
|
+
"ProbeResult",
|
|
12
|
+
"SampleStats",
|
|
13
|
+
"build_plan",
|
|
14
|
+
]
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""The result of planning a migration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import asdict, dataclass, field
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from vecshift.doctor.findings import Finding, Severity
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True, slots=True)
|
|
12
|
+
class Change:
|
|
13
|
+
kind: str
|
|
14
|
+
"""``add_column``, ``embed``, ``index``, or ``cutover``."""
|
|
15
|
+
summary: str
|
|
16
|
+
sql: str | None = None
|
|
17
|
+
note: str | None = None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True, slots=True)
|
|
21
|
+
class ProbeResult:
|
|
22
|
+
"""Measured by embedding a few sample documents with the real model."""
|
|
23
|
+
|
|
24
|
+
documents: int
|
|
25
|
+
dimensions: int
|
|
26
|
+
tokens_per_char: float
|
|
27
|
+
docs_per_second: float
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(slots=True)
|
|
31
|
+
class Estimates:
|
|
32
|
+
rows: int | None = None
|
|
33
|
+
rows_exact: bool = False
|
|
34
|
+
tokens: int | None = None
|
|
35
|
+
tokens_method: str | None = None
|
|
36
|
+
cost_usd: float | None = None
|
|
37
|
+
requests: int | None = None
|
|
38
|
+
seconds: float | None = None
|
|
39
|
+
seconds_method: str | None = None
|
|
40
|
+
new_bytes: int | None = None
|
|
41
|
+
old_bytes: int | None = None
|
|
42
|
+
index_memory_bytes: int | None = None
|
|
43
|
+
maintenance_work_mem: int | None = None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(slots=True)
|
|
47
|
+
class Plan:
|
|
48
|
+
job: str
|
|
49
|
+
source: str
|
|
50
|
+
target_column: str
|
|
51
|
+
model: str
|
|
52
|
+
dimensions: int | None
|
|
53
|
+
dimensions_source: str | None
|
|
54
|
+
"""``spec``, ``known``, ``probe``, or ``None`` when unknown."""
|
|
55
|
+
vector_type: str
|
|
56
|
+
metric: str = "cosine"
|
|
57
|
+
"""Distance for the new index: the job's, the current index's, or cosine."""
|
|
58
|
+
changes: list[Change] = field(default_factory=list)
|
|
59
|
+
estimates: Estimates = field(default_factory=Estimates)
|
|
60
|
+
findings: list[Finding] = field(default_factory=list)
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def errors(self) -> int:
|
|
64
|
+
return sum(1 for f in self.findings if f.severity is Severity.ERROR)
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def ok(self) -> bool:
|
|
68
|
+
return self.errors == 0
|
|
69
|
+
|
|
70
|
+
def sorted_findings(self) -> list[Finding]:
|
|
71
|
+
return sorted(self.findings, key=lambda f: -f.severity.rank)
|
|
72
|
+
|
|
73
|
+
def to_dict(self) -> dict[str, Any]:
|
|
74
|
+
return {
|
|
75
|
+
"job": self.job,
|
|
76
|
+
"ok": self.ok,
|
|
77
|
+
"source": self.source,
|
|
78
|
+
"target_column": self.target_column,
|
|
79
|
+
"model": self.model,
|
|
80
|
+
"dimensions": self.dimensions,
|
|
81
|
+
"dimensions_source": self.dimensions_source,
|
|
82
|
+
"vector_type": self.vector_type,
|
|
83
|
+
"metric": self.metric,
|
|
84
|
+
"changes": [asdict(c) for c in self.changes],
|
|
85
|
+
"estimates": asdict(self.estimates),
|
|
86
|
+
"findings": [f.to_dict() for f in self.sorted_findings()],
|
|
87
|
+
}
|