Base URL (default): http://localhost:8000
All request and response bodies are JSON (Content-Type: application/json).
Interactive, always-up-to-date docs are served by the running server at /docs
(Swagger UI) and /redoc (ReDoc); the OpenAPI schema is at /openapi.json.
- Conventions
- Authentication
- Errors
- Filter syntax
- Data types
- Index types & metrics
- Health
- Collections
- Documents
- Search
- Document ids are always strings. If you omit
idon insert/upsert, the server generates one (a UUID hex) and returns it in the write results. - A document has named vector fields (
vectors) and named scalar fields (fields). The vector field names must match the collection schema. - The server stores client-supplied vectors only. It does not generate embeddings.
Authentication is off by default. When the server is started with
ZVEC_SERVER_AUTH_ENABLED=true and a ZVEC_SERVER_API_KEY, every endpoint
except the health probes (/healthz, /readyz) requires the key as a bearer
token:
Authorization: Bearer <api_key>curl http://localhost:8000/collections \
-H "Authorization: Bearer $ZVEC_SERVER_API_KEY"A missing or invalid key returns 401 Unauthorized with the standard error
envelope (code: "unauthorized") and a WWW-Authenticate: Bearer response
header. When auth is enabled, the interactive docs (/docs, /openapi.json)
also require the key. See
CONFIGURATION.md.
Errors use a consistent envelope:
{
"error": {
"code": "collection_not_found",
"message": "Collection 'articles' not found.",
"details": { "name": "articles" }
}
}code is a stable, machine-readable string (snake_case); details is optional.
Status codes and their code values:
| Status | code |
When |
|---|---|---|
| 400 | invalid_argument |
Argument rejected by the server or engine (e.g. a malformed filter). |
| 401 | unauthorized |
Auth enabled and the API key was missing or invalid. |
| 404 | collection_not_found |
No such collection. |
| 404 | document_not_found |
No such document (single-document fetch). |
| 409 | collection_already_exists |
A collection with that name already exists. |
| 422 | schema_validation_error |
Invalid schema (bad dtype/index/metric). |
| 422 | validation_error |
Request body failed validation (ids+filter both/neither, etc.). |
| 500 | zvec_operation_error / internal_error |
Unexpected server / Zvec engine error. |
| 503 | collection_unavailable |
Collection registered but not currently open on disk. |
The filter string used by search and delete-by-filter uses Zvec's
SQL-like expression syntax. It is passed through to Zvec verbatim.
- Use a single
=for equality — not Python's==. - Quote string literals with single quotes:
'tech'. - Operators:
=,<,>,<=,>=,AND,OR,NOT,IN,BETWEEN,LIKE.
Examples:
category = 'tech'
category = 'tech' AND year > 2020
year BETWEEN 2018 AND 2022
category IN ('tech', 'science')
NOT (category = 'sports')
title LIKE 'intro%'
A malformed filter results in a 400 (invalid_argument).
Provide dtypes by name (case-sensitive, as Zvec defines them).
Vector dtypes (for vectors[].dtype):
| Name | Meaning |
|---|---|
VECTOR_FP32 |
32-bit float dense vector (default). |
VECTOR_FP16 |
16-bit float dense vector. |
VECTOR_FP64 |
64-bit float dense vector. |
VECTOR_INT8 |
8-bit int dense vector. |
SPARSE_VECTOR_FP16 |
16-bit float sparse vector. |
SPARSE_VECTOR_FP32 |
32-bit float sparse vector. |
Scalar dtypes (for fields[].dtype):
| Name | Meaning |
|---|---|
INT32, INT64 |
Signed integers. |
UINT32, UINT64 |
Unsigned integers. |
FLOAT, DOUBLE |
Floating point. |
STRING |
Text. |
BOOL |
Boolean. |
ARRAY_* |
Array variants of the above. |
Index types (for vectors[].index): hnsw (default), flat, ivf,
hnsw_rabitq, ivf_rabitq. The RaBitQ variants use RaBitQ quantization. The
engine supports them on Linux and macOS (x86_64 and ARM64) and on Windows
x86_64; on any other platform, creating one returns 422. They have not been
benchmarked here yet.
Optional per-index tuning goes in vectors[].params. Unknown keys return 422.
| Index | Recognized params |
|---|---|
hnsw |
m, ef_construction, plus quantization and PQ settings (below) |
ivf |
n_list, n_iters, use_soar, plus quantization and PQ settings (below) |
flat |
quantization only (quantize_type, enable_rotate) |
hnsw_rabitq |
m, ef_construction, total_bits, num_clusters, sample_count |
ivf_rabitq |
n_list, total_bits, sample_count |
Quantization. quantize_type makes the index search over compressed vectors.
Zvec keeps the original full-precision vectors alongside, and fetch returns them
unchanged, so the quantized index is extra storage, not a replacement. The
settings are fixed when the collection is created.
quantize_type |
Indexes | Requirements |
|---|---|---|
fp16, int8, int4 |
hnsw, flat, ivf |
— |
uniform_uint8, uniform_uint7, uniform_uint4 |
hnsw |
metric l2; no enable_rotate |
pq (product quantization) |
hnsw, ivf |
dtype VECTOR_FP32 |
-
enable_rotate: trueapplies a random rotation beforeint8/int4quantization. Withpq, it learns an OPQ rotation instead. Other quantize types don't support it and return422. -
Not on
hnswwithint8/int4, for now. Zvec 0.8.0 and 0.8.1 return near-random results afteroptimizefor a new HNSW index with scalar quantization and rotation (alibaba/zvec#886), so the server rejects that combination with422.flat/ivfwith rotation andhnswwithpq+ OPQ are unaffected, and so are HNSW collections created before the upgrade. -
The uniform quantizers share one value range across all vectors. That makes scoring cheaper but can cost recall. Pair them with the search-time refiner (below).
-
PQ settings, accepted only with
quantize_type: "pq":Param Type Default Notes num_chunkint 8 Sub-quantizers, 1.. dim. More chunks keep more information.num_bits4 or 8 8 Bits per code. fast_scanbool falseivfonly, withnum_bits: 4and anum_chunkthat dividesdim. Packed SIMD scan.opq_iterint 1–100 5 OPQ iterations. Requires enable_rotate: true.opq_pq_iterint 1–100 4 PQ iterations per OPQ iteration. Requires enable_rotate: true.PQ learns its codebook during
optimize. Until the first optimize, searches scan the vectors exactly.
Measure before adopting. SIFT1M (1M × 128, L2), HNSW
m=16,ef_construction=200, mmap on, searching atef=200with 16 concurrent clients. Zvec 0.8.1 on an Apple M5, engine tier:
quantize_typedisk vs fp32 recall@10 + refiner ( scale_factor0)QPS vs fp32 + refiner none (fp32) 687 MB 0.996 — 21.3k — fp16+38% 0.996 0.996 +13% −49% int8+20% 0.987 0.995 +25% −48% int4+11% 0.867 0.996 −5% −49% uniform_uint8+20% 0.995 0.995 +20% −48% uniform_uint7+20% 0.965 0.995 +22% −49% uniform_uint4+11% 0.834 0.994 +16% −50% pq(32 × 8-bit)+7% 0.717 0.991 +20% −48% pq(32 × 4-bit)+2% 0.400 0.922 +18% −52%
- Disk and memory grow. Every variant uses more disk than fp32, because Zvec keeps the full-precision vectors, and server memory was the same or higher. On this engine version quantization buys speed, not space.
uniform_uint8is the one free win. It matches fp32 recall and runs about 20% faster, with no refiner needed.- The refiner restores recall but halves throughput at
scale_factor0, because it re-scores max(topk,ef) = 200 candidates. A small window is much cheaper: forpq,scale_factor: 5gave 0.984 recall at +11% QPS, and2gave 0.908 at +17%. Start around 3–5 and tune.- PQ needs the refiner. OPQ (
enable_rotate: true) lowered PQ recall on SIFT (0.623 vs 0.717) and slowed optimize (66 s vs 56 s, against 47 s for fp32). The other variants optimize faster than fp32 (31–37 s).Results differ for other data and hardware, and SIFT is low-dimensional by modern embedding standards. Run the sweep on your own data with
python -m benchmarks quant(seebenchmarks/README.md).
{ "name": "embedding", "dim": 768, "index": "ivf", "metric": "l2",
"params": { "n_list": 1024, "quantize_type": "pq", "num_chunk": 96,
"num_bits": 4, "fast_scan": true } }index_param in the create response and in GET /collections/{name} echoes the
effective settings, including every quantizer_param default
(enable_rotate, num_chunk, num_bits, fast_scan, opq_iter,
opq_pq_iter).
Metrics (for vectors[].metric): cosine (default), ip (inner product),
l2 (Euclidean). Aliases such as dot / inner_product and euclidean are
accepted.
Liveness probe.
Response 200
{ "status": "ok" }Readiness probe with collection counts.
Response 200
{
"status": "ready",
"collections_loaded": 2,
"collections_unavailable": 0
}Create a collection.
Request body (CreateCollectionRequest):
| Field | Type | Required | Notes |
|---|---|---|---|
name |
string | yes | Matches ^[A-Za-z0-9_-]{3,64}$. |
vectors |
array of VectorFieldSpec |
yes | At least one. |
fields |
array of ScalarFieldSpec |
no | Defaults to []. |
options |
object | no | { "enable_mmap": bool }. |
embedding_model |
string | null | no | Free-form metadata label only; not used to compute embeddings. |
VectorFieldSpec:
| Field | Type | Default | Notes |
|---|---|---|---|
name |
string | — | Vector field name. |
dim |
int (> 0) | — | Vector dimension. |
dtype |
string | VECTOR_FP32 |
See data types. |
index |
string | hnsw |
See index types. |
metric |
string | cosine |
cosine / ip / l2. |
params |
object | null | null |
Index tuning params. |
ScalarFieldSpec:
| Field | Type | Default | Notes |
|---|---|---|---|
name |
string | — | Scalar field name: ASCII, up to 64 bytes. |
dtype |
string | — | See scalar dtypes. |
nullable |
bool | false |
Whether the field may be null. |
indexed |
bool | false |
Build an inverted index (faster filters). |
Example request
{
"name": "articles",
"vectors": [
{
"name": "embedding",
"dim": 4,
"dtype": "VECTOR_FP32",
"index": "hnsw",
"metric": "cosine",
"params": { "m": 16, "ef_construction": 200 }
}
],
"fields": [
{ "name": "category", "dtype": "STRING", "indexed": true },
{ "name": "year", "dtype": "INT32", "indexed": true }
],
"options": { "enable_mmap": true }
}Response 201 (CollectionInfo)
{
"name": "articles",
"path": "/data/collections/articles",
"schema_version": 1,
"embedding_dimension": 4,
"embedding_model": null,
"vectors": [
{ "name": "embedding", "data_type": "VECTOR_FP32", "dimension": 4 }
],
"fields": [
{ "name": "category", "data_type": "STRING", "nullable": false },
{ "name": "year", "data_type": "INT32", "nullable": false }
],
"options": { "enable_mmap": true },
"stats": { "doc_count": 0, "index_completeness": {} },
"available": true,
"created_at": "2026-06-23T12:00:00+00:00",
"updated_at": "2026-06-23T12:00:00+00:00"
}
409(collection_already_exists) if a collection with that name exists.422(schema_validation_error) for an invalid dtype/index/metric or bad name.
List collections.
Response 200 (CollectionListResponse)
{
"collections": [
{
"name": "articles",
"embedding_dimension": 4,
"embedding_model": null,
"doc_count": 3,
"created_at": "2026-06-23T12:00:00+00:00"
}
]
}Get full collection info, including live stats. Returns the same
CollectionInfo shape as create. 404 if not found.
Drop a collection. This deletes its data on disk.
Response 200 (MessageResponse)
{ "message": "Collection 'articles' deleted" }Flush pending writes to disk.
Response 200 (MessageResponse)
{ "message": "Collection 'articles' flushed" }Optimize the collection's indexes.
Response 200 (MessageResponse)
{ "message": "Collection 'articles' optimized" }All document endpoints are under /collections/{name}.
Document ids can be any non-empty UTF-8 string of up to 1,024 bytes without
a NUL or newline character, so URLs, paths, and titles work as-is. An invalid id
returns 400. Omit id on insert to get a random hex UUID. HTTP clients
normalize the path segments . and .., so to fetch an id like that, use
POST /docs/fetch, or encode its dots as %2E.
Insert documents. Use upsert to insert-or-replace and update to modify
existing documents; all three share the WriteRequest body and WriteResponse
shape.
Request body (WriteRequest):
{
"docs": [
{
"id": "a1",
"vectors": { "embedding": [0.10, 0.20, 0.30, 0.40] },
"fields": { "category": "tech", "year": 2021 }
},
{
"vectors": { "embedding": [0.90, 0.10, 0.05, 0.02] },
"fields": { "category": "science", "year": 2019 }
}
]
}DocIn:
| Field | Type | Default | Notes |
|---|---|---|---|
id |
string | null | null |
Auto-generated if omitted. |
vectors |
object (name → list of float) | {} |
Keys must match the schema's vector fields. |
fields |
object (name → value) | {} |
Scalar field values. |
Response 200 (WriteResponse)
{
"results": [
{ "id": "a1", "ok": true, "code": "OK", "message": "" },
{ "id": "f1c2...hex", "ok": true, "code": "OK", "message": "" }
],
"success_count": 2,
"error_count": 0
}Insert or replace documents. Same body/response as insert.
Update existing documents. Same body/response as insert.
Delete by either ids or filter — exactly one must be set
(422 otherwise).
Request body (DeleteRequest) — by ids:
{ "ids": ["a1", "a2"] }By filter (see filter syntax):
{ "filter": "category = 'science' AND year < 2020" }Response 200 (DeleteResponse) — by ids:
{
"ok": true,
"results": [
{ "id": "a1", "ok": true, "code": "OK", "message": "" },
{ "id": "a2", "ok": true, "code": "OK", "message": "" }
],
"filter": null,
"message": null
}By filter:
{
"ok": true,
"results": null,
"filter": "category = 'science' AND year < 2020",
"message": null
}A malformed filter returns
400(invalid_argument).
Fetch documents by id. Missing ids are simply omitted from the result map.
Request body (FetchRequest):
{
"ids": ["a1", "a2"],
"output_fields": ["category", "year"],
"include_vector": false
}| Field | Type | Default | Notes |
|---|---|---|---|
ids |
array of string | — | At least one. |
output_fields |
array of string | null | null |
Restrict returned scalar fields. |
include_vector |
bool | false |
Include vector values in the result. |
Response 200 (FetchResponse)
{
"docs": {
"a1": {
"id": "a1",
"score": null,
"vectors": null,
"fields": { "category": "tech", "year": 2021 }
}
}
}Fetch a single document by id. Returns 404 (document_not_found) if missing.
URL-encode the id. Ids that contain / work both encoded (%2F) and literally.
Query parameters
| Name | Type | Default | Notes |
|---|---|---|---|
include_vector |
bool | false |
Include vector values. |
output_fields |
repeated string | (all) | Restrict returned scalar fields. |
Example
GET /collections/articles/docs/a1?include_vector=true
Response 200 (DocOut)
{
"id": "a1",
"score": null,
"vectors": { "embedding": [0.10, 0.20, 0.30, 0.40] },
"fields": { "category": "tech", "year": 2021 }
}Stream every document as NDJSON (application/x-ndjson, one JSON object
per line) — for backups, migrations, or re-indexing. Each line is shaped like a
DocIn (id, vectors, fields; no score), so an export can be posted back
to /docs/insert in batches unchanged.
| Query param | Type | Default | Notes |
|---|---|---|---|
include_vector |
bool | true |
Include vectors (needed to re-import). |
output_fields |
string (repeatable) | all | Restrict scalar fields, e.g. ?output_fields=year. |
curl -s localhost:8000/collections/articles/export > articles.ndjson{"id":"a1","vectors":{"embedding":[0.1,0.2,0.3,0.4]},"fields":{"category":"tech","year":2021}}
{"id":"a2","vectors":{"embedding":[0.2,0.1,0.0,0.9]},"fields":{"category":"news","year":2019}}
- Consistent snapshot. The export reflects the collection as of the request; writes made while it streams are not included, and are not blocked by it (the collection lock is taken per batch, not for the whole stream).
- Errors. Bad input (unknown
output_fields, missing collection) returns a normal JSON error before streaming starts. If the export is cut short after it has started — e.g. the collection is dropped or the server shuts down — the last line is an error envelope ({"error": {...}}) instead of a document. Check for it before treating an export as complete. - Order is unspecified.
Vector similarity search. Each query searches by an explicit vector or by
an existing document id (exactly one per query).
Request body (SearchRequest):
| Field | Type | Default | Notes |
|---|---|---|---|
queries |
array of QuerySpec |
— | At least one. |
topk |
int (1–1000) | 10 |
Number of results per query. |
filter |
string | null | null |
SQL-like scalar filter (see above). |
include_vector |
bool | false |
Include vectors in hits. |
output_fields |
array of string | null | null |
Restrict returned scalar fields. |
QuerySpec (exactly one of vector / id):
| Field | Type | Notes |
|---|---|---|
field |
string | Vector field to search. |
vector |
list of float | null | Query vector. |
id |
string | null | Search by an existing document's vector. |
params |
object | null | Query tuning for the field's index (below). |
Query params by index type (unknown keys return 400):
| Index | Params |
|---|---|
hnsw, hnsw_rabitq |
ef (int), radius (float), is_linear (bool), is_using_refiner (bool), scale_factor (float) |
ivf |
nprobe (int), is_using_refiner (bool), scale_factor (float) |
ivf_rabitq |
nprobe, radius, is_linear, is_using_refiner, scale_factor |
flat |
none |
efandnprobetrade speed for recall.is_linear: trueforces an exact brute-force scan.radiuslimits hits to a distance threshold.
Refiner. is_using_refiner: true re-scores a larger candidate set against
the full-precision vectors, which recovers most of the recall that quantization
loses. scale_factor (0–1000) sizes that candidate set, and the defaults
differ by index:
- on
hnsw,0(the default) means max(topk,ef) candidates; - on
ivf, the default is10(topk× 10).
On hnsw, the refiner requires a quantized index (quantize_type set), and
otherwise returns 400.
Example request
{
"queries": [
{ "field": "embedding", "vector": [0.12, 0.22, 0.29, 0.41], "params": { "ef": 64 } }
],
"topk": 3,
"filter": "category = 'tech' AND year > 2020",
"include_vector": false,
"output_fields": ["category", "year"]
}Response 200 (SearchResponse)
{
"results": [
{
"id": "a1",
"score": 0.0123,
"vectors": null,
"fields": { "category": "tech", "year": 2021 }
}
]
}Results are a flat list of hits. For a single query they are ordered by score. A malformed filter returns
400(invalid_argument).
Run one query, bucket hits by the value of a scalar field, and return the
best topk_per_group hits from each of the best group_count groups. Typical
RAG use: chunks stored with a doc_id field, grouped so one long document
can't crowd every other document out of the results.
| Field | Type | Default | Notes |
|---|---|---|---|
query |
QuerySpec |
— | Same shape as a search query. |
group_by |
string | — | Scalar field defining the groups. |
group_count |
int (1–1000) | 10 |
Maximum groups returned. |
topk_per_group |
int (1–1000) | 3 |
Maximum hits per group. |
filter |
string | null | null |
SQL-like scalar filter. |
include_vector |
bool | false |
Include vectors in hits. |
output_fields |
array of string | null | null |
Restrict returned scalar fields. |
Example request
{
"query": { "field": "embedding", "vector": [0.12, 0.22, 0.29, 0.41] },
"group_by": "doc_id",
"group_count": 5,
"topk_per_group": 2
}Response 200 (GroupSearchResponse)
{
"groups": [
{ "value": "doc-7", "results": [{ "id": "doc-7#3", "score": 0.011, "fields": { "doc_id": "doc-7" } }] },
{ "value": "doc-2", "results": [{ "id": "doc-2#0", "score": 0.019, "fields": { "doc_id": "doc-2" } }] }
]
}Group
values are always strings (anINT64field's3comes back as"3"), and documents with a nullgroup_byvalue form a group with value"". An unknowngroup_byfield or a malformed filter returns400.