人工智能驱动的搜索(AI-Powered Search)——排名与基于内容的相关性

157 阅读51分钟

本章内容:

  • 执行查询并返回搜索结果
  • 根据查询的相关性对搜索结果进行排名
  • 关键词匹配和过滤与基于向量的排名
  • 使用功能查询控制和指定自定义排名函数
  • 针对特定领域定制排名函数

搜索引擎从根本上做三件事:获取内容(索引)、返回与传入查询匹配的内容(匹配),以及根据某种衡量内容与查询匹配度的标准对返回的内容进行排序(排名)。可以添加额外的功能,允许用户提供更好的查询(自动建议、聊天机器人对话等),并使用大型语言模型从结果中提取更好的答案或总结结果(参见第14-15章),但搜索引擎的核心功能是基于索引数据的匹配和排名。

相关性是指返回的内容与查询的匹配程度。通常,被匹配的内容是文档,而返回和排名的内容是匹配的文档及其对应的元数据。在大多数搜索引擎中,默认的相关性排序是基于一个分数,表示查询中的每个关键词与每个匹配文档中相同关键词的匹配程度。或者,查询可以映射到数值向量表示中,得分表示查询向量与每个匹配文档的相似度。最佳匹配的文档会得到最高的相关性分数,并显示在搜索结果的顶部。相关性计算是高度可配置的,可以根据每个查询进行调整,从而实现复杂的排名行为。

在本章中,我们将概述相关性是如何计算的,如何通过功能查询轻松控制和调整相关性函数,以及如何实现流行的领域特定和用户特定的相关性排名功能。

3.1 使用余弦相似度对查询和文档向量进行评分

在第2.3节中,我们演示了通过计算两个向量之间的余弦值来衡量它们相似度的概念。我们创建了表示不同食品项的向量(数字列表,其中每个数字代表某个特征的强度),然后计算余弦值(向量之间的角度大小)以确定它们的相似性。在本节中,我们将扩展这一技术,讨论如何将文本查询和文档映射为向量以用于排名。接下来,我们将探讨一些流行的基于文本的特征加权技术,以及如何将它们集成以创建改进的相关性排名公式。

运行代码示例

书中的所有代码示例都可以在运行预配置的Docker容器中的Jupyter笔记本中找到。这使你能够通过单个命令(docker compose up)运行交互式代码版本,而无需花费时间进行复杂的系统配置和依赖管理。代码示例还可以与多个搜索引擎和向量数据库一起运行。有关如何配置和启动Jupyter笔记本并在浏览器中跟随示例的说明,请参见附录A。

为了简洁起见,本书中的代码示例可能省略了某些代码行,如导入或附加代码,但笔记本中包含了所有的实现细节。

在本节中,我们将深入探讨本书的第一个代码示例。启动运行所需的Docker容器以运行附带的Jupyter笔记本会很有帮助,这样你就可以跟随交互式代码示例。启动方法的说明请参见附录A。

3.1.1 将文本映射为向量

在典型的搜索应用中,我们从一组文档开始,然后根据它们与某个用户查询的匹配程度对文档进行排序。在本节中,我们将演示如何将查询和文档的文本映射到向量中。

在上一章中,我们使用了一个关于食品和饮料项目(如苹果汁)搜索的示例,因此我们在这里将重新使用这个示例。假设我们有两篇不同的文档,我们希望根据它们与查询的匹配程度对它们进行排序。

查询:苹果汁
文档 1:
Lynn: 火腿奶酪三明治,巧克力饼干,冰水
Brian: 火鸡鳄梨三明治,原味土豆片,苹果汁
Mohammed: 烤鸡沙拉,水果杯,柠檬水

文档 2:
Orchard Farms苹果汁是高品质、有机苹果汁,由最新鲜的苹果制成,绝不使用浓缩汁。其汁液已连续三年获得地区最佳苹果汁奖。

如果我们将这两个文档(总共包含48个单词)映射到向量,它们将映射到一个48个单词的向量空间,维度如下:
[a, and, apple, apples, avocado, award, best, brian, cheese, chicken, chips,
chocolate, concentrate, cookie, cup, farms, for, freshest, from, fruit,
grilled, ham, has, ice, in, is, its, juice, lemonade, lynn, made,
mohammed, never, orchard, organic, plain, potato, premium, received,
regional, row, salad, sandwich, the, three, turkey, water, years]

如果你还记得在第2.3节中,我们建议将“苹果汁”短语的查询视为一个向量,该向量包含我们任何文档中每个单词的特征,对于术语“apple”和“juice”给定值1,对于所有其他术语给定值0。

由于术语“apple”在我们48个单词向量空间中的第3个位置,而“juice”在第28个位置,因此“apple juice”短语的查询向量将如下所示,参见图3.1。

image.png

即使查询向量只在两个维度(表示“apple”和“juice”的位置)上包含非零值,它仍然在所有其他可能的维度上包含值0。像这样表示一个向量,包括每个可能的值,称为稠密向量表示。

每个文档也基于它包含的每个术语映射到相同的向量空间:

文档 1:
[0 1 1 0 1 0 0 1 1 1 1 1 0 1 1 0 0 0 0 1 1 1 0 1
0 0 0 1 1 1 0 1 0 0 1 1 1 0 0 0 0 1 1 0 0 1 1 0]

文档 2:
[1 0 1 1 0 1 1 0 0 0 0 0 1 0 0 1 1 1 1 0 0 0 1 0
1 1 1 1 0 0 1 0 1 1 0 0 0 1 1 1 1 0 0 1 1 0 0 1]

通过这些稠密向量表示,我们现在可以使用线性代数来衡量查询向量与每个文档向量之间的相似度。

3.1.2 计算稠密向量表示之间的相似度

为了对文档进行排序,我们只需按照第2章中介绍的相同过程,计算每个文档和查询之间的余弦相似度。这个余弦值将作为相关性评分,用于对文档进行排序。 下面的代码展示了如何在代码中表示查询和文档向量,并计算查询与每个文档之间的余弦相似度。

代码清单 3.1 计算向量之间的余弦相似度

query_vector = numpy.array(
  [0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
   0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0])

doc1_vector = numpy.array(
  [0, 1, 1, 0, 1, 0, 0, 1, 1, 1, 1, 1, 0, 1, 1, 0, 0, 0, 0, 1, 1, 1, 0, 1,
   0, 0, 0, 1, 1, 1, 0, 1, 0, 0, 1, 1, 1, 0, 0, 0, 0, 1, 1, 0, 0, 1, 1, 0])

doc2_vector = numpy.array(
  [1, 0, 1, 1, 0, 1, 1, 0, 0, 0, 0, 0, 1, 0, 0, 1, 1, 1, 1, 0, 0, 0, 1, 0,
   1, 1, 1, 1, 0, 0, 1, 0, 1, 1, 0, 0, 0, 1, 1, 1, 1, 0, 0, 1, 1, 0, 0, 1])

def cosine_similarity(vector1, vector2):
  return dot(vector1, vector2) / (norm(vector1) * norm(vector2))

doc1_score = cosine_similarity(query_vector, doc1_vector)
doc2_score = cosine_similarity(query_vector, doc2_vector)

print_scores([doc1_score, doc2_score])

输出:

相关性评分:
  doc1: 0.2828
  doc2: 0.2828

有趣的是……即使这两个文档包含长度不同且内容差异较大的向量,它们的相关性评分居然相同。可能一开始不容易看出发生了什么,我们可以通过仅关注重要的特征来简化计算过程。

3.1.3 计算稀疏向量表示之间的相似度

理解上一节计算的关键在于认识到,只有查询和文档之间共享的特征才是相关的。所有其他特征(出现在文档中但与查询不匹配的单词)对文档的排名没有任何影响。因此,我们可以从向量中去除所有其他无关的项,以简化示例,将其从稠密向量表示转换为所谓的稀疏向量表示,如图3.2所示。

image.png

在大多数搜索引擎评分操作中,我们倾向于使用稀疏向量表示,因为在基于少量特征进行评分时,它们更高效。此外,我们可以通过创建仅包含“有意义项”——即查询中出现的词汇——的向量来进一步简化计算,如下所示。

代码清单 3.2 稀疏向量表示的余弦相似度

query_vector = [1, 1] #[apple, juice]
doc1_vector  = [1, 1]
doc2_vector  = [1, 1]

doc1_score = cosine_similarity(query_vector, doc1_vector)
doc2_score = cosine_similarity(query_vector, doc2_vector)

print_scores([doc1_score, doc2_score])

输出:

相关性评分:
  doc1: 1.0
  doc2: 1.0

请注意,doc1 和 doc2 仍然返回相等的相关性评分,但现在每个文档的评分都是 1.0。如果你还记得,余弦计算中的 1.0 分数意味着向量是完全匹配的,这也符合因为两个向量是完全相同的([1, 1])。

实际上,你会注意到几个非常有趣的点:

  • 这种简化的稀疏向量表示计算仍然显示 doc1 和 doc2 返回相等的相关性评分,因为它们都匹配查询中的所有词汇。
  • 尽管稠密向量表示相似度(0.2828)和稀疏向量表示相似度(1.0)之间的绝对评分不同,但它们在每种向量类型内的相对评分仍然相同。
  • 查询中的两个词项(“apple”和“juice”)的特征权重在查询和每个文档之间是相同的,因此得到了 1.0 的余弦分数。

向量与向量表示

我们特别提到“稠密向量表示”和“稀疏向量表示”,而不是“稠密向量”和“稀疏向量”。这是因为向量和其表示之间有一个概念上的区别,而这个区别常常会引起混淆。

向量的稀疏性指的是向量中具有有效值的特征所占的比例。具体来说,稠密向量是指那些大多数特征值都非零的向量,而稀疏向量则是指大多数特征值为零的向量,无论它们如何存储或表示。另一方面,向量表示则涉及用于处理向量的数据结构。对于稀疏向量来说,为所有零值分配内存和存储空间可能是浪费的,因此我们通常会使用稀疏数据结构(例如倒排索引)仅存储非零值。以下是一个例子:

稠密向量: 特征_1: 1.1, 特征_2: 2.3, 特征_3: 7.1, 特征_4: 5.2, 特征_5: 8.1
稠密向量表示:[1.1, 2.3, 7.1, 5.2, 8.1]
稀疏向量表示:N/A(该向量不是稀疏的,因此不能以稀疏方式表示)

稀疏向量: 特征_1: 1.1, 特征_2: 0, 特征_3: 0, 特征_4: 5.2, 特征_5: 0
稠密向量表示:[1.1, 0.0, 0.0, 5.2, 0.0]
稀疏向量表示:{1: 1.1, 4: 5.2},或者如果不需要特征位置,表示为[1.1, 5.2]

由于稀疏向量主要包含零值,而其对应的稀疏向量表示几乎包含了相反的内容(仅包含非零值),因此人们常常会混淆这两个概念,并错误地将稀疏向量的稠密向量表示称为“稠密向量”,甚至将具有很多维度的向量称为“稠密向量”,将维度较少的向量称为“稀疏向量”。你可能会在其他文献中看到这种混淆,因此理解这一区别是非常重要的。

由于我们的查询和文档向量都是稀疏向量(大多数值为零,因为特征的数量是搜索索引中的关键词数量),在进行关键词搜索时,使用稀疏向量表示是有意义的。

搜索引擎通过不仅将向量中的每个特征视为1(存在)或0(不存在),而是根据每个特征的匹配度为其提供一个得分,从而调整这些问题。

3.1.4 词频:衡量文档与某个术语的匹配程度

我们在上一节中遇到的问题是,我们的术语向量中的特征只表示“apple”或“juice”是否存在于文档中,而没有表示每个文档与这些术语的匹配程度。通过将查询中每个存在的术语表示为1的方式,带来的副作用是,doc1和doc2对查询的余弦相似度得分总是相同,尽管从定性分析来看,doc2实际上更好,因为它讨论了更多关于苹果汁的内容。

为了改进这一点,而不是简单地使用1来表示每个存在的术语,我们可以通过使用词频(TF)来模拟“匹配程度”,词频是衡量术语在每个文档中出现次数的指标。这里的想法是,术语在特定文档中出现的频率越高,文档与查询的相关性越大。

下面的代码展示了将每个术语在文档或查询中出现次数作为特征权重的向量。

代码清单 3.3 原始TF向量的余弦相似度

query_vector   = [1, 1] #[apple:1, juice:1]
doc1_tf_vector = [1, 1] #[apple:1, juice:1]
doc2_tf_vector = [3, 4] #[apple:3, juice:4]

doc1_score = cosine_similarity(query_vector, doc1_tf_vector)
doc2_score = cosine_similarity(query_vector, doc2_tf_vector)

print_scores([doc1_score, doc2_score])

输出:

相关性评分:
  doc1: 1.0
  doc2: 0.9899

与预期相反,doc1 被认为是比 doc2 更好的余弦相似度匹配。这是因为术语“apple”和“juice”在查询和 doc1 中都以“相同比例”出现(每个术语的出现次数与另一个术语的出现次数一致),使它们在文本上最为相似。换句话说,尽管 doc2 从直观上看与查询更加相关,因为它包含了更多的查询术语,余弦相似度仍然返回 doc1,因为它与查询是完全匹配的,而不是 doc2。

由于我们的目标是让像 doc2 这样词频较高的文档得分更高,我们可以通过将余弦相似度替换为另一种评分函数(如点积或欧几里得距离)来实现这一点,这种函数随着特征权重的增加而增大。我们可以使用点积(a . b),它等于余弦相似度乘以查询向量的长度与文档向量的长度的乘积:a . b = |a| × |b| × cos(θ)。点积将导致包含更多匹配术语的文档得分更高,而不是余弦相似度,后者在查询与文档之间包含更相似的匹配术语比例时得分更高。

短语匹配和其他相关性优化技巧

到现在为止,你可能会想,为什么我们一直将“apple”和“juice”当作独立的术语,而不是将“apple juice”作为一个短语来提高与该短语完全匹配的文档排名。短语匹配是我们稍后将在本章讨论的许多简便的相关性调优技巧之一。现在,我们将保持查询处理的简单性,仅处理单个关键词,以便专注于我们的主要目标——解释基于向量的相关性评分和基于文本的关键词评分特征。

在下一个代码示例中,我们将余弦相似度替换为点积计算,以便在相关性计算中考虑文档向量的大小(随着每个查询术语匹配的增多,大小会增加)。

代码清单 3.4 TF 向量的点积

query_vector   = [1, 1] #[apple:1, juice:1]
doc1_tf_vector = [1, 1] #[apple:1, juice:1]
doc2_tf_vector = [3, 4] #[apple:3, juice:4]

doc1_score = dot(query_vector, doc1_tf_vector)
doc2_score = dot(query_vector, doc2_tf_vector)

print_scores([doc1_score, doc2_score])

输出:

相关性评分:
  doc1: 2
  doc2: 7

正如你所看到的,doc2 现在为查询提供了比 doc1 更高的相关性评分,这一改进与我们的直觉更为一致。请注意,相关性评分不再像使用余弦相似度时那样被限制在 0 和 1 之间。这是因为点积考虑了文档向量的大小,这会随着匹配术语的增加而无限增长。

虽然使用词频(TF)作为向量中的特征权重肯定有帮助,但文本查询还存在其他需要考虑的挑战。到目前为止,我们的文档都包含了查询中的每个术语,这与大多数现实场景并不匹配。下面的例子将更好地展示在仅使用基于词频的加权进行基于文本的稀疏向量相似度评分时仍然存在的一些局限性。让我们从以下三个文本文档开始:

文档 1:

In light of the big reveal in her interview, the interesting thing is that the person in the wrong probably made a good decision in the end.

文档 2:

My favorite book is the cat in the hat, which is about a crazy cat in a hat who breaks into a house and creates the craziest afternoon for two kids.

文档 3:

My careless neighbors apparently let a stray cat stay in their garage unsupervised which resulted in my favorite hat that I let them borrow being ruined.

现在,我们将这些文档映射到它们对应的(稀疏)向量表示,并计算相似度得分。以下代码基于原始 TF(词频计数)对文本相似度进行排名。

代码清单 3.5 基于词频计数对文本相似度进行排名

def term_count(content, term):
  tokenized_content = tokenize(content)
  term_count = tokenized_content.count(term.lower())
  return float(round(term_count, 4))

query = "the cat in the hat"
terms = tokenize(query)

query_vector = list(numpy.repeat(1, len(terms)))
doc_vectors = [[term_count(doc, term) for term in terms] for doc in docs]
doc_scores = [dot(v, query_vector) for v in doc_vectors]

print_term_count_scores(terms, doc_vectors, doc_scores)

输出:

标签:['the', 'cat', 'in', 'the', 'hat']

查询向量:[1, 1, 1, 1, 1]

文档向量:
  doc1: [5.0, 0.0, 4.0, 5.0, 0.0]
  doc2: [3.0, 2.0, 2.0, 3.0, 2.0]
  doc3: [0.0, 1.0, 2.0, 0.0, 1.0]

相关性评分:
  doc1: 14.0
  doc2: 12.0
  doc3: 4.0

虽然我们现在根据每个术语匹配的次数为每个文档提供了不同的相关性评分,但结果的排序不一定符合我们对最佳匹配文档的预期。直观地,我们应该期望如下的排序:

  • doc2:因为它是关于《The Cat in the Hat》这本书的
  • doc3:因为它匹配了所有的单词“the”、“cat”、“in”和“hat”
  • doc1:因为它只匹配了“the”和“in”这两个词,尽管它们出现了很多次

这里的问题是,由于每当一个术语出现时都被认为是同样重要的,因此相关性评分随着每个术语的额外出现而不加区分地增加。在这种情况下,doc1 得到最高的评分,因为它包含了 14 个术语匹配(第一个“the”出现了五次,“in”出现了四次,第二个“the”五次),得到了比其他任何文档更多的术语匹配。

然而,将一个包含这些单词 14 次的文档认为比只包含一次匹配的文档相关性高 14 倍是没有意义的。相反,文档应该被认为更相关,如果它匹配了查询中的许多不同术语,而不是一次又一次地匹配相同的术语。通常,现实中的 TF 计算通过将每个术语出现次数的 TF 计算为其对数或平方根来抑制每个术语额外出现的影响(正如我们在图 3.3 中所做的那样)。此外,TF 通常也会相对于文档长度进行归一化,即将 TF 除以每个文档中的术语总数。因为较长的文档自然更有可能更频繁地包含任何给定的术语,所以这有助于通过归一化得分来考虑文档长度的可变性(参见图 3.3 的分母)。我们的最终归一化 TF 计算可以在图 3.3 中看到。

image.png

TF 计算的多种变体存在,其中只有一些会执行文档长度归一化(分母)或减轻术语出现次数的额外影响(在这里使用平方根,或者有时使用对数)。例如,Apache Lucene(为 Solr、OpenSearch 和 Elasticsearch 提供支持的搜索库)将 TF 计算为仅分子部分的平方根,但在执行某些排名计算时,会乘以一个独立的文档长度规范(相当于我们方程中分母的平方根)。

接下来,我们将使用这种归一化的 TF 计算,以确保同一术语的额外出现继续提高相关性,但其增加的速度会逐渐减缓。以下代码展示了新的 TF 函数的效果。

代码清单 3.6 基于 TF 排名文本相似度

def tf(term, doc):
  tokenized_doc = tokenize(doc)
  term_count = tokenized_doc.count(term.lower())
  doc_length = len(tokenized_doc)
  return numpy.sqrt(term_count / doc_length)

query = "the cat in the hat"
terms = tokenize(query)

query_vector = list(numpy.repeat(1, len(terms)))
doc_vectors = [[tf(term, doc) for term in terms] for doc in docs]
doc_scores = [dot(dv, query_vector) for dv in doc_vectors]

print_term_frequency_scores(terms, doc_vectors, doc_scores)

输出:

文档 TF 向量计算:
  doc1: [tf(doc1, "the"), tf(doc1, "cat"), tf(doc1, "in"),
         tf(doc1, "the"), tf(doc1, "hat")]
  doc2: [tf(doc2, "the"), tf(doc2, "cat"), tf(doc2, "in"),
         tf(doc2, "the"), tf(doc2, "hat")]
  doc3: [tf(doc3, "the"), tf(doc3, "cat"), tf(doc3, "in"),
         tf(doc3, "the"), tf(doc3, "hat")]

文档 TF 向量值:
标签:['the', 'cat', 'in', 'the', 'hat']
  doc1: [0.4303, 0.0, 0.3849, 0.4303, 0.0]
  doc2: [0.3111, 0.254, 0.254, 0.3111, 0.254]
  doc3: [0.0, 0.1961, 0.2774, 0.0, 0.1961]

相关性评分:
  doc1: 1.2456
  doc2: 1.3842
  doc3: 0.6696

归一化的 TF 函数显示了改进,doc2 现在排名最高,正如预期的那样。这主要是由于对 doc1 中术语出现次数的抑制效果(“the”和“in”出现了很多次),使得每次额外出现对特征权重的贡献低于之前的出现。遗憾的是,doc1 仍然排名第二,因此即使改进后的 TF 计算也不足以使匹配更好的 doc3 排名更高。

下一步的改进将是考虑术语的相对重要性,因为“cat”和“hat”显然比常见词汇如“the”和“in”更为重要。我们将通过引入一个新变量来修改评分计算,从而解决这个遗漏,纳入每个术语的重要性。

3.1.5 逆文档频率:衡量查询中术语的重要性

虽然 TF 在衡量文档与查询中每个术语的匹配程度方面很有用,但它对区分查询中术语的重要性几乎没有帮助。在本节中,我们将引入一种基于术语在文档中出现频率的技术,来衡量特定关键词的重要性。

一个术语的文档频率(DF)定义为包含该术语的文档总数,它是衡量术语重要性的一个有效指标。这里的想法是,更具体或稀有的词汇(如“cat”和“hat”)通常比常见的词汇(如“the”和“in”)更为重要。用于计算文档频率的函数如图3.4所示。

image.png

因为我们希望更重要的词汇得分更高,所以我们使用逆文档频率(IDF),如图3.5所定义。

image.png

延续我们在上一节中的《The Cat in the Hat》查询示例,IDF 向量将如下所示。

代码清单 3.7 计算逆文档频率

def idf(term):  #1
  df_map = {"the": 9500, "cat": 100,   #2
            "in": 9000, "hat": 50}    #2
  total_docs = 10000
  return 1 + numpy.log((total_docs+1) / (df_map[term] + 1))

terms = ["the", "cat", "in", "the", "hat"]
idf_vector = [idf(term) for term in terms] #3

print_inverse_document_frequency_scores(terms, idf_vector)
#1 IDF 函数,决定术语在查询中的重要性
#2 模拟倒排索引中的真实统计数据
#3 IDF 是术语依赖的,而不是文档依赖的,因此它对查询和文档都是相同的。

输出:

IDF 向量值:
  [idf("the"), idf("cat"), idf("in"), idf("the"), idf("hat")]

IDF 向量:
  [1.0513, 5.5953, 1.1053, 1.0513, 6.2786]

这些结果看起来令人鼓舞。术语现在可以根据它们对查询的描述性或重要性来加权:

  • “hat”:6.2786
  • “cat”:5.5953
  • “in”:1.1053
  • “the”:1.0513

接下来,我们将结合到目前为止学习的 TF 和 IDF 排名技术,构建一个平衡的相关性排名函数。

3.1.6 TF-IDF:文本相关性的平衡加权指标

现在我们已经有了文本相关性排名的两个主要组成部分:

  • TF 衡量术语如何描述文档。
  • IDF 衡量每个术语的重要性。

大多数搜索引擎和许多其他数据科学应用程序使用这些因素的组合作为文本相似度评分的基础,采用图3.6中的函数变体。

image.png

通过这种改进的特征加权函数,我们最终可以计算出平衡的相关性评分,方法如下所示。

代码清单 3.8 计算查询《the cat in the hat》的 TF-IDF

def tf_idf(term, doc):
  return TF(term, doc) * IDF(term)**2

query = "the cat in the hat"
terms = tokenize(query)

query_vector = list(numpy.repeat(1, len(terms)))
doc_vectors = [[tf_idf(doc, term) for term in terms] for doc in docs]
doc_scores = [[dot(query_vector, dv)] for dv in doc_vectors]

print_tf_idf_scores(terms, doc_vectors, doc_scores)

输出:

文档 TF-IDF 向量计算:
  doc1: [tf_idf(doc1, "the"), tf_idf(doc1, "cat"), tf_idf(doc1, "in"),
         tf_idf(doc1, "the"), tf_idf(doc1, "hat")]
  doc2: [tf_idf(doc2, "the"), tf_idf(doc2, "cat"), tf_idf(doc2, "in"),
         tf_idf(doc2, "the"), tf_idf(doc2, "hat")]
  doc3: [tf_idf(doc3, "the"), tf_idf(doc3, "cat"), tf_idf(doc3, "in"),
         tf_idf(doc3, "the"), tf_idf(doc3, "hat")]

文档 TF-IDF 向量得分:
标签:['the', 'cat', 'in', 'the', 'hat']
  doc1: [0.4756, 0.0, 0.4703, 0.4755, 0.0]
  doc2: [0.3438, 7.9521, 0.3103, 0.3438, 10.0129]
  doc3: [0.0, 6.1399, 0.3389, 0.0, 7.7311]

相关性评分:
  doc1: 1.4215
  doc2: 18.9633
  doc3: 14.2099

最终,我们的搜索结果是合理的!doc2 得到最高评分,因为它最匹配最重要的词汇,其次是 doc3,它包含了所有的词汇,但出现次数不如 doc2 多,再次是 doc1,它只包含大量无关紧要的词汇。

这种 TF-IDF 计算是许多搜索引擎相关性算法的核心,包括默认的相似度算法 BM25,该算法目前用于大多数搜索引擎中的基于关键词的排名。在下一节中,我们将介绍 BM25。

3.2 控制相关性计算

在上一节中,我们展示了如何将查询和文档表示为向量,以及如何使用余弦相似度或其他相似度度量(如点积)作为相关性函数来比较查询和文档。我们介绍了 TF-IDF 排名,它可以用来创建特征权重,平衡每个术语在术语向量中的出现强度(TF)和术语的重要性(IDF)。

在本节中,我们将展示如何在搜索引擎中指定和控制完整的相关性函数,包括常见的查询功能、将查询建模为函数、排名与过滤的区别,以及应用不同类型的提升技术。

3.2.1 BM25:行业标准的默认文本相似度算法

BM25 是 Apache Lucene、Apache Solr、Elasticsearch、OpenSearch 和许多其他搜索引擎中默认的相似度算法的名称。BM25(即 Okapi “Best Matching” 第25版)首次发布于1994年,并在许多基于文本的排名评估中展示了比标准的 TF-IDF 余弦相似度排名更好的改进。目前,它仍然优于大多数非精调的 LLM 嵌入排名模型,因此它作为基于关键词的排名的一个良好基准。

BM25 仍然以 TF-IDF 为核心,但它还包括多个其他参数,能够更好地控制如 TF 饱和点和文档长度归一化等因素。它还将每个匹配的关键词的权重相加,而不是计算余弦相似度。

完整的 BM25 计算公式如图3.7所示。变量定义如下:

  • t = 术语;d = 文档;q = 查询。
  • freq(t, >d) 是一个简单的 TF,Σ𝑡ϵ𝑑 1 表示术语 t 在文档 d 中出现的次数。

image.png

  • 这是 BM25 中使用的 IDF 变体,其中 N 是文档的总数,N(t) 是包含术语 t 的文档数量。

  • |d| 是文档 d 中术语的数量。

  • avgdl 是索引中每个文档的平均术语数量。

  • k 是一个自由参数,通常范围从 1.2 到 2.0,用于增加 TF 饱和点。

  • b 是一个自由参数,通常设置为约 0.75。它增加了文档归一化的效果。

image.png

你可以看到,分子包含了频率(简化的 TF)和 IDF 参数,而分母则添加了新的归一化参数 k 和 b。TF 饱和点由 k 控制,随着 k 的增加,针对同一术语的额外匹配的计数会减少;由 b 控制,它随着增大而增加文档长度归一化的效果。每个术语的 TF 计算公式为 freq(t, d) / (freq(t, d) + k · (1 – b + b · |d| / avgdl)),这个计算比我们在图3.3中使用的计算要复杂。

从概念上讲,BM25 提供了一种通过启发式方法比传统的 TF-IDF 更好的方式来归一化 TF。值得注意的是,BM25 算法有多个变种(如 BM25F、BM25+),并且根据你使用的搜索引擎,你可能会看到一些轻微的变更和优化。

与其在 Python 中重新实现所有这些数学运算,不如转而使用我们的搜索引擎,看看它是如何执行计算的。我们从在搜索引擎中创建一个集合开始(代码清单 3.9)。一个集合包含特定的模式和配置,它是我们用来索引文档、搜索、排名和检索搜索结果的单位。接着,我们将索引一些文档(使用我们之前的《The Cat in the Hat》示例),如代码清单 3.10 所示。

代码清单 3.9 创建 cat_in_the_hat 集合

engine = get_engine() #1
collection = engine.create_collection("cat_in_the_hat")
#1 默认设置为 Apache Solr 引擎。请参阅附录 B 使用其他支持的搜索引擎和向量数据库。

输出:

清除 "cat_in_the_hat" 集合
创建 "cat_in_the_hat" 集合
状态:成功

代码清单 3.10 向集合中添加文档

docs = [{"id": "doc1",
         "title": "Worst",
         "description": """The interesting thing is that the person in the
                           wrong made the right decision in the end."""},
        {"id": "doc2",
         "title": "Best",
         "description": """My favorite book is the cat in the hat, which is
                           about a crazy cat who breaks into a house and
                           creates a crazy afternoon for two kids."""},
        {"id": "doc3",
         "title": "Okay",
         "description": """My neighbors let the stray cat stay in their
                           garage, which resulted in my favorite hat that
                           I let them borrow being ruined."""}]
collection.add_documents(docs)

输出:

将文档添加到 "cat_in_the_hat" 集合
状态:成功

文档添加到搜索引擎后,我们现在可以发出查询并查看完整的 BM25 得分。以下代码用查询《The Cat in the Hat》进行搜索,并请求为每个文档提供相关性计算的解释。

代码清单 3.11 按 BM25 相似度得分进行排名并检查

query = "the cat in the hat"
request = {"query": query,
           "query_fields": ["description"],
           "return_fields": ["id", "title", "description", "score"],
           "explain": True}

response = collection.search(**request)
display_search(query, response["docs"])

输出:

查询:the cat in the hat
排名文档:
[{'id': 'doc2',
'title': ['Best'],
'description': ['My favorite book is the cat in the hat, which is about a
↪crazy cat who breaks into a house and creates a crazy afternoon for
↪two kids.'],
'score': 0.68231964, '[explain]': '
  0.68231964  = sum of:
    0.15655403 = weight(description:the in 1) [SchemaSimilarity], result of:
      0.15655403 = score(freq=2.0), product of:
        2.0 = boost
        0.13353139 = idf, computed as log(1 + (N - n + 0.5) / (
          n + 0.5)) from:
          3 = n, number of documents containing term
          3 = N, total number of documents with field
        0.58620685 = tf, computed as freq / (freq + k1 * (
          1 - b + b * dl / avgdl)) from:
          2.0 = freq, occurrences of term within document
          1.2 = k1, term saturation parameter
          0.75 = b, length normalization parameter
          28.0 = dl, length of field
          22.666666 = avgdl, average length of field
    0.19487953 = weight(description:hat in 1) ...
    0.27551934 = weight(description:cat in 1) ...
    0.05536667 = weight(description:in in 1) ...
'}, {'id': 'doc3',
'title': ['Okay'],
'description': ['My neighbors let the stray cat stay in their garage, which
↪resulted in my favorite hat that I let them borrow being ruined.'],
'score': 0.62850046, '[explain]': '
  0.62850046 = sum of:
    0.21236044  = weight(description:the in 2) ...
    0.08311336 = weight(description:hat in 2) ...
    0.21236044 = weight(description:cat in 2) ...
    0.120666236 = weight(description:in in 2) ...
'}, {'id': 'doc1',
'title': ['Worst'],
'description': ['The interesting thing is that the person in the wrong made
↪the right decision in the end.'],
'score': 0.3132525,
'[explain]': '
  0.3132525 = sum of:
    0.089769006 = weight(description:the in 0) ...
    0.2234835 = weight(description:in in 0) ...
'}]

对于排名第一的文档 doc2,你可以看到使用 tf 和 idf 组件计算得分的一部分,并且你可以看到其他两个文档中每个匹配术语的高层次得分。如果你想深入了解这些数学运算,可以在 Jupyter notebook 中查看完整的计算过程。

虽然 BM25 的计算比 TF-IDF 特征权重计算更复杂,但它仍然将 TF-IDF 作为其计算的核心部分。因此,BM25 排名与我们在代码清单 3.8 中的 TF-IDF 计算的相对顺序是相同的:

排名结果(代码清单 3.8:TF-IDF 余弦相似度):

  • doc2: 0.998
  • doc3: 0.9907
  • doc1: 0.0809

排名结果(代码清单 3.9:BM25 相似度):

  • doc2: 0.6878265
  • doc3: 0.62850046
  • doc1: 0.3132525

我们对《The Cat in the Hat》的查询仍然可以被认为是每个术语的 BM25 得分的向量:["the", "cat", "in", "the", "hat"]。

可能不那么显而易见的是,这些术语的特征权重实际上是可覆盖的函数。我们不仅仅将查询视为一堆关键词,还可以将查询看作是由其他函数组成的数学函数,其中一些函数以关键词作为输入,并返回用于相关性计算的数值(得分)。例如,我们的查询也可以表达为这个向量: [ query("the"), query("cat"), query("in"), query("the"), query("hat") ]

这里的查询函数简单地计算传入术语的 BM25,相对于所有被评分的文档。因此,整个查询的 BM25 就是每个术语的 TF-IDF 的总和。在 Solr 查询语法中,这将是:

{!func}query("the") {!func}query("cat") {!func}query("in")
{!func}query("the") {!func}query("hat")

如果我们执行这个“函数化”的查询版本,我们将得到与直接执行查询时完全相同的相关性得分。以下代码清单展示了执行此版本查询的代码。

代码清单 3.12 使用查询函数进行文本相似度计算

query = '{!func}query("the") {!func}query("cat") {!func}query("in")
       {!func}query("the") {!func}query("hat")'
request = {"query": query,
           "query_fields": "description",
           "return_fields": ["id", "title", "score"]}

response = collection.search(**request)
display_search(query, response["docs"])

输出:

查询:
 {!func}query("the") {!func}query("cat") {!func}query("in")
  {!func}query("the") {!func}query("hat")
结果:
 [{'id': 'doc2', 'title': ['Best'], 'score': 0.6823196},
  {'id': 'doc3', 'title': ['Okay'], 'score': 0.62850046},
  {'id': 'doc1', 'title': ['Worst'], 'score': 0.3132525}]

正如预期的那样,得分与之前相同——我们只是将隐式函数替换成了显式函数。

3.2.2 函数,处处皆函数!

我们刚刚遇到了查询函数(在上一节的末尾),它对关键词执行默认的(BM25)相似度计算。理解查询的每一部分实际上是一个可配置的评分函数,为操控相关性算法开辟了巨大的可能性。那么,查询中还能使用哪些其他类型的函数呢?我们能否在评分计算中使用其他特征——也许一些非基于文本的特征?

以下是常用于影响相关性得分的一些函数和评分技术的部分列表:

  • 地理空间加权——离执行查询的用户较近的文档应该排名更高。
  • 日期加权——较新的文档应该获得更高的相关性加权。
  • 流行度加权——更受欢迎的文档应该获得更高的相关性加权。
  • 字段加权——在某些字段中匹配的术语应该获得比其他字段更高的权重。
  • 类别加权——与查询术语相关的类别中的文档应该获得更高的相关性加权。
  • 短语加权——匹配查询中的多词短语的文档应该比仅匹配单个单词的文档排名更高。
  • 语义扩展——包含与查询关键词和上下文高度相关的其他词汇或概念的文档应该被提升。

使用本书的搜索引擎无关搜索 API

在本书和代码库中,我们实现了一组 Python 库,提供了一个通用的 API 用于索引文档(collection.add_documents(documents)collection.write(dataframe))、查询文档(collection.search(**query_parameters)),以及执行其他搜索引擎操作。这使得你可以在本书和相应的笔记本中执行相同的代码,无论你选择的是哪种支持的搜索引擎或向量数据库,将特定引擎的语法创建委托给客户端库。请参阅附录 B,了解如何在引擎之间无缝切换。

虽然这些通用方法对于通过你最喜欢的引擎执行 AI 驱动的搜索非常强大,但在某些情况下,查看搜索引擎底层实现的细节也很有帮助,对于更复杂的示例,有时使用高级的引擎无关 API 很难表达发生的全部功能。出于这个原因,我们偶尔也会在本书中包含默认搜索引擎(Apache Solr)的原始搜索引擎语法。如果你不熟悉 Apache Solr 和它的语法,请不要过于纠结细节。重要的是理解这些概念,足够了解它们后,你就能将它们应用到你选择的搜索引擎中。

这些技术(以及更多)被大多数主要的搜索引擎支持。例如,字段加权可以通过在查询中指定的 query_fields 字段后面添加 ^BOOST_AMOUNT 来实现:

通用搜索请求语法:

{"query": "the cat in the hat",
 "query_fields": ["title^10", "description^2.5"]}

这个查询请求为标题字段中的匹配项提供 10 倍的相关性加权,为描述字段中的匹配项提供 2.5 倍的相关性加权。当映射到 Solr 语法时,它看起来是这样的:

Solr 请求语法:

{"query": "the cat in the hat",
 "params": {"defType": "edismax",
            "qf": "title^10 description^2.5"}}

每个搜索引擎都是不同的,但许多这些技术都通过 Solr 的特定查询解析器内置实现,无论是通过查询语法还是通过查询解析器选项,如刚刚展示的 edismax 查询解析器。

完整短语匹配的加权两词短语加权三词短语加权也是 Solr 的 edismax 查询解析器的原生功能:

为包含确切短语 "the cat in the hat" 的文档加权: Solr 请求语法:

{"query": "the cat in the hat",
 "params": {"defType": "edismax",
            "qf": "title description",
            "pf": "title"}}

为包含两词短语 "the cat"、"cat in"、"in the" 或 "the hat" 的文档加权: Solr 请求语法:

{"query": "the cat in the hat",
 "params": {"defType": "edismax",
            "qf": "title description",
            "pf2": "title description"}}

为包含三词短语 "the cat in" 或 "in the hat" 的文档加权: Solr 请求语法:

{"query": "the cat in the hat",
 "params": {"defType": "edismax",
            "qf": "title description",
            "pf3": "description"}}

许多其他的相关性提升技术需要通过函数查询构造自定义特征。例如,如果我们希望创建一个仅提升地理位置上离执行搜索的用户最近的文档相关性排名的查询,我们可以执行以下 Solr 查询:

Solr 请求语法:

{"query": "*",
 "sort": "geodist(location, $user_latitude, $user_longitude) asc",
 "params": {"user_latitude": 33.748,
            "user_longitude": -84.39}}

这个查询使用 sort 参数通过 geodist 函数严格对文档进行排序,geodist 函数接受文档的 location 字段名以及用户的纬度和经度作为参数。这在考虑单一特征时非常有效,但如果我们想基于多个特征构建更复杂的排序呢?为了实现这一点,我们可以更新查询,在计算相关性得分时应用多个函数,然后按相关性得分进行排序:

Solr 请求语法:

{"query": "{!func}scale(query($keywords),0,25)
    {!func}recip(geodist($lat_long_field,$user_latitude,
    $user_longitude),1,25,1)
    {!func}recip(ms(NOW/HOUR,modify_date),3.16e-11,25,1)
    {!func}scale(popularity,0,25)",
 "params": {"keywords": "basketball",
            "lat_long_field": "location",
            "user_latitude": 33.748,
            "user_longitude": -84.391}}

这个查询有一些有趣的特点:

  • 它构建了一个包含四个特征的查询向量:关键词的 BM25 相关性得分(得分越高越好)、地理距离(得分越低越好)、发布日期(越新越好)和流行度(得分越高越好)。
  • 每个特征值都被缩放到 0 到 25 之间,这样它们就可以进行比较,每个特征的最佳得分为 25,最差得分接近 0。
  • 因此,“完美得分”将加起来达到 100(所有四个特征得分为 25),而最差得分大约为 0。
  • 由于 25 的相对贡献在查询的每个函数中已指定,我们可以轻松地调整任何特征的权重,从而影响最终的相关性计算。

通过最后一个查询,我们已经完全掌握了相关性计算,通过建模相关性特征并给它们加权。虽然这非常强大,但它仍然需要大量的人工努力来确定哪些特征对于给定领域最重要,并调整它们的权重。在第10章中,我们将介绍如何构建机器学习排名模型,以自动为我们做出这些决策(这个过程称为“学习排名”)。目前,我们的目标只是理解在查询向量中建模特征的机制,以及如何以编程方式控制它们的权重。

深入了解函数查询

如果你想深入了解如何使用 Solr 的函数查询,我们推荐阅读 Trey Grainger 和 Timothy Potter 之前出版的《Solr in Action》一书的第七章(Manning,2014;mng.bz/n0Y5)。对于 Solr 中可用的函数查询的完整列表,你也可以查阅 Solr 参考指南中的函数查询部分文档(mng.bz/vJop)。如果你使用的是其他搜索引擎,可以查阅它们的文档,寻找类似的指导。

我们已经看到了在查询中将函数作为特征使用的强大功能,但到目前为止,我们的示例都属于所谓的“加性”加权,在这种情况下,每个函数计算的值的总和构成了最终的相关性得分。通过“乘性”加权以更模糊、灵活的方式组合函数也是非常有用的,这将在下一节中介绍。

3.2.3 选择乘性加权与加性加权作为相关性函数的提升方式

最后一个需要讨论的话题是关于如何控制我们的相关性函数,即选择乘性加权与加性加权提升相关性特征。

在到目前为止的所有示例中,我们已经将多个特征添加到查询向量中,以便对得分做出贡献。例如,以下 Solr 查询都将产生相同的相关性计算,前提是它们被过滤到相同的结果集(即,filters=["the cat in the hat"]):

文本查询(得分 + 过滤)

{"query": "the cat in the hat"}

函数查询(仅得分,无过滤)

{"query": '{!func}query("the cat in the hat")'}

多个函数查询(仅得分,无过滤)

{"query": '{!func}query("the") {!func}query("cat") {!func}query("in") {!func}query("the") {!func}query("hat")'}

加权查询(仅得分,无过滤)

{"query": "*",
 "params": {"bq": "the cat in the hat"}}

在这些示例中,相关性提升的方式被称为加性加权,它与我们将查询视为仅仅是一个需要在文档之间比较其相似度的特征向量的概念非常契合。在加性加权中,每个特征的相对贡献随着更多特征的添加而减少,因为总得分仅仅是所有特征得分的总和。

与此相对,乘性加权允许一个文档的整个计算相关性得分通过一个或多个函数进行缩放(乘以)。乘性加权使得不同的提升可以“叠加”在一起,避免了像我们在 3.2.2 节中那样需要为查询的不同部分明确约束权重。在那个例子中,我们必须确保文档的关键词得分、地理距离、年龄和流行度各自被缩放到相关性得分的 25%,以使它们加起来总得分为 100%。

要在 Apache Solr 中提供乘性加权,你可以在查询向量中使用加权查询解析器(语法:{!boost …}),或者如果使用 edismax 查询解析器,可以使用简化的加权查询参数。以下两个查询将把文档的相关性得分乘以流行度字段值的 10 倍:

{"query": "the cat in the hat",
 "params": {"defType": "edismax",
            "boost": "mul(popularity,10)"}}
{"query": "{!boost b=mul(popularity,10)} the cat in the hat"}

在这个例子中,《The Cat in the Hat》的查询仍然使用加性加权(每个关键词的 BM25 值被相加),但最终得分被乘以流行度字段值的 10 倍。这种乘性加权使得流行度能够独立于任何其他特征来缩放相关性得分。

通常,乘性加权为你提供了更大的灵活性,可以在不需要明确预定义考虑每个潜在贡献因素的相关性公式的情况下,组合不同的相关性特征。另一方面,如果某些特征的乘性加权值过高而掩盖了其他特征,这种灵活性可能会导致意外的后果。相比之下,加性加权可能比较难以管理,因为你需要明确地缩放它们,以便它们能够组合起来,同时仍然保持对总体得分的可预测贡献。然而,通过这种显式的缩放,你可以更好地控制相关性评分的计算和得分范围。加性加权和乘性加权各有其用,因此最好根据实际问题进行考虑,并尝试不同方法以获得最佳结果。

我们现在已经介绍了在搜索引擎中控制相关性排名的主要方式,但文档的匹配和过滤往往同样重要,因此我们将在下一节中介绍它们。

3.2.4 区分文档的匹配(过滤)与排名(评分)

我们曾经将查询和文档视为特征向量,但到目前为止,我们主要讨论了搜索作为计算向量相似度(如余弦相似度或点积)或将查询中每个特征(关键词或函数)的文档得分相加的过程。

一旦文档被索引,执行查询通常涉及两个主要步骤:

  • 匹配——将结果过滤到已知的可能答案集合中
  • 排名——根据相关性对所有可能的答案进行排序

我们通常可以完全跳过第一步(匹配),仍然能够看到第一页(以及许多页面)上完全相同的结果,因为最相关的结果通常应该排名最高,因此会首先显示。如果你回想一下第2章,我们甚至看到了某些向量评分计算(比较食物项目的特征向量——即“苹果汁”与“甜甜圈”),在这些计算中我们根本无法进行过滤。相反,我们必须首先对每个文档进行评分,仅凭相关性来确定返回哪些文档。在这种情况下(使用稠密向量嵌入),我们甚至没有可以用作过滤器的关键词或其他属性。

那么,如果初步的匹配阶段实际上是可选的,为什么还要执行它呢?一个显而易见的答案是,它提供了显著的性能优化。我们可以通过先将初始结果过滤到一小组逻辑匹配的文档,从而极大地加速我们的相关性计算和搜索引擎的整体响应时间,而不是遍历每个文档并计算相关性得分。

能够过滤结果集还带来了额外的好处,因为我们可以提供分析信息,比如匹配文档的数量或文档中找到的特定值的计数(称为面板或聚合)。从搜索结果中返回面板和类似的聚合元数据有助于用户随后根据特定值进行过滤,从而进一步探索和优化结果集。最后,在许多情况下,“拥有逻辑匹配”应被视为排名函数中最重要的特征之一,因此仅仅在前期就对逻辑匹配进行过滤,可以大大简化相关性计算。我们将在下一节讨论这些权衡。

3.2.5 逻辑匹配:加权查询中术语之间的关系

我们刚刚提到,过滤结果而不是直接对其评分,主要是一种性能优化,并且搜索结果的前几页无论是否过滤结果,还是仅通过相关性排名,可能看起来都相同。

然而,只有当你的相关性函数成功包含已经适当提升更好的逻辑匹配的特征时,这种情况才成立。例如,考虑以下查询之间的差异:

  • "statue of liberty"
  • statue AND of AND liberty
  • statue OR of OR liberty
  • statue of liberty

从逻辑匹配的角度来看,第一个查询将非常精确,只匹配包含确切短语“statue of liberty”的文档。第二个查询只会匹配包含所有术语“statue”,“of”和“liberty”的文档,但不一定作为短语。第三个查询会匹配包含任意一个术语的文档,这意味着仅包含“of”的文档也会匹配,但包含“statue”和“liberty”的文档应该由于 BM25 评分计算而排名更高。

从理论上讲,如果启用了短语加权作为特征,包含完整短语的文档可能会排名最高,其次是包含所有术语的文档,再次是包含任何单词的文档。假设发生了这种情况,无论你是过滤到逻辑布尔匹配的结果,还是仅仅基于相关性函数进行排序,结果的顺序应该是类似的。

然而,在实践中,用户通常认为查询的逻辑结构与他们期望看到的文档高度相关,因此在排名之前尊重这种逻辑结构并进行过滤,能够去除用户查询中指示可以安全删除的结果。

然而,有时用户查询的逻辑结构是模糊的,比如我们第四个示例中的查询:statue of liberty。这在逻辑上是指 statue AND of AND liberty,statue OR of OR liberty,还是更细致的形式,如 (statue AND of) OR (statue AND liberty) OR (of AND liberty),这基本上意味着“匹配至少两个术语中的三个”?在我们的搜索 API 中使用“最小匹配”(min_match)参数,可以轻松地控制这些匹配阈值,甚至可以针对每个查询单独控制:

  • 必须匹配 100% 的查询术语(等价于 statue AND of AND liberty):

    通用搜索请求语法:

    {"query": "statue of liberty",
     "min_match": "100%"}
    

    Solr 请求语法:

    {"query": "statue of liberty",
     "params": {"defType": "edismax",
                "mm": "100%"}}
    
  • 至少一个查询术语必须匹配(等价于 statue OR of OR liberty):

    通用搜索请求语法:

    {"query": "statue of liberty",
     "min_match": "1"}
    

    Solr 请求语法:

    {"query": "statue of liberty",
     "params": {"defType": "edismax",
                "mm": "1"}}
    
  • 至少两个查询术语必须匹配(等价于 (statue AND of) OR (statue AND liberty) OR (of AND liberty)):

    通用搜索请求语法:

    {"query": "statue of liberty",
     "query_parser": "edismax",
     "min_match": "2"}
    

    Solr 请求语法:

    {"query": "statue of liberty",
     "params": {"defType": "edismax",
                "mm": "2"}}
    

在我们的 Python API 中,min_match 参数支持指定必须匹配的术语的最小百分比(0% 到 100%)或术语数量(1 到 N 个术语)。这个参数与 Solr 的 mm 参数以及 OpenSearch 和 Elasticsearch 的 minimum_should_match 参数相对应。除了接受匹配的百分比或术语数量外,这些引擎还支持像 mm=2<-30% 5<3 这样的步进函数。这个示例步进函数的意思是“如果术语少于 2 个,则所有术语都是必需的;如果术语少于 5 个,则最多可以缺少 30% 的术语;如果有 5 个或更多术语,则必须至少有 3 个术语”。在使用 Solr 时,mm 参数与 edismax 查询解析器一起工作,这是我们在本书中如果 Solr 被配置为引擎时,用于文本匹配查询的主要查询解析器(见附录 B)。你可以查阅 Solr 参考指南中的“扩展 DisMax 参数”部分,了解如何使用这些最小匹配功能来微调你的逻辑匹配规则(mng.bz/mRo8)。

在构建相关性函数时,过滤和评分的概念往往会混淆,特别是因为大多数搜索引擎对其主要查询参数同时执行这两项操作。我们将在下一节尝试分开讨论这些问题。

3.2.6 分离关注点:过滤与评分

在3.2.4节中,我们区分了匹配和排名的概念。结果的匹配是逻辑性的,通过将搜索结果过滤到一部分文档来实现,而结果的排名是定性的,通过根据查询对所有文档进行评分然后按计算的得分排序来实现。在本节中,我们将介绍一些技术,通过清晰地分离过滤和评分的关注点,为控制匹配和排名提供最大的灵活性。

我们的搜索 API 有两种主要的方式来控制过滤和评分:queryfilters 参数。考虑以下请求:

通用搜索请求语法:

{"query": "the cat in the hat",
 "query_fields": ["description"],
 "filters": [("category", "books"), ("audience", "kid")],
 "min_match": "100%"}

Solr 请求语法:

{"query": "the cat in the hat",
 "filters": ["category:books", "audience:kid"],
 "params": {"qf": ["description"],
            "mm": "100%",
            "defType": "edismax"}}

在这个查询中,搜索引擎被指示将结果集过滤到仅包含 category 字段中值为“books”且 audience 字段中值为“kid”的文档。除了这些过滤器外,查询本身也作为一个过滤器,因此结果集进一步过滤,只保留包含“the”,“cat”,“in”和“hat”这 100% 值的文档。

queryfilters 参数之间的逻辑区别在于,filters 仅作为过滤器使用,而 query 同时作为过滤器和相关性排名的特征向量使用。查询参数的这种双重使用是查询的有用默认行为,但在更复杂的查询中,将过滤和评分混合在同一个参数中可能不太理想,尤其是当我们只是试图操作相关性计算,而不是任意地从文档集中移除结果时。

有几种方法可以解决这个问题:

  • query 参数建模为函数(函数仅计算相关性,不进行过滤):

    Solr 请求语法:

    {"query": '{!func}query("{!edismax qf=description mm=100%
      v=$user_query}")',
     "filters": "{!cache=false v=$user_query}",
     "params": {"user_query": "the cat in the hat"}}
    
  • 让你的查询匹配所有文档(不进行过滤或评分),并应用 bq(加权查询)参数,以在不评分的情况下影响相关性:

    Solr 请求语法:

    {"query": "*",
     "filters": "{!cache=false v=$user_query}",
     "params": {"bq": "{!edismax qf=description mm=100% v=$user_query}",
                "user_query": "the cat in the hat"}}
    

query 参数既进行过滤,又基于相关性加权,filters 仅进行过滤,bq 仅进行加权。上述两种方法在逻辑上是等价的,但我们推荐第二种方法,因为使用专门的 bq 参数更清晰,它的设计是为了有助于相关性计算而不进行过滤。

你可能注意到,这两种查询也包含一个过滤查询 {!cache=false v=$user_query},它对 user_query 进行过滤。由于 query 参数故意不再过滤我们的搜索结果,如果我们仍然希望对用户输入的查询进行过滤,则现在需要这个 filters 参数。特殊的 cache=false 参数用于关闭过滤器的缓存。Solr 默认启用过滤器缓存,因为过滤器通常在请求之间被多次重用。由于 user_query 参数是用户输入的,并且在这个案例中变化较大(在请求之间不常重用),因此不适合用这些值污染搜索引擎的缓存。如果你在没有关闭缓存的情况下对用户输入的查询进行过滤,它将浪费系统资源,并可能降低搜索引擎的性能。

这里的主要思想是,可以通过清晰地将逻辑过滤与排名特征分离,来保持对搜索结果的完全控制和灵活性。虽然对简单的基于文本的排名来说,这种做法可能过于复杂,但当尝试构建更复杂的排名函数时,分离这些关注点变得至关重要。

现在你已经理解了如何构建这些专门的排名函数的机制,让我们在本章结束时简要讨论如何将这些技术应用于实现用户和领域特定的相关性排名。

3.3 实现用户和领域特定的相关性排名

在第3.2节中,我们讲解了如何动态修改查询到文档相似度算法的参数。这包括传入我们自己的函数作为特征,除了基于文本的相关性排名之外,它们还为得分做出贡献。

虽然使用 BM25、TF-IDF、向量余弦相似度或其他基于词频的统计方法的基于文本的相关性排名可以提供不错的通用搜索相关性,但它无法与良好的领域特定相关性因素相抗衡。以下是一些在不同领域中通常最重要的领域特定因素:

  • 餐厅搜索:地理位置、用户特定的饮食限制、用户特定的口味偏好、价格范围
  • 新闻搜索:新鲜度(日期)、流行度、地理区域
  • 电子商务:转化可能性(点击率、加入购物车、购买)
  • 电影搜索:名称匹配(标题、演员等)、电影流行度、发布日期、评论评分
  • 求职搜索:职位名称、职位级别、薪资范围、地理位置、行业领域
  • 网页搜索:页面上的关键词匹配、页面的流行度、网站的流行度、页面中匹配的位置(标题、头部、正文等)、页面质量(重复内容、垃圾内容等)、页面和查询的主题匹配

这些只是示例,但大多数搜索引擎和领域都有需要考虑的独特特征,以提供最佳的搜索体验。本章仅略微触及了控制匹配和排名功能的无数方式,以返回最佳内容的表面。实际上,有一个专门的职业——称为相关性工程——致力于许多组织中的搜索相关性调优。如果你想深入了解,我们强烈推荐 Doug Turnbull 和 John Berryman 之前的著作《Relevant Search》(Manning,2016),它是一本关于此类相关性工程的指南。

每个搜索引擎和领域都有独特的特征,需要加以考虑,以提供最佳的搜索体验。与手动建模这些相关性特征不同,AI 驱动的搜索引擎可以利用机器学习自动生成和加权这些特征。

本章的目标是为你提供必要的知识和工具,为接下来的章节做准备,在我们开始整合更多自动化机器学习技术时,能够影响相关性排名。我们将在下一章关于众包相关性的内容中开始应用这些技术。

总结

  • 我们可以将查询和文档表示为稠密或稀疏的数值向量,并根据向量相似度计算(如余弦相似度)为文档分配相关性排名。

  • 使用 TF-IDF 或 BM25 相似度计算(也基于 TF-IDF)来进行文本相似度评分,可以提供更有意义的特征(关键词)重要性衡量,这使得相较于仅仅查看术语匹配,文本排名得到了改进。

  • 文本相似度评分是我们可以在查询中调用的多种功能之一,用于相关性排名。我们可以将函数注入到查询中,与关键词匹配和评分一起使用,因为每个关键词短语实际上就是一个排名函数。

  • 将“过滤”和“评分”视为独立的关注点,在指定我们自己的排名函数时可以提供更好的控制。

  • 为了优化相关性,我们需要创建领域特定的相关性函数,并使用用户特定的特征,而不是仅仅依赖关键词匹配和排名。