Skip to content

搜索:更好、更快、更小

这是我们如何成功重建客户端搜索的故事,提供了显著更好的用户体验,同时使其更快、更小。

Material for MkDocs 的 [搜索] 无疑是其最优秀、最受欢迎的资产之一:[多语言]、[离线支持],最重要的是:全部在客户端。它为您的文档用户提供了一种解决方案,使他们能够立即找到所搜索的内容,而无需管理额外的服务器。然而,尽管已经进行了几次迭代,但仍然有改进的空间,这就是我们从头开始重建搜索插件和集成的原因。本文将阐明新搜索的内部结构,为什么它比以前的版本更强大,以及即将到来的内容。

下一部分讨论当前搜索实现的架构和问题。如果您想立即了解新内容,请跳到紧接着的部分

架构

Material for MkDocs 使用 lunr 结合 lunr-languages 实现其客户端搜索功能。当文档页面加载并且 JavaScript 可用时,将从服务器请求在构建过程中由 [内置搜索插件] 生成的搜索索引:

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

搜索索引

搜索索引包含所有页面的简化版本。让我们通过一个示例来准确理解搜索索引从原始 Markdown 文件中包含了什么:

展开以检查示例
# 示例

## 文本

使用 Markdown 使某些单词 **加粗** 和其他单词 *斜体* 非常简单。您甚至可以添加 [链接](#),或者甚至是 `代码`
```
if (isAwesome) {
  return true
}
```

## 列表

有时您想要编号列表:

1.2.3.
有时您想要项目符号:

* 以星号开始一行
* 收益!
{
  "config": {
    "indexing": "full",
    "lang": [
      "en"
    ],
    "min_search_length": 3,
    "prebuild_index": false,
    "separator": "[\\s\\-]+"
  },
  "docs": [
    {
      "location": "page/",
      "title": "示例",
      "text": "示例文本 使用 Markdown 使某些单词加粗和其他单词斜体非常简单。您甚至可以添加链接,或者甚至是代码:if (isAwesome) { return true } 列表 有时您想要编号列表:一 二 三 有时您想要项目符号:以星号开始一行 收益!"
    },
    {
      "location": "page/#example",
      "title": "示例",
      "text": ""
    },
    {
      "location": "page/#text",
      "title": "文本",
      "text": "使用 Markdown 使某些单词加粗和其他单词斜体非常简单。您甚至可以添加链接,或者甚至是代码:if (isAwesome) { return true }"
    },
    {
      "location": "page/#lists",
      "title": "列表",
      "text": "有时您想要编号列表:一 二 三 有时您想要项目符号:以星号开始一行 收益!"
    }
  ]
}

如果我们检查搜索索引,立刻会看到几个问题:

  1. 所有内容重复包含两次:搜索索引包含一个条目,包含页面的全部内容,以及每个页面部分的一个条目,即每个以标题或副标题开头的块。这显著增加了搜索索引的大小。

  2. 所有结构信息丢失:在构建搜索索引时,所有结构信息,如 HTML 标签和属性,都被从内容中剥离。虽然这种方法对于段落和行内格式化效果良好,但对于列表和代码块可能会出现问题。摘录如下:

    … links , or even code : if (isAwesome) { … } Lists 有时您想要 …
    
    • 上下文:对于未经过训练的眼睛,结果可能看起来像是乱码,因为不清楚什么是文本,什么是代码。此外,Lists 并不明显是一个标题,因为它与前面的代码块和后面的段落合并在一起。

    • 标点符号:紧跟在标点符号后面的行内元素(如链接)被空格分隔(见摘录中的 ,:)。这是因为在构建搜索索引时,所有提取的文本都用空格字符连接在一起。

不难看出,为主题作者实现良好的搜索体验可能相当具有挑战性,这就是为什么 Material for MkDocs(直到现在)做了一些 [猴子补丁] 以便能够呈现稍微更有意义的搜索预览。

搜索工作者

实际的搜索功能作为网络工作者的一部分实现1,该工作者创建和管理 lunr 搜索索引。当初始化搜索时,执行以下步骤:

  1. 将部分与页面链接:解析搜索索引,并将每个部分链接到其父页面。父页面本身 不被索引,因为这会导致重复结果,因此只保留部分。链接是必要的,因为搜索结果按页面分组。

  2. 标记化:使用在 mkdocs.yml 中配置的 separator 将每个部分的 titletext 值拆分为标记。标记化本身由 lunr 的默认标记器 执行,该标记器不允许前瞻或跨多个字符的分隔符。

    为什么这很重要且意义重大?我们稍后将看到使用能够进行前瞻的标记器可以实现更多功能。

  3. 索引:作为最后一步,对每个部分进行索引。当查询索引时,如果搜索查询包含步骤 2 返回的某个标记,则该部分被视为搜索结果的一部分并传递给主线程。

这基本上就是搜索工作者的操作方式。当然,还有一些额外的魔法,例如,搜索结果会被 [后处理] 和 [重新评分] 以考虑 lunr 的一些不足之处,但总体而言,这就是数据如何进出索引的方式。

搜索预览

用户应该能够快速浏览和评估给定上下文中搜索结果的相关性,这就是为什么简洁的摘要和突出显示找到的搜索词的出现是出色搜索体验的重要组成部分。

当前的搜索预览生成在这方面表现不佳,因为一些搜索预览似乎没有包含任何搜索词的出现。这是因为搜索预览在最大 320 个字符后被 截断,如下所示:

search preview

前两个结果看起来不相关,因为它们似乎不包含用户刚搜索的查询字符串。然而,它们是相关的。

解决这个问题的更好方案已经在路线图上很久了,但为了彻底解决这个问题,需要仔细考虑几个因素:

  1. 单词边界:一些静态网站生成器的主题2 通过扩展与出现相邻的文本来生成搜索预览,在消耗足够的单词时停止在空格字符处。预览可能看起来像这样:

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

    虽然这对于使用空格作为单词分隔符的语言可能有效,但对于日语或中文等语言3 则会失效,因为它们具有非空格的单词边界,并使用专门的分词器将字符串拆分为标记。

  2. 上下文感知:虽然空格并不适用于所有语言,但可以说这可能是一个足够好的解决方案。不幸的是,这对于代码块并不一定成立,因为删除空格可能会改变某些语言中的含义。

  3. 结构:保留结构信息并不是必须的,但显然有利于构建更有意义的搜索预览,从而允许快速评估相关性。如果单词出现是代码块的一部分,则应将其呈现为代码块。

新的内容是什么?

在我们对问题空间建立了扎实的理解之后,在深入了解我们新的搜索实现的内部结构以查看它已经解决了哪些问题之前,快速概述一下它带来的功能和改进:

  • 更好:支持 [丰富的搜索预览],保留代码块、行内代码和列表的结构信息,使其按原样呈现,以及 [前瞻标记化]、[更准确的高亮] 和 改进的自动补全稳定性。此外,还有 [稍微更好的用户体验]。
  • 更快更小:由于改进的提取和构建技术,搜索索引大小显著减少,最多可达 48%,从而实现高达 95% 更快的搜索体验,这对于大型文档项目特别有帮助。

丰富的搜索预览

由于我们从头开始重建搜索插件,我们重新构建了搜索索引的构造,以保留代码块、行内代码以及无序和有序列表的结构信息。使用 [搜索索引] 部分中的示例,结果如下:

search preview now

search preview before

现在,代码块是搜索预览的第一公民,甚至行内代码格式也得以保留。让我们看看搜索索引的新结构,以理解原因:

展开以检查搜索索引
{
  ...
  "docs": [
    {
      "location": "page/",
      "title": "示例",
      "text": ""
    },
    {
      "location": "page/#text",
      "title": "文本",
      "text": "<p>使用 Markdown 使某些单词加粗和其他单词斜体非常简单。您甚至可以添加链接,或者甚至是 <code>代码</code>:</p> <pre><code>if (isAwesome){\n  return true\n}\n</code></pre>"
    },
    {
      "location": "page/#lists",
      "title": "列表",
      "text": "<p>有时您想要编号列表:</p> <ol> <li>一</li> <li>二</li> <li>三</li> </ol> <p>有时您想要项目符号:</p> <ul> <li>以星号开始一行</li> <li>收益!</li> </ul>"
    }
  ]
}
{
  ...
  "docs": [
    {
      "location": "page/",
      "title": "示例",
      "text": "示例文本 使用 Markdown 使某些单词加粗和其他单词斜体非常简单。您甚至可以添加链接,或者甚至是代码:if (isAwesome) { return true } 列表 有时您想要编号列表:一 二 三 有时您想要项目符号:以星号开始一行 收益!"
    },
    {
      "location": "page/#example",
      "title": "示例",
      "text": ""
    },
    {
      "location": "page/#text",
      "title": "文本",
      "text": "使用 Markdown 使某些单词加粗和其他单词斜体非常简单。您甚至可以添加链接,或者甚至是代码:if (isAwesome) { return true }"
    },
    {
      "location": "page/#lists",
      "title": "列表",
      "text": "有时您想要编号列表:一 二 三 有时您想要项目符号:以星号开始一行 收益!"
    }
  ]
}

如果我们再次检查搜索索引,可以看到情况有所改善:

  1. 内容仅包含一次:搜索索引不再重复包含页面的内容,因为只有页面的部分是搜索索引的一部分。这导致大小显著减少,传输的字节更少,搜索索引更小。

  2. 保留了一些结构:搜索索引的每个部分都包含一小部分 HTML,以提供必要的结构,从而允许更复杂的搜索预览。回顾我们之前的示例,来看一个摘录:

    … links, or even <code>代码</code>:</p> <pre><code>if (isAwesome){ … }\n</code></pre>
    
    … links , or even code : if (isAwesome) { … }
    

    标点符号问题已解决,因为没有插入额外的空格,保留的标记提供了额外的上下文,使扫描搜索结果更有效。

接下来是流程的下一步:标记化

标记器前瞻

lunr 的 [默认标记器] 使用正则表达式通过将每个字符与 mkdocs.yml 中定义的 separator 进行匹配来拆分给定字符串。这不允许基于前瞻或多个字符的更复杂分隔符。

幸运的是,我们新的搜索实现提供了一个高级标记器,没有这些缺点,并支持更复杂的正则表达式。因此,Material for MkDocs 刚刚将其自己的分隔符配置更改为以下值:

[\s\-,:!=\[\]()"/]+|(?!\b)(?=[A-Z][a-z])|\.(?!\d)|&[lg]t;

虽然第一个部分到第一个 | 包含了应拆分字符串的单个控制字符列表,但以下三个部分解释了正则表达式的其余部分。4

大小写变化

许多编程语言使用 PascalCasecamelCase 命名约定。当用户搜索术语 case 时,自然会期望 PascalCasecamelCase 出现。通过将以下匹配组添加到分隔符,现在可以轻松实现:

(?!\b)(?=[A-Z][a-z])

这个正则表达式是一个负向前瞻(\b,即不是单词边界)和一个正向前瞻([A-Z][a-z],即一个大写字母后跟一个小写字母)的组合,具有以下行为:

  • PascalCase Pascal, Case
  • camelCase camel, Case
  • UPPERCASE UPPERCASE

搜索 searchHighlight 现在会显示讨论 search.highlight 特性标志的部分,这也证明了这现在甚至对搜索查询有效。5

版本号

索引版本号是另一个可以通过小前瞻解决的问题。通常,. 应被视为分隔符,以拆分像 search.highlight 这样的单词。然而,在 . 处分割版本号将使其无法发现。因此,以下表达式:

\.(?!\d)

这个正则表达式仅在后面不紧跟数字 \d 时匹配 .,这使得版本号可以被发现。搜索 7.2.6 会显示 7.2.6 发布说明。

HTML/XML 标签

如果您的文档包含 HTML/XML 代码示例,您可能希望允许用户查找特定的标签名称。不幸的是,<> 控制字符在代码块中编码为 &lt;&gt;。现在,向分隔符添加以下表达式可以实现这一点:

&[lg]t;

我们刚刚开始触及标记器前瞻带来的新可能性。如果您发现其他有用的表达式,欢迎在评论区分享。

准确的高亮

高亮是搜索过程的最后一步,涉及在给定搜索结果中高亮显示所有搜索词的出现。长期以来,高亮是通过动态生成的 [正则表达式] 实现的。6

这种方法在处理非空格语言(如日语或中文)时存在一些问题3,因为它仅在高亮的术语位于单词边界时有效。然而,亚洲语言是使用 [专门的分词器] 进行标记化的,这无法通过正则表达式建模。

现在,作为 [新标记化方法] 的直接结果,我们新的搜索实现使用标记位置进行高亮,使其与标记化同样强大:

  1. 单词边界:由于新的高亮器使用标记位置,单词边界等于标记边界。这意味着标记化的更复杂情况(例如,[大小写变化]、[版本号]、[HTML/XML 标签])现在都能准确高亮。

  2. 上下文感知:由于新的搜索索引保留了原始文档的一些结构信息,部分的内容现在被划分为独立的内容块——段落、代码块和列表。

    现在,只有实际包含搜索词出现的内容块才会被考虑纳入搜索预览。如果一个术语仅出现在代码块中,则呈现的就是代码块,例如,查看 twitter 的结果。

基准测试

我们进行了两个基准测试——一个是使用 Material for MkDocs 本身的文档,另一个是使用超过 800,000 个单词的非常庞大的 Markdown 文件语料库——这是大多数文档项目可能永远无法达到的大小:

之前 现在 相对
Material for MkDocs
索引大小 573 kB 335 kB –42%
索引大小 (gzip) 105 kB 78 kB –27%
索引时间7 265 ms 177 ms –34%
KJV Markdown8
索引大小 8.2 MB 4.4 MB –47%
索引大小 (gzip) 2.3 MB 1.2 MB –48%
索引时间 2,700 ms 1,390 ms –48%

基准测试结果

结果显示,索引时间,即在页面加载时设置搜索所需的时间,下降了最多 48%,这意味着 新的搜索速度提高了最多 95%。这是一个显著的改进,特别是对于大型文档项目来说。

虽然 1.3 秒仍然听起来像是很长的时间,但使用新的客户端搜索与 [即时加载] 结合时,仅在初始页面加载时创建搜索索引。在导航时,搜索索引在页面之间保持不变,因此成本只需支付一次。

用户界面

此外,还进行了一些小改进,最显著的是 此页面上的更多结果 按钮,现在在打开时会固定在搜索结果列表的顶部。这使用户能够更快地跳出列表。

下一步是什么?

我们新的搜索实现是对 Material for MkDocs 的重大改进。它解决了一些需要多年来解决的长期问题。然而,这只是一个搜索体验的开始,未来将会越来越好。接下来:

  • 上下文感知搜索摘要:目前,前两个匹配的内容块被呈现为搜索预览。通过新的标记化技术,我们为更复杂的缩短和摘要方法奠定了基础,这也是我们接下来要解决的内容。

  • 用户界面改进:由于我们现在完全控制了搜索插件,我们可以添加有意义的元数据,以提供更多上下文和更好的体验。我们将在未来探索一些这些路径。

如果您读到这里,感谢您对 Material for MkDocs 的关注和兴趣!这是我决定在一次简短的 [Twitter 调查] 后写的第一篇博客文章。欢迎您留下评论,分享您对新搜索实现的体验。


  1. 5.0.0 之前,搜索是在主线程中进行的,这锁定了浏览器,使其无法使用。这个问题首次在 #904 中报告,经过一些反复,最终在 5.0.0 中修复并发布。 

  2. 在撰写本文时,Just the DocsDocusaurus 使用此方法生成搜索预览。请注意,后者还与 Algolia 集成,这是一个完全托管的基于服务器的解决方案。 

  3. 中国和日本都是 Material for MkDocs 用户的前五个来源国之一。 

  4. 有趣的是,搜索插件的 separator [默认值] 为 [\s\-]+ 一直让人感到困惑,因为它暗示多个字符可以被视为分隔符。然而,+ 是完全无关的,因为涉及多个字符的正则表达式组从未被 lunr 的默认标记器 支持。 

  5. 之前,由于 lunr 处理通配符的方式,搜索查询未正确标记化,因为它禁用了包含通配符的搜索词的管道。为了提供良好的自动补全体验,Material for MkDocs 在每个未明确以 +- 开头的搜索词后添加通配符,从而有效地禁用标记化。 

  6. 使用在 mkdocs.yml 中定义的分隔符,构造了一个试图模仿标记器的正则表达式。例如,搜索查询 search highlight 被转换为相当繁琐的正则表达式 (^|<separator>)(search|highlight),这仅在单词边界处匹配。 

  7. 十次不同运行的最小值。 

  8. 我们无偏见地使用 KJV Markdown 作为测试工具,以了解 Material for MkDocs 在大型语料库上的表现,因为它是一个非常大的 Markdown 文件集,包含超过 800k 个单词。