etchmem 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.
- etchmem/__init__.py +25 -0
- etchmem/config.py +109 -0
- etchmem/connector/__init__.py +0 -0
- etchmem/connector/base.py +35 -0
- etchmem/connector/remote.py +121 -0
- etchmem/consolidate/__init__.py +0 -0
- etchmem/consolidate/worker.py +559 -0
- etchmem/engine.py +191 -0
- etchmem/models.py +147 -0
- etchmem/recall/__init__.py +0 -0
- etchmem/recall/recaller.py +154 -0
- etchmem/stores/__init__.py +0 -0
- etchmem/stores/base.py +171 -0
- etchmem/stores/chroma_store.py +647 -0
- etchmem/util/__init__.py +0 -0
- etchmem/util/affectability.py +104 -0
- etchmem/util/hashing.py +49 -0
- etchmem-0.1.0.dist-info/METADATA +29 -0
- etchmem-0.1.0.dist-info/RECORD +22 -0
- etchmem-0.1.0.dist-info/WHEEL +5 -0
- etchmem-0.1.0.dist-info/licenses/LICENSE +201 -0
- etchmem-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,647 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ChromaStore — the bundled default storage implementation.
|
|
3
|
+
|
|
4
|
+
One PersistentClient, one data directory, three collections.
|
|
5
|
+
All three tiers share one client and one embedding function
|
|
6
|
+
(Chroma's bundled default embedding model — no external API required).
|
|
7
|
+
|
|
8
|
+
The same ChromaStore class is instantiated three times:
|
|
9
|
+
- relational_store = ChromaStore(client, "relational") → used as RelationalStore
|
|
10
|
+
- buffer_store = ChromaStore(client, "buffer") → used as InjectedStore
|
|
11
|
+
- injected_store = ChromaStore(client, "injected") → used as InjectedStore
|
|
12
|
+
|
|
13
|
+
Stores receive TEXT, not pre-computed vectors. ChromaDB handles vectorization.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import time
|
|
19
|
+
import uuid
|
|
20
|
+
from typing import Any, Iterable
|
|
21
|
+
|
|
22
|
+
import chromadb
|
|
23
|
+
from chromadb.config import Settings
|
|
24
|
+
|
|
25
|
+
from etchmem.models import (
|
|
26
|
+
Cluster,
|
|
27
|
+
Deposit,
|
|
28
|
+
InjectedArticle,
|
|
29
|
+
Record,
|
|
30
|
+
RecallEvent,
|
|
31
|
+
SearchResult,
|
|
32
|
+
)
|
|
33
|
+
from etchmem.stores.base import InjectedStore, RelationalStore
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _build_client(data_dir: str) -> chromadb.PersistentClient:
|
|
37
|
+
"""Create or open the shared PersistentClient."""
|
|
38
|
+
return chromadb.PersistentClient(
|
|
39
|
+
path=data_dir,
|
|
40
|
+
settings=Settings(anonymized_telemetry=False),
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ── Optional lightweight embedding function for offline/test use ──────────────
|
|
45
|
+
|
|
46
|
+
def _hash_embed(texts) -> list[list[float]]:
|
|
47
|
+
"""Compute deterministic 64-dim hash embeddings for a list of texts."""
|
|
48
|
+
import hashlib
|
|
49
|
+
results = []
|
|
50
|
+
for text in texts:
|
|
51
|
+
digest = hashlib.sha256(str(text).encode()).digest() # 32 bytes
|
|
52
|
+
# Expand to 64 floats in [0, 1] by repeating the 32-byte digest
|
|
53
|
+
vec = [b / 255.0 for b in digest] + [b / 255.0 for b in digest]
|
|
54
|
+
results.append(vec)
|
|
55
|
+
return results
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class _HashEmbeddingFunction:
|
|
59
|
+
"""
|
|
60
|
+
Deterministic hash-based embedding for offline environments (CI, tests,
|
|
61
|
+
air-gapped deployments). NOT semantic — do not use in production.
|
|
62
|
+
|
|
63
|
+
Produces a 64-dim float vector from SHA-256 bytes of each input text.
|
|
64
|
+
Enabled by passing embedding_function="hash" to build_stores().
|
|
65
|
+
|
|
66
|
+
Implements all methods that Chroma 1.5+ may call on an embedding function.
|
|
67
|
+
Does NOT inherit from EmbeddingFunction (it is a Protocol, not a class).
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
# Called for document inserts
|
|
71
|
+
def __call__(self, input) -> list[list[float]]:
|
|
72
|
+
return _hash_embed(list(input))
|
|
73
|
+
|
|
74
|
+
# Called for query vectors in Chroma 1.5+
|
|
75
|
+
def embed_query(self, input) -> list[list[float]]:
|
|
76
|
+
return _hash_embed(list(input))
|
|
77
|
+
|
|
78
|
+
# Required by Chroma's embedding-function registry / config serialisation
|
|
79
|
+
@classmethod
|
|
80
|
+
def name(cls) -> str:
|
|
81
|
+
return "skillmem-hash-offline"
|
|
82
|
+
|
|
83
|
+
@classmethod
|
|
84
|
+
def build_from_config(cls, config):
|
|
85
|
+
return cls()
|
|
86
|
+
|
|
87
|
+
def get_config(self) -> dict:
|
|
88
|
+
return {}
|
|
89
|
+
|
|
90
|
+
# Silences the "legacy embedding function" deprecation warning in Chroma 1.5
|
|
91
|
+
def is_legacy(self) -> bool:
|
|
92
|
+
return False
|
|
93
|
+
|
|
94
|
+
# Silences the "supported_spaces" deprecation warning in Chroma 1.5
|
|
95
|
+
@staticmethod
|
|
96
|
+
def supported_spaces() -> list[str]:
|
|
97
|
+
return ["cosine", "l2", "ip"]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# ── Helpers ───────────────────────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
def _result_to_search_result(
|
|
103
|
+
doc_id: str,
|
|
104
|
+
document: str,
|
|
105
|
+
distance: float,
|
|
106
|
+
metadata: dict[str, Any],
|
|
107
|
+
weight: float = 1.0,
|
|
108
|
+
source: str = "unknown",
|
|
109
|
+
) -> SearchResult:
|
|
110
|
+
skill = metadata.get("skill") or None
|
|
111
|
+
return SearchResult(
|
|
112
|
+
id=doc_id,
|
|
113
|
+
content=document,
|
|
114
|
+
score=weight * (1.0 - distance), # convert distance → similarity score
|
|
115
|
+
source=source,
|
|
116
|
+
skill=skill,
|
|
117
|
+
metadata={k: v for k, v in metadata.items() if k != "skill"},
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _record_from_chroma(doc_id: str, document: str, metadata: dict) -> Record:
|
|
122
|
+
return Record(
|
|
123
|
+
id=doc_id,
|
|
124
|
+
content=document,
|
|
125
|
+
skill=metadata.get("skill") or None,
|
|
126
|
+
hint=metadata.get("hint"),
|
|
127
|
+
metadata=json.loads(metadata.get("extra_metadata", "{}")),
|
|
128
|
+
created_at=float(metadata.get("created_at", 0)),
|
|
129
|
+
expires_at=float(metadata.get("expires_at", 0)),
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _deposit_from_chroma(doc_id: str, document: str, metadata: dict) -> Deposit:
|
|
134
|
+
return Deposit(
|
|
135
|
+
id=doc_id,
|
|
136
|
+
content=document,
|
|
137
|
+
skill=metadata.get("skill") or None,
|
|
138
|
+
hint=metadata.get("hint"),
|
|
139
|
+
metadata=json.loads(metadata.get("extra_metadata", "{}")),
|
|
140
|
+
created_at=float(metadata.get("created_at", 0)),
|
|
141
|
+
doc_type="deposit",
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _recall_event_from_chroma(doc_id: str, document: str, metadata: dict) -> RecallEvent:
|
|
146
|
+
return RecallEvent(
|
|
147
|
+
id=doc_id,
|
|
148
|
+
query=metadata.get("query", ""),
|
|
149
|
+
relational_snapshot=metadata.get("relational_snapshot", ""),
|
|
150
|
+
skill=metadata.get("skill") or None,
|
|
151
|
+
hint=metadata.get("hint"),
|
|
152
|
+
metadata=json.loads(metadata.get("extra_metadata", "{}")),
|
|
153
|
+
created_at=float(metadata.get("created_at", 0)),
|
|
154
|
+
doc_type="recall_event",
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _injected_from_chroma(doc_id: str, document: str, metadata: dict) -> InjectedArticle:
|
|
159
|
+
source_hashes_raw = metadata.get("source_hashes", "[]")
|
|
160
|
+
try:
|
|
161
|
+
source_hashes = frozenset(json.loads(source_hashes_raw))
|
|
162
|
+
except (json.JSONDecodeError, TypeError):
|
|
163
|
+
source_hashes = frozenset()
|
|
164
|
+
|
|
165
|
+
tags_raw = metadata.get("tags", "{}")
|
|
166
|
+
try:
|
|
167
|
+
tags = json.loads(tags_raw)
|
|
168
|
+
except (json.JSONDecodeError, TypeError):
|
|
169
|
+
tags = {}
|
|
170
|
+
|
|
171
|
+
return InjectedArticle(
|
|
172
|
+
id=doc_id,
|
|
173
|
+
content=document,
|
|
174
|
+
source_hashes=source_hashes,
|
|
175
|
+
skill=metadata.get("skill") or None,
|
|
176
|
+
tags=tags,
|
|
177
|
+
metadata=json.loads(metadata.get("extra_metadata", "{}")),
|
|
178
|
+
created_at=float(metadata.get("created_at", 0)),
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# ── Core ChromaStore ──────────────────────────────────────────────────────────
|
|
183
|
+
|
|
184
|
+
class ChromaStore:
|
|
185
|
+
"""
|
|
186
|
+
Implements both RelationalStore and InjectedStore using a single
|
|
187
|
+
ChromaDB collection.
|
|
188
|
+
|
|
189
|
+
Instantiate once per logical tier (relational / buffer / injected),
|
|
190
|
+
all sharing the same `client`.
|
|
191
|
+
"""
|
|
192
|
+
|
|
193
|
+
def __init__(
|
|
194
|
+
self,
|
|
195
|
+
client: chromadb.PersistentClient,
|
|
196
|
+
collection_name: str,
|
|
197
|
+
ttl_seconds: int = 7 * 24 * 3600,
|
|
198
|
+
embedding_function=None,
|
|
199
|
+
) -> None:
|
|
200
|
+
"""
|
|
201
|
+
Args:
|
|
202
|
+
embedding_function: Optional Chroma-compatible embedding function.
|
|
203
|
+
None (default) → Chroma's bundled ONNX model (requires
|
|
204
|
+
network on first use to download weights).
|
|
205
|
+
Pass embedding_function="hash" to use the offline
|
|
206
|
+
_HashEmbeddingFunction (deterministic but NOT semantic —
|
|
207
|
+
only suitable for tests / CI / air-gapped environments).
|
|
208
|
+
Pass any chromadb EmbeddingFunction subclass for custom use.
|
|
209
|
+
"""
|
|
210
|
+
self._client = client
|
|
211
|
+
self._collection_name = collection_name
|
|
212
|
+
self._ttl_seconds = ttl_seconds
|
|
213
|
+
|
|
214
|
+
ef = None
|
|
215
|
+
if embedding_function == "hash":
|
|
216
|
+
ef = _HashEmbeddingFunction()
|
|
217
|
+
elif embedding_function is not None:
|
|
218
|
+
ef = embedding_function
|
|
219
|
+
|
|
220
|
+
kwargs: dict = {"name": collection_name, "metadata": {"hnsw:space": "cosine"}}
|
|
221
|
+
if ef is not None:
|
|
222
|
+
kwargs["embedding_function"] = ef
|
|
223
|
+
|
|
224
|
+
self._collection = client.get_or_create_collection(**kwargs)
|
|
225
|
+
|
|
226
|
+
# ── RelationalStore interface ─────────────────────────────────────────
|
|
227
|
+
|
|
228
|
+
def add(self, record: Record) -> str:
|
|
229
|
+
"""Insert a Record into this collection. Sets expires_at."""
|
|
230
|
+
now = time.time()
|
|
231
|
+
expires_at = now + self._ttl_seconds
|
|
232
|
+
record.expires_at = expires_at
|
|
233
|
+
record.created_at = record.created_at or now
|
|
234
|
+
|
|
235
|
+
metadata: dict[str, Any] = {
|
|
236
|
+
"skill": record.skill or "",
|
|
237
|
+
"created_at": record.created_at,
|
|
238
|
+
"expires_at": expires_at,
|
|
239
|
+
"extra_metadata": json.dumps(record.metadata),
|
|
240
|
+
"doc_type": "record",
|
|
241
|
+
}
|
|
242
|
+
if record.hint is not None:
|
|
243
|
+
metadata["hint"] = record.hint
|
|
244
|
+
|
|
245
|
+
self._collection.upsert(
|
|
246
|
+
ids=[record.id],
|
|
247
|
+
documents=[record.content],
|
|
248
|
+
metadatas=[metadata],
|
|
249
|
+
)
|
|
250
|
+
return record.id
|
|
251
|
+
|
|
252
|
+
def search(
|
|
253
|
+
self,
|
|
254
|
+
query_text: str,
|
|
255
|
+
top_k: int,
|
|
256
|
+
skill: str | None = None,
|
|
257
|
+
filters: dict[str, Any] | None = None,
|
|
258
|
+
weight: float = 1.0,
|
|
259
|
+
source: str = "relational",
|
|
260
|
+
) -> list[SearchResult]:
|
|
261
|
+
"""ANN search; filters by skill metadata if provided."""
|
|
262
|
+
now = time.time()
|
|
263
|
+
where: dict[str, Any] = {"expires_at": {"$gt": now}}
|
|
264
|
+
if skill:
|
|
265
|
+
where = {"$and": [where, {"skill": {"$eq": skill}}]}
|
|
266
|
+
if filters:
|
|
267
|
+
for k, v in filters.items():
|
|
268
|
+
where = {"$and": [where, {k: {"$eq": v}}]}
|
|
269
|
+
|
|
270
|
+
try:
|
|
271
|
+
results = self._collection.query(
|
|
272
|
+
query_texts=[query_text],
|
|
273
|
+
n_results=min(top_k, max(1, self._collection.count())),
|
|
274
|
+
where=where,
|
|
275
|
+
include=["documents", "metadatas", "distances"],
|
|
276
|
+
)
|
|
277
|
+
except Exception:
|
|
278
|
+
return []
|
|
279
|
+
|
|
280
|
+
out: list[SearchResult] = []
|
|
281
|
+
if not results["ids"] or not results["ids"][0]:
|
|
282
|
+
return out
|
|
283
|
+
for doc_id, doc, dist, meta in zip(
|
|
284
|
+
results["ids"][0],
|
|
285
|
+
results["documents"][0],
|
|
286
|
+
results["distances"][0],
|
|
287
|
+
results["metadatas"][0],
|
|
288
|
+
):
|
|
289
|
+
out.append(
|
|
290
|
+
_result_to_search_result(doc_id, doc, dist, meta, weight=weight, source=source)
|
|
291
|
+
)
|
|
292
|
+
return out
|
|
293
|
+
|
|
294
|
+
def expire(self) -> int:
|
|
295
|
+
"""Delete all records with expires_at < now."""
|
|
296
|
+
now = time.time()
|
|
297
|
+
try:
|
|
298
|
+
# Fetch ids of expired records
|
|
299
|
+
result = self._collection.get(
|
|
300
|
+
where={"expires_at": {"$lt": now}},
|
|
301
|
+
include=[],
|
|
302
|
+
)
|
|
303
|
+
ids = result.get("ids", [])
|
|
304
|
+
if ids:
|
|
305
|
+
self._collection.delete(ids=ids)
|
|
306
|
+
return len(ids)
|
|
307
|
+
except Exception:
|
|
308
|
+
return 0
|
|
309
|
+
|
|
310
|
+
def iter_for_consolidation(
|
|
311
|
+
self,
|
|
312
|
+
num_records: int | str,
|
|
313
|
+
method: str,
|
|
314
|
+
) -> Iterable[Record]:
|
|
315
|
+
"""Yield records for the consolidation worker."""
|
|
316
|
+
now = time.time()
|
|
317
|
+
try:
|
|
318
|
+
result = self._collection.get(
|
|
319
|
+
where={"expires_at": {"$gt": now}},
|
|
320
|
+
include=["documents", "metadatas"],
|
|
321
|
+
)
|
|
322
|
+
except Exception:
|
|
323
|
+
return
|
|
324
|
+
|
|
325
|
+
records = [
|
|
326
|
+
_record_from_chroma(rid, doc, meta)
|
|
327
|
+
for rid, doc, meta in zip(
|
|
328
|
+
result.get("ids", []),
|
|
329
|
+
result.get("documents", []),
|
|
330
|
+
result.get("metadatas", []),
|
|
331
|
+
)
|
|
332
|
+
]
|
|
333
|
+
|
|
334
|
+
# Sort by created_at
|
|
335
|
+
reverse = method.upper() == "LIFO"
|
|
336
|
+
records.sort(key=lambda r: r.created_at, reverse=reverse)
|
|
337
|
+
|
|
338
|
+
if num_records != "all":
|
|
339
|
+
records = records[: int(num_records)]
|
|
340
|
+
|
|
341
|
+
yield from records
|
|
342
|
+
|
|
343
|
+
def count(self) -> int:
|
|
344
|
+
return self._collection.count()
|
|
345
|
+
|
|
346
|
+
def get_by_id(self, record_id: str) -> Record | None:
|
|
347
|
+
try:
|
|
348
|
+
result = self._collection.get(
|
|
349
|
+
ids=[record_id],
|
|
350
|
+
include=["documents", "metadatas"],
|
|
351
|
+
)
|
|
352
|
+
if not result["ids"]:
|
|
353
|
+
return None
|
|
354
|
+
return _record_from_chroma(
|
|
355
|
+
result["ids"][0],
|
|
356
|
+
result["documents"][0],
|
|
357
|
+
result["metadatas"][0],
|
|
358
|
+
)
|
|
359
|
+
except Exception:
|
|
360
|
+
return None
|
|
361
|
+
|
|
362
|
+
# ── InjectedStore interface ───────────────────────────────────────────
|
|
363
|
+
|
|
364
|
+
def upsert(self, items: list[InjectedArticle | Deposit | RecallEvent]) -> None:
|
|
365
|
+
"""Write or overwrite items into this collection."""
|
|
366
|
+
ids, documents, metadatas = [], [], []
|
|
367
|
+
for item in items:
|
|
368
|
+
meta: dict[str, Any] = {
|
|
369
|
+
"created_at": getattr(item, "created_at", time.time()),
|
|
370
|
+
"skill": item.skill or "",
|
|
371
|
+
"extra_metadata": json.dumps(getattr(item, "metadata", {})),
|
|
372
|
+
}
|
|
373
|
+
if isinstance(item, InjectedArticle):
|
|
374
|
+
meta["doc_type"] = "injected"
|
|
375
|
+
meta["source_hashes"] = json.dumps(sorted(item.source_hashes))
|
|
376
|
+
meta["tags"] = json.dumps(item.tags)
|
|
377
|
+
document = item.content
|
|
378
|
+
elif isinstance(item, RecallEvent):
|
|
379
|
+
meta["doc_type"] = "recall_event"
|
|
380
|
+
meta["query"] = item.query
|
|
381
|
+
meta["relational_snapshot"] = item.relational_snapshot
|
|
382
|
+
if item.hint is not None:
|
|
383
|
+
meta["hint"] = item.hint
|
|
384
|
+
# For embedding purposes, use the query + snapshot as the document
|
|
385
|
+
document = f"{item.query}\n{item.relational_snapshot}"
|
|
386
|
+
elif isinstance(item, Deposit):
|
|
387
|
+
meta["doc_type"] = "deposit"
|
|
388
|
+
if item.hint is not None:
|
|
389
|
+
meta["hint"] = item.hint
|
|
390
|
+
document = item.content
|
|
391
|
+
else:
|
|
392
|
+
continue
|
|
393
|
+
|
|
394
|
+
ids.append(item.id)
|
|
395
|
+
documents.append(document)
|
|
396
|
+
metadatas.append(meta)
|
|
397
|
+
|
|
398
|
+
if ids:
|
|
399
|
+
self._collection.upsert(
|
|
400
|
+
ids=ids,
|
|
401
|
+
documents=documents,
|
|
402
|
+
metadatas=metadatas,
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
def query(
|
|
406
|
+
self,
|
|
407
|
+
query_text: str,
|
|
408
|
+
top_k: int,
|
|
409
|
+
filters: dict[str, Any] | None = None,
|
|
410
|
+
weight: float = 1.0,
|
|
411
|
+
source: str = "injected",
|
|
412
|
+
) -> list[SearchResult]:
|
|
413
|
+
"""ANN search this collection."""
|
|
414
|
+
count = self._collection.count()
|
|
415
|
+
if count == 0:
|
|
416
|
+
return []
|
|
417
|
+
|
|
418
|
+
where = None
|
|
419
|
+
if filters:
|
|
420
|
+
clauses = [{"$eq": {k: v}} for k, v in filters.items()] # type: ignore[dict-item]
|
|
421
|
+
# Use simple single-key filter directly
|
|
422
|
+
if len(filters) == 1:
|
|
423
|
+
k, v = next(iter(filters.items()))
|
|
424
|
+
where = {k: {"$eq": v}}
|
|
425
|
+
else:
|
|
426
|
+
where = {"$and": [{k: {"$eq": v}} for k, v in filters.items()]}
|
|
427
|
+
|
|
428
|
+
try:
|
|
429
|
+
results = self._collection.query(
|
|
430
|
+
query_texts=[query_text],
|
|
431
|
+
n_results=min(top_k, count),
|
|
432
|
+
where=where,
|
|
433
|
+
include=["documents", "metadatas", "distances"],
|
|
434
|
+
)
|
|
435
|
+
except Exception:
|
|
436
|
+
return []
|
|
437
|
+
|
|
438
|
+
out: list[SearchResult] = []
|
|
439
|
+
if not results["ids"] or not results["ids"][0]:
|
|
440
|
+
return out
|
|
441
|
+
for doc_id, doc, dist, meta in zip(
|
|
442
|
+
results["ids"][0],
|
|
443
|
+
results["documents"][0],
|
|
444
|
+
results["distances"][0],
|
|
445
|
+
results["metadatas"][0],
|
|
446
|
+
):
|
|
447
|
+
out.append(
|
|
448
|
+
_result_to_search_result(doc_id, doc, dist, meta, weight=weight, source=source)
|
|
449
|
+
)
|
|
450
|
+
return out
|
|
451
|
+
|
|
452
|
+
def delete(self, ids: list[str]) -> None:
|
|
453
|
+
"""Hard-delete by id. Deleting non-existent ids is a no-op."""
|
|
454
|
+
if not ids:
|
|
455
|
+
return
|
|
456
|
+
# Chroma silently ignores missing ids
|
|
457
|
+
try:
|
|
458
|
+
self._collection.delete(ids=ids)
|
|
459
|
+
except Exception:
|
|
460
|
+
pass
|
|
461
|
+
|
|
462
|
+
def exists(self, content_hash: str) -> bool:
|
|
463
|
+
"""Return True if a document with this id exists."""
|
|
464
|
+
try:
|
|
465
|
+
result = self._collection.get(ids=[content_hash], include=[])
|
|
466
|
+
return bool(result.get("ids"))
|
|
467
|
+
except Exception:
|
|
468
|
+
return False
|
|
469
|
+
|
|
470
|
+
def cluster(
|
|
471
|
+
self,
|
|
472
|
+
documents: list[Deposit | RecallEvent],
|
|
473
|
+
branch_threshold: float,
|
|
474
|
+
) -> list[Cluster]:
|
|
475
|
+
"""
|
|
476
|
+
Group documents into Clusters via embedding-based similarity.
|
|
477
|
+
|
|
478
|
+
Implementation: single-link agglomerative clustering using Chroma
|
|
479
|
+
embeddings obtained via query. Each un-clustered document queries
|
|
480
|
+
all others and merges those within the branch_threshold.
|
|
481
|
+
|
|
482
|
+
This is a best-effort, in-process clustering; for v0 correctness
|
|
483
|
+
is prioritised over performance.
|
|
484
|
+
"""
|
|
485
|
+
if not documents:
|
|
486
|
+
return []
|
|
487
|
+
|
|
488
|
+
# Obtain embeddings by doing pairwise queries
|
|
489
|
+
# For simplicity in v0, use a greedy single-pass nearest-neighbour
|
|
490
|
+
# approach: sort by created_at, then greedily assign to clusters.
|
|
491
|
+
# A document joins an existing cluster if it is within branch_threshold
|
|
492
|
+
# distance from ANY member of that cluster (single-link).
|
|
493
|
+
|
|
494
|
+
# We'll use Chroma's query to get distances between documents.
|
|
495
|
+
# First, upsert all documents into a temporary scratch space if they're
|
|
496
|
+
# not already in this collection, then query.
|
|
497
|
+
|
|
498
|
+
clusters: list[list[Deposit | RecallEvent]] = []
|
|
499
|
+
assigned: set[str] = set()
|
|
500
|
+
|
|
501
|
+
# Build a lookup
|
|
502
|
+
doc_map = {d.id: d for d in documents}
|
|
503
|
+
|
|
504
|
+
for doc in documents:
|
|
505
|
+
if doc.id in assigned:
|
|
506
|
+
continue
|
|
507
|
+
|
|
508
|
+
# Query this collection for the closest documents
|
|
509
|
+
doc_text = doc.content if isinstance(doc, Deposit) else f"{doc.query}\n{doc.relational_snapshot}"
|
|
510
|
+
count = self._collection.count()
|
|
511
|
+
if count == 0:
|
|
512
|
+
clusters.append([doc])
|
|
513
|
+
assigned.add(doc.id)
|
|
514
|
+
continue
|
|
515
|
+
|
|
516
|
+
try:
|
|
517
|
+
results = self._collection.query(
|
|
518
|
+
query_texts=[doc_text],
|
|
519
|
+
n_results=min(len(documents), count),
|
|
520
|
+
include=["metadatas", "distances"],
|
|
521
|
+
)
|
|
522
|
+
except Exception:
|
|
523
|
+
clusters.append([doc])
|
|
524
|
+
assigned.add(doc.id)
|
|
525
|
+
continue
|
|
526
|
+
|
|
527
|
+
similar_ids: list[str] = []
|
|
528
|
+
if results["ids"] and results["ids"][0]:
|
|
529
|
+
for rid, dist in zip(results["ids"][0], results["distances"][0]):
|
|
530
|
+
if rid in doc_map and rid not in assigned and dist <= branch_threshold:
|
|
531
|
+
similar_ids.append(rid)
|
|
532
|
+
|
|
533
|
+
cluster_members: list[Deposit | RecallEvent] = [doc]
|
|
534
|
+
assigned.add(doc.id)
|
|
535
|
+
for sid in similar_ids:
|
|
536
|
+
if sid not in assigned:
|
|
537
|
+
cluster_members.append(doc_map[sid])
|
|
538
|
+
assigned.add(sid)
|
|
539
|
+
|
|
540
|
+
clusters.append(cluster_members)
|
|
541
|
+
|
|
542
|
+
# Convert to Cluster objects
|
|
543
|
+
result_clusters: list[Cluster] = []
|
|
544
|
+
for i, members in enumerate(clusters):
|
|
545
|
+
centroid_text = members[0].content if isinstance(members[0], Deposit) else members[0].query
|
|
546
|
+
result_clusters.append(
|
|
547
|
+
Cluster(
|
|
548
|
+
cluster_id=str(uuid.uuid4()),
|
|
549
|
+
documents=members,
|
|
550
|
+
centroid_text=centroid_text,
|
|
551
|
+
)
|
|
552
|
+
)
|
|
553
|
+
return result_clusters
|
|
554
|
+
|
|
555
|
+
def get_all(
|
|
556
|
+
self,
|
|
557
|
+
filters: dict[str, Any] | None = None,
|
|
558
|
+
) -> list[Deposit | RecallEvent | InjectedArticle]:
|
|
559
|
+
"""Return all documents, optionally filtered by metadata."""
|
|
560
|
+
try:
|
|
561
|
+
where = None
|
|
562
|
+
if filters:
|
|
563
|
+
if len(filters) == 1:
|
|
564
|
+
k, v = next(iter(filters.items()))
|
|
565
|
+
where = {k: {"$eq": v}}
|
|
566
|
+
else:
|
|
567
|
+
where = {"$and": [{k: {"$eq": v}} for k, v in filters.items()]}
|
|
568
|
+
|
|
569
|
+
result = self._collection.get(
|
|
570
|
+
where=where,
|
|
571
|
+
include=["documents", "metadatas"],
|
|
572
|
+
)
|
|
573
|
+
except Exception:
|
|
574
|
+
return []
|
|
575
|
+
|
|
576
|
+
out: list[Deposit | RecallEvent | InjectedArticle] = []
|
|
577
|
+
for rid, doc, meta in zip(
|
|
578
|
+
result.get("ids", []),
|
|
579
|
+
result.get("documents", []),
|
|
580
|
+
result.get("metadatas", []),
|
|
581
|
+
):
|
|
582
|
+
doc_type = meta.get("doc_type", "deposit")
|
|
583
|
+
if doc_type == "recall_event":
|
|
584
|
+
out.append(_recall_event_from_chroma(rid, doc, meta))
|
|
585
|
+
elif doc_type == "injected":
|
|
586
|
+
out.append(_injected_from_chroma(rid, doc, meta))
|
|
587
|
+
else:
|
|
588
|
+
out.append(_deposit_from_chroma(rid, doc, meta))
|
|
589
|
+
return out
|
|
590
|
+
|
|
591
|
+
def get_neighbors(
|
|
592
|
+
self,
|
|
593
|
+
query_text: str,
|
|
594
|
+
top_k: int,
|
|
595
|
+
distance_threshold: float,
|
|
596
|
+
) -> list[SearchResult]:
|
|
597
|
+
"""ANN search with a distance cut-off."""
|
|
598
|
+
count = self._collection.count()
|
|
599
|
+
if count == 0:
|
|
600
|
+
return []
|
|
601
|
+
try:
|
|
602
|
+
results = self._collection.query(
|
|
603
|
+
query_texts=[query_text],
|
|
604
|
+
n_results=min(top_k, count),
|
|
605
|
+
include=["documents", "metadatas", "distances"],
|
|
606
|
+
)
|
|
607
|
+
except Exception:
|
|
608
|
+
return []
|
|
609
|
+
|
|
610
|
+
out: list[SearchResult] = []
|
|
611
|
+
if not results["ids"] or not results["ids"][0]:
|
|
612
|
+
return out
|
|
613
|
+
for doc_id, doc, dist, meta in zip(
|
|
614
|
+
results["ids"][0],
|
|
615
|
+
results["documents"][0],
|
|
616
|
+
results["distances"][0],
|
|
617
|
+
results["metadatas"][0],
|
|
618
|
+
):
|
|
619
|
+
if dist <= distance_threshold:
|
|
620
|
+
out.append(
|
|
621
|
+
_result_to_search_result(doc_id, doc, dist, meta, source="injected")
|
|
622
|
+
)
|
|
623
|
+
return out
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
# ── Factory ───────────────────────────────────────────────────────────────────
|
|
627
|
+
|
|
628
|
+
def build_stores(
|
|
629
|
+
data_dir: str,
|
|
630
|
+
collection_relational: str,
|
|
631
|
+
collection_buffer: str,
|
|
632
|
+
collection_injected: str,
|
|
633
|
+
ttl_seconds: int,
|
|
634
|
+
embedding_function=None,
|
|
635
|
+
) -> tuple[ChromaStore, ChromaStore, ChromaStore]:
|
|
636
|
+
"""
|
|
637
|
+
Build the shared PersistentClient and return three ChromaStore instances:
|
|
638
|
+
(relational_store, buffer_store, injected_store).
|
|
639
|
+
|
|
640
|
+
All three collections share the same embedding_function.
|
|
641
|
+
Pass embedding_function="hash" for offline/test use.
|
|
642
|
+
"""
|
|
643
|
+
client = _build_client(data_dir)
|
|
644
|
+
relational = ChromaStore(client, collection_relational, ttl_seconds=ttl_seconds, embedding_function=embedding_function)
|
|
645
|
+
buffer = ChromaStore(client, collection_buffer, ttl_seconds=ttl_seconds, embedding_function=embedding_function)
|
|
646
|
+
injected = ChromaStore(client, collection_injected, ttl_seconds=ttl_seconds, embedding_function=embedding_function)
|
|
647
|
+
return relational, buffer, injected
|
etchmem/util/__init__.py
ADDED
|
File without changes
|