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,226 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
from typing import Any, List, Optional
|
|
4
|
+
|
|
5
|
+
logger = logging.getLogger("SynapseEncryption")
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
from cryptography.fernet import Fernet
|
|
9
|
+
HAS_CRYPTOGRAPHY = True
|
|
10
|
+
except ImportError:
|
|
11
|
+
HAS_CRYPTOGRAPHY = False
|
|
12
|
+
Fernet = None # type: ignore[misc,assignment]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class KeyManagementProvider:
|
|
16
|
+
"""
|
|
17
|
+
Durable encryption key manager with team-safe loading order.
|
|
18
|
+
|
|
19
|
+
Resolution order (first hit wins):
|
|
20
|
+
1. `SYNAPSE_ENCRYPTION_KEY` — raw 32-byte urlsafe-base64 Fernet key.
|
|
21
|
+
2. `SYNAPSE_ENCRYPTION_KEY_B64_FILE` — path to a file containing ONLY the key.
|
|
22
|
+
(The correct way to inject a secret in Docker/K8s without baking it
|
|
23
|
+
into the image or leaving it in process args.)
|
|
24
|
+
3. KMS/Vault provider hook: if `SYNAPSE_KMS_PROVIDER` is set, the matching
|
|
25
|
+
loader below is invoked with `SYNAPSE_KMS_KEY_REF`. Providers are
|
|
26
|
+
best-effort optional — unknown or misconfigured providers raise, never
|
|
27
|
+
silently fall through, so a prod boot fails loudly instead of
|
|
28
|
+
encrypting with a throwaway key.
|
|
29
|
+
4. Local keyfile (dev fallback): `SYNAPSE_KEY_FILE` (default `.synapse_key`).
|
|
30
|
+
|
|
31
|
+
Production deployments MUST use (1), (2), or (3). The local keyfile (4) is a
|
|
32
|
+
convenience that must never back team/production data.
|
|
33
|
+
"""
|
|
34
|
+
@staticmethod
|
|
35
|
+
def get_encryption_keys(key_file: Optional[str] = None) -> List[bytes]:
|
|
36
|
+
"""Return the configured encryption keys, primary (encrypting) key first.
|
|
37
|
+
|
|
38
|
+
Resolution order (first configured source wins):
|
|
39
|
+
1. `SYNAPSE_ENCRYPTION_KEYS` — comma-separated list of urlsafe-base64
|
|
40
|
+
Fernet keys. The first key encrypts new writes; every listed key can
|
|
41
|
+
decrypt, so older keys can be retired without re-encrypting existing
|
|
42
|
+
ciphertext (MultiFernet rotation).
|
|
43
|
+
2. `SYNAPSE_ENCRYPTION_KEY` — single key (backward compatible).
|
|
44
|
+
3. `SYNAPSE_ENCRYPTION_KEY_B64_FILE` — path to a file containing ONE key.
|
|
45
|
+
4. KMS/Vault provider hook (`SYNAPSE_KMS_PROVIDER` + `SYNAPSE_KMS_KEY_REF`).
|
|
46
|
+
5. Local keyfile (dev fallback): `SYNAPSE_KEY_FILE` (default `.synapse_key`).
|
|
47
|
+
6. Auto-generate and persist one durable key (dev only).
|
|
48
|
+
|
|
49
|
+
Production deployments MUST use (1), (2), (3) or (4).
|
|
50
|
+
"""
|
|
51
|
+
keys: List[bytes] = []
|
|
52
|
+
|
|
53
|
+
# 1. Direct env key(s) — comma separated; first listed key encrypts.
|
|
54
|
+
env_keys = os.environ.get("SYNAPSE_ENCRYPTION_KEYS")
|
|
55
|
+
if env_keys:
|
|
56
|
+
for k in env_keys.split(","):
|
|
57
|
+
k = k.strip()
|
|
58
|
+
if k:
|
|
59
|
+
keys.append(k.encode("utf-8"))
|
|
60
|
+
|
|
61
|
+
# 2. Backward compatible single key.
|
|
62
|
+
if not keys:
|
|
63
|
+
env_key = os.environ.get("SYNAPSE_ENCRYPTION_KEY")
|
|
64
|
+
if env_key:
|
|
65
|
+
keys.append(env_key.strip().encode("utf-8"))
|
|
66
|
+
|
|
67
|
+
# 3. Key-from-file (Docker secrets / K8s secret volume mount).
|
|
68
|
+
if not keys:
|
|
69
|
+
b64_file = os.environ.get("SYNAPSE_ENCRYPTION_KEY_B64_FILE")
|
|
70
|
+
if b64_file:
|
|
71
|
+
try:
|
|
72
|
+
with open(b64_file, "rb") as f:
|
|
73
|
+
file_key = f.read().strip()
|
|
74
|
+
if file_key:
|
|
75
|
+
logger.info("Loaded encryption key from SYNAPSE_ENCRYPTION_KEY_B64_FILE.")
|
|
76
|
+
keys.append(file_key)
|
|
77
|
+
except Exception as e:
|
|
78
|
+
logger.warning(f"Could not read key file {b64_file}: {e}")
|
|
79
|
+
|
|
80
|
+
# 4. KMS/Vault provider hook.
|
|
81
|
+
if not keys:
|
|
82
|
+
kms_provider = os.environ.get("SYNAPSE_KMS_PROVIDER")
|
|
83
|
+
if kms_provider:
|
|
84
|
+
kms_ref = os.environ.get("SYNAPSE_KMS_KEY_REF", "")
|
|
85
|
+
loader = _KMS_LOADERS.get(kms_provider.lower())
|
|
86
|
+
if loader is None:
|
|
87
|
+
raise RuntimeError(
|
|
88
|
+
f"Unknown SYNAPSE_KMS_PROVIDER '{kms_provider}'. "
|
|
89
|
+
f"Supported: {sorted(_KMS_LOADERS)}."
|
|
90
|
+
)
|
|
91
|
+
logger.info(f"Loading encryption key via KMS provider '{kms_provider}'.")
|
|
92
|
+
keys.append(loader(kms_ref))
|
|
93
|
+
|
|
94
|
+
# 5. Local dev fallback keyfile.
|
|
95
|
+
if not keys:
|
|
96
|
+
target_file = key_file or os.environ.get("SYNAPSE_KEY_FILE", ".synapse_key")
|
|
97
|
+
if os.path.exists(target_file):
|
|
98
|
+
try:
|
|
99
|
+
with open(target_file, "rb") as f:
|
|
100
|
+
file_key = f.read().strip()
|
|
101
|
+
if file_key:
|
|
102
|
+
keys.append(file_key)
|
|
103
|
+
except Exception as e:
|
|
104
|
+
logger.warning(f"Could not read key file {target_file}: {e}")
|
|
105
|
+
|
|
106
|
+
# 6. Generate a durable 32-byte urlsafe base64 key (dev only).
|
|
107
|
+
if not keys:
|
|
108
|
+
new_key = FernetEncryptionProvider.generate_key()
|
|
109
|
+
target_file = key_file or os.environ.get("SYNAPSE_KEY_FILE", ".synapse_key")
|
|
110
|
+
try:
|
|
111
|
+
with open(target_file, "wb") as f:
|
|
112
|
+
f.write(new_key)
|
|
113
|
+
os.chmod(target_file, 0o600)
|
|
114
|
+
except Exception as e:
|
|
115
|
+
logger.warning(f"Could not persist encryption key to {target_file}: {e}")
|
|
116
|
+
keys.append(new_key)
|
|
117
|
+
|
|
118
|
+
return keys
|
|
119
|
+
|
|
120
|
+
@staticmethod
|
|
121
|
+
def get_encryption_key(key_file: Optional[str] = None) -> bytes:
|
|
122
|
+
return KeyManagementProvider.get_encryption_keys(key_file)[0]
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _kms_loader_aws(ref: str) -> bytes:
|
|
126
|
+
"""AWS KMS: decrypts a base64 ciphertext blob via kms:Decrypt (boto3 optional)."""
|
|
127
|
+
try:
|
|
128
|
+
import base64 as _b64
|
|
129
|
+
import boto3 # type: ignore
|
|
130
|
+
except ImportError as e:
|
|
131
|
+
raise RuntimeError("SYNAPSE_KMS_PROVIDER=aws requires boto3 to be installed.") from e
|
|
132
|
+
region = os.environ.get("SYNAPSE_KMS_REGION") or os.environ.get("AWS_REGION") or os.environ.get("AWS_DEFAULT_REGION")
|
|
133
|
+
client = boto3.client("kms", region_name=region) if region else boto3.client("kms")
|
|
134
|
+
resp = client.decrypt(CiphertextBlob=_b64.b64decode(ref))
|
|
135
|
+
return _b64.urlsafe_b64encode(resp["Plaintext"][:32].ljust(32, b"\0"))
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _kms_loader_vault(ref: str) -> bytes:
|
|
139
|
+
"""HashiCorp Vault KV: reads the key field over HTTPS (hvac optional)."""
|
|
140
|
+
try:
|
|
141
|
+
import hvac # type: ignore
|
|
142
|
+
except ImportError as e:
|
|
143
|
+
raise RuntimeError("SYNAPSE_KMS_PROVIDER=vault requires hvac to be installed.") from e
|
|
144
|
+
addr = os.environ.get("VAULT_ADDR", "")
|
|
145
|
+
token = os.environ.get("VAULT_TOKEN", "")
|
|
146
|
+
field = os.environ.get("SYNAPSE_KMS_FIELD", "key")
|
|
147
|
+
if not addr or not token or not ref:
|
|
148
|
+
raise RuntimeError("vault KMS needs VAULT_ADDR, VAULT_TOKEN and SYNAPSE_KMS_KEY_REF (KV path).")
|
|
149
|
+
client = hvac.Client(url=addr, token=token)
|
|
150
|
+
secret = client.secrets.kv.v2.read_secret_version(path=ref)
|
|
151
|
+
value = (secret.get("data", {}).get("data", {}) or {}).get(field, "")
|
|
152
|
+
if not value:
|
|
153
|
+
raise RuntimeError(f"Vault KV path '{ref}' has no '{field}' field.")
|
|
154
|
+
return str(value).strip().encode("utf-8")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _kms_loader_file(ref: str) -> bytes:
|
|
158
|
+
"""Alias of the key-file loader using an explicit path as the ref."""
|
|
159
|
+
with open(ref, "rb") as f:
|
|
160
|
+
return f.read().strip()
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
_KMS_LOADERS = {
|
|
164
|
+
"aws": _kms_loader_aws,
|
|
165
|
+
"vault": _kms_loader_vault,
|
|
166
|
+
"file": _kms_loader_file,
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# Aliases for backwards compatibility
|
|
171
|
+
FernetKeyManager = KeyManagementProvider
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class FernetEncryptionProvider:
|
|
175
|
+
"""
|
|
176
|
+
Encryption provider using Fernet symmetric authenticated encryption.
|
|
177
|
+
Underlying specification: 128-bit AES in CBC mode with PKCS7 padding
|
|
178
|
+
and HMAC-SHA256 authenticated encryption.
|
|
179
|
+
"""
|
|
180
|
+
def __init__(self, key: Optional[bytes] = None):
|
|
181
|
+
if key is None:
|
|
182
|
+
self.key = KeyManagementProvider.get_encryption_key()
|
|
183
|
+
elif isinstance(key, str):
|
|
184
|
+
self.key = key.encode("utf-8")
|
|
185
|
+
else:
|
|
186
|
+
self.key = key
|
|
187
|
+
|
|
188
|
+
self._cipher: Any = None
|
|
189
|
+
if HAS_CRYPTOGRAPHY and Fernet is not None:
|
|
190
|
+
self._cipher = Fernet(self.key) # type: ignore[operator]
|
|
191
|
+
|
|
192
|
+
@staticmethod
|
|
193
|
+
def generate_key() -> bytes:
|
|
194
|
+
if HAS_CRYPTOGRAPHY and Fernet is not None:
|
|
195
|
+
return Fernet.generate_key()
|
|
196
|
+
raise RuntimeError(
|
|
197
|
+
"Key generation requires the `cryptography` package (Fernet). "
|
|
198
|
+
"Install it with: pip install cryptography"
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
def encrypt(self, data: str) -> str:
|
|
202
|
+
"""Encrypts a string and returns a base64 encoded string."""
|
|
203
|
+
if self._cipher is None:
|
|
204
|
+
raise RuntimeError(
|
|
205
|
+
"Encryption requires the `cryptography` package (Fernet). "
|
|
206
|
+
"Install it with: pip install cryptography"
|
|
207
|
+
)
|
|
208
|
+
return self._cipher.encrypt(data.encode("utf-8")).decode("utf-8")
|
|
209
|
+
|
|
210
|
+
def decrypt(self, encrypted_data: str) -> str:
|
|
211
|
+
"""Decrypts a base64 encoded string and returns the original string."""
|
|
212
|
+
if self._cipher is None:
|
|
213
|
+
raise RuntimeError(
|
|
214
|
+
"Decryption requires the `cryptography` package (Fernet). "
|
|
215
|
+
"Install it with: pip install cryptography"
|
|
216
|
+
)
|
|
217
|
+
try:
|
|
218
|
+
return self._cipher.decrypt(
|
|
219
|
+
encrypted_data.encode("utf-8")
|
|
220
|
+
).decode("utf-8")
|
|
221
|
+
except Exception as e:
|
|
222
|
+
raise ValueError("Decryption failed: invalid key or payload") from e
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
# Aliases for backwards compatibility
|
|
226
|
+
EncryptedMemoryStore = FernetEncryptionProvider
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import math
|
|
2
|
+
import re
|
|
3
|
+
from typing import List, Dict, Any, Optional
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _tokenize(text: str) -> List[str]:
|
|
7
|
+
return [w for w in re.findall(r'[a-zA-Z0-9_]+', text.lower()) if len(w) > 1]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class HybridSearch:
|
|
11
|
+
"""
|
|
12
|
+
State-of-the-art Hybrid Information Retrieval Engine.
|
|
13
|
+
Combines Lexical Sparse Search (real BM25 algorithm) with Dense Cosine
|
|
14
|
+
vector similarity, fused via Reciprocal Rank Fusion (RRF).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(self, k1: float = 1.5, b: float = 0.75, rrf_k: int = 60, alpha: float = 0.5, k: Optional[int] = None, **kwargs: Any):
|
|
18
|
+
"""
|
|
19
|
+
Args:
|
|
20
|
+
k1: BM25 term frequency saturation parameter.
|
|
21
|
+
b: BM25 document length normalization parameter.
|
|
22
|
+
rrf_k: Constant used to smooth Reciprocal Rank Fusion ranks.
|
|
23
|
+
alpha: Hybrid fusion weight between dense (alpha) and sparse (1 - alpha).
|
|
24
|
+
k: Alias for rrf_k or top_k fallback.
|
|
25
|
+
"""
|
|
26
|
+
self.k1 = k1
|
|
27
|
+
self.b = b
|
|
28
|
+
self.rrf_k = k if k is not None else rrf_k
|
|
29
|
+
self.alpha = alpha
|
|
30
|
+
|
|
31
|
+
def _calculate_bm25_scores(self, query_terms: List[str], corpus: List[Dict[str, Any]]) -> Dict[str, float]:
|
|
32
|
+
"""
|
|
33
|
+
Calculates exact Okapi BM25 scores for all documents in the corpus.
|
|
34
|
+
"""
|
|
35
|
+
scores = {doc["id"]: 0.0 for doc in corpus}
|
|
36
|
+
if not query_terms or not corpus:
|
|
37
|
+
return scores
|
|
38
|
+
|
|
39
|
+
N = len(corpus)
|
|
40
|
+
tokenized_corpus = {doc["id"]: _tokenize(doc.get("content", "")) for doc in corpus}
|
|
41
|
+
doc_lengths = {doc_id: len(words) for doc_id, words in tokenized_corpus.items()}
|
|
42
|
+
avgdl = sum(doc_lengths.values()) / max(1, N)
|
|
43
|
+
|
|
44
|
+
# 1. Compute Document Frequency (df) for each query term
|
|
45
|
+
df = {}
|
|
46
|
+
for term in query_terms:
|
|
47
|
+
df[term] = sum(1 for words in tokenized_corpus.values() if term in words)
|
|
48
|
+
|
|
49
|
+
# 2. Compute BM25 scores for each document
|
|
50
|
+
for term in query_terms:
|
|
51
|
+
n_q = df.get(term, 0)
|
|
52
|
+
# Standard IDF with floor to avoid negative values
|
|
53
|
+
idf = math.log((N - n_q + 0.5) / (n_q + 0.5) + 1.0)
|
|
54
|
+
idf = max(0.0001, idf)
|
|
55
|
+
|
|
56
|
+
for doc in corpus:
|
|
57
|
+
doc_id = doc["id"]
|
|
58
|
+
words = tokenized_corpus[doc_id]
|
|
59
|
+
tf = words.count(term)
|
|
60
|
+
|
|
61
|
+
if tf > 0:
|
|
62
|
+
dl = doc_lengths[doc_id]
|
|
63
|
+
numerator = tf * (self.k1 + 1.0)
|
|
64
|
+
denominator = tf + self.k1 * (1.0 - self.b + self.b * (dl / max(1.0, avgdl)))
|
|
65
|
+
scores[doc_id] += idf * (numerator / denominator)
|
|
66
|
+
|
|
67
|
+
return scores
|
|
68
|
+
|
|
69
|
+
def _calculate_dense_similarities(
|
|
70
|
+
self,
|
|
71
|
+
query_vector: List[float],
|
|
72
|
+
corpus: List[Dict[str, Any]]
|
|
73
|
+
) -> Dict[str, float]:
|
|
74
|
+
"""Calculates true Cosine similarity between query vector and document embeddings."""
|
|
75
|
+
similarities = {doc["id"]: 0.0 for doc in corpus}
|
|
76
|
+
if not query_vector:
|
|
77
|
+
return similarities
|
|
78
|
+
|
|
79
|
+
norm_q = math.sqrt(sum(q * q for q in query_vector))
|
|
80
|
+
if norm_q == 0.0:
|
|
81
|
+
return similarities
|
|
82
|
+
|
|
83
|
+
for doc in corpus:
|
|
84
|
+
doc_id = doc["id"]
|
|
85
|
+
doc_vec = doc.get("embedding", [])
|
|
86
|
+
|
|
87
|
+
if not doc_vec or len(doc_vec) != len(query_vector):
|
|
88
|
+
continue
|
|
89
|
+
|
|
90
|
+
dot_product = sum(q * d for q, d in zip(query_vector, doc_vec))
|
|
91
|
+
norm_d = math.sqrt(sum(d * d for d in doc_vec))
|
|
92
|
+
|
|
93
|
+
if norm_d > 0.0:
|
|
94
|
+
similarities[doc_id] = dot_product / (norm_q * norm_d)
|
|
95
|
+
|
|
96
|
+
return similarities
|
|
97
|
+
|
|
98
|
+
def fused_search(
|
|
99
|
+
self,
|
|
100
|
+
query: str,
|
|
101
|
+
documents: List[Dict[str, Any]],
|
|
102
|
+
query_vector: Optional[List[float]] = None,
|
|
103
|
+
top_k: int = 10,
|
|
104
|
+
category_filter: Optional[str] = None,
|
|
105
|
+
tenant_filter: Optional[str] = None
|
|
106
|
+
) -> List[Dict[str, Any]]:
|
|
107
|
+
"""
|
|
108
|
+
Executes unified Reciprocal Rank Fusion (RRF) combining Sparse BM25 and Dense Cosine ranks.
|
|
109
|
+
Provides retrieval explainability via detailed score breakdown.
|
|
110
|
+
|
|
111
|
+
Formula:
|
|
112
|
+
RRF_Score(d) = 1 / (rrf_k + rank_bm25(d)) + 1 / (rrf_k + rank_dense(d))
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
query: Raw query string.
|
|
116
|
+
documents: List of memory documents.
|
|
117
|
+
query_vector: Dense vector array representing the query.
|
|
118
|
+
top_k: Maximum elements to return.
|
|
119
|
+
category_filter: Filter by memory category.
|
|
120
|
+
tenant_filter: Filter by tenant_id.
|
|
121
|
+
"""
|
|
122
|
+
# 1. Apply Filtering
|
|
123
|
+
filtered_docs = documents
|
|
124
|
+
if category_filter:
|
|
125
|
+
filtered_docs = [d for d in filtered_docs if d.get("category") == category_filter]
|
|
126
|
+
if tenant_filter:
|
|
127
|
+
filtered_docs = [d for d in filtered_docs if d.get("tenant_id") == tenant_filter]
|
|
128
|
+
|
|
129
|
+
if not filtered_docs:
|
|
130
|
+
return []
|
|
131
|
+
|
|
132
|
+
query_terms = _tokenize(query)
|
|
133
|
+
|
|
134
|
+
# 2. Compute pure Lexical BM25 scores and sort to get Sparse ranks
|
|
135
|
+
bm25_scores = self._calculate_bm25_scores(query_terms, filtered_docs)
|
|
136
|
+
sparse_ranking = sorted(filtered_docs, key=lambda x: bm25_scores[x["id"]], reverse=True)
|
|
137
|
+
sparse_ranks = {doc["id"]: idx + 1 for idx, doc in enumerate(sparse_ranking)}
|
|
138
|
+
|
|
139
|
+
# 3. Compute pure Cosine Vector similarities and sort to get Dense ranks
|
|
140
|
+
dense_scores = self._calculate_dense_similarities(query_vector or [], filtered_docs)
|
|
141
|
+
dense_ranking = sorted(filtered_docs, key=lambda x: dense_scores[x["id"]], reverse=True)
|
|
142
|
+
dense_ranks = {doc["id"]: idx + 1 for idx, doc in enumerate(dense_ranking)}
|
|
143
|
+
|
|
144
|
+
# 4. Apply Reciprocal Rank Fusion (RRF)
|
|
145
|
+
results = []
|
|
146
|
+
for doc in filtered_docs:
|
|
147
|
+
doc_id = doc["id"]
|
|
148
|
+
|
|
149
|
+
# Reciprocal sparse rank score
|
|
150
|
+
rank_s = sparse_ranks.get(doc_id, len(filtered_docs))
|
|
151
|
+
rrf_sparse = 1.0 / (self.rrf_k + rank_s)
|
|
152
|
+
|
|
153
|
+
# Reciprocal dense rank score
|
|
154
|
+
rank_d = dense_ranks.get(doc_id, len(filtered_docs))
|
|
155
|
+
rrf_dense = 1.0 / (self.rrf_k + rank_d)
|
|
156
|
+
|
|
157
|
+
fused_score = rrf_sparse + rrf_dense
|
|
158
|
+
|
|
159
|
+
# Structure Explainability Data
|
|
160
|
+
explanation = {
|
|
161
|
+
"lexical_score": bm25_scores[doc_id],
|
|
162
|
+
"semantic_score": dense_scores[doc_id],
|
|
163
|
+
"fused_score": fused_score,
|
|
164
|
+
"sparse_rank": rank_s,
|
|
165
|
+
"dense_rank": rank_d
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
scored_doc = doc.copy()
|
|
169
|
+
scored_doc["relevance_score"] = fused_score
|
|
170
|
+
scored_doc["explanation"] = explanation
|
|
171
|
+
results.append(scored_doc)
|
|
172
|
+
|
|
173
|
+
# 5. Final Ranking
|
|
174
|
+
results.sort(key=lambda x: x["relevance_score"], reverse=True)
|
|
175
|
+
return results[:top_k]
|
|
176
|
+
|
|
177
|
+
def search(
|
|
178
|
+
self,
|
|
179
|
+
query: str,
|
|
180
|
+
corpus: Optional[List[Dict[str, Any]]] = None,
|
|
181
|
+
documents: Optional[List[Dict[str, Any]]] = None,
|
|
182
|
+
query_vector: Optional[List[float]] = None,
|
|
183
|
+
top_k: int = 10,
|
|
184
|
+
category_filter: Optional[str] = None,
|
|
185
|
+
tenant_filter: Optional[str] = None,
|
|
186
|
+
**kwargs: Any
|
|
187
|
+
) -> List[Dict[str, Any]]:
|
|
188
|
+
"""
|
|
189
|
+
Intuitive alias for fused_search supporting both 'corpus' and 'documents' arguments.
|
|
190
|
+
"""
|
|
191
|
+
target_docs = corpus if corpus is not None else (documents or [])
|
|
192
|
+
return self.fused_search(
|
|
193
|
+
query=query,
|
|
194
|
+
documents=target_docs,
|
|
195
|
+
query_vector=query_vector,
|
|
196
|
+
top_k=top_k,
|
|
197
|
+
category_filter=category_filter,
|
|
198
|
+
tenant_filter=tenant_filter
|
|
199
|
+
)
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
from typing import List, Dict, Any, Optional
|
|
2
|
+
import logging
|
|
3
|
+
|
|
4
|
+
logger = logging.getLogger("KnapsackPacker")
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class MemoryNodeResult(dict):
|
|
8
|
+
"""
|
|
9
|
+
Dual-contract result node that acts both as a dictionary with keys
|
|
10
|
+
('id', 'content', etc.) and equals its string ID for backwards-compatible
|
|
11
|
+
membership checks.
|
|
12
|
+
"""
|
|
13
|
+
def __eq__(self, other: Any) -> bool:
|
|
14
|
+
if isinstance(other, str):
|
|
15
|
+
return self.get("id") == other
|
|
16
|
+
return super().__eq__(other)
|
|
17
|
+
|
|
18
|
+
def __hash__(self) -> int: # type: ignore[override]
|
|
19
|
+
return hash(self.get("id", ""))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class KnapsackPacker:
|
|
23
|
+
"""
|
|
24
|
+
Robust 0/1 Knapsack optimizer for packing memory nodes into a token budget.
|
|
25
|
+
Implements a memory-efficient Dynamic Programming solution with a greedy fallback.
|
|
26
|
+
Auto-switches to greedy when n*budget exceeds threshold to bound latency.
|
|
27
|
+
"""
|
|
28
|
+
MAX_BUDGET = 8000
|
|
29
|
+
DP_OPS_THRESHOLD = 2_000_000
|
|
30
|
+
|
|
31
|
+
def __init__(self, token_budget: int = 4096):
|
|
32
|
+
self.token_budget = min(token_budget, self.MAX_BUDGET)
|
|
33
|
+
|
|
34
|
+
def pack(
|
|
35
|
+
self,
|
|
36
|
+
memories: List[Dict[str, Any]],
|
|
37
|
+
token_budget: Optional[int] = None
|
|
38
|
+
) -> List[MemoryNodeResult]:
|
|
39
|
+
"""
|
|
40
|
+
Packs memories into the token budget using a DP approach.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
memories: List of memory nodes with 'id', 'token_cost', and 'relevance_score'.
|
|
44
|
+
token_budget: Optional override for budget. If None, uses self.token_budget.
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
List of packed memory nodes that maximize total relevance score within budget.
|
|
48
|
+
"""
|
|
49
|
+
budget = token_budget if token_budget is not None else self.token_budget
|
|
50
|
+
budget = min(budget, self.MAX_BUDGET)
|
|
51
|
+
if not memories or budget <= 0:
|
|
52
|
+
return []
|
|
53
|
+
|
|
54
|
+
# Guard: DP is O(n*budget). Fall back to greedy for huge inputs to keep p99 low.
|
|
55
|
+
if len(memories) * budget > self.DP_OPS_THRESHOLD:
|
|
56
|
+
logger.warning(
|
|
57
|
+
"Knapsack DP input too large (n=%d, budget=%d); using greedy fallback.",
|
|
58
|
+
len(memories),
|
|
59
|
+
budget,
|
|
60
|
+
)
|
|
61
|
+
return self.greedy_pack(memories, token_budget=budget)
|
|
62
|
+
|
|
63
|
+
n = len(memories)
|
|
64
|
+
weights = [max(1, int(m.get("token_cost", 1))) for m in memories]
|
|
65
|
+
values = [max(0, int(float(m.get("relevance_score", 0.0)) * 1000)) for m in memories]
|
|
66
|
+
|
|
67
|
+
dp = [0] * (budget + 1)
|
|
68
|
+
items_included: List[List[int]] = [[] for _ in range(budget + 1)]
|
|
69
|
+
|
|
70
|
+
for i in range(n):
|
|
71
|
+
weight = weights[i]
|
|
72
|
+
value = values[i]
|
|
73
|
+
if weight > budget:
|
|
74
|
+
continue
|
|
75
|
+
for w in range(budget, weight - 1, -1):
|
|
76
|
+
if dp[w - weight] + value > dp[w]:
|
|
77
|
+
dp[w] = dp[w - weight] + value
|
|
78
|
+
items_included[w] = items_included[w - weight] + [i]
|
|
79
|
+
|
|
80
|
+
selected_indices = items_included[budget]
|
|
81
|
+
return [MemoryNodeResult(memories[idx]) for idx in selected_indices]
|
|
82
|
+
|
|
83
|
+
def greedy_pack(
|
|
84
|
+
self,
|
|
85
|
+
memories: List[Dict[str, Any]],
|
|
86
|
+
token_budget: Optional[int] = None
|
|
87
|
+
) -> List[MemoryNodeResult]:
|
|
88
|
+
"""Greedy fallback: Pack by highest relevance/cost ratio."""
|
|
89
|
+
budget = token_budget if token_budget is not None else self.token_budget
|
|
90
|
+
sorted_memories = sorted(
|
|
91
|
+
memories,
|
|
92
|
+
key=lambda m: float(m.get("relevance_score", 0.0)) / max(1, m.get("token_cost", 1)),
|
|
93
|
+
reverse=True
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
selected: List[MemoryNodeResult] = []
|
|
97
|
+
current_cost = 0
|
|
98
|
+
for m in sorted_memories:
|
|
99
|
+
cost = m.get("token_cost", 1)
|
|
100
|
+
if current_cost + cost <= budget:
|
|
101
|
+
selected.append(MemoryNodeResult(m))
|
|
102
|
+
current_cost += cost
|
|
103
|
+
return selected
|