polign_db architecture
A deep tour of the polign_db vector database internals — the layered design, the three ANN index backends, the store, the transport-agnostic service, BM25 text & hybrid search, and the cold-first serverless serving tier.
This page documents the internals of the polign_db repository; the package and symbol names below refer to its source tree.
polign_db is a small vector database written in Go. Its core is
dependency-free (the index, the db, and the metrics use only the standard
library); the network layer adds gRPC, and an optional serverless storage
path (off by default) adds a durable write-ahead log — living in the object
store itself — plus object-storage
persistence. That path also includes a segment index (internal/segindex)
that makes object storage directly queryable — a node can serve "cold" queries
straight from S3/GCS/Azure segments without holding the whole index in memory. That
segment index also indexes document text, so the same node answers BM25
keyword and hybrid (vector + text, fused server-side) queries, not just
vector search.
A collection chooses one of three pluggable ANN backends: plain HNSW, the
default hybrid IVF-over-HNSW, or a compressed IVF-PQ index
(internal/ivfpq) that stores product-quantized codes (~32× smaller than
float32) for corpora where memory is the binding constraint. IVF-PQ also brings
an object-store generation scheme: a re-partitioned index is rebuilt out of
band and published as an immutable generation, then adopted into a live node
with no downtime — at boot and, optionally, on a running node via a periodic
ticker. The system is organized as a stack of layers, each a separate package
with a narrow responsibility.
Beyond the single-node engine, the serverless path has grown into a
cold-first serving tier: many collections share one fleet, object
storage is the system of record, and each node is a disposable cache that warms
resources on demand. Each collection's resource key (its name) isolates its
data across the db, object store, and write log; rendezvous
placement (internal/placement) assigns each resource to node(s); a bounded
LRU searcher cache holds only what fits in RAM; and a write-log tail
overlay gives cold reads read-your-writes freshness. These pieces are
off by default — the single-node server behaves
exactly as before — and this document shows how they
sit in the layer stack.
┌───────────────────────────┐ ┌───────────────────────────┐
L5 │ transport/httpserver │ │ transport/grpcserver │ TRANSPORTS
│ net/http JSON REST │ │ gRPC (internal/pb) │
└─────────────┬─────────────┘ └─────────────┬─────────────┘
│ both delegate to │
└───────────────┬────────────────┘
┌───────────────▼─────────────────────────────┐
L4 "the API" │ internal/service │ TRANSPORT-AGNOSTIC
│ PutVector · GetVector · ListVectors │ CORE
│ DeleteVector · SearchVectors │
│ name defaulting · auto-create · sentinels │
└───────────────────────┬─────────────────────┘
│ uses
┌───────────────────────▼─────────────────────┐
L3 "the DB" │ internal/db │ USER-FACING STORE
│ DB ─┬─ Collection │
│ │ • string id ⇄ node id mapping │
│ │ • metadata store │
│ │ • Upsert/Get/Delete/Query/List │
│ │ • vectorIndex (pluggable backend) │
│ └─ Save / Load (gzip + gob, atomic) │
└───────────────┬──────────────┬─────────────┘
selects one │ index backend │ scores with
┌──────────────────────┬──────┴───────┐ │
┌────▼────────┐ ┌──────────▼─────┐ ┌───────▼──────────┐ ┌────────────────────┐
L2 │internal/hnsw│ │ internal/ivf │ │ internal/ivfpq │ │ internal/distance │ L1
│ one HNSW │ │ k-means cells, │ │ IVF cells over │──▶│ l2 · cosine · dot │
│ graph │ │ each a │ │ PQ codes (M B/ │ │ Func:(a,b)->f32 │
│ Index·heaps │ │ hnsw.Index │ │ vec)·ADC+rescore │ └────────────────────┘
│ Snapshot │ │ Snapshot/cell │ │ gob Snapshot │
└─────────────┘ └────────────────┘ └──────────────────┘
all three backends score via internal/distance (L1)
internal/ivfpq builds on the internal/pq quantizer (codebooks · ADC)
entry points: main.go (library demo) cmd/server (runs L5: gRPC + HTTP together)
cmd/polign (command-line client, HTTP)
cmd/persistor (raw event log OR segment index → object storage)
cmd/maintain (index lifecycle from cron: republish + GC)
cmd/ivfpq-compact (out-of-band IVF-PQ re-partition → generations)
cmd/apikey (mint/manage API keys)
cmd/loadtest (load-test harness)
The stack above is the in-memory read path (the default). A parallel
cold path lets the service answer a query straight from object-storage
segments — service.Search(Cold) → db.QueryCold → segindex.Searcher — reusing
the L2 beam search over bytes instead of the in-memory slab. The same searcher
also serves BM25 text and hybrid search, and a cold-first node makes
the cold path the default for every read. See
Segment index and
Text & hybrid search.
One cross-cutting concern wraps the service (Layer 4) rather than living in the
engine: placement — a node
serves only the resources a rendezvous-hashed Router assigns it, redirecting
the rest. It is off in the single-node default. See
Layer 4.
Dependencies point downward only:
transport → service → db → {hnsw | ivf | ivfpq} → distance. Nothing lower knows
about anything above it — the index has no idea what a "record" or "metadata" is,
the db has no idea what HTTP or gRPC is, and the two transports never touch the db
directly. ivf is built on top of hnsw (each cell is a full HNSW graph);
ivfpq builds on the internal/pq quantizer (per-cell posting lists of PQ
codes); the segment index (segindex) also builds on hnsw and objectstore.
Package map
| Package | Layer | Responsibility |
|---|---|---|
internal/distance |
L1 | distance metrics (l2, cosine, dot) |
internal/hnsw |
L2 | HNSW graph index + snapshot persistence; NodeSource/SearchGraph (the beam search shared with on-store segments) |
internal/ivf |
L2 | IVF index: k-means cells, each backed by an HNSW graph |
internal/pq |
L2 | product-quantization primitive: per-subspace codebooks, encode/decode, ADC lookup tables, flat index |
internal/ivfpq |
L2 | IVF-PQ backend: coarse IVF cells over packed PQ codes; ADC scan + exact rescore (or CodesOnly); residual (L2), cold rescore (FlushCold/SearchCold), and the hot-replica lifecycle (generation vector blocks, RebuildFromBlocks, Replica) |
internal/db |
L3 | collections, records, metadata, pluggable index, DB save/load; metadata-filtered QueryFiltered; optional AttachSearcher/QueryCold cold read path; published generations (PublishGeneration/LoadGeneration/CurrentGeneration/GCGenerations) + atomic SwapCollection |
internal/service |
L4 | transport-agnostic API shared by all transports; resolves each request's collection name to its resource key; in-memory default + opt-in/ColdFirst cold search (SearchOptions.Cold/.Warm) + metadata filter (SearchOptions.Filter/.FilterExpr); BM25 text & hybrid search (SearchOptions.Text/.Fusion, fusion.go); bounded LRU searcher cache (searchercache.go); tail overlay for read-your-writes (tailoverlay.go, RunTailFollower); heat-promoted hot tier (hot.go, MaxHotResources); local-disk cache tier (Config.DiskCache); load-aware splitting (split.go, RefreshPlacement); placement gate (NotOwnerError); IVF-PQ cold-vector flush (FlushColdVectors/AttachColdVectors) |
internal/collection |
S | collection-name validation (stdlib only): the collection name is the sole addressable unit and the resource key everywhere below |
internal/placement |
S | rendezvous (highest-random-weight) placement: Ring/Node membership, Router.Owns/Owner/Owners/PickOwner over an atomic ring, per-resource replica overrides (UpdateOverrides, load-aware splitting), draining, minimal reshuffle on membership change |
internal/transport/httpserver |
L5 | net/http JSON REST endpoint (text/fusion/cold/filter in the query body; owner redirect via 421) |
internal/transport/grpcserver |
L5 | gRPC endpoint (text/fusion fields; owner redirect via FailedPrecondition) |
internal/pb |
— | generated protobuf/gRPC code (from proto/vectordb.proto) |
internal/writelog |
S | write-ahead log: WriteEvent (carries Org/Project/Namespace), codec, WriteLog/Reader interfaces, in-memory fakes |
internal/writelog/objectlog |
S | object-store-backed WriteLog + Reader (broker-free default): batch objects under .wal/, sequenced by conditional PUTs, per-group cursors |
internal/objectstore |
S | pluggable object storage: FSStore, MultiStore, S3Store/GCSStore/AzureStore (build-tagged), spec parser; Put/PutIfAbsent/Get/GetRange/List/Delete, Prefixed namespacing, GetMany parallel reads |
internal/segindex |
S | object-store-resident segment index: binary vector segment (Write/SegmentReader) and lexical/BM25 segment (writeLex/LexReader, analyze.go, textsearch.go), Manifest (MVCC root, lists cells + Lex lsegs), Builder (WAL→segments), Searcher (cold vector and SearchText), Compact/GC |
internal/persistor |
S | batch consumers: Persistor (raw event-log segments), SegmentPersistor (one resource), and MultiSegmentPersistor (multiplexes every resource on a shared log, per-partition commitTracker); Restore (startup replay); IVF-PQ re-partition + generations: RebuildIVFPQ, AdoptGeneration, BootFromGenerations, GenerationAdopter (periodic in-process adoption) |
internal/loadtest |
— | reusable load-test engine (clients, runner, stats) |
cmd/server |
— | binary that runs both transports over one DB (optionally a write-log producer, object-store restore, cold/cold-first search, tail-follower freshness, placement, and periodic generation adoption) |
cmd/persistor |
— | binary that drains the write log into object storage; -mode raw|segment|multi-segment, -text-field selects the BM25 field |
cmd/ivfpq-compact |
— | out-of-band IVF-PQ re-partition: rebuilds a collection from object storage and publishes a new generation |
cmd/loadtest |
— | CLI wrapper around internal/loadtest |
main.go |
— | standalone library-usage demo |
(Layer S = the optional serverless storage path; see Serverless storage path.)
Layer 1 — internal/distance (the math)
The smallest layer. It defines a Func type and three metrics, all normalized
to a smaller-is-closer convention so the index can treat them uniformly:
| Metric | Returned value | Notes |
|---|---|---|
l2 |
squared Euclidean | no sqrt — same ordering, cheaper |
cosine |
1 - cosine_sim |
magnitude-independent |
dot |
-(a·b) |
negated so "larger dot" → "smaller dist" |
Get(metric) returns the function; the index stores it once and calls it in its
hot loops.
The kernels are SIMD: hand-written NEON on arm64 and AVX2+FMA on amd64
(detected at runtime, falling back to portable unrolled Go on older CPUs and
other architectures). Cosine runs as one fused pass accumulating a·b, |a|²
and |b|² together. Measured on an 8-vCPU EC2 instance (AVX2+FMA)
this is ~5–8× per kernel over the scalar Go and ~1.7–2.5× on end-to-end HNSW
search; every scoring path — warm
graph traversal, cold segment scans, LUT construction, exact rescore, k-means —
goes through these functions, so all inherit the win. SIMD accumulates partial
sums in a different order than scalar code, so distances match up to float32
rounding, not bitwise.
Layer 2 — the index
Three interchangeable ANN backends live at this layer. A collection picks one via
CollectionConfig.IndexType (see L3); the default is the hybrid IVF-over-HNSW
index, with plain HNSW and compressed IVF-PQ as opt-ins. All expose the same small
surface (Add/Search/Delete/Vector/Snapshot), which is what makes them
swappable.
internal/hnsw — a single HNSW graph
The heart of the database: an HNSW (Hierarchical Navigable Small World) graph for approximate nearest-neighbour search.
A stack of proximity-graph layers. Every vector lives on layer 0; each higher layer keeps an exponentially smaller random subset. Search enters at the top and funnels down to the dense base layer.
entry point
│
layer 2 ( sparse ) (E)───────────────(n)
│ │
layer 1 ( denser ) (E)──(a)──────(b)──(n)──(c)
│ │ │ │ │
layer 0 ( ALL nodes ) (E)─(a)─(d)─(b)─(f)─(n)─(c)─(g)─ … ← k-NN happens here
Each node holds:
node {
vector []float32 // the embedding (copied on insert)
deleted bool // tombstone flag
links [][]uint32 // links[layer] = neighbour ids at that layer
}
The Index holds the node slab, the entry point, the current max level, the
config (M, EfConstruction, EfSearch, metric), a seeded RNG for level
assignment, and a single sync.RWMutex.
Graph traversal uses two heaps of {id, dist} candidates (heap.go):
minHeap— the frontier: nodes still to explore, closest first.maxHeap— the result set: best-found so far, worst on top so it can be evicted in O(log n) once the beam is full.
Insert (Add):
1. assign a random level L ~ floor(-ln(U) · 1/ln(M))
2. greedily descend from the top layer down to L+1 (beam width = 1)
3. for each layer from min(L, maxLevel) → 0:
• beam-search with EfConstruction to gather candidates
• selectNeighbors() keeps up to M (paper's heuristic: prefer a candidate
closer to the new node than to any already-picked neighbour)
• add bidirectional links; prune neighbours that exceed their budget
4. if L > maxLevel, the new node becomes the entry point
Search:
1. greedily descend from the top layer to layer 1 (beam width = 1)
2. beam-search layer 0 with ef = max(EfSearch, k)
3. sort by distance, drop tombstones, return the top k
Tuning knobs: M (links/node, default 16), EfConstruction (build quality, default 200), EfSearch (query recall vs. speed, default 50).
Search over any backing store (NodeSource/SearchGraph). The beam search
is factored to run over a small NodeSource interface (Vector(id),
Links(id, layer), Deleted(id)) rather than the in-memory node slab directly.
The in-memory Index implements it via a zero-allocation adapter, and
SearchGraph is the one shared implementation of the descent-plus-beam-search.
That lets an on-store segment (see Segment index)
reuse the exact same search code over bytes mapped out of object storage — no
second graph traversal to keep in sync.
internal/ivf — IVF partitions over HNSW
The default backend. IVF (Inverted File) coarse-quantizes vectors into nlist
cells with k-means; each cell is its own hnsw.Index. A query is routed to
its nprobe nearest centroids and only those cells are searched, then the
per-cell results are merged. A fresh collection serves queries by exact brute
force until it has enough vectors to train centroids, then switches to the
partitioned index transparently — so the default works well from the first
insert through to large scale.
query ─▶ k-means quantize ─▶ pick nprobe nearest centroids
│
┌─────────────┬──────────┴──┬─────────────┐
cell 3 cell 17 cell 42 (each = its own hnsw.Index,
hnsw.Search hnsw.Search hnsw.Search its own Snapshot / blob)
└─────────────┴──────┬──────┴─────────────┘
merge → top-k
Why this shape:
- Smaller graphs. N vectors across
nlistcells → each HNSW graph holds ~N/nlist nodes, so builds are faster and per-graph memory is bounded. - Independent persistence. Each cell snapshots on its own, so a cell maps cleanly to one object in cloud/blob storage — load on demand, evict when cold.
- Two recall knobs:
nprobe(cells probed) × per-cellef.
Subtleties unique to IVF:
- Training needs data. Centroids can't be placed on an empty index, so
inserts before training are buffered and brute-force searchable. Crossing
TrainThreshold(defaultmax(nlist*40, 1000)) learns the centroids and flushes the buffer into cells;Train()can also be called explicitly. - Global ids. Cells have local
uint32ids, so IVF assigns a stable globaluint64id and keeps alocmap (id → cell+local) plus a per-cell local→global table to translate search hits back.
internal/pq + internal/ivfpq — compressed IVF-PQ
The third backend, db.IndexIVFPQ, trades exact distances for a large memory
reduction — the one to pick when a corpus is too big to hold as full float32s.
It is built on internal/pq, a standalone product-quantization primitive.
Product quantization (internal/pq). A Dim-vector is cut into M
contiguous subvectors; per subspace, k-means learns 256 centroids. A vector
encodes to M bytes (one centroid id per subspace) — a 128-dim vector (512 B)
becomes 16 B at M=16 (~32× smaller). Scoring uses ADC (Asymmetric
Distance Computation): the query stays uncompressed, a per-query lookup table
holds each query subvector's distance to all 256 centroids, and a candidate's
distance is the sum of M table lookups — no per-dimension math in the inner
loop. For squared L2 this is exact per codebook (L2 decomposes across subspaces).
IVF-PQ (internal/ivfpq). Coarse IVF partitioning over the codes: a query
probes its nprobe nearest cells and ADC-scans only those cells' packed posting
lists, then rescores exactly on a small candidate pool by default
(CodesOnly opts into ADC-only ranking for maximum compression). It implements
the same vectorIndex surface as the other backends, with the same
Upsert/Query/Delete semantics, metadata filtering (the predicate gates the scan,
with none of HNSW's connectivity caveat), and gob persistence. Three refinements
matter architecturally:
nlist/nprobeauto-scale. Left unset, the index picksnlist ≈ √N,nprobe ≈ √nlistat training time and re-partitions as the corpus grows (at 4× growth), keeping per-query cost sub-linear.ExpectedSizesizesnlistup front forCodesOnly/cold collections, which can't re-partition (it needs full vectors in RAM).- Residual encoding (L2). Quantizes
vec − cellCentroidrather than the raw vector, lifting raw-ADC recall on clustered data; the lookup table becomes per-cell. L2 only — cosine/dot keep raw encoding. - Cold rescore.
FlushColdwrites full vectors to an object store and frees them from RAM;SearchColdADC-scans in memory and fetches only the candidate pool (ranged reads, ~KBs per query) from cold storage to rescore exactly — compressed in RAM and full-rescore recall. Writes after the flush stay byte-exact in a bounded RAM tail, so exact reads and rescore hold for them too. - Hot replica lifecycle. The cold-rescore pieces compose into a serving
mode:
RebuildFromBlocksstreams a published generation's vector blocks twice (corpus-wide sample → train, then routed encode; peak RAM is the compressed output) and publishes a CodesOnly structure generation with provenance;OpenReplicaboots it — a structure download plus a read-only block attach, never a re-encode — andRefreshadopts a newer generation with one atomic pointer swap.NeedsRebuildtriggers the next rebuild on a newer collection generation, live growth, or tombstone accumulation. The per-queryrescoreknob (down through every search entry point) selects the tier: default exact-rescore, a custom pool size, or ADC-only (< 0) with zero object reads.
Measured recall and compression numbers for this tier are on the benchmarks page.
Layer 3 — internal/db (the user-facing store)
The index only understands numeric node ids and raw vectors. This layer adds
everything a user actually wants: string ids, metadata, named collections,
pagination (List), persistence, and the choice of index backend.
Pluggable index
Collection holds a vectorIndex — a small interface (index.go) that all
three backends satisfy through thin adapters, so the collection code is identical
regardless of backend:
type vectorIndex interface {
Add(vec []float32) (uint64, error)
Search(q []float32, k, ef int) ([]searchResult, error)
SearchFiltered(q []float32, k, ef int, accept func(uint64) bool) ([]searchResult, error)
Delete(id uint64) bool
Vector(id uint64) []float32
}
hnswAdapter ─▶ *hnsw.Index (widens uint32 ⇄ uint64)
ivfAdapter ─▶ *ivf.Index (passes nprobe = configured default)
ivfpqAdapter ─▶ *ivfpq.Index (ADC scan + rescore; routes to cold rescore once flushed)
SearchFiltered restricts hits to node ids for which accept is true (a nil
predicate is equivalent to Search); the collection builds it by closing over
its metadata map, so filtering happens in-graph rather than post-hoc.
CollectionConfig.IndexType selects the backend: the default is "ivf" (the
hybrid IVF-over-HNSW index), "hnsw" for a plain single graph, or "ivfpq" for
the compressed backend. When the default IVF backend is used without
NList/NProbe, the db layer fills in recall-friendly defaults (NList=64,
NProbe=16); IVF-PQ instead auto-scales nlist/nprobe (or takes
ExpectedSize/PQM/CodesOnly). Those fields are ignored for HNSW. Ids are
uint64 throughout the collection so the same code path works for every backend.
Data model
A Collection owns one vectorIndex plus three maps that translate between the
user's world and the index's world:
user space index space
────────── ───────────
"doc-42" ──idToNode──▶ node 17 ──────────────▶ vector + graph links
◀─nodeToID── (in the vectorIndex)
meta[17] = {"title": "…"}
idToNode— external string id → live node id (lookup, dedup on upsert)nodeToID— node id → external string id (translate search hits back)meta— node id → metadata
Why Upsert tombstones
Index vectors are immutable in place (the graph is wired around them). So
replacing an id can't mutate a node — instead Upsert:
Upsert("doc-42", newVec):
if "doc-42" already exists:
index.Delete(oldNode) // tombstone, stays for connectivity
drop oldNode from nodeToID/meta
newNode = index.Add(newVec)
idToNode["doc-42"] = newNode
nodeToID[newNode] = "doc-42"
Delete works the same way: the node stays in the graph so it remains
navigable, but it's filtered out of results and removed from the id maps.
List(limit, offset) returns live records ordered by id (so pagination is
stable across calls) along with the full live total, independent of the page
window.
Query flow (within the store)
# Mermaid sequence diagram (source)
sequenceDiagram
participant U as Caller
participant C as Collection (db)
participant I as vectorIndex (hnsw | ivf)
participant D as distance.Func
U->>C: Query(vector, k, ef)
C->>I: Search(vector, k, ef)
Note over I: hnsw: descend+beam · ivf: probe cells, merge
loop scoring
I->>D: dist(query, candidate)
D-->>I: float32
end
I-->>C: []{nodeID, dist} (sorted, tombstones removed)
C->>C: nodeToID[id] + meta[id]
C-->>U: []QueryResult{ID, Distance, Metadata, Vector}
Cold read path (AttachSearcher / QueryCold)
A Collection can optionally carry a *segindex.Searcher. QueryCold answers a
query by reading the collection's segments from object storage (ids and metadata
come straight out of the segment), with no in-memory graph consulted — so a
disposable node can serve reads it never built. The default Query is unchanged
and stays fully in-memory; cold is opt-in per query (wired through the service's
SearchOptions.Cold). See Segment index.
Persistence (persist.go)
The whole database serializes to a single gzip-compressed gob file, written
atomically (temp file in the same dir, then os.Rename) so a crash mid-write
never corrupts an existing snapshot. Each collection records its IndexType and
Metric and carries exactly one of an HNSW, IVF, or IVF-PQ snapshot:
DB
└─ dbSnapshot { Version, []collectionSnapshot }
└─ collectionSnapshot {
Name
Metric // persisted per collection (v3)
IndexType = "hnsw" | "ivf" | "ivfpq"
HNSW *hnsw.Snapshot // exactly one of
IVF *ivf.Snapshot // these three
IVFPQ *ivfpq.Snapshot // is non-nil
NodeToID // rebuild idToNode on load
Meta
}
Save: collect snapshots ─▶ gob ─▶ gzip ─▶ temp file ─▶ rename
Load: open ─▶ gunzip ─▶ gob decode ─▶ {hnsw|ivf|ivfpq}.FromSnapshot ─▶ rebuild maps
The format is version 3 (v2 added IVF + uint64 ids; v3 persists each
collection's metric and carries the optional IVF-PQ index); loading an older file
is rejected with a clear error. Because the full graph (vectors and adjacency,
or PQ codes) is stored, loading is instant — no re-indexing on startup. A reloaded
CodesOnly/cold-flushed IVF-PQ collection serves ADC-only ranking until
Service.AttachColdVectors re-binds its cold store.
Metadata-filtered search (QueryFiltered)
Collection.QueryFiltered(vector, k, ef, filter) restricts a k-NN query to
records whose metadata matches a predicate — the legacy equality/AND map, or a
typed expression tree (QueryOptions.Expr: eq/ne/in/ranges/exists composed
with and/or/not). An inverted metadata index compiles the predicate to its
exact match set, and a selectivity-aware planner picks the strategy: zero
matches return immediately, small match sets are exactly scored (no traversal),
larger ones push an O(1) membership gate into the index (SearchFiltered,
per-graph for HNSW/IVF, gating the ADC scan for IVF-PQ) with an exact fallback
bounded by the match count. Exposed through the service as
SearchOptions.Filter / .FilterExpr.
Published generations (generation.go)
Beyond per-node snapshots, a collection can be published to object storage as an
immutable generation — the durable unit an out-of-band re-partition produces
and a live node adopts. PublishGeneration writes the collection blob plus a
manifest and swings a one-line CURRENT pointer (MVCC, mirroring the segment
index); LoadGeneration reads it back; CurrentGeneration is the cheap
"has it advanced?" probe; GCGenerations reclaims old ones. DB.SwapCollection
installs an adopted collection with a single pointer swap — in-flight queries
finish on the old collection undisturbed.
When the collection holds exact vectors in RAM (the classic path), the
generation also carries them as streamable vector blocks
(generation/<gen>/vec/: the cold block layout plus a per-block occupancy
bitmap, gathered under the same read lock as the structure snapshot and
recorded in the manifest; GC sweeps the whole prefix). The blocks are what a
hot-replica trainer streams without decoding the gob, and what a booted
replica attaches for byte-exact reads and rescore. LoadHotReplica is the
matching boot: it loads the current published compressed (CodesOnly) index and
pairs it with the id/metadata bookkeeping of the collection generation it was
built from, serving the result as an
ordinary Collection. The full re-partition/adoption flow lives at
Layer S.
Layer 4 — internal/service (the transport-agnostic API)
This layer is the single source of truth for what the database does, so the
gRPC and HTTP transports can never drift apart — they are thin adapters over the
same *service.Service. It wraps a *db.DB and exposes the core operations
(the data-plane gRPC RPCs, plus a Search variant carrying filter/cold/text options —
the collection-admin RPCs are handled by the collections registry when -byo-store
is on):
| Operation | Behaviour |
|---|---|
PutVector(collection, Vector) |
upsert; auto-creates the collection (inferring its dimension from the first vector) if missing |
PutVectors(collection, []Vector) |
batch upsert (≤ 5000 vectors); the whole batch is validated up front (id, values, uniform dimension) so an invalid batch applies nothing, then each vector is applied — and logged — exactly like a single put |
GetVector(collection, id) |
fetch one vector, or ErrNotFound |
ListVectors(collection, limit, offset) |
paginated list + total; a missing collection is empty, not an error |
DeleteVector(collection, id) |
returns whether a vector was removed; missing collection/id ⇒ (false, nil) |
SearchVectors(collection, query, k, ef) |
k-NN search, closest first (in-memory) |
Search(collection, query, k, ef, SearchOptions) |
one entry point for vector, text, and hybrid search — see SearchOptions below |
A SearchHit carries both a Distance (vector metric, smaller-is-closer,
meaningful on the vector/hybrid paths) and a Score (relevance, larger-is-better:
raw BM25 on a text search, the fused score on a hybrid search, 0 on a pure
vector search). SearchOptions selects the mode and tuning:
Filter— legacy metadata equality/AND map;FilterExpr— the typed filter expression tree (eq/ne/in/ranges/exists + and/or/not). Either is pushed in-graph (works warm and cold); setting both is an error.Cold— serve from object-storage segments;Warm— force the in-memory path even under cold-first (see search modes).Text— a BM25 query; with query values present the search becomes hybrid.Fusion— how a hybrid search fuses its two legs (RRF by default; see text & hybrid).
Three operational calls round it out — exposed at the service layer (like
persistence), not as transport endpoints: FlushColdVectors/AttachColdVectors
(IVF-PQ cold rescore, below) and RefreshSegments (reload cold searchers).
It also owns two cross-cutting concerns:
- Collection name defaulting — an empty collection name falls back to the
configured
DefaultCollection("default"). - Sentinel errors —
ErrNotFound,ErrInvalidArg,ErrDimMismatch, plus*NotOwnerError(placement). Each transport maps these onto its own status codes, so error semantics stay consistent across gRPC and HTTP.
Auto-creation is guarded by a dedicated createMu with a double-checked lookup,
so two concurrent PutVector calls for a brand-new collection can't race on
CreateCollection. (Auto-created collections use the default hybrid
IVF-over-HNSW backend; pass IndexType: IndexHNSW to CreateCollection via
the Go API to opt into a plain HNSW graph instead.)
Resource keys: one name isolates everything
The service is single-tenant by design — isolation between customers is a
deployment concern (one instance per customer), not an in-process one. Every
request resolves its collection through Service.resourceKey(collection), which
is simply the validated collection name (an empty name falls back to the
configured default). That resource key is the one string every isolation
boundary keys on — the in-memory db.DB map key, the
objectstore.Prefixed prefix, and the write-log partition key. Collection
names are validated by internal/collection (lowercase alphanumerics with
-/_, no / or .), so a name can never escape its prefix in the
bucket. The vector engine (hnsw/ivf/ivfpq/distance) knows nothing about
collections — isolation lives entirely in this layer and in the durable
artifacts it names.
Optional write log. When service.Config.WriteLog is set, the service is
durability-first: PutVector/DeleteVector append the mutation to a
write-ahead log synchronously, before applying it to the local db.DB, so an
acknowledged write is durable even if the node dies (and a log failure aborts the
write rather than leaving divergent local state). When WriteLog is nil — the
default — the service writes only to the in-memory store, exactly as before. This
is the entry point to the serverless storage path.
Search modes (warm, cold, cold-first, hot)
When service.Config.SegmentStore is set, a query can be served from the
in-memory index (warm) or straight from object-storage segments (cold).
The mode is decided per query:
- Default — in-memory, exactly as the non-serverless engine.
SearchOptions.Cold— always serve from segments (the in-memory index is never consulted). RequiresSegmentStore.Config.ColdFirst— flip the default read path to cold: a plain query serves from segments, so a node serves any resource without ever building an in-memory index. This is the serverless inversion — object storage is the primary read path.SearchOptions.Warmis the escape hatch that forces the in-memory path even in cold-first mode.- Hot tier (
Config.MaxHotResources, cold-first only) — heat, not configuration, buys a resource back into RAM: a resource whose sustained query rate crossesHotPromoteQPSis promoted, caught up from the tail overlay under the overlay lock (tailOverlay.handoff, so no write falls in the gap), and from then on the tail follower applies every write-log message to the index directly. Promotion prefers the hot PQ replica when a structure generation has been published for the resource (db.LoadHotReplica: codes in RAM + ranged-read rescore from the generation's vector blocks — a fraction of the classic promotion's RAM); otherwise it loads the full collection generation into memory. Promoted resources serve plain queries warm (the replica's rescore is a bounded set of small ranged reads, which the local disk cache absorbs); text legs stay segment-backed. Demotion — rate decay belowHotDemoteQPS, lost placement ownership, or displacement by a clearly hotter candidate when the (MaxHotResources-bounded) set is full — drops the index and the resource falls back to cold serving. Seeinternal/service/hot.go.
Cold search lazily builds and caches one segindex.Searcher per resource in
a bounded LRU searcherCache (searchercache.go): a resident searcher keeps
only the manifest, centroids, and tombstones in RAM (KBs–low MBs) and fetches
cell segments per query, and is evicted once MaxResidentSearchers is exceeded —
so one node serves far more resources than fit in memory, warming them on demand.
The searcher is built from the segment manifest's dimension/metric, so cold reads
work on a node that never restored the index. Eviction is a reference drop
(searchers own no closable resources), so in-flight queries survive it. A
node-level local-disk (NVMe) tier can sit between the in-memory caches and
the object store (Config.DiskCache, objectstore.DiskCache): segment blobs,
pack slices, lexical segments, and published generation structures — all
immutable, generation-addressed — are cached on disk with a byte-bounded LRU
that survives searcher eviction, hot demotion, and process restarts, so
rebuilding any of those pays a local read instead of an object-store fetch.
RefreshSegments advances resident searchers to the latest published generation;
cmd/server calls it on a ticker, and evicted searchers rebuild at the latest
generation on their next query.
Read-your-writes on the cold path (tail overlay)
Cold reads alone only see what the persistor has flushed into a published
generation; writes still in the write-log tail are invisible until the next
flush. Service.RunTailFollower(ctx, reader) closes that gap: it consumes the
write log into a per-resource in-memory tail overlay (tailoverlay.go) and
merges it over cold results. The correctness rule is simple — the overlay is
authoritative for any id it knows. Because it applies a contiguous suffix of the
log, it knows an id iff that id's latest write is in the suffix, so a cold segment
hit is dropped when the overlay knows its id (a fresh copy is re-added; a deleted
id is suppressed) and kept otherwise. The overlay stays bounded via the manifest
cutoff watermark: each generation records the highest write-log key it
included, and each cold query trims every overlay id at or below the searcher's
current cutoff (now served by the segments). The overlay is installed via an
atomic.Pointer, so the query path reads it lock-free and tolerates its absence.
Placement gate
When service.Config.Router is set, the cold path first checks ownership:
coldSearcher returns a *NotOwnerError (naming the owner) for a resource this
node does not own — before touching the searcher cache, so an un-owned resource
is never warmed. Both the vector-cold and text paths route through coldSearcher,
so both share the gate; the tail follower is likewise scoped to owned resources.
Placement gates only the cold read path — writes are durably appended and routed
to segments by resource-key partitioning regardless of which node received them.
See rendezvous placement.
Optional IVF-PQ cold-vector rescore. Distinct from cold search above (which
reads segindex segments), this frees an IVF-PQ collection's full vectors to the
SegmentStore while keeping its codes resident. FlushColdVectors(ctx, name)
flushes the vectors (under an ivfpqcold/ prefix) and drops them from RAM;
afterward ordinary searches — over any transport, with no query-time flag —
ADC-scan in memory and rescore exactly by fetching only the candidate pool from
cold storage. AttachColdVectors(name) re-binds the store after a Load (the
flushed state persists; the binding does not).
Text & hybrid search (BM25 + fusion)
Search is the single entry point for three query shapes, chosen by which inputs
are present:
| Inputs | Mode |
|---|---|
| query values only | vector k-NN (warm or cold, as above) |
SearchOptions.Text only |
BM25 text search over the segment index |
both values and Text |
hybrid — both legs run concurrently and are fused server-side |
Both text and hybrid read the segment index, so they require a SegmentStore
(and go through the same coldSearcher placement/ownership gate). The text leg
calls Searcher.SearchText, which scores every live lexical segment with BM25
(k1=1.2, b=0.75) and reconciles newest-generation-wins with tombstones — the same
MVCC discipline as the vector path. The tail overlay suppresses stale segment
copies of ids it knows, so text freshness is persist-lag: new text becomes
searchable only after it is flushed to a generation.
A hybrid search fetches each leg to depth 3k, then fuseHits (fusion.go)
merges them into the top k:
- RRF (
FusionRRF, the default) — Reciprocal Rank Fusion,Σ 1/(rrfK + rank)across legs (rrfK=60). Rank-based, so the vector distance and the BM25 score — which are not directly comparable — never need a common scale. - Linear (
FusionLinear) —alpha·norm(vectorSim) + (1−alpha)·norm(BM25), each leg min-max-normalized to[0,1](alpha=0.5default).
Fusion options are only valid on a true hybrid request (both legs); asking for
them on a single-leg query is an ErrInvalidArg. Metadata (and the fresher
distance from the overlay-merged vector leg) is taken from the vector copy when a
document appears in both legs.
Layer 5 — the transports
Two endpoints expose the same *service.Service. Both are pure translation:
decode request → call service → encode response → map sentinel
errors to status codes. Neither contains business logic. The data-plane routes
never require credentials; on a server running the collections registry
(-byo-store), the collection-management and /v1/admin routes check an API
key (internal/auth: plgn_<key_id>_<secret> bearer keys, hashed records
stored under .auth/ beside the registry, cached per -auth-cache-ttl).
internal/transport/httpserver — net/http JSON REST
Uses Go 1.22+ method+wildcard pattern routing ({collection}, {id}):
| Method & path | Operation |
|---|---|
POST /v1/collections/{c}/vectors |
PutVector (id in body) |
POST /v1/collections/{c}/vectors:batch |
PutVectors (batch upsert, ids in body) |
PUT /v1/collections/{c}/vectors/{id} |
PutVector (id in path) |
GET /v1/collections/{c}/vectors/{id} |
GetVector |
GET /v1/collections/{c}/vectors?limit&offset |
ListVectors |
DELETE /v1/collections/{c}/vectors/{id} |
DeleteVector |
POST /v1/collections/{c}/vectors:get |
GetVectors (batch get, ids in body) |
POST /v1/collections/{c}/query |
SearchVectors — body may carry filter, cold, text, and fusion |
GET /healthz |
liveness probe |
With -byo-store, the server additionally routes the collection lifecycle
(POST|GET|DELETE /v1/collections/{c}, GET /v1/collections,
POST /v1/collections/{c}/verify, GET /v1/setup) and the auth-gated
/v1/admin key-management API. The query
body decodes with DisallowUnknownFields, so an older server rejects an unknown
text/fusion field with a 400. Each hit's score is included (omitzero).
Error mapping: ErrNotFound→404, ErrInvalidArg/ErrDimMismatch→400,
*NotOwnerError→421 Misdirected Request (owner in the body and an
X-Polign-Owner header), else 500.
internal/transport/grpcserver — gRPC
Implements the generated pb.VectorDBServer from
proto/vectordb.proto. Same operations;
SearchVectorsRequest carries the full search surface — cold/nprobe
(segment-backed vector search), filter, and the additive text and fusion
(method/alpha/rrf_k) fields — and SearchHit gains score, so cold, text,
and hybrid search all work over gRPC (an older server silently ignores text).
Sentinel errors map to
gRPC codes: ErrNotFound→NotFound, ErrInvalidArg/ErrDimMismatch→
InvalidArgument, *NotOwnerError→FailedPrecondition, else Internal.
cmd/server — running both together
The binary wires one db.DB → one service.Service → both transports, each on
its own listener, and adds operational concerns the library core deliberately
omits:
go run ./cmd/server -http :23000 -grpc :23001 -data polign.db
-data(optional): load a snapshot on start, save it back on clean shutdown.-log-stores/-log-partitions: append writes to a broker-free write-ahead log living in the object store (durability-first) before applying them locally.-restore-stores: rebuild the in-memory index from object storage on start viaBootFromGenerations— load published generations + replay only the tail past each cutoff, falling back to a full segmentRestorefor collections without a generation.-adopt-refresh(default0= off): on a long-running node, re-scan-restore-storeson this interval and adopt newly published generations without a restart (persistor.GenerationAdopter).-segment-stores/-segment-refresh: enable cold queries (and text/hybrid search) served directly fromsegindexsegments, and how often the cached cold searchers reload to the latest generation.-cold-first: make segments the default read path (requires-segment-stores);-max-resident-searcherscaps the LRU searcher cache (0= unbounded).-tail-fresh: run a write-log tail follower for read-your-writes on the cold path (requires a write log and-cold-first); it uses a unique consumer group reading from the start.-node-id/-peers/-replicas: enable rendezvous placement — this node serves only the resources it owns and redirects the rest. Every node must share the same-peerslist to route consistently.- Graceful shutdown on SIGINT/SIGTERM (
grpc.GracefulStop+http.Shutdown).
A complete broker-free serverless node is:
polign-server -log-stores s3://bkt -segment-stores s3://bkt -cold-first -tail-fresh
polign-persistor -log-stores s3://bkt -stores s3://bkt -mode multi-segment
End-to-end request flow
# Mermaid sequence diagram (source)
sequenceDiagram
participant Client
participant T as Transport (HTTP / gRPC)
participant S as service.Service
participant DB as db.Collection
participant I as vectorIndex (hnsw | ivf)
Client->>T: PutVector / Query / ...
T->>S: decode → service call
Note over S: default name · auto-create · validate
S->>DB: Upsert / Query / List / ...
DB->>I: Add / Search (uint64 node ids)
I-->>DB: node ids + distances
DB-->>S: records (string ids + metadata)
S-->>T: result or sentinel error
T-->>Client: JSON / protobuf · status code
Serverless storage path (optional)
By default polign_db keeps all state in memory and persists via on-demand
db.Save/Load snapshots (Layer 3). For a disposable, cloud-native deployment
there is an opt-in path that moves durability off the node: writes go to a
durable write-ahead log, and a separate persistor process batches that
log into object storage (S3, GCS, and/or Azure Blob). The log is the
object-store log (internal/writelog/objectlog): it lives in
the bucket itself under .wal/ and is sequenced by conditional PUTs — no
broker, so the whole path needs nothing but the bucket. The serving
node's in-memory index becomes a fast cache that can be rebuilt from the log;
the system of record is the log + object storage. The path is entirely off
unless configured, and the cloud backends sit behind a cloud build tag, so
the default build keeps its lean dependency set.
This base durability path is also the foundation of the cold-first serving tier — cold-first reads (object storage as the primary read path), the read-your-writes tail overlay, rendezvous placement, and the multiplexing persistor all build on the log + segment index described here. Those serving-tier pieces are covered in Layer 4 and the Placement & routing section.
writes reads (Search/Get/List)
│ │
┌─────▼──────────── L4 service.Service ─────────────▼─────┐
│ PutVector / DeleteVector │
│ 1. WriteLog.Append(evt) ── synchronous & durable │
│ 2. on ack → db.Collection.Upsert / Delete (L3) │
└───────────────────────┬─────────────────────────────────┘
│ write log: ".wal/" in the bucket
│ partition key = resource key → per-collection ordering
▼
┌──────────── cmd/persistor (separate process) ───────────┐
│ writelog.Reader (consumer group, manual commit) │
│ → batch by count OR time │
│ → gzip+JSON segment per batch │
│ → ObjectStore.Put(key, segment) │
│ → commit offsets only AFTER Put succeeds │
└───────────────────────┬─────────────────────────────────┘
▼
ObjectStore (pluggable, fan-out)
fs: · s3:// · gcs:// · az:// · MultiStore(write to all)
Packages
internal/writelog— the log contract, no external deps: theWriteEventtype (carriesDim/Metricso a consumer can recreate a collection on replay), a JSON codec, theWriteLog(producer) andReader(consumer) interfaces, and in-memory fakes for tests.internal/writelog/objectlog— the broker-free default: the log lives in the object store itself as batch objects under.wal/<partition>/<seq>, sequenced by conditional PUTs (PutIfAbsent) with group commit; consumers poll with LIST and keep per-group cursors in the bucket. Appends are durable before the write is acknowledged — the only prerequisite is the bucket.internal/objectstore— a smallObjectStore(Put/PutIfAbsent/Get/GetRange/List/Delete) withFSStore(local, always available),MultiStore(fan-out to several stores for cross-cloud redundancy), andS3Store/GCSStore/AzureStorebehind thecloudbuild tag.Open(spec)turnsfs:/path,s3://bkt/pfx,gcs://bkt/pfx,az://acct/ctr/pfxinto a store.Prefixedscopes a store under a key prefix (one store hosts many collections);GetManyfetches many keys in bounded parallelism (the segment searcher's fan-out primitive).internal/persistor— three batch consumers.Persistor(raw mode) writes each batch as one gzip+JSON event-log segment.SegmentPersistor(segment mode, one resource) instead drives asegindex.Builder: it routes events into a searchable IVF-over-HNSW segment index (plus an optional BM25 lseg for itsTextField) and runs periodic compaction.MultiSegmentPersistor(multi-segment mode) consumes a shared log and runs a separate builder for every resource it sees, each under its own canonical-key prefix, with a per-partitioncommitTrackerthat commits only the longest contiguous persisted prefix so demultiplexing stays correct. All commit offsets only after the artifact is durably stored.Restorerebuilds an in-memory index from raw segments at startup;BootFromGenerations/AdoptGeneration/GenerationAdopteradd the generation-based fast boot and adoption (below).internal/segindex— the object-store-resident segment index that makes cold vector, BM25 text, and hybrid queries possible (its own section below).cmd/persistor— wires a log reader (-log-stores) + object store into the persistor;-mode raw|segment|multi-segmentselects the consumer, and-text-fieldchooses the metadata field indexed for BM25 ("-"disables the lexical index).cmd/ivfpq-compact— out-of-band re-partition: rebuilds a collection's IVF-PQ index from object storage and publishes it as a new generation. Reads object storage only; never touches a live node.cmd/server— gains-log-stores/-log-partitions/-log-batch-window(write log),-restore-stores/-adopt-refresh(boot + periodic generation adoption),-segment-stores/-segment-refresh(cold/text/hybrid queries from segments),-cold-first/-max-resident-searchers(cold-first serving + bounded searcher cache),-tail-fresh(read-your-writes tail follower), and-node-id/-peers/-replicas(rendezvous placement).
Guarantees
- Durability-first. Append-then-apply: an acknowledged write is durably in the log before the client sees success, and a log failure aborts the write with no divergent local state.
- Ordering. Keying by resource (the collection name) keeps per-collection upsert/delete order within one partition, so the persistor (and any future replayer) observes the same order the service applied, and one collection's write stream is partition-isolated from another's.
- At-least-once, idempotent. Offsets commit only after a successful
Put; segment keys derive from the batch's first offset, so a redelivered batch overwrites the same object rather than duplicating.
Startup restore is implemented: cmd/server -restore-stores <spec> calls
persistor.BootFromGenerations, which loads each collection that has a published
generation from its snapshot and replays only the segment tail past its cutoff,
and falls back to a full Restore (list every raw segment, decode, replay) for
collections without one — so it is a drop-in replacement for Restore, identical
when no generations exist. Either way it reflects only what the persistor has
flushed; writes still in the log tail are not replayed by this path.
IVF-PQ re-partition & generations
A CodesOnly/cold-flushed IVF-PQ collection can't re-partition in place
(re-clustering needs the original vectors in RAM). So re-partitioning runs out
of band and publishes its result as an immutable object-store generation,
which live nodes adopt with no downtime — the same MVCC shape as the segment
index, reusing db.PublishGeneration/SwapCollection. The scheme is purely
additive on disk (under a gen/<collection>/ prefix); it never touches existing
segments/ or segment-index keys.
cmd/ivfpq-compact (separate process, reads object storage only)
│ persistor.RebuildIVFPQ: replay write-log segments → fresh IVF-PQ
│ (nlist ≈ √N), record Cutoff watermark
▼
gen/<collection>/ generation/CURRENT → manifest/<gen>.json (+ blob) [write-once + 1 swap]
│
├──▶ boot: persistor.BootFromGenerations (cmd/server -restore-stores)
├──▶ running: persistor.GenerationAdopter (cmd/server -adopt-refresh, periodic)
│ AdoptGeneration: load gen → replay segments past Cutoff → SwapCollection
└──▶ GC: db.GCGenerations (-keep-generations; keeps live + keep-1 prior)
- Rebuild (
RebuildIVFPQ,cmd/ivfpq-compact) replays the collection's write-log segments — the source of record, so deletes/updates resolve correctly — into a fresh corpus-sized index and publishes a generation stamped with aCutoff(the highest segment key it included). - Adopt (
AdoptGeneration) loads the generation, replays segments past itsCutoffto catch up writes since, thenSwapCollections it in — a pointer swap, so in-flight queries finish on the old collection. It's current up to the last persisted segment; write-log-tail writes are picked up by the next adoption, exactly as withRestore. - Boot vs. running. Boot adoption (
BootFromGenerations) runs once at startup. TheGenerationAdopteris its running-node counterpart: it re-scans on a ticker and adopts only collections whoseCURRENTadvanced (an unchanged one costs a singleCurrentGenerationread), withPrimeseeding it from what boot already loaded so the first tick doesn't re-adopt. - Safety. Keep
-adopt-refreshwell inside the GC-keep-generationswindow, so a generation a node is about to adopt isn't reclaimed first.
Measured recall and compression numbers for this tier are on the benchmarks page.
Segment index (object-store-resident cold search)
The serverless path above keeps object storage as a write-only record that a
node replays into memory before it can answer queries. The segment index
(internal/segindex) removes that constraint: it stores the index itself in
object storage in a form a query can read directly, so a node serves "cold"
vector, BM25 text, and hybrid queries from S3/GCS/Azure without ever building the
full in-memory graph. Default vector search stays in-memory and fast; cold is
opt-in per query (or the default under cold-first). Full design rationale and
on-disk layout follow in this section.
The model is an LSM tree whose partitions are IVF cells, whose segments are self-contained HNSW mini-indexes in immutable binary blobs, whose MVCC root is a manifest, and whose only rewrite is background compaction.
object store (per resource, under <resource-key>/ prefix)
manifest/CURRENT # tiny pointer to the live generation
manifest/<gen>.json # immutable: per-cell segment lists + Lex lsegs + tombstone deltas
centroids/<gen>.cent # immutable: IVF centroid table (routing)
cells/<cell>/<gen>.seg # immutable: one cell's HNSW graph + vectors + id/meta
lex/<gen>.lseg # immutable: BM25 inverted index over the text field (optional)
tombstones/<gen>.json # immutable: gen-stamped delete deltas
write (SegmentPersistor → Builder) read (Searcher)
─────────────────────────────────── ─────────────────────────────
WAL batch query q, k, nprobe
├ route each vec → nearest centroid ├ manifest + centroids resident
├ build a fresh HNSW per touched cell ├ pick nprobe nearest cells
├ Put cells/<cell>/<gen>.seg (write-once) ├ GetMany the cells' segments (parallel)
├ deletes → tombstones/<gen>.json ├ SearchGraph each segment in place
└ publish manifest/<gen> + swap CURRENT └ merge: newest-gen-wins, drop tombstoned
What each piece does
- Segment format (
segment.go) — a binary blob with a fixed header of block offsets, then contiguous vectors, CSR adjacency, a deleted bitset, an id map, and metadata.SegmentReaderimplementshnsw.NodeSourcewith zero-copyVector/Linksviews into the bytes, sohnsw.SearchGraphruns over a fetched blob with no graph rebuild. IVF bounds each cell's size, which keeps a whole-segment read cheap. - Manifest (
manifest.go) — the MVCC root. Publishing a generation writes an immutablemanifest/<gen>.jsonthen swaps the one-lineCURRENTpointer — the only object ever overwritten. Readers pin a generation and see a consistent snapshot across concurrent writes. - Builder (
builder.go) — the single writer.Trainlearns centroids (k-means) and publishes generation 1;Flushroutes a WAL batch into per-cell segments and publishes the next generation;OpenBuilderresumes. Append-only: a flush only adds blobs plus oneCURRENTswap. - Searcher (
searcher.go) — keeps only the manifest, centroid table, and tombstone set resident (KBs–low MBs). A query routes tonprobecells, fetches their segments viaobjectstore.GetMany(parallel), searches each in place, and merges newest-generation-wins with tombstones applied.Refreshis cheap when nothing changed (a singleCURRENTread) and reuses the cached centroids when the generation reused them. - Lexical segment (
lexseg.go,analyze.go,textsearch.go) — an optional BM25 sibling of the vector segment. When the Builder is configured with aTextField(default"text";"-"disables), eachFlushalso writes one immutablelex/<gen>.lseg— an inverted index (uvarint postings, a sorted term directory) over the documents whose text field is non-empty — and records it in the manifest'sLexlist. It shares the generation stream, MVCC root, and newest-gen-wins reconciliation with the vector segments but is keyed and fetched independently, so a text query never pulls vector data into memory.Searcher.SearchTextscores every live lseg with BM25; there is no cell partitioning for text, so compaction (below) keeps the lseg chain short. - Compaction + GC (
compact.go) —Builder.Compactmerges a cell's segments (live, non-tombstoned, newest-gen-wins) into one fresh segment and republishes;MinSegments=1fully applies and clears tombstones. The lex chain is compacted under the same rules (and re-stamped whenever a cell is rebuilt, so cross-gen newest-wins stays correct).GC(keep)is stateless: it deletes blobs (cells, lsegs, tombstones, old manifests/centroids) that are unreferenced by the current manifest and older thancurrentGen - keep. Because the Builder owns the generation counter, compaction never races aFlush.
Serving wiring. A SegmentPersistor (one resource) or MultiSegmentPersistor
(every resource on a shared log) builds the index from the WAL and compacts on a
cadence. cmd/server -segment-stores enables cold, text, and hybrid queries; the
service caches a Searcher per resource (bounded LRU) and a -segment-refresh
ticker reloads them to the latest generation. A vector query opts in cold with
{"cold": true} (HTTP body) or the cold proto field (gRPC) →
service.Search(..., SearchOptions{Cold:true}) → db.Collection.QueryCold — or a
-cold-first node serves cold by default. Text and hybrid search
(SearchOptions.Text) read the segment index over both transports as well.
Safety contract. GC keeps orphaned blobs for keepGenerations generations, so
serving nodes must Refresh at least that often or a pinned searcher could 404 on
a GC'd segment. The server defaults (refresh 30s, keep 3 generations) satisfy this;
keep refresh frequency well inside the keep window if you tune them.
Cell moves. A re-upsert whose vector moves to a different cell is fenced at
write time: the moving flush writes a tombstone at gen-1 (a move fence), which
hides every older copy of the id in every cell while the fresh copy survives — so
queries that probe only the old cell never see the stale copy. Compaction remains
the physical remover and clears fences with the other tombstones. The Builder
tracks each live id's cell to detect moves (O(live ids) persistor memory; rebuilt
by one collection scan on resume, refreshed for free by each compaction).
Placement & routing (serverless tier)
In a multi-node fleet, which node serves which resource is decided by
rendezvous (highest-random-weight) hashing (internal/placement): each
(resource-key, node) pair gets a deterministic FNV-1a-derived score, and the
highest-scoring non-draining node owns the resource. This needs no coordination
beyond the nodes agreeing on the membership set, and adding or removing a node
moves only ~1/N of resources.
placement.Router (per node: self id + replication factor + atomic Ring + atomic overrides)
├ Owns(key) → is self among the resource's owners? (gates the cold read path)
├ Owner(key) → the top-scoring node (primary; stable under splitting)
├ Owners(key) → the owner set: replication factor wide, wider under a split override
└ PickOwner(key) → one owner, round-robin (named in a redirect, spreads load)
- Serving. A cold read (vector or text) for a resource this node does not own
returns a
*NotOwnerErrorbefore any searcher is built, so an un-owned resource is never warmed. Transports surface it as HTTP 421 (owner in the body +X-Polign-Owner) and gRPCFailedPrecondition, so a client or gateway redirects to the owner. - Freshness. The tail follower skips events for un-owned resources, bounding each node's overlay to its own assignment.
- Draining. A node marked draining is excluded from ownership (its resources shift to the next-best node) but stays in the set, so a deploy drains traffic without reshuffling the rest of the fleet.
Membership is an immutable Ring swapped atomically (Router.UpdateMembership),
so a control loop can react to fleet changes while queries route lock-free.
cmd/server takes a static fleet via -node-id/-peers/-replicas; every
node configured with the same peer list computes the same assignment. Dynamic
(coordinator-driven) membership is a follow-up.
Load-aware splitting (internal/service/split.go, Config.SplitQPS) widens
a hot resource's replica set with no coordinator: when a resource's sustained
query rate exceeds SplitQPS per replica, its primary owner publishes a
replica-count override record to the segment store
(placement/split/<resource-key>, one writer per record), and every node's
RefreshPlacement poll adopts the fleet's records as router overrides
(Router.UpdateOverrides). Records are TTL-renewed while the heat persists,
retired when it cools, and garbage-collected if their publisher dies; overrides
only ever widen the owner set, and the primary is unchanged, so single-writer
roles keyed on Owner stay stable.
Build-time codegen
internal/pb is generated, not hand-written, from the proto definition:
protoc --go_out=. --go_opt=module=github.com/Polign/polign_db \
--go-grpc_out=. --go-grpc_opt=module=github.com/Polign/polign_db \
proto/vectordb.proto
This is the one part of the tree that needs external tools (protoc plus the
protoc-gen-go / protoc-gen-go-grpc plugins). The generated code pulls in
google.golang.org/grpc and google.golang.org/protobuf — the only non-stdlib
runtime dependencies, and only for the transport layer.
Concurrency model
- Each
Collectionand each index (hnsw.Index,ivf.Index, and every IVF cell) has its ownsync.RWMutex. - Reads (
Query,Get,Search,List) take read locks and run concurrently. - Writes (
Upsert,Delete,Add) take write locks and are serialized — this is why throughput drops sharply when a read-heavy workload turns write-heavy. Snapshot()takes a read lock and deep-copies, so saving can run alongside ongoing reads.- The
DBhas its own lock guarding the set of collections (create/drop/lookup). service.Serviceadds acreateMufor safe collection auto-creation; both transports are otherwise stateless and rely on the locks below them.- When a
WriteLogis configured,PutVector/DeleteVectormake a synchronous, durability-first append before taking the collection write lock, so a slow broker backpressures writers while leaving readers unaffected. - In the segment index, the
Builderis the single writer per collection (it owns the generation counter), soFlushandCompactnever race — they take the Builder's own lock. TheSearcheris read-mostly: searches take a read lock over an immutable manifest snapshot, andRefreshswaps in a newer generation under a write lock, so concurrent searches never observe a torn view. - Generation adoption is lock-free for readers.
AdoptGenerationbuilds the caught-up collection off to the side and installs it with a singleDB.SwapCollectionpointer swap under the DB lock; queries in flight finish on the previous collection undisturbed. TheGenerationAdopteris driven from one ticker goroutine and isn't safe for concurrentTicks. - The serverless serving tier avoids read locks on its shared state. The
tail overlay is installed via an
atomic.Pointerand read lock-free on the query path (its own internals use anRWMutex, with the single tail-follower goroutine the only writer). Theplacement.Routerreads anatomic.Pointer[Ring], soUpdateMembershipnever blocks routing. The bounded searcher cache takes a shortMutex, but builds a new searcher outside the lock and evicts by dropping a reference, so in-flight cold queries are never blocked or invalidated. - Hybrid search runs its vector and text legs concurrently (a
sync.WaitGroup) and fuses the results.
Design decisions at a glance
| Decision | Why |
|---|---|
| Layered packages, downward deps only | Each layer testable in isolation; index/db reusable without the network |
Pluggable vectorIndex (HNSW, IVF, or IVF-PQ) |
Swap the ANN backend per collection without touching db/service/transport |
| IVF cell = one HNSW graph | Smaller graphs, independent per-cell persistence (cloud-storage friendly) |
| IVF-PQ: codes + ADC, rescore by default | ~32× smaller than float32 for memory-bound corpora; exact rescore restores recall, CodesOnly takes the full saving |
IVF-PQ nlist/nprobe auto-scale (≈√N) |
Per-query cost stays sub-linear as the corpus grows, instead of a fixed cell count |
Metadata filter pushed in-graph (SearchFiltered) |
High recall on selective filters vs. post-hoc filtering; brute-force fallback when matches are sparse |
Single transport-agnostic service core |
gRPC and HTTP can't drift; behaviour is defined once |
| Sentinel errors mapped per transport | Consistent semantics, but HTTP and gRPC each speak their native status codes |
| Auto-create collection on first Put | Zero-setup ergonomics; dimension inferred from the data |
| Squared L2 / negated dot | Avoid per-comparison sqrt; keep one "smaller-is-closer" rule |
| Tombstone deletes/updates | Index nodes are immutable; keeps the graph connected |
| Snapshot persistence (full graph) | Instant load, no re-indexing; trivially correct round-trip |
| Out-of-band re-partition → adopted generations | CodesOnly/cold indexes can't re-cluster in place; rebuild off-node and swap in with no downtime |
Generation MVCC (CURRENT + immutable blobs), additive on disk |
Same minimal-overwrite shape as the segment index; never disturbs existing ingested data |
| Atomic temp-file + rename writes | A crash can't corrupt a previous save |
| One RWMutex per index | Simple, correct; readers scale, writers serialize |
Generated internal/pb, stdlib core |
Only the transport layer needs grpc/protobuf; the engine stays dependency-free |
| Optional write-ahead log (append-then-apply; lives in the object store) | Durability without coupling to a node's local disk; serving nodes become disposable — the broker-free default needs only the bucket |
| Persistor batches the log into object-storage segments | S3/GCS/Azure becomes the system of record; at-least-once with idempotent, offset-derived keys |
Cloud backends behind a cloud build tag |
Default build stays dependency-light; cloud SDKs are pulled in only when needed |
| Segment index = IVF cells as LSM partitions | Each cell is one immutable, independently-searchable blob; bounds per-read size and per-cell rewrite |
hnsw.SearchGraph over a NodeSource |
One beam-search implementation serves both the in-memory index and on-store segments — no divergent traversal to maintain |
| Manifest MVCC root, append-only blobs | Minimum overwrites (only a tiny CURRENT pointer); readers get snapshot isolation for free |
| Compaction is the only rewrite (single-writer Builder) | Append-only writes stay cheap; fan-out is bounded in the background without racing flushes |
| Stateless GC (unreferenced ∧ age-gated) | Reclaims space without tracking per-blob refcounts; the age gate protects pinned readers |
| Cold search opt-in, in-memory default | Latency-sensitive reads stay fast; object-store reads are a deliberate per-query choice |
| The collection name is the resource key | One validated string isolates each collection across db, object store, and write log; no second addressing scheme to keep consistent |
| Single-tenant by design; credential-less data plane | Isolation between customers is a deployment concern (one instance per customer); API keys guard only the collection-management and admin surfaces |
| Cold-first + bounded LRU searcher cache | Object storage is the system of record; one node serves far more resources than fit in RAM, warming on demand |
| Tail overlay for read-your-writes (overlay authoritative for ids it knows) | Cold reads see writes still in the log tail; the cutoff watermark keeps the overlay bounded |
| Rendezvous (HRW) placement | Assign resources to nodes with no coordinator; membership changes move only ~1/N of resources |
| BM25 lexical segment as a sibling of the vector segment | Text and vector share the generation stream/MVCC/GC but fetch independently — a text query pulls no vector data |
| RRF the default hybrid fusion | Rank-based, so incomparable vector distances and BM25 scores never need a common scale |