Quiver
The security-first vector database. Client-side-encryptable, memory-frugal approximate-nearest-neighbour search that runs on a laptop — with a retro terminal cockpit.

Quiver is a from-scratch, native-Rust vector database. It is not trying to out-scale Milvus or out-feature Qdrant; its defensible edge is the combination of three things, executed well:
- Security-first, by default. Encryption-at-rest is on out of the box, sealing
every durable byte — segments, the manifest, and the write-ahead log — with
XChaCha20-Poly1305. Payloads can be client-side-encrypted so the server never
sees them; vectors can be encrypted too, either with the experimental
distance-comparison-preserving DCPE mode or the
semantically secure client-side opaque mode.
API-key scopes, RBAC, tenant isolation, an audit log, and crypto-shredding round
it out. Only audited cryptography (RustCrypto AEAD/KDF +
rustls) — never a home-grown primitive. - Memory frugality. A disk-resident graph index (DiskANN/Vamana) plus quantization (product / scalar / binary) serve large datasets from a laptop’s RAM budget. The headline metric is memory at a fixed recall.
- Developer experience. A single static binary; embeddable and server modes;
a
ratatuicockpit with a 2-D constellation view of the vector space; idiomatic Python and TypeScript SDKs; and an MCP server so AI agents can drive it.
We say plainly what Quiver does not do: billion-scale needs a server (a laptop comfortably serves tens-to-hundreds of millions); there is no homomorphic search in core; and each encryption mode states its exact trust boundary. See the honest threat model.
The name. A quiver holds arrows, and an arrow is a vector — apt for a database of them. And in mathematics a quiver is a directed graph, which is exactly what an HNSW or Vamana index is. The cockpit wears that identity in amber phosphor.
Where to start
- New to Quiver? Read the Concepts, then run the Quickstart.
- Running it yourself? See Self-hosting & configuration.
- Building against it? See the REST & gRPC reference, the MCP server, and the SDKs.
- Curious how it works? Start with the architecture deep dive and the decision records.
Quiver is open source under the AGPL-3.0 license. The source lives at github.com/achref-soua/quiver.
Concepts
A short tour of the nouns you will meet everywhere in Quiver.
Collections
A collection is a named set of vectors of a fixed dimension, with a fixed
distance metric (cosine, dot, or l2) and an index configuration. You
create a collection, upsert points into it, and search it. Each collection has its
own data-encryption key (envelope encryption), so dropping a collection
crypto-shreds it — its key is destroyed and its data becomes unrecoverable.
Points, vectors, and payloads
A point is an id, a vector, and an optional JSON payload. The vector
is what you search by; the payload carries metadata ({"tag": "x", "year": 2024}).
Payload fields can be declared filterable so they participate in hybrid search,
and they can be client-side-encrypted so the server stores only ciphertext for
the sensitive fields while cleartext siblings stay filterable.
Metrics
cosine— angle between vectors (normalize-and-dot); the usual choice for text embeddings.dot— inner product; for models trained with dot-product objectives.l2— Euclidean distance; required by the DCPE encrypted-search mode (its secret scaling preserves L2 ordering, not cosine/dot).
Indexes
The index is how Quiver answers approximate nearest-neighbour queries quickly. Pick per collection (see Indexing & memory frugality):
- HNSW — a fast in-memory graph; the default.
- IVF — inverted-file clustering; pairs well with quantization.
- Vamana — the DiskANN graph, in-memory or disk-resident.
disk_vamana— the memory-frugality wedge: the graph and full-precision vectors live in the encrypted on-disk index while only compact PQ codes stay in RAM.colbert— an opt-in ColBERTv2/PLAID token-pool index for multi-vector collections.
Every index supports incremental updates, so a streaming workload never pays an
O(N) rebuild per write.
Quantization
Optional compression of the stored vectors — product, scalar, or binary — trading a little recall for a large drop in memory. The tradeoff table documents the knobs.
Filtering & hybrid search
A search can carry a filter over filterable payload fields. Quiver’s planner either pre-filters to an exact scan (when the filter is selective) or post-filters the ANN results — so metadata constraints compose with vector similarity.
Multi-vector documents
A multivector collection stores each document as a set of token vectors and
ranks documents by MaxSim late interaction (ColBERT). See
Multi-vector / late interaction.
Tenancy, keys, and access control
Authentication is by API key; authorization is default-deny RBAC with roles
(read ⊆ write ⊆ admin) and collection scopes (exact names or a
trailing-* prefix for per-namespace isolation). Optional mutual TLS adds a
second factor, and an append-only audit log records every mutating or
administrative action. See Self-hosting & configuration.
Embeddable vs server
One binary runs three ways: an in-process embeddable database, a server (REST + gRPC), and an MCP server (stdio) so AI agents can drive it. A data directory is portable between them.
Quickstart
Pre-built binaries and a container image are on the roadmap; today you build from source. The whole loop — clone, build, run, first query — takes a few minutes.
Prerequisites
rustupwith the stable toolchainjust(cargo install just)uv(for the demo seed script and the Python SDK)
Clone and run the demo
git clone https://github.com/achref-soua/quiver
cd quiver
just demo # build, start an encrypted server, seed a demo collection
just demo brings up a server with encryption-at-rest on, seeds a small
collection through the Python SDK, and prints how to open the cockpit. Then, in
another terminal:
quiver tui --api-key quiver-demo-key # the retro cockpit
In the cockpit, press v (or enter) on a collection to open the constellation
view — a 2-D random-projection scatter of its vector space with the query’s
nearest neighbour highlighted; move the cursor and press enter to re-query around
any point.
Install the CLI
# from crates.io (the `quiver` binary, published as quiverdb-cli):
cargo install quiverdb-cli
# …or from a cloned repo:
cargo install --path crates/quiver-cli
quiver serve # gRPC + REST, encrypted by default
quiver tui # the cockpit
quiver mcp # MCP server (stdio) for AI agents
Heads-up: Quiver’s CLI publishes as
quiverdb-cli— thequiver-cliname on crates.io is an unrelated third-party project, which is why thequiverdb-*namespace is used (ADR-0056).
Your first query (Python)
from quiver import Client, Point
with Client("http://127.0.0.1:6333", api_key="…") as q:
q.create_collection("items", dim=3, metric="cosine")
q.upsert("items", [Point("a", [0.1, 0.2, 0.3], {"tag": "x"})])
hits = q.search("items", [0.1, 0.2, 0.3], k=5)
print(hits)
The same flow is available over REST & gRPC, the MCP server, and the TypeScript SDK.
Build, test, and the gate
just build # compile the workspace
just verify # the full local quality gate (lint · test · doc · deny · audit)
cargo run -p quiverdb-cli -- --help
just verify is the authoritative gate (the CI workflows are manual-only by
design). Next: Self-hosting & configuration.
Self-hosting & configuration
Quiver is one static binary. Every option is an environment variable with a
secure default; the full list lives in
.env.example and
the rationale in
ADR-0013.
Encryption at rest
Encryption-at-rest is on by default. The server requires a 256-bit key in
QUIVER_ENCRYPTION_KEY (generate one with openssl rand -hex 32) unless you opt
out with QUIVER_INSECURE=true. It seals segments, the manifest, and the WAL
alike. That key is a master key that wraps a per-collection data-encryption key
(envelope encryption,
ADR-0010),
so dropping a collection crypto-shreds it. For production, hold the master key
in a file via QUIVER_MASTER_KEY_FILE rather than an environment variable.
export QUIVER_ENCRYPTION_KEY=$(openssl rand -hex 32)
quiver serve
TLS
TLS (via rustls) is required for any non-loopback bind. Provide a certificate
and key; for an extra factor, set QUIVER_TLS_CLIENT_CA to require mutual TLS,
so both transports demand a client certificate chaining to that CA.
Authentication & RBAC
Authentication is by API key; authorization is default-deny RBAC. A bare
QUIVER_API_KEYS secret is an all-collections admin key. For least privilege,
define scoped keys in quiver.toml with a role (read ⊆ write ⊆ admin) and
a collections scope — exact names or a trailing-* prefix (e.g. acme.*) for
per-namespace isolation. Over-scope and cross-namespace access return 403, and
listing hides collections outside the scope. See
ADR-0011.
Audit logging
Set QUIVER_AUDIT_LOG to record every mutating and administrative operation, and
every denial, to an append-only audit log — the acting key, the action, the
resource, and the outcome, never the secret.
Running with Docker
just docker # build the image (infra/docker/Dockerfile)
docker run --rm -p 6333:6333 -p 6334:6334 \
-e QUIVER_ENCRYPTION_KEY=$(openssl rand -hex 32) \
-v quiver-data:/data quiver:dev serve
The image is multi-stage and runs as a non-root user. See
infra/ for the
Dockerfile and deployment scaffolding.
Replication
Run asynchronous read replicas by pointing a follower at a leader with
QUIVER_LEADER_URL (and QUIVER_LEADER_API_KEY). See Replication.
Lock-free reads (experimental)
Set QUIVER_MVCC_READS=1 to serve reads of single-vector, in-memory collections
from a lock-free MVCC snapshot so they no longer block on a concurrent writer’s
exclusive lock (ADR-0064). It is default-off and experimental: pure-vector,
filtered, and hybrid reads are served from the snapshot; the remaining work
(increment 3) is a loom model and a dedicated-hardware benchmark before it
becomes the default. See Concurrency.
Observability
Quiver exposes structured logs and metrics; see
ADR-0014.
The cockpit (quiver tui) shows live server metrics and a collection browser.
Configuration reference
Quiver reads configuration from, in increasing precedence: built-in defaults → an
optional quiver.toml → QUIVER_* environment variables. The config is
validated at startup — the server refuses to boot on an insecure
configuration unless QUIVER_INSECURE=true. The annotated, copy-pasteable
template is .env.example;
this page is the exhaustive reference for every variable.
Secure by default. Without
QUIVER_INSECURE=true, the server requires an API key and an encryption key, and refuses a non-loopback bind without TLS.
Server
| Variable | Default | Req. | Description |
|---|---|---|---|
QUIVER_REST_ADDR | 127.0.0.1:6333 | no | REST (HTTP/1.1+2) bind address. A non-loopback bind needs TLS (or INSECURE). |
QUIVER_GRPC_ADDR | 127.0.0.1:6334 | no | gRPC (HTTP/2) bind address. |
QUIVER_DATA_DIR | ./quiver-data | no | Directory for segments, the WAL, and the manifest. |
QUIVER_API_KEYS | — | yes¹ | Accepted API keys, comma-separated; each is an all-collections admin key. For role/collection-scoped keys, use [[api_keys]] tables in quiver.toml. |
¹ Required unless QUIVER_INSECURE=true. The coordinator needs keys too (see Cluster).
Encryption at rest (on by default)
| Variable | Default | Req. | Description |
|---|---|---|---|
QUIVER_ENCRYPTION_KEY | — | yes¹ | 256-bit master key as 64 hex chars (openssl rand -hex 32). Wraps a per-collection DEK; dropping a collection crypto-shreds it. |
QUIVER_MASTER_KEY_FILE | — | no | Read the hex master key from a file instead of the env (mounted secret). Set exactly one of this or QUIVER_ENCRYPTION_KEY. Restrict to 0600 (warned otherwise). |
TLS in transit
| Variable | Default | Req. | Description |
|---|---|---|---|
QUIVER_TLS_CERT | — | no² | PEM certificate chain. |
QUIVER_TLS_KEY | — | no² | PEM private key. Set together with QUIVER_TLS_CERT. |
QUIVER_TLS_CLIENT_CA | — | no | PEM CA for mutual TLS: when set, both transports require a client cert chaining to this CA (in addition to the API key). Requires TLS. |
² Required for a non-loopback bind unless QUIVER_INSECURE=true.
Development opt-out
| Variable | Default | Req. | Description |
|---|---|---|---|
QUIVER_INSECURE | false | no | Disables the secure defaults: allows no API keys, no encryption-at-rest, and a non-loopback bind without TLS. Never set in production. |
Observability
| Variable | Default | Req. | Description |
|---|---|---|---|
RUST_LOG | info | no | Log filter (e.g. debug, quiver_server=debug). |
QUIVER_AUDIT_LOG | — | no | Path for the append-only JSON-Lines audit log (mutations + denials). Always also emitted as quiver::audit tracing events. |
QUIVER_OTLP_ENDPOINT | — | no | OTLP/gRPC endpoint for trace export (requires the otlp build feature). |
QUIVER_OTLP_SERVICE_NAME | quiver | no | service.name resource attribute for exported traces. |
QUIVER_OTLP_TIMEOUT_SECS | 3 | no | Export timeout. |
Prometheus metrics are always served at GET /metrics (open; bind privately).
Replication (ADR-0030)
| Variable | Default | Req. | Description |
|---|---|---|---|
QUIVER_LEADER_URL | — | no | Run this node as a read-replica follower of the leader’s gRPC endpoint; it serves reads and refuses writes. Unset = a normal read-write leader. |
QUIVER_LEADER_API_KEY | — | no | API key the follower presents to the leader’s admin-scoped Replicate stream. |
Query cost limits (ADR-0040)
Per-request caps; over-limit requests are rejected with HTTP 400 / gRPC
InvalidArgument.
| Variable | Default | Description |
|---|---|---|
QUIVER_MAX_K | 10000 | Max top-k for search / multi-vector search. |
QUIVER_MAX_EF_SEARCH | 4096 | Max search beam width. |
QUIVER_MAX_FETCH_LIMIT | 10000 | Max fetch page size. |
QUIVER_MAX_VECTOR_DIM | 8192 | Max collection dimension and query-vector length. |
QUIVER_MAX_PAYLOAD_BYTES | 65536 | Max serialized-JSON payload per point (64 KiB). |
QUIVER_MAX_BATCH_SIZE | 1000 | Max points/documents per upsert request. |
QUIVER_MAX_REQUEST_BODY_BYTES | 33554432 | Max HTTP request body (32 MiB). |
QUIVER_MAX_SPARSE_TERMS | 4096 | Max non-zero terms in a hybrid sparse query (ADR-0043). |
QUIVER_MAX_BULK_BATCH_SIZE | 50000 | Max points per bulk upsert (points:bulk, ADR-0045). |
Rate limiting (ADR-0049, opt-in)
| Variable | Default | Description |
|---|---|---|
QUIVER_RATE_LIMIT_REQUESTS_PER_SECOND | 0 (off) | Per-key token-bucket refill rate; over-rate → HTTP 429 / gRPC ResourceExhausted. |
QUIVER_RATE_LIMIT_BURST | 0 | Bucket burst capacity. |
Concurrency
| Variable | Default | Description |
|---|---|---|
QUIVER_MVCC_READS | false | Experimental (ADR-0064): serve reads of single-vector in-memory collections from a lock-free snapshot. Durability/crash gate unchanged. |
Cluster router (ADR-0065/0066, opt-in)
| Variable | Default | Description |
|---|---|---|
QUIVER_CLUSTER_SHARDS | — | Bracketed list of shard base URLs → turns this server into a stateless router (HRW sharding + scatter-gather). Empty = single node. |
QUIVER_CLUSTER_SHARD_KEY | — | API key the router/coordinator presents to shards (and that a router presents to a keyed coordinator). |
QUIVER_CLUSTER_REPLICAS | — | Per-shard read replicas, each "<shard_index>=<replica_url>" (repeatable). |
QUIVER_COORDINATOR | false | Run this process as the cluster coordinator (membership API; data-plane-free). Its API is authenticated — set QUIVER_API_KEYS here too. |
QUIVER_COORDINATOR_STATE | — | File where the coordinator persists its versioned map + id counter. Unset = in-memory only. |
QUIVER_COORDINATOR_URL | — | On a router, refresh the shard map from this coordinator on an interval (no restart on membership change). |
Autoscaling (ADR-0065 increment 5, opt-in, coordinator)
| Variable | Default | Description |
|---|---|---|
QUIVER_AUTOSCALE_ENABLED | false | Enable automatic scale-out on the coordinator. |
QUIVER_AUTOSCALE_HIGH_WATER_POINTS | 0 | Per-shard point count above which to scale out (0 disables even when enabled). |
QUIVER_AUTOSCALE_STANDBY_URLS | — | Pool of standby shard URLs to grow into, consumed one per scale-out. |
QUIVER_AUTOSCALE_INTERVAL_SECS | — | How often to sample the load signal. |
QUIVER_AUTOSCALE_COOLDOWN_SECS | — | Minimum delay between scale-outs. |
QUIVER_AUTOSCALE_MAX_SHARDS | — | Cap on the shard count. |
Scale-in is not automated — shrink with a manual, drained DELETE /cluster/shards/{id}.
Per-shard Raft write HA (ADR-0067, opt-in, raft build feature)
| Variable | Default | Description |
|---|---|---|
QUIVER_RAFT_NODE_ID | — | This node’s Raft id within its shard group. |
QUIVER_RAFT_MEMBERS | — | The shard’s Raft members (<id>=<grpc_url>, …). A write is acknowledged only after a quorum. |
Embedded / CLI-only
| Variable | Default | Description |
|---|---|---|
QUIVER_CONFIG | quiver.toml | Config file path (the mcp/admin commands). |
QUIVER_TUI_URL | http://127.0.0.1:6333 | quiver tui target server. |
QUIVER_API_KEY | — | Bearer token for quiver tui. |
QUIVER_DEMO_DIR | platform data dir | Data directory override for quiver demo. |
Server-side embedding/rerank providers are configured per collection with
[embedding.<collection>] / [rerank.<collection>] tables in quiver.toml
(provider, model, endpoint, dim, api_key_env) — see the
embedding guide.
Kubernetes & Helm
Quiver ships a Helm chart (infra/helm/quiver) and raw manifests
(infra/k8s/quiver.yaml) for self-hosting on a cluster.
Helm
# A 256-bit master key, generated once and kept safe (rotating it re-encrypts).
KEY=$(openssl rand -hex 32)
helm install quiver ./infra/helm/quiver \
--set image.repository=ghcr.io/achref-soua/quiver \
--set image.tag=0.20.1 \
--set encryption.masterKey="$KEY" \
--set apiKeys="$(openssl rand -hex 24)"
Quiver encrypts at rest by default, so the install fails fast with a clear message unless you provide a key strategy:
| Value | Meaning |
|---|---|
encryption.masterKey | 64 hex chars; the chart stores it in a Secret. |
encryption.existingSecret (+ existingSecretKey) | reference a Secret you manage. |
encryption.insecure=true | dev only — disables at-rest encryption. |
The chart deploys a single-node server (REST 6333, gRPC 6334) with a PVC at
/data, runs as the distroless non-root user (uid 65532) with a read-only root
filesystem, and exposes a ClusterIP Service. Set ingress.enabled=true for
external REST access. See infra/helm/quiver/values.yaml
for every value.
Image. No container image is published by the project yet — build and push one from
infra/docker/Dockerfile(or use the release binaries) and pointimage.repository/image.tagat your registry.
Raw manifests
No Helm? Edit the Secret and image in
infra/k8s/quiver.yaml
and apply it:
kubectl apply -f infra/k8s/quiver.yaml
Reaching the server
kubectl port-forward svc/quiver 6333:6333
curl http://127.0.0.1:6333/metrics # the metrics endpoint is open (scrape it from Prometheus)
RAG with Quiver
Quiver is a drop-in retrieval backend for Retrieval-Augmented Generation. It is model-agnostic — you bring the embeddings (OpenAI, Cohere, a local sentence-transformer, anything), Quiver stores them, filters on metadata, and returns nearest neighbours fast. This guide walks the full loop: chunk → embed → upsert → filtered search → rerank → answer.
A runnable, dependency-light version of everything here is in
examples/rag/quickstart.py
(it uses a deterministic hash embedder so it runs with no API key; swap in a real
model for production).
1. Create a collection with filterable metadata
Pick the metric your embedding model was trained for (cosine for most
sentence encoders, l2 or dot otherwise), and declare the payload fields you
will filter on — the metadata pre-filter is exact, so retrieval can be
scoped to a tenant, a document set, a date range, etc.
from quiver import Client, FilterableField, Point
q = Client("http://127.0.0.1:6333", api_key="…")
q.create_collection(
"kb",
dim=384, # must match your embedder
metric="cosine",
filterable=[
FilterableField("source", "keyword"),
FilterableField("year", "numeric"),
],
)
2. Chunk and embed
Split long documents into overlapping windows (so a relevant passage is never cut across a boundary), embed each chunk, and keep the original text in the payload so you can feed it to the LLM later.
def chunk(text, size=800, overlap=120):
out, start = [], 0
while start < len(text):
out.append(text[start:start + size]); start += size - overlap
return out
points = []
for doc in documents:
for j, piece in enumerate(chunk(doc.text)):
points.append(Point(
id=f"{doc.id}-{j}",
vector=embed(piece), # your model
payload={"text": piece, "source": doc.source, "year": doc.year},
))
3. Upsert (batched, with progress)
upsert_iter chunks a large corpus into server-friendly batches (within the
configured max_batch_size) and reports progress — ideal for loading millions
of chunks.
q.upsert_iter("kb", points, batch=500, on_progress=lambda n: print(f"upserted {n}"))
For a high-throughput ingestion service, use the async client so embedding and upload overlap:
from quiver import AsyncClient
async with AsyncClient(api_key="…") as q:
await q.upsert_iter("kb", points, batch=500)
4. Retrieve (with a metadata filter)
Embed the question and search, scoping with a filter when you can — pre-filtering both improves answer quality and reduces the candidate set:
hits = q.search(
"kb",
embed(question),
k=8,
filter={"and": [
{"eq": {"field": "source", "value": "handbook"}},
{"gte": {"field": "year", "value": 2024}},
]},
)
context = "\n\n".join(h.payload["text"] for h in hits)
5. Rerank (optional) and answer
search already returns exact-reranked nearest neighbours. For higher precision,
over-fetch and re-score the top-k with a cross-encoder before trimming to the few
chunks you feed the LLM. The quiver.rerank helper handles the extract → score →
sort → truncate step (you bring the scorer):
# pip install sentence-transformers
from sentence_transformers import CrossEncoder
from quiver import rerank
ce = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2")
hits = q.search("kb", embed(question), k=50) # over-fetch
top = rerank(question, hits, lambda query, texts: ce.predict([(query, t) for t in texts]),
key="text", top_k=4) # best-first RerankResults
context = "\n\n".join(r.match.payload["text"] for r in top)
Then hand the assembled context plus the question to your LLM as grounding. For paragraph/token-level retrieval (ColBERT-style late interaction), see multi-vector.
Skip the embedding step (let Quiver embed)
Steps 2–5 assume you run the embedding model. If you’d rather not, configure a
server-side embedding provider (OpenAI / Cohere / Ollama / any
OpenAI-compatible endpoint) per collection and use upsert_text / search_text:
Quiver embeds the text for the dense side, indexes it for BM25, and (optionally)
reranks — all server-side, in one call each. The engine stays model-agnostic;
this is an opt-in edge convenience. Full setup and the provider table are in
Server-side embedding & reranking.
q.upsert_text("kb", [{"id": "1", "text": chunk, "payload": {"source": "manual"}}])
hits = q.search_text("kb", "how do refunds work?", k=5, rerank=True) # embed + BM25 + rerank
Hybrid retrieval
For queries with rare terms, exact matches, or out-of-domain phrasing, fuse the
dense embedding with a lexical signal. The easiest path is full-text: give
each point a __quiver_text__ string (or use upsert_text, which fills it for
you) and pass query_text to hybrid_search — Quiver tokenizes and scores it
with BM25, fused with the dense ranking via Reciprocal Rank Fusion. For
learned-sparse vectors (SPLADE/BGE-M3) store them under __quiver_sparse__
instead. See Hybrid search.
hits = q.hybrid_search("kb", vector=embed(query), query_text=query, k=10) # dense ⊕ BM25
Where to go next
- Tuning for RAG — choosing the index and quantizer for your recall ↔ latency ↔ RAM budget (including the memory-frugal disk path).
- Agentic patterns — let an LLM agent drive Quiver over MCP.
- LangChain / LlamaIndex / Haystack — Quiver ships vector-store adapters; pass
hybrid=Truefordense ⊕ BM25retrieval out of the box. See SDKs.
Agentic patterns
Quiver ships an MCP server (quiver mcp) that speaks JSON-RPC 2.0
over stdio, so any MCP-capable LLM agent (an IDE assistant or a custom agent) can
use Quiver as a tool: build a knowledge base, retrieve from it, and curate it, all
without bespoke glue. This guide covers the agent-facing surface and a typical loop.
Connect
Point your MCP client at the binary. Encryption-at-rest is on by default, so the agent’s memory is sealed on disk:
// e.g. an MCP client config
{
"mcpServers": {
"quiver": {
"command": "quiver",
"args": ["mcp", "--data-dir", "/var/lib/quiver"],
"env": { "QUIVER_ENCRYPTION_KEY": "<64-hex>" }
}
}
}
Tools the agent gets
| Tool | What it does |
|---|---|
list_collections | Enumerate collections |
collection_info | Inspect one collection’s shape — dim, metric, index, filterable fields, multivector, encryption, count |
database_stats | A whole-database overview in one call — collection count, total points, a per-collection summary, and snapshot status (manifest_version, disk_bytes) |
create_collection | Create one (dim, metric, index, filterable, multivector, vector_encryption) |
delete_collection | Drop an entire collection and all its points (reports whether it existed) |
snapshot | Take a consistent online backup of the whole database into a server-local directory (ADR-0050) |
upsert | Insert/replace a point (id, vector, payload) |
search | k-NN with an optional payload filter |
fetch | List points by filter without ranking |
get | Fetch one point by id |
delete | Delete a point by id |
upsert_document / search_multi_vector / delete_document | Multi-vector (ColBERT) late-interaction documents |
upsert_text / search_text | Store/query by text — Quiver embeds it server-side (needs a provider; run quiver mcp --config quiver.toml) |
All calls go through the same authorized op layer and cost limits (ADR-0040) as REST/gRPC, so an agent cannot exceed the server’s guardrails.
A typical agent loop
A research assistant maintaining its own long-term memory:
list_collections→ does aresearchcollection exist? If not,create_collection("research", dim=…, metric="cosine", filterable=[{path:"topic",field_type:"keyword"}]).- As it reads sources, the agent embeds passages (with its own model) and
upserts them with{topic, url, added}payloads. - To answer a question, it embeds the query and
searches with afilter(e.g.topic = "vector-db"), then grounds its answer in the returned text. - It
deletes stale entries orfetches a topic to review what it knows.
Because Quiver is model-agnostic, the agent owns the embedding step — pass the float vectors it produces. The server stores, filters, and ranks.
Or let Quiver do the embedding: configure an [embedding.<collection>] provider
(ADR-0047/0058), launch quiver mcp --config quiver.toml, and the agent can use
upsert_text / search_text to store and query by text directly — no client-side
model. search_text(rerank=true) additionally reranks in one call when a
[rerank.<collection>] provider is set.
Tips
- Declare
filterablefields up front so the agent can scope retrieval (per-user, per-topic, per-recency) — the pre-filter is exact. - Scope the agent’s API key (RBAC, security overview) to just its collection prefix, so a tool call can’t touch other data.
- Use
client_sidevector encryption (client-side vectors) if the agent’s host should not be able to read the stored vectors — the agent fetches and ranks locally. - For paragraph-grained memory, store documents as token sets and use
search_multi_vector(multi-vector).
Tuning Quiver for RAG
Every RAG workload sits somewhere on a recall ↔ latency ↔ RAM ↔ cost surface. Quiver lets you pick that point per collection — index family, quantizer, and a few query knobs. This guide is the practical map.
Pick the index
| Index | RAM resident | Best for |
|---|---|---|
hnsw (default) | graph + full vectors | small/hot collections; highest recall, lowest latency |
ivf (+PQ) | centroids (+ codes) | predictable RAM, fast build, a frugal fallback |
vamana | PQ codes + node cache | medium collections, one machine |
disk_vamana | PQ codes only | large collections (10M–100M+) on modest RAM — the memory-frugality wedge |
colbert | coarse centroids + residual codes | token-level (late-interaction) retrieval |
Rule of thumb: start with hnsw; if the working set no longer fits comfortably
in RAM, move to disk_vamana — it serves high recall while holding only the
PQ codes resident (the full vectors live on the encrypted on-disk index). On
SIFTSMALL the disk path holds recall@10 up to 1.000 at a ~32× smaller resident
footprint than full-precision vectors; the arithmetic scales (e.g. a 10M × 768-d
collection ≈ 1 GB resident vs ~31 GB). See indexing
and the disk-path numbers.
q.create_collection("kb", dim=768, metric="cosine", index="disk_vamana", pq_subspaces=48)
Pick the quantizer
pq_subspaces (product quantization) trades a little recall for a large RAM/disk
saving; scalar (4×) and binary (32×, a fast Hamming pre-filter then exact re-rank)
are also available. More subspaces → higher fidelity → more memory. Tune against
your embeddings; the quantization tradeoff table
shows the shape.
Tune the query
ef_search is the recall/latency dial. On SIFT1M (in-memory HNSW) Quiver’s
own curve — second only to FAISS on throughput at this recall bar
(full comparison):
ef_search | 16 | 32 | 64 | 128 | 256 |
|---|---|---|---|---|---|
| recall@10 | 0.793 | 0.895 | 0.958 | 0.986 | 0.995 |
| QPS (1T) | 1539 | 1424 | 1222 | 955 | 701 |
| p95 (ms) | 0.8 | 0.8 | 1.0 | 1.3 | 1.7 |
For RAG, recall@10 ≈ 0.95–0.99 (here ef_search 64–256) is the usual sweet spot:
the LLM tolerates a near-miss in the candidate set, and you save latency. Raise
k to give a reranker more to work with, then trim to the few chunks you ground
on.
Operational guardrails
The server enforces query cost limits (ADR-0040) — caps on k, ef_search,
fetch limit, vector dimension, payload size, and batch size — so one oversized
request can’t exhaust the node. The defaults are generous; raise a specific
QUIVER_MAX_* (see .env.example) if a legitimate workload needs more, rather
than removing the guardrail. Batched ingestion via upsert_iter stays within
max_batch_size automatically.
Quick checklist
- Embeddings normalized? Use
metric="cosine"for most sentence encoders. - Working set bigger than RAM?
index="disk_vamana"withpq_subspaces. - Need scoping? Declare
filterablefields and pre-filter every query. - Latency-bound? Lower
ef_search; recall-bound? Raise it (and add a reranker). - High concurrency? Use the async client and batch upserts.
Indexing & memory frugality
Memory frugality is Quiver’s wedge: serve large datasets from a laptop’s RAM budget at a fixed recall. The lever is the disk-resident graph index plus quantization. The full design — with cited papers — is in the architecture deep dive; this page is the practical overview.
Choosing an index
Set the index per collection at creation time.
| Index | Where it lives | Best for |
|---|---|---|
hnsw | RAM | the default; fast, high-recall in-memory search |
ivf | RAM | clustered datasets; pairs with quantization |
vamana | RAM | the DiskANN graph, in memory |
disk_vamana | disk (encrypted) + PQ codes in RAM | memory frugality — large datasets, small RAM |
colbert | RAM (derived) | multi-vector ColBERTv2/PLAID token pools |
Quantization
Compress stored vectors to cut RAM at a small recall cost:
- scalar — per-dimension 8-bit; simple, modest savings.
- product (PQ) — subspace codebooks; the largest savings, tunable via
pq_subspaces. - binary — 1-bit with a Hamming pre-filter and an exact re-rank.
The per-collection recall ↔ latency ↔ memory knobs and their measured
trade-offs are tabulated in
docs/benchmarks/quantization-tradeoffs.md.
The disk-resident path
disk_vamana keeps the graph and full-precision vectors in the encrypted on-disk
index and holds only compact PQ codes resident. On SIFTSMALL it serves recall@10 up
to 1.000 with a 32× smaller RAM-resident footprint than full-precision
vectors — a reduction that is exact arithmetic and scales (e.g. a 10M × 768-d
collection: ~1 GB resident vs ~31 GB). The head-to-head RSS vs Qdrant/LanceDB
is reference-hardware-pending and never fabricated; method and numbers live in
docs/benchmarks/results/disk-path.md.
Recall on SIFT1M
In-memory HNSW (M=16, efC=200), recall@10 vs exact ground truth — a property of
the index and data, so host-independent:
ef_search | 16 | 32 | 64 | 128 | 256 |
|---|---|---|---|---|---|
| recall@10 | 0.793 | 0.895 | 0.958 | 0.986 | 0.995 |
Reproduce with cargo run --release --example sift_recall.
Incremental updates
Every index family applies inserts, updates, and deletes incrementally, so
streaming workloads avoid an O(N) rebuild per write:
- IVF — SpFresh-style LIRE rebalancing (cell split/merge).
- HNSW —
O(1)soft-delete with an amortized rebuild. - Vamana / disk graph — FreshDiskANN StreamingMerge (a read-only base graph
plus an in-memory delta graph and an
O(1)deletion set, consolidated past a churn threshold).
All indexes stay derived and the disk artifact keeps its write-once contract, so
the kill -9 crash gate is untouched. See the
ADRs for the per-family designs.
Concurrency & the off-lock rebuild
Quiver is single-writer, many-reader. The server guards the engine with a
reader–writer lock (ADR-0057): a search takes the shared lock, so many searches
run in parallel; a write takes the exclusive lock. Durability is unchanged —
this is about read throughput and read visibility, never the WAL-fsync
acknowledgement (see Snapshots & backup and the crash gate).
Deferred rebuilds, and why they used to hurt
Some writes cannot be absorbed into the index in place — a bulk load, an HNSW in-place update of an existing id, a delete, a replicated write. The engine defers the rebuild: it marks the collection stale and keeps the prior, still valid index. The question is what the next reader does about it.
Before v0.22.0, that reader rebuilt the index under the exclusive lock before
serving — correct, but it blocked every other reader for the whole build. The
reproducible harness (crates/quiver-embed/tests/mvcc_measurement.rs) measured
the stall on a dev box (HNSW, dim 128, indicative):
| Collection size | single-thread rebuild | steady read p99 | reader stall during rebuild |
|---|---|---|---|
| 20 000 | 7.3 s | 422 µs | 8.1 s |
| 50 000 | 26.7 s | 379 µs | 29.7 s |
| 100 000 | 68.7 s | 408 µs | 76.6 s |
The stall is four to five orders of magnitude above the steady-state p99, and grows with collection size.
The fix — rebuild off the exclusive lock (ADR-0062)
The server now serves the prior snapshot while it rebuilds the index off-lock:
- Serve the prior snapshot when stale. A stale read returns results from the prior index (still a valid graph over the prior ids) instead of blocking — the snapshot-isolation contract sanctioned by ADR-0053.
- Rebuild with no lock held. The rebuild inputs are captured under the shared read lock (other reads continue), the new index is built holding no lock at all, and only the final pointer-swap takes a brief exclusive lock. One rebuild runs per stale collection (deduped by an in-flight set).
- A write-generation guard. A per-collection counter is bumped on every write; if it moved during a build, the collection stays stale and another rebuild is scheduled — so no write is lost.
The result: the seconds-long stall collapses to the cost of serving the prior
snapshot (sub-millisecond) plus the brief swap. No unsafe, no lock-free data
structure, no loom — just Arc and the existing RwLock.
What this changes (and what it doesn’t)
- Server reads are eventually consistent across a rebuild window: a read may briefly miss a write committed a moment ago, but never sees a half-applied one.
- Embedded
&mutcallers keep read-your-writes: the in-processsearch/hybrid_search/search_multi_vectorwrappers still rebuild synchronously, so a single-threaded program always sees its own write. - Durability and the
kill -9crash gate are byte-for-byte unchanged.
The fully lock-free path — reads proceeding during a write over an atomically-swapped snapshot (arc-swap, ADR-0053/ADR-0064) — fixed the rebuild’s lock scope first because that was where the seconds were. The remaining cost is the write’s exclusive-lock window.
Lock-free MVCC reads (experimental, QUIVER_MVCC_READS)
A measured contention sweep
showed that under the RwLock, a single concurrent writer of small upserts
already collapses retained read throughput to ~0.10× and four writers starve
readers to near zero — every writer’s exclusive-lock acquisition blocks all
readers. That justifies moving reads off the writer’s lock entirely (ADR-0064).
Set QUIVER_MVCC_READS=1 to enable the lock-free read snapshot for
single-vector, in-memory collections: the single writer publishes an immutable
CollectionSnapshot (the base index as of the last rebuild plus a small overlay
of writes since) into an arc-swap cell, and a reader load()s it without taking
any lock — searching the base and merging the overlay, snapshot-isolated, never
torn. Durability and the kill -9 crash gate are untouched (MVCC changes
visibility, not durability).
This ships in staged increments behind the default-off flag:
- Increments 1–2 (done): the snapshot infrastructure and reads over the snapshot — pure-vector, payload/vector enrichment, filtered (exact pre-filter and post-filter), and hybrid (dense ⊕ sparse/BM25) — all served from the published snapshot, reusing the same store-fetch and RRF logic as the locked path.
- Increment 3 (server cutover, done): the server caches each MVCC-served collection’s snapshot cell outside the database lock, so a pure-vector query loads the cell and searches it with no lock at all — it never blocks on a concurrent writer. (Payload, filtered, and hybrid reads still take the read lock for the store fetch — the store is not safe to read lock-free under a writer (ADR-0057) — but they too serve from the snapshot.) The writer drives off-lock consolidation, since fast-path reads bypass the reader-driven scheduler.
- Remaining: a before/after read-during-write benchmark (the ratio is measured on the shared dev box; absolute QPS stays reference-hardware-pending). The flag stays default-off until that evidence justifies flipping it.
Leave the flag off (the default) for the proven RwLock read path.
Hybrid (dense + sparse) search
Hybrid search combines a dense embedding (semantic similarity) with a sparse vector (learned-sparse like SPLADE/BGE-M3, or lexical term weights) and fuses the two rankings — the combination that beats dense-only retrieval on rare terms, exact matches, and out-of-domain queries. Quiver fuses them with Reciprocal Rank Fusion (RRF) (ADR-0043).
How it works
- A point carries a sparse vector in its payload under the reserved key
__quiver_sparse__— parallelindices(dimension ids) andvalues(weights). It rides the existing encrypted store, so there is no on-disk format change. hybrid_searchruns the dense ANN ranking and a sparse dot-product ranking independently, re-checks the same payload filter on both (results stay exact), and fuses by RRF: a document at rank r in a list contributes1 / (k0 + r + 1), summed across lists. RRF is rank-based, so the incomparable dense-distance and sparse-score scales need no normalisation.- Either query may be omitted — pass only a dense vector for pure dense search, or only a sparse vector for pure lexical/sparse search, through the same call.
Store a sparse vector
Put __quiver_sparse__ in the point payload (the dense vector is upserted as
usual):
from quiver import Client, Point
q = Client(api_key="…")
q.create_collection("kb", dim=384, metric="cosine")
q.upsert("kb", [Point(
id="doc1",
vector=dense_embedding, # your model
payload={
"text": "…",
"__quiver_sparse__": {"indices": [4, 17, 2090], "values": [0.7, 1.2, 0.3]},
},
)])
Query
from quiver import SparseVector
hits = q.hybrid_search(
"kb",
vector=dense_query, # omit for pure-sparse
sparse=SparseVector(indices=[4, 2090], values=[0.9, 0.4]), # omit for pure-dense
k=10,
filter={"eq": {"field": "lang", "value": "en"}},
rrf_k0=60.0,
)
Hybrid search is reachable from every surface (ADR-0045):
- REST:
POST /v1/collections/{name}/query/hybridwith{ "vector": [...], "sparse_indices": [...], "sparse_values": [...], "k": 10, "filter": {...}, "rrf_k0": 60 }. - gRPC: the
HybridSearchRPC (HybridSearchRequestwith a densevector, asparseSparseVector,filter,k,ef_search,rrf_k0). - MCP: the
hybrid_searchtool (vector,sparse_indices/sparse_values,query_text,k,filter,rrf_k0). - SDKs:
hybrid_search(Python) andhybridSearch(TypeScript).
Full-text (BM25) — search by words (ADR-0046)
You don’t have to build sparse vectors yourself. Give a point a __quiver_text__
string and Quiver tokenizes it (Unicode split, lowercase, stop-words, Snowball
stemming) into a term-frequency vector at ingest; query with query_text and Quiver
scores it with Okapi BM25 over the same inverted index — fused with a dense
vector through the same RRF path for dense ⊕ BM25 hybrid:
client.upsert("docs", [Point(id="1", vector=embed(text), payload={"__quiver_text__": text})])
client.hybrid_search("docs", vector=embed(query), query_text=query, k=10) # dense ⊕ BM25
An explicit __quiver_sparse__ vector (e.g. SPLADE) still takes precedence over
__quiver_text__. BM25 uses the index’s corpus statistics (document frequency,
average length), so it stays correct under incremental upsert/delete.
Performance: the derived inverted index
The sparse side is served by an in-memory inverted index (dim → {doc → weight},
ADR-0045): a query scores only the documents that share one of its nonzero terms,
rather than scanning every row. The index is derived — built from the store
when a collection’s index is (re)built and maintained incrementally on
upsert/delete — so there is no on-disk format change and the kill -9 crash gate is
untouched. A collection with no sparse vectors carries no index, and a not-yet-built
or client-side collection falls back to a correct full store scan.
Limits and scope
- The sparse query’s term count is bounded by
QUIVER_MAX_SPARSE_TERMS(default 4096), alongside the other query cost limits. - Tokenization uses the Snowball (Porter2) English stemmer (ADR-0048), so
morphological variants conflate (
connection/connected/connecting→connect); ingest and query share it, keeping the conflation consistent. Term ids are a 32-bit hash, so distinct tokens can in principle collide — negligible for realistic vocabularies.
See the RAG guide for where hybrid retrieval fits in a pipeline.
Server-side embedding & reranking
Quiver’s engine is model-agnostic — by default you bring the vectors. But the single biggest friction in RAG is “I have to embed the text myself.” So the server offers an opt-in, provider-agnostic embedding (and reranking) step: send text, and Quiver embeds it for you, stores it, and searches it — while the engine stays model-free (the adapter lives only at the server edge, never in the embeddable library). See ADR-0047.
Opt-in, off by default. A collection with no configured provider behaves exactly as before (you supply vectors). Library mode is unaffected.
Configure a provider (server config, secrets by reference)
Providers are configured per collection in quiver.toml — not in the
on-disk collection schema, so there is no format change and the data directory
never holds a secret. An API key is referenced by the name of an environment
variable and resolved at startup; the value is never persisted.
# quiver.toml
[embedding.docs] # collection "docs"
provider = "openai" # openai | ollama | http | cohere | fake
model = "text-embedding-3-small"
dim = 1536 # MUST equal the collection's vector dim
api_key_env = "OPENAI_API_KEY" # name of the env var holding the key
[rerank.docs] # optional, enables search_text(rerank=true)
provider = "cohere"
model = "rerank-v3.5"
api_key_env = "COHERE_API_KEY"
provider | Endpoint | Notes |
|---|---|---|
openai | https://api.openai.com/v1/embeddings | Bearer api_key_env. |
ollama | your endpoint (e.g. http://localhost:11434/v1/embeddings) | OpenAI-compatible; usually no key. |
http | your endpoint | Any OpenAI-compatible server (vLLM, LM Studio, llama.cpp, …). |
cohere | https://api.cohere.com/v2/embed · /v2/rerank | Bearer api_key_env (required). |
fake | — | Deterministic hash embedder for tests/CI; never a real model. |
The openai / ollama / http providers share one OpenAI-compatible adapter, so
“never hard-code a vendor” is satisfied by configuration, not a vendor matrix.
A missing required api_key_env is a hard error at startup, surfacing the
misconfiguration immediately. A provider call that fails returns HTTP 502 /
gRPC Unavailable with a secret-free message; it never corrupts state.
upsert_text — store text, Quiver embeds it
One call embeds the text for dense search and indexes it under
__quiver_text__ for BM25 (full-text) — so a corpus ingested
this way is immediately searchable both semantically and lexically.
curl -X POST localhost:6333/v1/collections/docs/points:text \
-H 'authorization: Bearer …' -H 'content-type: application/json' \
-d '{"points":[{"id":"1","text":"Quiver is a memory-frugal vector database","payload":{"src":"readme"}}]}'
q.upsert_text("docs", [{"id": "1", "text": "Quiver is a vector database", "payload": {"src": "readme"}}])
search_text — query by text, optionally rerank in one call
The server embeds the query, runs dense (⊕ BM25 when the collection has text)
retrieval, and — with rerank=true and a [rerank.<collection>] provider —
over-fetches a candidate pool and reorders it to the top k, all in one round
trip.
curl -X POST localhost:6333/v1/collections/docs/query/text \
-H 'authorization: Bearer …' -H 'content-type: application/json' \
-d '{"text":"what is quiver?","k":5,"rerank":true}'
hits = q.search_text("docs", "what is quiver?", k=5, rerank=True)
const hits = await client.searchText("docs", "what is quiver?", { k: 5, rerank: true });
Reachable on every surface: REST (POST …/points:text, POST …/query/text),
gRPC (UpsertText / SearchText), the Python (sync + async) and TypeScript SDKs
(upsert_text / search_text, upsertText / searchText), and the
MCP server (upsert_text / search_text tools — run
quiver mcp --config quiver.toml so the provider tables are loaded). The
sparse-term cost limit (QUIVER_MAX_SPARSE_TERMS) bounds the tokenized query.
When to use which
- You already embed (you run a model, want full control, or use a bespoke
encoder): keep
upsert/search/hybrid_search— no provider needed. - You want zero client-side embedding (prototyping, a simple service, a thin
agent): configure a provider and use
upsert_text/search_text. - Either way, hybrid
dense ⊕ BM25and the metadata pre-filter work the same —upsert_textco-populates the BM25 side for you.
Multi-vector / late interaction (ColBERT)
Create a collection multivector and each document is stored as a set of
token vectors and ranked by MaxSim late interaction: for each query token, take
its best-matching document token, and sum those across the query. This is the
ColBERT retrieval model.
How it works
Quiver models a document as a group of ordinary rows over the same row-addressed
store, so there is no on-disk format change and the kill -9 crash gate is
untouched. The token pool is the set the ANN index serves (candidate generation);
candidates are then re-ranked by exact MaxSim with an optional payload filter. A
ColBERT corpus — a large pool of low-dimensional vectors — is exactly what the
IVF+PQ and disk paths were built to compress, so late interaction showcases the
memory-frugality wedge.
Reachable from the embeddable database, REST + gRPC, the
MCP server, and the SDKs:
upsert_document / search_multi_vector / delete_document.
ColBERTv2 / PLAID compression
For multi-vector collections you can opt into a colbert index: coarse kmeans
centroids plus per-token (centroid id, quantized residual code) held in RAM, with
the exact token vectors on the encrypted store for the re-rank. Candidate generation
prunes by scoring centroids first (PLAID). It is derived and rebuilt from the store
on open, so the crash gate stays untouched. Create a multi-vector collection with
the colbert index over any transport or SDK.
Maintenance
Document upsert/delete maintain the token-pool index incrementally (no full rebuild), so a document write is size-independent.
The full design, including the deferred native variable-stride document-row storage (gated on a reference-hardware locality measurement), is in ADR-0028 and ADR-0034.
Encrypted vector search
Search your embeddings on a server you don’t fully trust, choosing per
collection (vector_encryption) where you sit on the confidentiality/performance
spectrum — because no scheme gives fast server-side ranking, zero leakage, and
practical performance all at once.
Both modes are opt-in and off by default, and they complement encryption at rest rather than replacing it.
DCPE (vector_encryption: "dcpe", experimental)
The client encrypts vectors with distance-comparison-preserving encryption — the published Scale-And-Perturb scheme, built only from audited RustCrypto primitives — so the server can rank ciphertexts by approximate L2 distance without ever holding the plaintext vectors or the key.
It is not semantically secure: L2-only, and it leaks the approximate distance-comparison relation by design (that is how the server ranks). It carries real, documented caveats and is broken by known-plaintext or strong-prior adversaries. Read the full specification — including the v2 hardening (a key-derived component shuffle and an ordering-preserving global normalisation) and what it can and cannot do — on the DCPE page before using it.
Native ciphers ship in Rust, Python, and TypeScript, validated against each other by a cross-language known-answer test.
Client-side opaque vectors (vector_encryption: "client_side", semantically secure)
The server stores only XChaCha20-Poly1305 ciphertext (the same audited AEAD as at-rest — no new cryptography) plus a zero placeholder, does no distance math, and learns nothing about the vectors — no coordinates, no distances, no geometry (genuinely IND-CPA).
The honest cost: the server doesn’t rank, so the client fetches the (optionally
pre-filtered) set and ranks locally — best for small/medium or server-pre-filtered
collections. Native VectorCiphers ship in Rust/Python/TypeScript with a bit-exact
cross-language test, plus a search-style helper that hides the fetch-and-rank
round-trip. Read the client-side opaque vectors page.
Which one?
| DCPE | Client-side opaque | |
|---|---|---|
| Server ranks? | yes (approximate L2) | no (client ranks) |
| Semantically secure? | no (leaks distance ordering) | yes (IND-CPA) |
| Metric | L2 only | any (client-side) |
| Best for | server-side ANN with a weaker, honest guarantee | strong confidentiality, small/pre-filtered sets |
See the cryptography overview for how these fit Quiver’s broader posture, and the threat model for the boundaries.
Migrating to Quiver
quiver admin import loads an export from another vector database — Qdrant,
Chroma, or pgvector — into a Quiver collection, preserving ids, vectors,
payloads, and (optionally) the filterable fields hybrid search needs. The design
is recorded in ADR-0024.
The importer can either read a file you export from the source tool or pull
directly from a running source — Qdrant, Chroma, or Postgres
(ADR-0027,
ADR-0029). Either way it
bulk-loads into a local data directory through the engine — so the result is an
ordinary Quiver store: crash-safe, encrypted at rest (unless --insecure), and
immediately serveable with quiver serve.
1. Export from your current database
Qdrant — scroll the collection to JSON Lines (one point per line). Using the Python client:
from qdrant_client import QdrantClient
import json
client = QdrantClient(url="http://localhost:6333")
with open("qdrant.jsonl", "w") as f:
offset = None
while True:
points, offset = client.scroll(
"my_collection", with_vectors=True, with_payload=True,
limit=1000, offset=offset,
)
for p in points:
f.write(json.dumps({"id": p.id, "vector": p.vector, "payload": p.payload}) + "\n")
if offset is None:
break
Chroma — dump the collection’s get(...) result as one JSON object:
import chromadb, json
col = chromadb.PersistentClient("./chroma").get_collection("my_collection")
data = col.get(include=["embeddings", "metadatas", "documents"])
json.dump(data, open("chroma.json", "w"))
pgvector — emit one JSON row per line with row_to_json (the embedding
column comes out as a "[1,2,3]" text literal, which the importer parses):
psql "$DATABASE_URL" -At -c \
"SELECT row_to_json(t) FROM (SELECT id, embedding, title, category FROM items) t" \
> pgvector.jsonl
2. Import into Quiver
# Qdrant → an encrypted local store (dimension inferred from the export)
export QUIVER_ENCRYPTION_KEY=<64-hex-character master key>
quiver admin import --source qdrant --input qdrant.jsonl \
--collection my_collection --data-dir ./data --metric cosine
# Chroma, declaring filterable payload fields for hybrid search
quiver admin import --source chroma --input chroma.json \
--collection docs --data-dir ./data --metric cosine \
--filterable category:keyword --filterable year:numeric
# pgvector, naming the id/vector columns, no encryption (dev only)
quiver admin import --source pgvector --input pgvector.jsonl \
--collection items --data-dir ./data --metric l2 \
--id-field id --vector-field embedding --insecure
For a live import — no export step — point at a running source instead of
--input. All three reuse the same normalization and write path as the offline
importer (ADR-0027,
ADR-0029):
# Qdrant — paginated points/scroll; --collection is the source collection name
quiver admin import --source qdrant --qdrant-url http://localhost:6333 \
--collection my_collection --data-dir ./data --metric cosine
# add --api-key <key> (or set QDRANT_API_KEY) for a secured Qdrant
# Chroma — v2 HTTP API; resolves the collection name to its id, then paginates get
quiver admin import --source chroma --chroma-url http://localhost:8000 \
--collection docs --data-dir ./data --metric cosine
# override --chroma-tenant / --chroma-database for a non-default deployment;
# add --api-key <token> for a secured Chroma (sent as x-chroma-token)
# Postgres/pgvector — reads row_to_json over the table; TLS per the URL's sslmode
quiver admin import --source pgvector \
--postgres-url postgresql://user:pass@localhost/db \
--table items --collection items --data-dir ./data --metric l2
# --table defaults to --collection; use sslmode=disable for a plaintext/local DB
Live connectors are validated against a hermetic in-process server (Qdrant, Chroma) or the offline mapper plus an opt-in integration test (Postgres); validating against your running instance is the final step on your side.
Then serve it with the same key (the importer writes the same encrypted format the server reads):
QUIVER_ENCRYPTION_KEY=<same key> quiver serve # data_dir defaults to ./data
Options
| Flag | Meaning | Default |
|---|---|---|
--source | qdrant, chroma, or pgvector | required |
--input | export file for an offline import (JSON Lines for qdrant/pgvector; one JSON object for chroma) | one of --input / a live --*-url |
--qdrant-url | base URL of a running Qdrant for a live import (qdrant only) | one of --input / a live --*-url |
--chroma-url | base URL of a running Chroma for a live import (chroma only) | — |
--chroma-tenant / --chroma-database | Chroma tenant / database for --chroma-url | default_tenant / default_database |
--postgres-url | connection URL of a running Postgres for a live import (pgvector only) | — |
--table | source table for --postgres-url | --collection |
--api-key | API key for a live import: Qdrant api-key / Chroma x-chroma-token (or QDRANT_API_KEY) | — |
--collection | target collection (created if absent, appended to otherwise); also the source collection name for a live import | required |
--data-dir | target data directory | ./data |
--metric | l2, cosine, or dot (for a newly created collection) | cosine |
--dim | vector dimensionality | inferred from the export |
--filterable | path:type (keyword|numeric), repeatable | none |
--id-field | id column name (pgvector) | id |
--vector-field | vector column name | vector (qdrant) / embedding (pgvector) |
--vector-name | which named vector to import (qdrant) | the sole one |
--encryption-key | 64-hex master key (or QUIVER_ENCRYPTION_KEY) | — |
--insecure | import without encryption-at-rest (dev only) | off |
Notes
- Ids are stringified (Qdrant/Chroma integer or UUID ids become strings).
- Payloads: Qdrant
payloadis kept as-is; for pgvector every non-id, non-vector column becomes a payload field; for Chroma themetadatasobject is the payload and eachdocumentsentry is stored under adocumentkey. - Filterable fields must be declared at import time to be usable by hybrid search later (they are extracted into the secondary index at flush — ADR-0022).
- Importing the same export twice appends (re-upserting the same ids replaces them); the importer never drops an existing collection.
- Live import is available for all three sources — Qdrant (
--qdrant-url, ADR-0027), Chroma (--chroma-url) and Postgres (--postgres-url, ADR-0029) — each pulling directly from a running instance through the same normalization as the offline path. Live Chroma uses its v2 HTTP API (resolving the collection name to an id by listing collections); live Postgres readsrow_to_jsonover the table and negotiates TLS per the URL’ssslmode.
Security of live import
quiver admin import is an operator command: the source URL you pass is
trusted input you chose, not a request an attacker can influence, so fetching it
is not server-side request forgery (see finding C1 in the
v0.17.0 audit note). Two operational cautions still
apply, and the CLI warns about both:
- Use TLS for credentials. A Qdrant/Chroma API key over a plaintext
http://URL, or a Postgres password withsslmode=disable, travels in cleartext to anyone on the path. Preferhttps://(Qdrant/Chroma) andsslmode=requireor stronger (Postgres). The importer prints awarning:to stderr when it detects a credential that would be sent unencrypted. - The SQL table name is interpolated as a quoted identifier (each
dot-separated part is double-quoted with embedded quotes doubled), so a crafted
--tablecannot break out of theSELECT.
Replication
Quiver supports asynchronous leader-follower replication (ADR-0030): one or more followers continuously apply a leader’s committed operations and serve reads, lagging the leader by the replication delay. This scales reads and gives you warm standbys — without consensus, failover, or the complexity of distributed clustering. It is an advanced / experimental feature; single-node remains the primary, fully-supported topology.
Topology
- The leader is a normal Quiver server. It exposes an admin-scoped
ReplicategRPC stream that yields a logical snapshot of current state followed by the live commit tail. - A follower is a server started with
QUIVER_LEADER_URLpointing at the leader’s gRPC address. It connects, applies the stream, serves reads, and refuses writes (a write returns HTTP 403 / gRPCPermissionDenied).
Running a follower
# Leader — a normal server
QUIVER_GRPC_ADDR=0.0.0.0:6334 quiver serve
# Follower — a read replica of that leader
QUIVER_LEADER_URL=http://leader-host:6334 \
QUIVER_LEADER_API_KEY=<an admin key on the leader> \
QUIVER_GRPC_ADDR=0.0.0.0:7334 quiver serve
The follower first bootstraps a full snapshot, then streams the live tail. Point your read traffic at followers and your writes at the leader.
Guarantees and limits (honest)
- Asynchronous / eventually consistent. A follower lags the leader; reads can be stale, and there is no read-your-writes across nodes.
- No failover, no consensus. If the leader fails, promoting a follower is a manual operator decision — Quiver does not elect a new leader.
- Reconnect re-bootstraps. On a stream error the follower keeps serving its last-known (stale) read-only state; restart it to re-sync from a fresh snapshot. There is no incremental resume yet.
- TLS to the leader is a follow-up. Run replication over a trusted network or a tunnel for now; the follower → leader connection is plaintext.
- Security. The
Replicatestream is admin-scoped; a follower authenticates withQUIVER_LEADER_API_KEY. On-disk and in-transit encryption for client traffic are configured independently, as on any node.
See ADR-0030 for the design and the explicit non-goals.
Snapshots & backup
Quiver can take a consistent online snapshot of a running database — a backup captured at one point in its history, without stopping the process (ADR-0050).
How it works
Under the single-writer lock (so no mutation is in flight), the engine:
- Checkpoints — seals the in-memory write buffer into immutable segments and advances the write-ahead-log floor to the head. This makes the manifest the durability anchor for the snapshot.
- Copies the entire data directory to the destination. The copy is
layout-independent — it never parses the
.vec/.pay/.dir/index/file grammar — so it captures new storage artifacts automatically and can never drift out of sync with the on-disk format.
Opening the copy replays an empty WAL tail and yields a database identical to the source at snapshot time. No on-disk format changes; the crash gate is untouched.
Taking a snapshot
REST (admin-scoped):
curl -X POST http://localhost:8080/v1/snapshot \
-H "Authorization: Bearer $QUIVER_API_KEY" \
-d '{"destination": "/backups/quiver-2026-06-23"}'
# → {"manifest_version": 12, "files": 48, "bytes": 10485760}
It is also available on the embeddable engine (Database::snapshot), the MCP
snapshot tool, and every SDK (snapshot(destination) in Python, TypeScript,
and Go). The database_stats MCP tool reports the snapshot-relevant catalog
state (manifest_version, disk_bytes).
The destination must not already exist (Quiver never overwrites a directory).
Restoring
Restore is an operator action: copy a snapshot into a fresh data directory and
point a Quiver instance at it (restore_snapshot(src, dest) does the copy and
guards the engine). Because the snapshot is just a portable data directory, you
can also archive it to S3/object storage with aws s3 cp / rclone.
Notes & limits
- A snapshot is a full copy (O(data size)). For very large stores, a hard-linked incremental snapshot is the documented optimization (immutable segments never change), not built yet.
- The writer is paused for the duration (single-writer engine) — fine for a backup operation.
- Run snapshots on a writable node; a read-only replica is backed up at the filesystem level instead.
Observability (metrics & tracing)
Quiver exposes operational signal three ways (ADR-0014, ADR-0054): Prometheus metrics, structured tracing spans, and health endpoints.
Metrics — GET /metrics
An open endpoint (no API key, so a scraper needs no credential — bind it on a private network) serving Prometheus text exposition:
| Metric | Type | Labels |
|---|---|---|
quiver_http_requests_total | counter | method, route |
quiver_http_request_errors_total | counter (status ≥ 400) | method, route |
quiver_http_request_duration_seconds | histogram | method, route |
quiver_auth_failures_total | counter | — |
quiver_rate_limited_total | counter | — |
The route label is the matched route template (/v1/collections/{name}/query),
never the concrete path — so cardinality is bounded and no ids leak. Latency
p50/p95/p99 are derived from the histogram with histogram_quantile.
Scrape it with Prometheus:
scrape_configs:
- job_name: quiver
static_configs:
- targets: ["quiver:8080"]
Grafana
An importable dashboard ships at infra/grafana/quiver-dashboard.json (QPS,
error rate, latency p50/p95/p99, and the security counters). See
infra/grafana/README.md.
Tracing
Engine-facing server operations carry #[tracing::instrument] spans with
secret-free fields (collection, k, counts — never vectors or payloads). By
default they go to the RUST_LOG-filtered fmt logger.
OpenTelemetry export (OTLP) — opt-in (ADR-0059)
To ship spans to an OTLP collector (Jaeger, Tempo, Grafana, …), build the server
with the otlp feature and point it at a collector. The feature is off by
default, so a normal build links none of the OpenTelemetry crates; even with
the feature compiled in, export stays off until an endpoint is configured.
# Build with the exporter compiled in.
cargo build -p quiverdb-cli --release --features otlp
# Enable it at runtime (OTLP/gRPC, default collector port 4317).
QUIVER_OTLP_ENDPOINT=http://localhost:4317 \
QUIVER_OTLP_SERVICE_NAME=quiver \
quiver serve
Equivalently, a [otlp] table in quiver.toml:
[otlp]
endpoint = "http://localhost:4317" # empty / omitted = disabled
service_name = "quiver"
timeout_secs = 10
The transport is OTLP/gRPC (reusing the tonic already in the tree, so no extra
HTTP stack). Spans are batched and flushed on shutdown. A failure to build the
exporter logs a warning and falls back to fmt-only — telemetry never takes the
server down.
Health
GET /healthz— liveness.GET /readyz— readiness (storage open, indexes loaded).
CLI reference
The single quiver binary is the entrypoint for every component — the server, the
terminal cockpit, the MCP server, admin tasks, benchmarks, the self-updater, and a
zero-config demo. This page documents every command and flag; it mirrors
quiver <command> --help.
Usage: quiver <COMMAND>
Commands:
serve Run the server (gRPC + REST)
tui Launch the terminal cockpit
mcp Run the MCP server for AI agents (JSON-RPC over stdio)
admin Administrative commands (imports, collections, keys)
bench Run benchmarks
update Check for a newer release and optionally install it
demo Zero-config demo: seeds vectors, starts the server, opens the cockpit
Global options: -h, --help, -V, --version.
Most configuration is supplied by environment variables / quiver.toml, not
flags — see the configuration reference. Flags shown below
override or supplement those.
quiver serve
Run the server (gRPC + REST). Takes no flags — it is configured entirely from
quiver.toml and QUIVER_* environment variables (bind addresses, API keys,
encryption, TLS, cluster, rate limits, …). See the
configuration reference.
QUIVER_API_KEYS=… QUIVER_ENCRYPTION_KEY=… quiver serve
quiver tui
Launch the terminal cockpit against a running server.
| Flag | Env | Default | Description |
|---|---|---|---|
--url <URL> | QUIVER_TUI_URL | http://127.0.0.1:6333 | REST base URL of the server to inspect. |
--api-key <API_KEY> | QUIVER_API_KEY | — | API key presented as a bearer token, if the server requires one. |
quiver mcp
Run the MCP server for AI agents (JSON-RPC over stdio). Opens the embedded database directly (no network server).
| Flag | Env | Default | Description |
|---|---|---|---|
--data-dir <DATA_DIR> | QUIVER_DATA_DIR | ./data | Data directory for the embedded database. |
--encryption-key <KEY> | QUIVER_ENCRYPTION_KEY | — | 64-hex-character key for encryption-at-rest. |
--insecure | QUIVER_INSECURE | false | Run without encryption-at-rest (development only). |
--config <CONFIG> | QUIVER_CONFIG | quiver.toml | Config file supplying [embedding.*]/[rerank.*] provider tables for the upsert_text/search_text tools. A missing file is fine — those tools then report no provider configured. |
See the MCP server reference for the tool catalog.
quiver admin
Administrative commands.
quiver admin import
Import an export from another vector database into a collection
(ADR-0024; see the migration guide).
Offline (--input) or live (--qdrant-url / --chroma-url / --postgres-url).
Usage: quiver admin import [OPTIONS] --source <SOURCE> --collection <COLLECTION>
| Flag | Env | Default | Description |
|---|---|---|---|
--source <SOURCE> | — | (required) | Source tool: qdrant, chroma, or pgvector. |
--collection <COLLECTION> | — | (required) | Target collection (created if absent, appended to otherwise). |
--input <INPUT> | — | — | Export file (offline): JSON Lines for qdrant/pgvector; a single collection.get(...) JSON object for chroma. |
--qdrant-url <URL> | — | — | Live import: base URL of a running Qdrant, instead of --input. |
--chroma-url <URL> | — | — | Live import: base URL of a running Chroma (v2 API). |
--chroma-tenant <T> | — | default_tenant | Chroma tenant for --chroma-url. |
--chroma-database <D> | — | default_database | Chroma database for --chroma-url. |
--postgres-url <URL> | — | — | Live import: Postgres URL (postgresql://…) to pull pgvector rows. |
--table <TABLE> | — | --collection | Source table for --postgres-url. |
--api-key <KEY> | QDRANT_API_KEY | — | API key for a live import: Qdrant api-key or Chroma x-chroma-token. |
--metric <METRIC> | — | cosine | Distance metric for a newly created collection (l2, cosine, dot). |
--dim <DIM> | — | (inferred) | Vector dimensionality (inferred from the export when omitted). |
--filterable <PATH:TYPE> | — | — | Filterable payload field as path:type (keyword|numeric); repeatable. |
--id-field <ID_FIELD> | — | id | Id column name (pgvector). |
--vector-field <FIELD> | — | qdrant vector, pgvector embedding | Vector column name. |
--vector-name <NAME> | — | — | Named vector to import (qdrant named vectors). |
--data-dir <DATA_DIR> | QUIVER_DATA_DIR | ./data | Data directory for the embedded database. |
--encryption-key <KEY> | QUIVER_ENCRYPTION_KEY | — | 64-hex master key for encryption-at-rest. |
--insecure | QUIVER_INSECURE | false | Import into an unencrypted database (development only). |
A live import that sends a credential over a plaintext
http://URL (or a Postgres URL withsslmode=disable) prints awarning:first — see the migration guide’s Security of live import.
quiver bench
Run the built-in benchmark harness. See the benchmark methodology in the README.
quiver update
Check for a newer release and optionally install it (downloads, verifies the SHA-256 checksum, and atomically replaces the binary).
| Flag | Description |
|---|---|
--check | Only check whether an update is available; do not download or install. |
quiver demo
Zero-config demo: seeds two collections (a text-searchable articles set and a
1 000-vector demo set for the constellation view), starts the server on :7333
with encryption-at-rest, and opens the cockpit — no config, no network. Override
the data directory with QUIVER_DEMO_DIR.
gRPC & REST Surface
The concrete API. The gRPC service in quiver-proto is the source of truth; REST + OpenAPI 3.1 are generated to match (ADR-0018). Both run on the same quiver-server (gRPC on HTTP/2, REST on HTTP/1.1+2), behind the same auth, RBAC, cost-limit (ADR-0040), and audit middleware.
gRPC service (representative sketch)
syntax = "proto3";
package quiver.v1;
service Quiver {
rpc CreateCollection(CreateCollectionRequest) returns (Collection);
rpc GetCollection(GetCollectionRequest) returns (Collection);
rpc ListCollections(ListCollectionsRequest) returns (ListCollectionsResponse);
rpc DeleteCollection(DeleteCollectionRequest) returns (DeleteCollectionResponse);
rpc Upsert(UpsertRequest) returns (UpsertResponse);
rpc UpsertStream(stream UpsertRequest) returns (UpsertResponse); // client-streaming bulk load (ADR-0045)
rpc UpsertText(UpsertTextRequest) returns (UpsertResponse); // server-side embedding (ADR-0047)
rpc DeletePoints(DeletePointsRequest) returns (DeletePointsResponse);
rpc GetPoints(GetPointsRequest) returns (GetPointsResponse);
rpc Fetch(FetchRequest) returns (FetchResponse); // unranked list (ADR-0032)
rpc Search(SearchRequest) returns (SearchResponse);
rpc HybridSearch(HybridSearchRequest) returns (SearchResponse); // dense ⊕ sparse BM25, RRF
rpc SearchText(SearchTextRequest) returns (SearchResponse); // embed query, optional rerank
rpc UpsertMultiVector(UpsertMultiVectorRequest) returns (UpsertMultiVectorResponse);
rpc SearchMultiVector(SearchMultiVectorRequest) returns (SearchMultiVectorResponse); // MaxSim
rpc DeleteDocuments(DeleteDocumentsRequest) returns (DeleteDocumentsResponse);
rpc Replicate(ReplicateRequest) returns (stream ReplicationOp); // leader→follower (ADR-0030, admin)
}
// A separate RaftService carries per-shard Raft AppendEntries/Vote/InstallSnapshot
// when the `raft` build feature is enabled (ADR-0067). API keys are provisioned
// through configuration (ADR-0011), not a runtime RPC.
message SearchRequest {
string collection = 1;
repeated float vector = 2; // dtype-specific encodings for f16/bf16/int8/binary
uint32 k = 3;
Filter filter = 4; // structured predicate tree
SearchParams params = 5; // ef | nprobe | rerank_factor
bool with_payload = 6;
bool with_vector = 7;
string idempotency_key = 15;
}
message Match { string id = 1; float score = 2; bytes payload = 3; repeated float vector = 4; }
message SearchResponse { repeated Match matches = 1; string next_cursor = 2; }
(Filter, dtype encodings, and the full message set are defined in the proto; this is the shape, not the whole file.)
REST mapping
| Method & path | Operation |
|---|---|
POST /v1/collections | CreateCollection |
GET /v1/collections/{id} | GetCollection |
GET /v1/collections | ListCollections (cursor) |
DELETE /v1/collections/{id} | DeleteCollection (crypto-shred) |
POST /v1/collections/{id}/points | Upsert (batch; Idempotency-Key) |
POST /v1/collections/{id}/points:bulk | Upsert (bulk load; one fsync + one index rebuild) |
POST /v1/collections/{id}/points:text | UpsertText (server-side embedding, ADR-0047) |
DELETE /v1/collections/{id}/points | DeletePoints |
POST /v1/collections/{id}/query | Search |
POST /v1/collections/{id}/query/hybrid | HybridSearch (dense ⊕ sparse/BM25, RRF) |
POST /v1/collections/{id}/query/text | SearchText (embed query, ⊕ BM25, optional rerank) |
POST /v1/collections/{id}/fetch | Fetch (list points without ranking; the client-side-encryption retrieval path, ADR-0032) |
GET /v1/collections/{id}/points/{point_id} | GetPoints (one point by id) |
POST /v1/collections/{id}/documents | UpsertMultiVector (late-interaction docs) |
DELETE /v1/collections/{id}/documents | DeleteDocuments |
POST /v1/collections/{id}/documents/query | SearchMultiVector (MaxSim) |
POST /v1/snapshot | Snapshot — consistent online backup to a server-local dir (ADR-0050, admin) |
GET /cluster/map | The shard map a router has adopted (404 on a non-router server; read-only) |
POST /cluster/raft/voters · DELETE /cluster/raft/voters/{id} | Add/remove a per-shard Raft voter at runtime (ADR-0067 increment 4c, admin; requires the raft build feature) |
GET /healthz · GET /readyz · GET /metrics | ops |
The complete, machine-readable contract for this surface is the committed OpenAPI 3.1 spec (docs/api/openapi.yaml), pinned to the router by a coverage test. The cluster coordinator runs a separate admin API (/cluster/shards, /cluster/shards/grow, /cluster/shards/{id}/promote, /cluster/shards/{id}/drain, DELETE /cluster/shards/{id}, /cluster/health) — authenticated like the data plane (ADR-0011): reads need any valid key, the mutating shard ops need the admin role.
CreateCollection selects the per-collection index (ADR-0007): the JSON body and
the proto request carry index (hnsw | vamana | disk_vamana | ivf,
default hnsw) and an optional pq_subspaces for the quantized kinds. Collection
responses echo both, so a client can confirm the memory-frugal disk_vamana path
was selected. Inner-product (dot) is rejected for the graph/IVF kinds (400).
The request also carries filterable — payload fields to index for pre-filtered
(hybrid) search (ADR-0022), each a { "path": "user.city", "field_type": "keyword" | "numeric" }. Declared fields are extracted into the secondary index
at flush time; a Search whose filter is selective on them is then answered by
an exact scan of the narrowed rows instead of post-filtering ANN hits (perfect
recall, no filtered-search cliff). Collection responses echo the declared
fields. Fields left undeclared still filter correctly — they fall back to
post-filtering — they just do not get the pre-filter speed-up.
REST bodies are JSON; vectors are JSON arrays (or base64 for int8/binary). Errors are RFC-9457 application/problem+json; gRPC uses the mapped Status (ADR-0017).
Auth, idempotency, limits (applied uniformly)
- Auth:
Authorization: Bearer <api-key>(REST) / metadataauthorization(gRPC), or mTLS client cert. Default-deny; scopes checked per resource (ADR-0011). - Idempotency:
Idempotency-Keyheader / field on all mutations (seewire-protocol.md). - Limits: query cost caps —
k,ef_search,fetchlimit, vector dimension, payload size, upsert batch size, and HTTP request body size (ADR-0040) — rejected with HTTP 400 / gRPCInvalidArgumentwhen exceeded. Configure withQUIVER_MAX_*(see.env.example). - Rate limiting: opt-in per-key token bucket (ADR-0049) —
QUIVER_RATE_LIMIT_REQUESTS_PER_SECOND/_BURST(0= off). Over-rate requests get HTTP 429 / gRPCResourceExhaustedwithRetry-After; successful REST responses carry theRateLimit-Limit/RateLimit-Remaining/RateLimit-Resetheaders. In-memory, per node. - Pagination: opaque
next_cursor(forward-only).
OpenAPI & SDKs
The OpenAPI 3.1 contract is committed at docs/api/openapi.yaml and kept in lock-step with the router by a coverage test (crates/quiver-server/tests/openapi.rs fails if a route is added or removed without updating the spec). The Python (uv) and TypeScript (pnpm) SDKs are maintained against the proto + this spec. The MCP server (quiver-mcp) exposes the collection/upsert/search/fetch/document tools over this same surface (ADR-0018/0058).
Observability hooks
GET /metrics serves Prometheus exposition (ADR-0014/0054): per matched-route-template request counters, error counters, and latency histograms (p50/p95/p99 derivable), plus process-wide quiver_auth_failures_total and quiver_rate_limited_total. The endpoint is open (no API key) so a scraper needs no credential — bind it privately. Engine-facing operations carry secret-free tracing spans, OTLP-exportable via a tracing-opentelemetry layer. Mutating/admin operations also emit an audit record (ADR-0011). An importable Grafana dashboard ships in infra/grafana/.
MCP Server
Quiver ships a Model Context Protocol server (ADR-0018) so an AI agent can drive a Quiver database directly as a set of tools. It speaks JSON-RPC 2.0 over newline-delimited stdio and operates an in-process database — there is no network hop and the data is encrypted at rest with the same secure-by-default posture as the network server.
Run
# Encrypted at rest (recommended): provide a 64-hex-character key.
QUIVER_ENCRYPTION_KEY=<64-hex> quiver mcp --data-dir ./data
# Development only — no encryption-at-rest:
quiver mcp --data-dir ./data --insecure
# Enable the text tools (upsert_text / search_text): point at a config with
# [embedding.<collection>] tables (the same file `quiver serve` uses).
QUIVER_ENCRYPTION_KEY=<64-hex> quiver mcp --data-dir ./data --config quiver.toml
The process reads requests on stdin and writes responses on stdout, so it is launched by an MCP-capable client (e.g. an agent runtime) as a subprocess.
Tools
| Tool | Arguments | Purpose |
|---|---|---|
list_collections | — | List collections |
collection_info | collection | Inspect one collection: dim, metric, index, filterable fields, multivector flag, vector-encryption mode, and live point count |
create_collection | name, dim, metric (l2|cosine|dot), index (hnsw|vamana|disk_vamana|ivf), pq_subspaces?, binary? (binary quantization for disk_vamana, ADR-0074), filterable? ([{path, field_type: keyword|numeric}]), multivector?, vector_encryption? (none|dcpe|client_side) | Create a collection (pick the index, incl. the memory-frugal disk_vamana; declare filterable fields for hybrid pre-filtered search; set multivector for late-interaction / ColBERT; set vector_encryption for client-side vector encryption — dcpe (experimental, server ranks, L2-only, ADR-0031) or client_side (semantically secure opaque AEAD, server does not rank, ADR-0032)) |
upsert | collection, id, vector, payload? | Insert/replace a point |
search | collection, vector, k?, filter? | k-NN with an optional payload filter |
fetch | collection, filter?, limit? | List points without ranking — the retrieval path for client_side-encrypted collections (ADR-0032) |
get | collection, id | Fetch one point |
delete | collection, id | Delete one point |
upsert_document | collection, id, vectors (token set), payload? | Insert/replace a multi-vector (ColBERT) document |
search_multi_vector | collection, query (token set), k?, filter? | MaxSim late-interaction search with an optional payload filter |
delete_document | collection, id | Delete a multi-vector document |
delete_collection | collection | Drop a whole collection and its points (reports whether it existed) |
database_stats | — | Whole-database overview: collection count, total points, per-collection summary, and snapshot status (manifest_version, disk_bytes) |
snapshot | destination | Take a consistent online backup of the whole database into a server-local directory (ADR-0050) |
upsert_text | collection, id, text, payload? | Embed text server-side and upsert it as a point, co-populating the BM25 full-text field (requires a provider — see below) |
search_text | collection, text, k?, filter?, rerank?, rrf_k0? | Embed the query server-side and run a hybrid dense+BM25 search, optionally reranking (requires a provider — see below) |
filter is a Quiver payload filter tree, e.g.
{"eq": {"field": "color", "value": "blue"}}. The full JSON-Schema for each
tool is returned by the standard tools/list request.
Text tools (server-side embedding)
upsert_text / search_text let an agent store and query documents by text,
with Quiver embedding them server-side (ADR-0047/0058) — the agent never runs an
embedding model itself. They require an embedding provider for the collection,
configured exactly as for quiver serve: an [embedding.<collection>] table
(and an optional [rerank.<collection>] for search_text(rerank=true)) in the
config passed via quiver mcp --config <path> (default quiver.toml). See
Server-side embedding for the provider table format
and secret handling (API keys are referenced by env-var name, never stored).
Both tools are always advertised by tools/list; with no provider configured
they return an isError result explaining how to enable them, so an agent can
discover the capability.
Protocol notes
- Protocol revision
2024-11-05; capabilities advertisetools. - Tool execution failures are returned as a normal result with
isError: trueand a human-readable message in the content, so the agent can read and recover from them. Malformed JSON-RPC (unknown method, missing tool name) returns a JSON-RPC error object instead. - Embeddings are produced by the caller for
upsert/search— Quiver stays model-agnostic — or, with a configured provider, server-side via theupsert_text/search_texttools.
Python, TypeScript & Go SDKs
Both SDKs are thin, idiomatic clients over the REST API. They are unpublished today — install from the repository — and a publish to PyPI/npm is a launch-time task.
Python
Install from PyPI as quiver-client
(pip install quiver-client; or pip install ./sdks/python from a checkout):
from quiver import Client, Point
with Client("http://127.0.0.1:6333", api_key="…") as q:
q.create_collection("items", dim=3, metric="cosine")
q.upsert("items", [Point("a", [0.1, 0.2, 0.3], {"tag": "x"})])
hits = q.search("items", [0.1, 0.2, 0.3], k=5)
Beyond search, the client exposes hybrid_search (dense ⊕ sparse/BM25 via
vector / sparse / query_text, fused with RRF) and — when the server has a
provider configured — upsert_text / search_text
(search_text(..., rerank=True) for retrieve→rerank in one call):
q.upsert_text("kb", [{"id": "1", "text": "Quiver is a vector database"}])
hits = q.search_text("kb", "what is quiver?", k=5, rerank=True)
q.hybrid_search("kb", vector=embed(query), query_text=query, k=10) # dense ⊕ BM25
LangChain, LlamaIndex, and Haystack adapters ship as extras
(pip install "quiver-client[langchain]" / [llamaindex] / [haystack]), so any
Quiver index — including the memory-frugal disk path — backs a retriever or
DocumentStore, with metadata filters mapped onto Quiver’s exact pre-filter. Pass
hybrid=True to any of them for dense ⊕ BM25 retrieval.
A synchronous Client and an async AsyncClient share one contract (with
upsert_iter / scroll / delete_by_filter and upsert_text / search_text
helpers), and quiver.rerank is a model-agnostic client-side helper for the
retrieve → rerank step of a RAG pipeline.
TypeScript
Install from npm as quiver-client
(npm install quiver-client; or pnpm add ./sdks/typescript from a checkout),
dependency-free over the global fetch:
import { Client } from "quiver-client";
const q = new Client("http://127.0.0.1:6333", { apiKey: "…" });
await q.createCollection("items", 3, { metric: "cosine", index: "disk_vamana", pqSubspaces: 1 });
await q.upsert("items", [{ id: "a", vector: [0.1, 0.2, 0.3], payload: { tag: "x" } }]);
const hits = await q.search("items", [0.1, 0.2, 0.3], { k: 5 });
The TypeScript client is fully Promise-based and mirrors the same surface as
the Python async client: hybridSearch (dense ⊕ sparse/BM25); with a
server-side provider, upsertText / searchText
({ rerank: true } to reorder in one call); and the bulk/maintenance helpers
upsertIter (batches a sync or async iterable), scroll (an async generator
over a collection, for export / re-embedding), and deleteByFilter (paged
erasure, for GDPR / re-indexing).
for await (const point of q.scroll("items", { batch: 500 })) {
// export or re-embed each point
}
await q.upsertIter("items", asyncSource, { batch: 500, onProgress: (n) => console.log(n) });
await q.deleteByFilter("items", { eq: { field: "tag", value: "stale" } });
Go
Install from sdks/go
(github.com/achref-soua/quiver/sdks/go), standard-library only:
import quiver "github.com/achref-soua/quiver/sdks/go"
c := quiver.New("http://127.0.0.1:8080", quiver.WithAPIKey("…"))
c.CreateCollection(ctx, "items", 3, &quiver.CreateCollectionOptions{Metric: "cosine"})
c.Upsert(ctx, "items", []quiver.Point{{ID: "a", Vector: []float32{0.1, 0.2, 0.3}}})
hits, _ := c.HybridSearch(ctx, "items", &quiver.HybridOptions{QueryText: "hello"})
The Go client mirrors the same surface — Search, HybridSearch, UpsertText /
SearchText, Fetch, and Snapshot, plus the bulk/maintenance helpers
UpsertBatch (batched upload), Scroll (page through a collection via a
callback), and DeleteByFilter (paged erasure). Every call takes a
context.Context; non-2xx responses return a typed *quiver.APIError.
Snapshots
All three clients expose snapshot(destination) — a consistent online backup of
the whole database (admin-scoped). See Snapshots & backup.
Client-side encryption helpers
The SDKs carry the client-side ciphers as optional subpath modules, so the core client stays dependency-free; install the audited crypto peer dependency only to use them. Each has a Rust reference and a cross-language known-answer test.
| Helper | Purpose | Python | TypeScript |
|---|---|---|---|
PayloadCipher | seal payload fields (ADR-0012) | quiver.encryption | quiver-client/encryption |
VectorCipher | opaque vectors (IND-CPA) | quiver.vector | quiver-client/vector |
DcpeCipher | DCPE encrypted search (experimental) | quiver.dcpe | quiver-client/dcpe |
DCPE example (encrypt vectors before upsert, queries before search, with the same cipher):
from quiver import Client
from quiver.dcpe import DcpeCipher # pip install quiver-client[dcpe]
cipher = DcpeCipher.from_hex("…64 hex chars…", approximation_factor=0.02)
with Client("https://…", api_key="…") as q:
q.create_collection("vault", dim=8, metric="l2", vector_encryption="dcpe")
sealed = cipher.encrypt([0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8])
q.upsert("vault", [{"id": "a", "vector": sealed.ciphertext}])
hits = q.search("vault", cipher.encrypt_query(my_query), k=10)
import { DcpeCipher } from "quiver-client/dcpe"; // pnpm add @stablelib/{chacha,hkdf,hmac,sha256}
const cipher = DcpeCipher.fromHex("…64 hex chars…", 0.02);
const sealed = cipher.encrypt([0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8]);
// upsert sealed.ciphertext; search with cipher.encryptQuery(myQuery).
Cryptography
Non-negotiable: Quiver implements no cryptographic primitives or protocols of its own. Every primitive comes from an audited library — rustls for TLS, and RustCrypto crates for AEAD, hashing, KDF, and key wrapping. Rolling our own crypto would disqualify a security-first project. Any experimental scheme uses a published, peer-reviewed construction and is clearly labelled. Decisions: ADR-0010, ADR-0012.
Implementation status (Phase 1). Encryption-at-rest is shipped and on by default. The
quiver-cryptocrate provides anAeadCodec(XChaCha20-Poly1305 with per-page/per-record HKDF-SHA256 subkeys and a fresh random 192-bit nonce per seal, from the RustCrypto crates — noring, no home-grown code). It is wired into the storage engine through thePageCodecseam so it seals all durable data: the paged manifest and segment files and the record-framed write-ahead log (the WAL is sealed per record, since a page-only codec would otherwise leave it in plaintext). TLS-in-transit is shipped too:rustlsover the auditedringprovider (no OpenSSL, noaws-lc-rsC toolchain) terminates TLS for REST (viaaxum-server) and gRPC (via tonic’stls-ring), and a non-loopback bind requires it.Update (Phase 3). The envelope hierarchy below is now shipped.
quiver-crypto’sEnvelopeKeyRingmakesQUIVER_ENCRYPTION_KEYa master key that wraps a random per-collection DEK (stored wrapped under<data_dir>/keys/<id>.dek); each collection’s segments and index are sealed under its own DEK, and the catalog (manifest + WAL) under a master-key-derived catalog key. This makes crypto-shredding real (below). Sourcing the master key from a0600file or a KMS is the remaining slice; the AEAD throughout is XChaCha20-Poly1305 (AES-256-GCM auto-select remains a future option). Format note: this changes the at-rest key hierarchy from v0.2.0’s single root key — pre-1.0 there is no migrator, so re-create encrypted collections under v0.3.0.
Key hierarchy (envelope encryption)
Master Key (MK) ── from env file (0600) or external KMS; never on disk in plaintext
└─ wraps ──> Collection DEK (256-bit, random, per collection)
└─ derives ──> per-page subkey = HKDF-SHA256(DEK, info = file_id ‖ page_id ‖ page_version)
└─ AEAD-seals each 16 KiB page
- MK is supplied by the operator via a file (mode
0600) or an external KMS (the server calls KMS to wrap/unwrap DEKs). The MK never touches disk in plaintext. - DEKs are random 256-bit keys, one per collection, stored wrapped by the MK in the collection metadata (wrap via AES-256-GCM-SIV / AES-KW, or KMS Encrypt). Plaintext DEKs live only in RAM and are zeroized (
zeroize) on drop. - Per-page subkeys are derived with HKDF-SHA-256 from the DEK and a unique context, so nonce reuse is impossible by construction (each page-version is sealed under a unique key) — this side-steps AES-GCM’s catastrophic nonce-reuse failure mode without relying on a global nonce counter.
Position binding (tamper-evidence)
Every sealed unit is bound to its position through the AEAD’s additional authenticated data (AAD), so an adversary with write access to the files — but not the key — cannot silently relocate an intact ciphertext:
- Pages fold their
page_idinto both the subkey and the AAD; a block moved to a different page slot fails to authenticate. - WAL records fold their byte offset into the AAD (ADR-0075); a record reordered, duplicated, or relocated within the log fails on recovery — a hard error, not a silently replayed frame. (The record’s own
lsnlives inside the ciphertext, so it cannot police the record’s physical position.) The WAL format version bumped to 2 for this; pre-2 logs — an un-checkpointed encrypted log from a crash before an upgrade — are still read losslessly (their records used an empty AAD), while all new logs are position-bound.
AEAD selection
Both options are standard, audited AEADs; the choice is recorded in the collection key metadata so data stays decryptable if the default changes:
- AES-256-GCM — default when AES-NI (hardware AES) is detected: fastest there, and the expected choice in compliance contexts.
- ChaCha20-Poly1305 — default when AES-NI is absent: constant-time in software, no timing-side-channel dependence on hardware AES. (
XChaCha20-Poly1305’s extended nonce is available where random nonces are preferable.)
The selection is automatic by default and overridable by config/compliance policy. AES-256-GCM-SIV (nonce-misuse-resistant) is used for DEK wrapping.
In transit
TLS 1.3 via rustls (a memory-safe, audited stack — no OpenSSL). Non-loopback binds require TLS (the server refuses to serve plaintext on a public interface absent an explicit, warned opt-out). mTLS is optional: client identity = certificate subject, mapped to an RBAC principal.
Secrets handling
- Secrets (MK, KMS creds, TLS keys) come from env/KMS/files with strict modes — never committed, never logged, never in the config file in plaintext (the config references a secret source).
- Master key source (shipped): the MK is
QUIVER_ENCRYPTION_KEY(hex) orQUIVER_MASTER_KEY_FILE(a0600file holding the hex), exactly one of the two. The file form suits a mounted Docker/Kubernetes secret or a KMS-decrypted file; a group/world-readable key file is warned about at startup. A built-in KMS client is a future decrypt-to-file step in front of this. The MK never touches disk via Quiver, and plaintext DEKs in memory are wrapped inzeroize-ing types. gitleaksruns pre-commit and in CI;.env.exampledocuments every variable; key material in memory is wrapped inzeroize-ing types.
Crypto-shredding
Because each collection has its own DEK, destroying that wrapped DEK renders the collection’s at-rest data cryptographically unrecoverable — even to the master-key holder, and even if the ciphertext survives in a backup. This is instant, verifiable erasure without overwriting every byte (the GDPR “right to erasure” pattern).
Store::shred_collection / Database::shred_collection drops the collection, checkpoints (so any un-checkpointed rows are sealed into DEK-protected segments and the catalog-keyed WAL is rotated away), then deletes <data_dir>/keys/<id>.dek. A plain drop_collection also reclaims the DEK at the next checkpoint’s garbage collection. After a shred, opening the collection’s codec fails — the DEK is gone — so its segments and index are permanently undecryptable (quiver-crypto/tests/envelope_shred.rs proves this end-to-end).
Scope: erasure covers the durable segments and index (the bulk store). A WAL backup captured before the shred would still be master-key-decryptable until rotation — the inherent caveat of erasing data that was already copied elsewhere.
Client-side payload encryption (ADR-0012)
A client may encrypt payloads with a key Quiver never sees; the server stores and returns the ciphertext as an opaque blob and performs no server-side filtering on those fields. This protects payload confidentiality against the server/operator (adversary A4). It does not encrypt vectors — see the threat model’s honest boundary statement.
Envelope format (the cross-language contract)
The reference implementation is quiver_crypto::payload::PayloadCipher; the Python and TypeScript SDKs mirror it byte-for-byte. A sealed value is one JSON object with a single reserved key:
{ "__quiver_enc__": {
"v": 1,
"alg": "xchacha20poly1305",
"n": "<base64 24-byte nonce>",
"ct": "<base64 ciphertext + 16-byte Poly1305 tag>"
} }
- AEAD: XChaCha20-Poly1305 (the same audited RustCrypto primitive as at-rest), a fresh random 192-bit nonce per seal — nonce reuse is impossible by construction. The associated data
quiver/payload/v1binds every ciphertext to this format version. - Key: a dedicated 256-bit key, used directly (no derivation) so the envelope is reproducible in any language. The plaintext is the UTF-8 JSON serialization of the original value.
Keeping some fields filterable
Encrypted fields cannot be filtered or indexed server-side. To keep a field server-filterable, leave it in cleartext and merge the sealed envelope alongside it — open reads only the reserved key and ignores cleartext siblings:
// stored payload: `tier` stays filterable; `ssn` is opaque to the server
{ "tier": "gold", "__quiver_enc__": { "v": 1, "alg": "xchacha20poly1305", "n": "…", "ct": "…" } }
Key management & honest limits
The client owns the key. Never reuse the QUIVER_ENCRYPTION_KEY (at-rest) key for payloads, and never send the payload key to the server. Losing the key means the data is unrecoverable. The boundary is exact: this hides only the sealed fields; cleartext siblings and all vectors remain visible to the server.
Vector confidentiality vs the server
Standard ANN needs plaintext vectors server-side, so vector confidentiality against the server is opt-in, per collection (vector_encryption), at two honest points on a spectrum — both client-side, the server never holding the key.
DCPE (dcpe, experimental, dcpe.md). A published, peer-reviewed distance-comparison-preserving construction (Scale-And-Perturb — never invented), so the server keeps ranking ciphertexts by approximate L2 distance. It reveals approximate distances/ordering by design (that is what lets the server rank) — a real confidentiality reduction, not semantic security. Cipher v2 (ADR-0035) adds the paper’s two hardening steps — a key-derived component shuffle (an exact L2 isometry) and an ordering-preserving global normalisation — which harden it without changing accuracy or the leakage class; full per-axis whitening is incompatible with searchable encryption and is not offered. Native ciphers ship in Rust, Python, and TypeScript, validated by a cross-language known-answer test.
Client-side opaque vectors (client_side, semantically secure, client-side-vectors.md). quiver_crypto::vector::VectorCipher seals the vector’s raw little-endian f32 bytes with the same XChaCha20-Poly1305 envelope as payloads (no new primitive), under the reserved key __quiver_vec__ with associated data quiver/vector/v1:
{ "__quiver_vec__": { "v": 1, "alg": "xchacha20poly1305", "dim": 8, "n": "…", "ct": "…" } }
The server stores the blob plus a zero placeholder vector and does no distance math, so it is genuinely IND-CPA for vectors — at the cost that the server cannot rank (the client fetches and ranks). The Python and TypeScript SDKs mirror the envelope bit-exactly (raw bytes, no transcendental floats).
Core makes no claim of homomorphic-encrypted search.
Test posture
Known-answer/test vectors for every AEAD and KDF; a test proving on-disk files are ciphertext; a test proving a client-side-encrypted payload is unreadable server-side; fuzzing of the parsers; cargo audit/deny on the dependency set.
Encrypted vector search (DCPE) — experimental
Quiver can search your embeddings on a server that never sees the plaintext vectors or the key, using Distance-Comparison-Preserving Encryption (DCPE). This page is the honest specification of what that does and — just as important — what it does not do. Read it before turning the feature on.
Warning
DCPE is experimental, is not semantically secure, and leaks information by design. It is off by default. It is a different, weaker tool than encryption-at-rest (ADR-0010) or client-side payload encryption (ADR-0012), for a different problem: approximate nearest-neighbour search over encrypted vectors on an untrusted server. It complements encryption-at-rest; it does not replace it. For a semantically secure alternative that leaks nothing about the vectors — at the cost that the server no longer ranks — see client-side opaque vectors.
The problem it solves
To run an ordinary ANN search, a server needs the vectors in plaintext — and embeddings can be inverted to approximately reconstruct the source text or image. Encryption-at-rest protects a stolen disk but still exposes plaintext vectors to the running server. DCPE closes exactly that gap: the client encrypts vectors before upload, the server stores and indexes the ciphertexts, and search still works because the ordering of Euclidean distances is preserved — without the server ever holding the key or the plaintext.
The scheme
Quiver implements Scale-And-Perturb (SAP), the published construction of Fuchsbauer, Ghosal, Hauke & O’Neill, “Approximate Distance-Comparison-Preserving Symmetric Encryption” (IACR ePrint 2021/1666, SCN 2022) — the same scheme behind IronCore Labs’ Cloaked AI. No primitive is invented; only the published composition, built from audited RustCrypto crates (ChaCha20, HMAC-SHA256, HKDF-SHA256).
This is cipher v2 (ADR-0035): it adds the paper’s two hardening steps — a key-derived component shuffle and an optional ordering-preserving normalisation — on top of the core SAP cipher of ADR-0031. v2 is a breaking change from v1 (v1 ciphertexts are not v2-decryptable); since the cipher is client-side, there is no on-disk format change.
One master secret derives, via HKDF-SHA256, a secret scaling factor s ∈ [1, 2),
a CSPRNG key, a shuffle CSPRNG key, and an HMAC key. To encrypt m ∈ ℝ^d with
approximation factor β ≥ 0:
- normalise (optional, ordering-preserving): apply a fixed global affine
transform
m₁ = (m − μ)·α— a per-dimension shift vectorμ(default0) and a single positive scalarα(default1); - shuffle: permute the components with a permutation
πderived from the key alone (HKDF sub-key + a fixed-IV ChaCha20 Fisher–Yates), identical for every vector and query; - draw a fresh random 96-bit IV;
- seed ChaCha20 from
(prfKey, iv)and sample a perturbationλuniformly in the d-ball of radius(s/4)·β(a Box-Muller Gaussian direction normalised and scaled byradius = (s/4)·β·U^{1/d},U ~ Uniform[0,1)); - the ciphertext vector is
c = s·π(m₁) + λ; - an HMAC-SHA256 tag over
(domain ‖ β ‖ iv ‖ c), with the domain bumped toquiver/dcpe/v2/tag, gives tamper-evidence (a v1 ciphertext fails a v2 integrity check — fail-closed — rather than decrypting to garbage).
Decryption re-derives λ from (prfKey, iv) (the perturbation is pseudorandom,
so it cancels), verifies the tag, and reverses the pipeline m = T⁻¹(π⁻¹((c − λ)/s)).
A query is encrypted the same way: the secret s, the permutation π, and the
normalisation are identical for data and queries, so they cancel in the distance
ordering, while the bounded per-vector perturbations are the margin β.
Both hardening steps preserve the L2 distance-comparison ordering exactly: L2
distance is invariant under any permutation of coordinates (so the shuffle costs
zero recall and only hides which ciphertext coordinate is which plaintext
coordinate), and a uniform per-coordinate shift cancels in any difference while a
single positive scalar scales every distance by the same factor (so normalisation
just canonicalises the cloud’s centroid and overall scale — making β’s meaning
consistent across datasets). The shuffle and normalisation therefore harden the
cipher without changing its accuracy or its leakage class.
What it hides, and what it leaks — honestly
Against an honest-but-curious server that sees only ciphertexts, with no known plaintext/ciphertext pairs and no strong prior on the embedding distribution, DCPE hides the exact coordinate values, the exact pairwise distances (perturbed by up to the ball radius), the coordinate frame (via the secret scale), and equality of repeated vectors (randomised IVs).
It leaks, by design: the approximate Euclidean distance-comparison relation among the ciphertexts — hence approximate pairwise distances (up to the secret scale and the margin), cluster structure, and dataset geometry. That leakage is the mechanism that makes encrypted search work: anyone holding the ciphertexts can run the same nearest-neighbour search and clustering you can.
It is broken by an adversary with known plaintext/ciphertext pairs (the low-entropy secret scale becomes recoverable), or with a strong distributional prior on the embeddings or access to the embedding model (embedding-inversion attacks apply — preserving distance preserves much of what inversion needs). DCPE assumes a high-entropy message distribution; real embeddings may not meet that.
It is not IND-CPA, and Quiver never claims it is. There is no homomorphic search in core, and no home-grown scheme.
The accuracy/security trade-off
The approximation factor β is the knob. A larger β adds more perturbation —
hiding exact distances better but lowering search recall; a smaller β keeps
recall high but hides less. Quiver’s tests demonstrate this directly: recall stays
high at a small β and degrades as β grows. Tune β against your own data and
recall target; there is no universally correct value.
Constraints
- L2 only. The secret scaling changes vector norms, so cosine and inner-product
orderings are not preserved. A DCPE collection must use the
l2metric (the server rejects anything else with a 400). - Encrypt and query from the same client, with the same key,
β, and normalisation. - Normalisation cannot whiten per-axis variance. The optional normalisation is a
global affine transform (a per-axis shift plus a single scalar scale) because
that is the strongest normalisation that preserves the L2 distance-comparison
ordering. Standardising each dimension by its own variance is anisotropic — it
re-weights the dimensions in the L2 distance and so breaks the ordering the
untrusted server ranks on. Full per-axis whitening is therefore incompatible
with searchable DCPE and is deliberately not offered; supply real corpus
statistics (a per-axis mean as the shift, a global RMS radius as
1/scale) to get the normalisation that is compatible. - The float-valued ciphertext uses transcendental functions, so cross-language
reproduction is validated within a tolerance, not bit-exactly (the integrity tag,
being over bytes, is bit-exact). The Rust module
quiver_crypto::dcpeis the canonical reference.
Using it
Create the collection with the flag, encrypt vectors and queries client-side:
from quiver import Client
from quiver.dcpe import DcpeCipher # pip install quiver-client[dcpe]
cipher = DcpeCipher.from_hex("…64 hex chars…", approximation_factor=0.02)
with Client("https://…", api_key="…") as q:
q.create_collection("vault", dim=8, metric="l2", vector_encryption="dcpe")
sealed = cipher.encrypt([0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8])
q.upsert("vault", [{"id": "a", "vector": sealed.ciphertext}])
hits = q.search("vault", cipher.encrypt_query(my_query), k=10)
A native cipher ships in all three languages — the Rust reference
(quiver_crypto::dcpe::DcpeCipher, available to embedders), the Python
quiver.dcpe.DcpeCipher, and the TypeScript DcpeCipher at the
quiver-client/dcpe subpath (pnpm add @stablelib/{chacha,hkdf,hmac,sha256}):
import { DcpeCipher } from "quiver-client/dcpe";
const cipher = DcpeCipher.fromHex("…64 hex chars…", 0.02);
const sealed = cipher.encrypt([0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8]);
// upsert sealed.ciphertext; search with cipher.encryptQuery(myQuery).
To use the optional normalisation, build it from a one-time measurement of your
corpus and pass it to the cipher (Normalization in each SDK; with_normalization
on the Rust cipher). The MCP create_collection tool accepts
vector_encryption="dcpe".
Key management
The DCPE key is the client’s; Quiver never sees it. Use a dedicated key —
never your at-rest (QUIVER_ENCRYPTION_KEY) or payload key. Losing the key makes
the vectors unrecoverable. Ideally use a distinct key per collection.
Status and follow-ups
Shipped in v0.10.0 (ADR-0031): the core scale-and-perturb cipher with an integrity tag, the per-collection flag across the API/MCP/SDKs, the Python cipher, and an end-to-end gate proof.
Hardened to cipher v2 in v0.15.0 (ADR-0035): the paper’s two security-boosting pre-processing steps now ship — the secret component shuffle (a key-derived permutation, an exact L2 isometry) and an ordering-preserving global normalisation (a per-dimension shift plus a single scalar scale) — together with a native TypeScript cipher, closing the SDK gap so Rust, Python, and TypeScript all have native DCPE ciphers validated against each other by a cross-language known-answer test. The honest limit recorded above stands: full per-axis variance whitening is incompatible with searchable encryption and is not offered. v2 is a breaking cipher change (v1 ciphertexts are not v2-decryptable), acceptable because DCPE is experimental, off by default, and stored as ordinary L2 vectors (no on-disk change).
Semantically secure vector search (client-side encryption)
Quiver can store your embeddings on a server that learns nothing about them —
no coordinates, no distances, no clustering, no geometry — and still give you
correct nearest-neighbour results. This is the semantically secure end of
Quiver’s encrypted-search spectrum, and the honest counterpart to DCPE
(dcpe.md): where DCPE lets the server rank ciphertexts but leaks the
distance-comparison relation by design, this mode leaks none of it — at the cost
that the server does not rank. The client fetches the entitled set, decrypts
locally, and ranks.
Note
This mode is opt-in and off by default. It is genuinely IND-CPA for vectors (the server holds only XChaCha20-Poly1305 ciphertext), reusing the same audited primitive as encryption-at-rest and payload encryption — no new cryptography. Its real cost is operational, not cryptographic: the server can’t rank opaque ciphertext, so it suits small/medium collections or server-pre-filtered subsets, where downloading the candidate set to rank client-side is acceptable.
The encrypted-search spectrum
You cannot have all three of: an untrusted server doing fast ANN ranking, zero
distance leakage, and practical performance. Pick two. Quiver offers the honest
points on that line, per collection (vector_encryption):
| Mode | Server sees | Server ranks? | Leakage | Best for |
|---|---|---|---|---|
none (default) | plaintext vectors | yes | everything | trusted server, max speed |
dcpe | ciphertext | yes (approx. L2) | approximate distance ordering, by design | untrusted server, ANN at scale, leak acceptable |
client_side (this page) | ciphertext only | no | size, dimension, chosen cleartext fields, access patterns | small/medium or pre-filtered sets, zero geometry leakage |
| (FHE — out of scope) | ciphertext | yes | none | tiny “secure exact search” only |
The scheme
A client-held VectorCipher seals a vector’s raw little-endian f32 bytes with
XChaCha20-Poly1305 (the audited RustCrypto AEAD that already protects pages,
the WAL, and payloads) under a fresh random 192-bit nonce, with associated data
quiver/vector/v1. The result is a one-key envelope stored under the reserved
payload key __quiver_vec__:
{ "__quiver_vec__": {
"v": 1,
"alg": "xchacha20poly1305",
"dim": 8,
"n": "<base64 24-byte nonce>",
"ct": "<base64 ciphertext+tag>"
} }
On upsert, the client sends this blob in the payload plus a zero placeholder
vector of the collection’s dimension. To the engine that is an ordinary point,
so there is no on-disk format change and the kill -9 crash gate is untouched
by construction — the blob rides the existing payload heap. The server builds
no ANN index for the collection and rejects a ranked query; retrieval is a
fetch (an optional cleartext payload filter narrows the set, a limit bounds
it), and the client decrypts and ranks.
Because the sealed message is raw bytes — not transcendental floats like DCPE — the round-trip is bit-exact and the envelope reproduces byte-identically across the Rust, Python, and TypeScript implementations (a stronger interop guarantee than DCPE’s tolerance-based equivalence).
What it hides, and what it leaks — honestly
Against an honest-but-curious server holding only ciphertexts, this mode hides the entire geometry: every coordinate, all pairwise distances, norms, clustering, and nearest-neighbour structure. The ciphertext is IND-CPA (fresh random nonce per seal), so the same vector sealed twice is indistinguishable from two unrelated vectors. The server cannot rank, cluster, or invert.
It leaks, by necessity rather than cryptographic weakness:
- the collection’s size and declared dimension;
- whatever payload fields you deliberately leave cleartext to keep server-filterable (you choose the trade-off per field);
- access patterns — which points are fetched, how often, in what batches (hiding these needs ORAM, which is out of scope).
It composes with encryption-at-rest, RBAC, and payload encryption; it does not replace them.
The cost, stated plainly
The server does not rank. Client.search_client_side (Python) /
Client.searchClientSide (TypeScript) fetches the candidate set and ranks it on
the client. That is practical for small/medium collections, or when a cleartext
payload filter (an ADR-0022 secondary index)
narrows the set the server returns — not for an unfiltered top-k over tens of
millions of vectors. This is the inherent price of zero leakage, not a bug. If you
need the server to rank at scale and can accept the distance-ordering leak, use
DCPE instead.
Constraints
- Any metric. The server never ranks, so there is no metric restriction (the
client ranks with
l2,cosine, ordot); the collection’s declared metric is advisory. (Multi-vector collections are not supported in this mode.) - A zero placeholder vector costs
dim × 4bytes per point on disk — the price of changing no on-disk format; negligible beside the payload blob. - Encrypt and decrypt from a client holding the key; the server never sees it.
Using it
Create the collection with vector_encryption="client_side", then upsert sealed
vectors and search client-side. Python:
from quiver import Client
from quiver.vector import VectorCipher # pip install quiver-client[encryption]
cipher = VectorCipher.from_hex("…64 hex chars…")
with Client("https://…", api_key="…") as q:
q.create_collection("vault", dim=8, metric="l2", vector_encryption="client_side")
vec = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8]
# A zero placeholder vector + the sealed blob (and any cleartext fields).
q.upsert("vault", [{"id": "a", "vector": [0.0] * 8,
"payload": {"tier": "gold", **cipher.seal(vec)}}])
# Fetch + decrypt + rank, entirely client-side:
hits = q.search_client_side("vault", my_query, cipher, k=10)
TypeScript (the cipher is at the quiver-client/vector subpath, an optional
@stablelib/xchacha20poly1305 peer dependency):
import { Client } from "quiver-client";
import { VectorCipher } from "quiver-client/vector";
const cipher = VectorCipher.fromHex("…64 hex chars…");
const q = new Client("https://…", { apiKey: "…" });
await q.createCollection("vault", 8, { metric: "l2", vectorEncryption: "client_side" });
await q.upsert("vault", [{ id: "a", vector: new Array(8).fill(0),
payload: { tier: "gold", ...cipher.seal(vec) } }]);
const hits = await q.searchClientSide("vault", myQuery, cipher, { k: 10 });
The Rust reference quiver_crypto::vector::VectorCipher is available to embedders;
the MCP fetch tool retrieves the entitled set for an agent that holds the key.
Key management
The vector-encryption key is the client’s; Quiver never sees it. Use a dedicated
key — never your at-rest (QUIVER_ENCRYPTION_KEY) or payload key. Losing the key
makes the vectors unrecoverable. Ideally use a distinct key per collection.
Status
Shipped in v0.11.0 (ADR-0032): the
quiver_crypto::vector reference cipher, the vector_encryption = client_side
collection mode enforced server-side (no index, ranked search rejected) with a
fetch path across REST/gRPC/MCP, native Python and TypeScript ciphers with a
bit-exact cross-language known-answer test, client-side search helpers, and an
end-to-end gate proof that the plaintext vectors never reach disk and the server
cannot rank them.
Threat Model
Security is Quiver’s foundation, not a feature. This document states what we defend, against whom, and — crucially — what we honestly do not protect. Overclaiming would discredit a security-first project. Crypto mechanisms are in crypto.md; decisions in ADR-0010–ADR-0014.
Assets
- Vector data — embeddings can leak information about their source content (embedding-inversion attacks are real), so vectors are sensitive, not just metadata.
- Payloads — arbitrary, often PII or business data.
- Keys — master key, per-collection data-encryption keys (DEKs), API-key secrets.
- Audit log integrity and service availability.
Adversaries
| # | Adversary | Primary defense |
|---|---|---|
| A1 | Network attacker (MITM) | TLS 1.3 (rustls); optional mTLS |
| A2 | Malicious / compromised client | AuthN (API key / mTLS), RBAC scopes, tenant isolation, query cost limits (ADR-0040) |
| A3 | Thief of disk / backups (data at rest) | Envelope encryption-at-rest (AEAD); crypto-shredding |
| A4 | Curious / compromised server operator (payloads) | Client-side payload encryption — server stores ciphertext it cannot read |
| A5 | Another tenant | Isolation enforced at the data-access layer; default-deny RBAC |
| A6 | Curious / compromised server operator (vectors) | DCPE (vector_encryption=dcpe, leaky, server ranks) or client-side opaque vectors (vector_encryption=client_side, semantically secure, server does not rank) — both opt-in, per collection |
| A6 | Supply-chain attacker | cargo deny/audit, minimal pinned deps, SBOM, gitleaks |
Trust boundaries
flowchart LR
c["Client app<br/>(+ client-side key)"] -- "TLS / mTLS" --> s
subgraph host["Semi-trusted host"]
s["Quiver server process<br/>(plaintext vectors in RAM)"] -- "AEAD pages" --> d[("Disk: ciphertext")]
s -. "wrap/unwrap DEK" .-> k["KMS (optional)"]
end
- Client ↔ Server (network). TLS 1.3 always for non-loopback; optional mTLS. The server authenticates the client, authorizes the request scope, and scopes all data access to the tenant.
- Server ↔ Disk. The filesystem is semi-trusted: everything at rest is AEAD-encrypted, so a stolen disk or backup yields only ciphertext.
- Server ↔ KMS (optional). The master key may live in a KMS; plaintext DEKs exist only in server RAM and are zeroized after use.
- Client ↔ Server for payloads (optional client-side encryption). When enabled, the server is untrusted for payload confidentiality — the trust boundary moves to the client, which encrypts payloads the server can only store and return as opaque blobs.
What the server can and cannot see — stated honestly
Without client-side encryption: to build and search an ANN index, the server necessarily holds vectors and payloads in plaintext in RAM while serving. At rest they are encrypted. Therefore at-rest encryption defends against A3 (stolen disk/backup) — it does not defend against an adversary with root on the live host who can read process memory. That residual risk is documented, not hidden.
With client-side payload encryption: the server never sees payload plaintext, even in RAM. But vectors remain plaintext server-side because standard ANN math requires them — and vectors can leak information about their source. So:
Client-side payload encryption protects payloads, not vectors. Confidentiality of vectors against the server is not provided by default. Two opt-in, per-collection modes address it, at opposite ends of the spectrum. DCPE (
vector_encryption = dcpe) — a published distance-comparison-preserving scheme (ADR-0031,dcpe.md) — lets the server keep ranking ciphertexts but by design leaks the approximate distance-comparison relation, so it is not semantically secure and is broken by known-plaintext or strong-prior adversaries (its v2 hardening — a key-derived component shuffle and a global normalisation, ADR-0035 — hides axis alignment and canonicalises scale but does not change this leakage class). Client-side opaque vectors (vector_encryption = client_side) — XChaCha20-Poly1305 AEAD (ADR-0032,client-side-vectors.md) — is genuinely semantically secure (the server holds only ciphertext and never ranks), at the cost that the client fetches the entitled set and ranks locally. Quiver does not claim homomorphic-encrypted search in core, and never ships a home-grown scheme.
This precise boundary is the honest core of the security story.
The most recent code-level review of these controls — extended to cluster mode, the coordinator, per-shard Raft, and a dynamic OWASP ZAP pass — is the v0.29.0 audit note; the prior pass (migration-connector SSRF posture and a cleartext-credential fix) is the v0.17.0 audit note.
STRIDE summary
- Spoofing → API-key/mTLS authentication; keys hashed at rest, shown once.
- Tampering → AEAD integrity on every page; append-only audit log (optionally hash-chained).
- Repudiation → audit log records actor, action, resource, time.
- Information disclosure → the encryption layers above; tenant isolation; sanitized errors (no internal paths/secrets); secrets never logged.
- Denial of service → query cost limits enforced at the op layer (caps on
k,ef_search,fetchlimit, vector dimension, payload size, upsert batch size, and HTTP request body size — ADR-0040), rejected with 400 /InvalidArgumentso one oversized request cannot exhaust the single-writer engine; plus opt-in per-key rate limiting (ADR-0049, token bucket, 429). The rate limiter is post-authentication by design: it is keyed by the caller’s authenticated actor identity and therefore holds at most one bucket per configured key, so it cannot itself be turned into a memory-exhaustion vector by an attacker minting arbitrary source identities. Consequently it does not throttle unauthenticated traffic (a flood of anonymous requests that all fail auth) — that is the job of an upstream reverse proxy / load balancer / WAF, which every production deployment should terminate TLS and rate-limit at (see the deployment docs). A coarse pre-auth per-source limiter would reintroduce an unbounded-by-source map and is deliberately left to that layer. Deferred (stated, not claimed): concurrent-query caps and a work-cancelling query timeout (not achievable under the currentspawn_blockingmodel without cooperative cancellation). - Elevation of privilege → default-deny RBAC scopes; tenant isolation at the data layer; no anonymous writes; no default credentials. In cluster mode the coordinator’s membership API is authenticated on the same footing: reshaping the cluster (
POST/DELETE /cluster/shards*) requires an admin key and reading the map requires any valid key, so a network-reachable coordinator cannot be reshaped by an unauthenticated caller (only/healthz//readyzare open; a keyless coordinator refuses to start unlessinsecure).
Crypto-shredding
Per-collection DEKs make cryptographic erasure a first-class operation: destroy a collection’s wrapped DEK and its at-rest data — including any backups — becomes unrecoverable, satisfying “right to erasure” without hunting down every copy.
Verification
Fuzzing of the wire-protocol and on-disk parsers (cargo-fuzz targets for the Filter JSON parser and the page/WAL decoders — malformed input must reject cleanly, never panic); cargo audit/deny; tests asserting (a) data files are ciphertext, (b) a client-side-encrypted payload is unreadable server-side, (c) RBAC denies cross-tenant/over-scope access, (d) a crypto-shredded collection is unrecoverable, (e) the audit log records actor/action/resource without leaking secrets, (f) a DCPE-encrypted query returns the right neighbour while the plaintext vector never reaches disk (the scoped ADR-0031 guarantee), and (g) a client-side-encrypted collection rejects a ranked query and never writes the plaintext vectors to disk while the client still recovers the right neighbour (the ADR-0032 guarantee). Tracked under risks R3/R4/R8 in ../risk-register.md.
Quiver is a Cargo workspace: a from-scratch storage engine, index structures, SIMD distance kernels, and a query planner, with a thin gRPC/REST shell and a TUI client. The C4 views — system context and container view — map the system at a glance; the crate map below and the index design that follows are the deep dive. Every significant decision is captured as an ADR.
Quiver — Architecture Overview
Quiver is a native-Rust vector database. It stores vectors and structured payloads, builds approximate-nearest-neighbour (ANN) indexes over them, and answers top-k similarity queries with optional metadata filtering — over gRPC, REST, an embeddable library API, and an MCP server, with a retro terminal cockpit for operators.
The wedge (and the non-goals)
Quiver competes on a narrow, defensible edge, not on raw scale or feature count:
- Security-first, by default. Encryption-at-rest is on out of the box with secure defaults; payloads can be client-side-encrypted so the server never sees plaintext; strong authN/Z, audit, and crypto-shredding. Only audited cryptography (
rustls, RustCrypto/ring) — never a primitive of our own. - Memory frugality. Disk-resident graph/inverted indexes plus quantization (product, scalar, binary) let large datasets serve from a laptop’s RAM budget. The headline benchmark metric is memory footprint at a fixed recall, not just QPS.
- Developer experience. A single static binary; embeddable and server modes; a
ratatuicockpit; idiomatic Python and TypeScript SDKs; an MCP server so agents can drive it.
Explicit non-goals for v1 (stated to keep the project honest): out-scaling Milvus/Pinecone; distributed clustering (single-node excellence first; replication is a clearly-labelled stretch); homomorphic-encrypted search in core (only a published distance-comparison-preserving scheme, behind an experimental flag, with honest leakage caveats). Embeddings are produced by the caller — Quiver stays model-agnostic.
Workspace map
The core (storage, indexes, kernels, query planner, on-disk format, wire protocol) is built from scratch. A minimal set of vetted crates is used only where reinventing would be reckless (async runtime, TLS, crypto, serialization, TUI). No embedded database engine is used (no RocksDB/LMDB/sqlite).
| Crate | Responsibility | Notable external deps |
|---|---|---|
quiver-simd | SIMD distance kernels (cosine/L2/dot/hamming), runtime CPU-feature dispatch, scalar fallback | none (uses std/core::arch) |
quiver-crypto | Thin wrappers over audited crypto: envelope encryption, AEAD, KDF, key hierarchy, TLS config | ring/RustCrypto, rustls |
quiver-core | Storage engine: segments, mmap + page/buffer manager, WAL, manifest, compaction, snapshots; the collection/payload model | memmap2, crc32c |
quiver-index | HNSW (in-mem), DiskANN/Vamana (disk), IVF; quantization (PQ/scalar/binary) | — |
quiver-query | Query planner; hybrid filtered search (vector + metadata predicate + optional BM25); top-k merge & re-rank | — |
quiver-proto | Wire types: gRPC service (tonic/prost), REST DTOs, OpenAPI generation | tonic, prost, serde |
quiver-embed | Embeddable in-process database handle — the clean Rust API over core+index+query+crypto | — |
quiver-providers | Edge embedding/rerank adapters (OpenAI-compatible/Cohere/fake) shared by the network and MCP servers (ADR-0047/0058) | ureq, figment |
quiver-server | The daemon: axum REST + tonic gRPC, auth, RBAC, audit, query cost limits (ADR-0040), config, observability | axum, tonic, tokio, tracing |
quiver-tui | The ratatui cockpit (API client; works local or remote) | ratatui, crossterm |
quiver-mcp | MCP server exposing Quiver as agent tools | MCP SDK / rmcp |
quiver-cli | Single binary entrypoint: serve, tui, mcp, admin, bench | clap |
Dependency DAG (acyclic by construction)
flowchart TD
simd[quiver-simd]
crypto[quiver-crypto]
proto[quiver-proto]
core[quiver-core] --> crypto
index[quiver-index] --> core
index --> simd
query[quiver-query] --> index
query --> core
embed[quiver-embed] --> query
embed --> crypto
providers[quiver-providers]
server[quiver-server] --> embed
server --> proto
server --> crypto
server --> providers
tui[quiver-tui] --> proto
mcp[quiver-mcp] --> embed
mcp --> crypto
mcp --> providers
cli[quiver-cli] --> server
cli --> tui
cli --> mcp
cli --> embed
Domain logic lives in the lower crates (core/index/query/crypto); framework code (HTTP, gRPC, TUI, MCP) stays at the edges. This keeps the engine testable in isolation and reusable in embedded mode.
Operating modes
- Embedded library —
quiver_embed::Database::open(path, config)?gives an in-process handle: no network, no auth surface, encryption-at-rest still on. For tests, notebooks, and apps that want a local vector store. - Server —
quiver serveexposes gRPC + REST with auth, RBAC, multi-tenant namespaces, audit, query cost limits (ADR-0040), and observability. The TUI and MCP server are API clients of it.
Both ship in one static binary; quiver-server is a thin network/policy shell over quiver-embed.
Request lifecycles
Write (upsert): client → server (TLS terminate → authenticate → authorize scope → cost-limit check → idempotency check) → quiver-embed → quiver-core appends a WAL record (encrypted, checksummed) and stages the vector + payload into the active segment → quiver-index inserts the vector into the live index → ack after WAL fsync (durability boundary). Payload secondary indexes updated transactionally with the segment.
Query (top-k + filter): client → server (authn/z) → quiver-query plans the filter strategy (pre-filter via metadata bitmap when selective; post-filter otherwise) → quiver-index runs ANN search (HNSW/Vamana/IVF), using quiver-simd kernels on quantized vectors → candidate set re-ranked with exact distances against full-precision vectors fetched & decrypted from quiver-core → top-k assembled (payloads decrypted at rest; returned as-is if client-side-encrypted) → response with a cursor for pagination.
Cross-cutting concerns
- Security layers: TLS/mTLS in transit (
quiver-crypto/rustls); envelope encryption at rest (per-collection DEK wrapped by a master key, AEAD on segment pages); optional client-side payload encryption (opaque ciphertext to the server); API-key scopes + RBAC + tenant isolation; append-only audit log; crypto-shredding by DEK destruction. See../security/threat-model.mdand../security/crypto.md. - Concurrency: single-writer, many-reader. The server guards the engine with a reader–writer lock (ADR-0057): searches take the shared lock and run in parallel, writes take the exclusive lock. A read that finds a collection’s index stale (a prior write deferred its rebuild) serves the prior snapshot and schedules an off-lock rebuild (ADR-0062) — the inputs are captured under the shared lock, the new index is built with no lock held, and it is swapped in under a brief write lock — so a rebuild never stalls concurrent readers (measured: a 100k-vector rebuild that blocked every read for ~77 s now keeps reads in the sub-millisecond tail). Reads are then snapshot-isolated and eventually consistent across a rebuild window; embedded
&mutcallers still rebuild synchronously for read-your-writes. Durability and thekill -9crash gate are unchanged. - Observability: OpenTelemetry-compatible
tracingspans across server→query→index→core; Prometheus/metrics; structured logs;/healthz+/readyz. - Configuration: typed, validated config with secure defaults; secrets via env + KMS pattern (see ADR-0013).
Where to read next
- C4 system context —
c4-context.md; container view —c4-container.md. - Per-subsystem diagrams (storage, indexing, retrieval, distributed, security, ops) —
../diagrams.md. - Decisions —
../adr/. Roadmap & DoDs —../roadmap.md. Risks —../risk-register.md. - The bridge to implementation —
../repo-scaffold-plan.md.
Index Design
The index engine (quiver-index) is the centerpiece. Indexes are pluggable per collection behind a common trait, so a collection picks the point on the recall / latency / memory surface it needs. The decisions are recorded in ADR-0007 (index roadmap) and ADR-0008 (quantization); the distance math is in distance-kernels.md.
The tradeoff surface
| Index | RAM resident | Disk | Recall | Latency | Build | Best for |
|---|---|---|---|---|---|---|
| HNSW (Phase 1) | graph + vectors | — | very high | lowest | medium | small/hot collections in RAM |
| Vamana / DiskANN (Phase 2) | PQ codes + node cache | graph + full vectors | high | low–med (SSD-bound) | slow | large collections, frugal RAM |
| IVF (+PQ / SPANN) (Phase 2) | centroids (+ codes) | posting lists | med–high | med | fast | predictable RAM, fast build, fallback |
Memory frugality is the headline; the disk-resident path is risk R1 and is de-risked with the analytical budget below before any code is written.
HNSW — Phase 1, in-memory
Hierarchical Navigable Small World graphs (Malkov & Yashunin, IEEE TPAMI 2020; arXiv:1603.09320). A multi-layer proximity graph: greedy descent through sparse upper layers to an entry region, then an ef-bounded best-first search at the dense base layer.
- Parameters:
M(neighbors/node/layer; base layer2M),efConstruction,efSearch, level factormL = 1/ln(M). Recall/latency tuned byefSearchat query time. - Neighbor selection: the paper’s heuristic (keep diverse neighbors, not merely the nearest) — materially better recall on clustered data than naive top-
M. - Memory layout: base-layer adjacency in a flat arena of fixed
2Mu32slots per node for cache locality; upper-layer lists (held only by the few nodes promoted above L0) in a compact side structure. Vectors are referenced by row id into the columnar store — full-precision for exact distance, or quantized codes when compressed. - Concurrency: built by the single writer; traversed lock-free by readers via atomic adjacency publication + EBR (see
../concurrency/model.md).
Vamana / DiskANN — Phase 2, disk-resident (the memory-frugal core)
DiskANN (Subramanya et al., NeurIPS 2019). A single flat Vamana graph (degree R, build list L, prune slack α≈1.2) laid out on SSD so each node co-locates its adjacency and its full-precision vector in one disk block — one random read per hop.
- RAM holds only PQ-compressed vectors (to navigate with approximate distances) plus a hot-node cache; the graph and full vectors stay on SSD. The candidate set returned by the beam search is re-ranked with exact distances by fetching full-precision vectors from SSD.
- Parameters:
R(e.g. 64–128),L,α, beam widthW(parallel SSD reads/hop).
IVF (+ PQ / SPANN) — Phase 2, the predictable-memory fallback
Inverted file: a coarse k-means quantizer partitions space into nlist Voronoi cells; a query probes the nprobe nearest cells. Combined with PQ (IVFADC; Jégou et al., IEEE TPAMI 2011) or with on-disk posting lists (SPANN; Chen et al., NeurIPS 2021, which keeps centroids in RAM and balanced posting lists on SSD). IVF gives a tighter, more predictable RAM profile (essentially just centroids) and fast builds, at somewhat lower recall-per-IO than a good graph — hence its role as the R1 fallback if a Vamana RAM budget slips.
Quantization (ADR-0008)
- Scalar (SQ): f32 → int8 per-dim min/max. 4× smaller, fast, good default for light compression.
- Product (PQ): split
dimintomsubspaces, k-means (256 centroids → 1 byte/subspace); asymmetric distance via precomputed lookup tables. The workhorse for RAM-resident codes. - Binary (BQ): 1 bit/dim (sign); Hamming via SIMD popcount as a fast pre-filter, then exact re-rank. ~32× smaller; strong for high-dim normalized embeddings.
- Re-rank flow: approximate distance (SQ/PQ/BQ) → candidate set → exact full-precision distance → final top-k. The candidate multiplier is the recall ↔ latency/memory knob.
Analytical memory budget — de-risking R1 (768-dim f32 embeddings)
| Representation | Bytes / vector | vs full |
|---|---|---|
| Full precision (f32) | 3072 | 1× |
| SQ int8 | 768 | 4× |
| PQ, m=192 | 192 | 16× |
| PQ, m=96 | 96 | 32× |
| Binary | 96 | 32× |
| HNSW base adjacency (M=16) | 128 | — |
10M × 768-dim: full vectors = 30.7 GB (won’t fit a laptop). DiskANN keeps PQ codes (m=96) ≈ 0.96 GB in RAM + a node cache, with full vectors + graph (~32 GB) on SSD → serve 10M from ~1–2 GB RAM. 100M ⇒ ~9.6 GB PQ in RAM (a 32 GB workstation) + ~320 GB SSD. Honest scope: billion-scale DiskANN needs a server (~64 GB RAM); on a 16–32 GB laptop/workstation the disk path comfortably serves tens to a few hundred million vectors. SIFT1M (128-dim) full-precision HNSW is ~0.5 GB of vectors + ~0.13 GB adjacency — trivially in RAM.
De-risk plan: (1) this budget + a recall model from the cited papers (now); (2) a .scratch spike on a public 1–10M set measuring real recall vs RAM for chosen (R, m, W) before Phase 2 implementation; (3) prove at 10M+ in Phase 2 benchmarks. Fallback: IVF+PQ.
Filtered search
The planner (in quiver-embed) decomposes the Filter into the predicates the secondary indexes can answer (And intersects, Or unions, negation/existence/undeclared fields widen to unconstrained — always a sound superset) and resolves a candidate id set via Store::matching_ids. When that set is selective (at or below a full-scan threshold) it scans those rows exactly — perfect recall, and immune to the filtered-ANN recall cliff that bites when a selective predicate starves an over-fetched candidate list; an empty candidate set short-circuits to no results. When the filter is broad (or not indexable) it post-filters the ANN candidates instead. Both arms re-check the full Filter, so results are exact regardless of path. This is the selectivity-based strategy for v1; constraining graph traversal to an allowed-node bitmap, and specialized filtered-graph search (e.g. Filtered-DiskANN, Gollapudi et al. WWW 2023; ACORN, Patel et al. SIGMOD 2024), are later enhancements.
Incremental updates (Phase 4 — ADR-0023)
Through v0.3.0 the index is a derived artifact: HNSW absorbs a brand-new id in place, but an update, a delete, or any write to a batch index (Vamana / IVF / DiskVamana) marks the collection stale and the next search rebuilds it from the store — which is also how every index is reconstructed on open. This is simple and correct (the store is the single source of truth, ADR-0020/0021, so the index never has to be crash-consistent), but the update cost is O(N), which does not suit streaming workloads.
Phase 4 closes the gap with SpFresh’s LIRE protocol (Lightweight Incremental REbalancing; Xu et al., SOSP 2023), applied first to IVF — the inverted-list structure LIRE was designed for (SPANN; Chen et al., NeurIPS 2021). Inserts append to the nearest centroid’s posting list; deletes mark a per-index deletion set; and local split / merge / reassign rebalancing keeps posting lists balanced as the distribution drifts, maintaining the invariant that each vector sits in its nearest centroid’s list — which is what preserves recall — without a global rebuild. The first increment (v0.4.0) kept the index in memory and derived, so the kill -9 crash gate (ADR-0005) was untouched by construction. v0.6.0 then made the IVF index durable: it is snapshotted at checkpoint, referenced by the manifest under the same atomic swap as the segments, and recovered on open by loading the snapshot and replaying only the post-checkpoint WAL tail instead of rebuilding — with the crash gate extended to cover it (ADR-0025). v0.13.0 closes the last gap with FreshDiskANN’s StreamingMerge for the graph family (Vamana / DiskVamana; Singh et al., 2021 — a different algorithm than LIRE): the batch-built graph becomes a read-only base, recent inserts land in a small in-memory delta graph (Vamana::insert) searched alongside it, deletes are O(1) tombstones filtered with live-fraction beam widening, and consolidation is a derived rebuild from the store once pending work passes a churn threshold — so the index stays in memory and derived, the on-disk artifact keeps its write-once contract, and the crash gate is untouched by construction (ADR-0033). The decisions and full scope are in ADR-0023, ADR-0025, and ADR-0033.
Multi-vector / late interaction (Phase 4 — ADR-0028)
Single-vector retrieval pools a document into one embedding; late-interaction models (ColBERT) keep a document as a set of token embeddings and score a query — also a set — by MaxSim: each query token takes its best-matching document token, and the maxima are summed. It is stronger out of domain, at the cost of storing 100–200 vectors per document — the large, low-dimensional pool that Quiver’s PQ and disk-resident index were built to compress, so late interaction showcases the memory-frugality wedge rather than straining it.
Quiver models a multi-vector document as a group of ordinary rows — one row per token vector — over the existing row-addressed store (ADR-0020), with no on-disk format change, so the kill -9 crash gate stays untouched by construction. The token pool is the set the collection’s ANN index serves, so candidate generation is a normal nearest-neighbour search (the PLAID shape): for each query token, find its nearest token rows, union their parent documents, then re-rank those candidates by the full MaxSim and apply the document-level Filter. Document grouping (doc-id → token rows) is derived in memory on open, like the existing id maps; the store stays the single source of truth. Late interaction requires a similarity metric (Cosine or Dot); the token pool compresses under the same IVF+PQ / disk path as any collection, with exact vectors read for the re-rank. The decision and full scope are in ADR-0028.
v0.14.0 takes two of ADR-0028’s three deferred follow-ups (ADR-0034). Incremental maintenance: document upsert/delete now dispatch to the token-pool index incrementally through the same per-point insert/delete path single-vector collections use, instead of marking the collection stale for a full rebuild — so a document write is size-independent, closing the last write-then-rebuild path. ColBERTv2 / PLAID compression: an opt-in colbert index holds coarse kmeans centroids plus per-token (centroid id, PQ residual code) in RAM (residuals trained under L2 so reconstruction is faithful; the collection metric is applied only at scoring) and prunes candidate generation by scoring centroids first — the exact token vectors stay on the encrypted store for the MaxSim re-rank. Both stay in memory and derived (rebuilt from the store on open), so neither changes the on-disk format or the crash gate. The third follow-up — native variable-stride document rows — is deferred: its benefit is storage locality / id-space only (candidate generation still needs the token-pool index, so it removes no work), and ADR-0034 gates the on-disk change on a measured locality win that is owner-reference-hardware-only, so it is not shipped on faith.
References
- Malkov, Yashunin. Efficient and robust ANN search using HNSW graphs. IEEE TPAMI, 2020.
- Subramanya et al. DiskANN: Fast, accurate billion-point NN search on a single node. NeurIPS, 2019.
- Chen et al. SPANN: Highly-efficient billion-scale ANN search. NeurIPS, 2021.
- Jégou, Douze, Schmid. Product quantization for nearest neighbor search. IEEE TPAMI, 2011.
- Xu et al. SpFresh: Incremental in-place update for billion-scale vector search. SOSP, 2023.
- Gollapudi et al. Filtered-DiskANN. WWW, 2023. · Patel et al. ACORN. SIGMOD, 2024.
- Singh et al. FreshDiskANN: A fast and accurate graph-based ANN index for streaming similarity search. 2021.
- Khattab, Zaharia. ColBERT: Efficient and effective passage search via contextualized late interaction over BERT. SIGIR, 2020. · Santhanam et al. ColBERTv2 (NAACL 2022) · PLAID (CIKM 2022).
Decision records (ADRs)
Every significant, hard-to-reverse decision is captured as a short, numbered Architecture Decision Record so future readers understand why the system is shaped the way it is. ADRs are immutable once Accepted; we supersede rather than edit, and numbers are never reused.
Browse the full set in the repository:
docs/adr/ (the
index lists
every record with its status).
Key records by theme
Foundations
- 0001 Language & workspace · 0004 On-disk format · 0005 Durability & crash recovery · 0006 Concurrency
Indexing & storage
- 0007 Index roadmap · 0008 Quantization · 0019 Disk-resident index · 0020 Row-addressed segments
- Incremental updates: 0023 IVF · 0026 HNSW · 0033 graph FreshDiskANN · 0025 durable on-disk index
- Multi-vector: 0028 late interaction · 0034 follow-ups
Security
- 0010 Envelope encryption & AEAD · 0011 AuthN/Z & tenancy · 0012 Client-side payload encryption
- Vector encryption: 0031 DCPE · 0032 client-side opaque vectors · 0035 docs site + DCPE hardening
Platform & integration