从排名到评分
我们在 Meilisearch 中为搜索结果添加相关性评分的旅程。
欢迎来到文字自助餐!下单吧,我们会为您奉上我们最精美的 20 个文档,它们与您的查询最为匹配!好吧,我们说最精美,但我们不会确切地告诉您它们与您的查询匹配得有多好,我们只是向您保证它们是我们拥有的最好的。您说,这不可接受?您是该地区最著名的美食评论家,您需要能够区分完美的匹配和文字沙拉,您说?哦,这听起来确实很麻烦……
Meilisearch 是一个开源搜索引擎,旨在提供闪电般快速、高度相关的搜索,且集成工作量很小。它能够将文档以称为索引的分组形式存储在磁盘上,并搜索这些索引以获取与每个查询相关的文档。顺便说一句,如果您想让您的生活更轻松并专注于您提供的搜索体验,我们提供云解决方案,该解决方案始终受益于我们的最新版本 😉
直到最近发布的 [1.3 版本](/blog/v1-3-release/),Meilisearch 都没有办法知道文档与特定搜索查询的匹配程度。本文深入探讨了我们添加此功能的过程。
动机
为文档提供相关性评分可满足许多高需求的使用场景
- 根据文档的分数调整搜索结果的呈现方式。例如,开发者 bakerfugu 有一个日历应用程序,并希望使用颜色区分来突出显示会议和活动的相关性。然而,搜索结果顺序是不够的,因为即使是排名第一的结果,其相关性也可能有所不同。如果没有评分,我们只知道它是 Meilisearch 能找到的最好的结果。
- 实现聚合搜索。聚合搜索是一种显示来自搜索多个索引的结果的方式,就像对单个统一索引执行搜索一样。用户在各种 GitHub 讨论中报告了实际使用案例。
- 实现分片。分片类似于联合搜索,因为它组合了多个搜索查询的结果。与联合搜索不同,分片涉及查询多个 Meilisearch 实例上的分区索引,而不是查询同一实例中的多个索引。数据预计会更同质化,但必须在查询之后(可以这么说,“离线”)进行重新排名。
- 理解相关性。Meilisearch 使用一组预定义的规则来对文档进行排名。通过为每个排名规则生成详细的分数,可以更精细地了解单个规则如何应用于特定查询的文档。这有助于发现最大化每个使用场景相关性的最佳设置。
从这四个使用案例中我们可以看到,实现评分功能的设计空间很大。解决方案应具备哪些属性才能最好地满足所有这些使用案例?让我们来看看它们。
目标属性
从不同的使用案例中,我们可以看到多个不同的用户和使用群体。我们确定了可以提供多种解决方案的两个轴
- 聚合分数与详细分数。对于日历用例,每个文档的单个“聚合”分数就足够了,而对于理解相关性,则需要每个排名规则的详细分数。理想的解决方案应同时提供聚合分数和详细分数。
- 机器可读与人类可读。大多数使用案例都需要集成或前端可以自动处理的信息。然而,如果我们旨在使相关性更容易理解,我们需要提供人类可读的信息。因此,解决方案应在这两个属性之间取得适当的平衡。
此外,为了实现聚合搜索和分片,分数必须独立于搜索索引中包含的文档。实际上,如果向索引添加文档会更改其他文档的分数,那么将该分数与包含不同文档的另一个索引进行比较是毫无意义的。
最后,我们希望评分系统直观:Meilisearch 应按照相关性分数递减的顺序返回文档,遵循最小惊讶原则。
特别是,最后一个属性使我们倾向于采用基于 Meilisearch 已执行的排名的解决方案,以此来保证排名和评分之间的一致性。
总而言之,解决广泛的使用案例需要一个评分功能,该功能不仅提供聚合分数和详细分数,而且还满足机器和人类的可读性。它还应保持分数独立性,而与搜索索引中的文档无关,并与 Meilisearch 现有的排名系统保持一致。为了了解我们如何基于排名构建此评分系统,我们首先需要了解排名本身。
递归桶排序
本节介绍 Meilisearch 如何对文档进行排名以响应搜索查询。如果您已经了解 Meilisearch 使用的递归桶排序算法,请随意跳过本节。此外,由于本节重点关注搜索算法,因此不涵盖引擎的其他部分(如索引)。如果您有兴趣了解更多相关信息,我们之前在[一篇专门的文章](/blog/how-full-text-search-engines-work/)中介绍了这些内容
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 给出的排名保持一致。请记住,后续的排名规则主要用于解决先前规则的平局。类似地,较晚的排名规则应该只优化从先前规则得出的分数。
考虑到这一点,让我们修改一下之前的图表。与其用“4/4”标记匹配所有单词的文档的 words
桶,不如说这个桶中的文档的范围从 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
桶,“蝙蝠侠:黑暗骑士归来,第一部”不再是完全匹配:它的分数变为 **0.875 而不是 1.0**。我们需要避免这个问题。
实现数据集独立性
我们的分数应该完全独立于索引中包含的文档。每个规则都应该能够仅根据查询计算理论上的最大桶数,而不是索引中的文档。
对于 `typo` 规则,这涉及到将索引的 错别字容错设置 应用于查询,并计算最大可能的错别字数量。默认设置通常允许 5 个或更多字符的词语出现 1 个错别字,对于至少 9 个字符的词语,最多允许出现 2 个错别字。
使用这些设置,查询 “Badman dark knight returns” 最多允许 3 个错别字(“badman” 上 1 个,“knight” 上 1 个,“returns” 上 1 个),总共 4 个可能的桶,从 0 到 3 个错别字。这意味着 “蝙蝠侠:黑暗骑士归来,第一部” 实际上应该得分为 **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 的更多信息,您还可以订阅我们的 新闻通讯,查看我们的 路线图,参与我们的 产品讨论,从 源代码 构建,或在 云上创建一个项目。再见!