Python SDK
nidus is the official Python client for nidus. It
drives a running nidus serve instance over HTTP, local or
remote.
pip install nidus # the sync client: pulls ZERO dependenciespip 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.
Connecting
Section titled “Connecting”“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
# Localdb = NidusClient("http://127.0.0.1:7700")
# Remote, with bearer-token authdb = 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 raisesThe async client
Section titled “The async client”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:
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 # explicitimport nidus; nidus.AsyncNidusClient # lazy: resolved on first attribute accessimport 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 ffrom 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.
Upserting and searching
Section titled “Upserting and searching”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.
Similar records (“more like this”)
Section titled “Similar records (“more like this”)”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.
Filtering
Section titled “Filtering”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.
Full-text and hybrid search
Section titled “Full-text and hybrid search”db.set_fts_schema("docs", ["body"])
# …or tune BM25 / the analyzer per field; an omitted knob keeps the server defaultdb.set_fts_schema("docs", ["title", {"field": "body", "k1": 1.5, "ascii_folding": True}])
# BM25 text search over one indexed fieldtext_hits = db.text_search(field="body", query="vector store", top_k=10)
# Fuse a vector query and a BM25 query via reciprocal rank fusionhybrid_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` expandstypeahead_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 (typeahead)
Section titled “Suggest (typeahead)”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 truncatedCompletions 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 and aggregation
Section titled “Batch search and aggregation”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 fusionfused = 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 totalsby_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.
Remembering and recalling
Section titled “Remembering and recalling”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 firsthits = 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.
The rest of the API
Section titled “The rest of the API”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, footprintdb.list(scope=["docs"], filter=[f.eq("lang", "rust")], offset=0, limit=50)db.records("docs") # every record, attrs decodeddb.get_meta("docs"); db.set_meta("docs", {"owner": "search-team"})db.delete("docs", ["a"]) # by iddb.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 targetdb.set_alias("docs", "docs_v2") # create or repoint; the target must already existdb.drop_alias("docs") # removes the alias, not the recordsdb.health() # boolr = db.ready() # Readiness(ready=..., role=..., staleness_secs=...)if not r.ready: print(r.reason) # a 503 is an answer, not an exceptiondb.cluster() # ClusterStatusdb.refresh() # bool: did it adopt newer statealiases(), 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().annisNonewhen the store does exact brute-force search, as opposed to anAnnInfofull of defaults.Record.vectorisNone(never[]) for a text-only document.
Three things the client refuses to send
Section titled “Three things the client refuses to send”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 timedb.search(query=vec, scope="docs") # TypeError: same slip, five collections that # do not exist, an empty result and a 200f.in_("lang", "rust") # TypeError: one predicate value per characterdb.delete_where("docs", []) # ValueError: an empty filter matches EVERYTHING, so this # deleted the whole collection; use drop_collectionNone 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.
Bulk ingest: supply a pooled transport
Section titled “Bulk ingest: supply a pooled transport”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.
Errors
Section titled “Errors”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).