Skip to content

Python SDK

nidus is the official Python client for nidus. It drives a running nidus serve instance over HTTP, local or remote.

Terminal window
pip install nidus # the sync client: pulls ZERO dependencies
pip install 'nidus[async]' # adds AsyncNidusClient (httpx)

NidusClient is built on urllib.request from the standard library, so pip install nidus brings nothing else with it: the same zero-dependency posture the JavaScript SDK gets from the platform fetch. Only the async client needs a third-party HTTP stack, and it lives behind the async extra. Python 3.9+, typed (py.typed ships in the wheel).

The SDK is versioned in lockstep with nidus: the crate’s version is the single source of truth, so a given nidus release on PyPI is the client for the identically-numbered nidus release. Match the two and the wire contract lines up.

“Local vs remote” is just the base URL: point the client at a local nidus serve or any reachable host. When the server was started with a token, pass it as token.

import os
from nidus import NidusClient
# Local
db = NidusClient("http://127.0.0.1:7700")
# Remote, with bearer-token auth
db = NidusClient(
"https://nidus.internal.example.com",
token=os.environ["NIDUS_TOKEN"],
timeout=5.0, # per-request timeout in SECONDS; None (the default) means no timeout
)

Nothing is opened until the first request. The client is also a context manager, and the default urllib transport is connectionless, so close() only really matters once a pooled transport or the async client is in play. Using with means never having to remember which case you are in:

with NidusClient("http://127.0.0.1:7700") as db:
print(db.health()) # True when the server answers; never raises

AsyncNidusClient mirrors the sync client method for method, with async def and aclose(). It is the one part of the SDK that needs a dependency, so it requires:

Terminal window
pip install 'nidus[async]'

Without httpx the import raises an ImportError that names that fix, rather than an opaque ModuleNotFoundError. Either spelling of the import works:

from nidus.aio import AsyncNidusClient # explicit
import nidus; nidus.AsyncNidusClient # lazy: resolved on first attribute access

import nidus itself never touches httpx; that is what keeps the dependency genuinely optional instead of one every caller pays for.

import asyncio
from nidus import f
from nidus.aio import AsyncNidusClient
async def main():
async with AsyncNidusClient("http://127.0.0.1:7700") as db:
await db.create_collection("docs")
await db.upsert("docs", [{"id": "a", "vector": [0.1, 0.2, 0.3]}])
hits = await db.search(query=[0.1, 0.2, 0.3], top_k=5, filter=[f.eq("lang", "rust")])
return hits
asyncio.run(main())

Everything below is written against the sync client; add await and the async client behaves identically.

attrs accept plain Python values (str, int, bool, lists of str, and None) and are normalized to nidus’s typed values for you. Results come back with attrs decoded to plain Python values.

db.create_collection("docs")
db.upsert("docs", [
{"id": "a", "vector": [0.1, 0.2, 0.3], "attrs": {"lang": "rust", "year": 2024}},
{"id": "b", "vector": [0.4, 0.5, 0.6], "attrs": {"lang": "go", "year": 2023}},
# text-only doc: omit the vector
{"id": "c", "attrs": {"body": "vector stores are neat"}},
])
for hit in db.search(query=[0.1, 0.2, 0.3], top_k=5):
print(hit.collection, hit.id, hit.score, hit.attrs.get("lang"))

upsert and delete return a count. The search family returns a list of frozen Hit dataclasses (collection, id, score, attrs), so a typo in a field name fails at the call site instead of quietly returning None. attrs itself is a plain dict: reach for .get unless every record in scope is known to carry the key, since attrs are per-record rather than a schema.

Python decides Int vs Float from the runtime type, so 2.0 is a Float and 2 is an Int. That matters because comparisons are same-type only: a Float range filter never matches a record whose value was stored as an Int. For an explicit type, use the v.* helpers (v.str, v.int, v.float, v.bool, v.list, v.datetime, v.nil); v.datetime takes a datetime and travels as UTC epoch milliseconds:

from nidus import v
db.upsert("docs", [{"id": "d", "attrs": {"tags": v.list(["a", "b"]), "rank": v.int(7)}}])

v.nil() is the explicit Null value (“set, and empty”), which is a different fact from an absent key (“not set / not indexed”). The SDK keeps the two apart in both directions.

search_similar runs a search using the vector already stored at a record, instead of a query you supply yourself:

hits = db.search_similar("docs", "a", top_k=5)

Takes the same keyword options as search (top_k, offset, min_score, filter, exact, rank_by, limit_per, diversity), plus scope: which collections to search, defaulting to the source record’s own collection rather than every collection in the store the way a plain search’s omitted scope does.

The source record is never in its own results, dropped by id after ranking, not by a score cutoff, so a genuine duplicate of it (also scoring near 1.0) still comes back. A collection/id pair naming no record, or a record with no stored vector (a text-only entry), raises NidusError naming the id and the reason, not an empty result.

Build an AND-filter with the f.* helpers. Each predicate is a positive assertion about a present attribute: an absent key matches nothing, including the negative predicates. See Search & filters for the full semantics.

from nidus import f
hits = db.search(
query=[0.1, 0.2, 0.3],
top_k=10,
filter=f.and_(
f.eq("lang", "rust"),
f.ge("year", 2020),
f.in_("status", ["published", "draft"]),
f.glob("path", "src/*"),
),
)

Predicates: eq, ne, glob, iglob, in_, not_in, lt, le, gt, ge, plus and_. iglob is glob with ASCII case folded on both sides.

Those three trailing underscores are not a style choice: in and and are reserved words in Python, so f.in_, f.not_in, and f.and_ are the JavaScript SDK’s f.in, f.notIn, and f.and. Nothing else in the surface deviates.

A Filter is just a list of predicates, AND-combined, so f.and_(...) is sugar for building that list; filter=[f.eq("lang", "rust")] is equally valid, and [] matches everything.

db.set_fts_schema("docs", ["body"])
# …or tune BM25 / the analyzer per field; an omitted knob keeps the server default
db.set_fts_schema("docs", ["title", {"field": "body", "k1": 1.5, "ascii_folding": True}])
# BM25 text search over one indexed field
text_hits = db.text_search(field="body", query="vector store", top_k=10)
# Fuse a vector query and a BM25 query via reciprocal rank fusion
hybrid_hits = db.hybrid_search(
vector=[0.1, 0.2, 0.3],
field="body",
text="vector store",
top_k=10,
)
# Prefix match for typeahead: only the final word of `query` expands
typeahead_hits = db.text_search(field="title", query="quick br", prefix=True, top_k=10)

hybrid_search takes no min_score: its score is a fused RRF rank, not a similarity, so there is no meaningful floor to set. rrf_k and candidates tune the fusion.

prefix (default False, and omitted from the request body when unset) expands only the final word of the query text to every indexed term carrying it as a prefix, for autocomplete as a caller is still typing; earlier words still match exactly. It is capped at 256 expansions, past which the commonest completions win rather than the call failing. FtsClause (the clauses spelling for several fields) carries the same key, so a multi-clause query can prefix-match one field while another stays exact.

suggest returns ranked term completions for a partial word, for an autocomplete dropdown, on both the sync and async clients:

result = db.suggest(field="title", prefix="nid", scope=["docs"], limit=5)
for s in result.suggestions:
print(s.term, s.df)
print(result.matched) # > len(result.suggestions) means the 256-term cap truncated

Completions are ranked by document frequency, commonest first: the opposite of how a prefix clause ranks documents. Only the prefix’s final token is completed. Unlike a prefix clause, which matches stems, suggest matches surface forms, so a corpus indexing “running” completes running at every keystroke. limit (default 10, a dropdown’s size rather than a page’s) truncates the already-256-capped list. An omitted scope completes from every collection.

Each df is a conditioned count. filter narrows it to the matching documents, so a completion whose only documents are filtered out is absent rather than present with a corpus-wide count. The words before the final token narrow it too, so pass the whole phrase typed so far:

# "brown" is the commonest br* here, but no document says both "quick" and "brown"
result = db.suggest(
field="body",
prefix="quick br",
scope=["docs"],
filter=[{"Eq": ["tenant", {"Str": "acme"}]}],
)
# result.suggestions: [Suggestion(term="bracket", df=1)] ("brown" is not offered)

A single-token prefix, or one whose earlier words are all stopwords, has no head terms and behaves exactly as the bare fragment does.

If the exact match finds nothing at all, suggest retries with a short edit-distance budget before giving up, so a mistyped fragment like "runing" still completes to "running". This is on by default (fuzzy=None); pass fuzzy=False to opt out.

batch_search answers several vector queries in one round-trip (16 max), saving a hop per query when one question is fanned into several phrasings:

queries = [
{"query": [0.1, 0.2, 0.3], "top_k": 5},
{"query": [0.4, 0.5, 0.6], "top_k": 5, "filter": [f.eq("lang", "rust")]},
]
results = db.batch_search(queries)
for hits in results:
...
# Merge every leg into one ranking via reciprocal rank fusion
fused = db.batch_search(queries, fuse=True, rrf_k=60.0)[0]

With fuse=True the answer is still a list, holding the single fused ranking as its one element, so indexing does not change shape with the flag. weights must be empty or exactly as long as queries.

aggregate counts the records a filter matches and sums the named attributes, answered from the in-RAM index alone: no record is built and no vector is read.

totals = db.aggregate(scope=["docs"], filter=[f.eq("lang", "rust")], sum=["year"])
print(totals.count, totals.sums["year"])
# One Group per distinct group_by value, alongside the unchanged whole-scope totals
by_lang = db.aggregate(sum=["year"], group_by="lang")
for group in by_lang.groups:
print(group.value, group.count, group.sums)

A missing or non-numeric value is skipped rather than counted as zero, so a field nothing matched sums to 0.

When the server is started with an embedder (nidus serve --embed-provider …) you can send text and let the server embed it: no need to compute vectors client-side. remember embeds and upserts; recall embeds the query and vector-searches.

# Embed "the quick brown fox" and store it under id "a"
db.remember("notes", "a", "the quick brown fox", attrs={"tag": "x"})
# Expire after an hour, and fold near-duplicates into the closest existing entry.
# On a dedupe match, result.deduped is True and result.id names the entry the
# write actually landed on.
result = db.remember("notes", "a2", "the quick brown fox!",
ttl_seconds=3600, dedupe_threshold=0.95)
# Summarize first, then embed the summary (the server also needs --summarize-provider).
# The stored record additionally carries the `nidus.summary` attr, with the raw
# input in `nidus.text`.
db.remember("notes", "b", long_article, mode="summarize")
# Embed the query text and search, best first
hits = db.recall("notes", "quick fox", top_k=5, min_score=0.2, filter=[f.eq("tag", "x")])
# Reinforce: stamp nidus.access_count / nidus.last_accessed on every hit
# returned, and push an existing expiry forward. Off by default, so a plain
# recall stays a pure read.
reinforced = db.recall("notes", "quick fox", top_k=5, reinforce=True, extend_ttl_seconds=86400)

remember returns a RememberResult (id, upserted, deduped): id is the record that actually changed, which is not always the one passed in, and upserted is the row count from the underlying write. See Parity across the surfaces for how these semantics line up with the other SDKs and the MCP surface.

Setting reinforce makes the call a write: it takes the server’s writer lock to apply the stamp, and against a server started --read-only the stamp is skipped with a warning rather than failing the recall. extend_ttl_seconds only applies with reinforce set, and only pushes an existing nidus.expires_at forward; it never gives an expiry to an entry that had none. See reinforcement.

Against a server started without an embedder both raise NidusError with status 400, and the message names --embed-provider; mode="summarize" without a summarizer configured is likewise a 400. The client only ever sends text; the embedding always happens server-side.

Every data-plane endpoint of the HTTP API has a typed method. The ops probes are typed too, with one exception: ready(), cluster(), and refresh() each have a method, while /metrics stays unwrapped since it is a scraper’s endpoint, not something application code calls. ready() returns a verdict rather than raising when the server reports not-ready, so a 503 is something you check, not something you catch:

db.collections() # list[str]
db.stats() # dimension, distance, ANN config, collections, footprint
db.list(scope=["docs"], filter=[f.eq("lang", "rust")], offset=0, limit=50)
db.records("docs") # every record, attrs decoded
db.get_meta("docs"); db.set_meta("docs", {"owner": "search-team"})
db.delete("docs", ["a"]) # by id
db.delete_where("docs", f.and_(f.lt("year", 2000)))
db.flush(); db.compact()
db.drop_collection("docs")
db.aliases() # dict[str, str]: every alias and its concrete target
db.set_alias("docs", "docs_v2") # create or repoint; the target must already exist
db.drop_alias("docs") # removes the alias, not the records
db.health() # bool
r = db.ready() # Readiness(ready=..., role=..., staleness_secs=...)
if not r.ready: print(r.reason) # a 503 is an answer, not an exception
db.cluster() # ClusterStatus
db.refresh() # bool: did it adopt newer state

aliases(), set_alias(), and drop_alias() exist on both NidusClient and AsyncNidusClient with matching signatures. An alias resolves in one hop: set_alias refuses a target that is itself an alias, and drop_collection refuses while an alias still points at the collection.

Optional arguments all default to None, which means “omit the key” so the server’s default applies (top_k = 10, limit = 100, rrf_k = 60.0, candidates = 100). Those numbers are deliberately not restated in Python, and it is why the defaults are None rather than a number: top_k=0 is a legitimate request for zero results, so 0 cannot double as “unset”.

Two Nones carry real information and are never flattened:

  • stats().ann is None when the store does exact brute-force search, as opposed to an AnnInfo full of defaults.
  • Record.vector is None (never []) for a text-only document.

Python’s type system cannot express two mistakes that produce a well-formed request the server accepts and answers wrongly, so the SDK refuses them at the call site instead:

db.delete("docs", "a") # TypeError: a str IS a Sequence[str]; this asked to
# delete the ids "a"... one character at a time
db.search(query=vec, scope="docs") # TypeError: same slip, five collections that
# do not exist, an empty result and a 200
f.in_("lang", "rust") # TypeError: one predicate value per character
db.delete_where("docs", []) # ValueError: an empty filter matches EVERYTHING, so this
# deleted the whole collection; use drop_collection

None of these raise anywhere else in the stack: mypy --strict accepts all four, and the server answers 200. The list forms (["a"], ["docs"], ["rust"]) are what was meant.

Vectors, conversely, are accepted more widely than JSON allows: elements are coerced with float(), so numpy arrays (np.float32 is not a float subclass and json refuses it), torch scalars and Decimal all work without a .tolist() first.

The honest cost of a standard-library-only client: urllib.request opens a fresh connection per request. For interactive use that is invisible; over a long run of sequential upserts the handshakes are measurable overhead.

The escape hatch is transport=: a callable (method, url, headers, body, timeout) -> (status, text). Hand in one backed by httpx or requests and you get pooling, keep-alive, retries, or instrumentation, without the SDK taking on a dependency for everyone:

import httpx
from nidus import NidusClient
class PooledTransport:
"""A connection-pooling transport for bulk ingest."""
def __init__(self):
self._client = httpx.Client()
def __call__(self, method, url, headers, body, timeout):
# A transport RETURNS non-2xx statuses; only a failure to get any response at
# all should raise. httpx already behaves that way.
r = self._client.request(method, url, content=body, headers=headers, timeout=timeout)
return r.status_code, r.text
def close(self):
# NidusClient.close() calls close() on the transport if it has one, so
# `with NidusClient(...)` shuts the pool down too.
self._client.close()
with NidusClient("http://127.0.0.1:7700", transport=PooledTransport()) as db:
for batch in batches:
db.upsert("docs", batch)

The same seam is what lets the SDK’s own unit tests exercise every endpoint with no server and no socket.

AsyncNidusClient takes the natural equivalent for its own stack: transport= there is an httpx.AsyncBaseTransport: a pre-tuned pool, or an httpx.MockTransport for tests. It pools by default, so an async caller needs nothing extra for bulk ingest.

Note also that batching is the bigger lever than pooling: upsert takes a list, and one request per batch is one fsync per batch on the server.

A failed request raises NidusError carrying the HTTP status the server reported, so you can tell a client fault from a server fault:

from nidus import NidusError
try:
db.upsert("docs", records)
except NidusError as err:
if err.is_bad_request: # 400: e.g. a vector dimension mismatch
...
if err.is_locked: # 409: the writer lock is held by another process
...
print(err.status, err.message)

Also available: is_read_only (403), is_out_of_capacity (507, max_vector_bytes exceeded, or OOM), and is_transport_error.

A status of 0 is the sentinel for no response at all: connection refused, DNS failure, or the request exceeded timeout. Every nidus SDK uses the same sentinel, so “was this even reachable?” is answered identically in all of them.

Bad attribute values are rejected locally, before any request is made: a float attribute or a non-string list element raises TypeError, and an integer outside i64 raises ValueError (Python’s ints are unbounded; the store’s Int is not).