veccore 0.2.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.
- veccore/__init__.py +56 -0
- veccore/adapters/__init__.py +6 -0
- veccore/adapters/qdrant_store.py +245 -0
- veccore/adapters/sqlite_numpy_store.py +162 -0
- veccore/audit.py +100 -0
- veccore/batch_ingest.py +455 -0
- veccore/core.py +264 -0
- veccore/durable.py +64 -0
- veccore/embedders/__init__.py +48 -0
- veccore/embedders/composite_embedder.py +55 -0
- veccore/embedders/local_embedder.py +68 -0
- veccore/embedders/mock_embedder.py +57 -0
- veccore/embedders/openai_embedder.py +73 -0
- veccore/errors.py +96 -0
- veccore/evaluate.py +97 -0
- veccore/idempotency.py +45 -0
- veccore/numeric.py +12 -0
- veccore/ports.py +142 -0
- veccore/pricing.py +73 -0
- veccore/registry.py +98 -0
- veccore-0.2.0.dist-info/METADATA +111 -0
- veccore-0.2.0.dist-info/RECORD +25 -0
- veccore-0.2.0.dist-info/WHEEL +5 -0
- veccore-0.2.0.dist-info/licenses/LICENSE +21 -0
- veccore-0.2.0.dist-info/top_level.txt +1 -0
veccore/__init__.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""veccore — the ports-and-adapters vector substrate for the estate (keelworks L2).
|
|
2
|
+
|
|
3
|
+
Generic MECHANISMS + clean SEAMS for `embed → index → search → hydrate`. Each consumer builds its domain
|
|
4
|
+
POLICY on top; nothing consumer-specific lives here, and no consumer reinvents embed/index/idempotency.
|
|
5
|
+
|
|
6
|
+
Quick start (a consumer's ENTIRE veccore-side adoption is: register a collection + hydrate from its own SoR):
|
|
7
|
+
|
|
8
|
+
from veccore import (VecCore, CollectionRegistry, CollectionSpec, AuditEnvelope,
|
|
9
|
+
MockEmbedder, SqliteNumpyStore)
|
|
10
|
+
|
|
11
|
+
store = SqliteNumpyStore(path="/tmp/mine.sqlite")
|
|
12
|
+
core = VecCore(registry=CollectionRegistry(),
|
|
13
|
+
stores={"sqlite_numpy": store},
|
|
14
|
+
embedders={"mock": MockEmbedder()})
|
|
15
|
+
core.register(CollectionSpec(name="notes", backend="sqlite_numpy", embedder="mock", dim=64))
|
|
16
|
+
|
|
17
|
+
audit = AuditEnvelope(who="my_tool", why="index notes for reuse search")
|
|
18
|
+
core.embed_upsert("notes", [{"id": "n1", "text": "vector search over notes", "payload": {"src": "n1"}}], audit)
|
|
19
|
+
|
|
20
|
+
hits = core.search("notes", "nearest ideas", k=5, audit=AuditEnvelope(who="my_tool", why="find related"))
|
|
21
|
+
# each hit is {id, score, payload}; hydrate the full record from your own SoR by hit.id
|
|
22
|
+
"""
|
|
23
|
+
from .audit import AuditEnvelope, JsonlObserver, NullObserver, ObserverPort
|
|
24
|
+
from .batch_ingest import BatchIngest, BatchReport
|
|
25
|
+
from .core import Estimate, IngestResult, VecCore
|
|
26
|
+
from .evaluate import bakeoff, recall_at_k, reciprocal_rank, score_queries
|
|
27
|
+
from .embedders import CompositeEmbedder, LocalEmbedder, MockEmbedder, OpenAIEmbedder, make_embedder
|
|
28
|
+
from .errors import (DimMismatch, GateRefused, ModelMismatch, NotSupported, OversizeRefused, Unavailable,
|
|
29
|
+
VeccoreError)
|
|
30
|
+
from .ports import EmbedderPort, GatePort, Hit, Item, NullGate, Point, VectorStorePort
|
|
31
|
+
from .registry import CollectionRegistry, CollectionSpec
|
|
32
|
+
from .adapters import QdrantStore, SqliteNumpyStore
|
|
33
|
+
|
|
34
|
+
__version__ = "0.2.0"
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
# orchestrator + records
|
|
38
|
+
"VecCore", "IngestResult", "Estimate",
|
|
39
|
+
"CollectionRegistry", "CollectionSpec",
|
|
40
|
+
"AuditEnvelope", "ObserverPort", "NullObserver", "JsonlObserver",
|
|
41
|
+
"Item", "Point", "Hit",
|
|
42
|
+
# ports
|
|
43
|
+
"EmbedderPort", "VectorStorePort", "GatePort", "NullGate",
|
|
44
|
+
# embedders
|
|
45
|
+
"MockEmbedder", "OpenAIEmbedder", "LocalEmbedder", "CompositeEmbedder", "make_embedder",
|
|
46
|
+
# backends
|
|
47
|
+
"SqliteNumpyStore", "QdrantStore",
|
|
48
|
+
# batch seam
|
|
49
|
+
"BatchIngest", "BatchReport",
|
|
50
|
+
# measurement seam (choose/fuse embedders on evidence)
|
|
51
|
+
"bakeoff", "score_queries", "recall_at_k", "reciprocal_rank",
|
|
52
|
+
# errors
|
|
53
|
+
"VeccoreError", "Unavailable", "NotSupported", "DimMismatch", "ModelMismatch", "OversizeRefused",
|
|
54
|
+
"GateRefused",
|
|
55
|
+
"__version__",
|
|
56
|
+
]
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""Backend adapters: `sqlite_numpy` (in-process) and `qdrant` (server). Future faiss/pgvector slot in
|
|
2
|
+
here by implementing VectorStorePort. Backend IDs are `sqlite_numpy` / `qdrant`, never `db`."""
|
|
3
|
+
from .qdrant_store import QdrantStore
|
|
4
|
+
from .sqlite_numpy_store import SqliteNumpyStore
|
|
5
|
+
|
|
6
|
+
__all__ = ["SqliteNumpyStore", "QdrantStore"]
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""QdrantStore — the server backend (backend id "qdrant", never "db").
|
|
2
|
+
|
|
3
|
+
For millions of points, an on-disk Qdrant collection with an ANN index is the right substrate (7thsense's
|
|
4
|
+
9.3M-point corpus). SQLite/numpy cannot; a server can. This adapter is the mature 7thsense Qdrant path
|
|
5
|
+
re-expressed on veccore's ports: dim-mismatch refusal, resumable existing-ids scroll, deterministic
|
|
6
|
+
point-ids, and the OPTIONAL filter push-down seam.
|
|
7
|
+
|
|
8
|
+
Capabilities: implements `search(filters=…)` (metadata / ACL push-down — permission enforcement in the
|
|
9
|
+
query, O(index) not O(retrieved)) and is a Batch-ingest target. Does NOT implement `matrix()` — pulling
|
|
10
|
+
millions of vectors client-side is not what a server backend is for; it raises typed NotSupported at that
|
|
11
|
+
seam, exactly as the design intends (the honest negotiable "no").
|
|
12
|
+
|
|
13
|
+
Point ids: Qdrant ids must be int or UUID, but a consumer's id is an arbitrary string. So the point id is
|
|
14
|
+
`uuid5(NAMESPACE_URL, id)` (deterministic ⇒ re-upsert updates in place), and the real id + text_sha are
|
|
15
|
+
carried in the payload under the reserved keys `_vid` / `_text_sha`, stripped from the payload returned to
|
|
16
|
+
the consumer.
|
|
17
|
+
|
|
18
|
+
Transport errors NEVER masquerade as 'missing'. `collection_exists()` separates a genuinely-absent
|
|
19
|
+
collection from a timeout/auth failure: a real absence takes the create/empty path; any transport error
|
|
20
|
+
PROPAGATES, so `ensure` never re-creates an existing collection and `existing_ids`/`stored_shas` never
|
|
21
|
+
report points absent (which would re-embed the whole corpus, breaking the never-re-embed guarantee).
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import uuid
|
|
26
|
+
import warnings
|
|
27
|
+
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
|
28
|
+
|
|
29
|
+
import numpy as np
|
|
30
|
+
|
|
31
|
+
from ..errors import DimMismatch, ModelMismatch, NotSupported, Unavailable
|
|
32
|
+
from ..ports import Hit, Point
|
|
33
|
+
|
|
34
|
+
BACKEND = "qdrant"
|
|
35
|
+
_VID = "_vid" # reserved payload key: the consumer's real string id
|
|
36
|
+
_TEXT_SHA = "_text_sha" # reserved payload key: idempotency sha
|
|
37
|
+
_MODEL = "_model" # reserved payload key: the embedder identity (model guard; Qdrant has no
|
|
38
|
+
# collection-metadata field, so the model is carried on every point and checked
|
|
39
|
+
# via a 1-point read on ensure())
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _point_uuid(vid: str) -> str:
|
|
43
|
+
"""Deterministic Qdrant point id from the consumer's string id (uuid5 over a stable namespace) — so a
|
|
44
|
+
re-upsert of the same id updates in place rather than duplicating. Parsing an id, not a judgement."""
|
|
45
|
+
return str(uuid.uuid5(uuid.NAMESPACE_URL, vid))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class QdrantStore:
|
|
49
|
+
backend = BACKEND
|
|
50
|
+
|
|
51
|
+
def __init__(self, url: str = "http://localhost:6333", timeout: int = 60, client=None):
|
|
52
|
+
self.url = url
|
|
53
|
+
self.timeout = timeout
|
|
54
|
+
self._client = client # injectable for tests; else lazily constructed
|
|
55
|
+
# in-process cache of each collection's model (set by ensure(model=...)) so upsert can stamp
|
|
56
|
+
# `_model` on its points; the DURABLE record is the payload itself, re-read on a fresh process.
|
|
57
|
+
self._model_by_collection = {}
|
|
58
|
+
# count of foreign/legacy points (no reserved _vid) skipped by the last search — for a consumer
|
|
59
|
+
# that wants to observe a mixed/migration collection programmatically (also warned about).
|
|
60
|
+
self.last_search_foreign_skipped = 0
|
|
61
|
+
|
|
62
|
+
def _qc(self):
|
|
63
|
+
if self._client is None:
|
|
64
|
+
try:
|
|
65
|
+
from qdrant_client import QdrantClient
|
|
66
|
+
except ImportError as e:
|
|
67
|
+
raise Unavailable("qdrant-client is not installed — `pip install veccore[qdrant]`",
|
|
68
|
+
code="backend_unavailable") from e
|
|
69
|
+
# check_compatibility=False: server/client minor gap is fine for the stable REST ops used here
|
|
70
|
+
self._client = QdrantClient(url=self.url, timeout=self.timeout, check_compatibility=False)
|
|
71
|
+
return self._client
|
|
72
|
+
|
|
73
|
+
# ── core port ──────────────────────────────────────────────────────────────────────────────────────
|
|
74
|
+
def ensure(self, collection: str, dim: int, model: str = None) -> None:
|
|
75
|
+
from qdrant_client import models as qm
|
|
76
|
+
qc = self._qc()
|
|
77
|
+
# collection_exists() distinguishes "genuinely missing" from a transport/auth error: a timeout or
|
|
78
|
+
# 401 raises and PROPAGATES (fail-safe) rather than being mistaken for "missing" and triggering a
|
|
79
|
+
# create over an existing collection. Only a real absence takes the create path.
|
|
80
|
+
if not qc.collection_exists(collection):
|
|
81
|
+
qc.create_collection(
|
|
82
|
+
collection_name=collection,
|
|
83
|
+
vectors_config=qm.VectorParams(size=dim, distance=qm.Distance.COSINE, on_disk=True))
|
|
84
|
+
if model is not None:
|
|
85
|
+
self._model_by_collection[collection] = model
|
|
86
|
+
return
|
|
87
|
+
info = qc.get_collection(collection)
|
|
88
|
+
size = info.config.params.vectors.size
|
|
89
|
+
if size != dim:
|
|
90
|
+
raise DimMismatch(collection, size, dim) # 7thsense guard, kept as a core invariant
|
|
91
|
+
if model is not None:
|
|
92
|
+
stored = self._first_model(qc, collection) # None if the collection is empty / pre-guard
|
|
93
|
+
if stored is not None and stored != model:
|
|
94
|
+
raise ModelMismatch(collection, stored, model)
|
|
95
|
+
self._model_by_collection[collection] = model
|
|
96
|
+
|
|
97
|
+
def upsert(self, collection: str, points: Sequence[Point]) -> None:
|
|
98
|
+
from qdrant_client import models as qm
|
|
99
|
+
qc = self._qc()
|
|
100
|
+
model = self._model_by_collection.get(collection)
|
|
101
|
+
structs = []
|
|
102
|
+
for p in points:
|
|
103
|
+
payload = dict(p.payload)
|
|
104
|
+
payload[_VID] = p.id
|
|
105
|
+
payload[_TEXT_SHA] = p.text_sha
|
|
106
|
+
if model is not None:
|
|
107
|
+
payload[_MODEL] = model # carries the model identity so ensure() can guard it later
|
|
108
|
+
structs.append(qm.PointStruct(
|
|
109
|
+
id=_point_uuid(p.id),
|
|
110
|
+
vector=np.asarray(p.vector, dtype=np.float32).ravel().tolist(),
|
|
111
|
+
payload=payload))
|
|
112
|
+
qc.upsert(collection_name=collection, wait=True, points=structs)
|
|
113
|
+
|
|
114
|
+
def search(self, collection: str, qvec: np.ndarray, k: int,
|
|
115
|
+
filters: Optional[Dict[str, Any]] = None) -> List[Hit]:
|
|
116
|
+
qc = self._qc()
|
|
117
|
+
flt = self._build_filter(filters) if filters else None
|
|
118
|
+
res = qc.query_points(
|
|
119
|
+
collection_name=collection,
|
|
120
|
+
query=np.asarray(qvec, dtype=np.float32).ravel().tolist(),
|
|
121
|
+
limit=k, query_filter=flt, with_payload=True).points
|
|
122
|
+
out = []
|
|
123
|
+
foreign = 0
|
|
124
|
+
for p in res:
|
|
125
|
+
payload = dict(p.payload or {})
|
|
126
|
+
vid = payload.pop(_VID, None)
|
|
127
|
+
payload.pop(_TEXT_SHA, None)
|
|
128
|
+
payload.pop(_MODEL, None) # veccore-internal — never surfaced to the consumer
|
|
129
|
+
if vid is None:
|
|
130
|
+
# a legacy/foreign point (not written by veccore, so no reserved _vid). SKIP it rather than
|
|
131
|
+
# emit Hit(id=None) — a null id crashes a consumer doing int(hit.id); a mixed/migration
|
|
132
|
+
# collection must degrade safely, not poison the result set. Counted + warned, never silent.
|
|
133
|
+
foreign += 1
|
|
134
|
+
continue
|
|
135
|
+
out.append(Hit(id=vid, score=float(p.score), payload=payload))
|
|
136
|
+
self.last_search_foreign_skipped = foreign
|
|
137
|
+
if foreign:
|
|
138
|
+
warnings.warn(
|
|
139
|
+
f"veccore.QdrantStore.search: skipped {foreign} foreign point(s) with no {_VID} in "
|
|
140
|
+
f"collection {collection!r} (legacy/non-veccore points, e.g. a migration in progress) — "
|
|
141
|
+
f"not returned as null ids. Rebuild the collection through veccore to clear them.",
|
|
142
|
+
stacklevel=2)
|
|
143
|
+
return out
|
|
144
|
+
|
|
145
|
+
def delete(self, collection: str, ids: Sequence[str]) -> int:
|
|
146
|
+
"""Remove points by id; return the count confirmed removed. Idempotent (absent ids → 0).
|
|
147
|
+
|
|
148
|
+
The DELETE is unconditional over the FULL requested set — Qdrant no-ops ids that are absent, so the
|
|
149
|
+
deletion always completes and can never leave a requested id half-deleted regardless of any lookup.
|
|
150
|
+
Qdrant's delete does not report a removed-count, so the returned number comes from a separate
|
|
151
|
+
pre-read (`retrieve`) of how many requested ids were present; that count is best-effort and only
|
|
152
|
+
ever CONSERVATIVE (a transient partial read under-reports, never over-reports) — the state change
|
|
153
|
+
does not depend on it, so a conservative count never corresponds to data left behind."""
|
|
154
|
+
from qdrant_client import models as qm
|
|
155
|
+
ids = list(ids)
|
|
156
|
+
if not ids:
|
|
157
|
+
return 0
|
|
158
|
+
qc = self._qc()
|
|
159
|
+
if not qc.collection_exists(collection):
|
|
160
|
+
return 0
|
|
161
|
+
puids = [_point_uuid(i) for i in ids]
|
|
162
|
+
confirmed = len(qc.retrieve(collection_name=collection, ids=puids,
|
|
163
|
+
with_payload=False, with_vectors=False))
|
|
164
|
+
qc.delete(collection_name=collection, wait=True, # full requested set; absent ids are a no-op
|
|
165
|
+
points_selector=qm.PointIdsList(points=puids))
|
|
166
|
+
return confirmed
|
|
167
|
+
|
|
168
|
+
def existing_ids(self, collection: str) -> set:
|
|
169
|
+
return set(self._scroll_field(collection, _VID).keys())
|
|
170
|
+
|
|
171
|
+
def stored_shas(self, collection: str) -> Dict[str, str]:
|
|
172
|
+
return self._scroll_field(collection, _TEXT_SHA)
|
|
173
|
+
|
|
174
|
+
def capabilities(self) -> Dict[str, bool]:
|
|
175
|
+
return {"matrix": False, "filters": True, "batch": True}
|
|
176
|
+
|
|
177
|
+
# ── optional capability seam (unimplemented here → honest typed NotSupported) ──────────────────────
|
|
178
|
+
def matrix(self, collection: str) -> Tuple[List[str], np.ndarray]:
|
|
179
|
+
raise NotSupported(
|
|
180
|
+
"matrix (materialize all vectors for X @ Xᵀ analytics)", self.backend,
|
|
181
|
+
"qdrant does not implement matrix() — pulling a whole collection client-side defeats the "
|
|
182
|
+
"server ANN index. Use the sqlite_numpy backend for in-process matrix analytics, or run the "
|
|
183
|
+
"analysis as server-side searches.")
|
|
184
|
+
|
|
185
|
+
# ── internals ────────────────────────────────────────────────────────────────────────────────────
|
|
186
|
+
def _first_model(self, qc, collection: str):
|
|
187
|
+
"""The model identity carried by any one point (they all carry the same), or None if the
|
|
188
|
+
collection is empty / predates the guard. A transport error PROPAGATES (not caught) so a failed
|
|
189
|
+
read never silently skips the model guard."""
|
|
190
|
+
points, _off = qc.scroll(collection_name=collection, limit=1,
|
|
191
|
+
with_payload=[_MODEL], with_vectors=False)
|
|
192
|
+
for p in points:
|
|
193
|
+
return (p.payload or {}).get(_MODEL)
|
|
194
|
+
return None
|
|
195
|
+
|
|
196
|
+
def _scroll_field(self, collection: str, field: str) -> Dict[str, str]:
|
|
197
|
+
"""Scroll the whole collection, payload-only, collecting {_vid: field_value}. This is what makes
|
|
198
|
+
an ingest RESUMABLE (a point already present is never re-embedded).
|
|
199
|
+
|
|
200
|
+
A genuinely-absent collection returns {} (nothing stored yet). A transport/auth error PROPAGATES
|
|
201
|
+
— it must NOT read as 'no points', because that would make an ingest treat every existing point as
|
|
202
|
+
new and re-embed the whole corpus (the never-re-embed guarantee broken, real money burned)."""
|
|
203
|
+
qc = self._qc()
|
|
204
|
+
if not qc.collection_exists(collection):
|
|
205
|
+
return {}
|
|
206
|
+
out: Dict[str, str] = {}
|
|
207
|
+
offset = None
|
|
208
|
+
while True:
|
|
209
|
+
points, offset = qc.scroll(
|
|
210
|
+
collection_name=collection, limit=4000, offset=offset,
|
|
211
|
+
with_payload=[_VID, field], with_vectors=False)
|
|
212
|
+
for p in points:
|
|
213
|
+
pl = p.payload or {}
|
|
214
|
+
vid = pl.get(_VID)
|
|
215
|
+
if vid is not None:
|
|
216
|
+
out[vid] = pl.get(field)
|
|
217
|
+
if offset is None:
|
|
218
|
+
return out
|
|
219
|
+
|
|
220
|
+
def _build_filter(self, filters: Dict[str, Any]):
|
|
221
|
+
"""Translate a generic filters dict into a Qdrant Filter — the metadata/ACL push-down MECHANISM;
|
|
222
|
+
the consumer builds its ACL POLICY on this (which principals/groups → which clauses).
|
|
223
|
+
|
|
224
|
+
Shapes:
|
|
225
|
+
{field: value} → must match value (MatchValue)
|
|
226
|
+
{field: [v1, v2]} → must match any of (MatchAny)
|
|
227
|
+
{"must": {...}, "should": {...}} → explicit AND / OR groups (e.g. an ACL 'should' set)
|
|
228
|
+
A `should` group is deny-by-default OR: at least one clause must hold (7thsense's ACL parity).
|
|
229
|
+
"""
|
|
230
|
+
from qdrant_client import models as qm
|
|
231
|
+
|
|
232
|
+
def conds(d: Dict[str, Any]):
|
|
233
|
+
out = []
|
|
234
|
+
for key, val in d.items():
|
|
235
|
+
if isinstance(val, (list, tuple, set)):
|
|
236
|
+
out.append(qm.FieldCondition(key=key, match=qm.MatchAny(any=list(val))))
|
|
237
|
+
else:
|
|
238
|
+
out.append(qm.FieldCondition(key=key, match=qm.MatchValue(value=val)))
|
|
239
|
+
return out
|
|
240
|
+
|
|
241
|
+
if "must" in filters or "should" in filters:
|
|
242
|
+
must = conds(filters.get("must", {})) or None
|
|
243
|
+
should = conds(filters.get("should", {})) or None
|
|
244
|
+
return qm.Filter(must=must, should=should)
|
|
245
|
+
return qm.Filter(must=conds(filters))
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""SqliteNumpyStore — the in-process backend (backend id "sqlite_numpy", never "db").
|
|
2
|
+
|
|
3
|
+
For a corpus of thousands-to-low-hundred-thousands of vectors, brute-force cosine in numpy is ~ms and
|
|
4
|
+
keeps the consumer self-contained / portable — no vector server (warden's design). SQLite holds the
|
|
5
|
+
vectors + their text_sha + payload, keyed by (collection, id); search is one L2-normalized matrix and a
|
|
6
|
+
dot product (both sides unit vectors ⇒ dot == cosine).
|
|
7
|
+
|
|
8
|
+
Capabilities: implements the OPTIONAL `matrix()` seam (the whole point of an in-process store — X @ Xᵀ
|
|
9
|
+
analytics: kNN, centroid, edge-gating). Does NOT implement `search(filters=…)` push-down — that is
|
|
10
|
+
qdrant's ACL/metadata seam; a filters request here raises typed NotSupported (never a silent ignore,
|
|
11
|
+
which for an ACL filter would leak). No Batch API (that is qdrant/openai's ingest seam).
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import sqlite3
|
|
18
|
+
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
|
19
|
+
|
|
20
|
+
import numpy as np
|
|
21
|
+
|
|
22
|
+
from ..errors import DimMismatch, ModelMismatch, NotSupported, Unavailable
|
|
23
|
+
from ..ports import Hit, Point
|
|
24
|
+
|
|
25
|
+
BACKEND = "sqlite_numpy"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class SqliteNumpyStore:
|
|
29
|
+
backend = BACKEND
|
|
30
|
+
|
|
31
|
+
def __init__(self, path: str = ":memory:"):
|
|
32
|
+
self.path = path if path == ":memory:" else os.path.expanduser(path)
|
|
33
|
+
if self.path != ":memory:":
|
|
34
|
+
os.makedirs(os.path.dirname(self.path) or ".", exist_ok=True)
|
|
35
|
+
try:
|
|
36
|
+
self._con = sqlite3.connect(self.path)
|
|
37
|
+
except sqlite3.Error as e:
|
|
38
|
+
raise Unavailable(f"sqlite_numpy store at {self.path} could not be opened "
|
|
39
|
+
f"({type(e).__name__}: {str(e)[:80]})", code="store_unusable") from e
|
|
40
|
+
self._con.execute("PRAGMA journal_mode=WAL")
|
|
41
|
+
self._ensure_schema()
|
|
42
|
+
# a small matrix cache keyed by (collection, row-count) so repeated searches within a process
|
|
43
|
+
# don't reload the whole table each time; the row-count auto-invalidates after an upsert.
|
|
44
|
+
self._matrix_cache: Dict[str, Tuple[int, List[str], np.ndarray]] = {}
|
|
45
|
+
|
|
46
|
+
def _ensure_schema(self) -> None:
|
|
47
|
+
self._con.execute(
|
|
48
|
+
"CREATE TABLE IF NOT EXISTS collections(name TEXT PRIMARY KEY, dim INTEGER NOT NULL)")
|
|
49
|
+
# additive migration: a nullable `model` column for the model-identity guard. A db created before
|
|
50
|
+
# this column has it added here as NULL (an existing collection's model is 'unknown' → adopted, not
|
|
51
|
+
# refused, on its next ensure(model=...)).
|
|
52
|
+
cols = {r[1] for r in self._con.execute("PRAGMA table_info(collections)")}
|
|
53
|
+
if "model" not in cols:
|
|
54
|
+
self._con.execute("ALTER TABLE collections ADD COLUMN model TEXT")
|
|
55
|
+
self._con.execute(
|
|
56
|
+
"CREATE TABLE IF NOT EXISTS points("
|
|
57
|
+
" collection TEXT NOT NULL, id TEXT NOT NULL, text_sha TEXT NOT NULL,"
|
|
58
|
+
" dim INTEGER NOT NULL, vec BLOB NOT NULL, payload TEXT NOT NULL,"
|
|
59
|
+
" PRIMARY KEY(collection, id))")
|
|
60
|
+
self._con.commit()
|
|
61
|
+
|
|
62
|
+
# ── core port ──────────────────────────────────────────────────────────────────────────────────────
|
|
63
|
+
def ensure(self, collection: str, dim: int, model: str = None) -> None:
|
|
64
|
+
row = self._con.execute(
|
|
65
|
+
"SELECT dim, model FROM collections WHERE name=?", (collection,)).fetchone()
|
|
66
|
+
if row is not None:
|
|
67
|
+
stored_dim, stored_model = row
|
|
68
|
+
if stored_dim != dim:
|
|
69
|
+
raise DimMismatch(collection, stored_dim, dim) # 7thsense guard, kept as a core invariant
|
|
70
|
+
if model is not None:
|
|
71
|
+
if stored_model is not None and stored_model != model:
|
|
72
|
+
raise ModelMismatch(collection, stored_model, model)
|
|
73
|
+
if stored_model is None:
|
|
74
|
+
# unknown stored model (legacy collection) → ADOPT the requested one, don't refuse
|
|
75
|
+
self._con.execute("UPDATE collections SET model=? WHERE name=?", (model, collection))
|
|
76
|
+
self._con.commit()
|
|
77
|
+
return
|
|
78
|
+
self._con.execute("INSERT INTO collections(name, dim, model) VALUES(?,?,?)",
|
|
79
|
+
(collection, dim, model))
|
|
80
|
+
self._con.commit()
|
|
81
|
+
|
|
82
|
+
def upsert(self, collection: str, points: Sequence[Point]) -> None:
|
|
83
|
+
rows = []
|
|
84
|
+
for p in points:
|
|
85
|
+
v = np.asarray(p.vector, dtype=np.float32).ravel()
|
|
86
|
+
rows.append((collection, p.id, p.text_sha, int(v.shape[0]), v.tobytes(),
|
|
87
|
+
json.dumps(p.payload, ensure_ascii=False)))
|
|
88
|
+
with self._con:
|
|
89
|
+
self._con.executemany(
|
|
90
|
+
"INSERT OR REPLACE INTO points(collection,id,text_sha,dim,vec,payload) "
|
|
91
|
+
"VALUES(?,?,?,?,?,?)", rows)
|
|
92
|
+
self._matrix_cache.pop(collection, None) # index changed → drop cached matrix
|
|
93
|
+
|
|
94
|
+
def search(self, collection: str, qvec: np.ndarray, k: int,
|
|
95
|
+
filters: Optional[Dict[str, Any]] = None) -> List[Hit]:
|
|
96
|
+
if filters:
|
|
97
|
+
# NEVER silently ignore a filter — for an ACL filter that would leak. Honest typed refusal.
|
|
98
|
+
raise NotSupported(
|
|
99
|
+
"search filters (metadata/ACL push-down)", self.backend,
|
|
100
|
+
"sqlite_numpy does not implement filter push-down — use the qdrant backend for ACL/"
|
|
101
|
+
"metadata-filtered search, or probe capabilities() before passing filters.")
|
|
102
|
+
ids, M, payloads = self._load(collection)
|
|
103
|
+
if not ids:
|
|
104
|
+
return []
|
|
105
|
+
q = np.asarray(qvec, dtype=np.float32).ravel()
|
|
106
|
+
qn = q / max(float(np.linalg.norm(q)), 1e-9)
|
|
107
|
+
scores = M @ qn # rows are stored L2-normalized ⇒ dot == cosine
|
|
108
|
+
order = np.argsort(-scores)[:max(0, k)]
|
|
109
|
+
return [Hit(id=ids[i], score=float(scores[i]), payload=payloads[i]) for i in order]
|
|
110
|
+
|
|
111
|
+
def delete(self, collection: str, ids: Sequence[str]) -> int:
|
|
112
|
+
"""Remove points by id; return the count removed. Idempotent (absent ids → 0). Chunks the IN-list
|
|
113
|
+
and invalidates the collection's matrix cache so a later search/matrix reflects the removal."""
|
|
114
|
+
ids = list(ids)
|
|
115
|
+
if not ids:
|
|
116
|
+
return 0
|
|
117
|
+
removed = 0
|
|
118
|
+
with self._con:
|
|
119
|
+
for i in range(0, len(ids), 500):
|
|
120
|
+
chunk = ids[i:i + 500]
|
|
121
|
+
marks = ",".join("?" * len(chunk))
|
|
122
|
+
cur = self._con.execute(
|
|
123
|
+
f"DELETE FROM points WHERE collection=? AND id IN ({marks})", [collection, *chunk])
|
|
124
|
+
removed += cur.rowcount
|
|
125
|
+
self._matrix_cache.pop(collection, None)
|
|
126
|
+
return removed
|
|
127
|
+
|
|
128
|
+
def existing_ids(self, collection: str) -> set:
|
|
129
|
+
return {r[0] for r in self._con.execute(
|
|
130
|
+
"SELECT id FROM points WHERE collection=?", (collection,))}
|
|
131
|
+
|
|
132
|
+
def stored_shas(self, collection: str) -> Dict[str, str]:
|
|
133
|
+
return {r[0]: r[1] for r in self._con.execute(
|
|
134
|
+
"SELECT id, text_sha FROM points WHERE collection=?", (collection,))}
|
|
135
|
+
|
|
136
|
+
def capabilities(self) -> Dict[str, bool]:
|
|
137
|
+
return {"matrix": True, "filters": False, "batch": False}
|
|
138
|
+
|
|
139
|
+
# ── optional capability seam ─────────────────────────────────────────────────────────────────────
|
|
140
|
+
def matrix(self, collection: str) -> Tuple[List[str], np.ndarray]:
|
|
141
|
+
ids, M, _payloads = self._load(collection)
|
|
142
|
+
return ids, M
|
|
143
|
+
|
|
144
|
+
# ── internals ────────────────────────────────────────────────────────────────────────────────────
|
|
145
|
+
def _load(self, collection: str) -> Tuple[List[str], np.ndarray, List[Dict[str, Any]]]:
|
|
146
|
+
n = self._con.execute("SELECT COUNT(*) FROM points WHERE collection=?", (collection,)).fetchone()[0]
|
|
147
|
+
cached = self._matrix_cache.get(collection)
|
|
148
|
+
ids: List[str] = []
|
|
149
|
+
vecs: List[np.ndarray] = []
|
|
150
|
+
payloads: List[Dict[str, Any]] = []
|
|
151
|
+
for id_, dim, blob, payload in self._con.execute(
|
|
152
|
+
"SELECT id, dim, vec, payload FROM points WHERE collection=? ORDER BY id", (collection,)):
|
|
153
|
+
ids.append(id_)
|
|
154
|
+
vecs.append(np.frombuffer(blob, dtype=np.float32))
|
|
155
|
+
payloads.append(json.loads(payload))
|
|
156
|
+
M = np.vstack(vecs).astype(np.float32) if vecs else np.zeros((0, 0), dtype=np.float32)
|
|
157
|
+
if cached is None or cached[0] != n:
|
|
158
|
+
self._matrix_cache[collection] = (n, ids, M)
|
|
159
|
+
return ids, M, payloads
|
|
160
|
+
|
|
161
|
+
def close(self) -> None:
|
|
162
|
+
self._con.close()
|
veccore/audit.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""The audit envelope — WHERE / WHO / WHAT / WHY on every veccore call (auditability + observability).
|
|
2
|
+
|
|
3
|
+
The user requirement: a caller must say to WHICH store it is sending things (no hidden default store),
|
|
4
|
+
and every call must record WHAT is being sent, WHO is sending it, and WHY — so the estate has a complete
|
|
5
|
+
audit trail of every embed/upsert/search and an observability feed it can watch live.
|
|
6
|
+
|
|
7
|
+
The envelope is REQUIRED by every core call. `who`/`why` the caller supplies (veccore refuses a blank
|
|
8
|
+
`why` — an un-reasoned spend/mutation is exactly what this exists to prevent). `where`/`what` veccore
|
|
9
|
+
fills from the call itself (collection + resolved backend; operation + item count + sha summary). The
|
|
10
|
+
completed envelope is emitted to an INJECTED ObserverPort, so the audit sink (a JSONL file, ccwatch, an
|
|
11
|
+
OTel exporter) composes WITHOUT veccore importing any of them — the same acyclic discipline as the gate.
|
|
12
|
+
|
|
13
|
+
Library sinks are DURABLE by construction: NullObserver (discard) and JsonlObserver (append-only, fsync'd
|
|
14
|
+
file of record). An in-memory capturing observer is test/inspection tooling and lives with the tests, not
|
|
15
|
+
here — the library never makes an in-process list the sole record of an audit trail.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import threading
|
|
22
|
+
from dataclasses import asdict, dataclass, field
|
|
23
|
+
from typing import Any, Dict, Optional, Protocol, runtime_checkable
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class AuditEnvelope:
|
|
28
|
+
"""One auditable veccore operation.
|
|
29
|
+
|
|
30
|
+
Caller-supplied (required): `who` (the consumer, e.g. "warden", "7thsense", "saga"), `why` (a human
|
|
31
|
+
intent string). veccore-filled: `where` (collection + backend), `op`, `n_items`, `n_embedded`,
|
|
32
|
+
`n_skipped`, `sha_summary`, `est_usd`, `outcome`, `error`.
|
|
33
|
+
"""
|
|
34
|
+
who: str
|
|
35
|
+
why: str
|
|
36
|
+
# veccore fills these as the call executes
|
|
37
|
+
op: str = ""
|
|
38
|
+
collection: str = ""
|
|
39
|
+
backend: str = ""
|
|
40
|
+
n_items: int = 0
|
|
41
|
+
n_embedded: int = 0
|
|
42
|
+
n_skipped: int = 0
|
|
43
|
+
sha_summary: str = ""
|
|
44
|
+
est_usd: Optional[float] = None
|
|
45
|
+
outcome: str = "started" # started | ok | refused | error
|
|
46
|
+
error: str = ""
|
|
47
|
+
extra: Dict[str, Any] = field(default_factory=dict)
|
|
48
|
+
|
|
49
|
+
def __post_init__(self):
|
|
50
|
+
# WHY IS REQUIRED. An un-reasoned embed/upsert/search is the thing this envelope exists to make
|
|
51
|
+
# impossible — refuse a blank one loudly rather than record an empty audit line.
|
|
52
|
+
if not (self.who or "").strip():
|
|
53
|
+
raise ValueError("AuditEnvelope.who is required — name the calling consumer.")
|
|
54
|
+
if not (self.why or "").strip():
|
|
55
|
+
raise ValueError("AuditEnvelope.why is required — state why this call is being made "
|
|
56
|
+
"(auditability: every embed/upsert/search records its intent).")
|
|
57
|
+
|
|
58
|
+
def where(self) -> str:
|
|
59
|
+
"""The human-readable target: 'collection@backend'. Filled once the core resolves the backend."""
|
|
60
|
+
return f"{self.collection}@{self.backend}" if self.backend else self.collection
|
|
61
|
+
|
|
62
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
63
|
+
d = asdict(self)
|
|
64
|
+
d["where"] = self.where()
|
|
65
|
+
return d
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@runtime_checkable
|
|
69
|
+
class ObserverPort(Protocol):
|
|
70
|
+
"""The injected audit/observability sink. `emit` is called once per completed operation with the
|
|
71
|
+
filled envelope. Implementations must never raise back into veccore (observability must not break the
|
|
72
|
+
operation) — the core guards the call, but a well-behaved observer swallows its own errors too."""
|
|
73
|
+
|
|
74
|
+
def emit(self, envelope: AuditEnvelope) -> None: ...
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class NullObserver:
|
|
78
|
+
"""Default sink: discard. veccore works with no observability configured; a consumer opts in."""
|
|
79
|
+
|
|
80
|
+
def emit(self, envelope: AuditEnvelope) -> None:
|
|
81
|
+
pass
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class JsonlObserver:
|
|
85
|
+
"""Durable append-only audit log — one JSON line per operation. Append-only + per-record flush + fsync
|
|
86
|
+
so a long run's audit trail is visible live and survives a crash (the durable-log standard). The file
|
|
87
|
+
is the estate's veccore audit of record; a cockpit/OTel exporter can tail it."""
|
|
88
|
+
|
|
89
|
+
def __init__(self, path: str):
|
|
90
|
+
self.path = os.path.expanduser(path)
|
|
91
|
+
os.makedirs(os.path.dirname(self.path) or ".", exist_ok=True)
|
|
92
|
+
self._lock = threading.Lock()
|
|
93
|
+
|
|
94
|
+
def emit(self, envelope: AuditEnvelope) -> None:
|
|
95
|
+
line = json.dumps(envelope.to_dict(), ensure_ascii=False, default=str)
|
|
96
|
+
with self._lock:
|
|
97
|
+
with open(self.path, "a", encoding="utf-8") as fh:
|
|
98
|
+
fh.write(line + "\n")
|
|
99
|
+
fh.flush()
|
|
100
|
+
os.fsync(fh.fileno())
|