Search
Find missing documentation from searches with no results
A saved report of the searches your docs can't answer, sorted into pages to write and words to add, with a keyword fix checked before and after.
By Hayden Bleasel9 min read

Blume's search dialog already reports what readers look for. With an analytics adapter configured, each query that settles arrives as a search event with the query, the number of results, and the path it was typed on. Each result a reader opens arrives as a search_select event with its position and url. Filter the searches to results = 0, group them by query, and each term becomes a backlog item: a page to write, or a word to teach a page you already have.
By the end of this guide you have a saved query for searches that found nothing, one for searches whose results nobody opened, a way to sort each term into a gap, a mismatch, or noise, a keyword fix checked before and after, and a loop to run again. The queries are PostHog SQL, since PostHog keeps every event property and answers SQL. Any adapter that forwards properties works the same way.
Two limits up front. This only counts readers who allow analytics, so if you can't collect reader input at all, write eval questions from your support tickets instead. And the zero-result count is a lead, not a score: it tells you what readers typed, not whether the pages they found answered them.
What the dialog sends
Blume records two events, whichever search adapter you use:
| Event | Sent when | Properties |
|---|---|---|
search | A query settles: a second without typing, or the reader opens a result or closes the dialog | query, results, path |
search_select | The reader opens a search result | query, position (from 1), url, path |
A few details decide how you read them:
- The query is what the reader typed, trimmed and cut at 100 characters, with its case kept. Group on a lowercased copy.
resultsis how many results the dialog showed, up to 12, not how many pages matched. It's counted after the dialog's scoping: the docs version being viewed, the reader's language, and any section filter they picked.- A reader who pauses mid-word sends the partial query too, so
webhcan sit besidewebhooks. The same query settling twice in a row counts once. - Opening a Popular link or handing the query to the assistant sends no
search_select. A search that fails, like a hosted engine that doesn't answer, sends nothing. Mixedbread is the exception: when its/api/searchroute fails, the dialog gets an empty list, so the search is recorded withresults: 0.
Send the events somewhere that keeps the query
Blume forwards both events through every analytics adapter with an event API, but not every provider takes properties. PostHog, Mixpanel, Amplitude, Heap, Segment, Hightouch, LogRocket, Adobe, Google Analytics, Google Tag Manager, Plausible, Databuddy, Pirsch, OneDollarStats, and Vercel receive the query. Fathom, Clarity, and Hotjar receive only the event name, and Cloudflare and Clearbit receive nothing, so none of those five can build this report.
Add PostHog, and ask readers before it runs:
import { defineConfig } from "blume";
import { posthog } from "blume/analytics";
import { native } from "blume/consent";
export default defineConfig({
analytics: [
posthog({ key: "phc_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" }),
],
consent: native({ policy: "https://acme.example/privacy" }),
});With consent set, PostHog waits until a reader allows analytics, and a reader who declines sends no search events at all. Other providers need a setup step before the query shows up. In Google Analytics, register query and results as event-scoped custom dimensions. In Plausible, add search as a custom event goal; events sent before the goal existed aren't backfilled. In Google Tag Manager, fire your tags from a Custom Event trigger named search.
Check the events locally
Analytics only loads in production builds, but the dialog also fires every event as a blume:track event on window, and that happens in blume dev too. Start the dev server:
npx blume devOpen your browser's console on the site and listen:
window.addEventListener("blume:track", (event) => console.log(event.detail));Search for a word your docs don't use, wait a second, and the console logs the event:
{ event: "search", props: { path: "/messages/send", query: "unsubscribe", results: 0 } }Open a result and a search_select follows with its position. After you deploy, accept the consent banner, search once on the live site, and check that the same event reaches PostHog.
Treat search terms as reader input
Readers paste things into search boxes: email addresses, API keys, order numbers. Blume sends the query as typed, and your analytics stores it. Google Analytics forbids personally identifiable information and names search boxes as a common way it slips in, and Plausible says the same about custom properties. So:
- Turn on your provider's redaction where it has one. GA4's data redaction removes likely email addresses from event parameters.
- If you forward
blume:trackwith your own code, put that code in ascript()analytics adapter. The event fires for every reader, andscript()adapters wait for consent like the rest. - Never publish the raw export. The queries below drop anything shaped like an email address, a long number, or a token, and keep only terms that at least three different readers searched. Share that output, not the event list.
List the searches that found nothing
Open PostHog's SQL editor and run:
SELECT
lower(properties.query) AS search_term,
count() AS searches,
uniq(distinct_id) AS readers
FROM events
WHERE event = 'search'
AND toFloat(properties.results) = 0
AND timestamp > now() - INTERVAL 30 DAY
AND NOT match(properties.query, '@|[0-9]{6,}|[A-Za-z0-9_-]{24,}')
GROUP BY search_term
HAVING readers >= 3
ORDER BY readers DESC, searches DESC
LIMIT 100PostHog can read an event property as a string, so toFloat makes the comparison numeric. The match pattern also drops long identifiers, so loosen it if readers search for long method names. Save the query as an insight so every run uses the same filters. Here's the output for a synthetic month on the Acme docs, a fictional email and SMS API:
| search_term | searches | readers |
|---|---|---|
| attachments | 34 | 21 |
| unsubscribe | 29 | 22 |
| invoice | 15 | 12 |
| retries | 14 | 11 |
| webhok | 6 | 5 |
Find the searches that found the wrong pages
A zero-result list misses two kinds of failure. On Blume's default Orama index, a query of several words matches pages that contain any of them, so a long question rarely comes back empty. And one word can match the wrong page: on the Acme docs, retry finds the Rate limits page, which mentions the Retry-After header, while the webhook retries readers want sit on a page that says "retried". Opens and positions catch both:
SELECT
lower(properties.query) AS search_term,
countIf(event = 'search') AS searches,
countIf(event = 'search_select') AS opened,
countIf(event = 'search_select') / countIf(event = 'search') AS open_rate,
avgIf(toFloat(properties.position), event = 'search_select') AS avg_position
FROM events
WHERE event IN ('search', 'search_select')
AND timestamp > now() - INTERVAL 30 DAY
AND NOT match(properties.query, '@|[0-9]{6,}|[A-Za-z0-9_-]{24,}')
GROUP BY search_term
HAVING searches >= 10 AND uniq(distinct_id) >= 3
ORDER BY open_rate ASC, searches DESC
LIMIT 50A low open_rate means readers saw results and left without opening one; in the synthetic month, retry has 18 searches and 3 opens. A high avg_position means readers found the page, but further down than it should be. Neither number tells you why, so search the term yourself and read what comes back.
Sort each term into a gap, a mismatch, or noise
Type every term from both reports into the dialog. Each one lands in one of three piles:
| Term | What the docs have | Verdict | Fix |
|---|---|---|---|
attachments | Nothing on sending attachments | Content gap | Write a page |
unsubscribe | A Suppression list page that never says "unsubscribe" | Terminology mismatch | Add keywords |
retries, retry | A Webhooks page that says "retried" | Terminology mismatch | Add keywords |
webhok | The Webhooks page, under the right spelling | Noise: a typo | None, unless it keeps coming back |
invoice | Nothing: billing lives in the Acme dashboard | Noise: out of scope | A short page that links out, or none |
A mismatch is the cheaper fix, so rule one out before calling a term a gap. Blume's default Orama index ignores case and matches the start of a word, but it doesn't correct typos or match other forms of a word: unsub finds a page that says "unsubscribe", but unsubscribed doesn't, and retries doesn't find "retried". Other adapters match differently, so test with the one you run. For a real gap, the path property shows which pages readers searched from, and those are where the new page needs a link.
Fix a terminology mismatch
Add the readers' words to the page's search.keywords:
---
title: Suppression list
description: Stop sending to recipients who asked not to be contacted.
search:
keywords: [unsubscribe, unsubscribed, opt out]
---
Acme adds a recipient to the suppression list when they opt out or when their
address bounces. Messages to a suppressed recipient are skipped.On the Orama index, keywords count as much as the title, and every adapter except Mixedbread searches them. List each form readers type, since unsubscribe won't match unsubscribed. The Webhooks page gets keywords: [retry, retries] the same way. Where the reader's word reads naturally, use it in the page text too, so a reader who lands there sees their term answered.
When the right page shows up but low, a boost of 2 to 5 moves it up. Keep boosts to the few pages readers need most.
Here's the fix on the Acme pages, before and after, on the Orama index:
| Search | Before | After |
|---|---|---|
unsubscribe | No results | Suppression list |
unsubscribed | No results | Suppression list |
retries | No results | Webhooks |
retry | Rate limits | Webhooks, then Rate limits |
attachments | No results | No results |
attachments stays empty on purpose: no keyword fixes a page that doesn't exist.
Fix a content gap
Write the page, and put the readers' word in its title or description, which rank above body text. For attachments, that's a new docs/messages/attachments.mdx titled "Send attachments", linked from the Send a message page that readers searched from. Then add the question they were asking to your evals.yaml, so blume eval fails if a later edit loses the answer.
For an out-of-scope term like invoice, a short page that says where billing lives and links there is often worth it, since readers keep looking for it in the docs. Give it the term as a keyword.
Repeat the same searches
Start with the terms from your report, locally. Run npx blume dev, open search, and type each one: the results should match your after column. Orama and FlexSearch update as you edit. Pagefind only indexes a build, so run npx blume build and then npx blume preview. Algolia, Typesense, and Orama Cloud only change when blume build syncs the hosted index, which needs the adapter's secret key in the build environment (and, for Orama Cloud, an indexId).
Once the fix has been live for a while, rerun the report for the terms you fixed:
SELECT
lower(properties.query) AS search_term,
countIf(event = 'search') AS searches,
countIf(event = 'search' AND toFloat(properties.results) = 0) AS no_results,
countIf(event = 'search_select') AS opened
FROM events
WHERE event IN ('search', 'search_select')
AND lower(properties.query) IN ('unsubscribe', 'unsubscribed', 'retry', 'retries', 'attachments')
AND timestamp > now() - INTERVAL 14 DAY
GROUP BY search_term
ORDER BY searches DESCA fixed term should stop showing no_results and start getting opens. If it leaves the zero-result list but nobody opens what it finds, the keyword sent readers to a page that doesn't answer them, and the term goes back in the backlog. Keep each run's filtered report wherever you track docs work, so the next run has something to compare against. Page ratings are the other half of this loop: see documentation feedback.
Troubleshooting
No search events reach your analytics
Analytics loads only from blume build, never blume dev. On the live site, a reader who declined the consent banner sends nothing, so accept it while you test. With Plausible, events only appear once search is a goal. With Google Tag Manager, check that a Custom Event trigger listens for search.
Events arrive without the query
Fathom, Clarity, and Hotjar receive only the event name, so add an adapter from the list above. In Google Analytics, parameters stay out of reports until they're registered as custom dimensions. OneDollarStats receives every property as a string.
A page that exists shows zero results
Check the dialog's scope first. On a versioned site, results default to the version being viewed, so a page that only exists in another version needs the All versions toggle. Languages work the same way, with an All languages toggle, except on Pagefind, which only searches the language of the page the reader is on whatever the toggle says. Then check the index. Orama, FlexSearch, Algolia, Orama Cloud, and Typesense skip code blocks by default, so an option name that only appears in code needs search.indexing.includeCodeBlocks. Pages with search.exclude and hidden pages aren't indexed either. If the zero-result terms are in Japanese, Chinese, Korean, or another non-Latin script, and your default language is written in Latin script, Orama can't match them at all: see Make docs search work in Chinese, Japanese and Korean.
A keyword changes nothing
Mixedbread searches your own store and never sees keywords. A hosted index keeps its old records until a build syncs it: Algolia reads ALGOLIA_ADMIN_API_KEY, Typesense TYPESENSE_ADMIN_API_KEY, and Orama Cloud ORAMA_PRIVATE_API_KEY. On Orama, also check the word form: a keyword of retry doesn't match a search for retries.
The report is full of half-typed words
Those are readers pausing mid-word, since every second-long pause sends the query so far. Raise the HAVING threshold, and when a fragment is the start of a longer term in the same list, count it with that term rather than as a gap of its own.
Next step
Watch your first searches
Start the dev server, listen for blume:track in the browser console, and search for a word your docs don't use.
npx blume devA step here not working for you? Report a broken step.