synapse-memory-engine 1.3.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.
- synapse_memory/__init__.py +31 -0
- synapse_memory/api/fastapi_server.py +1334 -0
- synapse_memory/cli/synapse_cli.py +112 -0
- synapse_memory/client.py +127 -0
- synapse_memory/core/__init__.py +50 -0
- synapse_memory/core/auth.py +1273 -0
- synapse_memory/core/decay_engine.py +123 -0
- synapse_memory/core/deduplicator.py +98 -0
- synapse_memory/core/embedder.py +119 -0
- synapse_memory/core/embedding_provider.py +241 -0
- synapse_memory/core/encryption.py +226 -0
- synapse_memory/core/hybrid_search.py +199 -0
- synapse_memory/core/knapsack_packer.py +103 -0
- synapse_memory/core/memory_manager.py +233 -0
- synapse_memory/core/migrations.py +241 -0
- synapse_memory/core/observability.py +48 -0
- synapse_memory/core/pgvector_db.py +1276 -0
- synapse_memory/core/pruner.py +68 -0
- synapse_memory/core/rate_limiter.py +180 -0
- synapse_memory/core/sqlite_db.py +564 -0
- synapse_memory/examples/langchain_integration.py +12 -0
- synapse_memory/examples/llamaindex_integration.py +12 -0
- synapse_memory/integrations/__init__.py +9 -0
- synapse_memory/integrations/langchain.py +209 -0
- synapse_memory/integrations/llamaindex.py +295 -0
- synapse_memory/main.py +112 -0
- synapse_memory/py.typed +0 -0
- synapse_memory_engine-1.3.0.dist-info/METADATA +75 -0
- synapse_memory_engine-1.3.0.dist-info/RECORD +32 -0
- synapse_memory_engine-1.3.0.dist-info/WHEEL +5 -0
- synapse_memory_engine-1.3.0.dist-info/entry_points.txt +4 -0
- synapse_memory_engine-1.3.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1276 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Production PostgreSQL + pgvector + HNSW Memory Store with Database-Level Multi-Tenancy
|
|
3
|
+
and PostgreSQL Row-Level Security (RLS).
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import time
|
|
11
|
+
from typing import Any, Dict, List, Optional
|
|
12
|
+
|
|
13
|
+
from synapse_memory.core.encryption import (
|
|
14
|
+
FernetEncryptionProvider,
|
|
15
|
+
KeyManagementProvider,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger("PGVectorMemoryStore")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
import psycopg2
|
|
23
|
+
from psycopg2 import pool
|
|
24
|
+
from psycopg2.extras import RealDictCursor
|
|
25
|
+
HAS_PSYCOPG2 = True
|
|
26
|
+
except ImportError:
|
|
27
|
+
try:
|
|
28
|
+
import psycopg as psycopg2 # type: ignore
|
|
29
|
+
from psycopg.rows import dict_row as RealDictCursor # type: ignore
|
|
30
|
+
pool = None # type: ignore
|
|
31
|
+
HAS_PSYCOPG2 = True
|
|
32
|
+
except ImportError:
|
|
33
|
+
HAS_PSYCOPG2 = False
|
|
34
|
+
psycopg2 = None # type: ignore
|
|
35
|
+
pool = None # type: ignore
|
|
36
|
+
RealDictCursor = None # type: ignore
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def searchable_projection(content: str) -> str:
|
|
40
|
+
"""Value to store in ``content_plain`` — the lexical/FTS search projection.
|
|
41
|
+
|
|
42
|
+
``content`` is Fernet-encrypted before storage, but lexical and full-text
|
|
43
|
+
search need an unencrypted, indexable copy, so ``content_plain`` (and the
|
|
44
|
+
tsvector built from it) is deliberately PLAINTEXT (F-05). That is a
|
|
45
|
+
searchability trade-off, not encryption: a database dump or backup can
|
|
46
|
+
recover memory text from this column.
|
|
47
|
+
|
|
48
|
+
Set ``SYNAPSE_STORE_SEARCHABLE_PLAINTEXT=false`` for data that must never be
|
|
49
|
+
persisted in the clear: the projection is then stored empty, ``content``
|
|
50
|
+
remains encrypted, and lexical/FTS search returns no matches (vector search
|
|
51
|
+
is unaffected).
|
|
52
|
+
"""
|
|
53
|
+
if os.environ.get("SYNAPSE_STORE_SEARCHABLE_PLAINTEXT", "true").strip().lower() == "true":
|
|
54
|
+
return content
|
|
55
|
+
return ""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# F-15: the in-process fallback cache is an offline/dev convenience, not a second
|
|
59
|
+
# copy of production data. Bound it so it cannot grow without limit when the
|
|
60
|
+
# database is unavailable.
|
|
61
|
+
_FALLBACK_CACHE_MAX = 1000
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class PGVectorMemoryStore:
|
|
65
|
+
"""
|
|
66
|
+
Enterprise-grade Postgres + pgvector + HNSW storage engine.
|
|
67
|
+
Executes vector similarity search directly in SQL using HNSW graph indexes
|
|
68
|
+
with database-level multi-tenancy, Row-Level Security (RLS), and payload encryption.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
def __init__(
|
|
72
|
+
self,
|
|
73
|
+
connection_string: Optional[str] = None,
|
|
74
|
+
dimension: int = 128,
|
|
75
|
+
m: int = 16,
|
|
76
|
+
ef_construction: int = 64,
|
|
77
|
+
encryption_provider: Optional[Any] = None,
|
|
78
|
+
auto_init: bool = True,
|
|
79
|
+
fail_closed: bool = False,
|
|
80
|
+
apply_schema: bool = True,
|
|
81
|
+
admin_role: Optional[str] = None,
|
|
82
|
+
runtime_role: Optional[str] = None,
|
|
83
|
+
):
|
|
84
|
+
# SECURITY: never ship a default password. Require explicit DATABASE_URL in prod.
|
|
85
|
+
# Local dev convenience default uses an obviously-insecure placeholder and logs a warning.
|
|
86
|
+
self.connection_string = connection_string or os.environ.get(
|
|
87
|
+
"DATABASE_URL", "postgresql://synapse_admin:CHANGEME_dev_only@127.0.0.1:5432/synapse_cognitive"
|
|
88
|
+
)
|
|
89
|
+
if "CHANGEME" in self.connection_string:
|
|
90
|
+
logger.warning(
|
|
91
|
+
"Using insecure dev-only Postgres credentials. Set DATABASE_URL with a strong password for any shared deployment."
|
|
92
|
+
)
|
|
93
|
+
self.dimension = dimension
|
|
94
|
+
self.m = m
|
|
95
|
+
self.ef_construction = ef_construction
|
|
96
|
+
self.fail_closed = fail_closed
|
|
97
|
+
# apply_schema=False = "migrate-then-run" production topology: DDL and the
|
|
98
|
+
# migration ledger are owned by a separate migration role/step, and the
|
|
99
|
+
# engine connects with a least-privilege runtime role that has DML only.
|
|
100
|
+
# See scripts/provision_db_roles.py. Default True preserves the
|
|
101
|
+
# single-role local/dev behaviour.
|
|
102
|
+
self.apply_schema = apply_schema
|
|
103
|
+
|
|
104
|
+
resolved_admin = admin_role or os.environ.get("SYNAPSE_DB_ADMIN_ROLE", "synapse_admin")
|
|
105
|
+
if not re.match(r"^[A-Za-z_][A-Za-z0-9_]{0,62}$", resolved_admin):
|
|
106
|
+
raise ValueError(f"admin_role must be a safe identifier, got: {resolved_admin!r}")
|
|
107
|
+
self.admin_role = resolved_admin
|
|
108
|
+
|
|
109
|
+
resolved_runtime = runtime_role or os.environ.get("SYNAPSE_DB_RUNTIME_ROLE", "synapse_app")
|
|
110
|
+
if not re.match(r"^[A-Za-z_][A-Za-z0-9_]{0,62}$", resolved_runtime):
|
|
111
|
+
raise ValueError(f"runtime_role must be a safe identifier, got: {resolved_runtime!r}")
|
|
112
|
+
self.runtime_role = resolved_runtime
|
|
113
|
+
|
|
114
|
+
self._pool: Optional[Any] = None
|
|
115
|
+
self._connected = False
|
|
116
|
+
|
|
117
|
+
if encryption_provider is not None:
|
|
118
|
+
self.encryption_provider = encryption_provider
|
|
119
|
+
else:
|
|
120
|
+
key = KeyManagementProvider.get_encryption_key()
|
|
121
|
+
self.encryption_provider = FernetEncryptionProvider(key)
|
|
122
|
+
|
|
123
|
+
# In-memory shadow cache for local development/testing fallback
|
|
124
|
+
self._fallback_cache: Dict[str, Dict[str, Any]] = {}
|
|
125
|
+
|
|
126
|
+
if HAS_PSYCOPG2 and auto_init:
|
|
127
|
+
self._connect()
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def is_connected(self) -> bool:
|
|
131
|
+
return self._connected
|
|
132
|
+
|
|
133
|
+
def pool_stats(self) -> Dict[str, int]:
|
|
134
|
+
"""Live pool utilization for /metrics (no secrets)."""
|
|
135
|
+
if self._pool is None:
|
|
136
|
+
return {"pool_size": 0, "in_use": 0}
|
|
137
|
+
try:
|
|
138
|
+
# psycopg2 pools track min/max; used = checked-out count via internal queue.
|
|
139
|
+
maxconn = getattr(self._pool, "maxconn", 10)
|
|
140
|
+
used = maxconn - len(getattr(self._pool, "_pool", []))
|
|
141
|
+
return {"pool_size": int(maxconn), "in_use": max(0, int(used))}
|
|
142
|
+
except Exception:
|
|
143
|
+
return {"pool_size": 0, "in_use": 0}
|
|
144
|
+
|
|
145
|
+
def _connect(self) -> None:
|
|
146
|
+
try:
|
|
147
|
+
if pool is not None:
|
|
148
|
+
# Thread-safe pool: FastAPI handles requests on worker threads.
|
|
149
|
+
pool_cls = getattr(pool, "ThreadedConnectionPool", None) or pool.SimpleConnectionPool
|
|
150
|
+
self._pool = pool_cls(
|
|
151
|
+
1, 10, self.connection_string, connect_timeout=5
|
|
152
|
+
)
|
|
153
|
+
self._connected = True
|
|
154
|
+
if self.apply_schema:
|
|
155
|
+
self._initialize_database()
|
|
156
|
+
else:
|
|
157
|
+
# Runtime-role topology: the operator already ran DDL + migrations.
|
|
158
|
+
# Verify that, so a missing table fails loudly instead of silently
|
|
159
|
+
# serving from the in-memory fallback cache.
|
|
160
|
+
self._verify_schema_present()
|
|
161
|
+
logger.info("PGVectorMemoryStore successfully connected to PostgreSQL.")
|
|
162
|
+
except Exception as e:
|
|
163
|
+
self._connected = False
|
|
164
|
+
if self.fail_closed:
|
|
165
|
+
logger.error(f"PostgreSQL/pgvector connection failed in fail-closed mode: {e}")
|
|
166
|
+
raise RuntimeError(f"PostgreSQL connection failed: {e}")
|
|
167
|
+
logger.warning(
|
|
168
|
+
f"PostgreSQL/pgvector connection unviable ({e}); operating in fallback mode."
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
def _verify_schema_present(self) -> None:
|
|
172
|
+
"""Read-only guard for the ``apply_schema=False`` (runtime-role) topology.
|
|
173
|
+
|
|
174
|
+
When the engine runs as a least-privilege DML-only role it must not attempt
|
|
175
|
+
DDL, so we cannot create the schema on start-up. Instead we prove the
|
|
176
|
+
schema was provisioned by the migration step; if ``memories`` is absent we
|
|
177
|
+
raise rather than silently degrading to the in-memory fallback cache
|
|
178
|
+
(which would fake successful writes and lose data on restart).
|
|
179
|
+
Also verifies the connecting runtime database role is not a superuser,
|
|
180
|
+
does not have BYPASSRLS, does not own table 'memories', and is not the
|
|
181
|
+
privileged admin role.
|
|
182
|
+
"""
|
|
183
|
+
conn = self._get_connection()
|
|
184
|
+
try:
|
|
185
|
+
with conn.cursor() as cur:
|
|
186
|
+
cur.execute("SELECT to_regclass('public.memories') IS NOT NULL;")
|
|
187
|
+
row = cur.fetchone()
|
|
188
|
+
present = bool(row[0]) if row else False
|
|
189
|
+
if not present:
|
|
190
|
+
raise RuntimeError(
|
|
191
|
+
"Schema not provisioned: table 'memories' is missing. Run the "
|
|
192
|
+
"migration step (SQLite/Postgres DDL + migrations) before starting "
|
|
193
|
+
"the engine with apply_schema=False."
|
|
194
|
+
)
|
|
195
|
+
cur.execute(
|
|
196
|
+
"""
|
|
197
|
+
SELECT r.rolsuper, r.rolbypassrls, t.tableowner, current_user
|
|
198
|
+
FROM pg_roles r
|
|
199
|
+
CROSS JOIN (
|
|
200
|
+
SELECT tableowner FROM pg_tables WHERE schemaname = 'public' AND tablename = 'memories'
|
|
201
|
+
) t
|
|
202
|
+
WHERE r.rolname = current_user;
|
|
203
|
+
"""
|
|
204
|
+
)
|
|
205
|
+
res = cur.fetchone()
|
|
206
|
+
if res:
|
|
207
|
+
is_super = res[0] if isinstance(res, (tuple, list)) else res.get("rolsuper")
|
|
208
|
+
has_bypassrls = res[1] if isinstance(res, (tuple, list)) else res.get("rolbypassrls")
|
|
209
|
+
owner = res[2] if isinstance(res, (tuple, list)) else res.get("tableowner")
|
|
210
|
+
curr_user = res[3] if isinstance(res, (tuple, list)) else res.get("current_user")
|
|
211
|
+
if is_super:
|
|
212
|
+
raise RuntimeError(f"Security violation: runtime connection user '{curr_user}' is a SUPERUSER.")
|
|
213
|
+
if has_bypassrls:
|
|
214
|
+
raise RuntimeError(f"Security violation: runtime connection user '{curr_user}' has BYPASSRLS privilege.")
|
|
215
|
+
if owner == curr_user:
|
|
216
|
+
raise RuntimeError(f"Security violation: runtime connection user '{curr_user}' owns table 'memories'.")
|
|
217
|
+
if curr_user == self.admin_role:
|
|
218
|
+
raise RuntimeError(f"Security violation: runtime connections must not use admin role '{curr_user}'.")
|
|
219
|
+
finally:
|
|
220
|
+
self._return_connection(conn)
|
|
221
|
+
|
|
222
|
+
def _apply_rls_policy(self, cur: Any) -> None:
|
|
223
|
+
"""Applies or updates the row-level security policy idempotently.
|
|
224
|
+
|
|
225
|
+
Enforces tenant isolation for normal operations:
|
|
226
|
+
tenant_id = NULLIF(current_setting('app.current_tenant', true), '') AND tenant_id IS NOT NULL
|
|
227
|
+
Matching USING and WITH CHECK clauses ensure cross-tenant writes/updates are rejected (SQLSTATE 42501).
|
|
228
|
+
Intentional cross-tenant administrative operations require the admin role and explicit bypass flag.
|
|
229
|
+
"""
|
|
230
|
+
cur.execute("ALTER TABLE memories ENABLE ROW LEVEL SECURITY;")
|
|
231
|
+
cur.execute("ALTER TABLE memories FORCE ROW LEVEL SECURITY;")
|
|
232
|
+
admin_quoted = self.admin_role.replace("'", "''")
|
|
233
|
+
rls_sql = f"""
|
|
234
|
+
DO $$
|
|
235
|
+
BEGIN
|
|
236
|
+
DROP POLICY IF EXISTS tenant_isolation_policy ON memories;
|
|
237
|
+
CREATE POLICY tenant_isolation_policy ON memories
|
|
238
|
+
FOR ALL
|
|
239
|
+
USING (
|
|
240
|
+
(
|
|
241
|
+
tenant_id = NULLIF(current_setting('app.current_tenant', true), '')
|
|
242
|
+
AND tenant_id IS NOT NULL
|
|
243
|
+
)
|
|
244
|
+
OR (
|
|
245
|
+
current_user = '{admin_quoted}'
|
|
246
|
+
AND current_setting('app.admin_bypass_rls', true) = 'on'
|
|
247
|
+
)
|
|
248
|
+
)
|
|
249
|
+
WITH CHECK (
|
|
250
|
+
(
|
|
251
|
+
tenant_id = NULLIF(current_setting('app.current_tenant', true), '')
|
|
252
|
+
AND tenant_id IS NOT NULL
|
|
253
|
+
)
|
|
254
|
+
OR (
|
|
255
|
+
current_user = '{admin_quoted}'
|
|
256
|
+
AND current_setting('app.admin_bypass_rls', true) = 'on'
|
|
257
|
+
)
|
|
258
|
+
);
|
|
259
|
+
END $$;
|
|
260
|
+
"""
|
|
261
|
+
cur.execute(rls_sql)
|
|
262
|
+
|
|
263
|
+
def _initialize_database(self) -> None:
|
|
264
|
+
if not self._connected or not HAS_PSYCOPG2:
|
|
265
|
+
return
|
|
266
|
+
conn = self._get_connection()
|
|
267
|
+
try:
|
|
268
|
+
with conn.cursor() as cur:
|
|
269
|
+
# 1. Enable extensions. On a least-privilege migration role these
|
|
270
|
+
# may already be installed by a DBA; CREATE EXTENSION needs
|
|
271
|
+
# superuser for non-trusted extensions, so treat it as deferrable
|
|
272
|
+
# rather than fatal (mirrors the FTS block below).
|
|
273
|
+
try:
|
|
274
|
+
cur.execute("CREATE EXTENSION IF NOT EXISTS vector;")
|
|
275
|
+
cur.execute("CREATE EXTENSION IF NOT EXISTS pg_trgm;")
|
|
276
|
+
except Exception as ext_err:
|
|
277
|
+
logger.warning(
|
|
278
|
+
"Extension initialization deferred (install `vector` and "
|
|
279
|
+
"`pg_trgm` as a DBA if this role lacks CREATE EXTENSION): %s",
|
|
280
|
+
ext_err,
|
|
281
|
+
)
|
|
282
|
+
conn.rollback()
|
|
283
|
+
|
|
284
|
+
# 2. Main cognitive memories table with tenant_id
|
|
285
|
+
cur.execute(f"""
|
|
286
|
+
CREATE TABLE IF NOT EXISTS memories (
|
|
287
|
+
id VARCHAR(128) PRIMARY KEY,
|
|
288
|
+
tenant_id VARCHAR(128) NOT NULL DEFAULT 'default',
|
|
289
|
+
content TEXT NOT NULL,
|
|
290
|
+
category VARCHAR(64) NOT NULL,
|
|
291
|
+
confidence REAL NOT NULL,
|
|
292
|
+
created_at DOUBLE PRECISION NOT NULL,
|
|
293
|
+
last_accessed_at DOUBLE PRECISION NOT NULL,
|
|
294
|
+
access_count INTEGER NOT NULL DEFAULT 0,
|
|
295
|
+
token_cost INTEGER NOT NULL DEFAULT 0,
|
|
296
|
+
feedback_multiplier REAL NOT NULL DEFAULT 1.0,
|
|
297
|
+
supersedes VARCHAR(128),
|
|
298
|
+
superseded_by VARCHAR(128),
|
|
299
|
+
embedding vector({self.dimension})
|
|
300
|
+
);
|
|
301
|
+
""")
|
|
302
|
+
|
|
303
|
+
# 3. Column migration if table already exists
|
|
304
|
+
cur.execute("""
|
|
305
|
+
DO $$
|
|
306
|
+
BEGIN
|
|
307
|
+
IF NOT EXISTS (
|
|
308
|
+
SELECT 1 FROM information_schema.columns
|
|
309
|
+
WHERE table_name='memories' AND column_name='tenant_id'
|
|
310
|
+
) THEN
|
|
311
|
+
ALTER TABLE memories ADD COLUMN tenant_id VARCHAR(128) NOT NULL DEFAULT 'default';
|
|
312
|
+
END IF;
|
|
313
|
+
END $$;
|
|
314
|
+
""")
|
|
315
|
+
|
|
316
|
+
# 3a. Supersession chain columns (deduplication lineage)
|
|
317
|
+
cur.execute("""
|
|
318
|
+
DO $$
|
|
319
|
+
BEGIN
|
|
320
|
+
IF NOT EXISTS (
|
|
321
|
+
SELECT 1 FROM information_schema.columns
|
|
322
|
+
WHERE table_name='memories' AND column_name='supersedes'
|
|
323
|
+
) THEN
|
|
324
|
+
ALTER TABLE memories ADD COLUMN supersedes VARCHAR(128);
|
|
325
|
+
END IF;
|
|
326
|
+
IF NOT EXISTS (
|
|
327
|
+
SELECT 1 FROM information_schema.columns
|
|
328
|
+
WHERE table_name='memories' AND column_name='superseded_by'
|
|
329
|
+
) THEN
|
|
330
|
+
ALTER TABLE memories ADD COLUMN superseded_by VARCHAR(128);
|
|
331
|
+
END IF;
|
|
332
|
+
END $$;
|
|
333
|
+
""")
|
|
334
|
+
|
|
335
|
+
# 3b. Vector dimension migration if column dimension differs from configured target
|
|
336
|
+
try:
|
|
337
|
+
cur.execute("""
|
|
338
|
+
SELECT atttypmod FROM pg_attribute
|
|
339
|
+
WHERE attrelid = 'memories'::regclass AND attname = 'embedding';
|
|
340
|
+
""")
|
|
341
|
+
row = cur.fetchone()
|
|
342
|
+
if row:
|
|
343
|
+
current_dim = row[0] if isinstance(row, (tuple, list)) else row.get("atttypmod")
|
|
344
|
+
if current_dim and current_dim > 0 and current_dim != self.dimension:
|
|
345
|
+
logger.warning(
|
|
346
|
+
f"Migrating PostgreSQL 'memories.embedding' column dimension from {current_dim} to {self.dimension}..."
|
|
347
|
+
)
|
|
348
|
+
cur.execute("DROP INDEX IF EXISTS idx_memories_hnsw;")
|
|
349
|
+
cur.execute(f"ALTER TABLE memories ALTER COLUMN embedding TYPE vector({self.dimension});")
|
|
350
|
+
except Exception as mig_err:
|
|
351
|
+
logger.warning(f"Vector dimension check/migration skipped: {mig_err}")
|
|
352
|
+
|
|
353
|
+
# 4. HNSW vector index
|
|
354
|
+
cur.execute(f"""
|
|
355
|
+
CREATE INDEX IF NOT EXISTS idx_memories_hnsw
|
|
356
|
+
ON memories USING hnsw (embedding vector_cosine_ops)
|
|
357
|
+
WITH (m = {self.m}, ef_construction = {self.ef_construction});
|
|
358
|
+
""")
|
|
359
|
+
|
|
360
|
+
# 5. Relational and tenant indexes
|
|
361
|
+
cur.execute("CREATE INDEX IF NOT EXISTS idx_memories_tenant ON memories(tenant_id);")
|
|
362
|
+
cur.execute("CREATE INDEX IF NOT EXISTS idx_memories_tenant_cat ON memories(tenant_id, category);")
|
|
363
|
+
cur.execute("CREATE INDEX IF NOT EXISTS idx_memories_cat ON memories(category);")
|
|
364
|
+
cur.execute("CREATE INDEX IF NOT EXISTS idx_memories_created ON memories(created_at DESC);")
|
|
365
|
+
cur.execute("CREATE INDEX IF NOT EXISTS idx_memories_access ON memories(last_accessed_at DESC);")
|
|
366
|
+
|
|
367
|
+
# 5b. Combined protection: `content` holds Fernet ciphertext;
|
|
368
|
+
# `content_plain` is the searchable projection feeding the
|
|
369
|
+
# tsvector index. Threat model: volume/database encryption
|
|
370
|
+
# protects everything at rest; field encryption protects the
|
|
371
|
+
# authoritative content column. Embeddings + tsvector remain
|
|
372
|
+
# readable derivatives by design (required for HNSW + BM25).
|
|
373
|
+
try:
|
|
374
|
+
cur.execute("""
|
|
375
|
+
DO $$
|
|
376
|
+
BEGIN
|
|
377
|
+
IF NOT EXISTS (
|
|
378
|
+
SELECT 1 FROM information_schema.columns
|
|
379
|
+
WHERE table_name='memories' AND column_name='content_plain'
|
|
380
|
+
) THEN
|
|
381
|
+
ALTER TABLE memories ADD COLUMN content_plain TEXT NOT NULL DEFAULT '';
|
|
382
|
+
END IF;
|
|
383
|
+
END $$;
|
|
384
|
+
""")
|
|
385
|
+
cur.execute("""
|
|
386
|
+
DO $$
|
|
387
|
+
BEGIN
|
|
388
|
+
IF NOT EXISTS (
|
|
389
|
+
SELECT 1 FROM information_schema.columns
|
|
390
|
+
WHERE table_name='memories' AND column_name='search_vector'
|
|
391
|
+
) THEN
|
|
392
|
+
ALTER TABLE memories
|
|
393
|
+
ADD COLUMN search_vector tsvector
|
|
394
|
+
GENERATED ALWAYS AS (to_tsvector('english', content_plain)) STORED;
|
|
395
|
+
END IF;
|
|
396
|
+
END $$;
|
|
397
|
+
""")
|
|
398
|
+
cur.execute("""
|
|
399
|
+
CREATE INDEX IF NOT EXISTS idx_memories_search_vector
|
|
400
|
+
ON memories USING GIN(search_vector);
|
|
401
|
+
""")
|
|
402
|
+
except Exception as fts_err:
|
|
403
|
+
logger.warning(f"Full-Text Search column initialization deferred: {fts_err}")
|
|
404
|
+
|
|
405
|
+
# 6. Database Row-Level Security (RLS)
|
|
406
|
+
self._apply_rls_policy(cur)
|
|
407
|
+
conn.commit()
|
|
408
|
+
except Exception as e:
|
|
409
|
+
conn.rollback()
|
|
410
|
+
if self.fail_closed:
|
|
411
|
+
raise RuntimeError(
|
|
412
|
+
f"PostgreSQL/pgvector initialization failed: {e}"
|
|
413
|
+
) from e
|
|
414
|
+
logger.warning(
|
|
415
|
+
f"PostgreSQL initialization failed; using fallback mode: {e}"
|
|
416
|
+
)
|
|
417
|
+
finally:
|
|
418
|
+
self._return_connection(conn)
|
|
419
|
+
|
|
420
|
+
# Apply forward-only schema migrations (embedding_dimension / model_version
|
|
421
|
+
# + migration ledger). Versioned + idempotent across restarts.
|
|
422
|
+
try:
|
|
423
|
+
from synapse_memory.core.migrations import run_migrations
|
|
424
|
+
run_migrations(
|
|
425
|
+
self._get_connection,
|
|
426
|
+
is_postgres=True,
|
|
427
|
+
fail_closed=self.fail_closed,
|
|
428
|
+
owns_connection=False,
|
|
429
|
+
)
|
|
430
|
+
except Exception as mig_err:
|
|
431
|
+
if self.fail_closed:
|
|
432
|
+
# Fail-closed deployments must NEVER boot with a half-migrated
|
|
433
|
+
# schema: defer-and-warn turns a migration bug into silent
|
|
434
|
+
# data-shape corruption at query time.
|
|
435
|
+
raise RuntimeError(f"Schema migrations failed (fail_closed): {mig_err}") from mig_err
|
|
436
|
+
logger.warning(f"Schema migrations deferred: {mig_err}")
|
|
437
|
+
|
|
438
|
+
def _handle_db_error(self, operation: str, error: Exception) -> None:
|
|
439
|
+
if self.fail_closed:
|
|
440
|
+
raise RuntimeError(
|
|
441
|
+
f"PostgreSQL operation failed: {operation}: {error}"
|
|
442
|
+
) from error
|
|
443
|
+
logger.error(
|
|
444
|
+
"PostgreSQL operation failed: %s: %s",
|
|
445
|
+
operation,
|
|
446
|
+
error
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
def _encrypt_field(self, plaintext: str) -> str:
|
|
450
|
+
try:
|
|
451
|
+
return self.encryption_provider.encrypt(plaintext)
|
|
452
|
+
except Exception as e:
|
|
453
|
+
logger.error("Field encryption failed: %s", e)
|
|
454
|
+
raise RuntimeError(f"Field encryption failed: {e}") from e
|
|
455
|
+
|
|
456
|
+
def _decrypt_field(self, ciphertext: str) -> str:
|
|
457
|
+
try:
|
|
458
|
+
return self.encryption_provider.decrypt(ciphertext)
|
|
459
|
+
except Exception as e:
|
|
460
|
+
logger.error("Field decryption failed (wrong key or corrupt payload).")
|
|
461
|
+
raise RuntimeError("Field decryption failed: wrong key or corrupt payload.") from e
|
|
462
|
+
|
|
463
|
+
def _decrypt_row(self, row: Dict[str, Any]) -> Dict[str, Any]:
|
|
464
|
+
mem = dict(row)
|
|
465
|
+
content = mem.get("content")
|
|
466
|
+
if isinstance(content, str) and content:
|
|
467
|
+
try:
|
|
468
|
+
mem["content"] = self._decrypt_field(content)
|
|
469
|
+
except Exception:
|
|
470
|
+
# Fail safe: do not return ciphertext as if it were plaintext.
|
|
471
|
+
raise
|
|
472
|
+
if isinstance(mem.get("embedding"), str):
|
|
473
|
+
try:
|
|
474
|
+
mem["embedding"] = json.loads(mem["embedding"])
|
|
475
|
+
except Exception:
|
|
476
|
+
pass
|
|
477
|
+
mem.pop("search_vector", None)
|
|
478
|
+
return mem
|
|
479
|
+
|
|
480
|
+
def _get_connection(self) -> Any:
|
|
481
|
+
if self._pool is not None:
|
|
482
|
+
return self._pool.getconn()
|
|
483
|
+
if psycopg2 is not None:
|
|
484
|
+
return psycopg2.connect(self.connection_string)
|
|
485
|
+
raise RuntimeError("psycopg2 driver not installed.")
|
|
486
|
+
|
|
487
|
+
def _return_connection(self, conn: Any) -> None:
|
|
488
|
+
if self._pool is not None:
|
|
489
|
+
try:
|
|
490
|
+
# Roll back any open transaction so SET LOCAL tenant context
|
|
491
|
+
# and failed statements never leak into the next checkout.
|
|
492
|
+
conn.rollback()
|
|
493
|
+
except Exception:
|
|
494
|
+
pass
|
|
495
|
+
try:
|
|
496
|
+
self._pool.putconn(conn)
|
|
497
|
+
except Exception:
|
|
498
|
+
try:
|
|
499
|
+
conn.close()
|
|
500
|
+
except Exception:
|
|
501
|
+
pass
|
|
502
|
+
else:
|
|
503
|
+
try:
|
|
504
|
+
conn.close()
|
|
505
|
+
except Exception:
|
|
506
|
+
pass
|
|
507
|
+
|
|
508
|
+
def _set_tenant_context(self, cur: Any, tenant_id: Optional[str]) -> None:
|
|
509
|
+
# None / "*" means admin (unscoped) access: the RLS policy gates the
|
|
510
|
+
# admin bypass on current_user = <admin_role>, not on a settable GUC
|
|
511
|
+
# sentinel. Empty string means "no tenant" and must see zero rows
|
|
512
|
+
# (fail-closed isolation).
|
|
513
|
+
if tenant_id is None or tenant_id == "*":
|
|
514
|
+
ctx = ""
|
|
515
|
+
else:
|
|
516
|
+
ctx = tenant_id
|
|
517
|
+
cur.execute("SET LOCAL app.current_tenant = %s;", (ctx,))
|
|
518
|
+
|
|
519
|
+
def _cache_fallback(self, memory: Dict[str, Any]) -> None:
|
|
520
|
+
"""Bounded insert into the offline fallback cache (F-15)."""
|
|
521
|
+
if len(self._fallback_cache) >= _FALLBACK_CACHE_MAX:
|
|
522
|
+
overflow = len(self._fallback_cache) - _FALLBACK_CACHE_MAX + 1
|
|
523
|
+
for key in list(self._fallback_cache.keys())[:overflow]:
|
|
524
|
+
del self._fallback_cache[key]
|
|
525
|
+
self._fallback_cache[memory["id"]] = memory
|
|
526
|
+
|
|
527
|
+
def insert_memory(self, memory: Dict[str, Any]) -> None:
|
|
528
|
+
"""Inserts memory with embedding vector into pgvector table with tenant isolation.
|
|
529
|
+
|
|
530
|
+
Combined protection: `content` stores Fernet ciphertext;
|
|
531
|
+
`content_plain` stores the searchable projection for tsvector/BM25.
|
|
532
|
+
"""
|
|
533
|
+
tenant_id = memory.get("tenant_id") or memory.get("tenantId") or "default"
|
|
534
|
+
|
|
535
|
+
if not self._connected or not HAS_PSYCOPG2:
|
|
536
|
+
# Offline/dev mode only: with no database the in-process cache IS the
|
|
537
|
+
# store. Previously every insert was mirrored here even when
|
|
538
|
+
# PostgreSQL was connected, keeping a second plaintext copy of the
|
|
539
|
+
# content and embedding per row (F-15).
|
|
540
|
+
mem_copy = memory.copy()
|
|
541
|
+
mem_copy["tenant_id"] = tenant_id
|
|
542
|
+
self._cache_fallback(mem_copy)
|
|
543
|
+
return
|
|
544
|
+
|
|
545
|
+
conn = self._get_connection()
|
|
546
|
+
try:
|
|
547
|
+
with conn.cursor() as cur:
|
|
548
|
+
self._set_tenant_context(cur, tenant_id)
|
|
549
|
+
emb = memory.get("embedding", [])
|
|
550
|
+
if len(emb) != self.dimension:
|
|
551
|
+
raise RuntimeError(
|
|
552
|
+
f"Embedding dimension mismatch: storage is configured for "
|
|
553
|
+
f"{self.dimension}-d vectors, received {len(emb)}-d."
|
|
554
|
+
)
|
|
555
|
+
emb_str = f"[{','.join(str(float(x)) for x in emb)}]" if emb else None
|
|
556
|
+
plaintext = str(memory["content"])
|
|
557
|
+
ciphertext = self._encrypt_field(plaintext)
|
|
558
|
+
# F-05: content_plain is a PLAINTEXT search projection, not
|
|
559
|
+
# encrypted data. Operators who cannot persist plaintext at rest
|
|
560
|
+
# can opt out; the column is then empty and lexical/FTS search
|
|
561
|
+
# finds nothing (vector search is unaffected).
|
|
562
|
+
projection = searchable_projection(plaintext)
|
|
563
|
+
cur.execute("""
|
|
564
|
+
INSERT INTO memories (
|
|
565
|
+
id, tenant_id, content, content_plain, category, confidence, created_at,
|
|
566
|
+
last_accessed_at, access_count, token_cost,
|
|
567
|
+
feedback_multiplier, embedding, embedding_dimension, model_version,
|
|
568
|
+
supersedes, superseded_by
|
|
569
|
+
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
|
570
|
+
ON CONFLICT (id) DO UPDATE SET
|
|
571
|
+
tenant_id = EXCLUDED.tenant_id,
|
|
572
|
+
content = EXCLUDED.content,
|
|
573
|
+
content_plain = EXCLUDED.content_plain,
|
|
574
|
+
category = EXCLUDED.category,
|
|
575
|
+
confidence = EXCLUDED.confidence,
|
|
576
|
+
last_accessed_at = EXCLUDED.last_accessed_at,
|
|
577
|
+
access_count = EXCLUDED.access_count,
|
|
578
|
+
token_cost = EXCLUDED.token_cost,
|
|
579
|
+
feedback_multiplier = EXCLUDED.feedback_multiplier,
|
|
580
|
+
embedding = EXCLUDED.embedding,
|
|
581
|
+
embedding_dimension = EXCLUDED.embedding_dimension,
|
|
582
|
+
model_version = EXCLUDED.model_version,
|
|
583
|
+
supersedes = EXCLUDED.supersedes,
|
|
584
|
+
superseded_by = EXCLUDED.superseded_by;
|
|
585
|
+
""", (
|
|
586
|
+
memory["id"],
|
|
587
|
+
tenant_id,
|
|
588
|
+
ciphertext,
|
|
589
|
+
projection,
|
|
590
|
+
memory["category"],
|
|
591
|
+
memory["confidence"],
|
|
592
|
+
memory["created_at"],
|
|
593
|
+
memory.get("last_accessed_at", time.time()),
|
|
594
|
+
memory.get("access_count", 0),
|
|
595
|
+
memory.get("token_cost", 0),
|
|
596
|
+
memory.get("feedback_multiplier", 1.0),
|
|
597
|
+
emb_str,
|
|
598
|
+
memory.get("embedding_dimension", len(emb or [])),
|
|
599
|
+
memory.get("model_version", "unknown"),
|
|
600
|
+
memory.get("supersedes"),
|
|
601
|
+
memory.get("superseded_by")
|
|
602
|
+
))
|
|
603
|
+
conn.commit()
|
|
604
|
+
except Exception as e:
|
|
605
|
+
conn.rollback()
|
|
606
|
+
self._handle_db_error("insert_memory", e)
|
|
607
|
+
finally:
|
|
608
|
+
self._return_connection(conn)
|
|
609
|
+
|
|
610
|
+
def mark_superseded(self, memory_id: str, superseded_by: str, tenant_id: Optional[str] = None) -> None:
|
|
611
|
+
"""Stamp `superseded_by` on an old memory that a dedup MERGE replaced.
|
|
612
|
+
|
|
613
|
+
Failure is non-fatal for ingestion (the caller guards with try/except),
|
|
614
|
+
but DB errors still flow through _handle_db_error for observability.
|
|
615
|
+
"""
|
|
616
|
+
cached = self._fallback_cache.get(memory_id)
|
|
617
|
+
if cached is not None:
|
|
618
|
+
cached["superseded_by"] = superseded_by
|
|
619
|
+
|
|
620
|
+
if not self._connected or not HAS_PSYCOPG2:
|
|
621
|
+
return
|
|
622
|
+
|
|
623
|
+
conn = self._get_connection()
|
|
624
|
+
try:
|
|
625
|
+
with conn.cursor() as cur:
|
|
626
|
+
self._set_tenant_context(cur, tenant_id)
|
|
627
|
+
cur.execute(
|
|
628
|
+
"UPDATE memories SET superseded_by = %s WHERE id = %s;",
|
|
629
|
+
(superseded_by, memory_id),
|
|
630
|
+
)
|
|
631
|
+
conn.commit()
|
|
632
|
+
except Exception as e:
|
|
633
|
+
conn.rollback()
|
|
634
|
+
self._handle_db_error("mark_superseded", e)
|
|
635
|
+
finally:
|
|
636
|
+
self._return_connection(conn)
|
|
637
|
+
|
|
638
|
+
def get_memory_by_id(self, memory_id: str, tenant_id: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
|
639
|
+
if not self._connected or not HAS_PSYCOPG2:
|
|
640
|
+
mem = self._fallback_cache.get(memory_id)
|
|
641
|
+
if mem and tenant_id and mem.get("tenant_id") != tenant_id:
|
|
642
|
+
return None
|
|
643
|
+
return mem
|
|
644
|
+
|
|
645
|
+
conn = self._get_connection()
|
|
646
|
+
try:
|
|
647
|
+
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
|
648
|
+
self._set_tenant_context(cur, tenant_id)
|
|
649
|
+
if tenant_id:
|
|
650
|
+
cur.execute("SELECT * FROM memories WHERE id = %s AND tenant_id = %s", (memory_id, tenant_id))
|
|
651
|
+
else:
|
|
652
|
+
cur.execute("SELECT * FROM memories WHERE id = %s", (memory_id,))
|
|
653
|
+
row = cur.fetchone()
|
|
654
|
+
if row:
|
|
655
|
+
return self._decrypt_row(dict(row))
|
|
656
|
+
return None
|
|
657
|
+
except Exception as e:
|
|
658
|
+
self._handle_db_error("get_memory", e)
|
|
659
|
+
return None
|
|
660
|
+
finally:
|
|
661
|
+
self._return_connection(conn)
|
|
662
|
+
|
|
663
|
+
def get_memories_by_category(
|
|
664
|
+
self, category: str, tenant_id: Optional[str] = None, include_superseded: bool = False
|
|
665
|
+
) -> List[Dict[str, Any]]:
|
|
666
|
+
"""Active-only by default: superseded rows are lineage/audit records."""
|
|
667
|
+
if not self._connected or not HAS_PSYCOPG2:
|
|
668
|
+
return [
|
|
669
|
+
m for m in self._fallback_cache.values()
|
|
670
|
+
if m.get("category") == category
|
|
671
|
+
and (tenant_id is None or m.get("tenant_id") == tenant_id)
|
|
672
|
+
and (include_superseded or not m.get("superseded_by"))
|
|
673
|
+
]
|
|
674
|
+
|
|
675
|
+
conn = self._get_connection()
|
|
676
|
+
try:
|
|
677
|
+
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
|
678
|
+
self._set_tenant_context(cur, tenant_id)
|
|
679
|
+
supersession = "" if include_superseded else " AND superseded_by IS NULL"
|
|
680
|
+
if tenant_id:
|
|
681
|
+
cur.execute(
|
|
682
|
+
"SELECT * FROM memories WHERE category = %s AND tenant_id = %s" + supersession,
|
|
683
|
+
(category, tenant_id),
|
|
684
|
+
)
|
|
685
|
+
else:
|
|
686
|
+
cur.execute(
|
|
687
|
+
"SELECT * FROM memories WHERE category = %s" + supersession,
|
|
688
|
+
(category,),
|
|
689
|
+
)
|
|
690
|
+
rows = cur.fetchall()
|
|
691
|
+
results = []
|
|
692
|
+
for r in rows:
|
|
693
|
+
results.append(self._decrypt_row(dict(r)))
|
|
694
|
+
return results
|
|
695
|
+
except Exception as e:
|
|
696
|
+
self._handle_db_error("get_memories_by_category", e)
|
|
697
|
+
return []
|
|
698
|
+
finally:
|
|
699
|
+
self._return_connection(conn)
|
|
700
|
+
|
|
701
|
+
def update_access_count(self, memory_id: str, count_increment: int = 1, tenant_id: Optional[str] = None) -> None:
|
|
702
|
+
if memory_id in self._fallback_cache:
|
|
703
|
+
self._fallback_cache[memory_id]["access_count"] = (
|
|
704
|
+
self._fallback_cache[memory_id].get("access_count", 0) + count_increment
|
|
705
|
+
)
|
|
706
|
+
self._fallback_cache[memory_id]["last_accessed_at"] = time.time()
|
|
707
|
+
|
|
708
|
+
if not self._connected or not HAS_PSYCOPG2:
|
|
709
|
+
return
|
|
710
|
+
|
|
711
|
+
conn = self._get_connection()
|
|
712
|
+
try:
|
|
713
|
+
with conn.cursor() as cur:
|
|
714
|
+
self._set_tenant_context(cur, tenant_id)
|
|
715
|
+
if tenant_id:
|
|
716
|
+
cur.execute("""
|
|
717
|
+
UPDATE memories
|
|
718
|
+
SET access_count = access_count + %s, last_accessed_at = %s
|
|
719
|
+
WHERE id = %s AND tenant_id = %s
|
|
720
|
+
""", (count_increment, time.time(), memory_id, tenant_id))
|
|
721
|
+
else:
|
|
722
|
+
cur.execute("""
|
|
723
|
+
UPDATE memories
|
|
724
|
+
SET access_count = access_count + %s, last_accessed_at = %s
|
|
725
|
+
WHERE id = %s
|
|
726
|
+
""", (count_increment, time.time(), memory_id))
|
|
727
|
+
conn.commit()
|
|
728
|
+
except Exception as e:
|
|
729
|
+
conn.rollback()
|
|
730
|
+
self._handle_db_error("update_access_count", e)
|
|
731
|
+
finally:
|
|
732
|
+
self._return_connection(conn)
|
|
733
|
+
|
|
734
|
+
def update_confidence(self, memory_id: str, new_confidence: float, tenant_id: Optional[str] = None) -> None:
|
|
735
|
+
if memory_id in self._fallback_cache:
|
|
736
|
+
self._fallback_cache[memory_id]["confidence"] = new_confidence
|
|
737
|
+
|
|
738
|
+
if not self._connected or not HAS_PSYCOPG2:
|
|
739
|
+
return
|
|
740
|
+
|
|
741
|
+
conn = self._get_connection()
|
|
742
|
+
try:
|
|
743
|
+
with conn.cursor() as cur:
|
|
744
|
+
self._set_tenant_context(cur, tenant_id)
|
|
745
|
+
if tenant_id:
|
|
746
|
+
cur.execute("UPDATE memories SET confidence = %s WHERE id = %s AND tenant_id = %s", (new_confidence, memory_id, tenant_id))
|
|
747
|
+
else:
|
|
748
|
+
cur.execute("UPDATE memories SET confidence = %s WHERE id = %s", (new_confidence, memory_id))
|
|
749
|
+
conn.commit()
|
|
750
|
+
except Exception as e:
|
|
751
|
+
conn.rollback()
|
|
752
|
+
self._handle_db_error("update_confidence", e)
|
|
753
|
+
finally:
|
|
754
|
+
self._return_connection(conn)
|
|
755
|
+
|
|
756
|
+
def vector_search(
|
|
757
|
+
self,
|
|
758
|
+
query_vec: List[float],
|
|
759
|
+
top_k: int = 10,
|
|
760
|
+
category: Optional[str] = None,
|
|
761
|
+
tenant_id: Optional[str] = None
|
|
762
|
+
) -> List[Dict[str, Any]]:
|
|
763
|
+
"""
|
|
764
|
+
Executes native pgvector cosine distance search using HNSW index:
|
|
765
|
+
ORDER BY embedding <=> query_vec::vector LIMIT top_k
|
|
766
|
+
"""
|
|
767
|
+
if not self._connected or not HAS_PSYCOPG2:
|
|
768
|
+
results = []
|
|
769
|
+
for mem in self._fallback_cache.values():
|
|
770
|
+
if tenant_id and mem.get("tenant_id") != tenant_id:
|
|
771
|
+
continue
|
|
772
|
+
if category and mem.get("category") != category:
|
|
773
|
+
continue
|
|
774
|
+
v = mem.get("embedding", [])
|
|
775
|
+
if v and len(v) == len(query_vec):
|
|
776
|
+
dot = sum(a * b for a, b in zip(query_vec, v))
|
|
777
|
+
mag_a = sum(a * a for a in query_vec) ** 0.5
|
|
778
|
+
mag_b = sum(b * b for b in v) ** 0.5
|
|
779
|
+
sim = dot / (mag_a * mag_b) if mag_a * mag_b > 0 else 0.0
|
|
780
|
+
else:
|
|
781
|
+
sim = 0.0
|
|
782
|
+
mem_copy = mem.copy()
|
|
783
|
+
mem_copy["cosine_similarity"] = sim
|
|
784
|
+
mem_copy["relevance_score"] = sim
|
|
785
|
+
results.append(mem_copy)
|
|
786
|
+
results.sort(key=lambda x: x["cosine_similarity"], reverse=True)
|
|
787
|
+
return results[:top_k]
|
|
788
|
+
|
|
789
|
+
conn = self._get_connection()
|
|
790
|
+
try:
|
|
791
|
+
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
|
792
|
+
self._set_tenant_context(cur, tenant_id)
|
|
793
|
+
emb_str = f"[{','.join(str(float(x)) for x in query_vec)}]"
|
|
794
|
+
|
|
795
|
+
conditions = []
|
|
796
|
+
params: List[Any] = [emb_str]
|
|
797
|
+
conditions.append("superseded_by IS NULL")
|
|
798
|
+
if tenant_id:
|
|
799
|
+
conditions.append("tenant_id = %s")
|
|
800
|
+
params.append(tenant_id)
|
|
801
|
+
if category:
|
|
802
|
+
conditions.append("category = %s")
|
|
803
|
+
params.append(category)
|
|
804
|
+
|
|
805
|
+
where_clause = ("WHERE " + " AND ".join(conditions)) if conditions else ""
|
|
806
|
+
params.extend([emb_str, top_k])
|
|
807
|
+
|
|
808
|
+
query = f"""
|
|
809
|
+
SELECT id, tenant_id, content, category, confidence, created_at,
|
|
810
|
+
last_accessed_at, access_count, token_cost, feedback_multiplier,
|
|
811
|
+
1 - (embedding <=> %s::vector) AS cosine_similarity
|
|
812
|
+
FROM memories
|
|
813
|
+
{where_clause}
|
|
814
|
+
ORDER BY embedding <=> %s::vector
|
|
815
|
+
LIMIT %s
|
|
816
|
+
"""
|
|
817
|
+
cur.execute(query, tuple(params))
|
|
818
|
+
rows = cur.fetchall()
|
|
819
|
+
results = []
|
|
820
|
+
for r in rows:
|
|
821
|
+
mem = self._decrypt_row(dict(r))
|
|
822
|
+
mem["relevance_score"] = mem.get("cosine_similarity", 0.0)
|
|
823
|
+
results.append(mem)
|
|
824
|
+
return results
|
|
825
|
+
except Exception as e:
|
|
826
|
+
self._handle_db_error("vector_search", e)
|
|
827
|
+
return []
|
|
828
|
+
finally:
|
|
829
|
+
self._return_connection(conn)
|
|
830
|
+
|
|
831
|
+
def lexical_search(
|
|
832
|
+
self,
|
|
833
|
+
query: str,
|
|
834
|
+
top_k: int = 100,
|
|
835
|
+
category: Optional[str] = None,
|
|
836
|
+
tenant_id: Optional[str] = None
|
|
837
|
+
) -> List[Dict[str, Any]]:
|
|
838
|
+
"""
|
|
839
|
+
Executes native PostgreSQL Full-Text Search ranking using GIN tsvector index:
|
|
840
|
+
ORDER BY ts_rank(search_vector, plainto_tsquery('english', query)) DESC
|
|
841
|
+
"""
|
|
842
|
+
if not self._connected or not HAS_PSYCOPG2:
|
|
843
|
+
query_terms = set(query.lower().split())
|
|
844
|
+
results = []
|
|
845
|
+
for mem in self._fallback_cache.values():
|
|
846
|
+
if mem.get("superseded_by"):
|
|
847
|
+
continue
|
|
848
|
+
if tenant_id and mem.get("tenant_id") != tenant_id:
|
|
849
|
+
continue
|
|
850
|
+
if category and mem.get("category") != category:
|
|
851
|
+
continue
|
|
852
|
+
words = set(mem.get("content", "").lower().split())
|
|
853
|
+
overlap = len(query_terms.intersection(words))
|
|
854
|
+
if overlap > 0:
|
|
855
|
+
mem_copy = mem.copy()
|
|
856
|
+
mem_copy["lexical_rank"] = float(overlap) / max(1.0, float(len(query_terms)))
|
|
857
|
+
results.append(mem_copy)
|
|
858
|
+
results.sort(key=lambda x: x.get("lexical_rank", 0.0), reverse=True)
|
|
859
|
+
return results[:top_k]
|
|
860
|
+
|
|
861
|
+
conn = self._get_connection()
|
|
862
|
+
try:
|
|
863
|
+
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
|
864
|
+
self._set_tenant_context(cur, tenant_id)
|
|
865
|
+
# SQL text order of %s placeholders:
|
|
866
|
+
# 1. SELECT ts_rank's plainto_tsquery -> query
|
|
867
|
+
# 2. WHERE @@ plainto_tsquery -> query
|
|
868
|
+
# 3. WHERE tenant_id -> tenant_id (if present)
|
|
869
|
+
# 4. WHERE category -> category (if present)
|
|
870
|
+
# 5. LIMIT -> top_k
|
|
871
|
+
conditions = ["search_vector @@ plainto_tsquery('english', %s)", "superseded_by IS NULL"]
|
|
872
|
+
params: List[Any] = [query, query]
|
|
873
|
+
if tenant_id:
|
|
874
|
+
conditions.append("tenant_id = %s")
|
|
875
|
+
params.append(tenant_id)
|
|
876
|
+
if category:
|
|
877
|
+
conditions.append("category = %s")
|
|
878
|
+
params.append(category)
|
|
879
|
+
|
|
880
|
+
where_clause = "WHERE " + " AND ".join(conditions)
|
|
881
|
+
|
|
882
|
+
sql = f"""
|
|
883
|
+
SELECT id, tenant_id, content, category, confidence, created_at,
|
|
884
|
+
last_accessed_at, access_count, token_cost, feedback_multiplier,
|
|
885
|
+
ts_rank(search_vector, plainto_tsquery('english', %s)) AS lexical_rank
|
|
886
|
+
FROM memories
|
|
887
|
+
{where_clause}
|
|
888
|
+
ORDER BY lexical_rank DESC
|
|
889
|
+
LIMIT %s
|
|
890
|
+
"""
|
|
891
|
+
params.append(top_k)
|
|
892
|
+
cur.execute(sql, tuple(params))
|
|
893
|
+
rows = cur.fetchall()
|
|
894
|
+
out = []
|
|
895
|
+
for r in rows:
|
|
896
|
+
mem = self._decrypt_row(dict(r))
|
|
897
|
+
mem["relevance_score"] = float(mem.get("lexical_rank", 0.0))
|
|
898
|
+
out.append(mem)
|
|
899
|
+
return out
|
|
900
|
+
except Exception as e:
|
|
901
|
+
self._handle_db_error("lexical_search", e)
|
|
902
|
+
return []
|
|
903
|
+
finally:
|
|
904
|
+
self._return_connection(conn)
|
|
905
|
+
|
|
906
|
+
def hybrid_search_sql(
|
|
907
|
+
self,
|
|
908
|
+
query: str,
|
|
909
|
+
query_vec: List[float],
|
|
910
|
+
top_k: int = 25,
|
|
911
|
+
category: Optional[str] = None,
|
|
912
|
+
tenant_id: Optional[str] = None,
|
|
913
|
+
candidate_pool: int = 100,
|
|
914
|
+
) -> List[Dict[str, Any]]:
|
|
915
|
+
"""Single-query hybrid retrieval pushed down into PostgreSQL.
|
|
916
|
+
|
|
917
|
+
Fuses dense HNSW (vector_cosine) + lexical ts_rank(tsvector) using
|
|
918
|
+
Reciprocal Rank Fusion computed in SQL. This is the scale path: instead
|
|
919
|
+
of loading the whole corpus into Python (O(N)), Postgres returns only the
|
|
920
|
+
top `candidate_pool` per channel and we RRF-fuse the union. Drops p95
|
|
921
|
+
from ~150ms@5k (Python scan) to tens of ms at 100k+.
|
|
922
|
+
|
|
923
|
+
Returns rows with `relevance_score` (RRF) + `explanation` breakdown.
|
|
924
|
+
Falls back to [] when offline so callers can use the Python engine.
|
|
925
|
+
"""
|
|
926
|
+
if not self._connected or not HAS_PSYCOPG2:
|
|
927
|
+
return []
|
|
928
|
+
|
|
929
|
+
emb_str = f"[{','.join(str(float(x)) for x in query_vec)}]"
|
|
930
|
+
conn = self._get_connection()
|
|
931
|
+
try:
|
|
932
|
+
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
|
933
|
+
self._set_tenant_context(cur, tenant_id)
|
|
934
|
+
# NOTE: placeholders are positional — build params in exact SQL
|
|
935
|
+
# text order: dense rank (emb), dense cat?, dense order (emb),
|
|
936
|
+
# dense limit, lexical rank (q), lexical where (q), lexical
|
|
937
|
+
# cat?, lexical order (q), lexical limit, final limit.
|
|
938
|
+
params: List[Any] = [emb_str]
|
|
939
|
+
cat_clause = ""
|
|
940
|
+
if category:
|
|
941
|
+
cat_clause = "AND category = %s"
|
|
942
|
+
params.append(category)
|
|
943
|
+
params.extend([emb_str, candidate_pool, query, query])
|
|
944
|
+
if category:
|
|
945
|
+
params.append(category)
|
|
946
|
+
params.extend([query, candidate_pool, top_k])
|
|
947
|
+
|
|
948
|
+
sql = f"""
|
|
949
|
+
WITH dense AS (
|
|
950
|
+
SELECT id,
|
|
951
|
+
ROW_NUMBER() OVER (ORDER BY embedding <=> %s::vector) AS rank
|
|
952
|
+
FROM memories
|
|
953
|
+
WHERE tenant_id = NULLIF(current_setting('app.current_tenant', true), '')
|
|
954
|
+
AND superseded_by IS NULL
|
|
955
|
+
{cat_clause}
|
|
956
|
+
ORDER BY embedding <=> %s::vector
|
|
957
|
+
LIMIT %s
|
|
958
|
+
),
|
|
959
|
+
lexical AS (
|
|
960
|
+
SELECT id,
|
|
961
|
+
ROW_NUMBER() OVER (ORDER BY ts_rank(search_vector, plainto_tsquery('english', %s)) DESC) AS rank
|
|
962
|
+
FROM memories
|
|
963
|
+
WHERE search_vector @@ plainto_tsquery('english', %s)
|
|
964
|
+
AND tenant_id = NULLIF(current_setting('app.current_tenant', true), '')
|
|
965
|
+
AND superseded_by IS NULL
|
|
966
|
+
{cat_clause}
|
|
967
|
+
ORDER BY ts_rank(search_vector, plainto_tsquery('english', %s)) DESC
|
|
968
|
+
LIMIT %s
|
|
969
|
+
),
|
|
970
|
+
fused AS (
|
|
971
|
+
SELECT COALESCE(d.id, l.id) AS id,
|
|
972
|
+
COALESCE(1.0 / (60 + d.rank), 0.0) + COALESCE(1.0 / (60 + l.rank), 0.0) AS rrf
|
|
973
|
+
FROM dense d
|
|
974
|
+
FULL OUTER JOIN lexical l ON d.id = l.id
|
|
975
|
+
)
|
|
976
|
+
SELECT m.*, f.rrf AS relevance_score,
|
|
977
|
+
json_build_object(
|
|
978
|
+
'sparse_rank', l.rank,
|
|
979
|
+
'dense_rank', d.rank,
|
|
980
|
+
'fused_score', f.rrf
|
|
981
|
+
) AS explanation
|
|
982
|
+
FROM fused f
|
|
983
|
+
JOIN memories m ON m.id = f.id
|
|
984
|
+
LEFT JOIN dense d ON d.id = f.id
|
|
985
|
+
LEFT JOIN lexical l ON l.id = f.id
|
|
986
|
+
ORDER BY f.rrf DESC
|
|
987
|
+
LIMIT %s;
|
|
988
|
+
"""
|
|
989
|
+
cur.execute(sql, tuple(params))
|
|
990
|
+
rows = cur.fetchall()
|
|
991
|
+
fused_out = []
|
|
992
|
+
for r in rows:
|
|
993
|
+
fused_out.append(self._decrypt_row(dict(r)))
|
|
994
|
+
return fused_out
|
|
995
|
+
except Exception as e:
|
|
996
|
+
self._handle_db_error("hybrid_search_sql", e)
|
|
997
|
+
return []
|
|
998
|
+
finally:
|
|
999
|
+
self._return_connection(conn)
|
|
1000
|
+
|
|
1001
|
+
def count_memories(self, tenant_id: Optional[str] = None) -> int:
|
|
1002
|
+
if not self._connected or not HAS_PSYCOPG2:
|
|
1003
|
+
if tenant_id:
|
|
1004
|
+
return len([m for m in self._fallback_cache.values() if m.get("tenant_id") == tenant_id])
|
|
1005
|
+
return len(self._fallback_cache)
|
|
1006
|
+
conn = self._get_connection()
|
|
1007
|
+
try:
|
|
1008
|
+
with conn.cursor() as cur:
|
|
1009
|
+
if tenant_id:
|
|
1010
|
+
self._set_tenant_context(cur, tenant_id)
|
|
1011
|
+
cur.execute("SELECT count(*) FROM memories WHERE tenant_id = %s;", (tenant_id,))
|
|
1012
|
+
else:
|
|
1013
|
+
cur.execute("SET LOCAL app.admin_bypass_rls = 'on';")
|
|
1014
|
+
cur.execute("SELECT count(*) FROM memories;")
|
|
1015
|
+
return int(cur.fetchone()[0])
|
|
1016
|
+
except Exception as e:
|
|
1017
|
+
self._handle_db_error("count_memories", e)
|
|
1018
|
+
return 0
|
|
1019
|
+
finally:
|
|
1020
|
+
self._return_connection(conn)
|
|
1021
|
+
|
|
1022
|
+
def count_active_memories(self, tenant_id: Optional[str] = None) -> int:
|
|
1023
|
+
"""Count of retrieval-visible (non-superseded) memories."""
|
|
1024
|
+
if not self._connected or not HAS_PSYCOPG2:
|
|
1025
|
+
values = list(self._fallback_cache.values())
|
|
1026
|
+
if tenant_id:
|
|
1027
|
+
values = [m for m in values if m.get("tenant_id") == tenant_id]
|
|
1028
|
+
return sum(1 for m in values if not m.get("superseded_by"))
|
|
1029
|
+
conn = self._get_connection()
|
|
1030
|
+
try:
|
|
1031
|
+
with conn.cursor() as cur:
|
|
1032
|
+
self._set_tenant_context(cur, tenant_id)
|
|
1033
|
+
if tenant_id:
|
|
1034
|
+
cur.execute(
|
|
1035
|
+
"SELECT count(*) FROM memories WHERE tenant_id = %s AND superseded_by IS NULL;",
|
|
1036
|
+
(tenant_id,),
|
|
1037
|
+
)
|
|
1038
|
+
else:
|
|
1039
|
+
cur.execute("SELECT count(*) FROM memories WHERE superseded_by IS NULL;")
|
|
1040
|
+
return int(cur.fetchone()[0])
|
|
1041
|
+
except Exception as e:
|
|
1042
|
+
self._handle_db_error("count_active_memories", e)
|
|
1043
|
+
return 0
|
|
1044
|
+
finally:
|
|
1045
|
+
self._return_connection(conn)
|
|
1046
|
+
|
|
1047
|
+
def get_all_memories(
|
|
1048
|
+
self, tenant_id: Optional[str] = None, include_superseded: bool = False
|
|
1049
|
+
) -> List[Dict[str, Any]]:
|
|
1050
|
+
"""Active-only by default: superseded rows are lineage/audit records."""
|
|
1051
|
+
if not self._connected or not HAS_PSYCOPG2:
|
|
1052
|
+
values = list(self._fallback_cache.values())
|
|
1053
|
+
if tenant_id:
|
|
1054
|
+
values = [m for m in values if m.get("tenant_id") == tenant_id]
|
|
1055
|
+
if not include_superseded:
|
|
1056
|
+
values = [m for m in values if not m.get("superseded_by")]
|
|
1057
|
+
return list(values)
|
|
1058
|
+
conn = self._get_connection()
|
|
1059
|
+
try:
|
|
1060
|
+
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
|
1061
|
+
supersession = "" if include_superseded else " AND superseded_by IS NULL"
|
|
1062
|
+
if tenant_id:
|
|
1063
|
+
self._set_tenant_context(cur, tenant_id)
|
|
1064
|
+
cur.execute(
|
|
1065
|
+
"SELECT * FROM memories WHERE tenant_id = %s" + supersession + " ORDER BY created_at DESC;",
|
|
1066
|
+
(tenant_id,),
|
|
1067
|
+
)
|
|
1068
|
+
else:
|
|
1069
|
+
cur.execute("SET LOCAL app.admin_bypass_rls = 'on';")
|
|
1070
|
+
cur.execute("SELECT * FROM memories WHERE TRUE" + supersession + " ORDER BY created_at DESC;")
|
|
1071
|
+
rows = cur.fetchall()
|
|
1072
|
+
return [self._decrypt_row(dict(r)) for r in rows]
|
|
1073
|
+
except Exception as e:
|
|
1074
|
+
self._handle_db_error("get_all_memories", e)
|
|
1075
|
+
return []
|
|
1076
|
+
finally:
|
|
1077
|
+
self._return_connection(conn)
|
|
1078
|
+
|
|
1079
|
+
def query_candidates(
|
|
1080
|
+
self,
|
|
1081
|
+
category: Optional[str] = None,
|
|
1082
|
+
tenant_id: Optional[str] = None,
|
|
1083
|
+
min_confidence: float = 0.0,
|
|
1084
|
+
limit: int = 100,
|
|
1085
|
+
order_by: str = "last_accessed_at DESC",
|
|
1086
|
+
) -> List[Dict[str, Any]]:
|
|
1087
|
+
"""
|
|
1088
|
+
Retrieves a bounded candidate set directly using SQL indexes
|
|
1089
|
+
instead of loading the entire corpus into application memory.
|
|
1090
|
+
"""
|
|
1091
|
+
if not self._connected or not HAS_PSYCOPG2:
|
|
1092
|
+
values = list(self._fallback_cache.values())
|
|
1093
|
+
if tenant_id:
|
|
1094
|
+
values = [m for m in values if m.get("tenant_id") == tenant_id]
|
|
1095
|
+
return [m for m in values if not m.get("superseded_by")][:limit]
|
|
1096
|
+
|
|
1097
|
+
conn = self._get_connection()
|
|
1098
|
+
try:
|
|
1099
|
+
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
|
1100
|
+
self._set_tenant_context(cur, tenant_id)
|
|
1101
|
+
|
|
1102
|
+
conditions = ["confidence >= %s", "superseded_by IS NULL"]
|
|
1103
|
+
params: List[Any] = [min_confidence]
|
|
1104
|
+
if tenant_id:
|
|
1105
|
+
conditions.append("tenant_id = %s")
|
|
1106
|
+
params.append(tenant_id)
|
|
1107
|
+
if category:
|
|
1108
|
+
conditions.append("category = %s")
|
|
1109
|
+
params.append(category)
|
|
1110
|
+
|
|
1111
|
+
safe_orders = {
|
|
1112
|
+
"last_accessed_at DESC": "last_accessed_at DESC",
|
|
1113
|
+
"created_at DESC": "created_at DESC",
|
|
1114
|
+
"access_count DESC": "access_count DESC",
|
|
1115
|
+
"confidence DESC": "confidence DESC",
|
|
1116
|
+
}
|
|
1117
|
+
order_clause = safe_orders.get(order_by, "last_accessed_at DESC")
|
|
1118
|
+
|
|
1119
|
+
where_clause = "WHERE " + " AND ".join(conditions)
|
|
1120
|
+
sql = f"""
|
|
1121
|
+
SELECT * FROM memories
|
|
1122
|
+
{where_clause}
|
|
1123
|
+
ORDER BY {order_clause}
|
|
1124
|
+
LIMIT %s
|
|
1125
|
+
"""
|
|
1126
|
+
params.append(limit)
|
|
1127
|
+
cur.execute(sql, tuple(params))
|
|
1128
|
+
rows = cur.fetchall()
|
|
1129
|
+
return [self._decrypt_row(dict(r)) for r in rows]
|
|
1130
|
+
except Exception as e:
|
|
1131
|
+
self._handle_db_error("query_candidates", e)
|
|
1132
|
+
return []
|
|
1133
|
+
finally:
|
|
1134
|
+
self._return_connection(conn)
|
|
1135
|
+
|
|
1136
|
+
def delete_memory(self, memory_id: str, tenant_id: Optional[str] = None) -> None:
|
|
1137
|
+
self._fallback_cache.pop(memory_id, None)
|
|
1138
|
+
if not self._connected or not HAS_PSYCOPG2:
|
|
1139
|
+
return
|
|
1140
|
+
conn = self._get_connection()
|
|
1141
|
+
try:
|
|
1142
|
+
with conn.cursor() as cur:
|
|
1143
|
+
if tenant_id:
|
|
1144
|
+
self._set_tenant_context(cur, tenant_id)
|
|
1145
|
+
cur.execute("DELETE FROM memories WHERE id = %s AND tenant_id = %s;", (memory_id, tenant_id))
|
|
1146
|
+
else:
|
|
1147
|
+
cur.execute("SET LOCAL app.admin_bypass_rls = 'on';")
|
|
1148
|
+
cur.execute("DELETE FROM memories WHERE id = %s;", (memory_id,))
|
|
1149
|
+
conn.commit()
|
|
1150
|
+
except Exception as e:
|
|
1151
|
+
conn.rollback()
|
|
1152
|
+
self._handle_db_error("delete_memory", e)
|
|
1153
|
+
finally:
|
|
1154
|
+
self._return_connection(conn)
|
|
1155
|
+
|
|
1156
|
+
def clear_memories(self, tenant_id: Optional[str] = None) -> int:
|
|
1157
|
+
deleted_count = 0
|
|
1158
|
+
if tenant_id:
|
|
1159
|
+
before_len = len(self._fallback_cache)
|
|
1160
|
+
self._fallback_cache = {k: v for k, v in self._fallback_cache.items() if v.get("tenant_id") != tenant_id}
|
|
1161
|
+
deleted_count = before_len - len(self._fallback_cache)
|
|
1162
|
+
else:
|
|
1163
|
+
deleted_count = len(self._fallback_cache)
|
|
1164
|
+
self._fallback_cache.clear()
|
|
1165
|
+
|
|
1166
|
+
if not self._connected or not HAS_PSYCOPG2:
|
|
1167
|
+
return deleted_count
|
|
1168
|
+
|
|
1169
|
+
conn = self._get_connection()
|
|
1170
|
+
try:
|
|
1171
|
+
with conn.cursor() as cur:
|
|
1172
|
+
if tenant_id:
|
|
1173
|
+
self._set_tenant_context(cur, tenant_id)
|
|
1174
|
+
cur.execute("DELETE FROM memories WHERE tenant_id = %s;", (tenant_id,))
|
|
1175
|
+
else:
|
|
1176
|
+
cur.execute("SET LOCAL app.admin_bypass_rls = 'on';")
|
|
1177
|
+
cur.execute("DELETE FROM memories;")
|
|
1178
|
+
deleted_count = cur.rowcount
|
|
1179
|
+
conn.commit()
|
|
1180
|
+
return deleted_count
|
|
1181
|
+
except Exception as e:
|
|
1182
|
+
conn.rollback()
|
|
1183
|
+
self._handle_db_error("clear_memories", e)
|
|
1184
|
+
return deleted_count
|
|
1185
|
+
finally:
|
|
1186
|
+
self._return_connection(conn)
|
|
1187
|
+
|
|
1188
|
+
def reembed_all_memories(self, embedder: Any, batch_size: int = 500) -> int:
|
|
1189
|
+
"""Regenerates vector embeddings for all stored memory records.
|
|
1190
|
+
|
|
1191
|
+
Production-safe: streams rows in batches via a named (server-side) cursor
|
|
1192
|
+
and commits per batch, so a 100k+ row corpus re-embed does not load the
|
|
1193
|
+
whole table into memory or hold one giant transaction open. Also records
|
|
1194
|
+
the new embedding dimension + model version on each row so future schema
|
|
1195
|
+
checks can detect drift instead of silently corrupting the HNSW index.
|
|
1196
|
+
"""
|
|
1197
|
+
if not self._connected or not HAS_PSYCOPG2:
|
|
1198
|
+
return 0
|
|
1199
|
+
|
|
1200
|
+
target_dim = getattr(embedder, "dimension", self.dimension)
|
|
1201
|
+
model_version = getattr(embedder, "model_name", None) or getattr(embedder, "provider_name", "unknown")
|
|
1202
|
+
|
|
1203
|
+
conn = self._get_connection()
|
|
1204
|
+
updated_count = 0
|
|
1205
|
+
try:
|
|
1206
|
+
# Keyset pagination instead of a named cursor: committing per batch
|
|
1207
|
+
# would invalidate a transaction-bound named cursor after batch #1
|
|
1208
|
+
# (psycopg2 closes it on commit), silently re-embedding only the
|
|
1209
|
+
# first batch while reporting full progress. Each batch is its own
|
|
1210
|
+
# transaction, so the admin (unscoped) context and the explicit
|
|
1211
|
+
# RLS bypass GUC are re-applied there (SET LOCAL is transaction-
|
|
1212
|
+
# scoped) — without them the RLS policy hides cross-tenant rows
|
|
1213
|
+
# from this maintenance scan.
|
|
1214
|
+
write_cur = conn.cursor()
|
|
1215
|
+
last_created: Any = None
|
|
1216
|
+
last_id = ""
|
|
1217
|
+
batch = 0
|
|
1218
|
+
while True:
|
|
1219
|
+
read_cur = conn.cursor()
|
|
1220
|
+
try:
|
|
1221
|
+
# Admin (unscoped) context + bypass GUC so the RLS policy
|
|
1222
|
+
# does not hide cross-tenant rows from this maintenance
|
|
1223
|
+
# scan (both are SET LOCAL, hence re-applied per batch).
|
|
1224
|
+
self._set_tenant_context(read_cur, None)
|
|
1225
|
+
read_cur.execute("SET LOCAL app.admin_bypass_rls = 'on';")
|
|
1226
|
+
if last_created is None:
|
|
1227
|
+
read_cur.execute(
|
|
1228
|
+
"SELECT id, content, created_at FROM memories "
|
|
1229
|
+
"ORDER BY created_at, id LIMIT %s;",
|
|
1230
|
+
(batch_size,),
|
|
1231
|
+
)
|
|
1232
|
+
else:
|
|
1233
|
+
read_cur.execute(
|
|
1234
|
+
"SELECT id, content, created_at FROM memories "
|
|
1235
|
+
"WHERE (created_at, id) > (%s, %s) "
|
|
1236
|
+
"ORDER BY created_at, id LIMIT %s;",
|
|
1237
|
+
(last_created, last_id, batch_size),
|
|
1238
|
+
)
|
|
1239
|
+
rows = read_cur.fetchall()
|
|
1240
|
+
finally:
|
|
1241
|
+
try:
|
|
1242
|
+
read_cur.close()
|
|
1243
|
+
except Exception:
|
|
1244
|
+
pass
|
|
1245
|
+
if not rows:
|
|
1246
|
+
break
|
|
1247
|
+
for row in rows:
|
|
1248
|
+
mem_id = row[0] if isinstance(row, (tuple, list)) else row["id"]
|
|
1249
|
+
raw_content = row[1] if isinstance(row, (tuple, list)) else row["content"]
|
|
1250
|
+
row_created = row[2] if isinstance(row, (tuple, list)) else row["created_at"]
|
|
1251
|
+
try:
|
|
1252
|
+
content = self._decrypt_field(raw_content)
|
|
1253
|
+
except Exception:
|
|
1254
|
+
content = raw_content
|
|
1255
|
+
new_vector = embedder.embed_query(content)
|
|
1256
|
+
vec_str = f"[{','.join(str(x) for x in new_vector)}]"
|
|
1257
|
+
write_cur.execute(
|
|
1258
|
+
"UPDATE memories SET embedding = %s, embedding_dimension = %s, model_version = %s, content_plain = %s WHERE id = %s;",
|
|
1259
|
+
(vec_str, len(new_vector), model_version, searchable_projection(content), mem_id),
|
|
1260
|
+
)
|
|
1261
|
+
updated_count += 1
|
|
1262
|
+
last_created = row_created
|
|
1263
|
+
last_id = mem_id
|
|
1264
|
+
conn.commit()
|
|
1265
|
+
batch += 1
|
|
1266
|
+
logger.info("Re-embed batch %d complete (running total: %d rows).", batch, updated_count)
|
|
1267
|
+
if len(rows) < batch_size:
|
|
1268
|
+
break
|
|
1269
|
+
|
|
1270
|
+
logger.info(f"Re-embedded {updated_count} memories to dimension {target_dim} (model={model_version}).")
|
|
1271
|
+
except Exception as e:
|
|
1272
|
+
conn.rollback()
|
|
1273
|
+
self._handle_db_error("reembed_all_memories", e)
|
|
1274
|
+
finally:
|
|
1275
|
+
self._return_connection(conn)
|
|
1276
|
+
return updated_count
|