人工智能驱动的搜索(AI-Powered Search)——通过语义搜索解释查询意图

176 阅读47分钟

本章内容包括:

  • 查询解释的机制
  • 实现端到端的查询意图管道,用于解析、增强、转换和搜索
  • 标记和分类查询术语和短语
  • 使用知识图谱遍历增强查询
  • 解释领域特定查询模式的语义

在第5章和第6章中,我们使用内容和信号来解释传入用户查询的领域特定含义。我们讨论了短语识别、拼写错误检测、同义词发现、查询意图分类、相关术语扩展,甚至是查询语义消歧。我们大多独立地讨论了这些技术,旨在展示它们如何各自独立地工作。

在本章中,我们将把所有这些技术付诸实践,将它们整合到一个统一的查询解释框架中。我们将展示一个示例搜索界面,该界面接受实际查询,解释这些查询,将它们重写以更好地表达最终用户的意图,然后返回排名结果。

我们需要注意的是,实现语义搜索的多种范式已经发展起来,包括基于嵌入的查询解释和使用大语言模型(LLMs)和预训练的Transformers进行的问答(返回提取或生成的答案,而不是文档)。这些方法通常涉及将查询编码成向量,搜索近邻向量的近似最邻域,然后执行向量相似性计算以对文档进行排名。排名后的文档通常会被分析,以总结、提取或生成答案。我们将在第13章到第15章中讨论基于LLM的语义搜索和问答方法。

本章我们将重点介绍如何整合你已经学到的每种AI驱动的搜索策略,以提供一个端到端的语义查询管道。我们将分四个阶段实现这个管道:

  1. 解析用户的查询
  2. 使用改进的上下文丰富解析后的查询
  3. 转换查询以优化目标搜索引擎中的相关性
  4. 使用优化后的查询进行搜索

这些步骤不必线性实现(有时它们会重复,有时可以跳过某些步骤),也可以进一步拆解(例如,搜索可以分解为匹配、排名和重排序)。然而,通过这个一致的框架,我们可以整合任何组合的AI驱动的搜索技术,这对于在自己的搜索应用中灵活运用各种方法将是无价的。

7.1 查询解释的机制

没有一种“正确的方法”来构建查询解释框架。每个构建智能搜索平台的组织,可能会根据他们的业务需求和搜索团队的专业知识,构建略有不同的框架。然而,在不同的实现中,有一些一致的主题值得探讨:

  • 管道(Pipelines) —无论是对文档进行索引还是处理查询,将所有必要的解析、解释和排名逻辑建模为工作流中的模块化阶段是非常有用的。这使得可以通过随时交换、重新排列或添加管道中的处理阶段来轻松进行实验。
  • 模型(Models) —无论是微调基于深度学习的复杂LLM(第13–14章)、学习排序模型(第10–12章)、信号增强或个性化模型(第8–9章),还是包含同义词、拼写错误和相关术语的知识图谱(第5–6章),正确的查询解释都需要将合适的模型按正确的顺序插入到索引和查询管道中。
  • 条件回退(Conditional fallbacks) —你永远无法完美地解释每一个查询。你可能会有许多模型帮助解释一个查询,而对另一个查询却毫无头绪。通常最好的做法是从一个基础或“回退”模型(通常是基于关键词的)开始,这个模型可以不完美地处理任何查询,然后在此基础上逐步加入更复杂的模型,以增强解释的精准度。此外,如果没有找到结果,返回一些推荐可能会很有用,确保搜索者看到一些可能有用的内容,即使这不是他们确切寻找的内容。

图7.1展示了一个查询管道的示例,演示了如何结合管道阶段、模型和条件回退的这些主题。

image.png

图7.1接收查询“bbq near atlanta”,并从解析查询阶段开始,该阶段对查询中的已知关键词、地点或其他已知术语进行实体提取。接下来,进入信号增强阶段,该阶段会检查信号增强模型(在第4章中介绍,并将在第8章中详细讨论),以增强给定查询的最受欢迎文档。

通常有三种不同但互补的方法用于解释单个关键词并将它们关联在一起,所有这些都包含在示例管道中:

  • 词汇搜索,例如在倒排索引中对布尔查询匹配进行BM25排名
  • 知识图谱搜索,例如使用语义知识图谱(SKG)或显式构建的知识图谱对查询中找到的实体及其与索引中最相似实体的关系进行排名
  • 稠密向量搜索,例如使用嵌入的近似最近邻向量的余弦相似度

在这三者中,最常见的“默认”匹配层往往是倒排索引上的词汇搜索,因为这种方法允许匹配语料库中存在的任何术语,无论该术语是否被理解。知识图谱和稠密向量方法都依赖于能够将每个查询中的术语与概念或实体关联,而这并不是在所有情况下都能实现的。

事实上,BM25排名通常在嵌入向量的稠密向量方法中表现更好,即使是来自最先进的LLM(大语言模型)的嵌入,除非这些语言模型最初在特定领域的内容上进行了训练或微调(随着预训练LLM的不断改进,这种情况可能会发生变化)。我们将在第9章和第13章中深入探讨如何使用LLM进行个性化搜索和语义搜索,并将在第14章和第15章中花时间微调并使用LLM进行更高级的搜索功能,如问答和生成搜索。本章我们将主要专注于演示词汇搜索和知识图谱的集成机制。

图7.1管道以回填/回退阶段结束,在之前的阶段未能返回完整结果集的情况下,这一阶段非常有用。这个阶段可以像返回推荐而不是搜索结果(第9章中讲解)一样简单,或者可能涉及返回一个部分匹配的查询,精度较低。

然后,所有管道阶段的最终结果会结合在一起,并根据需要重新排名,生成最终的相关性排序的搜索结果。重排序阶段可以很简单,但通常会使用机器学习排名,通过排名分类器来实现。在第10到第12章中,我们将深入探讨构建和自动化训练学习排名模型。

虽然本节中的示例管道在许多情况下可能会提供良好的结果,但管道的具体逻辑应该始终取决于应用的需求。在下一节中,我们将设置一个用于搜索本地商业评论的应用,并实现一个能够在该领域执行语义搜索的统一管道。

7.2 在本地评论数据集上进行索引和搜索

我们将创建一个搜索引擎,聚合来自网络上的产品和商业评论。如果某个商家有实体位置(如餐馆、商店等),我们希望找到与该商家相关的所有评论,并使其可供搜索。

以下列出了将爬取的本地评论数据导入搜索引擎的过程。

列表 7.1 加载和索引评论数据集

reviews_collection = engine.create_collection("reviews")
reviews_data = reviews.load_dataframe("data/reviews/reviews.csv")
reviews_collection.write(reviews_data)

输出:

Wiping "reviews" collection
Creating "reviews" collection
Status: Success

Loading Reviews...
root
 |-- id: string (nullable = true)
 |-- business_name: string (nullable = true)
 |-- city: string (nullable = true)
 |-- state: string (nullable = true)
 |-- content: string (nullable = true)
 |-- categories: string (nullable = true)
 |-- stars_rating: integer (nullable = true)
 |-- location_coordinates: string (nullable = true)

Successfully written 192138 documents

评论的数据模型可以在上述输出中看到。每条评论包括商家名称、位置、评论内容、评论评分(1到5颗星)以及被评论实体的类别。

数据加载完成后,我们可以进行搜索。在本章中,我们提供了一个比之前更具交互性的应用,启动一个Web服务器来支持动态搜索界面。运行列表7.2将启动Web服务器。

列表 7.2 启动Web服务器并加载搜索页面

start_reviews_search_webserver()

%%html
<iframe src="http://localhost:2345/search" width=100% height="800"/>

图7.2展示了从列表7.2加载的搜索界面。你可以从Jupyter Notebook中运行嵌入的搜索页面,但如果你在本地计算机的2345端口运行,也可以直接在浏览器中访问http://localhost:2345/search以获得更好的体验。

image.png

首先,我们尝试一个简单的查询:bbq near charlotte。现在,假设你还没有经过知识图谱学习过程(第6章),也不知道如何将SKG(第5章)应用于查询解释。在这种情况下,我们只进行开箱即用的词汇关键词匹配。图7.3展示了查询“bbq near charlotte”的顶部词汇搜索结果。

image.png

在我们的评论数据集中,这是唯一匹配我们查询的评论,尽管在美国北卡罗来纳州夏洛特市或其周边地区确实存在多家BBQ(也称为烧烤)餐厅。只有这个结果被返回的原因是,它是唯一包含所有三个术语(bbq、near和charlotte)的文档。如果你查看这条评论,会发现它并不是关于一家提供烧烤的餐厅——实际上,它是关于一个节日的评论,而这个节日恰好提到了另一个名字中包含“BBQ”的节日!

这里的主要问题是,大多数相关餐厅的评论中并没有包含单词“near”。图7.4显示,如果我们去掉单词“near”,改为搜索“bbq charlotte”,则会有更多的结果。

image.png

这两个顶部结果都包含了术语“bbq”,但第一个结果评分很低(1星),而第二个结果提到了“bbq chicken”(鸡肉配烧烤酱),但没有提到“bbq”(烧烤),通常“bbq”指的是像拉猪肉、拉鸡肉、排骨或牛胸肉这样的烟熏肉。此外,尽管这些结果都来自于夏洛特市(北卡罗来纳州),但这仅仅是因为它们在评论文本中匹配了关键词“charlotte”,这意味着很多优秀的结果因为没有在评论中提到城市名而被遗漏。显然,从这些结果来看,搜索引擎并没有正确地理解用户的查询意图。

我们可以做得更好!你已经学会了如何提取领域特定的知识,以及如何分类查询(例如,bbq暗示着是一家餐厅),所以我们只需要将这些技术和学习的模型端到端地整合起来。

7.3 一个端到端的语义搜索示例

上一节展示了依赖纯关键词搜索的不足之处。我们如何改进搜索引擎对查询的理解能力呢?图7.5展示了一个合理具体的查询结果,传统的关键词搜索很难正确解释:top kimchi near charlotte。

image.png

这个查询很有趣,因为只有一个关键词(“kimchi”)实际上包含了用于相关性排名的传统关键词。关键词“top”实际上意味着“最受欢迎”或“最高评分”,而短语“near charlotte”则表示一个地理过滤器,用于筛选搜索结果。你可以从图中看到,原始查询被解析为{top} kimchi {near} {charlotte}。我们使用大括号语法来表示,“top”、“near”和“charlotte”这三个术语是从我们的知识图谱中识别出来的,而“kimchi”没有被标记,因此它是一个未知的术语。

在这些关键词和短语被解析之后,你可以看到它们被丰富并转化为以下特定于搜索引擎的语法(Solr):

  • top: +{!func v="mul(if(stars_rating,stars_rating,0),20)"}. 该语法会根据评论(1到5星)提升所有文档的权重,乘以20以生成一个0到100之间的评分。
  • kimchi: +{!edismax v="kimchi^0.9193 korean^0.7069 banchan^0.6593 +doc_type:"Korean""}. 这是使用第5章中的SKG扩展方法对未知术语“kimchi”进行扩展的结果。在此案例中,SKG识别出“korean”作为结果过滤的类别,而与“kimchi”最相关的术语是“korean”和“banchan”。
  • near charlotte: +{!geofilt d=50 sfield="location_coordinates" pt="35.22709,-80.84313"}. 这个地理过滤器将结果限制为位于美国北卡罗来纳州夏洛特市经纬度50公里范围内的文档。

如果原始查询作为传统的词汇搜索执行,没有查询解释层,则不会匹配任何结果,如图7.6所示。

image.png

然而,图7.7展示了执行语义解析和丰富后的版本的结果。

image.png

结果看起来相当不错!你会注意到:

  • 有许多结果(而不是零结果)。
  • 所有结果都有很高的评分(5颗星)。
  • 所有结果都位于夏洛特市。
  • 即使没有包含主要关键词(“kimchi”),一些结果仍然匹配,而且它们显然是提供kimchi的韩国餐厅,因为评论中使用了类似的术语。

我们将在本章的剩余部分介绍如何实现这种级别的语义查询解释,首先从一个高层次的查询解释管道开始。

7.4 查询解释管道

虽然我们通常需要在查询管道中集成多个模型和不同的查询理解方法,但大多数查询管道共享一组相似的高层次阶段:

  • 解析(Parsing) ——从查询中提取关键实体及其逻辑关系。
  • 增强(Enriching) ——生成查询上下文、查询的实体及其语义关系的理解。
  • 转换(Transforming) ——重写用户的查询,以优化搜索引擎的召回率和排名。
  • 搜索(Searching) ——执行转换后的查询并返回排名结果。

你可以将每个阶段视为不同类型的管道阶段。与第7.1节中的示例管道一样,一些管道可能调用多个阶段来解析或增强查询,而一些管道甚至可能运行多个条件搜索并合并结果。

在接下来的子节中,我们将实现每个阶段,以演示我们端到端语义搜索示例的内部工作原理(参见第7.3节)。

7.4.1 解析语义搜索查询

正如你在第3.2.5节中看到的,大多数关键词搜索引擎默认会对传入查询执行某种形式的布尔解析。因此,查询“statue of liberty”会被解析为“statue AND of AND liberty”,其中任何包含这三个词(“statue”、“of”和“liberty”)的文档都会匹配,假设默认查询运算符为AND。

仅仅使用布尔匹配并不能产生很好的结果,但当与BM25排名(在第3.2.1节中讨论)结合使用时,它可以为一个没有对领域内术语有任何真正理解的简单算法提供很好的结果。

与这种布尔解析相比,还可以将整个查询转换为数值向量嵌入,正如第3.1.1节中所讨论的那样。我们将在第13至第14章中讨论使用LLM和嵌入进行稠密向量搜索。使用LLM和基于嵌入的查询解释的一个好处是,这些技术可以更好地将查询表示为一个整体的含义单位。然而,使用这种方法时,查询的逻辑结构有时可能会丢失,因此在必须保留布尔逻辑或必须确保某些关键词出现在搜索结果中的情况下,这种方法可能效果不佳。

解析查询的最后一种方法是通过从知识图谱中提取已知的术语和短语。我们在第7.3节的端到端示例中采用了这种方法。这种方法的一个好处是,除了提供对已知词汇的精细控制外,它还允许显式地建模特定短语和触发词(如“top”、“in”、“near”)的功能意义,而不仅仅是进行关键词匹配。这种方法的缺点是,任何在知识图谱中不存在的术语或短语无法轻松提取和解释。

由于我们将在后面的章节中深入探讨LLM,本章将重点介绍使用知识图谱进行显式查询解析,因为显式解析可以为领域提供显著的定制,实施成本低,并且使我们能够融入我们已经学到的所有其他AI技术。

实现语义查询解析器

在语义解析查询时,第一步是识别查询中的术语和短语(解析阶段)。在第6章中,我们讲解了如何从我们的内容和用户行为信号中识别重要的领域特定术语和短语。这些可以用作已知实体列表,用于对传入查询进行实体提取。

由于已知短语列表中可能有数百万个实体,使用有限状态转换器(FST)等高效结构使得可以在毫秒级别进行大规模的实体提取。我们在此不详细探讨FST的工作原理,但它们能非常紧凑地压缩许多术语序列,并能快速查找这些术语序列,从而实现快速的实体提取。

我们的示例搜索引擎Apache Solr实现了一个文本标签请求处理程序,专门用于快速的实体提取。它允许你将任意数量的术语索引到查找索引中,因此你可以将该索引构建为FST,并从该索引中提取传入文本流中的术语。

在第6章中,我们生成了领域特定短语的列表,这些短语还包括了替代拼写。我们可以将所有这些术语以及任何拼写变体映射到一个特别配置的实体集合中,从而使得从传入查询中无缝地提取实体。以下列出了我们在实体集合中使用的几种实体数据。

列表 7.3 实体数据用于标记和提取

entities_dataframe = from_csv("data/reviews/entities.csv", log=False)
display_entities(entities_dataframe, limit=20)

输出:

Entities
+---+--------------------+--------------------+-----------------+----------+
| id|        surface_form|      canonical_form|             type|popularity|
+---+--------------------+--------------------+-----------------+----------+
|  1|                near| {location_distance}|semantic_function|        90|
|  2|                  in| {location_distance}|semantic_function|       100|
|  3|                  by| {location_distance}|semantic_function|        90|
|  4|                  by|{text_within_one_...|semantic_function|        10|
|  5|                near|     {text_distance}|semantic_function|        10|
|  6|             popular|           {popular}|semantic_function|       100|
|  7|                 top|           {popular}|semantic_function|       100|
|  8|                best|           {popular}|semantic_function|       100|
|  9|                good|           {popular}|semantic_function|       100|
| 10|              violet|              violet|            color|       100|
| 11|       violet crowne|       violet crowne|            brand|       100|
| 12|violet crowne cha...|violet crowne cha...|    movie_theater|       100|
| 13|        violet crown|       violet crowne|            brand|       100|
| 14|violet crown char...|violet crowne cha...|    movie_theater|       100|
| 15|            haystack| haystack conference|            event|       100|
| 16|       haystack conf| haystack conference|            event|       100|
| 17| haystack conference| haystack conference|            event|       100|
| 18|            heystack| haystack conference|            event|       100|
| 19|       heystack conf| haystack conference|            event|       100|
| 20| heystack conference| haystack conference|            event|       100|
+---+--------------------+--------------------+-----------------+----------+
only showing top 20 rows

... Entities continued
+---+----------------------------------------------+
|id |semantic_function                             |
+---+----------------------------------------------+
|1  |location_distance(query, position)            |
|2  |location_distance(query, position)            |
|3  |location_distance(query, position)            |
|4  |text_within_one_edit_distance(query, position)|
|5  |text_distance(query, position)                |
|6  |popularity(query, position)                   |
|7  |popularity(query, position)                   |
|8  |popularity(query, position)                   |
|9  |popularity(query, position)                   |
+---+----------------------------------------------+

列表7.3中表示的实体字段包括:

  • surface_form:我们希望在未来查询中匹配的任何拼写变体的具体文本。
  • canonical_form:任何可能具有多个表面形式的术语的“官方”版本。
  • type:术语在我们领域中的分类(类别)。
  • popularity:用于优先考虑同一表面形式的不同含义。
  • semantic_function:仅适用于类型为“semantic_function”的实体。此字段用于对特殊的关键词组合进行编程处理。

在大多数情况下,surface_formcanonical_form是相同的,但我们的实体提取器将始终匹配surface_form并将其映射到canonical_form,因此该机制用于将实体的多个拼写变体映射到一个官方或“规范”的版本。这可以用于处理拼写错误(如“amin” ⇒ “admin”)、缩写和首字母缩写(如“cto” ⇒ “chief technology officer”)、歧义术语(如“cto” ⇒ “chief technology officer”与“cto” ⇒ “cancelled-to-order”),甚至将术语映射到特定的解释逻辑(如“near” ⇒ {location_distance})。

semantic_function”类型是一个特殊类型,我们将在第7.4.2节中进一步探讨;它使得非线性、条件查询解析规则成为可能。例如,“如果‘near’后跟一个具有地理位置的实体,则将查询的这一部分解释为地理过滤器”。

在出现歧义术语的情况下,将会有多个条目包含相同的surface_form,并映射到不同的canonical_form。在这种情况下,popularity字段将指定一个相对值,指示哪种含义更常见(值越高,越受欢迎)。

这种格式也具有可扩展性——你可以添加一个向量字段来表示canonical_form的语义含义,或者添加一个related_terms字段,包含其他具有相似含义的术语。这将使得缓存canonical_form的静态表示变得更加高效,查询时比每次请求都引用外部模型或知识图谱更为高效。

调用实体提取器

除了在列表7.1中创建的评论集合,我们还需要创建一个包含已知实体的实体集合,以供提取使用。这个集合将作为一个显式的知识图谱,包含来自列表7.3中的所有实体,以及全球所有主要城市的列表。以下列表配置并填充了实体集合。

列表 7.4 创建实体集合

entities_collection = engine.create_collection("entities")  #1
entities_dataframe = from_csv("data/reviews/entities.csv")
cities_dataframe = cities.load_dataframe("data/reviews/cities.csv")
entities_collection.write(entities_dataframe)  #2
entities_collection.write(cities_dataframe)  #2
#1 创建实体集合并配置它以保存从查询中提取的显式知识图谱实体
#2 将显式实体和城市实体索引到实体集合中,以供实体提取使用。

输出:

Wiping "entities" collection
Creating "entities" collection
Status: Success
Loading data/reviews/entities.csv
Schema:
root
 |-- id: integer (nullable = true)
 |-- surface_form: string (nullable = true)
 |-- canonical_form: string (nullable = true)
 |-- type: string (nullable = true)
 |-- popularity: integer (nullable = true)
 |-- semantic_function: string (nullable = true)

Loading Geonames...
Successfully written 21 documents
Successfully written 137581 documents

我们应当强调的一点配置是实体提取的设置,这发生在engine.create_collection("entities")中。在Solr用于提供显式知识图谱以从查询中提取实体的默认情况下,Solr的文本标签功能通过内部进行以下配置变更来启用:

  • 添加一个/entities/tag端点,使用Solr中的TaggerRequestHandler。我们可以将查询传递到此端点,以执行从实体集合中提取的任何实体的实体提取。
  • 在模式中添加一个tags字段类型,配置为使用内存中的FST,从潜在数百万个实体的集合中进行紧凑且快速的标记。
  • 添加一个name_tag字段,将surface_form字段的值复制到该字段中。name_tag字段是一个tags字段类型,/entities/tag端点用它来匹配查询中的实体。

如果你的搜索引擎具有原生的文本标记功能,配置会有所不同,但以下列表展示了这些更改的代码,适用于使用Apache Solr的默认实现。

列表 7.5 配置Solr文本标签器以进行实体提取

add_tag_type_commands = [{
  "add-field-type": {
    "name": "tag",  #1
    "class": "solr.TextField", #1
    "postingsFormat": "FST50", #1
    "omitNorms": "true",
    "omitTermFreqAndPositions": "true",
    "indexAnalyzer": {
      "tokenizer": {"class": "solr.StandardTokenizerFactory"},
      "filters": [
        {"class": "solr.EnglishPossessiveFilterFactory"},
        {"class": "solr.ASCIIFoldingFilterFactory"},
        {"class": "solr.LowerCaseFilterFactory"},
        {"class": "solr.ConcatenateGraphFilterFactory",  #2
         "preservePositionIncrements": "false"}]},  #2
    "queryAnalyzer": {
      "tokenizer": {"class": "solr.StandardTokenizerFactory"},
      "filters": [{"class": "solr.EnglishPossessiveFilterFactory"},
                  {"class": "solr.ASCIIFoldingFilterFactory"},
                  {"class": "solr.LowerCaseFilterFactory"}]}}
  },

  {"add-field": {"name": "name_tag", "type": "tag",  #3
                 "stored": "false"}},  #3
  {"add-copy-field": {"source": "surface_form",  #4
                      "dest": ["name_tag"]}}] #4

add_tag_request_handler_config = {
  "add-requesthandler": {  #5
    "name": "/tag",  #5
    "class": "solr.TaggerRequestHandler",  #5
    "defaults": {
      "field": "name_tag",  #5
      "json.nl": "map",
      "sort": "popularity desc",  #6
      "matchText": "true",
      "fl": "id,surface_form,canonical_form,type,semantic_function,
      ↪popularity,country,admin_area,*_p"
    }}}

说明:

  • #1 标签字段类型使用Lucene的FST50索引格式进行配置,启用基于内存的快速匹配。
  • #2 ConcatenateGraphFilter是文本标签器用来促进实体提取的特殊过滤器。
  • #3 我们添加了name_tag字段,用于将查询与索引中的实体标签进行匹配。
  • #4 name_tag字段通过surface_form值填充。
  • #5 配置了/tag请求处理程序,使用name_tag字段中的值作为从传入查询中提取的实体。
  • #6 如果多个实体匹配(多义词),默认情况下返回最受欢迎的实体。

实体集合创建完成,文本标签器配置好,实体都已索引,我们现在可以对查询执行实体提取。在以下的列表中,我们为查询“top kimchi near charlotte”运行实体提取。

列表 7.6 提取给定查询的实体

query = "top kimchi near charlotte"
entities_collection = engine.get_collection("entities")
extractor = get_entity_extractor(entities_collection)
query_entities = extractor.extract_entities(query)
print(query_entities)

输出:

{"query": "top kimchi near charlotte",
 "tags": [
  {"startOffset": 0, "endOffset": 3, "matchText": "top", "ids": ["7"]},
  {"startOffset": 11, "endOffset":15, "matchText":"near", "ids":["1","5"]},
  {"startOffset": 16, "endOffset": 25, "matchText": "charlotte",
   "ids": ["4460243", "4612828", "4680560", "4988584", "5234793"]}],
 "entities": [
  {"id":"1", "surface_form":"near", "canonical_form":"{location_distance}",
   "type": "semantic_function", "popularity": 90,
   "semantic_function": "location_distance(query, position)"},
  {"id": "5", "surface_form": "near", "canonical_form": "{text_distance}",
   "type": "semantic_function", "popularity": 10,
   "semantic_function": "text_distance(query, position)"},
  {"id": "7", "surface_form": "top", "canonical_form": "{popular}",
   "type": "semantic_function", "popularity": 100,
   "semantic_function": "popularity(query, position)"},
  {"id":"4460243", "canonical_form":"Charlotte", "surface_form":"Charlotte",
   "admin_area": "NC", "popularity": 827097, "type": "city",
   "location_coordinates": "35.22709,-80.84313"},
  {"id":"4612828", "canonical_form":"Charlotte", "surface_form":"Charlotte",
   "admin_area": "TN", "popularity": 1506, "type": "city",
   "location_coordinates": "36.17728,-87.33973"},
  {"id":"4680560", "canonical_form":"Charlotte", "surface_form":"Charlotte",
   "admin_area": "TX", "popularity": 1815, "type": "city",
   "location_coordinates": "28.86192,-98.70641"},
  {"id":"4988584", "canonical_form":"Charlotte", "surface_form":"Charlotte",
   "admin_area": "MI", "popularity": 9054, "type": "city",
   "location_coordinates": "42.56365,-84.83582"},
  {"id":"5234793", "canonical_form":"Charlotte", "surface_form":"Charlotte",
   "admin_area": "VT", "popularity": 3861, "type": "city",
   "location_coordinates": "44.30977,-73.26096"}]}

响应包括三个关键部分:

  • query:已标记的查询。
  • tags:在传入查询中找到的文本短语的列表,包含文本中的字符偏移(开始和结束位置)以及每个标签(表面形式)对应的所有可能实体匹配(规范形式)的列表。
  • entities:匹配实体的文档ID列表,这些实体可能与匹配的标签之一对应。

我们之前讨论过歧义术语,其中一个表面形式可以映射到多个规范形式。在我们的示例中,第一个标签是{'startOffset': 0, 'endOffset': 3, 'matchText': 'top', 'ids': ['7']}。这表示文本“top”在输入查询“top kimchi near charlotte”中的位置是从位置0到位置3。它还列出了唯一一个ID,表示只有一个可能的含义(规范表示)。然而,对于其他两个标签,列出了多个ID,使它们成为歧义标签:

{"startOffset": 11, "endOffset": 15, "matchText": "near", "ids": ["1", "5"]}
{"startOffset": 16, "endOffset": 25, "matchText": "charlotte", "ids": ["4460243", "4612828", "4680560", "4988584", "5234793"]}

这意味着表面形式“near”有两个规范形式(列出了两个ID),而表面形式“charlotte”有五个规范形式。在entities部分,我们还可以看到与标签中的ID列表相关联的所有不同的实体记录。

在本章中,我们将保持简单,始终使用最高流行度的规范形式。对于城市,我们在流行度字段中提供了城市的人口,这意味着所选择的“charlotte”是美国北卡罗来纳州的夏洛特市(世界上人口最多的夏洛特)。对于其他实体,流行度在entities.csv(来自列表7.3)中手动指定。你也可以使用信号增强值来指定流行度(如果你是从信号中衍生出实体,详见第8章),或者使用索引中包含实体的文档计数作为流行度的代理。

你可能还会发现,使用用户特定的上下文或查询特定的上下文来选择最合适的实体是有益的。例如,如果你正在消除地点歧义,可以通过地理距离计算增强流行度,使离用户更近的位置获得更高的权重。如果实体是关键词短语,你可以使用SKG来分类查询,或者加载术语向量并增强与整体查询更匹配的规范形式。

通过从知识图谱获取query_entities,我们现在可以生成一个用户友好的版本的原始查询,其中标记的实体已经被识别。以下列表实现了这个generate_tagged_query函数。

列表 7.7 生成标记查询

def generate_tagged_query(extracted_entities):  #1
  query = extracted_entities["query"]
  last_end = 0
  tagged_query = ""
  for tag in extracted_entities["tags"]:
    next_text = query[last_end:tag["startOffset"]].strip()
    if len(next_text) > 0:
      tagged_query += " " + next_text
    tagged_query += " {" + tag["matchText"] + "}"  #2
    last_end = tag["endOffset"]
  if last_end < len(query):
    final_text = query[last_end:len(query)].strip()
    if len(final_text):
      tagged_query += " " + final_text
  return tagged_query

tagged_query = generate_tagged_query(query_entities)
print(tagged_query)

输出:

{top} kimchi {near} {charlotte}

从这个标记查询中,我们现在可以看到,“top”、“near”和“charlotte”这些关键词映射到了已知实体,而“kimchi”是一个未知的关键词。这个格式是一个对用户友好的查询表示,但它过于简单,无法表示与每个实体相关联的元数据。因为我们需要对实体及其语义交互进行编程处理以丰富查询,所以我们还将实现一个更结构化的语义解析查询表示形式,我们将其称为query_tree

与纯文本查询不同,query_tree是查询中强类型节点的结构,以JSON对象的形式表示。列表7.8展示了generate_query_tree函数,它从传入的实体提取数据(query_entities)生成一个查询树。

列表 7.8 从用户查询生成类型化的查询树

def generate_query_tree(extracted_entities):
  query = extracted_entities["query"]
  entities = {entity["id"]: entity for entity  #1
              in extracted_entities["entities"]}  #1
  query_tree = []
  last_end = 0

  for tag in extracted_entities["tags"]:
    best_entity = entities[tag["ids"][0]]  #2
    for entity_id in tag["ids"]:  #2
      if (entities[entity_id]["popularity"] >  #2
          best_entity["popularity"]):  #2
        best_entity = entities[entity_id] #2

    next_text = query[last_end:tag["startOffset"]].strip()
    if next_text:
      query_tree.append({"type": "keyword",  #3
                         "surface_form": next_text,  #3
                         "canonical_form": next_text})  #3
    query_tree.append(best_entity)  #4
    last_end = tag["endOffset"] 

  if last_end < len(query):  #5
    final_text = query[last_end:len(query)].strip()  #5
    if final_text:  #5
      query_tree.append({"type": "keyword",  #5
                         "surface_form": final_text,  #5
                         "canonical_form": final_text})  #5
  return query_tree

parsed_query = generate_query_tree(query_entities)
display(parsed_query)

说明:

  • #1 创建实体ID到实体的映射。
  • #2 默认选择流行度最高的规范形式作为实体。
  • #3 为任何未标记的文本分配关键词类型作为回退。
  • #4 将实体对象以适当的位置添加到查询树中。
  • #5 在最后一个标记实体后的任何文本也会被视为关键词。

输出:

[{"semantic_function": "popularity(query, position)", "popularity": 100,  "id": "7", "surface_form": "top", "type": "semantic_function",  "canonical_form": "{popular}"}, {"type": "keyword", "surface_form": "kimchi", "canonical_form": "kimchi"}, {"semantic_function":"location_distance(query, position)", "popularity":90,  "id": "1", "surface_form": "near", "type": "semantic_function",  "canonical_form": "{location_distance}"}, {"country": "US", "admin_area": "NC", "popularity": 827097,  "id": "4460243", "surface_form": "Charlotte", "type": "city",  "location_coordinates": "35.22709,-80.84313",  "canonical_form": "Charlotte"}]

我们现在有了查询和标记实体的多种表示:

  • tagger_data:列表7.6的输出
  • tagged_query:列表7.7的输出
  • parsed_query:列表7.8的输出

parsed_query的输出是底层query_tree对象的序列化,完全表示了所有关键词和实体以及它们的相关元数据。此时,初步的解析阶段已经完成,查询已被映射为类型化的实体,我们可以开始利用实体之间的关系进一步丰富查询。

7.4.2 为语义搜索丰富查询

查询解释管道的丰富阶段聚焦于理解查询中实体之间的关系,以及如何最佳地解释和表示它们,以获得最优的搜索结果相关性。

本书的大部分内容已经涉及并将继续聚焦于丰富阶段。第4章介绍了众包相关性,它是一种通过先前用户交互的数据来丰富特定关键词短语的方法,帮助识别哪些文档最相关。第5章专注于知识图谱,它提供了一种方法,通过主题分类来丰富特定的关键词短语,并找出与之高度相关的其他术语。在第6章中,我们实现了算法来查找同义词、拼写错误和相关术语,这些都可以通过增强或替换解析的术语来丰富查询,从而提供更好的、经过学习的版本。接下来的章节将介绍信号增强、个性化以及稠密向量搜索等内容,这些方法将引入新的方式来解释解析后的实体,并丰富查询以优化相关性。

这些技术都是你工具箱中的工具,但将它们结合在一起以实现特定实现的最佳方式是领域特定的,因此我们在示例中会避免过度泛化。相反,我们将关注一个简单的端到端实现,将各个部分结合在一起,以便其他模型可以轻松地插入到这个框架中。我们的简单实现将包括两个组件:

  • 一个语义函数实现,使得能够为每个领域动态注入非线性的语义规则。
  • 一个SKG,用于查找未知关键词和查询分类的相关术语。

你已经掌握了扩展查询解析框架以处理来自前几章的其他丰富类型的工具。例如,你可以使用从表面形式到规范形式的映射来处理第6章中学习的所有替代表示。同样,通过向实体集合中的每个实体添加额外的字段,你可以注入信号增强、相关术语、查询分类或向量,并在查询解析后立即使用它们。

让我们通过讨论语义函数来开始我们的丰富实现。

实现语义函数

语义函数是一种非线性函数,可以在查询解析和丰富过程中应用,以更好地解释周围术语的含义。我们之前的示例“top kimchi near charlotte”包含两个映射到语义函数的术语:“top”和“near”。术语“top”具有非常特定的领域意义:优先显示评分最高的文档(即评论中的星级数量)。同样,术语“near”不是一个应当被匹配的关键词;相反,它修改后续术语的含义,尝试将它们转化为地理位置。从列表7.3中,你会看到以下实体引用了语义函数:

语义函数实体

+-------+-------------------+----------+----------------------------------+
|surface|canonical_form     |popularity|semantic_function                 |
+-------+-------------------+----------+----------------------------------+
|near   |{location_distance}|90        |location_distance(query, position)|
|in     |{location_distance}|100       |location_distance(query, position)|
|by     |{location_distance}|90        |location_distance(query, position)|
|by     |{text_within_on...}|10        |text_within_one_edit_distance(...)|
|near   |{text_distance}    |10        |text_distance(query, position)    |
|popular|{popular}          |100       |popularity(query, position)       |
|top    |{popular}          |100       |popularity(query, position)       |
|best   |{popular}          |100       |popularity(query, position)       |
|good   |{popular}          |100       |popularity(query, position)       |
+-------+-------------------+----------+----------------------------------+

你会注意到,表面形式“top”、“popular”、“good”和“best”都映射到{popular}的规范形式,后者由popularity(query, position)语义函数表示,如以下列表所示。

列表 7.9 一个处理流行度的语义函数

def popularity(query, position):
  if len(query["query_tree"]) -1 > position:  #1
    query["query_tree"][position] = {
      "type": "transformed",
      "syntax": "solr",
      "query": '+{!func v="mul(if(stars_rating,stars_rating,0),20)"}'}  #2
    return True  #3
  return False  #3

说明:

  • #1 查询树节点后面必须有另一个节点才能成功应用流行度。
  • #2 用一个新的节点替换查询树中的{popularity}节点,该节点表示针对流行度的相关性提升。
  • #3 如果语义函数被触发,则返回True,否则返回False

这个popularity函数允许我们应用语义解释逻辑来操作查询树。如果查询树以“top”关键词结束,函数将返回False,且不会做任何调整。同样,如果另一个函数被分配了更高的优先级(如在实体集合中指定的),它可能在函数执行之前就移除了{popularity}实体。

列表 7.10 处理位置的语义函数

def location_distance(query, position):
  if len(query["query_tree"]) -1 > position:  #1
    next_entity = query["query_tree"][position + 1] #1
    if next_entity["type"] == "city": #2

      query["query_tree"].pop(position + 1)  #3
      query["query_tree"][position] = {  #4
        "type": "transformed",  #4
        "syntax": "solr",  #4
        "query": create_geo_filter(  #4
          next_entity['location_coordinates'],  #4
          "location_coordinates", 50)}  #4
      return True
  return False  #5

def create_geo_filter(coordinates, field, distance_KM):
  return f'+{!geofilt d={distance_KM} sfield="{field}" pt="{coordinates}"}'

说明:

  • #1 函数必须修改下一个实体才能成功。
  • #2 下一个实体必须是位置类型(如城市)。
  • #3 移除下一个实体,因为它是一个位置,将被半径过滤器替换。
  • #4 添加替换实体,使用半径过滤器。
  • #5 如果下一个实体不是城市,则不应用该函数。

如你所见,我们的语义函数实现允许在解释查询时有条件地应用任意逻辑。如果需要,你甚至可以调用外部知识图谱或其他数据源,来拉取更多的信息以更好地解释查询。

你可能已经注意到,表面形式“near”、“in”和“by”都映射到{location_distance}规范形式,由location_distance(query, position)函数表示。这个函数在这些术语后跟随一个位置时效果很好,但如果有人搜索“chief near officer”呢?在这种情况下,最终用户可能是想让查询“在文档中找到‘chief’和‘officer’之间距离较近的术语”,本质上是一个编辑距离搜索。请注意,如果{location_distance}实体的语义函数返回False,则可以有条件地调用映射为“near” ⇒ {text_distance}的实体,用作这个回退的用例。

语义函数可以通过多种方式实现,但我们的示例实现提供了一种高度可配置的方法,将动态的语义模式编码到查询解释管道中,以便最好地与搜索应用程序中可用的多种AI驱动的搜索方法结合使用。我们在以下的列表7.11中展示了这个实现,该函数通过遍历查询树来调用所有匹配的语义函数。

列表 7.11 处理查询树中的所有语义函数

def process_semantic_functions(query_tree):
  position = 0  #1
  while position < len(query_tree):  #2
    node = query_tree[position]  #2
    if node["type"] == "semantic_function":
      query = {"query_tree": query_tree}  #1
      command_successful = eval(node["semantic_function"]) #3
      if not command_successful:  #4
        node["type"] = "invalid_semantic_function"  #4
    position += 1
  return query_tree

说明:

  • #1 查询和位置变量被传递给eval语句中的语义函数。
  • #2 遍历查询树中的所有项,寻找需要执行的语义函数。
  • #3 动态地评估语义函数,增强查询树。
  • #4 更新任何未成功执行的语义函数的类型。

由于语义函数作为实体的一部分存储在实体集合中,我们对这些函数执行延迟绑定(使用Python的eval函数)。这使得你可以随时将新的语义函数插入到实体集合中,而无需修改应用代码。

由于语义功能的成功与否取决于周围的上下文节点,每个语义功能必须返回 True 或 False,以便处理逻辑能够确定如何继续处理查询树的其余部分。

集成 SKG

在本节中,我们将把 SKG(第 5 章讨论过的内容)集成到我们的查询增强过程中。

您的实体集合可能包含许多使用第 6 章中的技术学习到的实体。您还可以使用 SKG 或其他方法对已知实体进行分类,或者生成相关术语的列表。如果是这样,我们建议将分类和相关术语作为附加字段添加到实体集合中,以便在查询时缓存响应,便于更快查找。

对于我们的实现,我们将实时调用 SKG 来增强未知术语。此方法为查询中的所有未知关键词短语注入相关关键词,可能会生成大量误报。您可能希望在任何生产实现中更加谨慎,但实现此功能对学习和实验目的非常有用。以下列出了如何查找关键词短语并将其与我们的评论集合一起作为 SKG 遍历的示例。

示例 7.12 从 SKG 获取相关术语和分类
def get_enrichments(collection, keyword, limit=4):
  enrichments = {}
  nodes_to_traverse = [{"field": "content",  #1
                        "values": [keyword],  #1
                        "default_operator": "OR"},  #1
                       [{"name": "related_terms",  #2
                         "field": "content", #2
                         "limit": limit},  #2
                        {"name": "doc_type", #3
                         "field": "doc_type", #3
                         "limit": 1}]]  #3
  skg = get_semantic_knowledge_graph(collection)
  traversals = skg.traverse(*nodes_to_traverse)
  if "traversals" not in traversals["graph"][0]["values"][keyword]:
    return enrichments  #4

  nested_traversals = traversals["graph"][0]["values"] \
                                [keyword]["traversals"]

  doc_types = list(filter(lambda t: t["name"] == "doc_type", #5
                          nested_traversals))  #5
  if doc_types:  #5
    enrichments["category"] = next(iter(doc_types[0]["values"]))  #5

  related_terms = list(filter(lambda t: t["name"] == "related_terms",  #6
                              nested_traversals))  #6
  if related_terms:  #6
    term_vector = ""  #6
    for term, data in related_terms[0]["values"].items():  #6
      term_vector += f'{term}^{round(data["relatedness"], 4)} '  #6
    enrichments["term_vector"] = term_vector.strip()  #6

  return enrichments
query = "kimchi"  #7
get_enrichments(reviews_collection, query)  #7

注释:

  • #1 SKG 遍历的起始节点是查询内容字段中的传入关键词。
  • #2 返回关键词的前 4 个相关术语。
  • #3 返回关键词的前 1 个文档类型(类别)。
  • #4 当没有找到增强信息时返回空。
  • #5 从遍历中返回发现的类别。
  • #6 从发现的相关术语构建一个增强查询,按照相关性加权。
  • #7 获取关键词“kimchi”的增强信息。

关键词“kimchi” 的输出如下:

{
  "category": "Korean",
  "term_vector": "kimchi^0.9193 korean^0.7069 banchan^0.6593 bulgogi^0.5497"
}

以下是其他潜在关键词的示例 SKG 输出:

  • bbq:

    {
      "category": "Barbeque",
      "term_vector": "bbq^0.9191 ribs^0.6187 pork^0.5992 brisket^0.5691"
    }
    
  • korean bbq:

    {
      "category": "Korean",
      "term_vector": "korean^0.7754 bbq^0.6716 banchan^0.5534 sariwon^0.5211"
    }
    
  • lasagna:

    {
      "category": "Italian",
      "term_vector": "lasagna^0.9193 alfredo^0.3992 pasta^0.3909 italian^0.3742"
    }
    
  • karaoke:

    {
      "category": "Karaoke",
      "term_vector": "karaoke^0.9193 sing^0.6423 songs^0.5256 song^0.4118"
    }
    
  • drive through:

    {
      "category": "Fast Food",
      "term_vector": "drive^0.7428 through^0.6331 mcdonald's^0.2873 window^0.2643"
    }
    

为了完成我们的增强阶段,我们需要将 get_enrichments 函数和之前讨论过的 process_semantic_functions 函数应用到查询树中。

示例 7.13 增强查询树节点
def enrich(collection, query_tree):
  query_tree = process_semantic_functions(query_tree)  #1
  for item in query_tree:

    if item["type"] == "keyword":  #2
      enrichments = get_enrichments(collection, item["surface_form"]) #2
      if enrichments:  #3
        item["type"] = "skg_enriched"  #3
        item["enrichments"] = enrichments  #3
  return query_tree

注释:

  • #1 遍历查询树并处理所有语义功能。
  • #2 查找所有未知的关键词短语,并在 SKG 中查找它们。
  • #3 如果找到增强信息,则将其应用到节点上。

enrich 函数包含了整个增强阶段,首先处理所有语义功能,然后使用 SKG 增强所有剩余的未知关键词。在进入转换阶段之前,让我们快速看一下我们所实现的基于 SKG 的关键词扩展的替代方法。

7.4.3 稀疏词汇和扩展模型

到目前为止,我们在本书中讨论了两种主要的搜索方法:词汇搜索——基于查询中特定术语或属性的匹配和排名——和语义搜索——基于查询的含义进行匹配和排名。你还被介绍了两种主要的查询表示方法:稀疏向量(只有少量非零值的向量)和密集向量(大多数值为非零值的向量)。词汇关键词搜索通常使用倒排索引实现,该索引保存每个文档的稀疏向量表示,每个维度对应索引中的一个术语。语义搜索通常使用密集向量表示,并基于嵌入进行搜索。

稀疏向量 VS 密集向量 VS 词汇搜索 VS 语义搜索

由于计算成本的原因,密集向量表示通常具有有限数量的维度(从几百到几千),这些维度紧凑地压缩了数据的语义表示,而稀疏向量表示则可以轻松拥有数十万到数千万个维度,表示更多可识别的术语或属性。词汇关键词搜索通常使用倒排索引实现,该索引保存每个文档的稀疏向量表示,每个维度对应索引中的一个术语。语义搜索同样通常使用密集向量表示,通过嵌入进行搜索。由于这些趋势,许多人误将“语义搜索”视为嵌入上的密集向量搜索的同义词,但这忽略了基于稀疏向量和图的语义搜索方法的丰富历史,这些方法更具可解释性和灵活性。本章强调了这些方法,13至15章将深入探讨密集向量搜索技术。

然而,正如你在本章中已经看到的,语义搜索也可以使用稀疏向量来实现,并且可以在典型的词汇查询上下文中使用。虽然我们实现了直接在用户查询上操作的语义查询解析,但我们也使用 SKG 生成了查询扩展,产生了术语和权重的稀疏向量来驱动语义搜索。

还有其他技术可以用于这种查询扩展,例如 SPLADE(稀疏词汇和扩展)。SPLADE 方法(arxiv.org/pdf/2107.05…)不是使用倒排索引作为其语言模型,而是使用预构建的语言模型生成上下文化的标记。我们不会使用,因为它没有发布在支持商业用途的许可证下,但示例 7.14 演示了来自一个开源替代实现(SPLADE++)的示例输出,展示了我们在 7.4.2 节中使用 SKG 方法测试的相同示例查询。

示例 7.14 使用 SPLADE++ 扩展查询
from spladerunner import Expander
expander = Expander('Splade_PP_en_v1', 128) #1
queries = ["kimchi", "bbq", "korean bbq",
           "lasagna", "karaoke", "drive through"]

for query in queries:
  sparse_vec = expander.expand(query,  #2
                  outformat="lucene")[0]  #3
  print(sparse_vec)

注释:

  • #1 指定 SPLADE++ 模型名称和最大序列长度。
  • #2 生成稀疏词汇向量。
  • #3 返回令牌标签(字符串),而不是令牌 ID(整数)。

以下是 SPLADE++ 扩展的输出结果:

  • kimchi:

    {"kim": 3.11, "##chi": 3.04, "ki": 1.52, ",": 0.92, "who": 0.72,
     "brand": 0.56, "genre": 0.46, "chi": 0.45, "##chy": 0.45, 
     "company": 0.41, "only": 0.39, "take": 0.31, "club": 0.25,
     "species": 0.22, "color": 0.16, "type": 0.15, "but": 0.13, 
     "dish": 0.12, "hotel": 0.11, "music": 0.09, "style": 0.08, 
     "name": 0.06, "religion": 0.01}
    
  • bbq:

    {"bb": 2.78, "grill": 1.85, "barbecue": 1.36, "dinner": 0.91, 
     "##q": 0.78, "dish": 0.77, "restaurant": 0.65, "sport": 0.46,
     "food": 0.34, "style": 0.34, "eat": 0.24, "a": 0.23, "genre": 0.12, 
     "definition": 0.09}
    
  • korean bbq:

    {"korean": 2.84, "korea": 2.56, "bb": 2.23, "grill": 1.58, "dish": 1.21,
     "restaurant": 1.18, "barbecue": 0.79, "kim": 0.67, "food": 0.64,
     "dinner": 0.39, "restaurants": 0.32, "japanese": 0.31, "eat": 0.27,
     "hotel": 0.16, "famous": 0.11, "brand": 0.11, "##q": 0.06, "diner": 0.02}
    
  • lasagna:

    {"las": 2.87, "##ag": 2.85, "##na": 2.39, ",": 0.84, "she": 0.5,
     "species": 0.34, "hotel": 0.33, "club": 0.31, "location": 0.3,
     "festival": 0.29, "company": 0.27, "her": 0.2, "city": 0.12, 
     "genre": 0.05}
    
  • karaoke:

    {"kara": 3.04, "##oke": 2.87, "music": 1.31, "lara": 1.07, 
     "song": 1.03, "dance": 0.97, "style": 0.94, "sara": 0.81, 
     "genre": 0.75, "dress": 0.48, "dish": 0.44, "singer": 0.37, 
     "hannah": 0.36, "brand": 0.31, "who": 0.29, "culture": 0.21, 
     "she": 0.17, "mix": 0.17, "popular": 0.12, "girl": 0.12,
     "kelly": 0.08, "wedding": 0.0}
    
  • drive through:

    {"through": 2.94, "drive": 2.87, "driving": 2.34, "past": 1.75,
     "drives": 1.65, "thru": 1.44, "driven": 1.22, "enter": 0.81,
     "drove": 0.81, "pierce": 0.75, "in": 0.72, "by": 0.71, "into": 0.64,
     "travel": 0.59, "mark": 0.51, ";": 0.44, "clear": 0.41,
     "transport": 0.41, "route": 0.39, "within": 0.36, "vehicle": 0.3, 
     "via": 0.15}
    

注意,outputformat=lucene 参数导致返回令牌(关键词或部分关键词)而不是令牌的整数 ID,因为看到令牌有助于我们更好地解释结果。

当将此输出与之前为相同查询显示的 SKG 输出进行比较时,您可能会注意到以下差异:

  1. 输出内容:SKG 输出返回的是索引中的实际术语,而 SPLADE 风格的输出返回的是 LLM(大语言模型)的标记。这意味着,您可以直接使用 SKG 输出(如“lasagna”、“alfredo”、“pasta”)在文档字段上进行搜索,而 SPLADE 的标记(如 las、##ag、na##)则需要为所有文档生成并索引,以便 SPLADE 风格的查询在查询时能够匹配正确的标记。
  2. 稀疏向量的相关性:SKG 稀疏向量对于领域内的术语看起来通常更简洁、更相关(例如,餐厅评论数据集)。例如,对于查询 "bbq",SKG 返回 {"bbq": 0.9191, "ribs":0.6186, "pork":0.5991, "brisket": 0.569},而 SPLADE 返回 {'bb': 2.78, 'grill': 1.85, 'barbecue': 1.36, 'dinner': 0.91, '##q': 0.78, 'dish': 0.77, 'restaurant': 0.65, 'sport': 0.46, 'food': 0.34, ...}。SPLADE 模型相较于 SKG 模型的表现较差,主要是因为 SPLADE 没有在搜索索引中的数据上进行训练,而 SKG 则直接使用搜索索引中的数据作为语言模型。对基于 SPLADE 的模型进行微调有助于缩小这一差距。
  3. 灵活性:SKG 模型更具灵活性,因为它可以返回多个维度之间的关系。请注意,在上一部分中,我们不仅返回了相关术语的稀疏向量,还返回了查询的分类。
  4. 上下文感知:SPLADE 和 SKG 模型都是上下文感知的。SPLADE 会基于编码的查询(或文档)的整个上下文对每个标记进行加权,而 SKG 请求也可以(可选地)使用传入的查询或文档上下文来使标记的权重具有上下文性。基于 SPLADE 的模型通常在较长的已知上下文(如一般文档)中表现更好,而 SKG 模型则更适合较短的、特定领域的上下文(如领域特定查询),但它们都有效,并代表了基于稀疏向量或词汇导向的语义搜索的新技术。

我们选择在本章中使用基于 SKG 的方法,而不是 SPLADE,主要是因为它还能够对查询进行分类,并进一步为查询进行上下文化处理,以帮助查询意义消歧。然而,不论您选择哪个模型,实现基于稀疏向量的语义搜索的类似概念都适用,因此了解多种技术是很有价值的。

在下一节中,我们将演示如何将增强后的查询树转换为搜索引擎特定的查询语法,以便发送到搜索引擎。

7.4.4 转换查询以进行语义搜索

现在,用户的查询已经被解析和增强,接下来是将查询树转换为适合的、特定于搜索引擎的语法。

在这个转换阶段,我们调用一个适配器,将查询树转换为查询的最有用的引擎特定表示——在我们的默认实现中是 Solr。对于我们的语义功能(如 popularity 和 location_distance 函数),我们已经将这个特定于引擎的语法({"type":"transformed", "syntax":"solr"})直接注入到查询树中的增强节点中。我们本可以通过创建每个语义功能输出的通用中间表示来稍微抽象化这一过程,然后等到转换阶段再转换为特定引擎的语法(Solr、OpenSearch 等),但我们选择避免使用中间表示,以保持示例的简洁。如果您使用不同的引擎运行代码(如附录 B 中所述),您将在转换后的节点中看到该引擎的语法。

以下列出了一个 transform_query 函数,它接受增强后的查询树,并将每个节点转换为特定于搜索引擎的节点。

示例 7.15 将查询树转换为特定于引擎的语法
def transform_query(query_tree):
  for i, item in enumerate(query_tree):
    match item["type"]:
      case "transformed":  #1
        continue  #1
      case "skg_enriched":  #2
        enrichments = item["enrichments"]  #2
        if "term_vector" in enrichments:  #2
          query_string = enrichments["term_vector"]  #2
          if "category" in enrichments: #2
            query_string += f' +doc_type:"{enrichments["category"]}"'  #2
          transformed_query =   #2'+{!edismax v="' + escape_quotes(query_string) + '"}'  #2
        else:  #2
          continue  #2
      case "color":  #3
        transformed_query = f'+colors:"{item["canonical_form"]}"'
      case "known_item" | "event":  #3
        transformed_query = f'+name:"{item["canonical_form"]}"'
      case "city":  #3
        transformed_query = f'+city:"{str(item["canonical_form"])}"'
      case "brand":  #3
        transformed_query = f'+brand:"{item["canonical_form"]}"'
      case _:  #4
        transformed_query = "+{!edismax v="" +  #4
        ↪escape_quotes(item["surface_form"]) + ""}"  #4
    query_tree[i] = {"type": "transformed",  #5
                     "syntax": "solr",  #5
                     "query": transformed_query}  #5
  return query_tree

enriched_query_tree = enrich(reviews_collection, query_tree)
processed_query_tree = transform_query(enriched_query_tree)
display(processed_query_tree)

注释:

  • #1 如果查询树的元素已经转换为特定于搜索引擎的语法,则无需进一步处理。
  • #2 为增强节点生成增强查询。
  • #3 处理其他可能的查询树元素,并应用自定义类型处理逻辑。
  • #4 对于所有没有自定义转换逻辑的类型,仅搜索其表面形式。
  • #5 用特定于引擎的语法和查询标记每个转换后的查询树节点。

输出:

[{"type": "transformed",
  "syntax": "solr",
  "query": "+{!func v="mul(if(stars_rating,stars_rating,0),20)"}"},
 {"type": "transformed",
  "syntax": "solr",
  "query": "{!edismax v="kimchi^0.9193 korean^0.7069 banchan^0.6593
  ↪+doc_type:\"Korean\""}"},
 {"type": "transformed",
  "syntax": "solr",
  "query": "+{!geofilt d=50 sfield="location_coordinates"
  ↪pt="35.22709,-80.84313"}"}]

到目前为止,查询树中的所有节点都已转换为 {'type': 'transformed', 'syntax': engine} 节点,这意味着它们内部包含了生成最终查询所需的特定于搜索引擎的语法。我们现在准备将查询树转换为字符串,并将请求发送到搜索引擎。

7.4.5 使用语义增强查询进行搜索

我们语义搜索过程的最后一步是搜索阶段。我们将完全转换后的 query_tree 转换为查询,运行查询并将结果返回给最终用户。

示例 7.16 运行查询
def to_query(query_tree):
  return [[node["query"] for node in query_tree]]

transformed_query = to_query(query_tree)
reviews_collection = engine.get_collection("reviews")
reviews_collection.search(query=transformed_query)

对于我们查询的“top kimchi near charlotte”,搜索结果将与我们在第 7.3 节中的端到端示例中展示的结果完全一致。由于我们现在可以处理语义功能的变化(例如,“in” 与 “near” 位置,“good” 与 “popular” 或 “top” 流行度),我们将展示一个稍作修改的查询结果:good kimchi in charlotte。如果将此变体的输出与原始查询“top kimchi near charlotte”的输出进行对比(如图 7.8 所示),您会发现它们生成了相同的转换查询和最终的搜索结果,这与本章前面图 7.5 和 7.7 的结果完全一致。

image.png

恭喜你,已经实现了一个端到端的语义搜索管道,它能够语义解析、增强、转换并运行搜索。本章并没有引入任何复杂的新机器学习算法,而是提供了如何将你在本书中学到的许多模型、算法和其他技术集成到一个端到端系统中的具体实现。

在接下来的章节中,我们将继续探索更多的高级方法,这些方法可以插件化地接入这个框架,以增强相关性排序并改善查询意图理解。

总结

  • 查询解释需要适当地将查询管道与学习模型混合,同时确保在匹配未知关键词时有足够的回退模型。
  • 仅仅基于关键词的匹配有时可以工作,但当连接词表达的意图(例如 "top"、"near" 等)没有被理解时,它会导致匹配效果差。解决这一问题的一种方法是通过实现特定领域的语义功能来克服这些限制。
  • 一个语义查询解析器,能够识别你领域内已知的术语和短语,允许你从基于关键词的搜索转向对实体及其关系的语义搜索。
  • 提取已知实体能够实现模型无缝集成到查询解释管道中,利用关键词的表面形式与从学习模型生成的实体的标准表示之间的映射(信号增强、替代拼写、相关术语和其他知识图谱数据)。
  • 语义搜索涉及解析已知实体,利用学习模型进行增强,转换查询以优化匹配和提高目标搜索引擎的相关性,然后执行搜索并返回结果给最终用户。我们将在接下来的章节中继续探索可以接入这些阶段的更多高级技术。