mkdocs-material/docs/blog/2021/search-better-faster-smaller.md

12 KiB

template search
overrides/main.html
boost
0.5

Search: better, faster, smaller

This is the story of how we managed to completely rebuild client-side search, delivering a significantly better user experience, while making it faster and smaller at the same time.


The search of Material for MkDocs is genuinely one of its best and most-loved assets: fast, multi-lingual, offline-capable and most importantly: all client-side. It provides a solution to empower the users of your documentation to find what they're searching for instantly without the headache of managing additional servers. However, even though several iterations have been made, there's still some room for improvement, which is why we rebuilt the search plugin and integration from the ground up. This article shines some light on the internals of the new search, why it's much more powerful than the previous version and what's about to come.

The next section explains the architecture and issues of the current search implementation. If you immediately want to learn what's new, skip to the next section.

Architecture

Material for MkDocs uses lunr together with lunr-languages to implement its client-side search capabilities. When a documentation page is loaded and JavaScript is available, the search index as generated by the built-in search plugin during the build process is requested from the server:

const index$ = document.forms.namedItem("search")
  ? __search?.index || requestJSON<SearchIndex>(
    new URL("search/search_index.json", config.base)
  )
  : NEVER

Search index

The search index includes a stripped-down version of all pages. Let's take a look at an example, to understand precisely what the search index contains from the original Markdown file:

??? example "Expand to see full example"

=== "`docs/page.md`"

    ```` markdown
    # Example

    ## Text

    It's very easy to make some words **bold** and other words *italic* with
    Markdown. You can even add [links](#), or even `code`:

    ```
    if (isAwesome) {
      return true
    }
    ```

    ## Lists

    Sometimes you want numbered lists:

    1. One
    2. Two
    3. Three

    Sometimes you want bullet points:

    * Start a line with a star
    * Profit!
    ````

=== "`search_index.json`"

    ``` json
    {
      "config": {
        "indexing": "full",
        "lang": [
          "en"
        ],
        "min_search_length": 3,
        "prebuild_index": false,
        "separator": "[\\s\\-]+"
      },
      "docs": [
        {
          "location": "page/",
          "text": "Example Text It's very easy to make some words bold and other words italic with Markdown. You can even add links , or even code : if (isAwesome) { return true } Lists Sometimes you want numbered lists: One Two Three Sometimes you want bullet points: Start a line with a star Profit!",
          "title": "Example"
        },
        {
          "location": "page/#example",
          "text": "",
          "title": "Example"
        },
        {
          "location": "page/#text",
          "text": "It's very easy to make some words bold and other words italic with Markdown. You can even add links , or even code : if (isAwesome) { return true }",
          "title": "Text"
        },
        {
          "location": "page/#lists",
          "text": "Sometimes you want numbered lists: One Two Three Sometimes you want bullet points: Start a line with a star Profit!",
          "title": "Lists"
        }
      ]
    }
    ```

If we inspect the search index, we immediately see several problems:

  1. All content is included twice: the search index includes one entry with the entire contents of the page, and one entry for each section of the page, i.e. each block preceded by a headline or subheadline. This significantly increases the size of the search index.

  2. All structure is lost: when the search index is built, all structural information like HTML tags and attributes are stripped from the content. While this approach works well for paragraphs and inline formatting, it might be problematic for lists and code blocks. An excerpt:

```
[...] links , or even code : if (isAwesome) { ... } Lists Sometimes [...]
```

- __Context__: for an untrained eye, the result can look like gibberish, as it's not immediately apparent what classifies as text and what as code. Furthermore, it's not clear that `Lists` is a headline as it's merged with the code block before and the paragraph after it.

- __Punctuation__: inline elements like links, that are immediately followed by punctuation are separated by whitespace (see `,` and `:` in the excerpt). This is because all extracted text is joined with a whitespace character during the construction of the search index.

It's not difficult to see that it can be quite challenging to implement a good search experience for theme authors, which is why Material for MkDocs (up to now) does some monkey patching to be able to render more meaningful search previews.

Search worker

The actual search functionality is implemented as part of a web worker1, which creates and manages the lunr search index. When search is initialized, the following steps are taken:

  1. Linking sections with pages: The search index is parsed and each section is linked to its parent page. The parent page itself is not indexed, as it would lead to duplicate results, so only the sections remain. Linking is necessary, as search results need to be grouped by page.

  2. Tokenization: The title and text values of each section are split into tokens by using the separator as configured in mkdocs.yml. Tokenization itself is carried out by lunr's default tokenizer, which doesn't allow for lookahead or separators spanning multiple characters.

    Why is this important and a big deal? We will see later how much more we can achieve with a tokenizer that is capable of separating strings with lookahead.

  3. Indexing: As a final step, each section is indexed. When querying the index, if a search query includes one of the tokens as returned by step 2., the section is considered to be part of the search result and passed to the main thread.

Now, that's basically how the search worker operates. Sure, there's a little more magic involved, e.g. search results are post-processed and rescored to account for some shortcomings of lunr, but in general this is how search results get into and out of the index.

Search previews

Users should be able to quickly scan an evaluate the relevance of a search result in the given context, which is why a concise summary with highlighted occurrences of the words found is an essential part of a great search experience.

This is where the current search preview generation falls short, as some of the search previews appear to not include any occurrence of any of the search terms. This was due to the fact that search previews were truncated after 320 characters, as can be seen here:

Search previews

The first two results look like they're not relevant, as they don't seem to include the query string the user just searched for. Yet, they are.

A better solution to this problem has been on the roadmap for a very, very long time, but in order to solve this once and for all, several factors need to be carefully considered:

  1. Word boundaries: some themes2 for static site generators generate search previews by expanding the text left and right next to an occurrence, stopping at a whitespace character when enough words have been consumed. A preview might look like this:

    ... channels, e.g. or which can be configured via mkdocs.yml.
    

    While this may work for languages that use whitespace as a separator between words, it breaks down for languages like Japanese or Chinese2, as they have non-whitespace word boundaries and use dedicated segmenters to split strings into tokens.

  1. Context awareness: Although whitespace doesn't work for all languages, one could argue that it could be a good-enough solution. Unfortunately, this is not necessarily true for code blocks, as the removal of whitespace might change meaning in some languages.

  2. Structure: Preserving structural information is not a must, but apparently beneficial to build more meaningful search previews which allow for a quick evaluation of relevance. If the user expects a match within a code block, it should be rendered as a code block.

What's new?

After we built a solid understanding of the problem space and before we dive into the internals of the new search implementation to see which of the problems it already solves, a quick overview:

  • Better: support for rich search results, preserving the structural information of code blocks, inline code and lists, so they are rendered as-is, as well as more accurate highlighting and improved stability of typeahead. Additionall, some small interface improvements.

  • Faster: Up to 40% faster indexing and querying

  • Smaller: Up to 50% savings in index size

Rich search results

  • HTML awareness
  • tokenization now
  • faster indexing
  • smaller index size
  • faster search results

Highlighting

x the problem with highlighting x how highlighting was implemented

  • how its implemented now

  • division into blocks

  • lookahead tokenization

  • add "jump to improvements"

  • ux improvements

    • scrolling more butotn


  1. Prior to version 5.0, search was carried out in the main thread which locked up the browser, rendering it unusable. This problem was first reported in #904 and, after some back and forth, fixed and released in version 5.0. ↩︎

  2. At the time of writing, Just the Docs and Docusaurus use this method for generating search previews. Note that the latter by default uses Algolia, which is a fully managed server-based solution. ↩︎