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.

Source

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):

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:

Subtleties unique to IVF:

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:

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": "…"}

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:

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:

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:

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:

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, *NotOwnerError421 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: ErrNotFoundNotFound, ErrInvalidArg/ErrDimMismatchInvalidArgument, *NotOwnerErrorFailedPrecondition, 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

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

Guarantees

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)

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

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)

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

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