Skip to content

Spike: Orama - #3372

Closed
enf0rc3 wants to merge 11 commits into
mainfrom
willlaugesen/docs-search-orama
Closed

Spike: Orama#3372
enf0rc3 wants to merge 11 commits into
mainfrom
willlaugesen/docs-search-orama

Conversation

@enf0rc3

@enf0rc3 enf0rc3 commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Spike 2 of 2 for the search engine bake-off. Branched off #3369, so that PR's commit is in this diff. Pagefind is #3371.

Closing unmerged. Pagefind won the bake-off — the comparison is at the bottom of this description. The branch stays for reference.

What it does

Replaces the hand-rolled search.json engine with Orama, behind the SearchEngine seam from #3369. Body text is searchable for the first time.

The corpus is the markdown llm-md-emitter already writes into dist/docs/, so there is no second extraction pipeline to keep in step — the emitter runs the same eligibility predicate search uses, so redirect stubs and navSearch: false pages are gone before this sees them.

The whole index is downloaded and restored into memory once, in a worker, and every query after that costs nothing. Restore is CPU-bound and would freeze the page while the overlay is open, which is why it is off the main thread.

Deletes 645 lines of custom search code: a Porter stemmer, a synonym map, the string helpers and the legacy engine.

Results

Measured through the search overlay against expected URLs written before each run. Two query sets, both from analytics and both weighted by traffic.

Query set Engine Top 5 Rank 1 MRR
Real searches, 57 terms Orama 80% 52% 0.635
Legacy 56% 38% 0.471
Top pages, 98 terms Orama 90% 79% 0.842
Legacy 89% 57% 0.716

Orama's real strength is the shape of the queries it handles, which is where an as-you-type overlay lives:

Orama Pagefind Legacy
Typo queries 67% 42% 42%
Top 5 on a partial query 72% 57% 49%
Head terms 94% 97% 81%
Natural language 73% 73% 67%
Legacy Orama
Index on the wire (brotli) 0.35 MB 0.66 MB
Parsed per page load 1.74 MB 5.33 MB
Cold first search, adjusted 699 ms 3950 ms
Warm search 486 ms 326 ms
Return visit 482 ms 288 ms
Keystroke p95 307 ms 324 ms

Once the index is in memory Orama is the fastest thing measured. Getting it there is the problem: it needs about five seconds of reading time before the first search is ready.

Configuration that matters

Four settings that are easy to get wrong and were, at first:

  • The worker must create its database with the same tokenizer the index was built with. load restores the data but the tokenizer comes from create, so building with the English stemmer and restoring without it compares unstemmed query terms against stemmed index terms. Searching variables matched 5 pages instead of 321 and no typo matched at all, while search kept returning results throughout.
  • Stop words are not on by default. Orama's tokenizer defaults stopWords to an empty list, so how, do and 178 others were live search terms. The list ships separately as @orama/stopwords.
  • sort: { enabled: false } — nothing here sorts, and the sort store was 2.69 MB of the index.
  • The stored body is trimmed to 200 characters after save(). The inverted index keeps every term; this is only the copy the result excerpt is cut from.

Together the last two took the index from 11.43 MB to 5.33 MB with rankings unchanged. Ranking was then tuned separately: boost: { title: 8, ... }, and the same landing-page reorder Pagefind needed, because a page ranking below its own children was a defect in every engine tested.

Why Pagefind was chosen

Relevance did not decide it. On the real search log the two are level — 81% against 80% in the top five, and identical at rank one. On the terms that carry the traffic Pagefind is clearly ahead, 98% against 90%.

Three things decided it:

  1. Payload. 0.66 MB on the wire and 5.33 MB parsed, against Pagefind's 0.08 MB and 0.11 MB. Every visitor pays that on every page load whether they open search or not.
  2. Cold start. 3950 ms against 1821 ms, and Orama needs roughly five seconds of reading time before its first search is ready.
  3. The 8 MB compression cliff. Front Door only compresses responses between 1 KB and 8 MB. At 5.33 MB there is headroom, but the corpus grows, and crossing it would stop compression silently and return the cold start to about 5.7 s. That is a latent failure tied to a number nobody watches.

What was given up is real and worth recording: typo tolerance drops from 67% to 42%, and top-five on a partial query from 72% to 57%. An as-you-type overlay spends most of its life on partial queries, so this is the cost of the decision rather than a rounding error.

Notes

  • 8 .mdx pages are missing from the index. All of them import .astro components, which the markdown emitter cannot resolve. Not fixed here.
  • @orama/plugin-data-persistence cannot be bundled for a browser worker — it reaches for Node's filesystem and buffers, and the worker dies on import with Class extends value undefined. Core save/load do the same job and are what this uses.
  • Facet counts come from Orama's own facets rather than being counted from returned hits, which capped them at the worker's result limit.
  • Orama returns whole documents rather than excerpts, so the result row cuts its own window around the first matched term.
  • Plausible.astro still listens for a searched event that nothing fires.

🤖 Generated with Claude Code

@enf0rc3
enf0rc3 force-pushed the willlaugesen/docs-search-orama branch from bed6fe0 to b4a37d7 Compare August 17, 2026 21:28
@enf0rc3
enf0rc3 force-pushed the willlaugesen/docs-search-orama branch 3 times, most recently from bbe6232 to 452365d Compare August 17, 2026 22:42
@team-marketing-branch-protections

Copy link
Copy Markdown

Pull request environment is available at https://stoctodocspr3372.z22.web.core.windows.net.

You can view the ephemeral environment status in Octopus Deploy.

This environment will be automatically deprovisioned when the pull request is closed, or after 7 days of inactivity.

@enf0rc3

enf0rc3 commented Aug 17, 2026

Copy link
Copy Markdown
Contributor Author

Correction and staging results

The 2.4 MB gzipped figure in the description is not achievable on this hosting. Azure Blob static website hosting does no on-the-fly compression — requesting Accept-Encoding: gzip returns the full 11,989,438 bytes with no Content-Encoding header. Verified on this PR's own ephemeral site:

curl -H "Accept-Encoding: gzip" .../docs/search-index.json
200  wire=11989438b  type=[application/json]   # no Content-Encoding

Nothing on that host is compressed, Pagefind's assets included. So the real wire cost for Orama is 11.4 MB, not 2.4 MB. Getting the compressed figure would need the microsite-deployment pipeline to pre-compress and set Content-Encoding at upload. Production sits behind Front Door which can compress, but 11.4 MB is above its documented 8 MB ceiling, so it probably would not help there either — worth confirming before relying on it.

It works, and on a fast connection it feels fine

https://stoctodocspr3372.z22.web.core.windows.net/docs

tentacle → 200 results, All 200 / Docs 151 / API 7 / CLI 42. Cold search 2.16s, which is actually a shade faster than Pagefind's 2.31s on the same connection. Results are relative and stay on staging.

That is the honest picture at ~47 Mbps: the download is 2 seconds and you would not notice. The gap is entirely about what happens below that.

Connection Orama 11.4 MB Pagefind 278 KB
47 Mbps (measured) 2.0s ~0.05s
10 Mbps (4G) 9.6s 0.2s
1.6 Mbps (Fast 3G) 60s 1.4s

Download time only; arithmetic from the measured wire sizes, not measured directly.

Recommendation

Close this in favour of #3371. Payload was always the deciding axis and this makes it worse, not better — every visitor who searches pays 11.4 MB uncompressed, against 1.86 MB today. Typo tolerance, the one thing that could have justified the cost, returns nothing for kuberntes.

Worth keeping from this spike if Pagefind is ever revisited: reusing llm-md-emitter's output as the corpus worked cleanly and needed no second extraction pipeline, and Orama ranked target tag and RBAC better than Pagefind does.

@enf0rc3
enf0rc3 force-pushed the willlaugesen/docs-search-orama branch 2 times, most recently from e4870cd to 344c86c Compare August 18, 2026 02:24
enf0rc3 and others added 9 commits August 19, 2026 08:43
Builds an Orama index from the markdown llm-md-emitter already writes into
dist/docs, and puts it behind the SearchEngine seam. Body text is indexed for
the first time, so a phrase that appears in an article but not its title or
headings is now findable.

Reusing the emitted markdown means there is no second extraction pipeline: the
emitter runs the same eligibility predicate search uses, so redirect stubs and
navSearch:false pages are already gone. 1,253 pages indexed.

The index is restored in a Web Worker. Restore is CPU-bound and would otherwise
freeze the page for as long as it takes, with the overlay open and taking
keystrokes.

Uses Orama core save/load rather than @orama/plugin-data-persistence, which
reaches for Node's filesystem and buffers and fails to bundle for a browser
worker.

Removes the search.json endpoint and the client scoring it fed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The worker created its database with only a schema, so query terms were
tokenized without the English stemmer the index was built with. Searching
`variables` matched 5 pages instead of 321, and typo tolerance matched nothing
at all. Search kept returning results throughout, so nothing failed loudly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three changes to what the index carries, none of which change what it can find.

Nothing in the engine sorts, but Orama builds and serializes a sort store for
every sortable field unless it is turned off. That was 2.69MB.

Each document was stored whole, so 2.2MB of page text shipped to every visitor
purely so the excerpt could be cut from it client-side. The inverted index is a
separate structure and keeps every term, so trimming the stored copy to 200
characters costs no recall. The excerpt window is 180.

Orama defaults stopWords to an empty list, so `how`, `do`, `the` and 177 others
were live search terms. The list ships separately as @orama/stopwords. Measured
against the deployed index this is worth 80% to 87% on natural-language queries.

The index goes 11.4MB to 5.3MB raw, and 1.32MB to 0.66MB brotli. That is below
the predicted 6.53MB because the stop-word list shrinks the index as well, which
had not been measured before.

sort and the tokenizer are set in both the integration and the worker, for the
same reason: the worker restores into a database it creates itself, so anything
the index was built with has to be declared on both sides.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The engine asked the worker for 200 hits and counted sections over them, so
every count saturated: a search for `variables` showed All (200) when the real
total is 321. It also filtered those 200 client-side, so a section whose matches
fell outside them came back short.

The worker now asks for facets, which report the whole match set, and applies
the selected section as a `where` clause so the filtered list is complete.

Counts come from an unfiltered search on purpose. A `where` clause narrows the
facet values to the section being filtered on, and the strip has to keep showing
what the other tabs hold.

Verified against the built index: All (321), Docs (290), API (19), CLI (12), and
selecting CLI returns all 12.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
excerptFrom preferred the description outright, so any page with a subtitle
showed its subtitle even when the match that earned it its rank was in the body.
It now picks whichever field the query actually hit, falling back as before.

BODY_LIMIT stays at 4,000, now as a measured decision rather than an assumption.
Scored against the bake-off query set with a local build:

  2,000    4.28MB  Success@5 78%  intent 87%
  4,000    5.33MB  Success@5 78%  intent 87%
  20,000   6.63MB  Success@5 76%  intent 80%

Raising it is worse and bigger — the extra text dilutes the terms that identify
a page, which is the opposite of what the audit expected. Lowering it scores the
same and saves 1.05MB, but narrows what is findable on long pages to buy bytes
the payload budget does not need: brotli is 0.66MB against a 0.81MB gate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
26% of top-5 rows rendered with a blank excerpt — 127 of 482 across the query
set. Two things compounded, and the first was mine:

`?? hit.description` treats an empty string as a value worth keeping, so it won
the fallback and blocked `hit.body`. 1,200 of the 1,254 documents have an empty
description and none have an empty body, so that path was taken almost every
time it was reached.

It was reached far more often than it looks, because the index matches stemmed
terms and the excerpt matches literally. "Guided failures" legitimately ranks a
page whose text only ever says "guides" — both stem to "guid" — and the literal
regex then finds nothing, falling straight into the trap above.

`||` instead, with body first. When nothing matches literally the window opens
at the start of the page, which is what a reader wants from a stemmed match; it
just arrives unhighlighted.

Trimming the stored body to 200 characters did not cause this. At 4,000 a
literal match usually turned up somewhere by luck, so it hid the bug.

Measured against the same 102 queries: blank rows 127/482 to 0/482, and queries
with a blank in the top five 50/102 to 0/102. The highlightable rate is
unchanged at 72%, which is the point — this replaces blanks with real text
rather than inventing highlights for stemmed matches.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`warm()` only ran when the overlay opened, so the whole index was fetched and
parsed while the reader waited. It now starts on the first pointer or focus
reaching a search field, which costs nothing for the majority who never go near
it.

`SearchEngine` carries the policy rather than the overlay assuming one, because
the right answer differs by engine: an engine whose runtime is small enough can
warm on page load, and this one cannot — 5.3MB spent on every visitor, searcher
or not. Ctrl/Cmd+K goes straight to open, which still warms, so the policy only
decides how much earlier it can start.

Measured against a local build on one server, so caching is held constant:
778ms to first result without the hover, 646ms with. The saving is smaller than
the load cost because the load is not what dominates — roughly 500ms of that
646ms is the query itself, which is worth looking at separately now that facet
counts are computed over the whole match set.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Main published 106 generated pages under docs/api and deliberately kept them out
of search and the sitemap until the section has a landing page. That check lived
in search.json.ts, which this branch deletes, so it had to move with the index
rather than be lost with it.

It does not come along on its own. The Orama index is built from what
llm-md-emitter writes, and the emitter's eligibility check knows nothing about
the section — those pages carry a layout and no `navSitemap: false`, so they
pass. The emitter now writes 1,360 files where it wrote 1,254, and all 106 of
the difference would have been searchable.

Indexed count is back to 1,254, with nothing under /docs/api/ in the index.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@enf0rc3
enf0rc3 force-pushed the willlaugesen/docs-search-orama branch from 91035c7 to eece801 Compare August 18, 2026 20:49
enf0rc3 and others added 2 commits August 19, 2026 09:26
BM25 has no notion of a site's shape, so `variables` returned
projects/variables/system-variables and `tentacle` returned tentacle/linux: a
child page repeats the term more often in less text. On the search terms readers
actually type this was the largest single source of missed traffic.

Two changes. The title boost goes from 4 to 8, worth 26 points of Success@5 on
its own, since most searches are one or two words naming a page. Then hits are
reordered so a page the query names outright comes first, and everything else is
scored with a penalty per path segment.

Measured against three query sets — real search terms, top-visited pages, and a
curated set — rather than only the one it was tuned on. On real search terms
Success@5 goes 54% to 80% and rank one 17% to 51%. Deep pages still rank first
when they are what was asked for.

A tiebreak on depth was tried first and did nothing: BM25 scores almost never
tie, so depth has to scale the score.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The test read /docs/search.json, which this branch deletes along with the rest of
the legacy engine, so it was parsing a 404 page as JSON.

It now drives the search overlay instead. The two spikes ship indexes of different
shapes — one JSON document, one directory of compressed chunks — and neither is
readable the way the old one was, but what has to hold is the same either way: a
reader searching a word the API reference is full of must not be sent into it.

`accounts` names pages both inside the API reference and outside it, so the result
list is never empty. A query that matched nothing would pass without proving
anything.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@enf0rc3

enf0rc3 commented Aug 19, 2026

Copy link
Copy Markdown
Contributor Author

Closing unmerged. We are going with Pagefind (#3371).

Relevance did not decide it. On the real search log the two engines are level — 81% against 80% in the top five, and identical at rank one. On the terms that carry the traffic Pagefind is ahead, 98% against 90%.

Payload and cold start decided it. Orama ships 0.66 MB on the wire and 5.33 MB parsed against Pagefind's 0.08 MB and 0.11 MB, and every visitor pays that on every page load whether they open search or not. First search is 3950 ms against 1821 ms, and Orama needs about five seconds of reading time before it is ready. Behind both sits the 8 MB compression cliff: Front Door only compresses between 1 KB and 8 MB, so a corpus that grows past it would silently lose compression and take the cold start back to about 5.7 s.

What we are giving up is real. Typo tolerance drops from 67% to 42%, and top-five on a partial query from 72% to 57%. An as-you-type overlay spends most of its life on partial queries, so that is the price of the decision rather than a rounding error. Worth revisiting if search analytics later show typo and partial queries are a bigger share of traffic than the log suggests.

The branch stays for reference. The updated description above has the full numbers and the configuration notes — the tokenizer mismatch in particular is the kind of thing that fails silently and would cost someone a day.

@enf0rc3 enf0rc3 closed this Aug 19, 2026
@enf0rc3

enf0rc3 commented Aug 19, 2026

Copy link
Copy Markdown
Contributor Author

Archived, so this survives the branch being deleted.

  • search-spike-orama — tag on 438570fc5, this branch as measured. The only tuned Orama integration we have: matched tokenizer, stop words, sort store dropped, body trimmed, ranking swept.
  • search-bakeoff-2026-08 — tag on the measurement harness, with both traffic-weighted query sets, every tuning tool and the decision doc. Also on branch worktree-search-bakeoff-harness.

To re-run the comparison later, start at tools/search-bakeoff/README.md, section Picking this up later. The staging environments both PRs were measured on are gone, so it covers pointing the harness at local builds instead.

Worth re-running when there is real search analytics. Pagefind was chosen knowing it is worse at typos (42% against 67%) and partial queries (57% against 72%), and nobody had data on how much traffic those account for. Plausible.astro listens for a searched event that still nothing fires; once it does, that assumption is checkable.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants