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, version, 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 composite block per present trigram,
[Phase1Len:32][Phase1Block] [Phase2Len:32][Phase2Block]-- each sub-block independently length-prefixed so the phase-1 (dense, delta+varint or roaring) bytes and the phase-2 (sparse, positional) bytes never bleed into each other;Phase2Len =:= 0means this trigram has no phase-2 data, - a gram -> doc-count table: a sorted
<<Gram:24, DocCount:32>>array, one entry per distinct trigram with phase-2 data, so the query planner can rank a literal's candidate grams by rarity with a binary search instead of a full postings read, - 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. A segment written by an
older or newer format than the reader expects fails to open with a distinguishable
{unsupported_segment_version, Path, Got, Expected} -- there is no migration, reindex
into a fresh corpus.
The manifest (the list of live segments plus the watermark) also persists the corpus's
own configuration -- phase2_selector_opts and fields -- and validates it on every
open/2: a reopen with a different value fails with
{error, {config_mismatch, Field, Persisted, Requested}} rather than silently reindexing
under the new value.
Opening and closing a corpus
open/2 and close/1 for the same corpus name never interleave: a one-shot coordinator
serializes them, so a reopen of an already-live corpus is always reconciled against the
running one, not raced against it.
Alongside the per-shard manifest, a corpus persists a corpus-level corpus.meta --
db, the database's instance id, shards, phase2_selector_opts, fields, and
postings -- checked before any shard starts and committed only once every shard is up.
A reopen that disagrees with it, or with the corpus's own currently-running configuration,
fails with {error, {config_mismatch, Field, Persisted, Requested}} before anything is
touched, closing two gaps a per-shard-only check could not: rebinding a corpus to a
different (or recreated) database, and changing shards on a live corpus, which would
otherwise spawn an entirely disjoint set of shard directories and silently orphan the old
ones.
That database-instance id is also re-checked continuously, on every shard resubscribe, not just at open. If the bound database is deleted and recreated under the same name while the corpus stays open, the affected shard detects the mismatch on its next resubscribe and stops itself instead of silently reattaching and indexing under a stale watermark.
A corpus indexed before corpus-level metadata existed (real segments on disk, no
corpus.meta) has no safe migration -- db and shards were never recoverable from what
it persisted -- so open/2 rejects it with
{error, {legacy_corpus_requires_reindex, Corpus}}. Reindex it into a fresh data_dir.
close/1 returns ok | {error, term()}: {error, busy} if another open/2/close/1 for
the same corpus is already in flight and contention isn't resolved within the retry
window, or an error if a shard could not be confirmed stopped. It is otherwise idempotent
-- closing an already-closed or never-opened corpus is ok.
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. Every corpus applies both: the dense selector (phase-1, every trigram) and the sparse selector (phase-2, a content-defined sample carrying byte offsets). A query always narrows through phase-1; when a literal (or a regex's chosen literal run, see regex) has phase-2 data to spare, the planner narrows further with a distance-checked candidate match start instead of just a candidate document. 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 -- or,
for a phase-2-narrowed candidate with a corpus
sourceconfigured (open/2'ssourceoption, a{Module, InitArg}pair implementingbarrel_ngram_source), apreadof just the matched region instead of the whole document. Without asource, phase-2 still narrows which documents get fetched; it just fetches them in full. - Storage is roughly the postings (proportional to text size) plus the offset table per segment; the phase-2 sample adds a smaller, sampled second set of postings alongside it, plus the doc-count table.
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 corpus with hot
trigrams (a common gram present in most documents); sharding keeps 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.
End-to-end query benchmark and profiling
barrel_ngram_bench_search:run/0 measures a real search/regex call over a real corpus,
comparing no source (full-document fetch) against a windowed source, across document
sizes. profile/0,1 runs one such call through fprof and prints the call graph, for
finding where a slow query actually spends its time. Neither runs as part of the test
suite; invoke them directly.
The comparison surfaced a real regression: a candidate whose only reliable phase-2 gram also occurs many times elsewhere in the same document (repetitive content) used to get one windowed read per occurrence, which for a 100 KB document with a common gram was an order of magnitude slower than a single full-content read would have been. Verification now caps the candidate count per document and falls back to one full read past the cap.