relvector 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.
- relvector/__init__.py +48 -0
- relvector/_version.py +1 -0
- relvector/api.py +187 -0
- relvector/embeddings.py +146 -0
- relvector/errors.py +31 -0
- relvector/extraction.py +141 -0
- relvector/indexing.py +138 -0
- relvector/integrations/__init__.py +1 -0
- relvector/integrations/sqlguard.py +33 -0
- relvector/mapping.py +405 -0
- relvector/py.typed +0 -0
- relvector/schema.py +130 -0
- relvector/store.py +410 -0
- relvector-0.1.0.dist-info/METADATA +189 -0
- relvector-0.1.0.dist-info/RECORD +18 -0
- relvector-0.1.0.dist-info/WHEEL +4 -0
- relvector-0.1.0.dist-info/licenses/LICENSE +202 -0
- relvector-0.1.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +54 -0
relvector/schema.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Read-only schema inspection through SQLAlchemy's inspector.
|
|
2
|
+
|
|
3
|
+
Inspection reads catalog metadata only (tables, columns, primary keys, declared foreign keys);
|
|
4
|
+
it never reads row data and never issues DDL or DML.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Iterable
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
|
|
12
|
+
from sqlalchemy import inspect
|
|
13
|
+
from sqlalchemy.engine import Engine
|
|
14
|
+
|
|
15
|
+
from relvector.errors import SchemaError
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class ColumnInfo:
|
|
20
|
+
name: str
|
|
21
|
+
type: str
|
|
22
|
+
nullable: bool
|
|
23
|
+
primary_key: bool
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class ForeignKey:
|
|
28
|
+
"""``table.columns`` references ``ref_table.ref_columns`` (declared in the database)."""
|
|
29
|
+
|
|
30
|
+
table: str
|
|
31
|
+
columns: tuple[str, ...]
|
|
32
|
+
ref_table: str
|
|
33
|
+
ref_columns: tuple[str, ...]
|
|
34
|
+
name: str | None = None
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def composite(self) -> bool:
|
|
38
|
+
return len(self.columns) > 1
|
|
39
|
+
|
|
40
|
+
def __str__(self) -> str:
|
|
41
|
+
cols = ", ".join(self.columns)
|
|
42
|
+
refs = ", ".join(self.ref_columns)
|
|
43
|
+
return f"{self.table}({cols}) -> {self.ref_table}({refs})"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class TableInfo:
|
|
48
|
+
name: str
|
|
49
|
+
columns: tuple[ColumnInfo, ...]
|
|
50
|
+
primary_key: tuple[str, ...]
|
|
51
|
+
foreign_keys: tuple[ForeignKey, ...] = ()
|
|
52
|
+
|
|
53
|
+
def column(self, name: str) -> ColumnInfo:
|
|
54
|
+
for col in self.columns:
|
|
55
|
+
if col.name == name:
|
|
56
|
+
return col
|
|
57
|
+
raise SchemaError(f"table {self.name!r} has no column {name!r}")
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def column_names(self) -> tuple[str, ...]:
|
|
61
|
+
return tuple(c.name for c in self.columns)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True)
|
|
65
|
+
class Schema:
|
|
66
|
+
tables: dict[str, TableInfo] = field(default_factory=dict)
|
|
67
|
+
schema: str | None = None
|
|
68
|
+
|
|
69
|
+
def table(self, name: str) -> TableInfo:
|
|
70
|
+
try:
|
|
71
|
+
return self.tables[name]
|
|
72
|
+
except KeyError:
|
|
73
|
+
known = ", ".join(sorted(self.tables)) or "(none)"
|
|
74
|
+
raise SchemaError(f"unknown table {name!r}; known tables: {known}") from None
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def foreign_keys(self) -> list[ForeignKey]:
|
|
78
|
+
return [fk for t in self.tables.values() for fk in t.foreign_keys]
|
|
79
|
+
|
|
80
|
+
def relationships_between(self, a: str, b: str) -> list[ForeignKey]:
|
|
81
|
+
"""Declared foreign keys linking ``a`` and ``b`` in either direction."""
|
|
82
|
+
return [
|
|
83
|
+
fk
|
|
84
|
+
for fk in self.foreign_keys
|
|
85
|
+
if {fk.table, fk.ref_table} == {a, b} or (a == b == fk.table == fk.ref_table)
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
def describe(self) -> str:
|
|
89
|
+
"""Compact, human-readable summary (no row data)."""
|
|
90
|
+
lines: list[str] = []
|
|
91
|
+
for t in sorted(self.tables.values(), key=lambda t: t.name):
|
|
92
|
+
cols = ", ".join(
|
|
93
|
+
f"{c.name}:{c.type}{'*' if c.primary_key else ''}" for c in t.columns
|
|
94
|
+
)
|
|
95
|
+
lines.append(f"{t.name} ({cols})")
|
|
96
|
+
lines.extend(f" FK {fk}" for fk in t.foreign_keys)
|
|
97
|
+
return "\n".join(lines)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def inspect_schema(
|
|
101
|
+
engine: Engine, *, schema: str | None = None, tables: Iterable[str] | None = None
|
|
102
|
+
) -> Schema:
|
|
103
|
+
"""Inspect tables, columns, primary keys and declared foreign keys."""
|
|
104
|
+
insp = inspect(engine)
|
|
105
|
+
names = list(tables) if tables is not None else insp.get_table_names(schema=schema)
|
|
106
|
+
out: dict[str, TableInfo] = {}
|
|
107
|
+
for name in names:
|
|
108
|
+
pk = tuple(insp.get_pk_constraint(name, schema=schema).get("constrained_columns") or ())
|
|
109
|
+
cols = tuple(
|
|
110
|
+
ColumnInfo(
|
|
111
|
+
name=c["name"],
|
|
112
|
+
type=str(c["type"]),
|
|
113
|
+
nullable=bool(c.get("nullable", True)),
|
|
114
|
+
primary_key=c["name"] in pk,
|
|
115
|
+
)
|
|
116
|
+
for c in insp.get_columns(name, schema=schema)
|
|
117
|
+
)
|
|
118
|
+
fks = tuple(
|
|
119
|
+
ForeignKey(
|
|
120
|
+
table=name,
|
|
121
|
+
columns=tuple(fk["constrained_columns"]),
|
|
122
|
+
ref_table=fk["referred_table"],
|
|
123
|
+
ref_columns=tuple(fk["referred_columns"]),
|
|
124
|
+
name=fk.get("name"),
|
|
125
|
+
)
|
|
126
|
+
for fk in insp.get_foreign_keys(name, schema=schema)
|
|
127
|
+
if fk.get("referred_table")
|
|
128
|
+
)
|
|
129
|
+
out[name] = TableInfo(name=name, columns=cols, primary_key=pk, foreign_keys=fks)
|
|
130
|
+
return Schema(tables=out, schema=schema)
|
relvector/store.py
ADDED
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
"""Persistent local vector store: one SQLite file holding vectors, metadata and checkpoints.
|
|
2
|
+
|
|
3
|
+
Vectors are stored as raw little-endian float32 blobs and read back with ``numpy.frombuffer``;
|
|
4
|
+
metadata is JSON. Nothing is ever unpickled, so opening an index file cannot execute code.
|
|
5
|
+
|
|
6
|
+
Search is exact cosine similarity, streamed from disk in fixed-size chunks while keeping only
|
|
7
|
+
the best ``top_k`` hits, so memory stays bounded however large the index is. Time is still
|
|
8
|
+
linear in the number of (filtered) vectors per query; very large or latency-sensitive
|
|
9
|
+
workloads need an ANN backend implementing :class:`VectorStore`.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import datetime as dt
|
|
15
|
+
import heapq
|
|
16
|
+
import json
|
|
17
|
+
import sqlite3
|
|
18
|
+
import uuid
|
|
19
|
+
from collections.abc import Iterable, Mapping, Sequence
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from decimal import Decimal
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any, Protocol
|
|
24
|
+
|
|
25
|
+
import numpy as np
|
|
26
|
+
from numpy.typing import NDArray
|
|
27
|
+
|
|
28
|
+
from relvector.embeddings import Vectors, normalize
|
|
29
|
+
from relvector.errors import IndexMismatchError, RelVectorError, RunSupersededError
|
|
30
|
+
from relvector.mapping import SemanticRecord
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class SearchResult:
|
|
35
|
+
vector_id: str
|
|
36
|
+
score: float
|
|
37
|
+
text: str
|
|
38
|
+
entity: str
|
|
39
|
+
source_table: str
|
|
40
|
+
source_pk: dict[str, Any]
|
|
41
|
+
metadata: dict[str, Any]
|
|
42
|
+
references: list[dict[str, Any]]
|
|
43
|
+
truncated: dict[str, int]
|
|
44
|
+
|
|
45
|
+
def __str__(self) -> str:
|
|
46
|
+
return f"[{self.score:.3f}] {self.source_table} {self.source_pk}: {self.text}"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class Checkpoint:
|
|
51
|
+
entity: str
|
|
52
|
+
run_id: str
|
|
53
|
+
last_key: list[Any] | None
|
|
54
|
+
status: str
|
|
55
|
+
processed: int
|
|
56
|
+
fingerprint: str = ""
|
|
57
|
+
owner: str = ""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class VectorStore(Protocol):
|
|
61
|
+
"""Adapter interface for vector backends."""
|
|
62
|
+
|
|
63
|
+
def ensure_compatible(self, model_id: str, dimension: int) -> None: ...
|
|
64
|
+
def content_hashes(self, vector_ids: Sequence[str]) -> dict[str, str]: ...
|
|
65
|
+
def start_run(self, checkpoint: Checkpoint) -> None: ...
|
|
66
|
+
def resume_run(self, checkpoint: Checkpoint) -> None: ...
|
|
67
|
+
def write_batch(self, records: Sequence[SemanticRecord], vectors: Vectors,
|
|
68
|
+
unchanged_ids: Sequence[str], checkpoint: Checkpoint) -> None: ...
|
|
69
|
+
def finish_run(self, checkpoint: Checkpoint, *, prune: bool) -> int: ...
|
|
70
|
+
def checkpoint(self, entity: str) -> Checkpoint | None: ...
|
|
71
|
+
def search(self, vector: Vectors, top_k: int, *, entity: str | None = None,
|
|
72
|
+
filters: Mapping[str, Any] | None = None) -> list[SearchResult]: ...
|
|
73
|
+
def count(self, entity: str | None = None) -> int: ...
|
|
74
|
+
def close(self) -> None: ...
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
_SCHEMA = """
|
|
78
|
+
CREATE TABLE IF NOT EXISTS rv_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
|
79
|
+
CREATE TABLE IF NOT EXISTS rv_records (
|
|
80
|
+
vector_id TEXT PRIMARY KEY,
|
|
81
|
+
entity TEXT NOT NULL,
|
|
82
|
+
source_table TEXT NOT NULL,
|
|
83
|
+
source_pk TEXT NOT NULL,
|
|
84
|
+
text TEXT NOT NULL,
|
|
85
|
+
metadata TEXT NOT NULL,
|
|
86
|
+
refs TEXT NOT NULL,
|
|
87
|
+
truncated TEXT NOT NULL,
|
|
88
|
+
mapping_version TEXT NOT NULL,
|
|
89
|
+
content_hash TEXT NOT NULL,
|
|
90
|
+
vector BLOB NOT NULL,
|
|
91
|
+
run_id TEXT NOT NULL,
|
|
92
|
+
updated_at TEXT NOT NULL
|
|
93
|
+
);
|
|
94
|
+
CREATE INDEX IF NOT EXISTS rv_records_entity_run ON rv_records (entity, run_id);
|
|
95
|
+
CREATE TABLE IF NOT EXISTS rv_checkpoints (
|
|
96
|
+
entity TEXT PRIMARY KEY,
|
|
97
|
+
run_id TEXT NOT NULL,
|
|
98
|
+
last_key TEXT,
|
|
99
|
+
status TEXT NOT NULL,
|
|
100
|
+
processed INTEGER NOT NULL,
|
|
101
|
+
updated_at TEXT NOT NULL,
|
|
102
|
+
fingerprint TEXT NOT NULL DEFAULT '',
|
|
103
|
+
owner TEXT NOT NULL DEFAULT ''
|
|
104
|
+
);
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
_FORMAT_VERSION = "1"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _now() -> str:
|
|
111
|
+
return dt.datetime.now(dt.UTC).isoformat(timespec="seconds")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# Checkpoint keys keep their Python type through JSON so that, on resume, ``WHERE pk > :last``
|
|
115
|
+
# binds the same type the column holds and the database compares it with the column's own
|
|
116
|
+
# ordering (a date compared as a date, a UUID as a UUID), never as text.
|
|
117
|
+
_KEY_TYPES: list[tuple[str, type, Any, Any]] = [
|
|
118
|
+
("datetime", dt.datetime, dt.datetime.isoformat, dt.datetime.fromisoformat),
|
|
119
|
+
("date", dt.date, dt.date.isoformat, dt.date.fromisoformat),
|
|
120
|
+
("time", dt.time, dt.time.isoformat, dt.time.fromisoformat),
|
|
121
|
+
("decimal", Decimal, str, Decimal),
|
|
122
|
+
("uuid", uuid.UUID, str, uuid.UUID),
|
|
123
|
+
("bytes", bytes, bytes.hex, bytes.fromhex),
|
|
124
|
+
]
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def encode_key(values: Sequence[Any]) -> list[Any]:
|
|
128
|
+
"""Encode a primary-key tuple for JSON without losing its types."""
|
|
129
|
+
out: list[Any] = []
|
|
130
|
+
for v in values:
|
|
131
|
+
if v is None or isinstance(v, bool | int | float | str):
|
|
132
|
+
out.append(v)
|
|
133
|
+
continue
|
|
134
|
+
if isinstance(v, memoryview | bytearray):
|
|
135
|
+
v = bytes(v)
|
|
136
|
+
for tag, typ, dump, _ in _KEY_TYPES: # datetime precedes date: it is a subclass
|
|
137
|
+
if isinstance(v, typ):
|
|
138
|
+
out.append({"$t": tag, "v": dump(v)})
|
|
139
|
+
break
|
|
140
|
+
else:
|
|
141
|
+
raise RelVectorError(
|
|
142
|
+
f"primary-key values of type {type(v).__name__} cannot be checkpointed; "
|
|
143
|
+
"supported: int, float, str, bool, date, datetime, time, Decimal, UUID, bytes"
|
|
144
|
+
)
|
|
145
|
+
return out
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def decode_key(values: Sequence[Any]) -> list[Any]:
|
|
149
|
+
loads = {tag: load for tag, _, _, load in _KEY_TYPES}
|
|
150
|
+
return [loads[v["$t"]](v["v"]) if isinstance(v, dict) else v for v in values]
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _dumps(value: Any) -> str:
|
|
154
|
+
return json.dumps(value, sort_keys=True, separators=(",", ":"))
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class LocalVectorStore:
|
|
158
|
+
"""SQLite-backed :class:`VectorStore` (the ``vector_store="local"`` backend)."""
|
|
159
|
+
|
|
160
|
+
def __init__(self, path: str | Path, *, search_chunk_size: int = 1024) -> None:
|
|
161
|
+
if search_chunk_size < 1:
|
|
162
|
+
raise ValueError("search_chunk_size must be >= 1")
|
|
163
|
+
self.path = Path(path)
|
|
164
|
+
self.search_chunk_size = search_chunk_size
|
|
165
|
+
self._conn = sqlite3.connect(self.path)
|
|
166
|
+
self._conn.execute("PRAGMA journal_mode=WAL")
|
|
167
|
+
self._conn.executescript(_SCHEMA)
|
|
168
|
+
cols = {r[1] for r in self._conn.execute("PRAGMA table_info(rv_checkpoints)")}
|
|
169
|
+
for col in ("fingerprint", "owner"): # added after the first 0.1.0.dev0 index files
|
|
170
|
+
if col not in cols:
|
|
171
|
+
self._conn.execute(
|
|
172
|
+
f"ALTER TABLE rv_checkpoints ADD COLUMN {col} TEXT NOT NULL DEFAULT ''")
|
|
173
|
+
self._conn.execute("INSERT OR IGNORE INTO rv_meta VALUES ('format_version', ?)",
|
|
174
|
+
(_FORMAT_VERSION,))
|
|
175
|
+
self._conn.commit()
|
|
176
|
+
fmt = self._meta("format_version")
|
|
177
|
+
if fmt != _FORMAT_VERSION:
|
|
178
|
+
raise IndexMismatchError(f"{self.path} uses index format {fmt}, expected "
|
|
179
|
+
f"{_FORMAT_VERSION}")
|
|
180
|
+
self._dimension = int(self._meta("dimension") or 0)
|
|
181
|
+
|
|
182
|
+
def _meta(self, key: str) -> str | None:
|
|
183
|
+
row = self._conn.execute("SELECT value FROM rv_meta WHERE key = ?", (key,)).fetchone()
|
|
184
|
+
return row[0] if row else None
|
|
185
|
+
|
|
186
|
+
def ensure_compatible(self, model_id: str, dimension: int) -> None:
|
|
187
|
+
stored_model, stored_dim = self._meta("model_id"), self._meta("dimension")
|
|
188
|
+
if stored_model is None:
|
|
189
|
+
with self._conn:
|
|
190
|
+
self._conn.execute("INSERT INTO rv_meta VALUES ('model_id', ?)", (model_id,))
|
|
191
|
+
self._conn.execute("INSERT INTO rv_meta VALUES ('dimension', ?)",
|
|
192
|
+
(str(dimension),))
|
|
193
|
+
self._dimension = dimension
|
|
194
|
+
return
|
|
195
|
+
if stored_model != model_id or int(stored_dim or 0) != dimension:
|
|
196
|
+
raise IndexMismatchError(
|
|
197
|
+
f"{self.path} was built with {stored_model} ({stored_dim} dims) but the provider "
|
|
198
|
+
f"is {model_id} ({dimension} dims); use a new index path to change models"
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
@property
|
|
202
|
+
def model_id(self) -> str | None:
|
|
203
|
+
return self._meta("model_id")
|
|
204
|
+
|
|
205
|
+
def content_hashes(self, vector_ids: Sequence[str]) -> dict[str, str]:
|
|
206
|
+
out: dict[str, str] = {}
|
|
207
|
+
ids = list(vector_ids)
|
|
208
|
+
for i in range(0, len(ids), 500):
|
|
209
|
+
chunk = ids[i:i + 500]
|
|
210
|
+
marks = ",".join("?" * len(chunk))
|
|
211
|
+
out.update(self._conn.execute(
|
|
212
|
+
f"SELECT vector_id, content_hash FROM rv_records WHERE vector_id IN ({marks})", # noqa: S608
|
|
213
|
+
chunk,
|
|
214
|
+
).fetchall())
|
|
215
|
+
return out
|
|
216
|
+
|
|
217
|
+
def write_batch(
|
|
218
|
+
self,
|
|
219
|
+
records: Sequence[SemanticRecord],
|
|
220
|
+
vectors: Vectors,
|
|
221
|
+
unchanged_ids: Sequence[str],
|
|
222
|
+
checkpoint: Checkpoint,
|
|
223
|
+
) -> None:
|
|
224
|
+
"""Upsert changed records, mark unchanged ones as seen, advance the checkpoint — atomically.
|
|
225
|
+
|
|
226
|
+
Raises :class:`RunSupersededError` (writing nothing) if the writer no longer owns the
|
|
227
|
+
entity's checkpoint because a newer run started or another process resumed this run.
|
|
228
|
+
"""
|
|
229
|
+
run_id = checkpoint.run_id
|
|
230
|
+
if len(records) != len(vectors):
|
|
231
|
+
raise RelVectorError("records and vectors differ in length")
|
|
232
|
+
if len(records) and vectors.shape[1] != self._dimension:
|
|
233
|
+
raise IndexMismatchError(f"vector dimension {vectors.shape[1]} != {self._dimension}")
|
|
234
|
+
vecs = normalize(vectors).astype("<f4") if len(records) else vectors
|
|
235
|
+
now = _now()
|
|
236
|
+
with self._conn:
|
|
237
|
+
self._claim(checkpoint, "running")
|
|
238
|
+
self._conn.executemany(
|
|
239
|
+
"INSERT INTO rv_records VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) "
|
|
240
|
+
"ON CONFLICT(vector_id) DO UPDATE SET entity=excluded.entity, "
|
|
241
|
+
"source_table=excluded.source_table, source_pk=excluded.source_pk, "
|
|
242
|
+
"text=excluded.text, metadata=excluded.metadata, refs=excluded.refs, "
|
|
243
|
+
"truncated=excluded.truncated, mapping_version=excluded.mapping_version, "
|
|
244
|
+
"content_hash=excluded.content_hash, vector=excluded.vector, "
|
|
245
|
+
"run_id=excluded.run_id, updated_at=excluded.updated_at",
|
|
246
|
+
[
|
|
247
|
+
(r.vector_id, r.entity, r.source_table, _dumps(r.source_pk), r.text,
|
|
248
|
+
_dumps(r.metadata), _dumps(r.references), _dumps(r.truncated),
|
|
249
|
+
r.mapping_version, r.content_hash, v.tobytes(), run_id, now)
|
|
250
|
+
for r, v in zip(records, vecs, strict=True)
|
|
251
|
+
],
|
|
252
|
+
)
|
|
253
|
+
self._conn.executemany("UPDATE rv_records SET run_id = ? WHERE vector_id = ?",
|
|
254
|
+
[(run_id, i) for i in unchanged_ids])
|
|
255
|
+
|
|
256
|
+
def _claim(self, cp: Checkpoint, status: str) -> None:
|
|
257
|
+
"""Compare-and-set: advance the checkpoint only if ``cp.owner`` still holds it."""
|
|
258
|
+
key = None if cp.last_key is None else _dumps(encode_key(cp.last_key))
|
|
259
|
+
cur = self._conn.execute(
|
|
260
|
+
"UPDATE rv_checkpoints SET last_key = ?, processed = ?, status = ?, updated_at = ? "
|
|
261
|
+
"WHERE entity = ? AND run_id = ? AND owner = ? AND status = 'running'",
|
|
262
|
+
(key, cp.processed, status, _now(), cp.entity, cp.run_id, cp.owner),
|
|
263
|
+
)
|
|
264
|
+
if cur.rowcount != 1:
|
|
265
|
+
raise RunSupersededError(
|
|
266
|
+
f"indexing run {cp.run_id} for {cp.entity!r} no longer owns the checkpoint (a "
|
|
267
|
+
"newer run started, another process resumed it, or it already finished); "
|
|
268
|
+
"stopped without writing or pruning"
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
def start_run(self, checkpoint: Checkpoint) -> None:
|
|
272
|
+
"""Begin a run, taking over the entity's checkpoint from any earlier run or writer."""
|
|
273
|
+
cp = checkpoint
|
|
274
|
+
with self._conn:
|
|
275
|
+
self._conn.execute(
|
|
276
|
+
"INSERT OR REPLACE INTO rv_checkpoints VALUES (?,?,?,?,?,?,?,?)",
|
|
277
|
+
(cp.entity, cp.run_id, None, "running", cp.processed, _now(), cp.fingerprint,
|
|
278
|
+
cp.owner),
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
def resume_run(self, checkpoint: Checkpoint) -> None:
|
|
282
|
+
"""Take over writing for an unfinished run as ``checkpoint.owner``.
|
|
283
|
+
|
|
284
|
+
Single writer: whichever process resumed last owns the run; an earlier writer of the
|
|
285
|
+
same run fails with :class:`RunSupersededError` at its next batch and never prunes.
|
|
286
|
+
"""
|
|
287
|
+
cp = checkpoint
|
|
288
|
+
with self._conn:
|
|
289
|
+
cur = self._conn.execute(
|
|
290
|
+
"UPDATE rv_checkpoints SET owner = ?, updated_at = ? "
|
|
291
|
+
"WHERE entity = ? AND run_id = ? AND status = 'running'",
|
|
292
|
+
(cp.owner, _now(), cp.entity, cp.run_id),
|
|
293
|
+
)
|
|
294
|
+
if cur.rowcount != 1:
|
|
295
|
+
raise RunSupersededError(f"run {cp.run_id} for {cp.entity!r} is no longer resumable")
|
|
296
|
+
|
|
297
|
+
def finish_run(self, checkpoint: Checkpoint, *, prune: bool) -> int:
|
|
298
|
+
"""Mark the run completed and, if ``prune``, delete records it did not see — atomically.
|
|
299
|
+
|
|
300
|
+
Only the writer that still owns the checkpoint can finish, so an interrupted or
|
|
301
|
+
superseded run never prunes. Returns the number of deleted records.
|
|
302
|
+
"""
|
|
303
|
+
cp = checkpoint
|
|
304
|
+
with self._conn:
|
|
305
|
+
self._claim(Checkpoint(cp.entity, cp.run_id, None, "running", cp.processed,
|
|
306
|
+
cp.fingerprint, cp.owner), "completed")
|
|
307
|
+
deleted = 0
|
|
308
|
+
if prune:
|
|
309
|
+
deleted = self._conn.execute(
|
|
310
|
+
"DELETE FROM rv_records WHERE entity = ? AND run_id != ?",
|
|
311
|
+
(cp.entity, cp.run_id)).rowcount
|
|
312
|
+
return deleted
|
|
313
|
+
|
|
314
|
+
def checkpoint(self, entity: str) -> Checkpoint | None:
|
|
315
|
+
row = self._conn.execute(
|
|
316
|
+
"SELECT entity, run_id, last_key, status, processed, fingerprint, owner "
|
|
317
|
+
"FROM rv_checkpoints WHERE entity = ?", (entity,)).fetchone()
|
|
318
|
+
if row is None:
|
|
319
|
+
return None
|
|
320
|
+
key = None if row[2] is None else decode_key(json.loads(row[2]))
|
|
321
|
+
return Checkpoint(row[0], row[1], key, row[3], row[4], row[5], row[6])
|
|
322
|
+
|
|
323
|
+
def count(self, entity: str | None = None) -> int:
|
|
324
|
+
if entity is None:
|
|
325
|
+
return int(self._conn.execute("SELECT COUNT(*) FROM rv_records").fetchone()[0])
|
|
326
|
+
return int(self._conn.execute(
|
|
327
|
+
"SELECT COUNT(*) FROM rv_records WHERE entity = ?", (entity,)).fetchone()[0])
|
|
328
|
+
|
|
329
|
+
def _where(self, entity: str | None, filters: Mapping[str, Any] | None
|
|
330
|
+
) -> tuple[str, list[Any]]:
|
|
331
|
+
clauses, params = [], []
|
|
332
|
+
if entity is not None:
|
|
333
|
+
clauses.append("entity = ?")
|
|
334
|
+
params.append(entity)
|
|
335
|
+
for key, value in (filters or {}).items():
|
|
336
|
+
if '"' in key or "\\" in key:
|
|
337
|
+
raise RelVectorError(f"invalid metadata filter key {key!r}")
|
|
338
|
+
clauses.append("json_extract(metadata, ?) IS ?")
|
|
339
|
+
params.extend([f'$."{key}"', value])
|
|
340
|
+
return (" WHERE " + " AND ".join(clauses)) if clauses else "", params
|
|
341
|
+
|
|
342
|
+
def search(self, vector: Vectors, top_k: int, *, entity: str | None = None,
|
|
343
|
+
filters: Mapping[str, Any] | None = None) -> list[SearchResult]:
|
|
344
|
+
"""Exact cosine top-k, streamed: memory is bounded by ``search_chunk_size`` and ``top_k``.
|
|
345
|
+
|
|
346
|
+
Rows are read from a single SQLite cursor ``search_chunk_size`` at a time and decoded
|
|
347
|
+
into one reused buffer, so the full vector collection is never held in memory. Entity
|
|
348
|
+
and metadata filters run in SQL, so the vectors of non-matching rows are never decoded.
|
|
349
|
+
Ties are broken by ``vector_id`` ascending, which keeps results independent of chunk
|
|
350
|
+
size and row order.
|
|
351
|
+
"""
|
|
352
|
+
if top_k < 1:
|
|
353
|
+
raise ValueError("top_k must be >= 1")
|
|
354
|
+
if not self._dimension:
|
|
355
|
+
return []
|
|
356
|
+
query = normalize(np.asarray(vector, dtype=np.float32).reshape(1, -1))[0]
|
|
357
|
+
if query.shape[0] != self._dimension:
|
|
358
|
+
raise IndexMismatchError(f"query dimension {query.shape[0]} != {self._dimension}")
|
|
359
|
+
return self._fetch(self._top_k(query.astype(np.float64), top_k, entity, filters))
|
|
360
|
+
|
|
361
|
+
def _top_k(self, query: NDArray[np.float64], top_k: int, entity: str | None,
|
|
362
|
+
filters: Mapping[str, Any] | None) -> list[tuple[str, float]]:
|
|
363
|
+
where, params = self._where(entity, filters)
|
|
364
|
+
# No ORDER BY: the scan streams in storage order (no sort buffer holding every row);
|
|
365
|
+
# determinism comes from the explicit (score desc, vector_id asc) ranking below.
|
|
366
|
+
cur = self._conn.execute(
|
|
367
|
+
f"SELECT vector_id, vector FROM rv_records{where}", params) # noqa: S608
|
|
368
|
+
dim, chunk = self._dimension, self.search_chunk_size
|
|
369
|
+
width = dim * 4
|
|
370
|
+
buf = np.empty((chunk, dim), dtype="<f4") # decoded vectors, reused for every chunk
|
|
371
|
+
prod = np.empty((chunk, dim), dtype=np.float64) # products, reused for every chunk
|
|
372
|
+
best: list[tuple[float, str]] = [] # (-score, vector_id), at most top_k entries
|
|
373
|
+
while rows := cur.fetchmany(chunk):
|
|
374
|
+
n = len(rows)
|
|
375
|
+
for i, (_, blob) in enumerate(rows):
|
|
376
|
+
if len(blob) != width:
|
|
377
|
+
raise IndexMismatchError(f"stored vector {rows[i][0]!r} has {len(blob)} "
|
|
378
|
+
f"bytes, expected {width}")
|
|
379
|
+
buf[i] = np.frombuffer(blob, dtype="<f4")
|
|
380
|
+
# Row-wise float64 sums do not depend on how many rows are in the chunk, so a
|
|
381
|
+
# vector's score (and hence tie-breaking) is identical for every chunk size.
|
|
382
|
+
np.multiply(buf[:n], query, out=prod[:n])
|
|
383
|
+
scores = prod[:n].sum(axis=1)
|
|
384
|
+
# Prune before building Python tuples. ">=" keeps ties so vector_id can break them.
|
|
385
|
+
floor = -np.inf
|
|
386
|
+
if n > top_k: # a row below the chunk's k-th best can never reach the top k
|
|
387
|
+
floor = np.partition(scores, n - top_k)[n - top_k]
|
|
388
|
+
if len(best) == top_k:
|
|
389
|
+
floor = max(floor, -best[-1][0])
|
|
390
|
+
keep = np.flatnonzero(scores >= floor)
|
|
391
|
+
if keep.size:
|
|
392
|
+
best = heapq.nsmallest(
|
|
393
|
+
top_k, [*best, *((-float(scores[i]), rows[i][0]) for i in keep)])
|
|
394
|
+
return [(vid, -neg) for neg, vid in best]
|
|
395
|
+
|
|
396
|
+
def _fetch(self, hits: Iterable[tuple[str, float]]) -> list[SearchResult]:
|
|
397
|
+
out = []
|
|
398
|
+
for vid, score in hits:
|
|
399
|
+
r = self._conn.execute(
|
|
400
|
+
"SELECT text, entity, source_table, source_pk, metadata, refs, truncated "
|
|
401
|
+
"FROM rv_records WHERE vector_id = ?", (vid,)).fetchone()
|
|
402
|
+
out.append(SearchResult(
|
|
403
|
+
vector_id=vid, score=score, text=r[0], entity=r[1], source_table=r[2],
|
|
404
|
+
source_pk=json.loads(r[3]), metadata=json.loads(r[4]),
|
|
405
|
+
references=json.loads(r[5]), truncated=json.loads(r[6]),
|
|
406
|
+
))
|
|
407
|
+
return out
|
|
408
|
+
|
|
409
|
+
def close(self) -> None:
|
|
410
|
+
self._conn.close()
|