From in-memory to Elasticsearch
GitHub Docs recently moved its site search from an in-memory solution to Elasticsearch. The old approach loaded all searchable records into the Node.js process that Express.js runs, which meant the entire corpus—titles, headings, breadcrumbs, content—had to be stored where the runtime could access it. Git itself served as that store, giving the Docker image built for Azure access to all the searchable text from disk.
The system generated a serialized index from that text to speed up loading, but it didn't scale. GitHub Docs publishes content in eight languages, each with five versions, so the in-memory model eventually hit its limits.
One practical advantage of Elasticsearch is that it runs locally. Most contributors making copy edits don't need a local Elasticsearch instance, so the default /api/search Express.js middleware just forwards requests to the production server. Engineers debugging the search engine can start Elasticsearch on http://localhost:9200, set that in their .env file, and test new query techniques entirely on their own machine.
if (process.env.ELASTICSEARCH_URL) {
router.use('/search', search)
} else {
router.use(
'/search',
createProxyMiddleware({
target: 'https://docs.github.com',
...
One query, carefully weighted
The core search implementation sends a single query to Elasticsearch that encodes the full ranking specification. An alternative approach would be to try a restrictive query and fall back to a broader one if results are sparse:
// NOTE! This is NOT what we do.
let result = await client.search({ index, body: searchQueryStrict })
if (result.hits.length === 0) {
// nothing found when being strict, try again with a loose query
result = await client.search({ index, body: searchQueryLoose })
}
Instead, GitHub Docs relies on boosts and a matrix of matching techniques. For multi-term queries (about 55% of all searches on docs.github.com, e.g. actions rest), the logic looks like this:
[
{ match_phrase: { title_explicit: [Object] } },
{ match_phrase: { title: [Object] } },
{ match_phrase: { headings_explicit: [Object] } },
{ match_phrase: { headings: [Object] } },
{ match_phrase: { content: [Object] } },
{ match_phrase: { content_explicit: [Object] } },
{ match: { title_explicit: [Object] } },
{ match: { headings_explicit: [Object] } },
{ match: { content_explicit: [Object] } },
{ match: { title: [Object] } },
{ match: { headings: [Object] } },
{ match: { content: [Object] } },
{ match: { title_explicit: [Object] } },
{ match: { headings_explicit: [Object] } },
{ match: { content_explicit: [Object] } },
{ match: { title: [Object] } },
{ match: { headings: [Object] } },
{ match: { content: [Object] } },
{ fuzzy: { title: [Object] } }
]
Single-term queries skip the phrase-matching parts:
[
{ match: { title_explicit: [Object] } },
{ match: { headings_explicit: [Object] } },
{ match: { content_explicit: [Object] } },
{ match: { title: [Object] } },
{ match: { headings: [Object] } },
{ match: { content: [Object] } },
{ fuzzy: { title: [Object] } }
]
Despite what looks like many queries, Elasticsearch executes the whole search in roughly 20 milliseconds, excluding network time.

The field and analyzer matrix
Each document field is indexed twice: once normally and once with an _explicit suffix. The underlying content is identical, but the tokenization differs, which changes how it matches queries. That difference is exploited through distinct boosts.
The search matrix has three dimensions:
Fields
title(the<h1>text)headings(the<h2>texts)content(the bulk of the article text)
Analyzer
- explicit (no stemming, no synonyms)
- regular (full Snowball stemming, possibly synonyms)
Matches (multi-term queries)
match_phrasematchwithOR(docs containing "foo" or "bar")matchwithAND(docs containing "foo" and "bar")
Every combination gets a unique boost value. The absolute numbers don't matter; what matters is that they differ. A match in title boosts higher than a match in content, and a match where all terms are present boosts higher than a partial match. So for the query docker action, an article titled "Creating a Docker container action" ranks ahead of "Publishing Docker images" or "Metadata syntax for GitHub Actions."
The boost calculation per node follows this pattern:
const BOOST_PHRASE = 10.0
const BOOST_TITLE = 4.0
const BOOST_HEADINGS = 3.0
const BOOST_CONTENT = 1.0
const BOOST_AND = 2.5
const BOOST_EXPLICIT = 3.5
...
match_phrase: { title_explicit: { boost: BOOST_EXPLICIT * BOOST_PHRASE * BOOST_TITLE, query } },
match: { headings: { boost: BOOST_HEADINGS * BOOST_AND, query, operator: 'AND' } },
...
Printing just the boost values for each node in the matrix yields:
[
{ match_phrase: { title_explicit: 140 } },
{ match_phrase: { title: 40 } },
{ match_phrase: { headings_explicit: 105 } },
{ match_phrase: { headings: 30 } },
{ match_phrase: { content: 10 } },
{ match_phrase: { content_explicit: 35 } },
{ match: { title_explicit: 35, operator: 'AND' } },
{ match: { headings_explicit: 26.25, operator: 'AND' } },
{ match: { content_explicit: 8.75, operator: 'AND' } },
{ match: { title: 10, operator: 'AND' } },
{ match: { headings: 7.5, operator: 'AND' } },
{ match: { content: 2.5, operator: 'AND' } },
{ match: { title_explicit: 14 } },
{ match: { headings_explicit: 10.5 } },
{ match: { content_explicit: 3.5 } },
{ match: { title: 4 } },
{ match: { headings: 3 } },
{ match: { content: 1 } },
{ fuzzy: { title: 0.1 } }
]
The whole query acts as a wishlist: match the content anywhere, but prefer a phrase in the title, and weigh exact matches over stemmed ones.
Why explicit boosts matter
A query like creating repositories should match "Create a private GitHub repository" because both deconstruct to the same stems. Stemming broadens recall, but it can drown out exact matches. If an article literally contains "Creating private GitHub repositories," it deserves a ranking boost over one that merely shares stems.
The keyword working-directory illustrates the problem. It's an exact term that appears inside content, but it also looks like ordinary English prose. Without an explicit match, an article titled "Directories that work" could compete on equal footing, since both reduce to the stems ['work', 'directori'].
The fix is to run two matches—one with stemming and one without—each with a different boost. The stemmed match catches related variants, but the explicit match ranks first. The code looks like this:
// Creating the index...
await client.indices.create({
mappings: {
properties: {
url: { type: 'keyword' },
title: { type: 'text', analyzer: 'text_analyzer', norms: false },
title_explicit: { type: 'text', analyzer: 'text_analyzer_explicit', norms: false },
content: { type: 'text', analyzer: 'text_analyzer' },
content_explicit: { type: 'text', analyzer: 'text_analyzer_explicit' },
// ...snip...
},
},
// ...snip...
})
// Searching...
matchQueries.push(
...[
{ match: { title_explicit: { boost: BOOST_EXPLICIT * BOOST_TITLE, query } } },
{ match: { content_explicit: { boost: BOOST_EXPLICIT * BOOST_CONTENT, query } } },
{ match: { title: { boost: BOOST_TITLE, query } } },
{ match: { content: { boost: BOOST_CONTENT, query } } },
// ...snip...
])
Popularity as a ranking signal
Matching alone isn't enough when a query returns dozens of documents. Users expect the first result to be the right one. To improve the odds, GitHub Docs blends in pageview metrics as a popularity signal.
Currently, metrics are gathered for the top 1,000 most popular URLs. Each page's pageview count is ranked and normalized to a value between 0.0 and 1.0. That number gets +1.0 added, and the result multiplies the Elasticsearch match score.
Suppose two documents match a query—one scores 15.6, the other 13.2. If the 13.2 match sits on a very popular page with a popularity number of 0.75, its final score becomes 13.2 × (1 + 0.75) = 23.1. The better term match on a less popular page, with popularity 0.44, ends up at 15.6 × (1 + 0.44) = 22.5. The popular page wins despite the lower textual match. This gives less "matchy" documents a chance to surface, while preventing a hugely popular page that merely mentions a term from overpowering a title match.
Future directions
Several ideas are on the roadmap. Elasticsearch supports synonyms (e.g. repo = repository), but the challenge is managing that mapping in a way writers can maintain conveniently.
Pageview metrics also have blind spots. A user uncertain where to start might land on a product landing page (there are roughly 20), drill down through several articles, and only arrive at the useful page at the end. Crediting every page equally in that journey isn't ideal. Recording non-clicks—times a search result URL ranked high but wasn't chosen—could counter the popularity loop, where popular listings only get more popular.
Contextual signals could also shape results. A user browsing the REST API docs who searches for billing would likely prefer the REST API "About billing" article over the billing email settings page. These ideas all involve adding a human touch to the scoring logic, an algorithmic problem that will never reach perfection as user behavior constantly shifts.



