从排序到评分
我们在 Meilisearch 中为搜索结果添加相关性评分的旅程。

欢迎来到文字自助餐!请点餐,我们将为您奉上 20 份最优质的文档,它们与您的查询最匹配!好吧,我们说的是最优质的,但我们不会确切地告诉您它们与您的查询匹配得有多好,我们只是向您保证它们是我们拥有的最好的。您说无法接受?您是该地区最著名的餐厅评论家,您需要能够区分完美匹配和文字沙拉,您说?哦,这听起来确实很麻烦……
Meilisearch 是一款 开源搜索引擎,旨在提供闪电般快速、高度相关的搜索,且集成工作量很小。它支持将文档以组的形式存储在磁盘上,称为 索引,并搜索这些索引以获取与每个查询相关的文档。顺便说一句,如果您想让您的生活更轻松,并专注于您提供的搜索体验,我们提供 云解决方案,它始终受益于我们的最新版本 😉
直到最近发布的 [1.3 版本](/blog/v1-3-release/),Meilisearch 都没有提供任何方法来了解文档与特定搜索查询的匹配程度。本文深入探讨了促使我们添加此功能的历程。
动机
为文档提供相关性评分可满足许多高需求用例
- 根据文档的分数调整搜索结果的呈现方式。 例如,开发者 bakerfugu 有一个 日历应用程序,并希望使用颜色区分来突出显示会议和事件的相关性。但是,搜索结果的顺序是不够的,因为即使是排名靠前的结果,相关性也可能有所不同。没有评分,我们只知道它是 Meilisearch 可以找到的最佳结果。
- 实施聚合搜索。 聚合搜索是一种显示来自 搜索多个索引 的结果的方式,就好像搜索是在单个统一索引上执行的一样。用户在 各种 GitHub 讨论中报告了现实世界的用例。
- 实施分片。 分片类似于联合搜索,因为它结合了多个搜索查询的结果。与联合搜索不同,分片涉及在多个 Meilisearch 实例上查询分区索引,而不是在同一实例中查询多个索引。预期数据更加同质,但在查询完成后必须可以重新排名,可以这么说,“离线”进行。
- 理解相关性。 Meilisearch 使用一组预定义的规则对文档进行排名。通过为每个 排名规则 生成详细的分数,可以更细致地了解各个规则如何应用于特定查询的文档。这有助于发现最大化每个用例相关性的最佳设置。
从这四个用例中我们可以看出,实现评分功能的设计空间很大。解决方案应展现哪些属性才能最好地解决所有这些用例?让我们来看看它们。
目标属性
从不同的用例中,我们可以看到不同的用户和用法群体。我们确定了可以提供多种解决方案的两个轴
- 聚合分数与详细分数。对于日历用例,每个文档的单个“聚合”分数就足够了,而对于理解相关性,则需要每个排名规则的详细分数。理想的解决方案应同时提供聚合分数和详细分数。
- 机器可读与人可读。大多数用例都需要可以由集成或前端自动消化的信息。但是,如果我们旨在使相关性更易于理解,我们需要提供人可读的信息。因此,解决方案应在这两个属性之间取得适当的平衡。
此外,为了启用聚合搜索和分片,分数必须独立于搜索索引中包含的文档。实际上,如果向索引添加文档会更改其他文档的分数,则将该分数与包含不同文档的另一个索引进行比较是没有意义的。
最后,我们希望评分系统直观:Meilisearch 应按相关性分数降序返回文档,遵循 最少惊讶原则。
特别是最后一个属性,使我们倾向于基于 Meilisearch 已经执行的排名的解决方案,以此来保证排名和评分之间的一致性。
总而言之,解决广泛的用例需要一个评分功能,该功能不仅提供聚合分数和详细分数,而且还满足机器和人的可读性。它还应保持分数独立性,而与搜索索引中的文档无关,并与 Meilisearch 现有的排名系统保持一致。为了了解如何基于排名构建此评分系统,我们首先需要了解排名本身。
递归桶排序
本节介绍 Meilisearch 如何响应搜索查询对文档进行排名。如果您已经了解 Meilisearch 使用的递归桶排序算法,请随意跳过本节。此外,由于本节重点关注搜索算法,因此不涵盖引擎的其他部分,例如索引编制。如果您有兴趣了解更多相关信息,我们在之前的 专题文章 中介绍过它们。
Meilisearch 的核心使用一种称为“桶排序”的算法。它根据一组排名规则将文档排序到不同的桶中。第一个排名规则适用于所有文档,而每个后续规则仅用作决胜局,以对被认为在一个桶中相等的文档进行排序。当所有“最内层”的桶都包含单个文档时,或者在应用最后一个排名规则后,排序完成。
例如,words
排名规则 根据文档中找到的查询词的数量对文档进行排序。如果多个文档最终位于同一个桶中,则使用另一个排名规则,例如 typo 来区分它们。
对于查询“Badman dark knight returns”,words
排名规则会将返回的文档排序到 4 个桶中,从包含所有词语(可能存在拼写错误)的文档到仅包含“Badman”的文档。typo
排名规则帮助我们进一步区分最后一个桶中的文档。
👉 请注意,此规则对其他三个桶没有影响,因为它们仅包含查询具有拼写错误“Badman -> Batman”的文档。
现在我们对 Meilisearch 对文档进行排名的方式有了很好的理解,让我们回顾一下我们对评分功能的期望属性
- 它应同时提供聚合分数和详细分数
- 它应满足机器和人的可读性
- 它应保持分数独立性,而与搜索索引中的文档无关
- 它应与 Meilisearch 的现有排名系统保持一致。
我们如何扩展 Meilisearch 的排名行为以生成满足这些标准的分数?接下来我们将探讨这一点。
从排序到评分
根据我们刚刚学到的知识,每个排名规则将整个数据集拆分为几个桶,然后按顺序返回它们。然后,我们可以使用每个桶的排名和每个规则的桶总数来计算分数,从而产生递归桶评分算法。
让我们重用我们领先的“badman”示例来付诸实践。我们计算出 words
桶的数量为 4,并且对于这些桶中的每一个,内部 typo
桶的数量。我们得到了下图所示的内容。
文档桶,用分数标签标记
通过对 示例 movies.json 数据集 上的查询应用递归桶排序,我们得到了以下排名。为了简单起见,我们配置了数据集,以便只有标题是可搜索属性,这使得结果更容易掌握。这样,我们就能够为每个文档分配一个由两个组件组成的详细分数,得到以下结果
单词和拼写错误分数 | 电影标题 |
---|---|
words 4/4,typo 1/1 | - 蝙蝠侠:黑暗骑士归来,第一部分 |
- 蝙蝠侠:黑暗骑士归来,第二部分 | |
words 3/4,typo 1/1 | - 蝙蝠侠揭秘:黑暗骑士的心理学 |
- 黑暗骑士传奇:蝙蝠侠的历史 | |
words 1/4,typo 2/2 | - 天使与恶棍 |
words 1/4,typo 1/2 | - 蝙蝠侠:元年 |
- 蝙蝠侠:红头罩之下 |
这为我们的每个排名规则的详细分数提供了一个初步形状,尽管我们可能希望使用特定于规则的语义信息来增强分数,例如匹配的单词数和拼写错误的数量。
我们尚未探索如何从这些详细分数生成每个文档的单个分数,如何确保其独立于数据集,以及如何处理排序规则的独特情况。
那么,我们如何从这些复杂的分数(适用于高级用例)中获得每个文档的单个聚合分数,以用于不需要如此高细节级别的用例?我们将在以下部分讨论它。
聚合分数
为了保持评分系统的直观性,聚合分数必须与 Meilisearch 给出的排名一致。让我们记住,后续的排名规则主要用于解决先前规则的并列关系。同样,后面的排名规则应该只细化从先前规则得出的分数。
考虑到这一点,让我们修改之前的图表。与其将 words
桶标记为“4/4”,表示文档与所有单词匹配,不如说此桶中的文档落在 3/4 到 4/4 的范围内。我们将让 typo
排名规则决定它们的确切位置。仅取最后一个 words
桶,因为它有两个 typo
内部桶
这样,我们可以计算每个文档的聚合分数
words
4/4,typo
1/1: 1.0words
3/4,typo
1/1: 0.75words
1/4,typo
2/2: 0.25words
1/4,typo
1/2: 0.125
以上给出了分数的直觉,但应注意实现的细节。特别是,我们只为 3 个最佳 words
桶放置了一个 typo
桶,因为在我们的索引中,我们没有文档落入这些单词桶中,而没有在“Batman”上出现拼写错误。
现在,如果我们添加一部名为“The badman returns to the dark knight”的新电影会发生什么?现在,对于第一个 words
桶,我们有两个 typo
桶,“Batman: the dark knight returns, part 1”不再是完美匹配:它的分数变为 0.875 而不是 1.0。我们需要避免这个问题。
达到数据集独立性
我们的分数应完全独立于索引中包含的文档。每个规则都应该能够仅根据查询而不是索引中的文档来计算理论上的最大桶数。
对于 typo
规则,这涉及将索引 拼写错误容忍度设置 应用于查询,并计算最大可能的拼写错误数。默认设置通常允许最多 1 个拼写错误(对于五个或更多字符的词语),以及最多 2 个拼写错误(对于至少九个字符长的词语)。
使用这些设置,查询“Badman dark knight returns”最多允许 3 个拼写错误(“badman”上 1 个,“knight”上 1 个,“returns”上 1 个),总共 4 个可能的桶,从 0 到 3 个拼写错误。这意味着“Batman: the dark knight returns, part 1”实际上应该得分为 0.9375,无论“The badman returns to the dark knight”是否是索引中或任何地方存在的电影(更正上面层级列表中的分数留给读者作为练习)。
幸运的是,对于大多数排名规则,计算理论上的最大桶数可以用自然的方式完成(详细说明每个排名规则的方式超出了本文的范围,但如果您有兴趣,我们很乐意回答问题 😊)。不幸的是, sort
和 geosort
排名规则 是值得注意的例外。
排序规则不影响分数
排序规则系列 允许按文档的其中一个字段的值对文档进行排序。
这引发了一个问题。如果我们需要规则可以返回的最大桶数,那么在例如按产品价格排序时,该数字应该是多少?请记住,该值必须独立于索引中的文档。
我们在这里考虑了多种选择,例如根据值分布自动推断各种桶,同时仍然允许开发人员在评分时指定分桶。最终,我们选择了最简单的选项,即不添加额外的 API 表面:分数不受排序排名规则的影响。它们被视为返回单个桶来处理。
此决定会产生一些不匹配阻抗,因为实际上,当使用排序排名规则时,Meilisearch 会在相同值的桶中对文档进行排名。这意味着,如果您的排名规则在按拼写错误区分之前按升序价格排序,您将获得以下顺序
- 价格为 100 美元的文档,没有拼写错误
- 价格为 100 美元的文档,有拼写错误
- 价格为 200 美元的文档,再次没有拼写错误
- 价格为 200 美元的文档,有拼写错误
由于排序排名规则不影响分数,因此 (2) 中返回的文档的相关性分数将低于 (3) 中返回的文档。毕竟,前者有拼写错误,而后者没有。这可能会让用户感到惊讶,这是我们本想避免但未能找到实用方法的事情。
看待问题的另一种方式可能会使其看起来更自然。虽然大多数排名规则按相关性对文档进行排序,但按字段排序的排名规则按该字段的值对它们进行排序,最终,只能有一个顺序。
总结一下,当使用聚合分数对文档进行排名时,任何按字段排序的排名规则的效果都会在排名中被抵消。当使用详细分数时,这不是问题,因为详细分数提供了用于按字段排序的排名规则对文档进行排名的值,因此开发人员可以在重新排名时将其考虑在内。
使用评分 API
Meilisearch v1.3 允许在搜索请求中指定 showRankingScore
查询参数,并将该参数设置为 true
会导致将 _rankingScore
浮点值注入到搜索返回的文档中。
然后,可以获取每个文档的分数,并例如根据值选择不同的表情符号或 CSS 类。
分数 | 表情符号 |
---|---|
0.99 | 👑 |
0.95 | 💎 |
0.90 | 🏆 |
... | ... |
0.25 | 🐓 |
我确信开发人员会想出许多使用此分数的方法,比这位可怜的后端工程师的尝试更富有想象力 😳
Meilisearch v1.3 还公开了 _rankingScoreDetails
。但是,由于它们添加了大量的 API 表面,因此目前通过 运行时实验性标志 进行控制。感谢您的 反馈!
结论
实现相关性评分是 Meilisearch 中针对设计空间很大的复杂功能的设计过程的一个示例。
它也代表了与社区进行良好互动的好机会,因为我们发布了该功能的几个原型并获得了用户的反馈。我们要特别感谢用户 @LukasKalbertodt,他在原型上提供的 出色反馈 绝对帮助我们改进了解决方案 ❤️
我们使用评分 API 获得的早期结果非常有希望
- 调试相关性。 Meilisearch v1.3 已经具有相关性改进,这些改进通过详细的分数揭示 相关性 问题 而变得显而易见
- 解锁聚合搜索。 使用聚合分数对来自异构搜索查询和索引的文档进行重新排名,从而解锁了聚合搜索的第一个版本。如果使用 实验性功能开关 来启用详细分数,则可以使用它们以更精细的方式重新排名文档
- 解锁混合搜索。 相关性分数可以用作实现混合搜索的一种手段,以及我们一直在 [语义搜索](/blog/vector-search-announcement/) 方面所做的工作
我们希望您喜欢深入了解评分功能,并且它对您有用。请随时在我们的 Discord 社区中向我们提供反馈。
如需了解更多 Meilisearch 的信息,您还可以订阅我们的 新闻通讯,查看我们的 路线图,参与我们的 产品讨论,从 源代码 构建,或在 云 上创建一个项目。再见!