hermes-client-python 1.8.145__tar.gz → 1.8.147__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,191 @@
1
+ Metadata-Version: 2.5
2
+ Name: hermes-client-python
3
+ Version: 1.8.147
4
+ Summary: Async Python client for Hermes search server
5
+ Project-URL: Homepage, https://github.com/SpaceFrontiers/hermes
6
+ Project-URL: Repository, https://github.com/SpaceFrontiers/hermes
7
+ Author: izihawa
8
+ License-Expression: MIT
9
+ Keywords: async,full-text-search,grpc,search
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: Database :: Database Engines/Servers
19
+ Classifier: Topic :: Text Processing :: Indexing
20
+ Requires-Python: >=3.10
21
+ Requires-Dist: grpcio>=1.76.0
22
+ Requires-Dist: protobuf>=6.33.4
23
+ Description-Content-Type: text/markdown
24
+
25
+ # Hermes Python client
26
+
27
+ Async Python client for the
28
+ [Hermes](https://github.com/SpaceFrontiers/hermes) gRPC search server.
29
+
30
+ ## Installation
31
+
32
+ ```bash
33
+ pip install hermes-client-python
34
+ ```
35
+
36
+ Python 3.10 or newer is required.
37
+
38
+ ## Quick start
39
+
40
+ ```python
41
+ import asyncio
42
+
43
+ from hermes_client_python import HermesClient
44
+
45
+
46
+ async def main():
47
+ async with HermesClient("localhost:50051") as client:
48
+ await client.create_index(
49
+ "articles",
50
+ """
51
+ index articles {
52
+ field id: text<raw> [primary, stored]
53
+ field title: text<simple> [indexed, stored]
54
+ field body: text<simple> [indexed, stored]
55
+ }
56
+ """,
57
+ )
58
+
59
+ indexed, error_count, errors = await client.index_documents(
60
+ "articles",
61
+ [
62
+ {"id": "1", "title": "Hello World", "body": "First article"},
63
+ {"id": "2", "title": "Hermes Search", "body": "Fast retrieval"},
64
+ ],
65
+ )
66
+ if error_count:
67
+ raise RuntimeError(errors)
68
+ print(f"Indexed {indexed} documents")
69
+
70
+ await client.commit("articles")
71
+
72
+ results = await client.search(
73
+ "articles",
74
+ query={"match": {"field": "title", "text": "hello"}},
75
+ fields_to_load=["title", "body"],
76
+ )
77
+ for hit in results.hits:
78
+ print(hit.address, hit.score, hit.fields)
79
+
80
+ if results.hits:
81
+ document = await client.get_document("articles", results.hits[0].address)
82
+ print(document.fields if document else "document not found")
83
+
84
+
85
+ asyncio.run(main())
86
+ ```
87
+
88
+ The context manager connects and closes the channel. Manual callers use
89
+ `await client.connect()` / `await client.close()`.
90
+
91
+ ## Index management
92
+
93
+ ```python
94
+ await client.list_indexes()
95
+ info = await client.get_index_info("articles")
96
+ await client.reorder("articles")
97
+ await client.retrain_vector_index("articles")
98
+ ```
99
+
100
+ `index_documents` returns `(indexed_count, error_count, errors)`;
101
+ `index_documents_stream` takes an async iterable and returns counts. Inspect
102
+ errors, then `commit()` to publish accepted work. Repeated values are lists;
103
+ flat numeric lists are dense vectors, `(dimension, weight)` pairs are sparse
104
+ vectors. See [client types](src/hermes_client_python/types.py).
105
+
106
+ ### Delete and upsert documents
107
+
108
+ With a text primary key, delete by exact key or supply a complete replacement:
109
+
110
+ ```python
111
+ await client.delete_document("articles", "2")
112
+ await client.upsert_document("articles", {"id": "1", "title": "Updated title"})
113
+ await client.commit("articles")
114
+ ```
115
+
116
+ `delete_documents` / `upsert_documents` return `DocumentMutationResult` with
117
+ `accepted_count` and `errors: [{index, error}]`. Single-item helpers raise on
118
+ rejection. Missing deletes succeed; upserts insert missing keys. Staged rows can
119
+ be replaced/deleted again before commit; the latest accepted version wins.
120
+ Optional [content hashes](../docs/content-deduplication.md) skip unchanged writes.
121
+
122
+ Limits: 100,000 deletion keys / 8 MiB key bytes; 1,000 replacements / 32 MiB
123
+ encoded protobuf, or one replacement / 200 MiB including the request envelope.
124
+ Broker commits are atomic per partition. See [mutation semantics](../docs/row-deletion.md).
125
+
126
+ ### Compact deleted rows
127
+
128
+ ```python
129
+ await client.force_merge("articles") # Retain tombstones.
130
+ await client.force_merge("articles", compact=True) # Physically remove deleted rows.
131
+ ```
132
+
133
+ Compaction handles singleton segments and may change addresses and BM25 scores.
134
+ Index info exposes `num_docs`, `physical_num_docs`, `num_deleted_docs`, and
135
+ `deleted_ratio`. Use primary keys for durable identity.
136
+
137
+ ## Searching
138
+
139
+ `search(index_name, query=..., limit=10, fields_to_load=[...])` accepts one query
140
+ variant: `term`, `match`, `phrase`, `boolean`, `sparse_vector`, `dense_vector`,
141
+ `binary_dense_vector`, `boost`, `range`, `prefix`, `all`, or `fusion`.
142
+ See [query types](src/hermes_client_python/types.py) and the
143
+ [wire contract](../hermes-proto/hermes.proto) for options.
144
+
145
+ ```python
146
+ results = await client.search(
147
+ "articles",
148
+ query={"boolean": {
149
+ "must": [{"match": {"field": "title", "text": "search"}}],
150
+ "must_not": [{"term": {"field": "title", "term": "draft"}}],
151
+ }},
152
+ fields_to_load=["title"],
153
+ )
154
+ ```
155
+
156
+ `get_document(index_name, hit.address)` uses the full segment/document address
157
+ and returns `None` on `NOT_FOUND`.
158
+
159
+ ## Ranking diagnostics and recall traces
160
+
161
+ Search options `include_rrf_scores=True` and `tracing=True` default to false.
162
+ RRF diagnostics describe organic branch nominations; traces retain bounded
163
+ candidates and query trees, including hits outside the final page. Neither
164
+ changes retrieval depth or ranking. Oversized exports fail explicitly.
165
+
166
+ For named branches, use `l1={"formula": "0.2 * title + 0.8 * body + 3 * rrf"}`.
167
+ The formula is the only L1 scoring interface; old coefficient fields are removed.
168
+ See [candidate scoring](../docs/candidate-rescoring.md) for backfill, passage
169
+ selection, expression limits, capability versions, and distributed behavior.
170
+
171
+ ## Deadlines and errors
172
+
173
+ RPCs accept `timeout` in seconds, overriding the constructor's `default_timeout`.
174
+ gRPC failures raise `grpc.aio.AioRpcError`, except document `NOT_FOUND` as above.
175
+ An expired mutation may already be staged, and an accepted commit continues
176
+ after disconnection. Resolve the outcome before retrying replacements.
177
+
178
+ ## Development
179
+
180
+ From this directory:
181
+
182
+ ```bash
183
+ uv sync --group dev --group test
184
+ uv run ruff check .
185
+ uv run ruff format --check .
186
+ uv run pytest tests/test_client_unit.py
187
+ uv run --group dev python generate_proto.py
188
+ ```
189
+
190
+ Integration tests require `target/debug/hermes-server`. Regenerate bindings after
191
+ [protocol changes](../hermes-proto/README.md#regeneration-and-validation).
@@ -0,0 +1,167 @@
1
+ # Hermes Python client
2
+
3
+ Async Python client for the
4
+ [Hermes](https://github.com/SpaceFrontiers/hermes) gRPC search server.
5
+
6
+ ## Installation
7
+
8
+ ```bash
9
+ pip install hermes-client-python
10
+ ```
11
+
12
+ Python 3.10 or newer is required.
13
+
14
+ ## Quick start
15
+
16
+ ```python
17
+ import asyncio
18
+
19
+ from hermes_client_python import HermesClient
20
+
21
+
22
+ async def main():
23
+ async with HermesClient("localhost:50051") as client:
24
+ await client.create_index(
25
+ "articles",
26
+ """
27
+ index articles {
28
+ field id: text<raw> [primary, stored]
29
+ field title: text<simple> [indexed, stored]
30
+ field body: text<simple> [indexed, stored]
31
+ }
32
+ """,
33
+ )
34
+
35
+ indexed, error_count, errors = await client.index_documents(
36
+ "articles",
37
+ [
38
+ {"id": "1", "title": "Hello World", "body": "First article"},
39
+ {"id": "2", "title": "Hermes Search", "body": "Fast retrieval"},
40
+ ],
41
+ )
42
+ if error_count:
43
+ raise RuntimeError(errors)
44
+ print(f"Indexed {indexed} documents")
45
+
46
+ await client.commit("articles")
47
+
48
+ results = await client.search(
49
+ "articles",
50
+ query={"match": {"field": "title", "text": "hello"}},
51
+ fields_to_load=["title", "body"],
52
+ )
53
+ for hit in results.hits:
54
+ print(hit.address, hit.score, hit.fields)
55
+
56
+ if results.hits:
57
+ document = await client.get_document("articles", results.hits[0].address)
58
+ print(document.fields if document else "document not found")
59
+
60
+
61
+ asyncio.run(main())
62
+ ```
63
+
64
+ The context manager connects and closes the channel. Manual callers use
65
+ `await client.connect()` / `await client.close()`.
66
+
67
+ ## Index management
68
+
69
+ ```python
70
+ await client.list_indexes()
71
+ info = await client.get_index_info("articles")
72
+ await client.reorder("articles")
73
+ await client.retrain_vector_index("articles")
74
+ ```
75
+
76
+ `index_documents` returns `(indexed_count, error_count, errors)`;
77
+ `index_documents_stream` takes an async iterable and returns counts. Inspect
78
+ errors, then `commit()` to publish accepted work. Repeated values are lists;
79
+ flat numeric lists are dense vectors, `(dimension, weight)` pairs are sparse
80
+ vectors. See [client types](src/hermes_client_python/types.py).
81
+
82
+ ### Delete and upsert documents
83
+
84
+ With a text primary key, delete by exact key or supply a complete replacement:
85
+
86
+ ```python
87
+ await client.delete_document("articles", "2")
88
+ await client.upsert_document("articles", {"id": "1", "title": "Updated title"})
89
+ await client.commit("articles")
90
+ ```
91
+
92
+ `delete_documents` / `upsert_documents` return `DocumentMutationResult` with
93
+ `accepted_count` and `errors: [{index, error}]`. Single-item helpers raise on
94
+ rejection. Missing deletes succeed; upserts insert missing keys. Staged rows can
95
+ be replaced/deleted again before commit; the latest accepted version wins.
96
+ Optional [content hashes](../docs/content-deduplication.md) skip unchanged writes.
97
+
98
+ Limits: 100,000 deletion keys / 8 MiB key bytes; 1,000 replacements / 32 MiB
99
+ encoded protobuf, or one replacement / 200 MiB including the request envelope.
100
+ Broker commits are atomic per partition. See [mutation semantics](../docs/row-deletion.md).
101
+
102
+ ### Compact deleted rows
103
+
104
+ ```python
105
+ await client.force_merge("articles") # Retain tombstones.
106
+ await client.force_merge("articles", compact=True) # Physically remove deleted rows.
107
+ ```
108
+
109
+ Compaction handles singleton segments and may change addresses and BM25 scores.
110
+ Index info exposes `num_docs`, `physical_num_docs`, `num_deleted_docs`, and
111
+ `deleted_ratio`. Use primary keys for durable identity.
112
+
113
+ ## Searching
114
+
115
+ `search(index_name, query=..., limit=10, fields_to_load=[...])` accepts one query
116
+ variant: `term`, `match`, `phrase`, `boolean`, `sparse_vector`, `dense_vector`,
117
+ `binary_dense_vector`, `boost`, `range`, `prefix`, `all`, or `fusion`.
118
+ See [query types](src/hermes_client_python/types.py) and the
119
+ [wire contract](../hermes-proto/hermes.proto) for options.
120
+
121
+ ```python
122
+ results = await client.search(
123
+ "articles",
124
+ query={"boolean": {
125
+ "must": [{"match": {"field": "title", "text": "search"}}],
126
+ "must_not": [{"term": {"field": "title", "term": "draft"}}],
127
+ }},
128
+ fields_to_load=["title"],
129
+ )
130
+ ```
131
+
132
+ `get_document(index_name, hit.address)` uses the full segment/document address
133
+ and returns `None` on `NOT_FOUND`.
134
+
135
+ ## Ranking diagnostics and recall traces
136
+
137
+ Search options `include_rrf_scores=True` and `tracing=True` default to false.
138
+ RRF diagnostics describe organic branch nominations; traces retain bounded
139
+ candidates and query trees, including hits outside the final page. Neither
140
+ changes retrieval depth or ranking. Oversized exports fail explicitly.
141
+
142
+ For named branches, use `l1={"formula": "0.2 * title + 0.8 * body + 3 * rrf"}`.
143
+ The formula is the only L1 scoring interface; old coefficient fields are removed.
144
+ See [candidate scoring](../docs/candidate-rescoring.md) for backfill, passage
145
+ selection, expression limits, capability versions, and distributed behavior.
146
+
147
+ ## Deadlines and errors
148
+
149
+ RPCs accept `timeout` in seconds, overriding the constructor's `default_timeout`.
150
+ gRPC failures raise `grpc.aio.AioRpcError`, except document `NOT_FOUND` as above.
151
+ An expired mutation may already be staged, and an accepted commit continues
152
+ after disconnection. Resolve the outcome before retrying replacements.
153
+
154
+ ## Development
155
+
156
+ From this directory:
157
+
158
+ ```bash
159
+ uv sync --group dev --group test
160
+ uv run ruff check .
161
+ uv run ruff format --check .
162
+ uv run pytest tests/test_client_unit.py
163
+ uv run --group dev python generate_proto.py
164
+ ```
165
+
166
+ Integration tests require `target/debug/hermes-server`. Regenerate bindings after
167
+ [protocol changes](../hermes-proto/README.md#regeneration-and-validation).
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "hermes-client-python"
7
- version = "1.8.145"
7
+ version = "1.8.147"
8
8
  description = "Async Python client for Hermes search server"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -1,380 +0,0 @@
1
- Metadata-Version: 2.5
2
- Name: hermes-client-python
3
- Version: 1.8.145
4
- Summary: Async Python client for Hermes search server
5
- Project-URL: Homepage, https://github.com/SpaceFrontiers/hermes
6
- Project-URL: Repository, https://github.com/SpaceFrontiers/hermes
7
- Author: izihawa
8
- License-Expression: MIT
9
- Keywords: async,full-text-search,grpc,search
10
- Classifier: Development Status :: 4 - Beta
11
- Classifier: Intended Audience :: Developers
12
- Classifier: License :: OSI Approved :: MIT License
13
- Classifier: Programming Language :: Python :: 3
14
- Classifier: Programming Language :: Python :: 3.10
15
- Classifier: Programming Language :: Python :: 3.11
16
- Classifier: Programming Language :: Python :: 3.12
17
- Classifier: Programming Language :: Python :: 3.13
18
- Classifier: Topic :: Database :: Database Engines/Servers
19
- Classifier: Topic :: Text Processing :: Indexing
20
- Requires-Python: >=3.10
21
- Requires-Dist: grpcio>=1.76.0
22
- Requires-Dist: protobuf>=6.33.4
23
- Description-Content-Type: text/markdown
24
-
25
- # Hermes Python client
26
-
27
- Async Python client for the
28
- [Hermes](https://github.com/SpaceFrontiers/hermes) gRPC search server.
29
-
30
- ## Installation
31
-
32
- ```bash
33
- pip install hermes-client-python
34
- ```
35
-
36
- Python 3.10 or newer is required.
37
-
38
- ## Quick start
39
-
40
- ```python
41
- import asyncio
42
-
43
- from hermes_client_python import HermesClient
44
-
45
-
46
- async def main():
47
- async with HermesClient("localhost:50051") as client:
48
- await client.create_index(
49
- "articles",
50
- """
51
- index articles {
52
- field title: text<simple> [indexed, stored]
53
- field body: text<simple> [indexed, stored]
54
- }
55
- """,
56
- )
57
-
58
- indexed, error_count, errors = await client.index_documents(
59
- "articles",
60
- [
61
- {"title": "Hello World", "body": "First article"},
62
- {"title": "Hermes Search", "body": "Fast retrieval"},
63
- ],
64
- )
65
- if error_count:
66
- raise RuntimeError(errors)
67
- print(f"Indexed {indexed} documents")
68
-
69
- await client.commit("articles")
70
-
71
- results = await client.search(
72
- "articles",
73
- query={"match": {"field": "title", "text": "hello"}},
74
- fields_to_load=["title", "body"],
75
- )
76
- for hit in results.hits:
77
- print(hit.address, hit.score, hit.fields)
78
-
79
- if results.hits:
80
- document = await client.get_document("articles", results.hits[0].address)
81
- print(document.fields if document else "document not found")
82
-
83
- await client.delete_index("articles")
84
-
85
-
86
- asyncio.run(main())
87
- ```
88
-
89
- The context manager calls `connect()` and `close()` automatically. For manual
90
- lifecycle management:
91
-
92
- ```python
93
- client = HermesClient("localhost:50051")
94
- await client.connect()
95
- try:
96
- ...
97
- finally:
98
- await client.close()
99
- ```
100
-
101
- ## Index management
102
-
103
- ```python
104
- await client.create_index("articles", schema_sdl)
105
- names = await client.list_indexes()
106
- info = await client.get_index_info("articles")
107
- print(info.num_docs, info.num_segments, info.vector_stats)
108
-
109
- await client.force_merge("articles")
110
- await client.reorder("articles")
111
- await client.retrain_vector_index("articles")
112
- await client.delete_index("articles")
113
- ```
114
-
115
- `commit()` is required before newly indexed documents become searchable.
116
-
117
- ### Batch and streaming indexing
118
-
119
- ```python
120
- indexed, error_count, errors = await client.index_documents(
121
- "articles",
122
- [
123
- {"title": "One", "tags": ["search", "rust"]},
124
- {"title": "Two", "tags": ["python"]},
125
- ],
126
- )
127
-
128
-
129
- async def documents():
130
- for number in range(10_000):
131
- yield {"title": f"Document {number}"}
132
-
133
-
134
- streamed, stream_errors = await client.index_documents_stream("articles", documents())
135
- ```
136
-
137
- Repeated list values become repeated field entries. Flat numeric lists are
138
- dense vectors; lists of `(dimension, weight)` pairs are sparse vectors.
139
-
140
- ## Searching
141
-
142
- Every search takes one `query` object whose single key matches a Hermes query
143
- variant:
144
-
145
- ```python
146
- # Exact term
147
- await client.search(
148
- "articles",
149
- query={"term": {"field": "title", "term": "hermes"}},
150
- )
151
-
152
- # Tokenized full-text match
153
- await client.search(
154
- "articles",
155
- query={"match": {"field": "body", "text": "fast retrieval"}},
156
- )
157
-
158
- # Recursive boolean query
159
- await client.search(
160
- "articles",
161
- query={
162
- "boolean": {
163
- "must": [{"match": {"field": "body", "text": "retrieval"}}],
164
- "must_not": [{"term": {"field": "title", "term": "draft"}}],
165
- }
166
- },
167
- )
168
-
169
- # Dense vector query and optional reranking
170
- await client.search(
171
- "articles",
172
- query={
173
- "dense_vector": {
174
- "field": "embedding",
175
- "vector": [0.1, 0.2, 0.3],
176
- "nprobe": 16,
177
- }
178
- },
179
- reranker={"field": "embedding", "vector": [0.1, 0.2, 0.3]},
180
- candidate_limit=20,
181
- limit=10,
182
- fields_to_load=["title"],
183
- )
184
-
185
- # Hybrid union fusion
186
- await client.search(
187
- "articles",
188
- query={
189
- "fusion": {
190
- "method": "rrf",
191
- "rrf_k": 60,
192
- "queries": [
193
- {
194
- "query": {
195
- "sparse_vector": {
196
- "field": "sparse_embedding",
197
- "indices": [1, 5],
198
- "values": [0.8, 0.2],
199
- }
200
- },
201
- "weight": 1.0,
202
- },
203
- {
204
- "query": {
205
- "dense_vector": {
206
- "field": "embedding",
207
- "vector": [0.1, 0.2, 0.3],
208
- }
209
- },
210
- "weight": 1.0,
211
- },
212
- ],
213
- }
214
- },
215
- )
216
- ```
217
-
218
- Other supported variants are `phrase`, `binary_dense_vector`, `boost`, `range`,
219
- `prefix`, and `all`. Search results expose the full `DocAddress` needed by
220
- `get_document()`:
221
-
222
- ```python
223
- hit = results.hits[0]
224
- document = await client.get_document("articles", hit.address)
225
- ```
226
-
227
- ## Deadlines and errors
228
-
229
- Every RPC accepts an optional `timeout` in seconds. A per-call value overrides
230
- the client default:
231
-
232
- ```python
233
- async with HermesClient("localhost:50051", default_timeout=5.0) as client:
234
- results = await client.search(
235
- "articles",
236
- query={"all": {}},
237
- timeout=0.5,
238
- )
239
- await client.force_merge("articles", timeout=3600)
240
- ```
241
-
242
- gRPC failures raise `grpc.RpcError` (normally
243
- `grpc.aio.AioRpcError`). `get_document()` is the exception: it returns `None`
244
- for `NOT_FOUND`.
245
-
246
- ```python
247
- import grpc
248
-
249
- try:
250
- await client.search("missing", query={"all": {}})
251
- except grpc.RpcError as error:
252
- if error.code() == grpc.StatusCode.NOT_FOUND:
253
- print("index not found")
254
- else:
255
- raise
256
- ```
257
-
258
- ## Development
259
-
260
- From `hermes-client-python`:
261
-
262
- ```bash
263
- uv sync --group dev --group test
264
- uv run ruff check .
265
- uv run ruff format --check .
266
- uv run pytest tests/test_client_unit.py
267
- ```
268
-
269
- The remaining tests are integration tests and expect a debug
270
- `target/debug/hermes-server` binary. Regenerate checked-in protobuf stubs after
271
- changing `hermes-proto/hermes.proto`:
272
-
273
- ```bash
274
- uv run --group dev python generate_proto.py
275
- ```
276
-
277
- ## License
278
-
279
- MIT
280
-
281
- ## Ranking diagnostics and recall traces
282
-
283
- Both options default to false and preserve the requested ranking:
284
-
285
- ```python
286
- response = await client.search(
287
- "articles",
288
- query={
289
- "fusion": {
290
- "queries": [
291
- {
292
- "name": "title",
293
- "query": {"match": {"field": "title", "text": "rust"}},
294
- },
295
- {"name": "body", "query": {"match": {"field": "body", "text": "rust"}}},
296
- ]
297
- }
298
- },
299
- include_rrf_scores=True,
300
- tracing=True,
301
- )
302
- for hit in response.hits:
303
- print(hit.score, hit.rrf_score, hit.rrf_contributions)
304
- for shard in response.trace.shards:
305
- for branch in shard.queries:
306
- print(shard.shard_id, branch.query_name, branch.candidates)
307
- ```
308
-
309
- `rrf_score` and per-branch votes use organic nomination ranks merged across all
310
- shards, independently of L1 or reranker scores. Ranks start at 1. `ordinal=None`
311
- is document context; ordinal 0 is a real passage. Backfilled and score-only
312
- features contribute no votes.
313
-
314
- The trace retains every shard's bounded branch nominations and selected results,
315
- including candidates absent from the final page, plus query trees and common
316
- filters. Candidates contain addresses, raw scores and ordinals; stored fields
317
- are loaded only for returned hits. Tracing does not expand retrieval depth or
318
- rerun individual Boolean clauses. Oversized diagnostics and unsupported backends
319
- fail explicitly. See the [scoring and tracing contract](../docs/candidate-rescoring.md).
320
-
321
- For a named, scoped L1 query, specify the complete scoring formula:
322
-
323
- ```python
324
- l1 = {"formula": "0.2 * title + 0.8 * log1p(body) + 3 * rrf"}
325
- ```
326
-
327
- `formula` is the only L1 scoring interface. Coefficients, offsets and RRF
328
- multipliers go in the expression; the former coefficient fields are removed.
329
- Arithmetic, powers, logarithms, `sqrt`, `abs`, `exp`, `min`/`max` and trigonometry
330
- are supported. Use `{body.bm25}` for punctuated branch names. `log` and `ln` are
331
- natural logarithms; `log2` and `log10` select those bases. Missing branch values
332
- use configured missing defaults, otherwise zero. Backfill remains optional.
333
-
334
- The formula runs before passage selection and the document combiner. A formula
335
- using `rrf` makes the broker obtain the complete bounded candidate and passage
336
- union before global inference. Exports that exceed budgets fail explicitly.
337
- Expressions are bounded to 4 KiB, 256 tokens and 32 parenthesis levels. Invalid
338
- variables, invalid syntax and non-finite predictions fail explicitly. Servers
339
- and brokers must support `formula_v1` (`candidate_scoring_version=3`).
340
-
341
- ### Compact deleted rows
342
-
343
- ```python
344
- await client.force_merge("articles") # Copy encoded data and retain tombstones.
345
- await client.force_merge("articles", compact=True) # Remove deleted rows physically.
346
- info = await client.get_index_info("articles")
347
- print(info.num_deleted_docs, info.physical_num_docs, info.deleted_ratio)
348
- ```
349
-
350
- Compaction also works when there is only one segment. It preserves surviving
351
- values and may change document addresses and BM25 statistics.
352
-
353
- ### Delete and upsert documents
354
-
355
- Declare one text field `[primary]` in the schema. Deletion removes the document
356
- and all of its chunks; upserts replace the entire document, including indexed-only
357
- fields, and insert when the key is absent.
358
-
359
- ```python
360
- await client.delete_document("articles", "obsolete-key")
361
- await client.upsert_document(
362
- "articles",
363
- {"id": "article-42", "body": ["replacement chunk one", "replacement chunk two"]},
364
- )
365
- await client.commit("articles")
366
-
367
- result = await client.delete_documents("articles", ["old-a", "old-b"])
368
- print(result.accepted_count, result.errors) # errors: [{"index": 0, "error": "..."}]
369
- await client.commit("articles") # publishes accepted operations
370
- ```
371
-
372
- `upsert_documents` takes a list of complete replacement documents and returns the
373
- same `DocumentMutationResult`. Single-document helpers raise on rejection. Missing
374
- deletion keys are accepted. Commit before deleting/upserting a key with a pending
375
- insertion or replacement. Limits are 100,000 deletion keys / 8 MiB key bytes and
376
- 1,000 replacement documents / 32 MiB encoded bytes, or one replacement / 200 MiB
377
- including the request envelope. Mutations use the usual timeout
378
- argument; an expired RPC may have staged work, so do not blindly retry replacements.
379
- Broker commits are atomic within each partition. Physical cleanup remains
380
- `await client.force_merge("articles", compact=True)`.
@@ -1,356 +0,0 @@
1
- # Hermes Python client
2
-
3
- Async Python client for the
4
- [Hermes](https://github.com/SpaceFrontiers/hermes) gRPC search server.
5
-
6
- ## Installation
7
-
8
- ```bash
9
- pip install hermes-client-python
10
- ```
11
-
12
- Python 3.10 or newer is required.
13
-
14
- ## Quick start
15
-
16
- ```python
17
- import asyncio
18
-
19
- from hermes_client_python import HermesClient
20
-
21
-
22
- async def main():
23
- async with HermesClient("localhost:50051") as client:
24
- await client.create_index(
25
- "articles",
26
- """
27
- index articles {
28
- field title: text<simple> [indexed, stored]
29
- field body: text<simple> [indexed, stored]
30
- }
31
- """,
32
- )
33
-
34
- indexed, error_count, errors = await client.index_documents(
35
- "articles",
36
- [
37
- {"title": "Hello World", "body": "First article"},
38
- {"title": "Hermes Search", "body": "Fast retrieval"},
39
- ],
40
- )
41
- if error_count:
42
- raise RuntimeError(errors)
43
- print(f"Indexed {indexed} documents")
44
-
45
- await client.commit("articles")
46
-
47
- results = await client.search(
48
- "articles",
49
- query={"match": {"field": "title", "text": "hello"}},
50
- fields_to_load=["title", "body"],
51
- )
52
- for hit in results.hits:
53
- print(hit.address, hit.score, hit.fields)
54
-
55
- if results.hits:
56
- document = await client.get_document("articles", results.hits[0].address)
57
- print(document.fields if document else "document not found")
58
-
59
- await client.delete_index("articles")
60
-
61
-
62
- asyncio.run(main())
63
- ```
64
-
65
- The context manager calls `connect()` and `close()` automatically. For manual
66
- lifecycle management:
67
-
68
- ```python
69
- client = HermesClient("localhost:50051")
70
- await client.connect()
71
- try:
72
- ...
73
- finally:
74
- await client.close()
75
- ```
76
-
77
- ## Index management
78
-
79
- ```python
80
- await client.create_index("articles", schema_sdl)
81
- names = await client.list_indexes()
82
- info = await client.get_index_info("articles")
83
- print(info.num_docs, info.num_segments, info.vector_stats)
84
-
85
- await client.force_merge("articles")
86
- await client.reorder("articles")
87
- await client.retrain_vector_index("articles")
88
- await client.delete_index("articles")
89
- ```
90
-
91
- `commit()` is required before newly indexed documents become searchable.
92
-
93
- ### Batch and streaming indexing
94
-
95
- ```python
96
- indexed, error_count, errors = await client.index_documents(
97
- "articles",
98
- [
99
- {"title": "One", "tags": ["search", "rust"]},
100
- {"title": "Two", "tags": ["python"]},
101
- ],
102
- )
103
-
104
-
105
- async def documents():
106
- for number in range(10_000):
107
- yield {"title": f"Document {number}"}
108
-
109
-
110
- streamed, stream_errors = await client.index_documents_stream("articles", documents())
111
- ```
112
-
113
- Repeated list values become repeated field entries. Flat numeric lists are
114
- dense vectors; lists of `(dimension, weight)` pairs are sparse vectors.
115
-
116
- ## Searching
117
-
118
- Every search takes one `query` object whose single key matches a Hermes query
119
- variant:
120
-
121
- ```python
122
- # Exact term
123
- await client.search(
124
- "articles",
125
- query={"term": {"field": "title", "term": "hermes"}},
126
- )
127
-
128
- # Tokenized full-text match
129
- await client.search(
130
- "articles",
131
- query={"match": {"field": "body", "text": "fast retrieval"}},
132
- )
133
-
134
- # Recursive boolean query
135
- await client.search(
136
- "articles",
137
- query={
138
- "boolean": {
139
- "must": [{"match": {"field": "body", "text": "retrieval"}}],
140
- "must_not": [{"term": {"field": "title", "term": "draft"}}],
141
- }
142
- },
143
- )
144
-
145
- # Dense vector query and optional reranking
146
- await client.search(
147
- "articles",
148
- query={
149
- "dense_vector": {
150
- "field": "embedding",
151
- "vector": [0.1, 0.2, 0.3],
152
- "nprobe": 16,
153
- }
154
- },
155
- reranker={"field": "embedding", "vector": [0.1, 0.2, 0.3]},
156
- candidate_limit=20,
157
- limit=10,
158
- fields_to_load=["title"],
159
- )
160
-
161
- # Hybrid union fusion
162
- await client.search(
163
- "articles",
164
- query={
165
- "fusion": {
166
- "method": "rrf",
167
- "rrf_k": 60,
168
- "queries": [
169
- {
170
- "query": {
171
- "sparse_vector": {
172
- "field": "sparse_embedding",
173
- "indices": [1, 5],
174
- "values": [0.8, 0.2],
175
- }
176
- },
177
- "weight": 1.0,
178
- },
179
- {
180
- "query": {
181
- "dense_vector": {
182
- "field": "embedding",
183
- "vector": [0.1, 0.2, 0.3],
184
- }
185
- },
186
- "weight": 1.0,
187
- },
188
- ],
189
- }
190
- },
191
- )
192
- ```
193
-
194
- Other supported variants are `phrase`, `binary_dense_vector`, `boost`, `range`,
195
- `prefix`, and `all`. Search results expose the full `DocAddress` needed by
196
- `get_document()`:
197
-
198
- ```python
199
- hit = results.hits[0]
200
- document = await client.get_document("articles", hit.address)
201
- ```
202
-
203
- ## Deadlines and errors
204
-
205
- Every RPC accepts an optional `timeout` in seconds. A per-call value overrides
206
- the client default:
207
-
208
- ```python
209
- async with HermesClient("localhost:50051", default_timeout=5.0) as client:
210
- results = await client.search(
211
- "articles",
212
- query={"all": {}},
213
- timeout=0.5,
214
- )
215
- await client.force_merge("articles", timeout=3600)
216
- ```
217
-
218
- gRPC failures raise `grpc.RpcError` (normally
219
- `grpc.aio.AioRpcError`). `get_document()` is the exception: it returns `None`
220
- for `NOT_FOUND`.
221
-
222
- ```python
223
- import grpc
224
-
225
- try:
226
- await client.search("missing", query={"all": {}})
227
- except grpc.RpcError as error:
228
- if error.code() == grpc.StatusCode.NOT_FOUND:
229
- print("index not found")
230
- else:
231
- raise
232
- ```
233
-
234
- ## Development
235
-
236
- From `hermes-client-python`:
237
-
238
- ```bash
239
- uv sync --group dev --group test
240
- uv run ruff check .
241
- uv run ruff format --check .
242
- uv run pytest tests/test_client_unit.py
243
- ```
244
-
245
- The remaining tests are integration tests and expect a debug
246
- `target/debug/hermes-server` binary. Regenerate checked-in protobuf stubs after
247
- changing `hermes-proto/hermes.proto`:
248
-
249
- ```bash
250
- uv run --group dev python generate_proto.py
251
- ```
252
-
253
- ## License
254
-
255
- MIT
256
-
257
- ## Ranking diagnostics and recall traces
258
-
259
- Both options default to false and preserve the requested ranking:
260
-
261
- ```python
262
- response = await client.search(
263
- "articles",
264
- query={
265
- "fusion": {
266
- "queries": [
267
- {
268
- "name": "title",
269
- "query": {"match": {"field": "title", "text": "rust"}},
270
- },
271
- {"name": "body", "query": {"match": {"field": "body", "text": "rust"}}},
272
- ]
273
- }
274
- },
275
- include_rrf_scores=True,
276
- tracing=True,
277
- )
278
- for hit in response.hits:
279
- print(hit.score, hit.rrf_score, hit.rrf_contributions)
280
- for shard in response.trace.shards:
281
- for branch in shard.queries:
282
- print(shard.shard_id, branch.query_name, branch.candidates)
283
- ```
284
-
285
- `rrf_score` and per-branch votes use organic nomination ranks merged across all
286
- shards, independently of L1 or reranker scores. Ranks start at 1. `ordinal=None`
287
- is document context; ordinal 0 is a real passage. Backfilled and score-only
288
- features contribute no votes.
289
-
290
- The trace retains every shard's bounded branch nominations and selected results,
291
- including candidates absent from the final page, plus query trees and common
292
- filters. Candidates contain addresses, raw scores and ordinals; stored fields
293
- are loaded only for returned hits. Tracing does not expand retrieval depth or
294
- rerun individual Boolean clauses. Oversized diagnostics and unsupported backends
295
- fail explicitly. See the [scoring and tracing contract](../docs/candidate-rescoring.md).
296
-
297
- For a named, scoped L1 query, specify the complete scoring formula:
298
-
299
- ```python
300
- l1 = {"formula": "0.2 * title + 0.8 * log1p(body) + 3 * rrf"}
301
- ```
302
-
303
- `formula` is the only L1 scoring interface. Coefficients, offsets and RRF
304
- multipliers go in the expression; the former coefficient fields are removed.
305
- Arithmetic, powers, logarithms, `sqrt`, `abs`, `exp`, `min`/`max` and trigonometry
306
- are supported. Use `{body.bm25}` for punctuated branch names. `log` and `ln` are
307
- natural logarithms; `log2` and `log10` select those bases. Missing branch values
308
- use configured missing defaults, otherwise zero. Backfill remains optional.
309
-
310
- The formula runs before passage selection and the document combiner. A formula
311
- using `rrf` makes the broker obtain the complete bounded candidate and passage
312
- union before global inference. Exports that exceed budgets fail explicitly.
313
- Expressions are bounded to 4 KiB, 256 tokens and 32 parenthesis levels. Invalid
314
- variables, invalid syntax and non-finite predictions fail explicitly. Servers
315
- and brokers must support `formula_v1` (`candidate_scoring_version=3`).
316
-
317
- ### Compact deleted rows
318
-
319
- ```python
320
- await client.force_merge("articles") # Copy encoded data and retain tombstones.
321
- await client.force_merge("articles", compact=True) # Remove deleted rows physically.
322
- info = await client.get_index_info("articles")
323
- print(info.num_deleted_docs, info.physical_num_docs, info.deleted_ratio)
324
- ```
325
-
326
- Compaction also works when there is only one segment. It preserves surviving
327
- values and may change document addresses and BM25 statistics.
328
-
329
- ### Delete and upsert documents
330
-
331
- Declare one text field `[primary]` in the schema. Deletion removes the document
332
- and all of its chunks; upserts replace the entire document, including indexed-only
333
- fields, and insert when the key is absent.
334
-
335
- ```python
336
- await client.delete_document("articles", "obsolete-key")
337
- await client.upsert_document(
338
- "articles",
339
- {"id": "article-42", "body": ["replacement chunk one", "replacement chunk two"]},
340
- )
341
- await client.commit("articles")
342
-
343
- result = await client.delete_documents("articles", ["old-a", "old-b"])
344
- print(result.accepted_count, result.errors) # errors: [{"index": 0, "error": "..."}]
345
- await client.commit("articles") # publishes accepted operations
346
- ```
347
-
348
- `upsert_documents` takes a list of complete replacement documents and returns the
349
- same `DocumentMutationResult`. Single-document helpers raise on rejection. Missing
350
- deletion keys are accepted. Commit before deleting/upserting a key with a pending
351
- insertion or replacement. Limits are 100,000 deletion keys / 8 MiB key bytes and
352
- 1,000 replacement documents / 32 MiB encoded bytes, or one replacement / 200 MiB
353
- including the request envelope. Mutations use the usual timeout
354
- argument; an expired RPC may have staged work, so do not blindly retry replacements.
355
- Broker commits are atomic within each partition. Physical cleanup remains
356
- `await client.force_merge("articles", compact=True)`.