veccore 0.2.0__tar.gz
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-0.2.0/LICENSE +21 -0
- veccore-0.2.0/PKG-INFO +111 -0
- veccore-0.2.0/README.md +71 -0
- veccore-0.2.0/pyproject.toml +66 -0
- veccore-0.2.0/setup.cfg +4 -0
- veccore-0.2.0/src/veccore/__init__.py +56 -0
- veccore-0.2.0/src/veccore/adapters/__init__.py +6 -0
- veccore-0.2.0/src/veccore/adapters/qdrant_store.py +245 -0
- veccore-0.2.0/src/veccore/adapters/sqlite_numpy_store.py +162 -0
- veccore-0.2.0/src/veccore/audit.py +100 -0
- veccore-0.2.0/src/veccore/batch_ingest.py +455 -0
- veccore-0.2.0/src/veccore/core.py +264 -0
- veccore-0.2.0/src/veccore/durable.py +64 -0
- veccore-0.2.0/src/veccore/embedders/__init__.py +48 -0
- veccore-0.2.0/src/veccore/embedders/composite_embedder.py +55 -0
- veccore-0.2.0/src/veccore/embedders/local_embedder.py +68 -0
- veccore-0.2.0/src/veccore/embedders/mock_embedder.py +57 -0
- veccore-0.2.0/src/veccore/embedders/openai_embedder.py +73 -0
- veccore-0.2.0/src/veccore/errors.py +96 -0
- veccore-0.2.0/src/veccore/evaluate.py +97 -0
- veccore-0.2.0/src/veccore/idempotency.py +45 -0
- veccore-0.2.0/src/veccore/numeric.py +12 -0
- veccore-0.2.0/src/veccore/ports.py +142 -0
- veccore-0.2.0/src/veccore/pricing.py +73 -0
- veccore-0.2.0/src/veccore/registry.py +98 -0
- veccore-0.2.0/src/veccore.egg-info/PKG-INFO +111 -0
- veccore-0.2.0/src/veccore.egg-info/SOURCES.txt +43 -0
- veccore-0.2.0/src/veccore.egg-info/dependency_links.txt +1 -0
- veccore-0.2.0/src/veccore.egg-info/requires.txt +20 -0
- veccore-0.2.0/src/veccore.egg-info/top_level.txt +1 -0
- veccore-0.2.0/tests/test_acyclic_and_naming.py +68 -0
- veccore-0.2.0/tests/test_audit_observability.py +79 -0
- veccore-0.2.0/tests/test_batch_ingest.py +274 -0
- veccore-0.2.0/tests/test_composite_and_oversize.py +66 -0
- veccore-0.2.0/tests/test_delete.py +91 -0
- veccore-0.2.0/tests/test_dim_mismatch_refusal.py +29 -0
- veccore-0.2.0/tests/test_evaluate.py +71 -0
- veccore-0.2.0/tests/test_hard_requirement_additive.py +54 -0
- veccore-0.2.0/tests/test_hard_requirement_new_consumer.py +57 -0
- veccore-0.2.0/tests/test_model_guard.py +62 -0
- veccore-0.2.0/tests/test_never_reembed.py +71 -0
- veccore-0.2.0/tests/test_parity_qdrant.py +119 -0
- veccore-0.2.0/tests/test_parity_sqlite_numpy.py +30 -0
- veccore-0.2.0/tests/test_realspend.py +76 -0
- veccore-0.2.0/tests/test_typed_errors.py +62 -0
veccore-0.2.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ash Damle
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
veccore-0.2.0/PKG-INFO
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: veccore
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Ports-and-adapters vector substrate: embed -> index -> search -> hydrate, backend/embedder-agnostic.
|
|
5
|
+
Author: Ash Damle
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/ashdamle/keelworks
|
|
8
|
+
Project-URL: Repository, https://github.com/ashdamle/keelworks
|
|
9
|
+
Project-URL: Issues, https://github.com/ashdamle/keelworks/issues
|
|
10
|
+
Keywords: embeddings,vector-search,qdrant,semantic-search,ports-and-adapters,retrieval
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
20
|
+
Classifier: Typing :: Typed
|
|
21
|
+
Requires-Python: >=3.11
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
License-File: LICENSE
|
|
24
|
+
Requires-Dist: numpy>=1.26
|
|
25
|
+
Provides-Extra: qdrant
|
|
26
|
+
Requires-Dist: qdrant-client>=1.9; extra == "qdrant"
|
|
27
|
+
Provides-Extra: openai
|
|
28
|
+
Requires-Dist: openai>=1.30; extra == "openai"
|
|
29
|
+
Requires-Dist: tiktoken>=0.7; extra == "openai"
|
|
30
|
+
Provides-Extra: st
|
|
31
|
+
Requires-Dist: sentence-transformers>=2.7; extra == "st"
|
|
32
|
+
Provides-Extra: fastembed
|
|
33
|
+
Requires-Dist: fastembed>=0.3; extra == "fastembed"
|
|
34
|
+
Provides-Extra: dev
|
|
35
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
36
|
+
Requires-Dist: pytest-timeout>=2.3; extra == "dev"
|
|
37
|
+
Requires-Dist: build>=1.2; extra == "dev"
|
|
38
|
+
Requires-Dist: twine>=5; extra == "dev"
|
|
39
|
+
Dynamic: license-file
|
|
40
|
+
|
|
41
|
+
# veccore
|
|
42
|
+
|
|
43
|
+
**A ports-and-adapters vector substrate: `embed → index → search → hydrate`, backend- and embedder-agnostic.**
|
|
44
|
+
|
|
45
|
+
veccore ships generic *mechanisms* and clean *seams* for semantic retrieval. You register a collection
|
|
46
|
+
(choosing a backend + embedder), embed and upsert your items, and search — veccore returns
|
|
47
|
+
`{id, score, payload}` and you hydrate the full record from your own system of record. Nothing about your
|
|
48
|
+
domain lives in veccore; you never reinvent embedding, indexing, or idempotency.
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
pip install veccore # core (numpy only)
|
|
52
|
+
pip install 'veccore[qdrant]' # + Qdrant server backend
|
|
53
|
+
pip install 'veccore[openai]' # + OpenAI embedder (gated egress you inject)
|
|
54
|
+
pip install 'veccore[fastembed]' # + on-device fastembed embedder (no API key, no network)
|
|
55
|
+
pip install 'veccore[st]' # + sentence-transformers embedder
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Quickstart
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
from veccore import (VecCore, CollectionRegistry, CollectionSpec, AuditEnvelope,
|
|
62
|
+
MockEmbedder, SqliteNumpyStore)
|
|
63
|
+
|
|
64
|
+
store = SqliteNumpyStore(path="/tmp/notes.sqlite")
|
|
65
|
+
core = VecCore(registry=CollectionRegistry(),
|
|
66
|
+
stores={"sqlite_numpy": store},
|
|
67
|
+
embedders={"mock": MockEmbedder()})
|
|
68
|
+
core.register(CollectionSpec(name="notes", backend="sqlite_numpy", embedder="mock", dim=64))
|
|
69
|
+
|
|
70
|
+
# every call carries an audit envelope: who + why (where/what are filled by veccore)
|
|
71
|
+
audit = AuditEnvelope(who="my_tool", why="index notes")
|
|
72
|
+
core.embed_upsert("notes",
|
|
73
|
+
[{"id": "n1", "text": "vector search over notes", "payload": {"src": "n1"}}],
|
|
74
|
+
audit)
|
|
75
|
+
|
|
76
|
+
hits = core.search("notes", "nearest ideas", k=5,
|
|
77
|
+
audit=AuditEnvelope(who="my_tool", why="find related"))
|
|
78
|
+
for h in hits: # each hit is {id, score, payload}
|
|
79
|
+
... # hydrate the full record from YOUR store by h.id
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## What's in the box
|
|
83
|
+
|
|
84
|
+
- **Core ports** — `EmbedderPort`, `VectorStorePort`, and `GatePort` (injected, so veccore stays free of
|
|
85
|
+
any spend/telemetry dependency — you hand it your gate, or `NullGate` for local/free embedders).
|
|
86
|
+
- **Backends** — `SqliteNumpyStore` (in-process, brute-force cosine; implements the optional `matrix()`
|
|
87
|
+
analytics seam) and `QdrantStore` (server; implements `search(filters=…)` metadata/ACL push-down + Batch
|
|
88
|
+
ingest). Future backends slot in by implementing `VectorStorePort`.
|
|
89
|
+
- **Embedders** — `MockEmbedder` (deterministic, zero-spend), `OpenAIEmbedder` (network, gated),
|
|
90
|
+
`LocalEmbedder` (fastembed / sentence-transformers, on-device), and `CompositeEmbedder` (fuse several
|
|
91
|
+
embedders so a plain dot product equals their weighted fused cosine).
|
|
92
|
+
- **Content-sha idempotency** — re-embed a row only when its text changes; a rerun over an unchanged corpus
|
|
93
|
+
embeds nothing, and changing one item's text re-embeds exactly that one.
|
|
94
|
+
- **Typed errors** — `Unavailable(code=…)` and `NotSupported(capability, backend)` — never a silent degrade.
|
|
95
|
+
- **Estimate-first** — `core.estimate(...)` sizes a job (tokens + `$`) with zero spend before you embed.
|
|
96
|
+
- **Batch ingest** — `BatchIngest` for large corpora via a provider Batch API: chunked, resumable, a durable
|
|
97
|
+
handle ledger, every result accounted; realtime and non-blocking collect both supported.
|
|
98
|
+
- **Optional seams, negotiated** — `capabilities()` + typed `NotSupported` mean expanding usage is additive:
|
|
99
|
+
a consumer starts on `search(collection, q, k)`, later adds `filters=…`, later calls `matrix()`, with no
|
|
100
|
+
rewrite.
|
|
101
|
+
|
|
102
|
+
## Design
|
|
103
|
+
|
|
104
|
+
veccore's line is **mechanism vs policy**: veccore ships the generic mechanisms + seams; each consumer builds
|
|
105
|
+
its domain policy on top. The gate (spend/egress) and any observability sink are **injected**, so veccore has
|
|
106
|
+
one hard dependency — `numpy` — and everything else (Qdrant, OpenAI, fastembed, sentence-transformers) is an
|
|
107
|
+
optional extra you install only for the adapter you choose.
|
|
108
|
+
|
|
109
|
+
## License
|
|
110
|
+
|
|
111
|
+
MIT © 2026 Ash Damle. Developed in the [keelworks](https://github.com/ashdamle/keelworks) monorepo.
|
veccore-0.2.0/README.md
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# veccore
|
|
2
|
+
|
|
3
|
+
**A ports-and-adapters vector substrate: `embed → index → search → hydrate`, backend- and embedder-agnostic.**
|
|
4
|
+
|
|
5
|
+
veccore ships generic *mechanisms* and clean *seams* for semantic retrieval. You register a collection
|
|
6
|
+
(choosing a backend + embedder), embed and upsert your items, and search — veccore returns
|
|
7
|
+
`{id, score, payload}` and you hydrate the full record from your own system of record. Nothing about your
|
|
8
|
+
domain lives in veccore; you never reinvent embedding, indexing, or idempotency.
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
pip install veccore # core (numpy only)
|
|
12
|
+
pip install 'veccore[qdrant]' # + Qdrant server backend
|
|
13
|
+
pip install 'veccore[openai]' # + OpenAI embedder (gated egress you inject)
|
|
14
|
+
pip install 'veccore[fastembed]' # + on-device fastembed embedder (no API key, no network)
|
|
15
|
+
pip install 'veccore[st]' # + sentence-transformers embedder
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Quickstart
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
from veccore import (VecCore, CollectionRegistry, CollectionSpec, AuditEnvelope,
|
|
22
|
+
MockEmbedder, SqliteNumpyStore)
|
|
23
|
+
|
|
24
|
+
store = SqliteNumpyStore(path="/tmp/notes.sqlite")
|
|
25
|
+
core = VecCore(registry=CollectionRegistry(),
|
|
26
|
+
stores={"sqlite_numpy": store},
|
|
27
|
+
embedders={"mock": MockEmbedder()})
|
|
28
|
+
core.register(CollectionSpec(name="notes", backend="sqlite_numpy", embedder="mock", dim=64))
|
|
29
|
+
|
|
30
|
+
# every call carries an audit envelope: who + why (where/what are filled by veccore)
|
|
31
|
+
audit = AuditEnvelope(who="my_tool", why="index notes")
|
|
32
|
+
core.embed_upsert("notes",
|
|
33
|
+
[{"id": "n1", "text": "vector search over notes", "payload": {"src": "n1"}}],
|
|
34
|
+
audit)
|
|
35
|
+
|
|
36
|
+
hits = core.search("notes", "nearest ideas", k=5,
|
|
37
|
+
audit=AuditEnvelope(who="my_tool", why="find related"))
|
|
38
|
+
for h in hits: # each hit is {id, score, payload}
|
|
39
|
+
... # hydrate the full record from YOUR store by h.id
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## What's in the box
|
|
43
|
+
|
|
44
|
+
- **Core ports** — `EmbedderPort`, `VectorStorePort`, and `GatePort` (injected, so veccore stays free of
|
|
45
|
+
any spend/telemetry dependency — you hand it your gate, or `NullGate` for local/free embedders).
|
|
46
|
+
- **Backends** — `SqliteNumpyStore` (in-process, brute-force cosine; implements the optional `matrix()`
|
|
47
|
+
analytics seam) and `QdrantStore` (server; implements `search(filters=…)` metadata/ACL push-down + Batch
|
|
48
|
+
ingest). Future backends slot in by implementing `VectorStorePort`.
|
|
49
|
+
- **Embedders** — `MockEmbedder` (deterministic, zero-spend), `OpenAIEmbedder` (network, gated),
|
|
50
|
+
`LocalEmbedder` (fastembed / sentence-transformers, on-device), and `CompositeEmbedder` (fuse several
|
|
51
|
+
embedders so a plain dot product equals their weighted fused cosine).
|
|
52
|
+
- **Content-sha idempotency** — re-embed a row only when its text changes; a rerun over an unchanged corpus
|
|
53
|
+
embeds nothing, and changing one item's text re-embeds exactly that one.
|
|
54
|
+
- **Typed errors** — `Unavailable(code=…)` and `NotSupported(capability, backend)` — never a silent degrade.
|
|
55
|
+
- **Estimate-first** — `core.estimate(...)` sizes a job (tokens + `$`) with zero spend before you embed.
|
|
56
|
+
- **Batch ingest** — `BatchIngest` for large corpora via a provider Batch API: chunked, resumable, a durable
|
|
57
|
+
handle ledger, every result accounted; realtime and non-blocking collect both supported.
|
|
58
|
+
- **Optional seams, negotiated** — `capabilities()` + typed `NotSupported` mean expanding usage is additive:
|
|
59
|
+
a consumer starts on `search(collection, q, k)`, later adds `filters=…`, later calls `matrix()`, with no
|
|
60
|
+
rewrite.
|
|
61
|
+
|
|
62
|
+
## Design
|
|
63
|
+
|
|
64
|
+
veccore's line is **mechanism vs policy**: veccore ships the generic mechanisms + seams; each consumer builds
|
|
65
|
+
its domain policy on top. The gate (spend/egress) and any observability sink are **injected**, so veccore has
|
|
66
|
+
one hard dependency — `numpy` — and everything else (Qdrant, OpenAI, fastembed, sentence-transformers) is an
|
|
67
|
+
optional extra you install only for the adapter you choose.
|
|
68
|
+
|
|
69
|
+
## License
|
|
70
|
+
|
|
71
|
+
MIT © 2026 Ash Damle. Developed in the [keelworks](https://github.com/ashdamle/keelworks) monorepo.
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"] # >=77 for PEP 639 SPDX license expression + license-files
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "veccore"
|
|
7
|
+
version = "0.2.0"
|
|
8
|
+
description = "Ports-and-adapters vector substrate: embed -> index -> search -> hydrate, backend/embedder-agnostic."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "Ash Damle" }]
|
|
14
|
+
keywords = ["embeddings", "vector-search", "qdrant", "semantic-search", "ports-and-adapters", "retrieval"]
|
|
15
|
+
# Core deps only. veccore ships MECHANISMS; heavy/optional backends and embedders are extras so a
|
|
16
|
+
# consumer installs only what its chosen adapter needs. numpy is the sole hard dependency.
|
|
17
|
+
dependencies = [
|
|
18
|
+
"numpy>=1.26",
|
|
19
|
+
]
|
|
20
|
+
classifiers = [
|
|
21
|
+
"Development Status :: 4 - Beta",
|
|
22
|
+
"Intended Audience :: Developers",
|
|
23
|
+
"Operating System :: OS Independent",
|
|
24
|
+
"Programming Language :: Python :: 3",
|
|
25
|
+
"Programming Language :: Python :: 3.11",
|
|
26
|
+
"Programming Language :: Python :: 3.12",
|
|
27
|
+
"Programming Language :: Python :: 3.13",
|
|
28
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
29
|
+
"Topic :: Software Development :: Libraries",
|
|
30
|
+
"Typing :: Typed",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
[project.optional-dependencies]
|
|
34
|
+
# Backends
|
|
35
|
+
qdrant = ["qdrant-client>=1.9"]
|
|
36
|
+
# Embedders (network / local)
|
|
37
|
+
openai = ["openai>=1.30", "tiktoken>=0.7"] # tiktoken → exact token counts for the paid estimate path
|
|
38
|
+
st = ["sentence-transformers>=2.7"]
|
|
39
|
+
fastembed = ["fastembed>=0.3"] # symgrep-codesearch[semantic] depends on veccore[fastembed]
|
|
40
|
+
dev = [
|
|
41
|
+
"pytest>=8",
|
|
42
|
+
"pytest-timeout>=2.3",
|
|
43
|
+
"build>=1.2",
|
|
44
|
+
"twine>=5",
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
[project.urls]
|
|
48
|
+
Homepage = "https://github.com/ashdamle/keelworks"
|
|
49
|
+
Repository = "https://github.com/ashdamle/keelworks"
|
|
50
|
+
Issues = "https://github.com/ashdamle/keelworks/issues"
|
|
51
|
+
|
|
52
|
+
[tool.setuptools.packages.find]
|
|
53
|
+
where = ["src"]
|
|
54
|
+
include = ["veccore*"] # ship ONLY veccore
|
|
55
|
+
exclude = ["hookdispatch*", "tests*"] # hookdispatch is a PARKED stub, never shipped in the veccore dist
|
|
56
|
+
|
|
57
|
+
[tool.pytest.ini_options]
|
|
58
|
+
testpaths = ["tests"]
|
|
59
|
+
# per-unit timeout so a hung embedder/network call fails the unit, not the suite
|
|
60
|
+
# (feedback_chunk_never_single_shot: per-unit timeout)
|
|
61
|
+
timeout = 120
|
|
62
|
+
markers = [
|
|
63
|
+
"realspend: opt-in test that makes REAL metered OpenAI calls, capped ~$1 (deselected by default)",
|
|
64
|
+
"qdrant: requires a live Qdrant at :6333 (uses a throwaway veccore_test_* collection)",
|
|
65
|
+
]
|
|
66
|
+
addopts = "-m 'not realspend'"
|
veccore-0.2.0/setup.cfg
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))
|