Ohhnews

分类导航

$ cd ..
foojay原文

MongoDB Atlas Search 评分机制深度解析

#mongodb#atlas search#全文检索#lucene#搜索优化

目录

相关性之谜 评分详情 Lucene 内核 Lucene 索引 Lucene 评分 最佳匹配 拆解评分详情 美化评分详情展示 谜题揭晓! 复合查询是王道 提升子句权重 相关性调优:微妙的平衡

全文搜索支撑着我们数字生活的方方面面——无论是谷歌搜索、询问 Siri 哪里有美味的晚餐、在亚马逊购物等等。即使存在拼写错误、语音转换失误或查询词表达模糊,我们依然能获得相关的搜索结果。我们已经习惯于在搜索结果的最上方看到最符合我们意图的内容。

现在,亲爱的开发者,轮到你为你的 Atlas 应用构建同样令人满意的用户体验了。

如果你尚未创建 Atlas Search 索引,建议在深入阅读本文之前先完成这一步。我们有一份方便的教程教你如何开始使用 Atlas Search。我们很乐意等待你准备好并获得一些搜索结果后再回来。

欢迎回来!我们看到你已经有了数据,并且它存储在 MongoDB Atlas 中。你已经开启了 Atlas Search 并运行了一些查询,现在你想要了解为什么结果会按当前的顺序排列,并希望获得一些关于调优相关性排序的技巧。

相关性之谜

在文章《从 Java 使用 Atlas Search》中,我们为读者留下了一个关于搜索相关性的谜题:使用 cast 字段查询短语 "keanu reeves"(小写;即使是这种不精确的查询,$match 也会失败),并将结果限定为既是剧情片(genres:Drama又是爱情片(genres:Romance)的电影。我们将在此使用同样的查询。该查询的结果匹配到了多个文档,但得分各不相同。唯一的评分因子是针对短语 "keanu reeves" 的 must 子句。为什么《甜蜜十一月》(Sweet November)和《云中漫步》(A Walk in the Clouds)的得分不完全相同?

[LOADING...]

你能看出区别吗?请继续阅读,我们将为你提供工具和技巧,以查明并解决这些由全文、非精确/模糊/近似搜索结果所带来的挑战。

评分详情

Atlas Search 让构建全文搜索应用变得简单,只需点击几下并接受默认设置,你就能拥有强大的搜索能力。你拥有一个相当不错的自动驾驶系统,但你现在正坐在波音 747 的驾驶舱里,周围布满了旋钮和仪表。飞机大多数时候可以自动起飞和降落,但根据具体条件和目标,你可能需要手动将音量旋钮调到 11.0,甚至在推力杆上加把劲,才能优雅地飞行。相关性调优也是如此,在接管参数之前,你需要了解这些设置的作用以及可以通过调整实现什么效果。

每个文档针对特定查询的评分详情可以被请求并返回。获取评分详情需要两个步骤:首先在 $search 请求中请求它们,然后将评分详情元数据投影到每个返回的文档中。请求评分详情会对底层搜索引擎造成性能开销,因此仅在诊断或学习目的下使用。要请求评分详情,请将 scoreDetails 设置为 true。这些评分详情可在每个文档的 $meta 数据中获取。

以下是获取评分详情所需的操作:

请求方式
[{ "$search": { ... "scoreDetails": true } }, { "$project": { ... "scoreDetails": {"$meta": "searchScoreDetails"} } }]

让我们搜索从教程中构建的电影集合,查找主演为 "keanu reeves" 的剧情爱情片(简而言之:添加示例集合,在电影集合上创建 dynamic="true"default 搜索索引),并获取评分和评分详情:

查询示例
[ { "$search": { "compound": { "filter": [ { "compound": { "must": [ { "text": { "query": "Drama", "path": "genres" } }, { "text": { "query": "Romance", "path": "genres" } } ] } } ], "must": [ { "phrase": { "query": "keanu reeves", "path": "cast" } } ] }, "scoreDetails": true } }, { "$project": { "_id": 0, "title": 1, "cast": 1, "genres": 1, "score": { "$meta": "searchScore" }, "scoreDetails": { "$meta": "searchScoreDetails" } } }, { "$limit": 10 } ]

内容警告!以下输出可能令人望而生畏。但这是我们深入探讨的原因,请耐心阅读,因为下文将对这些细节进行解释。对于第一个结果,投影出的 scoreDetails 值看起来大致如下:

评分详情结构
"scoreDetails": { "value": 6.011996746063232, "description": "sum of:", "details": [ { "value": 0, "description": "match on required clause, product of:", "details": [ { "value": 0, "description": "# clause", "details": [] }, { "value": 1, "description": "+ScoreDetailsWrapped ($type:string/genres:drama) +ScoreDetailsWrapped ($type:string/genres:romance)", "details": [] } ] }, { "value": 6.011996746063232, "description": "$type:string/cast:\"keanu reeves\" [BM25Similarity], result of:", "details": [ { "value": 6.011996746063232, "description": "score(freq=1.0), computed as boost * idf * tf from:", "details": [ { "value": 13.083234786987305, "description": "idf, sum of:", "details": [ { "value": 6.735175132751465, "description": "idf, computed as log(1 + (N - n + 0.5) / (n + 0.5)) from:", "details": [ { "value": 27, "description": "n, number of documents containing term", "details": [] }, { "value": 23140, "description": "N, total number of documents with field", "details": [] } ] }, { "value": 6.348059177398682, "description": "idf, computed as log(1 + (N - n + 0.5) / (n + 0.5)) from:", "details": [ { "value": 40, "description": "n, number of documents containing term", "details": [] }, { "value": 23140, "description": "N, total number of documents with field", "details": [] } ] } ] }, { "value": 0.4595191478729248, "description": "tf, computed as freq / (freq + k1 * (1 - b + b * dl / avgdl)) from:", "details": [ { "value": 1, "description": "phraseFreq=1.0", "details": [] }, { "value": 1.2000000476837158, "description": "k1, term saturation parameter", "details": [] }, { "value": 0.75, "description": "b, length normalization parameter", "details": [] }, { "value": 8, "description": "dl, length of field", "details": [] }, { "value": 8.217415809631348, "description": "avgdl, average length of field", "details": [] } ] } ] } ] } ] }

我们将在下面编写一段代码,以更简洁、可读的格式展示这种嵌套结构,并深入探讨其中的细节。在拆解评分之前,我们需要了解这些因素的来源。它们来自 Lucene。

Lucene 内核

Apache Lucene 支撑着全球绝大多数的搜索体验,从大多数电子商务网站到医疗保健和保险系统、内部网、顶级情报机构等等。众所周知,Apache Lucene 是 Atlas Search 的核心。Lucene 已被证明是稳健且可扩展的,并得到了广泛部署。许多人认为 Lucene 是有史以来最重要的开源项目,来自世界各地多个行业的搜索专家组成的多元化社区在此协作,不断改进和创新这个强大的项目。

那么,这个名为 Lucene 的神奇工具究竟是什么?Lucene 是一个用 Java 编写的开源搜索引擎库,它负责索引内容并处理复杂的查询,从而快速返回相关结果。此外,Lucene 还提供分面搜索、高亮显示、向量搜索等功能。

Lucene 索引

我们无法在不讨论方程的索引侧的情况下讨论搜索相关性,因为它们是相互关联的。当文档被添加到启用了 Atlas Search 索引的 Atlas 集合中时,文档字段会根据配置的索引映射被索引到 Lucene 中。

当对文本字段进行索引时,通过一个称为“分析”(analysis)的过程构建了一种称为“倒排索引”(inverted index)的数据结构。倒排索引就像一本实体字典,是一个按字典/字母顺序排列的词项列表,并交叉引用了包含它们的文档。分析过程在索引期间首先获取字段的完整文本值,并根据映射中定义的分析器将其分解为单个词项/单词。

例如,愚蠢的句子 "The quick brown fox jumps over the lazy dog" 会被 Atlas Search 默认分析器 (lucene.standard) 分析为以下词项:the, quick, brown, fox, jumps, over, the, lazy, dog。现在,如果我们对这些词项进行字母排序(并去重,记录频率),它看起来如下:

词项频率
brown1
dog1
fox1
jumps1
lazy1
over1
quick1
the2

除了记录哪些文档包含某个词项外,每个词项实例的位置也会被记录在倒排索引结构中。记录词项位置允许进行短语查询(如我们的 "keanu reeves" 示例),其中查询的词项必须在索引字段中彼此相邻。

假设我们有一个“愚蠢句子”集合,其中那是我们的第一个文档(文档 ID 1),我们添加了另一个文档(ID 2),内容为 "My dogs play with the red fox"。我们的倒排索引(显示文档 ID 和词项位置)变为:

词项文档 ID词项频率词项位置
brown11文档 1: 3
dog11文档 1: 9
dogs21文档 2: 2
fox1, 22文档 1: 4; 文档 2: 7
jumps11文档 1: 5
lazy11文档 1: 8
my21文档 2: 1
over11文档 1: 6
play21文档 2: 3
quick11文档 1: 2
red21文档 2: 6
the1, 23文档 1: 1, 7; 文档 2: 5
with21文档 2: 4

利用这种数据结构,Lucene 可以快速导航到查询的词项并返回包含它的文档。

这个倒排索引示例中有几个值得注意的特征。单词 "dog" 和 "dogs" 是不同的词项。从分析过程发出的词项(完全按照发出时的样子进行索引)是原子可搜索单元,其中 "dog" 不等于 "dogs"。你的应用程序是否需要在搜索其中任何一个词项时都找到两个文档?还是应该更精确?另外值得注意的是,这里有两个文档,而 "the" 出现了三次——出现的次数比文档数量还多。也许像 "the" 这样的词在你的数据中太常见了,以至于搜索该词毫无意义。你选择的分析器决定了什么内容会进入倒排索引,从而决定了什么内容是可搜索的。Atlas Search 提供了多种分析器选项,正确的选择是那个最适合你的领域和数据的选项。

通过分析和索引过程,会出现许多关于文档集合的统计信息,包括:

  • 词项频率(Term frequency):一个词项在该文档的字段中出现了多少次?
  • 文档频率(Document frequency):该词项在多少个文档中出现?
  • 字段长度(Field length):该字段中有多少个词项?
  • 词项位置(Term positions):每个实例出现在发出词项的哪个位置?

这些统计数据潜伏在 Lucene 索引结构的深处,并显眼地出现在我们上面看到的评分详情输出中,我们将在下文深入探讨。

Lucene 评分

索引期间捕获的统计数据会影响查询时文档的评分方式。Lucene 评分的核心建立在 TF/IDF(词频/逆文档频率)之上。通常来说,TF/IDF 会给词频较高的文档比词频较低的文档更高的分数,并给包含较常见词项的文档比包含较罕见词项的文档更低的分数——其理念是集合中的罕见词项比频繁出现的词项传达了更多信息,且词项的权重与其频率成正比。

在 Lucene 的 TF/IDF 实现背后还有一些数学原理,用于抑制 TF 的影响(例如,取平方根)并缩放 IDF(使用对数函数)。

经典的 TF/IDF 公式在一般情况下效果良好,即当文档字段长度大致相同,且数据中没有出现恶意的或奇怪的情况(如同一个词被重复多次)时——这种情况常出现在产品描述、博客评论、餐厅评论中,或者当有人试图通过提升文档权重使其排在结果顶部时。鉴于并非所有文档都是平等的——有些标题很长,有些很短,有些描述重复了很多词或者非常简洁——为了应对这些情况,进行一些微调是必要的。## 最佳匹配

随着搜索引擎的发展,人们对经典的 TF/IDF 相关性计算进行了改进,以解决术语饱和问题(即同一术语在字段中出现次数过多),并通过引入文档字段长度与集合平均字段长度的比值,降低了包含过多术语的长字段对评分的影响。目前流行的 BM25 方法已成为 Lucene 中的默认评分公式,也是 Atlas Search 使用的评分公式。BM25 代表“Best Match 25”(该评分算法的第 25 次迭代)。在 OpenSource Connections 上可以找到一篇非常棒的文章,其中通过直观的图表对比了经典的 TF/IDF 和 BM25。

BM25 包含用于附加因素 k1b 的内置值。k1 因子影响术语每出现一次对评分增加的影响程度,而 b 则控制字段长度的影响。这两个因子目前在内部均设置为 Lucene 的默认值,开发人员暂时无法调整,但这没关系,因为内置值已经过优化,能够提供出色的相关性。

解析评分详情

让我们以更简洁、更易读的方式查看这些评分详情:

[LOADING...]

在这种格式下,可以更容易地看出约 6.011 的总分是由两个数字相加得出的:0.0(非评分的 # clause 标签过滤器)和约 6.011。而这约 6.011 的因子来自于 BM25 评分公式,它将约 13.083 的“idf”(逆文档频率)因子与约 0.459 的“tf”(术语频率)因子相乘。其中的“idf”因子是两个组件之和,对应于我们 phrase 操作子句中的每个术语。我们查询的两个术语“keanu”和“reeves”的每个 idf 因子都是使用输出中的公式计算的:

log(1 + (N - n + 0.5) / (n + 0.5))

完整短语的“tf”因子计算公式如下:

freq / (freq + k1 * (1 - b + b * dl / avgdl))

这使用了其下方缩进的因子,例如集合中所有文档的“cast”字段的平均长度(以术语数量计)。

在此输出中,每个字段名称(“genres”和“cast”)前面都有一个内部用于标记字段类型的前缀(即 $type:string/ 前缀)。

美化输出评分详情

上述更人性化的评分详情输出是使用 MongoDB VS Code Playgrounds 生成的。这段 JavaScript 代码通过调用 print_score_details(doc.scoreDetails); 来打印更简洁、带缩进的 scoreDetails:

$ node
function print_score_details(details, indent_level) {
  if (!indent_level) { indent_level = 0; }
  spaces = " ".padStart(indent_level);
  console.log(spaces + details.value + ", " + details.description);
  details.details.forEach (d => {
    print_score_details(d, indent_level + 2);
  });
}

类似地,Java 中的美化输出可以参考 Using Atlas Search from Java 一文中的代码,该代码可在 GitHub 上找到。

谜题揭晓!

回到我们的相关性谜题,让我们看看评分详情:

[LOADING...]

利用 Lucene 倒排索引中捕获的统计信息,我们发现这两个文档的 cast 字段有一个有趣的差异。它们都有四名演员,但请记住分析过程会从文本中提取可搜索的术语。在评分较低的那个文档中,其中一名演员的姓氏是连字符形式的:Aitana Sènchez-Gijèn。破折号/连字符是 lucene.standard 分析器的术语分隔符,这为该文档增加了一个额外术语,从而增加了 cast 字段的长度(术语数量)。字段长度越长,术语匹配的权重就越低。

复合查询是关键

即使在这个简单的短语查询示例中,评分也由许多因子组成,这些因子是其他因子和公式的“总和”、“乘积”、“结果”或“来源”。相关性调整涉及使用 shouldmustcompound 操作符内嵌套各种子句。再次注意,filter 子句不参与评分,但对于缩小 shouldmust 子句评估的文档范围非常有价值。当然,mustNot 子句也不参与评分,因为匹配这些子句的文档会被完全排除在结果之外。

使用多个 compound.shouldcompound.must 来以不同方式加权不同字段中的匹配项。例如,一种常见的做法是通过在不同的查询操作符子句上设置 boost,使 title 字段中的匹配项权重高于 description 字段(或电影集合中的 plot 字段)。

提升子句权重

对于由多个子句组成的查询,您可以使用所有搜索操作符上提供的可选 score 设置,以多种方式修改评分。子句的评分因子可以通过以下四种方式控制:

  • constant:子句的评分因子设置为一个明确的值。
  • boost:将子句的常规计算评分因子乘以指定值,或乘以被评分文档中某个字段的值。
  • function:使用指定的公式表达式计算评分因子。
  • embedded:配合 embeddedDocument 搜索操作符,控制匹配的嵌入式文档如何影响顶级父文档的评分。

这提供了非常细致的控制!当您深入调整搜索结果排名时,这些是非常重要的控制手段。

相关性调整:微妙的平衡

通过此处说明的工具和机制,您已经掌握了 Atlas Search 评分洞察的基础知识。当面临不可避免的结果排名挑战时,您将能够评估情况并了解评分产生的原因和方式。调整这些结果很棘手。微调单个查询的结果顺序相当简单,但这只是一个查询。

调整 boost 因子、利用更细致的复合子句以及修改分析设置都会影响其他查询结果。为了确保您的用户获得相关结果,请遵循以下建议:

  • 反复测试,针对大量查询进行测试——尤其是从日志中挖掘出的真实查询,而不仅仅是您自己的测试查询。
  • 使用完整的数据集进行测试(尽可能具有代表性或真实),而不是仅用于开发目的的数据子集。
  • 请记住,索引统计信息对评分至关重要,例如每个字段的平均术语长度。如果您使用非生产质量和规模的数据进行测试,相关性指标将无法匹配生产环境的统计信息。

相关性关注点会因领域、规模、敏感性和搜索结果排序的货币价值而有很大差异。确保“最佳”(根据对您重要的任何指标)文档出现在顶部位置既是一门艺术,也是一门科学。电子商务巨头们一直在后台测试查询结果、运行回归测试和 A/B 实验,并调整所有可用的参数。然而,对于网站搜索而言,设置 title 的 boost 可能就足够了。

您已经拥有了工具,这只是数学问题,但请慎重进行调整,并使用完整真实的数据、真实查询,并投入时间和耐心来设置测试和实验。