Skip to content

Speed up search by ranking from a title map - #3408

Open
enf0rc3 wants to merge 5 commits into
mainfrom
willlaugesen/search-title-map
Open

Speed up search by ranking from a title map#3408
enf0rc3 wants to merge 5 commits into
mainfrom
willlaugesen/search-title-map

Conversation

@enf0rc3

@enf0rc3 enf0rc3 commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

The problem

🤖 AI Generated PR 🤖

Putting results in order needs each result's URL and title. Pagefind keeps both inside that result's own file, one file per result.

That tied two things together: how many results we could order was the same number as how many files we downloaded. We downloaded 30 files on every search to order 30 results, before a single row could be drawn.

The change

The build writes one map of result id → URL and title, beside the index.

Ordering reads the map, so it can order every match without downloading anything. Result files are then downloaded only for the rows actually on screen.

Why the map, and not just fewer downloads

Cutting downloads from 30 to 10 is the obvious fix. On its own it drops top-five accuracy from 84% to 71%, because only the results you download can be ordered.

The map breaks that link. Ordering reads the map, so every match is ordered however few files come down. This PR downloads 10 and holds 84%.

What does not change

Matching. Pagefind's word index is untouched, so the full body text of every page is searched exactly as before — a phrase from the middle of a page still finds it. The map holds only a URL and a title per page, and is read after matching to decide what order the matches go in.

Effects

before after
requests per search 36 16
first search, Slow 4G 8.8 s 7.3 s
second search, same session 5.9 s 4.1 s
relevance, both query sets 57/84, 90/98 unchanged

The map is 29 KB gzipped, loaded once per page. The first screen draws 10 rows where it drew 30, so 6 of 155 measured queries need one scroll to reach their answer — none ranked any lower.

@team-marketing-branch-protections

Copy link
Copy Markdown

Pull request environment is available at https://stoctodocspr3408.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.

Ranking needed a url and a title, and Pagefind keeps both in the per-page
fragment — so ordering thirty results meant fetching thirty files before the
panel could draw, and a page ranked past thirty could not be reached at all. The
build now writes a map of result id to url and title beside the index, which is
the join Pagefind's own result stub already carries.

So the whole result set is ranked before anything is fetched, and fragments are
fetched only for the rows being drawn: ten per batch, against thirty to
thirty-five for every settled query before. On Slow 4G with the map served
uncompressed, first results arrive in 7.2s against 8.9s; the map is 28 KB
gzipped, so most of that 122 KB is transfer a CDN removes.

The shallow-page search this replaces is gone with it — the second Pagefind
query, the landing filter, LANDING_DEPTH and the attribute it needed on every
page's content div. A page the query names now wins from anywhere in the list
rather than from a shortlist of 227.

Relevance holds on both traffic-weighted sets: real-searches 57% w-S@1 and 84%
w-S@5, top-pages 90% and 98%, unchanged either side.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@enf0rc3
enf0rc3 force-pushed the willlaugesen/search-title-map branch from 176d4d7 to 399b5a1 Compare August 26, 2026 03:36
@enf0rc3
enf0rc3 marked this pull request as ready for review August 26, 2026 04:53
enf0rc3 and others added 2 commits August 26, 2026 17:04
Two things found reviewing the branch before publishing it.

The fallback for a missing or mismatched title map drew results in Pagefind's own
order and never ranked them, while its comment claimed otherwise. That is the
order the reorder exists to correct — a bare BM25 list puts a getting-started
page above the section it belongs to. `draw` now orders each batch as it draws
it when the list reached it unranked, which is what the engine did before the map
existed, and `more()` carries the same flag so later batches match. Verified by
blocking the map: three of four sample queries still land on the right page, and
the fourth is one whose answer ranks past the batch.

A map that matches the index only partly dropped the unmatched results quietly,
because the fallback fires only when nothing joins at all. It now warns with the
counts.

Also removed a stale doc comment left on the Ranked type.

Relevance unchanged: real-searches holds at 57% w-S@1 and 84% w-S@5.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An unhashed name lets a cache serve one build's map against another build's
index. Pagefind hashes its own chunks for that reason, and the map has to be
versioned with them: read against the wrong index it joins against nothing.

The build now reads the hash out of pagefind-entry.json and writes
docs-titles.<hash>.json; the client reads the same file to build the same name.
Two requests where there was one, both while the index is warming and neither on
the path of a search. It also means the map can carry an immutable cache header,
which an unhashed name could not.

The build fails if the index has more than one language, because one map cannot
carry two hashes and the client would look under a name that does not exist.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@enf0rc3
enf0rc3 force-pushed the willlaugesen/search-title-map branch from efad2a1 to 3cf4285 Compare August 26, 2026 05:30
enf0rc3 and others added 2 commits August 27, 2026 08:52
Front Door caches `.js` for a week as immutable and compressed, and gives
anything it does not recognise `no-cache`. It also strips ETags, so revalidating
is a full re-download — and the map loads on every page that opens the overlay.
A `.json` name meant paying 29 KB per page view.

The map is now `docs-titles.<hash>.js`, an `export default`, loaded with the same
dynamic import the engine already uses for `pagefind.js`. It lands in the existing
static-content rule with no infrastructure change, and the hash in the name is
what makes a week of immutable caching safe.

`pagefind-entry.json` stays on the default no-cache rule, which is correct: it
changes every build and the client reads the hash out of it.

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

@borland borland left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While this is technically very amazing, and I can't fault the improved outcome, we're essentially writing code that should be private inside pagefind. The fact that we have to unzip the compressed pagefind fragments, understand their structure, and more makes this incredibly brittle. If pagefind vNext moves things even slightly, it's all going to blow up.

That's an awful lot of complexity and risk to be handing over to the web team; I'm not comfortable with it.

How about, leave pagefind alone, but instead of loading 30 rows in the search dialog, load 20. That will get us some of the improvement, and I'd guess people probably don't read beyond the first 20 anyway

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.

3 participants