RouteKitAI 0.1.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.
- routekitai/__init__.py +53 -0
- routekitai/cli/__init__.py +18 -0
- routekitai/cli/main.py +40 -0
- routekitai/cli/replay.py +80 -0
- routekitai/cli/run.py +95 -0
- routekitai/cli/serve.py +966 -0
- routekitai/cli/test_agent.py +178 -0
- routekitai/cli/trace.py +209 -0
- routekitai/cli/trace_analyze.py +120 -0
- routekitai/cli/trace_search.py +126 -0
- routekitai/core/__init__.py +58 -0
- routekitai/core/agent.py +325 -0
- routekitai/core/errors.py +49 -0
- routekitai/core/hooks.py +174 -0
- routekitai/core/memory.py +54 -0
- routekitai/core/message.py +132 -0
- routekitai/core/model.py +91 -0
- routekitai/core/policies.py +373 -0
- routekitai/core/policy.py +85 -0
- routekitai/core/policy_adapter.py +133 -0
- routekitai/core/runtime.py +1403 -0
- routekitai/core/tool.py +148 -0
- routekitai/core/tools.py +180 -0
- routekitai/evals/__init__.py +13 -0
- routekitai/evals/dataset.py +75 -0
- routekitai/evals/metrics.py +101 -0
- routekitai/evals/runner.py +184 -0
- routekitai/graphs/__init__.py +12 -0
- routekitai/graphs/executors.py +457 -0
- routekitai/graphs/graph.py +164 -0
- routekitai/memory/__init__.py +13 -0
- routekitai/memory/episodic.py +242 -0
- routekitai/memory/kv.py +34 -0
- routekitai/memory/retrieval.py +192 -0
- routekitai/memory/vector.py +700 -0
- routekitai/memory/working.py +66 -0
- routekitai/message.py +29 -0
- routekitai/model.py +48 -0
- routekitai/observability/__init__.py +21 -0
- routekitai/observability/analyzer.py +314 -0
- routekitai/observability/exporters/__init__.py +10 -0
- routekitai/observability/exporters/base.py +30 -0
- routekitai/observability/exporters/jsonl.py +81 -0
- routekitai/observability/exporters/otel.py +119 -0
- routekitai/observability/spans.py +111 -0
- routekitai/observability/streaming.py +117 -0
- routekitai/observability/trace.py +144 -0
- routekitai/providers/__init__.py +9 -0
- routekitai/providers/anthropic.py +227 -0
- routekitai/providers/azure_openai.py +243 -0
- routekitai/providers/local.py +196 -0
- routekitai/providers/openai.py +321 -0
- routekitai/py.typed +0 -0
- routekitai/sandbox/__init__.py +12 -0
- routekitai/sandbox/filesystem.py +131 -0
- routekitai/sandbox/network.py +142 -0
- routekitai/sandbox/permissions.py +70 -0
- routekitai/tool.py +33 -0
- routekitai-0.1.0.dist-info/METADATA +328 -0
- routekitai-0.1.0.dist-info/RECORD +64 -0
- routekitai-0.1.0.dist-info/WHEEL +5 -0
- routekitai-0.1.0.dist-info/entry_points.txt +2 -0
- routekitai-0.1.0.dist-info/licenses/LICENSE +21 -0
- routekitai-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,700 @@
|
|
|
1
|
+
"""Vector memory for semantic search with efficient similarity search.
|
|
2
|
+
|
|
3
|
+
This module provides a production-ready vector memory implementation with:
|
|
4
|
+
- Support for multiple embedding backends (sentence-transformers, OpenAI, custom)
|
|
5
|
+
- Efficient similarity search using cosine similarity with optional FAISS indexing
|
|
6
|
+
- Batch operations for performance
|
|
7
|
+
- Metadata filtering and hybrid search
|
|
8
|
+
- Persistence support
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import math
|
|
12
|
+
import pickle
|
|
13
|
+
import uuid
|
|
14
|
+
from collections import defaultdict
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from pydantic import BaseModel, Field
|
|
19
|
+
|
|
20
|
+
from routekitai.core.errors import RuntimeError as RouteKitRuntimeError
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class VectorMemoryError(RouteKitRuntimeError):
|
|
24
|
+
"""Error raised by vector memory operations."""
|
|
25
|
+
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class EmbeddingBackend:
|
|
30
|
+
"""Abstract embedding backend interface."""
|
|
31
|
+
|
|
32
|
+
def embed(self, text: str) -> list[float]:
|
|
33
|
+
"""Generate embedding for a single text.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
text: Input text
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
Embedding vector as list of floats
|
|
40
|
+
"""
|
|
41
|
+
raise NotImplementedError
|
|
42
|
+
|
|
43
|
+
def embed_batch(self, texts: list[str]) -> list[list[float]]:
|
|
44
|
+
"""Generate embeddings for multiple texts (optional optimization).
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
texts: List of input texts
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
List of embedding vectors
|
|
51
|
+
"""
|
|
52
|
+
return [self.embed(text) for text in texts]
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def dimension(self) -> int:
|
|
56
|
+
"""Return the embedding dimension."""
|
|
57
|
+
raise NotImplementedError
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class SimpleEmbeddingBackend(EmbeddingBackend):
|
|
61
|
+
"""Simple TF-IDF-like embedding backend for MVP (no external dependencies).
|
|
62
|
+
|
|
63
|
+
Uses character-level n-grams and frequency-based weighting.
|
|
64
|
+
For production, use SentenceTransformerBackend or OpenAIBackend.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
def __init__(self, dimension: int = 384, ngram_size: int = 3) -> None:
|
|
68
|
+
"""Initialize simple embedding backend.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
dimension: Embedding dimension
|
|
72
|
+
ngram_size: N-gram size for character-level features
|
|
73
|
+
"""
|
|
74
|
+
self._dimension = dimension
|
|
75
|
+
self.ngram_size = ngram_size
|
|
76
|
+
self._vocab: dict[str, int] = {}
|
|
77
|
+
self._idf: dict[str, float] = {}
|
|
78
|
+
self._document_count = 0
|
|
79
|
+
self._document_ngrams: list[set[str]] = []
|
|
80
|
+
|
|
81
|
+
def _extract_ngrams(self, text: str) -> list[str]:
|
|
82
|
+
"""Extract character n-grams from text."""
|
|
83
|
+
text_lower = text.lower()
|
|
84
|
+
ngrams = []
|
|
85
|
+
for i in range(len(text_lower) - self.ngram_size + 1):
|
|
86
|
+
ngram = text_lower[i : i + self.ngram_size]
|
|
87
|
+
ngrams.append(ngram)
|
|
88
|
+
return ngrams
|
|
89
|
+
|
|
90
|
+
def _build_vocab(self, texts: list[str]) -> None:
|
|
91
|
+
"""Build vocabulary from texts."""
|
|
92
|
+
ngram_counts: dict[str, int] = defaultdict(int)
|
|
93
|
+
for text in texts:
|
|
94
|
+
ngrams = set(self._extract_ngrams(text))
|
|
95
|
+
self._document_ngrams.append(ngrams)
|
|
96
|
+
for ngram in ngrams:
|
|
97
|
+
ngram_counts[ngram] += 1
|
|
98
|
+
|
|
99
|
+
# Build vocab with most frequent n-grams
|
|
100
|
+
sorted_ngrams = sorted(ngram_counts.items(), key=lambda x: x[1], reverse=True)
|
|
101
|
+
self._vocab = {
|
|
102
|
+
ngram: idx for idx, (ngram, _) in enumerate(sorted_ngrams[: self._dimension])
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
# Calculate IDF
|
|
106
|
+
self._document_count = len(texts)
|
|
107
|
+
for ngram in self._vocab.keys():
|
|
108
|
+
doc_freq = sum(1 for doc_ngrams in self._document_ngrams if ngram in doc_ngrams)
|
|
109
|
+
self._idf[ngram] = (
|
|
110
|
+
math.log((self._document_count + 1) / (doc_freq + 1)) if doc_freq > 0 else 0.0
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
def embed(self, text: str) -> list[float]:
|
|
114
|
+
"""Generate embedding using TF-IDF on character n-grams."""
|
|
115
|
+
if not self._vocab:
|
|
116
|
+
# Initialize with empty vocab if not trained
|
|
117
|
+
return [0.0] * self._dimension
|
|
118
|
+
|
|
119
|
+
ngrams = self._extract_ngrams(text)
|
|
120
|
+
ngram_counts: dict[str, int] = defaultdict(int)
|
|
121
|
+
for ngram in ngrams:
|
|
122
|
+
if ngram in self._vocab:
|
|
123
|
+
ngram_counts[ngram] += 1
|
|
124
|
+
|
|
125
|
+
# Build TF-IDF vector
|
|
126
|
+
vector = [0.0] * self._dimension
|
|
127
|
+
total_ngrams = len(ngrams) if ngrams else 1
|
|
128
|
+
|
|
129
|
+
for ngram, count in ngram_counts.items():
|
|
130
|
+
if ngram in self._vocab:
|
|
131
|
+
idx = self._vocab[ngram]
|
|
132
|
+
tf = count / total_ngrams
|
|
133
|
+
idf = self._idf.get(ngram, 0.0)
|
|
134
|
+
vector[idx] = tf * idf
|
|
135
|
+
|
|
136
|
+
# L2 normalization (important for cosine similarity)
|
|
137
|
+
norm = math.sqrt(sum(x * x for x in vector))
|
|
138
|
+
if norm > 0:
|
|
139
|
+
vector = [x / norm for x in vector]
|
|
140
|
+
else:
|
|
141
|
+
# If all zeros, return a small uniform vector to avoid division issues
|
|
142
|
+
vector = [1.0 / math.sqrt(self._dimension)] * self._dimension
|
|
143
|
+
|
|
144
|
+
return vector
|
|
145
|
+
|
|
146
|
+
def embed_batch(self, texts: list[str]) -> list[list[float]]:
|
|
147
|
+
"""Generate embeddings for batch with vocabulary building."""
|
|
148
|
+
if not self._vocab and texts:
|
|
149
|
+
# Build vocab from batch
|
|
150
|
+
self._build_vocab(texts)
|
|
151
|
+
|
|
152
|
+
return [self.embed(text) for text in texts]
|
|
153
|
+
|
|
154
|
+
@property
|
|
155
|
+
def dimension(self) -> int:
|
|
156
|
+
"""Return embedding dimension."""
|
|
157
|
+
return self._dimension
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class VectorMemory(BaseModel):
|
|
161
|
+
"""Production-ready vector memory with efficient similarity search.
|
|
162
|
+
|
|
163
|
+
Features:
|
|
164
|
+
- Multiple embedding backends (simple, sentence-transformers, OpenAI)
|
|
165
|
+
- Efficient cosine similarity search
|
|
166
|
+
- Optional FAISS indexing for large-scale search
|
|
167
|
+
- Metadata filtering
|
|
168
|
+
- Batch operations
|
|
169
|
+
- Persistence support
|
|
170
|
+
"""
|
|
171
|
+
|
|
172
|
+
dimension: int = Field(default=384, description="Vector dimension")
|
|
173
|
+
backend: str = Field(
|
|
174
|
+
default="simple", description="Embedding backend (simple, sentence-transformers, openai)"
|
|
175
|
+
)
|
|
176
|
+
use_faiss: bool = Field(default=False, description="Use FAISS for large-scale indexing")
|
|
177
|
+
persist_path: Path | None = Field(default=None, description="Path to persist vectors")
|
|
178
|
+
similarity_threshold: float = Field(
|
|
179
|
+
default=0.0, description="Minimum similarity threshold for search"
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
def __init__(self, **data: Any) -> None:
|
|
183
|
+
"""Initialize vector memory."""
|
|
184
|
+
super().__init__(**data)
|
|
185
|
+
self._vectors: dict[str, dict[str, Any]] = {}
|
|
186
|
+
self._embeddings: dict[str, list[float]] = {}
|
|
187
|
+
self._embedding_backend: EmbeddingBackend | None = None
|
|
188
|
+
self._faiss_index: Any = None # FAISS index if available
|
|
189
|
+
self._id_to_vector_id: dict[int, str] = {} # Map FAISS index to vector ID
|
|
190
|
+
|
|
191
|
+
def _get_embedding_backend(self) -> EmbeddingBackend:
|
|
192
|
+
"""Get or create embedding backend."""
|
|
193
|
+
if self._embedding_backend is None:
|
|
194
|
+
if self.backend == "simple":
|
|
195
|
+
self._embedding_backend = SimpleEmbeddingBackend(dimension=self.dimension)
|
|
196
|
+
elif self.backend == "sentence-transformers":
|
|
197
|
+
try:
|
|
198
|
+
from sentence_transformers import SentenceTransformer
|
|
199
|
+
|
|
200
|
+
model = SentenceTransformer("all-MiniLM-L6-v2")
|
|
201
|
+
self._embedding_backend = SentenceTransformerBackend(model)
|
|
202
|
+
except ImportError:
|
|
203
|
+
raise VectorMemoryError(
|
|
204
|
+
"sentence-transformers not installed. Install with: pip install sentence-transformers",
|
|
205
|
+
context={"backend": self.backend},
|
|
206
|
+
) from None
|
|
207
|
+
elif self.backend == "openai":
|
|
208
|
+
try:
|
|
209
|
+
import os
|
|
210
|
+
|
|
211
|
+
api_key = os.getenv("OPENAI_API_KEY")
|
|
212
|
+
if not api_key:
|
|
213
|
+
raise VectorMemoryError(
|
|
214
|
+
"OPENAI_API_KEY environment variable not set",
|
|
215
|
+
context={"backend": self.backend},
|
|
216
|
+
)
|
|
217
|
+
self._embedding_backend = OpenAIBackend(
|
|
218
|
+
api_key=api_key, dimension=self.dimension
|
|
219
|
+
)
|
|
220
|
+
except ImportError:
|
|
221
|
+
raise VectorMemoryError(
|
|
222
|
+
"openai package not installed. Install with: pip install openai",
|
|
223
|
+
context={"backend": self.backend},
|
|
224
|
+
) from None
|
|
225
|
+
else:
|
|
226
|
+
raise VectorMemoryError(
|
|
227
|
+
f"Unknown embedding backend: {self.backend}",
|
|
228
|
+
context={
|
|
229
|
+
"backend": self.backend,
|
|
230
|
+
"available": ["simple", "sentence-transformers", "openai"],
|
|
231
|
+
},
|
|
232
|
+
)
|
|
233
|
+
return self._embedding_backend
|
|
234
|
+
|
|
235
|
+
def _cosine_similarity(self, vec1: list[float], vec2: list[float]) -> float:
|
|
236
|
+
"""Calculate cosine similarity between two vectors.
|
|
237
|
+
|
|
238
|
+
Args:
|
|
239
|
+
vec1: First vector
|
|
240
|
+
vec2: Second vector
|
|
241
|
+
|
|
242
|
+
Returns:
|
|
243
|
+
Cosine similarity score between -1 and 1
|
|
244
|
+
"""
|
|
245
|
+
if len(vec1) != len(vec2):
|
|
246
|
+
return 0.0
|
|
247
|
+
|
|
248
|
+
dot_product = sum(a * b for a, b in zip(vec1, vec2, strict=True))
|
|
249
|
+
norm1 = math.sqrt(sum(a * a for a in vec1))
|
|
250
|
+
norm2 = math.sqrt(sum(a * a for a in vec2))
|
|
251
|
+
|
|
252
|
+
if norm1 == 0 or norm2 == 0:
|
|
253
|
+
return 0.0
|
|
254
|
+
|
|
255
|
+
return dot_product / (norm1 * norm2)
|
|
256
|
+
|
|
257
|
+
def _initialize_faiss(self) -> None:
|
|
258
|
+
"""Initialize FAISS index if available and enabled."""
|
|
259
|
+
if not self.use_faiss:
|
|
260
|
+
return
|
|
261
|
+
|
|
262
|
+
try:
|
|
263
|
+
import faiss # noqa: F401
|
|
264
|
+
import numpy as np # noqa: F401
|
|
265
|
+
except ImportError as e:
|
|
266
|
+
# FAISS or numpy not available, fall back to linear search
|
|
267
|
+
self.use_faiss = False
|
|
268
|
+
import logging
|
|
269
|
+
|
|
270
|
+
logger = logging.getLogger(__name__)
|
|
271
|
+
logger.warning(
|
|
272
|
+
f"FAISS not available ({e}). Falling back to linear search. "
|
|
273
|
+
"Install with: pip install faiss-cpu numpy"
|
|
274
|
+
)
|
|
275
|
+
return
|
|
276
|
+
|
|
277
|
+
# Use L2 distance index (we'll convert to cosine similarity)
|
|
278
|
+
self._faiss_index = faiss.IndexFlatL2(self.dimension)
|
|
279
|
+
|
|
280
|
+
async def add(self, text: str, metadata: dict[str, Any] | None = None) -> str:
|
|
281
|
+
"""Add text to vector memory with automatic embedding.
|
|
282
|
+
|
|
283
|
+
Args:
|
|
284
|
+
text: Text to embed and store
|
|
285
|
+
metadata: Optional metadata dictionary
|
|
286
|
+
|
|
287
|
+
Returns:
|
|
288
|
+
ID of stored vector
|
|
289
|
+
"""
|
|
290
|
+
if not text.strip():
|
|
291
|
+
raise VectorMemoryError("Cannot add empty text to vector memory")
|
|
292
|
+
|
|
293
|
+
vector_id = str(uuid.uuid4())
|
|
294
|
+
backend = self._get_embedding_backend()
|
|
295
|
+
|
|
296
|
+
# For simple backend, rebuild vocab with all texts (existing + new)
|
|
297
|
+
if isinstance(backend, SimpleEmbeddingBackend):
|
|
298
|
+
existing_texts = [v["text"] for v in self._vectors.values()]
|
|
299
|
+
all_texts = existing_texts + [text]
|
|
300
|
+
backend._build_vocab(all_texts)
|
|
301
|
+
|
|
302
|
+
# Generate embedding
|
|
303
|
+
embedding = backend.embed(text)
|
|
304
|
+
|
|
305
|
+
# Store vector data
|
|
306
|
+
self._vectors[vector_id] = {
|
|
307
|
+
"text": text,
|
|
308
|
+
"metadata": metadata or {},
|
|
309
|
+
"id": vector_id,
|
|
310
|
+
"embedding_dim": len(embedding),
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
# Store embedding
|
|
314
|
+
self._embeddings[vector_id] = embedding
|
|
315
|
+
|
|
316
|
+
# Add to FAISS index if enabled
|
|
317
|
+
if self.use_faiss:
|
|
318
|
+
if self._faiss_index is None:
|
|
319
|
+
self._initialize_faiss()
|
|
320
|
+
|
|
321
|
+
if self._faiss_index is not None:
|
|
322
|
+
try:
|
|
323
|
+
import faiss # noqa: F401
|
|
324
|
+
import numpy as np
|
|
325
|
+
except ImportError as e:
|
|
326
|
+
raise VectorMemoryError(
|
|
327
|
+
f"FAISS dependencies not available: {e}. Install with: pip install faiss-cpu numpy",
|
|
328
|
+
context={"operation": "add", "use_faiss": True},
|
|
329
|
+
) from e
|
|
330
|
+
|
|
331
|
+
embedding_array = np.array([embedding], dtype=np.float32)
|
|
332
|
+
idx = self._faiss_index.ntotal
|
|
333
|
+
self._faiss_index.add(embedding_array)
|
|
334
|
+
self._id_to_vector_id[idx] = vector_id
|
|
335
|
+
|
|
336
|
+
return vector_id
|
|
337
|
+
|
|
338
|
+
async def add_batch(
|
|
339
|
+
self, texts: list[str], metadata_list: list[dict[str, Any]] | None = None
|
|
340
|
+
) -> list[str]:
|
|
341
|
+
"""Add multiple texts to vector memory efficiently.
|
|
342
|
+
|
|
343
|
+
Args:
|
|
344
|
+
texts: List of texts to embed and store
|
|
345
|
+
metadata_list: Optional list of metadata dictionaries
|
|
346
|
+
|
|
347
|
+
Returns:
|
|
348
|
+
List of vector IDs
|
|
349
|
+
"""
|
|
350
|
+
if not texts:
|
|
351
|
+
return []
|
|
352
|
+
|
|
353
|
+
if metadata_list and len(metadata_list) != len(texts):
|
|
354
|
+
raise VectorMemoryError("metadata_list length must match texts length")
|
|
355
|
+
|
|
356
|
+
backend = self._get_embedding_backend()
|
|
357
|
+
|
|
358
|
+
# For simple backend, rebuild vocab with all texts
|
|
359
|
+
if isinstance(backend, SimpleEmbeddingBackend):
|
|
360
|
+
existing_texts = [v["text"] for v in self._vectors.values()]
|
|
361
|
+
all_texts = existing_texts + texts
|
|
362
|
+
backend._build_vocab(all_texts)
|
|
363
|
+
|
|
364
|
+
# Batch embed
|
|
365
|
+
embeddings = backend.embed_batch(texts)
|
|
366
|
+
|
|
367
|
+
vector_ids = []
|
|
368
|
+
for idx, (text, embedding) in enumerate(zip(texts, embeddings, strict=True)):
|
|
369
|
+
vector_id = str(uuid.uuid4())
|
|
370
|
+
metadata = metadata_list[idx] if metadata_list else None
|
|
371
|
+
|
|
372
|
+
self._vectors[vector_id] = {
|
|
373
|
+
"text": text,
|
|
374
|
+
"metadata": metadata or {},
|
|
375
|
+
"id": vector_id,
|
|
376
|
+
"embedding_dim": len(embedding),
|
|
377
|
+
}
|
|
378
|
+
self._embeddings[vector_id] = embedding
|
|
379
|
+
vector_ids.append(vector_id)
|
|
380
|
+
|
|
381
|
+
# Batch add to FAISS if enabled
|
|
382
|
+
if self.use_faiss and embeddings:
|
|
383
|
+
if self._faiss_index is None:
|
|
384
|
+
self._initialize_faiss()
|
|
385
|
+
|
|
386
|
+
if self._faiss_index is not None:
|
|
387
|
+
try:
|
|
388
|
+
import faiss # noqa: F401
|
|
389
|
+
import numpy as np
|
|
390
|
+
except ImportError as e:
|
|
391
|
+
raise VectorMemoryError(
|
|
392
|
+
f"FAISS dependencies not available: {e}. Install with: pip install faiss-cpu numpy",
|
|
393
|
+
context={"operation": "add_batch", "use_faiss": True},
|
|
394
|
+
) from e
|
|
395
|
+
|
|
396
|
+
embedding_array = np.array(embeddings, dtype=np.float32)
|
|
397
|
+
start_idx = self._faiss_index.ntotal
|
|
398
|
+
self._faiss_index.add(embedding_array)
|
|
399
|
+
|
|
400
|
+
for i, vector_id in enumerate(vector_ids):
|
|
401
|
+
self._id_to_vector_id[start_idx + i] = vector_id
|
|
402
|
+
|
|
403
|
+
return vector_ids
|
|
404
|
+
|
|
405
|
+
async def search(
|
|
406
|
+
self,
|
|
407
|
+
query: str,
|
|
408
|
+
top_k: int = 5,
|
|
409
|
+
filter_metadata: dict[str, Any] | None = None,
|
|
410
|
+
min_similarity: float | None = None,
|
|
411
|
+
) -> list[dict[str, Any]]:
|
|
412
|
+
"""Search for similar vectors using efficient similarity search.
|
|
413
|
+
|
|
414
|
+
Args:
|
|
415
|
+
query: Search query text
|
|
416
|
+
top_k: Number of results to return
|
|
417
|
+
filter_metadata: Optional metadata filter (exact match on keys)
|
|
418
|
+
min_similarity: Minimum similarity threshold (overrides instance default)
|
|
419
|
+
|
|
420
|
+
Returns:
|
|
421
|
+
List of similar vectors with metadata, scores, and distances
|
|
422
|
+
"""
|
|
423
|
+
if not query.strip():
|
|
424
|
+
return []
|
|
425
|
+
|
|
426
|
+
if not self._vectors:
|
|
427
|
+
return []
|
|
428
|
+
|
|
429
|
+
backend = self._get_embedding_backend()
|
|
430
|
+
|
|
431
|
+
# For simple backend, ensure vocab is built from existing vectors
|
|
432
|
+
if isinstance(backend, SimpleEmbeddingBackend) and not backend._vocab:
|
|
433
|
+
existing_texts = [v["text"] for v in self._vectors.values()]
|
|
434
|
+
if existing_texts:
|
|
435
|
+
backend._build_vocab(existing_texts)
|
|
436
|
+
else:
|
|
437
|
+
# No vectors yet, return empty
|
|
438
|
+
return []
|
|
439
|
+
|
|
440
|
+
query_embedding = backend.embed(query)
|
|
441
|
+
|
|
442
|
+
threshold = min_similarity if min_similarity is not None else self.similarity_threshold
|
|
443
|
+
|
|
444
|
+
results = []
|
|
445
|
+
|
|
446
|
+
if self.use_faiss and self._faiss_index is not None:
|
|
447
|
+
# Use FAISS for efficient search
|
|
448
|
+
try:
|
|
449
|
+
import faiss # noqa: F401
|
|
450
|
+
import numpy as np
|
|
451
|
+
except ImportError as e:
|
|
452
|
+
raise VectorMemoryError(
|
|
453
|
+
f"FAISS dependencies not available: {e}. Install with: pip install faiss-cpu numpy",
|
|
454
|
+
context={"operation": "search", "use_faiss": True},
|
|
455
|
+
) from e
|
|
456
|
+
|
|
457
|
+
query_array = np.array([query_embedding], dtype=np.float32)
|
|
458
|
+
k = min(top_k * 2, self._faiss_index.ntotal) # Get more candidates for filtering
|
|
459
|
+
|
|
460
|
+
if k > 0:
|
|
461
|
+
distances, indices = self._faiss_index.search(query_array, k)
|
|
462
|
+
|
|
463
|
+
for dist, idx in zip(distances[0], indices[0], strict=True):
|
|
464
|
+
vector_id = self._id_to_vector_id.get(idx)
|
|
465
|
+
if vector_id is None:
|
|
466
|
+
continue
|
|
467
|
+
|
|
468
|
+
vector_data = self._vectors[vector_id]
|
|
469
|
+
vector_embedding = self._embeddings[vector_id]
|
|
470
|
+
|
|
471
|
+
# Convert L2 distance to cosine similarity
|
|
472
|
+
# L2_dist^2 = 2 * (1 - cos_sim)
|
|
473
|
+
# cos_sim = 1 - (L2_dist^2 / 2)
|
|
474
|
+
similarity = 1.0 - (dist * dist / 2.0)
|
|
475
|
+
|
|
476
|
+
if similarity >= threshold:
|
|
477
|
+
# Apply metadata filter
|
|
478
|
+
if filter_metadata:
|
|
479
|
+
if not all(
|
|
480
|
+
vector_data.get("metadata", {}).get(k) == v
|
|
481
|
+
for k, v in filter_metadata.items()
|
|
482
|
+
):
|
|
483
|
+
continue
|
|
484
|
+
|
|
485
|
+
results.append(
|
|
486
|
+
{
|
|
487
|
+
"id": vector_id,
|
|
488
|
+
"text": vector_data["text"],
|
|
489
|
+
"metadata": vector_data["metadata"],
|
|
490
|
+
"score": similarity,
|
|
491
|
+
"distance": dist,
|
|
492
|
+
}
|
|
493
|
+
)
|
|
494
|
+
else:
|
|
495
|
+
# Linear search with cosine similarity
|
|
496
|
+
for vector_id, vector_data in self._vectors.items():
|
|
497
|
+
vector_embedding = self._embeddings[vector_id]
|
|
498
|
+
|
|
499
|
+
# Apply metadata filter first (early exit)
|
|
500
|
+
if filter_metadata:
|
|
501
|
+
if not all(
|
|
502
|
+
vector_data.get("metadata", {}).get(k) == v
|
|
503
|
+
for k, v in filter_metadata.items()
|
|
504
|
+
):
|
|
505
|
+
continue
|
|
506
|
+
|
|
507
|
+
# Calculate cosine similarity
|
|
508
|
+
similarity = self._cosine_similarity(query_embedding, vector_embedding)
|
|
509
|
+
|
|
510
|
+
if similarity >= threshold:
|
|
511
|
+
results.append(
|
|
512
|
+
{
|
|
513
|
+
"id": vector_id,
|
|
514
|
+
"text": vector_data["text"],
|
|
515
|
+
"metadata": vector_data["metadata"],
|
|
516
|
+
"score": similarity,
|
|
517
|
+
"distance": 1.0 - similarity, # Convert similarity to distance
|
|
518
|
+
}
|
|
519
|
+
)
|
|
520
|
+
|
|
521
|
+
# Sort by score descending and return top_k
|
|
522
|
+
results.sort(key=lambda x: x["score"], reverse=True)
|
|
523
|
+
return results[:top_k]
|
|
524
|
+
|
|
525
|
+
async def delete(self, vector_id: str) -> bool:
|
|
526
|
+
"""Delete a vector from memory.
|
|
527
|
+
|
|
528
|
+
Args:
|
|
529
|
+
vector_id: ID of vector to delete
|
|
530
|
+
|
|
531
|
+
Returns:
|
|
532
|
+
True if deleted, False if not found
|
|
533
|
+
"""
|
|
534
|
+
if vector_id not in self._vectors:
|
|
535
|
+
return False
|
|
536
|
+
|
|
537
|
+
del self._vectors[vector_id]
|
|
538
|
+
del self._embeddings[vector_id]
|
|
539
|
+
|
|
540
|
+
# Note: FAISS doesn't support deletion efficiently, so we'd need to rebuild
|
|
541
|
+
# For now, we'll just mark it as needing rebuild
|
|
542
|
+
if self.use_faiss:
|
|
543
|
+
# In production, you'd want to rebuild the index periodically
|
|
544
|
+
pass
|
|
545
|
+
|
|
546
|
+
return True
|
|
547
|
+
|
|
548
|
+
async def get(self, vector_id: str) -> dict[str, Any] | None:
|
|
549
|
+
"""Get vector data by ID.
|
|
550
|
+
|
|
551
|
+
Args:
|
|
552
|
+
vector_id: Vector ID
|
|
553
|
+
|
|
554
|
+
Returns:
|
|
555
|
+
Vector data or None if not found
|
|
556
|
+
"""
|
|
557
|
+
return self._vectors.get(vector_id)
|
|
558
|
+
|
|
559
|
+
def save(self, path: Path | str) -> None:
|
|
560
|
+
"""Save vector memory to disk.
|
|
561
|
+
|
|
562
|
+
Args:
|
|
563
|
+
path: Path to save to
|
|
564
|
+
"""
|
|
565
|
+
path = Path(path)
|
|
566
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
567
|
+
|
|
568
|
+
data = {
|
|
569
|
+
"vectors": self._vectors,
|
|
570
|
+
"embeddings": self._embeddings,
|
|
571
|
+
"dimension": self.dimension,
|
|
572
|
+
"backend": self.backend,
|
|
573
|
+
"id_to_vector_id": self._id_to_vector_id,
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
with open(path, "wb") as f:
|
|
577
|
+
pickle.dump(data, f)
|
|
578
|
+
|
|
579
|
+
@classmethod
|
|
580
|
+
def load(cls, path: Path | str, **kwargs: Any) -> "VectorMemory":
|
|
581
|
+
"""Load vector memory from disk.
|
|
582
|
+
|
|
583
|
+
Args:
|
|
584
|
+
path: Path to load from
|
|
585
|
+
**kwargs: Additional initialization parameters
|
|
586
|
+
|
|
587
|
+
Returns:
|
|
588
|
+
Loaded VectorMemory instance
|
|
589
|
+
"""
|
|
590
|
+
path = Path(path)
|
|
591
|
+
if not path.exists():
|
|
592
|
+
raise VectorMemoryError(f"Vector memory file not found: {path}")
|
|
593
|
+
|
|
594
|
+
with open(path, "rb") as f:
|
|
595
|
+
data = pickle.load(f)
|
|
596
|
+
|
|
597
|
+
instance = cls(
|
|
598
|
+
dimension=data.get("dimension", 384), backend=data.get("backend", "simple"), **kwargs
|
|
599
|
+
)
|
|
600
|
+
|
|
601
|
+
instance._vectors = data.get("vectors", {})
|
|
602
|
+
instance._embeddings = data.get("embeddings", {})
|
|
603
|
+
instance._id_to_vector_id = data.get("id_to_vector_id", {})
|
|
604
|
+
|
|
605
|
+
# Rebuild vocabulary for SimpleEmbeddingBackend if needed
|
|
606
|
+
if instance.backend == "simple" and instance._vectors:
|
|
607
|
+
backend = instance._get_embedding_backend()
|
|
608
|
+
if isinstance(backend, SimpleEmbeddingBackend):
|
|
609
|
+
existing_texts = [v["text"] for v in instance._vectors.values()]
|
|
610
|
+
if existing_texts:
|
|
611
|
+
backend._build_vocab(existing_texts)
|
|
612
|
+
|
|
613
|
+
# Rebuild FAISS index if needed
|
|
614
|
+
if instance.use_faiss and instance._embeddings:
|
|
615
|
+
instance._initialize_faiss()
|
|
616
|
+
if instance._faiss_index is not None:
|
|
617
|
+
try:
|
|
618
|
+
import faiss # noqa: F401
|
|
619
|
+
import numpy as np
|
|
620
|
+
except ImportError as e:
|
|
621
|
+
raise VectorMemoryError(
|
|
622
|
+
f"FAISS dependencies not available: {e}. Install with: pip install faiss-cpu numpy",
|
|
623
|
+
context={"operation": "load", "use_faiss": True},
|
|
624
|
+
) from e
|
|
625
|
+
|
|
626
|
+
embeddings_list = [instance._embeddings[vid] for vid in instance._vectors.keys()]
|
|
627
|
+
if embeddings_list:
|
|
628
|
+
embedding_array = np.array(embeddings_list, dtype=np.float32)
|
|
629
|
+
instance._faiss_index.add(embedding_array)
|
|
630
|
+
|
|
631
|
+
return instance
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
class SentenceTransformerBackend(EmbeddingBackend):
|
|
635
|
+
"""Sentence transformers embedding backend."""
|
|
636
|
+
|
|
637
|
+
def __init__(self, model: Any) -> None:
|
|
638
|
+
"""Initialize with sentence transformer model.
|
|
639
|
+
|
|
640
|
+
Args:
|
|
641
|
+
model: SentenceTransformer model instance
|
|
642
|
+
"""
|
|
643
|
+
self.model = model
|
|
644
|
+
|
|
645
|
+
def embed(self, text: str) -> list[float]:
|
|
646
|
+
"""Generate embedding using sentence transformer."""
|
|
647
|
+
embedding = self.model.encode(text, convert_to_numpy=True)
|
|
648
|
+
result = embedding.tolist()
|
|
649
|
+
return [float(x) for x in result]
|
|
650
|
+
|
|
651
|
+
def embed_batch(self, texts: list[str]) -> list[list[float]]:
|
|
652
|
+
"""Batch embed using sentence transformer."""
|
|
653
|
+
embeddings = self.model.encode(texts, convert_to_numpy=True)
|
|
654
|
+
return [[float(x) for x in e.tolist()] for e in embeddings]
|
|
655
|
+
|
|
656
|
+
@property
|
|
657
|
+
def dimension(self) -> int:
|
|
658
|
+
"""Return embedding dimension."""
|
|
659
|
+
return int(self.model.get_sentence_embedding_dimension())
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
class OpenAIBackend(EmbeddingBackend):
|
|
663
|
+
"""OpenAI embedding backend."""
|
|
664
|
+
|
|
665
|
+
def __init__(
|
|
666
|
+
self, api_key: str, dimension: int = 1536, model: str = "text-embedding-3-small"
|
|
667
|
+
) -> None:
|
|
668
|
+
"""Initialize OpenAI embedding backend.
|
|
669
|
+
|
|
670
|
+
Args:
|
|
671
|
+
api_key: OpenAI API key
|
|
672
|
+
dimension: Expected embedding dimension
|
|
673
|
+
model: OpenAI embedding model name
|
|
674
|
+
"""
|
|
675
|
+
try:
|
|
676
|
+
from openai import OpenAI
|
|
677
|
+
except ImportError:
|
|
678
|
+
raise VectorMemoryError(
|
|
679
|
+
"openai package not installed. Install with: pip install openai",
|
|
680
|
+
context={"backend": "openai"},
|
|
681
|
+
) from None
|
|
682
|
+
|
|
683
|
+
self.client = OpenAI(api_key=api_key)
|
|
684
|
+
self.model = model
|
|
685
|
+
self._dimension = dimension
|
|
686
|
+
|
|
687
|
+
def embed(self, text: str) -> list[float]:
|
|
688
|
+
"""Generate embedding using OpenAI API."""
|
|
689
|
+
response = self.client.embeddings.create(model=self.model, input=text)
|
|
690
|
+
return list(response.data[0].embedding)
|
|
691
|
+
|
|
692
|
+
def embed_batch(self, texts: list[str]) -> list[list[float]]:
|
|
693
|
+
"""Batch embed using OpenAI API."""
|
|
694
|
+
response = self.client.embeddings.create(model=self.model, input=texts)
|
|
695
|
+
return [list(item.embedding) for item in response.data]
|
|
696
|
+
|
|
697
|
+
@property
|
|
698
|
+
def dimension(self) -> int:
|
|
699
|
+
"""Return embedding dimension."""
|
|
700
|
+
return self._dimension
|