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,123 @@
|
|
|
1
|
+
import math
|
|
2
|
+
import time
|
|
3
|
+
from typing import Any, Dict, Optional
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class DecayEngine:
|
|
7
|
+
"""
|
|
8
|
+
Robust Ebbinghaus-style decay and reinforcement engine.
|
|
9
|
+
|
|
10
|
+
Formula:
|
|
11
|
+
Score = Confidence * exp(-(now - last_accessed) / Strength)
|
|
12
|
+
Strength = BaseStrength * (1 + (access_count * factor) * multiplier)
|
|
13
|
+
"""
|
|
14
|
+
def __init__(
|
|
15
|
+
self,
|
|
16
|
+
category_configs: Optional[Dict[str, Dict[str, float]]] = None
|
|
17
|
+
):
|
|
18
|
+
self.category_configs = category_configs or {}
|
|
19
|
+
self.default_config = {
|
|
20
|
+
'base_strength': 86400.0,
|
|
21
|
+
'reinforcement_factor': 0.45
|
|
22
|
+
}
|
|
23
|
+
self.decay_floor = 0.10
|
|
24
|
+
|
|
25
|
+
def _get_config(self, category: str) -> Dict[str, float]:
|
|
26
|
+
return self.category_configs.get(category, self.default_config)
|
|
27
|
+
|
|
28
|
+
def calculate_relevance(
|
|
29
|
+
self,
|
|
30
|
+
memory: Dict[str, Any],
|
|
31
|
+
now: Optional[float] = None
|
|
32
|
+
) -> float:
|
|
33
|
+
"""Calculates current relevance score for a memory node."""
|
|
34
|
+
if now is None:
|
|
35
|
+
now = time.time()
|
|
36
|
+
|
|
37
|
+
last_accessed = memory.get("last_accessed_at", memory["created_at"])
|
|
38
|
+
elapsed = max(0.0, now - last_accessed)
|
|
39
|
+
|
|
40
|
+
config = self._get_config(memory.get("category", "interaction"))
|
|
41
|
+
|
|
42
|
+
access_count = memory.get("access_count", 0)
|
|
43
|
+
feedback_multiplier = memory.get("feedback_multiplier", 1.0)
|
|
44
|
+
|
|
45
|
+
strength = config['base_strength'] * (
|
|
46
|
+
1 + (access_count * config['reinforcement_factor']) * feedback_multiplier
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
confidence = float(memory.get("confidence", 0.5))
|
|
50
|
+
return max(self.decay_floor, confidence * math.exp(-elapsed / max(1.0, strength)))
|
|
51
|
+
|
|
52
|
+
def temporal_multiplier(
|
|
53
|
+
self,
|
|
54
|
+
memory: Dict[str, Any],
|
|
55
|
+
now: Optional[float] = None,
|
|
56
|
+
) -> float:
|
|
57
|
+
"""Temporal-decay multiplier D(m,t) in [decay_floor, 1.0].
|
|
58
|
+
|
|
59
|
+
Separated from retrieval relevance R(m,q) so query relevance is
|
|
60
|
+
never erased: combined score S(m,q,t) = R(m,q) * D(m,t).
|
|
61
|
+
"""
|
|
62
|
+
if now is None:
|
|
63
|
+
now = time.time()
|
|
64
|
+
last_accessed = memory.get("last_accessed_at", memory["created_at"])
|
|
65
|
+
elapsed = max(0.0, now - last_accessed)
|
|
66
|
+
config = self._get_config(memory.get("category", "interaction"))
|
|
67
|
+
access_count = memory.get("access_count", 0)
|
|
68
|
+
feedback_multiplier = memory.get("feedback_multiplier", 1.0)
|
|
69
|
+
strength = config['base_strength'] * (
|
|
70
|
+
1 + (access_count * config['reinforcement_factor']) * feedback_multiplier
|
|
71
|
+
)
|
|
72
|
+
return max(self.decay_floor, math.exp(-elapsed / max(1.0, strength)))
|
|
73
|
+
|
|
74
|
+
def combine_scores(self, retrieval_score: float, memory: Dict[str, Any], now: Optional[float] = None) -> float:
|
|
75
|
+
"""S(m,q,t) = R(m,q) * D(m,t)."""
|
|
76
|
+
return float(retrieval_score) * self.temporal_multiplier(memory, now=now)
|
|
77
|
+
|
|
78
|
+
def calculate_retention(
|
|
79
|
+
self,
|
|
80
|
+
base_relevance: float,
|
|
81
|
+
created_epoch: float,
|
|
82
|
+
access_count: int = 0,
|
|
83
|
+
now: Optional[float] = None
|
|
84
|
+
) -> float:
|
|
85
|
+
"""
|
|
86
|
+
Calculates Ebbinghaus retention for a memory based on age and access count.
|
|
87
|
+
"""
|
|
88
|
+
if now is None:
|
|
89
|
+
now = time.time()
|
|
90
|
+
elapsed = max(0.0, now - created_epoch)
|
|
91
|
+
effective_half_life = 86400.0 * 2.0 * (1.0 + 0.5 * access_count)
|
|
92
|
+
decay = math.exp(-elapsed / effective_half_life)
|
|
93
|
+
return max(self.decay_floor, base_relevance * decay)
|
|
94
|
+
|
|
95
|
+
def compute_feedback_boost(
|
|
96
|
+
self,
|
|
97
|
+
current_confidence: float,
|
|
98
|
+
feedback_type: str
|
|
99
|
+
) -> float:
|
|
100
|
+
"""Helper to adjust confidence based on feedback."""
|
|
101
|
+
boost = 0.05 if feedback_type == "positive" else -0.1
|
|
102
|
+
return max(0.1, min(1.0, current_confidence + boost))
|
|
103
|
+
|
|
104
|
+
def reinforce(
|
|
105
|
+
self,
|
|
106
|
+
memory: Dict[str, Any],
|
|
107
|
+
feedback_value: float = 1.0
|
|
108
|
+
) -> Dict[str, Any]:
|
|
109
|
+
"""
|
|
110
|
+
Applies reinforcement to a memory node.
|
|
111
|
+
|
|
112
|
+
feedback_value > 0: Positive reinforcement
|
|
113
|
+
feedback_value < 0: Negative reinforcement (penalty)
|
|
114
|
+
"""
|
|
115
|
+
memory["access_count"] = memory.get("access_count", 0) + 1
|
|
116
|
+
memory["last_accessed_at"] = time.time()
|
|
117
|
+
|
|
118
|
+
current_multiplier = memory.get("feedback_multiplier", 1.0)
|
|
119
|
+
memory["feedback_multiplier"] = max(
|
|
120
|
+
0.1,
|
|
121
|
+
current_multiplier + (feedback_value * 0.1)
|
|
122
|
+
)
|
|
123
|
+
return memory
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from enum import Enum
|
|
3
|
+
import logging
|
|
4
|
+
import math
|
|
5
|
+
from typing import Any, Dict, List, Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger("Deduplicator")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Action(Enum):
|
|
12
|
+
CREATE = "CREATE"
|
|
13
|
+
MERGE = "MERGE"
|
|
14
|
+
REJECT = "REJECT"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class MergeResult:
|
|
19
|
+
action: Action
|
|
20
|
+
target_id: Optional[str]
|
|
21
|
+
reason: str
|
|
22
|
+
provenance: Dict[str, Any]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# Alias for backwards compatibility
|
|
26
|
+
DeduplicationResult = MergeResult
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Deduplicator:
|
|
30
|
+
def __init__(self, category_thresholds: Dict[str, float], default_threshold: float = 0.85):
|
|
31
|
+
self.category_thresholds = category_thresholds
|
|
32
|
+
self.default_threshold = default_threshold
|
|
33
|
+
|
|
34
|
+
def _get_threshold(self, category: str) -> float:
|
|
35
|
+
return self.category_thresholds.get(category, self.default_threshold)
|
|
36
|
+
|
|
37
|
+
def _jaccard_similarity(self, content_a: str, content_b: str) -> float:
|
|
38
|
+
set_a = set(content_a.lower().split())
|
|
39
|
+
set_b = set(content_b.lower().split())
|
|
40
|
+
intersection = len(set_a.intersection(set_b))
|
|
41
|
+
union = len(set_a.union(set_b))
|
|
42
|
+
return intersection / union if union > 0 else 0.0
|
|
43
|
+
|
|
44
|
+
def _cosine_similarity(self, vec_a: List[float], vec_b: List[float]) -> float:
|
|
45
|
+
if not vec_a or not vec_b or len(vec_a) != len(vec_b):
|
|
46
|
+
return 0.0
|
|
47
|
+
dot_product = sum(a * b for a, b in zip(vec_a, vec_b))
|
|
48
|
+
norm_a = math.sqrt(sum(a * a for a in vec_a))
|
|
49
|
+
norm_b = math.sqrt(sum(b * b for b in vec_b))
|
|
50
|
+
if norm_a == 0.0 or norm_b == 0.0:
|
|
51
|
+
return 0.0
|
|
52
|
+
return dot_product / (norm_a * norm_b)
|
|
53
|
+
|
|
54
|
+
def check_duplicate(self, new_mem: Dict[str, Any], existing_mems: List[Dict[str, Any]]) -> MergeResult:
|
|
55
|
+
threshold = self._get_threshold(new_mem.get("category", "interaction"))
|
|
56
|
+
|
|
57
|
+
for existing in existing_mems:
|
|
58
|
+
# 1. Exact Match Check (e.g., hash of content or specific ID)
|
|
59
|
+
if new_mem.get("id") == existing.get("id"):
|
|
60
|
+
return MergeResult(Action.REJECT, existing["id"], "Exact ID match", {"type": "exact"})
|
|
61
|
+
|
|
62
|
+
# 2. Lexical Similarity (Jaccard)
|
|
63
|
+
lexical_sim = self._jaccard_similarity(new_mem["content"], existing["content"])
|
|
64
|
+
|
|
65
|
+
# 3. Semantic Similarity (Cosine)
|
|
66
|
+
semantic_sim = self._cosine_similarity(new_mem.get("embedding", []), existing.get("embedding", []))
|
|
67
|
+
|
|
68
|
+
# 4. Conflict & Supersession Detection (Production-Grade Heuristic)
|
|
69
|
+
if semantic_sim > threshold:
|
|
70
|
+
# If high semantic similarity, compare metadata to decide supersession
|
|
71
|
+
new_conf = new_mem.get("confidence", 0.5)
|
|
72
|
+
old_conf = existing.get("confidence", 0.5)
|
|
73
|
+
|
|
74
|
+
# If new memory has significantly higher confidence, suggest supersession (merge)
|
|
75
|
+
if new_conf > old_conf + 0.1:
|
|
76
|
+
return MergeResult(
|
|
77
|
+
Action.MERGE,
|
|
78
|
+
existing["id"],
|
|
79
|
+
"Higher confidence supersession",
|
|
80
|
+
{"new_conf": new_conf, "old_conf": old_conf}
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
# Check for explicit contradiction in content
|
|
84
|
+
content_new = new_mem["content"].lower()
|
|
85
|
+
content_old = existing["content"].lower()
|
|
86
|
+
if ("true" in content_new and "false" in content_old) or ("false" in content_new and "true" in content_old):
|
|
87
|
+
return MergeResult(Action.REJECT, None, "Direct contradiction detected", {"type": "conflict"})
|
|
88
|
+
|
|
89
|
+
# 5. Threshold Decision
|
|
90
|
+
if semantic_sim >= threshold or (lexical_sim > 0.9 and semantic_sim > 0.7):
|
|
91
|
+
return MergeResult(
|
|
92
|
+
Action.MERGE,
|
|
93
|
+
existing["id"],
|
|
94
|
+
"Semantic/Lexical similarity threshold exceeded",
|
|
95
|
+
{"semantic_sim": semantic_sim, "lexical_sim": lexical_sim}
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
return MergeResult(Action.CREATE, None, "No duplicates found", {})
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
from typing import List, Optional
|
|
4
|
+
from synapse_memory.core.embedding_provider import (
|
|
5
|
+
EmbeddingManager,
|
|
6
|
+
LocalEmbeddingProvider,
|
|
7
|
+
GeminiEmbeddingProvider,
|
|
8
|
+
OpenAIEmbeddingProvider,
|
|
9
|
+
SentenceTransformerEmbeddingProvider
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger("SynapseEmbedder")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SynapseEmbedder:
|
|
16
|
+
"""
|
|
17
|
+
Multi-model embedding client. Provides seamless failover between Google Gemini API,
|
|
18
|
+
OpenAI API, SentenceTransformers, and deterministic hash vector providers.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self, provider: Optional[str] = None, allow_fallback: Optional[bool] = None):
|
|
22
|
+
env_provider = os.getenv("SYNAPSE_EMBEDDING_PROVIDER")
|
|
23
|
+
self.provider_name = (provider or env_provider or "deterministic").lower()
|
|
24
|
+
self.model_name = os.getenv("SYNAPSE_EMBEDDING_MODEL", "all-MiniLM-L6-v2")
|
|
25
|
+
self.gemini_key = os.getenv("GEMINI_API_KEY", "")
|
|
26
|
+
self.openai_key = os.getenv("OPENAI_API_KEY", "")
|
|
27
|
+
|
|
28
|
+
is_production = os.getenv("NODE_ENV", "").lower() == "production"
|
|
29
|
+
if allow_fallback is None:
|
|
30
|
+
allow_fallback = not is_production
|
|
31
|
+
|
|
32
|
+
provider_instance = self._get_provider(allow_fallback=allow_fallback)
|
|
33
|
+
self.manager = EmbeddingManager(provider_instance)
|
|
34
|
+
|
|
35
|
+
# Validate configured dimension if explicitly set
|
|
36
|
+
configured_dim = os.getenv("SYNAPSE_EMBEDDING_DIMENSION")
|
|
37
|
+
if configured_dim:
|
|
38
|
+
expected = int(configured_dim)
|
|
39
|
+
actual = self.manager.provider.dimension
|
|
40
|
+
if actual != expected:
|
|
41
|
+
raise RuntimeError(
|
|
42
|
+
f"Embedding dimension mismatch: provider '{self.provider_name}' produces {actual}-d vectors, "
|
|
43
|
+
f"but SYNAPSE_EMBEDDING_DIMENSION is configured as {expected}-d."
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def dimension(self) -> int:
|
|
48
|
+
return self.manager.provider.dimension
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def is_semantic(self) -> bool:
|
|
52
|
+
return self.manager.provider.is_semantic
|
|
53
|
+
|
|
54
|
+
def _get_provider(self, allow_fallback: bool = False):
|
|
55
|
+
if self.provider_name in ("deterministic", "hash", "local"):
|
|
56
|
+
# Deterministic hash provider: always available, no external deps,
|
|
57
|
+
# but NOT a real semantic embedding model. Safe for offline/dev use;
|
|
58
|
+
# production must not silently run on it.
|
|
59
|
+
is_production = os.getenv("NODE_ENV", "").lower() == "production"
|
|
60
|
+
allow_hash = os.getenv("SYNAPSE_ALLOW_DETERMINISTIC_EMBEDDING", "false").lower() == "true"
|
|
61
|
+
if is_production and not allow_hash:
|
|
62
|
+
raise RuntimeError(
|
|
63
|
+
"Refusing to start in production with the deterministic hash "
|
|
64
|
+
"embedding provider (it is not a semantic model). "
|
|
65
|
+
"Set SYNAPSE_EMBEDDING_PROVIDER to a real semantic provider "
|
|
66
|
+
"(gemini, openai, sentence-transformers) or opt in with "
|
|
67
|
+
"SYNAPSE_ALLOW_DETERMINISTIC_EMBEDDING=true."
|
|
68
|
+
)
|
|
69
|
+
dim = int(os.getenv("SYNAPSE_EMBEDDING_DIMENSION", "128"))
|
|
70
|
+
return LocalEmbeddingProvider(dim=dim)
|
|
71
|
+
elif self.provider_name == "gemini":
|
|
72
|
+
if not self.gemini_key:
|
|
73
|
+
if allow_fallback:
|
|
74
|
+
logger.warning(
|
|
75
|
+
"GEMINI_API_KEY not set; falling back to deterministic hash provider."
|
|
76
|
+
)
|
|
77
|
+
dim = int(os.getenv("SYNAPSE_EMBEDDING_DIMENSION", "128"))
|
|
78
|
+
return LocalEmbeddingProvider(dim=dim)
|
|
79
|
+
raise RuntimeError(
|
|
80
|
+
"SYNAPSE_EMBEDDING_PROVIDER=gemini but GEMINI_API_KEY is not set. "
|
|
81
|
+
"Set GEMINI_API_KEY or switch to provider=deterministic."
|
|
82
|
+
)
|
|
83
|
+
return GeminiEmbeddingProvider(self.gemini_key)
|
|
84
|
+
elif self.provider_name == "openai":
|
|
85
|
+
if not self.openai_key:
|
|
86
|
+
if allow_fallback:
|
|
87
|
+
logger.warning(
|
|
88
|
+
"OPENAI_API_KEY not set; falling back to deterministic hash provider."
|
|
89
|
+
)
|
|
90
|
+
dim = int(os.getenv("SYNAPSE_EMBEDDING_DIMENSION", "128"))
|
|
91
|
+
return LocalEmbeddingProvider(dim=dim)
|
|
92
|
+
raise RuntimeError(
|
|
93
|
+
"SYNAPSE_EMBEDDING_PROVIDER=openai but OPENAI_API_KEY is not set. "
|
|
94
|
+
"Set OPENAI_API_KEY or switch to provider=deterministic."
|
|
95
|
+
)
|
|
96
|
+
return OpenAIEmbeddingProvider(self.openai_key)
|
|
97
|
+
elif self.provider_name == "sentence-transformers":
|
|
98
|
+
# Explicit opt-in: requires [local-models] extra and model availability.
|
|
99
|
+
return SentenceTransformerEmbeddingProvider(
|
|
100
|
+
model_name=self.model_name,
|
|
101
|
+
allow_fallback=allow_fallback
|
|
102
|
+
)
|
|
103
|
+
# Unknown provider: fail closed in production, fall back in dev.
|
|
104
|
+
if allow_fallback:
|
|
105
|
+
logger.warning(
|
|
106
|
+
f"Unknown embedding provider '{self.provider_name}'; falling back to deterministic hash provider."
|
|
107
|
+
)
|
|
108
|
+
dim = int(os.getenv("SYNAPSE_EMBEDDING_DIMENSION", "128"))
|
|
109
|
+
return LocalEmbeddingProvider(dim=dim)
|
|
110
|
+
raise RuntimeError(
|
|
111
|
+
f"Unknown embedding provider '{self.provider_name}'. "
|
|
112
|
+
"Use one of: deterministic, gemini, openai, sentence-transformers."
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
def embed_query(self, text: str) -> List[float]:
|
|
116
|
+
"""Generates a dense float vector representation for text strings."""
|
|
117
|
+
if not text.strip():
|
|
118
|
+
return [0.0] * self.manager.provider.dimension
|
|
119
|
+
return self.manager.get_embedding(text)
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import json
|
|
3
|
+
import logging
|
|
4
|
+
import math
|
|
5
|
+
import re
|
|
6
|
+
import urllib.request
|
|
7
|
+
from abc import ABC, abstractmethod
|
|
8
|
+
from functools import lru_cache
|
|
9
|
+
from typing import List, Optional
|
|
10
|
+
|
|
11
|
+
from synapse_memory.core.observability import instrument_operation
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger("EmbeddingProvider")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class EmbeddingProvider(ABC):
|
|
17
|
+
"""Base class for all embedding providers."""
|
|
18
|
+
|
|
19
|
+
@abstractmethod
|
|
20
|
+
def embed(self, text: str) -> List[float]:
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
@abstractmethod
|
|
24
|
+
def embed_batch(self, texts: List[str]) -> List[List[float]]:
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
@abstractmethod
|
|
29
|
+
def dimension(self) -> int:
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def is_semantic(self) -> bool:
|
|
34
|
+
return True
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class DeterministicHashEmbeddingProvider(EmbeddingProvider):
|
|
38
|
+
"""
|
|
39
|
+
Local deterministic hash provider for offline unit testing and development.
|
|
40
|
+
NOTE: This generates deterministic word-frequency hash projections, NOT neural semantic embeddings.
|
|
41
|
+
"""
|
|
42
|
+
def __init__(self, dim: int = 128):
|
|
43
|
+
self._dim = dim
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def dimension(self) -> int:
|
|
47
|
+
return self._dim
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def is_semantic(self) -> bool:
|
|
51
|
+
return False
|
|
52
|
+
|
|
53
|
+
def embed(self, text: str) -> List[float]:
|
|
54
|
+
# Normalize text (lowercase and strip non-alphanumeric characters)
|
|
55
|
+
normalized = re.sub(r"[^\w\s]", "", text.lower()).strip()
|
|
56
|
+
words = normalized.split()
|
|
57
|
+
if not words:
|
|
58
|
+
return [0.0] * self._dim
|
|
59
|
+
|
|
60
|
+
vector = [0.0] * self._dim
|
|
61
|
+
for i, word in enumerate(words):
|
|
62
|
+
# SHA-256 digest instead of the built-in hash(): Python string hashing
|
|
63
|
+
# is salted per interpreter (PYTHONHASHSEED), so hash(word) is stable
|
|
64
|
+
# only within a single process and changes across invocations -- it must
|
|
65
|
+
# never back a stored index or a repeatable offline test.
|
|
66
|
+
digest = hashlib.sha256(word.encode("utf-8")).digest()
|
|
67
|
+
h = int.from_bytes(digest[:8], "big", signed=True)
|
|
68
|
+
idx = abs(h) % self._dim
|
|
69
|
+
sign = 1.0 if h >= 0 else -1.0
|
|
70
|
+
vector[idx] += sign * (1.0 / (1.0 + 0.05 * i))
|
|
71
|
+
|
|
72
|
+
# L2 normalization for accurate cosine similarities
|
|
73
|
+
norm = math.sqrt(sum(x * x for x in vector))
|
|
74
|
+
if norm > 0.0:
|
|
75
|
+
vector = [x / norm for x in vector]
|
|
76
|
+
return vector
|
|
77
|
+
|
|
78
|
+
def embed_batch(self, texts: List[str]) -> List[List[float]]:
|
|
79
|
+
return [self.embed(t) for t in texts]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# Alias for backward compatibility
|
|
83
|
+
LocalEmbeddingProvider = DeterministicHashEmbeddingProvider
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class SentenceTransformerEmbeddingProvider(EmbeddingProvider):
|
|
87
|
+
"""
|
|
88
|
+
Production-grade local neural semantic embedding provider using sentence-transformers.
|
|
89
|
+
Requires: sentence-transformers (pip install 'synapse-memory-engine[local-models]')
|
|
90
|
+
"""
|
|
91
|
+
def __init__(self, model_name: str = "all-MiniLM-L6-v2", allow_fallback: bool = False):
|
|
92
|
+
self.model_name = model_name
|
|
93
|
+
self._model = None
|
|
94
|
+
self._fallback: Optional[DeterministicHashEmbeddingProvider] = None
|
|
95
|
+
self._is_using_real_model = False
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
from sentence_transformers import SentenceTransformer
|
|
99
|
+
self._model = SentenceTransformer(model_name)
|
|
100
|
+
self._dim = int(self._model.get_sentence_embedding_dimension())
|
|
101
|
+
self._is_using_real_model = True
|
|
102
|
+
logger.info(f"Loaded neural embedding model '{model_name}' (dim: {self._dim}).")
|
|
103
|
+
except Exception as err:
|
|
104
|
+
if not allow_fallback:
|
|
105
|
+
raise ImportError(
|
|
106
|
+
f"Failed to load semantic embedding model '{model_name}': {err}. "
|
|
107
|
+
"For production neural embeddings, install sentence-transformers or set provider='deterministic'."
|
|
108
|
+
) from err
|
|
109
|
+
logger.warning(
|
|
110
|
+
f"[SynapseEmbedder Warning] sentence-transformers not installed ({err}). "
|
|
111
|
+
"Falling back to DeterministicHashEmbeddingProvider for non-production development. "
|
|
112
|
+
"For production neural embeddings, install with: pip install sentence-transformers"
|
|
113
|
+
)
|
|
114
|
+
self._fallback = DeterministicHashEmbeddingProvider()
|
|
115
|
+
self._dim = self._fallback.dimension
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def dimension(self) -> int:
|
|
119
|
+
return self._dim
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def is_semantic(self) -> bool:
|
|
123
|
+
return self._is_using_real_model
|
|
124
|
+
|
|
125
|
+
@instrument_operation("st_embed")
|
|
126
|
+
def embed(self, text: str) -> List[float]:
|
|
127
|
+
if self._model is not None:
|
|
128
|
+
return self._model.encode(text).tolist()
|
|
129
|
+
if self._fallback is None:
|
|
130
|
+
self._fallback = DeterministicHashEmbeddingProvider()
|
|
131
|
+
return self._fallback.embed(text)
|
|
132
|
+
|
|
133
|
+
def embed_batch(self, texts: List[str]) -> List[List[float]]:
|
|
134
|
+
if self._model is not None:
|
|
135
|
+
return self._model.encode(texts).tolist()
|
|
136
|
+
if self._fallback is None:
|
|
137
|
+
self._fallback = DeterministicHashEmbeddingProvider()
|
|
138
|
+
return self._fallback.embed_batch(texts)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class GeminiEmbeddingProvider(EmbeddingProvider):
|
|
142
|
+
"""
|
|
143
|
+
Google Gemini text embedding provider.
|
|
144
|
+
|
|
145
|
+
NOTE: text-embedding-004 was shut down on 2026-01-14 and is retired.
|
|
146
|
+
This provider now targets gemini-embedding-001 (3072 dimensions by default),
|
|
147
|
+
the supported text-only replacement. Embeddings from the two model families
|
|
148
|
+
are incompatible -- existing text-embedding-004 vectors must be re-embedded
|
|
149
|
+
with `reembed_all_memories()` (or the equivalent migration job) before
|
|
150
|
+
querying, since changing the column definition alone does not recalculate
|
|
151
|
+
stored vectors. See `docs/production-hardening-baseline.md` and
|
|
152
|
+
`docs/backup-recovery.md` for the batched re-embed/key-rotation sweep
|
|
153
|
+
pattern.
|
|
154
|
+
"""
|
|
155
|
+
def __init__(self, api_key: str, dim: int = 3072):
|
|
156
|
+
self.api_key = api_key
|
|
157
|
+
self._dim = dim
|
|
158
|
+
|
|
159
|
+
@property
|
|
160
|
+
def dimension(self) -> int:
|
|
161
|
+
return self._dim
|
|
162
|
+
|
|
163
|
+
@property
|
|
164
|
+
def is_semantic(self) -> bool:
|
|
165
|
+
return True
|
|
166
|
+
|
|
167
|
+
@instrument_operation("gemini_embed")
|
|
168
|
+
def embed(self, text: str) -> List[float]:
|
|
169
|
+
url = (
|
|
170
|
+
"https://generativelanguage.googleapis.com/v1beta/models/"
|
|
171
|
+
f"gemini-embedding-001:embedContent?key={self.api_key}"
|
|
172
|
+
)
|
|
173
|
+
headers = {"Content-Type": "application/json"}
|
|
174
|
+
payload = {"content": {"parts": [{"text": text}]}}
|
|
175
|
+
|
|
176
|
+
req = urllib.request.Request(
|
|
177
|
+
url,
|
|
178
|
+
data=json.dumps(payload).encode("utf-8"),
|
|
179
|
+
headers=headers,
|
|
180
|
+
method="POST"
|
|
181
|
+
)
|
|
182
|
+
with urllib.request.urlopen(req, timeout=5) as response:
|
|
183
|
+
res_data = json.loads(response.read().decode("utf-8"))
|
|
184
|
+
return res_data["embedding"]["values"]
|
|
185
|
+
|
|
186
|
+
def embed_batch(self, texts: List[str]) -> List[List[float]]:
|
|
187
|
+
return [self.embed(t) for t in texts]
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class OpenAIEmbeddingProvider(EmbeddingProvider):
|
|
191
|
+
def __init__(self, api_key: str, model: str = "text-embedding-3-small", dim: int = 1536):
|
|
192
|
+
self.api_key = api_key
|
|
193
|
+
self.model = model
|
|
194
|
+
self._dim = dim
|
|
195
|
+
|
|
196
|
+
@property
|
|
197
|
+
def dimension(self) -> int:
|
|
198
|
+
return self._dim
|
|
199
|
+
|
|
200
|
+
@property
|
|
201
|
+
def is_semantic(self) -> bool:
|
|
202
|
+
return True
|
|
203
|
+
|
|
204
|
+
@instrument_operation("openai_embed")
|
|
205
|
+
def embed(self, text: str) -> List[float]:
|
|
206
|
+
url = "https://api.openai.com/v1/embeddings"
|
|
207
|
+
headers = {
|
|
208
|
+
"Content-Type": "application/json",
|
|
209
|
+
"Authorization": f"Bearer {self.api_key}"
|
|
210
|
+
}
|
|
211
|
+
payload = {
|
|
212
|
+
"input": text,
|
|
213
|
+
"model": self.model
|
|
214
|
+
}
|
|
215
|
+
req = urllib.request.Request(
|
|
216
|
+
url,
|
|
217
|
+
data=json.dumps(payload).encode("utf-8"),
|
|
218
|
+
headers=headers,
|
|
219
|
+
method="POST"
|
|
220
|
+
)
|
|
221
|
+
with urllib.request.urlopen(req, timeout=5) as response:
|
|
222
|
+
res_data = json.loads(response.read().decode("utf-8"))
|
|
223
|
+
return res_data["data"][0]["embedding"]
|
|
224
|
+
|
|
225
|
+
def embed_batch(self, texts: List[str]) -> List[List[float]]:
|
|
226
|
+
return [self.embed(t) for t in texts]
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
class EmbeddingManager:
|
|
230
|
+
"""
|
|
231
|
+
Manages embedding provider operations with caching and observability.
|
|
232
|
+
"""
|
|
233
|
+
def __init__(self, provider: EmbeddingProvider):
|
|
234
|
+
self.provider = provider
|
|
235
|
+
|
|
236
|
+
@lru_cache(maxsize=1024)
|
|
237
|
+
def get_embedding(self, text: str) -> List[float]:
|
|
238
|
+
return self.provider.embed(text)
|
|
239
|
+
|
|
240
|
+
def get_batch_embeddings(self, texts: List[str]) -> List[List[float]]:
|
|
241
|
+
return self.provider.embed_batch(texts)
|