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
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""Embedding providers that satisfy :class:`vecshift.core.contracts.EmbeddingProvider`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import hashlib
|
|
7
|
+
import math
|
|
8
|
+
import os
|
|
9
|
+
import random
|
|
10
|
+
import re
|
|
11
|
+
import time
|
|
12
|
+
from collections.abc import Sequence
|
|
13
|
+
from typing import Any, Literal
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
from vecshift.core.fingerprint import EmbeddingFingerprint
|
|
18
|
+
from vecshift.embeddings.spec import ModelSpec
|
|
19
|
+
|
|
20
|
+
EmbedMode = Literal["document", "query"]
|
|
21
|
+
|
|
22
|
+
RETRY_STATUSES = {408, 409, 429, 500, 502, 503, 504}
|
|
23
|
+
MAX_ATTEMPTS = 6
|
|
24
|
+
CONCURRENCY = 4
|
|
25
|
+
|
|
26
|
+
_STOPWORDS = (
|
|
27
|
+
"a an and are as at be by for from has have in is it its of on or that the this to was "
|
|
28
|
+
"were will with which you your can not but if then than so such these those there their "
|
|
29
|
+
"they them we our may also into when where how what"
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class EmbeddingError(Exception):
|
|
34
|
+
"""A provider call failed for good. The message never contains credentials."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def api_key(spec: ModelSpec, error: type[Exception]) -> str | None:
|
|
38
|
+
"""The API key for ``spec``, after checking it can be sent safely."""
|
|
39
|
+
key = os.environ.get(spec.key_env) if spec.key_env else None
|
|
40
|
+
if spec.provider == "openai" and not key:
|
|
41
|
+
raise error(
|
|
42
|
+
f"{spec.name}: set {spec.key_env} to your OpenAI API key "
|
|
43
|
+
"(or key_env=NAME in the spec to read it from another variable)"
|
|
44
|
+
)
|
|
45
|
+
if key and spec.sends_plaintext:
|
|
46
|
+
raise error(
|
|
47
|
+
f"{spec.name}: refusing to send an API key over unencrypted http:// to a remote "
|
|
48
|
+
"host. Use an https:// URL."
|
|
49
|
+
)
|
|
50
|
+
return key
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class _Base:
|
|
54
|
+
def __init__(self, spec: ModelSpec) -> None:
|
|
55
|
+
self.spec = spec
|
|
56
|
+
self.tokens = 0
|
|
57
|
+
"""Tokens billed so far, as reported by the provider."""
|
|
58
|
+
self.tokens_estimated = False
|
|
59
|
+
"""Whether any token count had to be estimated."""
|
|
60
|
+
self.request_seconds: list[float] = []
|
|
61
|
+
self._dims: int | None = spec.dimensions
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def dimensions(self) -> int | None:
|
|
65
|
+
return self._dims
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def fingerprint(self) -> EmbeddingFingerprint:
|
|
69
|
+
if not self._dims:
|
|
70
|
+
raise EmbeddingError(f"{self.spec.name}: dimensions are unknown until the first call")
|
|
71
|
+
return EmbeddingFingerprint(
|
|
72
|
+
provider=self.spec.provider,
|
|
73
|
+
model=self.spec.model,
|
|
74
|
+
dimensions=self._dims,
|
|
75
|
+
task=None,
|
|
76
|
+
prefix=(self.spec.query_prefix + "|" + self.spec.doc_prefix) or None,
|
|
77
|
+
normalized=True,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
def _prefixed(self, texts: Sequence[str], mode: EmbedMode) -> list[str]:
|
|
81
|
+
prefix = self.spec.query_prefix if mode == "query" else self.spec.doc_prefix
|
|
82
|
+
return [prefix + t for t in texts] if prefix else list(texts)
|
|
83
|
+
|
|
84
|
+
async def aclose(self) -> None:
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class HashingEmbedder(_Base):
|
|
89
|
+
"""A lexical baseline: hashed bag of words, L2-normalized.
|
|
90
|
+
|
|
91
|
+
Free, offline, and deterministic. It captures word overlap only, so it's a floor
|
|
92
|
+
that real models should beat, and it makes a first run cost nothing.
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
_WORD = re.compile(r"\w+", re.UNICODE)
|
|
96
|
+
_STOP = frozenset(_STOPWORDS.split())
|
|
97
|
+
|
|
98
|
+
def __init__(self, spec: ModelSpec) -> None:
|
|
99
|
+
super().__init__(spec)
|
|
100
|
+
if not spec.dimensions:
|
|
101
|
+
raise EmbeddingError(f"{spec.name}: hash models need a size, e.g. hash/512")
|
|
102
|
+
self._dims = spec.dimensions
|
|
103
|
+
|
|
104
|
+
def _vector(self, text: str) -> list[float]:
|
|
105
|
+
dims = self._dims or 1
|
|
106
|
+
counts: dict[int, float] = {}
|
|
107
|
+
words = self._WORD.findall(text.lower())
|
|
108
|
+
self.tokens += len(words)
|
|
109
|
+
words = [w for w in words if w not in self._STOP] or words
|
|
110
|
+
for word in words:
|
|
111
|
+
h = hashlib.blake2b(word.encode(), digest_size=8).digest()
|
|
112
|
+
index = int.from_bytes(h[:4], "little") % dims
|
|
113
|
+
sign = 1.0 if h[4] & 1 else -1.0
|
|
114
|
+
counts[index] = counts.get(index, 0.0) + sign
|
|
115
|
+
vec = [0.0] * dims
|
|
116
|
+
for i, c in counts.items():
|
|
117
|
+
vec[i] = math.copysign(1 + math.log(abs(c)), c) if c else 0.0
|
|
118
|
+
norm = math.sqrt(sum(v * v for v in vec)) or 1.0
|
|
119
|
+
return [v / norm for v in vec]
|
|
120
|
+
|
|
121
|
+
async def embed(self, texts: Sequence[str], mode: EmbedMode = "document") -> list[list[float]]:
|
|
122
|
+
start = time.perf_counter()
|
|
123
|
+
out = [self._vector(t) for t in self._prefixed(texts, mode)]
|
|
124
|
+
self.request_seconds.append(time.perf_counter() - start)
|
|
125
|
+
return out
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class OpenAICompatEmbedder(_Base):
|
|
129
|
+
"""Any server that speaks OpenAI's ``POST /embeddings``: OpenAI itself, Ollama, vLLM,
|
|
130
|
+
TEI, LM Studio, LiteLLM, and most hosted inference platforms."""
|
|
131
|
+
|
|
132
|
+
def __init__(
|
|
133
|
+
self,
|
|
134
|
+
spec: ModelSpec,
|
|
135
|
+
client: httpx.AsyncClient | None = None,
|
|
136
|
+
sleep: Any = asyncio.sleep,
|
|
137
|
+
) -> None:
|
|
138
|
+
super().__init__(spec)
|
|
139
|
+
if not spec.url:
|
|
140
|
+
raise EmbeddingError(f"{spec.name}: no URL configured")
|
|
141
|
+
key = api_key(spec, EmbeddingError)
|
|
142
|
+
headers = {"Authorization": f"Bearer {key}"} if key else {}
|
|
143
|
+
# TLS certificates are always verified, and redirects are never followed, so a
|
|
144
|
+
# key can't be carried to a host other than the one in the spec.
|
|
145
|
+
self._client = client or httpx.AsyncClient(
|
|
146
|
+
timeout=httpx.Timeout(120, connect=15),
|
|
147
|
+
headers=headers,
|
|
148
|
+
verify=True,
|
|
149
|
+
follow_redirects=False,
|
|
150
|
+
)
|
|
151
|
+
if client is not None and key:
|
|
152
|
+
self._client.headers.update(headers)
|
|
153
|
+
self._owns_client = client is None
|
|
154
|
+
self._sleep = sleep
|
|
155
|
+
self._limit = asyncio.Semaphore(CONCURRENCY)
|
|
156
|
+
|
|
157
|
+
async def aclose(self) -> None:
|
|
158
|
+
if self._owns_client:
|
|
159
|
+
await self._client.aclose()
|
|
160
|
+
|
|
161
|
+
def _describe(self, response: httpx.Response) -> str:
|
|
162
|
+
try:
|
|
163
|
+
body: Any = response.json()
|
|
164
|
+
message = body.get("error", {})
|
|
165
|
+
message = message.get("message") if isinstance(message, dict) else message
|
|
166
|
+
message = message or body.get("detail") or response.text
|
|
167
|
+
except ValueError:
|
|
168
|
+
message = response.text
|
|
169
|
+
message = " ".join(str(message).split())[:300]
|
|
170
|
+
return f"{self.spec.name}: HTTP {response.status_code}: {message}"
|
|
171
|
+
|
|
172
|
+
async def _post(self, batch: list[str]) -> list[list[float]]:
|
|
173
|
+
payload: dict[str, Any] = {"model": self.spec.model, "input": batch}
|
|
174
|
+
if self.spec.dimensions:
|
|
175
|
+
payload["dimensions"] = self.spec.dimensions
|
|
176
|
+
delay = 1.0
|
|
177
|
+
for attempt in range(1, MAX_ATTEMPTS + 1):
|
|
178
|
+
start = time.perf_counter()
|
|
179
|
+
try:
|
|
180
|
+
response = await self._client.post(f"{self.spec.url}/embeddings", json=payload)
|
|
181
|
+
except httpx.TransportError as exc:
|
|
182
|
+
if attempt == MAX_ATTEMPTS:
|
|
183
|
+
raise EmbeddingError(
|
|
184
|
+
f"{self.spec.name}: couldn't reach {self.spec.url} ({type(exc).__name__})"
|
|
185
|
+
) from exc
|
|
186
|
+
await self._sleep(delay + random.uniform(0, delay / 2))
|
|
187
|
+
delay = min(delay * 2, 30)
|
|
188
|
+
continue
|
|
189
|
+
elapsed = time.perf_counter() - start
|
|
190
|
+
if response.status_code in RETRY_STATUSES and attempt < MAX_ATTEMPTS:
|
|
191
|
+
retry_after = response.headers.get("retry-after", "")
|
|
192
|
+
wait = float(retry_after) if retry_after.replace(".", "", 1).isdigit() else delay
|
|
193
|
+
await self._sleep(min(wait, 60) + random.uniform(0, 0.5))
|
|
194
|
+
delay = min(delay * 2, 30)
|
|
195
|
+
continue
|
|
196
|
+
if response.status_code >= 400:
|
|
197
|
+
raise EmbeddingError(self._describe(response))
|
|
198
|
+
self.request_seconds.append(elapsed)
|
|
199
|
+
return self._parse(response, batch)
|
|
200
|
+
raise EmbeddingError(
|
|
201
|
+
f"{self.spec.name}: gave up after {MAX_ATTEMPTS} attempts"
|
|
202
|
+
) # pragma: no cover
|
|
203
|
+
|
|
204
|
+
def _parse(self, response: httpx.Response, batch: list[str]) -> list[list[float]]:
|
|
205
|
+
try:
|
|
206
|
+
body = response.json()
|
|
207
|
+
items = sorted(body["data"], key=lambda d: d.get("index", 0))
|
|
208
|
+
vectors = [list(map(float, item["embedding"])) for item in items]
|
|
209
|
+
except (ValueError, KeyError, TypeError) as exc:
|
|
210
|
+
raise EmbeddingError(f"{self.spec.name}: unexpected response shape") from exc
|
|
211
|
+
if len(vectors) != len(batch):
|
|
212
|
+
raise EmbeddingError(
|
|
213
|
+
f"{self.spec.name}: sent {len(batch)} texts, got {len(vectors)} vectors"
|
|
214
|
+
)
|
|
215
|
+
usage = body.get("usage") or {}
|
|
216
|
+
tokens = usage.get("prompt_tokens") or usage.get("total_tokens")
|
|
217
|
+
if isinstance(tokens, int):
|
|
218
|
+
self.tokens += tokens
|
|
219
|
+
else:
|
|
220
|
+
self.tokens += sum(max(1, len(t) // 4) for t in batch)
|
|
221
|
+
self.tokens_estimated = True
|
|
222
|
+
if vectors:
|
|
223
|
+
self._dims = len(vectors[0])
|
|
224
|
+
return vectors
|
|
225
|
+
|
|
226
|
+
async def _batch(self, batch: list[str]) -> list[list[float]]:
|
|
227
|
+
async with self._limit:
|
|
228
|
+
return await self._post(batch)
|
|
229
|
+
|
|
230
|
+
async def embed(self, texts: Sequence[str], mode: EmbedMode = "document") -> list[list[float]]:
|
|
231
|
+
prepared = self._prefixed(texts, mode)
|
|
232
|
+
size = self.spec.batch_size
|
|
233
|
+
batches = [prepared[i : i + size] for i in range(0, len(prepared), size)]
|
|
234
|
+
results = await asyncio.gather(*(self._batch(b) for b in batches))
|
|
235
|
+
return [vec for batch in results for vec in batch]
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
Embedder = HashingEmbedder | OpenAICompatEmbedder
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def create_embedder(spec: ModelSpec, client: httpx.AsyncClient | None = None) -> Embedder:
|
|
242
|
+
if spec.provider == "hash":
|
|
243
|
+
return HashingEmbedder(spec)
|
|
244
|
+
return OpenAICompatEmbedder(spec, client=client)
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""Model specs: one string that says which model to call and how.
|
|
2
|
+
|
|
3
|
+
provider/model[,key=value,...]
|
|
4
|
+
|
|
5
|
+
Examples:
|
|
6
|
+
|
|
7
|
+
openai/text-embedding-3-small
|
|
8
|
+
openai/text-embedding-3-large,dims=256
|
|
9
|
+
ollama/nomic-embed-text
|
|
10
|
+
compat/BAAI/bge-m3,url=http://localhost:8080/v1,price=0
|
|
11
|
+
hash/512
|
|
12
|
+
|
|
13
|
+
Options: ``url``, ``dims``, ``price`` (USD per million tokens), ``key_env`` (the
|
|
14
|
+
environment variable holding the API key), ``query_prefix``, ``doc_prefix``, and
|
|
15
|
+
``batch`` (texts per request).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import re
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from urllib.parse import urlparse
|
|
23
|
+
|
|
24
|
+
# Hosts that keep traffic on this machine. ("0.0.0.0" is a destination here, not a bind.)
|
|
25
|
+
LOCAL_HOSTS = {"localhost", "127.0.0.1", "::1", "0.0.0.0", "host.docker.internal"} # noqa: S104
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class SpecError(ValueError):
|
|
29
|
+
"""A model spec couldn't be understood."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True, slots=True)
|
|
33
|
+
class Preset:
|
|
34
|
+
url: str | None
|
|
35
|
+
key_env: str | None
|
|
36
|
+
note: str
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
PRESETS = {
|
|
40
|
+
"openai": Preset("https://api.openai.com/v1", "OPENAI_API_KEY", "OpenAI"),
|
|
41
|
+
"ollama": Preset("http://localhost:11434/v1", None, "Ollama on this machine"),
|
|
42
|
+
# No default key: a key is only sent to a compat server when key_env= names one, so
|
|
43
|
+
# pointing a spec at a new URL never hands it a key meant for another server.
|
|
44
|
+
"compat": Preset(None, None, "any OpenAI-compatible server"),
|
|
45
|
+
"hash": Preset(None, None, "built-in hashing baseline, free and offline"),
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
# USD per million input tokens for OpenAI's embedding models. Prices change; pass
|
|
49
|
+
# price= in the spec to override.
|
|
50
|
+
KNOWN_PRICES = {
|
|
51
|
+
"text-embedding-3-small": 0.02,
|
|
52
|
+
"text-embedding-3-large": 0.13,
|
|
53
|
+
"text-embedding-ada-002": 0.10,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
# Native output size of well-known models, so plans know the target column size without
|
|
57
|
+
# calling the API. Matched on the model's base name, without any path prefix or tag.
|
|
58
|
+
KNOWN_DIMENSIONS = {
|
|
59
|
+
"text-embedding-3-small": 1536,
|
|
60
|
+
"text-embedding-3-large": 3072,
|
|
61
|
+
"text-embedding-ada-002": 1536,
|
|
62
|
+
"nomic-embed-text": 768,
|
|
63
|
+
"mxbai-embed-large": 1024,
|
|
64
|
+
"bge-m3": 1024,
|
|
65
|
+
"all-minilm": 384,
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
# Models known to accept a smaller ``dimensions`` request (Matryoshka truncation).
|
|
69
|
+
SUPPORTS_DIMENSIONS = {"text-embedding-3-small", "text-embedding-3-large"}
|
|
70
|
+
|
|
71
|
+
_SEARCH_INSTRUCTION = "Represent this sentence for searching relevant passages: "
|
|
72
|
+
|
|
73
|
+
# (pattern, query prefix, document prefix) for models trained with prompts. Using the
|
|
74
|
+
# wrong prefix quietly costs these models a lot of quality.
|
|
75
|
+
KNOWN_PREFIXES: tuple[tuple[re.Pattern[str], str, str], ...] = (
|
|
76
|
+
(re.compile(r"nomic-embed-text"), "search_query: ", "search_document: "),
|
|
77
|
+
(re.compile(r"(^|[/_-])e5-"), "query: ", "passage: "),
|
|
78
|
+
(re.compile(r"bge-(small|base|large)-(en|zh)"), _SEARCH_INSTRUCTION, ""),
|
|
79
|
+
(re.compile(r"mxbai-embed-large"), _SEARCH_INSTRUCTION, ""),
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass(frozen=True, slots=True)
|
|
84
|
+
class ModelSpec:
|
|
85
|
+
raw: str
|
|
86
|
+
provider: str
|
|
87
|
+
model: str
|
|
88
|
+
url: str | None = None
|
|
89
|
+
dimensions: int | None = None
|
|
90
|
+
price: float | None = None
|
|
91
|
+
"""USD per million tokens, or ``None`` if unknown."""
|
|
92
|
+
key_env: str | None = None
|
|
93
|
+
query_prefix: str = ""
|
|
94
|
+
doc_prefix: str = ""
|
|
95
|
+
batch_size: int = 64
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def name(self) -> str:
|
|
99
|
+
"""Short display name, such as ``openai/text-embedding-3-large (256d)``."""
|
|
100
|
+
base = f"{self.provider}/{self.model}"
|
|
101
|
+
explicit = self.dimensions and self.provider != "hash"
|
|
102
|
+
return f"{base} ({self.dimensions}d)" if explicit else base
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def is_local(self) -> bool:
|
|
106
|
+
"""Whether text stays on this machine."""
|
|
107
|
+
if self.provider == "hash":
|
|
108
|
+
return True
|
|
109
|
+
host = urlparse(self.url or "").hostname or ""
|
|
110
|
+
return host in LOCAL_HOSTS
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def safe_raw(self) -> str:
|
|
114
|
+
"""The spec as written, minus any URL query string, which can carry secrets."""
|
|
115
|
+
if not self.url or "?" not in self.url:
|
|
116
|
+
return self.raw
|
|
117
|
+
return self.raw.replace(self.url, self.url.split("?", 1)[0] + "?…")
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def sends_plaintext(self) -> bool:
|
|
121
|
+
"""Whether requests would cross the network unencrypted."""
|
|
122
|
+
return (self.url or "").startswith("http://") and not self.is_local
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def base_model(self) -> str:
|
|
126
|
+
"""``BAAI/bge-m3`` → ``bge-m3``; ``nomic-embed-text:latest`` → ``nomic-embed-text``."""
|
|
127
|
+
return self.model.rsplit("/", 1)[-1].split(":", 1)[0].lower()
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def native_dimensions(self) -> int | None:
|
|
131
|
+
return KNOWN_DIMENSIONS.get(self.base_model)
|
|
132
|
+
|
|
133
|
+
@property
|
|
134
|
+
def output_dimensions(self) -> int | None:
|
|
135
|
+
"""What this spec will produce, if known without calling the model."""
|
|
136
|
+
return self.dimensions or self.native_dimensions
|
|
137
|
+
|
|
138
|
+
@property
|
|
139
|
+
def identity(self) -> str:
|
|
140
|
+
"""Everything that changes the vectors this spec produces, for caching."""
|
|
141
|
+
return "|".join(
|
|
142
|
+
str(x)
|
|
143
|
+
for x in (
|
|
144
|
+
self.provider,
|
|
145
|
+
self.model,
|
|
146
|
+
self.url,
|
|
147
|
+
self.dimensions,
|
|
148
|
+
self.query_prefix,
|
|
149
|
+
self.doc_prefix,
|
|
150
|
+
)
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _number(key: str, value: str, kind: type[int] | type[float]) -> float:
|
|
155
|
+
try:
|
|
156
|
+
number = kind(value)
|
|
157
|
+
except ValueError:
|
|
158
|
+
raise SpecError(f"{key}= must be a number, got {value!r}") from None
|
|
159
|
+
if number < 0 or (kind is int and number == 0):
|
|
160
|
+
raise SpecError(f"{key}= must be positive, got {value!r}")
|
|
161
|
+
return number
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def parse_spec(raw: str) -> ModelSpec:
|
|
165
|
+
"""Parse ``provider/model[,key=value,...]``."""
|
|
166
|
+
head, *options = [part.strip() for part in raw.split(",")]
|
|
167
|
+
provider, sep, model = head.partition("/")
|
|
168
|
+
provider = provider.lower()
|
|
169
|
+
if not sep or not model:
|
|
170
|
+
raise SpecError(
|
|
171
|
+
f"Expected provider/model, got {raw!r}. For example: openai/text-embedding-3-small"
|
|
172
|
+
)
|
|
173
|
+
if provider not in PRESETS:
|
|
174
|
+
raise SpecError(f"Unknown provider {provider!r}. Use one of: {', '.join(sorted(PRESETS))}.")
|
|
175
|
+
preset = PRESETS[provider]
|
|
176
|
+
values: dict[str, str] = {}
|
|
177
|
+
for option in options:
|
|
178
|
+
key, eq, value = option.partition("=")
|
|
179
|
+
key = key.strip().lower()
|
|
180
|
+
if not eq or key not in {
|
|
181
|
+
"url",
|
|
182
|
+
"dims",
|
|
183
|
+
"price",
|
|
184
|
+
"key_env",
|
|
185
|
+
"query_prefix",
|
|
186
|
+
"doc_prefix",
|
|
187
|
+
"batch",
|
|
188
|
+
}:
|
|
189
|
+
raise SpecError(f"Unknown option {option!r} in {raw!r}")
|
|
190
|
+
values[key] = value
|
|
191
|
+
|
|
192
|
+
url = values.get("url", preset.url)
|
|
193
|
+
if provider == "compat" and not url:
|
|
194
|
+
raise SpecError(
|
|
195
|
+
f"compat models need a url=, for example {raw},url=http://localhost:8080/v1"
|
|
196
|
+
)
|
|
197
|
+
if url:
|
|
198
|
+
url = url.rstrip("/")
|
|
199
|
+
parsed = urlparse(url)
|
|
200
|
+
if parsed.scheme not in {"http", "https"}:
|
|
201
|
+
raise SpecError(f"url= must start with http:// or https://, got {url!r}")
|
|
202
|
+
if parsed.username or parsed.password:
|
|
203
|
+
raise SpecError(
|
|
204
|
+
"Don't put credentials in url=: they would end up in reports. Put the key in "
|
|
205
|
+
"an environment variable and name it with key_env=."
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
dimensions = int(_number("dims", values["dims"], int)) if "dims" in values else None
|
|
209
|
+
if provider == "hash":
|
|
210
|
+
dimensions = int(_number("hash dimensions", model, int))
|
|
211
|
+
|
|
212
|
+
price: float | None
|
|
213
|
+
if "price" in values:
|
|
214
|
+
price = float(_number("price", values["price"], float))
|
|
215
|
+
elif provider == "openai":
|
|
216
|
+
price = KNOWN_PRICES.get(model)
|
|
217
|
+
else:
|
|
218
|
+
price = 0.0 if provider in {"hash", "ollama"} else None
|
|
219
|
+
|
|
220
|
+
query_prefix, doc_prefix = "", ""
|
|
221
|
+
for pattern, qp, dp in KNOWN_PREFIXES:
|
|
222
|
+
if pattern.search(model.lower()):
|
|
223
|
+
query_prefix, doc_prefix = qp, dp
|
|
224
|
+
break
|
|
225
|
+
query_prefix = values.get("query_prefix", query_prefix)
|
|
226
|
+
doc_prefix = values.get("doc_prefix", doc_prefix)
|
|
227
|
+
|
|
228
|
+
batch = int(_number("batch", values["batch"], int)) if "batch" in values else 64
|
|
229
|
+
return ModelSpec(
|
|
230
|
+
raw=raw,
|
|
231
|
+
provider=provider,
|
|
232
|
+
model=model,
|
|
233
|
+
url=url,
|
|
234
|
+
dimensions=dimensions,
|
|
235
|
+
price=price,
|
|
236
|
+
key_env=values.get("key_env", preset.key_env),
|
|
237
|
+
query_prefix=query_prefix,
|
|
238
|
+
doc_prefix=doc_prefix,
|
|
239
|
+
batch_size=batch,
|
|
240
|
+
)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Compare a migration's old and new vectors before cutover: quality and latency."""
|
|
2
|
+
|
|
3
|
+
from vecshift.eval.metrics import Latency, Scores
|
|
4
|
+
from vecshift.eval.queries import EvalQuery, proxy_queries, script
|
|
5
|
+
from vecshift.eval.runner import EvalReport, Gates, SideReport, SweepPoint, Verdict, decide, run
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"EvalQuery",
|
|
9
|
+
"EvalReport",
|
|
10
|
+
"Gates",
|
|
11
|
+
"Latency",
|
|
12
|
+
"Scores",
|
|
13
|
+
"SideReport",
|
|
14
|
+
"SweepPoint",
|
|
15
|
+
"Verdict",
|
|
16
|
+
"decide",
|
|
17
|
+
"proxy_queries",
|
|
18
|
+
"run",
|
|
19
|
+
"script",
|
|
20
|
+
]
|