prismlib 0.3.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.
- prism/__init__.py +9 -0
- prism/bridge/__init__.py +32 -0
- prism/bridge/vector.py +704 -0
- prism/cache/__init__.py +54 -0
- prism/cache/cache.py +597 -0
- prism/cache/embedder.py +438 -0
- prism/cache/metrics.py +273 -0
- prism/cache/store.py +370 -0
- prism/ffi/__init__.py +34 -0
- prism/ffi/bindings.py +841 -0
- prism/lib/__init__.py +17 -0
- prism/lib/fabric.py +567 -0
- prism/lib/lang.py +531 -0
- prism/lib/resonance.py +691 -0
- prism/wrapper/__init__.py +49 -0
- prism/wrapper/config.py +116 -0
- prism/wrapper/daemon.py +236 -0
- prism/wrapper/interceptor.py +524 -0
- prism/wrapper/publisher.py +229 -0
- prismlib-0.3.0.dist-info/METADATA +529 -0
- prismlib-0.3.0.dist-info/RECORD +24 -0
- prismlib-0.3.0.dist-info/WHEEL +5 -0
- prismlib-0.3.0.dist-info/entry_points.txt +2 -0
- prismlib-0.3.0.dist-info/top_level.txt +1 -0
prism/bridge/vector.py
ADDED
|
@@ -0,0 +1,704 @@
|
|
|
1
|
+
"""
|
|
2
|
+
prism.bridge.vector — Drop-in Patch Adapters for External Vector Engines
|
|
3
|
+
=========================================================================
|
|
4
|
+
|
|
5
|
+
Implements:
|
|
6
|
+
- TaxonomyCategory: Named category with anchor direction for `prismrag-patch`.
|
|
7
|
+
- PrismRAGPatch: The category taxonomy logic. Intercepts any vector before it
|
|
8
|
+
is saved to a target engine, applies category blending via PrismProjector's
|
|
9
|
+
Spherical Blend, and attaches a `prismrag-patch` metadata tag.
|
|
10
|
+
- VectorStoreAdapter: Abstract base class for all vector engine adapters.
|
|
11
|
+
- PgVectorAdapter: Patch wrapper for PostgreSQL + pgvector extension.
|
|
12
|
+
- ChromaAdapter: Patch wrapper for ChromaDB.
|
|
13
|
+
- QdrantAdapter: Patch wrapper for Qdrant.
|
|
14
|
+
- PatchedVector: The output of PrismRAGPatch — a projected vector + metadata.
|
|
15
|
+
|
|
16
|
+
Architecture note on "bypass HTTP/JSON entirely"
|
|
17
|
+
-------------------------------------------------
|
|
18
|
+
pgvector, Chroma, and Qdrant all expose HTTP or Python client APIs internally.
|
|
19
|
+
The PrismBridge *application-to-bridge* boundary speaks raw float32 CHORUS
|
|
20
|
+
vectors; the bridge-to-engine boundary necessarily speaks the engine's native
|
|
21
|
+
protocol (SQL/HTTP/gRPC). This is the honest architecture: we eliminate the
|
|
22
|
+
serialisation tax at the *app layer*, not at the engine's own wire protocol.
|
|
23
|
+
|
|
24
|
+
The PrismRAGPatch runs inside the bridge container before the call to the
|
|
25
|
+
engine client, so all taxonomy and tenant-isolation logic executes in
|
|
26
|
+
Python-native float32 space — zero JSON encoding of the vector payload.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import abc
|
|
32
|
+
import logging
|
|
33
|
+
import time
|
|
34
|
+
import uuid
|
|
35
|
+
from dataclasses import dataclass, field
|
|
36
|
+
from typing import Any, Optional, Sequence
|
|
37
|
+
|
|
38
|
+
import numpy as np
|
|
39
|
+
|
|
40
|
+
from prism.lib.lang import PayloadEnvelope, PrismProjector, ProjectionConfig
|
|
41
|
+
|
|
42
|
+
logger = logging.getLogger(__name__)
|
|
43
|
+
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
# Errors
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class VectorBridgeError(Exception):
|
|
50
|
+
"""Base error for vector store adapter operations."""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class EngineConnectionError(VectorBridgeError):
|
|
54
|
+
"""Raised when the target vector engine is unreachable."""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class PatchError(VectorBridgeError):
|
|
58
|
+
"""Raised when PrismRAGPatch cannot classify or blend a vector."""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class UpsertError(VectorBridgeError):
|
|
62
|
+
"""Raised when a vector insert/upsert fails in the target engine."""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# ---------------------------------------------------------------------------
|
|
66
|
+
# Taxonomy
|
|
67
|
+
# ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True)
|
|
71
|
+
class TaxonomyCategory:
|
|
72
|
+
"""
|
|
73
|
+
A named category in the prismrag-patch taxonomy.
|
|
74
|
+
|
|
75
|
+
Attributes
|
|
76
|
+
----------
|
|
77
|
+
label:
|
|
78
|
+
Unique category identifier (e.g. "finance", "healthcare", "legal").
|
|
79
|
+
anchor:
|
|
80
|
+
Representative unit vector in the input embedding space. Vectors
|
|
81
|
+
blended toward this anchor acquire the semantic direction of this
|
|
82
|
+
category.
|
|
83
|
+
blend_weight:
|
|
84
|
+
Default alpha for Spherical Blend toward this anchor [0, 1].
|
|
85
|
+
Overrides ProjectionConfig.default_blend_weight for this category.
|
|
86
|
+
description:
|
|
87
|
+
Human-readable category description for audit logs.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
label: str
|
|
91
|
+
anchor: np.ndarray
|
|
92
|
+
blend_weight: float = 0.2
|
|
93
|
+
description: str = ""
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass
|
|
97
|
+
class PatchedVector:
|
|
98
|
+
"""
|
|
99
|
+
Output of PrismRAGPatch.patch() — a projected, taxonomy-tagged vector.
|
|
100
|
+
|
|
101
|
+
Attributes
|
|
102
|
+
----------
|
|
103
|
+
vector:
|
|
104
|
+
Final float32 array of shape (64,), ready for engine upsert.
|
|
105
|
+
category_label:
|
|
106
|
+
The matched taxonomy category label (or "uncategorised").
|
|
107
|
+
category_score:
|
|
108
|
+
Cosine similarity of the input to the matched category anchor.
|
|
109
|
+
envelope:
|
|
110
|
+
Full PayloadEnvelope from PrismProjector, including rule_chain.
|
|
111
|
+
patch_id:
|
|
112
|
+
UUID for deduplication.
|
|
113
|
+
patched_at:
|
|
114
|
+
Unix timestamp.
|
|
115
|
+
metadata:
|
|
116
|
+
Merged metadata dict: caller-supplied + patch provenance.
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
vector: np.ndarray
|
|
120
|
+
category_label: str
|
|
121
|
+
category_score: float
|
|
122
|
+
envelope: PayloadEnvelope
|
|
123
|
+
patch_id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
|
124
|
+
patched_at: float = field(default_factory=time.time)
|
|
125
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
126
|
+
|
|
127
|
+
def engine_metadata(self) -> dict[str, Any]:
|
|
128
|
+
"""Flat metadata dict suitable for passing to any vector engine client."""
|
|
129
|
+
return {
|
|
130
|
+
**self.metadata,
|
|
131
|
+
"prismrag_patch": True,
|
|
132
|
+
"category": self.category_label,
|
|
133
|
+
"category_score": round(self.category_score, 6),
|
|
134
|
+
"tenant_id": self.envelope.tenant_id,
|
|
135
|
+
"envelope_id": self.envelope.envelope_id,
|
|
136
|
+
"patch_id": self.patch_id,
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# ---------------------------------------------------------------------------
|
|
141
|
+
# PrismRAGPatch
|
|
142
|
+
# ---------------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class PrismRAGPatch:
|
|
146
|
+
"""
|
|
147
|
+
Taxonomy classification and blending pipeline.
|
|
148
|
+
|
|
149
|
+
For every incoming vector:
|
|
150
|
+
1. Compute cosine similarity against all registered category anchors.
|
|
151
|
+
2. Select the best-matching category (highest cosine similarity).
|
|
152
|
+
3. Apply Spherical Blend toward the selected anchor via PrismProjector.
|
|
153
|
+
4. Wrap the result in a PatchedVector with `prismrag-patch` metadata.
|
|
154
|
+
|
|
155
|
+
If `min_category_score` is set, vectors that do not match any category
|
|
156
|
+
above this threshold are assigned to "uncategorised" and projected without
|
|
157
|
+
blending.
|
|
158
|
+
"""
|
|
159
|
+
|
|
160
|
+
def __init__(
|
|
161
|
+
self,
|
|
162
|
+
projector: PrismProjector,
|
|
163
|
+
categories: Sequence[TaxonomyCategory],
|
|
164
|
+
min_category_score: float = 0.0,
|
|
165
|
+
) -> None:
|
|
166
|
+
if not categories:
|
|
167
|
+
raise PatchError("PrismRAGPatch requires at least one TaxonomyCategory.")
|
|
168
|
+
|
|
169
|
+
self._projector = projector
|
|
170
|
+
self._categories = list(categories)
|
|
171
|
+
self._min_score = min_category_score
|
|
172
|
+
|
|
173
|
+
# Pre-normalise anchors for fast cosine similarity
|
|
174
|
+
self._anchors: dict[str, np.ndarray] = {}
|
|
175
|
+
for cat in self._categories:
|
|
176
|
+
norm = float(np.linalg.norm(cat.anchor))
|
|
177
|
+
if norm < 1e-8:
|
|
178
|
+
raise PatchError(f"Category '{cat.label}' has a zero anchor vector.")
|
|
179
|
+
self._anchors[cat.label] = (cat.anchor / norm).astype(np.float32)
|
|
180
|
+
|
|
181
|
+
logger.info(
|
|
182
|
+
"PrismRAGPatch: registered %d categories: %s",
|
|
183
|
+
len(categories),
|
|
184
|
+
[c.label for c in categories],
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
def patch(
|
|
188
|
+
self,
|
|
189
|
+
vector: np.ndarray,
|
|
190
|
+
metadata: Optional[dict[str, Any]] = None,
|
|
191
|
+
) -> PatchedVector:
|
|
192
|
+
"""
|
|
193
|
+
Classify and project a single input vector.
|
|
194
|
+
|
|
195
|
+
Parameters
|
|
196
|
+
----------
|
|
197
|
+
vector:
|
|
198
|
+
Input embedding, any dimensionality up to MAX_INPUT_DIM.
|
|
199
|
+
metadata:
|
|
200
|
+
Caller-supplied metadata to merge into the output.
|
|
201
|
+
|
|
202
|
+
Returns
|
|
203
|
+
-------
|
|
204
|
+
PatchedVector with taxonomy annotation and 64-d projected vector.
|
|
205
|
+
"""
|
|
206
|
+
v = np.asarray(vector, dtype=np.float32).ravel()
|
|
207
|
+
norm = float(np.linalg.norm(v))
|
|
208
|
+
if norm < 1e-8:
|
|
209
|
+
raise PatchError("Input vector is zero — cannot classify.")
|
|
210
|
+
v_unit = v / norm
|
|
211
|
+
|
|
212
|
+
# --- Category selection via cosine similarity --------------------
|
|
213
|
+
best_label = "uncategorised"
|
|
214
|
+
best_score = -1.0
|
|
215
|
+
best_weight = 0.0
|
|
216
|
+
|
|
217
|
+
for cat in self._categories:
|
|
218
|
+
anchor = self._anchors[cat.label]
|
|
219
|
+
if len(anchor) != len(v_unit):
|
|
220
|
+
# Anchor dimensionality mismatch — skip (log once)
|
|
221
|
+
logger.warning(
|
|
222
|
+
"PrismRAGPatch: anchor '%s' dim %d != input dim %d — skipped.",
|
|
223
|
+
cat.label, len(anchor), len(v_unit),
|
|
224
|
+
)
|
|
225
|
+
continue
|
|
226
|
+
score = float(np.dot(v_unit, anchor))
|
|
227
|
+
if score > best_score:
|
|
228
|
+
best_score = score
|
|
229
|
+
best_label = cat.label
|
|
230
|
+
best_weight = cat.blend_weight
|
|
231
|
+
|
|
232
|
+
# --- Apply blend or project without blending ---------------------
|
|
233
|
+
# If the best score doesn't meet the minimum threshold, treat as
|
|
234
|
+
# uncategorised and project without any anchor blend.
|
|
235
|
+
if best_label != "uncategorised" and best_score >= self._min_score:
|
|
236
|
+
envelope = self._projector.project(
|
|
237
|
+
v,
|
|
238
|
+
anchor_label=best_label,
|
|
239
|
+
blend_weight=best_weight,
|
|
240
|
+
)
|
|
241
|
+
else:
|
|
242
|
+
best_label = "uncategorised" # reset — threshold not met
|
|
243
|
+
envelope = self._projector.project(v)
|
|
244
|
+
|
|
245
|
+
return PatchedVector(
|
|
246
|
+
vector=envelope.vector,
|
|
247
|
+
category_label=best_label,
|
|
248
|
+
category_score=best_score,
|
|
249
|
+
envelope=envelope,
|
|
250
|
+
metadata=dict(metadata or {}),
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
def patch_batch(
|
|
254
|
+
self,
|
|
255
|
+
vectors: Sequence[np.ndarray],
|
|
256
|
+
metadata_list: Optional[Sequence[Optional[dict[str, Any]]]] = None,
|
|
257
|
+
) -> list[PatchedVector]:
|
|
258
|
+
"""Patch a list of vectors, returning one PatchedVector per input."""
|
|
259
|
+
metas = metadata_list or [None] * len(vectors)
|
|
260
|
+
return [self.patch(v, m) for v, m in zip(vectors, metas)]
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
# ---------------------------------------------------------------------------
|
|
264
|
+
# VectorStoreAdapter (Abstract Base)
|
|
265
|
+
# ---------------------------------------------------------------------------
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
class VectorStoreAdapter(abc.ABC):
|
|
269
|
+
"""
|
|
270
|
+
Abstract contract for a patched vector engine adapter.
|
|
271
|
+
|
|
272
|
+
All implementations run the PrismRAGPatch pipeline before calling the
|
|
273
|
+
engine's native upsert/query API.
|
|
274
|
+
"""
|
|
275
|
+
|
|
276
|
+
def __init__(self, patch: PrismRAGPatch) -> None:
|
|
277
|
+
self._patch = patch
|
|
278
|
+
|
|
279
|
+
@abc.abstractmethod
|
|
280
|
+
async def connect(self) -> None:
|
|
281
|
+
"""Open the engine client connection."""
|
|
282
|
+
|
|
283
|
+
@abc.abstractmethod
|
|
284
|
+
async def close(self) -> None:
|
|
285
|
+
"""Close the engine client connection."""
|
|
286
|
+
|
|
287
|
+
@abc.abstractmethod
|
|
288
|
+
async def upsert(
|
|
289
|
+
self,
|
|
290
|
+
doc_id: str,
|
|
291
|
+
vector: np.ndarray,
|
|
292
|
+
metadata: Optional[dict[str, Any]] = None,
|
|
293
|
+
) -> PatchedVector:
|
|
294
|
+
"""
|
|
295
|
+
Patch `vector` through PrismRAGPatch and upsert it to the engine.
|
|
296
|
+
|
|
297
|
+
Returns the PatchedVector for audit logging.
|
|
298
|
+
"""
|
|
299
|
+
|
|
300
|
+
@abc.abstractmethod
|
|
301
|
+
async def query(
|
|
302
|
+
self,
|
|
303
|
+
vector: np.ndarray,
|
|
304
|
+
top_k: int = 10,
|
|
305
|
+
filter_metadata: Optional[dict[str, Any]] = None,
|
|
306
|
+
) -> list[dict[str, Any]]:
|
|
307
|
+
"""
|
|
308
|
+
Patch `vector` and query the engine for nearest neighbours.
|
|
309
|
+
|
|
310
|
+
Returns a list of result dicts with at least {"id", "score", "metadata"}.
|
|
311
|
+
"""
|
|
312
|
+
|
|
313
|
+
async def __aenter__(self) -> "VectorStoreAdapter":
|
|
314
|
+
await self.connect()
|
|
315
|
+
return self
|
|
316
|
+
|
|
317
|
+
async def __aexit__(self, *_: object) -> None:
|
|
318
|
+
await self.close()
|
|
319
|
+
|
|
320
|
+
def _apply_patch(
|
|
321
|
+
self,
|
|
322
|
+
vector: np.ndarray,
|
|
323
|
+
metadata: Optional[dict[str, Any]] = None,
|
|
324
|
+
) -> PatchedVector:
|
|
325
|
+
"""Run PrismRAGPatch and return the PatchedVector."""
|
|
326
|
+
return self._patch.patch(vector, metadata)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
# ---------------------------------------------------------------------------
|
|
330
|
+
# pgvector adapter
|
|
331
|
+
# ---------------------------------------------------------------------------
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
class PgVectorAdapter(VectorStoreAdapter):
|
|
335
|
+
"""
|
|
336
|
+
Patch adapter for PostgreSQL with the pgvector extension.
|
|
337
|
+
|
|
338
|
+
Requires: pip install asyncpg
|
|
339
|
+
The target table must have been created with:
|
|
340
|
+
CREATE EXTENSION IF NOT EXISTS vector;
|
|
341
|
+
CREATE TABLE {table} (
|
|
342
|
+
id TEXT PRIMARY KEY,
|
|
343
|
+
embedding vector({dim}),
|
|
344
|
+
metadata JSONB,
|
|
345
|
+
category TEXT,
|
|
346
|
+
tenant_id TEXT
|
|
347
|
+
);
|
|
348
|
+
"""
|
|
349
|
+
|
|
350
|
+
def __init__(
|
|
351
|
+
self,
|
|
352
|
+
patch: PrismRAGPatch,
|
|
353
|
+
dsn: str,
|
|
354
|
+
table: str = "prism_vectors",
|
|
355
|
+
dim: int = 64,
|
|
356
|
+
) -> None:
|
|
357
|
+
super().__init__(patch)
|
|
358
|
+
self._dsn = dsn
|
|
359
|
+
self._table = table
|
|
360
|
+
self._dim = dim
|
|
361
|
+
self._conn: Optional[object] = None
|
|
362
|
+
|
|
363
|
+
async def connect(self) -> None:
|
|
364
|
+
try:
|
|
365
|
+
import asyncpg # type: ignore[import]
|
|
366
|
+
|
|
367
|
+
self._conn = await asyncpg.connect(self._dsn)
|
|
368
|
+
# Register the vector type codec
|
|
369
|
+
await self._conn.execute( # type: ignore[attr-defined]
|
|
370
|
+
"CREATE EXTENSION IF NOT EXISTS vector"
|
|
371
|
+
)
|
|
372
|
+
logger.info("PgVectorAdapter: connected, dim=%d, table=%s", self._dim, self._table)
|
|
373
|
+
except ImportError as exc:
|
|
374
|
+
raise EngineConnectionError(
|
|
375
|
+
"asyncpg is required for PgVectorAdapter: pip install asyncpg"
|
|
376
|
+
) from exc
|
|
377
|
+
|
|
378
|
+
async def close(self) -> None:
|
|
379
|
+
if self._conn is not None:
|
|
380
|
+
await self._conn.close() # type: ignore[attr-defined]
|
|
381
|
+
|
|
382
|
+
async def upsert(
|
|
383
|
+
self,
|
|
384
|
+
doc_id: str,
|
|
385
|
+
vector: np.ndarray,
|
|
386
|
+
metadata: Optional[dict[str, Any]] = None,
|
|
387
|
+
) -> PatchedVector:
|
|
388
|
+
import json as _json
|
|
389
|
+
|
|
390
|
+
pv = self._apply_patch(vector, metadata)
|
|
391
|
+
vec_list = pv.vector.tolist() # pgvector driver expects a Python list
|
|
392
|
+
meta_json = _json.dumps(pv.engine_metadata())
|
|
393
|
+
|
|
394
|
+
sql = f"""
|
|
395
|
+
INSERT INTO {self._table} (id, embedding, metadata, category, tenant_id)
|
|
396
|
+
VALUES ($1, $2::vector, $3::jsonb, $4, $5)
|
|
397
|
+
ON CONFLICT (id) DO UPDATE
|
|
398
|
+
SET embedding = EXCLUDED.embedding,
|
|
399
|
+
metadata = EXCLUDED.metadata,
|
|
400
|
+
category = EXCLUDED.category
|
|
401
|
+
""" # noqa: S608
|
|
402
|
+
|
|
403
|
+
try:
|
|
404
|
+
await self._conn.execute( # type: ignore[attr-defined]
|
|
405
|
+
sql, doc_id, vec_list, meta_json, pv.category_label,
|
|
406
|
+
pv.envelope.tenant_id,
|
|
407
|
+
)
|
|
408
|
+
except Exception as exc:
|
|
409
|
+
raise UpsertError(f"PgVector upsert failed for doc_id={doc_id!r}: {exc}") from exc
|
|
410
|
+
|
|
411
|
+
logger.debug("PgVectorAdapter: upserted doc_id=%s category=%s", doc_id, pv.category_label)
|
|
412
|
+
return pv
|
|
413
|
+
|
|
414
|
+
async def query(
|
|
415
|
+
self,
|
|
416
|
+
vector: np.ndarray,
|
|
417
|
+
top_k: int = 10,
|
|
418
|
+
filter_metadata: Optional[dict[str, Any]] = None,
|
|
419
|
+
) -> list[dict[str, Any]]:
|
|
420
|
+
pv = self._apply_patch(vector)
|
|
421
|
+
vec_list = pv.vector.tolist()
|
|
422
|
+
|
|
423
|
+
where = ""
|
|
424
|
+
if filter_metadata:
|
|
425
|
+
# Build a simple JSONB containment filter
|
|
426
|
+
import json as _json
|
|
427
|
+
where = f"WHERE metadata @> '{_json.dumps(filter_metadata)}'::jsonb"
|
|
428
|
+
|
|
429
|
+
sql = f"""
|
|
430
|
+
SELECT id, 1 - (embedding <=> $1::vector) AS score, metadata
|
|
431
|
+
FROM {self._table}
|
|
432
|
+
{where}
|
|
433
|
+
ORDER BY embedding <=> $1::vector
|
|
434
|
+
LIMIT {int(top_k)}
|
|
435
|
+
""" # noqa: S608
|
|
436
|
+
|
|
437
|
+
rows = await self._conn.fetch(sql, vec_list) # type: ignore[attr-defined]
|
|
438
|
+
return [{"id": r["id"], "score": float(r["score"]), "metadata": r["metadata"]} for r in rows]
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
# ---------------------------------------------------------------------------
|
|
442
|
+
# ChromaDB adapter
|
|
443
|
+
# ---------------------------------------------------------------------------
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
class ChromaAdapter(VectorStoreAdapter):
|
|
447
|
+
"""
|
|
448
|
+
Patch adapter for ChromaDB.
|
|
449
|
+
|
|
450
|
+
Requires: pip install chromadb
|
|
451
|
+
"""
|
|
452
|
+
|
|
453
|
+
def __init__(
|
|
454
|
+
self,
|
|
455
|
+
patch: PrismRAGPatch,
|
|
456
|
+
collection_name: str,
|
|
457
|
+
chroma_host: str = "localhost",
|
|
458
|
+
chroma_port: int = 8000,
|
|
459
|
+
use_http: bool = True,
|
|
460
|
+
) -> None:
|
|
461
|
+
super().__init__(patch)
|
|
462
|
+
self._collection_name = collection_name
|
|
463
|
+
self._chroma_host = chroma_host
|
|
464
|
+
self._chroma_port = chroma_port
|
|
465
|
+
self._use_http = use_http
|
|
466
|
+
self._client: Optional[object] = None
|
|
467
|
+
self._collection: Optional[object] = None
|
|
468
|
+
|
|
469
|
+
async def connect(self) -> None:
|
|
470
|
+
try:
|
|
471
|
+
import chromadb # type: ignore[import]
|
|
472
|
+
import asyncio
|
|
473
|
+
|
|
474
|
+
loop = asyncio.get_event_loop()
|
|
475
|
+
|
|
476
|
+
if self._use_http:
|
|
477
|
+
client = await loop.run_in_executor(
|
|
478
|
+
None,
|
|
479
|
+
lambda: chromadb.HttpClient(
|
|
480
|
+
host=self._chroma_host,
|
|
481
|
+
port=self._chroma_port,
|
|
482
|
+
),
|
|
483
|
+
)
|
|
484
|
+
else:
|
|
485
|
+
client = chromadb.Client()
|
|
486
|
+
|
|
487
|
+
self._client = client
|
|
488
|
+
self._collection = await loop.run_in_executor(
|
|
489
|
+
None,
|
|
490
|
+
lambda: client.get_or_create_collection(self._collection_name),
|
|
491
|
+
)
|
|
492
|
+
logger.info("ChromaAdapter: connected, collection=%s", self._collection_name)
|
|
493
|
+
except ImportError as exc:
|
|
494
|
+
raise EngineConnectionError(
|
|
495
|
+
"chromadb is required for ChromaAdapter: pip install chromadb"
|
|
496
|
+
) from exc
|
|
497
|
+
|
|
498
|
+
async def close(self) -> None:
|
|
499
|
+
self._client = None
|
|
500
|
+
self._collection = None
|
|
501
|
+
|
|
502
|
+
async def upsert(
|
|
503
|
+
self,
|
|
504
|
+
doc_id: str,
|
|
505
|
+
vector: np.ndarray,
|
|
506
|
+
metadata: Optional[dict[str, Any]] = None,
|
|
507
|
+
) -> PatchedVector:
|
|
508
|
+
import asyncio
|
|
509
|
+
|
|
510
|
+
if self._collection is None:
|
|
511
|
+
raise EngineConnectionError("Not connected — call connect() first.")
|
|
512
|
+
|
|
513
|
+
pv = self._apply_patch(vector, metadata)
|
|
514
|
+
eng_meta = {k: str(v) for k, v in pv.engine_metadata().items()}
|
|
515
|
+
|
|
516
|
+
loop = asyncio.get_event_loop()
|
|
517
|
+
try:
|
|
518
|
+
await loop.run_in_executor(
|
|
519
|
+
None,
|
|
520
|
+
lambda: self._collection.upsert( # type: ignore[union-attr]
|
|
521
|
+
ids=[doc_id],
|
|
522
|
+
embeddings=[pv.vector.tolist()],
|
|
523
|
+
metadatas=[eng_meta],
|
|
524
|
+
),
|
|
525
|
+
)
|
|
526
|
+
except Exception as exc:
|
|
527
|
+
raise UpsertError(f"ChromaDB upsert failed for doc_id={doc_id!r}: {exc}") from exc
|
|
528
|
+
|
|
529
|
+
logger.debug("ChromaAdapter: upserted doc_id=%s category=%s", doc_id, pv.category_label)
|
|
530
|
+
return pv
|
|
531
|
+
|
|
532
|
+
async def query(
|
|
533
|
+
self,
|
|
534
|
+
vector: np.ndarray,
|
|
535
|
+
top_k: int = 10,
|
|
536
|
+
filter_metadata: Optional[dict[str, Any]] = None,
|
|
537
|
+
) -> list[dict[str, Any]]:
|
|
538
|
+
import asyncio
|
|
539
|
+
|
|
540
|
+
if self._collection is None:
|
|
541
|
+
raise EngineConnectionError("Not connected — call connect() first.")
|
|
542
|
+
|
|
543
|
+
pv = self._apply_patch(vector)
|
|
544
|
+
where = {k: str(v) for k, v in filter_metadata.items()} if filter_metadata else None
|
|
545
|
+
loop = asyncio.get_event_loop()
|
|
546
|
+
|
|
547
|
+
raw = await loop.run_in_executor(
|
|
548
|
+
None,
|
|
549
|
+
lambda: self._collection.query( # type: ignore[union-attr]
|
|
550
|
+
query_embeddings=[pv.vector.tolist()],
|
|
551
|
+
n_results=top_k,
|
|
552
|
+
where=where,
|
|
553
|
+
),
|
|
554
|
+
)
|
|
555
|
+
|
|
556
|
+
results: list[dict[str, Any]] = []
|
|
557
|
+
ids = raw.get("ids", [[]])[0]
|
|
558
|
+
distances = raw.get("distances", [[]])[0]
|
|
559
|
+
metas = raw.get("metadatas", [[]])[0]
|
|
560
|
+
for doc_id, dist, meta in zip(ids, distances, metas):
|
|
561
|
+
results.append({"id": doc_id, "score": 1.0 - dist, "metadata": meta})
|
|
562
|
+
return results
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
# ---------------------------------------------------------------------------
|
|
566
|
+
# Qdrant adapter
|
|
567
|
+
# ---------------------------------------------------------------------------
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
class QdrantAdapter(VectorStoreAdapter):
|
|
571
|
+
"""
|
|
572
|
+
Patch adapter for Qdrant.
|
|
573
|
+
|
|
574
|
+
Requires: pip install qdrant-client
|
|
575
|
+
"""
|
|
576
|
+
|
|
577
|
+
def __init__(
|
|
578
|
+
self,
|
|
579
|
+
patch: PrismRAGPatch,
|
|
580
|
+
collection_name: str,
|
|
581
|
+
qdrant_host: str = "localhost",
|
|
582
|
+
qdrant_port: int = 6333,
|
|
583
|
+
dim: int = 64,
|
|
584
|
+
api_key: Optional[str] = None,
|
|
585
|
+
) -> None:
|
|
586
|
+
super().__init__(patch)
|
|
587
|
+
self._collection_name = collection_name
|
|
588
|
+
self._qdrant_host = qdrant_host
|
|
589
|
+
self._qdrant_port = qdrant_port
|
|
590
|
+
self._dim = dim
|
|
591
|
+
self._api_key = api_key
|
|
592
|
+
self._client: Optional[object] = None
|
|
593
|
+
|
|
594
|
+
async def connect(self) -> None:
|
|
595
|
+
try:
|
|
596
|
+
from qdrant_client import AsyncQdrantClient # type: ignore[import]
|
|
597
|
+
from qdrant_client.models import Distance, VectorParams # type: ignore[import]
|
|
598
|
+
|
|
599
|
+
self._client = AsyncQdrantClient(
|
|
600
|
+
host=self._qdrant_host,
|
|
601
|
+
port=self._qdrant_port,
|
|
602
|
+
api_key=self._api_key,
|
|
603
|
+
)
|
|
604
|
+
# Ensure the collection exists
|
|
605
|
+
existing = await self._client.get_collections() # type: ignore[union-attr]
|
|
606
|
+
existing_names = {c.name for c in existing.collections}
|
|
607
|
+
if self._collection_name not in existing_names:
|
|
608
|
+
await self._client.create_collection( # type: ignore[union-attr]
|
|
609
|
+
collection_name=self._collection_name,
|
|
610
|
+
vectors_config=VectorParams(size=self._dim, distance=Distance.COSINE),
|
|
611
|
+
)
|
|
612
|
+
logger.info(
|
|
613
|
+
"QdrantAdapter: created collection '%s' dim=%d.",
|
|
614
|
+
self._collection_name, self._dim,
|
|
615
|
+
)
|
|
616
|
+
else:
|
|
617
|
+
logger.info("QdrantAdapter: using existing collection '%s'.", self._collection_name)
|
|
618
|
+
|
|
619
|
+
except ImportError as exc:
|
|
620
|
+
raise EngineConnectionError(
|
|
621
|
+
"qdrant-client is required for QdrantAdapter: pip install qdrant-client"
|
|
622
|
+
) from exc
|
|
623
|
+
|
|
624
|
+
async def close(self) -> None:
|
|
625
|
+
if self._client is not None:
|
|
626
|
+
await self._client.close() # type: ignore[attr-defined]
|
|
627
|
+
|
|
628
|
+
async def upsert(
|
|
629
|
+
self,
|
|
630
|
+
doc_id: str,
|
|
631
|
+
vector: np.ndarray,
|
|
632
|
+
metadata: Optional[dict[str, Any]] = None,
|
|
633
|
+
) -> PatchedVector:
|
|
634
|
+
from qdrant_client.models import PointStruct # type: ignore[import]
|
|
635
|
+
|
|
636
|
+
if self._client is None:
|
|
637
|
+
raise EngineConnectionError("Not connected — call connect() first.")
|
|
638
|
+
|
|
639
|
+
pv = self._apply_patch(vector, metadata)
|
|
640
|
+
point = PointStruct(
|
|
641
|
+
id=_str_to_qdrant_id(doc_id),
|
|
642
|
+
vector=pv.vector.tolist(),
|
|
643
|
+
payload=pv.engine_metadata(),
|
|
644
|
+
)
|
|
645
|
+
|
|
646
|
+
try:
|
|
647
|
+
await self._client.upsert( # type: ignore[union-attr]
|
|
648
|
+
collection_name=self._collection_name,
|
|
649
|
+
points=[point],
|
|
650
|
+
)
|
|
651
|
+
except Exception as exc:
|
|
652
|
+
raise UpsertError(f"Qdrant upsert failed for doc_id={doc_id!r}: {exc}") from exc
|
|
653
|
+
|
|
654
|
+
logger.debug("QdrantAdapter: upserted doc_id=%s category=%s", doc_id, pv.category_label)
|
|
655
|
+
return pv
|
|
656
|
+
|
|
657
|
+
async def query(
|
|
658
|
+
self,
|
|
659
|
+
vector: np.ndarray,
|
|
660
|
+
top_k: int = 10,
|
|
661
|
+
filter_metadata: Optional[dict[str, Any]] = None,
|
|
662
|
+
) -> list[dict[str, Any]]:
|
|
663
|
+
from qdrant_client.models import Filter, FieldCondition, MatchValue # type: ignore[import]
|
|
664
|
+
|
|
665
|
+
if self._client is None:
|
|
666
|
+
raise EngineConnectionError("Not connected — call connect() first.")
|
|
667
|
+
|
|
668
|
+
pv = self._apply_patch(vector)
|
|
669
|
+
|
|
670
|
+
qdrant_filter = None
|
|
671
|
+
if filter_metadata:
|
|
672
|
+
conditions = [
|
|
673
|
+
FieldCondition(key=k, match=MatchValue(value=v))
|
|
674
|
+
for k, v in filter_metadata.items()
|
|
675
|
+
]
|
|
676
|
+
qdrant_filter = Filter(must=conditions)
|
|
677
|
+
|
|
678
|
+
hits = await self._client.search( # type: ignore[union-attr]
|
|
679
|
+
collection_name=self._collection_name,
|
|
680
|
+
query_vector=pv.vector.tolist(),
|
|
681
|
+
limit=top_k,
|
|
682
|
+
query_filter=qdrant_filter,
|
|
683
|
+
with_payload=True,
|
|
684
|
+
)
|
|
685
|
+
|
|
686
|
+
return [
|
|
687
|
+
{"id": str(h.id), "score": float(h.score), "metadata": h.payload or {}}
|
|
688
|
+
for h in hits
|
|
689
|
+
]
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
# ---------------------------------------------------------------------------
|
|
693
|
+
# Helpers
|
|
694
|
+
# ---------------------------------------------------------------------------
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
def _str_to_qdrant_id(s: str) -> int:
|
|
698
|
+
"""
|
|
699
|
+
Qdrant point IDs must be unsigned integers or UUIDs.
|
|
700
|
+
We hash arbitrary string IDs to a stable 63-bit integer.
|
|
701
|
+
"""
|
|
702
|
+
import hashlib
|
|
703
|
+
digest = hashlib.sha256(s.encode()).digest()
|
|
704
|
+
return int.from_bytes(digest[:8], "big") & 0x7FFF_FFFF_FFFF_FFFF
|