astrocyte-sqlite 0.16.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.
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""SQLite adapter for Astrocyte — zero-infrastructure local storage.
|
|
2
|
+
|
|
3
|
+
One file on disk, no server, no native extensions. Provides a
|
|
4
|
+
:class:`SqliteStore` satisfying both the VectorStore and DocumentStore
|
|
5
|
+
protocols, with semantics matched to :class:`astrocyte_postgres.PostgresStore`
|
|
6
|
+
so recall behaves the same on a laptop as on the benched production backend.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from astrocyte_sqlite.store import SqliteStore
|
|
10
|
+
|
|
11
|
+
__all__ = ["SqliteStore"]
|
|
@@ -0,0 +1,799 @@
|
|
|
1
|
+
"""SQLite-backed VectorStore + DocumentStore.
|
|
2
|
+
|
|
3
|
+
Why this exists
|
|
4
|
+
---------------
|
|
5
|
+
"Install it and it works in your coding agent" needs storage with no server
|
|
6
|
+
to run. This adapter keeps every memory in one SQLite file so a developer can
|
|
7
|
+
``pip install`` and start retaining immediately.
|
|
8
|
+
|
|
9
|
+
Semantics are matched to ``PostgresStore``, not ``InMemoryVectorStore``
|
|
10
|
+
-----------------------------------------------------------------------
|
|
11
|
+
The two existing backends disagree in several places (the in-memory store lets
|
|
12
|
+
untagged items through a tag filter, never soft-deletes, returns unclamped
|
|
13
|
+
cosine, and applies ``time_range``/``session_id`` in ``search_similar`` where
|
|
14
|
+
Postgres does not). Postgres is what users deploy and what every benchmark
|
|
15
|
+
measures, so it is the contract here: identical config should recall
|
|
16
|
+
identically whichever of the two production backends holds the data.
|
|
17
|
+
``tests/test_parity_postgres.py`` enforces this differentially.
|
|
18
|
+
|
|
19
|
+
Design choices
|
|
20
|
+
--------------
|
|
21
|
+
* **No native extensions.** Embeddings are float32 BLOBs (pgvector's ``vector``
|
|
22
|
+
is also float4) and similarity is exact cosine in numpy. ``sqlite-vec`` would
|
|
23
|
+
need ``enable_load_extension``, which some Python builds omit. Exact search
|
|
24
|
+
also out-recalls ANN at personal-memory scale.
|
|
25
|
+
* **FTS5 when present, LIKE fallback otherwise.** Keyword queries drop the same
|
|
26
|
+
English stopwords ``plainto_tsquery('english', …)`` drops and AND the rest,
|
|
27
|
+
so "what is the database" matches like it does on Postgres.
|
|
28
|
+
* **Multi-process safe.** An MCP server and short-lived hook processes write
|
|
29
|
+
the same file concurrently: WAL journal, ``BEGIN IMMEDIATE`` for writes, a
|
|
30
|
+
busy timeout, and a fresh connection per call (sqlite3 connections are
|
|
31
|
+
thread-affine and cheap to open).
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import asyncio
|
|
37
|
+
import json
|
|
38
|
+
import os
|
|
39
|
+
import re
|
|
40
|
+
import sqlite3
|
|
41
|
+
import threading
|
|
42
|
+
import time
|
|
43
|
+
from collections.abc import Callable, Iterable
|
|
44
|
+
from datetime import UTC, datetime, timedelta
|
|
45
|
+
from pathlib import Path
|
|
46
|
+
from typing import Any, ClassVar, TypeVar
|
|
47
|
+
|
|
48
|
+
import numpy as np
|
|
49
|
+
from astrocyte.types import (
|
|
50
|
+
Document,
|
|
51
|
+
DocumentFilters,
|
|
52
|
+
DocumentHit,
|
|
53
|
+
HealthStatus,
|
|
54
|
+
VectorFilters,
|
|
55
|
+
VectorHit,
|
|
56
|
+
VectorItem,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
T = TypeVar("T")
|
|
60
|
+
|
|
61
|
+
DEFAULT_DB_PATH = "~/.local/share/astrocyte/astrocyte.db"
|
|
62
|
+
|
|
63
|
+
_EPOCH = datetime(1970, 1, 1, tzinfo=UTC)
|
|
64
|
+
_US = timedelta(microseconds=1)
|
|
65
|
+
|
|
66
|
+
# PostgreSQL's ``english`` text-search stopword list (snowball english.stop).
|
|
67
|
+
# plainto_tsquery('english', q) discards these before ANDing the remaining
|
|
68
|
+
# terms; FTS5 would otherwise require every one of them to appear.
|
|
69
|
+
_ENGLISH_STOPWORDS = frozenset(
|
|
70
|
+
"""
|
|
71
|
+
i me my myself we our ours ourselves you your yours yourself yourselves he
|
|
72
|
+
him his himself she her hers herself it its itself they them their theirs
|
|
73
|
+
themselves what which who whom this that these those am is are was were be
|
|
74
|
+
been being have has had having do does did doing a an the and but if or
|
|
75
|
+
because as until while of at by for with about against between into through
|
|
76
|
+
during before after above below to from up down in out on off over under
|
|
77
|
+
again further then once here there when where why how all any both each few
|
|
78
|
+
more most other some such no nor not only own same so than too very s t can
|
|
79
|
+
will just don should now
|
|
80
|
+
""".split()
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
_TOKEN_RE = re.compile(r"\w+", re.UNICODE)
|
|
84
|
+
|
|
85
|
+
_SCHEMA = """
|
|
86
|
+
CREATE TABLE IF NOT EXISTS astrocyte_meta (
|
|
87
|
+
key TEXT PRIMARY KEY,
|
|
88
|
+
value TEXT NOT NULL
|
|
89
|
+
);
|
|
90
|
+
CREATE TABLE IF NOT EXISTS astrocyte_vectors (
|
|
91
|
+
pk INTEGER PRIMARY KEY,
|
|
92
|
+
id TEXT NOT NULL UNIQUE,
|
|
93
|
+
bank_id TEXT NOT NULL,
|
|
94
|
+
embedding BLOB NOT NULL,
|
|
95
|
+
text TEXT NOT NULL,
|
|
96
|
+
metadata TEXT,
|
|
97
|
+
tags TEXT,
|
|
98
|
+
fact_type TEXT,
|
|
99
|
+
occurred_at INTEGER,
|
|
100
|
+
memory_layer TEXT,
|
|
101
|
+
retained_at INTEGER NOT NULL,
|
|
102
|
+
forgotten_at INTEGER,
|
|
103
|
+
chunk_id TEXT
|
|
104
|
+
);
|
|
105
|
+
CREATE INDEX IF NOT EXISTS astrocyte_vectors_bank_live
|
|
106
|
+
ON astrocyte_vectors (bank_id, forgotten_at);
|
|
107
|
+
CREATE INDEX IF NOT EXISTS astrocyte_vectors_bank_chunk
|
|
108
|
+
ON astrocyte_vectors (bank_id, chunk_id);
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
_FTS_SCHEMA = """
|
|
112
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS astrocyte_vectors_fts USING fts5(
|
|
113
|
+
text, content='astrocyte_vectors', content_rowid='pk',
|
|
114
|
+
tokenize='porter unicode61'
|
|
115
|
+
);
|
|
116
|
+
CREATE TRIGGER IF NOT EXISTS astrocyte_vectors_fts_ai
|
|
117
|
+
AFTER INSERT ON astrocyte_vectors BEGIN
|
|
118
|
+
INSERT INTO astrocyte_vectors_fts(rowid, text) VALUES (new.pk, new.text);
|
|
119
|
+
END;
|
|
120
|
+
CREATE TRIGGER IF NOT EXISTS astrocyte_vectors_fts_ad
|
|
121
|
+
AFTER DELETE ON astrocyte_vectors BEGIN
|
|
122
|
+
INSERT INTO astrocyte_vectors_fts(astrocyte_vectors_fts, rowid, text)
|
|
123
|
+
VALUES ('delete', old.pk, old.text);
|
|
124
|
+
END;
|
|
125
|
+
CREATE TRIGGER IF NOT EXISTS astrocyte_vectors_fts_au
|
|
126
|
+
AFTER UPDATE OF text ON astrocyte_vectors BEGIN
|
|
127
|
+
INSERT INTO astrocyte_vectors_fts(astrocyte_vectors_fts, rowid, text)
|
|
128
|
+
VALUES ('delete', old.pk, old.text);
|
|
129
|
+
INSERT INTO astrocyte_vectors_fts(rowid, text) VALUES (new.pk, new.text);
|
|
130
|
+
END;
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
_HIT_COLUMNS = "id, text, metadata, tags, fact_type, occurred_at, memory_layer, retained_at, chunk_id"
|
|
134
|
+
_ITEM_COLUMNS = "id, bank_id, embedding, text, metadata, tags, fact_type, occurred_at, memory_layer, retained_at"
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# ── value conversion ─────────────────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _to_us(dt: datetime | None) -> int | None:
|
|
141
|
+
"""Exact integer microseconds since the epoch (naive datetimes are UTC,
|
|
142
|
+
as ``timestamptz`` treats them under a UTC session)."""
|
|
143
|
+
if dt is None:
|
|
144
|
+
return None
|
|
145
|
+
if dt.tzinfo is None:
|
|
146
|
+
dt = dt.replace(tzinfo=UTC)
|
|
147
|
+
return (dt - _EPOCH) // _US
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _from_us(us: int | None) -> datetime | None:
|
|
151
|
+
return None if us is None else _EPOCH + timedelta(microseconds=us)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _now_us() -> int:
|
|
155
|
+
return _to_us(datetime.now(UTC)) # type: ignore[return-value]
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _encode_vector(vec: list[float]) -> bytes:
|
|
159
|
+
return np.asarray(vec, dtype=np.float32).tobytes()
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _decode_vector(blob: bytes) -> list[float]:
|
|
163
|
+
return np.frombuffer(blob, dtype=np.float32).tolist()
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _encode_tags(tags: list[str] | None) -> str | None:
|
|
167
|
+
return json.dumps(list(tags)) if tags is not None else None
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _decode_tags(raw: str | None) -> list[str] | None:
|
|
171
|
+
# PostgresStore returns None for both NULL and the empty array.
|
|
172
|
+
if not raw:
|
|
173
|
+
return None
|
|
174
|
+
tags = json.loads(raw)
|
|
175
|
+
return list(tags) if tags else None
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _decode_metadata(raw: str | None) -> Any:
|
|
179
|
+
return json.loads(raw) if raw is not None else None
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _keyword_terms(query: str) -> list[str]:
|
|
183
|
+
"""Terms plainto_tsquery('english', …) would keep, in query order."""
|
|
184
|
+
seen: set[str] = set()
|
|
185
|
+
terms: list[str] = []
|
|
186
|
+
for tok in _TOKEN_RE.findall(query.lower()):
|
|
187
|
+
if tok in _ENGLISH_STOPWORDS or tok in seen:
|
|
188
|
+
continue
|
|
189
|
+
seen.add(tok)
|
|
190
|
+
terms.append(tok)
|
|
191
|
+
return terms
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _placeholders(n: int) -> str:
|
|
195
|
+
return ",".join("?" * n)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
# ── store ────────────────────────────────────────────────────────────────
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
class SqliteStore:
|
|
202
|
+
"""VectorStore + DocumentStore over a single SQLite file.
|
|
203
|
+
|
|
204
|
+
Args:
|
|
205
|
+
path: Database file. Defaults to ``ASTROCYTE_SQLITE_PATH`` or
|
|
206
|
+
``~/.local/share/astrocyte/astrocyte.db``. Parent directories are
|
|
207
|
+
created. ``:memory:`` is rejected: every call opens its own
|
|
208
|
+
connection, so an in-memory database would vanish between calls.
|
|
209
|
+
embedding_dimensions: Expected vector length. When omitted, the first
|
|
210
|
+
write records it and every later write/query is checked against
|
|
211
|
+
it, so switching embedding models fails loudly instead of silently
|
|
212
|
+
mixing incomparable vectors.
|
|
213
|
+
busy_timeout_ms: How long a writer waits for another process's lock.
|
|
214
|
+
"""
|
|
215
|
+
|
|
216
|
+
SPI_VERSION: ClassVar[int] = 1
|
|
217
|
+
|
|
218
|
+
def __init__(
|
|
219
|
+
self,
|
|
220
|
+
path: str | None = None,
|
|
221
|
+
embedding_dimensions: int | None = None,
|
|
222
|
+
busy_timeout_ms: int = 10_000,
|
|
223
|
+
**_: Any,
|
|
224
|
+
) -> None:
|
|
225
|
+
raw = path or os.environ.get("ASTROCYTE_SQLITE_PATH") or DEFAULT_DB_PATH
|
|
226
|
+
if raw.strip() == ":memory:":
|
|
227
|
+
raise ValueError(
|
|
228
|
+
"SqliteStore does not support ':memory:' (each call opens its own "
|
|
229
|
+
"connection). Use a file path, or the in_memory store for tests."
|
|
230
|
+
)
|
|
231
|
+
self._path = str(Path(raw).expanduser())
|
|
232
|
+
self._configured_dim = int(embedding_dimensions) if embedding_dimensions else None
|
|
233
|
+
self._busy_timeout_ms = int(busy_timeout_ms)
|
|
234
|
+
self._schema_lock = threading.Lock()
|
|
235
|
+
self._schema_ready = False
|
|
236
|
+
self._fts = False
|
|
237
|
+
self._dim: int | None = self._configured_dim
|
|
238
|
+
|
|
239
|
+
# ── plumbing ──────────────────────────────────────────────────────
|
|
240
|
+
|
|
241
|
+
@property
|
|
242
|
+
def path(self) -> str:
|
|
243
|
+
return self._path
|
|
244
|
+
|
|
245
|
+
@property
|
|
246
|
+
def fts_enabled(self) -> bool:
|
|
247
|
+
self._ensure_schema()
|
|
248
|
+
return self._fts
|
|
249
|
+
|
|
250
|
+
def _connect(self) -> sqlite3.Connection:
|
|
251
|
+
# isolation_level=None: autocommit, so transactions are explicit.
|
|
252
|
+
conn = sqlite3.connect(self._path, timeout=self._busy_timeout_ms / 1000, isolation_level=None)
|
|
253
|
+
conn.row_factory = sqlite3.Row
|
|
254
|
+
conn.execute(f"PRAGMA busy_timeout = {self._busy_timeout_ms}")
|
|
255
|
+
return conn
|
|
256
|
+
|
|
257
|
+
def _ensure_schema(self) -> None:
|
|
258
|
+
if self._schema_ready:
|
|
259
|
+
return
|
|
260
|
+
with self._schema_lock:
|
|
261
|
+
if self._schema_ready:
|
|
262
|
+
return
|
|
263
|
+
Path(self._path).parent.mkdir(parents=True, exist_ok=True)
|
|
264
|
+
conn = self._connect()
|
|
265
|
+
try:
|
|
266
|
+
# WAL persists in the file: readers never block the writer, and
|
|
267
|
+
# concurrent hook processes don't trip "database is locked".
|
|
268
|
+
conn.execute("PRAGMA journal_mode = WAL")
|
|
269
|
+
conn.executescript(_SCHEMA)
|
|
270
|
+
try:
|
|
271
|
+
conn.executescript(_FTS_SCHEMA)
|
|
272
|
+
self._fts = True
|
|
273
|
+
except sqlite3.OperationalError:
|
|
274
|
+
self._fts = False # SQLite built without FTS5 → LIKE fallback
|
|
275
|
+
row = conn.execute("SELECT value FROM astrocyte_meta WHERE key = 'embedding_dimensions'").fetchone()
|
|
276
|
+
recorded = int(row["value"]) if row else None
|
|
277
|
+
if recorded and self._configured_dim and recorded != self._configured_dim:
|
|
278
|
+
raise ValueError(
|
|
279
|
+
f"{self._path} holds {recorded}-dim embeddings but "
|
|
280
|
+
f"embedding_dimensions={self._configured_dim} is configured. "
|
|
281
|
+
"Use the embedding model that built this store, or a new file."
|
|
282
|
+
)
|
|
283
|
+
self._dim = self._configured_dim or recorded
|
|
284
|
+
finally:
|
|
285
|
+
conn.close()
|
|
286
|
+
self._schema_ready = True
|
|
287
|
+
|
|
288
|
+
async def _run(self, fn: Callable[..., T], *args: Any) -> T:
|
|
289
|
+
return await asyncio.to_thread(fn, *args)
|
|
290
|
+
|
|
291
|
+
def _check_dim(self, n: int, what: str) -> None:
|
|
292
|
+
if self._dim is not None and n != self._dim:
|
|
293
|
+
raise ValueError(f"{what} length {n} != embedding_dimensions {self._dim}")
|
|
294
|
+
|
|
295
|
+
@staticmethod
|
|
296
|
+
def _live_filters(filters: VectorFilters | None, *, with_time_range: bool) -> tuple[list[str], list[Any]]:
|
|
297
|
+
"""WHERE clauses shared by search/list paths, mirroring PostgresStore.
|
|
298
|
+
|
|
299
|
+
``with_time_range`` reproduces a deliberate asymmetry in the reference:
|
|
300
|
+
``list_recent_vectors`` honours ``time_range`` but ``search_similar``
|
|
301
|
+
does not. Neither applies ``session_id`` or ``metadata_filters``.
|
|
302
|
+
"""
|
|
303
|
+
where: list[str] = []
|
|
304
|
+
params: list[Any] = []
|
|
305
|
+
if filters and filters.as_of:
|
|
306
|
+
as_of = _to_us(filters.as_of)
|
|
307
|
+
where.append("retained_at <= ?")
|
|
308
|
+
where.append("(forgotten_at IS NULL OR forgotten_at > ?)")
|
|
309
|
+
params.extend([as_of, as_of])
|
|
310
|
+
else:
|
|
311
|
+
where.append("forgotten_at IS NULL")
|
|
312
|
+
if filters and filters.tags:
|
|
313
|
+
where.append(
|
|
314
|
+
"EXISTS (SELECT 1 FROM json_each(astrocyte_vectors.tags) "
|
|
315
|
+
f"WHERE json_each.value IN ({_placeholders(len(filters.tags))}))"
|
|
316
|
+
)
|
|
317
|
+
params.extend(filters.tags)
|
|
318
|
+
if filters and filters.fact_types:
|
|
319
|
+
where.append(f"fact_type IN ({_placeholders(len(filters.fact_types))})")
|
|
320
|
+
params.extend(filters.fact_types)
|
|
321
|
+
if with_time_range and filters and filters.time_range:
|
|
322
|
+
start, end = filters.time_range
|
|
323
|
+
where.append("occurred_at >= ?")
|
|
324
|
+
where.append("occurred_at <= ?")
|
|
325
|
+
params.extend([_to_us(start), _to_us(end)])
|
|
326
|
+
return where, params
|
|
327
|
+
|
|
328
|
+
@staticmethod
|
|
329
|
+
def _row_to_hit(row: sqlite3.Row, score: float) -> VectorHit:
|
|
330
|
+
return VectorHit(
|
|
331
|
+
id=row["id"],
|
|
332
|
+
text=row["text"],
|
|
333
|
+
score=score,
|
|
334
|
+
metadata=_decode_metadata(row["metadata"]),
|
|
335
|
+
tags=_decode_tags(row["tags"]),
|
|
336
|
+
fact_type=row["fact_type"],
|
|
337
|
+
occurred_at=_from_us(row["occurred_at"]),
|
|
338
|
+
memory_layer=row["memory_layer"],
|
|
339
|
+
retained_at=_from_us(row["retained_at"]),
|
|
340
|
+
chunk_id=row["chunk_id"],
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
@staticmethod
|
|
344
|
+
def _row_to_item(row: sqlite3.Row) -> VectorItem:
|
|
345
|
+
# Mirrors PostgresStore, whose list paths do not return chunk_id.
|
|
346
|
+
return VectorItem(
|
|
347
|
+
id=row["id"],
|
|
348
|
+
bank_id=row["bank_id"],
|
|
349
|
+
vector=_decode_vector(row["embedding"]),
|
|
350
|
+
text=row["text"],
|
|
351
|
+
metadata=_decode_metadata(row["metadata"]),
|
|
352
|
+
tags=_decode_tags(row["tags"]),
|
|
353
|
+
fact_type=row["fact_type"],
|
|
354
|
+
occurred_at=_from_us(row["occurred_at"]),
|
|
355
|
+
memory_layer=row["memory_layer"],
|
|
356
|
+
retained_at=_from_us(row["retained_at"]),
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
# ── VectorStore ───────────────────────────────────────────────────
|
|
360
|
+
|
|
361
|
+
async def store_vectors(self, items: list[VectorItem]) -> list[str]:
|
|
362
|
+
return await self._run(self._store_vectors, items)
|
|
363
|
+
|
|
364
|
+
def _store_vectors(self, items: list[VectorItem]) -> list[str]:
|
|
365
|
+
self._ensure_schema()
|
|
366
|
+
if not items:
|
|
367
|
+
return []
|
|
368
|
+
dim = self._dim or len(items[0].vector)
|
|
369
|
+
# Validate the whole batch before writing: a bad item fails the batch
|
|
370
|
+
# atomically, as the reference's single transaction does.
|
|
371
|
+
for item in items:
|
|
372
|
+
if len(item.vector) != dim:
|
|
373
|
+
raise ValueError(f"Vector length {len(item.vector)} != embedding_dimensions {dim}")
|
|
374
|
+
conn = self._connect()
|
|
375
|
+
try:
|
|
376
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
377
|
+
try:
|
|
378
|
+
if self._dim is None:
|
|
379
|
+
# Another process may have recorded a dimension first.
|
|
380
|
+
row = conn.execute("SELECT value FROM astrocyte_meta WHERE key = 'embedding_dimensions'").fetchone()
|
|
381
|
+
if row and int(row["value"]) != dim:
|
|
382
|
+
raise ValueError(f"Vector length {dim} != embedding_dimensions {row['value']}")
|
|
383
|
+
conn.execute(
|
|
384
|
+
"INSERT OR IGNORE INTO astrocyte_meta(key, value) VALUES ('embedding_dimensions', ?)",
|
|
385
|
+
(str(dim),),
|
|
386
|
+
)
|
|
387
|
+
for item in items:
|
|
388
|
+
conn.execute(
|
|
389
|
+
"""
|
|
390
|
+
INSERT INTO astrocyte_vectors (
|
|
391
|
+
id, bank_id, embedding, text, metadata, tags, fact_type,
|
|
392
|
+
occurred_at, memory_layer, retained_at, chunk_id, forgotten_at
|
|
393
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)
|
|
394
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
395
|
+
bank_id = excluded.bank_id,
|
|
396
|
+
embedding = excluded.embedding,
|
|
397
|
+
text = excluded.text,
|
|
398
|
+
metadata = excluded.metadata,
|
|
399
|
+
tags = excluded.tags,
|
|
400
|
+
fact_type = excluded.fact_type,
|
|
401
|
+
occurred_at = excluded.occurred_at,
|
|
402
|
+
memory_layer = excluded.memory_layer,
|
|
403
|
+
retained_at = excluded.retained_at,
|
|
404
|
+
chunk_id = excluded.chunk_id,
|
|
405
|
+
forgotten_at = NULL
|
|
406
|
+
""",
|
|
407
|
+
(
|
|
408
|
+
item.id,
|
|
409
|
+
item.bank_id,
|
|
410
|
+
_encode_vector(item.vector),
|
|
411
|
+
item.text,
|
|
412
|
+
json.dumps(item.metadata) if item.metadata is not None else None,
|
|
413
|
+
_encode_tags(item.tags),
|
|
414
|
+
item.fact_type,
|
|
415
|
+
_to_us(item.occurred_at),
|
|
416
|
+
item.memory_layer,
|
|
417
|
+
_to_us(item.retained_at) if item.retained_at else _now_us(),
|
|
418
|
+
item.chunk_id,
|
|
419
|
+
),
|
|
420
|
+
)
|
|
421
|
+
conn.execute("COMMIT")
|
|
422
|
+
except BaseException:
|
|
423
|
+
conn.execute("ROLLBACK")
|
|
424
|
+
raise
|
|
425
|
+
finally:
|
|
426
|
+
conn.close()
|
|
427
|
+
self._dim = dim
|
|
428
|
+
return [item.id for item in items]
|
|
429
|
+
|
|
430
|
+
async def search_similar(
|
|
431
|
+
self,
|
|
432
|
+
query_vector: list[float],
|
|
433
|
+
bank_id: str,
|
|
434
|
+
limit: int = 10,
|
|
435
|
+
filters: VectorFilters | None = None,
|
|
436
|
+
) -> list[VectorHit]:
|
|
437
|
+
return await self._run(self._search_similar, query_vector, bank_id, limit, filters)
|
|
438
|
+
|
|
439
|
+
def _search_similar(
|
|
440
|
+
self,
|
|
441
|
+
query_vector: list[float],
|
|
442
|
+
bank_id: str,
|
|
443
|
+
limit: int,
|
|
444
|
+
filters: VectorFilters | None,
|
|
445
|
+
) -> list[VectorHit]:
|
|
446
|
+
self._ensure_schema()
|
|
447
|
+
self._check_dim(len(query_vector), "Query vector")
|
|
448
|
+
if limit <= 0:
|
|
449
|
+
return []
|
|
450
|
+
where, params = self._live_filters(filters, with_time_range=False)
|
|
451
|
+
conn = self._connect()
|
|
452
|
+
try:
|
|
453
|
+
# Phase 1: score only (pk, embedding). Phase 2: hydrate the winners.
|
|
454
|
+
rows = conn.execute(
|
|
455
|
+
f"SELECT pk, embedding FROM astrocyte_vectors WHERE bank_id = ? AND {' AND '.join(where)}",
|
|
456
|
+
[bank_id, *params],
|
|
457
|
+
).fetchall()
|
|
458
|
+
if not rows:
|
|
459
|
+
return []
|
|
460
|
+
pks = np.fromiter((r["pk"] for r in rows), dtype=np.int64, count=len(rows))
|
|
461
|
+
matrix = np.frombuffer(b"".join(r["embedding"] for r in rows), dtype=np.float32)
|
|
462
|
+
matrix = matrix.reshape(len(rows), -1)
|
|
463
|
+
# Rank on the raw cosine and clamp only the reported score — the
|
|
464
|
+
# reference ORDER BYs the unclamped distance, so every negatively
|
|
465
|
+
# correlated candidate (all reported as 0.0) still has a true order.
|
|
466
|
+
raw = _cosine_raw(np.asarray(query_vector, dtype=np.float32), matrix)
|
|
467
|
+
rank = {int(pks[i]): float(raw[i]) for i in _top_k(raw, limit)}
|
|
468
|
+
hydrated = conn.execute(
|
|
469
|
+
f"SELECT pk, {_HIT_COLUMNS} FROM astrocyte_vectors WHERE pk IN ({_placeholders(len(rank))})",
|
|
470
|
+
list(rank),
|
|
471
|
+
).fetchall()
|
|
472
|
+
finally:
|
|
473
|
+
conn.close()
|
|
474
|
+
# Best raw similarity first; id breaks exact ties deterministically
|
|
475
|
+
# (the reference leaves tie order to the planner).
|
|
476
|
+
hydrated.sort(key=lambda r: (-rank[r["pk"]], r["id"]))
|
|
477
|
+
return [self._row_to_hit(r, _clamp(rank[r["pk"]])) for r in hydrated]
|
|
478
|
+
|
|
479
|
+
async def delete(self, ids: list[str], bank_id: str) -> int:
|
|
480
|
+
return await self._run(self._delete, ids, bank_id)
|
|
481
|
+
|
|
482
|
+
def _delete(self, ids: list[str], bank_id: str) -> int:
|
|
483
|
+
"""Soft delete — sets ``forgotten_at`` so ``as_of`` time travel still
|
|
484
|
+
sees the memory as it was, exactly like the reference."""
|
|
485
|
+
if not ids:
|
|
486
|
+
return 0
|
|
487
|
+
self._ensure_schema()
|
|
488
|
+
conn = self._connect()
|
|
489
|
+
try:
|
|
490
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
491
|
+
cur = conn.execute(
|
|
492
|
+
f"UPDATE astrocyte_vectors SET forgotten_at = ? "
|
|
493
|
+
f"WHERE bank_id = ? AND forgotten_at IS NULL "
|
|
494
|
+
f"AND id IN ({_placeholders(len(ids))})",
|
|
495
|
+
[_now_us(), bank_id, *ids],
|
|
496
|
+
)
|
|
497
|
+
conn.execute("COMMIT")
|
|
498
|
+
return cur.rowcount or 0
|
|
499
|
+
finally:
|
|
500
|
+
conn.close()
|
|
501
|
+
|
|
502
|
+
async def purge(self, bank_id: str, ids: list[str] | None = None) -> int:
|
|
503
|
+
"""Erase forgotten memories from disk; returns how many were erased.
|
|
504
|
+
|
|
505
|
+
``delete`` is a soft delete (the reference's semantics: ``as_of``
|
|
506
|
+
still sees the past), so a forgotten memory's text stays in the file.
|
|
507
|
+
When a user removes something — a pasted key, a remark they regret —
|
|
508
|
+
it has to actually go. Only already-forgotten rows are erased (all of
|
|
509
|
+
the bank's, or those of ``ids``), so purge never bypasses forget.
|
|
510
|
+
|
|
511
|
+
Not part of the VectorStore SPI; ``astrocyte memory forget`` calls it
|
|
512
|
+
where available. FTS entries are removed outright (FTS5
|
|
513
|
+
``secure-delete``, SQLite 3.42+), the file is rebuilt (``VACUUM``,
|
|
514
|
+
with ``secure_delete``) and the WAL checkpointed, so no copy survives
|
|
515
|
+
in the database files. The rebuild is O(store size): seconds for a
|
|
516
|
+
local store, which is the trade for an erase that is one.
|
|
517
|
+
"""
|
|
518
|
+
return await self._run(self._purge, bank_id, ids)
|
|
519
|
+
|
|
520
|
+
def _purge(self, bank_id: str, ids: list[str] | None) -> int:
|
|
521
|
+
self._ensure_schema()
|
|
522
|
+
conn = self._connect()
|
|
523
|
+
try:
|
|
524
|
+
conn.execute("PRAGMA secure_delete = ON")
|
|
525
|
+
if self._fts:
|
|
526
|
+
try:
|
|
527
|
+
conn.execute(
|
|
528
|
+
"INSERT INTO astrocyte_vectors_fts(astrocyte_vectors_fts, rank) VALUES('secure-delete', 1)"
|
|
529
|
+
)
|
|
530
|
+
except sqlite3.OperationalError:
|
|
531
|
+
pass # older SQLite: deleted terms linger in the index until segments merge
|
|
532
|
+
sql = "DELETE FROM astrocyte_vectors WHERE bank_id = ? AND forgotten_at IS NOT NULL"
|
|
533
|
+
params: list[Any] = [bank_id]
|
|
534
|
+
if ids is not None:
|
|
535
|
+
if not ids:
|
|
536
|
+
return 0
|
|
537
|
+
sql += f" AND id IN ({_placeholders(len(ids))})"
|
|
538
|
+
params += ids
|
|
539
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
540
|
+
erased = conn.execute(sql, params).rowcount or 0
|
|
541
|
+
conn.execute("COMMIT")
|
|
542
|
+
if erased:
|
|
543
|
+
# The soft delete rewrote the row without secure_delete,
|
|
544
|
+
# leaving its old bytes in the page's free space; only a
|
|
545
|
+
# rebuild is sure to drop every stale copy.
|
|
546
|
+
conn.execute("VACUUM")
|
|
547
|
+
conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
|
|
548
|
+
return erased
|
|
549
|
+
finally:
|
|
550
|
+
conn.close()
|
|
551
|
+
|
|
552
|
+
async def list_vectors(self, bank_id: str, offset: int = 0, limit: int = 100) -> list[VectorItem]:
|
|
553
|
+
return await self._run(self._list_vectors, bank_id, offset, limit)
|
|
554
|
+
|
|
555
|
+
def _list_vectors(self, bank_id: str, offset: int, limit: int) -> list[VectorItem]:
|
|
556
|
+
self._ensure_schema()
|
|
557
|
+
conn = self._connect()
|
|
558
|
+
try:
|
|
559
|
+
rows = conn.execute(
|
|
560
|
+
f"SELECT {_ITEM_COLUMNS} FROM astrocyte_vectors "
|
|
561
|
+
"WHERE bank_id = ? AND forgotten_at IS NULL "
|
|
562
|
+
"ORDER BY id LIMIT ? OFFSET ?",
|
|
563
|
+
(bank_id, limit, offset),
|
|
564
|
+
).fetchall()
|
|
565
|
+
finally:
|
|
566
|
+
conn.close()
|
|
567
|
+
return [self._row_to_item(r) for r in rows]
|
|
568
|
+
|
|
569
|
+
async def list_recent_vectors(
|
|
570
|
+
self, bank_id: str, limit: int = 100, filters: VectorFilters | None = None
|
|
571
|
+
) -> list[VectorItem]:
|
|
572
|
+
return await self._run(self._list_recent_vectors, bank_id, limit, filters)
|
|
573
|
+
|
|
574
|
+
def _list_recent_vectors(self, bank_id: str, limit: int, filters: VectorFilters | None) -> list[VectorItem]:
|
|
575
|
+
self._ensure_schema()
|
|
576
|
+
where, params = self._live_filters(filters, with_time_range=True)
|
|
577
|
+
conn = self._connect()
|
|
578
|
+
try:
|
|
579
|
+
rows = conn.execute(
|
|
580
|
+
f"SELECT {_ITEM_COLUMNS} FROM astrocyte_vectors "
|
|
581
|
+
f"WHERE bank_id = ? AND {' AND '.join(where)} "
|
|
582
|
+
"ORDER BY COALESCE(occurred_at, retained_at) DESC, id LIMIT ?",
|
|
583
|
+
[bank_id, *params, limit],
|
|
584
|
+
).fetchall()
|
|
585
|
+
finally:
|
|
586
|
+
conn.close()
|
|
587
|
+
return [self._row_to_item(r) for r in rows]
|
|
588
|
+
|
|
589
|
+
async def list_banks(self) -> list[tuple[str, int, datetime | None]]:
|
|
590
|
+
"""Every bank holding live memories: ``(bank_id, count, newest)``.
|
|
591
|
+
|
|
592
|
+
Not part of the VectorStore SPI; ``astrocyte memory banks`` uses it
|
|
593
|
+
where available, since a local store has no other bank registry.
|
|
594
|
+
"""
|
|
595
|
+
return await self._run(self._list_banks)
|
|
596
|
+
|
|
597
|
+
def _list_banks(self) -> list[tuple[str, int, datetime | None]]:
|
|
598
|
+
self._ensure_schema()
|
|
599
|
+
conn = self._connect()
|
|
600
|
+
try:
|
|
601
|
+
rows = conn.execute(
|
|
602
|
+
"SELECT bank_id, COUNT(*), MAX(COALESCE(occurred_at, retained_at)) FROM astrocyte_vectors "
|
|
603
|
+
"WHERE forgotten_at IS NULL GROUP BY bank_id ORDER BY 3 DESC"
|
|
604
|
+
).fetchall()
|
|
605
|
+
finally:
|
|
606
|
+
conn.close()
|
|
607
|
+
return [(r[0], r[1], _from_us(r[2])) for r in rows]
|
|
608
|
+
|
|
609
|
+
async def get_by_chunk_ids(self, chunk_ids: list[str], bank_id: str) -> list[VectorHit]:
|
|
610
|
+
return await self._run(self._get_by_chunk_ids, chunk_ids, bank_id)
|
|
611
|
+
|
|
612
|
+
def _get_by_chunk_ids(self, chunk_ids: list[str], bank_id: str) -> list[VectorHit]:
|
|
613
|
+
if not chunk_ids:
|
|
614
|
+
return []
|
|
615
|
+
self._ensure_schema()
|
|
616
|
+
conn = self._connect()
|
|
617
|
+
try:
|
|
618
|
+
rows = conn.execute(
|
|
619
|
+
f"SELECT {_HIT_COLUMNS} FROM astrocyte_vectors "
|
|
620
|
+
"WHERE bank_id = ? AND forgotten_at IS NULL "
|
|
621
|
+
f"AND chunk_id IN ({_placeholders(len(chunk_ids))}) ORDER BY id",
|
|
622
|
+
[bank_id, *chunk_ids],
|
|
623
|
+
).fetchall()
|
|
624
|
+
finally:
|
|
625
|
+
conn.close()
|
|
626
|
+
return [self._row_to_hit(r, 1.0) for r in rows]
|
|
627
|
+
|
|
628
|
+
async def close(self) -> None:
|
|
629
|
+
"""No pooled resources: every call opens and closes its own connection."""
|
|
630
|
+
|
|
631
|
+
async def health(self) -> HealthStatus:
|
|
632
|
+
return await self._run(self._health)
|
|
633
|
+
|
|
634
|
+
def _health(self) -> HealthStatus:
|
|
635
|
+
started = time.perf_counter()
|
|
636
|
+
try:
|
|
637
|
+
self._ensure_schema()
|
|
638
|
+
conn = self._connect()
|
|
639
|
+
try:
|
|
640
|
+
conn.execute("SELECT 1").fetchone()
|
|
641
|
+
finally:
|
|
642
|
+
conn.close()
|
|
643
|
+
except Exception as e: # noqa: BLE001 — health reports, never raises
|
|
644
|
+
return HealthStatus(healthy=False, message=f"sqlite unhealthy ({self._path}): {e!s}")
|
|
645
|
+
mode = "fts5" if self._fts else "LIKE fallback (no FTS5)"
|
|
646
|
+
return HealthStatus(
|
|
647
|
+
healthy=True,
|
|
648
|
+
message=f"sqlite {self._path} — keyword search: {mode}",
|
|
649
|
+
latency_ms=(time.perf_counter() - started) * 1000,
|
|
650
|
+
last_check_at=datetime.now(UTC),
|
|
651
|
+
)
|
|
652
|
+
|
|
653
|
+
# ── DocumentStore ─────────────────────────────────────────────────
|
|
654
|
+
# Same arrangement as PostgresStore: memory text is already stored by
|
|
655
|
+
# store_vectors(), and the FTS5 triggers keep the index in sync, so the
|
|
656
|
+
# DocumentStore methods read the same table.
|
|
657
|
+
|
|
658
|
+
async def store_document(self, document: Document, bank_id: str) -> str:
|
|
659
|
+
"""No-op: text is indexed at retain time by store_vectors()."""
|
|
660
|
+
return document.id
|
|
661
|
+
|
|
662
|
+
async def search_fulltext(
|
|
663
|
+
self,
|
|
664
|
+
query: str,
|
|
665
|
+
bank_id: str,
|
|
666
|
+
limit: int = 10,
|
|
667
|
+
filters: DocumentFilters | None = None,
|
|
668
|
+
) -> list[DocumentHit]:
|
|
669
|
+
return await self._run(self._search_fulltext, query, bank_id, limit, filters)
|
|
670
|
+
|
|
671
|
+
def _search_fulltext(
|
|
672
|
+
self,
|
|
673
|
+
query: str,
|
|
674
|
+
bank_id: str,
|
|
675
|
+
limit: int,
|
|
676
|
+
filters: DocumentFilters | None,
|
|
677
|
+
) -> list[DocumentHit]:
|
|
678
|
+
if not query or not query.strip():
|
|
679
|
+
return []
|
|
680
|
+
terms = _keyword_terms(query)
|
|
681
|
+
if not terms:
|
|
682
|
+
return [] # all stopwords: plainto_tsquery yields an empty query
|
|
683
|
+
self._ensure_schema()
|
|
684
|
+
tag_sql, tag_params = "", []
|
|
685
|
+
if filters and filters.tags:
|
|
686
|
+
tag_sql = (
|
|
687
|
+
" AND EXISTS (SELECT 1 FROM json_each(v.tags) "
|
|
688
|
+
f"WHERE json_each.value IN ({_placeholders(len(filters.tags))}))"
|
|
689
|
+
)
|
|
690
|
+
tag_params = list(filters.tags)
|
|
691
|
+
conn = self._connect()
|
|
692
|
+
try:
|
|
693
|
+
if self._fts:
|
|
694
|
+
match = " AND ".join('"' + t.replace('"', '""') + '"' for t in terms)
|
|
695
|
+
rows = conn.execute(
|
|
696
|
+
"SELECT v.id, v.text, v.metadata, -bm25(astrocyte_vectors_fts) AS score "
|
|
697
|
+
"FROM astrocyte_vectors_fts "
|
|
698
|
+
"JOIN astrocyte_vectors v ON v.pk = astrocyte_vectors_fts.rowid "
|
|
699
|
+
"WHERE astrocyte_vectors_fts MATCH ? AND v.bank_id = ? "
|
|
700
|
+
f"AND v.forgotten_at IS NULL{tag_sql} "
|
|
701
|
+
"ORDER BY score DESC, v.id LIMIT ?",
|
|
702
|
+
[match, bank_id, *tag_params, limit],
|
|
703
|
+
).fetchall()
|
|
704
|
+
else:
|
|
705
|
+
rows = self._like_search(conn, terms, bank_id, tag_sql, tag_params, limit)
|
|
706
|
+
finally:
|
|
707
|
+
conn.close()
|
|
708
|
+
return [
|
|
709
|
+
DocumentHit(
|
|
710
|
+
document_id=r["id"],
|
|
711
|
+
text=r["text"],
|
|
712
|
+
score=float(r["score"]),
|
|
713
|
+
metadata=_decode_metadata(r["metadata"]),
|
|
714
|
+
)
|
|
715
|
+
for r in rows
|
|
716
|
+
]
|
|
717
|
+
|
|
718
|
+
@staticmethod
|
|
719
|
+
def _like_search(
|
|
720
|
+
conn: sqlite3.Connection,
|
|
721
|
+
terms: list[str],
|
|
722
|
+
bank_id: str,
|
|
723
|
+
tag_sql: str,
|
|
724
|
+
tag_params: list[Any],
|
|
725
|
+
limit: int,
|
|
726
|
+
) -> list[sqlite3.Row]:
|
|
727
|
+
"""Degraded keyword search for SQLite builds without FTS5: every term
|
|
728
|
+
must appear (no stemming); ranked by matched-term density."""
|
|
729
|
+
likes = " AND ".join("lower(v.text) LIKE ?" for _ in terms)
|
|
730
|
+
score = " + ".join("(length(lower(v.text)) - length(replace(lower(v.text), ?, ''))) / length(?)" for _ in terms)
|
|
731
|
+
score_params: list[Any] = []
|
|
732
|
+
for t in terms:
|
|
733
|
+
score_params.extend([t, t])
|
|
734
|
+
return conn.execute(
|
|
735
|
+
f"SELECT v.id, v.text, v.metadata, "
|
|
736
|
+
f"CAST(({score}) AS REAL) / (length(v.text) + 1) AS score "
|
|
737
|
+
f"FROM astrocyte_vectors v WHERE v.bank_id = ? AND v.forgotten_at IS NULL "
|
|
738
|
+
f"AND {likes}{tag_sql} ORDER BY score DESC, v.id LIMIT ?",
|
|
739
|
+
[*score_params, bank_id, *(f"%{t}%" for t in terms), *tag_params, limit],
|
|
740
|
+
).fetchall()
|
|
741
|
+
|
|
742
|
+
async def get_document(self, document_id: str, bank_id: str) -> Document | None:
|
|
743
|
+
return await self._run(self._get_document, document_id, bank_id)
|
|
744
|
+
|
|
745
|
+
def _get_document(self, document_id: str, bank_id: str) -> Document | None:
|
|
746
|
+
self._ensure_schema()
|
|
747
|
+
conn = self._connect()
|
|
748
|
+
try:
|
|
749
|
+
row = conn.execute(
|
|
750
|
+
"SELECT id, text, metadata, tags FROM astrocyte_vectors "
|
|
751
|
+
"WHERE id = ? AND bank_id = ? AND forgotten_at IS NULL",
|
|
752
|
+
(document_id, bank_id),
|
|
753
|
+
).fetchone()
|
|
754
|
+
finally:
|
|
755
|
+
conn.close()
|
|
756
|
+
if row is None:
|
|
757
|
+
return None
|
|
758
|
+
return Document(
|
|
759
|
+
id=row["id"],
|
|
760
|
+
text=row["text"],
|
|
761
|
+
metadata=_decode_metadata(row["metadata"]),
|
|
762
|
+
tags=_decode_tags(row["tags"]),
|
|
763
|
+
)
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
# ── numerics ─────────────────────────────────────────────────────────────
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
# Below any real cosine (>= -1), so zero-norm rows rank last — where pgvector's
|
|
770
|
+
# NaN distance puts them under ORDER BY.
|
|
771
|
+
_UNRANKABLE = -2.0
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
def _cosine_raw(query: np.ndarray, matrix: np.ndarray) -> np.ndarray:
|
|
775
|
+
"""Unclamped cosine similarity, i.e. ``1 - cosine_distance``.
|
|
776
|
+
|
|
777
|
+
A zero vector has no direction: pgvector returns NaN distance for it, which
|
|
778
|
+
sorts last. Those rows (and every row, for a zero query) get
|
|
779
|
+
``_UNRANKABLE`` and report a score of 0.0 rather than NaN.
|
|
780
|
+
"""
|
|
781
|
+
q_norm = float(np.linalg.norm(query))
|
|
782
|
+
if q_norm == 0.0:
|
|
783
|
+
return np.full(matrix.shape[0], _UNRANKABLE, dtype=np.float64)
|
|
784
|
+
wide = matrix.astype(np.float64)
|
|
785
|
+
row_norms = np.linalg.norm(wide, axis=1)
|
|
786
|
+
dots = wide @ query.astype(np.float64)
|
|
787
|
+
with np.errstate(divide="ignore", invalid="ignore"):
|
|
788
|
+
return np.where(row_norms > 0, dots / (row_norms * q_norm), _UNRANKABLE)
|
|
789
|
+
|
|
790
|
+
|
|
791
|
+
def _clamp(raw: float) -> float:
|
|
792
|
+
"""The reported score: the reference clamps ``1 - distance`` to [0, 1]."""
|
|
793
|
+
return min(max(raw, 0.0), 1.0)
|
|
794
|
+
|
|
795
|
+
|
|
796
|
+
def _top_k(scores: np.ndarray, k: int) -> Iterable[int]:
|
|
797
|
+
if k >= scores.shape[0]:
|
|
798
|
+
return range(scores.shape[0])
|
|
799
|
+
return np.argpartition(-scores, k - 1)[:k].tolist()
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: astrocyte-sqlite
|
|
3
|
+
Version: 0.16.0
|
|
4
|
+
Summary: SQLite adapter for Astrocyte (vector + document stores in one local file; zero infrastructure)
|
|
5
|
+
License-Expression: Apache-2.0
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Requires-Dist: astrocyte<2,>=0.15.0
|
|
8
|
+
Requires-Dist: numpy>=1.26
|
|
9
|
+
Provides-Extra: dev
|
|
10
|
+
Requires-Dist: astrocyte-postgres; extra == 'dev'
|
|
11
|
+
Requires-Dist: astrocyte[mcp]; extra == 'dev'
|
|
12
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
13
|
+
Requires-Dist: pytest-timeout>=2.2; extra == 'dev'
|
|
14
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# astrocyte-sqlite
|
|
18
|
+
|
|
19
|
+
Zero-infrastructure storage for Astrocyte: every memory in one SQLite file.
|
|
20
|
+
No server, no Docker, no native extensions — `pip install` and retain.
|
|
21
|
+
|
|
22
|
+
```yaml
|
|
23
|
+
# astrocyte.yaml
|
|
24
|
+
provider_tier: storage
|
|
25
|
+
vector_store: sqlite
|
|
26
|
+
vector_store_config:
|
|
27
|
+
path: ~/.local/share/astrocyte/astrocyte.db # default; or ASTROCYTE_SQLITE_PATH
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
`SqliteStore` satisfies both the `VectorStore` and `DocumentStore` protocols,
|
|
31
|
+
so the pipeline's keyword retrieval leg turns on automatically (the same
|
|
32
|
+
auto-wire `PostgresStore` gets).
|
|
33
|
+
|
|
34
|
+
## Behaves like Postgres, deliberately
|
|
35
|
+
|
|
36
|
+
Semantics match `astrocyte-postgres`, not the in-memory test store, because
|
|
37
|
+
Postgres is what users deploy and what every benchmark measures:
|
|
38
|
+
|
|
39
|
+
| Behaviour | `SqliteStore` / `PostgresStore` |
|
|
40
|
+
|---|---|
|
|
41
|
+
| `delete` | soft delete (`forgotten_at`); `as_of` time travel still sees it |
|
|
42
|
+
| re-storing a deleted id | resurrects it |
|
|
43
|
+
| `tags` / `fact_types` filters | items without tags / type are excluded |
|
|
44
|
+
| similarity score | cosine, clamped to [0, 1] |
|
|
45
|
+
| `search_similar` + `time_range` | not applied (`list_recent_vectors` applies it) |
|
|
46
|
+
| batch with a wrong-length vector | whole batch rejected |
|
|
47
|
+
| keyword query | English stopwords dropped, remaining terms ANDed, stemmed |
|
|
48
|
+
|
|
49
|
+
`tests/test_parity_postgres.py` runs identical operations against both
|
|
50
|
+
backends and fails on any divergence. Known, accepted differences: keyword
|
|
51
|
+
*ranking* (FTS5 `bm25` vs `ts_rank_cd` — the pipeline fuses by rank, and the
|
|
52
|
+
match set is identical), stemmer edge cases (Porter vs Snowball), and zero
|
|
53
|
+
vectors (score 0.0 here; NaN in pgvector).
|
|
54
|
+
|
|
55
|
+
## Design
|
|
56
|
+
|
|
57
|
+
- **Exact cosine in numpy over float32 BLOBs.** pgvector stores float4 too.
|
|
58
|
+
`sqlite-vec` was rejected: it needs `enable_load_extension`, which some Python
|
|
59
|
+
builds (including macOS system Python) omit.
|
|
60
|
+
- **FTS5 with a LIKE fallback** for SQLite builds compiled without it.
|
|
61
|
+
`health()` reports which is active.
|
|
62
|
+
- **Safe across processes.** An MCP server and short-lived agent-hook
|
|
63
|
+
processes write the same file: WAL journal, `BEGIN IMMEDIATE` writes, busy
|
|
64
|
+
timeout, a connection per call.
|
|
65
|
+
- **Embedding dimension is pinned on first write.** Switching embedding models
|
|
66
|
+
against an existing file fails loudly instead of mixing incomparable vectors.
|
|
67
|
+
- **Scale.** Exact search is linear in a bank's live memories. Measured on an
|
|
68
|
+
Apple Silicon laptop, median of 15 queries, `limit=50`:
|
|
69
|
+
|
|
70
|
+
| memories | dim | vector query | keyword query | file |
|
|
71
|
+
|---:|---:|---:|---:|---:|
|
|
72
|
+
| 1,000 | 384 | 3.7 ms | 3.5 ms | 2 MB |
|
|
73
|
+
| 10,000 | 384 | 23 ms | 19 ms | 21 MB |
|
|
74
|
+
| 50,000 | 384 | 116 ms | 95 ms | 107 MB |
|
|
75
|
+
| 10,000 | 1536 | 68 ms | 27 ms | 83 MB |
|
|
76
|
+
| 50,000 | 1536 | 420 ms | 143 ms | 414 MB |
|
|
77
|
+
|
|
78
|
+
Use the embedding model's native width: `local_embeddings` with
|
|
79
|
+
`pad_to: null` gives bge-small's 384 dims. Zero-padding to 1536 (needed only
|
|
80
|
+
for Postgres's fixed `vector(1536)` column) costs ~4x in time and disk and
|
|
81
|
+
changes no similarity. Keyword figures are pessimistic: the synthetic corpus
|
|
82
|
+
puts the query terms in nearly every document.
|
|
83
|
+
- **Identifiers sort bytewise.** `list_vectors` orders by `id` in byte order.
|
|
84
|
+
Postgres orders by the database collation; the two agree for the lowercase
|
|
85
|
+
UUIDs Astrocyte generates, but could differ for arbitrary mixed-case ids.
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
astrocyte_sqlite/__init__.py,sha256=JionWmBdpD4jlFZgcD1R93xB0tDjHXdRjVJykfArtnI,440
|
|
2
|
+
astrocyte_sqlite/store.py,sha256=eEytYziCyC6WZ0motGUOiJoP1z2YAhvQJiNt6CHgr3o,33466
|
|
3
|
+
astrocyte_sqlite-0.16.0.dist-info/METADATA,sha256=M6glEkvXiikT7rnbek6CT9e3RTQxYTgNp2GpN_tQPis,3868
|
|
4
|
+
astrocyte_sqlite-0.16.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
5
|
+
astrocyte_sqlite-0.16.0.dist-info/entry_points.txt,sha256=74qzezc8ANXUWz4VYVhN6D4GZnox45UtaPj82qYUU90,143
|
|
6
|
+
astrocyte_sqlite-0.16.0.dist-info/RECORD,,
|