Python SDK
polign_db ships a Python package,
polign.
It has two interchangeable transports with the same operations and identical semantics:
put, put_many, get, get_many,
list, delete, search.
- HTTP (
polign.Client) is pure standard library with zero dependencies. Talks JSON to the server's HTTP listener (default:23000). - gRPC (
polign.GrpcClient) is the[grpc]extra, and it runs against the gRPC listener (default:23001).
Install
# HTTP client — no dependencies pip install polign # with the gRPC transport (adds grpcio + protobuf) pip install "polign[grpc]"
Quick start
from polign import Client client = Client("http://localhost:23000") # Upsert. Collections are auto-created on first put, inferring their # dimension from the vector. Values accept lists or numpy arrays. client.put("docs", "doc-1", embedding, metadata={"title": "Cats", "url": "/cats"}) # Nearest-neighbour search (distance: smaller = closer) for hit in client.search("docs", values=query_embedding, k=10): print(hit.id, hit.distance, hit.metadata)
Swap in gRPC by changing two lines. The rest of the code is identical:
from polign import GrpcClient client = GrpcClient("localhost:23001")
The operations
client.put("docs", "doc-1", values, metadata={"k": "v"}) # upsert, returns id
client.put_many("docs", [Vector(id="a", values=va), Vector(id="b", values=vb)])
# batch upsert, one request
v = client.get("docs", "doc-1") # Vector(id, values, metadata)
vs = client.get_many("docs", ["doc-1", "doc-2"]) # batch get, one request;
# values come back byte-exact
page = client.list("docs", limit=100, offset=0) # page.vectors, page.total
page = client.list("docs", filter={"user_id": "u1"})
# filtered listing: same dict language
# as search; offset/total count matches
client.delete("docs", "doc-1") # True; False if absent (no error)
hits = client.search("docs", values=q, k=10) # [Hit(id, distance, score, metadata)]
put_many is the bulk-load path. It makes one request per batch instead of one per
vector, up to 5000 vectors per batch. The server validates the whole batch up front (an
invalid batch applies nothing); on a rarer mid-batch failure earlier vectors remain applied,
and since puts are idempotent upserts you simply retry the batch. Chunk larger loads into
batches of 5000.
Both clients are context managers (with Client(…) as c:) and expose
close(); the HTTP client also has client.health(), a boolean
GET /healthz probe.
Typed metadata
Metadata values are typed scalars: strings, numbers, and booleans (server v0.3.0+,
SDK 0.3.0+). Write them as plain Python values on either transport. Reads render every
value as a string by default, so existing code sees no change; pass
typed_metadata=True to get, get_many,
list, or search to get values back with their stored types:
client.put("docs", "m1", values, metadata={"score": 0.85, "published": True})
v = client.get("docs", "m1") # {"score": "0.85", "published": "true"}
v = client.get("docs", "m1", typed_metadata=True) # {"score": 0.85, "published": True}
Filters compare typed values by kind, so numbers compare numerically
(5 matches 5.0). Nested objects and arrays are rejected;
keep timestamps as RFC3339 strings, which sort correctly in lexicographic ranges.
Search options
from polign import Fusion client.search( "docs", values=q, # vector leg (either values or text required) k=10, ef=64, # HNSW beam width override (0 = server default) filter={"lang": "en"}, # metadata predicate (dict language, see below) text="quick brown fox", # BM25 leg (needs a segment index server-side) fusion=Fusion(method="linear", alpha=0.6), # hybrid fusion; default RRF cold=True, nprobe=8, # serve from object-store segments )
| Argument | What it does |
|---|---|
values | The query embedding. Omit it (and set text) for a pure BM25 search. |
k | How many hits to return (default 10). |
ef | Search beam width override; 0 = server default. Larger = more accurate, slower. |
filter | Metadata predicate. Bare values are equality (AND across keys); per-key operators ($eq, $ne, $in, $gt/$gte/$lt/$lte, $exists) and the composers $and/$or/$not express richer predicates. Identical on both transports (the gRPC client converts the dict to the wire's typed tree). Applied in-graph on both the in-memory and cold paths. |
text | BM25 query. Alone = text search; together with values = hybrid. |
fusion | Fusion(method="rrf", rrf_k=60) (default) or Fusion(method="linear", alpha=…), where alpha is the vector-leg weight, default 0.5. Only valid when both legs are present. |
cold | Serve straight from object-storage segments instead of the in-memory index. Requires a segment store. |
nprobe | IVF probe-count override for cold queries; 0 = searcher default. |
rescore | Accuracy/speed dial for compressed (IVF-PQ) collections: 0 = server default (accuracy-first), > 0 = exact-rescore pool of that size, < 0 = fastest approximate ranking with no exact rescore. Other collection types ignore it. |
typed_metadata | Return hit metadata with its stored types (numbers, booleans) instead of the default string rendering. |
Metadata filters
The filter dict extends the plain equality map with Mongo-style operators, so every
{"key": "value"} filter keeps working unchanged. Comparisons are type-aware:
number bounds compare numerically, string range bounds compare lexicographically (store
sortable encodings like RFC3339 timestamps). Two legacy rules keep pre-typed data working:
a string record value matches a typed operand by its literal form, and numeric range bounds
still match string values that parse as floats. The reverse is strict — once a value is
stored typed, a string operand no longer matches it:
client.search("docs", values=q, k=10, filter={
"author": "acme", # bare value = equality
"score": {"$gte": 0.5}, # numeric range
"ts": {"$gte": "2026-01-01T00:00:00Z"}, # lexicographic range
"tag": {"$in": ["a", "b"]},
"$or": [{"lang": "en"}, {"lang": {"$exists": False}}],
})
Each Hit carries a distance (vector metric, smaller-is-closer)
and a score (relevance, larger-is-better: BM25 on a text search, the fused score
on a hybrid one, 0.0 on a pure vector search). Text and hybrid search read the
object-store segment index, so they need the server started with
-segment-stores and a persistor running. See the
install & use guide.
Auth & TLS
client = Client(
"https://db.example.com:23000",
api_key="plgn_<key_id>_<secret>", # sent as Authorization: Bearer
)
Both constructors take api_key and timeout (default 30 s), and
both clients are safe to share across threads. Everyday reads and writes never need a key, because
an API key is only checked on the collection-management calls of a server running the
collections registry (-byo-store). For TLS, use an https:// URL
(HTTP client, with an optional ssl_context) or pass
credentials=grpc.ssl_channel_credentials() (gRPC client, plaintext by
default).
Collection management
Collections are auto-created on first put, so most applications never touch these. On a
server running the collections registry (-byo-store), both clients also expose
the collection API — binding collections to customer-owned buckets, with an API key:
from polign import CollectionBackend info = client.create_collection( "docs", CollectionBackend(uri="s3://my-bucket/docs", role_arn="arn:aws:iam::…") ) # info.status: "active" or "pending" info = client.get_collection("docs") # describe one collection cols = client.list_collections() # every registered collection client.verify_collection("docs") # re-run bucket verification now client.delete_collection("docs") # unregister; bucket data is untouched
A pending collection activates automatically (within ~30s) once you finish your side:
write info.claim_token to info.claim_path in the bucket, or attach
the trust policy from client.backend_setup(uri) (HTTP client only) to the role. Without
-byo-store these calls raise NotEnabledError. The
operations guide walks the whole
lifecycle.
Errors
All errors subclass polign.PolignError, so callers never touch HTTP status
codes or gRPC status types:
| Exception | HTTP | gRPC |
|---|---|---|
InvalidArgumentError | 400 | INVALID_ARGUMENT |
AuthenticationError | 401 | UNAUTHENTICATED |
PermissionDeniedError | 403 | PERMISSION_DENIED |
NotFoundError | 404 | NOT_FOUND |
ConflictError | 409 | ALREADY_EXISTS; FAILED_PRECONDITION on admin calls (collection still pending) |
NotOwnerError | 421 | FAILED_PRECONDITION |
RateLimitError | 429 | RESOURCE_EXHAUSTED |
ServerError | 5xx / other unmapped status | INTERNAL / other unmapped code |
NotEnabledError | 501 | UNIMPLEMENTED |
UnavailableError | 503 | UNAVAILABLE (collection-admin calls only) |
ConnectionError | network failure | UNAVAILABLE / network failure |
In fleet mode, NotOwnerError means another node owns the resource; over HTTP
its owner attribute names the node to reconnect to (over gRPC the owner is in
the error message).
Notes & caveats
- Embed documents and queries with the same model, because distances are only meaningful within one embedding space.
- Auto-created collections use the server's default metric (L2) and hybrid IVF index; metric and index tuning are not yet exposed over the wire.
- Metadata values are typed scalars (string, number, boolean); nested objects and
arrays are rejected. Reads render strings by default — opt in with
typed_metadata=True. Numbers written over gRPC travel as doubles, so a source literal like0.50reads back as0.5(HTTP JSON preserves the literal).
The same operations are available from the command line, over raw HTTP/JSON (curl examples in the HTTP API reference), and the gRPC wire API.