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.

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
)
ArgumentWhat it does
valuesThe query embedding. Omit it (and set text) for a pure BM25 search.
kHow many hits to return (default 10).
efSearch beam width override; 0 = server default. Larger = more accurate, slower.
filterMetadata 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.
textBM25 query. Alone = text search; together with values = hybrid.
fusionFusion(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.
coldServe straight from object-storage segments instead of the in-memory index. Requires a segment store.
nprobeIVF probe-count override for cold queries; 0 = searcher default.
rescoreAccuracy/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_metadataReturn 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:

ExceptionHTTPgRPC
InvalidArgumentError400INVALID_ARGUMENT
AuthenticationError401UNAUTHENTICATED
PermissionDeniedError403PERMISSION_DENIED
NotFoundError404NOT_FOUND
ConflictError409ALREADY_EXISTS; FAILED_PRECONDITION on admin calls (collection still pending)
NotOwnerError421FAILED_PRECONDITION
RateLimitError429RESOURCE_EXHAUSTED
ServerError5xx / other unmapped statusINTERNAL / other unmapped code
NotEnabledError501UNIMPLEMENTED
UnavailableError503UNAVAILABLE (collection-admin calls only)
ConnectionErrornetwork failureUNAVAILABLE / 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

Other ways in

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.