Skip to content

Spike: Orama - #3372

Open
enf0rc3 wants to merge 8 commits into
mainfrom
willlaugesen/docs-search-orama
Open

Spike: Orama#3372
enf0rc3 wants to merge 8 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.

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 on the deployed environment, 102 queries, against expected URLs written before the run. Fast 4G with a 4× CPU throttle.

Legacy Orama
Index on the wire (brotli) 0.35 MB 0.66 MB
Parsed per page load 1.74 MB 5.33 MB
Success@5 76% 78%
Success@1 58% 50%
Natural-language queries 67% 87%
Typo queries 42% 58%
While still typing 49% 63%
Warm search 276 ms 256 ms
Return visit 232 ms 201 ms

Better top-five recall than the engine it replaces, and substantially better at natural language, misspellings, and partial queries — which is most of the time the panel is open, since the overlay searches on every keystroke. Legacy still leads rank-one and MRR.

Cold start

The first search of a visit measures 5714 ms on this environment, because Azure static website endpoints serve blobs uncompressed and the raw index is 5.33 MB. Front Door returns Content-Encoding: br for the /docs/ route, and with bandwidth scaled for that ratio the same search is ~1807 ms.

Warming the index on page load rather than on overlay open takes it to 275 ms with two seconds of reading time first. That is not done here — #3371 added an eager flag to the seam for it, and this branch should use it before merging.

The index has to stay under 8 MB. Front Door only compresses responses between 1 KB and 8 MB. At 5.33 MB there is headroom, but crossing it would stop compression silently and return the cold start to ~5.7 s. Worth a build-time assertion alongside the existing page-count check.

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.

Notes

  • @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.
  • Typo tolerance works, but only once the tokenizer matches at both ends. It is worth 16 points on the typo bucket.
  • Plausible.astro still listens for a searched event that nothing fires. Left for whichever spike merges.

🤖 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 mentioned this pull request Aug 17, 2026
@enf0rc3
enf0rc3 force-pushed the willlaugesen/docs-search-orama branch from 452365d to ec81dd4 Compare August 17, 2026 23:15
enf0rc3 and others added 2 commits August 18, 2026 14:21
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>
@enf0rc3
enf0rc3 force-pushed the willlaugesen/docs-search-orama branch from e4870cd to 344c86c Compare August 18, 2026 02:24
enf0rc3 and others added 6 commits August 18, 2026 14:38
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>
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