polign 0.1.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.
- polign-0.1.0/.gitignore +54 -0
- polign-0.1.0/PKG-INFO +194 -0
- polign-0.1.0/README.md +172 -0
- polign-0.1.0/polign/__init__.py +66 -0
- polign-0.1.0/polign/_filter.py +161 -0
- polign-0.1.0/polign/_pb/__init__.py +0 -0
- polign-0.1.0/polign/_pb/vectordb_pb2.py +117 -0
- polign-0.1.0/polign/_pb/vectordb_pb2.pyi +326 -0
- polign-0.1.0/polign/_pb/vectordb_pb2_grpc.py +584 -0
- polign-0.1.0/polign/client.py +413 -0
- polign-0.1.0/polign/errors.py +87 -0
- polign-0.1.0/polign/grpc_client.py +322 -0
- polign-0.1.0/polign/types.py +103 -0
- polign-0.1.0/pyproject.toml +48 -0
- polign-0.1.0/tests/test_filter_expr.py +132 -0
- polign-0.1.0/tests/test_integration.py +195 -0
- polign-0.1.0/tests/test_unit.py +273 -0
polign-0.1.0/.gitignore
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# Binaries for programs and plugins
|
|
2
|
+
*.exe
|
|
3
|
+
*.exe~
|
|
4
|
+
*.dll
|
|
5
|
+
*.so
|
|
6
|
+
*.dylib
|
|
7
|
+
|
|
8
|
+
# Test binary, built with `go test -c`
|
|
9
|
+
*.test
|
|
10
|
+
|
|
11
|
+
# Output of the go coverage tool
|
|
12
|
+
*.out
|
|
13
|
+
|
|
14
|
+
# Dependency directories
|
|
15
|
+
vendor/
|
|
16
|
+
|
|
17
|
+
# Go workspace file
|
|
18
|
+
go.work
|
|
19
|
+
go.work.sum
|
|
20
|
+
|
|
21
|
+
# Build output (incl. `go build ./cmd/...` binaries dropped at the repo root)
|
|
22
|
+
/bin/
|
|
23
|
+
/dist/
|
|
24
|
+
/ann
|
|
25
|
+
/apikey
|
|
26
|
+
/ivfpq-compact
|
|
27
|
+
/loadtest
|
|
28
|
+
/maintain
|
|
29
|
+
/persistor
|
|
30
|
+
/polign
|
|
31
|
+
/server
|
|
32
|
+
example/demo/demo
|
|
33
|
+
|
|
34
|
+
# Benchmark run logs
|
|
35
|
+
bench/**/*.log
|
|
36
|
+
|
|
37
|
+
# Native install data dir (default POLIGN_DATA_DIR)
|
|
38
|
+
/.polign-data/
|
|
39
|
+
|
|
40
|
+
# Environment / secrets
|
|
41
|
+
.env
|
|
42
|
+
.env.native
|
|
43
|
+
|
|
44
|
+
# Internal design docs (kept local, not published)
|
|
45
|
+
/docs/
|
|
46
|
+
|
|
47
|
+
# macOS Finder metadata
|
|
48
|
+
.DS_Store
|
|
49
|
+
|
|
50
|
+
# Python SDK (sdk/python)
|
|
51
|
+
__pycache__/
|
|
52
|
+
*.egg-info/
|
|
53
|
+
sdk/python/dist/
|
|
54
|
+
sdk/python/.pytest_cache/
|
polign-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: polign
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python client for polign_db, a vector database with hot/cold search and hybrid BM25 fusion
|
|
5
|
+
Project-URL: Homepage, https://polign.com
|
|
6
|
+
Project-URL: Documentation, https://polign.com/python.html
|
|
7
|
+
Project-URL: Issues, https://github.com/Polign/polign/issues
|
|
8
|
+
Author: Polign
|
|
9
|
+
License: Apache-2.0
|
|
10
|
+
Keywords: embeddings,polign,similarity search,vector database,vector search
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
15
|
+
Classifier: Topic :: Database
|
|
16
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
17
|
+
Requires-Python: >=3.9
|
|
18
|
+
Provides-Extra: grpc
|
|
19
|
+
Requires-Dist: grpcio>=1.80; extra == 'grpc'
|
|
20
|
+
Requires-Dist: protobuf<7,>=6.31; extra == 'grpc'
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# polign — Python client for polign_db
|
|
24
|
+
|
|
25
|
+
A thin Python client for [polign](https://polign.com) with
|
|
26
|
+
two interchangeable transports:
|
|
27
|
+
|
|
28
|
+
- **HTTP** (`polign.Client`) — zero dependencies, pure stdlib. Talks JSON to
|
|
29
|
+
the server's HTTP listener (default `:23000`).
|
|
30
|
+
- **gRPC** (`polign.GrpcClient`) — install with the `[grpc]` extra.
|
|
31
|
+
Wire-compatible with the Go client, against the gRPC listener (default
|
|
32
|
+
`:23001`).
|
|
33
|
+
|
|
34
|
+
Both expose the same seven operations with identical semantics: `put`,
|
|
35
|
+
`put_many`, `get`, `get_many`, `list`, `delete`, `search`.
|
|
36
|
+
|
|
37
|
+
## Install
|
|
38
|
+
|
|
39
|
+
Not yet published to PyPI — install from a checkout of the repo:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pip install ./sdk/python # HTTP client, no dependencies
|
|
43
|
+
pip install './sdk/python[grpc]' # + gRPC transport (grpcio, protobuf)
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
(From inside this directory: `pip install .`; add `-e` for an editable
|
|
47
|
+
development install — needs pip ≥ 21.3.)
|
|
48
|
+
|
|
49
|
+
## Quick start
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
from polign import Client
|
|
53
|
+
|
|
54
|
+
client = Client("http://localhost:23000")
|
|
55
|
+
|
|
56
|
+
# Upsert. Collections are auto-created on first put, inferring their
|
|
57
|
+
# dimension from the vector. Values accept lists or numpy arrays.
|
|
58
|
+
client.put("docs", "doc-1", embedding, metadata={"title": "Cats", "url": "/cats"})
|
|
59
|
+
|
|
60
|
+
# Nearest-neighbour search (distance: smaller = closer)
|
|
61
|
+
for hit in client.search("docs", values=query_embedding, k=10):
|
|
62
|
+
print(hit.id, hit.distance, hit.metadata)
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Swap in gRPC by changing two lines — the rest of the code is identical:
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
from polign import GrpcClient
|
|
69
|
+
|
|
70
|
+
client = GrpcClient("localhost:23001")
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Operations
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
client.put("docs", "doc-1", values, metadata={"k": "v"}) # upsert, returns id
|
|
77
|
+
client.put_many("docs", [Vector(id="a", values=va), Vector(id="b", values=vb)])
|
|
78
|
+
# batch upsert, one request
|
|
79
|
+
v = client.get("docs", "doc-1") # Vector(id, values, metadata)
|
|
80
|
+
vs = client.get_many("docs", ["a", "b"]) # batch read, byte-exact values:
|
|
81
|
+
# never a compressed reconstruction
|
|
82
|
+
# (get may return one on a cold-
|
|
83
|
+
# flushed collection); unknown ids
|
|
84
|
+
# omitted, request order kept
|
|
85
|
+
page = client.list("docs", limit=100, offset=0) # page.vectors, page.total
|
|
86
|
+
client.delete("docs", "doc-1") # True; False if absent (no error)
|
|
87
|
+
hits = client.search("docs", values=q, k=10) # [Hit(id, distance, score, metadata)]
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### Search options
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
from polign import Fusion
|
|
94
|
+
|
|
95
|
+
client.search(
|
|
96
|
+
"docs",
|
|
97
|
+
values=q, # vector leg (either values or text required)
|
|
98
|
+
k=10,
|
|
99
|
+
ef=64, # HNSW beam width override (0 = server default)
|
|
100
|
+
filter={"lang": "en"}, # metadata predicate (see below)
|
|
101
|
+
text="quick brown fox", # BM25 leg (needs a segment index server-side)
|
|
102
|
+
fusion=Fusion(method="linear", alpha=0.6), # hybrid fusion; default RRF
|
|
103
|
+
cold=True, nprobe=8, # serve from object-store segments
|
|
104
|
+
)
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
`text` alone runs a pure BM25 search; `values` + `text` runs hybrid search
|
|
108
|
+
fused server-side. `hit.score` is the BM25/fused relevance (larger = better)
|
|
109
|
+
and is `0.0` on a pure vector search.
|
|
110
|
+
|
|
111
|
+
`filter` takes the same dict language on both transports (see
|
|
112
|
+
`docs/FILTERING.md` in the main repo): bare values are equality
|
|
113
|
+
(ANDed across keys); per-key operator objects (`$eq`, `$ne`, `$in`, `$gt`,
|
|
114
|
+
`$gte`, `$lt`, `$lte`, `$exists`) and the composers `$and`/`$or`/`$not`
|
|
115
|
+
express richer predicates:
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
filter={
|
|
119
|
+
"tenant": "acme",
|
|
120
|
+
"score": {"$gte": 0.5},
|
|
121
|
+
"$or": [{"lang": "en"}, {"lang": {"$exists": False}}],
|
|
122
|
+
}
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## Auth and tenants
|
|
126
|
+
|
|
127
|
+
```python
|
|
128
|
+
client = Client(
|
|
129
|
+
"https://db.example.com:23000",
|
|
130
|
+
api_key="plgn_<key_id>_<secret>", # sent as Authorization: Bearer
|
|
131
|
+
tenant="acme/search/prod", # org/project/namespace
|
|
132
|
+
)
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Servers started without `-auth-stores` need no credentials. With TLS enabled
|
|
136
|
+
server-side, use an `https://` URL (HTTP) or pass
|
|
137
|
+
`credentials=grpc.ssl_channel_credentials()` (gRPC).
|
|
138
|
+
|
|
139
|
+
## Errors
|
|
140
|
+
|
|
141
|
+
All errors subclass `polign.PolignError`:
|
|
142
|
+
|
|
143
|
+
| Exception | HTTP | gRPC |
|
|
144
|
+
|-------------------------|------|----------------------|
|
|
145
|
+
| `InvalidArgumentError` | 400 | `INVALID_ARGUMENT` |
|
|
146
|
+
| `AuthenticationError` | 401 | `UNAUTHENTICATED` |
|
|
147
|
+
| `PermissionDeniedError` | 403 | `PERMISSION_DENIED` |
|
|
148
|
+
| `NotFoundError` | 404 | `NOT_FOUND` |
|
|
149
|
+
| `NotOwnerError` | 421 | `FAILED_PRECONDITION`|
|
|
150
|
+
| `RateLimitError` | 429 | `RESOURCE_EXHAUSTED` |
|
|
151
|
+
| `ServerError` | 5xx | `INTERNAL` |
|
|
152
|
+
| `ConnectionError` | — | `UNAVAILABLE` |
|
|
153
|
+
|
|
154
|
+
`NotOwnerError.owner` names the owning node in fleet mode — reconnect there
|
|
155
|
+
and retry.
|
|
156
|
+
|
|
157
|
+
## Notes & caveats
|
|
158
|
+
|
|
159
|
+
Mirrors the Go client's caveats (see `docs/CLIENT.md` in the main repo):
|
|
160
|
+
|
|
161
|
+
- Embed documents and queries with the **same model** — distances are only
|
|
162
|
+
meaningful within one embedding space.
|
|
163
|
+
- Auto-created collections use the server's default metric (L2) and hybrid
|
|
164
|
+
IVF index; metric and index tuning are not yet exposed over the wire.
|
|
165
|
+
- **Bulk loads should use `put_many`** — one request per batch instead of one
|
|
166
|
+
per vector. The server validates the whole batch up front (id, non-empty
|
|
167
|
+
values, uniform dimension, at most 5000 vectors per batch: an invalid batch
|
|
168
|
+
applies nothing); on a rarer mid-batch failure earlier vectors remain
|
|
169
|
+
applied, and since puts are idempotent upserts you simply retry the batch.
|
|
170
|
+
Chunk larger loads into batches of 5000.
|
|
171
|
+
- Metadata is `str -> str` only; filter scalars (numbers, booleans) compare
|
|
172
|
+
against the stored string by their literal form (`"0.5"`, `"true"`).
|
|
173
|
+
|
|
174
|
+
## Development
|
|
175
|
+
|
|
176
|
+
```bash
|
|
177
|
+
cd sdk/python
|
|
178
|
+
pip install -e .[grpc] pytest
|
|
179
|
+
pytest tests/test_unit.py # stub-server unit tests
|
|
180
|
+
pytest tests/test_integration.py # builds & boots the real server (needs Go)
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
Regenerate the vendored gRPC stubs after changing `proto/vectordb.proto`
|
|
184
|
+
(from the repo root):
|
|
185
|
+
|
|
186
|
+
```bash
|
|
187
|
+
python -m grpc_tools.protoc -I proto \
|
|
188
|
+
--python_out=sdk/python/polign/_pb \
|
|
189
|
+
--grpc_python_out=sdk/python/polign/_pb \
|
|
190
|
+
--pyi_out=sdk/python/polign/_pb \
|
|
191
|
+
proto/vectordb.proto
|
|
192
|
+
sed -i '' 's/^import vectordb_pb2 as/from . import vectordb_pb2 as/' \
|
|
193
|
+
sdk/python/polign/_pb/vectordb_pb2_grpc.py
|
|
194
|
+
```
|
polign-0.1.0/README.md
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# polign — Python client for polign_db
|
|
2
|
+
|
|
3
|
+
A thin Python client for [polign](https://polign.com) with
|
|
4
|
+
two interchangeable transports:
|
|
5
|
+
|
|
6
|
+
- **HTTP** (`polign.Client`) — zero dependencies, pure stdlib. Talks JSON to
|
|
7
|
+
the server's HTTP listener (default `:23000`).
|
|
8
|
+
- **gRPC** (`polign.GrpcClient`) — install with the `[grpc]` extra.
|
|
9
|
+
Wire-compatible with the Go client, against the gRPC listener (default
|
|
10
|
+
`:23001`).
|
|
11
|
+
|
|
12
|
+
Both expose the same seven operations with identical semantics: `put`,
|
|
13
|
+
`put_many`, `get`, `get_many`, `list`, `delete`, `search`.
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
Not yet published to PyPI — install from a checkout of the repo:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pip install ./sdk/python # HTTP client, no dependencies
|
|
21
|
+
pip install './sdk/python[grpc]' # + gRPC transport (grpcio, protobuf)
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
(From inside this directory: `pip install .`; add `-e` for an editable
|
|
25
|
+
development install — needs pip ≥ 21.3.)
|
|
26
|
+
|
|
27
|
+
## Quick start
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
from polign import Client
|
|
31
|
+
|
|
32
|
+
client = Client("http://localhost:23000")
|
|
33
|
+
|
|
34
|
+
# Upsert. Collections are auto-created on first put, inferring their
|
|
35
|
+
# dimension from the vector. Values accept lists or numpy arrays.
|
|
36
|
+
client.put("docs", "doc-1", embedding, metadata={"title": "Cats", "url": "/cats"})
|
|
37
|
+
|
|
38
|
+
# Nearest-neighbour search (distance: smaller = closer)
|
|
39
|
+
for hit in client.search("docs", values=query_embedding, k=10):
|
|
40
|
+
print(hit.id, hit.distance, hit.metadata)
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Swap in gRPC by changing two lines — the rest of the code is identical:
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from polign import GrpcClient
|
|
47
|
+
|
|
48
|
+
client = GrpcClient("localhost:23001")
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Operations
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
client.put("docs", "doc-1", values, metadata={"k": "v"}) # upsert, returns id
|
|
55
|
+
client.put_many("docs", [Vector(id="a", values=va), Vector(id="b", values=vb)])
|
|
56
|
+
# batch upsert, one request
|
|
57
|
+
v = client.get("docs", "doc-1") # Vector(id, values, metadata)
|
|
58
|
+
vs = client.get_many("docs", ["a", "b"]) # batch read, byte-exact values:
|
|
59
|
+
# never a compressed reconstruction
|
|
60
|
+
# (get may return one on a cold-
|
|
61
|
+
# flushed collection); unknown ids
|
|
62
|
+
# omitted, request order kept
|
|
63
|
+
page = client.list("docs", limit=100, offset=0) # page.vectors, page.total
|
|
64
|
+
client.delete("docs", "doc-1") # True; False if absent (no error)
|
|
65
|
+
hits = client.search("docs", values=q, k=10) # [Hit(id, distance, score, metadata)]
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### Search options
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
from polign import Fusion
|
|
72
|
+
|
|
73
|
+
client.search(
|
|
74
|
+
"docs",
|
|
75
|
+
values=q, # vector leg (either values or text required)
|
|
76
|
+
k=10,
|
|
77
|
+
ef=64, # HNSW beam width override (0 = server default)
|
|
78
|
+
filter={"lang": "en"}, # metadata predicate (see below)
|
|
79
|
+
text="quick brown fox", # BM25 leg (needs a segment index server-side)
|
|
80
|
+
fusion=Fusion(method="linear", alpha=0.6), # hybrid fusion; default RRF
|
|
81
|
+
cold=True, nprobe=8, # serve from object-store segments
|
|
82
|
+
)
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
`text` alone runs a pure BM25 search; `values` + `text` runs hybrid search
|
|
86
|
+
fused server-side. `hit.score` is the BM25/fused relevance (larger = better)
|
|
87
|
+
and is `0.0` on a pure vector search.
|
|
88
|
+
|
|
89
|
+
`filter` takes the same dict language on both transports (see
|
|
90
|
+
`docs/FILTERING.md` in the main repo): bare values are equality
|
|
91
|
+
(ANDed across keys); per-key operator objects (`$eq`, `$ne`, `$in`, `$gt`,
|
|
92
|
+
`$gte`, `$lt`, `$lte`, `$exists`) and the composers `$and`/`$or`/`$not`
|
|
93
|
+
express richer predicates:
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
filter={
|
|
97
|
+
"tenant": "acme",
|
|
98
|
+
"score": {"$gte": 0.5},
|
|
99
|
+
"$or": [{"lang": "en"}, {"lang": {"$exists": False}}],
|
|
100
|
+
}
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Auth and tenants
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
client = Client(
|
|
107
|
+
"https://db.example.com:23000",
|
|
108
|
+
api_key="plgn_<key_id>_<secret>", # sent as Authorization: Bearer
|
|
109
|
+
tenant="acme/search/prod", # org/project/namespace
|
|
110
|
+
)
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Servers started without `-auth-stores` need no credentials. With TLS enabled
|
|
114
|
+
server-side, use an `https://` URL (HTTP) or pass
|
|
115
|
+
`credentials=grpc.ssl_channel_credentials()` (gRPC).
|
|
116
|
+
|
|
117
|
+
## Errors
|
|
118
|
+
|
|
119
|
+
All errors subclass `polign.PolignError`:
|
|
120
|
+
|
|
121
|
+
| Exception | HTTP | gRPC |
|
|
122
|
+
|-------------------------|------|----------------------|
|
|
123
|
+
| `InvalidArgumentError` | 400 | `INVALID_ARGUMENT` |
|
|
124
|
+
| `AuthenticationError` | 401 | `UNAUTHENTICATED` |
|
|
125
|
+
| `PermissionDeniedError` | 403 | `PERMISSION_DENIED` |
|
|
126
|
+
| `NotFoundError` | 404 | `NOT_FOUND` |
|
|
127
|
+
| `NotOwnerError` | 421 | `FAILED_PRECONDITION`|
|
|
128
|
+
| `RateLimitError` | 429 | `RESOURCE_EXHAUSTED` |
|
|
129
|
+
| `ServerError` | 5xx | `INTERNAL` |
|
|
130
|
+
| `ConnectionError` | — | `UNAVAILABLE` |
|
|
131
|
+
|
|
132
|
+
`NotOwnerError.owner` names the owning node in fleet mode — reconnect there
|
|
133
|
+
and retry.
|
|
134
|
+
|
|
135
|
+
## Notes & caveats
|
|
136
|
+
|
|
137
|
+
Mirrors the Go client's caveats (see `docs/CLIENT.md` in the main repo):
|
|
138
|
+
|
|
139
|
+
- Embed documents and queries with the **same model** — distances are only
|
|
140
|
+
meaningful within one embedding space.
|
|
141
|
+
- Auto-created collections use the server's default metric (L2) and hybrid
|
|
142
|
+
IVF index; metric and index tuning are not yet exposed over the wire.
|
|
143
|
+
- **Bulk loads should use `put_many`** — one request per batch instead of one
|
|
144
|
+
per vector. The server validates the whole batch up front (id, non-empty
|
|
145
|
+
values, uniform dimension, at most 5000 vectors per batch: an invalid batch
|
|
146
|
+
applies nothing); on a rarer mid-batch failure earlier vectors remain
|
|
147
|
+
applied, and since puts are idempotent upserts you simply retry the batch.
|
|
148
|
+
Chunk larger loads into batches of 5000.
|
|
149
|
+
- Metadata is `str -> str` only; filter scalars (numbers, booleans) compare
|
|
150
|
+
against the stored string by their literal form (`"0.5"`, `"true"`).
|
|
151
|
+
|
|
152
|
+
## Development
|
|
153
|
+
|
|
154
|
+
```bash
|
|
155
|
+
cd sdk/python
|
|
156
|
+
pip install -e .[grpc] pytest
|
|
157
|
+
pytest tests/test_unit.py # stub-server unit tests
|
|
158
|
+
pytest tests/test_integration.py # builds & boots the real server (needs Go)
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
Regenerate the vendored gRPC stubs after changing `proto/vectordb.proto`
|
|
162
|
+
(from the repo root):
|
|
163
|
+
|
|
164
|
+
```bash
|
|
165
|
+
python -m grpc_tools.protoc -I proto \
|
|
166
|
+
--python_out=sdk/python/polign/_pb \
|
|
167
|
+
--grpc_python_out=sdk/python/polign/_pb \
|
|
168
|
+
--pyi_out=sdk/python/polign/_pb \
|
|
169
|
+
proto/vectordb.proto
|
|
170
|
+
sed -i '' 's/^import vectordb_pb2 as/from . import vectordb_pb2 as/' \
|
|
171
|
+
sdk/python/polign/_pb/vectordb_pb2_grpc.py
|
|
172
|
+
```
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""polign — Python client for polign_db.
|
|
2
|
+
|
|
3
|
+
HTTP client (zero dependencies):
|
|
4
|
+
|
|
5
|
+
from polign import Client
|
|
6
|
+
c = Client("http://localhost:23000")
|
|
7
|
+
|
|
8
|
+
gRPC client (pip install polign[grpc]):
|
|
9
|
+
|
|
10
|
+
from polign import GrpcClient
|
|
11
|
+
c = GrpcClient("localhost:23001")
|
|
12
|
+
|
|
13
|
+
Both expose the same operations: put, put_many, get, list, delete, search.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from .client import Client
|
|
17
|
+
from .errors import (
|
|
18
|
+
AuthenticationError,
|
|
19
|
+
ConflictError,
|
|
20
|
+
ConnectionError,
|
|
21
|
+
InvalidArgumentError,
|
|
22
|
+
NotEnabledError,
|
|
23
|
+
NotFoundError,
|
|
24
|
+
NotOwnerError,
|
|
25
|
+
PermissionDeniedError,
|
|
26
|
+
PolignError,
|
|
27
|
+
RateLimitError,
|
|
28
|
+
ServerError,
|
|
29
|
+
UnavailableError,
|
|
30
|
+
)
|
|
31
|
+
from .types import CollectionBackend, CollectionInfo, Fusion, Hit, Vector, VectorPage
|
|
32
|
+
|
|
33
|
+
__version__ = "0.1.0rc8"
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"Client",
|
|
37
|
+
"GrpcClient",
|
|
38
|
+
"Vector",
|
|
39
|
+
"Hit",
|
|
40
|
+
"Fusion",
|
|
41
|
+
"VectorPage",
|
|
42
|
+
"PolignError",
|
|
43
|
+
"ConnectionError",
|
|
44
|
+
"InvalidArgumentError",
|
|
45
|
+
"NotFoundError",
|
|
46
|
+
"AuthenticationError",
|
|
47
|
+
"PermissionDeniedError",
|
|
48
|
+
"RateLimitError",
|
|
49
|
+
"NotOwnerError",
|
|
50
|
+
"ServerError",
|
|
51
|
+
"ConflictError",
|
|
52
|
+
"NotEnabledError",
|
|
53
|
+
"UnavailableError",
|
|
54
|
+
"CollectionBackend",
|
|
55
|
+
"CollectionInfo",
|
|
56
|
+
"__version__",
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def __getattr__(name):
|
|
61
|
+
# Lazy import so the base package works without grpcio installed.
|
|
62
|
+
if name == "GrpcClient":
|
|
63
|
+
from .grpc_client import GrpcClient
|
|
64
|
+
|
|
65
|
+
return GrpcClient
|
|
66
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Converts the dict filter language into the wire FilterExpr tree.
|
|
2
|
+
|
|
3
|
+
The gRPC client accepts the same metadata-filter dicts as the HTTP client
|
|
4
|
+
(the syntax of docs/FILTERING.md) and converts them client-side into the
|
|
5
|
+
``polign.v1.FilterExpr`` proto. The semantics mirror the server's JSON parser
|
|
6
|
+
(internal/filter/json.go): bare values are equality, per-key operator objects
|
|
7
|
+
($eq, $ne, $in, $gt, $gte, $lt, $lte, $exists) AND together with the four
|
|
8
|
+
range operators merged into one range, and $and/$or/$not compose sub-filters.
|
|
9
|
+
|
|
10
|
+
Only imported by the gRPC transport — needs the ``grpc`` extra for protobuf.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import Any, Dict, List, Optional
|
|
16
|
+
|
|
17
|
+
from . import errors
|
|
18
|
+
from ._pb import vectordb_pb2 as pb
|
|
19
|
+
|
|
20
|
+
_RANGE_OPS = {"$gt": "gt", "$gte": "gte", "$lt": "lt", "$lte": "lte"}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def filter_expr_from_dict(obj: Optional[Dict[str, Any]]) -> Optional["pb.FilterExpr"]:
|
|
24
|
+
"""Convert a filter dict to a FilterExpr, or None for no filter.
|
|
25
|
+
|
|
26
|
+
Raises :class:`polign.InvalidArgumentError` on a malformed filter, with
|
|
27
|
+
the same messages the server would produce.
|
|
28
|
+
"""
|
|
29
|
+
if not obj:
|
|
30
|
+
return None
|
|
31
|
+
if not isinstance(obj, dict):
|
|
32
|
+
raise errors.InvalidArgumentError("filter: not a JSON object")
|
|
33
|
+
return _parse_object(obj)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _parse_object(obj: Dict[str, Any]) -> Optional["pb.FilterExpr"]:
|
|
37
|
+
exprs: List[pb.FilterExpr] = []
|
|
38
|
+
for key in sorted(obj):
|
|
39
|
+
val = obj[key]
|
|
40
|
+
if key in ("$and", "$or"):
|
|
41
|
+
children = _parse_object_list(key, val)
|
|
42
|
+
junction = pb.FilterJunction(exprs=children)
|
|
43
|
+
if key == "$and":
|
|
44
|
+
exprs.append(pb.FilterExpr(**{"and": junction}))
|
|
45
|
+
else:
|
|
46
|
+
exprs.append(pb.FilterExpr(**{"or": junction}))
|
|
47
|
+
elif key == "$not":
|
|
48
|
+
if not isinstance(val, dict):
|
|
49
|
+
raise errors.InvalidArgumentError("filter: $not takes a filter object")
|
|
50
|
+
child = _parse_object(val)
|
|
51
|
+
if child is None:
|
|
52
|
+
raise errors.InvalidArgumentError(
|
|
53
|
+
"filter: $not takes a non-empty filter object"
|
|
54
|
+
)
|
|
55
|
+
exprs.append(pb.FilterExpr(**{"not": child}))
|
|
56
|
+
elif key.startswith("$"):
|
|
57
|
+
raise errors.InvalidArgumentError(f"filter: unknown operator {key!r}")
|
|
58
|
+
else:
|
|
59
|
+
exprs.append(_parse_field(key, val))
|
|
60
|
+
return _and_collapse(exprs)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _parse_object_list(op: str, val: Any) -> List["pb.FilterExpr"]:
|
|
64
|
+
if not isinstance(val, list) or not val:
|
|
65
|
+
raise errors.InvalidArgumentError(
|
|
66
|
+
f"filter: {op} takes a non-empty array of filter objects"
|
|
67
|
+
)
|
|
68
|
+
children = []
|
|
69
|
+
for item in val:
|
|
70
|
+
if not isinstance(item, dict):
|
|
71
|
+
raise errors.InvalidArgumentError(
|
|
72
|
+
f"filter: {op} takes a non-empty array of filter objects"
|
|
73
|
+
)
|
|
74
|
+
child = _parse_object(item)
|
|
75
|
+
if child is None:
|
|
76
|
+
raise errors.InvalidArgumentError(f"filter: {op}: empty filter object")
|
|
77
|
+
children.append(child)
|
|
78
|
+
return children
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _parse_field(key: str, val: Any) -> "pb.FilterExpr":
|
|
82
|
+
if isinstance(val, dict):
|
|
83
|
+
return _parse_field_ops(key, val)
|
|
84
|
+
return _cond(pb.FilterCond(key=key, eq=_scalar(key, val)))
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _parse_field_ops(key: str, ops: Dict[str, Any]) -> "pb.FilterExpr":
|
|
88
|
+
if not ops:
|
|
89
|
+
raise errors.InvalidArgumentError(f"filter: key {key!r}: empty operator object")
|
|
90
|
+
exprs: List[pb.FilterExpr] = []
|
|
91
|
+
bounds: Dict[str, str] = {}
|
|
92
|
+
numeric_set = lex_set = False
|
|
93
|
+
for op in sorted(ops):
|
|
94
|
+
val = ops[op]
|
|
95
|
+
if op == "$eq":
|
|
96
|
+
exprs.append(_cond(pb.FilterCond(key=key, eq=_scalar(key, val))))
|
|
97
|
+
elif op == "$ne":
|
|
98
|
+
eq = _cond(pb.FilterCond(key=key, eq=_scalar(key, val)))
|
|
99
|
+
exprs.append(pb.FilterExpr(**{"not": eq}))
|
|
100
|
+
elif op == "$in":
|
|
101
|
+
if not isinstance(val, list):
|
|
102
|
+
raise errors.InvalidArgumentError(f"filter: key {key!r}: $in takes an array")
|
|
103
|
+
values = [_scalar(key, item) for item in val]
|
|
104
|
+
exprs.append(_cond(pb.FilterCond(key=key, **{"in": pb.ValueList(values=values)})))
|
|
105
|
+
elif op in _RANGE_OPS:
|
|
106
|
+
if isinstance(val, str):
|
|
107
|
+
lex_set = True
|
|
108
|
+
elif isinstance(val, (int, float)) and not isinstance(val, bool):
|
|
109
|
+
numeric_set = True
|
|
110
|
+
else:
|
|
111
|
+
raise errors.InvalidArgumentError(
|
|
112
|
+
f"filter: key {key!r}: {op} takes a string or number"
|
|
113
|
+
)
|
|
114
|
+
bounds[_RANGE_OPS[op]] = val if isinstance(val, str) else str(val)
|
|
115
|
+
elif op == "$exists":
|
|
116
|
+
if not isinstance(val, bool):
|
|
117
|
+
raise errors.InvalidArgumentError(
|
|
118
|
+
f"filter: key {key!r}: $exists takes a boolean"
|
|
119
|
+
)
|
|
120
|
+
exprs.append(_cond(pb.FilterCond(key=key, exists=val)))
|
|
121
|
+
else:
|
|
122
|
+
raise errors.InvalidArgumentError(
|
|
123
|
+
f"filter: key {key!r}: unknown operator {op!r}"
|
|
124
|
+
)
|
|
125
|
+
if bounds:
|
|
126
|
+
if numeric_set and lex_set:
|
|
127
|
+
raise errors.InvalidArgumentError(
|
|
128
|
+
f"filter: key {key!r}: range bounds mix numbers and strings"
|
|
129
|
+
)
|
|
130
|
+
rng = pb.FilterRange(numeric=numeric_set, **bounds)
|
|
131
|
+
exprs.append(_cond(pb.FilterCond(key=key, range=rng)))
|
|
132
|
+
collapsed = _and_collapse(exprs)
|
|
133
|
+
assert collapsed is not None # ops was non-empty
|
|
134
|
+
return collapsed
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _and_collapse(exprs: List["pb.FilterExpr"]) -> Optional["pb.FilterExpr"]:
|
|
138
|
+
if not exprs:
|
|
139
|
+
return None
|
|
140
|
+
if len(exprs) == 1:
|
|
141
|
+
return exprs[0]
|
|
142
|
+
return pb.FilterExpr(**{"and": pb.FilterJunction(exprs=exprs)})
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _cond(c: "pb.FilterCond") -> "pb.FilterExpr":
|
|
146
|
+
return pb.FilterExpr(cond=c)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _scalar(key: str, val: Any) -> str:
|
|
150
|
+
"""A scalar's metadata string form: strings as-is, numbers by their
|
|
151
|
+
literal ("0.5"), booleans as "true"/"false" — matching what the HTTP
|
|
152
|
+
client's JSON encoding would send."""
|
|
153
|
+
if isinstance(val, bool): # before int: bool subclasses int
|
|
154
|
+
return "true" if val else "false"
|
|
155
|
+
if isinstance(val, str):
|
|
156
|
+
return val
|
|
157
|
+
if isinstance(val, (int, float)):
|
|
158
|
+
return str(val)
|
|
159
|
+
raise errors.InvalidArgumentError(
|
|
160
|
+
f"filter: key {key!r}: expected a string, number or boolean"
|
|
161
|
+
)
|
|
File without changes
|