concierge-graph 3.8.2__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.
- agents/__init__.py +14 -0
- agents/revisor_critico.py +610 -0
- concierge_graph-3.8.2.dist-info/METADATA +327 -0
- concierge_graph-3.8.2.dist-info/RECORD +37 -0
- concierge_graph-3.8.2.dist-info/WHEEL +5 -0
- concierge_graph-3.8.2.dist-info/entry_points.txt +3 -0
- concierge_graph-3.8.2.dist-info/licenses/LICENSE +21 -0
- concierge_graph-3.8.2.dist-info/top_level.txt +8 -0
- core/__init__.py +23 -0
- core/config.py +192 -0
- core/hybrid_search.py +288 -0
- core/memory_extractor.py +245 -0
- core/middleware.py +723 -0
- core/probabilistic_retriever.py +99 -0
- core/project_index.py +316 -0
- core/vector_backend.py +471 -0
- ingestion/__init__.py +25 -0
- ingestion/crawler.py +722 -0
- ingestion/orchestrator.py +920 -0
- ingestion/parser.py +984 -0
- ingestion/summarizer.py +948 -0
- interface/__init__.py +16 -0
- interface/action_hooks.py +261 -0
- interface/cli.py +391 -0
- interface/mcp_server.py +1737 -0
- main.py +281 -0
- scripts/bootstrap_core_memory.py +93 -0
- services/__init__.py +13 -0
- services/janitor.py +762 -0
- storage/__init__.py +31 -0
- storage/base_backend.py +241 -0
- storage/connection.py +310 -0
- storage/logic.py +703 -0
- storage/schema.py +390 -0
- storage/semantic_logic.py +125 -0
- storage/store.py +802 -0
- storage/vector_store.py +759 -0
storage/vector_store.py
ADDED
|
@@ -0,0 +1,759 @@
|
|
|
1
|
+
"""
|
|
2
|
+
storage/vector_store.py — Grafo Concierge v3.8.0 (Absolute Solidity)
|
|
3
|
+
|
|
4
|
+
Concrete vector backend based on ChromaDB (default) with support for:
|
|
5
|
+
- Model Tiering (Flash vs Elite) for cost optimization
|
|
6
|
+
- Batch Processing for massive ingestion (concierge mine)
|
|
7
|
+
- Reconciliation Loop (verify_sync) to eliminate orphan vectors
|
|
8
|
+
- Semantic Fallback (embedding failed → log + skip, without blocking pipeline)
|
|
9
|
+
- Vector search with scores ready for Hybrid Search / Reranking
|
|
10
|
+
|
|
11
|
+
Dependency: chromadb>=0.4.0 (pip install chromadb)
|
|
12
|
+
|
|
13
|
+
Architecture:
|
|
14
|
+
EmbeddingManager → Generates embeddings with Flash/Elite tiering + fallback
|
|
15
|
+
ChromaVectorStore → Implements BaseVectorBackend on ChromaDB
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import logging
|
|
21
|
+
import math
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any, Optional
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger("grafo-concierge.vector-store")
|
|
26
|
+
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
# Defensive import of ChromaDB — allows importing the module without the dependency
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
try:
|
|
32
|
+
import chromadb
|
|
33
|
+
from chromadb.config import Settings as ChromaSettings
|
|
34
|
+
CHROMADB_AVAILABLE = True
|
|
35
|
+
except ImportError:
|
|
36
|
+
CHROMADB_AVAILABLE = False
|
|
37
|
+
logger.warning(
|
|
38
|
+
"chromadb not installed. Install with: pip install chromadb>=0.4.0"
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
# ---------------------------------------------------------------------------
|
|
42
|
+
# Defensive import of SentenceTransformers (local Flash model)
|
|
43
|
+
# ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
from sentence_transformers import SentenceTransformer
|
|
47
|
+
SENTENCE_TRANSFORMERS_AVAILABLE = True
|
|
48
|
+
except ImportError:
|
|
49
|
+
SENTENCE_TRANSFORMERS_AVAILABLE = False
|
|
50
|
+
logger.warning(
|
|
51
|
+
"sentence-transformers not installed. Tier FLASH unavailable. "
|
|
52
|
+
"Install with: pip install sentence-transformers"
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
from storage.base_backend import (
|
|
56
|
+
BaseVectorBackend,
|
|
57
|
+
EmbeddingTier,
|
|
58
|
+
VectorSearchResult,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
# ---------------------------------------------------------------------------
|
|
62
|
+
# Vector module exceptions
|
|
63
|
+
# ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
class EmbeddingError(Exception):
|
|
66
|
+
"""Error during embedding generation (model not available, invalid input)."""
|
|
67
|
+
|
|
68
|
+
class VectorStoreNotAvailableError(Exception):
|
|
69
|
+
"""ChromaDB is not installed or accessible."""
|
|
70
|
+
|
|
71
|
+
# ---------------------------------------------------------------------------
|
|
72
|
+
# EmbeddingManager — embedding generation with tiering and fallback
|
|
73
|
+
# ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
class EmbeddingManager:
|
|
76
|
+
"""Generates embeddings with support for Model Tiering.
|
|
77
|
+
|
|
78
|
+
Tiers:
|
|
79
|
+
FLASH: all-MiniLM-L6-v2 (local, 384 dims, free)
|
|
80
|
+
ELITE: text-embedding-3-small (OpenAI API, 1536 dims, paid)
|
|
81
|
+
|
|
82
|
+
Semantic Fallback:
|
|
83
|
+
If generation fails, returns None + logs the error.
|
|
84
|
+
The caller decides whether to skip the item or abort.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
tier: EmbeddingTier.FLASH or EmbeddingTier.ELITE.
|
|
88
|
+
openai_api_key: OpenAI API key (required for tier ELITE).
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
# Configurations per tier
|
|
92
|
+
TIER_CONFIG: dict[EmbeddingTier, dict] = {
|
|
93
|
+
EmbeddingTier.FLASH: {
|
|
94
|
+
"model_name": "all-MiniLM-L6-v2",
|
|
95
|
+
"dimensions": 384,
|
|
96
|
+
"provider": "local",
|
|
97
|
+
},
|
|
98
|
+
EmbeddingTier.ELITE: {
|
|
99
|
+
"model_name": "text-embedding-3-small",
|
|
100
|
+
"dimensions": 1536,
|
|
101
|
+
"provider": "openai",
|
|
102
|
+
},
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
def __init__(
|
|
106
|
+
self,
|
|
107
|
+
tier: EmbeddingTier = EmbeddingTier.FLASH,
|
|
108
|
+
openai_api_key: Optional[str] = None,
|
|
109
|
+
) -> None:
|
|
110
|
+
self._tier = tier
|
|
111
|
+
self._config = self.TIER_CONFIG[tier]
|
|
112
|
+
self._dimensions = self._config["dimensions"]
|
|
113
|
+
self._model: Any = None
|
|
114
|
+
self._openai_key = openai_api_key
|
|
115
|
+
|
|
116
|
+
logger.info(
|
|
117
|
+
"EmbeddingManager initialized: tier=%s, model=%s, dims=%d",
|
|
118
|
+
tier.value, self._config["model_name"], self._dimensions
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def dimensions(self) -> int:
|
|
123
|
+
"""Number of dimensions of the active model."""
|
|
124
|
+
return self._dimensions
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def tier(self) -> EmbeddingTier:
|
|
128
|
+
"""Current tier (FLASH or ELITE)."""
|
|
129
|
+
return self._tier
|
|
130
|
+
|
|
131
|
+
def _load_model(self) -> None:
|
|
132
|
+
"""Loads the embedding model (lazy loading).
|
|
133
|
+
|
|
134
|
+
Raises:
|
|
135
|
+
EmbeddingError: If the model cannot be loaded.
|
|
136
|
+
"""
|
|
137
|
+
if self._model is not None:
|
|
138
|
+
return
|
|
139
|
+
|
|
140
|
+
import os
|
|
141
|
+
if os.environ.get("GRAFO_LIGHTWEIGHT_MODE", "false").lower() == "true":
|
|
142
|
+
raise EmbeddingError("Lightweight mode active - local model disabled.")
|
|
143
|
+
|
|
144
|
+
if self._tier == EmbeddingTier.FLASH:
|
|
145
|
+
if not SENTENCE_TRANSFORMERS_AVAILABLE:
|
|
146
|
+
raise EmbeddingError(
|
|
147
|
+
"sentence-transformers not installed. "
|
|
148
|
+
"Install with: pip install sentence-transformers"
|
|
149
|
+
)
|
|
150
|
+
try:
|
|
151
|
+
self._model = SentenceTransformer(self._config["model_name"])
|
|
152
|
+
logger.info("FLASH model loaded: %s", self._config["model_name"])
|
|
153
|
+
except Exception as e:
|
|
154
|
+
raise EmbeddingError(f"Failed to load FLASH model: {e}") from e
|
|
155
|
+
|
|
156
|
+
elif self._tier == EmbeddingTier.ELITE:
|
|
157
|
+
if not self._openai_key:
|
|
158
|
+
raise EmbeddingError(
|
|
159
|
+
"openai_api_key is required for the ELITE tier."
|
|
160
|
+
)
|
|
161
|
+
try:
|
|
162
|
+
import openai
|
|
163
|
+
self._model = openai.OpenAI(api_key=self._openai_key)
|
|
164
|
+
logger.info("OpenAI client initialized for ELITE tier.")
|
|
165
|
+
except ImportError:
|
|
166
|
+
raise EmbeddingError(
|
|
167
|
+
"openai not installed. Install with: pip install openai>=1.0"
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
def embed(self, text: str) -> Optional[list[float]]:
|
|
171
|
+
"""Generates embedding for a text.
|
|
172
|
+
|
|
173
|
+
Semantic Fallback: returns None in case of error (does not block the pipeline).
|
|
174
|
+
|
|
175
|
+
Args:
|
|
176
|
+
text: Text to generate embedding.
|
|
177
|
+
|
|
178
|
+
Returns:
|
|
179
|
+
List of floats (vector) or None if it failed.
|
|
180
|
+
"""
|
|
181
|
+
try:
|
|
182
|
+
self._load_model()
|
|
183
|
+
except EmbeddingError as e:
|
|
184
|
+
logger.error("Model unavailable for embed(): %s", e)
|
|
185
|
+
return None
|
|
186
|
+
|
|
187
|
+
try:
|
|
188
|
+
if self._tier == EmbeddingTier.FLASH:
|
|
189
|
+
vector = self._model.encode(text).tolist()
|
|
190
|
+
return vector
|
|
191
|
+
|
|
192
|
+
elif self._tier == EmbeddingTier.ELITE:
|
|
193
|
+
response = self._model.embeddings.create(
|
|
194
|
+
model=self._config["model_name"],
|
|
195
|
+
input=text,
|
|
196
|
+
)
|
|
197
|
+
return response.data[0].embedding
|
|
198
|
+
|
|
199
|
+
except Exception as e:
|
|
200
|
+
# SEMANTIC FALLBACK: logs the error but DOES NOT propagate
|
|
201
|
+
logger.error(
|
|
202
|
+
"Semantic Fallback activated — embed failed for text (%.40s...): %s",
|
|
203
|
+
text, e
|
|
204
|
+
)
|
|
205
|
+
return None
|
|
206
|
+
|
|
207
|
+
def embed_batch(self, texts: list[str]) -> list[Optional[list[float]]]:
|
|
208
|
+
"""Generates embeddings in batch.
|
|
209
|
+
|
|
210
|
+
Semantic Fallback applied individually: failing items
|
|
211
|
+
return None in the corresponding position.
|
|
212
|
+
|
|
213
|
+
Args:
|
|
214
|
+
texts: List of texts to generate embeddings.
|
|
215
|
+
|
|
216
|
+
Returns:
|
|
217
|
+
List of vectors (or None for items that failed).
|
|
218
|
+
"""
|
|
219
|
+
try:
|
|
220
|
+
self._load_model()
|
|
221
|
+
except EmbeddingError as e:
|
|
222
|
+
logger.error("Model unavailable for embed_batch(): %s", e)
|
|
223
|
+
return [None] * len(texts)
|
|
224
|
+
|
|
225
|
+
results: list[Optional[list[float]]] = []
|
|
226
|
+
|
|
227
|
+
if self._tier == EmbeddingTier.FLASH:
|
|
228
|
+
try:
|
|
229
|
+
vectors = self._model.encode(texts)
|
|
230
|
+
return [v.tolist() for v in vectors]
|
|
231
|
+
except Exception as e:
|
|
232
|
+
logger.error("Semantic Fallback (batch FLASH): %s", e)
|
|
233
|
+
return [None] * len(texts)
|
|
234
|
+
|
|
235
|
+
elif self._tier == EmbeddingTier.ELITE:
|
|
236
|
+
# OpenAI supports native batch
|
|
237
|
+
try:
|
|
238
|
+
response = self._model.embeddings.create(
|
|
239
|
+
model=self._config["model_name"],
|
|
240
|
+
input=texts,
|
|
241
|
+
)
|
|
242
|
+
return [item.embedding for item in response.data]
|
|
243
|
+
except Exception as e:
|
|
244
|
+
logger.error("Semantic Fallback (batch ELITE): %s", e)
|
|
245
|
+
return [None] * len(texts)
|
|
246
|
+
|
|
247
|
+
return results
|
|
248
|
+
|
|
249
|
+
# ---------------------------------------------------------------------------
|
|
250
|
+
# ChromaVectorStore — concrete implementation of BaseVectorBackend
|
|
251
|
+
# ---------------------------------------------------------------------------
|
|
252
|
+
|
|
253
|
+
class ChromaVectorStore(BaseVectorBackend):
|
|
254
|
+
"""Vector backend based on ChromaDB.
|
|
255
|
+
|
|
256
|
+
Implements all methods of BaseVectorBackend:
|
|
257
|
+
- store_embedding / store_embeddings_batch (Batch Processing)
|
|
258
|
+
- search (Top-K with scores for Hybrid Search)
|
|
259
|
+
- delete / delete_batch
|
|
260
|
+
- verify_sync (Reconciliation Loop)
|
|
261
|
+
- health_check / count
|
|
262
|
+
|
|
263
|
+
Args:
|
|
264
|
+
persist_dir: Persistence directory of ChromaDB.
|
|
265
|
+
collection_name: Name of the collection (default: 'grafo_concierge').
|
|
266
|
+
embedding_manager: Instance of EmbeddingManager to generate embeddings.
|
|
267
|
+
"""
|
|
268
|
+
|
|
269
|
+
# Maximum size of each batch for ChromaDB (avoids OOM in massive projects)
|
|
270
|
+
BATCH_SIZE: int = 100
|
|
271
|
+
|
|
272
|
+
def __init__(
|
|
273
|
+
self,
|
|
274
|
+
persist_dir: str | None = None,
|
|
275
|
+
collection_name: str = "grafo_concierge",
|
|
276
|
+
embedding_manager: Optional[EmbeddingManager] = None,
|
|
277
|
+
) -> None:
|
|
278
|
+
if persist_dir is None:
|
|
279
|
+
persist_dir = str(Path(__file__).parent.parent.resolve() / "data" / "chroma")
|
|
280
|
+
self._embedding_mgr = embedding_manager or EmbeddingManager()
|
|
281
|
+
self._collection_name = collection_name
|
|
282
|
+
|
|
283
|
+
import os
|
|
284
|
+
if os.environ.get("GRAFO_LIGHTWEIGHT_MODE", "false").lower() == "true":
|
|
285
|
+
logger.info("ChromaVectorStore operating in NO-OP mode (Lightweight Mode active).")
|
|
286
|
+
self._available = False
|
|
287
|
+
self._client = None
|
|
288
|
+
self._collection = None
|
|
289
|
+
return
|
|
290
|
+
|
|
291
|
+
self._available = CHROMADB_AVAILABLE
|
|
292
|
+
|
|
293
|
+
if not CHROMADB_AVAILABLE:
|
|
294
|
+
logger.warning(
|
|
295
|
+
"ChromaVectorStore operating in NO-OP mode (chromadb not installed)."
|
|
296
|
+
)
|
|
297
|
+
self._client = None
|
|
298
|
+
self._collection = None
|
|
299
|
+
return
|
|
300
|
+
|
|
301
|
+
resolved_dir = str(Path(persist_dir).expanduser().absolute())
|
|
302
|
+
Path(resolved_dir).mkdir(parents=True, exist_ok=True)
|
|
303
|
+
|
|
304
|
+
self._client = chromadb.PersistentClient(
|
|
305
|
+
path=resolved_dir,
|
|
306
|
+
settings=ChromaSettings(anonymized_telemetry=False),
|
|
307
|
+
)
|
|
308
|
+
self._collection = self._client.get_or_create_collection(
|
|
309
|
+
name=collection_name,
|
|
310
|
+
metadata={"hnsw:space": "cosine"},
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
logger.info(
|
|
314
|
+
"ChromaVectorStore initialized: dir=%s, collection=%s, tier=%s",
|
|
315
|
+
resolved_dir, collection_name, self._embedding_mgr.tier.value
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
# ===================================================================
|
|
319
|
+
# STORE — individual storage
|
|
320
|
+
# ===================================================================
|
|
321
|
+
|
|
322
|
+
def store_embedding(
|
|
323
|
+
self,
|
|
324
|
+
doc_id: str,
|
|
325
|
+
embedding: list[float],
|
|
326
|
+
metadata: dict,
|
|
327
|
+
) -> None:
|
|
328
|
+
"""Stores an embedding with metadata in ChromaDB.
|
|
329
|
+
|
|
330
|
+
Args:
|
|
331
|
+
doc_id: Unique ID (recommended format: 'node_{node_id}').
|
|
332
|
+
embedding: Embedding vector.
|
|
333
|
+
metadata: Dict with 'node_id' (int) and 'project_uuid' (str).
|
|
334
|
+
|
|
335
|
+
Raises:
|
|
336
|
+
ValueError: If metadata does not contain required fields.
|
|
337
|
+
"""
|
|
338
|
+
if not self._available:
|
|
339
|
+
logger.warning("store_embedding ignored: ChromaDB unavailable.")
|
|
340
|
+
return
|
|
341
|
+
|
|
342
|
+
self._validate_metadata(metadata)
|
|
343
|
+
|
|
344
|
+
# ChromaDB requires all metadata values to be str, int or float
|
|
345
|
+
safe_meta = self._sanitize_metadata(metadata)
|
|
346
|
+
|
|
347
|
+
self._collection.upsert(
|
|
348
|
+
ids=[doc_id],
|
|
349
|
+
embeddings=[embedding],
|
|
350
|
+
metadatas=[safe_meta],
|
|
351
|
+
)
|
|
352
|
+
logger.debug("Embedding stored: doc_id=%s, node_id=%s", doc_id, metadata.get("node_id"))
|
|
353
|
+
|
|
354
|
+
# ===================================================================
|
|
355
|
+
# STORE BATCH — batch insertion
|
|
356
|
+
# ===================================================================
|
|
357
|
+
|
|
358
|
+
def store_embeddings_batch(self, items: list[dict]) -> int:
|
|
359
|
+
"""Stores multiple embeddings in controlled batches.
|
|
360
|
+
|
|
361
|
+
Processes in chunks of BATCH_SIZE to avoid OOM.
|
|
362
|
+
Items with embedding=None (Semantic Fallback) are ignored silently.
|
|
363
|
+
|
|
364
|
+
Args:
|
|
365
|
+
items: List of dicts, each containing:
|
|
366
|
+
- doc_id (str)
|
|
367
|
+
- embedding (list[float] or None)
|
|
368
|
+
- metadata (dict with node_id and project_uuid)
|
|
369
|
+
|
|
370
|
+
Returns:
|
|
371
|
+
Number of embeddings effectively stored.
|
|
372
|
+
"""
|
|
373
|
+
if not self._available:
|
|
374
|
+
logger.warning("store_embeddings_batch ignored: ChromaDB unavailable.")
|
|
375
|
+
return 0
|
|
376
|
+
|
|
377
|
+
stored = 0
|
|
378
|
+
# Filters items with valid embedding
|
|
379
|
+
valid_items = [
|
|
380
|
+
item for item in items
|
|
381
|
+
if item.get("embedding") is not None
|
|
382
|
+
]
|
|
383
|
+
|
|
384
|
+
skipped = len(items) - len(valid_items)
|
|
385
|
+
if skipped > 0:
|
|
386
|
+
logger.warning(
|
|
387
|
+
"Batch store: %d items ignored (Semantic Fallback — embedding None)", skipped
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
# Processes in chunks
|
|
391
|
+
for i in range(0, len(valid_items), self.BATCH_SIZE):
|
|
392
|
+
chunk = valid_items[i:i + self.BATCH_SIZE]
|
|
393
|
+
|
|
394
|
+
ids = []
|
|
395
|
+
embeddings = []
|
|
396
|
+
metadatas = []
|
|
397
|
+
|
|
398
|
+
for item in chunk:
|
|
399
|
+
try:
|
|
400
|
+
self._validate_metadata(item["metadata"])
|
|
401
|
+
ids.append(item["doc_id"])
|
|
402
|
+
embeddings.append(item["embedding"])
|
|
403
|
+
metadatas.append(self._sanitize_metadata(item["metadata"]))
|
|
404
|
+
except (ValueError, KeyError) as e:
|
|
405
|
+
logger.warning("Item ignored in batch (invalid metadata): %s", e)
|
|
406
|
+
continue
|
|
407
|
+
|
|
408
|
+
if ids:
|
|
409
|
+
self._collection.upsert(
|
|
410
|
+
ids=ids,
|
|
411
|
+
embeddings=embeddings,
|
|
412
|
+
metadatas=metadatas,
|
|
413
|
+
)
|
|
414
|
+
stored += len(ids)
|
|
415
|
+
logger.debug("Batch chunk stored: %d embeddings", len(ids))
|
|
416
|
+
|
|
417
|
+
logger.info("Batch store finished: %d/%d embeddings stored.", stored, len(items))
|
|
418
|
+
return stored
|
|
419
|
+
|
|
420
|
+
# ===================================================================
|
|
421
|
+
# SEARCH — vector search for Hybrid Search
|
|
422
|
+
# ===================================================================
|
|
423
|
+
|
|
424
|
+
def search(
|
|
425
|
+
self,
|
|
426
|
+
query_embedding: list[float],
|
|
427
|
+
project_uuids: list[str],
|
|
428
|
+
top_k: int = 10,
|
|
429
|
+
filters: Optional[dict] = None,
|
|
430
|
+
) -> list[VectorSearchResult]:
|
|
431
|
+
"""Search by cosine similarity with Strict Scoping per project.
|
|
432
|
+
|
|
433
|
+
Results are returned with normalized scores [0, 1],
|
|
434
|
+
ready for the Hybrid Search / Reranking pipeline.
|
|
435
|
+
|
|
436
|
+
Args:
|
|
437
|
+
query_embedding: Vector of the query.
|
|
438
|
+
project_uuids: List of UUIDs to filter (Strict Scoping).
|
|
439
|
+
top_k: Maximum results.
|
|
440
|
+
filters: Additional filters (e.g. {'node_type': 'FACT'}).
|
|
441
|
+
|
|
442
|
+
Returns:
|
|
443
|
+
List of VectorSearchResult sorted by score DESC.
|
|
444
|
+
"""
|
|
445
|
+
if not self._available:
|
|
446
|
+
logger.warning("search ignored: ChromaDB unavailable.")
|
|
447
|
+
return []
|
|
448
|
+
|
|
449
|
+
# Builds the ChromaDB filter
|
|
450
|
+
where_filter = self._build_where_filter(project_uuids, filters)
|
|
451
|
+
|
|
452
|
+
try:
|
|
453
|
+
results = self._collection.query(
|
|
454
|
+
query_embeddings=[query_embedding],
|
|
455
|
+
n_results=top_k,
|
|
456
|
+
where=where_filter if where_filter else None,
|
|
457
|
+
include=["metadatas", "distances"],
|
|
458
|
+
)
|
|
459
|
+
except Exception as e:
|
|
460
|
+
logger.error("Error in vector search: %s", e)
|
|
461
|
+
return []
|
|
462
|
+
|
|
463
|
+
# ChromaDB returns distances (less = more similar for cosine)
|
|
464
|
+
# We convert to score (greater = more similar): score = 1 - distance
|
|
465
|
+
search_results: list[VectorSearchResult] = []
|
|
466
|
+
|
|
467
|
+
if results and results.get("ids") and results["ids"][0]:
|
|
468
|
+
ids = results["ids"][0]
|
|
469
|
+
distances = results["distances"][0] if results.get("distances") else [0.0] * len(ids)
|
|
470
|
+
metadatas = results["metadatas"][0] if results.get("metadatas") else [{}] * len(ids)
|
|
471
|
+
|
|
472
|
+
for doc_id, distance, meta in zip(ids, distances, metadatas):
|
|
473
|
+
# Cosine distance -> similarity score
|
|
474
|
+
score = max(1.0 - distance, 0.0)
|
|
475
|
+
search_results.append(VectorSearchResult(
|
|
476
|
+
doc_id=doc_id,
|
|
477
|
+
node_id=int(meta.get("node_id", 0)),
|
|
478
|
+
project_uuid=str(meta.get("project_uuid", "")),
|
|
479
|
+
score=round(score, 4),
|
|
480
|
+
metadata=meta,
|
|
481
|
+
))
|
|
482
|
+
|
|
483
|
+
logger.debug("Vector search returned %d results (top_k=%d)", len(search_results), top_k)
|
|
484
|
+
return search_results
|
|
485
|
+
|
|
486
|
+
# ===================================================================
|
|
487
|
+
# DELETE — individual and batch deletion
|
|
488
|
+
# ===================================================================
|
|
489
|
+
|
|
490
|
+
def delete(self, doc_id: str) -> None:
|
|
491
|
+
"""Removes an embedding by doc_id."""
|
|
492
|
+
if not self._available:
|
|
493
|
+
return
|
|
494
|
+
|
|
495
|
+
try:
|
|
496
|
+
self._collection.delete(ids=[doc_id])
|
|
497
|
+
logger.debug("Embedding removed: %s", doc_id)
|
|
498
|
+
except Exception as e:
|
|
499
|
+
logger.warning("Failed to delete embedding %s: %s", doc_id, e)
|
|
500
|
+
|
|
501
|
+
def delete_batch(self, doc_ids: list[str]) -> int:
|
|
502
|
+
"""Removes multiple embeddings in batch.
|
|
503
|
+
|
|
504
|
+
Args:
|
|
505
|
+
doc_ids: List of doc_ids to remove.
|
|
506
|
+
|
|
507
|
+
Returns:
|
|
508
|
+
Number of processed IDs for removal.
|
|
509
|
+
"""
|
|
510
|
+
if not self._available:
|
|
511
|
+
return 0
|
|
512
|
+
|
|
513
|
+
if not doc_ids:
|
|
514
|
+
return 0
|
|
515
|
+
|
|
516
|
+
deleted = 0
|
|
517
|
+
for i in range(0, len(doc_ids), self.BATCH_SIZE):
|
|
518
|
+
chunk = doc_ids[i:i + self.BATCH_SIZE]
|
|
519
|
+
try:
|
|
520
|
+
self._collection.delete(ids=chunk)
|
|
521
|
+
deleted += len(chunk)
|
|
522
|
+
except Exception as e:
|
|
523
|
+
logger.error("Failed to delete batch of %d embeddings: %s", len(chunk), e)
|
|
524
|
+
|
|
525
|
+
logger.info("Batch delete finished: %d/%d removed.", deleted, len(doc_ids))
|
|
526
|
+
return deleted
|
|
527
|
+
|
|
528
|
+
# ===================================================================
|
|
529
|
+
# RECONCILIATION LOOP — verify_sync
|
|
530
|
+
# ===================================================================
|
|
531
|
+
|
|
532
|
+
def verify_sync(self, sqlite_node_ids: set[int]) -> list[str]:
|
|
533
|
+
"""Detects orphan vectors by comparing with the valid SQLite IDs.
|
|
534
|
+
|
|
535
|
+
Paged flow (OOM protection):
|
|
536
|
+
1. Gets only doc_ids from ChromaDB (without heavy metadata).
|
|
537
|
+
2. Iterates in batches of BATCH_SIZE, loading metadata per slice.
|
|
538
|
+
3. Compares node_id of each batch with sqlite_node_ids.
|
|
539
|
+
4. Accumulates doc_ids whose node_id does NOT exist in SQLite.
|
|
540
|
+
|
|
541
|
+
Args:
|
|
542
|
+
sqlite_node_ids: Set of valid node_ids in SQLite.
|
|
543
|
+
|
|
544
|
+
Returns:
|
|
545
|
+
List of orphan doc_ids (exist in vector but not in SQLite).
|
|
546
|
+
"""
|
|
547
|
+
if not self._available:
|
|
548
|
+
return []
|
|
549
|
+
|
|
550
|
+
orphans: list[str] = []
|
|
551
|
+
|
|
552
|
+
try:
|
|
553
|
+
# Phase 1: get only IDs (without heavy metadata in RAM)
|
|
554
|
+
id_data = self._collection.get(include=[])
|
|
555
|
+
all_ids = id_data.get("ids", [])
|
|
556
|
+
|
|
557
|
+
if not all_ids:
|
|
558
|
+
logger.info("Reconciliation Loop: collection empty — nothing to verify.")
|
|
559
|
+
return []
|
|
560
|
+
|
|
561
|
+
# Phase 2: iterate in batches of BATCH_SIZE, loading metadata per slice
|
|
562
|
+
for offset in range(0, len(all_ids), self.BATCH_SIZE):
|
|
563
|
+
batch_ids = all_ids[offset:offset + self.BATCH_SIZE]
|
|
564
|
+
batch_data = self._collection.get(
|
|
565
|
+
ids=batch_ids,
|
|
566
|
+
include=["metadatas"],
|
|
567
|
+
)
|
|
568
|
+
batch_metas = batch_data.get("metadatas", [])
|
|
569
|
+
batch_doc_ids = batch_data.get("ids", batch_ids)
|
|
570
|
+
|
|
571
|
+
for doc_id, meta in zip(batch_doc_ids, batch_metas):
|
|
572
|
+
node_id = meta.get("node_id") if meta else None
|
|
573
|
+
if node_id is None:
|
|
574
|
+
# Metadata corrupted — consider orphan
|
|
575
|
+
orphans.append(doc_id)
|
|
576
|
+
continue
|
|
577
|
+
|
|
578
|
+
if int(node_id) not in sqlite_node_ids:
|
|
579
|
+
orphans.append(doc_id)
|
|
580
|
+
|
|
581
|
+
except Exception as e:
|
|
582
|
+
logger.error("Failed in Reconciliation Loop (verify_sync): %s", e)
|
|
583
|
+
|
|
584
|
+
if orphans:
|
|
585
|
+
logger.warning(
|
|
586
|
+
"Reconciliation Loop: %d orphan vectors detected.", len(orphans)
|
|
587
|
+
)
|
|
588
|
+
else:
|
|
589
|
+
logger.info("Reconciliation Loop: all vectors are synchronized.")
|
|
590
|
+
|
|
591
|
+
return orphans
|
|
592
|
+
|
|
593
|
+
# ===================================================================
|
|
594
|
+
# HEALTH CHECK + COUNT
|
|
595
|
+
# ===================================================================
|
|
596
|
+
|
|
597
|
+
def health_check(self) -> bool:
|
|
598
|
+
"""Verifies if ChromaDB is operational."""
|
|
599
|
+
if not self._available:
|
|
600
|
+
return False
|
|
601
|
+
|
|
602
|
+
try:
|
|
603
|
+
self._client.heartbeat()
|
|
604
|
+
return True
|
|
605
|
+
except Exception as e:
|
|
606
|
+
logger.error("Health check failed: %s", e)
|
|
607
|
+
return False
|
|
608
|
+
|
|
609
|
+
def count(self, project_uuid: Optional[str] = None) -> int:
|
|
610
|
+
"""Returns the total number of stored vectors.
|
|
611
|
+
|
|
612
|
+
Args:
|
|
613
|
+
project_uuid: If provided, counts only vectors of this project.
|
|
614
|
+
"""
|
|
615
|
+
if not self._available:
|
|
616
|
+
return 0
|
|
617
|
+
|
|
618
|
+
try:
|
|
619
|
+
if project_uuid:
|
|
620
|
+
result = self._collection.get(
|
|
621
|
+
where={"project_uuid": project_uuid},
|
|
622
|
+
include=[],
|
|
623
|
+
)
|
|
624
|
+
return len(result.get("ids", []))
|
|
625
|
+
return self._collection.count()
|
|
626
|
+
except Exception as e:
|
|
627
|
+
logger.error("Failed to count vectors: %s", e)
|
|
628
|
+
return 0
|
|
629
|
+
|
|
630
|
+
def reset_collection(self) -> bool:
|
|
631
|
+
"""Destroys and recreates the physical collection of vectors (emergency repair).
|
|
632
|
+
|
|
633
|
+
WARNING: This operation deletes ALL embeddings irreversibly.
|
|
634
|
+
After reset, it will be necessary to re-ingest projects to recreate vectors.
|
|
635
|
+
|
|
636
|
+
Returns:
|
|
637
|
+
True if the operation was successful, False otherwise.
|
|
638
|
+
"""
|
|
639
|
+
if not self._available or self._client is None:
|
|
640
|
+
logger.warning("reset_collection ignored: ChromaDB unavailable.")
|
|
641
|
+
return False
|
|
642
|
+
|
|
643
|
+
try:
|
|
644
|
+
old_count = self._collection.count() if self._collection else 0
|
|
645
|
+
self._client.delete_collection(self._collection_name)
|
|
646
|
+
self._collection = self._client.get_or_create_collection(
|
|
647
|
+
name=self._collection_name,
|
|
648
|
+
metadata={"hnsw:space": "cosine"},
|
|
649
|
+
)
|
|
650
|
+
logger.info(
|
|
651
|
+
"reset_collection OK: collection '%s' destroyed and recreated "
|
|
652
|
+
"(%d embeddings eliminated).",
|
|
653
|
+
self._collection_name, old_count,
|
|
654
|
+
)
|
|
655
|
+
return True
|
|
656
|
+
except Exception as e:
|
|
657
|
+
logger.error("reset_collection FAILED: %s", e)
|
|
658
|
+
return False
|
|
659
|
+
|
|
660
|
+
# ===================================================================
|
|
661
|
+
# AUXILIARY INTERNAL METHODS
|
|
662
|
+
# ===================================================================
|
|
663
|
+
|
|
664
|
+
def update_metadata(self, doc_id: str, metadata: dict) -> None:
|
|
665
|
+
"""Updates the metadata of an existing vector without replacing the embedding.
|
|
666
|
+
|
|
667
|
+
Allows JanitorService to inject community_id and other attributes
|
|
668
|
+
without needing to access self._collection directly.
|
|
669
|
+
|
|
670
|
+
Args:
|
|
671
|
+
doc_id: Document identifier (e.g. 'node_42').
|
|
672
|
+
metadata: Metadata dictionary to apply.
|
|
673
|
+
"""
|
|
674
|
+
if not self._available or self._collection is None:
|
|
675
|
+
logger.debug("update_metadata: ChromaDB unavailable — ignored for %s.", doc_id)
|
|
676
|
+
return
|
|
677
|
+
|
|
678
|
+
try:
|
|
679
|
+
safe_meta = self._sanitize_metadata(metadata)
|
|
680
|
+
self._collection.update(ids=[doc_id], metadatas=[safe_meta])
|
|
681
|
+
logger.debug("Vector metadata updated: doc_id=%s", doc_id)
|
|
682
|
+
except Exception as e:
|
|
683
|
+
logger.error("Failed to update metadata in ChromaDB for %s: %s", doc_id, e)
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
@staticmethod
|
|
687
|
+
def _validate_metadata(metadata: dict) -> None:
|
|
688
|
+
"""Validates that metadata contains required fields."""
|
|
689
|
+
if "node_id" not in metadata:
|
|
690
|
+
raise ValueError("metadata must contain 'node_id' (int).")
|
|
691
|
+
if "project_uuid" not in metadata:
|
|
692
|
+
raise ValueError("metadata must contain 'project_uuid' (str).")
|
|
693
|
+
|
|
694
|
+
@staticmethod
|
|
695
|
+
def _sanitize_metadata(metadata: dict) -> dict:
|
|
696
|
+
"""Ensures that all metadata values are types accepted by ChromaDB.
|
|
697
|
+
|
|
698
|
+
ChromaDB accepts: str, int, float, bool. Lists and dicts are converted to str.
|
|
699
|
+
"""
|
|
700
|
+
safe = {}
|
|
701
|
+
for k, v in metadata.items():
|
|
702
|
+
if isinstance(v, (str, int, float, bool)):
|
|
703
|
+
safe[k] = v
|
|
704
|
+
elif v is None:
|
|
705
|
+
safe[k] = ""
|
|
706
|
+
else:
|
|
707
|
+
safe[k] = str(v)
|
|
708
|
+
return safe
|
|
709
|
+
|
|
710
|
+
@staticmethod
|
|
711
|
+
def _build_where_filter(
|
|
712
|
+
project_uuids: list[str],
|
|
713
|
+
extra_filters: Optional[dict] = None,
|
|
714
|
+
) -> Optional[dict]:
|
|
715
|
+
"""Builds the ChromaDB 'where' filter combining project + extra filters.
|
|
716
|
+
|
|
717
|
+
Args:
|
|
718
|
+
project_uuids: List of UUIDs for Strict Scoping.
|
|
719
|
+
extra_filters: Additional filters (e.g. {'node_type': 'FACT'}).
|
|
720
|
+
|
|
721
|
+
Returns:
|
|
722
|
+
Dict in ChromaDB where format, or None if no filters.
|
|
723
|
+
"""
|
|
724
|
+
conditions: list[dict] = []
|
|
725
|
+
|
|
726
|
+
if project_uuids:
|
|
727
|
+
if len(project_uuids) == 1:
|
|
728
|
+
conditions.append({"project_uuid": project_uuids[0]})
|
|
729
|
+
else:
|
|
730
|
+
conditions.append({"project_uuid": {"$in": project_uuids}})
|
|
731
|
+
|
|
732
|
+
if extra_filters:
|
|
733
|
+
for key, value in extra_filters.items():
|
|
734
|
+
conditions.append({key: value})
|
|
735
|
+
|
|
736
|
+
if not conditions:
|
|
737
|
+
return None
|
|
738
|
+
if len(conditions) == 1:
|
|
739
|
+
return conditions[0]
|
|
740
|
+
return {"$and": conditions}
|
|
741
|
+
|
|
742
|
+
def get_all_stored_node_ids(self) -> set[int]:
|
|
743
|
+
"""Returns all numerical node_ids present in the Chroma collection."""
|
|
744
|
+
if not self._available:
|
|
745
|
+
return set()
|
|
746
|
+
try:
|
|
747
|
+
id_data = self._collection.get(include=[])
|
|
748
|
+
ids = id_data.get("ids", [])
|
|
749
|
+
node_ids = set()
|
|
750
|
+
for doc_id in ids:
|
|
751
|
+
if doc_id.startswith("node_"):
|
|
752
|
+
try:
|
|
753
|
+
node_ids.add(int(doc_id.split("_")[1]))
|
|
754
|
+
except (IndexError, ValueError):
|
|
755
|
+
pass
|
|
756
|
+
return node_ids
|
|
757
|
+
except Exception as e:
|
|
758
|
+
logger.error("Error obtaining stored node_ids in Chroma: %s", e)
|
|
759
|
+
return set()
|