Skip to main content

LuxirClient

A lightweight Python client for Luxir, a hybrid search engine (BM25 + vector + facets, all in one request). This client talks to Luxir's HTTP API.

Installation

This isn't published to PyPI yet — install it from a local checkout:

pip install -e /path/to/LuxirClient

It depends only on requests.

Quickstart

from luxir_client import LuxirClient

client = LuxirClient("http://localhost:9400")

# Collections auto-create on first write — no setup step required.
client.index("books", [
    {"id": "b1", "title_t": "Dune", "year_i": 1965},
    {"id": "b2", "title_t": "Dune Messiah", "year_i": 1969},
])

res = client.search("books", {
    "query": "title_t:dune",
    "get_number": True,
    "get_scores": True,
})
print(res.get_num_found())   # 2
print(res.get_docs())        # [{'id': 'b1', ...}, {'id': 'b2', ...}]

Concepts

A few Luxir ideas that shape how you'll use this client:

  • Collections auto-create. There's no required "create collection" step — the first index() call into a new name creates it. Use client.collections.create(...) only when you want to install a schema before any documents land.

  • Field names carry their type. Luxir infers a field's type from a naming suffix instead of requiring an upfront schema:

    Suffix Meaning
    _t full-text, stemmed/analyzed
    _s exact-match string
    _i / _is int, single or multi-valued
    _f / _fs float
    _dt date
    _v vector
    _name text with folding, no stemming

    So {"id": "b1", "title_t": "Dune", "year_i": 1965} is enough — no schema declaration needed. Declare one later with client.schema if you want tighter control.

  • Queries are either strings or structured dicts. "query": "title_t:dune AND year_i:>=1960" and "query": {"match": {"field": "title_t", "val": "dune"}} are both valid — this client just passes whatever you give it straight through as JSON.

  • Facets and metrics live under ops, not a separate facet param. Search, facets, and analytics all execute in a single request.

Guide

Connecting

client = LuxirClient("http://localhost:9400")

Pass a list of hosts to get automatic failover to the next host if one is unreachable:

client = LuxirClient(["http://search-a:9400", "http://search-b:9400"])

Collections

client.collections.list()                 # ['books', 'main']
client.collections.exists("books")         # True
client.collections.create("reviews")       # explicit create, no schema
client.collections.create("reviews", schema={...})  # create with a schema
client.collections.delete("reviews")
client.collections.stats("books")          # per-collection stats
client.collections.stats("books", segments=True)

Schema

client.schema.get("books")                  # authored schema
client.schema.get("books", resolved=True)   # expanded/physical view
client.schema.set_fields("books", {
    "title": {"type": "text", "stored": True},
})
client.schema.replace_all("books", {"fields": {...}})
client.schema.does_field_exist("books", "title_t")

Indexing

# Convenience wrapper — commits by default.
client.index("books", [
    {"id": "b1", "title_t": "Dune", "year_i": 1965},
])

# Index without committing immediately (visibility delayed).
client.index("books", docs, commit=False)

# Full control via update(): mix upserts and deletes in one request.
client.update("books",
    docs=[{"id": "b3", "title_t": "Children of Dune"}],
    delete_ids=["b1"],
    commit={},
)

Searching

res = client.search("books", {
    "query": "title_t:(dune OR messiah) AND year_i:>=1965",
    "filter": ["stock_i:>0"],       # non-scoring, ANDed with query
    "limit": 10,
    "get_number": True,             # populates res.get_num_found()
    "get_scores": True,             # populates res.get_scores()
    "fields": ["id", "title_t", "year_i"],
})

res.get_docs()          # list of doc dicts
res.get_results_count() # len(docs) in this response
res.get_num_found()     # exact total match count (needs get_number=True)
res.get_scores()        # {doc_id: score} (needs get_scores=True)
res.get_json()          # raw response as a JSON string

Omit "query" entirely to match every document (useful for count-only or facet-only requests with "limit": 0).

search_raw() returns the plain parsed dict instead of a LuxirResponse, if you'd rather work with it directly.

Faceting

Facets are requested under ops and read back the same way:

res = client.search("books", {
    "limit": 0,  # don't need docs, just the facet
    "ops": {
        "by_genre": {"field_facet": {"field": "genre_s"}},
    },
})
res.get_facet_buckets("by_genre")
# [{'val': 'scifi', 'count': 2}, {'val': 'romance', 'count': 1}]

res.get_ops()             # the whole `ops` block
res.get_ops("by_genre")   # just this op's result

Range facets, date histograms, and per-bucket metrics (avg, min, ...) all go through the same ops mechanism — see Luxir's faceting docs for the full request shapes. This client doesn't wrap them individually; pass whatever ops dict you need.

Fetching a single document

doc = client.get("books", "b1")   # raises NotFoundError if missing

Luxir has no dedicated realtime-get endpoint, so this is sugar over a search() filtered to that id — not a separate, cheaper code path.

Deleting

client.delete_by_id("books", "b1")             # single id
client.delete_by_id("books", ["b1", "b2"])      # multiple ids
client.delete_by_id("books", "b1", commit=False)

There's no delete_by_query — Luxir doesn't implement one.

Stats and health

client.health()             # {'status': 'ok'}
client.stats()               # node-wide stats
client.stats("books")        # per-collection stats

Error handling

Every failure — whether the server returned an HTTP error status or a 200 with an error embedded in the body (Luxir does both, depending on the endpoint) — raises a typed exception from luxir_client.exceptions, all inheriting from LuxirError:

from luxir_client.exceptions import NotFoundError, AlreadyExistsError

try:
    client.search("does_not_exist", {})
except NotFoundError as e:
    print(e.kind, e.code, e.message)
    # not_found collection_not_found "collection 'does_not_exist' does not exist"
Exception Luxir kind Typical cause
InvalidRequestError invalid_request Malformed query/payload
NotFoundError not_found Missing collection/document
AlreadyExistsError already_exists Duplicate collection create
FailedPreconditionError failed_precondition State conflict
ResourceExhaustedError resource_exhausted Rate-limited (HTTP 429) — safe to retry with backoff
UnavailableError unavailable Server temporarily unavailable — safe to retry
InternalError internal Server-side bug
ConnectionError — Couldn't reach any configured host at all

An unrecognized kind still raises, as the base LuxirError, so new server-side error kinds fail loudly instead of silently.

Gotchas

Things confirmed by hand against a live instance that are easy to trip over:

  • "query": "*" is not match-all. Omit the query key entirely to match everything. A bare * raises InvalidRequestError (unfielded term).
  • Search errors can come back as HTTP 200. The error envelope ({"error": {...}}) can be embedded in a 200 response body just as often as a 4xx/5xx status — this client checks for the error key before looking at the status code, so you don't need to handle this yourself, but don't assume response.status_code == 200 means success if you're bypassing the client.
  • collections.list()'s response key (collections) isn't documented, just inferred from behavior — flag it if a future Luxir version changes the field name.

What's not here yet

This is deliberately a lightweight first cut. Not implemented, add as needed:

  • gRPC transport (luxir.Indexer/luxir.Searcher/luxir.Admin)
  • NDJSON bulk streaming for indexing (currently one bounded JSON request per update()/index() call)
  • A structured query-builder helper (Q.match(...), Q.boolean(...), etc.)
  • kind-aware retry/backoff (currently: no automatic retries at all, other than failing over to the next host on connection errors)
  • An async client

Development

pip install -e ".[test]"
pytest

Tests run against a mocked requests.Session — no live Luxir instance required.

Release files for luxir-client 0.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for luxir-client 0.1.0
File Size Uploaded
luxir_client-0.1.0.tar.gz 19.5 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for luxir-client 0.1.0
File Interpreter ABI Platform
luxir_client-0.1.0-py3-none-any.whl Python 3 none any Details

Total release size: 36.8 kB

Release files / luxir_client-0.1.0.tar.gz

Download URL luxir_client-0.1.0.tar.gz
Size 19.5 kB
Tags Source
SHA-256 checksum
How to use checksums
0c2c330c1d59937ffbc0e668cb076ecb7472cdf292afb4bdaeb2c4a1fbead15f
BLAKE2b-256 checksum
How to use checksums
214a5974dff64a3580585c53663b37d47e37c8c34925e87fba0d9fb1f1d85fd4
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.12.3

Release files / luxir_client-0.1.0-py3-none-any.whl

Download URL luxir_client-0.1.0-py3-none-any.whl
Size 17.3 kB
Tags Python 3
SHA-256 checksum
How to use checksums
657202a786a0205e37a36c26516f8a60bd3534c82d0b427532984e8bae1b91f4
BLAKE2b-256 checksum
How to use checksums
62735b143fd41b488c48de34f7b04d6e6d0d8670566811473953e2512b1ab9cc
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.12.3

Release history Release notifications | RSS feed

0.1.1

2 release files

This release

0.1.0 This release

2 release 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