
欢迎来到文字自助餐!下单后,我们将为您提供 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 给出的排名保持一致。请记住,后续的排名规则主要用于解决先前规则中的平局。同样,靠后的排名规则只应细化从先前规则得出的分数。
考虑到这一点,让我们修改之前的图表。与其将匹配所有单词的文档的 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”没有错别字。
现在,如果我们添加一部名为“坏蛋归来黑暗骑士”的新电影会发生什么?我们现在有第一个words
桶的两个typo
桶,而“蝙蝠侠:黑暗骑士归来,第一部分”不再是完美匹配:它的分数从1.0 变为 0.875。我们需要避免这个问题。
实现数据集独立性
我们的分数应该与索引中包含的文档完全无关。每个规则都应该能够仅根据查询计算理论上的最大桶数,而不是根据索引中的文档。
对于 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”是否是索引中或任何地方存在的电影(纠正上述排名列表中的分数留给读者作为练习)。
幸运的是,对于大多数排名规则,计算理论上的最大桶数可以自然地完成(详细说明每种排名规则的方式超出了本文的范围,但如果您感兴趣,我们很乐意回答问题 😊)。不幸的是,排序和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 相关内容,您还可以订阅我们的时事通讯,查看我们的路线图,参与我们的产品讨论,从源代码构建,或者在云端创建项目。期待与您再会!