Design
View SourceThis page explains how barrel_ngram works so you can reason about its behaviour,
storage, and cost. Read it when you are tuning a corpus, debugging a result, or deciding
whether the index fits your workload. It is inspired by GitHub Blackbird, stripped to the
core.
The index in one paragraph
Every document is reduced to a byte string (its indexed text). That string is cut into overlapping byte trigrams. For each trigram, the index stores the set of documents that contain it (a posting list). A query is cut into the same trigrams; the documents that contain the query are a subset of the documents that contain all of the query's trigrams, so intersecting those posting lists yields a small candidate set. Trigram presence is necessary but not sufficient, so each candidate is confirmed by fetching the current document and running the real match. The confirm pass is what makes results exact.
Segments
The index is a set of immutable segment files under the corpus directory, read with
file:pread (the OS/ZFS page cache does the caching; there is no mmap). A segment has:
- a header (magic, offsets, document count, an HLC watermark),
- a direct-addressed offset table:
u32per trigram over the whole 2^24 gram space, so a trigram's posting list is one indexed read (mostly zeros, relies on filesystem compression), - a postings region: one delta+varint block of local document ordinals per present trigram,
- a sidecar: per ordinal, the document key, the change HLC, and a deleted flag.
Documents are addressed inside a segment by dense local ordinals; the sidecar maps an ordinal back to its barrel document key for the confirm pass.
The live lifecycle
The corpus tracks the database, so segments come and go:
- Buffer. Incoming changes are applied to an in-memory buffer keyed by document id (an update replaces, a delete becomes a tombstone).
- Freeze. When the buffer crosses a threshold (or on
refresh/1), it is written to a new immutable segment, and the manifest (the list of live segments + the watermark) is committed by an atomic rename. - Query. A search fans across every live segment plus the buffer.
- Compaction. When the segment count crosses a threshold (or on
compact/1), a worker merges segments, collapsing each key to its newest version by HLC and physically evicting superseded and deleted entries. - Recovery. On start the corpus reads the manifest and resubscribes from its watermark, so only the feed tail is replayed.
Why the confirm pass matters
Because the confirm pass re-fetches the current document and runs the real match, the index never has to be perfectly consistent to be correct. A stale entry left by an update, a deleted document still present in an old segment, or a trigram false positive are all dropped at confirm time. This is why updates and deletes need no separate liveness bookkeeping for correctness, and why compaction can be approximate about eviction.
Selectors
Which trigrams a document contributes is decided by a selector, applied identically by the indexer and the query planner. The dense selector emits every trigram; the sparse selector emits a content-defined sample for a smaller index. See selectors.
Cost
- A trigram lookup is one
preadon the offset table plus one on the postings region. - A query does trigram intersection then a batched multi-get for the confirm pass.
- Storage is roughly the postings (proportional to text size, smaller with the sparse selector) plus the offset table per segment.
Posting codecs and intersection performance
The default posting codec is delta+varint, intersected by galloping over decoded ordinal
lists. Measured over a 100k-document shard intersecting 12 lists
(barrel_ngram_bench:run/0): the galloping intersect is fast, but decoding the varint
blocks (materializing the ordinal lists) dominates for large lists, and it is inherent to
delta+varint (sequential, no random access). That only bites a large, dense corpus with
hot trigrams (a common gram present in most documents); sharding and the sparse selector
keep per-list sizes small in normal use.
For that regime, open the corpus with postings => roaring. Posting lists are stored as
roaring bitmaps and intersected with a native AND in the NIF (barrel_ngram_roaring,
backed by vendored CRoaring), with no Erlang list materialization. Measured on the same
benchmark, roaring intersects twelve 50k-ordinal lists in about 0.1 ms versus 140 ms for
varint (a fourth of a millisecond even at 10k), at the cost of a fixed per-list overhead
that makes it slightly slower for tiny lists. So varint stays the default and roaring
is opt-in for large dense corpora. Both produce identical results (a differential oracle
holds roaring byte-for-byte against varint). The codec is a per-segment property, so a
corpus records it and reads it back at query and merge time.