Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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.

The Quiver cockpit dashboard

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 ratatui cockpit 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

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.

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 (readwriteadmin) 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

  • rustup with the stable toolchain
  • just (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 — the quiver-cli name on crates.io is an unrelated third-party project, which is why the quiverdb-* 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 (readwriteadmin) 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.tomlQUIVER_* 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

VariableDefaultReq.Description
QUIVER_REST_ADDR127.0.0.1:6333noREST (HTTP/1.1+2) bind address. A non-loopback bind needs TLS (or INSECURE).
QUIVER_GRPC_ADDR127.0.0.1:6334nogRPC (HTTP/2) bind address.
QUIVER_DATA_DIR./quiver-datanoDirectory for segments, the WAL, and the manifest.
QUIVER_API_KEYSyes¹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)

VariableDefaultReq.Description
QUIVER_ENCRYPTION_KEYyes¹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_FILEnoRead 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

VariableDefaultReq.Description
QUIVER_TLS_CERTno²PEM certificate chain.
QUIVER_TLS_KEYno²PEM private key. Set together with QUIVER_TLS_CERT.
QUIVER_TLS_CLIENT_CAnoPEM 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

VariableDefaultReq.Description
QUIVER_INSECUREfalsenoDisables the secure defaults: allows no API keys, no encryption-at-rest, and a non-loopback bind without TLS. Never set in production.

Observability

VariableDefaultReq.Description
RUST_LOGinfonoLog filter (e.g. debug, quiver_server=debug).
QUIVER_AUDIT_LOGnoPath for the append-only JSON-Lines audit log (mutations + denials). Always also emitted as quiver::audit tracing events.
QUIVER_OTLP_ENDPOINTnoOTLP/gRPC endpoint for trace export (requires the otlp build feature).
QUIVER_OTLP_SERVICE_NAMEquivernoservice.name resource attribute for exported traces.
QUIVER_OTLP_TIMEOUT_SECS3noExport timeout.

Prometheus metrics are always served at GET /metrics (open; bind privately).

Replication (ADR-0030)

VariableDefaultReq.Description
QUIVER_LEADER_URLnoRun 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_KEYnoAPI 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.

VariableDefaultDescription
QUIVER_MAX_K10000Max top-k for search / multi-vector search.
QUIVER_MAX_EF_SEARCH4096Max search beam width.
QUIVER_MAX_FETCH_LIMIT10000Max fetch page size.
QUIVER_MAX_VECTOR_DIM8192Max collection dimension and query-vector length.
QUIVER_MAX_PAYLOAD_BYTES65536Max serialized-JSON payload per point (64 KiB).
QUIVER_MAX_BATCH_SIZE1000Max points/documents per upsert request.
QUIVER_MAX_REQUEST_BODY_BYTES33554432Max HTTP request body (32 MiB).
QUIVER_MAX_SPARSE_TERMS4096Max non-zero terms in a hybrid sparse query (ADR-0043).
QUIVER_MAX_BULK_BATCH_SIZE50000Max points per bulk upsert (points:bulk, ADR-0045).

Rate limiting (ADR-0049, opt-in)

VariableDefaultDescription
QUIVER_RATE_LIMIT_REQUESTS_PER_SECOND0 (off)Per-key token-bucket refill rate; over-rate → HTTP 429 / gRPC ResourceExhausted.
QUIVER_RATE_LIMIT_BURST0Bucket burst capacity.

Concurrency

VariableDefaultDescription
QUIVER_MVCC_READSfalseExperimental (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)

VariableDefaultDescription
QUIVER_CLUSTER_SHARDSBracketed list of shard base URLs → turns this server into a stateless router (HRW sharding + scatter-gather). Empty = single node.
QUIVER_CLUSTER_SHARD_KEYAPI key the router/coordinator presents to shards (and that a router presents to a keyed coordinator).
QUIVER_CLUSTER_REPLICASPer-shard read replicas, each "<shard_index>=<replica_url>" (repeatable).
QUIVER_COORDINATORfalseRun this process as the cluster coordinator (membership API; data-plane-free). Its API is authenticated — set QUIVER_API_KEYS here too.
QUIVER_COORDINATOR_STATEFile where the coordinator persists its versioned map + id counter. Unset = in-memory only.
QUIVER_COORDINATOR_URLOn 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)

VariableDefaultDescription
QUIVER_AUTOSCALE_ENABLEDfalseEnable automatic scale-out on the coordinator.
QUIVER_AUTOSCALE_HIGH_WATER_POINTS0Per-shard point count above which to scale out (0 disables even when enabled).
QUIVER_AUTOSCALE_STANDBY_URLSPool of standby shard URLs to grow into, consumed one per scale-out.
QUIVER_AUTOSCALE_INTERVAL_SECSHow often to sample the load signal.
QUIVER_AUTOSCALE_COOLDOWN_SECSMinimum delay between scale-outs.
QUIVER_AUTOSCALE_MAX_SHARDSCap 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)

VariableDefaultDescription
QUIVER_RAFT_NODE_IDThis node’s Raft id within its shard group.
QUIVER_RAFT_MEMBERSThe shard’s Raft members (<id>=<grpc_url>, …). A write is acknowledged only after a quorum.

Embedded / CLI-only

VariableDefaultDescription
QUIVER_CONFIGquiver.tomlConfig file path (the mcp/admin commands).
QUIVER_TUI_URLhttp://127.0.0.1:6333quiver tui target server.
QUIVER_API_KEYBearer token for quiver tui.
QUIVER_DEMO_DIRplatform data dirData 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:

ValueMeaning
encryption.masterKey64 hex chars; the chart stores it in a Secret.
encryption.existingSecret (+ existingSecretKey)reference a Secret you manage.
encryption.insecure=truedev 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 point image.repository/image.tag at 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=True for dense ⊕ BM25 retrieval 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

ToolWhat it does
list_collectionsEnumerate collections
collection_infoInspect one collection’s shape — dim, metric, index, filterable fields, multivector, encryption, count
database_statsA whole-database overview in one call — collection count, total points, a per-collection summary, and snapshot status (manifest_version, disk_bytes)
create_collectionCreate one (dim, metric, index, filterable, multivector, vector_encryption)
delete_collectionDrop an entire collection and all its points (reports whether it existed)
snapshotTake a consistent online backup of the whole database into a server-local directory (ADR-0050)
upsertInsert/replace a point (id, vector, payload)
searchk-NN with an optional payload filter
fetchList points by filter without ranking
getFetch one point by id
deleteDelete a point by id
upsert_document / search_multi_vector / delete_documentMulti-vector (ColBERT) late-interaction documents
upsert_text / search_textStore/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:

  1. list_collections → does a research collection exist? If not, create_collection("research", dim=…, metric="cosine", filterable=[{path:"topic",field_type:"keyword"}]).
  2. As it reads sources, the agent embeds passages (with its own model) and upserts them with {topic, url, added} payloads.
  3. To answer a question, it embeds the query and searches with a filter (e.g. topic = "vector-db"), then grounds its answer in the returned text.
  4. It deletes stale entries or fetches 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 filterable fields 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_side vector 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

IndexRAM residentBest for
hnsw (default)graph + full vectorssmall/hot collections; highest recall, lowest latency
ivf (+PQ)centroids (+ codes)predictable RAM, fast build, a frugal fallback
vamanaPQ codes + node cachemedium collections, one machine
disk_vamanaPQ codes onlylarge collections (10M–100M+) on modest RAM — the memory-frugality wedge
colbertcoarse centroids + residual codestoken-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_search163264128256
recall@100.7930.8950.9580.9860.995
QPS (1T)153914241222955701
p95 (ms)0.80.81.01.31.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" with pq_subspaces.
  • Need scoping? Declare filterable fields 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.

IndexWhere it livesBest for
hnswRAMthe default; fast, high-recall in-memory search
ivfRAMclustered datasets; pairs with quantization
vamanaRAMthe DiskANN graph, in memory
disk_vamanadisk (encrypted) + PQ codes in RAMmemory frugality — large datasets, small RAM
colbertRAM (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_search163264128256
recall@100.7930.8950.9580.9860.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).
  • HNSWO(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 sizesingle-thread rebuildsteady read p99reader stall during rebuild
20 0007.3 s422 µs8.1 s
50 00026.7 s379 µs29.7 s
100 00068.7 s408 µs76.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:

  1. 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.
  2. 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).
  3. 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 &mut callers keep read-your-writes: the in-process search/hybrid_search/search_multi_vector wrappers still rebuild synchronously, so a single-threaded program always sees its own write.
  • Durability and the kill -9 crash 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__ — parallel indices (dimension ids) and values (weights). It rides the existing encrypted store, so there is no on-disk format change.
  • hybrid_search runs 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 contributes 1 / (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/hybrid with { "vector": [...], "sparse_indices": [...], "sparse_values": [...], "k": 10, "filter": {...}, "rrf_k0": 60 }.
  • gRPC: the HybridSearch RPC (HybridSearchRequest with a dense vector, a sparse SparseVector, filter, k, ef_search, rrf_k0).
  • MCP: the hybrid_search tool (vector, sparse_indices/sparse_values, query_text, k, filter, rrf_k0).
  • SDKs: hybrid_search (Python) and hybridSearch (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/connectingconnect); 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.tomlnot 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"
providerEndpointNotes
openaihttps://api.openai.com/v1/embeddingsBearer api_key_env.
ollamayour endpoint (e.g. http://localhost:11434/v1/embeddings)OpenAI-compatible; usually no key.
httpyour endpointAny OpenAI-compatible server (vLLM, LM Studio, llama.cpp, …).
coherehttps://api.cohere.com/v2/embed · /v2/rerankBearer api_key_env (required).
fakeDeterministic 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 ⊕ BM25 and the metadata pre-filter work the same — upsert_text co-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?

DCPEClient-side opaque
Server ranks?yes (approximate L2)no (client ranks)
Semantically secure?no (leaks distance ordering)yes (IND-CPA)
MetricL2 onlyany (client-side)
Best forserver-side ANN with a weaker, honest guaranteestrong 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

FlagMeaningDefault
--sourceqdrant, chroma, or pgvectorrequired
--inputexport file for an offline import (JSON Lines for qdrant/pgvector; one JSON object for chroma)one of --input / a live --*-url
--qdrant-urlbase URL of a running Qdrant for a live import (qdrant only)one of --input / a live --*-url
--chroma-urlbase URL of a running Chroma for a live import (chroma only)
--chroma-tenant / --chroma-databaseChroma tenant / database for --chroma-urldefault_tenant / default_database
--postgres-urlconnection URL of a running Postgres for a live import (pgvector only)
--tablesource table for --postgres-url--collection
--api-keyAPI key for a live import: Qdrant api-key / Chroma x-chroma-token (or QDRANT_API_KEY)
--collectiontarget collection (created if absent, appended to otherwise); also the source collection name for a live importrequired
--data-dirtarget data directory./data
--metricl2, cosine, or dot (for a newly created collection)cosine
--dimvector dimensionalityinferred from the export
--filterablepath:type (keyword|numeric), repeatablenone
--id-fieldid column name (pgvector)id
--vector-fieldvector column namevector (qdrant) / embedding (pgvector)
--vector-namewhich named vector to import (qdrant)the sole one
--encryption-key64-hex master key (or QUIVER_ENCRYPTION_KEY)
--insecureimport without encryption-at-rest (dev only)off

Notes

  • Ids are stringified (Qdrant/Chroma integer or UUID ids become strings).
  • Payloads: Qdrant payload is kept as-is; for pgvector every non-id, non-vector column becomes a payload field; for Chroma the metadatas object is the payload and each documents entry is stored under a document key.
  • 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 reads row_to_json over the table and negotiates TLS per the URL’s sslmode.

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 with sslmode=disable, travels in cleartext to anyone on the path. Prefer https:// (Qdrant/Chroma) and sslmode=require or stronger (Postgres). The importer prints a warning: 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 --table cannot break out of the SELECT.

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 Replicate gRPC stream that yields a logical snapshot of current state followed by the live commit tail.
  • A follower is a server started with QUIVER_LEADER_URL pointing at the leader’s gRPC address. It connects, applies the stream, serves reads, and refuses writes (a write returns HTTP 403 / gRPC PermissionDenied).

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 Replicate stream is admin-scoped; a follower authenticates with QUIVER_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:

  1. 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.
  2. 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:

MetricTypeLabels
quiver_http_requests_totalcountermethod, route
quiver_http_request_errors_totalcounter (status ≥ 400)method, route
quiver_http_request_duration_secondshistogrammethod, route
quiver_auth_failures_totalcounter
quiver_rate_limited_totalcounter

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.

FlagEnvDefaultDescription
--url <URL>QUIVER_TUI_URLhttp://127.0.0.1:6333REST base URL of the server to inspect.
--api-key <API_KEY>QUIVER_API_KEYAPI 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).

FlagEnvDefaultDescription
--data-dir <DATA_DIR>QUIVER_DATA_DIR./dataData directory for the embedded database.
--encryption-key <KEY>QUIVER_ENCRYPTION_KEY64-hex-character key for encryption-at-rest.
--insecureQUIVER_INSECUREfalseRun without encryption-at-rest (development only).
--config <CONFIG>QUIVER_CONFIGquiver.tomlConfig 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>
FlagEnvDefaultDescription
--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_tenantChroma tenant for --chroma-url.
--chroma-database <D>default_databaseChroma database for --chroma-url.
--postgres-url <URL>Live import: Postgres URL (postgresql://…) to pull pgvector rows.
--table <TABLE>--collectionSource table for --postgres-url.
--api-key <KEY>QDRANT_API_KEYAPI key for a live import: Qdrant api-key or Chroma x-chroma-token.
--metric <METRIC>cosineDistance 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>idId column name (pgvector).
--vector-field <FIELD>qdrant vector, pgvector embeddingVector column name.
--vector-name <NAME>Named vector to import (qdrant named vectors).
--data-dir <DATA_DIR>QUIVER_DATA_DIR./dataData directory for the embedded database.
--encryption-key <KEY>QUIVER_ENCRYPTION_KEY64-hex master key for encryption-at-rest.
--insecureQUIVER_INSECUREfalseImport into an unencrypted database (development only).

A live import that sends a credential over a plaintext http:// URL (or a Postgres URL with sslmode=disable) prints a warning: 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).

FlagDescription
--checkOnly 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 & pathOperation
POST /v1/collectionsCreateCollection
GET /v1/collections/{id}GetCollection
GET /v1/collectionsListCollections (cursor)
DELETE /v1/collections/{id}DeleteCollection (crypto-shred)
POST /v1/collections/{id}/pointsUpsert (batch; Idempotency-Key)
POST /v1/collections/{id}/points:bulkUpsert (bulk load; one fsync + one index rebuild)
POST /v1/collections/{id}/points:textUpsertText (server-side embedding, ADR-0047)
DELETE /v1/collections/{id}/pointsDeletePoints
POST /v1/collections/{id}/querySearch
POST /v1/collections/{id}/query/hybridHybridSearch (dense ⊕ sparse/BM25, RRF)
POST /v1/collections/{id}/query/textSearchText (embed query, ⊕ BM25, optional rerank)
POST /v1/collections/{id}/fetchFetch (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}/documentsUpsertMultiVector (late-interaction docs)
DELETE /v1/collections/{id}/documentsDeleteDocuments
POST /v1/collections/{id}/documents/querySearchMultiVector (MaxSim)
POST /v1/snapshotSnapshot — consistent online backup to a server-local dir (ADR-0050, admin)
GET /cluster/mapThe 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 /metricsops

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) / metadata authorization (gRPC), or mTLS client cert. Default-deny; scopes checked per resource (ADR-0011).
  • Idempotency: Idempotency-Key header / field on all mutations (see wire-protocol.md).
  • Limits: query cost caps — k, ef_search, fetch limit, vector dimension, payload size, upsert batch size, and HTTP request body size (ADR-0040) — rejected with HTTP 400 / gRPC InvalidArgument when exceeded. Configure with QUIVER_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 / gRPC ResourceExhausted with Retry-After; successful REST responses carry the RateLimit-Limit / RateLimit-Remaining / RateLimit-Reset headers. 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

ToolArgumentsPurpose
list_collectionsList collections
collection_infocollectionInspect one collection: dim, metric, index, filterable fields, multivector flag, vector-encryption mode, and live point count
create_collectionname, 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))
upsertcollection, id, vector, payload?Insert/replace a point
searchcollection, vector, k?, filter?k-NN with an optional payload filter
fetchcollection, filter?, limit?List points without ranking — the retrieval path for client_side-encrypted collections (ADR-0032)
getcollection, idFetch one point
deletecollection, idDelete one point
upsert_documentcollection, id, vectors (token set), payload?Insert/replace a multi-vector (ColBERT) document
search_multi_vectorcollection, query (token set), k?, filter?MaxSim late-interaction search with an optional payload filter
delete_documentcollection, idDelete a multi-vector document
delete_collectioncollectionDrop a whole collection and its points (reports whether it existed)
database_statsWhole-database overview: collection count, total points, per-collection summary, and snapshot status (manifest_version, disk_bytes)
snapshotdestinationTake a consistent online backup of the whole database into a server-local directory (ADR-0050)
upsert_textcollection, 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_textcollection, 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 advertise tools.
  • Tool execution failures are returned as a normal result with isError: true and 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 the upsert_text / search_text tools.

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 configuredupsert_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.

HelperPurposePythonTypeScript
PayloadCipherseal payload fields (ADR-0012)quiver.encryptionquiver-client/encryption
VectorCipheropaque vectors (IND-CPA)quiver.vectorquiver-client/vector
DcpeCipherDCPE encrypted search (experimental)quiver.dcpequiver-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-crypto crate provides an AeadCodec (XChaCha20-Poly1305 with per-page/per-record HKDF-SHA256 subkeys and a fresh random 192-bit nonce per seal, from the RustCrypto crates — no ring, no home-grown code). It is wired into the storage engine through the PageCodec seam 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: rustls over the audited ring provider (no OpenSSL, no aws-lc-rs C toolchain) terminates TLS for REST (via axum-server) and gRPC (via tonic’s tls-ring), and a non-loopback bind requires it.

Update (Phase 3). The envelope hierarchy below is now shipped. quiver-crypto’s EnvelopeKeyRing makes QUIVER_ENCRYPTION_KEY a 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 a 0600 file 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_id into 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 lsn lives 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) or QUIVER_MASTER_KEY_FILE (a 0600 file 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 in zeroize-ing types.
  • gitleaks runs pre-commit and in CI; .env.example documents every variable; key material in memory is wrapped in zeroize-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/v1 binds 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:

  1. normalise (optional, ordering-preserving): apply a fixed global affine transform m₁ = (m − μ)·α — a per-dimension shift vector μ (default 0) and a single positive scalar α (default 1);
  2. 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;
  3. draw a fresh random 96-bit IV;
  4. 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 by radius = (s/4)·β·U^{1/d}, U ~ Uniform[0,1));
  5. the ciphertext vector is c = s·π(m₁) + λ;
  6. an HMAC-SHA256 tag over (domain ‖ β ‖ iv ‖ c), with the domain bumped to quiver/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 l2 metric (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::dcpe is 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):

ModeServer seesServer ranks?LeakageBest for
none (default)plaintext vectorsyeseverythingtrusted server, max speed
dcpeciphertextyes (approx. L2)approximate distance ordering, by designuntrusted server, ANN at scale, leak acceptable
client_side (this page)ciphertext onlynosize, dimension, chosen cleartext fields, access patternssmall/medium or pre-filtered sets, zero geometry leakage
(FHE — out of scope)ciphertextyesnonetiny “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, or dot); the collection’s declared metric is advisory. (Multi-vector collections are not supported in this mode.)
  • A zero placeholder vector costs dim × 4 bytes 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-0010ADR-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

#AdversaryPrimary defense
A1Network attacker (MITM)TLS 1.3 (rustls); optional mTLS
A2Malicious / compromised clientAuthN (API key / mTLS), RBAC scopes, tenant isolation, query cost limits (ADR-0040)
A3Thief of disk / backups (data at rest)Envelope encryption-at-rest (AEAD); crypto-shredding
A4Curious / compromised server operator (payloads)Client-side payload encryption — server stores ciphertext it cannot read
A5Another tenantIsolation enforced at the data-access layer; default-deny RBAC
A6Curious / 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
A6Supply-chain attackercargo 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
  1. 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.
  2. Server ↔ Disk. The filesystem is semi-trusted: everything at rest is AEAD-encrypted, so a stolen disk or backup yields only ciphertext.
  3. Server ↔ KMS (optional). The master key may live in a KMS; plaintext DEKs exist only in server RAM and are zeroized after use.
  4. 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, fetch limit, vector dimension, payload size, upsert batch size, and HTTP request body size — ADR-0040), rejected with 400 / InvalidArgument so 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 current spawn_blocking model 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//readyz are open; a keyless coordinator refuses to start unless insecure).

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:

  1. 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.
  2. 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.
  3. Developer experience. A single static binary; embeddable and server modes; a ratatui cockpit; 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).

CrateResponsibilityNotable external deps
quiver-simdSIMD distance kernels (cosine/L2/dot/hamming), runtime CPU-feature dispatch, scalar fallbacknone (uses std/core::arch)
quiver-cryptoThin wrappers over audited crypto: envelope encryption, AEAD, KDF, key hierarchy, TLS configring/RustCrypto, rustls
quiver-coreStorage engine: segments, mmap + page/buffer manager, WAL, manifest, compaction, snapshots; the collection/payload modelmemmap2, crc32c
quiver-indexHNSW (in-mem), DiskANN/Vamana (disk), IVF; quantization (PQ/scalar/binary)
quiver-queryQuery planner; hybrid filtered search (vector + metadata predicate + optional BM25); top-k merge & re-rank
quiver-protoWire types: gRPC service (tonic/prost), REST DTOs, OpenAPI generationtonic, prost, serde
quiver-embedEmbeddable in-process database handle — the clean Rust API over core+index+query+crypto
quiver-providersEdge embedding/rerank adapters (OpenAI-compatible/Cohere/fake) shared by the network and MCP servers (ADR-0047/0058)ureq, figment
quiver-serverThe daemon: axum REST + tonic gRPC, auth, RBAC, audit, query cost limits (ADR-0040), config, observabilityaxum, tonic, tokio, tracing
quiver-tuiThe ratatui cockpit (API client; works local or remote)ratatui, crossterm
quiver-mcpMCP server exposing Quiver as agent toolsMCP SDK / rmcp
quiver-cliSingle binary entrypoint: serve, tui, mcp, admin, benchclap

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 libraryquiver_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.
  • Serverquiver serve exposes 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-embedquiver-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.md and ../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 &mut callers still rebuild synchronously for read-your-writes. Durability and the kill -9 crash gate are unchanged.
  • Observability: OpenTelemetry-compatible tracing spans 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).

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

IndexRAM residentDiskRecallLatencyBuildBest for
HNSW (Phase 1)graph + vectorsvery highlowestmediumsmall/hot collections in RAM
Vamana / DiskANN (Phase 2)PQ codes + node cachegraph + full vectorshighlow–med (SSD-bound)slowlarge collections, frugal RAM
IVF (+PQ / SPANN) (Phase 2)centroids (+ codes)posting listsmed–highmedfastpredictable 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 layer 2M), efConstruction, efSearch, level factor mL = 1/ln(M). Recall/latency tuned by efSearch at 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 2M u32 slots 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 width W (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 dim into m subspaces, 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)

RepresentationBytes / vectorvs full
Full precision (f32)3072
SQ int8768
PQ, m=19219216×
PQ, m=969632×
Binary9632×
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.

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

  1. Malkov, Yashunin. Efficient and robust ANN search using HNSW graphs. IEEE TPAMI, 2020.
  2. Subramanya et al. DiskANN: Fast, accurate billion-point NN search on a single node. NeurIPS, 2019.
  3. Chen et al. SPANN: Highly-efficient billion-scale ANN search. NeurIPS, 2021.
  4. Jégou, Douze, Schmid. Product quantization for nearest neighbor search. IEEE TPAMI, 2011.
  5. Xu et al. SpFresh: Incremental in-place update for billion-scale vector search. SOSP, 2023.
  6. Gollapudi et al. Filtered-DiskANN. WWW, 2023. · Patel et al. ACORN. SIGMOD, 2024.
  7. Singh et al. FreshDiskANN: A fast and accurate graph-based ANN index for streaming similarity search. 2021.
  8. 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

  • 0024 Migration importers · 0027 live connectors · 0030 replication · 0018 SDK strategy · 0015 CI policy