relvector 0.1.0__tar.gz → 0.2.0__tar.gz
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-0.1.0 → relvector-0.2.0}/.gitignore +3 -0
- relvector-0.2.0/CHANGELOG.md +60 -0
- {relvector-0.1.0 → relvector-0.2.0}/PKG-INFO +86 -6
- {relvector-0.1.0 → relvector-0.2.0}/README.md +82 -5
- {relvector-0.1.0 → relvector-0.2.0}/docs/relvector/architecture.md +68 -9
- {relvector-0.1.0 → relvector-0.2.0}/pyproject.toml +4 -2
- {relvector-0.1.0 → relvector-0.2.0}/src/relvector/__init__.py +5 -0
- relvector-0.2.0/src/relvector/_version.py +1 -0
- {relvector-0.1.0 → relvector-0.2.0}/src/relvector/api.py +63 -4
- {relvector-0.1.0 → relvector-0.2.0}/src/relvector/extraction.py +110 -8
- relvector-0.2.0/src/relvector/faiss_store.py +473 -0
- {relvector-0.1.0 → relvector-0.2.0}/src/relvector/indexing.py +44 -4
- {relvector-0.1.0 → relvector-0.2.0}/src/relvector/mapping.py +60 -1
- {relvector-0.1.0 → relvector-0.2.0}/src/relvector/store.py +146 -14
- relvector-0.2.0/src/relvector/sync.py +374 -0
- {relvector-0.1.0 → relvector-0.2.0}/tests/relvector/conftest.py +33 -0
- relvector-0.2.0/tests/relvector/test_faiss.py +302 -0
- relvector-0.2.0/tests/relvector/test_incremental.py +440 -0
- {relvector-0.1.0 → relvector-0.2.0}/tests/relvector/test_postgres.py +0 -29
- {relvector-0.1.0 → relvector-0.2.0}/tests/relvector/test_search.py +59 -0
- relvector-0.2.0/tests/relvector/test_v02_integration.py +111 -0
- relvector-0.1.0/CHANGELOG.md +0 -25
- relvector-0.1.0/src/relvector/_version.py +0 -1
- {relvector-0.1.0 → relvector-0.2.0}/LICENSE +0 -0
- {relvector-0.1.0 → relvector-0.2.0}/THIRD_PARTY_NOTICES.md +0 -0
- {relvector-0.1.0 → relvector-0.2.0}/examples/relvector/orders_shipments.py +0 -0
- {relvector-0.1.0 → relvector-0.2.0}/src/relvector/embeddings.py +0 -0
- {relvector-0.1.0 → relvector-0.2.0}/src/relvector/errors.py +0 -0
- {relvector-0.1.0 → relvector-0.2.0}/src/relvector/integrations/__init__.py +0 -0
- {relvector-0.1.0 → relvector-0.2.0}/src/relvector/integrations/sqlguard.py +0 -0
- {relvector-0.1.0 → relvector-0.2.0}/src/relvector/py.typed +0 -0
- {relvector-0.1.0 → relvector-0.2.0}/src/relvector/schema.py +0 -0
- {relvector-0.1.0 → relvector-0.2.0}/tests/relvector/test_relvector.py +0 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes are listed here. The project follows semantic versioning once released.
|
|
4
|
+
|
|
5
|
+
## 0.2.0 (2026-10-25)
|
|
6
|
+
|
|
7
|
+
Added (all opt-in; v0.1.0 behaviour and APIs are unchanged):
|
|
8
|
+
|
|
9
|
+
* **Incremental sync.** `configure(change_tracking={table: column, ...}, soft_delete=...)` and
|
|
10
|
+
`rv.sync(batch_size, lookback=..., full_fallback=False) -> SyncStats`.
|
|
11
|
+
* Changed rows are read per mapped table in bounded `(tracking column, pk)` keyset batches,
|
|
12
|
+
starting at `watermark - lookback`.
|
|
13
|
+
* Affected root documents are rebuilt, including the previous parents of reassigned
|
|
14
|
+
children, which are found through the index's provenance.
|
|
15
|
+
* Per-table watermarks are committed atomically with each batch.
|
|
16
|
+
* `index()` records watermarks captured before its scan.
|
|
17
|
+
* Missing tracking columns or stale watermarks fail clearly (`MappingError`,
|
|
18
|
+
`SyncUnavailableError`).
|
|
19
|
+
* **Optional FAISS backend.** `vector_store="faiss"` / `FaissVectorStore` (`pip install
|
|
20
|
+
"relvector[faiss]"`).
|
|
21
|
+
* A CPU `IndexIVFFlat` derived from the SQLite store, with ANN candidates re-scored
|
|
22
|
+
exactly.
|
|
23
|
+
* Exact fallback for metadata filters, a requested entity in a multi-entity index, or a
|
|
24
|
+
small index.
|
|
25
|
+
* Crash-safe through a dirty marker, atomic saves and automatic rebuild from SQLite.
|
|
26
|
+
* **Store contract.**
|
|
27
|
+
* `Checkpoint.state`: durable, typed, and committed with each claim.
|
|
28
|
+
* `VectorStore.delete_records`: owner-checked.
|
|
29
|
+
* `VectorStore.referencing`: an indexed reverse lookup from related-row primary keys to the
|
|
30
|
+
documents that reference them.
|
|
31
|
+
* A post-commit `_after_commit` hook for derived indexes.
|
|
32
|
+
|
|
33
|
+
Compatibility: index files written by 0.1.0 open in place. The new checkpoint `state` column
|
|
34
|
+
and the reference lookup table are added and backfilled on first open. This migration is
|
|
35
|
+
one-way. After it, 0.1.0 can still open and search the file, but indexing with 0.1.0 fails
|
|
36
|
+
with `OperationalError` (rv_checkpoints has 9 columns). There is no silent corruption. To
|
|
37
|
+
go back, rebuild the index with 0.1.0 at a new path. FAISS files are derived and can be
|
|
38
|
+
deleted at any time.
|
|
39
|
+
|
|
40
|
+
## 0.1.0 (2026-10-25)
|
|
41
|
+
|
|
42
|
+
First public release:
|
|
43
|
+
|
|
44
|
+
* Read-only connections for SQLite (`mode=ro`) and PostgreSQL (read-only `REPEATABLE READ`
|
|
45
|
+
transactions).
|
|
46
|
+
* Schema inspection: tables, columns, primary keys and foreign keys.
|
|
47
|
+
* Entity mappings with one-hop related tables, explicit `Include(via=...)` joins,
|
|
48
|
+
sensitive-field blocking, exclusions and deterministic templates.
|
|
49
|
+
* Bounded keyset extraction with per-relation `IN` queries and `ROW_NUMBER()` caps that report
|
|
50
|
+
truncation.
|
|
51
|
+
* Embedding providers: local sentence-transformers and dependency-free hashing.
|
|
52
|
+
* A persistent SQLite vector store with exact cosine search and metadata filters.
|
|
53
|
+
* Idempotent re-indexing with content hashes, pruning of deleted rows, atomic per-batch
|
|
54
|
+
checkpoints, typed checkpoint keys (date, datetime, UUID, Decimal), resume, and single-writer
|
|
55
|
+
ownership (`RunSupersededError`).
|
|
56
|
+
* Optional SQLGuard Ninja validation of extraction queries.
|
|
57
|
+
* Streaming exact search: chunked cursor reads, a bounded top-k heap, SQL-side filtering and
|
|
58
|
+
deterministic tie-breaking. Search memory no longer grows with index size
|
|
59
|
+
(`LocalVectorStore(search_chunk_size=...)`).
|
|
60
|
+
* PostgreSQL validation tests on a disposable database.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: relvector
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Schema-aware semantic indexes over existing relational databases, with source references back to the rows.
|
|
5
5
|
Project-URL: Homepage, https://github.com/Pratik653/RelVector
|
|
6
6
|
Project-URL: Issues, https://github.com/Pratik653/RelVector/issues
|
|
@@ -24,11 +24,14 @@ Requires-Dist: numpy>=1.26
|
|
|
24
24
|
Requires-Dist: sqlalchemy<3.0,>=2.0
|
|
25
25
|
Provides-Extra: dev
|
|
26
26
|
Requires-Dist: build>=1.2; extra == 'dev'
|
|
27
|
+
Requires-Dist: faiss-cpu>=1.8; extra == 'dev'
|
|
27
28
|
Requires-Dist: mypy>=1.10; extra == 'dev'
|
|
28
29
|
Requires-Dist: psycopg[binary]>=3.1; extra == 'dev'
|
|
29
30
|
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
30
31
|
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
31
32
|
Requires-Dist: sqlguard-ninja<0.8,>=0.7; extra == 'dev'
|
|
33
|
+
Provides-Extra: faiss
|
|
34
|
+
Requires-Dist: faiss-cpu>=1.8; extra == 'faiss'
|
|
32
35
|
Provides-Extra: local
|
|
33
36
|
Requires-Dist: sentence-transformers>=2.3; extra == 'local'
|
|
34
37
|
Provides-Extra: postgres
|
|
@@ -50,7 +53,7 @@ points back to the exact source rows.
|
|
|
50
53
|
RelVector does **not** convert a database into a vector database. The database stays the
|
|
51
54
|
authoritative source and is only ever read.
|
|
52
55
|
|
|
53
|
-
> Status: `0.
|
|
56
|
+
> Status: `0.2.0`, alpha. Tested with SQLite and PostgreSQL 16. APIs may change before 1.0.
|
|
54
57
|
|
|
55
58
|
## Install
|
|
56
59
|
|
|
@@ -60,6 +63,7 @@ Python 3.11+:
|
|
|
60
63
|
pip install relvector # core: SQLAlchemy + NumPy
|
|
61
64
|
pip install "relvector[local]" # + sentence-transformers for local embeddings (pulls in PyTorch)
|
|
62
65
|
pip install "relvector[postgres]" # + psycopg 3; use postgresql+psycopg://user@host/db URLs
|
|
66
|
+
pip install "relvector[faiss]" # + faiss-cpu for the optional approximate (ANN) backend
|
|
63
67
|
pip install "relvector[sqlguard]" # + optional SQLGuard Ninja query validation
|
|
64
68
|
```
|
|
65
69
|
|
|
@@ -99,6 +103,81 @@ The record for order 1002 reads:
|
|
|
99
103
|
Runnable example: [examples/relvector/orders_shipments.py](examples/relvector/orders_shipments.py)
|
|
100
104
|
(`--provider hashing` runs without any model).
|
|
101
105
|
|
|
106
|
+
## Incremental sync (opt-in)
|
|
107
|
+
|
|
108
|
+
If every mapped table has a column that your application reliably sets on insert *and*
|
|
109
|
+
update, `sync()` re-indexes only what changed:
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
rv.configure(
|
|
113
|
+
root_table="orders", include_tables=["shipments"],
|
|
114
|
+
text_fields=["orders.status", "shipments.delay_reason"],
|
|
115
|
+
change_tracking={"orders": "updated_at", "shipments": "updated_at"}, # every mapped table
|
|
116
|
+
soft_delete="deleted_at", # optional: root rows with a non-NULL value are removed
|
|
117
|
+
)
|
|
118
|
+
rv.index() # a full run first; records the starting watermarks
|
|
119
|
+
stats = rv.sync(lookback=timedelta(minutes=5)) # later: only changed rows, in bounded batches
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
What `sync()` guarantees:
|
|
123
|
+
|
|
124
|
+
* Rows are read in `(tracking column, pk)` order from `watermark - lookback`, using `>=` so
|
|
125
|
+
equal timestamps are never skipped, up to the maximum value read when that table's scan
|
|
126
|
+
starts. The default lookback is 5 minutes for date/time columns and 0 for numeric ones.
|
|
127
|
+
* Which documents are rebuilt:
|
|
128
|
+
* A changed root row rebuilds itself.
|
|
129
|
+
* A changed child rebuilds its current parent and its previous parent. The previous parent
|
|
130
|
+
comes from the index's own provenance, so a reassigned shipment leaves neither order stale.
|
|
131
|
+
* A changed many-to-one parent rebuilds every root that references it.
|
|
132
|
+
* Roots whose row is gone or soft-deleted are removed.
|
|
133
|
+
* Rebuilt documents go through the same deterministic mapping and hashing as `index()`, and
|
|
134
|
+
only changed content is re-embedded.
|
|
135
|
+
* Each batch's watermark is committed in the same transaction as that batch's index changes.
|
|
136
|
+
An interrupted sync can just be run again.
|
|
137
|
+
|
|
138
|
+
What it does **not** guarantee:
|
|
139
|
+
|
|
140
|
+
* **Numeric tracking columns get no lookback by default.** Values allocated from a sequence
|
|
141
|
+
can commit out of order (a transaction holding 41 may commit after 42 was read), so set
|
|
142
|
+
`lookback` to a number that covers the values that can be in flight at once.
|
|
143
|
+
* It misses changes whose tracking value is older than `watermark - lookback`. Examples are
|
|
144
|
+
transactions that commit later than the lookback, clock skew between writers, backfills
|
|
145
|
+
with old timestamps, and code paths that don't bump the column. Rows with a NULL tracking
|
|
146
|
+
value are never seen.
|
|
147
|
+
* Hard deletes are invisible to timestamps. Run a full `index()` periodically to reconcile;
|
|
148
|
+
it prunes them. A soft delete must also bump the root's tracking column.
|
|
149
|
+
* Many-to-one changes are missed in three cases: a parent's primary key changes, a parent is
|
|
150
|
+
hard-deleted, or a root's FK changes without its tracking column being bumped.
|
|
151
|
+
* Rows sitting inside the lookback window are re-read on every sync. They are skipped by
|
|
152
|
+
content hash, so this is cheap, but a bulk load that shares one timestamp is re-read until
|
|
153
|
+
newer changes move the watermark past it.
|
|
154
|
+
|
|
155
|
+
If a mapped table has no tracking column, `sync()` raises `MappingError`. If watermarks are
|
|
156
|
+
missing or stale (no full index yet, or the mapping or tracking changed), it raises
|
|
157
|
+
`SyncUnavailableError`. `sync(full_fallback=True)` runs `index()` instead. This is a
|
|
158
|
+
timestamp-based sync, not change-data-capture.
|
|
159
|
+
|
|
160
|
+
## Optional FAISS backend (approximate search)
|
|
161
|
+
|
|
162
|
+
```python
|
|
163
|
+
rv = RelVector(url, vector_store="faiss", index_path="shop.rvindex") # pip install "relvector[faiss]"
|
|
164
|
+
# or tune it: RelVector(url, vector_store=FaissVectorStore("shop.rvindex", ann_min_vectors=5000))
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
* SQLite stays the source of truth. A FAISS `IndexIVFFlat` file (inner product on normalised
|
|
168
|
+
vectors) sits next to it and is rebuilt from SQLite automatically if it is missing,
|
|
169
|
+
unreadable, flagged dirty after a crash, or inconsistent with SQLite.
|
|
170
|
+
* Search is **approximate**: FAISS proposes `top_k × oversample` candidates, and these are
|
|
171
|
+
re-scored exactly. Returned scores are therefore true cosine similarities, but a true
|
|
172
|
+
nearest neighbour can be missed (recall below 1). `store.last_search_mode` reports `"ann"`
|
|
173
|
+
or `"exact"`.
|
|
174
|
+
* The FAISS backend falls back to exact search in three cases: metadata `filters` are given,
|
|
175
|
+
the index holds several entities and one is requested, or there are fewer than
|
|
176
|
+
`ann_min_vectors` (default 5000) vectors. Filtered results are therefore never truncated by
|
|
177
|
+
the ANN step.
|
|
178
|
+
* Upserts, `sync()`, deletes and pruning keep the FAISS index current. It is saved atomically
|
|
179
|
+
at the end of each run and on close. Use one writer process per index.
|
|
180
|
+
|
|
102
181
|
## How it works
|
|
103
182
|
|
|
104
183
|
| Stage | Behaviour |
|
|
@@ -153,8 +232,8 @@ a record is close to the query in embedding space. Check the source rows.
|
|
|
153
232
|
isolation in the index.
|
|
154
233
|
* Relationships: one hop from the root table, single-column joins. No self-references,
|
|
155
234
|
multi-hop paths or composite foreign keys yet. Composite root primary keys are supported.
|
|
156
|
-
*
|
|
157
|
-
|
|
235
|
+
* `index()` is a full re-scan. `sync()` is timestamp-based and has the limits listed under
|
|
236
|
+
[Incremental sync](#incremental-sync-opt-in). Neither is change-data-capture. Edits to related rows (child shipments, a parent customer) are picked up on the
|
|
158
237
|
next completed run. Rows changed during a run may be read at different moments, and the next
|
|
159
238
|
run corrects them. See
|
|
160
239
|
[how source changes reach documents](docs/relvector/architecture.md#how-source-changes-reach-semantic-documents).
|
|
@@ -167,8 +246,9 @@ a record is close to the query in embedding space. Check the source rows.
|
|
|
167
246
|
fail with a clear error.
|
|
168
247
|
* `LocalVectorStore` search is exact and streamed: memory is bounded by
|
|
169
248
|
`search_chunk_size` (default 1024 rows) and `top_k`, but every query scans all vectors that
|
|
170
|
-
pass the filters
|
|
171
|
-
|
|
249
|
+
pass the filters, so latency grows linearly with index size. The optional FAISS backend
|
|
250
|
+
is sub-linear but approximate, and filtered queries on it are still exact and linear. See
|
|
251
|
+
[search performance](docs/relvector/architecture.md#search-performance).
|
|
172
252
|
* The hashing provider is lexical, not semantic.
|
|
173
253
|
* SQLite and PostgreSQL only. Other databases are not supported.
|
|
174
254
|
|
|
@@ -11,7 +11,7 @@ points back to the exact source rows.
|
|
|
11
11
|
RelVector does **not** convert a database into a vector database. The database stays the
|
|
12
12
|
authoritative source and is only ever read.
|
|
13
13
|
|
|
14
|
-
> Status: `0.
|
|
14
|
+
> Status: `0.2.0`, alpha. Tested with SQLite and PostgreSQL 16. APIs may change before 1.0.
|
|
15
15
|
|
|
16
16
|
## Install
|
|
17
17
|
|
|
@@ -21,6 +21,7 @@ Python 3.11+:
|
|
|
21
21
|
pip install relvector # core: SQLAlchemy + NumPy
|
|
22
22
|
pip install "relvector[local]" # + sentence-transformers for local embeddings (pulls in PyTorch)
|
|
23
23
|
pip install "relvector[postgres]" # + psycopg 3; use postgresql+psycopg://user@host/db URLs
|
|
24
|
+
pip install "relvector[faiss]" # + faiss-cpu for the optional approximate (ANN) backend
|
|
24
25
|
pip install "relvector[sqlguard]" # + optional SQLGuard Ninja query validation
|
|
25
26
|
```
|
|
26
27
|
|
|
@@ -60,6 +61,81 @@ The record for order 1002 reads:
|
|
|
60
61
|
Runnable example: [examples/relvector/orders_shipments.py](examples/relvector/orders_shipments.py)
|
|
61
62
|
(`--provider hashing` runs without any model).
|
|
62
63
|
|
|
64
|
+
## Incremental sync (opt-in)
|
|
65
|
+
|
|
66
|
+
If every mapped table has a column that your application reliably sets on insert *and*
|
|
67
|
+
update, `sync()` re-indexes only what changed:
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
rv.configure(
|
|
71
|
+
root_table="orders", include_tables=["shipments"],
|
|
72
|
+
text_fields=["orders.status", "shipments.delay_reason"],
|
|
73
|
+
change_tracking={"orders": "updated_at", "shipments": "updated_at"}, # every mapped table
|
|
74
|
+
soft_delete="deleted_at", # optional: root rows with a non-NULL value are removed
|
|
75
|
+
)
|
|
76
|
+
rv.index() # a full run first; records the starting watermarks
|
|
77
|
+
stats = rv.sync(lookback=timedelta(minutes=5)) # later: only changed rows, in bounded batches
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
What `sync()` guarantees:
|
|
81
|
+
|
|
82
|
+
* Rows are read in `(tracking column, pk)` order from `watermark - lookback`, using `>=` so
|
|
83
|
+
equal timestamps are never skipped, up to the maximum value read when that table's scan
|
|
84
|
+
starts. The default lookback is 5 minutes for date/time columns and 0 for numeric ones.
|
|
85
|
+
* Which documents are rebuilt:
|
|
86
|
+
* A changed root row rebuilds itself.
|
|
87
|
+
* A changed child rebuilds its current parent and its previous parent. The previous parent
|
|
88
|
+
comes from the index's own provenance, so a reassigned shipment leaves neither order stale.
|
|
89
|
+
* A changed many-to-one parent rebuilds every root that references it.
|
|
90
|
+
* Roots whose row is gone or soft-deleted are removed.
|
|
91
|
+
* Rebuilt documents go through the same deterministic mapping and hashing as `index()`, and
|
|
92
|
+
only changed content is re-embedded.
|
|
93
|
+
* Each batch's watermark is committed in the same transaction as that batch's index changes.
|
|
94
|
+
An interrupted sync can just be run again.
|
|
95
|
+
|
|
96
|
+
What it does **not** guarantee:
|
|
97
|
+
|
|
98
|
+
* **Numeric tracking columns get no lookback by default.** Values allocated from a sequence
|
|
99
|
+
can commit out of order (a transaction holding 41 may commit after 42 was read), so set
|
|
100
|
+
`lookback` to a number that covers the values that can be in flight at once.
|
|
101
|
+
* It misses changes whose tracking value is older than `watermark - lookback`. Examples are
|
|
102
|
+
transactions that commit later than the lookback, clock skew between writers, backfills
|
|
103
|
+
with old timestamps, and code paths that don't bump the column. Rows with a NULL tracking
|
|
104
|
+
value are never seen.
|
|
105
|
+
* Hard deletes are invisible to timestamps. Run a full `index()` periodically to reconcile;
|
|
106
|
+
it prunes them. A soft delete must also bump the root's tracking column.
|
|
107
|
+
* Many-to-one changes are missed in three cases: a parent's primary key changes, a parent is
|
|
108
|
+
hard-deleted, or a root's FK changes without its tracking column being bumped.
|
|
109
|
+
* Rows sitting inside the lookback window are re-read on every sync. They are skipped by
|
|
110
|
+
content hash, so this is cheap, but a bulk load that shares one timestamp is re-read until
|
|
111
|
+
newer changes move the watermark past it.
|
|
112
|
+
|
|
113
|
+
If a mapped table has no tracking column, `sync()` raises `MappingError`. If watermarks are
|
|
114
|
+
missing or stale (no full index yet, or the mapping or tracking changed), it raises
|
|
115
|
+
`SyncUnavailableError`. `sync(full_fallback=True)` runs `index()` instead. This is a
|
|
116
|
+
timestamp-based sync, not change-data-capture.
|
|
117
|
+
|
|
118
|
+
## Optional FAISS backend (approximate search)
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
rv = RelVector(url, vector_store="faiss", index_path="shop.rvindex") # pip install "relvector[faiss]"
|
|
122
|
+
# or tune it: RelVector(url, vector_store=FaissVectorStore("shop.rvindex", ann_min_vectors=5000))
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
* SQLite stays the source of truth. A FAISS `IndexIVFFlat` file (inner product on normalised
|
|
126
|
+
vectors) sits next to it and is rebuilt from SQLite automatically if it is missing,
|
|
127
|
+
unreadable, flagged dirty after a crash, or inconsistent with SQLite.
|
|
128
|
+
* Search is **approximate**: FAISS proposes `top_k × oversample` candidates, and these are
|
|
129
|
+
re-scored exactly. Returned scores are therefore true cosine similarities, but a true
|
|
130
|
+
nearest neighbour can be missed (recall below 1). `store.last_search_mode` reports `"ann"`
|
|
131
|
+
or `"exact"`.
|
|
132
|
+
* The FAISS backend falls back to exact search in three cases: metadata `filters` are given,
|
|
133
|
+
the index holds several entities and one is requested, or there are fewer than
|
|
134
|
+
`ann_min_vectors` (default 5000) vectors. Filtered results are therefore never truncated by
|
|
135
|
+
the ANN step.
|
|
136
|
+
* Upserts, `sync()`, deletes and pruning keep the FAISS index current. It is saved atomically
|
|
137
|
+
at the end of each run and on close. Use one writer process per index.
|
|
138
|
+
|
|
63
139
|
## How it works
|
|
64
140
|
|
|
65
141
|
| Stage | Behaviour |
|
|
@@ -114,8 +190,8 @@ a record is close to the query in embedding space. Check the source rows.
|
|
|
114
190
|
isolation in the index.
|
|
115
191
|
* Relationships: one hop from the root table, single-column joins. No self-references,
|
|
116
192
|
multi-hop paths or composite foreign keys yet. Composite root primary keys are supported.
|
|
117
|
-
*
|
|
118
|
-
|
|
193
|
+
* `index()` is a full re-scan. `sync()` is timestamp-based and has the limits listed under
|
|
194
|
+
[Incremental sync](#incremental-sync-opt-in). Neither is change-data-capture. Edits to related rows (child shipments, a parent customer) are picked up on the
|
|
119
195
|
next completed run. Rows changed during a run may be read at different moments, and the next
|
|
120
196
|
run corrects them. See
|
|
121
197
|
[how source changes reach documents](docs/relvector/architecture.md#how-source-changes-reach-semantic-documents).
|
|
@@ -128,8 +204,9 @@ a record is close to the query in embedding space. Check the source rows.
|
|
|
128
204
|
fail with a clear error.
|
|
129
205
|
* `LocalVectorStore` search is exact and streamed: memory is bounded by
|
|
130
206
|
`search_chunk_size` (default 1024 rows) and `top_k`, but every query scans all vectors that
|
|
131
|
-
pass the filters
|
|
132
|
-
|
|
207
|
+
pass the filters, so latency grows linearly with index size. The optional FAISS backend
|
|
208
|
+
is sub-linear but approximate, and filtered queries on it are still exact and linear. See
|
|
209
|
+
[search performance](docs/relvector/architecture.md#search-performance).
|
|
133
210
|
* The hashing provider is lexical, not semantic.
|
|
134
211
|
* SQLite and PostgreSQL only. Other databases are not supported.
|
|
135
212
|
|
|
@@ -124,15 +124,74 @@ Remaining limitations:
|
|
|
124
124
|
* **One long read.** A search holds a SQLite read snapshot for its duration. In WAL mode,
|
|
125
125
|
writers are not blocked.
|
|
126
126
|
|
|
127
|
+
## Store contract (v0.2)
|
|
128
|
+
|
|
129
|
+
`VectorStore` (in `store.py`) is the interface every backend implements.
|
|
130
|
+
|
|
131
|
+
* **Mutations.** `start_run`, `resume_run`, `write_batch` (upserts and touch-unchanged),
|
|
132
|
+
`delete_records` and `finish_run(prune=...)`. Each is owner-checked against the entity's
|
|
133
|
+
checkpoint and is atomic with the checkpoint update it carries.
|
|
134
|
+
* **Checkpoint state.** `Checkpoint.state` holds typed, JSON-serialisable run and sync state,
|
|
135
|
+
such as per-table watermarks. It is committed in the same transaction as the claim that
|
|
136
|
+
writes it, so a watermark can never be durable before the index changes it describes.
|
|
137
|
+
* **Reverse lookup.** `referencing(entity, table, pks)` maps related-row primary keys to the
|
|
138
|
+
documents whose stored references include them. It is backed by the `rv_refs` table,
|
|
139
|
+
maintained on every mutation and backfilled once for 0.1.0 files. Incremental sync uses it
|
|
140
|
+
to find the previous parent of a reassigned or deleted related row.
|
|
141
|
+
* **Derived indexes.** `LocalVectorStore._after_commit(upserted, deleted)` runs after every
|
|
142
|
+
committed mutation, including prune. The FAISS backend uses it to stay in sync.
|
|
143
|
+
* **Identity and scores.** `vector_id` is `f"{entity}:{json pk}"`. Vectors are L2-normalised
|
|
144
|
+
float32. `score` is cosine similarity, and exact ranking breaks ties by `vector_id`
|
|
145
|
+
ascending.
|
|
146
|
+
|
|
147
|
+
## Measured performance (workload-specific, not guarantees)
|
|
148
|
+
|
|
149
|
+
Measured on one Windows laptop (Python 3.11, NumPy 2.4, faiss-cpu 1.15.1), in-process, with
|
|
150
|
+
SQLite files on local disk. Expect different numbers on other hardware and data.
|
|
151
|
+
|
|
152
|
+
| Workload | Exact / full | v0.2 path | Notes |
|
|
153
|
+
|---|---|---|---|
|
|
154
|
+
| 20,000 orders + 29,994 shipments, 1% changed (150 order updates, 50 reassignments), `HashingEmbedding(256)`, batch 500 | full `index()` 4.39 s | `sync()` 0.367 s (about 12×) | 252 roots rebuilt, 250 embedded |
|
|
155
|
+
| 50,000 clustered vectors, dim 128, 100 queries, top 10 | exact 369.8 ms/query | FAISS 3.86 ms/query (about 96×) | recall@10 1.000; nlist 894, nprobe 224, oversample 4 |
|
|
156
|
+
| 50,000 random Gaussian vectors, same settings | exact 351.5 ms/query | FAISS 3.04 ms/query (about 116×) | recall@10 **0.724**; 1.000 with nprobe = nlist at 6.5 ms/query |
|
|
157
|
+
|
|
158
|
+
Random high-dimensional data is close to the worst case for IVF recall. Tune `nprobe` for
|
|
159
|
+
your data, and measure recall against exact search before relying on ANN results.
|
|
160
|
+
|
|
161
|
+
## Optional FAISS backend (v0.2)
|
|
162
|
+
|
|
163
|
+
`relvector/faiss_store.py` (`FaissVectorStore`) subclasses `LocalVectorStore`.
|
|
164
|
+
* **Source of truth.** SQLite remains authoritative. `<index>.faiss` is a derived
|
|
165
|
+
`IndexIVFFlat` (inner product on normalised vectors). Each vector's FAISS id is its
|
|
166
|
+
`rv_records` rowid, which an upsert keeps.
|
|
167
|
+
* **Search.** Candidates (`top_k × oversample`) are re-scored with the same float64 arithmetic
|
|
168
|
+
as exact search, so scores and tie order match; only recall is approximate.
|
|
169
|
+
* **Exact fallback** applies when there are metadata filters, when one entity of a
|
|
170
|
+
multi-entity index is requested, or when the index is below `ann_min_vectors`.
|
|
171
|
+
* **Crash safety.** A dirty marker is written before each SQLite commit, the change is applied
|
|
172
|
+
in memory after the commit, and the file is saved atomically at the end of each run and on
|
|
173
|
+
close.
|
|
174
|
+
* **Rebuild.** On open the index is rebuilt from SQLite if the file is missing, dirty,
|
|
175
|
+
unreadable, or does not match SQLite (count, checksum, model or dimension). It is also
|
|
176
|
+
rebuilt when another connection has changed SQLite (`PRAGMA data_version`), and when it has
|
|
177
|
+
grown `retrain_growth`× past the size it was trained on.
|
|
178
|
+
|
|
179
|
+
## Incremental sync (v0.2)
|
|
180
|
+
|
|
181
|
+
`relvector/sync.py` (`Syncer`, reached through `RelVector.sync`). Per-table watermarks live in
|
|
182
|
+
`Checkpoint.state` and are committed with each batch's index changes. The guarantees and
|
|
183
|
+
limitations are listed in the README under "Incremental sync". Previous parents of
|
|
184
|
+
reassigned or deleted children come from `VectorStore.referencing`, a reverse index over the
|
|
185
|
+
references each record stores, maintained on every mutation.
|
|
186
|
+
|
|
127
187
|
## Roadmap (not implemented)
|
|
128
188
|
|
|
129
|
-
1.
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
already gives a single committer.
|
|
189
|
+
1. Metadata pre-filtering inside ANN search (per-entity or per-filter FAISS partitions), so
|
|
190
|
+
that filtered queries don't fall back to exact search.
|
|
191
|
+
2. Multi-hop and composite-key relationships.
|
|
192
|
+
3. A deletion feed or tombstone table for hard deletes, so that `sync()` doesn't need periodic
|
|
193
|
+
full reconciliation.
|
|
194
|
+
4. Parallel embedding workers behind the batch loops; the owner-token checkpoint already
|
|
195
|
+
ensures a single committer.
|
|
137
196
|
5. An opt-in remote embedding provider (`sends_data_externally = True`).
|
|
138
|
-
6. A CLI
|
|
197
|
+
6. A CLI and a mapping config file format.
|
|
@@ -31,10 +31,12 @@ dependencies = [
|
|
|
31
31
|
[project.optional-dependencies]
|
|
32
32
|
# Local embedding model (pulls in PyTorch).
|
|
33
33
|
local = ["sentence-transformers>=2.3"]
|
|
34
|
+
# Optional approximate nearest-neighbour backend (CPU FAISS).
|
|
35
|
+
faiss = ["faiss-cpu>=1.8"]
|
|
34
36
|
postgres = ["psycopg[binary]>=3.1"]
|
|
35
37
|
sqlguard = ["sqlguard-ninja>=0.7,<0.8"]
|
|
36
38
|
dev = [
|
|
37
|
-
"relvector[postgres,sqlguard]",
|
|
39
|
+
"relvector[faiss,postgres,sqlguard]",
|
|
38
40
|
"pytest>=8.0",
|
|
39
41
|
"ruff>=0.6",
|
|
40
42
|
"mypy>=1.10",
|
|
@@ -81,5 +83,5 @@ strict = true
|
|
|
81
83
|
files = ["src/relvector"]
|
|
82
84
|
|
|
83
85
|
[[tool.mypy.overrides]]
|
|
84
|
-
module = ["sentence_transformers.*", "sqlguard_ninja.*", "pgserver.*"]
|
|
86
|
+
module = ["sentence_transformers.*", "sqlguard_ninja.*", "pgserver.*", "faiss.*"]
|
|
85
87
|
ignore_missing_imports = true
|
|
@@ -17,14 +17,17 @@ from relvector.errors import (
|
|
|
17
17
|
SchemaError,
|
|
18
18
|
SensitiveFieldError,
|
|
19
19
|
)
|
|
20
|
+
from relvector.faiss_store import FaissVectorStore # faiss itself is imported lazily
|
|
20
21
|
from relvector.indexing import IndexStats
|
|
21
22
|
from relvector.mapping import EntityMapping, Include, SemanticRecord
|
|
22
23
|
from relvector.schema import Schema, inspect_schema
|
|
23
24
|
from relvector.store import LocalVectorStore, SearchResult, VectorStore
|
|
25
|
+
from relvector.sync import SyncStats, SyncUnavailableError
|
|
24
26
|
|
|
25
27
|
__all__ = [
|
|
26
28
|
"EmbeddingProvider",
|
|
27
29
|
"EntityMapping",
|
|
30
|
+
"FaissVectorStore",
|
|
28
31
|
"HashingEmbedding",
|
|
29
32
|
"Include",
|
|
30
33
|
"IndexMismatchError",
|
|
@@ -41,6 +44,8 @@ __all__ = [
|
|
|
41
44
|
"SemanticRecord",
|
|
42
45
|
"SensitiveFieldError",
|
|
43
46
|
"SentenceTransformerEmbedding",
|
|
47
|
+
"SyncStats",
|
|
48
|
+
"SyncUnavailableError",
|
|
44
49
|
"VectorStore",
|
|
45
50
|
"__version__",
|
|
46
51
|
"connect",
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.2.0"
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import dataclasses
|
|
5
6
|
import sqlite3
|
|
6
7
|
from collections.abc import Callable, Mapping, Sequence
|
|
7
8
|
from pathlib import Path
|
|
@@ -17,6 +18,7 @@ from relvector.indexing import Indexer, IndexStats
|
|
|
17
18
|
from relvector.mapping import EntityMapping, Include, ResolvedMapping, resolve
|
|
18
19
|
from relvector.schema import Schema, inspect_schema
|
|
19
20
|
from relvector.store import LocalVectorStore, SearchResult, VectorStore
|
|
21
|
+
from relvector.sync import Lookback, Syncer, SyncStats
|
|
20
22
|
|
|
21
23
|
SUPPORTED_DIALECTS = ("sqlite", "postgresql")
|
|
22
24
|
|
|
@@ -88,9 +90,15 @@ class RelVector:
|
|
|
88
90
|
"service; pass allow_external_embeddings=True to opt in explicitly"
|
|
89
91
|
)
|
|
90
92
|
if isinstance(vector_store, str):
|
|
91
|
-
if vector_store
|
|
92
|
-
|
|
93
|
-
|
|
93
|
+
if vector_store == "local":
|
|
94
|
+
self.store: VectorStore = LocalVectorStore(index_path)
|
|
95
|
+
elif vector_store == "faiss": # optional extra: relvector[faiss]
|
|
96
|
+
from relvector.faiss_store import FaissVectorStore # noqa: PLC0415
|
|
97
|
+
|
|
98
|
+
self.store = FaissVectorStore(index_path)
|
|
99
|
+
else:
|
|
100
|
+
raise RelVectorError(f"unknown vector store {vector_store!r}; use 'local' or "
|
|
101
|
+
"'faiss'")
|
|
94
102
|
else:
|
|
95
103
|
self.store = vector_store
|
|
96
104
|
self.db_schema = schema
|
|
@@ -131,8 +139,23 @@ class RelVector:
|
|
|
131
139
|
name: str | None = None,
|
|
132
140
|
version: str = "1",
|
|
133
141
|
max_children: int = 20,
|
|
142
|
+
change_tracking: Mapping[str, str] | None = None,
|
|
143
|
+
soft_delete: str | None = None,
|
|
134
144
|
) -> ResolvedMapping:
|
|
135
|
-
"""Set the entity mapping (pass an :class:`EntityMapping` or keyword arguments).
|
|
145
|
+
"""Set the entity mapping (pass an :class:`EntityMapping` or keyword arguments).
|
|
146
|
+
|
|
147
|
+
``change_tracking`` (``{table: column}``, e.g. ``{"orders": "updated_at"}``) and
|
|
148
|
+
``soft_delete`` (a root column, non-NULL = deleted) opt in to :meth:`sync`; with a
|
|
149
|
+
mapping object they override the mapping's own values. Neither changes documents.
|
|
150
|
+
"""
|
|
151
|
+
if mapping is not None:
|
|
152
|
+
changes: dict[str, Any] = {}
|
|
153
|
+
if change_tracking is not None:
|
|
154
|
+
changes["change_tracking"] = dict(change_tracking)
|
|
155
|
+
if soft_delete is not None:
|
|
156
|
+
changes["soft_delete"] = soft_delete
|
|
157
|
+
if changes:
|
|
158
|
+
mapping = dataclasses.replace(mapping, **changes)
|
|
136
159
|
if mapping is None:
|
|
137
160
|
if root_table is None:
|
|
138
161
|
raise MappingError("root_table is required")
|
|
@@ -142,6 +165,7 @@ class RelVector:
|
|
|
142
165
|
exclude_fields=tuple(exclude_fields), allow_sensitive=tuple(allow_sensitive),
|
|
143
166
|
templates=dict(templates or {}), labels=dict(labels or {}), name=name,
|
|
144
167
|
version=version, max_children=max_children,
|
|
168
|
+
change_tracking=dict(change_tracking or {}), soft_delete=soft_delete,
|
|
145
169
|
)
|
|
146
170
|
self._mapping = resolve(mapping, self.inspect_schema())
|
|
147
171
|
return self._mapping
|
|
@@ -168,6 +192,41 @@ class RelVector:
|
|
|
168
192
|
return Indexer(self.extractor(), self.provider, self.store).run(
|
|
169
193
|
batch_size, resume=resume, prune=prune, progress=progress)
|
|
170
194
|
|
|
195
|
+
def sync(
|
|
196
|
+
self,
|
|
197
|
+
batch_size: int = 500,
|
|
198
|
+
*,
|
|
199
|
+
lookback: Lookback = None,
|
|
200
|
+
full_fallback: bool = False,
|
|
201
|
+
progress: Callable[[SyncStats], None] | None = None,
|
|
202
|
+
) -> SyncStats:
|
|
203
|
+
"""Apply source changes since the last index/sync, using the change-tracking columns.
|
|
204
|
+
|
|
205
|
+
Requires ``change_tracking`` for the root and every included table and a completed
|
|
206
|
+
:meth:`index` first. ``lookback`` is the overlap re-scanned below each watermark
|
|
207
|
+
(default 5 minutes for date/time columns, 0 for numeric ones). Hard deletes are not
|
|
208
|
+
detected; see :mod:`relvector.sync` for exactly what is and is not guaranteed.
|
|
209
|
+
|
|
210
|
+
When sync cannot run safely (missing tracking, no prior index, mapping changed) it
|
|
211
|
+
raises, unless ``full_fallback=True``, which runs a full :meth:`index` instead and
|
|
212
|
+
returns stats with ``mode == "full"``.
|
|
213
|
+
"""
|
|
214
|
+
syncer = Syncer(self.extractor(), self.provider, self.store)
|
|
215
|
+
if full_fallback:
|
|
216
|
+
problem = syncer.unavailable()
|
|
217
|
+
if problem is not None:
|
|
218
|
+
full = self.index(batch_size)
|
|
219
|
+
stats = SyncStats(run_id=full.run_id, mode="full", embedded=full.embedded,
|
|
220
|
+
unchanged=full.unchanged, deleted=full.deleted,
|
|
221
|
+
batches=full.batches, roots_rebuilt=full.rows,
|
|
222
|
+
truncated_records=full.truncated_records,
|
|
223
|
+
reason=str(problem), timings=dict(full.timings))
|
|
224
|
+
cp = self.store.checkpoint(self.mapping.entity)
|
|
225
|
+
if cp is not None and cp.state is not None:
|
|
226
|
+
stats.watermarks = dict(cp.state.get("watermarks") or {})
|
|
227
|
+
return stats
|
|
228
|
+
return syncer.run(batch_size, lookback=lookback, progress=progress)
|
|
229
|
+
|
|
171
230
|
def search(
|
|
172
231
|
self,
|
|
173
232
|
query: str,
|