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 ADDED
@@ -0,0 +1,48 @@
1
+ """RelVector — schema-aware semantic indexes over existing relational databases.
2
+
3
+ RelVector does not turn a SQL database into a vector database. It builds and maintains a
4
+ semantic index over *selected* relational data, keeping source references back to the rows;
5
+ the original database remains the authoritative source and is only ever read.
6
+ """
7
+
8
+ from relvector._version import __version__
9
+ from relvector.api import RelVector, connect
10
+ from relvector.embeddings import EmbeddingProvider, HashingEmbedding, SentenceTransformerEmbedding
11
+ from relvector.errors import (
12
+ IndexMismatchError,
13
+ MappingError,
14
+ QueryRejectedError,
15
+ RelVectorError,
16
+ RunSupersededError,
17
+ SchemaError,
18
+ SensitiveFieldError,
19
+ )
20
+ from relvector.indexing import IndexStats
21
+ from relvector.mapping import EntityMapping, Include, SemanticRecord
22
+ from relvector.schema import Schema, inspect_schema
23
+ from relvector.store import LocalVectorStore, SearchResult, VectorStore
24
+
25
+ __all__ = [
26
+ "EmbeddingProvider",
27
+ "EntityMapping",
28
+ "HashingEmbedding",
29
+ "Include",
30
+ "IndexMismatchError",
31
+ "IndexStats",
32
+ "LocalVectorStore",
33
+ "MappingError",
34
+ "QueryRejectedError",
35
+ "RelVector",
36
+ "RelVectorError",
37
+ "RunSupersededError",
38
+ "Schema",
39
+ "SchemaError",
40
+ "SearchResult",
41
+ "SemanticRecord",
42
+ "SensitiveFieldError",
43
+ "SentenceTransformerEmbedding",
44
+ "VectorStore",
45
+ "__version__",
46
+ "connect",
47
+ "inspect_schema",
48
+ ]
relvector/_version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
relvector/api.py ADDED
@@ -0,0 +1,187 @@
1
+ """The :class:`RelVector` facade: connect, inspect, configure, index, search."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sqlite3
6
+ from collections.abc import Callable, Mapping, Sequence
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from sqlalchemy import create_engine
11
+ from sqlalchemy.engine import Engine, make_url
12
+
13
+ from relvector.embeddings import EmbeddingProvider, get_provider
14
+ from relvector.errors import MappingError, RelVectorError
15
+ from relvector.extraction import Extractor, SqlValidator
16
+ from relvector.indexing import Indexer, IndexStats
17
+ from relvector.mapping import EntityMapping, Include, ResolvedMapping, resolve
18
+ from relvector.schema import Schema, inspect_schema
19
+ from relvector.store import LocalVectorStore, SearchResult, VectorStore
20
+
21
+ SUPPORTED_DIALECTS = ("sqlite", "postgresql")
22
+
23
+
24
+ def connect(source_url: str) -> Engine:
25
+ """Create a read-only engine for a SQLite file or a PostgreSQL database.
26
+
27
+ SQLite files are opened with ``mode=ro`` (a missing file is an error, never silently
28
+ created); PostgreSQL sessions are set to ``READ ONLY`` transactions. Read-only mode is a
29
+ safety net; the database role should also be read-only.
30
+ """
31
+ url = make_url(source_url)
32
+ backend = url.get_backend_name()
33
+ if backend == "sqlite":
34
+ if not url.database or url.database == ":memory:":
35
+ raise RelVectorError("an in-memory SQLite URL has no data to index; pass engine=")
36
+ path = Path(url.database).resolve()
37
+ if not path.is_file():
38
+ raise RelVectorError(f"SQLite database not found: {path}")
39
+ uri = f"{path.as_uri()}?mode=ro"
40
+ return create_engine(
41
+ "sqlite://",
42
+ creator=lambda: sqlite3.connect(uri, uri=True, check_same_thread=False),
43
+ )
44
+ if backend == "postgresql":
45
+ # REPEATABLE READ gives each batch's root and related-row queries one snapshot.
46
+ return create_engine(source_url, execution_options={
47
+ "postgresql_readonly": True, "isolation_level": "REPEATABLE READ"})
48
+ raise RelVectorError(f"unsupported database {backend!r}; supported: "
49
+ f"{', '.join(SUPPORTED_DIALECTS)}")
50
+
51
+
52
+ class RelVector:
53
+ """Semantic index over selected relational data; the source database stays authoritative.
54
+
55
+ Example::
56
+
57
+ rv = RelVector("sqlite:///shop.db", embedding_provider="local",
58
+ index_path="shop.rvindex")
59
+ rv.configure(root_table="orders", include_tables=["shipments"],
60
+ text_fields=["orders.status", "shipments.delay_reason"],
61
+ metadata_fields=["orders.status"])
62
+ rv.index(batch_size=500)
63
+ for hit in rv.search("orders delayed by transport problems", top_k=5):
64
+ print(hit.score, hit.source_pk, hit.text)
65
+ """
66
+
67
+ def __init__(
68
+ self,
69
+ source_url: str | None = None,
70
+ *,
71
+ engine: Engine | None = None,
72
+ embedding_provider: str | EmbeddingProvider = "local",
73
+ vector_store: str | VectorStore = "local",
74
+ index_path: str | Path = "relvector_index.sqlite",
75
+ schema: str | None = None,
76
+ sql_validator: SqlValidator | None = None,
77
+ allow_external_embeddings: bool = False,
78
+ ) -> None:
79
+ if (source_url is None) == (engine is None):
80
+ raise RelVectorError("pass exactly one of source_url or engine")
81
+ self.engine = engine if engine is not None else connect(source_url) # type: ignore[arg-type]
82
+ self.source = (make_url(source_url).render_as_string(hide_password=True) if source_url
83
+ else engine.url.render_as_string(hide_password=True)) # type: ignore[union-attr]
84
+ self.provider = get_provider(embedding_provider)
85
+ if self.provider.sends_data_externally and not allow_external_embeddings:
86
+ raise RelVectorError(
87
+ f"embedding provider {self.provider.model_id!r} sends source text to an external "
88
+ "service; pass allow_external_embeddings=True to opt in explicitly"
89
+ )
90
+ if isinstance(vector_store, str):
91
+ if vector_store != "local":
92
+ raise RelVectorError(f"unknown vector store {vector_store!r}; use 'local'")
93
+ self.store: VectorStore = LocalVectorStore(index_path)
94
+ else:
95
+ self.store = vector_store
96
+ self.db_schema = schema
97
+ self.sql_validator = sql_validator
98
+ self._schema: Schema | None = None
99
+ self._mapping: ResolvedMapping | None = None
100
+
101
+ def __repr__(self) -> str:
102
+ return f"RelVector(source={self.source!r}, provider={self.provider.model_id!r})"
103
+
104
+ def __enter__(self) -> RelVector:
105
+ return self
106
+
107
+ def __exit__(self, *exc: object) -> None:
108
+ self.close()
109
+
110
+ def close(self) -> None:
111
+ self.store.close()
112
+ self.engine.dispose()
113
+
114
+ def inspect_schema(self, *, refresh: bool = False) -> Schema:
115
+ if self._schema is None or refresh:
116
+ self._schema = inspect_schema(self.engine, schema=self.db_schema)
117
+ return self._schema
118
+
119
+ def configure(
120
+ self,
121
+ mapping: EntityMapping | None = None,
122
+ *,
123
+ root_table: str | None = None,
124
+ include_tables: Sequence[str | Include] = (),
125
+ text_fields: Sequence[str] = (),
126
+ metadata_fields: Sequence[str] = (),
127
+ exclude_fields: Sequence[str] = (),
128
+ allow_sensitive: Sequence[str] = (),
129
+ templates: Mapping[str, str] | None = None,
130
+ labels: Mapping[str, str] | None = None,
131
+ name: str | None = None,
132
+ version: str = "1",
133
+ max_children: int = 20,
134
+ ) -> ResolvedMapping:
135
+ """Set the entity mapping (pass an :class:`EntityMapping` or keyword arguments)."""
136
+ if mapping is None:
137
+ if root_table is None:
138
+ raise MappingError("root_table is required")
139
+ mapping = EntityMapping(
140
+ root_table=root_table, include=tuple(include_tables),
141
+ text_fields=tuple(text_fields), metadata_fields=tuple(metadata_fields),
142
+ exclude_fields=tuple(exclude_fields), allow_sensitive=tuple(allow_sensitive),
143
+ templates=dict(templates or {}), labels=dict(labels or {}), name=name,
144
+ version=version, max_children=max_children,
145
+ )
146
+ self._mapping = resolve(mapping, self.inspect_schema())
147
+ return self._mapping
148
+
149
+ @property
150
+ def mapping(self) -> ResolvedMapping:
151
+ if self._mapping is None:
152
+ raise MappingError("call configure() before indexing or searching")
153
+ return self._mapping
154
+
155
+ def extractor(self) -> Extractor:
156
+ return Extractor(self.engine, self.mapping, schema=self.db_schema,
157
+ validator=self.sql_validator)
158
+
159
+ def index(
160
+ self,
161
+ batch_size: int = 500,
162
+ *,
163
+ resume: bool = False,
164
+ prune: bool = True,
165
+ progress: Callable[[IndexStats], None] | None = None,
166
+ ) -> IndexStats:
167
+ """(Re-)index the configured entity. Idempotent; see :mod:`relvector.indexing`."""
168
+ return Indexer(self.extractor(), self.provider, self.store).run(
169
+ batch_size, resume=resume, prune=prune, progress=progress)
170
+
171
+ def search(
172
+ self,
173
+ query: str,
174
+ top_k: int = 5,
175
+ *,
176
+ filters: Mapping[str, Any] | None = None,
177
+ entity: str | None = None,
178
+ ) -> list[SearchResult]:
179
+ """Nearest records by cosine similarity. Similarity is not evidence of causation.
180
+
181
+ ``filters`` match stored metadata exactly, e.g. ``{"orders.status": "delayed"}``.
182
+ ``entity`` defaults to the configured mapping's entity, if any.
183
+ """
184
+ if entity is None and self._mapping is not None:
185
+ entity = self._mapping.entity
186
+ vector = self.provider.embed([query])
187
+ return self.store.search(vector[0], top_k, entity=entity, filters=filters)
@@ -0,0 +1,146 @@
1
+ """Embedding providers.
2
+
3
+ A provider is any object with ``model_id``, ``dimension``, ``sends_data_externally`` and
4
+ ``embed(texts) -> ndarray[(n, dimension), float32]``. Two are built in:
5
+
6
+ * :class:`SentenceTransformerEmbedding` — a local ``sentence-transformers`` model
7
+ (``pip install relvector[local]``). Nothing leaves the machine; the model is downloaded by
8
+ sentence-transformers on first use unless ``local_files_only=True``.
9
+ * :class:`HashingEmbedding` — dependency-free, deterministic lexical feature hashing. It is not a
10
+ semantic model; it exists for tests, offline demos and as a keyword-ish baseline.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import hashlib
16
+ import re
17
+ from collections.abc import Sequence
18
+ from typing import Any, Protocol, runtime_checkable
19
+
20
+ import numpy as np
21
+ from numpy.typing import NDArray
22
+
23
+ from relvector.errors import RelVectorError
24
+
25
+ Vectors = NDArray[np.float32]
26
+
27
+
28
+ @runtime_checkable
29
+ class EmbeddingProvider(Protocol):
30
+ model_id: str
31
+ sends_data_externally: bool
32
+
33
+ @property
34
+ def dimension(self) -> int: ...
35
+
36
+ def embed(self, texts: Sequence[str]) -> Vectors: ...
37
+
38
+
39
+ def normalize(vectors: Any) -> Vectors:
40
+ arr = np.asarray(vectors, dtype=np.float32)
41
+ if arr.ndim != 2:
42
+ raise RelVectorError(f"embeddings must be 2-dimensional, got shape {arr.shape}")
43
+ norms = np.linalg.norm(arr, axis=1, keepdims=True)
44
+ norms[norms == 0] = 1.0
45
+ out: Vectors = arr / norms
46
+ return out
47
+
48
+
49
+ _TOKEN = re.compile(r"[a-z0-9]+")
50
+
51
+
52
+ class HashingEmbedding:
53
+ """Signed feature hashing of words and character trigrams (stable across processes)."""
54
+
55
+ sends_data_externally = False
56
+
57
+ def __init__(self, dimension: int = 512) -> None:
58
+ if dimension < 8:
59
+ raise ValueError("dimension must be >= 8")
60
+ self._dimension = dimension
61
+ self.model_id = f"hashing-v1-{dimension}"
62
+
63
+ @property
64
+ def dimension(self) -> int:
65
+ return self._dimension
66
+
67
+ def _features(self, text: str) -> list[str]:
68
+ feats: list[str] = []
69
+ for word in _TOKEN.findall(text.lower()):
70
+ feats.append(f"w:{word}")
71
+ padded = f"#{word}#"
72
+ feats.extend(f"c:{padded[i:i + 3]}" for i in range(len(padded) - 2))
73
+ return feats
74
+
75
+ def embed(self, texts: Sequence[str]) -> Vectors:
76
+ out = np.zeros((len(texts), self._dimension), dtype=np.float32)
77
+ for row, text in enumerate(texts):
78
+ for feat in self._features(text):
79
+ h = int.from_bytes(hashlib.blake2b(feat.encode(), digest_size=8).digest(), "little")
80
+ out[row, h % self._dimension] += 1.0 if (h >> 63) & 1 else -1.0
81
+ return normalize(out)
82
+
83
+
84
+ class SentenceTransformerEmbedding:
85
+ """A local sentence-transformers model, loaded lazily on first use."""
86
+
87
+ sends_data_externally = False
88
+
89
+ def __init__(
90
+ self,
91
+ model_name: str = "sentence-transformers/all-MiniLM-L6-v2",
92
+ *,
93
+ device: str | None = None,
94
+ batch_size: int = 64,
95
+ local_files_only: bool = False,
96
+ ) -> None:
97
+ self.model_name = model_name
98
+ self.model_id = f"sentence-transformers:{model_name}"
99
+ self.device = device
100
+ self.batch_size = batch_size
101
+ self.local_files_only = local_files_only
102
+ self._model: Any = None
103
+
104
+ def _load(self) -> Any:
105
+ if self._model is None:
106
+ try:
107
+ from sentence_transformers import SentenceTransformer # noqa: PLC0415
108
+ except ImportError as exc: # pragma: no cover - depends on the environment
109
+ raise RelVectorError(
110
+ f"the 'local' embedding provider could not import sentence-transformers "
111
+ f"({exc}); pip install 'relvector[local]' or use "
112
+ "embedding_provider='hashing'"
113
+ ) from exc
114
+ self._model = SentenceTransformer(
115
+ self.model_name, device=self.device, local_files_only=self.local_files_only
116
+ )
117
+ return self._model
118
+
119
+ @property
120
+ def dimension(self) -> int:
121
+ dim = self._load().get_sentence_embedding_dimension()
122
+ if not dim:
123
+ raise RelVectorError(f"model {self.model_name!r} does not report a dimension")
124
+ return int(dim)
125
+
126
+ def embed(self, texts: Sequence[str]) -> Vectors:
127
+ if not texts:
128
+ return np.zeros((0, self.dimension), dtype=np.float32)
129
+ vectors = self._load().encode(
130
+ list(texts), batch_size=self.batch_size, convert_to_numpy=True,
131
+ normalize_embeddings=True, show_progress_bar=False,
132
+ )
133
+ return normalize(vectors)
134
+
135
+
136
+ def get_provider(spec: str | EmbeddingProvider, **kwargs: Any) -> EmbeddingProvider:
137
+ """Resolve ``"local"``/``"sentence-transformers"``, ``"hashing"`` or a provider instance."""
138
+ if not isinstance(spec, str):
139
+ if not isinstance(spec, EmbeddingProvider):
140
+ raise RelVectorError("embedding_provider must be a name or an EmbeddingProvider")
141
+ return spec
142
+ if spec in ("local", "sentence-transformers"):
143
+ return SentenceTransformerEmbedding(**kwargs)
144
+ if spec == "hashing":
145
+ return HashingEmbedding(**kwargs)
146
+ raise RelVectorError(f"unknown embedding provider {spec!r}; use 'local' or 'hashing'")
relvector/errors.py ADDED
@@ -0,0 +1,31 @@
1
+ """Exception hierarchy: every error RelVector raises on purpose is a :class:`RelVectorError`."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class RelVectorError(Exception):
7
+ """Base class for RelVector errors."""
8
+
9
+
10
+ class SchemaError(RelVectorError):
11
+ """A table, column or relationship does not exist or cannot be resolved."""
12
+
13
+
14
+ class MappingError(RelVectorError):
15
+ """An entity mapping is invalid (unknown field, ambiguous join, conflicting options)."""
16
+
17
+
18
+ class SensitiveFieldError(MappingError):
19
+ """A mapping would embed or store a column that looks sensitive and was not authorised."""
20
+
21
+
22
+ class IndexMismatchError(RelVectorError):
23
+ """A persisted index was built with a different embedding model or dimension."""
24
+
25
+
26
+ class RunSupersededError(RelVectorError):
27
+ """Another indexing run for the same entity started; this run stopped without pruning."""
28
+
29
+
30
+ class QueryRejectedError(RelVectorError):
31
+ """An optional SQL validator (for example SQLGuard Ninja) rejected an extraction query."""
@@ -0,0 +1,141 @@
1
+ """Bounded, read-only extraction of root rows and their related rows.
2
+
3
+ * Root rows are read in primary-key order with keyset pagination (``WHERE pk > :last``), never
4
+ ``OFFSET``, so each batch costs an index range scan regardless of how deep the run is.
5
+ * Related rows are fetched per batch with ``IN (...)`` on the join key: one query per relation,
6
+ no joins, so a one-to-many relation can never multiply root rows (no Cartesian products).
7
+ * One-to-many relations are capped per parent with ``ROW_NUMBER()`` in the database and the true
8
+ total is counted separately, so memory is bounded by ``batch_size * max_items`` and any
9
+ truncation is reported rather than silent.
10
+
11
+ Only ``SELECT`` statements built with SQLAlchemy Core are executed; all values are bound
12
+ parameters. Identifiers come from the inspected schema, not from free-form user SQL.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from collections.abc import Callable, Iterator, Mapping, Sequence
18
+ from dataclasses import dataclass
19
+ from typing import Any
20
+
21
+ from sqlalchemy import Select, column, func, select, table, tuple_
22
+ from sqlalchemy.engine import Connection, Engine
23
+ from sqlalchemy.sql.expression import TableClause
24
+
25
+ from relvector.mapping import Relation, ResolvedMapping
26
+
27
+ SqlValidator = Callable[[str], None]
28
+ """Called with each extraction query (named bind parameters) before it runs; raise to block."""
29
+
30
+ Row = Mapping[str, Any]
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class RowBundle:
35
+ row: Row
36
+ related: dict[str, tuple[list[Row], int]]
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class Batch:
41
+ bundles: list[RowBundle]
42
+ last_key: list[Any]
43
+
44
+
45
+ class Extractor:
46
+ def __init__(
47
+ self,
48
+ engine: Engine,
49
+ resolved: ResolvedMapping,
50
+ *,
51
+ schema: str | None = None,
52
+ validator: SqlValidator | None = None,
53
+ ) -> None:
54
+ self.engine = engine
55
+ self.resolved = resolved
56
+ self.schema = schema
57
+ self.validator = validator
58
+
59
+ def _table(self, name: str, columns: Sequence[str]) -> TableClause:
60
+ return table(name, *(column(c) for c in columns), schema=self.schema)
61
+
62
+ def _execute(self, conn: Connection, stmt: Select[Any]) -> list[Row]:
63
+ if self.validator is not None:
64
+ self.validator(self.render(stmt))
65
+ return [dict(r) for r in conn.execute(stmt).mappings()]
66
+
67
+ def render(self, stmt: Select[Any]) -> str:
68
+ """The statement as SQL text with named parameters (what a validator sees)."""
69
+ dialect = type(self.engine.dialect)(paramstyle="named") # type: ignore[call-arg]
70
+ return str(stmt.compile(dialect=dialect, compile_kwargs={"render_postcompile": True}))
71
+
72
+ def root_query(self, batch_size: int, after: Sequence[Any] | None) -> Select[Any]:
73
+ r = self.resolved
74
+ t = self._table(r.root_table, r.root_select_columns)
75
+ pk = [t.c[c] for c in r.primary_key]
76
+ # SQLite permits NULL in non-INTEGER primary keys; such rows have no stable identity.
77
+ stmt = (select(*(t.c[c] for c in r.root_select_columns))
78
+ .where(*(c.is_not(None) for c in pk)).order_by(*pk).limit(batch_size))
79
+ if after is not None:
80
+ if len(pk) == 1:
81
+ stmt = stmt.where(pk[0] > after[0])
82
+ else:
83
+ stmt = stmt.where(tuple_(*pk) > tuple_(*after))
84
+ return stmt
85
+
86
+ def iter_batches(
87
+ self, batch_size: int = 500, after: Sequence[Any] | None = None
88
+ ) -> Iterator[Batch]:
89
+ """Yield batches of at most ``batch_size`` root rows, resuming after key ``after``."""
90
+ if batch_size < 1:
91
+ raise ValueError("batch_size must be >= 1")
92
+ last = list(after) if after is not None else None
93
+ while True:
94
+ with self.engine.connect() as conn:
95
+ rows = self._execute(conn, self.root_query(batch_size, last))
96
+ if not rows:
97
+ return
98
+ related = {rel.table: self._fetch_related(conn, rel, rows)
99
+ for rel in self.resolved.relations}
100
+ bundles = [
101
+ RowBundle(row, {t: per_key.get(row[rel.root_column], ([], 0))
102
+ for t, (rel, per_key) in related.items()})
103
+ for row in rows
104
+ ]
105
+ last = [rows[-1][c] for c in self.resolved.primary_key]
106
+ yield Batch(bundles, last)
107
+ if len(rows) < batch_size:
108
+ return
109
+
110
+ def _fetch_related(
111
+ self, conn: Connection, rel: Relation, rows: list[Row]
112
+ ) -> tuple[Relation, dict[Any, tuple[list[Row], int]]]:
113
+ keys = sorted({r[rel.root_column] for r in rows if r[rel.root_column] is not None},
114
+ key=lambda k: (str(type(k)), k))
115
+ if not keys:
116
+ return rel, {}
117
+ t = self._table(rel.table, rel.select_columns)
118
+ join_col = t.c[rel.related_column]
119
+ cols = [t.c[c] for c in rel.select_columns]
120
+ out: dict[Any, tuple[list[Row], int]] = {}
121
+ if rel.kind == "many_to_one":
122
+ for r in self._execute(conn, select(*cols).where(join_col.in_(keys))):
123
+ out[r[rel.related_column]] = ([r], 1)
124
+ return rel, out
125
+
126
+ rn = func.row_number().over(
127
+ partition_by=join_col, order_by=[t.c[c] for c in rel.primary_key]
128
+ ).label("_rv_rn")
129
+ ranked = select(*cols, rn).where(join_col.in_(keys)).subquery()
130
+ capped = (select(*(ranked.c[c] for c in rel.select_columns))
131
+ .where(ranked.c["_rv_rn"] <= rel.max_items)
132
+ .order_by(ranked.c[rel.related_column], ranked.c["_rv_rn"]))
133
+ counts = select(join_col, func.count().label("_rv_n")).where(
134
+ join_col.in_(keys)).group_by(join_col)
135
+ totals = {r[rel.related_column]: r["_rv_n"] for r in self._execute(conn, counts)}
136
+ grouped: dict[Any, list[Row]] = {}
137
+ for r in self._execute(conn, capped):
138
+ grouped.setdefault(r[rel.related_column], []).append(r)
139
+ for key, total in totals.items():
140
+ out[key] = (grouped.get(key, []), total)
141
+ return rel, out