How it works
The conceptual model of polign_db: what lives in the bucket, how a write becomes durable and searchable, how reads are served hot or cold, and exactly what the consistency contract promises, and where its edges are.
The mental model
Most vector databases are servers that own your data: the index lives on their disks, and the cluster is the system of record. polign_db inverts that. The bucket is the database. Everything durable lives as objects in a bucket you own: the write log, the data, the search indexes. Serving nodes are stateless caches in front of it.
writes reads
│ │
┌───────▼───────────────────────────────▼───────┐
│ serving nodes │ stateless — kill, add,
│ RAM tier · optional NVMe cache │ or scale to zero freely
└───────┬───────────────────────────────▲───────┘
│ append │ ranged reads
┌───────▼───────────────────────────────┴───────┐
│ your bucket │ the system of record
│ write log · segments · manifests │ S3 · GCS · Azure · MinIO · R2
└───────────────────────────────────────────────┘
Two consequences fall out of this shape:
- Nodes are disposable. A node holds nothing that isn't already in the bucket, so killing one loses nothing, and a fresh one can answer queries without any restore step, because it reads what it needs per query.
- There is no other infrastructure. No Kafka for the log, no etcd for coordination, no separate metadata service. The whole system's only dependency is the bucket.
There are two ways to run it. The default is a single in-memory binary with no bucket
at all, and state persists to a local snapshot file. That's how you start on a laptop. Adding
-store switches to the storage-backed deployment described on this page, where
the bucket becomes the source of truth. Everything below describes the storage-backed
mode; the guarantees are its guarantees.
The write path
A write (an upsert or delete) goes through three stages, each with a durable artifact in the bucket:
put ──▶ 1. write log (in the bucket) ──▶ 2. immutable segments ──▶ 3. compaction
durable before acked searchable in place background merge
- Append to the write log. The log itself lives in the bucket. Batches of writes are stored as objects, sequenced by conditional PUTs, so no broker is involved. The client sees success only after the append is durable; if the append fails, the write is rejected rather than half-applied.
- Persist into segments. A background process drains the log into segments: immutable, self-contained blobs that hold vectors, a search graph, ids, and metadata, with an optional BM25 text index alongside. Each published batch of segments is recorded in a manifest; making it live is a single swap of a tiny pointer object. That pointer is the only object in the bucket that is ever overwritten. Everything else is written once, so a reader always sees a consistent snapshot and a crash mid-publish can't corrupt anything.
- Compact in the background. Compaction merges small segments and physically removes deleted entries; garbage collection reclaims blobs no longer referenced by the current manifest, with an age gate so a reader pinned to a recent snapshot never has an object deleted out from under it.
Updates and deletes are handled with tombstones: a newer entry hides every older copy of the same id, and compaction is what eventually removes the bytes. Nothing in the hot path rewrites data in place.
The read path
The defining property of the read path is that object storage is directly queryable. Segments are laid out so a query can search them where they are. A node fetches just the few blobs relevant to one query, in parallel, and runs the search over those bytes. No index build, no restore, no warm-up requirement.
Reads are served from one of three tiers, and heat, not configuration, decides which:
| Tier | What's resident | When it's used |
|---|---|---|
| Cold (the floor) | KBs per collection: manifest, routing table, tombstones | Always available, because any node can serve any collection it has never seen |
| Disk cache (optional) | Recently fetched blobs on local NVMe | Repeat reads pay a local read instead of an object-store fetch |
| Hot (earned) | The collection's index in RAM | Sustained query rate promotes a collection automatically; cooling demotes it |
This is what makes many small collections cheap: a node keeps thousands of collections servable while holding almost nothing for each, and only the ones actually being queried earn RAM. A collection nobody queries costs only its object storage.
The trade is latency, and it's worth stating plainly: a cold search against same-region S3 measures ~144 ms typical (~354 ms p99), versus ~18 ms against a same-machine store, and a hot in-RAM collection is faster still. The full numbers, including where RAM-resident engines beat this design, are on the benchmarks page.
The consistency contract
These are the promises the storage-backed deployment makes. They hold across node crashes, node replacement, and cold reads:
- A write is durable before it is acknowledged. Success means the write is in the bucket's log, not in a node's memory awaiting a flush. A node dying the instant after acking loses nothing.
- Every collection has one write order. All of a collection's writes flow through one log partition keyed by the collection's name, so every consumer, whether the persistor, a replaying node, or a rebuild, observes the same order the writes were acknowledged in.
- Reads see acknowledged writes, even cold. Segments alone would lag the log by one flush interval. To close that gap, each serving node follows the log's tail into a small in-memory overlay and merges it over segment results. The rule that makes this correct: the overlay is authoritative for any id it knows. A write acked on one agent step is readable on the next, whether the collection is hot or cold.
Keyword (BM25) search reads only segments. The freshness overlay covers vector reads, not text. So a document's text becomes keyword-searchable at the next segment flush, seconds later, while vector search over the same write is immediate. This is the contract's one freshness exception, and it is deliberate rather than accidental.
Two further caveats apply only to a cold-first node serving a resource it keeps no in-memory index for (memory stays log-only by design): listing such a resource fails with "not supported" (HTTP 501) rather than returning results, since its records live in segments — search or get by id instead — and a delete of an already-persisted id there is acknowledged optimistically, without a local existence check. Search and point reads honor the full contract everywhere.
Search three ways
One query endpoint serves three shapes, chosen by what the request contains:
- Vector — approximate nearest-neighbour over embeddings, with filters over typed metadata (strings, numbers, booleans, compared by type) applied inside the index traversal (not by discarding results afterward, which quietly ruins recall on selective filters).
- Keyword — BM25 over a designated text field, served from a lexical index that lives beside the vector segments in the bucket. A text query never fetches vector data.
- Hybrid — both legs running concurrently, fused server-side. The default fusion is rank-based (RRF), so vector distances and BM25 scores, which share no scale, never need to be made comparable; a weighted linear blend is available when you want an explicit dial.
Three index types, one dial
Each collection picks an index type, and all three sit behind the same API, so the choice is purely a speed-versus-memory trade-off:
| Index | Character | Pick it when |
|---|---|---|
| Graph (HNSW) | Fastest queries, most RAM | The corpus fits comfortably in memory and latency is everything |
| Partitioned (default) | Balanced; partitions map cleanly to bucket objects | Almost always. It works from the first vector to large scale |
| Compressed (IVF-PQ) | ~32× smaller in RAM; accuracy restored by re-checking a small candidate pool against exact vectors. Cold queries scan the compact codes first, cutting the bytes fetched from the bucket to a fraction of the exact path | Memory is the binding constraint, or cold-serving cost is |
Index maintenance never blocks serving. When a compressed index needs re-partitioning as the corpus grows, the rebuild runs out of band. A separate process reads the bucket, builds the new index, and publishes it as an immutable generation. A live node adopts it with one atomic swap: in-flight queries finish on the old index, new ones use the new. The same publish-and-adopt mechanism is how a replacement node boots quickly (load the latest generation, replay only the tail) instead of replaying history.
Failure and scaling model
- A node dies. Nothing acknowledged is lost. It was in the bucket before the ack. Its collections are immediately servable by any other node, cold.
- You add or remove nodes. Ownership of collections is computed by rendezvous hashing from the member list alone, with no coordinator and no rebalancing service. A membership change moves only about 1/N of collections. A node asked about a collection it doesn't own answers with a redirect naming the owner, so requests converge without central routing state.
- One collection gets hot. Its owner widens the replica set by publishing a record to the bucket that every node picks up, again with no coordinator, and the extra replicas retire when the heat passes.
- Traffic stops. Tear all the nodes down. The bucket holds everything; the next node to start serves from it. This is what "scale to zero" means here. It's not a managed feature, just the absence of state on nodes.
The fine print
Design choices with real limits, stated up front:
- Cold reads cost object-store latency. Serving from the bucket means paying its round trips (~144 ms typical against same-region S3); the heat mechanism exists precisely so you only pay that on rarely-touched data.
- Writes serialize per collection. One ordered log partition per collection is what buys the ordering guarantee; a single collection's write throughput is bounded by it (measured ~6,200/s per log). Collections are independent, so throughput scales across them.
- Keyword freshness is flush-bound, as described in the contract above.
- Write backpressure is explicit. The freshness overlay buffers
unpersisted writes; a writer's RAM is bounded by how far the persistor lags. The
server warns at a configurable buffer depth (
-overlay-warn-buffered) and, at the hard cap (-overlay-max-buffered), refuses writes with HTTP 503 andRetry-After(gRPCRESOURCE_EXHAUSTED) until the persistor catches up — backpressure instead of unbounded memory. - Fleet membership is static configuration. Every node is configured with the same peer list; dynamic membership is a planned follow-up.
- Single-tenant by design. Isolation between customers is a deployment concern, not an in-process one. You run one instance per customer, each with its own bucket. API keys guard the management surface; there is no per-request tenant model.
Go deeper
- Benchmarks — the measured numbers behind every claim above, with the harness to rerun them.
- Comparison — where this design wins and loses against other vector databases.
- Get started — run it, from a laptop binary to your first searches; Operate in production takes it from there.