OpenAlex Search a RoaringRange demo · how it works

Powered by RoaringRange

Searching half a billion papers, with no server.

This RoaringRange demo can search OpenAlex works entirely in your browser, with no backend — or hand a query to a tiny regional Lambda when you want raw speed (the trigram default). Either way the index is one static file on S3, and a query fetches just a few small byte-ranges over HTTP — never the whole file. It's powered by RoaringRange, and inspired by Lunr.js and Pagefind.

484M
OpenAlex works indexed
0
backends required
~3MB
downloaded at boot
KB–MB
fetched per query
01From work to document

The OpenAlex data, mapped

Each OpenAlex Work — a paper, book chapter, dataset, preprint — becomes one ranked document. Documents are numbered by cited_by_count, most-cited first, which is exactly what lets the popular “head” paint instantly.

What’s indexed

The searchable text of each work is concatenated and cut into trigrams, so a query matches across any field — a phrase from the abstract, an author, or a journal, not just the title. OpenAlex doesn’t ship abstracts as plain text; it stores an abstract_inverted_index — a map of each word to the positions where it occurs — so the builder reconstructs the running abstract from that index before indexing it (that’s what the abstract ← abstract_inverted_index chip below means).

title abstract ← abstract_inverted_index author names host venue trigrams

The same text is indexed a second way — as whole words, not character trigrams. This term index (the .rrt file) Snowball-stems each word and stores one posting per term, so a query word costs a single fetch carrying that word’s true rarity — strongest for exact and prefix/autocomplete matches, where trigrams shine at substring and typo-tolerant search. Both indexes share the same doc IDs, so a query can use either or fuse them; both formats sit side by side in section 04.

The five facets

Five fields from each work become filter categories — the .rrf sidecar. Counts are free; selecting one fetches a single small posting.

Year
publication_year
The work’s publication year.
Type
type
article, book-chapter, dataset, preprint, …
Open access
oa_status
gold, green, hybrid, bronze, or closed.
Language
language
Shown as a full language name.
Topic
primary_topic
Falls back to the first concept.
OR / AND Within a field, categories OR together; across fields they AND — all computed from doc-ID bitmaps, with no backend.

What a result stores

The record behind each hit keeps just enough to render a card and link out — opaque bytes the format never inspects.

Example result card

Deep Residual Learning for Image Recognitiontitle

211,420citations

Kaiming He, Xiangyu Zhang, Shaoqing Ren, Jian Sun authors

2016 year IEEE CVPR host venue green OA

Deeper neural networks are more difficult to train. We present a residual learning framework to ease the training of networks that are substantially deeper than those used previously… abstract

id  W2194775991  → openalex.org/W2194775991

DOI lookup

A small companion index — the .rril file — maps each work’s DOI straight to its document. Pasting a DOI, or a doi.org URL, jumps to the exact work in a couple of byte-range reads instead of a text search. The format appears in section 04.

03Meaning, not characters

Semantic & hybrid search

Trigram search matches characters — exact substrings. Semantic search matches meaning: the query and every work become vectors, and the nearest by cosine win — even with no shared words. Same static ethos — an IVFPQ index (the .rrvi file) boots once, and each query range-fetches only a few clusters.

From query to nearest works

The corpus is partitioned into nlist clusters by coarse centroids, and every vector is stored as a compact product-quantization code — 32 bytes, not a 2 KB float array — optionally rotated by OPQ for accuracy. Boot downloads the small boot region once: the OPQ rotation, the centroids, the PQ codebooks, and a cluster directory. Per query, the reader finds the nprobe nearest centroids in memory, range-fetches just those clusters’ code lists, and ranks them by asymmetric distance — the real query vector scored against quantized codes through precomputed tables, no full vectors needed.

query text embed ← 512-d unit vector nprobe clusters ← range reads PQ ADC rank top-K doc IDs

The query path mirrors the trigram one — a near-constant number of round-trips, independent of corpus size.

flowSemantic search · embed → range-fetch clusters
Semantic search — embed the query, then range-fetch the nearest clusters no backend with in-browser model2vec; one tiny embed call with the Gemma Lambda — the rest is the same range-read path as the text index Browser — WASM reader (Rust) boot once: OPQ + centroids + PQ codebooks + directory (in memory) embed query → 512-d unit vector model2vec·wasm or Lambda·Gemma nprobe nearest centroids (in memory) fetch those clusters' codes → ADC-rank → top-K doc IDs (PQ: 32 B/doc; no full vectors) optional facet filter — keep IDs in selected bitmaps fetch record JSON → render cards CloudFront → S3 (+ Lambda) boot region .rrvi · centroids+codebooks+dir .rrm2 model matrix static · in-browser Lambda · Gemma ONNX · server-side cluster code lists .rrvi · [u32 ids][u8 codes] × nlist clusters records.bin GET boot region (once) GET .rrm2 (once · model2vec) or POST /embed (Gemma Lambda) GET nprobe cluster lists (parallel) GET records (coalesced) downloaded once range-fetched per query parked · Gemma Lambda Rust VectorIndex::open(f).search(query_vec, k, nprobe) → [ VectorHit { doc_id, score } ]
The query embeds to a 512-d vector (in the browser, or via the EmbeddingGemma Lambda), the reader picks the nprobe nearest centroids from the in-memory boot region, and range-fetches only those clusters’ PQ codes to rank by asymmetric distance. Out come ranked doc IDs in the same numbering as the text index — so records render through the very same path.

Two ways to embed a query

The search machinery is identical; only the embedder differs. Each model defines its own vector space, so each embedder has its own .rrvi — but the corpus is embedded once, offline, for $0 either way.

In the browser — model2vec
in-browser · no backend
A static model (potion-retrieval-32M) ported to wasm: tokenize (BERT WordPiece), average the per-token vectors, normalize. No neural net, no API key, $0. The token matrix (.rrm2, ~33 MB int8) downloads once and is browser-cached. Slightly lower quality than a transformer.
Lambda-based — EmbeddingGemma
server-side · AWS Lambda
EmbeddingGemma-300M runs in a Lambda as ONNX (no PyTorch), fronted by CloudFront. A real transformer with asymmetric prompts (query: vs document:) gives stronger paraphrase recall; ~40 ms warm. Only the live query calls it — the corpus was embedded locally.
INVARIANT Corpus and query must share the identical model, pooling, and prompt, or the query lands in the wrong space. Both modes guarantee it — the same model embeds both sides (the Lambda runs the very model the corpus was built with).

Facets over vector results

Vectors reuse the exact doc-ID numbering of the text index and the facet sidecar (vector_id == doc_id) — so the same roaring-bitmap facets apply with no remapping. Filtering a semantic search keeps only the candidate IDs that fall in the selected category bitmaps, the same membership test the trigram path already does. Two strategies trade recall for simplicity:

Post-filter
retrieve → drop
Over-fetch a larger top-N, then drop IDs outside the selected bitmaps. Dead simple — one bitmap test per candidate — but a very selective filter can thin a page, so N is raised to compensate.
Pre-filter (ID selector)
filter while scanning
Push the allowed-set bitmap into the cluster scan: skip codes whose doc ID isn’t allowed and probe more clusters until top-K fills. Guarantees a full page even under a tight filter.

Counts are computed over the retrieved candidate pool rather than a complete bitmap — “semantic matches” is a ranked list, not an exact set — with the same in-wasm machinery and no backend. And hybrid inherits all of it: it fuses the filtered trigram and filtered semantic lists.

Hybrid — reciprocal rank fusion

Run both. Trigram nails exact terms (names, identifiers, rare phrases); semantic catches paraphrase and synonymy. Reciprocal rank fusion blends the two ranked lists without needing comparable scores: each list contributes 1 / (k + rank) to a doc (k ≈ 60), so anything near the top of either ranks high.

RRF score(doc) = Σ 1 / (k + ranklist(doc)) across the trigram and semantic lists — exact matches and meaning matches fused into one ranking, no score calibration required.
.rrviVector index · one static file
Vector index — one static file (.rrvi) IVFPQ: a boot region read once, then per-cluster PQ code lists range-fetched only for the nprobe nearest clusters loaded at boot range-fetched Header 48 B OPQ rot. D×D · optional Centroids nlist×D f32 PQ codebooks m×256×dsub f32 Directory nlist×(u64,u32) Cluster lists per cluster · range-fetched magic "RRVI" · version · dim · nlist · m · nbits · metric · flags (OPQ?) boot region — read once, kept in memory centroids nlist coarse vectors → find the nprobe nearest codebooks m subspaces × 256 entries → score PQ codes OPQ optional D×D rotation → applied to the query directory per cluster → (byte offset, count) centroids dominate the boot size (≈ nlist × D × 4 bytes) one cluster list — fetched only if among the nprobe nearest u32 doc IDs × count u8 PQ codes × count · m doc IDs share the text index's numbering → records & facets reuse them with no remapping. count comes from the directory; m bytes/doc (here 32). Reader — boot once, then one wave of nprobe ranged reads. boot header + OPQ + centroids + codebooks + directory → memory (one read). per query embed → nprobe nearest centroids → GET those clusters' codes → ADC rank → top-K doc IDs. Rust write build_ivfpq_from_parts(parts).write(w) read VectorIndex::open(f).search(q, k, nprobe)
A 48-byte header, then the boot region — an optional OPQ rotation, the coarse centroids, the PQ codebooks, and a cluster directory — read once into memory. Each query fetches only the nprobe nearest clusters’ code lists ([u32 ids][u8 codes]). An optional .rrm2 (model2vec static matrix) and .rrvr (bf16 full-vector re-rank sidecar, fetched only for the surviving top-K) ride alongside. The whole index is never downloaded.
04One index, a family of sidecars

The file formats

One static text index plus a family of companion files. Every box below loads by HTTP byte-range; nothing runs server-side. Doc IDs are assigned by citation count throughout, so the most-cited works sit in the “head” and top-K is free.

The full index family

Every artifact below is a standalone static file (or a set of them) sharing one global doc-ID space, so they compose à la carte — pick the indexes a query needs and ignore the rest. The demo publishes the whole family over the full OpenAlex corpus, 484,369,476 works; each box loads by HTTP byte-range.

Trigram text index
.rrs · RRSI · ~107 GB
Substring & typo-tolerant full-text search — the monolith detailed below.
Term index
.rrt · RRTI · ~50 GB
Whole-word, Snowball-stemmed exact & prefix matches — one fetch per query word.
Facet sidecar
.rrf · RRSF · ~2.2 GB
Year, type, open-access, language, and topic filters with free counts.
Vector index
.rrvi (+ .rrm2) · RRVI · ~16 GB
IVFPQ semantic search; in-browser model2vec, or a Gemma Lambda embedder.
DOI lookup
.rril · RRIL · ~4.5 GB
A DOI, or a doi.org URL, straight to its doc ID in a couple of reads.
Record store
.idx / .bin (+ .dict) · RRSR · ~119 GB
Doc ID → the stored card fields, zstd-compressed against a trained dictionary.
BM25 impacts
.rrb · RRSB · ~35 GB
Lexical-relevance rerank for the term index — one quantized impact byte per (word, doc).
Split sets
.rrss · RRSS
Byte-capped immutable splits, pruned queries — a geometric 19-tier trigram set (~109 GB) and a 12-tier term set (~53 GB); the demo's default client-side trigram/term backend.
Sort columns
.rrsc · RRSC
Secondary sort keys & full second indexes — client-side re-rank, e.g. newest-first.
.rrsText index · one static file
Text index — one static file (.rrs) a trigram dictionary + one roaring posting per term, paged by popularity-ordered container buckets; a few small ranged reads per query, independent of corpus size loaded at boot range-fetched Header 16 B Sparse index 8 B × ⌈ngrams/stride⌉ Dictionary 20 B/entry · key-sorted Postings per trigram · one roaring bitmap magic "RRSI" · version 3 · gramSize · ngrams · stride one dictionary entry — 20 B, sorted by key key u64 · 8 B offset u64 · 8 B size u32 the term's posting at [ offset, offset + size ) one portable RoaringBitmap — no separate head/tail (v3) one posting — a RoaringBitmap of 64K-doc buckets bucket 0 docs [0, 65536) · eager prefix buckets 1 … N paged on demand (TailScan) Doc IDs descend by popularity → bucket 0 is the top-K, fetched eagerly; deeper buckets only when a page needs them. Reader — boot, then a couple of ranged reads per query. boot header (16 B) + sparse index → kept in memory, ~tens of KB. per query binary-search the sparse index → fetch one dictionary block → each trigram's eager prefix (bucket 0), AND smallest-first. deeper buckets fetched only when a page needs more than bucket 0's top-K. Boot is ~tens of KB; each query is a handful of small ranged reads — independent of corpus size. Rust write build::write_index(w, gram, stride, entries) read Index::open(f).search(q, limit)
A 16-byte header, a small sparse index loaded once, a key-sorted dictionary (20 B/entry), then one portable RoaringBitmap per trigram. v3 collapsed the old head/tail blobs into a single posting: because doc IDs descend by citation, a posting’s first container bucket (docs [0, 65536)) holds the most-popular docs, so the reader fetches that eager prefix for an instant first page and pages deeper 64K-doc buckets (TailScan) only as a query needs them. Boot keeps the header + sparse index in memory; each query binary-searches it, fetches one dictionary block, then each trigram’s eager prefix.
.rrfFacet sidecar · filtering without a backend
Facet sidecar — companion file (.rrf) categorical filters (year, type, language, …) with free counts — same range-fetch model as the index loaded at boot range-fetched on demand Header 24 B Field table 16 B/field Category table 36 B/category, key-sorted Strings names Postings per category, range-fetched magic "RRSF" · ver · #fields · #cats · strBytes Header + tables + strings (a few KB) load once at boot → listing categories and their full-corpus counts is then free. one category entry (36 B) key u64 · headOff u64 · headSize u32 · tailSize u32 cardinality u32 · nameOff u32 · nameLen u16 cardinality = full-corpus doc count → facet counts are free (no fetch) per category: [ head ][ tail ] head docs [0, headBoundary) tail docs ≥ headBoundary Split at a fixed 64K-doc bucket boundary — a multiple of 65,536 (default 65,536), raised for larger corpora. A ≤ ~8 KB head per category; tails only when paging in. Filter semantics — mirrors a BitmapFilter. Within a field, selected categories OR together; distinct fields AND. Result = textMatch AND filter, applied to the head first. Live (search-filtered) counts = |resultBitmap ∩ categoryHead|, computed in memory over the query's head result. Doc IDs share the index's popularity order, so facet postings split at the same 64K-doc container boundary and range-fetch the same way (see format.svg). Rust write build::write_facets(w, fields) read FacetIndex::open(f).counts(result)
Maps each category (year, type, language, …) to a doc-ID bitmap. Header, tables, and names load once — so listing categories and their full-corpus counts is free; selecting one fetches a single small head posting. Because facet doc IDs share the index’s popularity order, postings split at the same 64K-doc container boundary the text index buckets at. Within a field categories OR; across fields they AND.
.idx / .binRecord store · doc ID → stored fields
Record store — a hit's stored fields (.idx + .bin) a search returns ranked doc IDs; the store maps each to its record bytes over HTTP Range — search → details, no backend loaded at boot range-fetched on demand records.idx — offset index Header 16 B Offsets — (N+1) × u64, one per doc ID off[d] … off[d+1] bound record d magic "RRSR" · ver · count N records.bin — record blob, in doc-ID (rank) order rec 0 rec 1 rec 2 rec 3 rec N-1 lookup(doc id d) — two ranged reads 1. read 16 B at idx[16 + d*8] → (off[d], off[d+1]) 2. read bin[off[d] … off[d+1]) → record bytes A results page is consecutive doc IDs → one contiguous blob slice (a single fetch). Records are opaque to the library — the container is standard, the encoding is yours. The store frames bytes for O(1) lookup by doc ID; what's inside (JSON, msgpack, …) and which fields it holds is the application's choice. RecordStore (the reader) returns the raw bytes; the app decodes them. Rust write build::write_records(bin, idx, recs) read RecordStore::open(idx, bin).get_many(ids)
A search returns ranked doc IDs; the store turns each back into its fields. An offset index (.idx) maps a doc ID to a byte range in the record blob (.bin) — two ranged reads, and a page of consecutive IDs is one contiguous slice. Records are opaque bytes, so the format never dictates your data model.
.rrilDOI lookup · identifier → doc ID
DOI lookup — an exact identifier straight to a doc ID (.rril) paste a DOI or a doi.org URL; a handful of 16-byte ranged reads land on the exact work — no text search loaded at boot range-fetched work.rril — identifier index, sorted by hash Header 16 B Records — fixed 16 B each, sorted by hash64(doi) hash64 u64 · verify u32 · docId u32 magic "RRIL" · ver · count N binary-search the sorted hashes — halve the window per 16 B probe ≈ ⌈log₂ N⌉ ≈ 29 probes for 484M probe lo mid hi resolve a DOI in four steps — each probe is one 16 B Range read 1. normalize — strip the doi.org / https prefix, lower-case → hash64(doi) 2. binary-search the record hashes → the candidate record 3. verify — a second hash rejects collisions (a miss → no result) 4. docId → the record store renders the exact work An exact identifier lands on an exact work — or nothing — without ever touching the text index.
Maps each work’s normalized DOI to a doc ID: a header, then fixed-size records sorted by hash. A lookup normalizes the query and binary-searches the hashes — a handful of 16-byte ranged reads — and a second “verify” hash rejects collisions. An exact DOI lands on the exact work, or nothing, with no text search.
.rrscSort columns · secondary indexes & client-side re-rank
Sort columns — re-rank by a secondary key (RRSC) a dense value-per-doc column; fetch a candidate set's values over HTTP Range and top-K client-side — sort by date, rating, any metric loaded at boot range-fetched work.rrsc — dense columns, indexed by doc ID Header 16 B Column table 24 B / column Names string blob Dense data — one column per sort key rows × width · doc-ID order magic "RRSC" · ver · colCount · rows N · strBytes value(d) = data[ dataOff + d·width ] Re-rank a materialized candidate set — fetch values, then top-K in the browser fetch each candidate's value(d) offsets sorted & coalesced into a few spans → one concurrent wave, mirroring the record store 2019 2021 2015 2023 d3 d7 d12 d18 pub_date column topk(candidates, k, descending) — partial-sort client-side candidates · citation rank d3 d7 d12 d18 pub_date ↓ re-ranked · newest first d18 d7 d3 d12 ties keep primary rank → “newest, then most-cited” A few KB per page; the multi-GB column data is never read whole — only the candidates' cells. Full second index — a second .rrs reindexed in secondary order + a u32 permutation column (secondary_docid → primary_docid). A result page is a contiguous run, so slice_u32(start, len) maps it back to primary IDs in one ranged read. Records & facets stay keyed by primary ID.
An optional, range-fetchable store of dense values per doc ID — the build-time counterpart of an alternate sort key. A search returns IDs in citation rank; an .rrsc column lets the reader fetch one value per candidate and top-K client-side (sort by date, rating, any metric), ties broken by doc ID. The same container also backs a full second index: a u32 permutation column maps secondary_docid → primary_docid, so a results page — a contiguous run — resolves back to primary IDs in a single ranged read.
.rrtTerm index · whole-word, blocked dictionary
Term index — one static file (.rrt) a blocked, front-coded word dictionary + a small resident FST router — one ranged read locates a word, one fetches its posting loaded at boot range-fetched Header 40 B Router FST block last-terms · O(#blocks) Dict blocks front-coded · byte-capped Postings per term · [ tail_size ][ head ][ tail ] magic "RRTI" · version 2 · flags(stemmed, stop-words) · termCount · headBoundary · routerLen · dictLen Router FST — resident, a few MB even for tens of millions of terms maps each block's last term(blockOff << 24) | blockLen router.range().ge(term) → the one block that can hold the term blocks are sorted & contiguous, so the first key ≥ term names its block — no full vocabulary in RAM (that was v1's monolithic FST; v2 dropped it). O(#blocks) resident · O(vocabulary) range-fetched on demand one dict entry — front-coded against the previous term [ shared : uvarint ] bytes shared w/ prev term [ suffixLen ][ suffix ] term = prev[..shared] + suffix [ headOffΔ : uvarint ] posting offset, delta-coded [ headSize : uvarint ] head posting byte length scholarly vocabulary shares long prefixes → front-coding pays. Reader — resident router, then two ranged reads. boot header (40 B) + router FST → kept in memory. per word router → one dict-block fetch → front-coded scan → (head_off, headSize) → fetch the head posting (AND across words, smallest first). postings are the RRS [head][tail] rank split verbatim — doc IDs descend by citations, so top-K lives in the head; tails fetched only when a page needs more. Rust TermIndex::open(f).search(query, limit) → ranked doc IDs · .complete(prefix) → autocomplete
A whole-word inverted index: the dictionary is a blocked, front-coded sorted-string table (the Quickwit/tantivy-sstable shape) with a small resident FST routing over block boundaries — only the router (O(#blocks)) stays in memory while the dictionary blocks range-fetch on demand, so a full-corpus vocabulary loads in a browser. Each query word costs one block read plus one posting read, versus ~(L−2) trigram fetches; stemming and stop-word removal are recorded in the header so the reader tokenizes queries identically. The postings region is byte-identical to the trigram .rrs, so doc IDs and the head/tail rank split carry straight over.
.rrbBM25 impacts · lexical relevance as a sidecar
BM25 impact sidecar — companion file (.rrb) lexical relevance for the term index — one precomputed impact byte per (word, doc), addressed by the posting bitmap itself loaded at boot range-fetched Header 64 B Sparse index every 512th key · 8 B Entry table 20 B/term · sorted by head_off Impacts 1 B per (word, doc) · posting order magic "RRSB" · ver 1 · scale · k1 · b · avgdl · termCount · stride · docCount The impact byte — BM25's per-doc half, baked at build s = tf·(k1+1) / (tf + k1·(1 − b + b·dl/avgdl)) byte = round(255 · s / (k1+1)) ∈ 1…255 term frequency tf AND the doc-length norm dl/avgdl are folded into the byte — no tf file, no norms file, no per-query math beyond a weighted sum. idf is free too: df is the posting cardinality the reader already has Addressing — the posting bitmap IS the index the .rrt lookup already resolved each query word to its posting offset head_off (unique per word) and its roaring bitmap, so: entry: sparse → one ~10 KB stripe → (impactsOff, df) byte: impactsOff + rank(doc) − 1 rank(doc) = how many of the word's docs precede this one — an O(1) roaring query. No term IDs, no second dictionary: the .rrt is untouched. Reader — candidate-window rerank boot header + sparse index (~3 MB for 187M terms) → kept in memory. per query term search → first M candidates in citation-rank order → one entry-stripe wave + one coalesced impact-byte wave → score(doc) = Σ idf(word) · byte · scale/255 → top-K by relevance, ties keep citation rank. doc ID == citation rank is authority, not aboutness — the rerank reorders the most-cited matches by lexical relevance; hybrid's vector arm covers docs outside the window. Rust search_bm25(terms, impacts, query, m, k) → [(doc_id, score)] · ImpactIndex::rerank scores ANY mode's candidates (shared doc-ID space)
Search results arrive in citation-rank order — that’s authority, not how well a doc matches the words you typed. The .rrb sidecar adds the missing relevance signal: at build time, BM25’s per-document half (term frequency saturated by k1, normalized by document length) is computed and quantized into one byte per (word, doc); at query time the reader takes the first M matches, fetches just those candidates’ bytes, and reorders them by Σ idf × impact. The trick that keeps it additive: impact bytes are stored in each word’s posting order, so the roaring bitmap the search already fetched is the addressing structure (rank(doc) names the byte) — no term IDs, no norms file, and the .rrt itself is byte-identical to before. Because every index shares the doc-ID space, the same sidecar can rerank trigram and hybrid candidates too.
.rrssSplit set · many immutable splits, pruned queries
Split set — a manifest over many splits (.rrss) Quickwit-style: the manifest names N byte-capped immutable splits; a query prunes to the few that can match and reads only those manifest · resident split · range-fetched Header 64 B Split entries 56 B × splitCount String blob split file names Summary blob Bloom · facets · tombstones magic "RRSS" · policy(rank-tiered | stable-key) · bodyKind(.rrs | .rrt) · tierCount · splitCount · baseCount one split entry — 56 B name → tier → docCount → [docIdLo, docIdHi] byteSize · epoch · summaryOff / Len doc IDs are rank-ordered, so a split's [docIdLo,docIdHi] is its rank band — tier 0 = top-cited; the manifest prunes across splits without opening them. splits — separate files, one plain RRS / RRTI each s0 · tier 0 hot · read s1 · tier 1 cold · pruned s2 … sN cold · pruned each split is exactly today's monolith → one split = one .rrs. a split is a v3 RRS — one posting per term, paged by bucket; a tier-0 split is mostly its eager prefix. Pruning — read only the splits that can match. tier read tier 0, descend only if the page under-fills · Bloom skip a split whose vocabulary can't hold a query term · facet skip a split with none of a filtered category. RRHC boot one .rrhc bundle inlines the manifest + the top tier's split boots, so N splits still boot in 1–2 round trips. base + delta: a small delta of new docs merges at read time (tombstones supersede), compacted into the base later — fresh docs without a full rebuild. Rust SplitSet::open(manifest).search(query, k, fetcher) → ranked doc IDs · pruned splits never fetched
The split set replaces nothing: each split is a vanilla .rrs (or .rrt) — one split is exactly today’s monolith — and only the manifest (.rrss) is new, carrying the cross-split pruning metadata (rank tier, doc-ID range, byte size, and optional term-Bloom / facet-presence / tombstone summaries). The bandwidth win is pruning, not log-structuredness: because doc IDs are rank-ordered, a top-K query reads only the top tier and leaves the cold tail in S3. The tiers size geometrically (per-tier byte caps double down the rank order), so a worst-case descent is ~log-many split visits, and a small base + delta lifecycle absorbs freshly-added docs without a full rebuild. The demo publishes a 19-tier trigram set and a 12-tier term set; each manifest is ~1 KB (no per-split summaries), so the whole set boots in one GET.
05What it costs — honestly

The economics

The unit of cost is bytes moved per query — this demo’s link is bandwidth-bound (measured ~1–3.5 MB/s down, ~150–200 ms RTT), so per-query bytes, not CPU, set wall time. Storage is nearly free — the full 484M index family (trigram monolith + term + vector + records + facet sidecars + the geometric trigram and term split sets + the BM25 .rrb sidecar, ~550 GB) is about $13/month on S3 — and there is no idle cost. Trigram defaults to a regional /search Lambda (~1–3 KB, ~0.66 s, faceting included); the client-side range-read modes — searchable with no backend at all — are one toggle away, and that’s where bytes climb.

What one query moves

What grows with the corpus is the density of common postings — a trigram like ing appears in a constant fraction of documents, so its bitmap grows linearly with the corpus. Measured per query at 484M, by mode (with the one-time resident boot each mode pays on first use):

mode (484M, warm)per queryresident boot
trigram — server (default)~1–3 KBnone
trigram — client geo split~240–300 KB~1.5 KB
trigram — client monolith~0.87–1.07 MB~1.7 MB
term — client geo split~40–270 KB~1 KB
term — client monolith~15–20 KB~76 MB
semantic (8 IVFPQ probes)~2–9.6 MB~64 MB
records page (25 cards)~25–50 KB
client facet filter (membership)~tens of KB
server facet filter~2 KB (+ exact counts)none

The split sets cut client bytes by pruning (read only the tiers that can match) and geometric tiering caps a worst-case descent at ~log-many visits. Faceting is cheap client-side too: the demo post-filters the ranked candidates with a membership read — only the 64K-doc buckets the candidates occupy (container-granularity seeks on the .rrf), not the whole category bitmap — so a category that's tens of MB whole costs ~tens of KB here, and counts come free from the resident facet heads. The server’s facet edge is exact totals + counts, not bytes. The sweet spot is large corpus × modest traffic, and it widens as the corpus shrinks: per-query bytes scale with the corpus, so a smaller index makes the client-side path cheaper and the CDN free tier go further. The demo runs the full 484M corpus.

The monthly bill, vs a backend

Behind CloudFront (1 TB + 10M requests/month free), baselined on the server default (~2 KB/query) and a representative client query (geo split, ~0.3 MB / ~30 range-GETs). The Lambda runs the same intersection over the same files, in-region where S3 bandwidth is free, returning only result IDs + facet counts.

queries / monthserver default (this)client range readsalways-on servermanaged search
10k – 100k~$13 (inside free tier)~$13~$100–150 flat$700+
1M~$20~$30–50~$100–150$700+
10M~$110–150~$300–400~$150+$1,500+
Trade Below a few hundred thousand queries a month, nothing is cheaper or lower-ops than static files — server or client, you’re inside the CDN free tier with no box to babysit, and in the in-browser semantic mode the query text never leaves the browser. For interactive latency on a modest connection the server path wins decisively (KB not MB, no 64–76 MB client boots); the client-side modes stay to demonstrate the no-backend story and its tradeoffs. The two compose: the same artifacts serve both.

So the demo’s production shape is now server-side by default with the client-side range-read modes as the no-backend option. And because the dictionary records every posting’s byte size, the client can estimate a query’s cost before fetching anything and auto-route the expensive ones to the Lambda — the “server-side search” toggle is that routing made explicit.