forked from odysseus-dev/odysseus
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmemory_vector.py
More file actions
251 lines (216 loc) · 8.62 KB
/
Copy pathmemory_vector.py
File metadata and controls
251 lines (216 loc) · 8.62 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
"""
memory_vector.py
ChromaDB-backed vector store for memory entries.
Shares the EmbeddingClient with RAG to save memory.
Stores pre-computed embeddings (ChromaDB does not manage embedding).
"""
import logging
from typing import List, Dict, Optional
from src.embedding_lanes import (
LANE_CUSTOM,
LANE_FASTEMBED,
build_embedding_lanes,
collection_name,
dedupe_results,
lane_count,
migrate_legacy_collection,
)
logger = logging.getLogger(__name__)
class MemoryVectorStore:
"""Vector index over memory entries for semantic retrieval."""
COLLECTION_NAME = "odysseus_memories"
def __init__(self, data_dir: str, embedding_model=None):
self._model = embedding_model
self._collection = None
self._lanes = []
self._healthy = False
self._initialize()
def _initialize(self):
try:
self._lanes = build_embedding_lanes(self.COLLECTION_NAME)
if not self._lanes:
raise RuntimeError("No embedding lanes available")
self._healthy = True
self._collection = next(
(lane.collection for lane in self._lanes if lane.name == LANE_FASTEMBED),
self._lanes[0].collection,
)
migrate_legacy_collection(self.COLLECTION_NAME, self._lanes)
logger.info(
"MemoryVectorStore ready (lanes=%s entries=%s)",
[lane.name for lane in self._lanes],
self.count(),
)
except Exception as e:
logger.error(f"MemoryVectorStore init failed: {e}")
@property
def healthy(self) -> bool:
return self._healthy
def _embed(self, texts: List[str]) -> List[List[float]]:
if not self._lanes:
return []
return self._lanes[0].encode(texts)
def count(self) -> int:
"""Return the number of stored vectors."""
if not self._healthy:
return 0
return lane_count(self._lanes)
def _collections_for_delete(self):
collections = []
seen = set()
def add(collection) -> None:
if collection is None:
return
key = getattr(collection, "name", None) or id(collection)
if key in seen:
return
seen.add(key)
collections.append(collection)
for lane in self._lanes:
add(lane.collection)
try:
from src.chroma_client import get_chroma_client
client = get_chroma_client()
for lane_name in (LANE_CUSTOM, LANE_FASTEMBED):
try:
add(client.get_collection(collection_name(self.COLLECTION_NAME, lane_name)))
except Exception:
pass
except Exception:
pass
return collections
def add(self, memory_id: str, text: str):
"""Add a single memory entry to the vector index."""
if not self._healthy:
return
for lane in self._lanes:
try:
existing = lane.collection.get(ids=[memory_id])
if existing["ids"]:
continue
lane.collection.add(
ids=[memory_id],
embeddings=lane.encode([text]),
documents=[text],
metadatas=[{"source": "memory"}],
)
except Exception as e:
logger.warning("memory add failed in %s lane for %s: %s", lane.name, memory_id, e)
def remove(self, memory_id: str):
"""Remove a memory entry. O(1) — no rebuild needed."""
if not self._healthy:
return
for collection in self._collections_for_delete():
try:
collection.delete(ids=[memory_id])
except Exception as e:
logger.warning(f"memory remove {memory_id}: {e}")
def search(self, query: str, k: int = 8) -> List[Dict]:
"""Search for the most relevant memory IDs by semantic similarity.
Returns list of {"memory_id": str, "score": float}.
ChromaDB cosine distance = 1 - cosine_similarity.
We convert back: similarity = 1.0 - distance.
"""
if not self._healthy or self.count() == 0:
return []
out = []
lane_priority = {LANE_CUSTOM: 0, LANE_FASTEMBED: 1}
for lane in self._lanes:
try:
if lane.count() == 0:
continue
results = lane.collection.query(
query_embeddings=lane.encode([query]),
n_results=min(k, lane.count()),
include=["distances"],
)
for idx, mid in enumerate(results["ids"][0]):
distance = results["distances"][0][idx]
out.append({
"memory_id": mid,
"score": round(1.0 - distance, 4),
"embedding_lane": lane.name,
})
except Exception as e:
logger.warning("memory search failed in %s lane: %s", lane.name, e)
out.sort(key=lambda row: (-row["score"], lane_priority.get(row["embedding_lane"], 99)))
return dedupe_results(out, id_key="memory_id", limit=k)
def find_similar(self, text: str, threshold: float = 0.92) -> Optional[str]:
"""Check if a near-duplicate exists. Returns memory_id if found, else None."""
if not self._healthy or self.count() == 0:
return None
for lane in self._lanes:
try:
if lane.count() == 0:
continue
results = lane.collection.query(
query_embeddings=lane.encode([text]),
n_results=1,
include=["distances"],
)
if results["ids"][0]:
distance = results["distances"][0][0]
similarity = 1.0 - distance
if similarity >= threshold:
return results["ids"][0][0]
except Exception as e:
logger.warning("memory similarity search failed in %s lane: %s", lane.name, e)
return None
def rebuild(self, memories: List[Dict]):
"""Rebuild the entire index from a list of memory entries.
Each entry must have 'id' and 'text' keys."""
if not self._healthy:
return
from src.chroma_client import get_chroma_client
client = get_chroma_client()
lane_names = [
self.COLLECTION_NAME,
collection_name(self.COLLECTION_NAME, LANE_CUSTOM),
collection_name(self.COLLECTION_NAME, LANE_FASTEMBED),
]
for name in lane_names:
try:
client.delete_collection(name)
except Exception:
pass
# Explicit rebuilds must start from the supplied memory list, so clear
# legacy unsuffixed collections too.
self._lanes = build_embedding_lanes(self.COLLECTION_NAME)
self._collection = next(
(lane.collection for lane in self._lanes if lane.name == LANE_FASTEMBED),
self._lanes[0].collection if self._lanes else None,
)
texts = []
ids = []
for mem in memories:
text = mem.get("text", "").strip()
mid = mem.get("id", "")
if text and mid:
texts.append(text)
ids.append(mid)
if texts:
# Batch in chunks of 100 to avoid oversized requests
failed_lanes = set()
for i in range(0, len(texts), 100):
batch_texts = texts[i:i + 100]
batch_ids = ids[i:i + 100]
for lane in self._lanes:
if lane.name in failed_lanes:
continue
try:
lane.collection.add(
ids=batch_ids,
embeddings=lane.encode(batch_texts),
documents=batch_texts,
metadatas=[{"source": "memory"}] * len(batch_ids),
)
except Exception as e:
failed_lanes.add(lane.name)
logger.warning("memory rebuild failed in %s lane: %s", lane.name, e)
logger.info(f"MemoryVectorStore rebuilt with {len(ids)} entries across {len(self._lanes)} lanes")
def get_stats(self) -> Dict:
return {
"healthy": self.healthy,
"count": self.count(),
"lanes": [lane.stats() for lane in self._lanes],
}