runtime-memory 3.0.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.
- runtime_memory/__init__.py +28 -0
- runtime_memory/claude_code/__init__.py +48 -0
- runtime_memory/claude_code/commands.py +698 -0
- runtime_memory/claude_code/daemon.py +852 -0
- runtime_memory/claude_code/hooks.py +722 -0
- runtime_memory/cli/__init__.py +8 -0
- runtime_memory/cli/main.py +1936 -0
- runtime_memory/core/__init__.py +216 -0
- runtime_memory/core/config.py +473 -0
- runtime_memory/core/embeddings.py +908 -0
- runtime_memory/core/engine.py +1007 -0
- runtime_memory/core/exceptions.py +547 -0
- runtime_memory/core/legacy_env.py +39 -0
- runtime_memory/core/logging.py +160 -0
- runtime_memory/core/models.py +1051 -0
- runtime_memory/core/observability.py +725 -0
- runtime_memory/core/paths.py +30 -0
- runtime_memory/core/resilience.py +511 -0
- runtime_memory/core/retrieval.py +819 -0
- runtime_memory/core/storage.py +1105 -0
- runtime_memory/extraction/__init__.py +36 -0
- runtime_memory/extraction/extractor.py +1143 -0
- runtime_memory/hermes/__init__.py +39 -0
- runtime_memory/hermes/_base.py +154 -0
- runtime_memory/hermes/bridge.py +119 -0
- runtime_memory/hermes/plugin.yaml +13 -0
- runtime_memory/hermes/provider.py +536 -0
- runtime_memory/hermes/tools.py +230 -0
- runtime_memory/hermes/trace.py +177 -0
- runtime_memory/plugin/__init__.py +646 -0
- runtime_memory/sdk/__init__.py +97 -0
- runtime_memory/sdk/client.py +1577 -0
- runtime_memory/server/__init__.py +75 -0
- runtime_memory/server/api.py +1665 -0
- runtime_memory/server/mcp.py +1574 -0
- runtime_memory/server/static/css/styles.css +1110 -0
- runtime_memory/server/static/index.html +264 -0
- runtime_memory/server/static/js/api.js +294 -0
- runtime_memory/server/static/js/app.js +771 -0
- runtime_memory/tasks/__init__.py +114 -0
- runtime_memory/tasks/adapter.py +501 -0
- runtime_memory/tasks/claude_code_adapter.py +495 -0
- runtime_memory/tasks/claude_code_parser.py +339 -0
- runtime_memory/tasks/cli_bridge.py +415 -0
- runtime_memory/tasks/linking.py +397 -0
- runtime_memory/tasks/models.py +520 -0
- runtime_memory/tasks/outcomes.py +320 -0
- runtime_memory/tasks/parser.py +305 -0
- runtime_memory/tasks/unified_adapter.py +661 -0
- runtime_memory-3.0.0.dist-info/METADATA +497 -0
- runtime_memory-3.0.0.dist-info/RECORD +54 -0
- runtime_memory-3.0.0.dist-info/WHEEL +4 -0
- runtime_memory-3.0.0.dist-info/entry_points.txt +6 -0
- runtime_memory-3.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,908 @@
|
|
|
1
|
+
"""Embedding providers for Runtime Memory.
|
|
2
|
+
|
|
3
|
+
Provides vector embeddings with:
|
|
4
|
+
- Abstract provider interface
|
|
5
|
+
- Local embedding (sentence-transformers)
|
|
6
|
+
- API embedding (OpenAI-compatible)
|
|
7
|
+
- Caching layer
|
|
8
|
+
- Batch processing
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import hashlib
|
|
14
|
+
import importlib.util
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import time
|
|
18
|
+
from abc import ABC, abstractmethod
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import TYPE_CHECKING, Any, ClassVar
|
|
22
|
+
|
|
23
|
+
import numpy as np
|
|
24
|
+
|
|
25
|
+
from runtime_memory.core.logging import get_logger
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
from numpy.typing import NDArray
|
|
29
|
+
|
|
30
|
+
logger = get_logger(__name__)
|
|
31
|
+
|
|
32
|
+
# Type alias for embeddings
|
|
33
|
+
Embedding = list[float]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class EmbeddingConfig:
|
|
38
|
+
"""Configuration for embedding providers."""
|
|
39
|
+
|
|
40
|
+
# Model settings
|
|
41
|
+
model_name: str = "all-MiniLM-L6-v2"
|
|
42
|
+
dimensions: int | None = None # None = use model default
|
|
43
|
+
|
|
44
|
+
# Cache settings
|
|
45
|
+
cache_enabled: bool = True
|
|
46
|
+
cache_dir: Path | None = None
|
|
47
|
+
cache_ttl_seconds: int = 86400 * 30 # 30 days
|
|
48
|
+
|
|
49
|
+
# Batch settings
|
|
50
|
+
batch_size: int = 32
|
|
51
|
+
|
|
52
|
+
# API settings (for APIEmbeddingProvider)
|
|
53
|
+
api_base_url: str | None = None
|
|
54
|
+
api_key_env_var: str = "EMBEDDING_API_KEY"
|
|
55
|
+
api_timeout: float = 30.0
|
|
56
|
+
api_max_retries: int = 3
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class EmbeddingResult:
|
|
61
|
+
"""Result from an embedding operation."""
|
|
62
|
+
|
|
63
|
+
embedding: Embedding
|
|
64
|
+
model: str
|
|
65
|
+
dimensions: int
|
|
66
|
+
cached: bool = False
|
|
67
|
+
latency_ms: float = 0.0
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass
|
|
71
|
+
class BatchEmbeddingResult:
|
|
72
|
+
"""Result from a batch embedding operation."""
|
|
73
|
+
|
|
74
|
+
embeddings: list[Embedding]
|
|
75
|
+
model: str
|
|
76
|
+
dimensions: int
|
|
77
|
+
cached_count: int = 0
|
|
78
|
+
computed_count: int = 0
|
|
79
|
+
total_latency_ms: float = 0.0
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class EmbeddingError(Exception):
|
|
83
|
+
"""Base exception for embedding errors."""
|
|
84
|
+
|
|
85
|
+
pass
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class ModelNotFoundError(EmbeddingError):
|
|
89
|
+
"""Raised when embedding model is not found."""
|
|
90
|
+
|
|
91
|
+
pass
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class APIError(EmbeddingError):
|
|
95
|
+
"""Raised when API call fails."""
|
|
96
|
+
|
|
97
|
+
pass
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class EmbeddingCache:
|
|
101
|
+
"""File-based cache for embeddings."""
|
|
102
|
+
|
|
103
|
+
def __init__(
|
|
104
|
+
self,
|
|
105
|
+
cache_dir: Path | None = None,
|
|
106
|
+
ttl_seconds: int = 86400 * 30,
|
|
107
|
+
) -> None:
|
|
108
|
+
"""Initialize the cache.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
cache_dir: Directory for cache files. Defaults to ~/.cache/runtime-memory/embeddings
|
|
112
|
+
ttl_seconds: Time-to-live for cache entries in seconds.
|
|
113
|
+
"""
|
|
114
|
+
if cache_dir is None:
|
|
115
|
+
cache_dir = Path.home() / ".cache" / "runtime-memory" / "embeddings"
|
|
116
|
+
self.cache_dir = cache_dir
|
|
117
|
+
self.ttl_seconds = ttl_seconds
|
|
118
|
+
self._memory_cache: dict[str, tuple[Embedding, float]] = {}
|
|
119
|
+
|
|
120
|
+
def _get_cache_key(self, text: str, model: str) -> str:
|
|
121
|
+
"""Generate a cache key for text and model."""
|
|
122
|
+
content = f"{model}:{text}"
|
|
123
|
+
return hashlib.sha256(content.encode()).hexdigest()
|
|
124
|
+
|
|
125
|
+
def _get_cache_path(self, key: str) -> Path:
|
|
126
|
+
"""Get the file path for a cache key."""
|
|
127
|
+
# Use first 2 chars as subdirectory to avoid too many files in one dir
|
|
128
|
+
subdir = key[:2]
|
|
129
|
+
return self.cache_dir / subdir / f"{key}.json"
|
|
130
|
+
|
|
131
|
+
def get(self, text: str, model: str) -> Embedding | None:
|
|
132
|
+
"""Get an embedding from cache.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
text: The text that was embedded.
|
|
136
|
+
model: The model used for embedding.
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
The cached embedding, or None if not found or expired.
|
|
140
|
+
"""
|
|
141
|
+
key = self._get_cache_key(text, model)
|
|
142
|
+
|
|
143
|
+
# Check memory cache first
|
|
144
|
+
if key in self._memory_cache:
|
|
145
|
+
embedding, timestamp = self._memory_cache[key]
|
|
146
|
+
if time.time() - timestamp < self.ttl_seconds:
|
|
147
|
+
return embedding
|
|
148
|
+
del self._memory_cache[key]
|
|
149
|
+
|
|
150
|
+
# Check file cache
|
|
151
|
+
cache_path = self._get_cache_path(key)
|
|
152
|
+
if not cache_path.exists():
|
|
153
|
+
return None
|
|
154
|
+
|
|
155
|
+
try:
|
|
156
|
+
data = json.loads(cache_path.read_text())
|
|
157
|
+
timestamp = data.get("timestamp", 0)
|
|
158
|
+
if time.time() - timestamp >= self.ttl_seconds:
|
|
159
|
+
cache_path.unlink(missing_ok=True)
|
|
160
|
+
return None
|
|
161
|
+
|
|
162
|
+
cached_embedding: Embedding = data["embedding"]
|
|
163
|
+
# Store in memory cache for faster subsequent access
|
|
164
|
+
self._memory_cache[key] = (cached_embedding, timestamp)
|
|
165
|
+
return cached_embedding
|
|
166
|
+
except (json.JSONDecodeError, KeyError, OSError):
|
|
167
|
+
return None
|
|
168
|
+
|
|
169
|
+
def set(self, text: str, model: str, embedding: Embedding) -> None:
|
|
170
|
+
"""Store an embedding in cache.
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
text: The text that was embedded.
|
|
174
|
+
model: The model used for embedding.
|
|
175
|
+
embedding: The embedding to cache.
|
|
176
|
+
"""
|
|
177
|
+
key = self._get_cache_key(text, model)
|
|
178
|
+
timestamp = time.time()
|
|
179
|
+
|
|
180
|
+
# Store in memory cache
|
|
181
|
+
self._memory_cache[key] = (embedding, timestamp)
|
|
182
|
+
|
|
183
|
+
# Store in file cache
|
|
184
|
+
cache_path = self._get_cache_path(key)
|
|
185
|
+
try:
|
|
186
|
+
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
|
187
|
+
cache_path.write_text(
|
|
188
|
+
json.dumps(
|
|
189
|
+
{
|
|
190
|
+
"embedding": embedding,
|
|
191
|
+
"model": model,
|
|
192
|
+
"timestamp": timestamp,
|
|
193
|
+
}
|
|
194
|
+
)
|
|
195
|
+
)
|
|
196
|
+
except OSError as e:
|
|
197
|
+
logger.warning(f"Failed to write embedding cache: {e}")
|
|
198
|
+
|
|
199
|
+
def get_many(
|
|
200
|
+
self, texts: list[str], model: str
|
|
201
|
+
) -> tuple[dict[int, Embedding], list[int]]:
|
|
202
|
+
"""Get multiple embeddings from cache.
|
|
203
|
+
|
|
204
|
+
Args:
|
|
205
|
+
texts: List of texts to look up.
|
|
206
|
+
model: The model used for embedding.
|
|
207
|
+
|
|
208
|
+
Returns:
|
|
209
|
+
Tuple of (cached embeddings by index, list of uncached indices).
|
|
210
|
+
"""
|
|
211
|
+
cached: dict[int, Embedding] = {}
|
|
212
|
+
uncached: list[int] = []
|
|
213
|
+
|
|
214
|
+
for i, text in enumerate(texts):
|
|
215
|
+
embedding = self.get(text, model)
|
|
216
|
+
if embedding is not None:
|
|
217
|
+
cached[i] = embedding
|
|
218
|
+
else:
|
|
219
|
+
uncached.append(i)
|
|
220
|
+
|
|
221
|
+
return cached, uncached
|
|
222
|
+
|
|
223
|
+
def set_many(
|
|
224
|
+
self, texts: list[str], model: str, embeddings: list[Embedding]
|
|
225
|
+
) -> None:
|
|
226
|
+
"""Store multiple embeddings in cache.
|
|
227
|
+
|
|
228
|
+
Args:
|
|
229
|
+
texts: List of texts that were embedded.
|
|
230
|
+
model: The model used for embedding.
|
|
231
|
+
embeddings: The embeddings to cache.
|
|
232
|
+
"""
|
|
233
|
+
for text, embedding in zip(texts, embeddings, strict=True):
|
|
234
|
+
self.set(text, model, embedding)
|
|
235
|
+
|
|
236
|
+
def clear(self) -> int:
|
|
237
|
+
"""Clear all cached embeddings.
|
|
238
|
+
|
|
239
|
+
Returns:
|
|
240
|
+
Number of entries cleared.
|
|
241
|
+
"""
|
|
242
|
+
self._memory_cache.clear()
|
|
243
|
+
count = 0
|
|
244
|
+
if self.cache_dir.exists():
|
|
245
|
+
for cache_file in self.cache_dir.rglob("*.json"):
|
|
246
|
+
cache_file.unlink(missing_ok=True)
|
|
247
|
+
count += 1
|
|
248
|
+
return count
|
|
249
|
+
|
|
250
|
+
def clear_expired(self) -> int:
|
|
251
|
+
"""Clear expired cache entries.
|
|
252
|
+
|
|
253
|
+
Returns:
|
|
254
|
+
Number of entries cleared.
|
|
255
|
+
"""
|
|
256
|
+
now = time.time()
|
|
257
|
+
count = 0
|
|
258
|
+
|
|
259
|
+
# Clear expired memory cache entries
|
|
260
|
+
expired_keys = [
|
|
261
|
+
key
|
|
262
|
+
for key, (_, timestamp) in self._memory_cache.items()
|
|
263
|
+
if now - timestamp >= self.ttl_seconds
|
|
264
|
+
]
|
|
265
|
+
for key in expired_keys:
|
|
266
|
+
del self._memory_cache[key]
|
|
267
|
+
count += 1
|
|
268
|
+
|
|
269
|
+
# Clear expired file cache entries
|
|
270
|
+
if self.cache_dir.exists():
|
|
271
|
+
for cache_file in self.cache_dir.rglob("*.json"):
|
|
272
|
+
try:
|
|
273
|
+
data = json.loads(cache_file.read_text())
|
|
274
|
+
if now - data.get("timestamp", 0) >= self.ttl_seconds:
|
|
275
|
+
cache_file.unlink(missing_ok=True)
|
|
276
|
+
count += 1
|
|
277
|
+
except (json.JSONDecodeError, OSError):
|
|
278
|
+
cache_file.unlink(missing_ok=True)
|
|
279
|
+
count += 1
|
|
280
|
+
|
|
281
|
+
return count
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
class EmbeddingProvider(ABC):
|
|
285
|
+
"""Abstract base class for embedding providers."""
|
|
286
|
+
|
|
287
|
+
def __init__(self, config: EmbeddingConfig | None = None) -> None:
|
|
288
|
+
"""Initialize the provider.
|
|
289
|
+
|
|
290
|
+
Args:
|
|
291
|
+
config: Configuration for the provider.
|
|
292
|
+
"""
|
|
293
|
+
self.config = config or EmbeddingConfig()
|
|
294
|
+
self._cache: EmbeddingCache | None = None
|
|
295
|
+
|
|
296
|
+
if self.config.cache_enabled:
|
|
297
|
+
self._cache = EmbeddingCache(
|
|
298
|
+
cache_dir=self.config.cache_dir,
|
|
299
|
+
ttl_seconds=self.config.cache_ttl_seconds,
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
@property
|
|
303
|
+
def available(self) -> bool:
|
|
304
|
+
"""Whether this provider can actually produce embeddings.
|
|
305
|
+
|
|
306
|
+
Providers that need an optional dependency report False when it is
|
|
307
|
+
missing, which lets retrieval fall back to keyword matching instead of
|
|
308
|
+
raising.
|
|
309
|
+
"""
|
|
310
|
+
return True
|
|
311
|
+
|
|
312
|
+
@property
|
|
313
|
+
@abstractmethod
|
|
314
|
+
def model_name(self) -> str:
|
|
315
|
+
"""Get the model name."""
|
|
316
|
+
...
|
|
317
|
+
|
|
318
|
+
@property
|
|
319
|
+
@abstractmethod
|
|
320
|
+
def dimensions(self) -> int:
|
|
321
|
+
"""Get the embedding dimensions."""
|
|
322
|
+
...
|
|
323
|
+
|
|
324
|
+
@abstractmethod
|
|
325
|
+
async def _embed_texts(self, texts: list[str]) -> list[Embedding]:
|
|
326
|
+
"""Embed multiple texts (internal implementation).
|
|
327
|
+
|
|
328
|
+
Args:
|
|
329
|
+
texts: Texts to embed.
|
|
330
|
+
|
|
331
|
+
Returns:
|
|
332
|
+
List of embeddings.
|
|
333
|
+
"""
|
|
334
|
+
...
|
|
335
|
+
|
|
336
|
+
async def embed(self, text: str) -> EmbeddingResult:
|
|
337
|
+
"""Embed a single text.
|
|
338
|
+
|
|
339
|
+
Args:
|
|
340
|
+
text: Text to embed.
|
|
341
|
+
|
|
342
|
+
Returns:
|
|
343
|
+
Embedding result.
|
|
344
|
+
"""
|
|
345
|
+
start_time = time.time()
|
|
346
|
+
|
|
347
|
+
# Check cache
|
|
348
|
+
if self._cache:
|
|
349
|
+
cached = self._cache.get(text, self.model_name)
|
|
350
|
+
if cached is not None:
|
|
351
|
+
return EmbeddingResult(
|
|
352
|
+
embedding=cached,
|
|
353
|
+
model=self.model_name,
|
|
354
|
+
dimensions=len(cached),
|
|
355
|
+
cached=True,
|
|
356
|
+
latency_ms=(time.time() - start_time) * 1000,
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
# Compute embedding
|
|
360
|
+
embeddings = await self._embed_texts([text])
|
|
361
|
+
embedding = embeddings[0]
|
|
362
|
+
|
|
363
|
+
# Apply dimension reduction if configured
|
|
364
|
+
if self.config.dimensions and len(embedding) > self.config.dimensions:
|
|
365
|
+
embedding = embedding[: self.config.dimensions]
|
|
366
|
+
|
|
367
|
+
# Cache result
|
|
368
|
+
if self._cache:
|
|
369
|
+
self._cache.set(text, self.model_name, embedding)
|
|
370
|
+
|
|
371
|
+
return EmbeddingResult(
|
|
372
|
+
embedding=embedding,
|
|
373
|
+
model=self.model_name,
|
|
374
|
+
dimensions=len(embedding),
|
|
375
|
+
cached=False,
|
|
376
|
+
latency_ms=(time.time() - start_time) * 1000,
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
async def embed_many(self, texts: list[str]) -> BatchEmbeddingResult:
|
|
380
|
+
"""Embed multiple texts with batching and caching.
|
|
381
|
+
|
|
382
|
+
Args:
|
|
383
|
+
texts: Texts to embed.
|
|
384
|
+
|
|
385
|
+
Returns:
|
|
386
|
+
Batch embedding result.
|
|
387
|
+
"""
|
|
388
|
+
if not texts:
|
|
389
|
+
return BatchEmbeddingResult(
|
|
390
|
+
embeddings=[],
|
|
391
|
+
model=self.model_name,
|
|
392
|
+
dimensions=self.dimensions,
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
start_time = time.time()
|
|
396
|
+
result_embeddings: list[Embedding | None] = [None] * len(texts)
|
|
397
|
+
cached_count = 0
|
|
398
|
+
|
|
399
|
+
# Check cache for all texts
|
|
400
|
+
if self._cache:
|
|
401
|
+
cached, uncached_indices = self._cache.get_many(texts, self.model_name)
|
|
402
|
+
for idx, embedding in cached.items():
|
|
403
|
+
result_embeddings[idx] = embedding
|
|
404
|
+
cached_count += 1
|
|
405
|
+
texts_to_embed = [texts[i] for i in uncached_indices]
|
|
406
|
+
indices_to_embed = uncached_indices
|
|
407
|
+
else:
|
|
408
|
+
texts_to_embed = texts
|
|
409
|
+
indices_to_embed = list(range(len(texts)))
|
|
410
|
+
|
|
411
|
+
# Embed uncached texts in batches
|
|
412
|
+
computed_count = 0
|
|
413
|
+
for batch_start in range(0, len(texts_to_embed), self.config.batch_size):
|
|
414
|
+
batch_end = min(batch_start + self.config.batch_size, len(texts_to_embed))
|
|
415
|
+
batch_texts = texts_to_embed[batch_start:batch_end]
|
|
416
|
+
batch_indices = indices_to_embed[batch_start:batch_end]
|
|
417
|
+
|
|
418
|
+
batch_embeddings = await self._embed_texts(batch_texts)
|
|
419
|
+
|
|
420
|
+
for i, emb in enumerate(batch_embeddings):
|
|
421
|
+
# Apply dimension reduction if configured
|
|
422
|
+
final_emb = emb
|
|
423
|
+
if self.config.dimensions and len(emb) > self.config.dimensions:
|
|
424
|
+
final_emb = emb[: self.config.dimensions]
|
|
425
|
+
|
|
426
|
+
idx = batch_indices[i]
|
|
427
|
+
result_embeddings[idx] = final_emb
|
|
428
|
+
computed_count += 1
|
|
429
|
+
|
|
430
|
+
# Cache computed embeddings
|
|
431
|
+
if self._cache:
|
|
432
|
+
self._cache.set_many(batch_texts, self.model_name, batch_embeddings)
|
|
433
|
+
|
|
434
|
+
# Ensure all embeddings are present
|
|
435
|
+
final_embeddings: list[Embedding] = []
|
|
436
|
+
for result_emb in result_embeddings:
|
|
437
|
+
if result_emb is None:
|
|
438
|
+
raise EmbeddingError("Missing embedding in result")
|
|
439
|
+
final_embeddings.append(result_emb)
|
|
440
|
+
|
|
441
|
+
return BatchEmbeddingResult(
|
|
442
|
+
embeddings=final_embeddings,
|
|
443
|
+
model=self.model_name,
|
|
444
|
+
dimensions=self.dimensions,
|
|
445
|
+
cached_count=cached_count,
|
|
446
|
+
computed_count=computed_count,
|
|
447
|
+
total_latency_ms=(time.time() - start_time) * 1000,
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
def cosine_similarity(
|
|
451
|
+
self, embedding1: Embedding, embedding2: Embedding
|
|
452
|
+
) -> float:
|
|
453
|
+
"""Compute cosine similarity between two embeddings.
|
|
454
|
+
|
|
455
|
+
Args:
|
|
456
|
+
embedding1: First embedding.
|
|
457
|
+
embedding2: Second embedding.
|
|
458
|
+
|
|
459
|
+
Returns:
|
|
460
|
+
Cosine similarity score (-1 to 1).
|
|
461
|
+
"""
|
|
462
|
+
arr1: NDArray[np.floating[Any]] = np.array(embedding1)
|
|
463
|
+
arr2: NDArray[np.floating[Any]] = np.array(embedding2)
|
|
464
|
+
|
|
465
|
+
norm1 = np.linalg.norm(arr1)
|
|
466
|
+
norm2 = np.linalg.norm(arr2)
|
|
467
|
+
|
|
468
|
+
if norm1 == 0 or norm2 == 0:
|
|
469
|
+
return 0.0
|
|
470
|
+
|
|
471
|
+
return float(np.dot(arr1, arr2) / (norm1 * norm2))
|
|
472
|
+
|
|
473
|
+
def find_most_similar(
|
|
474
|
+
self,
|
|
475
|
+
query_embedding: Embedding,
|
|
476
|
+
embeddings: list[Embedding],
|
|
477
|
+
top_k: int = 5,
|
|
478
|
+
) -> list[tuple[int, float]]:
|
|
479
|
+
"""Find the most similar embeddings to a query.
|
|
480
|
+
|
|
481
|
+
Args:
|
|
482
|
+
query_embedding: The query embedding.
|
|
483
|
+
embeddings: List of embeddings to search.
|
|
484
|
+
top_k: Number of top results to return.
|
|
485
|
+
|
|
486
|
+
Returns:
|
|
487
|
+
List of (index, similarity_score) tuples, sorted by score descending.
|
|
488
|
+
"""
|
|
489
|
+
if not embeddings:
|
|
490
|
+
return []
|
|
491
|
+
|
|
492
|
+
query_arr: NDArray[np.floating[Any]] = np.array(query_embedding)
|
|
493
|
+
embeddings_arr: NDArray[np.floating[Any]] = np.array(embeddings)
|
|
494
|
+
|
|
495
|
+
# Compute all similarities at once
|
|
496
|
+
query_norm = np.linalg.norm(query_arr)
|
|
497
|
+
if query_norm == 0:
|
|
498
|
+
return [(i, 0.0) for i in range(min(top_k, len(embeddings)))]
|
|
499
|
+
|
|
500
|
+
embeddings_norms = np.linalg.norm(embeddings_arr, axis=1)
|
|
501
|
+
# Avoid division by zero
|
|
502
|
+
embeddings_norms = np.where(embeddings_norms == 0, 1, embeddings_norms)
|
|
503
|
+
|
|
504
|
+
similarities = np.dot(embeddings_arr, query_arr) / (embeddings_norms * query_norm)
|
|
505
|
+
|
|
506
|
+
# Get top-k indices
|
|
507
|
+
if top_k >= len(similarities):
|
|
508
|
+
top_indices = np.argsort(similarities)[::-1]
|
|
509
|
+
else:
|
|
510
|
+
top_indices = np.argpartition(similarities, -top_k)[-top_k:]
|
|
511
|
+
top_indices = top_indices[np.argsort(similarities[top_indices])[::-1]]
|
|
512
|
+
|
|
513
|
+
return [(int(idx), float(similarities[idx])) for idx in top_indices]
|
|
514
|
+
|
|
515
|
+
def clear_cache(self) -> int:
|
|
516
|
+
"""Clear the embedding cache.
|
|
517
|
+
|
|
518
|
+
Returns:
|
|
519
|
+
Number of entries cleared.
|
|
520
|
+
"""
|
|
521
|
+
if self._cache:
|
|
522
|
+
return self._cache.clear()
|
|
523
|
+
return 0
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
class LocalEmbeddingProvider(EmbeddingProvider):
|
|
527
|
+
"""Embedding provider using sentence-transformers (local models)."""
|
|
528
|
+
|
|
529
|
+
def __init__(self, config: EmbeddingConfig | None = None) -> None:
|
|
530
|
+
"""Initialize the local provider.
|
|
531
|
+
|
|
532
|
+
Args:
|
|
533
|
+
config: Configuration for the provider.
|
|
534
|
+
"""
|
|
535
|
+
super().__init__(config)
|
|
536
|
+
self._model: Any = None
|
|
537
|
+
self._model_name: str = self.config.model_name
|
|
538
|
+
self._dimensions: int | None = None
|
|
539
|
+
|
|
540
|
+
def _load_model(self) -> Any:
|
|
541
|
+
"""Load the sentence-transformers model."""
|
|
542
|
+
if self._model is not None:
|
|
543
|
+
return self._model
|
|
544
|
+
|
|
545
|
+
try:
|
|
546
|
+
from sentence_transformers import SentenceTransformer # noqa: PLC0415
|
|
547
|
+
except ImportError as e:
|
|
548
|
+
raise ModelNotFoundError(
|
|
549
|
+
"sentence-transformers not installed. "
|
|
550
|
+
"Install with: pip install 'runtime-memory[phase1]'"
|
|
551
|
+
) from e
|
|
552
|
+
|
|
553
|
+
try:
|
|
554
|
+
self._model = SentenceTransformer(self._model_name)
|
|
555
|
+
# Get model dimensions
|
|
556
|
+
self._dimensions = self._model.get_sentence_embedding_dimension()
|
|
557
|
+
logger.info(
|
|
558
|
+
f"Loaded model {self._model_name} with {self._dimensions} dimensions"
|
|
559
|
+
)
|
|
560
|
+
return self._model
|
|
561
|
+
except Exception as e:
|
|
562
|
+
raise ModelNotFoundError(
|
|
563
|
+
f"Failed to load model {self._model_name}: {e}"
|
|
564
|
+
) from e
|
|
565
|
+
|
|
566
|
+
@property
|
|
567
|
+
def model_name(self) -> str:
|
|
568
|
+
"""Get the model name."""
|
|
569
|
+
return self._model_name
|
|
570
|
+
|
|
571
|
+
@property
|
|
572
|
+
def dimensions(self) -> int:
|
|
573
|
+
"""Get the embedding dimensions."""
|
|
574
|
+
if self._dimensions is None:
|
|
575
|
+
self._load_model()
|
|
576
|
+
return self._dimensions or 384 # Default for MiniLM
|
|
577
|
+
|
|
578
|
+
async def _embed_texts(self, texts: list[str]) -> list[Embedding]:
|
|
579
|
+
"""Embed multiple texts using sentence-transformers.
|
|
580
|
+
|
|
581
|
+
Args:
|
|
582
|
+
texts: Texts to embed.
|
|
583
|
+
|
|
584
|
+
Returns:
|
|
585
|
+
List of embeddings.
|
|
586
|
+
"""
|
|
587
|
+
import asyncio # noqa: PLC0415
|
|
588
|
+
|
|
589
|
+
model = self._load_model()
|
|
590
|
+
|
|
591
|
+
# Run in thread pool to avoid blocking
|
|
592
|
+
loop = asyncio.get_event_loop()
|
|
593
|
+
embeddings = await loop.run_in_executor(
|
|
594
|
+
None,
|
|
595
|
+
lambda: model.encode(
|
|
596
|
+
texts,
|
|
597
|
+
convert_to_numpy=True,
|
|
598
|
+
show_progress_bar=False,
|
|
599
|
+
),
|
|
600
|
+
)
|
|
601
|
+
|
|
602
|
+
return [emb.tolist() for emb in embeddings]
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
class APIEmbeddingProvider(EmbeddingProvider):
|
|
606
|
+
"""Embedding provider using API (OpenAI-compatible)."""
|
|
607
|
+
|
|
608
|
+
# Known model dimensions
|
|
609
|
+
MODEL_DIMENSIONS: ClassVar[dict[str, int]] = {
|
|
610
|
+
"text-embedding-3-small": 1536,
|
|
611
|
+
"text-embedding-3-large": 3072,
|
|
612
|
+
"text-embedding-ada-002": 1536,
|
|
613
|
+
"voyage-large-2": 1024,
|
|
614
|
+
"voyage-code-2": 1536,
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
# Default API endpoints
|
|
618
|
+
DEFAULT_API_URLS: ClassVar[dict[str, str]] = {
|
|
619
|
+
"openai": "https://api.openai.com/v1/embeddings",
|
|
620
|
+
"voyage": "https://api.voyageai.com/v1/embeddings",
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
def __init__(
|
|
624
|
+
self,
|
|
625
|
+
config: EmbeddingConfig | None = None,
|
|
626
|
+
api_provider: str = "openai",
|
|
627
|
+
) -> None:
|
|
628
|
+
"""Initialize the API provider.
|
|
629
|
+
|
|
630
|
+
Args:
|
|
631
|
+
config: Configuration for the provider.
|
|
632
|
+
api_provider: API provider name (openai, voyage).
|
|
633
|
+
"""
|
|
634
|
+
super().__init__(config)
|
|
635
|
+
self._api_provider = api_provider
|
|
636
|
+
|
|
637
|
+
# Set default model based on provider
|
|
638
|
+
if self.config.model_name == "all-MiniLM-L6-v2": # Default local model
|
|
639
|
+
if api_provider == "openai":
|
|
640
|
+
self.config.model_name = "text-embedding-3-small"
|
|
641
|
+
elif api_provider == "voyage":
|
|
642
|
+
self.config.model_name = "voyage-large-2"
|
|
643
|
+
|
|
644
|
+
# Set API URL
|
|
645
|
+
if not self.config.api_base_url:
|
|
646
|
+
self.config.api_base_url = self.DEFAULT_API_URLS.get(
|
|
647
|
+
api_provider, self.DEFAULT_API_URLS["openai"]
|
|
648
|
+
)
|
|
649
|
+
|
|
650
|
+
def _get_api_key(self) -> str:
|
|
651
|
+
"""Get the API key from environment."""
|
|
652
|
+
# Try provider-specific env var first
|
|
653
|
+
provider_env_vars = {
|
|
654
|
+
"openai": "OPENAI_API_KEY",
|
|
655
|
+
"voyage": "VOYAGE_API_KEY",
|
|
656
|
+
}
|
|
657
|
+
env_var = provider_env_vars.get(self._api_provider, self.config.api_key_env_var)
|
|
658
|
+
|
|
659
|
+
api_key = os.environ.get(env_var) or os.environ.get(self.config.api_key_env_var)
|
|
660
|
+
if not api_key:
|
|
661
|
+
raise APIError(
|
|
662
|
+
f"API key not found. Set {env_var} or {self.config.api_key_env_var} "
|
|
663
|
+
"environment variable."
|
|
664
|
+
)
|
|
665
|
+
return api_key
|
|
666
|
+
|
|
667
|
+
@property
|
|
668
|
+
def model_name(self) -> str:
|
|
669
|
+
"""Get the model name."""
|
|
670
|
+
return self.config.model_name
|
|
671
|
+
|
|
672
|
+
@property
|
|
673
|
+
def dimensions(self) -> int:
|
|
674
|
+
"""Get the embedding dimensions."""
|
|
675
|
+
if self.config.dimensions:
|
|
676
|
+
return self.config.dimensions
|
|
677
|
+
return self.MODEL_DIMENSIONS.get(self.config.model_name, 1536)
|
|
678
|
+
|
|
679
|
+
async def _embed_texts(self, texts: list[str]) -> list[Embedding]:
|
|
680
|
+
"""Embed multiple texts using API.
|
|
681
|
+
|
|
682
|
+
Args:
|
|
683
|
+
texts: Texts to embed.
|
|
684
|
+
|
|
685
|
+
Returns:
|
|
686
|
+
List of embeddings.
|
|
687
|
+
"""
|
|
688
|
+
import httpx # noqa: PLC0415
|
|
689
|
+
|
|
690
|
+
api_key = self._get_api_key()
|
|
691
|
+
|
|
692
|
+
headers = {
|
|
693
|
+
"Authorization": f"Bearer {api_key}",
|
|
694
|
+
"Content-Type": "application/json",
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
payload: dict[str, Any] = {
|
|
698
|
+
"input": texts,
|
|
699
|
+
"model": self.config.model_name,
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
# Add dimensions parameter if supported and configured
|
|
703
|
+
if self.config.dimensions and self.config.model_name.startswith("text-embedding-3"):
|
|
704
|
+
payload["dimensions"] = self.config.dimensions
|
|
705
|
+
|
|
706
|
+
last_error: Exception | None = None
|
|
707
|
+
for attempt in range(self.config.api_max_retries):
|
|
708
|
+
try:
|
|
709
|
+
async with httpx.AsyncClient(timeout=self.config.api_timeout) as client:
|
|
710
|
+
response = await client.post(
|
|
711
|
+
self.config.api_base_url or "",
|
|
712
|
+
headers=headers,
|
|
713
|
+
json=payload,
|
|
714
|
+
)
|
|
715
|
+
|
|
716
|
+
if response.status_code == 429:
|
|
717
|
+
# Rate limited, wait and retry
|
|
718
|
+
wait_time = min(2**attempt, 32)
|
|
719
|
+
logger.warning(f"Rate limited, waiting {wait_time}s before retry")
|
|
720
|
+
import asyncio # noqa: PLC0415
|
|
721
|
+
|
|
722
|
+
await asyncio.sleep(wait_time)
|
|
723
|
+
continue
|
|
724
|
+
|
|
725
|
+
response.raise_for_status()
|
|
726
|
+
data = response.json()
|
|
727
|
+
|
|
728
|
+
# Extract embeddings (sorted by index)
|
|
729
|
+
embedding_data = sorted(data["data"], key=lambda x: x["index"])
|
|
730
|
+
return [item["embedding"] for item in embedding_data]
|
|
731
|
+
|
|
732
|
+
except httpx.HTTPStatusError as e:
|
|
733
|
+
last_error = APIError(f"API request failed: {e.response.status_code} - {e.response.text}")
|
|
734
|
+
except httpx.RequestError as e:
|
|
735
|
+
last_error = APIError(f"API request failed: {e}")
|
|
736
|
+
except (KeyError, json.JSONDecodeError) as e:
|
|
737
|
+
last_error = APIError(f"Invalid API response: {e}")
|
|
738
|
+
|
|
739
|
+
if attempt < self.config.api_max_retries - 1:
|
|
740
|
+
import asyncio # noqa: PLC0415
|
|
741
|
+
|
|
742
|
+
await asyncio.sleep(2**attempt)
|
|
743
|
+
|
|
744
|
+
raise last_error or APIError("API request failed after retries")
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
class MockEmbeddingProvider(EmbeddingProvider):
|
|
748
|
+
"""Mock embedding provider for testing."""
|
|
749
|
+
|
|
750
|
+
def __init__(
|
|
751
|
+
self,
|
|
752
|
+
config: EmbeddingConfig | None = None,
|
|
753
|
+
fixed_embedding: Embedding | None = None,
|
|
754
|
+
) -> None:
|
|
755
|
+
"""Initialize the mock provider.
|
|
756
|
+
|
|
757
|
+
Args:
|
|
758
|
+
config: Configuration for the provider.
|
|
759
|
+
fixed_embedding: Fixed embedding to return (for deterministic tests).
|
|
760
|
+
"""
|
|
761
|
+
# Disable cache for mock provider by default
|
|
762
|
+
if config is None:
|
|
763
|
+
config = EmbeddingConfig(cache_enabled=False)
|
|
764
|
+
super().__init__(config)
|
|
765
|
+
self._fixed_embedding = fixed_embedding
|
|
766
|
+
self._call_count = 0
|
|
767
|
+
self._embedded_texts: list[str] = []
|
|
768
|
+
|
|
769
|
+
@property
|
|
770
|
+
def model_name(self) -> str:
|
|
771
|
+
"""Get the model name."""
|
|
772
|
+
return "mock-embedding-model"
|
|
773
|
+
|
|
774
|
+
@property
|
|
775
|
+
def dimensions(self) -> int:
|
|
776
|
+
"""Get the embedding dimensions."""
|
|
777
|
+
return self.config.dimensions or 384
|
|
778
|
+
|
|
779
|
+
@property
|
|
780
|
+
def call_count(self) -> int:
|
|
781
|
+
"""Get the number of embedding calls made."""
|
|
782
|
+
return self._call_count
|
|
783
|
+
|
|
784
|
+
@property
|
|
785
|
+
def embedded_texts(self) -> list[str]:
|
|
786
|
+
"""Get all texts that were embedded."""
|
|
787
|
+
return self._embedded_texts
|
|
788
|
+
|
|
789
|
+
def reset(self) -> None:
|
|
790
|
+
"""Reset call tracking."""
|
|
791
|
+
self._call_count = 0
|
|
792
|
+
self._embedded_texts = []
|
|
793
|
+
|
|
794
|
+
async def _embed_texts(self, texts: list[str]) -> list[Embedding]:
|
|
795
|
+
"""Generate mock embeddings.
|
|
796
|
+
|
|
797
|
+
Args:
|
|
798
|
+
texts: Texts to embed.
|
|
799
|
+
|
|
800
|
+
Returns:
|
|
801
|
+
List of mock embeddings.
|
|
802
|
+
"""
|
|
803
|
+
self._call_count += 1
|
|
804
|
+
self._embedded_texts.extend(texts)
|
|
805
|
+
|
|
806
|
+
embeddings: list[Embedding] = []
|
|
807
|
+
for text in texts:
|
|
808
|
+
if self._fixed_embedding:
|
|
809
|
+
embeddings.append(self._fixed_embedding.copy())
|
|
810
|
+
else:
|
|
811
|
+
# Generate deterministic embedding based on text hash
|
|
812
|
+
# MD5 is used only for creating deterministic seeds, not for security
|
|
813
|
+
text_hash = hashlib.md5(text.encode(), usedforsecurity=False).hexdigest() # noqa: S324
|
|
814
|
+
seed = int(text_hash[:8], 16)
|
|
815
|
+
rng = np.random.default_rng(seed)
|
|
816
|
+
embedding = rng.random(self.dimensions).tolist()
|
|
817
|
+
# Normalize
|
|
818
|
+
norm = sum(x**2 for x in embedding) ** 0.5
|
|
819
|
+
embeddings.append([x / norm for x in embedding])
|
|
820
|
+
|
|
821
|
+
return embeddings
|
|
822
|
+
|
|
823
|
+
|
|
824
|
+
class NullEmbeddingProvider(EmbeddingProvider):
|
|
825
|
+
"""Fallback provider used when no embedding backend is installed.
|
|
826
|
+
|
|
827
|
+
Returns an empty embedding for every text. Nothing is written to the vector
|
|
828
|
+
index, so retrieval scores memories with BM25 keyword matching alone, which
|
|
829
|
+
needs no third-party packages. This keeps a core ``pip install
|
|
830
|
+
runtime-memory`` usable; install the ``embedding`` extra to turn semantic
|
|
831
|
+
search back on.
|
|
832
|
+
"""
|
|
833
|
+
|
|
834
|
+
def __init__(self, config: EmbeddingConfig | None = None) -> None:
|
|
835
|
+
"""Initialize the null provider.
|
|
836
|
+
|
|
837
|
+
Args:
|
|
838
|
+
config: Configuration for the provider.
|
|
839
|
+
"""
|
|
840
|
+
if config is None:
|
|
841
|
+
config = EmbeddingConfig(cache_enabled=False)
|
|
842
|
+
super().__init__(config)
|
|
843
|
+
|
|
844
|
+
@property
|
|
845
|
+
def available(self) -> bool:
|
|
846
|
+
"""Null provider never produces usable embeddings."""
|
|
847
|
+
return False
|
|
848
|
+
|
|
849
|
+
@property
|
|
850
|
+
def model_name(self) -> str:
|
|
851
|
+
"""Get the model name."""
|
|
852
|
+
return "null-embedding-provider"
|
|
853
|
+
|
|
854
|
+
@property
|
|
855
|
+
def dimensions(self) -> int:
|
|
856
|
+
"""Get the embedding dimensions."""
|
|
857
|
+
return 0
|
|
858
|
+
|
|
859
|
+
async def _embed_texts(self, texts: list[str]) -> list[Embedding]:
|
|
860
|
+
"""Return an empty embedding per text.
|
|
861
|
+
|
|
862
|
+
Args:
|
|
863
|
+
texts: Texts to embed.
|
|
864
|
+
|
|
865
|
+
Returns:
|
|
866
|
+
One empty embedding for each input text.
|
|
867
|
+
"""
|
|
868
|
+
return [[] for _ in texts]
|
|
869
|
+
|
|
870
|
+
|
|
871
|
+
def get_embedding_provider(
|
|
872
|
+
provider_type: str = "local",
|
|
873
|
+
config: EmbeddingConfig | None = None,
|
|
874
|
+
**kwargs: Any,
|
|
875
|
+
) -> EmbeddingProvider:
|
|
876
|
+
"""Factory function to create embedding providers.
|
|
877
|
+
|
|
878
|
+
Args:
|
|
879
|
+
provider_type: Type of provider ("local", "openai", "voyage", "mock",
|
|
880
|
+
"null").
|
|
881
|
+
config: Configuration for the provider.
|
|
882
|
+
**kwargs: Additional arguments passed to the provider.
|
|
883
|
+
|
|
884
|
+
Returns:
|
|
885
|
+
Embedding provider instance.
|
|
886
|
+
"""
|
|
887
|
+
if provider_type == "local":
|
|
888
|
+
if importlib.util.find_spec("sentence_transformers") is None:
|
|
889
|
+
# Info, not a warning: keyword retrieval is a supported way to run,
|
|
890
|
+
# and the CLI builds a provider for every command, so a warning here
|
|
891
|
+
# would print on each one. `mem check` reports the same state.
|
|
892
|
+
logger.info(
|
|
893
|
+
"sentence-transformers is not installed, so semantic search is "
|
|
894
|
+
"disabled and retrieval will use keyword matching only. "
|
|
895
|
+
"Install with: pip install 'runtime-memory[embedding]'"
|
|
896
|
+
)
|
|
897
|
+
return NullEmbeddingProvider(config)
|
|
898
|
+
return LocalEmbeddingProvider(config)
|
|
899
|
+
elif provider_type == "openai":
|
|
900
|
+
return APIEmbeddingProvider(config, api_provider="openai")
|
|
901
|
+
elif provider_type == "voyage":
|
|
902
|
+
return APIEmbeddingProvider(config, api_provider="voyage")
|
|
903
|
+
elif provider_type == "mock":
|
|
904
|
+
return MockEmbeddingProvider(config, **kwargs)
|
|
905
|
+
elif provider_type == "null":
|
|
906
|
+
return NullEmbeddingProvider(config)
|
|
907
|
+
else:
|
|
908
|
+
raise ValueError(f"Unknown provider type: {provider_type}")
|