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.
Files changed (33) hide show
  1. {relvector-0.1.0 → relvector-0.2.0}/.gitignore +3 -0
  2. relvector-0.2.0/CHANGELOG.md +60 -0
  3. {relvector-0.1.0 → relvector-0.2.0}/PKG-INFO +86 -6
  4. {relvector-0.1.0 → relvector-0.2.0}/README.md +82 -5
  5. {relvector-0.1.0 → relvector-0.2.0}/docs/relvector/architecture.md +68 -9
  6. {relvector-0.1.0 → relvector-0.2.0}/pyproject.toml +4 -2
  7. {relvector-0.1.0 → relvector-0.2.0}/src/relvector/__init__.py +5 -0
  8. relvector-0.2.0/src/relvector/_version.py +1 -0
  9. {relvector-0.1.0 → relvector-0.2.0}/src/relvector/api.py +63 -4
  10. {relvector-0.1.0 → relvector-0.2.0}/src/relvector/extraction.py +110 -8
  11. relvector-0.2.0/src/relvector/faiss_store.py +473 -0
  12. {relvector-0.1.0 → relvector-0.2.0}/src/relvector/indexing.py +44 -4
  13. {relvector-0.1.0 → relvector-0.2.0}/src/relvector/mapping.py +60 -1
  14. {relvector-0.1.0 → relvector-0.2.0}/src/relvector/store.py +146 -14
  15. relvector-0.2.0/src/relvector/sync.py +374 -0
  16. {relvector-0.1.0 → relvector-0.2.0}/tests/relvector/conftest.py +33 -0
  17. relvector-0.2.0/tests/relvector/test_faiss.py +302 -0
  18. relvector-0.2.0/tests/relvector/test_incremental.py +440 -0
  19. {relvector-0.1.0 → relvector-0.2.0}/tests/relvector/test_postgres.py +0 -29
  20. {relvector-0.1.0 → relvector-0.2.0}/tests/relvector/test_search.py +59 -0
  21. relvector-0.2.0/tests/relvector/test_v02_integration.py +111 -0
  22. relvector-0.1.0/CHANGELOG.md +0 -25
  23. relvector-0.1.0/src/relvector/_version.py +0 -1
  24. {relvector-0.1.0 → relvector-0.2.0}/LICENSE +0 -0
  25. {relvector-0.1.0 → relvector-0.2.0}/THIRD_PARTY_NOTICES.md +0 -0
  26. {relvector-0.1.0 → relvector-0.2.0}/examples/relvector/orders_shipments.py +0 -0
  27. {relvector-0.1.0 → relvector-0.2.0}/src/relvector/embeddings.py +0 -0
  28. {relvector-0.1.0 → relvector-0.2.0}/src/relvector/errors.py +0 -0
  29. {relvector-0.1.0 → relvector-0.2.0}/src/relvector/integrations/__init__.py +0 -0
  30. {relvector-0.1.0 → relvector-0.2.0}/src/relvector/integrations/sqlguard.py +0 -0
  31. {relvector-0.1.0 → relvector-0.2.0}/src/relvector/py.typed +0 -0
  32. {relvector-0.1.0 → relvector-0.2.0}/src/relvector/schema.py +0 -0
  33. {relvector-0.1.0 → relvector-0.2.0}/tests/relvector/test_relvector.py +0 -0
@@ -38,3 +38,6 @@ audit*.jsonl
38
38
  *.rvindex-*
39
39
  relvector_index.sqlite*
40
40
  examples/relvector/_output/
41
+
42
+ # Local tool state
43
+ .claude/
@@ -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.1.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.1.0`, alpha. Tested with SQLite and PostgreSQL 16. APIs may change before 1.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
- * Re-indexing is a full re-scan. There is no change-data-capture, and no triggers or timestamps
157
- are used. Edits to related rows (child shipments, a parent customer) are picked up on the
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. Latency grows linearly with index size, and there is no approximate (ANN)
171
- index. See [search performance](docs/relvector/architecture.md#search-performance).
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.1.0`, alpha. Tested with SQLite and PostgreSQL 16. APIs may change before 1.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
- * Re-indexing is a full re-scan. There is no change-data-capture, and no triggers or timestamps
118
- are used. Edits to related rows (child shipments, a parent customer) are picked up on the
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. Latency grows linearly with index size, and there is no approximate (ANN)
132
- index. See [search performance](docs/relvector/architecture.md#search-performance).
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. **Next scalability step: sub-linear search.** Add an ANN backend (FAISS or pgvector)
130
- behind `VectorStore`. Promote frequently filtered metadata fields to indexed columns so
131
- filters seek instead of scanning.
132
- 2. Multi-hop and composite-key relationships, and explicit child records as separate vectors.
133
- 3. Incremental sync driven by `updated_at` columns or a deletion log across every mapped table.
134
- CDC comes later.
135
- 4. Parallel embedding workers behind the `Indexer` batch loop. The owner-token checkpoint
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 (`relvector index config.yaml`) and a mapping config file format.
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 != "local":
92
- raise RelVectorError(f"unknown vector store {vector_store!r}; use 'local'")
93
- self.store: VectorStore = LocalVectorStore(index_path)
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,