Spike: Orama - #3372
Conversation
bed6fe0 to
b4a37d7
Compare
bbe6232 to
452365d
Compare
|
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. |
Correction and staging resultsThe 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 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 It works, and on a fast connection it feels fine
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.
Download time only; arithmetic from the measured wire sizes, not measured directly. RecommendationClose 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 Worth keeping from this spike if Pagefind is ever revisited: reusing |
452365d to
ec81dd4
Compare
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>
e4870cd to
344c86c
Compare
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>
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.jsonengine with Orama, behind theSearchEngineseam from #3369. Body text is searchable for the first time.The corpus is the markdown
llm-md-emitteralready writes intodist/docs/, so there is no second extraction pipeline to keep in step — the emitter runs the same eligibility predicate search uses, so redirect stubs andnavSearch: falsepages 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.
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: brfor 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
eagerflag 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:
loadrestores the data but the tokenizer comes fromcreate, so building with the English stemmer and restoring without it compares unstemmed query terms against stemmed index terms. Searchingvariablesmatched 5 pages instead of 321 and no typo matched at all, while search kept returning results throughout.stopWordsto an empty list, sohow,doand 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.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-persistencecannot be bundled for a browser worker — it reaches for Node's filesystem and buffers, and the worker dies on import withClass extends value undefined. Coresave/loaddo the same job and are what this uses.facetsrather than being counted from returned hits, which capped them at the worker's result limit.Plausible.astrostill listens for asearchedevent that nothing fires. Left for whichever spike merges.🤖 Generated with Claude Code