Skip to main content

nidus (Python)

The Python client for nidus — a small, fast vector store. This package drives a running nidus serve instance over HTTP, whether it is on your laptop or a remote host.

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

NidusClient is built on urllib.request, so installing this package brings nothing else with it — the same zero-dependency posture the JS SDK gets from the platform fetch. Only the async client needs a third-party HTTP stack, and it is quarantined behind the async extra. Python 3.9+.

This package is versioned in lockstep with nidus itself: 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

"Local vs remote" is just the base URL — point the client at a local nidus serve or any reachable host.

import os

from nidus import NidusClient

# Local
db = NidusClient("http://127.0.0.1:7700")

# Remote, with the bearer token the server was started with (`nidus serve --token`)
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
)

Both clients work as context managers. Nothing is opened until the first request, and the default urllib transport is connectionless, so close() matters only once a pooled transport (below) or the async client is in play — using with means you never have 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

The async client

AsyncNidusClient mirrors the sync client method for method, with async def and aclose(). It requires pip install 'nidus[async]'; without httpx the import fails with an ImportError that names that fix. Either spelling works:

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

import nidus itself never touches httpx, which is what keeps the dependency genuinely optional.

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")])

asyncio.run(main())

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}},
    # a 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 Hit dataclasses (collection, id, score, attrs). attrs is a plain dict, so reach for .get unless every record in scope is known to carry the key — a search spans whatever the scope holds, and attrs are per-record, not a schema.

There is no float attribute type — floats belong in the vector, so passing one raises TypeError rather than silently truncating. For an explicit type, use the v.* helpers (v.str, v.int, v.bool, v.list, v.nil):

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 them apart.

Filtering

Build an AND-filter with the f.* helpers. Each predicate is a positive assertion about a present attribute, so an absent key matches nothing — including the negative predicates (ne, not_in) and the ranges.

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. The three trailing underscores are not style — in and and are reserved words in Python, so f.in_, f.not_in, and f.and_ are the JS SDK's f.in, f.notIn, and f.and. Nothing else 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.

Comparisons are same-type only (int↔int numeric, str↔str lexical, bool↔bool). A range predicate against a mismatched type matches nothing, which is the usual reason a filter mysteriously returns no rows.

Full-text and hybrid search

db.set_fts_schema("docs", ["body"])

# 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,
)

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.

Remembering and recalling (text-native)

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"})

# Summarize first, then embed the summary (the server also needs --summarize-provider).
# The stored record additionally carries `nidus.summary` and `nidus.source` attrs.
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")])

Both raise NidusError with status 400 against a server that has no embedder configured (the message names --embed-provider), and mode="summarize" without a summarizer is likewise a 400. The client only ever sends text; the embedding always happens server-side.

Everything else

Every endpoint of the HTTP API has a method:

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; vector is None for text-only
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.health()                         # bool

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".

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

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 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.

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; for 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) -> None:
        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) -> None:
        # 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 nothing extra is needed for bulk ingest.

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.

Value errors are raised locally, before any request: a float attribute or a non-string list element is a TypeError, and an integer outside i64 is a ValueError (Python's ints are unbounded; the store's Int is not).

Documentation

Full documentation: https://nidus.duckedup.org/sdks/python/

License

MIT

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

nidus-0.43.0.tar.gz (59.2 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

nidus-0.43.0-py3-none-any.whl (38.0 kB view details)

Uploaded Python 3

File details

Details for the file nidus-0.43.0.tar.gz.

File metadata

  • Download URL: nidus-0.43.0.tar.gz
  • Upload date:
  • Size: 59.2 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/7.0.0 CPython/3.12.13

File hashes

Hashes for nidus-0.43.0.tar.gz
Algorithm Hash digest
SHA256 2a24e44ef6847b8d5116aa655dcd5c941762279b4bf2c4377cdcb9118f6aae33
MD5 01eba89781100a133279f5a5a8de43c0
BLAKE2b-256 b5bf585b9869101e1bfd4d42d17f52fc6c4cf38f8689562057aae1a2f89ba425

See more details on using hashes here.

File details

Details for the file nidus-0.43.0-py3-none-any.whl.

File metadata

  • Download URL: nidus-0.43.0-py3-none-any.whl
  • Upload date:
  • Size: 38.0 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/7.0.0 CPython/3.12.13

File hashes

Hashes for nidus-0.43.0-py3-none-any.whl
Algorithm Hash digest
SHA256 3eeaf82ce2306ed8f4e4aaf3c2e6e180c499d6f7361b3a2ab86c361c47ac987c
MD5 b2f008e110f98e36f336264858a6bc1f
BLAKE2b-256 40709fc4e23abcaf80ea01663f086e2a827d1e1066f8e3d68946d38d1a20a242

See more details on using hashes here.

Release history Release notifications | RSS feed

0.97.0

2 files

0.96.1

2 files

0.96.0

2 files

0.95.0

2 files

0.94.0

2 files

0.93.0

2 files

0.92.0

2 files

0.91.1

2 files

0.91.0

2 files

0.90.0

2 files

0.89.0

2 files

0.88.0

2 files

0.87.0

2 files

0.86.0

2 files

0.85.0

2 files

0.84.0

2 files

0.83.0

2 files

0.82.0

2 files

0.81.0

2 files

0.80.0

2 files

0.79.0

2 files

0.78.0

2 files

0.77.0

2 files

0.76.0

2 files

0.75.0

2 files

0.74.0

2 files

0.73.0

2 files

0.72.0

2 files

0.71.0

2 files

0.70.0

2 files

0.69.0

2 files

0.68.0

2 files

0.67.0

2 files

0.66.0

2 files

0.65.0

2 files

0.64.0

2 files

0.63.1

2 files

0.63.0

2 files

0.62.2

2 files

0.62.1

2 files

0.62.0

2 files

0.61.0

2 files

0.60.0

2 files

0.59.0

2 files

0.58.0

2 files

0.57.0

2 files

0.56.1

2 files

0.56.0

2 files

0.55.4

2 files

0.55.3

2 files

0.55.2

2 files

0.55.1

2 files

0.55.0

2 files

0.54.0

2 files

0.53.1

2 files

0.53.0

2 files

0.52.0

2 files

This release

0.43.0 This release

2 files

0.42.0

2 files

0.41.0

2 files

0.40.0

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page