Skip to main content

Hermes Python client

Async Python client for the Hermes gRPC search server.

Installation

pip install hermes-client-python

Python 3.10 or newer is required.

Quick start

import asyncio

from hermes_client_python import HermesClient


async def main():
    async with HermesClient("localhost:50051") as client:
        await client.create_index(
            "articles",
            """
            index articles {
                field title: text<simple> [indexed, stored]
                field body: text<simple> [indexed, stored]
            }
            """,
        )

        indexed, error_count, errors = await client.index_documents(
            "articles",
            [
                {"title": "Hello World", "body": "First article"},
                {"title": "Hermes Search", "body": "Fast retrieval"},
            ],
        )
        if error_count:
            raise RuntimeError(errors)
        print(f"Indexed {indexed} documents")

        await client.commit("articles")

        results = await client.search(
            "articles",
            query={"match": {"field": "title", "text": "hello"}},
            fields_to_load=["title", "body"],
        )
        for hit in results.hits:
            print(hit.address, hit.score, hit.fields)

        if results.hits:
            document = await client.get_document("articles", results.hits[0].address)
            print(document.fields if document else "document not found")

        await client.delete_index("articles")


asyncio.run(main())

The context manager calls connect() and close() automatically. For manual lifecycle management:

client = HermesClient("localhost:50051")
await client.connect()
try:
    ...
finally:
    await client.close()

Index management

await client.create_index("articles", schema_sdl)
names = await client.list_indexes()
info = await client.get_index_info("articles")
print(info.num_docs, info.num_segments, info.vector_stats)

await client.force_merge("articles")
await client.reorder("articles")
await client.retrain_vector_index("articles")
await client.delete_index("articles")

commit() is required before newly indexed documents become searchable.

Batch and streaming indexing

indexed, error_count, errors = await client.index_documents(
    "articles",
    [
        {"title": "One", "tags": ["search", "rust"]},
        {"title": "Two", "tags": ["python"]},
    ],
)


async def documents():
    for number in range(10_000):
        yield {"title": f"Document {number}"}


streamed, stream_errors = await client.index_documents_stream("articles", documents())

Repeated list values become repeated field entries. Flat numeric lists are dense vectors; lists of (dimension, weight) pairs are sparse vectors.

Searching

Every search takes one query object whose single key matches a Hermes query variant:

# Exact term
await client.search(
    "articles",
    query={"term": {"field": "title", "term": "hermes"}},
)

# Tokenized full-text match
await client.search(
    "articles",
    query={"match": {"field": "body", "text": "fast retrieval"}},
)

# Recursive boolean query
await client.search(
    "articles",
    query={
        "boolean": {
            "must": [{"match": {"field": "body", "text": "retrieval"}}],
            "must_not": [{"term": {"field": "title", "term": "draft"}}],
        }
    },
)

# Dense vector query and optional reranking
await client.search(
    "articles",
    query={
        "dense_vector": {
            "field": "embedding",
            "vector": [0.1, 0.2, 0.3],
            "nprobe": 16,
        }
    },
    reranker={"field": "embedding", "vector": [0.1, 0.2, 0.3]},
    candidate_limit=20,
    limit=10,
    fields_to_load=["title"],
)

# Hybrid union fusion
await client.search(
    "articles",
    query={
        "fusion": {
            "method": "rrf",
            "rrf_k": 60,
            "queries": [
                {
                    "query": {
                        "sparse_vector": {
                            "field": "sparse_embedding",
                            "indices": [1, 5],
                            "values": [0.8, 0.2],
                        }
                    },
                    "weight": 1.0,
                },
                {
                    "query": {
                        "dense_vector": {
                            "field": "embedding",
                            "vector": [0.1, 0.2, 0.3],
                        }
                    },
                    "weight": 1.0,
                },
            ],
        }
    },
)

Other supported variants are phrase, binary_dense_vector, boost, range, prefix, and all. Search results expose the full DocAddress needed by get_document():

hit = results.hits[0]
document = await client.get_document("articles", hit.address)

Deadlines and errors

Every RPC accepts an optional timeout in seconds. A per-call value overrides the client default:

async with HermesClient("localhost:50051", default_timeout=5.0) as client:
    results = await client.search(
        "articles",
        query={"all": {}},
        timeout=0.5,
    )
    await client.force_merge("articles", timeout=3600)

gRPC failures raise grpc.RpcError (normally grpc.aio.AioRpcError). get_document() is the exception: it returns None for NOT_FOUND.

import grpc

try:
    await client.search("missing", query={"all": {}})
except grpc.RpcError as error:
    if error.code() == grpc.StatusCode.NOT_FOUND:
        print("index not found")
    else:
        raise

Development

From hermes-client-python:

uv sync --group dev --group test
uv run ruff check .
uv run ruff format --check .
uv run pytest tests/test_client_unit.py

The remaining tests are integration tests and expect a debug target/debug/hermes-server binary. Regenerate checked-in protobuf stubs after changing hermes-proto/hermes.proto:

uv run --group dev python generate_proto.py

License

MIT

Ranking diagnostics and recall traces

Both options default to false and preserve the requested ranking:

response = await client.search(
    "articles",
    query={
        "fusion": {
            "queries": [
                {
                    "name": "title",
                    "query": {"match": {"field": "title", "text": "rust"}},
                },
                {"name": "body", "query": {"match": {"field": "body", "text": "rust"}}},
            ]
        }
    },
    include_rrf_scores=True,
    tracing=True,
)
for hit in response.hits:
    print(hit.score, hit.rrf_score, hit.rrf_contributions)
for shard in response.trace.shards:
    for branch in shard.queries:
        print(shard.shard_id, branch.query_name, branch.candidates)

rrf_score and per-branch votes use organic nomination ranks merged across all shards, independently of L1 or reranker scores. Ranks start at 1. ordinal=None is document context; ordinal 0 is a real passage. Backfilled and score-only features contribute no votes.

The trace retains every shard's bounded branch nominations and selected results, including candidates absent from the final page, plus query trees and common filters. Candidates contain addresses, raw scores and ordinals; stored fields are loaded only for returned hits. Tracing does not expand retrieval depth or rerun individual Boolean clauses. Oversized diagnostics and unsupported backends fail explicitly. See the scoring and tracing contract.

For a named, scoped L1 query, specify the complete scoring formula:

l1 = {"formula": "0.2 * title + 0.8 * log1p(body) + 3 * rrf"}

formula is the only L1 scoring interface. Coefficients, offsets and RRF multipliers go in the expression; the former coefficient fields are removed. Arithmetic, powers, logarithms, sqrt, abs, exp, min/max and trigonometry are supported. Use {body.bm25} for punctuated branch names. log and ln are natural logarithms; log2 and log10 select those bases. Missing branch values use configured missing defaults, otherwise zero. Backfill remains optional.

The formula runs before passage selection and the document combiner. A formula using rrf makes the broker obtain the complete bounded candidate and passage union before global inference. Exports that exceed budgets fail explicitly. Expressions are bounded to 4 KiB, 256 tokens and 32 parenthesis levels. Invalid variables, invalid syntax and non-finite predictions fail explicitly. Servers and brokers must support formula_v1 (candidate_scoring_version=3).

Compact deleted rows

await client.force_merge("articles")  # Copy encoded data and retain tombstones.
await client.force_merge("articles", compact=True)  # Remove deleted rows physically.
info = await client.get_index_info("articles")
print(info.num_deleted_docs, info.physical_num_docs, info.deleted_ratio)

Compaction also works when there is only one segment. It preserves surviving values and may change document addresses and BM25 statistics.

Delete and upsert documents

Declare one text field [primary] in the schema. Deletion removes the document and all of its chunks; upserts replace the entire document, including indexed-only fields, and insert when the key is absent.

await client.delete_document("articles", "obsolete-key")
await client.upsert_document(
    "articles",
    {"id": "article-42", "body": ["replacement chunk one", "replacement chunk two"]},
)
await client.commit("articles")

result = await client.delete_documents("articles", ["old-a", "old-b"])
print(result.accepted_count, result.errors)  # errors: [{"index": 0, "error": "..."}]
await client.commit("articles")  # publishes accepted operations

upsert_documents takes a list of complete replacement documents and returns the same DocumentMutationResult. Single-document helpers raise on rejection. Missing deletion keys are accepted. Commit before deleting/upserting a key with a pending insertion or replacement. Limits are 100,000 deletion keys / 8 MiB key bytes and 1,000 replacement documents / 32 MiB encoded bytes. Mutations use the usual timeout argument; an expired RPC may have staged work, so do not blindly retry replacements. Broker commits are atomic within each partition. Physical cleanup remains await client.force_merge("articles", compact=True).

Metadata

Release files for hermes-client-python 1.8.142

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

Source distribution (sdist)

Source distribution for hermes-client-python 1.8.142
File Size Uploaded Provenance
hermes_client_python-1.8.142.tar.gz 25.3 kB Without provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for hermes-client-python 1.8.142
File Interpreter ABI Platform
hermes_client_python-1.8.142-py3-none-any.whl Python 3 none any Details

Total release size: 52.2 kB

Release files / hermes_client_python-1.8.142.tar.gz

Download URL hermes_client_python-1.8.142.tar.gz
Size 25.3 kB
Tags Source
SHA-256 checksum
How to use checksums
61d1f49669873248cf805aeee25cb1d5393ca7e1bed729c624f47477049f92bf
BLAKE2b-256 checksum
How to use checksums
862d6104a0947371c9691cc178d074a8e8a86f54c04efa8495122d383f27bd27
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.12.13 {"installer":{"name":"uv","version":"0.12.13","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

Release files / hermes_client_python-1.8.142-py3-none-any.whl

Download URL hermes_client_python-1.8.142-py3-none-any.whl
Size 26.9 kB
Tags Python 3
SHA-256 checksum
How to use checksums
85ea163403cee709f6910ba1d454875fa6fed66cc28d7200b596fa01c4930132
BLAKE2b-256 checksum
How to use checksums
c39d2a08dc1f45bbfb8ed36421036a64f03a70a3a52564a8b7674424a8bde558
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.12.13 {"installer":{"name":"uv","version":"0.12.13","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

Security for hermes-client-python 1.8.142

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

No provenance found

None of the files in this release were uploaded with provenance metadata.

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

Release history Release notifications | RSS feed

This release

1.8.142 This release

2 release files No provenance

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