How Global Search Works
This page explains the search box on the hub landing page — the one that searches every book at once, as opposed to the per-book search inside each track. It assumes no prior knowledge of search engines, mdBook internals, or static site deployment, and it goes all the way down: what the index actually contains, why it is shaped the way it is, the exact ranking formula, and every failure mode that had to be designed around.
Nothing here is a dependency you must install. The whole feature is one build script, one JavaScript file, and one stylesheet.
Table of Contents
- 1. The problem
- 2. What mdBook's search already is
- 3. Approaches that were rejected
- 4. The design
- 5. The build step
- 6. The client
- 7. Performance, measured
- 8. Failure modes and the fixes
- 9. Adding a new book
- 10. File map
- 11. References
1. The problem
This site is not one book. It is a hub book plus one independent mdBook per role
track, each with its own book.toml, its own SUMMARY.md, and its own build
output under dist/book/<slug>/. That structure is deliberate: each track stands
alone, with a focused sidebar and a search scoped to its own material.
The cost of that structure is that mdBook's built-in search is scoped to one book. Standing on the hub landing page, there was no way to ask "which track covers PagedAttention?" without opening books one at a time and searching each. With 18 books and 23,418 indexed sections, that is not a workable way to find anything.
The goal: one search box on the landing page, covering everything, on a static site with no server, no database, and no search service — and with new tracks appearing in it automatically, since new tracks get added regularly.
2. What mdBook's search already is
Before building anything, it is worth knowing exactly what mdBook already produces, because the whole design is built on reusing it.
When search is enabled (the default), mdBook builds a client-side search index at
build time and writes it next to the HTML as searchindex.js. The browser loads it
and searches happen entirely in the page — there is no server involved at any point.
The engine is elasticlunr.js, a fork of lunr.js.
The unit of indexing is not the page. mdBook splits each page at its headings, so
one document in the index is roughly "one section under one heading". That is why a
result can deep-link to page.html#some-heading rather than dumping you at the top of
a long page.
2.1 Inside searchindex.js
The file is a single assignment:
window.search = Object.assign(window.search, JSON.parse('{ ...the whole index... }'));
The JSON has two parts that matter here:
| Field | What it holds |
|---|---|
doc_urls | Array of page.html#anchor strings, one per indexed section |
index.documentStore.docs | Map of document number → { title, body, breadcrumbs } |
title is the heading text, body is the section's text content, and breadcrumbs
is the trail of ancestor headings joined with » — e.g.
Track D — ML and Inference Infrastructure » The Eight ML-Infrastructure Designs » m02 — The KV Cache Tier.
The rest of the file — the large majority of it — is elasticlunr's inverted index: a token trie mapping every stemmed term to the documents containing it, with term frequencies for scoring. That is the part that makes search fast, and it is also the part that makes the file enormous.
2.2 The size problem, measured
For this repo, as built:
| Quantity | Size |
|---|---|
| All source markdown | 21.9 MB |
All 18 mdBook searchindex.js files | 119.8 MB |
| Just the section bodies from those indexes | 18.25 MB |
The index is roughly 5× the source text, and the document store — the actual words — is only about 1× of it. The other 4× is the token trie.
That ratio is the single most important fact in this design. It rules out the obvious approach and points directly at the one that was taken.
3. Approaches that were rejected
Merge the elasticlunr indexes into one. This is the intuitive answer, and it fails on arithmetic: 119.8 MB of index, and elasticlunr indexes cannot be concatenated anyway — merging means re-indexing every document, so the browser would have to build a 120 MB structure at page load. Rejected.
Load each book's index lazily and search them one at a time. Correct results, but the largest single book's index is 18 MB on its own. Searching "everything" would still mean pulling most of the 119.8 MB. Rejected.
A search service (Algolia, Typesense, …). Works well, and is the right answer for many sites. It means an external account, an API key in the build, an upload step on every deploy, and content leaving the repo. For a personal curriculum site that must also work when opened straight off disk, that is a large amount of moving machinery. Rejected.
Pagefind. The strongest alternative, and worth naming explicitly: it is purpose-built for exactly this problem, shards its index so the browser downloads only the fragments a query needs, and would have handled multi-book merging. It was not adopted because it adds a binary/npm dependency to the build for a corpus where the entire document store is 18 MB — small enough to handle directly — and because doing it by hand keeps the whole mechanism inspectable in ~400 lines. If this corpus grows by an order of magnitude, Pagefind is the migration to make.
What was chosen: throw away the token trie, keep the document store, and match in the browser. The trie is what makes elasticlunr fast at scale; at 23,418 sections a plain scan is fast enough, as measured below.
4. The design
4.1 Two tiers
The index is split so that the first keystroke is never waiting on a large download.
Tier 1 — headings (search/headings.js): every section's title, breadcrumb trail,
page URL, anchor and owning book, for all 18 books. Loaded eagerly on page load.
2.00 MB raw / 0.41 MB gzipped. This alone gives instant search across every heading
in the site.
Tier 2 — bodies (search/body/<slug>.js): the section text, one shard per book.
Loaded in the background smallest book first, after the headings land. 18.25 MB raw
/ 6.26 MB gzipped across 18 shards. Results re-rank as each shard arrives, and the
status line shows loading full text 7/18… so the incompleteness is visible rather
than silent.
Full-text loading can be turned off entirely with the full text: on/off toggle next
to the status line; the choice is remembered in localStorage. With it off, the
feature costs 0.41 MB and still searches every heading.
4.2 Columnar layout and interning
Tier 1 is loaded on every visit, so its size is worth optimising. Two transformations cut it by more than half.
Columnar, not row-wise. Instead of an array of {url, title, breadcrumbs, book}
objects, the file holds parallel arrays: p, a, t, r, b. Every repeated JSON
key name disappears.
Interning the repetitive fields. A page contributes many sections, and every one of
them repeats the same page URL and nearly the same breadcrumb trail. So the file holds
two lookup tables — pages and trails — and each section stores an integer index
into them. The measured redundancy:
| Count | Ratio | |
|---|---|---|
| Sections | 23,418 | — |
| Unique page URLs | 1,799 | 13.0× |
| Unique breadcrumb trails | 1,716 | 13.6× |
Result: 5.27 MB → 2.00 MB raw, and 0.75 MB → 0.41 MB gzipped.
Interning pays a second dividend at runtime. Matching is case-insensitive, which means lowercasing the searchable text once at load. With interning that is ~1,716 trail strings instead of 23,418 — the same win, in CPU.
Trails are stored with the section's own title stripped off the end. mdBook's breadcrumb ends with the section itself, and echoing the heading back directly underneath it is noise. (See §8 — this needed more care than it first appears.)
4.3 Why the index ships as .js, not .json
The index files are JavaScript that assign into window.__hubSearch, and the client
loads them by injecting <script> tags — not by fetch()ing JSON.
The reason is file://. Browsers treat local files as opaque origins and refuse
fetch() against them. A JSON index would make global search dead whenever
dist/book/index.html is opened directly from disk rather than served over HTTP — and
these books do get opened from disk. A <script> tag has no such restriction.
This is not a novel trick; it is precisely why mdBook itself ships searchindex.js
rather than searchindex.json, and why per-book search keeps working off disk.
The payload is still JSON — each file is JSON.parse('…') on a string literal rather
than an inline object literal, because JSON.parse on a large string is markedly
faster than having the JS engine parse an equivalent object literal.
5. The build step
All of it lives in tools/build-search-index.mjs,
run by build.sh after every book has been built.
Ordering matters: the hub's build-dir is the site root dist/book/, and mdBook
wipes its build directory at the start of a build. The hub must therefore build first
(or it would delete every role book underneath it), and the search index must be
written last (or the hub build would delete it).
5.1 Discovering books
Nothing is hardcoded. The generator globs for book.toml at depth 1 and 2 below the
repo root, skipping build output and dependency directories, and reads each one for
its title, src and build-dir. The build-dir is what maps a book to its slug on
the site (../dist/book/red-team-engineer → red-team-engineer; the hub's own
../dist/book → the empty slug, the site root).
build.sh discovers books the same way, so the two can never disagree about what
the site contains.
One extra condition: a directory needs a SUMMARY.md as well as a book.toml to
count. A scaffolded track that has a config but no table of contents yet is skipped
with a notice, because mdBook aborts on it and would take the whole deploy down.
Optionally, a book can override the short name shown on its result badges — the automatic rule (take the part before the em-dash) turns "Software Engineer — Rack Management (Senior / Staff)" into the uselessly generic "Software Engineer":
[hub]
short-title = "Rack Management"
mdBook ignores unknown top-level tables, so this costs the build nothing.
5.2 Reading each book's index
searchindex.js has a content hash in its filename and its exact wrapper has changed
across mdBook versions, so rather than parse it with a regex the generator executes
it in a Node vm context with a stub window, and reads back what it assigned:
const context = vm.createContext({ window: { search: {} } });
vm.runInContext(fs.readFileSync(indexPath, 'utf8'), context, { timeout: 30_000 });
return context.window.search;
This works with any mdBook version's wrapper and needs no unescaping. The input is a file generated seconds earlier by the same build, so there is no untrusted code here.
Section bodies are whitespace-collapsed and capped at MAX_BODY_CHARS = 3000
characters on a word boundary — enough for matching and teasers on a section, without
letting one enormous appendix dominate a shard.
5.3 What gets written
Everything lands under dist/book/search/:
| File | Contents |
|---|---|
manifest.js | Per book: slug, full title, short title, offset/count into the section columns, shard filename, byte size |
headings.js | pages, trails lookup tables + the p/a/t/r/b section columns |
body/<slug>.js | Section bodies for one book, aligned to that book's slice of the columns |
The offset and count in the manifest are what let a shard be a plain array: book
n's bodies map onto sections offset … offset + count - 1. The manifest's bytes
field is what the client sorts on to load smallest-first.
The generator prints a per-book section count and the raw/gzip sizes on every build, so a regression in index size is visible in the deploy log.
6. The client
hub/global-search.js,
loaded via additional-js in the hub's book.toml, mounting into a
<div id="global-search"> in the hub's README.md. It is plain ES5-style script — no
build step, no framework, no bundler.
6.1 Loading
manifest.js → headings.js → background shards. Each is a script injection whose
promise resolves once the file has assigned its slot on window.__hubSearch; failure
to load is reported in the status line rather than left silent.
A query typed while the index is still downloading is picked up and re-run the moment the headings land — otherwise an early typist gets a permanently empty result list.
6.2 The ranking formula
Every term must appear somewhere in a section for it to match at all (AND, not OR). Per term, per field:
| Field | Points |
|---|---|
| Section title | 10 |
| Breadcrumb trail | 3 |
| Body text | 1 |
Then, per result:
| Bonus | Points |
|---|---|
| Whole query as a phrase in the title | +20 |
| …in the trail (if not in title) | +8 |
| …in the body (if not in title or trail) | +6 |
| Title equals the query exactly | +25 |
Shallow breadcrumb (max(0, 4 − depth)) | +0 … +4 |
The shallow-breadcrumb bonus is a deliberate bias toward general pages: given a phase overview and a sub-sub-section that both match, the overview is usually the better landing point.
Ties break on document order, which keeps results from the same page adjacent. The top
MAX_RESULTS = 200 are kept, PAGE_SIZE = 25 render at a time, and the search itself
is debounced 110 ms behind typing.
Sections whose shard has not loaded yet simply score 0 on the body field — they still match on title and trail, and re-rank once their book arrives.
6.3 Teasers and highlighting
A teaser is a TEASER_CHARS = 190 window of the body centred on the first matching
term, trimmed to word boundaries and ellipsised on both sides.
Highlighting deliberately does not build an HTML string and inject it. It locates
match ranges on the lowercased text, then constructs DOM text nodes and <mark>
elements around those offsets. Escaping is therefore never a concern, and searching for
something like amp cannot corrupt the output by matching inside an HTML entity.
6.4 Interaction
sor/focuses the box. mdBook binds those keys to its own search, so the handler is registered in the capture phase and stops propagation.- Arrow keys walk the results; Enter opens the selected one, or the first if none is.
- The book dropdown restricts matching to a single track.
- The query is mirrored into the URL as
#q=…, so a result set can be linked or bookmarked; the page reads it back on load.
7. Performance, measured
On the full 23,418-section corpus with all 18 body shards loaded, a query takes 8–14 ms end to end — scan, score, sort, render. That is well inside the 110 ms debounce, so typing never queues up work.
The scan is String.prototype.indexOf over pre-lowercased strings. Lowercased copies
of the bodies roughly double their memory (~36 MB resident with everything loaded); the
alternative — lowercasing 18 MB per keystroke, or a case-insensitive regex scan — is
what the memory is bought with.
8. Failure modes and the fixes
Every one of these was hit during development. They are recorded because each is a trap that reappears in any similar feature.
fetch() is blocked on file://. The original implementation fetched JSON and
worked perfectly over HTTP. Opened from disk it showed "Search index unavailable"
while per-book search kept working right beside it. Fixed by shipping the index as
scripts (§4.3). A DOM test harness could not
have caught this — it stubbed fetch, so it never modelled the restriction. A real
browser did.
Two search boxes on one page. mdBook's own search bar sat above the global one on the hub. The hub book is a single page, so its index holds four sections — a search for "llm" there returns one hit from the page's own table, which reads exactly like the global search being broken. mdBook's search UI is now hidden on the hub only; per-book search is untouched.
Invisible highlights. <mark> inherited the body text colour. Every mdBook theme's
highlight colour is a pale wash, so matched words in teasers rendered as solid blocks
with the word invisible inside. Only a screenshot showed this — the DOM was correct.
The mark colour is now pinned to dark ink, legible on all four themes.
Breadcrumbs echoing the title. Stripping one trailing crumb was not enough: a page
whose H1 matches its SUMMARY.md entry contributes the same text twice, so the
trail still repeated the heading. Trailing crumbs are now popped until they stop
matching — verified at 0 duplicates across all 23,418 sections.
U+2028 in generated JavaScript. Line/paragraph separators are legal inside JSON but
were only legalised inside JS string literals in ES2019. The generator escapes them —
and the escaping code must itself spell them \u2028 / \u2029 rather than
embedding the literal characters, which silently terminated the regex literal they
were written into.
mdBook wipes its build directory. The hub builds into the site root, so build order is load-bearing: hub first, role books after, search index last. Rebuilding the hub alone during development deletes every other book.
Unhashed assets and edge caches. additional-js files are not content-hashed by
mdBook, so a browser can hold a stale global-search.js across a deploy. A hard reload
fixes it. Separately, immediately after a deploy a CDN edge can still serve the
previous HTML for a short window — during verification one request returned the old
page while a simultaneous one returned the new.
9. Adding a new book
Drop a directory containing book.toml and SUMMARY.md at depth 1 or 2 of the repo,
with build-dir = "../dist/book/<slug>". That is the whole procedure.
build.sh discovers and builds it, the verification step derives its expected output
path from its own build-dir, and the generator indexes it into the global search.
There is no list of books to update anywhere.
10. File map
| Path | Role |
|---|---|
tools/build-search-index.mjs | Build-time generator: discovery, parsing, packing, output |
hub/global-search.js | The widget: loading, ranking, rendering, keyboard |
hub/global-search.css | Styling in mdBook theme variables; hides mdBook's own search on the hub |
hub/README.md | Contains the <div id="global-search"> mount point |
hub/book.toml | additional-css / additional-js wiring |
build.sh | Book discovery, build order, output verification, index generation |
dist/book/search/** | Generated output — not committed |
11. References
- mdBook documentation — and its
output.html.searchconfiguration - mdBook
search.rs— how the per-book index is built, and the source of thesearchindex.jsformat - elasticlunr.js — the engine behind mdBook's search
- Pagefind — the purpose-built alternative, and the migration path if this corpus grows an order of magnitude
- MDN: Fetch API and
origin of
file:URLs — whyfetch()fails off disk - MDN:
Node.prototype.appendChild— DocumentFragment semantics used when rendering result batches - ECMAScript 2019: JSON superset — U+2028 / U+2029 in string literals
- Cloudflare Pages build configuration —
where
build.shruns