人工智能驱动的搜索(AI-Powered Search)——利用上下文学习领域特定语言

152 阅读53分钟

本章内容包括:

  • 查询意图分类
  • 查询意义消歧
  • 从用户信号中识别关键术语
  • 从用户信号中学习相关短语
  • 从用户信号中学习拼写错误和替代术语变体

在第5章中,我们演示了如何生成和使用语义知识图谱(SKG),以及如何将实体、事实和关系显式提取到知识图谱中。这两种技术都依赖于在单个文档中的术语之间的语言连接,或者在多个文档和上下文中的术语的统计共现。你学习了如何使用知识图谱来查找相关术语,以及这些相关术语如何集成到各种查询重写策略中,以提高召回率或精确度。

在本章中,我们将更深入地理解查询意图以及如何利用不同上下文来解释查询中的领域特定术语。我们将从查询分类开始,然后展示如何使用这些分类来消除多个潜在含义的查询歧义。这两种方法将扩展我们在上一章中使用SKG的方式。

虽然基于SKG的方法在上下文化和解释查询方面更有效,但它们仍然依赖于具有高质量文档的内容,这些文档能准确地表示你的领域。因此,它们在解释用户查询时的有效性取决于查询与被搜索内容的重叠程度。

例如,如果75%的用户在搜索服装,但你的大多数库存是电影和数字媒体,那么当用户搜索“shorts”(短裤)时,所有结果都是时长较短的视频(称为“数字短片”),大多数用户会对这些结果感到困惑。根据你的查询日志数据,最好将“shorts”映射到其他更常见的查询信号中的相关术语,如“pants”(裤子)、“clothing”(衣物)和“shirts”(衬衫)。

不仅依赖于文档的内容来学习术语和短语之间的关系,而且还可以利用用户生成的信号,这将非常有益。本章中,我们将演示从用户信号中提取关键短语、学习相关短语以及识别常见拼写错误或替代拼写的技术。通过结合基于内容的上下文和来自真实用户交互的行为上下文,你的搜索引擎将更好地理解领域特定术语和实际用户意图。

6.1 查询意图分类

查询的目标或意图通常比关键词更为重要。在新闻或旅游内容的背景下,搜索“driver crashed”可能意味着两种完全不同的事情,而在计算机技术的背景下则是另外一种含义。同样,在电子商务中,搜索特定产品名称或产品ID的人,通常是在寻找一个非常具体的商品,并且很可能希望购买它。像“kitchen appliances”(厨房电器)这样的通用搜索可能表示用户只是打算浏览可用的产品,看看有哪些选择。

在这两种情况下,查询分类器都可以有效地判断查询的类型。根据领域的不同,查询的上下文可以自动应用(例如,过滤文档类别)、用于修改相关性算法(自动提升特定产品的权重),甚至用来推动不同的用户体验(跳过结果页面,直接进入某个产品页面)。在本节中,我们将展示如何利用第5章中的SKG作为分类器来处理输入的查询,以构建查询分类器。

SKG遍历在图遍历的每个层级执行k近邻搜索。k近邻是一种分类方法,它取一个数据点(例如查询或术语),并试图在向量空间中找到最相似的k个数据点。如果我们有一个类似类别或分类的字段,我们可以请求SKG“找到与我的起始节点相关性最高的类别”。由于起始节点通常是用户的查询,因此SKG可以对该查询进行分类。

我们将继续使用已索引的Stack Exchange数据集作为SKG,以扩展用于查询分类(本节)和查询意义消歧(第6.2节)。

代码示例6.1展示了一个函数,该函数接收一个用户查询并遍历SKG,找到语义相关的类别来分类查询。由于我们已经索引了多个不同的Stack Exchange类别(如scifi、health、cooking、devops等),我们将这些类别作为我们的分类。

代码示例6.1 使用SKG进行查询分类

def print_query_classification(query, classification_field="category",
      classification_limit=5, keywords_field="body", min_occurrences=5):

  nodes_to_traverse = [{"field": keywords_field, #1
                        "values": [query]},  #1
                       {"field": classification_field, #2
                        "min_occurrences": min_occurrences,  #3
                        "limit": classification_limit}]  #4

  traversal = skg.traverse(*nodes_to_traverse)  #5
  print_classifications(query, traversal)  #6

skg = get_skg(get_engine().get_collection("stackexchange"))

print_query_classification("docker", classification_limit=3)
print_query_classification("airplane", classification_limit=1)
print_query_classification("airplane AND crash", classification_limit=2)
print_query_classification("vitamins", classification_limit=2)
print_query_classification("alien", classification_limit=1)
print_query_classification("passport", classification_limit=1)
print_query_classification("driver", classification_limit=2)
print_query_classification("driver AND taxi", classification_limit=2)
print_query_classification("driver AND install", classification_limit=2)

代码说明:

  1. 基于查询匹配字段构建图的初始节点
  2. 我们将从中查找相关分类的字段,在此情况下是“category”字段
  3. 只返回至少出现在一定数量文档中的分类
  4. 设置返回的分类数量
  5. 遍历SKG以分类查询
  6. 打印查询及其分类结果

查询分类示例:

查询: docker
分类:
devops 0.87978

查询: airplane
分类:
travel 0.33334

查询: airplane AND crash
分类:
scifi 0.02149
travel 0.00475

查询: vitamins
分类:
health 0.48681
cooking 0.09441

查询: alien
分类:
scifi 0.62541

查询: passport
分类:
travel 0.82883

查询: driver
分类:
travel 0.38996
devops 0.08917

查询: driver AND taxi
分类:
travel 0.24184
scifi -0.13757

查询: driver AND install
分类:
devops 0.22277
travel -0.00675

这个请求使用SKG通过比较查询与每个可用分类(在“category”字段内)之间的语义相似性来查找前k个最邻近的项。

我们可以看到每个查询的潜在分类的得分。例如,“airplane”和“passport”被分类为“travel”,“vitamins”被分类为“health”和“cooking”,“alien”被分类为“scifi”。然而,当我们将查询“airplane”细化为更具体的查询“airplane AND crash”时,分类从“travel”变为“scifi”,因为关于飞机失事的文档更可能出现在“scifi”类别中,而不是“travel”类别中。

另一个例子是,“driver”可能有多种含义。在没有提供其他上下文的情况下,它返回两个潜在分类(“travel”或“devops”),其中“travel”类别显然是首选。然而,当提供更多上下文时,我们可以看到查询“driver AND taxi”被适当地分类为“travel”,而“driver AND install”被适当地分类为“devops”。

SKG能够发现术语组合之间的语义关系,这使得它在实时分类输入查询时非常有用。你可以将分类自动应用为查询过滤器或提升策略,或者将查询路由到特定上下文的算法或目标页面,甚至自动消除查询术语的歧义。我们将在下一节中探讨使用双层图遍历来实现查询意义消歧。

6.2 查询意义消歧

在从用户的查询中解读他们的意图时,准确理解每个词的含义是具有挑战性的。多义词或歧义词的问题可能会显著影响搜索结果。

例如,如果有人搜索“server”(服务器),这可能指的是餐厅中的服务员,负责接单和为顾客提供餐饮服务,或者指的是在网络上运行软件的计算机。理想情况下,我们希望我们的搜索引擎能够消除这些词义的歧义,并在每个消歧后的上下文中生成一个独特的相关术语列表。图6.1展示了“server”一词的这两种潜在上下文,以及在每个上下文中可能找到的相关术语类型。

image.png

6.2 查询意义消歧

在解读用户查询的意图时,准确理解每个词的含义是一个挑战。多义词或歧义词的问题可能会显著影响搜索结果。

例如,如果有人搜索“server”(服务器),这可能指的是在餐厅中接单并为顾客提供餐饮服务的服务员,或者指的是一台在网络上运行软件的计算机。理想情况下,我们希望搜索引擎能够消除这些词义的歧义,并在每个消歧后的上下文中生成一个独特的相关术语列表。图6.1展示了“server”一词的两种潜在上下文以及在每个上下文中可能出现的相关术语类型。

在第6.1节中,我们展示了如何使用SKG自动将查询分类到一组已知类别中。鉴于我们已经知道如何对查询进行分类,添加第二级遍历可以为每个查询分类提供上下文化的相关术语列表。

换句话说,通过从查询到分类再到术语的遍历,我们可以生成一个术语列表,该列表描述了原始查询在每个顶级分类中的上下文化解读。以下代码示例展示了如何利用SKG以这种方式消歧查询。

代码示例6.2 跨不同上下文消歧查询意图

def print_query_disambigutaion(query,
      context_field="category", context_limit=5,
      keywords_field="body", keywords_limit=10, min_occurrences=5):

  nodes_to_traverse = [{"field": keywords_field,  #1
                        "values": [query]}, #1
                       {"field": context_field,  #2
                        "min_occurrences": min_occurrences, #2
                        "limit": context_limit}, #2
                       {"field": keywords_field,  #3
                        "min_occurrences": min_occurrences, #3
                        "limit": keywords_limit}] #3

  traversal = skg.traverse(*nodes_to_traverse)
  print_disambigutaions(query, traversal)

代码说明:

  1. 图遍历的起始节点(用户的查询)
  2. 第一级遍历返回用于消歧查询的上下文
  3. 第二级遍历是从与查询和每个相关上下文都相关的关键词中寻找

从这个代码示例中可以看到,默认情况下使用“category”作为上下文字段,“body”作为关键词字段,作为二级遍历的一部分。对于传入的任何查询,我们首先找到最语义相关的类别,然后在该类别中找到与原始查询最语义相关的术语。

以下代码示例展示了如何调用此函数,传入三个包含歧义词的查询,并为其寻找不同的含义。

代码示例6.3 对多个查询执行查询意义消歧

print_query_disambigutaion("server")
print_query_disambigutaion("driver", context_limit=2)
print_query_disambigutaion("chef", context_limit=2)

代码示例6.3中的查询结果可以在表6.1–6.3中找到,接着是用于消歧“chef”查询的搜索引擎特定SKG请求(见代码示例6.4)。每个消歧上下文(category字段)与查询的得分相关,每个发现的关键词(body字段)与查询和消歧上下文的得分相关。

表6.1 显示了查询“server”按类别上下文化的相关术语列表

查询: server
上下文: devops 0.83796
关键词:
server 0.93698
servers 0.76818
docker 0.75955
code 0.72832
configuration 0.70686
deploy 0.70634
nginx 0.70366
jenkins 0.69934
git 0.68932
ssh 0.6836

上下文: cooking -0.1574
关键词:
server 0.66363
restaurant 0.16482
pie 0.12882
served 0.12098
restaurants 0.11679
knife 0.10788
pieces 0.10135
serve 0.08934
staff 0.0886
dish 0.08553

上下文: travel -0.15959
关键词:
server 0.81226
tipping 0.54391
vpn 0.45352
tip 0.41117

上下文: scifi -0.28208
关键词:
server 0.78173
flynn's 0.53341
computer 0.28075
computers 0.2593
servers 0.39053
firewall 0.33092
restaurant 0.21698
tips 0.19524
bill 0.18951
cash 0.18485
flynn 0.24963
servers 0.24778
grid 0.23889
networking 0.2178
shutdown 0.21121
hacker 0.19444

表6.1显示了查询“server”的最语义相关的类别,接着是每个类别上下文中的最语义相关的关键词。根据数据,我们看到“devops”类别的相关性最强(得分为0.83796),而接下来的三个类别都包含负分数(“cooking”为-0.1574,“travel”为-0.15959, “scifi”为-0.28208)。因此,对于查询“server”,“devops”类别显然是最相关的类别。

如果我们查看每个类别返回的不同术语列表,我们还可以看到出现了几种不同的含义。在“devops”类别中,术语“server”的含义侧重于与管理、构建和部署代码到计算机服务器相关的工具。在“scifi”类别中,含义围绕着计算机网格被黑客攻击并关闭网络的情节。而在“travel”类别中,术语“server”主要与餐厅中的服务员有关,出现了“tipping”(小费)、“restaurant”(餐馆)和“bill”(账单)等词汇。

在使用这些数据实现智能搜索应用时,如果你知道用户的上下文与旅行相关,那么在“travel”类别中使用该词的特定含义是有意义的。如果上下文未知,通常最好的选择是使用最语义相关的类别或在用户中最受欢迎的类别。

表6.2 显示了查询“driver”的按类别上下文化的相关术语列表

查询: driver
上下文: travel 0.38996
关键词:
driver 0.93417
drivers 0.76932
taxi 0.71977
car 0.65572
license 0.61319
driving 0.60849
taxis 0.57708
traffic 0.52823
bus 0.52306
driver's 0.51043

上下文: devops 0.08917
关键词:
ipam 0.78219
driver 0.77583
aufs 0.73758
overlayfs 0.73758
container_name 0.73483
overlay2 0.69079
cgroup 0.68438
docker 0.67529
compose.yml 0.65012
compose 0.55631

表6.2展示了查询“driver”的意义消歧。在这种情况下,有两个相关类别,其中“travel”类别最为语义相关(0.38996),而“devops”类别为(0.08917)。我们可以看到在这两个上下文中“driver”一词的两种非常不同的含义,在“travel”类别中,“driver”与“taxi”(出租车)、“car”(汽车)、“license”(驾驶执照)、“driving”(驾驶)和“bus”(公交)相关,而在“devops”类别中,“driver”与“ipam”、 “aufs”和“overlayfs”等计算机相关的驱动程序相关。

如果某人搜索“driver”,他们通常不打算在搜索结果中看到这个词的两种含义的文档。有几种方式可以处理查询关键词的多重潜在含义,比如按意义对结果进行分组以突出差异、只选择最可能的含义、在搜索结果中小心地交替显示不同的含义以提供多样性,或为不同上下文提供替代的查询建议。这里的有意选择通常比懒散地将多种不同的含义混在一起要好得多。

表6.3 显示了查询“chef”的按类别上下文化的相关术语列表

查询: chef
上下文: cooking 0.37731
关键词:
chef 0.93239
chefs 0.5151
www.pamperedchef.com 0.41292
kitchen 0.39127
restaurant 0.38975
cooking 0.38332
chef's 0.37392
professional 0.36688
nakiri 0.36599
pampered 0.34736

上下文: devops 0.34959
关键词:
chef 0.87653
puppet 0.79142
docs.chef.io 0.7865
ansible 0.73888
www.chef.io 0.72073
learn.chef.io 0.71902
default.rb 0.70194
configuration 0.68296
inspec 0.65237
cookbooks 0.61503

作为最后一个示例,表6.3展示了查询“chef”的消歧。前两个上下文都显示了相当正面的相关性得分,表明这两种含义可能都是合理的解释。虽然“cooking”上下文的得分(0.37731)略高于“devops”上下文(0.34959),但在选择这两种含义时,仍然重要的是尽可能考虑用户的上下文。在“devops”上下文中,“chef”的含义与用于构建和部署服务器的Chef配置管理软件有关(相关术语包括“puppet”和“ansible”),而在“cooking”上下文中,它指的是一个准备食物的人(相关术语包括“cooking”、“taste”、“restaurant”、“ingredients”)。Chef软件借用了烹饪领域的灵感,作为准备和提供软件的隐喻,因此在“devops”类别中看到“cookbooks”这一术语也并不令人惊讶。

用于消歧查询的搜索引擎特定SKG请求可以通过调用print_disambigutaion_request函数来查看。这对于理解和直接在配置的搜索引擎或向量数据库上运行内部SKG请求非常有用。用于消歧“chef”查询的Solr特定SKG请求语法如下所示。

代码示例6.4 Solr SKG消歧请求(查询“chef”)

print_disambigutaion_request("chef", context_limit=2)

结果:

{
 "limit": 0,
 "params": {
   "q": "*",
   "fore": "{!${defType} v=$q}",
   "back": "*",
   "defType": "edismax",
   "f0_0_query": "chef"
 },
 "facet": {
   "f0_0": {
     "type": "query",
     "query": "{!edismax qf=body v=$f0_0_query}",
     "field": "body",
     "sort": {"relatedness": "desc"},
     "facet": {
       "relatedness": {
         "type": "func",
         "func": "relatedness($fore,$back)"
       },
       "f1_0": {
         "type": "terms",
         "field": "category",
         "mincount": 5,
         "limit": 2,
         "sort": {"relatedness": "desc"},
         "facet": {
           "relatedness": {
             "type": "func",
             "func": "relatedness($fore,$back)"
           },
           "f2_0": {
             "type": "terms",
             "field": "body",
             "mincount": 5,
             "limit": 10,
             "sort": {"relatedness": "desc"},
             "facet": {
               "relatedness": {
                 "type": "func",
                 "func": "relatedness($fore,$back)"
               }
             }
           }
         }
       }
     }
   }
 }
}

说明:

  1. 起始节点是查询“chef”。
  2. 第一级SKG遍历找到与起始节点最相关的“category”字段,这些类别就是消歧的上下文。
  3. 最后一级SKG遍历从“body”字段找到与消歧上下文相关的术语。

这是用于消歧查询“chef”的Solr内部SKG请求,设置context_limit为2。该请求将根据配置的搜索引擎或向量数据库进行调整,或者如果引擎没有SKG功能,将回退到Solr。有关更改配置搜索引擎的说明,请参见附录B。

通过结合查询分类、术语消歧和查询扩展,SKG可以在你的AI驱动搜索引擎中提供增强的领域特定和高度上下文化的语义搜索能力。我们将在第7章中进一步探讨如何在实际的语义搜索应用中应用这些技术。

6.3 从查询信号中学习相关短语

到目前为止,你已经了解了如何使用内容作为知识图谱来发现相关术语、分类查询和消歧义术语。虽然这些技术非常强大,但它们也完全依赖于文档的质量。在本章的其余部分,我们将探讨关于你领域的另一个主要知识来源——用户信号(查询、点击和后续操作)。通常,用户信号在解释查询时可以提供类似的,甚至更有用的见解,而不仅仅是文档内容。

作为从真实用户行为中学习领域特定术语的起点,让我们考虑一下查询日志的意义。每个发送到搜索引擎的查询,查询日志中都包含了执行搜索的人的标识符、执行的查询以及查询的时间戳。这意味着,如果同一个用户进行了多次搜索,你可以将这些搜索归类在一起,并且还可以知道这些术语是按什么顺序输入的。

虽然并非总是如此,但一个合理的假设是,如果某人在非常短的时间内输入了两个不同的查询,第二个查询很可能是第一个查询的细化或与第一个查询相关的主题。图6.2展示了你可能在查询日志中找到的一个单一用户的真实搜索序列。

image.png

到目前为止,你已经看到了如何使用内容作为知识图谱来发现相关术语、分类查询和消歧义术语。虽然这些技术非常强大,但它们完全依赖于文档的质量。在本章的其余部分,我们将探讨另一个关于你领域的重要知识来源——用户信号(查询、点击和后续操作)。通常,用户信号在解释查询时能够提供与文档内容类似,甚至更有用的见解。

作为从真实用户行为中学习领域特定术语的起点,让我们考虑查询日志所代表的内容。对于发送到搜索引擎的每个查询,查询日志包含了执行搜索的人的标识符、执行的查询和查询的时间戳。这意味着,如果同一个用户进行了多个查询,你可以将这些查询归类在一起,并且还可以知道这些术语是按照什么顺序输入的。

虽然并非总是如此,但一个合理的假设是,如果某人在非常短的时间内输入了两个不同的查询,第二个查询很可能是第一个查询的细化或与第一个查询相关的主题。图6.2展示了你可能在查询日志中找到的一个单一用户的真实搜索序列。

6.3.1 从查询日志中挖掘相关查询

在我们开始挖掘用户信号以查找相关查询之前,首先要将信号转换成一个更简单的格式,以便处理。以下代码示例提供了一个转换,从我们的通用信号结构转换为一个简单的结构,将每次查询术语与搜索该术语的用户配对。

代码示例6.5 将信号映射到关键词和用户对

signals_collection = engine.get_collection("signals")
create_view_from_collection(signals_collection, "signals") #1
query = """SELECT LOWER(searches.target) AS keyword, searches.user
           FROM signals AS searches  #2
           WHERE searches.type='query'"""  #2
spark.sql(query).createOrReplaceTempView("user_searches")  #2
print_keyword_user_pairs()

代码说明:

  1. 从信号集合中加载所有文档到Spark视图中
  2. 从查询信号中选择关键词和用户数据

输出:

Number of keyword user pairs: 725459

Keyword user pairs derived from signals:
User "u10" searched for "joy stick"
User "u10" searched for "xbox"
User "u10" searched for "xbox360"

从这个代码示例中可以看出,代表了超过725,000个查询。我们的目标是根据有多少用户输入了这两个查询来找出相关的查询对。两个查询在不同用户查询日志中共同出现的频率越高,这两个查询被认为越相关。

接下来的代码示例展示了每个查询对,其中两个查询是由同一个用户搜索的,以及搜索这两个查询的用户数量(users_cooc)。

代码示例6.6 查询对的总出现次数和共同出现次数

query = """SELECT k1.keyword AS keyword1, k2.keyword AS keyword2,
           COUNT(DISTINCT k1.user) users_cooc  #1
           FROM user_searches k1
           JOIN user_searches k2 ON k1.user = k2.user
           WHERE k1.keyword > k2.keyword  #2
           GROUP BY k1.keyword, k2.keyword"""  #3
spark.sql(query).createOrReplaceTempView("keywords_users_cooc")
query = """SELECT keyword, COUNT(DISTINCT user) users_occ FROM
           user_searches GROUP BY keyword"""
spark.sql(query).createOrReplaceTempView("keywords_users_oc")
print_keyword_cooccurrences()

代码说明:

  1. 计算搜索了k1和k2的用户数量
  2. 限制关键词对为仅一个排列,以避免重复对
  3. 使用用户字段将user_searches视图与自身连接,找到所有由同一用户搜索的关键词对

输出:

+-----------+---------+
|    keyword|users_occ|
+-----------+---------+
|     lcd tv|     8449|
|       ipad|     7749|
|hp touchpad|     7144|
|  iphone 4s|     4642|
|   touchpad|     4019|
|     laptop|     3625|
|    laptops|     3435|
|      beats|     3282|
|       ipod|     3164|
| ipod touch|     2992|
+-----------+---------+

Number of co-occurring keyword searches: 244876

+-------------+---------------+----------+
|     keyword1|       keyword2|users_cooc|
+-------------+---------------+----------+
|green lantern|captain america|        23|
|    iphone 4s|         iphone|        21|
|       laptop|      hp laptop|        20|
|         thor|captain america|        18|
|         bose|          beats|        17|
|    iphone 4s|       iphone 4|        17|
|   skullcandy|          beats|        17|
|      laptops|         laptop|        16|
|      macbook|            mac|        16|
|         thor|  green lantern|        16|
+-------------+---------------+----------+

在代码示例6.6中,第一个查询产生了最常搜索的关键词,如结果所示。虽然这些可能是最流行的查询,但它们不一定是与其他查询最常共同出现的查询。第二个查询产生了查询对的总数(244,876),即两个查询至少被同一用户搜索过一次。最终的查询根据流行度对这些查询对进行排序。这些最上面的查询对高度相关。

然而,请注意,顶部结果只有23个共同搜索的用户,这意味着数据点的稀疏性较高,可能会导致列表下方出现更多噪音。在下一节中,我们将探讨一种技术,通过另一个轴(产品交互)结合信号,这可以帮助解决稀疏性问题。

虽然通过按用户聚合搜索次数到共同出现有助于找到最流行的查询对,但搜索的流行度并不是唯一有用的度量标准。“and”和“of”这些关键词高度共同出现,“phones”、“movies”、“computers”和“electronics”也高度共同出现,因为它们都是许多人都会搜索的通用词汇。为了进一步关注术语之间关系的强度,而不依赖于它们各自的流行度,我们可以使用一种叫做点互信息(PMI)的方法。

点互信息(PMI)是一种衡量两个事件之间关联性的方法。在自然语言处理的上下文中,PMI预测两个词一起出现的可能性,因为它们是相关的,而不是它们偶然一起出现的可能性。计算和归一化PMI有多种公式,但我们将使用一种变体叫做PMIk,其中k=2,它比PMI更好地保持得分的一致性,无论单词频率如何。

计算PMI2的公式如图6.3所示。

image.png

在我们的实现中,k1和k2表示我们希望比较的两个不同的关键词。P(k1,k2)表示同一用户搜索这两个关键词的频率,而P(k1)和P(k2)分别表示用户仅搜索第一个或第二个关键词的频率。从直觉上讲,如果两个关键词一起出现的频率超过了它们随机出现的期望频率,那么它们将具有更高的PMI2得分。得分越高,词语之间的语义相关性越强。

以下代码示例展示了在我们共同出现的查询对数据集上计算PMI2。

代码示例6.7 在用户搜索数据上计算PMI2

query = """
SELECT k1.keyword AS k1, k2.keyword AS k2, k1_k2.users_cooc,
k1.users_occ AS n_users1, k2.users_occ AS n_users2,
LOG(POW(k1_k2.users_cooc, 2) /  #1
    (k1.users_occ * k2.users_occ)) AS pmi2  #1
FROM keywords_users_cooc AS k1_k2
JOIN keywords_users_oc AS k1 ON k1_k2.keyword1 = k1.keyword
JOIN keywords_users_oc AS k2 ON k1_k2.keyword2 = k2.keyword"""
spark.sql(query).createOrReplaceTempView("user_related_keywords_pmi")

spark.sql("""SELECT k1, k2, users_cooc, n_users1,
                    n_users2, ROUND(pmi2, 3) AS pmi2
             FROM user_related_keywords_pmi
             WHERE users_cooc > 5 ORDER BY pmi2 DESC, k1 ASC""").show(10)

代码说明:

  1. PMI计算公式

输出:

+-----------------+--------------------+----------+--------+--------+------+
|               k1|                  k2|users_cooc|n_users1|n_users2|  pmi2|
+-----------------+--------------------+----------+--------+--------+------+
|  iphone 4s cases|      iphone 4 cases|        10|     158|     740|-7.064|
|     sony laptops|          hp laptops|         8|     209|     432|-7.252|
|otterbox iphone 4|            otterbox|         7|     122|     787| -7.58|
|    green lantern|     captain america|        23|     963|    1091|-7.594|
|          kenwood|              alpine|        13|     584|     717|-7.815|
|      sony laptop|         dell laptop|        10|     620|     451|-7.936|
|   wireless mouse|           godfather|         6|     407|     248|-7.939|
|       hp laptops|        dell laptops|         6|     432|     269| -8.08|
|      mp3 players|        dvd recorder|         6|     334|     365|-8.128|
|          quicken|portable dvd players|         6|     281|     434|-8.128|
+-----------------+--------------------+----------+--------+--------+------+

从代码示例6.7中的结果可以看到,结果按PMI2得分排序,我们设置了最小出现次数阈值(>5),以帮助去除噪音。像“hp laptops”、“dell laptops”和“sony laptops”这样的查询对显示为相关,品牌如“kenwood”和“alpine”也显示为相关。值得注意的是,也有噪音存在于某些查询对中,如“wireless mouse”和“godfather”、以及“quicken”和“portable dvd players”。使用PMI的一个警告是,少量共同出现的次数,尤其是在少数用户中,可能会比使用共同出现模型时更容易产生噪音,后者基于术语常常共同出现的假设。

将共同出现模型和PMI2模型的优点结合起来的一种方法是创建一个复合得分。这将提供流行度和出现概率的结合,应该会将那些在这两个得分上都匹配的查询对排到列表的顶部。代码示例6.8展示了将这两种度量结合在一起的一种方式。具体来说,我们将所有共同出现得分的排名列表(r1)与所有PMI2得分的排名列表(r2)结合起来,生成一个复合排名得分,如图6.4所示。

image.png

复合得分(comp_score)如图6.4所示,为查询对(查询q1和查询q2)分配一个高得分,其中它们在共同出现列表(r1)中的排名和在PMI2列表(r2)中的排名都很高;而当术语在排名列表中进一步下移时,它们的排名会更低。结果是一个结合了查询的流行度(共同出现)和查询之间的相关性可能性(无论它们的流行度如何)(PMI2)的混合排名。以下代码示例展示了如何基于已经计算好的共同出现和PMI2得分来计算复合得分。

代码示例6.8 从共同出现和PMI计算复合得分

query = """
SELECT *, (r1 + r2 / (r1 * r2)) / 2 AS comp_score  #1
FROM (
  SELECT *,
  RANK() OVER (PARTITION BY 1  #2
               ORDER BY users_cooc DESC) r1, #2
  RANK() OVER (PARTITION BY 1  #3
               ORDER BY pmi2 DESC) r2  #3
  FROM user_related_keywords_pmi)"""
spark.sql(query).createOrReplaceTempView("users_related_keywords_comp_score")

spark.sql("""SELECT k1, k2, users_cooc, ROUND(pmi2, 3) as pmi2,
             r1, r2, ROUND(comp_score, 3) as comp_score
             FROM users_related_keywords_comp_score
             ORDER BY comp_score ASC, pmi2 ASC""").show(20)

代码说明:

  1. 复合得分计算结合了PMI2得分和共同出现得分的排序。
  2. 根据共同出现得分从最佳(最高共同出现)到最差(最低共同出现)对其进行排序。
  3. 根据PMI2得分从最佳(最高PMI2得分)到最差(最低PMI2得分)对其进行排序。

输出:

+-------------+---------------+----------+-------+---+------+----------+
|           k1|             k2|users_cooc|   pmi2| r1|    r2|comp_score|
+-------------+---------------+----------+-------+---+------+----------+
|green lantern|captain america|        23| -7.594|  1|  8626|       1.0|
|    iphone 4s|         iphone|        21|-10.217|  2| 56156|      1.25|
|       laptop|      hp laptop|        20| -9.133|  3| 20383|     1.667|
|         thor|captain america|        18| -8.483|  4| 13190|     2.125|
|    iphone 4s|       iphone 4|        17|-10.076|  5| 51964|       2.6|
|         bose|          beats|        17|-10.074|  5| 51916|       2.6|
|   skullcandy|          beats|        17| -9.001|  5| 18792|       2.6|
|      laptops|         laptop|        16|-10.792|  8| 80240|     4.063|
|      macbook|            mac|        16| -9.891|  8| 45464|     4.063|
|         thor|  green lantern|        16| -8.594|  8| 14074|     4.063|
|   headphones|   beats by dre|        15| -9.989| 11| 49046|     5.545|
|  macbook pro|        macbook|        15| -9.737| 11| 39448|     5.545|
|  macbook air|        macbook|        15| -9.443| 11| 26943|     5.545|
|   ipod touch|           ipad|        13|-11.829| 14|200871|     7.036|
|       ipad 2|           ipad|        13|-11.765| 14|196829|     7.036|
|         nook|         kindle|        13| -9.662| 14| 36232|     7.036|
|  macbook pro|    macbook air|        13| -9.207| 14| 21301|     7.036|
|      kenwood|         alpine|        13| -7.815| 14|  9502|     7.036|
| beats by dre|          beats|        12|-10.814| 19| 82811|     9.526|
|      macbook|          apple|        12|-10.466| 19| 62087|     9.526|
+-------------+---------------+----------+-------+---+------+----------+

总体来说,复合得分合理地将我们的共同出现和PMI2度量结合起来,克服了每种方法的局限性。代码示例6.8中显示的前几个结果都很合理。然而,我们在本节中已经注意到的一个问题是,关于共同出现的数字非常稀疏。具体来说,在超过70万个查询信号中,任何查询对的最高共同出现次数是“green lantern”和“captain america”之间的23个重叠用户,如代码示例6.6所示。

在下一节中,我们将展示一种解决稀疏数据问题的方法,特别是在特定查询对的用户之间缺乏重叠的情况下。我们将通过将多个用户聚合到一个具有相似行为的大组中来解决这个问题。具体来说,我们将把焦点转向用户查询重叠的产品,而不是关注发出重叠查询的单个用户。

6.3.2 通过产品交互查找相关查询

在第6.3.1节中使用的技术依赖于多个用户搜索重叠的查询。如我们所见,尽管有超过70万个查询信号,任何查询对的最高重叠用户数为23人。由于数据可能非常稀疏,因此在很多情况下,聚合基于用户以外的其他信息会更加合理。

在本节中,我们将展示如何使用相同的技术(使用共同出现和PMI2),但将焦点转向基于产品点击信号的聚合,而不是基于用户的聚合。由于你可能拥有比产品更多的用户,并且特定产品很可能会对类似的关键词作出点击响应,这种技术有助于克服数据稀疏性问题,并生成查询之间更高的重叠度。

代码示例6.9中的转换将查询和点击信号结合成具有三个关键列的单行数据:关键词、用户和产品。

代码示例6.9 将原始信号映射为关键词、用户和产品组合

query = """SELECT LOWER(searches.target) AS keyword, searches.user AS user,
           clicks.target AS product FROM signals AS searches
           RIGHT JOIN signals AS clicks  #1
           ON searches.query_id = clicks.query_id  #1
           WHERE searches.type = 'query'  #1
           AND clicks.type = 'click'"""  #1
spark.sql(query).createOrReplaceTempView("keyword_click_product")
print_signals_format()

代码说明:

  1. 使用点击信号将关键词、用户和产品组合在一起

输出: 原始信号格式:

+-------------------+-----------+----------------+-----------+-----+-------+
|                 id|   query_id|     signal_time|     target| type|   user|
+-------------------+-----------+----------------+-----------+-----+-------+
|000001e9-2e5a-4a...|u112607_0_1|2020-04-18 16:33|        amp|query|u112607|
|00001666-1748-47...|u396779_0_1|2019-10-16 10:22|Audio stand|query|u396779|
|000029d2-197d-4a...|u466396_0_1|2020-05-07 11:39|alarm clock|query|u466396|
+-------------------+-----------+----------------+-----------+-----+-------+

简化后的信号格式:

+-------------+----+------------+
|      keyword|user|     product|
+-------------+----+------------+
|    joy stick| u10|097855018120|
|         xbox| u10|885370235876|
|virgin mobile|u100|799366521679|
+-------------+----+------------+

利用这些数据,我们现在能够根据用户搜索相同产品时对关键词的使用情况,来确定两个关键词之间关系的强度。代码示例6.10生成了关键词对,以确定它们之间的潜在关系,适用于所有关键词对,在这些对中,两个关键词都在同一产品的查询中被使用。在第6.3.1节中寻找每个用户重叠查询的想法是,每个用户很可能会搜索相关的商品。每个产品也很可能会通过相关的查询被搜索到,因此我们可以将思维模型从“找出有多少用户搜索了这两个查询”转变为“找出有多少文档是通过这两个查询在所有用户中找到的”。

代码示例6.10 显示导致相同产品被点击的关键词对

query = """
SELECT k1.keyword AS k1, k2.keyword AS k2, SUM(p1) n_users1, sum(p2) n_users2,
SUM(p1 + p2) AS users_cooc, COUNT(1) n_products FROM (
  SELECT keyword, product, COUNT(1) AS p1 FROM keyword_click_product
  GROUP BY keyword, product) AS k1 JOIN (
  SELECT keyword, product, COUNT(1) AS p2 FROM keyword_click_product
  GROUP BY keyword, product) AS k2 ON k1.product = k2.product
WHERE k1.keyword > k2.keyword GROUP BY k1.keyword, k2.keyword"""
spark.sql(query).createOrReplaceTempView("keyword_click_product_cooc")
print_keyword_pair_data()

输出: 共同出现的查询数量:1,579,710

+--------------+-------------+--------+--------+----------+----------+
|            k1|           k2|n_users1|n_users2|users_cooc|n_products|
+--------------+-------------+--------+--------+----------+----------+
|       laptops|       laptop|    3251|    3345|      6596|       187|
|       tablets|       tablet|    1510|    1629|      3139|       155|
|        tablet|         ipad|    1468|    7067|      8535|       146|
|       tablets|         ipad|    1359|    7048|      8407|       132|
|       cameras|       camera|     637|     688|      1325|       116|
|          ipad|        apple|    6706|    1129|      7835|       111|
|      iphone 4|       iphone|    1313|    1754|      3067|       108|
|    headphones|  head phones|    1829|     492|      2321|       106|
|        ipad 2|         ipad|    2736|    6738|      9474|        98|
|     computers|     computer|     536|     392|       928|        98|
|iphone 4 cases|iphone 4 case|     648|     810|      1458|        95|
|       netbook|       laptop|    1017|    2887|      3904|        94|
|        laptop|    computers|    2794|     349|      3143|        94|
|       netbook|      laptops|    1018|    2781|      3799|        91|
|    headphones|    headphone|    1617|     367|      1984|        90|
|        laptop|           hp|    2078|     749|      2827|        89|
|        tablet|    computers|    1124|     449|      1573|        89|
|       laptops|    computers|    2734|     331|      3065|        88|
|           mac|        apple|    1668|    1218|      2886|        88|
|     tablet pc|       tablet|     296|    1408|      1704|        87|
+--------------+-------------+--------+--------+----------+----------+

users_coocn_products计算是两种不同的方法,用于衡量我们对任意两个术语k1和k2之间的关系的信心。结果目前按n_products排序,您可以看到列表顶部的关系非常清晰。这些关键词对代表了多种有意义的语义关系,包括:

  • 拼写变体——“laptops” ⇒ “laptop”;“headphones” ⇒ “head phones”;等
  • 品牌关联——“tablet” ⇒ “ipad”;“laptop” ⇒ “hp”;“mac” ⇒ “apple”;等
  • 同义词/替代名称——“netbook” ⇒ “laptop”;“tablet pc” ⇒ “tablet”
  • 类别扩展——“ipad” ⇒ “tablet”;“iphone 4” ⇒ “iphone”;“tablet” ⇒ “computers”;“laptops” ⇒ “computers”

您可以编写自定义的、特定领域的算法来识别这些具体类型的关系,如我们将在第6.5节中处理拼写变体一样。

也可以使用 n_users1 和 n_users2 来识别哪个查询更受欢迎。在拼写变体的情况下,我们看到“headphones”比“head phones”更常用(1,829 vs 492 个用户),也比“headphone”更常用(1,617 vs 367 个用户)。同样,我们看到“tablet”比“tablet pc”更常见(1,408 vs 296 个用户)。

虽然我们当前的关键词对列表看起来很整洁,但它仅表示在导致相同产品的搜索中同时出现的关键词对。确定每个关键词的总体受欢迎程度将更好地帮助我们了解哪些特定的关键词对我们的知识图谱最为重要。下面的查询计算了从我们的查询信号中获得的最受欢迎的关键词,这些查询最终至少点击了一个产品。

清单 6.11 计算导致点击的关键词搜索

query = """SELECT keyword, COUNT(1) AS n_users FROM keyword_click_product
           GROUP BY keyword"""
spark.sql(query).createOrReplaceTempView("keyword_click_product_oc")
print_keyword_popularity()

输出:

点击导致的关键词搜索数量:13744

关键词用户数
ipad7554
hp touchpad4829
lcd tv4606
iphone 4s4585
laptop3554
beats3498
laptops3369
ipod2949
ipod touch2931
ipad 22842
kindle2833
touchpad2785
star wars2564
iphone2430
beats by dre2328
macbook2313
headphones2270
bose2071
ps32041
mac1851

这个列表与清单 6.6 中的列表相同,但显示的不是搜索关键词的用户数,而是搜索关键词并点击了产品的用户数。我们将使用此列表作为 PMI2 计算的主查询列表。

通过将我们的查询对和查询受欢迎程度基于查询和产品交互进行计算,接下来的计算(PMI2 和综合得分)与第 6.3.1 节中的相同,因此我们在这里省略它们(它们已包含在笔记本中供您运行)。计算出 PMI2 和综合得分后,以下清单显示了基于产品交互的相关术语计算的最终结果。

清单 6.12 基于产品交互的相关术语评分

query = """SELECT k1, k2, n_users1, n_users2, ROUND(pmi2, 3) AS pmi2,
           ROUND(comp_score, 3) AS comp_score
           FROM product_related_keywords_comp_score
           ORDER BY comp_score ASC"""
dataframe = spark.sql(query)
print("共同出现的查询数量:", dataframe.count(), "\n")
dataframe.show(20)

输出:

共同出现的查询数量:1579710

k1k2n_users1n_users2pmi2comp_score
ipadhp touchpad755448291.2321.0
ipad 2ipad284275541.4311.25
tabletipad181875541.6691.667
touchpadipad278575541.2232.125
tabletsipad162775541.7492.6
ipad2ipad125475541.9033.083
ipadapple755418141.53.571
touchpadhp touchpad278548291.3944.063
ipadhp tablet755414211.5944.556
ipod touchipad293175540.8635.05
ipadi pad75546122.4155.545
kindleipad283375540.8286.042
laptopipad355475540.5936.538
ipadapple ipad75543262.9167.036
ipad 2hp touchpad284248291.1817.533
laptopslaptop336935541.298.031
ipadhp755411251.5348.529
ipadsipad25475543.0159.028
ipadhtc flyer755418341.0169.526
ipadi pad 275542043.1810.025

清单 6.11 和 6.12 的结果显示了在较低粒度级别聚合的好处。通过查看所有导致特定产品被点击的查询,查询对的列表现在比第 6.3.1 节中按用户聚合的列表要大得多。在聚合时,考虑的查询对现在有 1,579,710 对,而第 6.6 清单中按用户聚合时为 244,876 对。

此外,您可以看到相关查询包括了更多针对热门查询(如 ipad、ipad 2、ipad2、i pad、ipads、i pad 2)的细化变体。拥有这样的细化变体将有助于如果您将此相关术语发现与其他算法(如拼写错误检测)结合使用时,如第 6.5 节中所述。

在上一章中的 SKG 方法和本章中的查询日志挖掘方法之后,您已经看到了多种发现相关短语的技术。然而,在我们可以应用这些相关短语之前,我们首先需要能够识别传入查询中的已知短语。在接下来的部分,我们将讨论如何从我们的查询信号中生成已知短语的列表。

6.4 从用户信号中检测短语

在第 5.3 节中,我们讨论了从文档中提取任意短语和关系的几种技术。虽然这种方法可以帮助我们发现内容中的所有相关领域特定短语,但它也存在两个不同的问题:

  • 产生大量噪声:并非文档中每个名词短语都是重要的,随着文档数量的增加,识别错误短语(假阳性)的几率也会增加。
  • 忽视了用户关心的内容:用户兴趣的真正衡量标准通过他们的搜索行为传达出来。他们可能只对内容的某个子集感兴趣,或者可能在寻找内容中没有很好呈现的东西。

在本节中,我们将重点讨论如何从用户信号中识别重要的领域特定短语。

6.4.1 将查询视为实体

从查询日志中提取实体的最简单方法是将整个查询视为一个实体。在像我们的 RetroTech 电子商务网站这样的用例中,这种方法非常有效,因为许多查询都是产品名称、类别、品牌名称、公司名称或人名(演员、音乐家等)。鉴于此上下文,大多数高流行度的查询最终都是实体,可以直接作为短语使用,而不需要任何特殊的解析。

回顾清单 6.11 的输出,你会发现以下是最受欢迎的查询:

关键词用户数
ipad7554
hp touchpad4829
lcd tv4606
iphone 4s4585
laptop3554
......

这些是属于已知实体列表的实体,其中许多是多词短语。在这种情况下,提取实体的最简单方法也是最有效的——直接使用查询作为实体列表。每个查询在用户中出现的频率越高,你就越有信心将其添加到实体列表中。

减少来自噪声查询的潜在假阳性的一种方法是找到在文档和查询中都有重叠的短语。此外,如果你的文档中有不同的字段,例如产品名称或公司名称,你可以通过交叉引用查询与这些字段来为查询中找到的实体分配类型。

根据查询的复杂性,使用最常见的搜索作为你的关键实体可能是实现高质量实体列表的最直接方法。

6.4.2 从更复杂的查询中提取实体

在某些用例中,查询可能包含更多的噪声(布尔结构、复杂的查询操作符等),因此可能无法直接作为实体使用。在这些情况下,提取实体的最佳方法可能是重新应用第 5 章中的实体提取策略,但这次应用于你的查询信号。

开箱即用的词法搜索引擎将查询解析为单个关键词,并在倒排索引中查找。例如,查询“new york city”将自动解释为布尔查询“new AND york AND city”(或者如果将默认操作符设置为 OR,则为“new OR york OR city”)。相关性排名算法随后将单独评分每个关键词,而不是理解某些词汇组合起来形成短语,从而赋予其不同的含义。

能够识别并从查询中提取领域特定短语可以帮助更准确地理解查询和提升相关性。在第 5.3 节中,我们已经演示了从文档中提取领域特定短语的一种方法,使用 spaCy NLP 库进行依赖分析并提取名词短语。虽然查询通常太短,无法执行真正的依赖分析,但仍然可以对查询中发现的短语应用部分词性过滤,以排除非名词短语。如果需要拆分查询的各个部分,你还可以对查询进行分词,并在寻找要提取的短语之前删除查询语法(如 AND、OR 等)。处理应用程序的特定查询模式可能需要一些领域特定的查询解析逻辑,但如果你的查询大多是单一短语或容易分词为多个短语,那么你的查询很可能代表了最好的领域特定短语来源,可以将其提取并添加到你的知识图谱中。我们将在第 7.4 节中展示解析查询时识别短语的代码示例。

6.5 拼写错误和替代表示

我们已经讨论了如何检测领域特定短语和找到相关短语,但有两个非常重要的相关短语子类别通常需要特殊处理:拼写错误和替代拼写(也称为替代标签)。在输入查询时,用户通常会拼错关键词,而普遍的期望是,AI 驱动的搜索系统能够理解并正确处理这些拼写错误。

虽然“laptop”的一般相关短语可能是“computer”、“netbook”或“tablet”,但拼写错误更可能表现为“latop”、“laptok”或“lapptop”。替代标签在功能上与拼写错误没有区别,但它们发生在一个短语有多个有效变体时(例如“specialized”与“specialised”或“cybersecurity”与“cyber security”)。对于拼写错误和替代标签的情况,最终目标通常是将较不常见的变体规范化为较常见的标准形式,然后搜索标准版本。

拼写检查可以通过多种方式实现。在本节中,我们将介绍大多数搜索引擎中基于文档的拼写检查,以及如何利用用户信号来细化拼写修正,基于真实用户与搜索引擎的交互进行调整。

6.5.1 从文档中学习拼写修正

大多数搜索引擎都自带某些拼写检查功能,这些功能是基于文档集合中的词汇。例如,Apache Solr 提供基于文件、字典和索引的拼写检查组件。基于文件的拼写检查器需要组装一个可以进行拼写修正的词汇列表。基于字典的拼写检查器可以从索引中的字段构建拼写修正词汇表。基于索引的拼写检查器可以直接使用主索引中的字段进行拼写检查,而无需构建单独的拼写检查索引。此外,如果有人已经离线构建了拼写修正列表,你可以使用同义词列表直接替换或扩展任何拼写错误为标准形式。

Elasticsearch 和 OpenSearch 也有类似的拼写检查功能,甚至允许特定上下文来细化拼写建议的范围,针对特定类别或地理位置。

虽然我们鼓励你测试这些开箱即用的拼写检查算法,但不幸的是,它们都有一个主要问题:缺乏用户上下文。具体而言,任何搜索中出现的关键词,如果在索引中出现的次数未达到最小阈值,拼写检查组件就会开始查看所有与最小字符差异匹配的词汇,并返回在索引中最为常见的符合条件的关键词。以下清单显示了开箱即用的基于索引的拼写检查配置如何失效的一个例子。

清单 6.13 使用开箱即用的拼写修正对文档进行检查

products_collection = engine.get_collection("products")
query = "moden"
results = engine.spell_check(products_collection, query)
print(results)

输出:

{'modes': 421, 'model': 159, 'modern': 139, 'modem': 56, 'mode6': 9}

在清单 6.13 中,您可以看到用户查询“moden”。拼写检查器返回了“modes”、“model”、“modern”和“modem”的拼写修正建议,另外还有一个仅出现在少数文档中的建议(我们将忽略)。由于我们的集合是技术产品,可能很容易判断出最好的拼写修正是“modem”。事实上,用户不太可能故意搜索“modes”或“model”作为单独的查询,因为这两个词通常是通用词,只有在包含其他单词的上下文中才有意义。

基于内容的索引无法轻松区分最终用户不会搜索“modern”或“model”的情况。因此,虽然基于内容的拼写检查器在许多情况下可以很好地工作,但从用户查询行为中学习拼写修正通常更为准确。

6.5.2 从用户信号中学习拼写修正

回到第 6.3 节的核心观点,即用户通常会搜索相关查询,直到找到预期结果,因此,拼写错误的用户查询会导致他们尝试修正查询。

我们已经知道如何找到相关短语(在第 6.3 节中讨论过),但在本节中,我们将讨论如何基于用户信号具体区分拼写错误。这个任务主要涉及两个目标:

  1. 查找拼写相似的词汇。
  2. 确定哪个词是正确拼写,哪个是拼写错误的变体。

对于这个任务,我们将仅依赖查询信号。我们将进行一些预处理,使查询分析不区分大小写,并过滤重复查询,以避免信号垃圾(信号规范化将在第 8.2–8.3 节中讨论)。以下清单展示了一个获取我们规范化查询信号的查询示例。

清单 6.14 获取用户搜索的所有查询

def get_search_queries():
  query = """SELECT searches.user AS user,
             LOWER(TRIM(searches.target)) As query  #1
             FROM signals AS searches WHERE searches.type = 'query'
             GROUP BY searches.target, user"""  #2
  return spark.sql(query).collect()
#1 将查询转换为小写,使查询分析忽略大小写变体。
#2 按用户分组,以防止单个用户多次输入相同查询产生的垃圾信号。

为了本节的目的,我们假设查询可能包含多个不同的关键词,并且我们希望将这些关键词视为潜在的拼写变体。这将允许在未来的查询中找到并替换单个术语,而不是将整个查询视为单一短语。这也将允许我们丢弃某些可能是噪音的术语,例如停用词或单独的数字。

以下清单演示了通过对每个查询进行分词的过程,从而生成一个可以进行进一步分析的词汇表。

清单 6.15 通过分词和过滤查询词来查找词汇

from nltk import tokenize, corpus, download  #1
download('stopwords') #1
stop_words = set( #1
  corpus.stopwords.words("english")) #1

def is_term_valid(term, minimum_length=4): #2
  return (term not in stop_words and
          len(term) >= minimum_length and
          not term.isdigit())

def tokenize_query(query):  #3
  return tokenize.RegexpTokenizer(r'\w+').tokenize(query) #3

def valid_keyword_occurrences(searches, tokenize=True):
  word_list = defaultdict(int)
  for search in searches:
    query = search["query"]
    terms = tokenize_query(query) if tokenize else [query]  #3
    for term in terms:  #4
      if is_term_valid(term):  #4
        word_list[term] += 1  #4
  return word_list
#1 使用自然语言工具包(nltk)定义不应视为拼写错误或修正的停用词
#2 移除噪声术语,包括停用词、非常短的术语和数字
#3 如果分词,则按空格将查询分割成单独的术语
#4 聚合有效关键词的出现次数

一旦词汇列表清理完毕,下一步是区分高频词汇和低频词汇。由于拼写错误通常较少出现,而正确的拼写则会更频繁出现,我们将利用出现的相对频率来确定哪个版本最有可能是标准拼写,哪个变体是拼写错误。

为了确保我们的拼写修正列表尽可能干净,我们将为流行词设定一些阈值,并为低频词设定一些阈值,这些低频词更有可能是拼写错误。由于某些集合可能包含数百个文档,而其他集合可能包含数百万个文档,我们不能仅仅依赖绝对数值来设定这些阈值,因此我们将使用分位数。以下清单展示了计算0.1到0.9之间每个分位数的过程。

清单 6.16 计算分位数以识别拼写候选项

def calculate_quantiles(word_list):
  quantiles_to_check = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9]
  quantile_values = numpy.quantile(numpy.array(list(word_list.values())),
                                   quantiles_to_check)
  return dict(zip(quantiles_to_check, quantile_values))

query_signals = get_search_queries()
word_list = valid_keyword_occurrences(query_signals, tokenize=True)
quantiles = calculate_quantiles(word_list)
display(quantiles)

输出:

{0.1: 5.0,
 0.2: 6.0,
 0.3: 8.0,
 0.4: 12.0,
 0.5: 16.0,
 0.6: 25.0,
 0.7: 47.0,
 0.8: 142.20000000000027,
 0.9: 333.2000000000007}

在这里,我们看到 80% 的术语被搜索了 142.2 次或更少。同样,只有 20% 的术语被搜索了 6.0 次或更少。根据帕累托原则,我们假设大多数拼写错误都落在我们搜索次数最少的 20% 术语中,而大多数最重要的术语落在搜索次数最多的 20% 术语中。如果你想要更高的精度(只为高价值术语生成拼写修正,并且只有在假阳性概率较低的情况下),可以将拼写错误的阈值设定为0.1分位数,而正确拼写的阈值设定为0.9分位数。你也可以反其道而行之,尝试生成一个更大的拼写错误列表,但这样做可能会有更高的假阳性概率。

清单 6.17中,我们将这些术语分为不同的桶,将低频术语分配到拼写错误桶中,将高频术语分配到修正桶中。这些桶将作为在足够多用户同时搜索拼写错误候选项和修正候选项时找到高质量拼写修正的起点。

清单 6.17 识别拼写修正候选项

def create_spelling_candidates(word_list):
  quantiles = calculate_quantiles(word_list)
  misspellings = {"misspelling": [],
                  "misspell_counts": [],
                  "misspell_length": [],
                  "initial": []}
  corrections = {"correction": [],
                 "correction_counts": [],
                 "correction_length": [],
                 "initial": []}
  for key, value in word_list.items():
    if value <= quantiles[0.2]:  #1
      misspellings["misspelling"].append(key) #1
      misspellings["misspell_counts"].append(value)  #2
      misspellings["misspell_length"].append(len(key))  #3
      misspellings["initial"].append(key[0])  #4
    if value >= quantiles[0.8]: #5
      corrections["correction"].append(key) #5
      corrections["correction_counts"].append(value) #5
      corrections["correction_length"].append(len(key)) #5
      corrections["initial"].append(key[0])  #5
  return (pandas.DataFrame(misspellings), pandas.DataFrame(corrections))
#1 出现次数小于等于0.2分位数的术语被加入拼写错误列表。
#2 保留搜索次数以跟踪流行度。
#3 术语的长度稍后用于设置编辑距离计算的阈值。
#4 存储术语的首字母,以限制检查的拼写错误范围。
#5 出现次数大于等于0.8分位数的术语也被加入修正列表。

为了高效地比较所有拼写错误和修正值,我们首先将它们加载到数据框中,如清单 6.17所示。你可以想象,修正列表是最受欢迎搜索的术语,而拼写错误列表则提供了一个很好的候选列表,包含了搜索次数较少、更有可能是拼写错误的术语。

当我们比较拼写错误的候选项和正确拼写的候选项,并决定允许多少个字符差异(或编辑距离)时,我们需要考虑术语的长度。以下清单显示了一个简单的 good_match 函数,它定义了一个通用启发式规则,规定了在匹配术语时,编辑距离可以偏离多少个字符,同时仍然认为拼写错误是修正候选项的可能变体。

清单 6.18 根据长度和编辑距离找到正确拼写

def good_match(word_length_1, word_length_2, edit_dist):
  min_length = min(word_length_1, word_length_2)
  return ((min_length < 8 and edit_dist == 1) or
          (min_length >= 8 and min_length < 11 and edit_dist <= 2) or
          (min_length >= 11 and edit_dist == 3))

在我们将拼写错误和修正候选项加载到数据框中并定义 good_match 函数后,接下来就是生成我们的拼写修正列表。就像在第 6.5.1 节中,我们是基于编辑距离和术语在文档集合中的出现次数生成拼写修正的,清单 6.19 基于编辑距离和术语在查询日志中的出现次数生成拼写修正。

清单 6.19 将拼写错误映射到正确拼写

from nltk import edit_distance

def calculate_spelling_corrections(word_list):
  (misspellings, corrections) = create_spelling_candidates(word_list)
  matches_candidates = pandas.merge(misspellings,  #1
                       corrections, on="initial")  #1
  matches_candidates["edit_dist"] = matches_candidates.apply(
    lambda row: edit_distance(row.misspelling,  #2
                      row.correction), axis=1)  #2
  matches_candidates["good_match"] = matches_candidates.apply(
    lambda row: good_match(row.misspell_length, #3
                           row.correction_length,  #3
                           row.edit_dist),axis=1)  #3
  cols = ["misspelling", "correction", "misspell_counts",
          "correction_counts", "edit_dist"]
  matches = matches_candidates[matches_candidates["good_match"]] \
    .drop(["initial", "good_match"],axis=1) \
    .groupby("misspelling").first().reset_index() \  #4
    .sort_values(by=["correction_counts", "misspelling",  #4
                     "misspell_counts"],  #4
                 ascending=[False, True, False])[cols] #4
  return matches

query_signals = get_search_queries()
word_list = valid_keyword_occurrences(query_signals, tokenize=True)
corrections = calculate_spelling_corrections(word_list)
display(corrections.head(20)) #5
#1 按单词的首字母对拼写错误和修正候选项进行分组
#2 计算每个拼写错误和修正候选项之间的编辑距离
#3 使用术语的长度和编辑距离应用 `good_match` 函数
#4 按名称聚合所有拼写错误
#5 获取20个最常见的拼写错误词

输出:

misspelling   correction  misspell_counts correction_counts edit_dist
50   iphone3       iphone      6               16854             1
61   laptopa       laptop      6               14119             1
62   latop         laptop      5               14119             1
...
76   moden         modem       5               3590              1
77   modum         modem       6               3590              1
135  tosheba       toshiba     6               3432              1
34   gates         games       6               3239              1
84   phono         phone       5               3065              1

正如你所看到的,我们现在得到了一个基于用户信号的相对干净的拼写修正列表。我们的查询“moden”正确映射为“modem”,而不是像“model”和“modern”这样的不太可能的搜索词,这些我们在基于文档的拼写修正中看到过(在清单 6.13 中)。

你可以通过许多其他方法来创建拼写修正模型。如果你想从文档中生成多术语的拼写修正,你可以生成二元组和三元组,通过对连续术语出现的概率执行链式贝叶斯分析。同样,为了从查询信号中生成多术语拼写修正,你可以在调用 valid_keyword_occurrences 时将 tokenize 设置为 False,以便不对查询进行分词。

清单 6.20 从完整查询中查找多术语拼写修正

query_signals = get_search_queries()
word_list = valid_keyword_occurrences(query_signals, tokenize=False)
corrections = calculate_spelling_corrections(word_list)
display(corrections.head(20))

输出:

misspelling    correction   misspell_counts  correction_counts edit_dist
181 ipad.          ipad         6                7749              1
154 hp touchpad 32 hp touchpad  5                7144              3
155 hp toucpad     hp touchpad  6                7144              1
153 hp tochpad     hp touchpad  6                7144              1
190 iphone s4      iphone 4s    5                4642              2
193 iphone4 s      iphone 4s    5                4642              2
194 iphones 4s     iphone 4s    5                4642              1
412 touchpaf       touchpad     5                4019              1
406 tochpad        touchpad     6                4019              1
407 toichpad       touchpad     6                4019              1
229 latop          laptop       5                3625              1
228 laptopa        laptops      6                3435              1
237 loptops        laptops      5                3435              1
205 ipods touch    ipod touch   6                2992              1
204 ipod tuch      ipod touch   6                2992              1
165 i pod tuch     ipod touch   5                2992              2
173 ipad 2         ipad 2       6                2807              1
215 kimdle         kindle       5                2716              1
206 ipone          iphone       6                2599              1
192 iphone3        iphone       6                2599              1

你可以看到,在不进行查询分词的情况下,清单 6.20 中列出了常见的多词拼写错误及其修正。请注意,单一词汇的拼写修正结果大体相同,但多词查询也已进行了拼写检查。这是标准化产品名称的一个好方法,以确保像“iphone4 s”,“iphones 4s”以及“iphone s4”都能正确映射到标准形式“iphone 4s”。值得注意的是,在某些情况下,这可能是一个有损过程,因为“hp touchpad 32”被映射到“hp touchpad”,而“iphone3”被映射到“iphone”。根据你的应用场景,你可能会发现只对单个术语进行拼写修正,或者在 good_match 函数中为品牌变体设置特殊处理,以确保拼写检查代码不会错误地删除相关查询上下文是有益的。

6.6 将所有内容整合起来

在本章中,我们深入探讨了理解领域特定语言的上下文和意义。我们展示了如何使用语义知识图谱(SKG)来对查询进行分类,并根据上下文消歧义具有不同或微妙意义的术语。我们还探讨了如何从用户信号中挖掘关系,通常这比单纯查看文档提供了更好的上下文,帮助我们理解用户。我们还展示了如何从查询信号中提取短语、拼写错误和替代标签,使得领域特定术语能够直接从用户那里学习,而不仅仅是从文档中学习。

到此为止,你应该能够从文档或用户信号中学习领域特定短语和相关短语,对查询进行分类,并根据查询分类来消歧义术语的含义。这些技术是解读查询意图的重要工具。

然而,我们的目标不仅仅是组建一个庞大的工具箱。我们的目标是根据需要在适当的地方使用这些工具,构建一个端到端的语义搜索层。这意味着我们需要将已知短语建模到我们的知识图谱中,从传入的查询中提取这些短语,处理拼写错误,分类查询,消歧义传入的术语,最终为搜索引擎生成一个改写后的查询,利用我们每个AI驱动的搜索技术。在下一章中,我们将展示如何将这些技术整合成一个有效的语义搜索系统,旨在最佳地解读和建模查询意图。

总结

  • 使用语义知识图谱(SKG)对查询进行分类可以帮助解读查询意图,并改善查询路由和过滤。
  • 查询语义消歧义能够提供更具上下文理解的用户查询,特别是对于在不同上下文中意义显著不同的术语。
  • 除了从文档中学习外,领域特定短语和相关短语也可以从用户信号中学习。
  • 拼写错误和拼写变体可以从文档和用户信号中学习,基于文档的方法更为稳健,而基于用户信号的方法更能代表用户意图。