AI-驱动的搜索指南-全-
AI 驱动的搜索指南(全)
原文:
zh.annas-archive.org/md5/b722bee7ab72bc04ba981ed4a01fe346译者:飞龙
第一部分 现代搜索相关性
搜索引擎是获取人类大部分知识的门户。网络搜索引擎提供了一个可查询的互联网缓存,允许你瞬间在任何主题上找到数十亿个网站上的信息。生成式人工智能(人工智能)在执行检索增强生成(RAG)时严重依赖搜索引擎,这是使用搜索来找到相关上下文以提供给 AI 模型的过程,以便它们为传入的提示生成准确的响应。
搜索算法还驱动着大多数数据驱动应用中的匹配和排名:从电子商务到电子邮件,再到社交媒体,以及公司内部网和私有文件系统。进行有效的搜索需要优化搜索相关性——即找到并排名给定查询中最相关结果的能力。
在本书的第一部分,我们将探讨现代搜索相关性。第一章介绍了 AI 驱动的搜索,强调了你在本书中将学习的主要主题。第二章是关于自然语言处理,提供了实现 AI 驱动的搜索所需的关键自然语言处理(NLP)概念的背景。第三章深入探讨了相关性排名,涵盖了搜索引擎和向量数据库如何找到和排名给定查询的最佳结果。最后,第四章涵盖了众包相关性,这是使用用户与搜索结果持续互动的过程来学习提高相关性排名的模型。
第一章:介绍人工智能搜索
本章涵盖
-
什么是人工智能搜索?
-
理解用户意图
-
人工智能搜索的工作原理
-
内容和行为智能
-
构建人工智能搜索引擎
搜索框已经成为大多数现代应用程序中与数据交互的默认用户界面。如果你想到你每天使用的每一个主要应用程序或网站,你可能在每次访问时做的第一件事就是输入一个查询来找到对你最相关的内容或操作。
当你没有明确搜索时,你可能会消费定制到你口味和兴趣的内容流。无论是视频推荐、购买物品、优先电子邮件、新闻文章或其他内容,你很可能仍在查看过滤或排名的结果,并有机会通过自己的查询浏览或明确过滤内容。
对于大多数人来说,“搜索引擎”这个短语会让人联想到像 Google、Bing 或 Baidu 这样的网站,这些网站通过爬取整个公共互联网来允许基于搜索的查询。然而,现实情况是,现在搜索几乎已经融入到我们每天与众多网站和应用进行数字互动的每一个环节中。
这些搜索引擎远非静态。我们看到像 OpenAI 的 ChatGPT、Anthropic 的 Claude 和 Google 的 Gemini 这样的商业技术,以及数百个其他更开放的大型语言模型(LLMs),如 Meta 的 Llama 和 Mistral 的 Mixtral,它们的源代码和模型权重已公开发布供公众使用。这些都作为世界信息的模型,可以生成对任意查询的解释和响应。这些模型正在积极集成到主要搜索引擎中,并将继续对人工智能搜索的演变产生重大影响。
虽然搜索框的预期响应可能历史性地是返回“十个蓝色链接”——一个按排名排列的文档列表,供用户进一步调查以找到他们查询的信息——但近年来对搜索技术智能水平的期望已经急剧上升。
用户今天期望搜索技术能够
-
领域感知——搜索技术应该理解每个特定用例和文档集合中的实体、术语、类别和属性,而不仅仅是使用字符串文本的通用统计数据。
-
情境化和个性化——它应该能够考虑到用户情境(位置、上次搜索、个人资料、先前互动、用户推荐和用户分类),查询情境(其他关键词、类似搜索)和领域情境(库存、业务规则、领域特定术语),以便更好地解释用户意图。
-
对话式——它应该能够用自然语言进行交互,并在学习并记住相关新信息的同时,引导用户通过多步骤的发现过程。
-
多模态—它应该能够解决由文本、语音、图像、视频或其他内容类型提出的查询,并使用这些查询在跨其他内容类型中进行搜索。
-
智能—它应该能够提供预测性自动补全,并理解用户的意思(拼写纠正、短语和属性检测、意图分类、概念搜索),以便在正确的时间提供正确的答案,并不断变得更聪明。
-
辅助—它应该超越仅仅提供链接,提供答案、摘要、解释和可用的操作。
许多这些功能都是由大型语言模型(LLMs)实现的,而其他功能则是由分析用户行为和构建特定领域的个性化配置文件、知识图谱和排名模型驱动的。
随着 LLMs 的普及,搜索界面也在不断发展,包括更多的聊天机器人和会话信息发现会话,但即使是目前最好的模型,在没有连接到实际的信息源(如搜索引擎索引)的情况下,也难以避免胡编乱造(提供错误的答案)和偏离轨道,除非连接到可靠的信息源。检索增强生成(RAG),即使用搜索引擎或向量数据库作为知识源,为 LLMs 提供准确和最新的信息作为上下文,是目前提高生成人工智能模型准确性的最可靠技术之一。
人工智能搜索的目标是利用自动机器学习技术来实现所有这些期望的功能。虽然许多组织从基本的文本搜索开始,花费多年时间尝试手动优化同义词列表、业务规则、本体、字段权重以及搜索配置的无数其他方面,但一些组织开始意识到,这个过程的大部分可以自动化。
在整本书中,你将学习到实现许多关键人工智能搜索技术,例如
-
使用 LLMs 进行查询解释、嵌入、问答和结果摘要
-
微调 LLMs 以进行搜索和问答
-
收集和使用用户信号以进行众包相关性
-
信号增强模型
-
从信号和内容中学习知识图谱
-
语义知识图谱
-
查询意图分类和查询意义消歧
-
个性化搜索和推荐
-
机器学习排名(学习排名)
-
用于隐式相关性反馈的点击模型
-
通过主动学习避免排名模型中的偏差
-
文本、图像和混合内容类型的混合搜索和多模态搜索
-
使用知识图谱和 LLMs 进行语义搜索
这本书是一个以实例为导向的指南,介绍了构建智能搜索系统最常用的机器学习算法和技术。我们不仅会讲解关键概念,还会提供可重用的代码示例,涵盖数据收集和处理技术,以及用于提供 AI 驱动的搜索能力的自我学习查询解释和相关性策略,希望很快也能包括您自己的组织!
1.1 什么是 AI 驱动的搜索?
在 2022 年 11 月之前,当 OpenAI 将 ChatGPT 作为一个可通用的算法发布给世界,让非技术用户可以通过它来解决许多问题时,“人工智能”的定义对公众来说有些模糊。人们理解它包括自动驾驶汽车、自主机器人和其他使计算机看起来具有智能的未来技术,但对许多人来说,AI 似乎更像是一个营销术语,而不是一个定义明确的术语。然而,在软件行业,多年来一直存在一个更具体的定义。
在软件开发背景下,术语人工智能通常指任何能够执行以前需要人类智能的任务的计算机程序。该程序通常包括机器学习技术,使其能够从数据中学习并随着时间的推移提高其性能。尽管如此,即使不涉及机器学习技术但能生成类似人类反馈的基于规则的系统也一直被视为“人工智能”系统。在这本书中,我们将采用这种更广泛的 AI 定义,尽管我们主要会讨论 AI 的机器学习方面。
术语搜索(或搜索引擎)同样被公众认为是像 Google 或 Bing 这样的网络搜索引擎。在软件开发中,这个术语也用来描述任何能够使用户查询和找到信息的任何技术。搜索通常至少涉及两个关键步骤——找到与查询匹配的文档(匹配)然后根据查询的相关性对这些文档进行排序(排名)。搜索还可以包括许多预处理步骤来更好地理解查询,以及后处理步骤从匹配的文档中提取答案或总结结果。搜索通常是用户找到信息的主要方式,无论是进行一般网络搜索、产品搜索、企业搜索、视频/图像搜索,还是数百种其他常见的信息查找和排名用例。它也是生成式 AI 系统快速找到更新的事实内容作为其提示上下文的主要方式。
但什么是人工智能驱动的搜索,它与传统的“搜索”有何不同?许多流行词汇,如“人工智能”、“机器学习”、“数据科学”和“深度学习”,常常被互换使用,了解它们之间的区别以及它们如何与人工智能驱动的搜索重叠是很重要的。图 1.1 展示了这些相关领域之间的重要关系。

图 1.1 人工智能驱动的搜索包括搜索和人工智能领域的交叉点上的所有技术和技巧。这些技术与数据科学、机器学习和深度学习领域重叠很大,并使用这些领域。
机器学习是人工智能的一个子集,它专注于使用数据来训练模型,根据从训练数据中学习到的见解执行任务。深度学习是机器学习的一个进一步子集,它专注于训练人工神经网络——部分模仿人类大脑结构的算法——以学习解决复杂问题。在图 1.1 中,请注意深度学习是机器学习的一个完全包含的子集,而机器学习又是人工智能的一个完全包含的子集。数据科学是一个与人工智能和搜索重叠很大的学科,但它还包含其他独特的关注领域,因此它既不是另一个学科的完全超集也不是子集。
本书关注的重点是搜索(也称为信息检索)与人工智能的交叉点,特别是将机器学习和深度学习技术应用于提高搜索结果的相关性以及自动化调整搜索相关性的过程。构建人工智能驱动的搜索涉及许多众所周知的机器学习技术,但也涉及许多特定于信息检索和搜索领域的技术。图 1.2 提供了本书将涵盖的一些关键人工智能驱动的搜索技术的分类列表,按是否为深度学习技术、不需要深度学习的其他机器学习技术或不需要机器学习的其他人工智能技术分类。

图 1.2 按是否为深度学习技术、不需要深度学习的其他机器学习技术或不需要机器学习的其他人工智能技术分类的具体人工智能驱动的搜索技术
在仅限人工智能的类别中,问答系统、虚拟助手、聊天机器人和基于规则的关联性都是通常使用机器学习构建但不需要机器学习的人工智能技术的例子。许多人基于规则构建聊天机器人来理解不同的用户话语和意图,同样,问答系统也可以仅基于规则和本体来构建。但即便如此,机器学习通常用于学习这些规则和本体,因此这些类别之间的界限往往模糊不清。
当算法开始使用数据来训练模型时,我们就进入了人工智能搜索的机器学习子类别。我们使用搜索引擎用户的(点击、点赞、加入购物车、购买等)行为信号来构建能够学习更好地对文档进行排序的模型。这可以包括信号增强模型(每个查询或类别的顶级文档)、生成推荐或个性化搜索结果的协同过滤模型,以及从内容和行为信号中学习以更好地排序结果的排序分类器(学习排序)。机器学习还用于学习知识图谱,这些图谱是实体、概念及其关系的图,可用于更好地理解领域并更好地解释用户查询。语义搜索(基于意义而非仅关键词的搜索)可以通过此类知识图谱以及传统的自然语言处理方法、查询意图分类、文档聚类和其他由用户查询、文档和用户行为信号驱动的技术来实现。
最后,在人工智能搜索的深度学习子类别中,我们看到使用神经网络来构建能够理解用户查询和文档、排序和总结搜索结果的模型。在这里,文本用于训练大型语言模型(LLMs)以理解词语和短语的含义,生成问题的答案,以及生成文档的摘要。LLMs 是一种可以解释文本内容的基础模型,通常在互联网上的大量文本上进行训练。基础模型还可以在除了文本之外的其他类型的内容上进行训练(如图像、音频、视频),以实现跨这些内容类型的多模态搜索:文本到图像搜索、文本到音频、图像到视频等。LLMs 还用于生成嵌入,这是内容的向量表示,代表内容的含义。由于搜索引擎的主要任务是找到并排序与进入查询相似的内容,这些嵌入使得在查询的意义上进行搜索的能力更加复杂,并显著提高了查询理解和排序。在特定目标或特定领域的数据集上进一步微调基础模型也将使它们在理解这些领域的细微差别或用例方面变得更好。
基础模型压缩了大量的人类知识(通常包括大部分互联网),使它们在大多数领域拥有广泛的理解。然而,这种知识压缩是有损压缩——原始数据没有存储,具体事实和概念很容易混淆。基础模型因能够对问题产生幻觉答案而闻名,这使得它们在回答事实问题时通常不可靠。因此,除了搜索引擎使用基础模型来改进查询理解和排名之外,我们还在看到它们被大量用于 RAG——其中搜索作为知识源,为生成式人工智能任务提供准确和最新的信息作为上下文。
我们将在整本书中详细介绍这些人工智能驱动的搜索技术。但首先,让我们讨论人工智能驱动的搜索的目标以及它与传统搜索的不同之处。
1.2 理解用户意图
要提供人工智能驱动的搜索,我们需要对涉及解释用户意图和返回匹配内容维度的整体理解。在信息检索领域,搜索引擎和推荐引擎是两种最流行的技术,用于提供满足用户信息需求的相关内容。许多组织认为搜索引擎和推荐引擎是不同的技术,解决不同的用例。通常,同一组织内的不同团队——通常具有不同的技能集——独立工作在不同的搜索引擎和推荐引擎上。在本节中,我们将讨论为什么将搜索和推荐分离成独立的功能和团队往往会导致不太理想的结果。
1.2.1 什么是搜索引擎?
搜索引擎通常被视为一种明确输入查询并接收响应的技术(图 1.3)。它通常通过一个文本框暴露给最终用户,用户可以在其中输入关键词或问题。结果通常以列表形式返回,并列有额外的过滤选项,这些选项可以进一步细化初始查询。使用这种机制,搜索被用作直接发现相关内容的工具。当用户完成他们的搜索会话后,他们通常可以发出新的查询,并从一张白纸开始,忽略之前搜索的上下文。

图 1.3 一个典型的搜索体验,用户输入查询并看到带有过滤选项的搜索结果,以支持进一步细化搜索结果
搜索引擎是软件工程世界中功能最全面的一种系统。大多数底层搜索引擎技术被设计成以大规模可扩展的方式运行,针对数百万、数十亿甚至数万亿的文档处理大量查询,并在数百毫秒或更短的时间内提供结果。在许多情况下,对新摄入的数据进行实时处理和近实时搜索是必需的,所有这些都必须在多个服务器上并行化以扩展并满足如此高的性能要求。
实现搜索引擎还需要大量工作来构建特定的搜索数据结构,如倒排索引或基于 ANN 的向量存储,理解线性代数和向量相似度评分,具备文本分析和自然语言处理的经验,以及了解众多特定的搜索类型的数据模型和能力(拼写检查、自动建议、细分、文本高亮、嵌入等)。
为了让搜索引擎完全理解用户意图,你结合对内容、用户和领域的彻底理解至关重要。在简要讨论了相关主题推荐引擎之后,我们将重新审视为什么这一点很重要。
1.2.2 推荐引擎提供了什么?
大多数人认为推荐引擎(或称为“推荐系统”)是不接受直接用户输入,而是根据引擎从他们那里学到的信息来提供内容,计算与他们的兴趣和行为最佳匹配的系统。这些兴趣通过用户偏好、用户行为、查看的内容等多种方式推断出来。对于推荐引擎来说,缺乏直接用户输入与搜索引擎形成鲜明对比,后者传统上被认为是一种需要明确用户驱动的查询的技术。
如果你经常访问 Amazon.com 或其他任何主要电子商务网站,你无疑熟悉那些声称“根据你对这个物品的兴趣,你可能还会喜欢……”或其他基于你的集体浏览和购买历史推荐物品列表的推荐引擎部分,如图 1.4 中的示例。这些推荐往往为公司带来显著收入,并帮助客户发现相关、个性化且相关的内容,这些内容通常补充了他们明确搜索的内容。

图 1.4 基于用户对相似物品表示兴趣的推荐
推荐算法大致可以分为三类:
-
基于内容的推荐器—这些推荐器基于物品或用户的属性进行匹配。
-
基于行为的推荐器—这些推荐器基于相似用户与相似物品之间的交互重叠进行匹配。
-
多模态推荐器—这些推荐器基于相似的内容属性和重叠的行为交互进行混合匹配。
1.2.3 搜索和推荐之间的个性化范围
搜索引擎和推荐引擎之间的关键区别在于,搜索引擎通常由用户引导,匹配用户明确输入的查询,而推荐引擎通常不接受任何直接用户输入,而是基于已知或推断的知识推荐用户可能想要看到的内容。
但这两个系统实际上是同一枚硬币的两面,将它们视为独立的系统会创造一个虚假的二分法。在两种情况下,目标都是理解用户在寻找什么,并交付相关结果以满足用户的信息需求。在搜索和推荐系统之间存在着广泛个性化的能力范围。
假设你在尝试为你的最终用户寻找内容时,既有明确的查询又有用户特定的个性化资料可用,你可以做以下任何一项:
-
传统关键词搜索—忽略个人资料,仅使用明确输入。
-
个性化搜索—隐式使用个人资料,并结合其他明确用户输入。
-
用户引导推荐—明确使用个人资料,并赋予用户调整它的能力。
-
传统推荐—明确使用个人资料,但用户无法调整。
图 1.5 展示了这一个性化范围。

图 1.5 个性化范围,展示了传统关键词搜索和传统推荐作为更大连续体两端。
尽管个性化范围的两端代表了极端,但它们也是两种最常见的方法。不幸的是,我们在许多组织中看到的一个最大的错误是围绕搜索和推荐是独立问题的信念构建团队。这通常导致数据科学团队构建复杂的个性化分段模型,这些模型只能进行推荐而不能进行搜索,以及工程团队构建大规模关键词匹配引擎,这些引擎难以利用推荐团队构建的强大模型。
更多的时候,推荐团队由具有最少信息检索背景的数据科学家组成,而搜索团队通常由具有最少数据科学背景的工程师组成。由于康威定律(“设计系统的组织……受限于产生的设计是这些组织沟通结构的复制品”),这最终导致在个性化范围(尤其是中间部分)解决问题的挑战,需要两个团队的最佳表现。在这本书中,我们专注于共享技术,通过统一方法使搜索变得更智能,使推荐变得更灵活。AI 驱动的搜索平台需要能够从您的用户和内容中持续学习,并使您的用户能够引导结果,以便它们持续改进。
1.2.4 语义搜索和知识图谱
我们在图 1.5 中将搜索和推荐作为个性化范围呈现,个性化搜索和用户引导推荐位于其中,但还有一个对于构建良好的 AI 驱动搜索系统至关重要的维度——对给定领域的深入理解。仅仅匹配关键词和根据用户如何集体互动文档来推荐内容是不够的。引擎还必须尽可能多地了解该领域。这包括
-
学习所有重要的特定领域短语、同义词和相关术语
-
在文档和查询中识别实体
-
生成一个关联这些实体的知识图谱
-
消除由特定领域术语表示的许多细微含义的歧义
-
能够有效地解析、解释和概念上匹配您领域内用户细微的意图。
图 1.6 展示了查询语义解析的一个示例,目标是搜索“事物”(已知实体)而不是“字符串”(仅仅是文本匹配)。

图 1.6 展示了查询的语义解析,展示了理解由查询术语表示的实体(“事物”)
为了使他们的搜索更智能,许多公司花费大量资金雇佣大型团队手动创建词典和知识图谱,以识别用户查询中实体之间的关系。本书侧重于一种更可扩展的方法:构建一个能够自动持续学习这些关系的 AI 驱动搜索引擎。我们还深入探讨了语义搜索的附加技术,包括在嵌入上进行密集向量搜索和使用 LLMs 进行生成搜索。
1.2.5 理解用户意图的维度
我们讨论了传统关键词搜索、推荐和个性化范围之间的重要作用。我们还讨论了需要语义搜索以提供对您的内容和用户查询的特定领域理解的需求。所有这些都是实现一个更大目标的关键支柱:完全理解用户意图。图 1.7 展示了这些用户意图关键支柱之间的相互作用。
图 1.7 中左上角的圆圈代表内容理解——基于关键词、语言模式和已知属性匹配找到正确内容的能力。右上角的圆圈代表用户理解——理解每个用户的特定偏好,并使用这些偏好返回更个性化的结果。最后,下方的圆圈代表领域理解——在您自己的特定领域背景下解释单词、短语、概念、实体以及这些实体之间的细微解释和关系的能力。

图 1.7 用户意图的维度:内容理解、用户理解和领域理解的组合
仅在内容理解圈中的查询代表传统的关键词搜索,它能够基于关键词进行匹配,但不使用任何领域或用户特定的上下文。仅在用户理解圈中的查询将是基于协同过滤的推荐,用户无法覆盖输入,也不理解底层文档的领域或内容。仅在领域理解圈中的查询可能是对已知标签、类别或实体的结构化查询,甚至是一个类似浏览器的界面,允许探索这些特定领域实体的知识图谱及其关系,但没有任何针对用户的个性化或找到任意术语、短语和内容的能力。
当传统关键词搜索和推荐重叠时,我们得到个性化搜索或引导推荐。当传统关键词搜索和知识图谱重叠时,我们得到语义搜索:一种智能的、特定领域的搜索体验。最后,当推荐和知识图谱重叠时,我们得到更智能的领域感知推荐,它可以在类似文档的众包用户交互以及这些文档的特定领域理解的重要属性上进行匹配。
人工智能搜索的圣杯是利用所有三个类别的交集:语义搜索、个性化搜索和领域感知推荐。也就是说,要真正理解用户意图,我们需要以下所有内容:
-
对用户搜索领域的专家理解
-
对用户及其偏好的专家理解
-
能够匹配和排序任意查询与任何内容的专家能力
人工智能搜索始于用户意图(内容、领域和用户)的三个支柱,然后采用智能算法在这些领域不断学习和改进。这种学习包括自动学习排名标准、自动学习用户偏好以及自动学习代表领域的知识图谱和语言模型等技术。最终,这三个方法的平衡组合是理解用户及其查询意图的关键,这也是我们人工智能搜索系统的最终目标。
1.3 人工智能搜索是如何工作的?
我们明确了我们的最终目标,即通过内容理解、用户理解和领域理解来匹配用户意图。在建立这个背景之后,让我们以对实际所需组件的概述来结束本章,这些组件用于提供人工智能搜索平台。搜索智能通常随着时间的推移,以可预测的迭代方式成熟,如图 1.8 所示。基本关键词搜索是组织的一个典型起点。一旦投入生产,他们意识到他们的搜索相关性需要改进,于是开始手动调整字段权重、提升、文本和语言分析,并引入额外的功能和功能。

图 1.8 典型的搜索智能进步,从基本关键词搜索到完整的自我学习搜索平台
最终,他们意识到他们需要将领域理解注入到他们的搜索能力中,此时组织开始投资于同义词列表、分类法、已知实体列表以及特定领域的业务规则。虽然这些都很有帮助,但组织最终也发现,相关的搜索在很大程度上取决于成功解释用户查询和理解用户意图,因此他们开始投资于查询分类、语义查询解析、知识图谱、个性化以及其他正确解释用户查询的尝试。
由于这些任务会产生改进,这种成功通常会导致创建大型团队投入大量时间手动调整列表和参数,最终组织可能会意识到,通过从用户信号、用户测试(A/B 测试、离线相关性模拟和主动学习)以及构建机器学习相关性模型中学习,可以尽可能自动化这一过程。最终目标是完全自动化搜索智能进步过程中的每个步骤,并使引擎能够自我学习。
1.3.1 核心搜索基础
构建搜索平台的第一步几乎总是要使传统的关键词搜索工作(图 1.7 中的“内容理解”部分)。团队通常会花费数年时间调整和改进这一步骤,一个名为相关性工程的整个学科也因此产生,它历史上一直致力于理解内容;改进搜索中的内容;调整提升、查询参数和查询函数;以及其他尝试最大化传统搜索体验的相关性。对于深入了解这个相关性工程和调整传统关键词搜索相关性的世界,我们推荐道格·特恩布尔和约翰·贝里曼(Manning,2016 年)所著的《相关搜索》一书。
随着相关性工程师变得更加复杂,他们的工作往往进入用户理解、推荐以及领域理解和语义搜索的领域。近年来,大型语言模型的出现使得实现即插即用的语义搜索变得容易,但要达到优化相关性和匹配的下一级水平,需要更加复杂的方法,正如你将在本书中学到的。在《人工智能驱动的搜索》中,我们的重点是自动化学习和优化搜索相关性的过程,使其作为一个连续的反馈循环运行。我们本质上希望自动化相关性工程师的大部分工作,尽可能依赖算法来不断地学习最优匹配和排名策略。
那么,什么特征区分了一个调校良好的搜索引擎和一个人工智能驱动的搜索引擎?一个调校良好的搜索引擎是人工智能驱动的搜索的基础,但人工智能驱动的搜索远远超出了这一点,它通过反映智能不断地学习和改进。反映智能的概念是利用用户输入、内容更新和用户与内容的交互的持续反馈循环,以不断地学习和改进搜索应用的质量。
1.3.2 通过反馈循环反映的智能
反馈循环对于构建人工智能驱动的搜索解决方案至关重要。想象一下,如果你的整个教育(从小学到最高学位)都只是阅读教科书:没有老师提问,没有考试来测试你的知识并提供反馈,也没有同学或其他人与你互动、学习或合作。你可能会在无法完全理解某些概念甚至不理解你所阅读的内容的地方遇到无尽的障碍,你可能会错误地理解许多想法,并且从未有机会意识到这一点或调整你的假设。
搜索引擎通常以这种方式运行。聪明的工程师将数据推送到搜索引擎,并调整某些特性和特征权重,但引擎只是读取这些配置并以相同的方式对每次重复的用户查询做出反应。然而,当我们引入反馈循环时,搜索引擎是交互式学习的完美系统。
图 1.9 显示了搜索反馈循环中信息流动的典型流程。首先,用户提出一个查询。这个查询执行搜索,返回结果,如特定答案、答案列表或指向页面的链接列表,给最终用户。一旦呈现列表,用户就会采取一个或多个行动。这些行动通常从点击文档开始,但这些点击最终可能导致将商品添加到购物车并购买(电子商务)、对商品点赞或踩(媒体消费网站)、喜欢或评论结果(社交媒体网站),或任何其他上下文特定的行动。

图 1.9 反馈循环中的反射智能
这些行动可以用来生成改进的未来搜索的相关性排名模型。您的搜索应用可以自动调整未来搜索结果的排名,为下一个用户的搜索提供改进的搜索体验。
1.3.3 信号增强、协同过滤和排序学习
您的搜索应用中的搜索、点击、点赞、添加到购物车、购买、评论以及其他与您的搜索应用的互动都是您需要捕获的关键数据。我们把这些数据点统称为信号。信号为您的搜索应用提供持续的反馈流,记录与最终用户每次有意义的互动。这些数字时刻可以由机器学习算法用来生成模型,以驱动用户理解、内容理解和领域理解。
图 1.10 显示了典型 AI 驱动搜索应用中信号收集和处理的流程。你可以看到每个搜索都会收集信号,以及产生的点击和购买。还可以记录任何其他类型的用户交互的独有信号(如添加到购物车、分类点击、书签、悬停或甚至页面停留时间)。

图 1.10 信号收集和处理数据流
信号是驱动 AI 驱动搜索应用智能引擎的两个数据源之一,另一个是内容。许多 AI 驱动搜索算法结合信号反馈循环来构建反射智能模型。这些关键类型的反射智能算法包括
-
流行相关性——信号增强算法创建使用聚合信号来提升您最常用查询的最重要文档排名的模型。
-
个性化相关性——协同过滤算法使用矩阵分解或类似技术创建模型,利用信号生成推荐和用户配置文件,以个性化每个用户的搜索结果。
-
广义相关性——学习排名算法训练排名分类器,根据基于用户点击模型的用户信号生成的相关性判断进行机器学习排名。这个过程学习到一组特征和排名权重,可以普遍应用于所有查询——即使是之前未见过的查询。
这些算法使您的搜索应用能够从用户交互中学习,并自动调整未来搜索结果的排名,为下一批用户的搜索提供改进的搜索体验。
1.3.4 内容和领域智能
虽然信号为您的搜索应用提供了一连串的使用和反馈数据,但您的文档也是一个丰富的信息来源,可以纳入反馈循环中。例如,如果有人搜索特定的关键词,返回的文档中的其他关键词和顶级类别可以作为有价值的数据点。这些数据点可以用来标记或分类查询,并可以展示给其他最终用户(例如,作为维度),从而产生进一步的交互,这些交互会生成信号,搜索引擎可以从中学到东西。
您文档的内容构成了您领域的一个代表性的文本模型。实体、领域特定术语以及您文档中包含的句子构成了一个丰富、语义的图。该图可以被用来驱动强大的概念和语义搜索,更好地理解您的领域。我们将在第二章中更深入地了解您的内容,在第五章中深入探讨使用这个丰富的语义知识图谱(SKG)的语义搜索能力。
近年来,大型语言模型(LLMs)彻底改变了搜索引擎如何解释查询和响应的方式。LLMs 是在大量文本数据上训练的深度神经网络。它们可以根据接收到的提示和任何额外的上下文识别、翻译、总结、预测和生成新数据。通常,LLMs 在文本上训练,以文本形式接收提示,并以文本形式返回响应,尽管类似的多元模型也可以在图像、音频、其他数据或所有上述数据上训练。LLMs 在神经网络中通常包含数十亿个参数,并且只要模型性能随着参数的增加而持续提高,这个数字很可能会继续增长。
今天最成功的语言模型是基于 2017 年由谷歌研究人员提出的变换器架构,该架构将“注意力”的概念应用于语言学习(“Attention is All You Need”,Ashish Vaswani 等人)。大量的文本数据被输入到神经网络中,使用无监督学习对单词及其在每个上下文中的关系进行建模。一旦模型构建完成,它就能够将输入的文本字符串,即提示,解释为上下文,并将上下文编码到嵌入中,嵌入是提示意义的数值向量表示。除了能够将提示编码到嵌入中,变换器还包含一个解码层,可以将嵌入转换回文本。变换器可以用于解决许多类型的问题,从嵌入上的相似性搜索(文本搜索、图像搜索等),到问答,到分类,到内容摘要,甚至到生成新内容(写作、代码、诗歌、图像等)。
变换器是上下文敏感的。一个针对问答任务调整过的语言模型可能会对提示“首都和州首府的区别是什么?”的回答是:“首都是一个城市或城镇,作为州或国家的政府所在地。州首府是一个州立法机构所在的建筑。”然而,同一个语言模型在回答“大写字母和小写字母的区别是什么?”这个问题时,可能会给出以下基于上下文的回答:“大写字母和小写字母的区别在于,大写字母用于句子的开头或专有名词,而小写字母用于单词中的其他字母。”
许多语言模型是开源的,但为了获得最佳输出质量,语言模型需要针对特定任务进行微调,使用特定领域的内容和提示。微调是指使用已经对语言和一般概念有强大理解能力的预训练模型,并“教授”它关于新内容和任务的知识。原始预训练模型通常被称为基础模型,因为它们构成了特定领域微调的基础。微调的过程通常只需要训练原始语言模型所需时间的一小部分。一些语言模型在大量数据和广泛多样的数据(如互联网的全面爬取)上进行了训练,因此它们在无需重新训练的情况下可以表现得相当好,但针对特定任务进行重新训练几乎总是可以提高性能。
1.3.5 生成式 AI 和检索增强生成
生成式 AI 正在以极快的速度发展,搜索引擎既从中受益,又作为生成式 AI 系统的一个关键组件。LLMs(以及其他基础模型)作为推理引擎,拥有足够的世界知识来解释语言,并且通常可以就大多数概念进行推理,但它们没有可靠回忆事实信息的能力,而不会出现幻觉(编造虚假信息)的风险。
因此,搜索引擎在检索增强生成(RAG)管道中用作 LLMs 的知识源,允许检索相关上下文并将其传递给 LLMs,以确保它有最新和准确的数据来回答问题。整本书实际上都是关于使用 AI 来优化 RAG 中的“检索”部分,我们将在第十五章中介绍“生成”部分。
虽然 RAG 使搜索引擎成为生成式 AI 系统的一个关键组件,但 LLMs 也作为搜索引擎的关键组件。LLMs 可以用来解释查询,为向量搜索生成嵌入,生成搜索结果的摘要,甚至可以直接从搜索结果中生成问题的答案。
从传统的信息检索过渡到这些新的生成式搜索能力在图 1.11 中展示。几十年来,传统的搜索返回一系列搜索结果(“十个蓝色链接”),显示与查询最相关的顶级文档。对于实体和知名主题的查询,搜索引擎通常会显示预先计算的包含总结信息的信息框或显示对已知问题的预定答案。搜索引擎通常还会从搜索结果中提取单词、句子或段落片段来回答问题,而不是强迫用户打开和阅读搜索结果来找到答案。这个过程被称为抽取式问答,它是一种更具有针对性的搜索形式,因为它还会在文档中搜索和排名找到的答案。

图 1.11 从传统信息检索到生成式搜索的过渡
然而,从搜索结果中提取答案和综合新内容以返回结果之间存在一条细线,这就是我们过渡到生成式搜索领域的时刻。结果摘要是将搜索结果重写为更简洁、更易读的格式的过程,通常结合来自多个来源的信息,甚至在摘要响应中为来源提供引用。抽象式问答是通过综合一个或多个排名搜索结果的信息来生成问题的答案的过程。与提取式问答相比,抽象式问答是从文档中找到相关内容作为答案(“提取它”),而抽象式问答通过解释结果并生成一个可能不同于任何文档中写的内容的答案来编写综合响应。在生成式搜索体验中,新内容生成也是可能的,例如,根据用户提交的关键词或提示,以创意新散文、代码、诗歌、图像或其他内容来响应查询。
总结来说,生成式 AI 和 AI 驱动的搜索紧密相连。生成式 AI 是“AI 驱动的搜索”(提供答案生成和结果摘要)的一个关键组件,而 AI 驱动的搜索是“搜索驱动的 AI”(RAG)的一个关键组件。两者都大量使用 LLMs 和其他基础模型,并且都是智能和准确 AI 系统的关键组件。
1.3.6 精选 AI 与黑盒 AI
类似于 LLMs,许多现代 AI 技术严重依赖于基于人工神经网络的深度学习。不幸的是,由于学习模型的内部复杂性,人类往往难以理解任何特定预测或深度学习模型输出的具体因素。
这有时会导致一个“黑盒 AI”系统,其中结果可能是正确或令人印象深刻的,但当模型做出错误判断时,它们并不容易调试或纠正。为了能够理解、整理和信任这些模型,一个名为可解释 AI(有时称为可解释 AI或透明 AI)的整个领域应运而生。
在这本书中,我们将介绍搜索的深度学习方法,例如在嵌入上进行密集向量搜索、问答、使用大型语言模型生成合成训练数据以及使用 LLMs 进行结果摘要。然而,我们主要将精力集中在创建可以用人类术语表达并随后由人类智能进行纠正和增强的智能。你可以将其视为“AI 辅助的人类编辑”,或者“人类辅助的 AI”,但无论如何,本书的总体哲学是使用 AI 自动化搜索智能的过程,同时保持人类在循环中,具有控制能力,并能够增强或覆盖系统。
作为学习练习,这种方法也导致了对搜索排名和相关性工作原理的更深入、直观的理解,以及如何在不放弃系统控制权的情况下集成许多不同的 AI 驱动方法。
1.3.7 AI 驱动搜索引擎的架构
AI 驱动搜索引擎的架构通常需要组装许多构建块以形成一个智能端到端系统。你从一个核心搜索引擎开始,如 Apache Solr、OpenSearch 或附录 B 中确定的其它搜索引擎或向量数据库。然后,将你的可搜索内容输入到引擎中,运行各种转换使其更有用。这些索引时间转换可能包括以下变化:
-
使用大型语言模型将你文档的意义转换为嵌入
-
对文档进行分类,将分类作为字段添加
-
标准化字段值
-
从文本中提取实体,将实体添加到单独的字段中
-
对内容进行聚类,将聚类作为字段添加
-
识别和注释短语
-
从知识图谱、外部 API 或其他数据源中拉取额外数据
-
执行词性(POS)检测和其他自然语言处理步骤
-
提取事实(如 RDF 三元组)
-
应用其他机器学习模型或 ETL 规则来丰富文档
一旦数据进入引擎,你的目标就是使其可供搜索。这需要查询管道,它可以解析传入的查询;识别概念、短语和实体;纠正拼写错误;扩展查询以包括相关术语、同义词、概念或嵌入表示;然后重写查询,以便你的核心引擎可以找到最相关的结果。然后,可以返回单个搜索文档给最终用户,也可以从语言模型生成结果摘要,或者明确地从结果中提取答案。
然而,许多查询智能需要你对领域有稳健的理解。这需要在你内容和用户信号上运行批量作业,以学习模式和推导出特定领域的智能。用户最常见的拼写错误是什么,他们在多个候选者中选择哪个作为正确的拼写?当用户搜索特定查询时,哪些文档应该被提升为最流行的?对于未知查询,所有可用于匹配的属性或特征的理想排名是什么?
我们需要在查询时(无论是预先计算的还是快速可计算的)获取这些答案,因为我们期望查询在毫秒到秒内返回。这需要一个作业处理框架(本书中使用 Apache Spark)和一个工作流程调度机制,以保持作业按顺序运行。
你还需要一个机制来收集不断流入的用户信号(在前端应用程序中捕获它们,然后存储在您的搜索引擎或其他后端数据存储中)。
这些信号将被用来生成各种模型——从增强最流行项目的信号增强模型,到学习排名模型,该模型将可推广的排名函数应用于所有查询,再到输出针对每个用户或用户群体的特定推荐和个性化偏好的个性化模型。
由人工智能驱动的搜索远不止是使用最新的 LLM 来解释查询。它关乎构建一个端到端系统以实现持续学习。最终,你将拥有一个系统,该系统接收不断变化的文档流和用户信号,持续处理这些流以改进模型,然后不断调整未来的搜索结果并衡量变化的影响,以便提供更智能的结果。这就是人工智能驱动搜索的关键:基于真实用户交互、更新内容模式和演变模型,以最佳方式理解当前用户意图并提供不断改进的搜索体验。
摘要
-
随着大型语言模型(LLMs)的兴起,对搜索复杂度的期望正在演变,最终用户现在期望搜索能够具备领域感知、情境化、个性化、对话式、多模态、智能和辅助功能。
-
搜索和推荐是信息检索中个性化连续光谱的两个极端,考虑两者之间的机会以优化相关性非常重要。
-
正确理解用户意图需要同时理解你的内容、你的用户及其偏好,以及你的平台运营的知识领域。
-
最佳搜索相关性位于个性化搜索(传统关键词搜索加上协同推荐)、语义搜索(传统关键词搜索加上知识图谱)和领域感知推荐(协同推荐加上知识图谱)的交汇处。
-
人工智能驱动的搜索在两种关键类型的数据上运行并从中学习:内容和用户信号。
-
搜索和生成式人工智能相辅相成。生成式搜索能力,如 RAG,是现代生成式人工智能系统(防止幻觉)的关键组成部分;而生成式人工智能能力,如结果摘要,是现代搜索引擎(提供更好的答案)的关键组成部分。
-
反射式智能——利用反馈循环持续收集信号、调整结果和衡量改进——是使人工智能驱动的搜索能够学习和不断改进的引擎。
第二章:与自然语言一起工作
本章涵盖
-
非结构化数据中的隐藏结构
-
以搜索为中心的语言哲学
-
探索分布语义和基于向量的嵌入
-
建模特定领域的知识
-
自然语言和查询的挑战
-
将自然语言理解技术应用于内容和信号
在第一章中,我们提供了一个关于构建人工智能搜索系统的高层次概述。在本书的其余部分,我们将探索并展示您的搜索应用如何从您的内容和用户的行为信号中持续学习,以更好地理解您的内容、用户和领域,并最终为用户提供他们需要的答案。在第三章中,我们将更加深入地实践,启动您选择的搜索服务器和数据处理层(Apache Spark),并从我们的第一个 Jupyter 笔记本开始,我们将使用它来逐步演示许多示例。
然而,在我们深入探讨那些实际案例和具体实现之前,在本章中我们首先建立一个共享的思维方式来解决问题是非常重要的。具体来说,当涉及到智能搜索时,我们必须处理自然语言中的许多复杂性和细微差别——无论是我们在搜索的内容,还是用户的搜索查询。我们必须处理关键词、实体、概念、拼写错误、同义词、首字母缩略词、缩写词、模糊术语、概念之间的显性和隐含关系、通常在分类学中找到的层次关系、通常在本体中找到的高级关系,以及通常在综合知识图中找到的实体关系的具体实例。
虽然可能很诱人立即深入研究一些具体问题,比如如何从内容中自动学习拼写错误,或者如何从用户搜索会话中挖掘同义词,但首先建立一个概念基础来解释我们在搜索和自然语言理解(NLU)中必须处理的问题类型将更为谨慎。建立这个哲学基础将使我们能够在我们的 AI 搜索系统中构建更好的端到端解决方案,其中所有部分以协调和整合的方式协同工作。因此,本章将为我们在本书中处理自然语言理解问题提供哲学基础,以及我们如何将这些解决方案应用到使我们的搜索应用更加智能。
我们将首先讨论一些关于自由文本和其他非结构化数据源性质的常见误解。
2.1 非结构化数据的神话
“非结构化数据”这个术语多年来一直被用来描述文本数据,因为它看起来没有以可以轻松解释和查询的方式格式化。然而,广泛持有的观点认为文本,或任何不符合预定义模式(“结构”)的其他数据实际上是“非结构化的”,但这是一种在本节中我们将花时间重新考虑的神话。
如果你查阅维基百科中的“非结构化数据”,它被定义为“既没有预定义的数据模型,也没有以预定义方式组织的信息”。条目接着说,“非结构化信息通常是文本密集型的,但可能包含日期、数字和事实等数据”。
然而,“非结构化数据”这个短语并不是描述文本内容的良好术语。实际上,文本中出现的术语和短语编码了大量的意义,应用于文本以赋予其意义的语言规则本身也构成了其结构。称文本为非结构化有点像称广播电台播放的歌曲为“任意的音频波”。尽管每首歌曲都有独特的特征,但大多数都表现出共同的属性(节奏、旋律、和声、歌词等)。尽管这些属性可能不同或在某些歌曲中缺失,但它们仍然符合共同的期望,使得意义可以通过和从每首歌曲中传达和提取。文本信息通常遵循类似的规则——句子结构、语法、标点符号、词性之间的相互作用等。图 2.1 展示了我们将要更深入探讨的文本示例,作为我们研究这一结构的下一节内容。

图 2.1 非结构化数据。这段文本代表了在搜索引擎中可能找到的典型非结构化数据。
虽然文本是最常见的非结构化数据类型,但还有几种其他类型的非结构化数据具有相似的特征,我们将在下一节中看到。
2.1.1 非结构化数据的类型
自由文本内容被认为是非结构化数据的主要类型,但搜索引擎也通常索引许多其他类似地不适合整齐地放入结构化数据库的数据。常见的例子包括图像、音频、视频和事件日志。图 2.2 在图 2.1 的文本示例基础上进行了扩展,并包括了几种其他类型的非结构化数据,如音频、图像和视频。

图 2.2 多种类型的非结构化数据。除了上一图中的文本外,我们现在还看到了图像、音频和视频,这些都是非结构化数据的其他形式。
音频与文本内容最为相似,因为它通常只是编码单词和句子的另一种方式。当然,音频可以包含比仅说话的词语更多——它可以包含音乐和非语言声音,并且它可以更有效地编码细微差别,如情感、语调和同时重叠的交流。
图像是另一种非结构化数据。正如文字形成句子和段落来表达思想一样,图像形成由颜色组成的网格,这些颜色组合在一起形成图片。
因此,视频作为另一种非结构化数据,它是由多个图像随时间组合而成的,以及与图像进展同步的可选音频。
当非结构化数据与结构化数据混合在一起时,我们通常将其称为半结构化数据。日志数据是这种半结构化数据的一个很好的例子。通常,日志消息包含结构化的事件日期、结构化的事件类型(如警告与错误或搜索与点击),然后是一些非结构化的消息或描述文本。
从技术上讲,几乎任何类型的文件都可以被认为是非结构化数据,但我们将主要处理上述类型。搜索引擎通常负责处理每种类型的非结构化数据,因此我们将在整本书中讨论处理它们的策略。
2.1.2 传统结构化数据库中的数据类型
为了更好地处理我们的非结构化数据,首先将其与 SQL 数据库中的结构化数据进行对比可能是有用的。这将使我们能够后来在查询非结构化数据表示与结构化数据表示之间建立平行关系。
SQL 数据库中的一个记录(行)被分割成列,每一列都可以是特定数据类型。其中一些数据类型表示离散值——来自枚举列表的值,如 ID、名称或文本属性。其他列可能持有连续值,如日期/时间范围、数字以及其他表示没有有限可能值的范围的列类型。
一般而言,当一个人想要将不同的行关联起来,或者将它们与其他数据库表中的行关联起来时,会在离散值上执行“连接”操作。连接使用共享值(通常是 ID 字段)将两个或多个记录链接在一起,形成一个组合记录。
例如,如果有人有两个数据表,一个代表员工,另一个代表公司,那么在“公司”表中可能有一个id列,在“员工”表中有一个相应的company_id列。员工表中的company_id字段被称为外键,它是一个表中的值,它引用另一个表中的实体,基于共享标识符将记录链接在一起。图 2.3 展示了这一点,展示了离散值、连续值以及使用外键在表之间进行连接的示例。
基于已知关系(键和外键)将不同记录组合在一起的概念,是处理关系数据的一种强大方式,这种处理方式是明确跨视频数据进行的。

图 2.3 典型数据库中的结构化数据。离散值代表标识符和枚举值,连续值代表落在范围内的数据,当相同值存在于两个表之间时,外键存在,因此可以用作创建每个表对应行之间关系的共享属性。
模型表,但正如我们将在下一节中看到的,非常类似的技术也可以应用于自由形式的非结构化数据。
2.1.3 非结构化数据中的连接、模糊连接和实体解析
而在数据库中的结构化数据已经是易于查询的形式,现实是未结构化数据受结构缺乏的影响较小,而更多地是由于大量信息被包装在一个非常灵活的结构中。在本节中,我们将通过一个具体的例子来揭示未结构化数据中的这种隐藏结构,并展示它如何以类似的方式用于在文档之间找到和连接关系。
非结构化数据中的外键
我们已经讨论了如何使用外键根据两个记录之间的共享标识符在数据库中将两行连接起来。在本节中,我们将展示如何使用文本数据实现相同的目标。
例如,我们可以轻松地将 SQL 表中使用的外键的概念映射到我们在图 2.2 中探索的非结构化信息。注意图 2.4 中,两个不同的文本部分都包含单词“Haystack”,它指的是一个专注于搜索相关性的技术会议。

图 2.4 非结构化数据中的外键。在这个例子中,相同的术语被用来连接两个相关的文本文档。
第一个实例表示一个正在进行的会议,而第二个文本块包含关于该事件的一般信息。为了我们的示例,让我们假设每个信息块(文本块、图像、视频和音频剪辑)在我们的搜索引擎中都被表示为一个单独的文档。在数据库表中,有两个行各包含一个值为“Haystack”的列与我们的搜索引擎中有两个包含值为“Haystack”的单独文档之间,在功能上几乎没有区别。在两种情况下,我们可以将这些文档视为通过外键相关联。
非结构化数据中的模糊外键
然而,与传统的结构化数据建模相比,我们在非结构化数据方面拥有更多的能力。例如,在图 2.5 中,注意现在有两个文档被链接,并且它们都提到了本书的首席作者——一个使用我的全名“Trey Grainger”,另一个简单地使用我的名字“Trey”。

图 2.5 模糊外键。在这个例子中,使用不同的术语引用了相同的实体,并且基于多个短语解析为同一实体而进行连接。
这是一个实体识别的例子,其中实体有两种不同的表示形式,但它们仍然可以被解析为相同的意义,因此仍然可以用来在两个文档之间连接信息。你可以将其视为“模糊的外键”,因为它仍然是一个外键,但它不是在严格的标记匹配意义上,因为它需要额外的自然语言处理和实体识别技术来解析。
一旦我们打开了这扇门,进入了高级文本处理以进行实体识别,我们就能从我们的非结构化信息中学到更多。例如,在这些文档中,“Trey”和“Trey Grainger”这两个名字不仅指代同一个实体,"he"和"his"这两个词也是如此。
你还会注意到,一张我(位于左下角,以防你不知道我长什么样)的照片以及包含我名字的视频都被识别为相关内容,并重新与文本引用连接起来。我们正依赖于所有这些非结构化数据中存在的隐藏结构来理解意义,将文档联系起来,并更多地了解这些文档中引用的每个实体。
处理模糊术语
到目前为止,一切顺利,但在现实世界的内容中,并不总是合适的假设多个地方出现的相同术语具有相同的意义,或者我们的实体识别总是正确地解析实体。这种相同拼写但具有多种潜在意义的问题被称为多义性,在搜索应用中处理这些模糊术语可能是一个大问题。
你可能已经注意到在前面图例右上角有一个奇怪的照片,看起来有点格格不入。这张照片是一个相当可怕的人拿着大刀。显然,如果你去 Google 搜索Trey Grainger,这张照片就会返回。如果你进一步挖掘,你会在图 2.6 中看到有一个名为“Trey Grainger”的 x.com(前身为 Twitter)用户,这张照片是他的个人头像。

图 2.6 多义性。这张图片显示了搜索Trey Grainger的 Google 搜索结果。返回了多张不同人的照片,因为这些人的名字拼写相同,使得短语“Trey Grainger”变得模糊。
这张照片显然是罗伯特·肖(在 1975 年的电影《大白鲨》中扮演昆特)的,但绝对不是你希望人们在搜索你时首先看到的那种类型!
这里有两个关键教训可以吸取。首先,也许永远不要搜索自己的名字(你可能会对找到的东西感到震惊!)。其次,更严肃地说,多义性是搜索和自然语言理解中的一个主要问题。我们不能假设一个术语在不同的上下文中只有一个意义,甚至是一个一致的意义,因此我们的基于人工智能的搜索引擎需要使用上下文来区分这些不同的意义。
非结构化数据作为一个关系的大图
在前面的章节中,我们了解到非结构化数据不仅包含丰富的信息(实体及其关系),而且通过在共享实体上连接不同文档,可以关联不同的文档,这与传统数据库中外键的工作方式类似。然而,典型的非结构化数据包含如此多的这些关系,因此,与其从行和列的角度思考,不如将数据集合视为一个巨大的关系图,正如我们将在本节中探讨的那样。
到目前为止,应该已经很清楚,非结构化数据中隐藏的结构比大多数人所欣赏的要更多。非结构化信息实际上更像“超结构化”信息——它是一个包含比典型“结构化数据”更多结构的图。
图 2.7 展示了即使在我们的例子中只有少量文档的情况下也存在的关系巨大图。你可以看到名称、日期、事件、地点、人物、公司和其他实体,你可以通过在文档间实体之间的连接来推断它们之间的关系。你还会注意到,图像已经被正确地消歧,因此砍刀男子现在与图无关。如果所有这些都可以从仅仅几份文档中学习到,那么想象一下从你搜索引擎中的成千上万、数百万或数十亿份文档中可以学习到什么。

图 2.7 关系巨大图。即使是从一小部分相关文档中也能出现丰富的关系图。
人工智能搜索平台的一部分价值在于能够从你的数据中学习这样的见解。问题是,你如何使用这个巨大的语义知识图来驱动这种智能?
使用文本数据中的图的最强大方式之一是通过大型语言模型(LLM),例如在 1.3.4 节中介绍的 Transformer 模型。这些模型通过深度学习在庞大的数据集上学习数十亿个参数,例如对大部分互联网的爬取,以构建对语言的详细理解。这种理解包括不同语境中单词的含义以及单词之间的语言和概念联系。这些模型内部表示所有训练数据中找到的巨大关系图,这通常比你的数据集更通用,因此模型必须经过微调来从你的数据中学习任何特定领域的关联。这种微调的需求可能会由于 LLM 某种程度上是一个黑盒而带来一些挑战,因为它们否则不会最优地表示你的数据集,它们返回的信息可能是错误的。
幸运的是,你的搜索引擎中倒排索引的固有结构使得在没有额外显式数据建模要求的情况下,轻松遍历数据中的大型关系图。倒排索引是用于词汇搜索的主要数据结构,将你的文档字段中的每个关键词或术语映射到包含这些关键词的所有文档的列表(称为倒排列表)。倒排索引使得对包含任何给定术语(或术语序列,当考虑位置匹配和通过集合操作实现的布尔逻辑时)的文档集的查找非常快速。通过这些查找,可以遍历不同的术语序列,使用它们的共享文档来计算图中的一个加权边。我们将在第五章深入探讨如何利用隐藏在数据中的这个语义知识图。
2.2 自然语言的结构
在上一节中,我们讨论了文本和非结构化数据通常包含一个巨大的关系图,这些关系可以通过查看不同记录之间的共享术语来推导。如果你已经构建搜索引擎一段时间了,你习惯于从“文档”、“字段”和字段内的“术语”的角度来考虑内容。然而,在解释你内容的意义时,还有更多层次需要考虑。
图 2.8 解释了这些额外的语义层次。在最基本层面上,你有字符,它们是单个字母、数字或符号,如图中的字母“e”。然后一个或多个字符组合成字符序列,如“e”、“en”、“eng”……“engineer”和“engineers”。一些字符序列形成术语,这些术语是完整的单词或标记,带有可识别的意义,如“engineer”、“engineers”、“engineering”或“software”。然后一个或多个术语可以组合成术语序列——当术语都是连续的时,通常称为短语。这包括像“software engineer”、“software engineers”和“senior software engineer”这样的东西。为了本书的简洁性,我们也认为单个术语是“术语序列”,所以当我们提到“短语”时,这包括单个术语。

图 2.8 展示了语义数据编码到自由文本内容中。字符形成字符序列,这些序列形成术语,这些术语形成术语序列,这些序列形成字段,这些字段形成文档,这些文档形成一个语料库。
术语序列与短语
你可能想知道“词序列”和“短语”之间的区别是什么。简单来说,短语是一个词序列,其中所有词都按顺序出现。例如,“首席执行官”既是一个短语也是一个词序列,而“首席官员~2”(意味着在“首席”两个位置或编辑距离内的“官员”)只是一个词序列,因为它指定了一个不一定按顺序的词序列。在绝大多数情况下,你只会处理按顺序的词序列,所以为了简便起见,本书在指代单词和多词序列序列时,我们将主要使用“短语”这个词。为了避免混淆,请注意,“术语”这个词单独用来指代“搜索引擎字段中的一个唯一值”。因此,我们有时也会将搜索引擎中包含多个单词的未分割字符串称为“术语”,尽管从语言学角度来看,它们被认为是“短语”或“词序列”。
当然,我们知道许多词序列可以组成句子,多个句子可以组成段落,而段落又可以进一步组合成更大的文本组。对于搜索引擎来说,然而,在词序列之后,我们通常关注的下一个更高层次的分组是字段。在搜索引擎中,“字段”是文档的一个分区和标记的部分,通常用于搜索或作为文档的独立部分返回。包含文本的字段可以使用文本分析器以任何数量的方式进行分析,这通常包括在空白和标点符号上分割、将所有术语转换为小写以便它们不区分大小写、去除噪声(停用词和某些字符)、词干提取或词形还原以将术语还原到基本形式,以及去除重音。如果你对文本分析过程不熟悉,或者你想复习一下,我们建议查看 Trey Grainger 和 Timothy Potter 所著的《Solr in Action》第六章(Manning, 2014)。
然后,一个或多个字段组合成一个“文档”,多个文档形成一个“语料库”或数据集。每当对搜索索引执行查询时,它将语料库过滤成一个“文档集”,这是一个与特定查询特别相关的语料库子集。
这些语言层次中的每一个——字符序列、术语、词序列、字段、文档、文档集和语料库——都能为你理解你的内容及其在你特定领域中的独特含义提供独特的见解。
2.3 分布式语义和嵌入
分布式语义是自然语言处理领域中的一个研究领域,它侧重于基于分布假设的术语和短语之间的语义关系。分布假设是,在相似上下文中出现的词往往具有相似的意义。这被一句流行的引语很好地概括了:“你可以通过它所伴随的词语来了解一个词。”¹
当将机器学习方法应用于您的文本时,这些分布语义变得越来越重要,搜索引擎使得从您的语料库中的大多数语言表示中推导出上下文变得极其容易。例如,如果有人想找到所有关于 C 级高管的文章,他们可以发出如下查询:
c?o
这个查询将匹配“CEO”、“CMO”、“CFO”或任何其他 CXO 风格的头衔,因为它要求找到以“c”开头并以“o”结尾的单个字符之间的任何字符序列。
查询任意复杂的术语序列也存在同样的自由度:
"VP Engineering"~2
这个查询将匹配“VP Engineering”、“VP of Engineering”、“Engineering VP”或甚至是“VP of Software Engineering”,因为它要求在两个位置(编辑距离)内找到“VP”和“Engineering”。
当然,搜索引擎倒排索引的性质也使得支持任意布尔查询变得非常简单。例如,如果有人搜索“Word”这个词,但我们想确保匹配的任何文档也包含文档中的“Microsoft”或“MS”这两个词中的任何一个,我们可以发出以下查询:
(Microsoft OR MS) AND Word
搜索引擎支持在整个语料库中对字符序列、术语和术语序列进行任意复杂的查询组合,返回作为查询内容匹配唯一上下文的文档集。例如,如果您运行一个针对pizza的查询,返回的文档更有可能是餐厅而不是汽车租赁公司,如果您运行一个针对machine learning的查询,您更有可能看到数据科学家或软件工程师的工作,而不是会计师、餐饮工人或药剂师。这意味着您可以从“machine learning”和“software engineering”之间推断出强关系,以及从“machine learning”和“food service worker”之间推断出弱关系。如果您进一步挖掘,您还将能够看到与您的语料库中的其他部分相比,在机器学习文档集中最常共同出现的其他术语和短语,从而更好地理解“machine learning”这个短语的含义和用法。我们将在第五章中深入探讨使用这些关系进行实际操作的例子。
引入向量
在你阅读这本书的过程中,对向量操作的基本理解将非常重要。向量是一系列描述某个项目属性的值。例如,如果你的项目是房子,你可能有一系列属性,如价格、大小和卧室数量。如果你有一套价值 10 万美元、1000 平方英尺和 2 个卧室的房子,这可以表示为向量 [100000, 1000, 2]。
这些属性(在这个例子中是价格、大小和卧室数量)被称为维度或特征,而特定维度的集合被称为向量空间。如果你可以在向量空间的维度内为其他项目(如其他房子、公寓或住宅)分配值,你就可以在同一个向量空间中表示这些项目。
如果我们考虑同一向量空间内的其他向量(例如,一套价值 100 万美元、9850 平方英尺和 12 个卧室的房子 [1000000, 9850, 12] 和另一套价值 12 万美元、1400 平方英尺和 3 个卧室的房子 [120000, 1400, 3]),我们可以在向量上执行数学运算来学习趋势并比较向量。例如,你可能直观地查看这三个示例向量并确定“房价随着房间数量的增加而增加”或“房间数量随着房子大小的增加而增加”。我们还可以在向量上执行相似度计算,以确定价值 12 万美元、1400 平方英尺和 3 个卧室的房子比价值 100 万美元、1000 平方英尺和 2 个卧室的房子更相似,而不是与价值 100 万美元、9850 平方英尺和 12 个卧室的房子相似。
近年来,分布假设已被应用于通过所谓的嵌入来创建术语和术语序列的语义理解。嵌入是一组坐标,我们在其中映射(或“嵌入”)一个概念。更具体地说,这组坐标是一个数值向量(一系列数字),旨在表示你数据的语义意义(文本、图像、音频、行为或其他数据模式)。基于文本的嵌入可以表示任何长度的术语序列,但当表示单个单词或短语时,我们称这些嵌入为词嵌入。
术语序列通常被编码成一个低维嵌入,可以与语料库中所有其他嵌入的向量进行比较,以找到语义上最相关的文档。
要理解这个过程,可能有用的是思考一下搜索引擎是如何从零开始工作的。让我们想象每个术语都有一个向量存在,它包含了你语料库中每个单词的值(维度)。它可能看起来像图 2.9 所示。

图 2.9 从倒排索引中每个术语一个维度的向量。左侧的每个查询映射到右侧的向量,对于索引中也在查询中的任何术语,其值为1,而对于索引中不在查询中的任何术语,其值为0。
图 2.9 展示了默认情况下词汇搜索引擎中文档匹配的概念工作方式。一个 词汇搜索 是一种搜索,其中文档根据它们包含查询中指定的实际关键词或其他属性的程度的匹配和排名。对于每个关键词查询,存在一个包含倒排索引中每个术语维度的向量。如果该术语存在于查询中,则该维度的值是 1,如果该值不存在于查询中,则该维度的值是 0。倒排索引中的每个文档都有一个类似的向量,对于索引中出现在文档中的任何术语,其值为 1,而对于所有其他术语,其值为 0。
当执行查询时,会在索引中查找任何匹配的术语,然后根据查询向量与被评分文档的向量之间的比较来计算相似度得分。我们将在第三章中详细介绍具体的评分计算,但现在的这个高级理解已经足够了。
这种方法有明显的缺点。虽然它非常适合查找具有精确关键词匹配的文档,但当你想查找“相关”的东西时会发生什么?例如,你会注意到术语“soda”出现在查询中,但从未出现在索引中。即使有其他类型的饮料(“苹果汁”、“水”、“卡布奇诺”和“拿铁”),搜索引擎总是会返回零结果,因为它不理解用户正在搜索饮料。同样,你会注意到尽管术语“caffeine”存在于索引中,但查询 latte、cappuccino 和 green tea 永远不会匹配术语“caffeine”,即使它们是相关的。
由于这些原因,现在普遍的做法是使用降维密集嵌入来为索引和查询中的术语序列建模语义意义。一个 密集嵌入(也称为 密集向量嵌入)是一个包含更抽象特征的向量,它在语义空间中编码输入的概念意义。图 2.10 展示了现在映射到降维向量中的术语,这些向量可以作为密集嵌入使用。

图 2.10 展示了降维密集嵌入。在这种情况下,不再是每个术语一个维度(存在或缺失),而是现在存在更高层次的维度,这些维度评估跨项目共享的属性,例如“健康”,包含“咖啡因”或“面包”或“乳制品”,或者项目是“食物”还是“饮料”。
现在对于图 2.10 最左侧列中的每个词序列都可用一个新的嵌入向量,我们可以现在使用它们向量之间的相似度来评估每一对词序列之间的关系。在线性代数中,我们使用余弦相似度函数(或另一个相似度度量)来评估两个向量之间的关系。余弦相似度是通过执行两个向量的点积并按每个向量的模(长度)进行缩放来计算的。我们将在下一章更详细地介绍数学,但现在,图 2.11 显示了评估这些向量之间相似度的结果。

图 2.11 嵌入之间的相似性。向量之间的余弦值显示了按与“绿茶”、“芝士披萨”和“甜甜圈”的相似度排序的项目列表。
如你在图 2.11 中看到的,由于每个词序列都被编码成一个向量,该向量代表其在高级特征方面的意义,现在这个嵌入可以用来评估该词序列与任何其他相似向量的相似度。你会在图的下部看到三个向量相似度列表:一个用于“绿茶”,一个用于“芝士披萨”,一个用于“甜甜圈”。
通过比较“绿茶”与其他所有词序列的向量相似度,我们发现最相关的项目是“水”、“卡布奇诺”、“拿铁”、“苹果汁”和“苏打水”,而最不相关的是“甜甜圈”。这从直观上是有道理的,因为“绿茶”与列表中更高的项目共享更多的属性。对于“芝士披萨”向量,我们看到最相似的其它嵌入是“芝士面包棒”、“肉桂面包棒”和“甜甜圈”,而“水”位于列表底部。最后,对于“甜甜圈”这个术语,我们发现最相关的项目是“肉桂面包棒”、“芝士面包棒”和“芝士披萨”,而“水”再次位于列表底部。这些结果很好地找到了与我们原始查询最相似的项目。
值得注意的是,这种向量评分仅在计算项目之间的相似度时使用。在你的搜索引擎中,通常有一个两阶段过程,你首先过滤到一组文档(匹配阶段),然后评分这些结果文档(排名阶段)。除非你打算跳过第一步并相对于你的查询向量评分所有文档(这可能需要时间和处理资源),否则你仍然需要在排名阶段之前使用某种形式的初始匹配来过滤查询到一个合理的文档数量进行评分。我们将在第三章、第九章、第十三章、第十四章和第十五章中更深入地探讨成功实现嵌入和向量搜索的机制。
嵌入可以表示查询、文档的部分,甚至整个文档。将术语和术语序列编码到词嵌入中是很常见的,但句子嵌入(编码一个表示句子意义的向量)、段落嵌入(编码一个表示段落意义的向量)和文档嵌入(编码一个表示整个文档意义的向量)也是常见的技巧。
维度比我们这里给出的例子更抽象也是很常见的。例如,像 LLMs 这样的深度学习模型可能会从字符序列以及文档在语料库中的聚类方式中检测出看似难以理解的特征。我们可能无法轻易将这些维度在嵌入向量中应用一个可读的标签,但只要它能提高模型的预测能力并增加相关性,这通常不会成为大多数搜索团队的担忧。事实上,由于向量通过不同的抽象数值特征来编码“意义”,因此也有可能创建和搜索代表不同类型(或模态)数据的向量,例如图像、音频、视频,甚至是信号和活动模式。我们将在第 15.3 节中介绍多模态搜索(在不同数据模态上的搜索)。
最终,结合多个模型以利用分布语义和嵌入的力量往往能创造出最佳结果,我们将在本书的其余部分深入探讨众多基于图和向量的方法来使用这些技术。
2.4 域特定知识建模
在第一章中,我们讨论了搜索智能的进步(参见图 1.8),组织从基本的关键词搜索开始,经过几个额外的改进阶段,最终实现一个完全的自学习系统。搜索智能进步的第二阶段是构建分类法和本体,第三阶段(“查询意图”)包括构建和使用知识图谱。不幸的是,有时行业内从业者对于正确的定义和关键术语,如“本体”、“分类法”、“同义词列表”、“知识图谱”、“替代标签”等,可能会有很大的混淆。为此,我们将在本书中提供一些定义以避免任何歧义。具体来说,我们将为“知识图谱”、“本体”、“分类法”、“同义词”和“替代标签”等关键术语列出定义。图 2.12 显示了它们之间的高层次关系。

图 2.12 域特定知识建模的层次。知识图谱扩展了本体,本体扩展了分类法。同义词扩展了替代标签并映射到分类法中的条目。
我们如下定义这些知识建模技术:
- 替代标签(或 alt. labels)—用具有相同意义的替换术语序列。
Examples:
CTO => Chief Technology Officer
specialise => specialize
- 同义词——可以用来表示相同或非常相似事物的替换词序列。
Examples:
human => homo sapiens, mankind
food => sustenance, meal
- 分类法——将事物分类到类别中的方法。
Examples:
human is mammal
mammal is animal
- 本体——事物类型之间关系的映射。
Examples:
animal eats food
food contains ingredients
- 知识图谱——本体的实例化,其中还包含相关的事物。
Examples:
John is human
John eats food
创建替代标签是这些技术中最容易理解的。缩写(如“RN”=>“注册护士”)和首字母缩略词几乎总是作为替代标签,同样,拼写错误和替代拼写也是如此。有时,将这些映射存储在单独的列表中是有用的,尤其是如果你正在使用算法来确定它们,并且你预计将允许人类修改它们,或者如果你计划稍后重新运行算法。
同义词是这些技术中最常见的,因为几乎每个搜索引擎都会有一些同义词列表的实现。替代标签是同义词列表的一个子集,并且是最明显的同义词类型。大多数人认为“高度相关”的词序列也是同义词。例如,“软件工程师”和“软件开发者”通常被认为是同义词,因为它们通常可以互换使用,尽管这两个词之间有一些细微的含义差异。有时,你甚至会在双语搜索用例的同义词中看到不同语言之间单词的翻译。
替代标签和更一般的同义词之间的一个关键区别在于,替代标签可以被视为原始标签的替换词,而同义词则更常被用作扩展词,与原始词一起使用。实现方式可能大相径庭,但这最终归结于你是否确信两个词序列具有完全相同的含义(并且你希望将它们标准化),或者你只是试图包含额外的相关词序列,以免错过其他相关结果。
分类法是同义词之上的下一步。分类法更少关注替代或扩展词,而是专注于将你的内容分类到层次结构中。分类信息通常用于驱动网站导航,改变搜索结果子集的行为(例如,根据父产品类别显示不同的细分或过滤选项),或根据查询映射的类别应用动态过滤。例如,如果有人在家居装修网站上搜索“范围”,网站可能会自动过滤到“电器”,以去除其他产品描述中包含“在范围内”等短语的其他产品的噪音。同义词随后映射到分类法中,指向分类法中的特定项目。
与分类法倾向于指定类别之间的父子关系并将事物映射到这些类别不同,本体提供了在领域内定义事物(术语序列、实体)之间更丰富关系的能力。本体通常定义更抽象的关系,试图模拟领域内事物种类之间的关系,例如“员工向老板汇报”、“CMO 的老板是 CEO”、“CMO 是员工”。这使得本体在通过将事实映射到本体并基于本体中的关系进行逻辑推理以应用于这些事实来推导新信息时非常有用。
知识图谱是知识管理领域中的相对新成员。虽然本体定义了适用于事物类型的高级关系,但知识图谱往往是对本体的完整实现,包括那些属于这些类型的每个特定实体。以我们之前提到的本体为例,知识图谱还会包含“Michael 是 CMO”、“Michael 向 Marcia 汇报”和“Marcia 是 CEO”等作为图中的关系。在知识图谱成为主流之前,这些更详细的关系通常会被建模到本体中,而且许多人至今仍在这样做。因此,你经常会看到“知识图谱”和“本体”这两个术语被交替使用,尽管随着时间的推移,这种情况变得越来越少见。
在本书中,我们将主要讨论替代标签、同义词和知识图谱,因为分类法和本体主要被包含在知识图谱中。我们将在第五章更深入地探讨知识图谱。
2.5 搜索中自然语言理解面临的挑战
在最后几节中,我们讨论了嵌入在非结构化数据(如文本)中的丰富语义图,以及如何使用分布语义和嵌入来推导和评分查询和文档中术语序列之间的语义关系。我们还介绍了知识建模的关键技术和本书中使用的相关术语。在本节中,我们将讨论与自然语言理解相关的一些关键挑战,我们将在接下来的章节中努力克服这些挑战。
2.5.1 不确定性的挑战(多义性)
在 2.1.3 节中,我们介绍了多义性或模糊术语的概念。在那个章节中,我们处理的是一个被标记为“Trey Grainger”名称但指代不同于本书作者的图像。然而,在文本数据中,我们面临相同的问题,而且可能会变得非常混乱。
以“driver”这个词为例。它可以广泛地指代“车辆驾驶员”,一种用于击打球从 tee 上起飞的高尔夫球杆,使硬件设备能够工作的软件,一种工具(螺丝刀),或者推动某物前进的动因(“成功的关键推动者”)。这个词有许多潜在的含义,你可以探索更细粒度的含义。例如,在“车辆驾驶员”类别中,它可能指出租车驾驶员、Uber 驾驶员、Lyft 驾驶员、CDL 驾驶员(持有商业驾驶执照的专业卡车司机),甚至公交车驾驶员。在公交车驾驶员的子集中,它可能指校车驾驶员、公共城市公交车驾驶员、旅游巴士驾驶员等等。我们至少可以将这个列表进一步细分为几十个额外的类别。
在构建搜索应用时,工程师们通常会天真地创建静态的同义词列表,并假设术语具有单一的含义,可以普遍应用。然而,现实情况是,每个术语(单词或短语)都基于其被使用的特定上下文获得独特的含义。
TIP 每个术语都基于其被使用的特定上下文获得独特的含义。
虽然我们在第五章讨论了使用语义知识图来近似无限多种潜在含义的技术,但通常并不实用。不过,无论你支持每个短语多种含义还是只有几种,认识到能够为用户可能遇到的任何短语生成准确(并且往往是细微的)解释的明确需求是很重要的。
2.5.2 理解上下文的挑战
我喜欢说,你遇到的每一个术语(单词或短语)都是一个“具有模糊标签的、依赖于上下文的含义集群”。也就是说,有一个标签(术语的文本表示)被应用于某个概念(含义集群),这个概念依赖于它所处的上下文。根据这个定义,如果不理解其上下文,就永远无法精确地解释一个术语。因此,创建无法考虑上下文的固定同义词列表可能会为你的用户提供次优的搜索体验。
Transformer 模型在很大程度上基于这个前提,通过使用输入提示作为上下文来解释提示中的每个词部分(或标记)。根据周围的标记以及它们与模型中学习到的表示的关系,对每个标记给予关注,这种表示也是上下文相关的。我们将在第十三章深入探讨 Transformer 的工作原理,并在第十四章中微调一个 Transformer 以用于问答任务。
仅因为上下文很重要并不意味着总是容易正确应用。当你的搜索引擎不理解查询时,通常有必要执行基本的关键词搜索作为备选方案,而且几乎总是有用预先构建的领域理解来帮助解释查询。这种预先构建的领域理解最终会覆盖一些默认的关键词匹配行为(例如将单个关键词组合成短语、注入同义词和纠正拼写错误)。
正如我们在第一章中讨论的,查询的上下文不仅包括搜索关键词和你的文档中的内容。它还包括对你所在领域的理解,以及对你用户的理解。根据你对用户和任何特定领域理解的了解,查询可以完全不同的含义。这种上下文对于检测和解决我们在上一节中讨论的模糊性是必要的,同时也确保你的用户能够获得尽可能智能的搜索体验。在这本书的整个过程中,我们将关注基于查询使用的独特上下文自动学习每个查询的上下文解释的技术。
2.5.3 个性化挑战
当考虑用户特定上下文作为增强查询理解的一种工具时,并不总是明显如何将用户特定个性化应用于现有内容和领域特定评分之上。例如,假设你了解到某个用户非常喜欢苹果这个品牌,因为他们一直在搜索 iPhone。这意味着当他们在搜索手表、电脑、键盘、耳机和音乐播放器时,苹果也应该被提升吗?可能这个用户只喜欢苹果品牌的手机,通过在其他类别中提升品牌,你可能会让用户感到沮丧。例如,即使用户之前搜索过 iPhone,你怎么知道他们不是只是想比较他们正在考虑的其他手机呢?
在所有用户意图维度(图 1.5)中,个性化是最容易出错的一个,因此,它也是现代基于 AI 的搜索应用(当然不包括推荐引擎)中最少见的一个。我们将在第九章中详细讨论这些问题,以突出我们如何在推出个性化搜索体验时找到正确的平衡点。
2.5.4 解释查询与文档的挑战
当工程师和数据科学家刚开始使用搜索时,我们常见的一个问题是倾向于将标准自然语言处理技术,如语言检测、词性检测、短语检测和情感分析应用于查询。通常,这些技术是在较长的文本块上训练的——通常是文档、段落或至少句子级别。
文档往往更长,并且为周围文本提供显著更多的上下文,而在大多数情况下,查询往往很短(只有几个关键词)。即使它们更长,查询也倾向于结合多个想法,而不是提供更多的语言上下文。因此,在尝试解释查询时,需要尽可能多地使用外部上下文。
而不是使用依赖于句子结构来解释查询的自然语言处理库,例如,你可以尝试在你的文档语料库中查找查询中的短语,以找到它们最常见的特定领域解释。同样,你可以通过挖掘用户行为信号来使用查询中术语在先前用户搜索会话中的共现。这使得你能够从类似用户那里学习真实意图,这从标准自然语言处理库中可靠地推导出来是非常具有挑战性的。
简而言之,由于查询往往简短且常常暗示比其明确表达的内容更多,因此需要对查询进行特殊处理和解释,所以使用以搜索为中心的数据科学方法来处理查询将比传统的自然语言处理方法产生更好的结果。
2.5.5 解释查询意图的挑战
虽然解析查询以理解其中包含的术语和短语的过程很重要,但查询背后通常还隐藏着更高层次的意图——我们可以称之为查询意图。例如,让我们考虑以下查询之间的固有差异:
who is the CEO?
support
iphone screen blacked out
iphone
verizon silver iphone 8 plus 64GB
sale
refrigerators
pay my bill
第一个查询“谁是 CEO?”的意图很明显,是为了找到事实性的答案,而不是文档列表。第二个查询“支持”的意图是导航到网站的“支持”部分,或者以其他方式联系支持团队。第三个查询“iPhone 屏幕变黑”也是在寻求支持,但它针对的是特定问题,并且此人可能希望在联系实际支持团队之前找到可能帮助解决该特定问题的故障排除页面。
接下来的两个查询“iPhone”和“Verizon 银色 iPhone 8 Plus 64GB”非常有趣。虽然它们都是关于 iPhone 的,但第一个搜索是一个通用搜索,可能表明浏览或产品研究意图,而第二个查询是第一个搜索的一个更具体的变体,表明用户确切知道他们要找什么,并且可能更接近做出购买决定。对于“iPhone”的通用查询可能更适合返回一个提供 iPhone 和可用选项概览的着陆页,而更具体的查询可能更适合直接进入带有购买按钮的产品页面。作为一个一般性的规则,查询越通用,用户浏览的可能性就越大。更具体的查询——尤其是当它们通过名称引用特定项目时——通常表明购买意图或寻找特定已知物品的愿望。
搜索词sale表明用户正在寻找以折扣价可购买的物品,这将触发一些特别实现的过滤器或重定向到特定销售活动的着陆页。搜索词refrigerators表明用户想要浏览特定类别的产品文档。最后,搜索词pay my bill表明用户想要采取行动——对此查询的最佳响应不是一组搜索结果,甚至不是一个答案,而是一个重定向到应用程序账单审查和支付部分的链接。
这些查询中的每一个都包含了一个超越仅匹配一组关键词的意图。无论意图是重定向到特定页面、应用特定过滤器、浏览或购买物品,甚至采取特定领域的行动,关键在于用户可能向您的搜索引擎表达他们的目标存在特定领域的细微差别。通常,自动推导这些特定领域的用户意图可能很困难。对于企业来说,实施特定的业务规则来处理这些一次性请求是相当常见的。查询意图分类器可以构建来处理这个问题的一部分,但在构建自然语言查询解释能力时,成功解释每一个可能的查询意图仍然是一个挑战。
2.6 内容 + 信号:驱动人工智能搜索的燃料
在第一章中,我们介绍了反射智能的概念——利用反馈循环从内容和用户交互中不断学习。本章完全专注于理解您内容中嵌入的意义和智能,但重要的是要认识到,我们将应用于您文档中“非结构化数据”的许多技术同样可以轻松应用于您的用户行为信号。例如,我们在第 2.3 节中讨论了如何通过找到它们在语料库中最常出现的其他短语来推导出短语的含义。我们指出,“机器学习”与“数据科学家”和“软件工程师”相比,与“会计师”、“餐饮服务人员”或“药剂师”出现的频率更高。
如果你将分布假设抽象到文档之外,并将其应用于用户行为,你可能会预期查询你的搜索引擎的相似用户可能会表现出相似的查询行为。具体来说,数据科学家或寻找数据科学家的人更有可能搜索或与关于机器学习的文档互动,而餐饮服务人员或会计搜索机器学习内容的可能性远低于软件工程师。因此,我们可以将这些相同的技巧应用于从查询日志中学习相关术语和术语序列,在这里,我们不是将术语和术语序列映射到文档的字段,而是将查询中的术语和搜索结果的点击映射到用户会话,然后映射到用户。我们将在第六章中采用这种方法,从用户查询日志中学习相关术语、同义词和拼写错误。
一些搜索应用内容丰富,但用户信号却非常少。其他搜索应用则拥有大量的信号,但内容却很少,或者内容从自动学习角度来看具有挑战性。在理想情况下,你将拥有丰富的内容和大量的用户信号来学习,这使你能够将两者的优点结合到一个更智能的 AI 搜索应用中。无论你处于哪种情况,请记住,你的内容和用户信号都可以作为推动学习算法的动力,你应该尽最大努力最大化收集和每个方面的质量。
最后关于自然语言理解的一点:随着 LLMs(在大量人类知识上训练的深度神经网络,包括大部分互联网和精选来源)的兴起,我们现在能够以前所未有的质量水平解释一般知识问题的意义和意图。LLMs 在处理领域特定理解方面并不出色,至少对于不属于其训练集的信息来说是这样,但通过在领域特定数据上微调 LLMs,这些模型通常可以快速适应更封闭的领域数据。LLMs 在提高我们学习自然语言细微差别、根据这些细微差别解释任意文档和查询以及推动更相关搜索的能力方面迈出了巨大的步伐。
LLMs(大型语言模型),虽然通常是最令人印象深刻的广泛自然语言理解技术,但绝不是我们 AI 搜索工具箱中唯一强大的工具。我们将在第九章、第十三章、第十四章和第十五章中深入探讨使用 LLMs 进行搜索。与此同时,我们还有许多其他关键算法和技术需要探索,用于自然语言和领域理解、解释用户行为以及学习最优的相关性排名模型。
现在我们已经涵盖了开始从您的自然语言内容中提取意义所需的所有背景知识,现在是时候卷起袖子,动手实践了。在下一章中,我们将通过探索基于内容的关联性,在人工智能驱动的搜索应用中深入研究大量示例。
摘要
-
非结构化数据是一个误称——它实际上更像是一种超结构化数据,因为它代表了一个特定领域知识的巨大图。
-
搜索引擎可以使用分布语义——基于分布假设来解释术语和短语之间的语义关系——以在字符序列、术语、术语序列(通常是短语)、字段、文档、文档集以及整个语料库的层面上利用丰富的语义意义。
-
分布语义方法使我们能够从它们更大的周围上下文中学习查询和内容的细微意义。
-
嵌入技术是一种强大的搜索结果排序方法,它基于文本(以及其他数据模态)的语义意义,而不是仅仅基于特定关键词的存在和出现次数。
-
特定领域的知识通常通过结合替代标签、同义词列表、分类法、本体和知识图来建模。知识图通常将其他方法中的输出建模为特定领域的统一知识表示。
-
多义性(歧义术语)、上下文、个性化以及针对特定查询的自然语言处理方法代表了自然语言搜索中一些更有趣的挑战。
-
内容和用户信号都是我们人工智能驱动的搜索应用在解决自然语言挑战时需要使用的重要燃料。
[1] 约翰·鲁珀特·费尔思,《语言学理论概览,1930-1955》,载于 J.R. 费尔思等,《语言学分析研究》,语言学协会特别卷(牛津大学出版社,1957 年)。
第三章:排序和基于内容的相关性
本章涵盖
-
执行查询并返回搜索结果
-
根据与进入查询的相关性对搜索结果进行排名
-
关键词匹配和过滤与基于向量的排名
-
使用函数查询控制并指定自定义排名函数
-
针对特定领域定制排名函数
搜索引擎本质上做三件事:摄取内容(索引)、返回与进入查询匹配的内容(匹配)以及根据某些衡量标准对返回的内容进行排序(排序)。可以添加额外的层,使用户能够提供更好的查询(自动建议、聊天机器人对话等)并从结果中提取更好的答案或使用大型语言模型总结结果(见第 14-15 章),但搜索引擎的核心功能是对索引数据进行匹配和排序。
相关性 是指返回的内容与查询匹配得有多好的概念。通常,被匹配的内容是文档,返回并排序的内容是匹配的文档及其相应的元数据。在大多数搜索引擎中,默认的相关性排序是基于一个分数,表示查询中的每个关键词与每个匹配文档中相同关键词匹配得有多好。或者,查询可以被映射到数值向量表示,此时分数表示查询向量与每个匹配文档的相似程度。最佳匹配产生最高的相关性分数,并显示在搜索结果的最顶部。相关性计算非常灵活,可以根据每个查询进行调整,从而实现复杂的排序行为。
在本章中,我们将概述如何计算相关性,如何通过函数查询轻松控制并调整相关性函数,以及如何实现流行的特定领域和用户特定相关性排名功能。
3.1 使用余弦相似度对查询和文档向量进行评分
在第 2.3 节中,我们展示了通过计算两个向量之间的余弦值来衡量两个向量相似度的概念。我们创建了代表不同食品项目的向量(数字列表,其中每个数字代表某些特征的强度),然后计算余弦值(向量之间角度的大小)以确定它们的相似度。在本节中,我们将扩展这一技术,讨论文本查询和文档如何映射到向量以用于排序目的。然后,我们将探讨一些流行的基于文本的特征加权技术以及如何将它们集成以创建改进的相关性排序公式。
运行代码示例
书中的所有代码示例都可在预配置的 Docker 容器中运行的 Jupyter 笔记本中找到。这使得您可以通过单个命令(docker compose up)运行代码的交互式版本,而无需花费时间进行复杂的系统配置和依赖关系管理。代码示例也可以在多个搜索引擎和向量数据库上运行。有关如何配置和启动 Jupyter 笔记本以及如何在网络浏览器中跟踪的说明,请参阅附录 A。
为了简洁,本书中的列表可能省略了某些代码行,例如导入或辅助代码,但笔记本包含所有实现细节。
在本节中,我们将深入探讨本书的第一个代码示例。启动运行伴随 Jupyter 笔记本所需的 Docker 容器将有助于您跟随交互式代码示例。有关如何执行此操作的说明请参阅附录 A。
3.1.1 文本到向量的映射
在典型的搜索应用中,我们从一个文档集合开始,然后尝试根据它们与某些用户查询的匹配程度对文档进行排序。在本节中,我们将介绍将查询和文档的文本映射到向量的过程。
在上一章中,我们使用了搜索食品和饮料项目(如苹果汁)的例子,所以让我们在这里重用这个例子。假设我们有两个不同的文档,我们希望根据它们与查询的匹配程度进行排序。
查询: apple juice
*Document 1:*
Lynn: ham and cheese sandwich, chocolate cookie, ice water
Brian: turkey avocado sandwich, plain potato chips, apple juice
Mohammed: grilled chicken salad, fruit cup, lemonade
*Document 2:*
Orchard Farms apple juice is premium, organic apple juice made from the
freshest apples, never from concentrate. Its juice has received the
regional award for best apple juice three years in a row.
如果我们将两个文档(包含总共 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 节中回忆一下,我们提出将针对短语“苹果汁”的查询视为一个向量,该向量包含我们任何文档中每个单词的特征,对于“苹果”和“juice”这些术语,其值为1,而对于所有其他术语,其值为0。
由于术语“苹果”位于我们的 48 个单词向量空间的第 3 位,而“juice”位于第 28 位,因此短语“苹果汁”的查询向量将如图 3.1 所示。

图 3.1 查询向量。对于apple juice的查询被映射到一个包含每个已知术语一个维度的向量,对于“苹果”和juice这些术语,其值为1,而对于所有其他术语,其值为0。
即使查询向量只包含两个非零值(代表“苹果”和“juice”的位置),它仍然包含所有其他可能维度的0值。表示这种包含每个可能值的向量,称为密集向量表示。
每个文档也根据其包含的每个术语映射到相同的向量空间:
*Document 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]
*Document 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 计算密集向量表示之间的相似性
为了对文档进行排序,我们只需要遵循第二章中计算每个文档和查询之间余弦值的过程。然后,这个余弦值将成为我们根据其排序每个文档的相关性得分。
以下列表显示了如何在代码中表示查询和文档向量,以及如何计算查询和每个文档之间的余弦相似性。
列表 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])
输出:
Relevance Scores:
doc1: 0.2828
doc2: 0.2828
有趣的是……尽管这两个文档包含内容非常不同的长向量,但它们仍然获得了相同的相关性得分。这可能不是立即显而易见的,所以让我们通过只关注相关的特征来简化计算。
3.1.3 计算稀疏向量表示之间的相似性
理解上一节计算的关键在于意识到唯一相关的特征是查询和文档之间共享的特征。所有其他特征(出现在不匹配查询的文档中的单词)对判断一个文档是否比另一个文档排名更高没有影响。因此,我们可以从我们的向量中删除所有其他不重要的术语来简化示例,从密集向量表示转换为如图 3.2 所示的稀疏向量表示。

图 3.2 稀疏向量表示仅包含“存在”的特征,与密集向量表示不同,密集向量表示还包含每个特征的 0 值条目。
在大多数搜索引擎评分操作中,我们倾向于处理稀疏向量表示,因为当基于少量特征评分时,它们更有效率。
此外,我们可以通过创建只包含“有意义的条目”——即查询中存在的术语——的向量来进一步简化我们的计算,如下列所示。
列表 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])
输出:
Relevance Scores:
doc1: 1.0
doc2: 1.0
注意到 doc1 和 doc2 仍然产生相同的相关性得分,但现在每个得分都是1.0。如果你还记得,余弦计算中的1.0得分意味着向量是完美匹配的,考虑到这两个向量都是相同的([1, 1]),这是合理的。
事实上,你会注意到几个非常有趣的事情:
-
这个简化的稀疏向量表示计算仍然显示 doc1 和 doc2 返回等效的相关性得分,因为它们都匹配查询中的所有单词。
-
尽管密集向量表示相似性(
0.2828)和稀疏向量表示相似性(1.0)之间的绝对得分不同,但相对于每种向量类型,得分仍然是相同的。 -
两个查询词(“apple”,“juice”)的特征权重在查询和每个文档之间是相同的,导致余弦得分为
1.0。
向量与向量表示
我们一直小心翼翼地使用“密集向量表示”和“稀疏向量表示”,而不是“密集向量”和“稀疏向量”。这是因为向量的概念与其表示之间存在概念上的区别,这种区别往往会导致混淆。
向量的稀疏性指的是向量特征中具有有效值的比例。具体来说,密集向量是指特征值大部分不为零的任何向量,而稀疏向量是指特征值大部分为零的任何向量,无论它们是如何存储或表示的。另一方面,向量表示涉及处理向量的数据结构。对于稀疏向量,为所有零值分配内存和存储空间可能是浪费的,因此我们通常会使用稀疏数据结构(如倒排索引)来仅存储非零值。以下是一个例子:
密集向量:
feature_1: 1.1, feature_2: 2.3, feature_3: 7.1, feature_4: 5.2, feature_5: 8.1
密集向量表示: [1.1,2.3,7.1,5.2,8.1]
稀疏向量表示: N/A(该向量不是稀疏的,因此不能稀疏表示)
稀疏向量: feature_1: 1.1, feature_2: 0, feature_3: 0, feature_4: 5.2, feature_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 词语频率:衡量文档与词语匹配的程度
我们在上一个章节中遇到的问题是,我们的术语向量中的特征只表示“苹果”或“果汁”这个词是否存在于文档中,而不是每个文档代表这两个术语的好坏。如果查询中的每个术语存在时用 1 的值来表示每个术语,副作用是 doc1 和 doc2 将始终具有相同的余弦相似度评分,尽管从质量上讲,doc2 是一个更好的匹配,因为它更多地提到了苹果汁。
我们可以使用 词频(TF)来模拟“文档匹配得有多好”,而不是为每个现有术语使用 1 的值,词频是衡量术语在每个文档中出现的次数的度量。这里的想法是,一个术语在特定文档中出现的频率越高,该文档与查询的相关性就越大。
以下列表显示了具有每个术语在文档或查询中出现的次数作为特征权重的向量。
列表 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])
输出:
Relevance Scores:
doc1: 1.0
doc2: 0.9899
与你预期的相反,doc1 被认为比 doc2 更好的余弦相似度匹配。这是因为“苹果”和“果汁”这两个词在查询和 doc1 中都以“相同的比例”出现(每个词在每个其他词出现一次),使它们在文本上最相似。换句话说,尽管 doc2 直观上更接近查询,因为它包含的查询中的词显著更多,但余弦相似度返回 doc1,因为它是查询的精确匹配,而 doc2 则不是。
由于我们的目标是使像 doc2 这样具有更高 TF 的文档得分更高,我们可以通过从余弦相似度切换到另一个评分函数来实现,例如 点积 或 欧几里得距离,随着特征权重的增加而增加。让我们使用点积 (a . b),它等于余弦相似度乘以查询向量长度和文档向量长度的乘积:a . b = |a| × |b| × cos(θ)。点积将导致包含更多匹配项的文档得分更高,与余弦相似度相反,余弦相似度在查询和文档之间包含更多相似比例的匹配项时,会给文档更高的评分。
短语匹配和其他相关性技巧
到现在为止,你可能想知道为什么我们一直将“苹果”和“果汁”作为独立术语处理,为什么我们不直接将“苹果汁”作为一个短语来提高匹配精确短语的文档的得分。短语匹配是我们将在本章后面讨论的许多简单相关性调整技巧之一。现在,我们将保持我们的查询处理简单,只处理单个关键词,以保持我们对主要目标的关注——解释基于向量的相关性评分和基于文本的关键词评分特征。
在下一个列表中,我们将余弦相似度替换为点积计算,以考虑文档向量在相关度计算中的幅度(随着每个查询术语的匹配次数增加而增加)。
列表 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])
输出:
Relevance Scores:
doc1: 2
doc2: 7
如您所见,doc2 现在对查询的相关度得分高于 doc1,这种改进与我们的直觉更吻合。请注意,相关度得分不再被限制在 0 和 1 之间,就像余弦相似度那样。这是因为点积考虑了文档向量的幅度,它可以随着额外匹配的关键词出现次数的无限制增加而增加。
虽然在我们的向量中用 TF 作为特征权重确实有所帮助,但文本查询表现出额外的挑战,需要考虑。到目前为止,我们的文档都包含查询中的每个术语,这与大多数现实世界场景不符。以下示例将更好地说明,当仅使用基于词频的加权对基于文本的稀疏向量相似度评分进行评分时,仍然存在的一些局限性。让我们从以下三个文本文档开始:
*Document 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.
*Document 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.
*Document 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)
输出:
labels: ['the', 'cat', 'in', 'the', 'hat']
query vector: [1, 1, 1, 1, 1]
Document Vectors:
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]
Relevance Scores:
doc1: 14.0
doc2: 12.0
doc3: 4.0
虽然我们现在根据每个术语匹配的次数为每个文档接收不同的相关度得分,但结果的排序并不一定符合我们对哪些文档是最佳匹配的预期。直观上,我们更期望以下排序:
-
doc2: 因为它关于书籍 《戴帽子的猫》
-
doc3: 因为它与所有单词“the”、“cat”、“in”和“hat”相匹配
-
doc1: 因为它只匹配单词“the”和“in”,尽管它包含它们很多次
这里的问题在于,由于每次一个术语出现时都被视为同等重要,因此相关度得分会随着该术语出现的每一次额外增加而毫无区别地增加。在这种情况下,doc1 获得了最高的得分,因为它包含总共 14 个术语匹配(第一个“the”出现五次,“in”出现四次,第二个“the”出现五次),比任何其他文档的总术语匹配数都要多。
然而,一个包含这些词语出现 14 次的文档被认为比只有一个匹配的文档相关 14 倍,这实际上并不合理。相反,如果一个文档与查询中的许多不同术语匹配,而不是反复匹配相同的术语,那么应该认为该文档更相关。通常,现实世界的 TF 计算通过将每个术语出现的次数计算为对数或平方根来衰减每个额外出现的影响(如图 3.3 所示)。此外,TF 通常也相对于文档长度进行归一化,通过将 TF 除以每个文档中的术语总数。由于较长的文档自然更有可能包含任何给定的术语更频繁,这有助于将分数归一化以考虑这些文档长度变化(根据图 3.3 的分母)。我们的最终、归一化的 TF 计算可以在图 3.3 中看到。

图 3.3 标准化 TF 计算图。t 代表一个术语,d 代表一个文档。TF 等于当前文档中术语出现的次数的平方根(f**[t,d])除以文档中的术语数量(∑[t'][∈]d f**[t',d])。平方根衰减了术语每次额外出现的额外相关性贡献,而分母将这种衰减频率归一化到文档长度,以便较长的文档(包含更多术语)与较短的文档(包含较少术语)具有可比性。
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)
输出:
Document TF Vector Calculations:
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")]
Document TF Vector Values:
Labels: ['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]
Relevance Scores:
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 所示。

图 3.4 文档频率计算。D是所有文档的集合,t是输入术语,D[i]是D中的第i个文档。一个术语的文档频率越低(DF(t)),在查询中看到的术语就越具体、越重要。
由于我们希望更重要的词得分更高,所以我们采用逆文档频率(IDF),如图 3.5 所示。

图 3.5 逆文档频率。|D|是所有文档的总数,t是术语,DF(t)是文档频率。IDF(t)越低,术语就越不重要,越高,术语在查询中的相关性得分就应该越高。
沿用上一节中我们的“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 Vector Values:
[idf("the"), idf("cat"), idf("in"), idf("the"), idf("hat")]
IDF Vector:
[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 中的函数的变体。

图 3.6 TF-IDF 分数。将 TF 和 IDF 计算结合成一个平衡的文本排名相似度分数。
在此改进的特征加权函数到位后,我们最终可以计算一个平衡的相关性分数如下。
列表 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)
输出:
Document TF-IDF Vector Calculations
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")]
Document TF-IDF Vector Scores
Labels: ['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]
Relevance Scores:
doc1: 1.4215
doc2: 18.9633
doc3: 14.2099
最后,我们的搜索结果变得有意义!doc2 获得最高分数,因为它与最重要的单词匹配得最多,其次是包含所有单词但出现次数较少的 doc3,然后是只包含大量不相关单词的 doc1。
这种 TF-IDF 计算是许多搜索引擎相关性算法的核心,包括默认的相似度算法,即 BM25,目前它被用于大多数搜索引擎中的基于关键词的排名。我们将在下一节介绍 BM25。
3.2 控制相关性计算
在上一节中,我们展示了如何将查询和文档表示为向量,以及如何使用余弦相似度或其他相似度度量(如点积)作为相关性函数来比较查询和文档。我们介绍了 TF-IDF 排名,它可以用于创建一个特征权重,平衡基于向量的每个术语的强度(TF)和术语的重要性(IDF)。
在本节中,我们将展示如何在搜索引擎中指定和控制完整的相关性函数,包括常见的查询功能、将查询建模为函数、排名与过滤,以及应用不同类型的提升技术。
3.2.1 BM25:行业标准默认文本相似度算法
BM25 是 Apache Lucene、Apache Solr、Elasticsearch、OpenSearch 以及许多其他搜索引擎中默认的相似度算法。BM25(Okapi “最佳匹配”版本 25 的缩写)首次于 1994 年发表,它在许多基于文本的排名评估中显示出比标准 TF-IDF 余弦相似度排名的改进。目前,它仍然优于大多数未微调的 LLMs 的嵌入表示的排名模型,因此它作为基于关键词排名的良好基线。
BM25 仍然使用 TF-IDF 作为其核心,但它还包括其他几个参数,提供了对 TF 饱和点和文档长度归一化等因素的更多控制。它还总结了每个匹配关键字的权重,而不是计算余弦值。
全部 BM25 计算如图 3.7 所示。变量定义如下:
-
t = 术语;d = 文档;q = 查询。
-
freq(t, >d) 是一个简单的 TF,Σ[𝑡ϵ𝑑] 1 显示了术语
t在文档d中出现的次数。 -
![方程式图像]()
-
是 BM25 中使用的 IDF 的变体,其中 N 是文档总数,N(t) 是包含项 t 的文档数。
-
|d| 是文档 d 中的项数。
-
avgdl 是索引中每个文档的平均项数。
-
k 是一个通常在 1.2 到 2.0 之间变化的自由参数,它增加了 TF 饱和点。
-
b 是一个通常设置为约 0.75 的自由参数。它增加了文档归一化的效果。

图 3.7 BM25 评分函数。它仍然主要使用简化的 TF 和 IDF 的变体,但它提供了更多控制每个额外出现项对分数贡献(k 参数)以及基于文档长度归一化分数(b 参数)的程度。
您可以看到,分子包含 freq(简化的 TF)和 IDF 参数,而分母增加了新的归一化参数 k 和 b。TF 饱和点由 k 控制,使得随着 k 的增加,相同项的额外匹配计数减少,由 b 控制,随着其增加,对文档长度归一化的控制水平更高。每个项的 TF 计算为 freq(t,d) / (freq(t,d) + k · (1 – b + b · |d| / avgd**l)),这比我们在图 3.3 中使用的方法计算更复杂。
从概念上讲,BM25 只是提供了一个比传统的 TF-IDF 更优化的 TF 归一化方法。还值得注意的是,存在几种 BM25 算法的变体(BM25F,BM25+),并且根据您使用的搜索引擎,您可能会看到一些轻微的修改和优化。
在测试 BM25 时,我们不重新实现所有这些数学公式,现在让我们切换到使用我们的搜索引擎并查看它的计算性能。让我们首先在搜索引擎中创建一个集合(列表 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 以使用其他支持的搜索引擎和向量数据库。
输出:
Wiping "cat_in_the_hat" collection
Creating "cat_in_the_hat" collection
Status: Success
列表 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)
输出:
Adding Documents to 'cat_in_the_hat' collection
Status: Success
将我们的文档添加到搜索引擎后,我们现在可以发出查询并查看完整的 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"])
输出:
*Query*: the cat in the hat
*Ranked Docs*:
[{'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 笔记本中的完整计算。
虽然 BM25 计算比 TF-IDF 特征权重计算更复杂,但它仍然将 TF-IDF 作为其计算的核心部分。因此,BM25 排名与列表 3.8 中的 TF-IDF 计算的相对顺序相同:
*Ranked Results (Listing 3.8: TF-IDF Cosine Similarity)*:
doc2: 0.998
doc3: 0.9907
doc1: 0.0809
*Ranked Results (Listing 3.9: BM25 Similarity)*:
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") ]
这里的 query 函数只是计算传入术语相对于所有评分文档的 BM25 值。因此,整个查询的 BM25 是每个术语 TF-IDF 的总和。在 Solr 查询语法中,这将表示为
{!func}query("the") {!func}query("cat") {!func}query("in")
{!func}query("the") {!func}query("hat")
如果我们执行这个“函数化”版本的查询,我们将得到与直接执行查询完全相同的相关性分数。以下列表显示了执行此版本查询的代码。
列表 3.12 使用 query 函数进行文本相似度
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"])
输出:
*Query:*
{!func}query("the") {!func}query("cat") {!func}query("in")
{!func}query("the") {!func}query("hat")
*Results:*
[{'id': 'doc2', 'title': ['Best'], 'score': 0.6823196},
{'id': 'doc3', 'title': ['Okay'], 'score': 0.62850046},
{'id': 'doc1', 'title': ['Worst'], 'score': 0.3132525}]
如预期的那样,分数与之前相同——我们只是用显式函数替换了之前隐式函数的位置。
3.2.2 函数,到处都是函数!
我们刚刚遇到了 query 函数(在前一节的末尾),它对关键词执行默认(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在我们的搜索客户端中实现:
*Generic search request syntax:*
{"query": "the cat in the hat",
"query_fields": ["title¹⁰", "description².5"]}
此查询请求为title字段中的匹配项提供 10 倍的相关性增强,为description字段中的匹配项提供 2.5 倍的相关性增强。当映射到 Solr 语法时,它看起来像这样:
*Solr request syntax:*
{"query": "the cat in the hat",
"params": {"defType": "edismax",
"qf": "title¹⁰ description².5"}}
每个搜索引擎都不同,但许多这些技术都内置在 Solr 的特定查询解析器中,无论是通过查询语法还是通过查询解析器选项,就像刚刚展示的edismax查询解析器一样。
在全短语匹配、双词短语和三词短语上增强,也是 Solr 的edismax查询解析器的原生功能:
- 在
title字段中包含确切短语"the cat in the hat"的文档:
*Solr request syntax:*
{"query": "the cat in the hat",
"params": {"defType": "edismax",
"qf": "title description",
"pf": "title"}}
- 在
title或description字段中包含双词短语"the cat"、"cat in"、"in the"或"the hat"的文档:
*Solr request syntax:*
{"query": "the cat in the hat",
"params": {"defType": "edismax",
"qf": "title description",
"pf2": "title description"}}
- 在
description字段中包含三词短语"the cat in"或"in the hat"的文档:
*Solr request syntax:*
{"query": "the cat in the hat",
"params": {"defType": "edismax",
"qf": "title description",
"pf3": "description"}}
许多其他的相关性提升技术需要使用函数查询来构建自定义特征。例如,如果我们想创建一个查询,只提升运行搜索的用户地理位置最近的文档的相关性排名,我们可以发出以下 Solr 查询:
*Solr request syntax:*
{"query": "*",
"sort": "geodist(location, $user_latitude, $user_longitude) asc",
"params": {"user_latitude": 33.748,
"user_longitude": -84.39}}
最后一个查询使用 sort 参数严格按 geodist 函数对文档进行排序,该函数接受文档的位置字段名称以及用户的纬度和经度作为参数。当考虑单个特征时,这效果很好,但如果我们想根据许多特征构建更复杂的排序怎么办?为了实现这一点,我们可以更新我们的查询,在计算相关性得分时应用多个函数,然后按相关性得分进行排序:
*Solr request syntax:*
{"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(所有 4 个特征得分25),最差得分将大约为0。 -
由于
25的相对贡献是作为每个函数查询的一部分指定的,因此我们可以轻松地实时调整任何特征的权重,以影响最终的相关性计算。
通过最后一个查询,我们已经完全将相关性计算掌握在自己手中,通过建模相关性特征并赋予它们权重。虽然这非常强大,但仍需要大量的手动工作来确定给定领域最重要的特征,并调整它们的权重。在第十章中,我们将介绍构建机器学习排名模型的过程,以自动为我们做出这些决定(这个过程称为 学习排名)。目前,我们的目标只是理解在查询向量中建模特征以及如何编程控制它们的权重。
深入了解函数查询
如果你想了解更多关于如何利用 Solr 的函数查询的信息,我们建议阅读我们之前的一本书《Solr in Action》的第七章,由 Trey Grainger 和 Timothy Potter 撰写(Manning,2014;mng.bz/n0Y5)。要查看 Solr 中可用的所有函数查询的完整列表,你还可以查看 Solr 参考指南中函数查询部分的文档(mng.bz/vJop)。如果你使用的是不同的搜索引擎,请查看它们的文档以获取类似指南。
我们已经看到了利用函数作为查询特征的力量,但到目前为止,我们的例子都是所谓的“加性”提升,其中每个函数计算的值之和构成了最终的相关性得分。通过“乘性”提升以更模糊、更灵活的方式组合函数也经常很有用,我们将在下一节中介绍。
3.2.3 选择乘性提升与加性提升用于相关性函数
最后一个要讨论的话题,关于我们如何控制我们的相关性函数,是乘性提升与加性提升之间的比较。
到目前为止的所有例子中,我们都将多个特征添加到我们的查询向量中以贡献得分。例如,以下 Solr 查询都将产生等效的相关性计算,假设它们被过滤到相同的结果集(即,filters=["the cat in the hat"]):
*Text query (score + filter):*
{"query": "the cat in the hat"}
*Function Query (score only, no filter):*
{"query": '{!func}query("the cat in the hat")'}
*Multiple Function Queries (score only, no filter):*
{"query": '{!func}query("the")
↪{!func}query("cat")
↪{!func}query("in")
↪{!func}query("the")
↪{!func}query("hat")'}
*Boost Query (score only, no filter):*
{"query": "*",
"params": {"bq": "the cat in the hat"}}
这些例子中的相关性提升类型被称为加性提升,并且很好地映射到我们将查询视为仅仅是一个需要跨文档比较其相似性的特征向量的概念。在加性提升中,随着更多特征的添加,每个特征的相对贡献会减少,因为总得分只是所有特征得分的总和。
相比之下,乘性提升允许文档的整个计算相关性得分通过一个或多个函数进行缩放(乘法)。乘性提升使得提升可以“叠加”在一起,避免了在 3.2.2 节中我们不得不对查询的不同部分分别约束权重的情况。在那里,我们必须确保关键词得分、地理距离、年龄和文档的流行度各自缩放到相关性得分的 25%,以便它们加起来达到最大得分 100%。
要在 Apache Solr 中提供乘性提升,你可以在查询向量中使用boost查询解析器(语法:{!boost …}),或者如果你使用的是edismax查询解析器,可以使用简化的boost查询参数。以下两个查询将文档的相关性得分乘以popularity字段值的 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 值相加),但最终得分乘以popularity字段值的 10 倍。这种乘性提升允许流行度独立于其他任何特征来调整相关性得分。
通常,乘法增强提供了更大的灵活性,可以在不显式预定义一个考虑每个潜在贡献因素的关联公式的情况下,结合不同的关联特征。另一方面,这种灵活性可能导致意外的后果,如果特定特征的乘法增强值过高,以至于掩盖了其他特征。相比之下,加法增强可能难以管理,因为你需要显式地调整它们,以便在保持对整体得分的可预测贡献的同时进行组合。然而,通过这种显式缩放,你可以保持对关联得分计算和得分范围的紧密控制。无论是加法增强还是乘法增强,都可能很有用,因此最好考虑手头的问题并实验哪种方法能带来最佳结果。
我们现在已经介绍了控制搜索引擎中相关性排名的主要方法,但文档的匹配和过滤通常同样重要,因此我们将在下一节中介绍它们。
3.2.4 区分文档的匹配(过滤)与排名(评分)
我们已经将查询和文档视为特征向量,但到目前为止,我们主要讨论了搜索作为一个计算向量相似度(如余弦或点积)的过程,或者将查询中每个特征(关键词或函数)的文档得分相加。
一旦文档被索引,执行查询涉及两个主要步骤:
-
匹配—将结果过滤到一组已知的可能答案中
-
排名—按相关性对所有可能的答案进行排序
我们经常可以完全跳过第一步(匹配),仍然在第一页(以及许多页面)上看到完全相同的结果,因为最相关的结果通常排名最高,因此会首先显示。如果你回想起第二章,我们甚至看到了一些向量得分计算(比较食品项目的特征向量——即“苹果汁”与“甜甜圈”),在这种情况下,我们根本无法过滤结果。我们不得不首先对每个文档进行评分,以确定哪些文档基于相关性返回。在这种情况下(使用密集向量嵌入),我们甚至没有可以作为过滤器的关键词或其他属性。
那么,如果初始匹配阶段实际上是可选的,为什么还要进行它呢?一个明显的答案是它提供了显著的性能优化。我们不必遍历每一份文档并计算关联得分,通过首先将初始结果过滤到一组较小的、逻辑上匹配的文档集合中,我们可以大大加快我们的关联计算和搜索引擎的整体响应时间。
能够过滤结果集还有其他好处,例如我们可以提供分析,如匹配文档的数量或文档中找到的特定值的计数(称为维面或聚合)。从搜索结果中返回维面和类似的聚合元数据有助于用户随后根据特定值进行过滤,以进一步探索和细化他们的结果集。最后,在许多场景中,“具有逻辑匹配”应被视为排名函数中最重要的特征之一,因此简单地根据逻辑匹配进行过滤可以极大地简化相关性计算。我们将在下一节讨论这些权衡。
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%的查询词必须匹配(相当于
statueANDofANDliberty):
*Generic search request syntax:*
{"query": "statue of liberty",
"min_match": "100%"}
*Solr request syntax:*
{"query": "statue of liberty",
"params": {"defType": "edismax",
"mm": "100%"}}
- 至少有一个查询词必须匹配(相当于
statueORofORliberty):
*Generic search request syntax:*
{"query": "statue of liberty",
"min_match": "1"}
*Solr request syntax:*
{"query": "statue of liberty",
"params": {"defType": "edismax",
"mm": "1"}}
- 至少有两个查询词必须匹配(相当于
(statueANDof)OR (statueANDliberty)OR(ofANDliberty)):
*Generic search request syntax:*
{"query": "statue of liberty",
"query_parser": "edismax",
"min_match": "2"}
*Solr request syntax:*
{"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 有两种主要方式来控制过滤和评分:query和filters参数。考虑以下请求:
*Generic search request syntax:*
{"query": "the cat in the hat",
"query_fields": ["description"],
"filters": [("category", "books"), ("audience", "kid")],
"min_match": "100%"}
*Solr request syntax:*
{"query": "the cat in the hat",
"filters": ["category:books", "audience:kid"],
"params": {"qf": ["description"],
"mm": "100%",
"defType": "edismax"}}
在这个查询中,搜索引擎被指示过滤可能的结果集,只保留 category 字段值为“books”和 audience 字段值为“kid”的文档。然而,除了这些过滤器之外,查询本身也充当过滤器,因此结果集进一步过滤,只保留 description 字段中包含(100%)“the”、“cat”、“in”和“hat”值的文档。
query 和 filters 参数之间的逻辑区别在于,filters 只作为过滤器,而 query 则作为 同时 过滤器和相关性排名的特征向量。query 参数的这种双重用途对于查询来说是帮助默认行为,但将过滤和评分的关注点混合在同一个参数中对于更高级的查询来说可能不是最佳选择,尤其是如果我们只是试图操纵相关性计算而不是任意从我们的文档集中删除结果。
有几种方法可以解决这个问题:
- 将
query参数建模为一个函数(函数只计算相关性,不进行过滤):
*Solr request syntax:*
{"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 request syntax:*
{"query": "*",
"filters": "{!cache=false v=$user_query}",
"params": {"bq": "{!edismax qf=description mm=100% v=$user_quer}",
"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),这是一本关于此类相关性工程的指南。
每个搜索引擎和领域都有独特的特征需要考虑,以提供最佳的搜索体验。而不是手动建模这些相关性特征,一个由人工智能驱动的搜索引擎可以利用机器学习自动生成和权衡这些特征。
本章的目标是为你提供在接下来的章节中需要的知识和工具,以便在我们开始集成更多自动化机器学习技术时影响相关性排名。我们将在下一章关于众包相关性中开始应用这些技术。
摘要
-
我们可以将查询和文档表示为密集或稀疏的数值向量,并根据向量相似度计算(如余弦相似度)为文档分配相关性排名。
-
使用 TF-IDF 或基于 TF-IDF 的 BM25 相似度计算(同样基于 TF-IDF)来计算我们的文本相似度得分,为我们查询和文档中的特征(关键词)重要性提供了一个更有意义的度量,使得仅通过查看术语匹配就能实现文本排名的改进。
-
文本相似度评分是我们可以在查询中调用的许多函数之一,用于相关性排名。我们可以在查询中注入函数,包括关键词匹配和评分,因为每个关键词短语实际上就是一个排名函数。
-
将“过滤”和“评分”视为两个独立的问题,在指定我们自己的排名函数时提供了更好的控制。
-
为了优化相关性,我们需要同时创建特定领域的相关性函数和使用特定于用户的特征,而不是仅仅依赖于关键词匹配和排名。
第四章:众包相关性
本章涵盖
-
利用用户集体洞察力提高搜索平台的相关性
-
收集和使用用户行为信号
-
利用反射智能创建自调优模型
-
构建端到端信号增强模型
在第一章中,我们介绍了用户意图的维度,即内容理解、用户理解和领域理解。为了创建一个最佳的 AI 驱动搜索平台,我们需要能够结合每个这些上下文来理解我们的用户查询意图。然而,问题是我们是如何得出这些理解的?
我们可以从许多信息来源中学习:文档、数据库、内部知识图谱、用户行为、领域专家等等。一些组织有团队手动用主题或类别标记文档,有些甚至使用像 Amazon Mechanical Turk 这样的工具外包这些任务,这使他们能够从世界各地的人那里众包答案。为了识别网站上的恶意行为或错误,公司通常允许用户报告问题甚至建议更正。所有这些都是众包的例子——依赖于许多人的输入来学习新信息。
在搜索相关性方面,众包可以发挥至关重要的作用,尽管通常重要的是不要通过不断请求帮助而惹恼您的宝贵客户。幸运的是,根据他们的行为,通常可以从用户那里隐式地学习。例如,为了发现查询的最相关文档,我们可以检查日志,以确定在运行相同搜索时其他用户点击最多的文档。这些点击提供了关于哪些结果对查询最相关的信号。
在本章中,我们将探讨如何收集、分析和从这些信号中生成洞察力以众包相关性。我们还将介绍反射智能过程,介绍三种用于普及相关性(信号增强)、个性化相关性(协同过滤)和泛化相关性(学习排序)的关键模型类型。您还将索引一个电子商务数据集并构建您自己的第一个反射智能模型。
4.1 与用户信号合作
每当客户采取行动——例如发布查询或购买产品——这都为该用户的意图提供了信号。我们可以记录和处理这些信号,以了解每个用户、不同用户群体或我们整个用户群体的洞察力。
本节介绍了使用用户信号的力量,并使用我们在整本书中都会使用的示例电子商务数据集,引导您了解收集、存储和处理这些信号的机制。
4.1.1 内容 vs. 信号 vs. 模型
在构建搜索引擎时,两个高级数据源会影响搜索的相关性:内容和信号。大多数内容以文档的形式存在,可以代表网页、产品列表、计算机文件、图像、视频、事实或任何其他可搜索信息。内容文档通常包含用于搜索的文本或嵌入字段,以及代表与内容相关的属性(作者、大小、颜色、日期等)的其他字段。内容文档的标志性特征是它们包含用户搜索的信息,以及在理想情况下,它们的查询答案。
当用户在查询的响应中看到内容时,他们可能会点击结果、将其添加到购物车或采取其他行动。这些行动是信号,它们对于提供用户如何与内容互动的见解至关重要。这些信号可以随后汇总并用于构建模型,以改进匹配和排名算法的相关性。信号的标志性特征是它们是用户提供的见解,用于展示用户希望如何与内容互动。
有时,依赖外部数据源——或称为模型——作为搜索体验的一部分也可能很有用。这可以包括查询知识图谱、引用实体列表或调用在外部数据源上训练的大型语言模型(LLM)或其他基础模型。这些外部模型可以用来更好地解释用户查询、推理和理解内容,甚至总结或生成新的内容以返回。虽然我们可以将模型视为我们搜索引擎的第三种数据源,但它们是在内容和/或信号上训练的,因此作为这两个原始数据源的衍生和精炼表示。
总结来说,我们使用三个主要信息来源来改进搜索:项目的属性(内容)、用户与内容的观察到的交互(信号)以及外部模型(这些模型是从内容和/或信号中派生出来的)。
在构建人工智能驱动的搜索时,我们执行许多任务,可以使用内容或信号来得出相似的结果,但它们为我们提供了两种不同的相关性视角。在理想情况下,我们可以应用这两种视角来构建一个更智能的系统,但了解它们的优缺点对于最佳地使用它们是有用的。
例如,当试图寻找“driver”这个词的同义词时,我们可以查看文本内容中常见于同一文档中的词语。在这种情况下,我们可能会找到一些词语(按它们在文档中出现的百分比优先排序)如“taxi”(40%)、“car”(35%)、“golf”(15%)、“club”(12%)、“printer”(3%)、“linux”(3%)和“windows”(1%)。同样,我们可以查看搜索“driver”的用户发出的信号,并按优先顺序汇总他们其他搜索中的常见关键词,如“screwdriver”(50%)、“printer”(30%)、“windows”(25%)、“mac”(15%)、“golf”(2%)和“club”(2%)。从信号和内容中得出的列表可能相似,也可能非常不同。基于内容的方法告诉我们文档中最常见的意义,而基于信号的方法告诉我们用户正在寻找的最常见的意义。
由于我们的最终目标是向用户提供他们所寻找的内容,因此通常更有效地依赖于从信号中得出的意义,而不是从内容中得出的意义。但如果我们没有与从信号中得出的意义相对应的良好内容呢?我们是使用从内容中得出的意义,还是尝试根据信号数据建议其他相关搜索?如果我们没有足够的信号,或者信号数据不是很干净呢?我们能否以某种方式使用从内容中得出的数据清理信号中得出的数据?
我们在推荐方面遇到类似的问题。基于内容的推荐使用文档中的属性,但不了解用户,而基于信号的推荐不了解内容属性,没有足够的交互将无法工作。基于内容的推荐可能基于对用户不重要的特征,而基于信号的推荐可能会创建自我强化的循环,其中用户只与推荐的物品互动,而这些物品只被推荐是因为用户与它们互动。
理想情况下,我们希望创建一个平衡的系统,能够利用从内容中得出的和从信号中得出的智能的最好部分。虽然本章主要关注基于信号的、众包的智能,但本书的一个主要目标是如何平衡和结合这两种方法,以产生更优化的 AI 驱动搜索体验。
4.1.2 设置我们的产品和信号数据集(RetroTech)
在我们探索不同的用例时,本书将使用各种数据集,但拥有一个一致的示例,随着我们的进展可以在此基础上构建,也是非常宝贵的。我们将通过拥有一个具有大量数据和用户交互的强大搜索用例而受益,我们将在该部分中设置它。
值得注意的是,本书中的大多数技术几乎适用于所有搜索案例。何时使用特定技术的决定因素通常更多地取决于内容和信号的量与种类,而不是具体的用例。
电子商务搜索为 AI 驱动的搜索技术的价值提供了一个最具体的用例,并且它也是潜在读者中最容易理解的问题之一,因此我们创建了一个电子商务数据集,以帮助我们探索这个领域:RetroTech 数据集。
RetroTech 用例
在零售商之间激烈的竞争,销售尖端电子产品、多媒体和科技产品的情况下,小型在线业务很难竞争。然而,一个细分但正在兴起的群体选择避免最新的最优秀的产品,而是回归到几十年前的熟悉技术。RetroTech 公司成立是为了满足这一独特消费者群体的需求,提供可能难以在当今货架上找到的复古硬件、软件和多媒体产品。
让我们加载 RetroTech 公司的数据集,这样我们就可以开始学习文档与用户信号之间的关系,以及众包智能如何提高我们的搜索相关性。
加载产品目录
RetroTech 网站上有大约 50,000 种产品可供销售,我们需要将这些产品加载到我们的搜索引擎中。如果你构建了 AI 驱动的搜索代码库以运行第三章的示例,那么你的搜索引擎已经启动并运行。否则,构建和运行本书所有示例的说明可以在附录 A 中找到。
在你的搜索引擎启动后,你需要做的下一件事是下载本书附带的 RetroTech 数据集。该数据集包括两个 CSV 文件,一个包含所有 RetroTech 的产品,另一个包含 RetroTech 用户一年的信号数据。以下列表展示了产品目录数据集的一些行,以便你熟悉其格式。
列表 4.1 探索 RetroTech 产品目录
"upc","name","manufacturer","short_description","long_description"
"096009010836","Fists of Bruce Lee - Dolby - DVD", , ,
"043396061965","The Professional - Widescreen Uncut - DVD", , ,
"085391862024","Pokemon the Movie: 2000 - DVD", , ,
"067003016025","Summerbreeze - CD","Nettwerk", ,
"731454813822","Back for the First Time [PA] - CD","Def Jam South", ,
"024543008200","Big Momma's House - Widescreen - DVD", , ,
"031398751823","Kids - DVD", , ,
"037628413929","20 Grandes Exitos - CD","Sony Discos Inc.", ,
"060768972223","Power Of Trinity (Box) - CD","Sanctuary Records", ,
你可以看到,产品通过 UPC(通用产品代码)进行标识,并且还有一个名称、制造商,以及一个简短描述(用作搜索结果中的预告)和一个长描述(用于产品详情页面上的完整描述)。
由于我们正在尝试搜索产品,我们的下一步是将它们发送到搜索引擎进行索引。为了在我们的 RetroTech 产品目录上启用搜索,让我们运行以下列表中的文档索引代码,将产品文档发送到搜索引擎。
列表 4.2 将产品文档发送到搜索引擎
products_collection = engine.create_collection("products")
products_dataframe = load_dataframe("data/retrotech/products.csv")
products_collection.write(products_dataframe)
输出:
Wiping "products" collection
Creating "products" collection
Status: Success
Loading Products
Schema:
root
|-- upc: long (nullable = true)
|-- name: string (nullable = true)
|-- manufacturer: string (nullable = true)
|-- short_description: string (nullable = true)
|-- long_description: string (nullable = true)
Successfully written 48194 documents
最后,为了验证文档现在已被索引并可搜索,让我们运行一个示例关键词搜索。以下列表展示了搜索ipod这个真正的经典设备的示例!
列表 4.3 在产品目录上运行搜索
def product_search_request(query, param_overrides={}):
request = {"query": query,
"query_fields": ["name", "manufacturer", "long_description"],
"return_fields": ["upc", "name", "manufacturer",
"short_description", "score"],
"limit": 5,
"order_by": [("score", "desc"), ("upc", "asc")]}
return request | param_overrides
query = "ipod"
request = product_search_request(query)
response = products_collection.search(**request)
display_product_search(query, response["docs"])
前面的ipod搜索结果如图 4.1 所示,表明我们的产品现在已被索引并可搜索。不幸的是,结果的相关性相当差。

图 4.1 产品搜索结果。我们可以看到产品目录已被索引,现在对ipod的查询返回了搜索结果。
虽然搜索结果排名的质量还不是很好,但我们有一个现成的“关键词匹配”搜索引擎,我们可以开始改进它。我们将以此为基础,并在本书的其余部分介绍更多智能 AI 驱动的搜索功能。我们的下一步是介绍我们的信号数据。
加载信号数据
因为 RetroTech 运行在您的计算机上,没有真实用户在搜索、点击或其他方式生成信号。相反,我们生成了一个数据集,它近似于您在类似现实世界数据集中期望的信号活动类型。
为了简单起见,我们将把我们的信号存储在搜索引擎中,以便在实时搜索场景和外部处理中都可以访问。运行以下列表将模拟并索引一些样本信号,我们可以在本章的其余部分使用这些信号。
列表 4.4 索引用户信号数据集
signals_collection = engine.create_collection("signals")
signals_collection.write(from_csv("data/retrotech/signals.csv"))
在加载了我们的 RetroTech 产品和信号数据后,我们将很快开始探索如何使用信号数据来增强搜索相关性。首先,让我们熟悉信号数据,以便我们了解信号在现实世界系统中是如何结构化、使用和收集的。
4.1.3 探索信号数据
不同类型的信号有不同的属性需要记录。对于“查询”信号,我们希望记录用户的搜索关键词。对于“点击”信号,我们希望记录被点击的文档以及导致点击的查询。为了后续分析,我们还希望记录查询后返回并可能被用户查看的文档。
为了使示例更具可扩展性并避免为每种新的信号类型编写自定义代码,我们在本书中采用了表示信号的通用格式。这个格式可能与您目前记录信号的方式不同,但只要您最终可以将您的信号映射到这个格式,本书中的所有代码都应该可以在不进行特定用例修改的情况下工作。
我们在本书中使用的信号格式如下:
-
query_id—产生此信号的查询信号的唯一 ID -
user—代表搜索引擎特定用户的标识符 -
type—信号的类型(“查询”、“点击”、“购买”等) -
target—在signal_time时此信号应用的 内容 -
signal_time—信号发生的时间和日期
例如,假设用户执行了以下一系列操作:
-
查询了
ipad,并返回了三个文档(doc1、doc2 和 doc3)。 -
点击了 doc1。
-
返回并点击了 doc3。
-
将 doc3 添加到购物车中。
-
返回并搜索了
ipadcover,并返回了两个文档(doc4 和 doc5)。 -
点击了 doc4。
-
将 doc4 添加到购物车中。
-
购买了购物车中的商品(doc3 和 doc4)。
这些交互将导致表 4.1 中显示的信号。
表 4.1 示例信号格式
| query_id | user | type | target | signal_time |
|---|---|---|---|---|
| 1 | u123 | query | ipad | 2024-05-01-09:00:00 |
| 1 | u123 | results | doc1,doc2,doc3 | 2024-05-01-09:00:00 |
| 1 | u123 | click | doc1 | 2024-05-01-09:00:10 |
| 1 | u123 | click | doc3 | 2024-05-01-09:00:29 |
| 1 | u123 | add-to-cart | doc3 | 2024-05-01-09:03:40 |
| 2 | u123 | query | ipad cover | 2024-05-01-09:04:00 |
| 2 | u123 | results | doc4,doc5 | 2024-05-01-09:04:00 |
| 2 | u123 | click | doc4 | 2024-05-01-09:04:40 |
| 2 | u123 | add-to-cart | doc4 | 2024-05-01-09:05:50 |
| 1 | u123 | purchase | doc3 | 2024-05-01-09:07:15 |
| 2 | u123 | purchase | doc4 | 2024-05-01-09:07:15 |
关于信号格式的几点需要注意:
-
“查询”类型和“结果”类型被拆分为单独的信号。这并不是必要的,因为它们同时发生,但这样做可以保持表格结构的一致性,并且不需要添加一个仅适用于查询信号的额外结果列。此外,如果用户点击下一页链接或向下滚动页面并看到更多结果,这种结构允许我们创建一个新的信号,而无需返回并修改原始信号。
-
每个信号都关联回原始“查询”信号的
query_id,该信号启动了内容交互系列。query_id不仅仅是对用户输入的关键词的引用,而是对特定“查询”信号的引用,该信号标识了用户查询关键词的时间戳实例。因为针对相同查询关键词的结果可能会随时间变化,这使得我们能够更深入地处理用户对特定查询结果的反应。 -
大多数信号类型在
target中只包含一个项目,但“结果”信号类型包含一个有序的文档列表。结果的顺序对于我们在本书后面介绍的一些算法来说很重要,用于衡量相关性。因此,保留搜索结果的精确顺序是很重要的。在这种情况下,target是一个有序的文档列表,而不是单个文档。 -
结账产生了每个商品的单独“购买”信号,而不是只有一个“结账”信号。这样做是为了我们可以追踪每个购买是否来自不同的查询。可以额外添加一个“结账”信号类型来跟踪交易,并且可能将两个购买列为
target,但这对于本书中的需求来说是多余的。
在有了这些原始信号作为我们的构建块之后,我们现在可以开始思考如何将这些信号链接起来,以开始了解我们的用户及其兴趣。在下一节中,我们将讨论在搜索平台内建模用户、会话和请求的方法。
4.1.4 用户、会话和请求的建模
在上一节中,我们研究了用户信号的结构,作为与原始查询相关联的独立交互列表。我们假设有一个“用户”存在,并且有一个唯一的 ID,但如何识别和追踪一个唯一的用户?此外,一旦你确定了如何追踪唯一用户,最好的方法是将他们的交互分解成会话,以了解他们的上下文何时可能发生变化?
网络搜索中的“用户”概念可能相当灵活。如果你的搜索引擎有经过认证(登录)的用户,那么你已经有一个内部用户 ID 来追踪他们。如果你的搜索引擎支持未认证访问或公开可用,那么你将有许多没有正式用户 ID 的用户在运行搜索。但这并不意味着你不能追踪他们;只是需要更灵活地解释“用户”的含义。一个统一的追踪标识符使我们能够将来自同一用户的多个信号联系起来,以了解他们的交互模式。
如果我们认为可追踪的信息是一个从用户最持久的表示到最不持久的表示的层次结构,它看起来可能像这样:
-
用户 ID—一个在所有设备上持续存在的唯一用户 ID(经过认证)
-
设备 ID—一个在相同设备上的会话中持续存在的唯一 ID(例如设备 ID 或 IP 地址加上设备指纹)
-
浏览器 ID—一个只在相同应用或浏览器会话中持续存在的唯一 ID(持久 cookie ID)
-
会话 ID—一个在单个会话中持续存在的唯一 ID(例如浏览器隐身模式中的 cookie)
-
请求 ID—一个只在单个请求中持续存在的唯一 ID(关闭了 cookies 的浏览器)
在大多数现代搜索应用中,尤其是在大多数电子商务应用中,我们通常需要处理所有这些。作为一个经验法则,你希望将用户与最持久的标识符联系起来——尽可能高的列表中的那个。请求 ID 和会话 ID 之间的链接,以及会话 ID 和浏览器 ID 之间的链接,都是通过用户的 cookie 实现的,所以最终浏览器 ID(存储在 cookie 中的持久唯一 ID)是这些中的共同分母。
特别是,
-
如果用户启用了持久 cookies,一个浏览器 ID 可以有多个会话 ID,这些会话 ID 可以有多个请求 ID。
-
如果用户在每次会话后清除 cookies(例如使用隐身模式),则每个浏览器 ID 只有一个会话 ID,这个会话 ID 可以有许多请求 ID。
-
如果用户关闭了 cookies,那么每个请求 ID 都有一个新的会话 ID 和一个新的浏览器 ID。
在构建搜索平台时,大多数组织没有正确规划和设计它们的信号跟踪机制。如果它们无法将访客的查询与随后的行动相关联,那么最大化其人工智能搜索平台的能力就会变得困难。在某些情况下,可以在事后推导出缺失的信号跟踪信息(例如,通过使用时间戳将信号建模到可能的会话中),但通常最好在设计系统时提前考虑,以更好地处理用户跟踪,防止潜在的信息丢失。在下一节中,我们将讨论如何通过称为“反射智能”的过程使用这些丰富的信号来提高相关性。
4.2 介绍反射智能
在上一节中,我们介绍了如何捕捉用户与我们搜索引擎交互时的信号。虽然这些信号本身有助于我们了解我们的搜索引擎是如何被使用的,但它们也作为构建模型的输入,这些模型可以从用户交互中不断学习,并使我们的搜索引擎能够自我调整其相关性模型。在本节中,我们将通过反射智能的概念介绍这些自我调整模型的工作原理。
4.2.1 什么是反射智能?
想象你是一家五金店的员工。有人问你他们可以在哪里找到一把锤子,你告诉他们“第二通道”。几分钟后,你看到同一个人从第二通道走到第五通道,但没有拿锤子,然后他们从第五通道拿着锤子走了出来。第二天,另一个人要求一把锤子,你又告诉他们“第二通道”,并且你观察到几乎相同的模式。如果你没有注意到这个模式并调整你的建议,以提供更好的客户体验,你将是一个糟糕的员工。
不幸的是,大多数搜索引擎默认以这种方式运行——它们为每个查询返回大量静态的文档集,无论每个用户是谁或先前用户如何对显示的文档列表做出反应。然而,通过将机器学习应用于收集到的信号,我们可以了解用户的意图,并将这些知识反映出来以改善未来的搜索结果。这个过程被称为反射智能。
反射智能的核心是创建反馈循环,这些循环不断学习并基于不断发展的用户交互进行改进。图 4.2 展示了反射智能过程的高级概述。

图 4.2 反射智能过程。用户提出查询,查看结果,并采取一系列行动。然后对这些行动(信号)进行处理,以创建学习相关性模型,从而改善未来的搜索。
在图 4.2 中,用户(Alonzo)运行了一个搜索,在搜索框中输入查询 ipad。记录了一个查询信号,其中包含显示给 Alonzo 的所有搜索结果列表。Alonzo 然后看到搜索结果列表并采取两个行动:点击一个文档(doc22)然后购买该文档所代表的商品。这两个额外的行动被记录为额外的信号。Alonzo 的所有信号以及来自每个其他用户的信号,然后可以被各种机器学习算法聚合和处理,以创建学习相关性模型。
这些学习相关性模型可能会提高特定查询的最受欢迎的结果,为每个用户个性化结果,甚至学习在所有用户中通常最重要的文档属性。这些模型还可以学习如何更好地解释用户查询,例如识别常见的拼写错误、短语、同义词或其他语言模式和特定领域的术语。
一旦生成这些学习相关性模型,它们就可以部署回生产搜索引擎,并立即应用于增强未来查询的结果。然后,这个过程再次开始,下一个用户运行搜索,看到(现在希望是改进的)搜索结果,并与这些结果进行交互。这个过程创建了一个自我学习系统,随着每个额外用户交互的进行而不断改进,随着时间的推移变得越来越智能和相关性更强,并自动调整以适应用户兴趣和内容的演变。
在以下章节中,我们将探讨几种反射智能模型类别,包括信号增强(流行相关性)、协同过滤(个性化相关性)和排序学习(泛化相关性)。我们将从最简单且最有效的一种开始:信号增强模型。
4.2.2 通过信号增强实现流行相关性
发送到您搜索引擎的最受欢迎的查询通常也是从相关性角度优化最重要的查询。幸运的是,由于更受欢迎的查询会产生更多信号,我们通常可以聚合并提高每个查询中具有最高信号数量文档的相关性。这种被称为 信号增强 的 流行相关性 是最简单形式的反射智能之一,也是提高您最受欢迎、最高流量查询相关性的最有效方法之一。以下列表展示了在我们的 RetroTech 搜索引擎中应用任何信号增强之前,使用查询 ipad 的即插即用搜索。
列表 4.5 执行针对匹配 ipad 的产品的关键词搜索
query = "ipad"
request = product_search_request(query)
response = products_collection.search(**request)
display_product_search(query, response["docs"])
如预期的那样,这个查询返回了许多包含关键词 ipad 的文档,通常包含 ipad 的文档排名最高。图 4.3 显示了此查询的结果。

图 4.3 查询ipad的关键词搜索结果。结果主要基于关键词出现的次数返回,因此提及关键词多次的配件比用户意图看到的实际产品排名更高。
虽然这些结果的内容中都多次包含“ipad”这个词,但大多数用户会对这些结果感到失望,因为它们是次要配件,而不是搜索焦点的主要产品类型。仅根据文档文本进行排名时,这是一个重大的限制。然而,对于非常流行的查询,许多客户可能会反复运行相同的查询,并忍受令人沮丧的搜索结果,以找到他们真正寻求的实际产品。我们可以将这些重复的搜索与一个反馈循环联系起来,该循环基于新的信号持续更新信号增强模型,如图 4.4 所示。

图 4.4 信号增强反馈循环。用户的搜索被记录下来,并将当前的信号增强模型应用于返回增强后的结果。用户对这些结果采取行动后,所有与文档的用户交互信号都会由原始查询聚合,以生成一个更新的模型,以进一步改进未来的搜索。
一旦您的产品被索引并且您已经开始收集用户查询和文档交互的信号,实施信号增强所需的额外步骤仅包括聚合您的信号,然后将您的聚合信号作为增强添加到您的查询或文档中。列表 4.6 展示了将信号聚合到辅助集合的简单模型。
辅助集合
辅助集合是位于您的搜索引擎中与主集合并排的额外集合,其中包含其他有助于改进您的搜索应用的有用数据。在我们的电子商务示例中,我们的主集合是products。我们已添加的signals集合可以被视为辅助集合。我们将添加另一个辅助集合signals_boosting,我们将在查询时使用它来增强我们的查询。在本书中,我们将介绍许多其他辅助集合来存储我们生成的模型的输入和输出。
列表 4.6 通过聚合信号创建信号增强模型
signals_collection = engine.get_collection("signals")
create_view_from_collection(signals_collection, "signals") #1
signals_aggregation_query = """
SELECT q.target AS query, c.target AS doc, #2
COUNT(c.target) AS boost #2
FROM signals c LEFT JOIN signals q ON c.query_id = q.query_id
WHERE c.type = 'click' AND q.type = 'query'
GROUP BY q.target, doc #2
ORDER BY boost DESC"""
dataframe = spark.sql(signals_aggregation_query) #3
signals_boosting_collection = \ #4
engine.create_collection("signals_boosting") #4
signals_boosting_collection.write(dataframe) #4
1 创建一个视图,使信号集合可使用 SQL 查询
2 为每个关键词统计每个文档的总点击量
3 执行信号聚合 SQL 查询
4 将结果写入新的 signals_boosting 集合
列表 4.6 最重要的部分是signals_aggregation_query,它被定义为可读性的 SQL 查询。对于每个查询,我们将获取用户在搜索结果中点击过的文档列表,以及文档被点击的次数。通过按每个查询的点击次数对文档进行排序,我们得到一个按流行度排序的文档列表。
这里的想法是,用户倾向于选择他们认为最相关的产品,因此如果我们提升这些文档,我们预计我们的顶级搜索结果将变得更加相关。我们将在下一个列表中通过使用这些聚合计数作为信号提升来测试这个理论。让我们回顾一下之前的ipad查询。
列表 4.7 使用信号提升来提高相关性的搜索
def search_for_boosts(query, collection, query_field="query"):
boosts_request = {"query": query,
"query_fields": [query_field],
"return_fields": ["query", "doc", "boost"],
"limit": 10,
"order_by": [("boost", "desc")]}
response = collection.search(**boosts_request)
return response["docs"]
def create_boosts_query(boost_documents):
print(f"Boost Documents: \n{boost_documents}")
boosts = " ".join([f'"{b["doc"]}"^{b["boost"]}'
for b in boost_documents])
print(f"\nBoost Query: \n{boosts}\n")
return boosts
query = "ipad"
boost_docs = search_for_boosts(query, signals_boosting_collection)
boosts_query = create_boosts_query(boost_docs)
request = product_search_request(query)
request["query_boosts"] = boosts_query
response = products_collection.search(**request)
display_product_search(query, response["docs"])
提升文档:
[{"query": "ipad", "doc": "885909457588", "boost": 966},
{"query": "ipad", "doc": "885909457595", "boost": 205},
{"query": "ipad", "doc": "885909471812", "boost": 202},
{"query": "ipad", "doc": "886111287055", "boost": 109},
{"query": "ipad", "doc": "843404073153", "boost": 73},
{"query": "ipad", "doc": "635753493559", "boost": 62},
{"query": "ipad", "doc": "885909457601", "boost": 62},
{"query": "ipad", "doc": "885909472376", "boost": 61},
{"query": "ipad", "doc": "610839379408", "boost": 29},
{"query": "ipad", "doc": "884962753071", "boost": 28}]
提升查询:
"885909457588"⁹⁶⁶ "885909457595"²⁰⁵ "885909471812"²⁰² "886111287055"¹⁰⁹
"843404073153"⁷³ "635753493559"⁶² "885909457601"⁶² "885909472376"⁶¹
"610839379408"²⁹ "884962753071"²⁸
列表 4.7 中的查询做了两件值得注意的事情:
-
它查询
signals_boosting辅助集合中按提升排序的文档,并将这些信号提升转换为另一个查询。 -
然后,它将这个提升查询作为查询时间的提升传递给搜索引擎,作为
query_boosts参数。在 Solr(我们的默认搜索引擎)的情况下,这内部转换为在搜索请求中添加一个boost参数sum(1,query($boost_query)),将相关性分数乘以1(因此总是增加)加上boost_query计算出的相关性分数。(如果你想要复习如何通过函数和乘法提升来影响排名,请参阅第 3.2 节。)
如果你还记得图 4.3,我们原始的ipad关键字搜索主要返回了 iPad 配件,而不是实际的 iPad 设备。图 4.5 展示了在原始查询上应用信号提升后的改进结果。

图 4.5 展示了启用信号提升的搜索结果。与之前只显示 iPad 配件不同,我们现在看到了实际的 iPad,因为我们基于用户选择的互动文档进行了众包。
新的结果比仅关键字的结果要好得多。我们现在看到了用户更有可能寻找的产品——iPad!你可以期待在你的搜索引擎中,大多数其他流行查询也会看到类似的改进。当然,当我们向下移动到流行产品列表时,信号提升的相关性改进将开始下降,并且由于信号不足,我们甚至可能会降低相关性。幸运的是,我们将介绍许多其他技术来提高信号量不足的查询的相关性。
本节的目标是向您展示实现端到端反射智能模型的初始、具体示例。在这个实现中使用的信号聚合非常简单,尽管结果不言自明。在实现信号增强模型时有许多考虑和细微差别——是在查询时间还是索引时间增强,如何增加新信号与旧信号之间的权重,如何避免恶意用户通过生成虚假信号试图在搜索结果中提升特定产品,如何引入和融合来自不同来源的信号,等等。我们将在第八章中详细讨论这些主题。
让我们暂时放下流行的相关性提升和信号增强,讨论几种其他类型的反射智能模型。
4.2.3 通过协同过滤实现个性化相关性
现在我们来看一种称为协同过滤的反射智能方法,我们将它归类为个性化相关性。而流行的相关性确定通常在许多用户中哪些结果最受欢迎,个性化相关性则专注于确定哪些项目最有可能对特定用户相关。
协同过滤是使用关于某些用户偏好的观察来预测其他用户偏好的过程。它是推荐引擎中最受欢迎的算法类型,也是许多网站上常见的“喜欢这个项目的用户还喜欢这些项目”推荐列表的来源。图 4.6 展示了协同过滤如何遵循我们之前看到的信号增强模型的相同反射智能反馈循环。

图 4.6 用户到项目推荐的协同过滤。根据他的过去行为,我们的用户(Alonzo)接收到基于其他用户喜欢的项目的推荐,其中这些用户也与其他一些项目与 Alonzo 进行了交互。
与信号增强类似,协同过滤涉及一个连续的反馈循环。收集信号,通过这些信号构建模型,由这些模型生成推荐,然后记录与这些推荐的交互作为额外的信号。协同过滤方法通常生成一个用户-项目交互矩阵,将每个用户映射到每个项目(文档),每个用户和项目之间的关系强度基于积极的交互强度(点击、购买、评分等)。
如果交互矩阵足够丰富,就可以从中推断出任何具有交互数据的用户或项目的推荐。这是通过直接查找与同一项目交互的其他用户,然后增强那些用户也交互的其他项目(类似于信号增强)来实现的。然而,如果用户-项目交互矩阵过于稀疏,通常需要应用矩阵分解方法。
矩阵分解是将用户-项目交互矩阵分解成两个矩阵的过程:一个将用户映射到潜在特征(或因素),另一个将那些潜在因素映射到项目。这与我们在第三章中描述的降维方法类似,我们当时从使用包含倒排索引中每个单词特征的大多数精确关键词(一个包括倒排索引中每个单词特征的向量)来表示食品项目,转变为使用更少的具有意义的维度(一个包含八个特征描述食品项目的向量)来压缩数据。这种矩阵分解使得通过将有限的信号数据提炼成更少的、更有意义的维度,从而更好地泛化项目之间的相似性,从而能够推导出用户对项目的偏好以及项目之间的相似性。
在协同过滤的矩阵分解背景下,潜在因素代表了我们文档的属性,这些属性被学习为用户之间共享兴趣的重要指标。通过根据这些因素匹配其他文档,我们正在使用众包来找到其他具有相同共享兴趣的相似文档。
尽管协同过滤在基于众包相关性学习用户兴趣和品味方面可能非常强大,但它存在一个被称为冷启动问题的主要缺陷。这是一个结果返回依赖于信号存在的情况,但新文档由于从未生成信号而不会被返回。这创造了一个恶性循环,新内容不太可能被展示给用户(生成信号的前提),因为它尚未生成任何信号(这是内容被展示所必需的)。在一定程度上,信号增强模型也表现出类似的问题,即已经流行的文档往往会获得更高的提升,从而获得更多的信号,而未知的文档则继续没有信号增强。这个过程创造了一个自我强化的循环,可能导致搜索结果缺乏多样性。这个问题被称为展示偏差,我们将在第十二章中展示如何克服它。
你也可以通过其他方式生成推荐,例如通过基于内容的推荐,我们将在下一章(5.4.6 节)中探讨。然而,协同过滤是独特的,因为它可以在不知道任何关于文档内容的情况下,学习用户对其他文档的偏好和口味。这是因为所有决策都是通过观察用户与内容的互动,并根据这些观察确定相似性的强度来做出的。我们将在第九章实现个性化搜索时,更深入地探讨协同过滤。
除了仅利用流行和个性化相关性模型(在文档已经具有信号时表现最佳)之外,搜索引擎还可以从一种更广义的相关性模型中受益,该模型可以应用于所有搜索和文档。这有助于解决冷启动问题。接下来,我们将探讨如何通过称为“学习排序”的技术将众包相关性进行推广。
4.2.4 通过学习排序实现广义相关性
由于信号增强(流行相关性,4.2.2 节)和协同过滤(个性化相关性,4.2.3 节)仅适用于已经具有信号的文档,因此在文档获得流量之前,大量查询不会受益。这就是学习排序作为一种广义相关性的形式证明有价值的地方。
学习排序(LTR),也称为机器学习排序,是构建和使用一种排序分类器的过程,该分类器可以评估任何文档与任何任意查询的匹配程度。你可以将排序分类器视为一个训练好的相关性模型。而不是手动调整搜索增强和其他参数,LTR 过程训练了一个机器学习模型,该模型可以理解你文档的重要特征,然后适当地评分搜索结果。图 4.7 展示了推出 LTR 的一般流程。

图 4.7 学习排序(广义相关性)。排序分类器是根据用户对每个查询(训练集)中已知文档相关性的判断构建的。然后使用该排序分类器模型重新排序搜索结果,以便排名靠前的文档更具相关性。
在 LTR 系统中,与信号增强和协同过滤中相同的高级反射智能过程适用(参见图 4.2)。区别在于 LTR 可以使用相关性判断列表(查询到其理想排名文档集的映射)来自动训练一个相关性模型,然后可以将其普遍应用于所有查询。您将看到图 4.7 中“构建排名分类器”步骤的输出是一个相关性特征模型(title_match_any_terms、is_known_category、popularity和content_age),并且该模型定期部署到生产搜索引擎中以提高搜索结果排名。一个非常简单的机器学习排名模型的特征可能看起来像这样,但并没有要求排名分类器必须是可解释或可解释的,而且许多基于深度学习的先进排名分类器都不是。
在图 4.7 中,请注意,实时用户流从对ipad的查询开始。然后,初始搜索结果通过部署的排序学习分类器运行,该分类器返回最终的重新排序搜索结果集。由于排名分类器通常比传统的基于关键词的排名相关性模型更智能,并且使用更复杂的排名参数,因此通常使用排名分类器对搜索引擎中所有匹配的文档进行评分会非常慢。相反,LTR 通常会使用一个初始的、更快的排名函数(如 BM25)来找到前 N 个文档(通常是数百或数千个文档),然后只将这部分文档通过排名分类器。虽然可以使用排名分类器作为主要的相关性函数而不是应用这种重新排序技术,但更常见的是看到重新排序的方法,因为它通常要快得多,同时仍然产生大约相同的结果。
LTR 可以使用显式相关性判断(由专家手动创建)或隐式判断(从用户信号中提取),或者两者的组合。我们将在第十章至第十二章中介绍从显式和隐式判断列表实现 LTR 的示例。
4.2.5 其他反射智能模型
除了深入探讨信号增强(第八章)、协同过滤(第九章)和排序学习(第十章)之外,我们将在本书中探索许多其他类型的反射智能。在第六章中,我们将探讨挖掘用户查询以自动学习特定领域的短语、常见拼写错误、同义词和相关术语,而在第十一章至第十二章中,我们将探讨从用户交互中自动学习相关性判断的自动化方法,以便我们可以自动生成用于有趣机器学习方法的训练数据。
通常,用户与内容之间的每一次交互都会创建一个连接——图中的一个边——我们可以用它来理解新兴关系并得出更深入的见解。图 4.8 展示了通过探索这个交互图我们可以学习到的各种关系。相同的传入信号数据可以通过不同的信号聚合和机器学习方法进行处理,以学习
-
用户与项目之间的相似性(用户-项目推荐)
-
项目与项目之间的相似性(项目-项目推荐)
-
可以生成用户兴趣概要的特定属性偏好
-
查询与项目之间的相似性

图 4.8 多种反射智能模型。最左边的框表示推荐中的用户到项目的相似性,接下来显示的是根据用户个人资料学习特定属性偏好,第三个显示的是根据推荐学习项目到项目的相似性,最右边显示的是学习查询到项目的推荐。
我们将在接下来的章节中继续探讨这些技术,但记住,信号数据包含了一个宝库的潜在见解,并且通常提供与用户交互的文档内容一样多的好处。反射智能和众包不仅限于我们描述的信号提升、协同过滤和学习排序技术。它们也可以从内容而不是信号中得出,正如我们将在下一节中讨论的。
4.2.6 从内容中进行众包
虽然我们通常认为众包是要求用户提供输入,但我们在这个章节中看到,隐式反馈通常可以在许多用户信号中提供同样多的甚至更多的价值。虽然这个章节完全专注于使用用户信号来进行这种众包,但也很重要的是要指出,内容本身也可以用作你人工智能搜索平台的众包智能。
例如,如果你试图了解你文档的一般质量,你可能能够查看客户评论来生成产品评分或查看产品是否被报告为滥用或垃圾邮件。如果客户留下了评论,你可能能够在文本上运行情感分析算法,以确定评论是积极的、中性的还是消极的。根据检测到的情感,你可以相应地提高或惩罚源文档。这个过程本质上是从用户提交的内容中提取信号,因此它仍然是一种众包形式,尽管是从用户提供的其他内容中进行的。
我们提到,在第六章中,我们将介绍如何挖掘用户信号来自动学习特定领域的术语(短语、拼写错误、同义词等)。就像您可以通过用户查询和交互来学习这种术语一样,您也应该意识到文档通常是由人编写的,因此,术语之间非常相似的关系因此反映在书面内容中。我们将在下一章进一步探讨这些基于内容的关系。
存在的最著名的搜索算法之一是Page Rank算法——这个突破性的算法最初使谷歌成为最相关的网络搜索引擎而闻名。Page Rank 超越了任何给定网页的文本,并查看所有其他网页创建者的隐含行为,以了解他们如何链接到其他网页。通过测量 incoming 和 outgoing 链接,可以测量网页的“质量”,假设网站更有可能链接到高质量、更权威的来源,而那些高质量来源不太可能链接到低质量来源。这种超越单个文档内容并将其与其他文档相关联的想法——无论是通过它们之间的直接链接、用户评论或反馈、任何其他用户交互,甚至是文档中术语的不同、细微的使用方式——是非常强大的。利用您的内容和用户的所有可用信息的艺术和科学是构建高度相关的 AI 搜索引擎的关键。在第五章中,我们将探讨知识图的概念以及我们如何使用文档之间隐含链接中嵌入的一些关系来自动进一步理解特定领域。
摘要
-
内容、信号和模型(这些是从内容和信号中派生出来的)是驱动一个 AI 搜索引擎的三个主要“燃料”来源,其中信号是众包相关性的主要来源。
-
反射智能是创建学习反馈循环的过程,它从每个用户交互中改进,并将学习到的智能反映回来,以持续提高未来结果的相关性。
-
信号增强是一种“普及化相关性”的形式,通常对您最高流量、最受欢迎的查询影响最大。
-
协同过滤是一种“个性化相关性”的形式,它可以使用用户与物品的交互模式来学习用户偏好或物品之间关系的强度,然后根据这些学习到的关系推荐类似物品。
-
学习排序(LTR)是一种“广义相关性”的形式,它是基于相关性判断列表(查询映射到正确排序的文档)来训练排序分类器的过程。LTR 可以应用于对所有文档进行排序,并避免冷启动问题。
-
其他类型的反射智能存在,包括使用内容(而不仅仅是信号)进行众包相关性的技术。
第二部分 学习特定领域意图
在第一部分,你学习了基于关键词(使用 TF-IDF 和 BM25)和数值向量(使用余弦和点积)的匹配和排名机制。你还了解到了众包相关性排名的概述。然而,在执行这些排名技术之前,能够正确解读用户的查询并运行一个理解用户意图的适当搜索是非常重要的。如果运行了错误的查询并且匹配了错误的文档,那么在这些不良结果上进行的任何排名逻辑都无法克服误解的查询。
正确解读用户的查询是第二部分的重点。这一步骤常常被忽视,因为它非常具有领域特定性,但对于满足用户的信息需求至关重要。在第五章,你将学习如何遍历语义知识图谱,这是在第二章中引入的包含在你搜索引擎内部的超结构化数据的巨大图。这些图构成了搜索引擎的基础,并能够推断出数据中特定领域术语的细微含义。在第六章,你将学习如何执行查询意图分类,使用这些图来消除词语和短语的歧义,以及如何利用你的内容和用户行为信号来学习特定领域的术语、相关术语、拼写错误和术语的变体形式。在第七章,我们将把所有你学到的知识结合起来,构建一个查询管道以执行语义搜索。这个管道将解析用户查询的特定领域意图,并将它们重写以向搜索引擎表示用户意图的更准确的语义解释,从而产生更加相关的搜索结果。
第五章:知识图谱学习
本章涵盖了
-
构建和使用知识图谱
-
实现开放信息提取以从文本生成知识图谱
-
使用语义知识图谱发现任意语义关系
-
使用知识图谱进行查询扩展和重写
-
使用知识图谱解释文档
在上一章中,我们主要关注基于用户行为信号学习查询和文档之间的相似性。在第二章中,我们也讨论了文本文档内容,而不是“非结构化数据”,更像是一个包含丰富语义关系的巨大超结构化数据图,这些关系连接了我们文档集合中的许多字符序列、术语和短语。
在本章中,我们将展示如何利用我们内容中的这个巨大的语义关系图来更好地解释特定领域的术语。我们将通过使用传统的知识图谱和语义知识图谱来实现这一目标,前者能够在一个领域内显式地建模关系,而后者能够在一个领域内实时推断细微的语义关系。
语义知识图谱是一种简单的语言模型(语言模型表示一系列单词的概率分布)。我们将使用语义知识图谱作为理解后续章节中大型语言模型(LLMs)的垫脚石。LLMs 通常是经过数十亿参数和大量数据(通常包括大部分互联网)训练的深度神经网络,以模拟人类知识的通用表示。然而,语义知识图谱是可查询的语言模型,仅代表您搜索索引中实际存在的那些关系。虽然语义知识图谱不具备在语言上进行一般推理的能力,但它们在特定领域的上下文推理方面可以非常强大,正如我们将看到的。
我们还将在这个章节中玩转几个有趣的数据集,以展示知识图谱如何构建和应用,以及如何在不同领域改善查询理解。
5.1 与知识图谱一起工作
在第 2.4 节中,我们介绍了知识图谱的概念,并讨论了它们与其他类型知识模型(如本体、分类法、同义词和备选标签)之间的关系。如果您还记得,知识图谱整合了所有这些其他类型的知识模型,因此在我们本章的构建过程中,我们将把它们统称为“知识图谱”。
知识图谱(或任何图)是通过节点(也称为顶点)和边来表示的。节点是知识图谱中代表的一个实体(如术语、人、地点、事物或概念),而边则代表两个节点之间的关系。图 5.1 显示了显示节点和边的图的一个示例。

图 5.1 图结构。图由表示实体的节点(也称为“顶点”)和表示节点之间关系的边组成。图提供了一种通过遍历(或“跟随”)节点之间的边来建模知识和推断新见解的方法。
在这个图中,您可以看到四个表示作者的节点,一个表示他们共同撰写的研究论文的节点,一个表示论文所展示和发表的学术会议,然后是表示会议举行的城市、省份、国家和日期的节点。通过遍历(或“跟随”)节点之间的独立边,您可能会推断出其中一位作者在 2016 年 10 月的蒙特利尔,加拿大。虽然任何具有节点和边的结构都被称为图,但这个特定的图代表了事实知识,因此也被称为知识图谱。
建立和表示知识图谱的方法有很多,既可以通过显式地将数据建模为节点和边,也可以通过实时动态地(发现)从您的数据中生成节点和边。后者被称为语义知识图谱。在本章中,我们将通过各种示例进行讲解,包括手动构建显式知识图谱、自动生成显式知识图谱以及使用已存在于您的搜索索引中的语义知识图谱。
要开始使用知识图谱,您实际上有三个选择:
-
使用图数据库(Neo4j、Apache TinkerPop、ArangoDB 等)从头开始构建知识图谱
-
插入一个现有的知识图谱(ConceptNet、DBpedia、大型语言模型等)
-
从您的数据中自动生成知识图谱,直接使用您的内容来提取知识
每种方法都有其优势和劣势,尽管这些方法并不一定是相互排斥的。如果您正在构建一个通用知识搜索引擎(如网络搜索引擎),利用现有的知识图谱或大型语言模型是一个很好的起点。然而,如果您的搜索引擎更具有领域特定性,那么您的特定领域实体和术语可能不会出现在现有的图谱中,这就需要您创建一个定制的知识图谱。
在本章中,我们将主要关注第三个选项:从您的内容中自动生成知识图谱。其他两种技术已在外部材料中得到很好的介绍,使用了像 SPARQL、RDF 三元组、Apache Jena 或现有的知识图谱如 DBpedia 和 Yago 等技术。您仍然需要能够覆盖您的知识图谱并添加自定义内容,因此我们将包括如何将显式定义的知识图谱(使用特定预定义关系列表构建)和隐式定义的知识图谱(从数据中动态发现的自动生成关系)集成到您的搜索平台中的示例。
5.2 将我们的搜索引擎作为知识图谱使用
许多组织投入大量资源为其组织构建知识图谱,但难以将其集成到其搜索引擎中。幸运的是,我们为示例选择了默认的搜索引擎实现(Apache Solr),它内置了显式的图遍历功能,因此无需引入新的外部系统来实现或遍历我们的知识图谱。
虽然使用支持更复杂图遍历语义的外部图数据库(如 Neo4J 或 ArangoDB)可能有一些优势,但使用这样的外部系统会使协调请求、保持数据同步和基础设施管理变得更加复杂。此外,由于某些类型的图操作只能在搜索引擎中有效地执行(例如,使用倒排索引进行的语义知识图谱遍历,我们将在稍后遇到),将搜索引擎作为统一平台,用于搜索和知识图谱功能,可以减少我们需要管理的系统数量。
我们将在第七章中详细讨论实现语义搜索系统,包括语义查询解析、短语提取、拼写检测、同义词扩展和查询重写,所有这些都将建模为显式构建的知识图谱。由于当前章节的目的是专注于知识图谱学习,我们将把关于查询时集成模式的讨论大部分留到第七章,那时我们可以将本章和第六章的内容结合起来,构建适当的知识图谱结构。
5.3 从内容中自动提取知识图谱
虽然您需要能够修改您知识图谱中的节点和边,但手动维护大规模知识图谱是非常具有挑战性的。手动维护的知识图谱需要大量的专业知识,必须积极更新以适应信息的变化,并且容易受到维护者偏见和错误的干扰。
开放信息提取 是自然语言处理(NLP)研究的一个发展中的领域。开放信息提取旨在直接从您的文本内容中提取事实。这通常是通过使用 NLP 库和语言模型来解析句子并评估它们之间的依赖图来完成的。依赖图 是对句子中每个单词和短语的词性分解,以及指示哪些单词指代哪些其他单词的指示。
知识图谱提取的较新方法往往使用专门针对实体提取训练的 LLM(大型语言模型),例如 UniRel(统一表示和交互用于联合关系三元组提取)和 REBEL(通过端到端语言生成进行关系提取)。由于 LLM 方法能够比传统的基于依存图的方法更细致地表示和提取实体之间的关系,它们很可能会随着时间的推移成为知识图谱提取的标准。然而,为了本章的学习,我们将专注于基于依存图的方法,因为它将为理解从文本中提取知识图谱的机制以及定制关系提取模式提供更好的基础。如果需要,你总是可以在以后切换到一个更先进的 LLM 驱动的方法,如果它更适合你的需求。
在本节中,我们将使用语言模型和依存图来提取两种不同类型的关系:任意关系和上下位关系。
5.3.1 从文本中提取任意关系
考虑到文本的超结构性质以及典型句子和段落中表达出的丰富关系,我们可以合理地认为我们应该能够识别句子的主语和宾语以及它们之间的关系。在本节中,我们将专注于从文本内容中的句子提取实体之间的任意关系。
通过分析句子中的名词和动词,通常可以推断出句子中存在的事实,并将该事实映射到一个 RDF 三元组(也称为语义三元组)。资源描述框架(RDF)是一种用于表示图和关系的数据模型。一个RDF 三元组是一个包含三个部分的数据结构,代表一个主题(起始节点)、关系(边)和对象(结束节点)。例如,在句子“Colin attends Riverside High School”中,动词“attends”可以被提取为一个关系类型,将主题(“Colin”)与对象(“Riverside High School”)连接起来。因此,RDF 三元组是("Colin", "attends", "Riverside High School")。
列表 5.1 展示了如何使用基于 Python 的 spaCy 库从文本内容中提取事实的示例。SpaCy 是一个流行的自然语言处理库,它包含了用于词性标注、依存句法分析、文本分类和命名实体识别的最先进的统计神经网络模型。
列表 5.1 提取关系和解决共指
def extract_relationships(text, lang_model, coref_model):
resolved_text = resolve_coreferences(text, coref_model) #1
sentences = get_sentences(resolved_text, lang_model) #2
return resolve_facts(sentences, lang_model) #3
text = """
Data Scientists build machine learning models. They also write code.
Companies employ Data Scientists.
Software Engineers also write code. Companies employ Software Engineers.
"""
lang_model = spacy.load("en_core_web_sm")
coref_model = spacy.load("en_coreference_web_trf") #4
graph = extract_relationships(text, lang_model, coref_model)
print(graph)
1 解析实体,例如用名词替换代词
2 对文本进行词性分类
3 生成 RDF 三元组
4 用于共指消解的 spaCy 实验模型
输出:
sentence: Data Scientists build machine learning models.
dependence_parse: ['nsubj', 'ROOT', 'dobj', 'punct']
---------------------
sentence: Data Scientists also write code.
dependence_parse: ['nsubj', 'advmod', 'ROOT', 'dobj', 'punct']
---------------------
sentence: Companies employ Data Scientists.
dependence_parse: ['nsubj', 'ROOT', 'dobj', 'punct']
---------------------
sentence: Software Engineers also write code.
dependence_parse: ['nsubj', 'advmod', 'ROOT', 'dobj', 'punct']
---------------------
sentence: Companies employ Software Engineers.
dependence_parse: ['nsubj', 'ROOT', 'dobj', 'punct']
---------------------
[['Data Scientists', 'build', 'machine learning models'],
['Data Scientists', 'write', 'code'],
['Companies', 'employ', 'Data Scientists'],
['Software Engineers', 'write', 'code'],
['Companies', 'employ', 'Software Engineers']]
如您所见,示例代码已经提取了文本内容,将其解析为句子,并确定了这些句子中的主语、关系和宾语。然后可以将这些 RDF 三元组保存到显式构建的知识图中进行遍历。
图 5.2 展示了从该图中提取的图形的可视化。虽然这个例子很简单,但高级算法可以从更复杂的语言模式中提取事实。在代码示例中,我们使用了 spaCy 库,该库使用基于深度学习的神经语言模型来检测输入文本中的词性、短语、依存关系和共指。然后我们采用的机制是将这些语言输出解析为 RDF 三元组,这个机制更依赖于规则,遵循英语语言中的已知语义模式。
不幸的是,以这种方式将任意动词解析为关系时,提取的关系可能会变得相当嘈杂。由于动词的变形不同,有同义词,并且有重叠的意义,通常需要修剪、合并以及其他方式清理任何任意提取的关系列表。
相比之下,某些关系类型要简单得多,例如统计关系(“is related to”)和下位词(“is a”)。我们将在本章的剩余部分主要关注使用这两种特殊类型,从下位词开始。

图 5.2 提取的知识图。该图中的节点和边是基于词性模式从文本内容中自动提取的。
5.3.2 从文本中提取下位词和上位词
虽然在知识图中将任意动词映射到干净的关系列表可能具有挑战性,但提取下位词和上位词可能要容易得多。下位词是与更一般形式的实体保持“是”或“是实例”关系的实体,更一般的形式被称为上位词。例如,对于“phillips head”( Phillips 头)、“screwdriver”(螺丝刀)和“tool”(工具)之间的关系,我们会说“phillips head”是“screwdriver”的下位词,“tool”是“screwdriver”的上位词,而“screwdriver”既是“phillips head”的上位词也是“tool”的下位词。
从文本中提取下位词/上位词关系的一种常见且相当准确的方法是通过使用 Hearst 模式,由 Marti Hearst 在“从大型文本语料库中自动获取下位词”(在COLING 1992 Volume 2: 第 14 届国际计算语言学会议,1992 年)中描述。这些模式描述了常见的语言模板,这些模板可靠地指示句子中存在下位词。以下列表演示了此类模式的一些示例。
列表 5.2 Hearst 模式识别语义关系
simple_hearst_patterns = [
("(NP_\\w+ (, )?such as (NP_\\w+ ?(, )?(and |or )?)+)", "first"),
("(such NP_\\w+ (, )?as (NP_\\w+ ?(, )?(and |or )?)+)", "first"),
("((NP_\\w+ ?(, )?)+(and |or )?other NP_\\w+)", "last"),
("(NP_\\w+ (, )?include (NP_\\w+ ?(, )?(and |or )?)+)", "first"),
("(NP_\\w+ (, )?especially (NP_\\w+ ?(, )?(and |or )?)+)", "first")]
这五个简单的模式分别用 Python 元组表示,第一个元素是一个 正则表达式,第二个元素是在模式匹配中的位置(即 first 或 last)。如果你不熟悉正则表达式,它们提供了一种在字符串中进行模式匹配的通用且强大的语法。任何你看到 NP 字符的地方,这表示句子中存在一个 名词短语。元组的第二个元素(first 或 last)指定的位置表示句子中哪个名词短语代表上位词,所有其他与模式匹配的名词短语被认为是下位词。
在以下列表中,我们运行了几乎 50 个这样的 Hearst 模式,以匹配我们内容中许多“是”关系的组合。
列表 5.3 使用 Hearst 模式提取下位词关系
text_content = """Many data scientists have skills such as machine learning,
python, deep learning, apache spark, among others. Job candidates most
prefer job benefits such as commute time, company culture, and salary.
Google, Apple, or other tech companies might sponsor the conference.
Big cities such as San Francisco, Miami, and New York often appeal to
new graduates. Job roles such as Software Engineer, Registered Nurse,
and DevOps Engineer are in high demand. There are job benefits including
health insurance and pto."""
extracted_relationships = HearstPatterns().find_hyponyms(text_content)
facts = [[pair[0], "is_a", pair[1]] for pair in extracted_relationships]
print(*facts, sep="\n")
输出:
['machine learning', 'is_a', 'skill']
['python', 'is_a', 'skill']
['deep learning', 'is_a', 'skill']
['apache spark', 'is_a', 'skill']
['commute time', 'is_a', 'job benefit']
['company culture', 'is_a', 'job benefit']
['salary', 'is_a', 'job benefit']
['Google', 'is_a', 'tech company']
['Apple', 'is_a', 'tech company']
['San Francisco', 'is_a', 'big city']
['Miami', 'is_a', 'big city']
['New York', 'is_a', 'big city']
['Software Engineer', 'is_a', 'Job role']
['Registered Nurse', 'is_a', 'Job role']
['DevOps Engineer', 'is_a', 'Job role']
['health insurance', 'is_a', 'job benefit']
['pto', 'is_a', 'job benefit']
如此列表所示,通过专注于提取固定类型的关系(以及最普遍的一种——“是”关系),我们可以生成一个整洁的、清晰的分类事实列表,其中更具体的术语(下位词)通过 is_a 边指向更一般的术语(上位词)。图 5.3 以视觉方式展示了这个生成的图。

图 5.3 由 Hearst 模式派生的知识图谱。我们可以看到所有节点都通过 is_a 边与其他节点相连。
通过利用 Hearst 模式,可以显著减少任意关系提取中存在的不一致性和噪声。我们仍然可能对相似术语之间的关系存在歧义(例如,拼写错误、替代拼写、已知短语或同义词),但这些更容易解决。实际上,我们将在下一章中讨论如何从你的信号和内容中学习这种特定领域的语言,以便在解释传入的用户查询时使用。
虽然将信息从我们的文本中提取到显式的知识图谱中以便后续遍历可能很有用,但现实是这种提取是一个有损过程,因为项目的表示与我们的内容中这些项目的原始上下文(周围的文本和包含文本的文档)断开连接。在下一节中,我们将介绍一种完全不同的知识图谱——语义知识图谱,它优化了实时遍历和排序我们内容中术语和短语之间关系的能力,而无需显式构建,也不会将术语与其原始文本上下文分开。
5.4 通过遍历语义知识图谱学习意图
在第二章的第 2.1 节和第 2.2 节中,我们讨论了文本内容是“非结构化数据”的神话,以及实际上文本文档如何代表超结构化数据。我们讨论了分布假设(“一词应当通过其伴随的词群来认识”)并探讨了如何将字符序列、术语、短语以及其他任意术语序列视为模糊的外键,这些外键在文档之间关联相似的概念。我们还讨论了这些文档之间的链接可以被视为一个巨大关系图中的边,使我们能够学习文档语料库中术语和实体的上下文意义。
在本节中,我们将介绍一个语义知识图,这是一个工具和技术,将使我们能够遍历文档中存在的巨大语义关系图。
5.4.1 什么是语义知识图?
一个语义知识图(SKG)是一个“紧凑、自动生成的模型,用于实时遍历和排序领域内任何关系”。¹我们可以将 SKG 视为一个搜索引擎,它不是匹配和排序文档,而是找到并排序与查询最佳匹配的术语。
例如,如果我们索引了一个关于健康主题的文档集合,并搜索advil,那么一个 SKG 会自动(无需手动创建列表或数据建模)返回如下值:
advil 0.71
motrin 0.60
aleve 0.47
ibuprofen 0.38
alleve 0.37
这样的结果可以被视为“动态同义词”,但与具有相同意义的术语不同,它们更像是概念上相关的术语。你可以将针对advil的词汇搜索查询扩展到包括这些其他术语,以提高搜索结果的召回率或提升与advil概念匹配的文档,而不仅仅是包含五个字符a、d、v、i、l的字符串。
除了找到相关术语之外,一个语义知识图(SKG)可以在你的倒排索引字段之间进行遍历(“找到与这个职位名称最相关的技能”),深入多个层级(“找到与这个查询最相关的职位名称,然后找到与这个查询和每个职位名称最相关的技能”),并且可以使用你发送给搜索引擎的任何任意查询作为图遍历的节点来找到任何字段中的语义相关术语。
语义知识图的使用案例多种多样。它们可用于查询扩展、生成基于内容推荐、查询分类、查询消歧、异常检测、数据清洗和预测分析。我们将在本章剩余部分探讨其中的一些,但首先让我们为测试我们的 SKG 设置一些数据集。
5.4.2 索引数据集
SKG 在那些文档中使用的术语重叠度更高的数据集上工作得最好。两个词在文档中出现的频率越高,我们就能越好地确定这些术语在统计上出现的频率是否比预期的高。
虽然维基百科对于许多用例来说通常是一个好的起点数据集,但它通常只有一个关于主要主题的权威页面,因此大多数文档之间没有显著的重叠,这使得维基百科对于这个用例来说是一个较差的数据集。相比之下,大多数其他用户提交内容的网站(问题、论坛帖子、职位发布、社交媒体帖子、评论)对于 SKG 用例来说往往拥有优秀的数据集。
对于本章,我们选择了两个主要数据集:一个职位数据集(职位板发布)以及一系列包含以下论坛帖子在内的 Stack Exchange 数据存档:
-
健康
-
科幻
-
开发运维
-
旅行
-
烹饪
5.4.3 SKG 的结构
为了最好地利用 SKG,了解其底层结构如何工作是有用的。
与必须显式建模到节点和边中的传统知识图谱不同,SKG 是从您的搜索引擎的底层倒排索引中实例化的。这意味着您要产生一个 SKG 所需要做的只是将文档索引到搜索引擎中。不需要额外的数据建模。
倒排索引和相应的正向索引随后作为底层数据结构,使得在您的文档集合中实时遍历和排名任何任意语义关系成为可能。
图 5.4 展示了文档如何被添加到正向索引和倒排索引中。在图的左侧,你可以看到三个文档,每个文档都有一个job_title字段、一个desc字段和一个skills字段。图的右侧显示了这些文档如何映射到您的搜索引擎中。我们看到倒排索引将每个字段映射到一个术语列表,然后将每个术语映射到一个包含文档列表的帖子列表(包括文档中的位置以及图中未包含的一些其他数据)。这使得查找任何字段中的任何术语并找到包含该术语的所有文档变得快速且高效。

图 5.4 倒排索引和正向索引。文档被添加到倒排索引中,该索引将文档映射到术语列表,并映射到正向索引,该索引将术语映射回文档列表。能够映射两个方向的能力对于图遍历和关系发现将证明非常重要。
除了众所周知的倒排索引之外,你还可以在图 5.4 的中心看到不太为人所知的正向索引。正向索引可以被视为一个非倒排索引:对于每个字段,它将每个文档映射到包含在该文档中的术语列表。正向索引是搜索引擎用来生成搜索结果上的分面(也称为聚合)的工具,它显示了来自一组文档的每个字段的最高值。在基于 Lucene 的搜索引擎(如 Solr、OpenSearch 和 Elasticsearch)中,正向索引通常通过在字段上启用一个称为doc values的功能来在索引时间生成一个字段。或者,Apache Solr 还允许你在查询时间通过“非倒排”内存中的倒排索引来生成相同的正向索引,即使在那些未将 doc values 添加到索引的字段上也能启用分面功能。
如果你具备通过倒排索引(从术语到文档的遍历)搜索任意查询并找到文档集合的能力,同时你也具备从任意文档集合中查找术语的能力(从文档到术语的遍历),这意味着通过进行两次遍历(术语到文档到术语),你可以找到出现在任何匹配查询的文档中的所有相关术语。图 5.5 展示了这种遍历如何发生,包括数据结构视图、集合论视图和图形视图。

图 5.5 SKG 的三个表示。数据结构视图显示了术语映射到文档集合,集合论视图显示了文档集合的交集如何形成它们之间的关系,图形视图显示了节点和边。
在数据结构视图中,它代表我们的倒排和正向索引,我们可以看到术语如何根据它们是否出现在其中与文档相关联。这些关系链接仅在集合论视图中任何两个节点(在这种情况下是术语)出现的文档集合之间存在交集时才存在。最后,图形视图展示了同一底层数据的第三种视图,在这种情况下,我们看到的是节点(而不是文档集合)和边(而不是相交的文档集合)。本质上,SKG 存在于已构建和更新的倒排索引之上,这发生在搜索引擎索引内容时。
我们通常认为搜索引擎的主要功能是接受查询,找到匹配的文档,并按相关性排序返回这些文档。我们在第三章中专门讨论了这一过程,包括匹配(3.2.4-3.2.6 节)、TF-IDF 排序(3.1 节)和常用的 BM25 排序函数(3.2.1 节)。然而,在 SKG 中,我们关注的是匹配和排序相关术语,而不是相关文档。
任何任意的查询(你可以解析为文档集的任何内容)都可以成为你图中的一个节点,你可以从该节点遍历到任何文档字段中的任何其他术语(或任意查询)。此外,由于两个节点之间的边遍历每次都使用倒排索引(术语到文档)和正向索引(文档到术语),因此将这些遍历串联成多级图遍历是微不足道的,如图 5.6 所示。
在图中,数据结构视图显示了从技能节点(Java)到其他技能节点层(Java、肿瘤学、Hibernate和Scala),再到职位名称节点层(软件工程师、数据科学家和Java 开发者)的遍历。你可以看到并非所有节点都是连接的——例如,肿瘤学节点在图形视图中没有出现,因为没有任何原始节点可以通过任何边连接到它——没有重叠的文档。
由于并非所有可能的节点都会对任何给定的遍历相关,因此 SKG 能够对节点之间的关系进行评分和分配权重,以便在图遍历过程中优先考虑这些边,这也是非常重要的。我们将在下一节中介绍边的评分和权重分配。
5.4.4 计算边权重以衡量节点之间的相关性
由于 SKG 的主要功能是发现节点之间的相关语义关系,因此计算语义相似度的能力是至关重要的。但语义相似度究竟是什么?
如果你还记得,第 2.3 节中引入的分布假设指出,在相同语境和相似分布中出现的词语往往具有相似的意义。直观上,这很有道理——术语“疼痛”或“肿胀”在提及“艾德维尔”、“布洛芬”或“冰袋”的文档中出现的可能性,要比在随机文档中高。有趣的是,然而,“冰袋”也可能出现在包含“冷却器”、“长途旅行”或“寒冷”等术语的文档中,而“艾德维尔”和“布洛芬”可能就不会。

图 5.6 多级图遍历。在数据结构视图中,我们看到两次遍历:每次都是通过倒排索引然后是正向索引。在图形结构视图中,我们看到相应的两级遍历:从技能到技能再到职位名称。
这些示例显示了具有相似意义的词语(及其上下文),但让我们也考虑像“a”、“the”、“of”、“and”、“if”、“they”以及无数其他非常常见的停用词。这些词也会在“pain”、“swelling”、“advil”、“ibuprofen”或其他我们检查过的词语的相同上下文中大量出现。这指向分布假设的第二部分——即词语也必须以相似的方式出现。本质上,这意味着给定包含第一个术语的一些文档,任何第二个术语如果它比在包含其他随机术语的文档中更频繁地与第一个术语共现,那么它就倾向于与第一个术语在语义上相似。
实际上,由于“the”或“a”通常与几乎所有其他术语共同出现,尽管它们的共现程度很高,但它们在语义上并不被认为与这些术语相似。然而,像“pain”和“ibuprofen”这样的术语,其出现频率在统计上比任一术语与随机其他术语一起出现的频率要高得多,因此它们被认为是语义相似的。
以下方程展示了计算术语与一组文档语义相关性的方法:

其中
-
x是一个查询(通常是一个术语或术语序列),其相关性是相对于另一个查询,前景查询fg来计算的。D[x]是匹配查询x的文档集。 -
D[fg]是匹配前景查询fg的文档集。x的相关性是相对于这个前景集来计算的。 -
D[bg]是匹配背景查询bg的文档集。这个bg查询应该与x和fg无关,通常设置为匹配整个文档集合D或D的随机样本。 -
P[x]是在背景集中随机文档中找到x的概率,计算公式为![equation image]()
这种“相关性”计算(在概念上类似于正态分布中的 z 分数)依赖于“前景”文档集和“背景”文档集的概念,并使得可以在两个集合之间对术语x的分布进行统计比较。例如,如果前景集是所有匹配查询pain的文档,而背景集是所有文档,那么术语“advil”的相关性就是一个衡量“advil”在包含单词pain的文档(前景集)中出现的频率与在任意随机文档(背景集)中出现的频率之间的差异的度量。最常见的方法是使用 Sigmoid 函数来归一化相关性分数,将值映射到-1.0 和 1.0 之间,其中 0.0 表示术语之间没有关系。为了简单起见,我们将在代码和所有后续示例中依赖这个归一化值域。
如果两个术语高度相关,它们的相似度将是一个接近 1.0 的正数。如果术语高度不相关(意味着它们倾向于只在不同的领域出现),分数将更接近-1.0。最后,完全不相关的术语——如停用词——的相似度分数通常会接近零。
Apache Solr 直接在其分面 API 中构建了 SKG 功能。分面提供了从术语遍历到文档集合再到术语的能力,一个相关度聚合函数(RelatednessAgg)实现了我们刚刚描述的语义相似性计算。以下列表演示了在 Stack Exchange 健康数据集中搜索与“advil”语义相关的术语。
列表 5.4 发现与advil语义相关的术语
health_skg = get_skg(engine.get_collection("health"))
nodes_to_traverse = [{"field": "body", #1
"values": ["advil"]}, #2
{"field": "body",
"min_occurrences": 2, #3
"limit": 8}] #4
traversal = health_skg.traverse(*nodes_to_traverse) #5
print_graph(traversal, "advil") #6
1 查找起始节点的值所在的字段
2 我们的起始节点是查询“advil”。
3 通过排除至少出现这么多次的术语来减少噪声
4 返回多少个节点(术语)
5 执行图遍历
6 打印 SKG 遍历的结果
输出:
advil 0.70986
motrin 0.59897
aleve 0.4662
ibuprofen 0.38264
alleve 0.36649
tylenol 0.33048
naproxen 0.31226
acetaminophen 0.17706
正如你所见,在 Stack Exchange 健康数据集中的论坛帖子中,与advil最语义相关的术语的排名顺序是一系列类似的止痛药。这是使用分布假设通过语义相似性发现和排名术语的魔力——它为我们提供了实时自动发现关系的能力,这些关系可以进一步改进我们对传入查询的理解。
以下是一个 Solr SKG 请求,它使用了 Solr 的 JSON 分面 API 和基于函数排序的能力——我们刚刚讨论的relatedness计算。
{
"limit": 0,
"params": {
"q": "*",
"fore": "{!${defType} v=$q}",
"back": "*",
"defType": "edismax",
"f0_0_query": "advil"
},
"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",
"mincount": 2,
"limit": 8,
"sort": {"relatedness": "desc"},
"facet": {"relatedness": {"type": "func",
"func": "relatedness($fore,$back)"}
}}}}}}
列表 5.4 中的skg.traverse(*nodes_to_traverse)函数抽象了特定引擎的语法,但如果你试图理解你的特定搜索引擎或向量数据库内部如何处理这类知识图谱遍历的细微差别,你可以检查笔记本中的函数。我们将主要展示skg.traverse的抽象,但你可以始终直接调用skg.transform_request(*nodes_to_traverse)函数来查看和调试内部、特定引擎的请求。
在下一节中,我们将讨论如何将此 SKG 遍历返回的相关术语应用于提高查询的相关性。
5.4.5 使用 SKGs 进行查询扩展
仅根据搜索过程中输入的关键词进行匹配和排名并不总是提供足够的信息来找到和排名最佳结果。在这些情况下,通过动态扩展或以其他方式增强查询以包括概念上相关的术语,你可以显著提高搜索结果的质量。在本节中,我们将介绍如何生成这些相关术语,并展示几种将术语应用于提高搜索结果质量的方法。
由于其能够从任何关键词或查询开始,并在任何领域找到其他高度相关的术语,一个明显的用例是动态扩展查询以包含相关术语。这种扩展有时被称为稀疏词汇扩展,因为它在由基于(词汇)特征的查询标记的稀疏向量上操作。实现这种查询扩展的一个著名技术是 SPLADE(稀疏词汇和扩展模型),我们将在第 7.4.3 节中介绍。语义知识图谱也提供了一种生成上下文稀疏词汇扩展的绝佳方式,并且它们的好处是无需对您的数据集进行额外的微调。这使得即使文档不包含用户输入的确切关键词,也能匹配文档,但它们确实包含其他具有非常相似意义的术语。例如,对于一个用户对advil的查询,由 SKG 生成的增强术语的扩展查询可能看起来像这样:advil OR motrin⁰.59897 OR aleve⁰.4662 OR ibuprofen⁰.3824 OR . . .。
让我们通过使用来自不同领域的数据集(Stack Exchange scifi 数据集)来实现这种查询扩展的步骤进行说明。以下列表显示了此过程的第一个步骤:在 SKG 中搜索一个晦涩的术语(作为 SKG 中的一个节点)并找到相关的其他术语(作为 SKG 中的相关节点)。在这种情况下,我们将使用对vibranium的查询作为我们的起始节点。
列表 5.5 发现未知术语“vibranium”的上下文
stackexchange_skg = get_skg(engine.get_collection("stackexchange"))
query = "vibranium"
nodes_to_traverse = [{"field": "body", "values": [query]},
{"field": "body", "min_occurrences": 2, "limit": 8}]
traversal = stackexchange_skg.traverse(*nodes_to_traverse)
print_graph(traversal, query)
响应:
vibranium 0.94237
wakandan 0.8197
adamantium 0.80724
wakanda 0.79122
alloy 0.75724
maclain 0.75623
klaw 0.75222
america's 0.74002
对于任何不熟悉术语“vibranium”的人来说,它是一种强大、虚构的金属,存在于漫威漫画和电影中(通过 2018 年好莱坞热门电影《黑豹》广受欢迎)。返回的最相关术语是与“Wakandan”和“Wakanda”相关的,这是 vibranium 起源的虚构文化和国家,“adamantium”,另一种来自漫威漫画的强大(虚构)金属,以及“Maclain”和“Klaw”的名字,这些是漫威漫画中与金属 vibranium 高度相关的角色。Maclain 创造了用于制造“美国队长”盾牌的 vibranium“合金”,因此这些词的相关性。
自动生成的知识图谱在识别相关信息方面非常有效。通过使用 SKG 并将您的查询扩展到包括额外的相关上下文,您可以极大地提高搜索请求的召回率。通过增强与您的查询概念上最佳匹配的结果(而不是仅仅匹配文本),您也可能能够提高您顶级搜索结果的精确度。
以下列表演示了将原始查询以及 SKG 输出翻译成扩展查询的示例。
列表 5.6 使用 SKG 中的节点扩展查询
expansion = ""
for term, stats in traversal["graph"][0]["values"][query] \
["traversals"][0]["values"].items():
expansion += f'{term}^{stats["relatedness"]} '
expanded_query = f"{query}⁵ " + expansion
print(f"Expanded Query:\n{expanded_query}")
扩展查询:
vibranium⁵ vibranium⁰.94237 wakandan⁰.8197 adamantium⁰.80724
wakanda⁰.79122 alloy⁰.75724 maclain⁰.75623 klaw⁰.75222 america's⁰.74002
在这种情况下,我们正在进行简单的布尔 OR 搜索,以查找与原始查询 vibranium 相关的任何关键词,将原始查询项的权重提高 5 倍,并根据其语义相似度分数对后续术语对相关性分数的影响进行加权。选择将原始术语提高 5 倍是任意的——你可以选择任何值来分配与其他(扩展)术语相比的相对相关性提升。
你可能还会注意到,“vibranium”这个术语出现了两次——第一次作为原始术语,然后再次作为扩展术语(因为术语也是与其自身最语义相似的)。如果你正在搜索单个关键词,这几乎总是这种情况,但由于你的查询可能包含短语或其他结构,使得原始查询与返回的术语(如果有)不同,通常将原始查询作为扩展(重新编写)查询的一部分是一个好主意,这样用户的实际查询总是会在结果中表示出来。
虽然先前扩展的查询应该能够合理地排序结果(优先考虑匹配多个相关术语的文档),但它也高度关注召回率(扩展以包含任何相关内容),而不是精度(确保包含的内容都是相关的)。增强查询可以根据你的主要目标以多种方式构建。
重新编写的查询可以执行简单的扩展,需要匹配最小百分比或数量的术语,需要匹配原始查询中的特定术语,或者甚至只需更改相同的初始结果集的排名。以下列表展示了几个示例,使用最小匹配阈值和百分比,可以根据需要调整精度和召回率之间的平衡。
列表 5.7 不同的查询增强策略
def generate_request(query, min_match=None, boost=None):
request = {"query": query,
"query_fields": ["title", "body"]}
if min_match:
request["min_match"] = min_match
if boost:
request["query_boosts"] = boost
return request
simple_expansion = generate_request(f"{query} {expansion}", "1")
increased_conceptual_precision = \
generate_request(f"{query} {expansion}", "30%")
increased_precision_same_recall = \
generate_request(f"{query} AND ({expansion})", "2")
slightly_increased_recall = generate_request(f"{query} {expansion}", "2")
same_results_better_ranking = generate_request(query, "2", expansion)
让我们来看看前面提到的查询增强技术的最终搜索查询。
简单查询扩展:simple_expansion
{"query": "vibranium vibranium⁰.94237 wakandan⁰.8197 adamantium⁰.80724
↪wakanda⁰.79122 alloy⁰.75724 maclain⁰.75623 klaw⁰.75222
↪america's⁰.74002 ",
"query_fields": ["title", "body"],
"min_match": "0%"}
这种简单的查询扩展与之前描述的相同,匹配包含原始查询或任何语义相关术语的任何文档。
提高精度、降低召回率的查询:increased_conceptual_precision
{"query": "vibranium AND (vibranium⁰.94237 wakandan⁰.8197
↪adamantium⁰.80724 wakanda⁰.79122 alloy⁰.75724
↪maclain⁰.75623 klaw⁰.75222 america's⁰.74002)",
"query_fields": ["title", "body"],
"min_match": "30%"}
这个提高精度、降低召回率的示例指定了一个“最小匹配”阈值为 30%,这意味着为了匹配,文档必须包含至少 30%(向下取整)的查询术语。
提高顶部结果精度,不减少召回率:increased_precision_ same_recall
{"query": "vibranium AND (vibranium⁰.94237 wakandan⁰.8197
↪adamantium⁰.80724 wakanda⁰.79122 alloy⁰.75724
↪maclain⁰.75623 klaw⁰.75222 america's⁰.74002)",
"query_fields": ["title", "body"],
"min_match": "2"}
这个提高精度、相同召回率的查询要求“vibranium”这个术语匹配,并且当其他扩展术语匹配时,将提高文档的排名,从而提高顶部结果的精度。
略微提高召回率的查询:slightly_increased_recall
{"query": "vibranium vibranium⁰.94237 wakandan⁰.8197
↪adamantium⁰.80724 wakanda⁰.79122 alloy⁰.75724
↪maclain⁰.75623 klaw⁰.75222 america's⁰.74002",
"query_fields": ["title", "body"],
"min_match": "2"}
这个略微增加召回率的查询需要两个术语匹配,但它并不明确要求原始查询,因此它可以扩展到其他概念上相似但不必包含原始查询术语的文档。由于术语“vibranium”重复两次,任何仅包含“vibranium”的文档也将匹配。
相同的结果,更好的概念排名:same_results_better_ranking
{"query": "vibranium",
"query_fields": ["title", "body"],
"min_match": "2",
"query_boosts": "vibranium⁰.94237 wakandan⁰.8197 adamantium⁰.80724
↪wakanda⁰.79122 alloy⁰.75724 maclain⁰.75623
↪klaw⁰.75222 america's⁰.74002 "}
这个最终的查询返回与原始查询vibranium相同的文档,但根据它们与知识图谱中语义相似术语的匹配程度进行不同的排名。这确保了关键词存在于所有匹配的文档中,并且返回包含用户查询的所有文档,同时通过提升更多上下文相关的文档来大大提高排名。
当然,在重写查询以包括增强的语义上下文时,你可以探索无限多种可能的查询排列,但前面的例子应该能提供对可用的选项和需要考虑的权衡的良好感觉。
5.4.6 使用 SKGs 进行基于内容的推荐
在最后一节中,我们探讨了如何通过发现和使用 SKG 中的相关节点来增强查询,包括多种结构重写查询的方法以优化精确度、召回率,甚至是在相同结果上改进概念排名。除了使用语义相关术语扩展查询外,还可以使用 SKG 通过根据文档中术语的语义相似性将文档翻译成查询来生成基于内容的推荐。
由于 SKG 中的节点可以代表任何任意查询,我们可以从文档中提取术语并将它们建模为任意节点,相对于文档的已知上下文进行评分。这意味着我们可以从文档中提取数十或数百个术语,将它们全部相对于文档的主题进行评分,然后使用最语义相似的术语生成一个最能代表文档细微、语境意义的查询。
下面的列表展示了如何将一个被分类为“星球大战”的文档进行翻译,并按该主题对所有文档中的术语进行排名。
列表 5.8 计算文档术语与“星球大战”的相关性
from aips import extract_phrases
stackexchange_skg = get_skg(engine.get_collection("stackexchange"))
classification = "star wars"
document = """this doc contains the words luke, magneto, cyclops,
darth vader, princess leia, wolverine, apple, banana,
galaxy, force, blaster, and chloe."""
parsed_document = extract_phrases(document)
nodes_to_traverse = [{"field": "body", "values": [classification]},
{"field": "body", "values": parsed_document}]
traversal = stackexchange_skg.traverse(*nodes_to_traverse)
print_graph(traversal, classification)
评分节点:
luke 0.75212
force 0.73248
darth vader 0.69378
galaxy 0.58693
princess leia 0.50491
blaster 0.47143
this 0.19193
the 0.17519
words 0.10144
and 0.09709
contains 0.03434
doc 0.00885
chloe 0.0
cyclops -0.01825
magneto -0.02175
banana -0.0319
wolverine -0.03362
apple -0.03894
在这些结果中,你可以看到一份根据与“星球大战”主题的语义相似性很好地排序的文档术语列表。得分较低的术语将与指定主题没有相关性或负相关性。下面的列表通过过滤至少具有0.25以上相关性的术语来获取文档中非常干净的、相关的术语列表。
列表 5.9 从评分短语生成推荐查询
def get_scored_terms(traversal):
return {term: data["relatedness"]
for term, data in traversal["graph"][0]["values"]["star wars"] \
["traversals"][0]["values"].items()}
rec_query = " ".join(f'"{term}"^{score}'
for term, score in get_scored_terms(traversal).items()
if score > 0.25)
print(f"Expanded Query:\n{rec_query}")
扩展查询:
"luke"⁰.75212 "force"⁰.73248 "darth vader"⁰.69378 "galaxy"⁰.58693
"princess leia"⁰.50491 "blaster"⁰.47143
下一个列表展示了此过程的最后一步——运行搜索以返回与原始文档最语义相似的顶级文档。
列表 5.10 运行基于内容的推荐查询
stackexchange_collection = engine.get_collection("stackexchange")
request = {"query": rec_query,
"query_fields": ["title", "body"],
"return_fields": ["title"],
"limit": 5,
"filters": [("title", "*")]}
response = stackexchange_collection.search(**request)
print(json.dumps(response["docs"], indent=2))
输出:
[{"title": "At the end of Return of the Jedi, did Darth Vader learn
↪that Princess Leia was his daughter?"},
{"title": "Did Luke know the "Chosen One" prophecy?"},
{"title": "Was Darth Vader at his strongest during Episode III?"},
{"title": "Why couldn't Snoke or Kylo Ren trace Luke using the Force?"},
{"title": "Does Kylo Ren know that Darth Vader reconciled with Luke?"}]
我们刚刚创建的是一个基于内容的推荐算法。当用户行为信号不足,无法进行基于信号的推荐(如协同过滤,见第 4.2.3 节)时,基于内容的方法可以生成仍然具有上下文和领域意识的推荐。
本节中的示例生成了一个基于起始文档中找到的术语的基于内容的推荐查询,但值得记住的是,SKG 并不限于使用传入的术语。你可以添加一个额外的遍历级别,以找到与原始文档中的术语语义相关的其他术语,但实际上并不包含在文档中。这对于主题狭窄且推荐查询匹配的文档不足的情况尤其有用——进一步的遍历将打开新的探索可能性。
在下一节中,我们将快速超越“相关”图关系,看看我们是否可以使用 SKG 生成和遍历一些更有趣的边。
5.4.7 使用 SKGs 来建模任意关系
到目前为止,我们所有的 SKG 遍历都使用了“相关”关系。也就是说,我们一直在使用relatedness函数找到两个单词或短语之间的语义关系的强度,但我们只测量了节点是“相关的”,而没有测量它们是如何相关的。如果我们能找到节点之间除了“相关”类型边之外的其他类型的边会怎样呢?
如果你还记得,SKG 中的节点是通过执行一个匹配一组文档的查询而即时物化的。如果你从engineer节点开始,该节点在内部表示为包含单词engineer的所有文档的集合。如果节点被标记为software engineer,那么该节点在内部表示为包含术语“software”的所有文档与包含术语engineer的所有文档的交集。如果搜索是“software engineer OR java”,那么它在内部表示为包含术语“software”且位于engineer(短语)之前一个位置的文档集合与包含术语“java”的所有文档集合的并集。所有查询,无论其复杂程度如何,在内部都表示为文档集合。
你可能也记得,边是通过找到包含两个节点的文档集合来形成的。这意味着节点和边都是使用相同的机制——文档集合——在内部表示。从实际的角度来看,这意味着如果我们可以使用查询构建一个节点,该查询近似于一个有趣的关系(而不是实体),那么我们可以通过“关系节点”以类似的方式将两个节点联系起来,就像在传统的图结构中使用边将节点联系起来一样。
让我们通过一个例子来分析。回顾我们的科幻数据集,假设我们想要问一个关于 Jean Grey 的问题,她是漫威漫画 X 战警系列中的一位受欢迎的角色。具体来说,假设我们想要弄清楚谁爱上了 Jean Grey。
我们可以通过使用起始节点jean grey,遍历到节点in love with,然后在jean grey的上下文中请求与in love with相关的顶级相关术语来实现这一点。列表 5.11 展示了这个查询。通过遍历一个旨在捕获显式语言关系的节点(在这种情况下是in love with),我们可以使用中间节点来表示起始节点和终止节点之间的边。
列表 5.11 通过“关系节点”实例化一条边
scifi_skg = get_skg(engine.get_collection("scifi"))
starting_node = "jean grey"
relationship = "in love with"
nodes_to_traverse = [{"field": "body", "values": [starting_node]},
{"field": "body", "values": [relationship],
"default_operator": "OR"},
{"field": "body",
"min_occurrences": 25, "limit": 10}]
traversal = scifi_skg.traverse(*nodes_to_traverse)
print_graph(traversal, starting_node, relationship)
输出:
jean 0.84915
grey 0.74742
summers 0.61021
cyclops 0.60693
xavier 0.53004
wolverine 0.48053
mutant 0.46532
x 0.45028
mutants 0.42568
magneto 0.42197
如果你对这些角色不熟悉,以下是关于 Jean Grey 的相关背景:她与两个变种人有着持续的关系,一个名叫 Cyclops(真名:Scott Summers),另一个名叫 Wolverine。此外,大多数粉丝不知道,Jean Grey 的两个导师,查尔斯·泽维尔教授和磁力王,在漫画书中的一些时候被知道对 Jean Grey 有爱慕之情。
如果我们检查列表 5.11 的结果,我们会看到所有这些预期的名字都被列出。前两个术语,“jean”和“grey”,是最相关的,因为我们正在搜索与jean grey相关的in love with。她的名字将与她自己高度语义相关。接下来的两个术语,“summers”和“cyclops”,都指的是同一个人,Jean 的最突出的爱情对象。然后我们看到“xavier”和“wolverine”,列表中的最后一个结果是“magneto”。图 5.7 展示了这次遍历的一些底层图关系。

图 5.7 遍历任意定义的边类型。通过实例化一个结合了起始节点("jean grey")和新的节点("in love with")的上下文的节点,我们可以从这个组合节点("jean grey" + "in love with")遍历到其他节点。这相当于说我们正在通过"in love with"边从"jean grey"遍历到其他节点。
通过使用一个中间节点(即in love with)来表示其他节点之间的关系,我们可以形成节点之间的任意类型边,只要我们能将这条边表达为一个搜索查询。
虽然列表 5.11 中我们的图遍历结果相当不错,但我们确实看到了术语“x”(可能是来自“x-men”)和“mutant”也出现了。Jean Grey 和列出的其他所有人都是 X 战警漫画中的变种人,这就是为什么这些术语在语义上如此相关。然而,这些术语并不是对“谁爱上了 Jean Grey?”这个问题的好答案。
这提出了一个重要观点:SKG 是一个统计知识图谱。in love with 这种关系的存在纯粹是基于我们收集的术语之间的统计相关性,因此就像任何本体学习方法一样,总会存在一些噪声。尽管如此,对于一个没有显式建模实体的自动生成的图谱来说,这些结果相当不错。
如果我们想要提高这些结果的质量,最简单的事情之一就是对内容进行预处理以识别实体(人物、地点和事物)并索引这些实体,而不是仅仅索引单个关键词。这将导致返回实际人物的名字(例如,“斯科特·萨默斯”、“查尔斯·泽维尔”、“珍妮·格雷”),而不是仅仅返回单个关键词(“summers”、“xavier”、“jean”、“grey”)。
值得注意的是,关系的遍历完全取决于这些关系是否在底层文档的语料库中讨论过。在这种情况下,大量的论坛帖子讨论了这些人与珍妮·格雷的关系。如果存在不足的文档,返回的结果可能很差或不存在。为了避免结果中的噪声,我们设置了 min_occurrences 阈值为 25,这意味着至少必须有 25 篇文档讨论 jean grey、in love with 以及找到和评分的其他节点。我们建议将 min_occurrences 设置为一个大于 1 的数字,以避免假阳性。
虽然从探索的角度来看,遍历像“in love with”这样的任意语言关系可能是有用的,但从查询理解的角度来看,通常只需要坚持默认的“is related to”关系,并使用术语之间的相关性分数来处理大多数语义搜索用例。然而,遍历多个层次的关系以生成更好的上下文仍然是有用的。具体来说,从术语遍历到分类字段以提供一些额外的上下文,然后到该类别中术语的相关含义,这可能是有用的。我们将在第六章中更详细地介绍这种策略,其中我们将关注具有多个含义的术语的消歧。
5.5 使用知识图谱进行语义搜索
通过提供接受任意查询并动态发现上下文中相关术语的能力,SKGs 成为了查询解释和相关性排名的关键工具。我们已经看到,SKGs 不仅可以帮助解释和扩展查询,还可以提供实时分类查询和关键词以及消歧每个查询中术语的多种含义的能力。
在本章早期,我们也探讨了如何通过开放信息提取技术构建显式知识图谱。可能还不明显的是如何解析任意传入的查询并在知识图谱中查找适当上下文和实体。我们将用第七章的大部分内容来介绍如何构建一个端到端的语义搜索系统,该系统能够解析查询并整合这些知识图谱功能。
我们仍需要添加一些对搜索引擎至关重要的关键关系到我们的知识图谱中,例如拼写错误、同义词和领域特定短语。我们将在下一章中介绍如何从用户信号或内容中自动学习这些领域特定术语的每个来源,该章节专注于学习领域特定语言。
摘要
-
知识图谱建模了您领域内实体之间的关系,可以通过已知关系显式构建,也可以从您的内容中动态提取。
-
开放信息提取,即从您的内容中提取事实(主题、关系、对象三元组)的过程,可以用来学习任意关系(通常会产生噪声数据)或从文本中提取下位词和上位词关系(噪声较少)到显式知识图谱中。
-
语义知识图谱(SKGs)能够遍历和排名您搜索索引中任何内容之间的任意语义关系。这使得您可以直接使用索引内容作为知识图谱和语言模型,而无需进行任何额外的数据建模。
-
不依赖于用户信号的基于内容的推荐可以通过对文档中最具语义意义的术语和短语进行排名,并使用它们作为查询来查找和排名其他相关文档来生成。
-
SKGs 通过提供领域敏感和上下文敏感的关系发现和查询扩展,能够更好地理解用户意图。
[1] Grainger 等人,“语义知识图谱:一个紧凑的、自动生成的模型,用于实时遍历和排名领域内的任何关系。”在2016 年 IEEE 国际数据科学和高级分析会议(DSAA),第 420-429 页。IEEE,2016。
第六章:使用上下文学习特定领域的语言
本章涵盖
-
分类查询意图
-
查询意义消歧
-
从用户信号中识别关键术语
-
从用户信号中学习相关短语
-
从用户信号中学习拼写错误和替代术语变体
在第五章中,我们展示了如何生成和使用语义知识图谱(SKG),以及如何将实体、事实和关系明确地提取到知识图谱中。这两种技术都依赖于在单个文档中术语之间的语言联系,或者多个文档和上下文中术语的统计共现。你学习了如何使用知识图谱来查找相关术语,以及这些相关术语如何整合到各种查询重写策略中,以提高召回率或精确度。
在本章中,我们将更深入地了解查询意图以及使用不同上下文来解释查询中特定领域术语的细微差别。我们将从探索查询分类开始,然后展示如何使用这些分类来消除具有多种潜在意义的查询的歧义。这两种方法都将扩展我们在上一章中使用的 SKG。
尽管基于 SKG 的方法在语境化和解释查询方面更有效,但它们仍然依赖于拥有高质量文档,这些文档能够准确代表你的领域。因此,它们在解释用户查询方面的有效性取决于查询与搜索内容重叠的程度。
例如,如果你的 75%用户正在搜索服装,但你的库存大多是电影和数字媒体,那么当用户搜索查询“短裤”时,所有结果都是运行时间短的视频(称为“数字短片”),大多数用户会对结果感到困惑。根据你的查询日志中的数据,如果“短裤”能够映射到你在查询信号中更常见的其他相关术语,如“裤子”、“服装”和“衬衫”,那就更好了。
不仅依赖于文档内容来学习术语和短语之间的关系,而且使用用户生成的信号也是非常有益的。在本章中,我们将展示从用户信号中提取关键短语、学习相关短语以及识别常见拼写错误或替代拼写的技巧。通过结合基于内容的上下文和来自真实用户交互的行为上下文,你的搜索引擎将更好地理解特定领域的术语和实际用户意图。
6.1 分类查询意图
查询的目标或意图通常比关键词更重要。在新闻或旅行内容与计算机技术背景下,搜索driver crashed可能意味着两种 非常 不同的含义。同样,在电子商务中搜索特定产品名称或产品 ID 的人可能正在寻找一个非常具体的商品,并且有很大可能性想要购买它。像kitchen appliances这样的通用搜索可能表明用户只是打算浏览可用的产品,看看有什么可用。
在这两种情况下,查询分类器可以有效地确定所发出查询的一般类型。根据领域,查询的上下文可以自动应用(例如,过滤文档的类别),用于修改相关性算法(自动提升特定产品),甚至可以用来驱动不同的用户体验(跳过结果页面,直接进入特定产品的页面)。在本节中,我们将展示如何使用第五章中的 SKG 作为分类器,为传入的查询构建查询分类器。
SKG 遍历在每个图遍历级别执行 k 个最近邻搜索。K 个最近邻是一种分类,它接受一个数据点(如查询或术语)并尝试在向量空间中找到最接近的 k 个其他数据点。如果我们文档上有category或classification这样的字段,我们可以要求 SKG“找到与我的起始节点最相关的类别”。由于起始节点通常是用户的查询,SKG 可以对该查询进行分类。
我们将继续使用索引的 Stack Exchange 数据集作为 SKG,以扩展查询分类(在本节中)和查询意义消歧(在第 6.2 节中)。
列表 6.1 显示了一个函数,它接受用户查询并遍历 SKG 以找到与查询语义相关的类别进行分类。由于我们已经索引了多个不同的 Stack Exchange 类别(科幻、健康、烹饪、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 我们将从中找到相关分类的字段。在这种情况下,我们遍历到类别字段。
3 只有至少出现在这么多文档中的分类才会被返回。
4 设置要返回的分类数量
5 遍历 SKG 以分类查询
6 打印查询及其分类
示例查询分类:
Query: docker
Classifications:
devops 0.87978
Query: airplane
Classifications:
travel 0.33334
Query: airplane AND crash
Classifications:
scifi 0.02149
travel 0.00475
Query: vitamins
Classifications:
health 0.48681
cooking 0.09441
Query: alien
Classifications:
scifi 0.62541
Query: passport
Classifications:
travel 0.82883
Query: driver
Classifications:
travel 0.38996
devops 0.08917
Query: driver AND taxi
Classifications:
travel 0.24184
scifi -0.13757
Query: driver AND install
Classifications:
devops 0.22277
travel -0.00675
这个请求使用 SKG 根据查询与每个可用分类(在category字段内)之间的语义相似度比较,找到最接近的 k 个邻居。
我们可以看到每个查询的每个潜在类别的分类得分,例如,“飞机”和“护照”被分类为“旅行”,“维生素”被分类为“健康”和“烹饪”,“外星人”被分类为“科幻”。然而,当我们将“飞机”查询细化为一个更具体的查询,如“飞机 AND 碰撞”时,我们看到分类从“旅行”变为“科幻”,因为关于飞机坠毁的文档更有可能出现在“科幻”文档中,而不是“旅行”文档中。
作为另一个例子,“司机”可能有多种含义。它返回两个潜在分类(“旅行”或“devops”),在没有其他上下文提供的情况下,“旅行”类别是明显的选择。然而,当提供额外的上下文时,我们可以看到查询“司机 AND 出租车”被适当地分类到“旅行”类别,而“司机 AND 安装”则被适当地分类到“devops”类别。
SKG 能够找到任意组合的术语之间的语义关系,这使得它在实时分类传入查询时非常有用。您可以自动应用分类作为查询过滤器或提升,将查询路由到特定上下文的算法或着陆页,或自动消歧查询术语。我们将在下一节中探讨使用两级图遍历来实现查询意义消歧。
6.2 查询意义消歧
当从用户的查询中解读用户意图时,理解他们每个词的确切含义是具有挑战性的。多义性问题或模糊术语可能会显著影响您的搜索结果。
如果有人搜索“服务器”,这可能指的是在餐厅接受订单并服务等候的员工,或者它可能意味着运行网络软件的计算机。理想情况下,我们希望我们的搜索引擎能够区分这些词义,并在每个区分的上下文中生成一个独特的相关术语列表。图 6.1 展示了“服务器”这个词的两个潜在上下文以及在每个上下文中可能找到的相关术语类型。

图 6.1 区分模糊术语“服务器”的多个意义
在第 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 中找到,随后是用于在列表 6.4 中区分chef的搜索引擎特定 SKG 请求。每个区分上下文(category字段)相对于查询进行评分,每个发现的关键词(body字段)相对于查询和区分上下文进行评分。
表 6.1 相关术语列表根据类别对查询服务器进行上下文化
| 查询:服务器 |
|---|
| 上下文:devops 0.83796 关键词:
服务器 0.93698
服务器 0.76818
docker 0.75955
代码 0.72832
配置 0.70686
部署 0.70634
nginx 0.70366
jenkins 0.69934
git 0.68932
ssh 0.6836
| 上下文:烹饪 -0.1574 关键词:
服务器 0.66363
餐厅 0.16482
派 0.12882
已提供 0.12098
餐厅 0.11679
刀片 0.10788
部件 0.10135
服务 0.08934
员工 0.0886
菜肴 0.08553
|
| 上下文:旅行 -0.15959 关键词:
服务器 0.81226
小费 0.54391
VPN 0.45352
小费 0.41117
| 上下文:科幻 -0.28208 关键词:
服务器 0.78173
弗莱尼的 0.53341
计算机 0.28075
计算机 0.2593
|
| 服务器 0.39053 防火墙 0.33092
餐厅 0.21698
小贴士 0.19524
账单 0.18951
现金 0.18485
| 弗莱尼 0.24963 服务器 0.24778
网格 0.23889
网络 0.2178
关闭 0.21121
黑客 0.19444
|
表 6.1 显示了查询服务器的最相关语义类别,随后是每个类别上下文中的body字段中最相关的关键词。根据数据,我们看到“devops”类别的语义相关性最高(得分为0.83796),而接下来的三个类别都包含负分数(“烹饪”为-0.1574,“旅行”为-0.15959,“科幻”为-0.28208)。对于查询服务器,因此“devops”类别是最可能相关的类别。
如果我们查看每个类别返回的不同术语列表,我们也会看到出现几个不同的含义。在“devops”类别中,术语“server”的含义集中在与管理和部署代码到计算机服务器相关的工具上。在“scifi”类别中,含义则围绕计算机网格被黑客攻击和网络被关闭。在“travel”类别中,另一方面,“server”一词的压倒性含义与在餐厅工作的人相关,如“小费”、“餐厅”和“账单”等术语的出现。
当使用此数据实现智能搜索应用时,如果你知道用户的上下文与旅行相关,那么在“旅行”类别中使用特定的含义是有意义的。如果上下文未知,通常最好的选择是使用与语义最相关的类别或用户中最受欢迎的类别。
表 6.2 查询driver的按类别划分的上下文相关术语列表
| 查询:driver | |
|---|---|
| --- | --- |
| 上下文:旅行 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与“出租车”、“汽车”、“驾照”、“驾驶”和“公交车”相关,而在“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的消歧。前两个上下文都显示了合理的正相关度分数,表明这两种含义都可能是解释。虽然“烹饪”上下文的分数(0.37731)略高于“devops”上下文(0.34959),但在选择这两种含义之间时,尽可能考虑用户上下文仍然很重要。在“devops”上下文中,chef的含义与用于构建和部署服务器的 Chef 配置管理软件相关(相关术语包括“puppet”和“ansible”),而在“烹饪”上下文中,它指的是准备食物的人(“烹饪”、“口味”、“餐厅”、“食材”)。Chef 软件从烹饪领域汲取灵感,作为准备和提供软件的隐喻,因此看到“cookbooks”这样的术语出现在“devops”类别中并不令人惊讶。
用于消歧查询的特定搜索引擎 SKG 请求可以通过调用print_disambigutaion_request函数来查看。这有助于理解和直接在配置的搜索引擎或向量数据库上运行内部 SKG 请求。为这个chef查询含义消歧函数调用打印的 Solr 特定 SKG 请求语法如下所示。
列表 6.4 为查询chef的 Solr SKG 消歧请求
print_disambigutaion_request("chef", context_limit=2)
结果:
{"limit": 0,
"params": {"q": "*",
"fore": "{!${defType} v=$q}",
"back": "*",
"defType": "edismax",
"f0_0_query": "chef"}, #1
"facet": {
"f0_0": {
"type": "query",
"query": "{!edismax qf=body v=$f0_0_query}", #1
"field": "body",
"sort": {"relatedness": "desc"},
"facet": {"relatedness": {"type": "func",
"func": "relatedness($fore,$back)"},
"f1_0": {
"type": "terms",
"field": "category", #2
"mincount": 5, "limit": 2,
"sort": {"relatedness": "desc"},
"facet": {"relatedness": {"type": "func",
"func": "relatedness($fore,$back)"},
"f2_0": {
"type": "terms", #3
"field": "body", #3
"mincount": 5, "limit": 10,
"sort": {"relatedness": "desc"},
"facet": {"relatedness":{"type": "func",
"func": "relatedness($fore,$back)"}}
}}}}}}}
1 起始节点是对 chef 的查询。
2 第一次 SKG 遍历从类别字段中找到与起始节点最相关的术语。这些类别是消歧上下文。
3 最后一次 SKG 遍历找到与消歧上下文相关的正文字段中的术语。
这是用于消歧具有context_limit为2的查询chef的内部 Solr SKG 请求。请求将特定于配置的任何搜索引擎或向量数据库,如果没有 SKG 功能,将回退到 Solr。有关更改配置的搜索引擎的说明,请参阅附录 B。
通过结合查询分类、术语消歧和查询扩展,SKG 可以为您的 AI 搜索引擎提供增强的特定领域和高度上下文化的语义搜索能力。我们将在第七章中进一步探讨这些技术的应用,当时我们将它们应用于一个实际的语义搜索应用中。
6.3 从查询信号中学习相关短语
到目前为止,你已经看到了如何使用你的内容作为知识图谱来发现相关术语、分类查询和消歧术语。虽然这些技术很强大,但它们完全依赖于你文档的质量。在本章的其余部分,我们将探讨关于你的领域的另一个主要知识来源——用户信号(查询、点击和后续操作)。通常,用户信号可以导致与文档内容相似,甚至更有用的见解,用于解释查询。
作为从真实用户行为中学习特定领域术语的起点,让我们考虑一下你的查询日志代表什么。对于你搜索引擎的每个查询,查询日志包含运行搜索的人的标识符、运行的查询以及查询的时间戳。这意味着如果单个用户搜索多个术语,你可以将这些搜索分组,并且也知道术语输入的顺序。
虽然这并不总是正确的,但一个合理的假设是,如果有人在很短的时间内输入两个不同的查询,那么第二个查询很可能是第一个查询的细化或关于相关主题的。图 6.2 演示了你在查询日志中可能找到的某个用户的真实搜索序列。

图 6.2 某特定用户的查询日志中典型的搜索序列
当查看这些查询时,我们直观地理解到iphond是iphone的拼写错误,iphone accesories是iphone accessories的拼写错误,以及iphone、pink phone case和pink iphone case都是相关查询。我们将在后面的章节中处理这些拼写错误,但现在我们可以考虑这些也是相关术语。
虽然依赖单个用户的信号来推断两个查询相关并不明智,但许多用户之间的相似查询模式表明可能存在关联关系。正如我们在 5.4.5 节中展示的那样,查询可以扩展以包含相关术语来提高召回率。在本节中,我们将探讨学习相关查询的技术,首先通过挖掘查询日志,然后通过交叉引用产品交互日志。
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个共同出现的用户,这意味着数据点的数量稀疏,并且列表下方的噪声可能会更多。在下一节中,我们将探讨一种沿不同轴(产品交互)组合信号的技术,这有助于解决稀疏性问题。
虽然直接将搜索数量聚合为用户共同出现的情况有助于找到最受欢迎的查询对,但搜索的流行度并不是唯一有用的度量标准,用于寻找相关性。关键词“和”和“的”高度共同出现,同样,“手机”、“电影”、“电脑”和“电子产品”也是高度共同出现的,因为它们都是许多人搜索的通用词。为了进一步关注术语之间关系强度,而独立于它们的个人流行度,我们可以使用一种称为点互信息的技术。
点互信息(PMI)是衡量任何两个事件之间关联度的指标。在自然语言处理的情况下,PMI 预测两个词共同出现的可能性,因为它们是相关的,而不是因为它们偶然共同出现的可能性。可以用于计算和归一化 PMI 的公式有很多,但我们将使用一种称为 PMI^k 的变体,其中k = 2,这种变体比 PMI 在保持分数一致方面做得更好,无论词频如何。
计算 PMI²的公式显示在图 6.3 中。

图 6.3 PMI²分数
在我们的实现中,k1 和 k2 代表我们想要比较的两个不同的关键词。P(k1,k2) 表示相同用户同时搜索这两个关键词的频率,而 P(k1) 和 P(k2) 分别表示用户只搜索第一个关键词或第二个关键词的频率。直观上,如果关键词出现的频率比它们随机出现的频率更高,那么它们的 PMI² 分数将更高。分数越高,这些术语在语义上相关的可能性就越大。
下面的列表展示了在我们共现查询对数据集上进行的 PMI² 计算。
列表 6.7 用户搜索的 PMI² 计算
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 的结果按 PMI² 分数排序,我们设置了一个最小出现次数阈值 >5 以帮助去除噪声。“hp 笔记本”,“dell 笔记本”和“sony 笔记本”显示为相关,以及像“kenwood”和“alpine”这样的品牌。值得注意的是,在这些成对中出现了一些噪声,如“无线鼠标”与“教父”以及“quicken”与“便携式 DVD 播放器”。使用 PMI 的一个缺点是,在少数用户中同时出现的小数量可以比使用基于术语通常共现的共现更容易产生噪声。
一种结合共现模型和 PMI² 模型优点的方法是创建一个综合分数。这将提供流行度和发生可能性的混合,这应该会将同时在两个分数上匹配的查询对移动到列表的顶部。列表 6.8 展示了将这两个度量结合起来的方法。具体来说,我们取所有共现分数的排名列表(r1)以及所有 PMI² 分数的排名列表(r2),并将它们混合在一起,生成如图 6.4 所示的综合排名分数。

图 6.4 结合共现和 PMI² 排名的综合排名分数
图 6.4 中显示的 comp_score,或综合排名分数,为那些在共现列表(r1)和 PMI² 列表(r2)中排名高的查询对(查询 q1 和查询 q2)分配高分,而当术语在排名列表中进一步下降时,它分配的排名较低。结果是考虑了流行度(共现)和查询相关性的可能性(无论其流行度如何)的混合排名。下面的列表展示了如何根据已计算的共现和 PMI² 分数计算 comp_score。
列表 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 综合分数计算结合了 PMI² 分数的排序排名和共现。
2 从最佳(最高共现)到最差(最低共现)对共现分数进行排名
3 从最佳(最高 PMI²)到最差(最低 PMI² 分数)对 PMI² 分数进行排名
输出:
+-------------+---------------+----------+-------+---+------+----------+
| 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|
+-------------+---------------+----------+-------+---+------+----------+
总体而言,复合排名分数合理地融合了我们的共现和 PMI²指标,克服了各自的局限性。列表 6.8 中显示的前几项结果看起来都很合理。然而,在本节中我们已经指出的问题之一是,共现数字非常稀疏。具体来说,在超过 70 万个查询信号中,任何查询对之间的最高共现是列表 6.6 中显示的“绿灯侠”和“美国队长”的23个重叠用户。
在下一节中,我们将展示一种克服这种稀疏数据问题的方法,即对于特定的查询对,用户之间缺乏重叠。我们将通过将许多用户聚集到一个具有相似行为的较大群体中来实现这一点。具体来说,我们将把我们的重点从关注发出重叠查询的个别用户转移到关注用户查询重叠的产品上。
6.3.2 通过产品交互查找相关查询
在 6.3.1 节中用于查找相关术语的技术依赖于许多用户搜索重叠查询。正如我们所看到的,在超过 70 万个查询信号中,任何查询对之间的最高重叠是23个用户。由于数据可能非常稀疏,通常有理由在除了用户之外的其他事物上进行汇总。
在本节中,我们将演示如何使用相同的技巧(使用共现和 PMI²)但基于产品点击信号而不是用户进行汇总。由于你可能会拥有比产品多得多的用户,并且由于特定产品可能会因类似关键词而被点击,这种技巧有助于克服数据稀疏性问题,并在查询之间产生更高的重叠。
列表 6.9 中的转换将单独的查询和点击信号合并为具有三个关键列的单行:keyword(关键词),user(用户)和product(产品)。
列表 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 利用点击信号生成关键词、用户和产品分组
输出:
Original signals format:
+-------------------+-----------+----------------+-----------+-----+-------+
| 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|
+-------------------+-----------+----------------+-----------+-----+-------+
Simplified signals format:
+-------------+----+------------+
| keyword|user| product|
+-------------+----+------------+
| joy stick| u10|097855018120|
| xbox| u10|885370235876|
|virgin mobile|u100|799366521679|
+-------------+----+------------+
使用这些数据,我们现在将能够根据独立用户在搜索相同产品时对它们的使用情况来确定任何两个关键词之间的关系强度。列表 6.10 生成关键词对,以确定所有关键词对中两个关键词的潜在关系,其中两个关键词都用于同一文档的查询。在 6.3.1 节中寻找重叠查询的背后的想法是,每个用户都可能搜索相关项目。然而,每个产品也可能被相关查询搜索,因此我们可以将我们的心理模型从“找出有多少用户搜索了两个查询”转变为“找出所有用户通过两个查询找到的文档数量”。
列表 6.10 中这种转换的结果现在包括以下列:
-
k1,k2——这两个关键词可能相关,因为它们都导致点击了同一产品。 -
n_users1—搜索k1并点击了在某个用户搜索k2后也点击的产品的人数。 -
n_users2—搜索k2并点击了在某个用户搜索k1后也点击的产品的人数。 -
users_cooc—表示搜索过k1或k2并访问了其他搜索者搜索k1或k2后访问的产品的人数。计算为n_users1+n_users2。 -
n_products—被同时搜索k1和k2的搜索者点击的产品数量。
列表 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()
输出:
Number of co-occurring queries: 1579710
+--------------+-------------+--------+--------+----------+----------+
| 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_cooc 和 n_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 比 492 用户)更常用,也比 headphone(1,617 比 367 用户)更常用。同样,我们看到 tablet 在使用上比 tablet pc(1,408 比 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()
输出:
Keyword searches that resulted in clicks: 13744
+------------+-------+
| keyword|n_users|
+------------+-------+
| ipad| 7554|
| hp touchpad| 4829|
| lcd tv| 4606|
| iphone 4s| 4585|
| laptop| 3554|
| beats| 3498|
| laptops| 3369|
| ipod| 2949|
| ipod touch| 2931|
| ipad 2| 2842|
| kindle| 2833|
| touchpad| 2785|
| star wars| 2564|
| iphone| 2430|
|beats by dre| 2328|
| macbook| 2313|
| headphones| 2270|
| bose| 2071|
| ps3| 2041|
| mac| 1851|
+------------+-------+
这个列表与列表 6.6 中的列表相同,但这个列表显示的是搜索过关键词并点击了产品的人数,而不是搜索关键词的人数。我们将使用这个列表作为我们的 PMI² 计算的主查询列表。
由于我们的查询对和查询流行度现在基于查询和产品交互,我们其余的计算(PMI²和综合评分)与第 6.3.1 节中的相同,因此我们在这里省略它们(它们包含在笔记本中供你运行)。在计算了 PMI² 和综合评分之后,以下列表显示了基于产品交互的相关术语计算的最终结果。
列表 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("Number of co-occurring queries:", dataframe.count(), "\n")
dataframe.show(20)
输出:
Number of co-occurring queries: 1579710
+----------+-----------+--------+--------+-----+----------+
| k1| k2|n_users1|n_users2| pmi2|comp_score|
+----------+-----------+--------+--------+-----+----------+
| ipad|hp touchpad| 7554| 4829|1.232| 1.0|
| ipad 2| ipad| 2842| 7554|1.431| 1.25|
| tablet| ipad| 1818| 7554|1.669| 1.667|
| touchpad| ipad| 2785| 7554|1.223| 2.125|
| tablets| ipad| 1627| 7554|1.749| 2.6|
| ipad2| ipad| 1254| 7554|1.903| 3.083|
| ipad| apple| 7554| 1814| 1.5| 3.571|
| touchpad|hp touchpad| 2785| 4829|1.394| 4.063|
| ipad| hp tablet| 7554| 1421|1.594| 4.556|
|ipod touch| ipad| 2931| 7554|0.863| 5.05|
| ipad| i pad| 7554| 612|2.415| 5.545|
| kindle| ipad| 2833| 7554|0.828| 6.042|
| laptop| ipad| 3554| 7554|0.593| 6.538|
| ipad| apple ipad| 7554| 326|2.916| 7.036|
| ipad 2|hp touchpad| 2842| 4829|1.181| 7.533|
| laptops| laptop| 3369| 3554| 1.29| 8.031|
| ipad| hp| 7554| 1125|1.534| 8.529|
| ipads| ipad| 254| 7554|3.015| 9.028|
| ipad| htc flyer| 7554| 1834|1.016| 9.526|
| ipad| i pad 2| 7554| 204| 3.18| 10.025|
+----------+-----------+--------+--------+-----+----------+
列表 6.11 和 6.12 的结果显示了在较粗粒度级别聚合的好处。通过查看导致特定产品被点击的所有查询,查询对列表现在比第 6.3.1 节中按单个用户聚合的查询对列表大得多。你可以看到,现在有 1,579,710 个查询对在考虑之中,而按用户聚合时为 244,876(参见列表 6.6)。
此外,你可以看到相关查询包括更多针对顶级查询(ipad、ipad 2、ipad2、i pad、ipads、i pad 2)的细粒度变体。如果你将这种相关术语发现与其他算法(如拼写错误检测)结合使用,这些细粒度变体将非常有用,我们将在第 6.5 节中介绍拼写错误检测。
在上一章中介绍的 SKG 方法与本章中的查询日志挖掘之间,你已经看到了多种发现相关短语的技巧。然而,在我们能够应用这些相关短语之前,我们首先需要能够识别这些已知短语在进入的查询中。在下一节中,我们将介绍如何从我们的查询信号中生成已知短语列表。
6.4 从用户信号中检测短语
在第 5.3 节中,我们讨论了几种从文档中提取任意短语和关系的技术。虽然这种方法可以大大有助于发现你内容中的所有相关特定领域短语,但这种方法存在两个不同的问题:
-
它会产生很多噪声——在你可能的大量文档集中,并非每个名词短语都是重要的,随着你文档数量的增加,识别错误短语(假阳性)的概率也会增加。
-
它忽略了用户所关心的内容——用户兴趣的真实度量是通过他们所搜索的内容来传达的。他们可能只对你内容的一个子集感兴趣,或者他们可能正在寻找的内容在你的内容中甚至没有得到很好的体现。
在本节中,我们将关注如何从你的用户信号中识别重要的特定领域短语。
6.4.1 将查询视为实体
从查询日志中提取实体的最简单方法是将整个查询视为一个实体。在我们的 RetroTech 电子商务网站等用例中,这种方法非常有效,因为许多查询都是产品名称、类别、品牌名称、公司名称或人们的名字(演员、音乐家等)。考虑到这个背景,大多数高流行度查询最终都成为可以直接用作短语而无需任何特殊解析的实体。
回顾列表 6.11 的输出,你会发现以下最受欢迎的查询:
+------------+-------+
| keyword|n_users|
+------------+-------+
| ipad| 7554|
| hp touchpad| 4829|
| lcd tv| 4606|
| iphone 4s| 4585|
| laptop| 3554|
| ... | ... |
+------------+-------+
这些是应属于已知实体列表中的实体,其中许多是多词短语。在这种情况下,提取实体的最简单方法也是最强大的方法——只需将查询作为你的实体列表即可。每个查询在用户中的频率越高,你将其添加到实体列表中的信心就越大。
减少噪声查询中潜在假阳性的一个方法是在你的文档和查询中找到重叠的短语。此外,如果你的文档中有不同的字段,如产品名称或公司,你可以将你的查询与这些字段交叉引用,为查询中找到的实体分配一个类型。
根据查询的复杂性,使用最常见的搜索作为你的关键实体可能是实现高质量实体列表的最直接方法。
6.4.2 从更复杂的查询中提取实体
在某些用例中,查询可能包含更多噪声(布尔结构、高级查询运算符等),因此可能不能直接用作实体。在这些情况下,提取实体的最佳方法可能是重新应用第五章中提到的实体提取策略,但针对你的查询信号。
默认情况下,词汇搜索引擎将查询解析为单个关键词并在倒排索引中查找它们。例如,对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 驱动的搜索系统能够理解和正确处理这些拼写错误。
对于“笔记本电脑”的一般相关短语可能是“计算机”、“上网本”或“平板电脑”,而拼写错误可能看起来更像是“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”作为独立的查询,因为这些词都是通用术语,通常只有在包含其他单词的上下文中才有意义。
基于内容索引没有简单的方法来区分最终用户不太可能搜索“现代”或“型号”。因此,尽管基于内容的拼写检查器在很多情况下可以很好地工作,但通常从用户的查询行为中学习拼写修正会更准确。
6.5.2 从用户信号中学习拼写修正
回到第 6.3 节的核心论点,即用户倾向于搜索相关查询,直到找到预期的结果,因此,如果一个用户拼写了一个特定的查询并收到了不良的结果,那么他们就会尝试纠正他们的查询。
我们已经知道如何找到相关短语(在第 6.3 节中讨论),但在这个章节中,我们将介绍如何根据用户信号特别区分拼写错误。这项任务主要归结为两个目标:
-
找到拼写相似的术语。
-
确定哪个术语是正确的拼写与拼写错误的变体。
对于这个任务,我们将仅依靠查询信号。我们将进行一些前置规范化,使查询分析不区分大小写,并过滤重复的查询以避免信号垃圾邮件。(我们将在第 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 顶部 20%的术语在更正列表中有相同的数据存储。
为了有效地比较所有拼写错误和更正值,我们首先将它们加载到列表 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”,而不是像在列表 6.13 中看到的基于文档的拼写纠正中那样的不可能的搜索词,如“model”和“modern”。
您可以以多种方式创建拼写纠正模型。如果您想从文档中生成多词拼写纠正,您可以从文档中生成二元组和三元组以执行链式贝叶斯分析,分析连续术语出现的概率。同样,要从查询信号中生成多词拼写纠正,您可以在调用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 汇总
在本章中,我们更深入地了解了特定领域语言的上下文和意义。我们展示了如何使用 SKGs 来分类查询和区分基于上下文具有不同或细微意义的术语。我们还探讨了如何从用户信号中挖掘关系,这通常比仅查看您的文档更能提供理解用户的好上下文。我们还展示了如何从查询信号中提取短语、拼写错误和替代标签,使特定领域的术语可以直接从用户那里学习,而不仅仅是来自文档。
到目前为止,您应该对从文档或用户信号中学习特定领域短语和相关短语、对可用内容进行查询分类以及根据查询分类区分术语的意义感到自信。这些技术是您工具箱中用于解释查询意图的关键工具。
我们的目标不仅仅是组装一个大型工具箱。我们的目标是根据需要使用这些工具中的每一个来构建一个端到端的语义搜索层。这意味着我们需要将已知短语建模到我们的知识图谱中,从传入的查询中提取这些短语,处理拼写错误,对查询进行分类,消除传入术语的歧义,并最终为使用我们每个 AI 驱动的搜索技术的搜索引擎生成一个重写的查询。在下一章中,我们将向您展示如何将这些技术组装成一个工作语义搜索系统,该系统旨在最佳解释和建模查询意图。
摘要
-
使用语义知识图谱(SKG)对查询进行分类可以帮助解释查询意图并改进查询路由和过滤。
-
查询意义消歧可以提供对用户查询的更语境化的理解,尤其是对于在不同语境中有显著不同含义的术语。
-
除了从文档中学习外,特定领域的短语和相关短语也可以从用户信号中学习。
-
拼写错误和拼写变体可以从文档和用户信号中学习,基于文档的方法更稳健,而基于用户信号的方法更好地代表用户意图。
第七章:通过语义搜索解释查询意图
本章涵盖
-
查询解释的机制
-
实现一个端到端查询意图管道以解析、丰富、转换和搜索
-
标记和分类查询术语和短语
-
使用知识图谱遍历增强查询
-
解释特定领域查询模式的语义
在第五章和第六章中,我们使用内容和信号来解释传入用户查询的特定领域含义。我们讨论了短语识别、拼写错误检测、同义词发现、查询意图分类、相关术语扩展,甚至查询意义消歧。我们主要独立讨论了这些技术,以展示它们各自如何独立工作。
在本章中,我们将所有这些技术付诸实践,将它们整合到一个统一的查询解释框架中。我们将展示一个示例搜索界面,该界面接受真实查询,解释它们,将它们重写以更好地表达最终用户的意图,然后返回排序后的结果。
我们应该注意,为了实现语义搜索,已经发展出多种范式,包括基于嵌入的查询解释和问答(返回提取或生成的答案而不是文档)使用大型语言模型(LLMs)和预训练的 Transformers。这些通常涉及将查询编码为向量,搜索向量的近似最近邻,然后执行向量相似度计算以对文档进行排序。排序后的文档通常随后进行分析以总结、提取或生成答案。我们将涵盖第 13-15 章中基于 LLM 的语义搜索和问答方法。
在本章中,我们将专注于将您已经学到的每个 AI 驱动的搜索策略整合到端到端语义查询管道中。我们将分四个阶段实现该管道:
-
解析用户的查询
-
丰富解析查询以改进上下文
-
转换查询以优化我们目标搜索引擎的相关性
-
搜索使用优化的查询
这些步骤不必线性实现(有时它们会重复,有时可以跳过步骤),并且还可以进一步分解(例如,搜索可以分解为匹配、排序和重新排序)。然而,通过这个一致的框架,我们可以整合任何组合的 AI 驱动的搜索技术,这将非常有价值,因为您可以在自己的搜索应用中混合和匹配方法。
7.1 查询解释的机制
没有一种“正确”的方式来构建查询解释框架。每个构建智能搜索平台的公司都可能构建出略有不同的事物,这取决于他们的业务需求和搜索团队的专长。然而,在实现中存在一些一致的主题值得探索:
-
管道——在索引文档和处理查询时,将所有必要的解析、解释和排名逻辑建模为工作流程中的模块化阶段是有用的。这允许在任何时候通过交换、重新排列或添加管道中的处理阶段来轻松进行实验。
-
模型——无论你是微调一个基于复杂深度学习的 LLM(第 13-14 章)、一个学习排序模型(第 10-12 章)、一个信号增强或个性化模型(第 8-9 章),还是包含同义词、拼写错误和相关术语的知识图谱(第 5-6 章),适当的查询解释需要在索引和查询管道中按正确顺序插入正确的模型。
-
条件回退——你永远无法完美地解释每个查询。你可能有很多模型帮助解释一个查询,而对另一个查询的含义却一无所知。通常最好从一个基础或“回退”模型(通常是基于关键词的)开始,它可以不完美地处理任何查询,并在其上分层更复杂的模型,以提高解释的精确度。此外,如果没有找到结果,返回推荐可能是有用的,以确保搜索者看到一些可能有用的东西,即使它不是他们所寻求的精确内容。
图 7.1 展示了一个示例查询管道,演示了这些主题:结合管道阶段、模型和条件回退。

图 7.1 示例查询解释管道
图 7.1 接收查询bbq near atlanta,并从对查询中已知关键词、位置或其他已知术语进行实体提取的解析查询阶段开始。然后进入信号增强阶段,该阶段会检查一个信号增强模型(在第四章中介绍,将在第八章中详细讨论)以增强给定查询中最受欢迎的文档。
常常使用三种不同但互补的方法来解释单个关键词并将它们相互关联,所有这些方法都包含在示例管道中:
-
词汇搜索,例如在倒排索引中对布尔查询匹配进行 BM25 排名
-
知识图谱搜索,例如使用语义知识图谱(SKG)或显式构建的知识图谱对查询中找到的实体及其与索引中最相似实体的关系进行排序
-
密集向量搜索,例如使用嵌入的近似最近邻的向量余弦相似度
在这三个中,最常见的“默认”匹配层往往是基于倒排索引的词汇搜索,因为这种方法允许匹配文档集中存在的任何术语,无论该术语是否被理解。知识图谱和密集向量方法都依赖于能够将每个查询中的术语与概念或实体相关联,但这在所有情况下都做不到。
实际上,BM25 排名在嵌入中通常优于密集向量方法,即使是从最先进的 LLMs 中提取的,除非这些语言模型最初是在特定领域内容上训练或微调的(这可能会随时间变化,因为预训练的 LLMs 继续变得更加健壮)。我们将在第九章和第十三章开始深入探讨使用 LLMs 进行个性化搜索和语义搜索,我们将在第十四章和第十五章中花费时间微调和使用 LLMs 进行更高级的搜索功能,如问答和生成搜索。我们将在本章中主要关注演示将词汇搜索和知识图谱集成的机制。
图 7.1 管道以一个回填/回退阶段结束,这在之前阶段都无法返回完整结果集的情况下插入额外结果可能很有用。这可以简单到返回推荐而不是搜索结果(在第九章中介绍),或者可能涉及返回部分匹配的查询,精度较低。
然后将所有管道阶段的最终结果组合并按需重新排序,以产生一组最终的相关性排序搜索结果。重新排序阶段可能很简单,但通常将通过使用 机器学习排名 通过排名分类器来实现。我们将在第十章至第十二章深入探讨构建和自动化学习排名模型的训练。
尽管本节中的示例管道在许多情况下可能提供良好的结果,但管道的具体逻辑应始终取决于您应用程序的需求。在下一节中,我们将设置一个用于搜索本地商业评论的应用程序,然后我们将实现一个能够在这个领域进行语义搜索的统一管道。
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 笔记本中运行嵌入的搜索页面,但如果你在自己的电脑上运行,端口为2345,你还可以在网页浏览器中导航到http://localhost:2345/search以获得更好的体验。

图 7.2 从本地网络浏览器访问评论搜索页面
让我们先尝试一个简单的查询bbq near charlotte。目前,让我们假设你没有完成知识图谱学习过程(第六章)并且还不知道如何将 SKG(第五章)应用于你的查询解释。在这种情况下,我们只是在做标准化的词汇关键词匹配。图 7.3 显示了查询bbq near charlotte的顶级词汇搜索结果。

图 7.3 对bbq near charlotte的基本词汇关键词搜索,仅匹配关键词
在我们的评论数据集中,这是唯一与我们的查询匹配的评论,尽管在北卡罗来纳州夏洛特市及其附近存在多个烧烤(也称烧烤)餐厅。只有这个结果被返回的原因是它是唯一包含所有三个术语(bbq、near和charlotte)的文档。如果你查看评论,它甚至不是一家提供烧烤的餐厅——实际上它是一场节庆活动的评论,恰好提到了另一个名为“BBQ”的节庆活动!
这里主要的问题是大多数相关的餐厅名称中不包含单词near。图 7.4 显示,如果我们去掉单词near并搜索bbq charlotte,会有更多结果出现。

图 7.4 对bbq charlotte进行的基本词汇关键词搜索。移除“near”单词后,有更多匹配的结果。
前两个结果都包含“bbq”这个术语,但第一个有一个很低的(1 星)评分,第二个提到了“bbq chicken”(带有烧烤酱的鸡肉),但没有提到“bbq”(烧烤),这通常指的是像拉猪肉、拉鸡肉、排骨或牛腩这样的烟熏肉类。此外,虽然所有结果都在夏洛特市,NC,但这仅仅是因为它们在评论文本中匹配了关键词charlotte,这意味着许多没有在评论中提及城市的名称的好结果被遗漏了。从结果来看,搜索引擎并没有正确地解释用户的查询意图。
我们可以做得比这更好!你已经学会了如何提取特定领域的知识以及如何对查询进行分类(例如,bbq意味着餐厅),所以我们只需要将这些技术和学习模型端到端地整合。
7.3 一个端到端语义搜索示例
上一节展示了仅依赖纯关键词搜索的不足。我们如何提高搜索引擎解释查询的能力?图 7.5 展示了传统关键词搜索难以正确解释的相对具体查询的结果:top kimchi near charlotte。

图 7.5 查询top kimchi near charlotte的语义搜索
这个查询很有趣,因为只有一个关键词(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⁰.9193 korean⁰.7069banchan⁰.6593 +doc_type:\"Korean\""}。这是使用第五章中提到的 SKG 扩展方法对未知术语“kimchi”进行扩展。在这种情况下,SKG 确定“Korean”是过滤结果的类别,与“kimchi”最相关的术语是“korean”和“banchan”。 -
near charlotte:
+{!geofilt d=50 sfield="location_coordinates" pt="35.22709,-80.84313"}。这个地理过滤器将结果限制在距离美国北卡罗来纳州夏洛特纬度/经度 50 公里范围内的文档。
如果原始查询像传统词汇搜索那样没有查询解释层,则不会有任何结果匹配,如图 7.6 所示。

图 7.6 传统词汇搜索由于没有包含所有关键词的文档而返回无结果
然而,图 7.7 展示了执行语义解析和丰富后的结果。

图 7.7 语义搜索示例通过更好地解释和执行查询返回相关结果
结果看起来相当不错!您会注意到
-
有许多结果(而不是零)。
-
所有结果都有top评分(5 星)。
-
所有结果都在夏洛特。
** 即使没有包含主要关键词(“kimchi”),也有一些结果匹配,并且它们显然是为提供 kimchi 的韩国餐厅准备的,因为评论中使用了类似的术语。**
*我们将在本章剩余部分介绍如何实现这种语义查询解释级别,从高级查询解释管道开始。
7.4 查询解释管道
虽然我们通常需要在查询管道中集成多个模型和不同的查询理解方法,但大多数查询管道都共享一组类似的高级阶段:
-
解析——从查询中提取关键实体及其逻辑关系
-
丰富——生成对查询上下文、查询实体及其语义关系的理解
-
转换——重写用户的查询以优化搜索引擎的召回率和排名
-
搜索——执行转换后的查询并返回排名结果
您可以将每个阶段视为不同类型的管道阶段。正如 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 章中介绍使用 LLMs 和嵌入的密集向量搜索。使用 LLMs 和基于嵌入的查询解释的一个好处是,这些技术提供了对查询作为意义单元的更好表示。使用这种方法,查询的逻辑结构有时可能会丢失,因此它可能不适合需要保留布尔逻辑或确保某些关键词出现在搜索结果中的场景。
解析查询的另一种方法是从知识图中提取已知的术语和短语。我们在第 7.3 节的端到端示例中采用了这种方法。这种方法的一个优点是,除了提供对已知词汇的精细控制外,它还允许显式地建模特定的短语和触发词(top、in、near),以反映其功能意义,而不仅仅是关键词匹配。这种方法的一个缺点是,知识图中不存在任何术语或短语无法被轻松提取和解释。
由于我们将在后面的章节中深入研究大型语言模型(LLMs),因此在本章中,我们将专注于使用知识图进行显式查询解析,因为显式解析可以为特定领域提供显著的定制化,实现成本低,并使我们能够结合我们已学到的所有其他人工智能技术。
实现语义查询解析器
在语义解释查询的第一步是识别查询中的术语和短语(解析阶段)。在第六章中,我们介绍了如何从我们的内容和用户行为信号中识别重要的特定领域术语和短语。这些可以作为已知实体列表,用于在传入查询上启用实体提取。
由于已知短语列表中可能存在数百万个实体,因此一个高效的结构,如有限状态转换器(FST),使得在毫秒内仅用这种规模进行实体提取成为可能。我们不会在这里深入探讨 FST 的工作原理,但它们能够非常紧凑地压缩许多术语序列,并在这些术语序列上快速查找,从而实现闪电般的实体提取。
我们的示例搜索引擎 Apache Solr 实现了一个文本标记器请求处理器,它是专门为快速实体提取而构建的。它允许您将任意数量的术语索引到一个查找索引中,因此您可以将该索引构建到 FST 中,并在任何传入的文本流中从该索引中提取术语。
在第六章中,我们生成了特定领域短语的列表,这些列表还包括了变体拼写。我们可以将这些术语映射到一个特别配置的entities集合中,包括任何拼写变体,以实现从传入查询中无缝提取实体。以下列表探讨了entities集合中几种类型的实体数据。
列表 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_form和canonical_form将是相同的,但我们的实体提取器将始终匹配surface_form并将其映射到canonical_form,因此使用此机制将实体的拼写多个变体映射到一个官方或“规范”版本。这可以用来处理拼写错误(“amin”⇒“admin”),首字母缩略词和缩写(“cto”⇒“首席技术官”),模糊术语(“cto”⇒“首席技术官”与“cto”⇒“已取消订单”),甚至将术语映射到特定的解释逻辑(语义函数)如“附近”⇒{location_distance}。
“语义函数”类型是一个特殊类型,我们将在第 7.4.2 节中探讨;它允许非线性、条件查询解析规则。例如,“如果单词near后面跟着一个具有地理位置的实体,则将查询的这一部分解释为地理过滤器”。
在出现模糊术语的情况下,将存在多个条目,包含相同的外部形式,但映射到不同的规范形式。在这种情况下,popularity字段将指定一个相对值,表示哪种含义更常见(值越高,越流行)。
此格式也是可扩展的——您可以添加一个表示规范形式语义意义的vector字段,或者一个包含具有相似意义的其他术语的related_terms字段。这将使缓存canonical_form的静态意义表示成为可能,这在查询时可能比引用外部模型或知识图上的已知术语更有效。
调用实体提取器
除了在列表 7.1 中创建的reviews集合外,我们还需要创建一个包含要提取的已知实体的entities集合。此集合将作为显式知识图,包含列表 7.3 中的所有实体,以及世界上所有主要城市的列表。下一个列表配置并填充了entities集合。
列表 7.4 创建entities集合
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 的文本标记功能将通过内部进行以下配置更改来启用:
-
使用 Solr 中的
TaggerRequestHandler添加/entities/tag端点。我们可以向此端点传递查询以执行entities集合中找到的任何实体的实体提取。 -
在模式中添加一个配置为使用内存中 FST 的
tags字段类型,从而实现从可能包含数百万个实体的集合中以毫秒级的速度进行紧凑且快速的标记。 -
添加一个
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 索引格式进行配置,这允许使用 FST 进行快速内存匹配。
2 ConcatenateGraphFilter 是文本标记器用于促进实体提取的特殊过滤器。
3 我们添加了 name_tag 字段,我们将使用它来对索引进行查询标记。
4 使用表面形式值填充 name_tag 字段。
5 配置了一个 /tag 请求处理器,用于使用在 name_tag 字段中索引的值作为从传入查询中提取的实体。
6 如果多个实体匹配(多义性),则默认返回最流行的一个。
在创建了 entities 集合、配置了文本标签器并将所有实体索引后,我们现在可以开始对查询进行实体提取。在下面的列表中,我们运行了一个针对 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。它还只在 ids 中列出一条记录,这意味着只有一个可能的意义(规范表示)。然而,对于其他两个标签,列出了多个 ids,使它们成为模糊标签:
-
{"startOffset":11,"endOffset":15,"matchText":"near","ids":["1", "5"]} -
{"startOffset":16,"endOffset":25,"matchText":"charlotte","ids": ["4460243", "4612828", "4680560", "4988584", "5234793"]}
这意味着对于表面形式“near”有两个规范形式(列出两个ids),对于表面形式“charlotte”有五个规范形式。在entities部分,我们还可以看到与标签中的ids列表相关联的所有不同实体记录。
在本章中,我们将保持简单,始终使用具有最高popularity的规范形式。对于城市,我们在popularity字段中提供了城市的人口,这意味着所选的“charlotte”是北卡罗来纳州的夏洛特(世界上人口最多的夏洛特)。对于我们的其他实体,其流行度在列表 7.3 中的 entities.csv 中手动指定。你也可以使用信号增强值(如果你从信号中导出实体,这将在第八章中详细介绍)或使用包含实体的索引中文档的数量作为流行度的代理来指定流行度。
你可能会发现使用用户特定的上下文或查询特定的上下文来选择最合适的实体是有益的。例如,如果你正在消除地点歧义,你可以通过地理距离计算来提高流行度,使靠近用户的地点获得更高的权重。如果实体是一个关键词短语,你可以使用 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)
1 使用标记实体重构查询
2 将已知实体用大括号括起来,以使其与常规关键词区分开来
输出:
{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 为语义搜索丰富查询
我们查询解释管道的 丰富 阶段专注于理解查询中实体之间的关系以及如何最佳地解释和表示它们以优化搜索结果的相关性。
本书的大部分内容已经集中,并将继续集中在丰富阶段。第四章介绍了众包相关性,这是一种通过基于先前用户交互的信息来丰富特定关键词短语的方法。第五章专注于知识图谱,它提供了一种通过主题分类丰富特定关键词短语并找到其他高度相关术语的方法。在第六章中,我们实现了查找同义词、拼写错误和相关术语的算法,这些算法可以通过增强或替换解析术语以更好的学习版本来丰富查询。即将到来的关于信号增强、个性化以及嵌入上的密集向量搜索的章节也将介绍新的方法来解释解析实体并丰富查询以优化相关性。
这些技术都是你的工具箱中的工具,但将它们结合到任何特定实现的最佳方式将是领域特定的,因此我们将在示例中避免过度泛化。相反,我们将专注于一个简单的端到端实现,以便将其他模型轻松地插入其中。我们的简单实现将包括两个组件:
-
一种 语义函数 实现,它允许为每个领域注入动态和非线性的语义规则
-
一个 SKG 来寻找未知关键词和查询分类的相关术语
你已经有了扩展查询解析框架以处理来自先前章节的其他丰富类型的工具。例如,你可以使用从表面形式到规范形式的映射来处理第六章中学到的所有替代表示。同样,通过向 entities 集合中的每个实体添加额外的字段,你可以注入信号提升、相关术语、查询分类或向量,使它们在查询解析后即可使用。
让我们通过讨论语义函数来启动我们的丰富实现。
实现语义函数
语义函数 是一个非线性函数,可以在查询解析和丰富过程中应用,以更好地解释周围术语的含义。我们之前的例子 top kimchi near charlotte 包含两个映射到语义函数的术语:“top”和“near”。术语“top”具有非常特定的领域含义:优先考虑评分最高的文档(评论中的星级数量)。同样,术语“near”不是一个应该匹配的关键词;相反,它修改了后续术语的含义,试图将它们组合成一个地理位置。从列表 7.3 中,你会看到以下实体引用了语义函数:
Semantic Function Entities
+-------+-------------------+----------+----------------------------------+
|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”都映射到 {popularity} 规范化形式,在下一列表中由 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 另一个查询树节点必须跟随流行度节点(“top mountain”而不是“mountain top”)。
2 将查询树中的 {popularity} 节点替换为一个新的节点,该节点表示对流行度的相关性提升。
3 返回语义函数是否被触发。如果为 False,则可以尝试另一个优先级较低的覆盖函数。
此流行度函数使我们能够应用语义解释逻辑来操纵查询树。如果查询树以关键词“top”结束,则函数将返回 False 并不会进行任何调整。同样,如果另一个函数被分配了更高的优先级(如 entities 集合中指定),则它可能在执行其函数之前就移除了 {popularity} 实体。
location_distance 函数稍微复杂一些,如下一列表所示。
列表 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”——本质上是一个编辑距离搜索。请注意,还有一个实体映射near ⇒ {text_distance},可以在 {location_distance} 实体的语义函数返回 False 的情况下有条件地调用此回退用例。
语义函数可以以许多不同的方式实现,但我们的示例实现提供了一种高度可配置的方法,将动态语义模式编码到查询解释管道中,以最佳地连接到您的搜索应用可用的许多不同的 AI 驱动搜索方法。我们将在下面的列表中展示这个实现,该列表通过循环查询树来调用所有匹配的语义函数。
列表 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 在评估时,将查询和位置变量传递给语义函数。
2 遍历查询树中的所有项,寻找要执行的语义函数
3 动态评估语义函数,这些函数增强了查询树
4 更新任何失败的语义函数的类型
由于语义函数存储为 entities 集合中其实体的一部分,我们对这些函数执行后期绑定(使用 Python 的 eval 函数)。这允许你随时将新的语义函数插入到 entities 集合中,而无需修改应用程序代码。
由于语义函数可能成功或失败取决于周围上下文节点,每个语义函数都必须返回 True 或 False,以便处理逻辑确定如何处理查询树的其余部分。
集成 SKG
在本节中,我们将集成一个 SKG(在第五章中讨论)到我们的查询丰富过程中。
你的 entities 集合可能包含许多使用第六章中技术学习到的实体。你也可以使用 SKG 或其他方法来分类已知实体或生成相关术语列表。如果你这样做,我们建议将分类和相关术语作为额外的字段添加到 entities 集合中,以便在查询时缓存响应以加快查找速度。
在我们的实现中,我们将实时调用 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 个 doc_type(类别)
4 未找到增强项时返回空
5 返回遍历中发现的类别
6 从发现的与相关术语相关的增强项中构建一个增强查询
7 为关键词“kimchi”获取增强项
列表 7.12 对于关键词“kimchi”的输出如下:
{"category": "Korean",
"term_vector": "kimchi⁰.9193 korean⁰.7069 banchan⁰.6593 bulgogi⁰.5497"}
这里是一些其他潜在关键词的样本 SKG 输出:
- bb**q:
{"category": "Barbeque",
"term_vector": "bbq⁰.9191 ribs⁰.6187 pork⁰.5992 brisket⁰.5691"}
- korean bb**q:
{"category": "Korean",
"term_vector": "korean⁰.7754 bbq⁰.6716 banchan⁰.5534 sariwon⁰.5211"}
- lasagna:
{"category": "Italian",
"term_vector": "lasagna⁰.9193 alfredo⁰.3992 pasta⁰.3909
↪italian⁰.3742"}
- karaok**e:
{"category": "Karaoke",
"term_vector": "karaoke⁰.9193 sing⁰.6423 songs⁰.5256 song⁰.4118"}
- drive through:
{"category": "Fast Food",
"term_vector": "drive⁰.7428 through⁰.6331 mcdonald's⁰.2873
↪window⁰.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.05720)不是使用倒排索引作为其语言模型,而是使用预先构建的语言模型来生成上下文化的标记。我们不会使用 SPLADE(或 SPLADE V2 或后续版本),因为它没有在允许商业使用的许可下发布,但列表 7.14 展示了与我们在第 7.4.2 节中用 SKG 方法测试的相同示例查询的替代开源实现(SPLADE++)的样本输出。
列表 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++扩展的输出:
- *泡菜**:
{"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}
- *泡菜烤肉:
{"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": 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}
- *千层面**:
{"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}
- *卡拉 OK**:
{"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}
- *驾车通过**:
{"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 输出进行比较时,您可能会注意到以下差异:
-
SKG 的输出返回索引中的实际术语,而 SPLADE 风格的输出返回 LLM 的标记。这意味着您可以直接使用 SKG 输出(“lasagna”,“alfredo”,“pasta”)在您的文档字段上搜索,而 SPLADE 标记(
las,##ag,na##)则需要从 SPLADE 生成并索引到所有文档中,以便在查询时匹配正确的标记。 -
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,…}`。与 SKG 模型相比,SPLADE 模型的这种表现不足主要是由于 SPLADE 没有在搜索索引中的数据上训练,而 SKG 直接使用搜索索引中的数据作为其语言模型。微调基于 SPLADE 的模型将有助于缩小这一差距。 -
SKG 模型更灵活,因为它可以返回多个维度的关系。注意在上一节中,我们不仅返回了相关术语的稀疏向量,还返回了查询的分类。
-
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⁰.9193 korean⁰.7069 banchan⁰.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。如果你对比这个变体的输出(如图 7.8 所示)与原始查询 top kimchi near charlotte 的输出,你会发现它们产生了与第 7.5 和 7.7 节中较早的章节中相同的转换查询和最终的搜索结果集。

图 7.8 为good kimchi in charlotte的搜索结果,解释为与top kimchi near charlotte端到端示例的语义相同
恭喜你,你现在已经实现了一个端到端语义搜索管道,它可以对内容进行语义解析、丰富、转换,并执行搜索。本章没有介绍任何新的机器学习算法,而是提供了一个具体的实现,说明你在这本书中学到的许多模型、算法和其他技术如何集成到一个端到端系统中。
在本书的剩余部分,我们将继续探索更多高级方法,这些方法可以插入到这个框架中,以增强相关性排名并改善查询意图理解。
摘要
-
查询解释需要适当地混合查询管道与学习模型,同时确保有足够的后备模型来匹配未知关键词。
-
仅匹配关键词有时可能有效,但当连接词(如
top、near等)所表达的意思未被理解时,会导致匹配效果不佳。解决这一问题的方法之一是通过实现特定领域的语义函数来克服这些限制。 -
一个了解你领域内已知术语和短语的语义查询解析器,允许你从基于关键词的搜索过渡到对实体及其关系的语义搜索。
-
提取已知实体使得模型能够无缝集成到你的查询解释管道中,通过将关键词的表面形式映射到由你的学习模型生成的实体的规范表示(信号增强、替代拼写、相关术语以及其他知识图谱数据)。
-
语义搜索涉及解析已知实体,丰富使用学习模型,转换查询以优化目标搜索引擎的匹配和相关性,然后搜索并将结果返回给最终用户。我们将在接下来的章节中继续探索更多高级技术,以将这些阶段插入到这些阶段中。*
第三部分 反映智能
搜索排名不应该是静态的函数。每个查询和用户与搜索结果的互动都是一个可以用来提高未来结果相关性的信号。在最后一部分,我们从内容和信号中学习了特定领域的知识,并使用这些知识来解释查询意图。
在这部分,我们将更深入地探讨反映智能的主题,即从用户与搜索结果的互动中学习以改进相关性排名模型的过程。我们将扩展第四章中关于众包相关性算法的覆盖范围,并设有章节专门介绍三种关键类型的反映智能:普及的相关性(信号增强)、个性化的相关性(协同过滤)和通用的相关性(学习排名)。
第八章涵盖了各种信号增强方法,以更好地对您最受欢迎的查询进行排名。第九章随后利用信号构建更个性化的相关性排名,根据用户的行为满足他们特定的兴趣。第十章开始了一段三章节的旅程,深入到机器学习排名的世界,也称为“学习排名”。在第十章中,您将学习如何从相关性判断构建排名分类器模型,以及如何部署该模型作为通用的相关性排名算法。第十一章介绍了点击模型——利用用户搜索和点击信号来学习隐含的相关性判断,并使用它们来自动化学习排名的过程。最后,第十二章介绍了主动学习和 A/B 测试方法,通过智能地探索以前未见过的结果和收集实时用户反馈,来克服那些隐式训练模型中的偏差。
第八章:信号增强模型
本章涵盖
-
聚合用户信号以创建基于流行度的排名模型
-
对噪声查询输入进行信号归一化
-
在众包信号中打击信号垃圾邮件
-
应用时间衰减以优先考虑近期信号
-
将多种信号类型混合到一个模型中
-
选择查询时增强或索引时增强
在第四章中,我们介绍了三种不同类别的反射智能:信号增强(流行相关性)、协同过滤(个性化相关性)和排序学习(泛化相关性)。在本章中,我们将深入探讨这些中的第一个,实现信号增强以增强您最热门的查询和文档的相关性排名。
在大多数搜索引擎中,相对较少的查询往往占很大一部分总查询量。这些热门查询,称为头部查询,也倾向于产生更多信号(例如电子商务用例中的点击和购买),这有助于对顶级搜索结果的热度做出更强的推断。
信号增强模型直接利用这些更强的推理,并确保您最重要的和最高可见度的查询得到最佳调整以返回最相关的文档。
8.1 基本信号增强
在 4.2.2 节中,我们在 RetroTech 数据集上构建了我们的第一个信号增强模型,显著提高了最常搜索和点击的搜索结果的相关性。在本节中,我们将快速回顾创建简单信号增强模型的过程,我们将在接下来的章节中在此基础上解决一些更高级的需求。
您会记得从 4.2.2 节中,信号增强模型聚合了文档上的有用用户行为信号(如点击信号),这些信号是特定查询的结果。我们使用对ipad的搜索,并根据该搜索结果中该文档被点击的次数来增强每个文档。图 8.1 展示了查询ipad在应用信号增强前后的搜索结果。

图 8.1 应用信号增强模型前后的对比。信号增强通过将最受欢迎的项目推到搜索结果顶部来提高相关性。
导致图 8.1 中相关性提高的信号增强模型是一个基本的信号增强模型。它查看给定查询中每个曾经被点击的文档,并应用与该查询在该文档上的过去点击总数相等的增强。
尽管本章 4.2.2 节中介绍的基本信号增强模型极大地提高了相关性,但它不幸地容易受到一些数据偏差甚至操纵的影响。在 8.2 节中,我们将讨论一些去除信号中的噪声的技术,以最大化信号增强模型的质量并降低不希望偏差的可能性。
8.2 归一化信号
在聚合之前对传入的用户查询进行标准化非常重要,以便将变体视为相同的查询。鉴于最终用户可以将任何任意文本作为查询输入,聚合的信号本质上是有噪声的。第四章中的基本信号增强模型(在第 8.1 节中总结)没有进行标准化。它为每个查询和文档对生成聚合的增强,但由于传入的查询尚未标准化为共同的形式,查询的变体将被视为完全不同的查询。以下列表生成了一个列表,列出了在搜索结果中增强最受欢迎的 iPad 型号的顶级查询。
列表 8.1 聚合信号并检索相关查询
def create_boosting_collection(collection_name):
basic_signals_aggregation_query = """ #1
SELECT q.target AS query, c.target AS doc, #1
COUNT(c.target) AS boost #1
FROM signals c LEFT JOIN signals q #1
ON c.query_id = q.query_id #1
WHERE c.type = 'click' AND q.type = 'query' #1
GROUP BY q.target, doc #1
ORDER BY boost DESC #1
"""
collection = engine.get_collection(collection_name) #2
return aggregate_signals(collection, "basic_signals_boosts", #2
basic_signals_aggregation_query) #2
def search_for_boosts(query, collection, query_field="query"): #3
boosts_request = {"query": query, #3
"query_fields": [query_field], #3
"return_fields": ["query", "doc", "boost"], #3
"limit": 20, 3((CO1-16)) #3
"order_by": [("boost", "desc")]} #3
response = collection.search(**boosts_request) #3
return response["docs"] #3
signals_boosting_collection = create_boosting_collection("signals")
query = "885909457588" #4
signals_docs = search_for_boosts(query, signals_boosting_collection, "doc") #5
show_raw_boosted_queries(signals_docs) #5
1 定义信号聚合查询
2 从信号集合运行聚合到基本信号增强集合
3 加载指定查询和集合的信号增强
4 最受欢迎的 iPad 型号
5 返回指定文档的顶级信号增强列表
输出:
Raw Boosted Queries
"iPad" : 1050
"ipad" : 966
"Ipad" : 829
"iPad 2" : 509
"ipad 2" : 347
"Ipad2" : 261
"ipad2" : 238
"Ipad 2" : 213
"I pad" : 203
"i pad" : 133
"IPad" : 77
"Apple" : 76
"I pad 2" : 60
"apple ipad" : 55
"Apple iPad" : 53
"ipads" : 43
"tablets" : 42
"apple" : 41
"iPads" : 38
"i pad 2" : 38
此列表通过其查询聚合所有信号,并将每个查询及其出现次数存储到一个新的集合中。您可以从输出中看到,在基本信号增强模型中存在许多相同查询的变体。变体的最大罪魁祸首似乎是对大小写敏感,因为我们看到iPad、ipad、Ipad和IPad作为常见变体。空格似乎也是另一个问题,有ipad 2与i pad 2与ipad2之间的区别。我们甚至在ipad与ipads中看到了单数与复数表示。
关键词搜索字段通常将查询标准化为不区分大小写,使用词干分析来忽略术语的复数形式,并在单词之间的大小写变化和字母到数字的转换处分割。同样,对信号进行标准化也很有用,因为保持不同查询术语和增强的变体,这些变体对于搜索引擎来说无法区分,可能是适得其反的。未能标准化术语会分散信号的价值,因为信号被分散到具有较低增强的相同关键词的变体中,而不是合并成更有意义的查询并具有更强的增强。
在信号聚合之前,您需要确定查询标准化应该有多复杂,但即使只是将传入的查询转换为小写以使信号聚合不区分大小写,也能走很长的路。以下列表演示了与之前相同的信号聚合基本操作,但这次首先将查询转换为小写。
列表 8.2 不区分大小写的信号聚合
normalized_signals_aggregation_query = """
SELECT LOWER(q.target) AS query, #1
c.target AS doc, COUNT(c.target) AS boost #2
FROM signals c LEFT JOIN signals q ON c.query_id = q.query_id
WHERE c.type = 'click' AND q.type = 'query'
GROUP BY LOWER(q.target), doc #2
ORDER BY boost DESC
"""
normalized_collection = \
aggregate_signals(signals_collection, "normalized_signals_boosts",
normalized_signals_aggregation_query)
query = "885909457588" #3
signals_documents = search_for_boosts(query, normalized_collection, "doc")
show_raw_boosted_queries(signals_documents)
1 通过将每个查询转换为小写来标准化情况
2 按标准化查询分组增加了这些查询的信号计数,增加了信号增强
3 最受欢迎的 iPad 型号
输出:
Raw Boosted Queries
"ipad" : 2939
"ipad 2" : 1104
"ipad2" : 540
"i pad" : 341
"apple ipad" : 152
"ipads" : 123
"apple" : 118
"i pad 2" : 99
"tablets" : 67
"tablet" : 61
"ipad 1" : 52
"apple ipad 2" : 27
"hp touchpad" : 26
"ipaq" : 20
"i pad2" : 19
"wi" : 19
"apple computers" : 18
"apple i pad" : 15
"ipad 2 16gb" : 15
"samsung galaxy" : 14
原始增强查询列表看起来已经整洁多了!不仅冗余更少,而且请注意,信号增强的强度已经增加,因为更多的信号被归因于查询的规范形式(小写版本)。
将查询转换为小写,也许删除空白或多余的字符,通常在聚合信号之前对查询进行充分规范就足够了。然而,本节的重要启示是,信号增强模型越强,你就能确保在聚合时将相同的查询视为相同。
查询中的变化并不是我们需要担心数据中的唯一类型的噪声。在下一节中,我们将讨论如何克服由垃圾邮件引起的潜在的重大问题。
8.3 拒绝信号垃圾邮件
任何时候我们使用如点击信号这样的众包数据来影响搜索引擎的行为时,我们需要问自己“数据输入如何被操纵以产生不理想的结果?”在本节中,我们将展示如何通过点击信号垃圾邮件来操纵搜索结果,以及如何阻止它。
8.3.1 使用信号垃圾邮件来操纵搜索结果
让我们想象有一个用户,出于某种原因,非常讨厌星球大战,并认为最新的电影完全是垃圾。他们感情如此强烈,以至于他们想要确保任何搜索star wars都能返回作为顶级搜索结果的物理垃圾桶。这个用户对搜索引擎略知一二,并注意到你的杀手级相关性算法似乎在使用用户信号和信号增强。图 8.2 显示了查询star wars的默认响应,信号增强将最受欢迎的产品带到搜索结果的顶部。

图 8.2 在查询star wars时最受欢迎的搜索结果,信号增强已开启。在没有恶意信号垃圾邮件的情况下,这些是预期的结果。
用户决定既然你的搜索引擎排名基于流行项目,他们将通过大量搜索star wars来垃圾邮件搜索引擎。他们将跟随每次搜索后对找到的星战主题垃圾桶的虚假点击,试图使垃圾桶出现在搜索结果的顶部。
为了模拟这种场景,我们将在以下列表中运行一个简单的脚本,以生成 5,000 个star wars查询和在该查询后对垃圾桶进行 5,000 次相应的点击。
列表 8.3 生成垃圾查询和点击信号
signals_collection = engine.get_collection("signals")
spam_user = "u8675309"
spam_query = "star wars"
spam_signal_boost_doc_upc = "45626176" #1
signal_docs = []
for num in range(5000): #2
query_id = f"u8675309_0_{num}" #2
query_signal = { #2
"query_id": query_id, #2
"user": spam_user, #2
"type": "query", #2
"target": spam_query, #2
"signal_time": datetime.now().strftime("%Y-%m-%dT%H:%M:%SZ"), #2
"id": f"spam_signal_query_{num}"} #2
click_signal = { #2
"query_id": query_id, #2
"user": spam_user, #2
"type": "click", #2
"target": spam_signal_boost_doc_upc, #2
"signal_time": datetime.now().strftime("%Y-%m-%dT%H:%M:%SZ"), #2
"id": f"spam_signal_click_{num}"} #2
signal_docs.extend([click_signal, query_signal]) #2
signals_collection.add_documents(signal_docs) #2
spam_signals_collection = \ #3
aggregate_signals(signals_collection, "signals_boosts_with_spam", #3
normalized_signals_aggregation_query) #3
1 将垃圾邮件想要移至搜索结果顶部的内容文档
2 生成并发送 5,000 个查询和点击信号到搜索引擎
3 生成并发送 5,000 个查询和点击信号到搜索引擎
4 运行信号聚合以生成包括垃圾信号的信号增强模型
列表 8.3 向我们的搜索引擎发送数千个垃圾查询和点击信号,模拟了如果用户搜索并点击特定的搜索结果数千次我们会看到的结果。该列表然后重新运行基本的信号聚合。
为了看到恶意用户垃圾点击行为对我们搜索结果的影响,以下列表运行了查询 star wars,现在结合了操纵后的信号增强模型。
列表 8.4 受垃圾用户信号影响的搜索结果
def boosted_product_search_request(query, collection, boost_field=None):
signals_documents = search_for_boosts(query, collection) #1
signals_boosts = create_boosts_query(signals_documents) #1
boosted_request = product_search_request(query) #2
if boost_field: #2
signals_boosts = (boost_field, signals_boosts) #2
boosted_request["query_boosts"] = signals_boosts #2
return boosted_request
query = '"star wars"'
boosted_request = boosted_product_search_request(query,
spam_signals_collection, "upc")
response = products_collection.search(**boosted_request)
display_product_search(query, response["docs"])
1 从包含垃圾信号的信号增强模型中加载信号增强
2 使用信号增强模型提升星球大战查询
图 8.3 显示了从列表 8.4 生成的新的操纵后的搜索结果,其中星球大战垃圾桶位于顶部位置。

图 8.3 用户通过向搜索引擎发送虚假信号来操纵搜索结果。用户只需多次点击就能修改顶部结果。
垃圾邮件发送者成功了,这些被操纵的搜索结果现在将出现在每个在 RetroTech 网站上搜索 star wars 的后续访客面前!看起来我们需要使我们的信号增强模型更加健壮,以对抗这种信号垃圾。
8.3.2 通过基于用户的过滤来对抗信号垃圾
如果你打算使用如用户信号这样的众包数据来影响搜索引擎排名,那么采取步骤最小化用户操纵基于信号的排名算法的能力是很重要的。
为了对抗我们刚刚展示的星球大战垃圾桶问题,最简单的技术是确保同一用户重复点击只获得信号增强聚合中的一个“投票”。这样,无论恶意用户点击一次还是一百万次,他们的点击只算作一个信号,因此对信号增强模型没有实质性的影响。以下列表重新设计了信号聚合查询,只计算每个用户的唯一点击信号。
列表 8.5 去除噪声用户信号
anti_spam_aggregation_query = """
SELECT query, doc, COUNT(doc) AS boost FROM (
SELECT c.user unique_user, LOWER(q.target) AS query, c.target AS doc,
MAX(c.signal_time) AS boost #1
FROM signals c LEFT JOIN signals q ON c.query_id = q.query_id
WHERE c.type = 'click' AND q.type = 'query'
**GROUP BY unique_user**, LOWER(q.target), doc) #2
GROUP BY query, doc
ORDER BY boost DESC"""
anti_spam_collection = \
aggregate_signals(signals_collection, "signals_boosts_anti_spam",
anti_spam_aggregation_query)
1 信号日期是如果有重复的用户最新的信号。
2 按用户分组以限制每个用户在信号增强模型中对每个查询/文档对只有一个“投票”。
如果我们使用新的 signals_boosts_anti_spam 模型重新运行列表 8.3 中的 star wars 查询,我们会看到我们的正常搜索结果已经返回,看起来与图 8.2 中的相同。这是因为恶意用户的额外垃圾信号都被减少为单个不良信号,如表 8.1 所示。
表 8.1 5,000 个垃圾信号在反垃圾信号增强模型中已去重为 1 个信号。
| 模型 | 查询 | 文档 | 增强 |
|---|---|---|---|
在垃圾信号之前 (normalized_signals_boosts) |
star wars | 400032015667 | 0(尚未有信号) |
在处理垃圾邮件信号 (normalized_signals_boosts) 之后 |
星球大战 | 400032015667 | 5000 |
在清理垃圾邮件信号之后 (signals_boosts_anti_spam) |
星球大战 | 400032015667 | 1 |
你可以看到,signals_boosts_anti_spam模型中的聚合信号计数与我们在生成垃圾邮件信号之前构建的normalized_signals_boosts模型的总数非常接近。由于在signals_boosts_anti_spam模型中,每个用户在每个查询/文档对中仅限于一个信号,因此用户操纵信号增强模型的能力现在大大降低。
当然,你可以识别出任何似乎在垃圾邮件你的搜索引擎的用户账户,并从你的信号增强聚合中完全删除它们的信号,但通过去重减少信号的范围要简单得多,并且通常可以达到恢复良好、众包相关排名的相同最终目标。
在列表 8.5 中,我们使用用户 ID 作为关键标识符来去重垃圾邮件信号,但任何标识符在这里都适用:用户 ID、会话 ID、浏览器 ID、IP 地址,甚至某种类型的浏览器指纹。只要你能找到一些可以唯一标识用户或识别低质量流量(如机器人和网络爬虫)的价值,你就可以使用这些信息来去重信号。如果上述技术都不奏效,并且你的点击信号中噪音太多,你也可以选择只查看已知(已认证)用户的点击信号,你可能会更有信心认为这是合法流量。
减少信号垃圾的一种最终方法是通过找到一种方法来区分重要的信号类型和那些容易被操纵的嘈杂信号。例如,从运行查询和点击搜索结果中生成信号是很容易的。而从购买产品中产生的信号则要难以操纵得多,因为它们需要用户在购买记录之前登录或输入支付信息。恶意购买 5000 个星球大战垃圾桶的可能性相当低,因为这样做存在多个金融和物流障碍。
不仅从打击垃圾邮件的角度来看,将购买行为视为比点击更强烈的信号是有价值的,而且从相关性的角度来看,这也是有价值的,因为购买行为更能清楚地表明用户的意图。在下一节中,我们将介绍如何将不同的信号类型组合成一个考虑每种不同信号类型相对重要性的信号增强模型。
8.4 组合多种信号类型
到目前为止,我们只处理了两种信号类型——查询和点击。对于某些搜索引擎(如网络搜索引擎),点击信号可能是构建信号增强模型可用的唯一良好来源的众包数据。然而,存在许多不同的信号类型,它们可以为构建信号增强模型提供额外的、通常更好的输入。
在我们的 RetroTech 数据集中,我们有几种在电子商务用例中常见的信号:
-
查询
-
点击
-
添加到购物车
-
购买
虽然对查询的点击有帮助,但它们并不一定意味着对产品的强烈兴趣,因为某人可能只是浏览以查看有什么可用。如果某人将产品添加到他们的购物车中,这通常比点击代表更强的兴趣信号。购买是一个更强的信号,表明用户对产品感兴趣,因为用户愿意为搜索到的商品支付金钱。
虽然一些电子商务网站可能拥有足够的流量来完全忽略点击信号,而只关注添加到购物车和购买信号,但在计算信号提升时,通常包括所有信号类型更有用。幸运的是,在信号聚合时,将相对权重作为乘数分配给每个信号类型非常简单:
signals_boost = (1 * sum(click_signals)) +
(10 * sum(add_to_cart_signals)) +
(25 * sum(purchase_signals))
通过将每个点击计为 1 个信号,每个添加到购物车计为 10 个信号,每个购买计为 25 个信号,在信号提升模型中,购买比点击重 25 倍,添加到购物车比点击重 10 倍。这有助于减少来自不可靠信号的噪声,同时提高更可靠信号的权重,同时在更好的信号不太普遍的情况下(如新产品或冷门商品)仍然利用大量不可靠信号。
以下列表演示了一个信号聚合设计,旨在将不同权重的不同信号类型组合在一起。
列表 8.6 使用不同权重的多个信号类型组合
mixed_signal_types_aggregation_query = """
SELECT query, doc, ((1 * click_boost) **#1
**+ (10 * add_to_cart_boost) +** #1
**(25 * purchase_boost)) AS boost** FROM ( #1
SELECT query, doc,
**SUM(click) AS click_boost**, #2
**SUM(add_to_cart) AS add_to_cart_boost**, #2
**SUM(purchase) AS purchase_boost** FROM ( #2
SELECT lower(q.target) AS query, cap.target AS doc,
IF(cap.type = 'click', 1, 0) AS click,
IF(cap.type = 'add-to-cart', 1, 0) AS add_to_cart,
IF(cap.type = 'purchase', 1, 0) AS purchase
FROM signals cap LEFT JOIN signals q on cap.query_id = q.query_id
WHERE (cap.type != 'query' AND q.type = 'query')
) raw_signals
GROUP BY query, doc) AS per_type_boosts"""
type_weighted_collection = \
aggregate_signals(signals_collection, "signals_boosts_weighted_types",
mixed_signal_types_aggregation_query)**
#1 通过将多个信号以不同的相对权重组合,来计算总的提升值。
#2 每种信号类型在组合之前都是独立求和的。 您可以从 SQL 查询中看到,每个查询/文档对的总体提升是通过计算所有点击(权重为 1),计算所有添加到购物车信号并将其乘以权重 10,以及计算所有购买信号并将其乘以权重 25 来计算的。
这些建议的权重(添加到购物车信号为 10 倍,购买信号为 25 倍)在许多电子商务场景中应该效果良好,但这些相对权重也可以为每个领域完全配置。您的网站可能被设置为几乎每个人将产品添加到购物车都会购买该产品(例如,一个杂货店配送应用程序,使用网站的唯一目的是填充购物车并购买)。在这些情况下,您可能会发现将项目添加到购物车不会增加任何额外的价值,但从购物车中移除项目可能具有潜在的惩罚,表明该产品与查询不匹配。
在这种情况下,你可能想引入负面信号增强的概念。就像我们讨论点击、加入购物车和购买一样,作为用户意图的信号,你的用户体验也可能有无数种方式来衡量用户对你搜索结果的不满。例如,你可能有一个点赞按钮或从购物车中移除按钮,或者你可能能够在购买后跟踪产品退货。你可能甚至想要计算搜索结果中被跳过的文档,并为这些文档记录一个“跳过”信号,以表明用户看到了它们,但没有表现出兴趣。我们将在第十一章讨论点击建模时,涵盖管理点击与跳过文档的主题。
幸运的是,处理负面反馈与处理正面信号一样简单:你不仅可以对信号分配越来越积极的权重,还可以对负面信号分配越来越消极的权重。以下是一个例子:
positive_signals = (1 * sum(click_signals)) +
(25 * (purchase_signals)) +
(10 * sum(add_to_cart_signals)) +
(0.025 * sum(seen_doc_signals))
negative_signals = (-0.025 * sum(skipped_doc_signals)) +
(-20 * sum(remove_from_cart_signals)) +
(-100 * sum(returned_item_signals)) +
(-50 * sum(negative_post_about_item_in_review_signals))
type_based_signal_weight = positive_signals + negative_signals
这个简单的线性函数提供了一个高度可配置的基于信号的排名模型,它接受多个输入参数,并根据这些参数的相对权重返回一个排名分数。你可以将尽可能多的有用信号组合到这个加权信号聚合中,以提高模型的鲁棒性。当然,调整每种信号类型的权重以实现最佳平衡可能需要一些努力。你可以手动进行,或者可以使用一种称为学习排名的机器学习技术来完成。我们将在第十章和第十一章中探讨学习排名。
不仅需要根据彼此的重要性对不同的信号进行加权,有时还可能需要将相同类型的信号以不同的方式相互加权。在下一节中,我们将讨论一个关键示例:对更近期的交互赋予更高的价值。
8.5 时间衰减和短暂信号
信号并不总是无限期地保持其有用性。在上一个章节中,我们展示了如何调整信号增强模型,使其将不同类型的信号视为比其他信号更重要。在本节中,我们将讨论一个不同的挑战——在信号随着时间的推移而变得不那么有用时,考虑信号的“时间价值”。
想象三种不同的搜索引擎使用场景:
-
一个具有稳定产品的电子商务搜索引擎
-
一个求职搜索引擎
-
一个新闻网站
对于像 RetroTech 这样的电子商务搜索引擎,文档(产品)通常可以持续多年,而最好的产品往往是那些有长期兴趣记录的产品。
在一个求职搜索引擎中,文档(工作)可能只存在几周或几个月,直到工作被填补,然后它们永远消失。然而,当文档存在时,新的点击或求职申请并不一定比旧的交互作为信号更重要。
在新闻搜索引擎中,尽管新闻文章会永远存在,但新文章通常比旧文章更重要,新信号也比旧信号更重要,因为人们的兴趣每天,甚至每小时都在变化。
让我们深入这些用例,并展示在执行信号增强时如何最好地处理信号的时间敏感性。
8.5.1 处理非时间敏感信号
在我们的 RetroTech 用例中,我们的文档故意很旧,已经存在十年或更长时间,随着产品变得越老越“复古”,对它们的兴趣可能只会增加。因此,我们很少看到物品流行度的巨大波动,新信号不一定比旧信号重要得多。这种用例有点不典型,但许多搜索用例确实处理像这样的“静态”文档集。在这种情况下,最好的解决方案是我们已经在本章中采取的策略:在合理的时间范围内处理所有信号,并给予它们相同的权重。当所有时间段具有相同的权重时,信号增强模型可能不需要经常重建,因为模型随时间缓慢变化。频繁处理信号是不必要的计算开销。
然而,在职位搜索用例中,情况却非常不同。为了辩论的目的,让我们假设平均需要 30 天来填补一个职位空缺。这意味着代表该职位的文档将在搜索引擎中存在 30 天,为该文档收集的任何信号也只有在那 30 天的信号增强窗口内才有用。当职位发布时,它通常在前几天非常受欢迎,因为它新鲜,可能会吸引许多现有的求职者,但在 30 天内的任何时间与该职位的互动都同样有用。在这种情况下,所有点击信号应该得到相同的权重,所有职位申请信号也应同样得到相同的权重(权重高于点击信号)。然而,由于文档的寿命非常短,因此尽快处理所有信号以充分利用其价值是非常重要的。
对于像职位搜索用例这样的短期文档用例,通常不是信号增强的最佳候选者,因为文档可能在信号增强模型变得有效之前就被删除。因此,对于这些用例,查看个性化模型(如第九章中介绍的协同过滤)和可推广的相关性模型(如第十章和第十一章中介绍的排序学习)可能更有意义。
在 RetroTech 和职位搜索用例中,信号在整个文档存在期间都同样有用。在接下来的新闻搜索用例中,我们将看到信号的价值会随着时间的推移而下降。
8.5.2 处理时间敏感信号
在新闻搜索引擎用例中,最新发布的新闻通常获得最多的互动,因此较新的信号比旧信号更有价值。一些新闻条目可能非常受欢迎和相关性高,可能持续数天或更长时间,但通常来说,过去十分钟内的信号比过去一小时的信号更有价值,过去一小时的信号比过去一天的信号更有价值,依此类推。新闻搜索是一个极端用例,其中信号需要快速处理,并且较新的信号需要被赋予比旧信号大得多的权重。
一种简单的方式来模拟这种情况是使用衰减函数,例如半衰期函数,它将分配给信号的权重减半(50%)在等间隔的时间段内。例如,半衰期为 30 天的衰减函数会给“现在”发生的信号分配 100%的权重,给 15 天前的信号分配 75%的权重,给 30 天前的信号分配 50%的权重,给 60 天前的信号分配 25%的权重,给 90 天前的信号分配 12.5%,依此类推。使用衰减函数计算信号基于时间的权重的数学公式是
starting_weight × 0.5(signal_age / half_life)
在应用这个计算时,starting_weight 通常是基于信号类型的相对权重,例如点击的权重为 1,加入购物车信号的权重为 10,购买信号的权重为 25。如果你没有结合多种信号类型,那么 starting_weight 将只是 1。
signal_age 是信号的年龄,half_life 是信号减半其价值所需的时间。图 8.4 展示了这种衰减函数如何影响不同半衰期值下信号权重随时间的变化。

图 8.4 基于各种半衰期值的信号衰减。随着半衰期的增加,单个信号保持其增强能力的时间更长。
1 天的半衰期非常激进,在大多数用例中都不实用,因为你不太可能在一天内收集到足够的信号来增强有意义的信号,而且信号迅速变得无关的可能性也很低。
30 天、60 天和 120 天的半衰期在积极折价旧信号的同时,仍然在六到十二个月期间保持折价信号的残余价值。如果你有非常持久的文档,你可以将时间推得更长,利用多年的信号。以下列表演示了一个更新后的信号聚合查询,为每个信号实现了 30 天的半衰期。
列表 8.7 将时间衰减应用于信号增强模型
half_life_days = 30
target_date = '2024-06-01' #1
**signal_weight** = 1 #2
time_decay_aggregation = f"""
SELECT query, doc, sum(time_weighted_boost) AS boost FROM (
SELECT user, query, doc, {signal_weight} * #3
POW(0.5, (DATEDIFF('{target_date}', signal_time) / #3
{half_life_days})) #3
AS time_weighted_boost FROM (
SELECT c.user AS user, lower(q.target) AS query, c.target AS doc,
MAX(c.signal_time) as signal_time #4
FROM signals c LEFT JOIN signals q ON c.query_id = q.query_id
WHERE c.type = 'click' AND q.type = 'query'
AND c.signal_time <= '{target_date}' #5
GROUP BY c.user, q.target, c.target #4
) AS raw_signals
) AS time_weighted_signals
GROUP BY query, doc
ORDER BY boost DESC"""
time_weighted_collection = \
aggregate_signals(signals_collection, "signals_boosts_time_weighted",
time_decay_aggregation)
1 可能的最晚信号日期。在实时系统中,这应该是 now(),但对于像 RetroTech 这样的冻结数据集,可以设置为固定日期。
2 可以在这里添加一个函数来区分不同信号类型的权重。
3 半衰期计算
4 获取每个用户、查询和产品组合的最新唯一信号
5 只包括目标日期之前的信号
这个衰减函数有几个独特的可配置参数:
-
它包含一个
half_life_days参数,该参数使用可配置的半衰期计算加权平均值,我们将其设置为30天开始。 -
它包含一个
signal_weight参数,可以替换为返回信号类型的函数,如上一节所示(“click” = 1,“add-to-cart” = 10,“purchase” = 25 等)。 -
它包含一个
target_date参数,这是信号获得1的全值的日期。在此日期之前的任何信号都将根据半衰期衰减,而在此日期之后的任何信号都将被忽略(过滤掉)。
你的target_date通常将是当前日期,这样你就可以利用你最新的信号并将它们分配最高的权重。然而,如果你的文档有每月或每年重复的季节性模式,你也可以将其应用于过去的时间段。
虽然我们的产品文档变化不频繁,而且最新的信号不一定比旧信号更有价值,但在正常的电子商务数据集中可能存在潜在的年度模式。例如,某些类型的商品可能在母亲节、父亲节和黑色星期五等重大节日更受欢迎。同样,对“铲子”等物品的搜索在夏季(用于挖土)与冬季(用于清除人行道上的雪)可能具有不同的含义。如果你探索你的信号,可能会出现许多趋势,时间敏感性应影响你的信号权重。
最终,信号是一个滞后指标。它们反映了你的用户刚刚做了什么,但只有当学习到的模式可能重复时,它们作为未来行为预测才有用。
现在我们已经探讨了通过查询归一化、减轻垃圾邮件和相关性操纵、结合不同相对权重的多种信号类型以及应用信号的时间衰减来改进我们的信号模型的技术,你应该能够灵活地实施最适合你用例的信号增强模型。然而,在以规模推出信号增强时,你可以采取两种不同的方法来优化灵活性与性能,我们将在下一节中介绍。
8.6 索引时间与查询时间增强:平衡规模与灵活性
本章中所有信号提升模型都使用 查询时提升 进行了演示,该提升在查询时从单独的副车集合中加载信号提升,并在将其发送到搜索引擎之前修改查询以添加提升。也有可能使用 索引时提升 来实现信号提升模型,其中提升直接添加到应用提升的查询的文档中。在本节中,我们将向您展示如何实现索引时提升,并讨论查询时提升与索引时提升之间的利弊。
8.6.1 使用查询时提升时的权衡
正如我们所见,查询时提升将每个查询转化为一个两步过程。每个用户查询都会在信号提升集合中进行查找,并使用找到的任何提升文档来修改用户的查询。查询时提升是实现信号提升最常见的方式,但它既有优点也有缺点。
查询时提升的优势
查询时提升的主要架构特点是它将主搜索集合(products)和信号提升集合(*_signals_boosts)保持分离。这种分离提供了几个好处:
-
它允许通过仅修改代表该查询的一个文档来增量更新每个查询的信号。
-
它允许通过不执行查找或修改用户的查询来轻松开启或关闭提升。
-
它允许在任何时候交换不同的信号提升算法。
最终,能够在任何时间根据当前上下文更改信号提升的灵活性是查询时信号提升的主要优势。这使实时信号的整合和不同排名函数的实验更容易进行。
查询时提升的缺点
尽管它很灵活,但查询时提升也引入了一些影响查询性能、规模和相关的重大缺点,这可能会使其不适合某些用例:
-
它需要在执行提升搜索之前进行额外的搜索来查找提升,这增加了更多的处理(执行两个搜索)和延迟(最终的查询必须在处理之前等待信号查找查询的结果)。
-
它导致了相关性(提升所有相关文档)和可扩展性(限制提升文档的数量以保持查询时间和查询吞吐量合理)之间的不幸权衡。
-
它使得分页效率低下,并且可能不准确,因为增加提升数量以适应增加的文档偏移量,在翻页过程中会减慢查询速度,并可能导致文档被推到更早的页面(用户错过)或更晚的页面(用户视为重复)。
第一个缺点很明显,因为每个查询本质上变成了两个连续执行的查询,从而增加了总体的搜索时间。第二个缺点可能不那么明显。在查询时间 boosting 中,我们查找一定数量的文档来提升查询结果中的搜索排名。在我们的 8.1 图例中的ipad搜索示例中(代码见 4.7 列表),查询的最终 boost 变为
"885909457588"⁹⁶⁶ "885909457595"²⁰⁵ "885909471812"²⁰² "886111287055"¹⁰⁹
"843404073153"⁷³ "885909457601"⁶² "635753493559"⁶² "885909472376"⁶¹
"610839379408"²⁹ "884962753071"²⁸
这个 boost 包含 10 个文档,但这仅仅是因为我们请求了这么多的 boost。假设我们只在第一页显示了 10 个文档,整个第一页看起来会很好,但用户如果导航到第 2 页会怎样呢?在这种情况下,将不会显示任何 boosted 文档,因为只有具有查询信号的前 10 个文档被 boosted!
为了为第 2 页 boost 文档,我们需要确保我们有足够的文档 boost 来覆盖前两页的全部内容,这意味着将 boost 的数量从 10 个增加到 20 个(在 boost 查找查询中将limit参数修改为 20):
"885909457588"⁹⁶⁶ "885909457595"²⁰⁵ "885909471812"²⁰² "886111287055"¹⁰⁹
"843404073153"⁷³ "635753493559"⁶² "885909457601"⁶² "885909472376"⁶¹
"610839379408"²⁹ "884962753071"²⁸ "635753490879"²⁷ "885909457632"²⁶
"885909393404"²⁶ "716829772249"²³ "821793013776"²¹ "027242798236"¹⁵
"600603132827"¹⁴ "886111271283"¹⁴ "722868830062"¹³ "092636260712"¹³
你可以通过增加每次有人导航到“下一页”时查找的 boost 数量来解决这个问题的绝大部分,但这将极大地减慢后续查询的速度,因为第 3 页需要查找和应用 30 个 boost,第 10 页需要 100 个 boost,以此类推。对于每个查询只存在少量 boosted 文档的情况,这并不是一个大问题,但对于许多用例,可能有数百或数千个文档将受益于被 boost。在我们的ipad查询中,有超过 200 个包含聚合信号的文档,所以除非有人翻到搜索结果的很后面,否则这些文档中的大多数永远不会被 boost。到那时,查询可能会变慢,甚至可能超时。
只包含 boost 的子集又带来了另一个问题:搜索结果并不总是严格按照 boost 值排序!我们假设请求前 10 个 boost 将足以覆盖 10 个结果的第 1 页,但 boost 只是影响相关性的因素之一。可能是文档列表中更靠后的文档具有更高的基本相关性分数,如果它们的 boost 也被加载,它们可能会跳到搜索结果的第一页。
因此,当用户从第 1 页导航到第 2 页且加载的 boost 数量增加时,可能会发生不希望的重新排序,结果可能会跳到第 1 页而永远看不到,或者跳到第 2 页再次被看到作为重复项。
即使这些结果比没有应用信号 boosting 的搜索结果更相关,但这并不构成最佳的用户体验。索引时间信号 boosting 可以帮助克服这些缺点,我们将在下一节中展示。
8.6.2 实现索引时间信号 boosting
索引时间信号增强将信号增强问题颠倒过来——不是在查询时间对查询的流行文档进行增强,而是在索引时间对文档的流行查询进行增强。这是通过将流行查询及其增强值添加到每个文档的字段中实现的。然后,在查询时间,我们只需在新的字段上搜索,如果该字段包含来自我们查询的术语,它将根据为该术语索引的增强值自动增强。
当实现索引时间增强时,我们使用完全相同的信号聚合来生成文档对和每个查询的增强权重。一旦生成了这些信号增强,我们只需在我们的工作流程中添加一个额外的步骤:更新产品集合,为每个文档添加一个字段,包含文档应增强的每个术语,以及该术语相关的信号增强。以下列表展示了这个额外的步骤。
列表 8.8 将增强索引到主要产品集合中
from aips.data_loaders import index_time_boosts
boosts_collection = engine.get_collection("normalized_signals_boosts")
create_view_from_collection(boosts_collection, #1
boosts_collection.name) #1
boosted_products_collection = \
engine.get_collection("products_with_signals_boosts")
create_view_from_collection(boosted_products_collection, #2
boosted_products_collection.name) #2
boosted_products = index_time_boosts.load_dataframe( #3
boosted_products_collection, #3
boosts_collection) #3
boosted_products_collection.write(boosted_products) #4
1 加载之前生成的信号增强模型
2 注册产品表,以便我们可以从中加载并添加增强后保存回它
3 将每个文档的所有带信号增强的关键词插入到文档的新信号增强字段中
4 将产品保存回增强产品集合,并添加更新的信号增强
代码读取每个产品文档之前生成的所有信号增强,然后将查询和增强映射到该文档的新signals_boosts字段上。signals_boosts字段包含一个以逗号分隔的术语列表(用户查询),每个术语都有一个相应的权重。
当使用 Solr 作为您的搜索引擎(默认)时,这个signals_boosts字段是一个特殊字段,包含一个DelimitedPayloadBoostFilter过滤器,允许术语(查询)被索引,并带有相关的增强,这些增强可以用来影响查询时间评分。例如,对于最受欢迎的 iPad,产品文档现在将被修改如下:
{...
"id": "885909457588",
"name": "Apple® - iPad® 2 with Wi-Fi - 16GB - Black"
"signals_boosts": "ipad|2939,ipad 2|1104,ipad2|540,i pad|341,apple ipad|
152,ipads|123,apple|118,i pad 2|99,tablets|67,..."
...
}
指定索引时间术语增强的格式将因搜索引擎而异。在查询时间,将搜索此signals_boosts字段,如果查询存在于字段中,该文档的相关性评分将根据匹配查询的索引负载进行增强。以下列表展示了如何执行利用索引时间信号增强的查询。
列表 8.9 使用索引时间增强进行排名搜索结果
def get_boosted_search_request(query, boost_field):
request = product_search_request(query)
request["index_time_boost"] = (boost_field, query) #1
return request
query = "ipad"
boosted_query = get_boosted_search_request(query, "signals_boosts")
response = boosted_products_collection.search(**boosted_query)
display_product_search(query, response["docs"])
1 根据查询的索引信号增强来增强相关性评分
虽然各种搜索引擎对索引时间增强项的查询支持处理方式不同,但在 Solr(我们的默认搜索引擎)的情况下,这内部转化为向搜索请求中添加一个boost参数值payload("signals_boosts", "ipad", 1, "first"),通过附加到查询ipad在signals_boosts字段中的第一个匹配项的负载来增强文档(如果没有索引负载,则为1)。如果您想回顾如何通过函数和乘法增强来影响排名,请参阅第 3.2 节。
图 8.5 显示了这种索引时间信号增强的结果。正如您所看到的,现在结果看起来与之前在图 8.1 中显示的查询时间信号增强输出相似。

图 8.5 索引时间信号增强,展示了与查询时间索引增强类似的结果
当进行索引时间增强与查询时间增强时,相关性分数可能不会完全相同,因为当对索引负载进行评分时与对增强查询术语进行评分时的数学不同。尽管如此,结果的相对顺序应该非常相似。索引时间信号增强也将适用于所有具有匹配信号增强负载的文档,而查询时间信号增强仅适用于查询中明确增强的前N个文档。
8.6.3 实施索引时间增强时的权衡
正如我们所看到的,索引时间增强将执行信号增强的大部分工作从查询执行阶段转移到搜索的索引阶段。这解决了查询时间增强中固有的某些问题,但也引入了一些新的挑战,我们将在本节中讨论。
索引时间增强的优点
索引时间增强解决了查询时间增强的大部分缺点:
-
查询工作流程更简单、更快,因为它不需要执行两个查询——一个用于查找信号增强,另一个使用这些信号增强运行增强查询。
-
随着增强文档数量的增加,每个查询都变得更高效、更快,因为查询是对
signals_boosts字段的单个关键字搜索,而不是包含不断增加的增强文档的长查询。 -
结果分页不再是问题,因为所有与查询匹配的文档都被增强了,而不仅仅是那些可以高效加载并添加到查询中的前N个文档。
考虑到这些特性,索引时间增强可以通过确保所有查询都接收到其所有匹配文档的一致和完整的增强,从而显著提高结果排序的相关性和一致性。它还可以通过删除查询术语(文档增强)和消除在执行主查询之前进行的额外查找来显著提高查询速度。
索引时间增强的缺点
如果索引时间增强解决了查询时间增强的所有问题,为什么我们总是不使用索引时间信号增强呢?
索引时间提升的主要缺点是,由于查询的增强值被索引到每个文档上(每个文档包含该文档应增强的术语),从信号增强模型中添加或删除关键词需要重新索引与该关键词相关的所有文档。如果信号增强聚合是按关键词增量更新的,这意味着可能需要持续不断地重新索引您搜索引擎内的所有文档。如果您的信号增强模型是批量更新整个索引的,这意味着每次信号增强模型被重新生成时,可能需要重新索引所有文档。
这种索引压力会给您的搜索引擎增加操作复杂性。为了保持查询性能快速且一致,考虑到这种索引压力,您可能希望将文档的索引分离到与搜索索引托管服务器不同的单独服务器上,以服务于查询。
关注点分离:索引与查询
在执行高量索引时,如果可能的话,最好将索引服务器与查询服务器隔离开来。否则,繁忙的索引操作产生的内存或 CPU 压力可能会影响查询延迟和吞吐量。并非所有搜索引擎都支持索引服务器和查询服务器之间这种关注点的分离,但许多搜索引擎都支持。
例如,Elasticsearch 和 OpenSearch 通过使用follower indexes的概念支持这种关注点的分离,而 Solr 则是通过支持不同的replica types来实现。这三个引擎都有shards的概念,即包含集合中部分文档的分区,以及replicas的概念,即其分片所有数据的精确副本。每个分片都有一个领导者,负责接收更新并将它们转发到所有副本。
默认情况下,领导者将所有文档更新发送到分片的所有副本,然后每个副本(冗余地)索引该文档,以便它立即可在副本上被搜索。不幸的是,即时可用性的代价是高量索引会消耗每个副本的资源,这可能会降低整个搜索引擎的查询性能。
通过在 Solr 中将查询服务器上的副本类型从NRT(近实时)更改为TLOG(事务日志)或PULL,您将指示副本从分片领导者(它已经在索引文档)那里拉取预构建的索引文件,而不是执行重复索引。在 Elasticsearch 和 OpenSearch 中类似地,如果您配置了从索引副本,托管从索引副本的服务器将从领导者索引复制预构建的索引文件,而不是冗余地索引文档。一些其他搜索引擎和向量数据库具有类似的跨服务器隔离索引操作和查询操作的能力,您可以探索这些能力。
如果您计划进行索引时间信号增强并预计将不断以高量重新索引信号,您应强烈考虑隔离索引和查询时间操作。这确保您的查询性能不会因持续信号增强聚合的显著额外索引开销而受到负面影响。
索引时间增强的另一个缺点是,对您的信号增强函数进行更改可能需要更多的规划。例如,如果您想将点击信号与购买信号的权重从 1:25 更改为 1:20,您需要创建一个带有新权重的signals_boosts_2字段,重新索引所有文档并添加新的增强,然后切换查询以使用新字段而不是原始的signals_boosts字段。否则,您的增强值和排名分数将不一致地波动,直到所有文档的分数都已更新。
然而,如果能够克服这些缺点,实施索引时间信号增强可以解决查询时间信号增强的所有缺点,从而提高查询性能,完全支持结果分页,并使用所有文档的所有信号,而不是仅从最受欢迎的文档中采样。
正如我们在本章中看到的,信号增强允许流行相关性——增强特定查询的最重要项目。在下一章中,我们将实现个性化相关性——根据每个用户进行调整的排名,使用每个用户的信号(相对于其他用户)来学习他们的特定兴趣。
摘要
-
信号增强是一种排名算法,它聚合每个查询的用户信号计数,并使用这些计数作为未来该查询的相关性增强。这确保了每个查询最受欢迎的项目被推到搜索结果的最顶部。
-
通过将不同的变体(大小写、拼写等)视为相同的查询来规范化查询有助于清理用户信号中的噪声,并构建一个更健壮的信号增强模型。
-
群智数据容易受到操纵,因此,明确防止垃圾邮件和恶意信号影响您相关性模型的质量是很重要的。
-
您可以通过为每种类型分配相对权重并对信号类型进行加权求和,将不同的信号类型组合成一个单一的信号增强模型。这使得您能够给予更强的信号(正面或负面)更多的相关性,并减少较弱信号的噪声。
-
引入时间衰减函数可以使近期信号比旧信号具有更大的权重,允许旧信号随时间逐渐消失。
-
使用查询时间信号增强(更灵活)或索引时间信号增强(更可扩展且更一致的相关性排名)可以将信号增强模型投入生产。
第九章:个性化搜索
本章涵盖
-
搜索与推荐之间的个性化范围
-
使用用户信号的潜在特征实现协同过滤和个性化
-
使用嵌入创建个性化配置文件
-
从内容和行为的多模态个性化
-
应用基于聚类的个性化限制
-
避免个性化搜索的陷阱
搜索引擎越了解您的用户,就越有可能成功解释他们的查询。在第一章中,我们介绍了正确解释查询意图所需的三个关键上下文:内容理解、领域理解和用户理解。在本章中,我们将深入探讨用户理解上下文。
我们已经关注了从文档中学习特定领域上下文(第五章)以及根据许多不同用户的流行结果(第八章),但并不总是合理地假设“最佳”结果在所有用户中都是一致的。而信号增强模型在所有用户中找到最流行的答案,而个性化搜索则试图了解每个特定用户的兴趣,并返回满足这些兴趣的搜索结果。
例如,当搜索餐厅时,用户的位置显然很重要。当搜索工作时,每个用户的就业历史(以前的职位,经验水平,薪资范围)和位置可能很重要。当搜索产品时,特定的品牌偏好,家电颜色,互补商品购买以及类似个人品味可能很重要。
在本章中,我们将使用用户信号来学习描述用户兴趣的潜在特征。潜在特征是隐藏在数据中的特征,但可以通过对数据进行建模来推断关于用户或物品的信息。这些潜在特征将被用于生成产品推荐和提升个性化搜索结果。我们还将使用基于内容的嵌入来关联产品,并使用每个用户交互的产品的嵌入来生成基于向量的个性化配置文件,以个性化搜索结果。
最后,我们将根据其嵌入对产品进行聚类,以生成个性化限制,确保用户不会看到基于无关类别产品的个性化搜索结果。
应非常谨慎地将个性化应用于搜索结果。通过基于他们之前的搜索活动所做的假设来覆盖他们的明确意图(通常指定为搜索关键词),很容易让用户感到沮丧。我们将深入探讨在更好地个性化搜索的好处与由引擎过度努力读取用户想法而可能引起的用户挫败感之间取得平衡的细微差别。并非所有搜索都应该个性化,但做得好的时候,您会看到它如何极大地改善搜索体验。
9.1 个性化搜索与推荐
搜索引擎和推荐引擎代表了个人化范围的两端,我们在第一章中介绍了这一概念(见图 1.5)。我们也在第一章中讨论了用户意图的维度(见图 1.7),指出要完全理解用户意图需要内容理解、用户理解和领域理解。图 9.1 重新提出了这两个心智模型。
虽然关键词搜索只代表内容理解,协同推荐只代表用户理解,但在可能的情况下,它们都可以也应该被结合。个性化搜索位于关键词搜索和协同推荐之间的交叉点。

图 9.1 个人化范围和用户意图的维度
图 9.2 将个人化范围叠加在用户意图维度的图上,以描绘出个性化搜索如何在个人化范围内更细腻的图景。

图 9.2 个性化搜索位于关键词搜索和协同推荐之间的交叉点
搜索引擎和推荐引擎之间的关键区别在于,搜索引擎通常由用户引导并匹配他们明确输入的查询,而推荐引擎通常不接受直接的用户输入,而是根据已知或推断的知识推荐内容。然而,现实情况是这两种系统构成了同一枚硬币的两面。在两种情况下,目标都是理解用户在寻找什么,并交付相关结果以满足用户的信息需求。在本节中,我们将讨论搜索和推荐系统之间个人化范围内广泛的能力范围。
9.1.1 个性化查询
让我们想象我们正在运行一个餐厅搜索引擎。我们的用户,米歇尔,在纽约午餐时间用手机进行关键词搜索,她输入了“蒸面包圈”这个关键词。她看到了位于美国南卡罗来纳州格林维尔、俄亥俄州哥伦布和英国伦敦的顶级蒸面包圈店。
这些搜索结果有什么问题?嗯,在这种情况下,答案很明确——米歇尔正在纽约寻找午餐,但搜索引擎却显示了她数百到数千公里之外的结果。但米歇尔从未告诉搜索引擎她只想看到纽约的结果,也没有告诉搜索引擎她正在寻找附近的午餐地点,因为她想吃现在。尽管如此,搜索引擎应该能够推断出这些信息并根据这些信息个性化搜索结果。
考虑另一种场景——米歇尔在长途飞行后到达机场,并在手机上搜索司机。返回的顶部结果是高尔夫球场的击球杆,接着是打印机驱动程序的链接,然后是螺丝刀。如果搜索引擎知道米歇尔的位置,它是否应该能够推断出她的意图——她在寻找一辆车?
以我们之前提到的职位搜索为例,假设米歇尔访问她最喜欢的职位搜索引擎并输入了护士职位。就像我们之前的餐厅例子一样,如果纽约的护士职位能出现在列表的顶部,那岂不是理想的情况?如果她后来输入了西雅图的工作,那岂不是理想的情况——不是看到随机的西雅图工作(医生、工程师、厨师等),而是由于引擎之前了解到她是一名护士,护士职位现在出现在列表的顶部?
这些都是个性化查询的例子:将用户的明确查询与对用户意图和偏好的隐含理解结合起来,形成一个专门针对该用户的搜索结果。做好这种个性化搜索是棘手的,因为你必须仔细平衡对用户的理解,同时不覆盖他们明确想要查询的内容。然而,当做得好的时候,个性化查询可以显著提高搜索的相关性。
9.1.2 用户引导推荐
正如可以在显式关键词搜索中添加对用户特定属性的隐含理解来生成个性化搜索结果一样,也可以通过允许用户覆盖自动生成的推荐输入来启用用户引导的推荐。
推荐引擎允许用户查看和编辑他们的推荐偏好变得越来越普遍。这些偏好通常包括用户之前通过查看、点击或购买而与之互动的项目列表。在广泛的应用场景中,这些偏好可能包括特定项目偏好,如喜欢的电影、餐厅或地点,以及汇总或推断出的偏好,如服装尺码、品牌偏好、喜欢的颜色、偏好的本地商店、期望的职位和技能、偏好的薪资范围等等。这些偏好构成了用户档案:它们定义了关于客户所知的内容,而你能够给予用户更多查看、调整和改进此档案的控制权,你就越能更好地理解你的用户,他们可能也会对结果更加满意。
9.2 推荐算法方法
在本节中,我们将讨论不同类型的推荐算法。推荐引擎的实现取决于可用于驱动其推荐的数据。一些系统只有用户行为信号,以及非常少的关于被推荐项目的内 容或信息,而其他系统则有关于项目的丰富内容,但与项目的用户交互非常少。我们将介绍基于内容、基于行为和多模态推荐器。
9.2.1 基于内容的推荐器
基于内容的推荐算法根据不同实体(通常在用户和项目之间、项目与项目之间或用户与用户之间)共享的属性来推荐新内容。例如,想象一个求职网站。工作可能具有诸如“职位名称”、“行业”、“薪资范围”、“工作经验年限”和“技能”等属性。用户在其个人资料或简历/简历中将有类似的属性。基于这些属性,基于内容的推荐算法可以确定哪些特征最重要,然后根据用户的期望属性为任何给定用户排名最佳匹配的工作。这被称为用户-项目(或用户到项目)推荐器。
同样地,如果一个用户喜欢某个特定的工作,可以使用这个相同的过程根据这些工作与第一个工作的属性匹配程度来推荐类似的工作。这种推荐在产品详情页上很受欢迎,因为用户已经在查看一个项目,可能希望帮助他们探索相关项目。这种推荐算法被称为项目-项目(或项目到项目)推荐器。
图 9.3 演示了基于内容的推荐器如何使用用户先前交互的项目属性来匹配对该用户相似的项目。在这种情况下,我们的用户查看了“洗涤剂”产品,然后根据这些项目在相同类别字段(“洗衣”类别)中的匹配以及在其产品描述中包含与“洗涤剂”产品相似的文本,推荐了“织物柔软剂”和“烘干剂”。
在第五章介绍知识图谱学习时以及在第六章介绍使用知识图谱的查询扩展时,我们展示了这种相关属性和类别匹配的类型。在那两种情况下,我们主要是扩展关键字查询以包含额外的相关术语,但你可以根据任何其他属性进行项目匹配,如品牌、颜色或尺寸。

图 9.3 基于用户感兴趣的项目属性(如类别和文本关键词)的基于内容的推荐
还可以将用户匹配到其他用户,或将任何实体匹配到任何其他实体。在基于内容推荐的上下文中,所有推荐都可以看作是项目-项目推荐,其中每个项目是一个与其他被推荐实体共享属性的任意实体。
9.2.2 基于行为的推荐器
基于行为的推荐器利用用户与物品(文档)的交互来发现物品组中相似的兴趣模式。这个过程被称为 协同过滤,指的是使用多人(协同)投票过程来过滤匹配项,这些匹配项通过有多少重叠用户与相同物品的交互来衡量相似度。这里的想法是,相似的用户(即具有相似偏好的用户)倾向于与相同的物品进行交互,当用户与多个物品交互时,他们更有可能与相似物品而不是无关物品进行交互。
协同过滤算法的一个令人惊讶的特性是,它们完全从您的最终用户那里众包了相关性评分过程。事实上,不需要物品本身的特征(名称、品牌、颜色、文本等)——所需的是每个物品的唯一 ID 以及知道哪些用户与哪些物品进行了交互。此外,您拥有的用户交互信号越多,这些算法通常越智能,因为更多的人在不断地投票并告知您的排名算法。这通常导致协同过滤算法在性能上显著优于基于内容的算法。
图 9.4 展示了如何使用来自多个用户的重叠行为信号来驱动协同推荐。在这个图中,一位新用户表示对肥料感兴趣,因为之前表示对肥料感兴趣的其他用户倾向于也点击、加入购物车或购买土壤和覆盖物,因此土壤或覆盖物将被作为推荐返回。另一个包括螺丝刀、锤子和钉子的基于行为的项目簇也被描绘出来,但它们与用户当前的兴趣(肥料)没有足够重叠,因此它们不会被作为推荐返回。

图 9.4 基于协同过滤的推荐,这是一种使用多个用户之间行为信号重叠的技术
我们将在第 9.3 节中实现一个端到端的协同过滤示例,涵盖从用户行为信号中发现潜在用户和物品特征的过程,并使用这些特征为用户生成物品推荐。由于协同过滤是完全众包的,因此它对您的文档或相关内容属性可能缺失或不正确的数据质量问题具有免疫力。
不幸的是,正是这种对用户行为信号的依赖使得协同过滤如此强大,同时也暴露了其弱点。当只有少量与特定项目的互动,或者完全没有互动时会发生什么?答案是,该项目要么永远不会被推荐(当没有信号时),要么很可能会产生糟糕的推荐或显示为与其他项目的糟糕匹配(当信号很少时)。这种情况被称为冷启动问题,这是基于行为推荐者面临的主要挑战。为了解决这个问题,你通常需要将基于行为的推荐者与基于内容的推荐者结合起来,正如我们接下来将要讨论的。
9.2.3 多模态推荐者
多模态推荐者(有时也称为混合推荐者)结合了基于内容和基于行为推荐方法。由于协同过滤通常适用于具有许多信号的物品,但在信号很少或没有信号的情况下表现不佳,因此通常最有效的方法是将基于内容的特征作为基线,然后在上面叠加协同过滤模型。这样,如果信号很少,基于内容的匹配器仍然会返回结果;如果信号很多,协同过滤算法在排名结果时将更加突出。结合两种方法可以让你兼得两者之长:高质量的众包匹配,同时避免新内容和不太为人所知的内容的冷启动问题。图 9.5 展示了这在实践中是如何工作的。

图 9.5 多模态推荐将基于内容的匹配和协同过滤结合到一个混合匹配算法中。
你可以在图 9.5 中看到,用户可以与钻头(没有信号)或螺丝刀(有来自其他用户的先前信号,以及内容)进行互动,并且在这两种情况下用户都会收到推荐。这提供了基于信号的协同过滤可以使用的优势,同时也允许对信号不足的物品进行基于内容的匹配。
在下一节中,我们将实现一个协同过滤模型,然后在 9.4 节中实现一个混合个性化搜索系统。
9.3 实现协同过滤
在本节中,我们将实现一个协同过滤算法。我们将使用用户-项目互动信号,并演示如何从这些信号中学习表示用户偏好的潜在(隐藏)特征。然后我们将使用这些学习到的偏好来生成推荐。
纯协同过滤,如图 9.2 所示,使我们能够根据用户与这些项目的互动模式来学习项目之间的相似性。这是一个强大的概念,因为它允许在不了解项目本身的情况下(如标题、文本或其他属性)了解项目。
9.3.1 通过矩阵分解学习潜在用户和项目特征
协同过滤通常使用一种称为矩阵分解的技术来根据用户交互学习关于项目的潜在特征。潜在特征是那些没有直接观察到但可以从其他观察到的特征推断出来的特征。例如,假设你有四个用户,以下是他们购买电影的历史记录:
-
用户 1—复仇者联盟 4:终局之战,黑豹和黑寡妇
-
用户 2—黑寡妇,惊奇队长和黑豹
-
用户 3—黑寡妇,黑暗骑士和蝙蝠侠
-
用户 4—小美人鱼,狮子王和玩具总动员
-
用户 5—冰雪奇缘,玩具总动员和狮子王
这些购买行为中有没有什么模式?如果你知道标题或描述,你可以推断出以下内容:
用户 1–3:
-
所有这些电影都是关于超级英雄的。
-
其中大部分是由漫威工作室制作的,尽管也有一些是由华纳兄弟(DC 漫画)制作的。
-
它们都是动作电影。
-
由于暴力和/或语言问题,它们都不适合小孩子们。
用户 4–5:
-
所有这些都是动画电影。
-
所有这些都很适合小孩子们。
-
所有这些都是由迪士尼/皮克斯制作的。
假设你除了产品 ID 之外没有其他任何信息。通过使用矩阵分解,可以观察到用户如何与项目互动,并推断出关于这些项目的潜在特征。如果前面提到的特征是最能预测类似用户购买行为的,那么它们很可能会在矩阵分解学习的潜在特征中体现出来。矩阵分解还可能发现其他不那么明显的特征。
作为另一个例子,在 RetroTech 数据集中,用户信号可能显示一组用户购买不锈钢微波炉、不锈钢冰箱和不锈钢铁锅,而另一组用户可能购买黑色微波炉、黑色冰箱和黑色铁锅。通过将用户-项目交互聚类在一起,可以统计地确定一个将颜色区分这些项目的潜在特征。此外,一组用户可能购买电视、PlayStation 和 DVD 播放器,而另一组用户可能购买 iPhone、手机壳和屏幕保护器。通过将这些行为聚类在一起,我们可以将这些产品类别(家庭影院与移动电话)区分为一或多个潜在特征。
图 9.6 展示了几个产品和用户之间的示例用户-项目交互矩阵。这些数字是评分,表示用户(y-轴)对项目(x-轴)的兴趣强度,购买行为比加入购物车动作更重要,而加入购物车信号比点击更重要。空单元格表示用户和项目之间没有交互。

图 9.6 用户-项目交互矩阵。数字代表用户对项目的偏好,范围从 1(非常不利)到 10(非常有利)。空单元格表示用户和项目之间没有交互。
给定用户-项目交互矩阵,我们的目标是找出特定项目为什么被每个用户所偏好。我们假设某些用户兴趣和项目相似性的组合解释了这些偏好。因此,矩阵分解将用户-项目评分矩阵分解成两个独立的矩阵——一个将每个用户映射到一组特征,另一个将每个项目映射到一组特征。
图 9.7 展示了矩阵分解过程,将用户-项目排名矩阵R转换为相应的用户特征矩阵U和项目特征矩阵I。

图 9.7 矩阵分解。用户-项目矩阵R被分解成两个矩阵,一个用户矩阵U和一个项目矩阵I。这两个矩阵的乘积(U . I)应尽可能接近原始的用户-项目矩阵R。
用户矩阵(U)中的每一行代表一个用户,每一列代表三个潜在用户特征之一(标记为潜在用户特征 1、潜在用户特征 2和潜在用户特征 3)。在项目矩阵(I)中,每一列代表一个项目,每一行代表三个潜在项目特征之一(标记为潜在项目特征 1、潜在项目特征 2和潜在项目特征 3)。
我们没有为这些潜在特征命名,也不知道它们的确切含义,但它们是通过数学方法发现的,并且可以预测实际的用户-项目兴趣。潜在特征的数量是一个可以调整的超参数,但在这个例子中设置为3。这意味着每个用户由一个有三个维度(潜在特征)的向量表示,每个项目也由一个有三个维度(潜在特征)的向量表示。
一旦学习到矩阵U和I,它们之后可以独立使用来预测任何用户和项目之间的相似性(通过比较U中的用户与I中的项目),任何两个用户之间的相似性(通过比较U中的用户与另一个U中的用户),或者任何两个项目之间的相似性(通过比较I中的项目与另一个I中的项目)。我们将只关注用户-项目相似性,作为为每个用户个性化推荐的手段。图 9.8 展示了如何为任何用户生成项目评分预测。

图 9.8 从分解矩阵中计算用户-项目偏好。将每个潜在用户特征值(用户行的第一个、第二个和第三个值)乘以相应的潜在项目特征值(项目列的第一个、第二个和第三个值),然后将结果相加。这是所选用户和项目的预测用户-项目偏好。
对于第一个用户(U 中的第一行),我们可以通过在用户矩阵 U 的第一行(0.67, -0.51, 2.81)和项目矩阵 I 的第一列(0.09, 0.75, 3.43)之间执行点积来生成电影《复仇者联盟 4:终局之战》(I 中的第一列)的预测评分,结果为 (0.67 * 0.09) + (-0.51 * 0.75) + (2.81 * 3.43) = 9.32。同样,对于第二个用户(U 中的第二行),我们可以通过在用户矩阵 U 的第二行(1.13, 3.18, -0.13)和项目矩阵 I 的第四列(1.74, 2.54, 0.46)之间执行点积来生成电影《恋恋笔记本》(I 中的第四列)的预测评分,结果为 9.98。
虽然在某些情况下,例如在增量用户交互后立即生成实时推荐时,对单个用户和项目进行单独预测可能有所帮助,但通常更有用的是生成一个包含所有用户和项目的预测评分的完整用户-项目矩阵 R'。图 9.9 展示了通过执行用户矩阵 U 与项目矩阵 I 的点积生成的最终用户-项目矩阵 R'(在右侧)。

图 9.9 重构的用户-项目矩阵 R',其中图 9.8 中的先前计算突出显示。请注意,原始用户-项目矩阵 R 中的空值现在用预测值填充(以黑色突出显示)。
当计算用户矩阵和项目矩阵(U . I)的点积时,得到的结果用户-项目矩阵 R' 应尽可能接近原始用户-项目矩阵 R。最小化原始矩阵 R 和预测矩阵 R' 之间的差异是矩阵分解的训练优化目标。两个矩阵越接近,模型预测未来相似个性化推荐的能力就越好。
在实践中,潜在特征并不能完美地代表所有可能的相关特征。然而,通过使用一个减少原始 R 和预测 R' 之间差异的损失函数进行训练,模型将最大化代表 R 的可能性,从而能够根据过去用户-项目交互最佳预测未来的推荐。
9.3.2 使用交替最小二乘法实现协同过滤
对于基于用户与项目交互的纯协同过滤(仅基于用户与项目的交互)的一种流行算法是交替最小二乘法(ALS)。ALS 是一种迭代算法,通过交替学习项目和用户的潜在特征来执行矩阵分解。
ALS 背后的逻辑是用户-项目评分矩阵中的潜在特征是用户潜在特征和项目潜在特征的组合。虽然每个潜在特征的相对权重在开始时并不知道,但可以通过最初使用随机的项目权重并冻结它们(保持它们恒定)来开始学习用户特征权重。随着用户特征权重开始合并,它们可以被冻结并用作学习项目特征权重的输入。然后,ALS 继续交替训练用户特征矩阵(冻结最新的项目特征权重)和项目特征矩阵(冻结最新的用户特征权重)。这个过程会重复进行一定次数的迭代,直到两个矩阵的权重都得到很好的平衡和优化。通过交替学习项目和用户的潜在特征,ALS 可以迭代地学习两个矩阵的最佳组合权重,以提高模型的预测能力。
使用矩阵分解学习到的潜在特征的数量是一个超参数,称为秩。秩越高,你能够学习的特征越细粒度,但你也倾向于需要更多的数据点来可靠地学习更细粒度的特征。虽然你无法为每个潜在特征应用标签(特征仅以数字表示),但仍然有可能在数据中发现有意义的类别,这些类别最能预测相似的项目。ALS 是一种流行的协同过滤算法,因为它相对容易实现,并且可以扩展到大型数据集。
在本节中,我们将讨论如何使用 Spark 实现 ALS(交替最小二乘法),以基于用户-项目交互生成推荐模型。我们将使用 RetroTech 数据集,因为它包含了一组产品的用户-项目交互。我们将利用用户-项目交互来学习关于用户和项目的潜在特征,然后我们将使用这些潜在特征来生成未来的推荐。
我们将首先使用 Spark 内置的 ALS 实现生成每个用户-项目对的隐式偏好列表。列表 9.1 生成了一个user_product_implicit_preferences集合,根据用户交互的强度分配评分。
列表 9.1 从用户信号生成隐式用户-项目评分
click_weight = 1 #1
add_to_cart_weight = 0 #1
purchase_weight = 0 #1
signals_collection = engine.get_collection("signals")
mixed_signal_types_aggregation = f"""
SELECT user, product,
(click_boost + add_to_cart_boost + purchase_boost) AS rating
FROM (
SELECT user, product,
SUM(click) AS click_boost,
SUM(add_to_cart) AS add_to_cart_boost,
SUM(purchase) AS purchase_boost
FROM (
SELECT s.user, s.target AS product,
IF(s.type = 'click', {click_weight}, 0) AS click,
IF(s.type = 'add-to-cart', {add_to_cart_weight}, 0) AS add_to_cart,
IF(s.type = 'purchase', {purchase_weight}, 0) AS purchase
FROM signals s
WHERE (s.type != 'query')) AS raw_signals
GROUP BY user, product) AS per_type_boosts"""
signals_agg_collection = \ #2
aggregate_signals(signals_collection, "user_product_implicit_preferences", #2
mixed_signal_types_aggregation) #2
1 目前只有点击信号被加权,但可以为每种信号类型设置权重。
2 对所有信号进行聚合,为每个用户-项目对生成一个单一评分
我们对点击、添加到购物车和购买信号进行了建模支持,尽管我们只给点击分配了权重1,给添加到购物车和购买信号分配了0。我们这样做是为了使 ALS 算法的数学更简单,但您可以尝试通过将它们的权重增加到正数来打开添加到购物车或购买信号。这些权重有些随意,但目的是根据用户的交互程度区分用户对产品的兴趣强度。如果您对用户更多的交互不一定表示更强的评分或您选择的权重有意义的信心不足,您也可以通过为每个用户-项目对分配评分1来简化处理。
在我们的用户-项目评分准备就绪后,我们将从准备好的集合中生成一个数据框来训练和测试模型。我们的数据集包含不到 50,000 个产品,我们将在列表 9.2 中使用它们全部;然而,如果您想快速运行,可以将top_product_count_for_recs修改为一个显著更低的数字。根据您的硬件和 Docker 资源配置,运行时间可能从几分钟到几天不等。为了快速(但质量较低)的运行,可以考虑最初用 1,000 个产品进行测试(top_product_count_for_recs=1000),然后根据您的舒适度进行扩展。
列表 9.2 准备用于训练的用户-产品-评分数据
create_view_from_collection(signals_agg_collection,
"user_product_implicit_preferences")
top_product_count_for_recs = 50000 #1
user_preference_query = f"""
SELECT user, product, rating #2
FROM user_product_implicit_preferences
WHERE product IN (
SELECT product FROM (
SELECT product, COUNT(user) user_count
FROM user_product_implicit_preferences
GROUP BY product
ORDER BY user_count DESC #3
LIMIT {top_product_count_for_recs} #3
) AS top_products)
ORDER BY rating DESC"""
user_prefs = spark.sql(user_preference_query)
1 减少产品数量可以加快训练速度,但会降低准确性。
2 返回用户、产品和评分
3 限制推荐数量到最受欢迎的产品
我们的数据框包含三列:user、product和rating。出于性能考虑,许多机器学习算法(包括我们将要使用的 Spark 的 ALS 实现)更愿意处理数字 ID 而不是字符串。Spark 包含一个StringIndexer辅助对象,可以用来将字符串 ID 转换为数字 ID,以及一个相应的IndexToString对象,可以用来将数字 ID 转换回字符串 ID。列表 9.3 将这种 ID 转换整合到我们的数据框中。
列表 9.3 将 ID 转换为整数以供 Spark 的 ALS 算法使用
def order_preferences(prefs):
return prefs.orderBy(col("userIndex").asc(),
col("rating").desc(),
col("product").asc())
def strings_to_indexes(ratings, user_indexer,
product_indexer):
transformed = product_indexer.transform( #1
user_indexer.transform(ratings)) #1
return order_preferences(transformed)
def indexes_to_strings(ratings, user_indexer,
product_indexer):
user_converter = IndexToString(inputCol="userIndex", #2
outputCol="user", #2
labels=user_indexer.labels) #2
product_converter = IndexToString(inputCol="productIndex", #2
outputCol="product", #2
labels=product_indexer.labels) #2
converted = user_converter.transform( #3
product_converter.transform(ratings)) #3
return order_preferences(converted)
user_indexer = StringIndexer(inputCol="user", #4
outputCol="userIndex").fit(user_prefs) #4
product_indexer = StringIndexer(inputCol="product", #5
outputCol="productIndex").fit(user_prefs) #5
indexed_prefs = strings_to_indexes(user_prefs, user_indexer, product_indexer)
indexed_prefs.show(10)
1 将用户和产品列转换为数据框的索引列
2 产品和用户的数字索引到字符串映射
3 对用户标识符执行索引到字符串的转换
4 将字符串用户字段映射到名为 userIndex 的整数索引
5 将字符串产品字段映射到名为 productIndex 的整数索引
输出:
+-------+------------+------+---------+------------+
| user| product|rating|userIndex|productIndex|
+-------+------------+------+---------+------------+
|u159789|008888345435| 1| 0.0| 5073.0|
|u159789|014633196870| 1| 0.0| 4525.0|
|u159789|018713571687| 1| 0.0| 10355.0|
|u159789|024543718710| 1| 0.0| 263.0|
|u159789|025192979620| 1| 0.0| 12289.0|
|u159789|025193102324| 1| 0.0| 9650.0|
|u159789|085391163121| 1| 0.0| 9196.0|
|u159789|720616236029| 1| 0.0| 2781.0|
|u159789|801213001996| 1| 0.0| 28736.0|
|u159789|813985010007| 1| 0.0| 5819.0|
+-------+------------+------+---------+------------+
only showing top 10 rows
如您从列表 9.3 中可以看到,我们的数据框现在包含了两列额外的列:userIndex和productIndex。在最后调用indexes_to_strings函数将它们转换回原始字符串 ID 之前,我们将使用这些数字 ID 在 ALS 实现代码中继续使用。
现在我们已经准备好了用户-项目偏好数据框,是时候调用 ALS 算法了。ALS 需要三个参数:userCol、itemCol和ratingCol,它们分别对应于我们数据框中的userIndex、productIndex和rating列。我们还会设置一些其他参数,包括以下内容:
-
maxIter=3(运行的最大迭代次数) -
rank=10(要学习的潜在特征数量) -
regParam=0.15(正则化参数) -
implicitPrefs=True(是否将评分视为隐式或显式) -
coldStartStrategy=drop(如何处理训练数据中不存在的新用户或项目)
列表 9.4 展示了如何使用这些参数调用 ALS。
列表 9.4 使用 Spark 训练 ALS 模型
from pyspark.ml.evaluation import RegressionEvaluator
from pyspark.ml.recommendation import ALS
from pyspark.sql import Row
als = ALS(maxIter=3, rank=10, regParam=0.15, implicitPrefs=True,
userCol="userIndex", itemCol="productIndex", ratingCol="rating",
coldStartStrategy="drop", seed=0)
(training_data, test_data) = \ #1
user_prefs.randomSplit([0.95, 0.05], 0) #1
training_data = strings_to_indexes(training_data, user_indexer, product_indexer)
test_data = strings_to_indexes(test_data, user_indexer, product_indexer)
model = als.fit(training_data) #2
predictions = model.transform(test_data) #3
evaluator = RegressionEvaluator(metricName="rmse", #3
labelCol="rating", #3
predictionCol="prediction") #3
rmse = evaluator.evaluate(predictions) #3
print(f"Root-mean-square error = {rmse}") #3
1 将偏好分为 95%的训练数据和 5%的测试数据
2 使用训练集中的用户偏好训练 ALS 模型
3 将训练好的模型与测试集中的用户偏好进行比较
输出:
Root-mean-square error = 1.0007877733299877
你现在已经训练了一个推荐模型!我们将数据分为训练集(95%)和测试集(5%),构建了 ALS 模型,然后运行评估器来计算均方根误差(RMSE)损失函数以衡量模型的质量。RMSE 是预测评分与实际评分之间差异的度量,因此 RMSE 越低,模型越好。RMSE 的绝对值相对于不同模型训练过程中的相对值来说不太重要,因为计算依赖于底层数据使用的尺度。如果你增加maxIter,找到最优的rank,并在准备用户-产品-评分数据时增加top_product_count_for_recs,你可能会看到 RMSE 略有下降,这是由于模型改进导致的。
现在模型已经训练好了,我们可以用它来生成推荐。列表 9.5 展示了如何从 ALS 模型生成所有用户的物品推荐。我们将为每个用户生成 10 个推荐,并显示前 5 个用户的推荐。
列表 9.5 从 ALS 模型生成用户-项目推荐
indexed_user_recs = model.recommendForAllUsers(10) \
.orderBy(col("userIndex").asc())
indexed_user_recs.show(5, truncate=64)
输出:
+---------+----------------------------------------------------------------+
|userIndex| recommendations|
+---------+----------------------------------------------------------------+
| 0|[{6, 0.022541389}, {13, 0.015104328}, {36, 0.010634022}, {20,...|
| 1|[{13, 0.009001873}, {3, 0.007981183}, {23, 0.0050935573}, {31...|
| 2|[{9, 0.06319133}, {17, 0.04681776}, {3, 0.041046627}, {14, 0....|
| 3|[{17, 0.0145240165}, {14, 0.01413305}, {12, 0.012459144}, {39...|
| 4|[{14, 0.006752351}, {4, 0.004651022}, {10, 0.004487163}, {17,...|
+---------+----------------------------------------------------------------+
only showing top 5 rows
注意,推荐的格式有点尴尬。我们只能使用userIndex而不是原始的user,而recommendations列是一个结构体数组,每个结构体包含一个productIndex和一个rating。让我们通过将每个用户-项目推荐转换为行,并用我们的原始user和product ID 替换userIndex和productIndex值来清理它。列表 9.6 展示了如何做到这一点。
列表 9.6 将推荐转换为最终、清理过的格式
column_exploder = explode("recommendations").alias("productIndex_rating")
user_item_recs = indexed_user_recs.select("userIndex", column_exploder) \
.select("userIndex", col("productIndex_rating.*"))
user_item_recs = indexes_to_strings(user_item_recs, user_indexer,
product_indexer)
user_item_recs = user_item_recs.select("user", "product",
col("rating").alias("boost"))
在这个列表中,我们首先使用rec.productIndex和rec.rating列将推荐explode到每个推荐的单独行。然后,我们将userIndex选择到每一行上,选择rec.productIndex作为productIndex,将rec.rating作为rating。最后,我们将从userIndex和productIndex转换回user和product,并返回user、product和boost。
让我们将我们的推荐保存到集合中以便将来使用。这将使我们能够从搜索引擎中即时提供推荐,或者将它们用作增强以个性化搜索结果。列表 9.7 将我们的用户-项目推荐数据框写入搜索引擎中的user_item_recommendations集合,遵循与第八章中我们用来表示信号增强类似的数据格式。
列表 9.7 将推荐索引到搜索引擎中
recs_collection = engine.create_collection("user_item_recommendations")
recs_collection.write(user_item_recs)
您现在已经根据用户与项目的交互生成了项目推荐,并将它们保存在搜索引擎中的user_item_recommendations集合中供将来使用。接下来,我们将演示我们如何提供这些推荐并使用它们来个性化搜索结果。
9.3.3 使用推荐增强个性化搜索结果
生成用户-项目推荐后,我们现在可以个性化搜索结果。第八章中signals_boosts集合和这里的user_item_recommendations集合之间的唯一区别是将query列替换为user列。换句话说,信号增强是基于匹配特定的关键字查询并应用相关项目相关性增强,而个性化是基于匹配特定的用户并应用相关项目相关性增强。
现在根据列表 9.7 填充了我们的推荐集合后,我们可以直接提供推荐(无关键字查询)或使用推荐根据用户的推荐进行增强以个性化搜索结果。
纯协同推荐
直接提供推荐很简单,所以我们将从这里开始。列表 9.8 显示了我们的一个用户的最近信号,我们将演示这些个性化技术。
列表 9.8 目标用户的交互历史
def signals_request(user_id):
return {"query": "*",
"return_fields": ["signal_time", "type", "target"],
"order_by": [("signal_time", "asc")],
"filters": [("user", user_id)]}
user_id = "u478462" #1
signals_collection = engine.get_collection("signals")
request = signals_request(user_id)
previous_signals = signals_collection.search(**request)["docs"]
print_interaction_history(user_id, previous_signals)Previous Product Interactions for User: u478462
+-----------+-----------+------------+-------------------------------------+
|signal_time| type| target| name|
+-----------+-----------+------------+-------------------------------------+
|05/20 06:05| query| apple| apple|
|05/20 07:05| click|885909457588|Apple® - iPad® 2 with Wi-Fi - 16GB...|
|05/20 07:05|add-to-cart|885909457588|Apple® - iPad® 2 with Wi-Fi - 16GB...|
|05/20 07:05| purchase|885909457588|Apple® - iPad® 2 with Wi-Fi - 16GB...|
|05/25 06:05| query| macbook| macbook|
|05/25 07:05| click|885909464043|Apple® - MacBook® Air - Intel® Cor...|
+-----------+-----------+------------+-------------------------------------+
1 我们将为谁个性化结果
根据用户的历史记录,很明显他们对苹果产品、平板电脑和计算机感兴趣。以下列表演示了如何从我们的user_item_recommendations集合为该用户提供推荐。
列表 9.9 使用信号增强查询提供推荐
def get_query_time_boosts(user, boosts_collection):
request = {"query": "*",
"return_fields": ["product", "boost"],
"filters": [("user", user)] if user else [],
"limit": 10,
"order_by": [("boost", "desc")]}
response = boosts_collection.search(**request)
signals_boosts = response["docs"]
return " ".join(f'"{b["product"]}"^{b["boost"] * 100}'
for b in signals_boosts)
def search_for_products(query, signals_boosts):
request = product_search_request(query if query else "*") #1
if signals_boosts:
request["query_boosts"] = ("upc", signals_boosts)
return products_collection.search(**request)
user = "u478462"
boosts = get_query_time_boosts(user, recs_collection)
response = search_for_products("", boosts) #2
print(f"Boost Query:\n{boosts}")
display_product_search("", response["docs"])
1 为了简洁,省略了函数;它可以在列表 4.3 中看到。
2 查询推荐集合以索引产品增强
图 9.10 显示了列表 9.9 的输出。在顶部,你会注意到一个“提升查询”的条目,显示了为用户推荐的最推荐产品及其对用户的相对提升(计算为rating * 100)。在提升查询下方,你会看到针对此空白关键词搜索的增强搜索结果,这是用户的原始推荐。

图 9.10 仅基于协同过滤的用户推荐
推荐将 16GB 的 iPad 提升到顶部,这是有道理的,因为用户之前搜索并点击了 16GB 的 iPad,另一款苹果 iPad(32GB 型号)排名第四。你还会看到其他制造商生产的具有类似配置的平板电脑在顶级推荐中。这是一个很好的例子,说明了协同过滤如何帮助展示可能不直接匹配用户先前互动(只有苹果笔记本电脑和 iPad)的项目,但这些项目可能仍然与用户的兴趣相关(与 iPad 类似的平板电脑)。
这样的推荐可以与传统的搜索结果集成使用,或者甚至可以插入到一组搜索结果中。但也可以将它们用作关键词排名算法的增强,以个性化搜索结果,我们将在下一节探讨。
纯关键词搜索与个性化搜索
除了独立于关键词搜索提供推荐外,还可以将它们作为额外的信号融入搜索排名算法中,以个性化结果。回到我们上一个例子,想象一下,如果我们的用户对苹果的 iPad 和 MacBook 感兴趣,他执行了“平板”的关键词搜索。如果使用平板推荐来个性化搜索结果,这会有什么不同?列表 9.10 在应用基于用户个性化推荐的信号提升之前和之后运行了查询。
列表 9.10 非个性化与个性化搜索结果
query = "tablet"
response = search_for_products(query, None) #1
print(f"Non-personalized Query")
display_product_search(query, response["docs"])
response = search_for_products(query, boosts) #2
print(f"Personalized Query")
display_product_search(query, response["docs"])
1 非个性化搜索结果(仅关键词搜索)
2 个性化搜索结果(关键词 + 用户-项目推荐提升)
图 9.11 显示了针对“平板”的非个性化查询输出,而图 9.12 显示了应用推荐提升以个性化搜索结果后的输出。
个性化搜索结果可能比非个性化结果对用户的相关性更大。值得注意的是,在我们的实现中,个性化仅作为相关性提升应用。这意味着与用户明确查询不匹配的产品将不会返回,并且所有匹配查询的项目仍然会返回;唯一的区别是产品的排序,因为针对用户个性化的项目现在应该显示在第一页。

图 9.11 无个性化应用的“平板”传统关键词搜索

图 9.12 个人化搜索tablet,其中用户对 Apple 品牌表示了兴趣
还请注意,在增强推荐(推荐示例中的平板电脑)之后,第五个搜索结果是来自非个性化搜索结果的项目,“纪念平板”标题为 CD。
这意味着两件事:
-
如果你正在个性化搜索结果,而不仅仅是提供纯推荐,你可能希望为每个用户生成超过 10 个推荐,尤其是当推荐只有在它们也匹配用户的明确查询时才会显示出来。
-
非个性化相关性算法仍然至关重要。如果在基于查询(第八章所述)的信号增强(基于用户)的基础上,再加上基于用户的推荐增强(基于用户),你将看到最上面的热门平板电脑(而不是平板电脑套和 CD),而个性化的平板电脑由于个性化而将在热门结果中上升更高。
我们现在已经了解了协同过滤是如何通过矩阵分解工作的,我们已经实现了基于协同过滤算法(ALS)的推荐,并展示了如何使用这些推荐来个性化搜索结果。在下一节中,我们将探讨另一种基于文档嵌入的个性化技术。
9.4 使用基于内容的嵌入个性化搜索
在上一节中,我们使用了用户信号来学习特定项目的个性化增强。这些增强是通过在用户-项目交互模式上使用矩阵分解来学习用户和项目的潜在特征生成的。
你还可以直接使用这些潜在因素将用户或项目聚在一起。不幸的是,没有一种可靠的方法仅基于用户交互信号将查询映射到特定项目的集群中,而不需要事先看到相应的查询(再次是冷启动问题)。幸运的是,搜索引擎没有关于项目(如标题、描述和其他属性)的额外知识是非常罕见的。
在本节中,我们将探讨一种混合方法,结合基于内容的理解和用户交互模式来构建一个不断发展的用户配置文件以个性化搜索结果。
9.4.1 生成基于内容的潜在特征
我们已经介绍了许多利用字段来过滤和增强文档中显式属性的技术。第五章至第七章特别关注生成知识图谱和解析特定领域的实体,以帮助实现上下文相关的相关性。
尽管这些技术确实可以用于实现个性化搜索(并且我们鼓励您尝试它们),但在这个部分,我们将探索一种不同的方法。我们不会使用显式属性,而是将使用从文档内容中学习的潜在特征来生成个性化搜索结果。我们将使用大型语言模型(LLM)为每个文档生成嵌入,然后我们将使用这些嵌入以及用户与文档的交互来构建一个不断发展的用户档案。最后,我们将使用该用户档案来个性化搜索结果。
图 9.13 从概念上展示了如何使用 LLM 为文档生成嵌入。类似于我们在 9.3.1 节中使用矩阵分解创建一个映射每个项目到其潜在特征列表的矩阵,我们将使用 LLM 为每个文档生成一个潜在特征的向量。我们将根据文档文本映射到一个 LLM 已经学习过的向量空间中提取这些潜在特征。现在不必担心 LLM 是如何训练的机制——我们将在第十四章和第十五章中深入探讨这一点。只需知道它是在一个大型文本语料库上训练的,并且它学会了如何使用一些代表文本意义的潜在特征将单词和短语映射到向量空间。向量空间中的每个维度代表一个潜在特征,每个维度的值代表该潜在特征在文本中的表示强度。

图 9.13 展示了来自一个 LLM 的项目嵌入。向量空间中的每个维度代表一个潜在特征,每个维度的值代表该潜在特征在文本中对该项目的表示强度。
图 9.13 中的值仅用于说明目的,并不是我们 LLM 实际生成的值。我们为这些特征分配了简化的标签来描述它们似乎代表的内容(“大小”、“颜色”、“类似计算机”和“成本”),但在现实世界的场景中,这些特征将是未标记的,并且将代表 LLM 在训练过程中学习到的更复杂的潜在特征,这些潜在特征结合了许多不同的方面。
在我们的示例中,我们将使用all-mpnet-base-v2 LLM,这是一个公开可用的模型(huggingface.co/sentence-transformers/all-mpnet-base-v2),它是一个适用于语义搜索和句子及短段落(如我们 RetroTech 数据集中的段落)的通用 LLM。这是一个轻量级模型(只有 768 个维度),它基于来自整个网络的超过 11.7 亿个句子对进行训练,提供了一个良好的通用知识库。
下面的列表检索了我们需要传递给 LLM 的字段。
列表 9.11 检索产品数据以生成嵌入
query = "SELECT DISTINCT name, string(upc), short_description FROM products"
spark.sql(query).createOrReplaceTempView("products_samples")
product_names = dataframe.select("name").rdd.flatMap(lambda x: x).collect()
product_ids = dataframe.select("upc").rdd.flatMap(lambda x: x).collect()
要生成嵌入,我们首先使用 Spark 创建一个新的products_samples表,其中包含用于生成嵌入和识别相关产品的字段子集。列表 9.12 展示了我们如何使用all-mpnet-base-v2 LLM 和Sentence_Transformers库为每个产品生成嵌入。我们将生成一个包含每个产品的 768 维向量的product_embeddings对象,以及一个包含每个产品名称的product_names对象和一个包含每个产品 ID 的product_ids对象。
列表 9.12 生成产品嵌入
from sentence_transformers import SentenceTransformer #1
transformer = SentenceTransformer("all-mpnet-base-v2") #1
... #2
def get_embeddings(texts, model, cache_name, ignore_cache=False):
... #2
embeddings = model.encode(texts) #3
... #2
return embeddings
product_embeddings = get_embeddings(product_names,
transformer, cache_name="all_product_embeddings")
1 加载 all-mpnet-base-v2 LLM
2 为了简洁起见,省略了缓存生成的嵌入的优化代码。
3 为所有产品生成 768 维向量嵌入
由于我们使用的是现成的all-mpnet-base-v2模型,因此为所有产品加载和生成嵌入的过程就像列表 9.12 中的代码一样简单。因为为所有产品生成嵌入的过程可能需要一些时间,笔记本中还包含了一些省略的代码优化,用于缓存和重用嵌入以节省额外的处理时间。
如果我们想比较两个产品的相似度,我们可以直接使用点积或余弦相似度计算来比较它们的向量。向量中的 768 个特征是每个文档的预训练潜在特征,类似于图 9.7 中项目特征矩阵中表示的潜在特征。这意味着我们现在可以
-
为任何项目或查询生成嵌入以获取该项目或查询的向量表示。
-
从任何查询嵌入开始进行语义搜索,找到最接近(余弦或点积)的其他嵌入。
-
使用一个项目的嵌入来生成其他项目的推荐,通过找到与最相似(余弦或点积)嵌入的项目。
但关于基于用户生成推荐或个性化搜索结果怎么办?在图 9.7 中,我们不仅提取了潜在的项目特征,还提取了潜在的用户特征。第 9.2 节中协同过滤背后的整个想法是,相似的用户与相似的项目互动,正是因为这些项目具有与用户兴趣重叠的特征。换句话说,代表用户兴趣的向量应该与代表用户表示兴趣的项目向量相似。
为了根据嵌入向量个性化搜索结果,我们需要生成一个代表用户兴趣的向量。一种方法是取用户已与之互动的商品表示向量的平均值。这是一种简单的方法来生成一个代表用户所有过去兴趣的向量,并且在实践中效果惊人。不幸的是,基于每次过去搜索来个性化每次未来搜索可能会有些过于激进,因为用户经常在不同时间进行与不同类型商品无关的搜索。为了避免在这些情况下出现无用的过度个性化,首先在不同商品类别上应用一些防护栏可能是有用的,我们将在下一节中介绍。
9.4.2 实现个性化分类的防护栏
某人搜索某件商品并不总是意味着他们想看到类似商品。但如果他们确实想要个性化,通常在概念或类别边界上应用个性化是一个非常糟糕的想法。例如,如果某人观看了包含暴力时间旅行的机器人的电影《终结者》,这并不意味着他们想购买机器人吸尘器或枪。作为一个来自我们数据集的具体例子,想象一下,如果某人之前对“Hello Kitty 水瓶”、“GE 黑色电动剃须刀”、“GE 明亮白灯泡”和“三星不锈钢冰箱”表示了兴趣。如果他们随后搜索“微波炉”,图 9.14 中的哪些商品最合适推荐?

图 9.14 个性化防护栏可以帮助防止无关过去的兴趣意外影响未来的搜索
当用户之前查看“白色”灯和“黑色”电动剃须刀时,没有充分的理由将那些颜色偏好应用到无关的“厨房电器”类别中。此外,是否将“Hello Kitty 水瓶”的兴趣转移到“Hello Kitty 微波炉”上,或者查看“灯泡”和由“GE”公司制造的“电动剃须刀”是否以任何方式转化为用户在查看“厨房电器”时对“GE”品牌的品牌忠诚度,这都是值得怀疑的。然而,鉴于这位特定用户已经对另一件电器(由“三星”公司制造的“不锈钢”冰箱)表示了兴趣,因此,他们可能会对“三星”公司(或至少是“GE”公司以外的其他公司)制造的“不锈钢”电器更感兴趣,例如他们现在正在搜索的微波炉。
应该轻触应用个性化。很容易出错,并以对用户无益(甚至可能令人沮丧和适得其反)的方式应用个性化,因此通常最好谨慎行事,并确保只有在可能有益的情况下才应用个性化。一种简单的方法是在与查询相似的类别内应用个性化。这是应用护栏的一种方式,并且是一种非常有效的方法,可以避免以可能对用户无益的方式应用个性化。
虽然你的数据可能或可能没有明确的类别字段用于过滤,但也可以通过根据它们的相似性将项目聚在一起来动态生成类别。这可以通过对所有项目的嵌入进行聚类来实现,以动态创建一个数据驱动的类别集。以下列表演示了从项目的嵌入中生成簇的简单方法。
列表 9.13 从聚类产品生成动态类别
def get_clusters(data, algorithm, args):
return algorithm(**args).fit(data)
def assign_clusters(labels, product_names):
clusters = defaultdict(lambda:[], {})
for i in range(len(labels)):
clusters[labels[i]].append(product_names[i])
return clusters
args = {"n_clusters": 100, "n_init": 10, "random_state": 0} #1
algo = get_clusters(product_embeddings, cluster.KMeans, args) #1
labels = algo.predict(product_embeddings)
clusters = assign_clusters(labels, product_names) #2
1 使用 KMeans 聚类算法生成 100 个簇
2 将每个产品名称分配给其对应的簇标签
为了确保我们的聚类效果良好,我们可以检查每个簇中的顶级单词,以确保它们相关并形成一个连贯的类别。列表 9.14 展示了识别每个簇中的顶级单词并使用主成分分析(PCA)将 768 维嵌入映射到二维以进行可视化的代码。
列表 9.14 检查每个产品簇中的流行术语
import collections, numpy as np, matplotlib.pyplot as plt
from adjustText import adjust_text
from sklearn.decomposition import PCA
plt.figure(figsize=(15, 15))
pca = PCA(100, svd_solver="full") #1
centers = algo.cluster_centers_ #1
plot_data = pca.fit_transform(centers) #1
points = []
for i, cluster_name in enumerate(plot_data): #2
plt.scatter(plot_data[i,0], plot_data[i, 1], #2
s=30, color="k") #2
label = f"{i}_{"_".join(top_words(clusters[i], 2))}" #3
points.append(plt.text(plot_data[i, 0], #4
plot_data[i, 1], #4
label, size=12)) #4
adjust_text(points, arrowprops=dict(arrowstyle="-", #5
color="gray", alpha=0.3)) #5
plt.show()
1 执行 PCA 将嵌入降低到二维以进行可视化
2 遍历每个簇并在图上绘制它
3 顶级单词函数从簇中获取最常见的单词。
4 为每个簇添加一个文本标签,包括簇 ID 和每个簇中的顶级-N 个单词
5 显示改进:调整文本标签以最小化重叠
图 9.15 显示了列表 9.14 的输出。每个点代表一个簇,每个簇的文本标签包括簇 ID 和该簇中的顶级单词。
虽然图 9.15 可能看起来很混乱,它代表了我们的近 50,000 个产品被分类到其中的 100 个簇,但在语义空间中你可以看到清晰的模式。图的左上角包含厨房电器,音乐倾向于位于图的右上角剩余的已填充区域(右上角是 CD,右上中间是乐器和扬声器),与视频和数据存储相关的项目倾向于位于图的底部(右下角是 DVD 和蓝光,右下中间是家庭影院和相机,左下角是计算机内存卡和存储,以及其他计算机外围设备)。请随意检查各种类别和簇之间的关系,但请意识到它们已经被从 768 维映射到 2 维,因此 KMeans 聚类算法所表示的丰富性在可视化中会丢失很多。

图 9.15 由 KMeans 聚类生成的所有产品嵌入簇,用于对查询和产品进行分类
现在我们有了可用于分类产品的簇(以及与交互过的产品对应的信号),我们需要确保我们可以将查询映射到正确的簇中。有多种方法可以实现这一点:
-
模型驱动—只需将查询通过 LLM,并使用生成的嵌入向量来找到最近的类别。
-
行为驱动—使用查询信号和相应的交互信号(如点击)来确定热门查询最可能的类别。
-
内容驱动—运行关键词或语义搜索,并在结果中找到顶级类别。
-
混合—使用这些方法的任意组合。
行为驱动的方法遵循第八章中提到的信号增强方法,但它通过与顶级增强文档相关的类别进行聚合,而不是通过查询。内容驱动的方法使您能够使用第 5-7 章中探索的其他语义搜索技术。为了简单起见,我们将在这里使用模型驱动的方法,并让大型语言模型(LLM)来确定查询的含义。以下列表展示了三种不同的方法,用于根据嵌入向量推导查询的顶级类别。
列表 9.15 比较将查询映射到簇的技术
import sentence_transformers, heapq
def get_top_labels_centers(query, centers, n=2): #1
query_embedding = transformer.encode([query], convert_to_tensor=False)
similarities = sentence_transformers.util.cos_sim(
query_embedding, centers)
sim = similarities.tolist()[0]
return [sim.index(i) for i in heapq.nlargest(n, sim)]
def get_query_cluster(query): #2
query_embedding = transformer.encode([query], convert_to_tensor=False)
return algo.predict(query_embedding)
def get_cluster_description(cluser_num):
return "_".join(top_words(clusters[cluser_num], 5))
query = "microwave"
kmeans_predict = get_query_cluster(query)[0] #3
print("K-means Predicted Cluster:")
print(f" {kmeans_predict} ({get_cluster_description(kmeans_predict)})")
closest_sim = get_top_labels_centers(query, centers, 1)[0] #4
print(f"\nCosine Predicted Cluster:")
print(f" {closest_sim} ({get_cluster_description(closest_sim)})")
knn_cosine_similarity = get_top_labels_centers(query, centers, 5) #5
print(f"\nKNN Cosine Predicted Clusters: {knn_cosine_similarity}")
for n in knn_cosine_similarity:
print(f" {n} ({get_cluster_description(n)})")
1 基于与簇质心的余弦相似度获取顶级 N 个簇
2 基于 KMeans 模型的预测获取簇
3 选项 1:预测最近的簇(KMeans)
4 选项 2:找到最相似的簇(余弦相似度)
5 选项 3(推荐):找到 N 个最相似的簇(余弦相似度)
输出:
K-means Predicted Cluster:
44 (Microwave_Cu._Ft._Stainless-Steel_Oven)
Cosine Predicted Cluster:
44 (Microwave_Cu._Ft._Stainless-Steel_Oven)
KNN Cosine Predicted Clusters: [44, 52, 5, 83, 6]
44 (Microwave_Cu._Ft._Stainless-Steel_Oven)
52 (Stainless-Steel_30"_Black_Range_Cooktop)
5 (KitchenAid_Black_White_Stand_Mixer)
83 (Black_Coffeemaker_Maker_Coffee_Stainless-Steel)
6 (Range_30"_Self-Cleaning_Freestanding_Stainless-Steel)
在列表 9.15 中,我们看到计算了三个预测:最近簇(K-means)、最相似簇(余弦相似度)和N个最相似簇(余弦相似度)。get_top_labels_centers函数根据与簇质心的余弦相似度计算前N个簇。聚类函数get_query_cluster根据 K-means 预测计算一个簇。
这三种方法输出的结果展示了一个重要的观点。虽然查询是针对微波,但我们知道类别是动态生成的,产品之间可能存在重叠。K-means 模型和余弦相似度方法在这个例子中都选择了类别44(Microwave_Cu._Ft._Stainless-Steel_Oven)。虽然依靠余弦相似度来衡量语义相似度比 K-means 预测可能找到更好的结果,但每个返回的类别可能都密切相关。因此,任何个性化都应该应用于相关的每个类别,而不仅仅是其中一个。产品可以分布在多个相关类别中,并且可以根据物品数量和描述的细微差别任意划分有意义的类别。
为了克服相似类别之间的重叠,我们建议使用前N个余弦预测簇(Knn,选项 3)而不是过滤到单个簇。在列表 9.15 的结果中,这种混合方法返回了五个相关类别:44(“微波炉”)、52(“炉灶”)、5(“杂项电器”)、83(“柜台电器”)和33(“烤箱”)。
接下来,我们将使用这些预测类别,以及用户之前交互的嵌入,来个性化搜索结果。
9.4.3 将基于嵌入的个性化集成到搜索结果中
我们个性化旅程的最终一步是执行个性化搜索。我们可以用许多不同的方式来完成这项任务:
-
在预测的簇中,对查询向量(
微波的嵌入)和用户之前交互的向量进行加权平均。这将生成一个代表用户个性化查询的单个向量,因此所有结果都将个性化。 -
执行标准搜索,但然后根据用户在预测簇中之前交互的嵌入的平均值来提升结果。这将是一个混合关键字和基于向量的排名函数,其中关键字搜索将是结果的主要驱动因素,但用户的先前交互将用于提升相关结果。
-
做上述之一,但然后在搜索结果中只个性化几个项目,而不是所有结果。这遵循轻触心态,以免打扰所有用户的搜索结果,同时仍然注入新颖性,使用户能够发现他们可能否则找不到的个性化项目。
-
执行标准搜索(关键字或向量),但随后根据查询向量和用户在预测簇内先前交互的向量之间的加权平均重新排序结果。这使用原始搜索通过默认的相关性算法找到候选结果,但这些结果被重新排序以提升个性化的偏好。
我们将演示最后一种技术,因为它很容易在任何搜索引擎中复制,因为个性化/重新排序步骤可以在原始搜索之后作为一个最终步骤来完成。因此,这项技术将很好地与传统的搜索引擎和向量数据库一起工作。
列表 9.16 展示了我们用于生成个性化向量的两个关键函数:一个 get_user_embeddings 函数,该函数查找产品列表的嵌入并返回每个产品的关联簇,以及一个 get_personalization_vector 函数,该函数可以结合查询和所有相关用户-项目交互向量之间的嵌入。
列表 9.16 生成个性化向量的函数
def top_clusters_for_embedding(embedding, n=2):
similarities = sentence_transformers.util.cos_sim(embedding, centers)
sim = similarities.tolist()[0]
return [sim.index(i) for i in heapq.nlargest(n, sim)]
def get_user_embeddings(products=[]): #1
values = []
embeddings = get_indexed_product_embeddings()
for p in products:
values.append([embeddings[p],
top_clusters_for_embedding(embeddings[p], 1)[0]])
return pandas.DataFrame(data=numpy.array(values), index=products,
columns=["embedding", "cluster"])
def get_personalization_vector(query=None, #2
user_items=[],
query_weight=1, #3
user_items_weights=[]): #3
query_embedding = transformer.encode(query) if query else None
if len(user_items) > 0 and len(user_items_weights) == 0: #4
user_items_weights = numpy.full(shape=len(user_items),
fill_value=1 / len(user_items))
embeddings = []
embedding_weights = []
for weight in user_items_weights: #3
embedding_weights.append(weight) #3
for embedding in user_items:
embeddings.append(embedding)
if query_embedding.any():
embedding_weights.append(query_weight) #3
embeddings.append(query_embedding)
return numpy.average(embeddings, weights=numpy.array(embedding_weights),
axis=0).astype("double") if len(embeddings) else None
1 返回一个包含每个产品的嵌入和护栏簇的数据框
2 返回一个向量,该向量结合了(加权平均)查询的嵌入和传递的用户 _items 的嵌入
3 可以可选地指定查询权重和用户 _item 权重以影响每个嵌入对个性化向量的影响程度。
4 默认情况下,权重在查询嵌入和用户 _items 权重之间平分(各占 50%)。
在能够组合嵌入并查找任何产品的护栏簇之后,我们现在可以根据用户的查询和过去的产品交互生成一个个性化向量。我们将生成带有护栏和无护栏的个性化向量以比较结果。
列表 9.17 展示了如何生成个性化向量。在这种情况下,用户之前与两个产品进行了交互:一个 Hello Kitty 水壶和一个不锈钢电炉。他们现在正在运行一个新的以关键字 microwave 为查询的查询。
列表 9.17 从用户查询生成个性化向量
product_interests = ["7610465823828", #hello kitty water bottle
"36725569478"] #stainless steel electric range
user_embeddings = get_user_embeddings(product_interests)
query = "microwave"
unfiltered_personalization_vector = #1
get_personalization_vector(query=query, #1
user_items=user_embeddings['embedding'].to_numpy()) #1
print("\nPersonalization Vector (No Cluster Guardrails):")
print(format_vector(unfiltered_personalization_vector))
query_clusters = get_top_labels_centers(query, #2
centers, n=5) #2
print("\nQuery Clusters ('microwave'):\n" + str(query_clusters))
clustered = user_embeddings.cluster.isin(query_clusters) #3
products_in_cluster = user_embeddings[clustered] #3
print("\nProducts Filtered to Query Clusters:\n" + str(products_in_cluster))
filtered_personalization_vector = get_personalization_vector(query=query, #4
user_items=filtered['embedding'].to_numpy()) #4
print("\nFiltered Personalization Vector (With Cluster Guardrails):")
print(format_vector(filtered_personalization_vector))
1 无护栏的个性化向量(使用查询和所有过去的项目交互)
2 获取查询的前 5 个簇作为护栏使用
3 过滤到仅包含护栏查询簇中的项目
4 生成带有护栏的个性化向量(使用查询和仅与查询相关的项目)
输出:
Products Interactions for Personalization:
product embedding cluster
7610465823828 [0.06417941, 0.04178553, -0.0017139615, -0.020... 1
36725569478 [0.0055417763, -0.024302201, -0.024139373, -0.... 6
Personalization Vector (No Cluster Guardrails):
[0.016, -0.006, -0.02, -0.032, -0.016, 0.008, -0.0, 0.017, 0.011, 0.007 ...]
Query Clusters ('microwave'):
[44, 52, 5, 83, 6]
Products Filtered to Query Clusters:
product embedding cluster
36725569478 [0.0055417763, -0.024302201, -0.024139373, -0.... 6
Filtered Personalization Vector (With Cluster Guardrails):
[0.002, -0.023, -0.026, -0.037, -0.025, 0.002, -0.009, 0.007, 0.033, -0 ...]
列表 9.17 对用户查询生成个性化向量执行了四个步骤的过程:
-
获取产品交互列表,以及相关的产品嵌入和簇。
-
找到查询最相似的 N(本例中为 5)个簇。
-
将用户交互列表过滤到仅包含查询簇中的项目。
-
通过组合查询和过滤的用户-项目交互向量生成个性化向量(
filtered_personalization_vector)。(注意:我们还生成了一个unfiltered_personalization_vector,它不应用分类保护措施,以便稍后进行对比。)
最终的filtered_personalization_vector可以直接用于在嵌入中进行向量搜索,因为它代表了一个在 768 维嵌入向量空间中将查询拉向用户兴趣的嵌入。在我们的案例中,我们打算对查询进行独立搜索,然后使用filtered_personalization_vector来重新排序顶级结果。以下列表展示了这个搜索和重新排序的过程。
列表 9.18 使用个性化向量重新排序结果
def rerank_with_personalization(docs, #1
personalization_vector): #1
embeddings = get_indexed_product_embeddings()
result_embeddings = numpy.array(
[embeddings[docs[x]["upc"]]
for x in range(len(docs))]).astype(float)
similarities = sentence_transformers.util.cos_sim(
personalization_vector, result_embeddings).tolist()[0]
reranked = [similarities.index(i)
for i in heapq.nlargest(len(similarities), similarities)]
reranked, _ = zip(*sorted(enumerate(similarities),
key=itemgetter(1), reverse=True))
return [docs[i] for i in reranked]
query = "microwave"
request = product_search_request(query, {"limit": 100})
response = products_collection.search(**request)
docs = response["docs"]
print("No Personalization:")
display_product_search(query, docs[0:4]) #2
print("Global Personalization (No Category Guardrails):")
reranked_seach_results_no_guardrails = \ #3
rerank_with_personalization(docs, #3
unfiltered_personalization_vector) #3
display_product_search(query, reranked_seach_results_no_guardrails[0:4])
print("Contextual Personalization (with Category Guardrails):")
reranked_seach_results_with_guardrails = \ #4
rerank_with_personalization(docs, #4
filtered_personalization_vector) #4
display_product_search(query, reranked_seach_results_with_guardrails[0:4])
1 根据与个性化查询向量的余弦相似度重新排序所有搜索结果
2 显示原始搜索结果(无个性化)
3 无保护措施的个性化搜索(使用 unfiltered_personalization_vector)
4 带有保护措施的个性化搜索(使用 filtered_personalization_vector)
列表 9.18 展示了将个性化向量应用于重新排序搜索结果的全过程。rerank_with_personalization函数接收原始搜索结果和个性化向量,然后根据个性化向量与每个搜索结果嵌入向量之间的余弦相似度重新排序搜索结果。为了比较,我们进行了两次重新排序:一次是在应用了保护措施的情况下,另一次则没有。最终的排序结果集分别传递给display_product_search函数以渲染图 9.16 中比较的三个结果集:未个性化搜索结果、无保护措施的个性化搜索结果以及带有保护措施的个性化搜索结果。

图 9.16 比较非个性化、始终个性化(无保护措施)和上下文个性化(有保护措施)的搜索结果
在左侧,我们看到microwave的原始搜索结果,包括微波炉盖、一些不锈钢微波炉和一个基本的微波炉。在中间,我们看到无分类保护措施的个性化搜索结果。用户的个性化向量包括不锈钢微波炉和 Hello Kitty 水瓶的嵌入。正如您所看到的,Hello Kitty 微波炉直接跳到了结果的最顶部,尽管用户之前查看过不锈钢冰箱,而且他们对水瓶的兴趣不太可能转化为对 Hello Kitty 微波炉的兴趣。在右侧,我们看到应用了保护措施的个性化。我们看到所有这些结果现在都是不锈钢微波炉,反映了用户之前对不锈钢冰箱的兴趣,这被自动识别为相似类别。
您现在已经实现了一个端到端的个性化搜索算法。如果谨慎且轻柔地实施,个性化搜索可以显著提高相关性,但重要的是不要通过过度个性化来令用户沮丧。在下一节中,我们将回顾一些需要记住的个性化陷阱和挑战,以避免潜在的用户沮丧。
9.5 个性化搜索结果的挑战
在本章中,我们强调了个性化搜索结果所面临的许多挑战。虽然个性化可以是一个强大的工具,用于推动更相关的搜索结果,但了解潜在的陷阱并确保仅在可能对用户有帮助时应用个性化是很重要的。在本章中,我们简要讨论了以下关键挑战:
-
冷启动问题—在使用协同过滤时,未与任何物品互动的用户缺乏任何基于个性化所需的信息。对于这类用户,退回到非个性化搜索结果是重要的。将基于内容的过滤方法(搜索或基于属性的匹配)与协同过滤相结合可以帮助克服冷启动问题。
-
边界限制很重要—在类别边界上应用个性化通常是一个坏主意。否则,当用户切换上下文查看不相关的物品时,搜索结果看起来会很奇怪,并且会适得其反。查看“白皮书”或“白灯泡”并不意味着用户在搜索家电时想要看到“白色”冰箱。同样,喜欢电影《终结者》并不意味着某人想要购买枪支或机器人吸尘器。在个性化搜索结果时,了解相关范围,在这个范围内应该应用学习到的用户偏好,是很重要的。为物品和查询建模相关类别,并将个性化限制为仅使用与查询相关的物品,是避免这些问题的良好方法。
-
过度个性化令人沮丧—当某人输入一个搜索查询时,他们期望搜索引擎返回与他们的特定查询最相关的结果。虽然个性化在某些用例中(例如,在餐厅中的位置个性化)可能非常有帮助,但如果个性化的程度干扰了用户的搜索体验控制,它也可能非常令人沮丧。作为一个极端案例,想象一下,如果每个查询都由每个先前查询或物品交互的特征所增强;搜索体验将迅速退化成一个无法使用的混乱,这将阻止用户找到他们想要的东西。考虑只对前几个结果进行个性化,而不是整个搜索结果集,这样如果个性化出错,非个性化的结果仍然可用。此外,考虑为用户提供一种关闭个性化功能的方法,如果他们觉得这令人沮丧。
-
反馈循环至关重要——用户兴趣会随时间变化。无论你是展示推荐内容还是为搜索构建个性化配置文件,用户都需要能够向系统提供反馈,以帮助系统学习和适应他们不断变化的需求。这可以通过允许用户对推荐内容提供明确的反馈(例如,点赞或踩)来实现,或者通过持续收集来自行为信号(点击、购买等)的隐式反馈,并使用新的交互来更新个性化配置文件。在两种情况下,提供一种让用户向系统提供反馈的方式都至关重要,这样系统才能随着时间的推移学习和适应用户不断变化的需求。
-
隐私可能成为问题——因为个性化基于之前的用户交互模式,展示个性化推荐和搜索结果意味着收集和暴露用户的过去行为。想象一下,一个电影流媒体服务推荐暴力或成人主题的电影,一家书店推荐浪漫小说或自我提升的书籍,或者一家杂货店推广垃圾食品和酒精。这可能会让用户感到尴尬和沮丧,损害对服务的信任和信心。重要的是要透明地说明正在收集哪些信号以及如何使用它们。同样重要的是,如果用户对隐私问题感到担忧,应提供一种让他们退出个性化服务的方式。
-
轻触式应用个性化——大多数搜索引擎不个性化搜索结果,让用户完全控制通过当前查询表达他们的兴趣。偏离这种范式在很多情况下可能有益,但重要的是要确保只有在可能对用户有帮助的情况下才应用个性化。确保轻触式应用的一种策略是仅对前几个结果应用个性化。在个性化方面,通常更谨慎地应用它,非常保守地应用。大多数用户对缺乏个性化不如对试图过度解读他们的想法并出错搜索引擎感到沮丧。
在所有基于人工智能的搜索技术中,个性化既是理解用户意图最未被充分利用的方法之一,也是最具挑战性的方法之一。虽然推荐引擎很普遍,但搜索和推荐之间的个性化范围更加微妙且探索较少。只要个性化搜索得到妥善实施,它就可以成为一个强大的工具,推动更相关的搜索结果,并节省用户发现最符合他们特定兴趣的项目的时间。
摘要
-
个性化搜索位于个性化范围的中部,介于关键字搜索(由显式用户输入驱动)和基于用户行为的协同推荐(由隐式输入驱动)之间。
-
通过文档间的用户交互模式学习协同推荐,但它们存在冷启动问题。将协同过滤与基于内容的属性相结合可以克服冷启动问题,并驱动更灵活的个性化搜索体验。
-
将文档和用户表示为嵌入向量,可以构建动态的个性化配置文件,这些配置文件可用于驱动更好的个性化搜索结果。
-
通过嵌入向量对产品进行聚类,可以生成动态类别,作为个性化搜索的护栏,确保用户不会看到与其兴趣相差太远的个性化结果。
-
将反馈循环纳入以从用户互动中学习是很重要的,只要保护用户隐私,并且轻柔地应用以避免过度个性化。
-
个性化搜索可以驱动更相关的搜索结果,但重要的是要在个性化的好处与用户因个性化过于激进而可能产生的挫败感之间取得平衡。找到正确的平衡点可以显著提高搜索引擎对用户意图的理解。
第十章:为可推广的搜索相关性学习排名
本章涵盖
-
机器学习排名简介,也称为学习排名(LTR)
-
LTR 与其他机器学习方法的区别
-
训练和部署排名分类器
-
特征工程、判断列表以及将机器学习排名模型集成到搜索引擎中
-
使用训练/测试分割验证 LTR 模型
-
基于 LTR 的排名模型的性能权衡
今天是个随意的星期二。你回顾了你的搜索日志,搜索内容从沮丧的跑者的polar m430 运动手表充电器查询,到担忧的疑病症患者的鼻子上的奇怪肿块 - 癌症?,再到好奇的电影爱好者的william shatner 第一部电影。即使这些可能只是一次性的查询,你知道每个用户都期望得到不亚于惊人的搜索结果。
你感到绝望。你知道许多查询字符串本身就很罕见。你几乎没有点击数据来了解这些搜索的相关性。每一天都变得更加具有挑战性:趋势、用例、产品、用户界面,甚至用户术语都在不断演变。当用户似乎不断以新的搜索方式让我们感到惊讶时,任何人如何希望构建令人惊叹的搜索呢?
不要绝望,还有希望!在本章中,我们将介绍可推广的相关性模型。这些模型学习驱动相关性排名的潜在模式。我们不再需要记住标题为“Zits:鼻子上的肿块”的文章是针对查询weird bump on nose - cancer?的答案,我们观察到潜在的规律——一个强有力的标题匹配对应着高相关性的可能性。如果我们能够学习这些模式并将它们编码到模型中,我们就可以为我们从未见过的搜索查询提供相关结果。
本章探讨了学习排名 (LTR):一种使用机器学习来创建可推广的相关性排名模型的技巧。我们将使用搜索引擎准备、训练和搜索 LTR 模型。
10.1 什么是 LTR?
让我们探索 LTR 做了什么。我们将看到 LTR 如何通过寻找预测相关性的模式来创建可推广的排名模型。然后我们将探索构建模型的更多细节。
10.1.1 超越手动相关性调整
回顾第三章中的手动相关性调整。我们观察到与相关结果相对应的因素,并将这些因素通过数学方法组合成一个排名函数。排名函数返回一个相关性分数,将结果尽可能紧密地排序到我们的理想排名。
例如,考虑一个包含以下列表中文档的电影搜索引擎。这份文档来自 TheMovieDB (tmdb)语料库(themoviedb.org),我们将在本章中使用它。如果你希望跟随本章的代码,请使用本章的第一个笔记本来索引 tmdb 数据集。
列表 10.1 电影《社交网络》的文档
{"title": ["The Social Network"],
"overview": ["On a fall night in 2003, Harvard undergrad and computer
↪programming genius Mark Zuckerberg sits down at his computer and
↪heatedly begins working on a new idea. In a fury of blogging and
↪programming, what begins in his dorm room as a small site among
↪friends soon becomes a global social network and a revolution in
↪communication. A mere six years and 500 million friends later,
↪Mark Zuckerberg is the youngest billionaire in history... but for
↪this entrepreneur, success leads to both personal and legal
↪complications."],
"tagline": ["You don't get to 500 million friends without making a few
↪enemies."],
"release_year": 2010}
通过无数次的迭代和调整,我们可能得到一个通用的电影排名函数,其外观可能类似于下一个列表。
列表 10.2 使用手动提升的通用排名函数
keywords = "some example keywords"
{"query": f"title:({keywords})¹⁰ overview:({keywords})²⁰
↪{!func}release_year⁰.01"}
手动优化此类通用排名函数的特征权重以适用于许多查询可能需要大量的努力,但此类优化非常适合机器学习。
这就是 LTR 发挥作用的地方——它从我们提出的相关性因素中学习一个最佳排名函数。LTR 可以采取多种形式,从一组简单的线性权重(如这里的提升)到复杂的深度学习模型。
为了学习基础知识,我们将在本章构建一个简单的 LTR 模型。我们将找到评分函数中title、overview和release_year的最佳权重,如列表 10.2 中的函数。通过这个相对简单的任务,我们将看到开发 LTR 解决方案的完整生命周期。
10.1.2 在现实世界中实现 LTR
随着我们继续从高层次定义 LTR,让我们快速明确 LTR 在搜索系统整体图景中的位置。然后我们可以看看我们需要构建 LTR 模型所需的数据类型。
我们将专注于构建适用于生产搜索系统的 LTR,这可能与研究环境大不相同。我们不仅需要相关的结果,还需要以主流、易于理解的技术快速返回结果。
从概念上讲,调用 LTR 通常涉及三个高级步骤:
-
训练一个 LTR 模型
-
将模型部署到生产环境
-
使用模型对搜索结果进行排序(或重新排序)
大多数现代搜索引擎支持直接将排名模型部署到搜索引擎中,允许 LTR 模型在“数据所在之处”高效地被调用。通常,LTR 模型在排序方面比基于基本关键词的排名函数(如 BM25)慢得多,因此 LTR 模型通常仅用于对初始、更快的排名函数排序的顶级搜索结果子集的后续排序(或重新排序)。将 LTR 模型推入引擎(如果支持)可以防止需要从搜索引擎返回数百或数千个文档及其元数据到外部模型服务进行重新排序,这相对于在引擎内和大规模上工作可能会慢且效率低下。
因此,在本章中,我们的ltr库实现了对部署和调用每个支持的搜索引擎或向量数据库的本地 LTR 模型集成功能的可插拔支持,当这些功能可用时。每个列表中的代码将与任何支持的引擎一起工作(参见附录 B 进行更改),但本章中您将看到的列表输出将反映 Solr 的 LTR 实现,因为 Solr 默认配置。如果您更改引擎,当您运行 Jupyter 笔记本时,您将看到您选择的引擎的输出。
Solr 是第一个原生支持 LTR 模型服务的开源搜索引擎之一,后来这些功能被移植到社区开发的 Elasticsearch LTR 插件(github.com/o19s/elasticsearch-learning-to-rank)中,然后被分叉到 OpenSearch LTR 插件(github.com/opensearch-project/opensearch-learning-to-rank-base)。因此,Elasticsearch 和 OpenSearch LTR 插件在概念上几乎与 Solr 中的相同。Vespa 实现了分阶段排名(重排名)和在每个阶段调用模型的能力,而 Weaviate 也实现了各种重排名功能。其他支持原生 LTR 的引擎将遵循类似的模式。
图 10.1 概述了开发实用 LTR 解决方案的工作流程。

图 10.1 LTR 系统将我们的训练数据(判断列表)转换为泛化相关性排名的模型。这类系统使我们能够找到训练数据中的潜在模式。
你可能会注意到 LTR 与传统基于机器学习的分类或回归系统工作流程之间的相似之处。但正是这些例外让它变得有趣。表 10.1 将传统机器学习目标与 LTR 之间的定义进行了映射。
表 10.1 传统机器学习与 LTR
| 概念 | 传统机器学习 | LTR |
|---|---|---|
| 训练数据 | 模型应尝试预测的历史或“真实”示例集,例如,过去某天的股票价格,如“苹果”在 2021 年 6 月 6 日的价格为 125 美元。 | 判断列表:一个判断简单地标记一个文档对于查询的相关性或非相关性。在图 10.2 中,“星球大战”被标记为相关(grade为1),针对查询star wars。 |
| 特征 | 我们可以用来预测训练数据的资料,例如,苹果公司在 2021 年有 147,000 名员工,收入为 900 亿美元。 | 用于使相关结果比不相关结果排名更高的数据,理想情况下,搜索引擎可以快速计算的价值。我们的特征是来自列表 10.2 的搜索查询,如title:({keywords})。 |
| 模型 | 输入特征以进行预测的算法。鉴于苹果公司在 2021 年 7 月 6 日有 157,000 名员工,收入为 950 亿美元,该模型可能会预测该日期的股价为 135 美元。 | 将排名特征(搜索查询)组合起来,为每个潜在搜索结果分配一个相关性分数。结果按分数降序排列,希望将更相关的结果排在前面。 |
本章遵循图 10.1 中的步骤来训练一个 LTR 模型:
-
收集判断—我们从点击或其他来源中推导出判断。我们将在第十一章中深入探讨这一步骤。
-
特征记录——为了训练一个模型,我们必须将判断与特征结合起来,以查看整体模式。这一步骤需要我们要求搜索引擎存储和计算代表特征的查询。
-
转换为传统的机器学习问题——你会发现,大多数 LTR 实际上是将排名任务转换为类似于表 10.1 中“传统机器学习”列的东西。
-
训练和评估模型——在这里,我们构建我们的模型,并确认它确实是可泛化的,因此对于它尚未看到的查询将表现良好。
-
存储模型——我们将模型上传到我们的搜索基础设施,告诉搜索引擎哪些特征作为输入使用,并启用用户在他们的搜索中使用它。
-
使用模型进行搜索——我们终于可以使用模型进行搜索了!
本章的其余部分将详细介绍这些步骤,以构建我们的第一个 LTR 实现。让我们开始吧!
10.2 步骤 1:判断列表,从训练数据开始
你已经从高层次了解了 LTR 是什么,那么让我们深入了解。在实现 LTR 之前,我们首先必须了解用于训练 LTR 模型的训练数据:判断列表。
判断列表是一系列相关性标签或评分,每个标签都指示一个文档与查询的相关性。评分可以有多种形式。目前,我们将坚持简单的二元判断——0表示无关文档,而1表示相关文档。
使用本书代码提供的Judgment类,我们将通过创建一个Judgment将《社交网络》标记为social network查询的相关:
from ltr.judgments import Judgment
Judgment(grade=1, keywords="social network", doc_id=37799)
查看多个查询更有趣。在列表 10.3 中,我们将social network和star wars作为两个不同的查询,对电影进行相关或不相关的评分。
列表 10.3 标记电影判断为相关或不相关
sample_judgments = [
# for 'social network' query
Judgment(1, "social network", 37799), # The Social Network
Judgment(0, "social network", 267752), # #chicagoGirl
Judgment(0, "social network", 38408), # Life As We Know It
Judgment(0, "social network", 28303), # The Cheyenne Social Club
# for 'star wars' query
Judgment(1, "star wars", 11), # Star Wars
Judgment(1, "star wars", 1892), # Return of the Jedi
Judgment(0, "star wars", 54138), # Star Trek Into Darkness
Judgment(0, "star wars", 85783), # The Star
Judgment(0, "star wars", 325553) # Battlestar Galactica
]
你可以看到,我们将《星际迷航:暗黑无界》和《星际迷航:银河系漫游指南》标记为与查询star wars无关,但将《星球大战:绝地归来》标记为相关。
你可能想知道“这些评分是从哪里来的?”是由电影专家手工标记的吗?基于用户点击的吗?好问题!基于用户与搜索结果交互创建一个好的训练集对于 LTR 良好工作至关重要。为了大量获取训练数据,我们通常使用一种称为点击模型的算法从点击流量中提取这些标签。由于这一步骤非常基础,我们将用整个第十一章深入探讨这个主题。然而,在这一章中,我们将从手动标记的判断开始,以便我们最初可以专注于 LTR 的机制。
每个判断还有一个features向量,可以用来训练模型。features向量的第一个特征可以对应于title BM25 得分,第二个对应于overview BM25 得分,依此类推。我们还没有填充features向量,所以如果您检查sample_judgments[0].features,它目前是空的([])。
让我们使用搜索引擎来收集一些特征。
10.3 步骤 2:特征记录和工程
特征工程需要识别文档属性和相关性之间的模式。例如,我们可能会假设“我们判断中的相关结果对应于强大的标题匹配”。在这种情况下,“标题匹配”将是我们需要定义的特征。在本节中,您将了解什么是特征(如“标题匹配”),以及如何使用现代搜索引擎从语料库中构建和提取这些特征。
对于 LTR 的目的,一个特征是文档、查询或查询-文档关系的某些数值属性。特征是我们用来构建排名函数的数学构建块。您已经看到了一个带有特征的手动排名函数,如列表 10.2 中的关键字得分在title字段中就是一个这样的特征,同样还有release_year和overview关键字得分:
{"query": f"title:({keywords})¹⁰ overview:({keywords})²⁰
↪{!func}release_year⁰.01"}
当然,你最终使用的功能可能更复杂或更具有领域特定性,例如在求职中通勤的距离,或者查询与文档之间的某些知识图谱关系。当用户搜索时,任何可以相对快速计算的内容都可能是一个合理的特征。
特征记录从判断列表中计算每个标记的查询-文档对的特征。如果我们为查询social network计算列表 10.2 中每个组件的值,我们就会得到类似于表 10.2 的内容。
表 10.2 为关键词social network记录的相关(grade=1)和不相关(grade=0)文档的特征
| 等级 | 电影 | title:({keywords}) |
overview: ({keywords}) |
{!func}release_year |
|---|---|---|---|---|
| 1 | Social Network | 8.243603 | 3.8143613 | 2010.0 |
| 0 | #chicagoGirl | 0.0 | 6.0172443 | 2013.0 |
| 0 | Life As We Know It | 0.0 | 4.353118 | 2010.0 |
| 0 | The Cheyenne Social Club | 3.4286604 | 3.1086721 | 1970.0 |
机器学习算法可能会检查表 10.2 中的特征值,并收敛到一个好的排名函数。仅从表 10.2 中的数据来看,这样的算法可能会产生一个对title特征赋予更高权重而对其他特征赋予更低权重的排名函数。
然而,在我们到达算法之前,我们需要检查生产搜索系统中的特征记录工作流程。
10.3.1 在现代搜索引擎中存储特征
支持 LTR 的现代搜索引擎帮助我们存储、管理和提取特征。像 Solr、Elasticsearch 和 OpenSearch 这样的引擎在特征存储中跟踪特征——一个命名特征的列表。我们以与搜索引擎执行模型一致的方式记录用于训练的特征至关重要。
如列表 10.4 所示,我们生成并上传特征到搜索引擎。我们在本书的代码库中使用通用的特征存储抽象,允许我们生成各种基于搜索的特征,并将它们作为特征集上传到支持搜索引擎的特征存储。在这里,我们创建了三个特征:标题字段的相关度分数title_bm25、概述字段的相关度分数overview_bm25以及release_year字段的值。这里的 BM25 对应于第三章中定义的基于 BM25 的评分,这将成为我们在文本字段中评分词匹配的默认方法。
列表 10.4 创建 LTR 的三个特征
feature_set = [
ltr.generate_query_feature(feature_name="title_bm25",
field_name="title"),
ltr.generate_query_feature(feature_name="overview_bm25",
field_name="overview"),
ltr.generate_field_value_feature(feature_name="release_year",
field_name="release_year")]
ltr.upload_features(features=feature_set, model_name="movie_model")
display(feature_set)
特定于引擎的特征集定义(对于engine=solr):
[{"name": "title_bm25", #1
"store": "movies", #2
"class": "org.apache.solr.ltr.feature.SolrFeature",
"params": {"q": "title:(${keywords})"}}, #3
{"name": "overview_bm25", #4
"store": "movies",
"class": "org.apache.solr.ltr.feature.SolrFeature",
"params": {"q": "overview:(${keywords})"}},
{"name": "release_year", #5
"store": "movies",
"class": "org.apache.solr.ltr.feature.SolrFeature",
"params": {"q": "{!func}release_year"}}] #6
1 特征的名称
2 将特征保存到特征存储
3 一个参数化特征,接受关键词(例如,星球大战)并搜索标题字段
4 另一个针对概述字段进行搜索的特征
5 仅文档特征,电影的发布年份
6 params 是与 Solr 查询相同的参数,允许您使用 Solr 广泛的查询 DSL 的全部功能来构建特征。
列表 10.4 的输出显示了上传到搜索引擎的特征集——在本例中,是一个 Solr 特征集。根据你配置的搜索引擎实现(如附录 B 所述),此输出将明显不同。前两个特征是参数化的:它们各自接受搜索关键词(例如,“社交网络”、“星球大战”)并在相应的字段上执行搜索。最后一个是一个利用电影发布年份的字段值特征,这将使较新的电影排名更高。
10.3.2 从我们的搜索引擎语料库记录特征
在将特征加载到搜索引擎后,我们的下一个重点是记录我们判断列表中每一行的特征。在我们完成最后一部分管道之后,我们将训练一个模型,该模型可以观察每个查询中每个相关和不相关文档之间的关系。
对于我们判断列表中的每个唯一查询,我们需要提取查询评分文档的特征。对于列表 10.3 中样本判断列表中的查询“社交网络”,我们有一个相关文档(37799)和三个不相关文档(267752、38408 和 28303)。
下面的列表显示了查询“社交网络”的特征记录示例。
列表 10.5 记录“社交网络”结果的特征值
ids = ["37799", "267752", "38408", "28303"] #1
options = {"keywords": "social network"}
ltr.get_logged_features("movie_model", ids, #2
options=options, ) #2
fields=["id", "title"]) #2
display(response)
1 “社交网络”查询的相关和不相关文档
2 查询包含在电影特征存储中的特征值
特定于引擎的搜索请求(对于engine=solr):
{"query": "{!terms f=id}37799,267752,38408,28303",
"fields": ["id", "title",
'[features store=movies efi.keywords="social network"]']} #1
1 示例 Solr 查询语法,用于从每个返回的文档中检索特征值
带有日志特征的文档:
[{"id": "37799",
"title": "The Social Network",
"[features]": {"title_bm25": 8.243603, #1
"overview_bm25": 3.8143613, #1
"release_year": 2010.0}}, #1
{"id": "267752",
"title": "#chicagoGirl",
"[features]": {"title_bm25": 0.0,
"overview_bm25": 6.0172443,
"release_year": 2013.0}},
{"id": "38408",
"title": "Life As We Know It",
"[features]": {"title_bm25": 0.0,
"overview_bm25": 4.353118,
"release_year": 2010.0}},
{"id": "28303",
"title": "The Cheyenne Social Club",
"[features]": {"title_bm25": 3.4286604,
"overview_bm25": 3.1086721,
"release_year": 1970.0}}]
1 为“社交网络”查询记录的此电影的特征值
注意到列表 10.5 中的搜索请求(在本例中为 Solr)包含一个包含方括号的返回字段。这种语法告诉 Solr 在每份文档上返回一个额外的字段,包含特征存储库中定义的特征数据(在本例中为movies特征存储库)。efi参数代表外部特征信息,它在这里用于传递关键字查询(social network)以及计算每个特征所需的任何附加查询时间信息。响应包含四个请求的文档及其相应的特征。这些参数对于每个搜索引擎都不同,但概念将是相似的。
通过一些平凡的 Python 数据转换,我们可以从响应中填充训练集中查询social network的特征。在列表 10.6 中,我们将特征数据应用于查询social network的判断:
列表 10.6 对于查询social network的带有日志特征的判断
[Judgment(grade=1, keywords="social network", doc_id=37799, qid=1,
features=[8.243603, 3.8143613, 2010.0], weight=1), #1
Judgment(0, "social network", 267752, 1, [0.0, 6.0172443, 2013.0], 1),
Judgment(0, "social network", 38408, 1, [0.0, 4.353118, 2010.0],1), #2
Judgment(0, "social network", 28303, 1, [3.4286604, 3.1086721, 1970.0], 1)]
1 与“社交网络”查询相关的电影《社交网络》的判断,包括日志特征值
2 对于“社交网络”查询的不相关文档(注意第一个特征值的低值,标题 BM25 得分为 0.0)
在列表 10.6 中,正如我们可能预期的,第一个特征值对应于我们特征存储库中的第一个特征(title_bm25),第二个值对应于我们特征存储库中的第二个特征(overview_bm25),依此类推。让我们重复对查询star wars的判断进行特征日志记录的过程。
列表 10.7 对于查询star wars的日志判断
[Judgment(1, "star wars", 11, 2, [6.7963624, 0.0, 1977.0], 1),
Judgment(1, "star wars", 1892, 2, [0.0, 1.9681965, 1983.0], 1),
Judgment(0, "star wars", 54138, 2, [2.444128, 0.0, 2013.0], 1),
Judgment(0, "star wars", 85783, 2, [3.1871135, 0.0, 1952.0], 1),
Judgment(0, "star wars", 325553, 2, [0.0, 0.0, 2003.0], 1)]
通过生成日志判断的能力,让我们将判断列表扩展到大约一百个电影查询,每个查询大约有 40 部电影被标记为相关/不相关。加载和记录这个更大训练集特征的代码基本上重复了列表 10.5 中显示的搜索引擎请求。特征记录的最终结果看起来就像列表 10.7,但来自一个更大的判断列表。
接下来,我们将考虑如何将排名问题作为一个机器学习问题来处理。
10.4 步骤 3:将 LTR 转换为传统机器学习问题
在本节中,我们将探讨将排名作为一个机器学习问题。这将帮助我们理解如何将众所周知的传统机器学习概念应用于我们的 LTR 任务。
LTR 的任务是在许多相关和不相关的训练示例中查找查询,然后构建一个模型,将更多相关文档置于顶部(反之,将不太相关的文档推到底部)。每个训练示例本身并没有多少价值;重要的是它在查询中与同侪的排序。图 10.2 展示了这个任务,有两个查询。目标是找到一个评分函数,可以使用特征来正确排序结果。

图 10.2 LTR 是关于将每个查询的结果集放置在理想顺序中,而不是关于预测单个相关性等级。这意味着我们需要将每个查询视为一个独立的案例。
将 LTR 与一个更传统的点 wise 机器学习任务进行对比:例如,预测公司在表 10.2 中提到的股价。Pointwise 机器学习意味着我们可以独立评估模型在每个示例上的准确性,预测其绝对值而不是与其他示例的相对值。仅通过观察一家公司,我们就能知道我们预测该公司股价的准确性。将图 10.3 显示的点 wise 任务与图 10.2 进行比较。注意在图 10.3 中,学习到的函数试图直接预测股价,而 LTR 中,函数的输出仅对查询中相对于其同侪的排序有意义。

图 10.3 Pointwise 机器学习试图优化单个点(如股价或温度)的预测。搜索相关性是一个与点 wise 预测不同的问题。相反,我们需要优化由搜索查询分组的示例的排序。
LTR 的目标与 pointwise 机器学习(预测结果的具体值)非常不同(对多个结果进行排序)。大多数 LTR 方法使用巧妙的炼金术将这个“成对排序”任务转化为每个文档的分类任务,该任务学习预测哪些特征和特征权重最能区分“相关”文档和“不相关”文档。这种转换是构建一个可推广的 LTR 模型的关键,该模型可以针对特定文档操作,而不仅仅是文档对。我们将在下一节通过探索一个名为 SVMrank 的流行 LTR 模型来查看一个模型转换排序任务的方法。
10.4.1 SVMrank:将排序转换为二元分类
LTR 的核心是模型:学习相关性/非相关性与title_bm25、overview_bm25等特征之间关系的实际算法。在本节中,我们将探讨这样一个模型,SVMrank,首先了解“SVM”代表什么,然后了解它如何被用来构建一个优秀且可推广的 LTR 模型。
SVMrank 将相关性转化为二元分类问题。二元分类简单地说就是使用可用特征将项目分类为两个类别之一(如“相关”与“不相关”,“成人”与“儿童”,“狗”与“猫”)。
支持向量机或支持向量机是执行二元分类的一种方法。我们不会深入探讨支持向量机,因为您不需要成为机器学习专家就能理解讨论。不过,如果您想对支持向量机有一个更深入的了解,您可以查阅 Luis Serrano 所著的《Grokking Machine Learning》(Manning, 2021)一书。
直观地说,支持向量机(SVM)寻找最佳、最通用的超平面来在两类之间绘制。一个超平面是一个将向量空间分成两部分的边界。一个一维点可以是一个将二维线分成两部分的超平面,就像一条线可以是一个将三维空间分成两部分的超平面。一个平面通常是一个将四维空间分开的三维边界。所有这些,以及甚至超过三维的边界,通常统称为超平面。
例如,如果我们试图构建一个预测动物是狗还是猫的模型,我们可能会查看已知狗或猫的高度和重量的二维图,并绘制一条线来分隔这两类,如图 10.4 所示。

图 10.4 SVM 示例:动物是狗还是猫?这个超平面(这里的线)根据两个特征:高度和重量,将这两个案例分开。很快你就会看到我们如何可能做到类似的事情,以分隔查询的相关和不相关搜索结果。
在类别之间绘制的良好分离超平面试图最小化它在分类训练数据时犯的错误(猫一边的狗少,反之亦然)。我们还希望一个可泛化的超平面,这意味着它可能会很好地对训练期间未看到的动物进行分类。毕竟,如果一个模型不能对新数据进行预测,那它有什么用呢?它不会很有 AI 能力!
关于支持向量机(SVMs)的另一个细节是,它们可能对我们的特征范围敏感。例如,想象一下,如果高度特征是毫米而不是厘米,就像图 10.5 中那样,它迫使数据在x轴上拉伸,分离超平面看起来相当不同!

图 10.5 受一个特征范围影响的分离超平面。这导致支持向量机(SVMs)对特征的范围敏感,因此我们需要对特征进行归一化,以便一个特征不会对模型产生不适当的影响。
SVM 在数据归一化时表现最佳。归一化只是将特征缩放到一个可比较的范围。我们将通过将0映射到特征值的平均值来归一化我们的数据。如果平均release_year是1990,那么在1990发布的电影将归一化为0。我们还将+1和-1映射到平均值以上或以下的一个标准差。所以如果电影发布年份的标准差是 22 年,那么在 2012 年的电影将变成1.0;在 1968 年的电影将变成-1.0。我们可以使用训练数据中这些特征的均值和标准差对title_bm25和overview_bm25重复此操作。这有助于在寻找分离超平面时使特征更具有可比性。
在简要介绍背景之后,现在让我们探索 SVMrank 如何创建一个可推广的模型来区分相关和不相关的文档,即使对于它以前从未见过的查询。
10.4.2 将我们的 LTR 训练任务转换为二进制分类
在 LTR 中,我们必须将任务从排序重新定位为一个传统的机器学习任务。在本节中,我们将探索 SVMrank 如何将排序转换为适合 SVM 的二进制分类任务。
在我们开始之前,让我们检查从步骤 2 的末尾开始的完整日志训练集,针对我们最喜欢的两个查询star wars和social network。在本节中,我们将只关注两个特征(title_bm25和overview_bm25),以帮助我们通过图形探索特征之间的关系。图 10.6 显示了针对star wars和social network查询的每个评分文档的这两个特征,并标记了训练集中的几个突出电影。

图 10.6 社交网络和星球大战查询的日志特征分数
首先,归一化 LTR 特征
我们的第一步是归一化每个特征。以下列表将步骤 2 的日志输出归一化到normed_judgments。
列表 10.8 归一化日志 LTR 训练数据
means, std_devs, normed_judgments = normalize_features(logged_judgments)
print(logged_judgments[360])
print(normed_judgments[360])
输出:
#Judgment(grade, keywords, doc_id,
# qid, features, weight)
Judgment(1, "social network", 37799, #1
11, [8.244, 3.814, 2010.0], 1) #1
Judgment(1, "social network", 37799, #2
11, [4.483, 2.100, 0.835], 1) #2
1 未归一化的示例,包含原始的 title_bm25、overview_bm25 和 release_year
2 相同的判断,但归一化
你可以看到,列表 10.8 的输出首先显示了标题和概述的日志 BM25 分数(8.244, 3.814),以及发布年份(2010)。然后,这些特征被归一化,其中title_bm25的8.244对应于平均title_bm25以上的4.483个标准差,每个特征都是如此。
我们已经在图 10.7 中绘制了归一化的特征。这看起来与图 10.6 非常相似,只是每个轴的刻度不同。

图 10.7 归一化的星球大战和社交网络评分电影。图中每一点的增量都是相对于平均值的正负一个标准差。
接下来,我们将把排序转换成一个二进制分类学习问题,以区分相关和不相关的结果。
第二,计算成对差异
通过归一化数据,我们已强制特征保持一致的范围。现在我们的 SVM 不应受到具有非常大范围的特征的偏差。在本节中,我们将任务转换为二元分类问题,为我们训练模型做好准备。
SVMrank 使用成对转换将 LTR 重新表述为二元分类问题。成对简单来说就是将排序任务转化为最小化查询中顺序错误的成对。
在本节的其余部分,我们将仔细介绍 SVMrank 的成对算法,该算法在列表 10.9 中概述。SVMrank 算法对每个查询的每个判断进行比较,并与该查询的每个其他判断进行比较。它计算该查询中每个相关和不相关成对的特征差异(feature_deltas)。在添加到 feature_deltas 时,如果第一个判断比第二个判断更相关,则在 predictor_deltas 中标记为 +1。如果第一个判断不如第二个判断相关,则标记为 -1。这种成对转换算法产生了用于二元分类所需的训练数据(feature_deltas 和 predictor_deltas)。
列表 10.9 将特征转换为 SVMrank 的成对数据
for doc1_judgment in query_judgments:
for doc2_judgment in query_judgments:
j1_features = numpy.array(doc1_judgment.features)
j2_features = numpy.array(doc2_judgment.features)
if doc1_judgment.grade > doc2_judgment.grade:
predictor_deltas.append(+1) #1
feature_deltas.append(j1_features - #2
j2_features) #2
elif doc1_judgment.grade < doc2_judgment.grade:
predictor_deltas.append(-1) #3
feature_deltas.append(j1_features - #4
j2_features) #4
1 如果 doc1 比 doc2 更相关,则存储标签为 +1。
2 存储特征差值
3 如果 doc1 比 doc2 不相关,则存储标签为 -1。
4 存储特征差值
图 10.8 绘制了成对差异并突出了重要点。

图 10.8 展示了 SVMrank 对 social network 和 star wars 文档的转换后的成对差异,以及一个候选分离超平面。
你会注意到正的成对差值(+)倾向于向上右方。这意味着相关文档的 title_bm25 和 overview_bm25 与无关文档相比更高。
这需要很多消化!让我们仔细地一步一步地通过几个例子,看看这个算法是如何在图 10.9 中构建数据点的。这个算法比较每个查询的相关和不相关文档,比较查询 social network 中的两个文档(Network 和 The Social Network),如图 10.9 所示。

图 10.9 比较 social network 查询中的 Network 与 The Social Network
这些是《社交网络》的特征:
#[title_bm25, overview_bm25] #1
[4.483, 2.100] #1
1 title_bm25 的均值高于标准差 4.483,而 overview_bm25 的均值高于标准差 2.100。
这些是《网络》的特征:
#[title_bm25, overview_bm25] #1
[3.101, 1.443] #1
1 title_bm25 的均值高于标准差 3.101,而 overview_bm25 的均值高于标准差 1.443。
我们随后将《社交网络》与《网络》之间的差值插入到下面的列表中。
列表 10.10 计算并存储特征差值
predictor_deltas.append(+1)
feature_deltas.append([4.483, 2.100] - [3.101, 1.443]) #1
1 将 [1.382, 0.657] 添加到 feature_deltas
为了重申列表 10.10,我们可以说,这里有一个例子,即电影《社交网络》,对于这个查询social network比电影《网络》更相关。有趣!让我们看看它们有什么不同。当然,数学中的“差异”意味着减法,我们在这里会这样做。是的,在取差之后,我们看到The Social Network的title_bm25比Network的title_bm25高 1.382 个标准差;同样,overview_bm25高 0.657 个标准差。确实,注意图 10.8 中显示的The Social Network减去Network的+号,在变化量中点 [1.382, 0.657]。
算法还会注意到,对于查询social network,Network不如The Social Network相关,如图 10.10 所示。

图 10.10 比较查询social network下的《网络》与《社交网络》
正如列表 10.9 中所示,我们的代码捕捉了这两个文档之间的相关性差异,但这次是在相反的方向(不相关减去相关)。因此,我们看到相同的值并不奇怪,但它们是负值。
predictor_deltas.append(-1)
feature_deltas.append([3.101, 1.443] - [4.483, 2.100]) #1
1 计算结果为[–1.382, –0.657]
在图 10.11 中,我们继续对两个与查询social network相关的文档进行相关-不相关比较,并将另一个比较添加到新的训练集中。
列表 10.11 显示了在图 10.11 中比较的突出显示的文档对中,添加了正变化量(更相关的文档排在前面)和负变化量(不那么相关的文档排在前面)。
列表 10.11 添加正负变化量
# Positive example
predictor_deltas.append(+1)
feature_deltas.append([4.483, 2.100] - [2.234, -0.444]) #1
# Negative example
predictor_deltas.append(-1)
feature_deltas.append([2.234, -0.444] - [4.483, 2.100]) #2
1 计算结果为[2.249, 2.544]
2 计算结果为[–2.249, –2.544]

图 10.11 比较查询social network下的《社会大屠杀》与《社交网络》
一旦我们迭代通过匹配查询social network的每对文档之间的差异以创建点训练集,我们就可以继续记录其他查询的差异。图 10.12 显示了第二个查询的差异,这次比较的是匹配查询star wars的文档的相关性。

图 10.12 比较查询star wars下的《侠盗一号:星球大战外传》与《星!》。我们已经从social network转向了另一个查询,并开始研究另一个查询中的模式。
# Positive example
predictor_deltas.append(+1)
feature_deltas.append([2.088, 1.024] - [1.808, -0.444]) #1
# Negative example
predictor_deltas.append(-1)
feature_deltas.append([1.808, -0.444] - [2.088, 1.024]) #2
1 《侠盗一号》特色减去《星!》特色
2 《星!》特色减去《侠盗一号》特色
我们继续这个过程,计算相关和不相关文档的特征值之间的差异,直到我们计算出所有训练和测试查询的成对差异。
你可以在图 10.8 中看到,正例显示了正的title_bm25变化量,可能还有略微正的overview_bm25变化量。如果我们计算 100 个查询的全数据集的变化量,如图 10.13 所示,这会变得更加清晰。

图 10.13 完整训练集与分离相关和无关文档的超平面。我们看到一个模式!相关文档的title_bm25更高,也许overview_bm25也略有提高。
有趣的是,现在很容易直观地识别出更大的title_bm25分数匹配与文档对查询的相关性高度相关,并且拥有更高的overview_bm25分数至少在一定程度上是正相关的。
现在值得退一步思考,看看这种排名公式的形式是否适合你的领域。不同的 LTR 模型都有自己的方法,将成对比较映射到所需的分类问题。作为另一个例子,LambdaMART——一种基于提升树的流行 LTR 算法——使用成对交换并测量折算累积增益(DCG)的变化。
接下来,我们将训练一个鲁棒的模型来捕捉我们完全转换后的排名数据集中的模式。
10.5 步骤 4:训练(和测试!)模型
良好的机器学习显然需要大量的数据准备。幸运的是,你已经到达了实际训练模型的章节!有了上一节中的feature_deltas和predictor_deltas,我们现在有一个适合训练排名分类器的训练集。这个模型将使我们能够预测文档何时可能相关,即使对于它尚未见过的查询和文档。
10.5.1 将分离超平面的向量转换为评分函数
我们已经看到了 SVMrank 的分离超平面如何将无关的例子与相关的例子区分开来。这很有用,但你可能还记得,我们的任务是找到我们特征的最优权重,而不仅仅是分类文档。因此,让我们看看我们如何使用这个超平面来评分搜索结果。
结果表明,分离超平面也为我们提供了学习最优权重所需的东西。任何超平面都是由垂直于平面的向量定义的。因此,当 SVM 机器学习库执行其工作时,它给我们一个感觉,即每个特征应该有的权重,如图 10.14 所示。

图 10.14 完整训练集与候选分离超平面,显示定义超平面的正交向量。
考虑一下这个正交向量代表什么。这个向量指向相关性的方向!它说相关例子是这样的,无关的例子在相反的方向。这个向量肯定指向title_bm25对相关性有强烈的影响,同时overview_bm25有一些较小的影响。这个向量可能类似于:
[0.65, 0.40]
我们使用列表 10.9 中的成对转换算法来计算执行无关和相关信息分类所需的 delta 值。如果我们在这个数据上训练一个 SVM,如下面的列表所示,模型会给我们定义分离超平面的向量。
列表 10.12 使用 scikit-learn 训练线性 SVM
from sklearn import svm
model = svm.LinearSVC(max_iter=10000 #1
model.fit(feature_deltas, predictor_deltas) #2
display(model.coef_) #3
1 使用 sklearn 创建线性模型
2 使用 SVM 对 delta 进行拟合
3 定义分离超平面的向量
输出:
array([0.40512169, 0.29006328, 0.14451715])
列表 10.12 训练一个 SVM 来分离predictor_deltas(记住它们是+1和-1),使用相应的feature_deltas(归一化title_bm25、overview_bm25和release_year特征中的 delta)。得到的模型是与分离超平面正交的向量。正如预期的那样,它对title_bm25有很强的权重,对overview_bm25有更适度的权重,对release_year的权重较弱。
10.5.2 对模型进行测试
这个模型作为排名函数是如何工作的?假设用户输入查询wrath of khan。这个模型会如何对这个查询的文档星际迷航 II:怒火中烧进行评分?未归一化的特征向量表明这个查询有很强的标题和概述匹配。
[5.9217176, 3.401492, 1982.0] #1
1 “星际迷航 II”的原始特征
归一化后,每个特征值是每个特征平均值的多少个标准差以上或以下:
[3.099, 1.825, -0.568] #1
1 “星际迷航 II”的归一化特征
我们只是将每个归一化特征与其对应的coef_值相乘。将它们相加后,我们得到一个相关性分数:
(3.099 * 0.405) + (1.825 * 0.290) + (-0.568 * 0.1445) = 1.702 #1
1 “星际迷航 II”的相关性分数计算
这个模型会如何将“星际迷航 III:寻宝”相对于“星际迷航 II:怒火中烧”在我们的查询wrath of khan中的排名?希望不会太高!实际上,它并没有:
[0.0, 0.0, 1984.0] #1
[-0.432, -0.444, -0.468] #2
(-0.432 * 0.405) + (-0.444 * 0.290) + (-0.468 * 0.1445) = -0.371 #3
1 “星际迷航 III”的原始特征
2 “星际迷航 III”的归一化特征
3 计算“星际迷航 III”的相关性
该模型似乎正确地预测了最相关的答案。
10.5.3 验证模型
测试几个查询有助于我们发现问题,但我们更希望有一个更系统的方法来检查模型是否具有可推广性。
LTR 与传统机器学习之间的一个区别是,我们通常评估查询和整个结果集,而不是单个数据点,以证明我们的模型是有效的。我们将在查询级别进行测试/训练分割。这将使我们能够发现存在问题的查询类型。我们将使用简单的精确度指标进行评估,计算前K(在我们的案例中k=5)个结果中有多少是相关的。你应该选择最适合你自己的用例的相关性指标。
首先,我们将我们的查询随机放入测试集或训练集,如下所示。
列表 10.13 在查询级别进行简单的测试/训练分割
all_qids = list(set([j.qid for j in normed_judgments]))
random.shuffle(all_qids) #1
proportion_train = 0.1 #1
#1
split_idx = int(len(all_qids) * proportion_train) #1
test_qids = all_qids[:split_index] #2
train_qids = all_qids[split_index:] #2
train_data = []; test_data=[]
for j in normed_judgments:
if j.qid in train_qids: #2
train_data.append(j) #2
elif j.qid in test_qids: #2
test_data.append(j) #2
1 从中随机选择 10%的判断进入训练集
2 将每个判断放入训练数据(10%)或测试集(90%)
将训练数据分割出来后,我们可以执行步骤 3 中的成对转换技巧。然后我们可以在仅使用训练数据的情况下重新训练。
列表 10.14 仅使用训练数据进行训练
train_data_features, train_data_predictors = pairwise_transform(train_data)
from sklearn import svm
model = svm.LinearSVC(max_iter=10000, verbose=1)
model.fit(train_data_features, train_data_predictors) #1
display(model.coef_[0])
1 仅适用于训练数据
输出:
array([0.37486809, 0.28187458, 0.12097921])
到目前为止,我们保留了测试数据。就像一个好的老师一样,我们不想给学生所有答案。我们想看看模型是否学到了除了死记硬背训练示例之外的东西。
在下一个列表中,我们使用测试数据评估我们的模型。此代码遍历每个测试查询,并使用模型对每个测试判断进行排序。然后,它计算前四个判断的精确度。
列表 10.15 我们的模型能否泛化到训练数据之外?
def score_one(features, model):
score = 0.0
for idx, f in enumerate(features):
this_coef = model.coef_[0][idx].item()
score += f * this_coef
return score
def rank(query_judgments, model):
for j in query_judgments:
j.score = score_one(j.features, model)
return sorted(query_judgments, key=lambda j: j.score, reverse=True)
def evaluate_model(test_data, model, k=5):
total_precision = 0
unique_queries = groupby(test_data, lambda j: j.qid)
num_groups = 0
for qid, query_judgments in unique_queries: #1
num_groups += 1
ranked = rank(list(query_judgments), model) #2
total_relevant = len([j for j in ranked[:k]
if j.grade == 1]) #3
total_precision += total_relevant / float(k)
return total_precision / num_groups
evaluation = evaluate_model(test_data, model)
print(evaluation)
1 对于每个测试查询
2 使用模型对每个判断进行评分并排序此查询
3 计算此查询的精确度
评估:
0.36
在多次运行中,你应该期望精确度大约为 0.3–0.4。对于我们第一次迭代,只是猜测了几个特征(title_bm25、overview_bm25 和 release_year),这还不错!
在 LTR 中,你总是可以回顾之前的步骤,看看可能有哪些改进。这个精确度测试是我们第一次能够系统地评估我们的模型,因此这是一个自然的时间来回顾特征,看看在后续运行中精确度可能如何提高。一直回溯到步骤 2。看看哪些例子位于分离超平面的错误一侧。例如,如果你回顾图 10.8,第三部星球大战电影,《星球大战:绝地归来》,它符合一个相关文档的模式,标题中没有关键词匹配。在没有标题的情况下,可以添加哪些其他特征来帮助捕捉这部电影属于像星球大战这样的特定收藏?也许 TMDB 数据集中有一个我们可以实验的属性。
然而,现在让我们看看我们刚刚构建的模型,看看我们如何将其部署到生产环境中。
10.6 步骤 5 和 6:上传模型和搜索
在本节中,我们最终将上传我们的模型,以便它可以应用于对未来的搜索结果进行排名。然后,我们将讨论将模型应用于对所有文档进行排名,以及将其应用于重新排名一个已经运行且可能更有效的初始查询。最后,我们将讨论在生产中使用 LTR 模型的性能影响。
10.6.1 部署和使用 LTR 模型
最初,我们提出的目标是找到对硬编码的排名函数(如列表 10.2 中的函数)的 理想 提升值:
{"query": f"title:({keywords})¹⁰ overview:({keywords})²⁰
↪{!func}release_year⁰.01"}
这个增强查询确实将每个特征乘以一个权重(增强)并将结果相加。但结果证明,我们不想让搜索引擎乘以 原始 特征值。相反,我们需要特征值进行归一化。
许多搜索引擎允许我们存储线性排名模型以及特征归一化统计信息。我们保存了每个特征的 means 和 std_devs,这些将被用于对任何正在评估的文档进行归一化。这些系数在上传模型时与每个特征相关联,如下一个列表所示。
列表 10.16 生成和上传线性模型
model_name = "movie_model"
feature_names = ["title_bm25", "overview_bm25", "release_year"]
linear_model = ltr.generate_model(model_name, feature_names,
means, std_devs, model.coef_[0])
response = ltr.upload_model(linear_model)
display(linear_model)
生成的线性模型(对于 engine=solr):
{"store": "movies", #1
"class": "org.apache.solr.ltr.model.LinearModel",
"name": "movie_model",
"features": [
{"name": "title_bm25", #2
"norm": {"class": "org.apache.solr.ltr.norm.StandardNormalizer",
"params": {"avg": "0.7245440735518126", #3
"std": "1.6772600303613545"}}}, #3
{"name": "overview_bm25",
"norm": {"class": "org.apache.solr.ltr.norm.StandardNormalizer",
"params": {"avg": "0.6662927508611409",
"std": "1.4990448120673643"}}},
{"name": "release_year",
"norm": {"class": "org.apache.solr.ltr.norm.StandardNormalizer",
"params": {"avg": "1993.3349740932642",
"std": "19.964916628520722"}}}],
"params": {
"weights": {
"title_bm25": 0.3748679655554891, #4
"overview_bm25": 0.28187459845467566, #4
"release_year": 0.12097924576841014}}} #4
1 特征存储以定位特征
2 在评估此模型之前要执行哪个特征
3 在应用权重之前如何归一化此特征
4 模型中每个特征的权重
列表 10.16 中的response是 Solr 特定的,并且会根据您配置的搜索引擎而变化。接下来,我们可以使用上传的 LTR 模型发出搜索,如下面的列表所示。
列表 10.17 使用 LTR 模型对harry potter进行所有文档排序
request = {"query_fields": ["title", "overview"],
"return_fields": ["title", "id", "score"],
"rerank_query": "harry potter",
"log": True}
response = ltr.search_with_model("movie_model", **request)
display(response["docs"])
特定于引擎的搜索请求(对于engine=solr):
{"fields": ["title", "id", "score"],
"limit": 5,
"query": "{!ltr reRankDocs=9999999 #1
↪model=movie_model efi.keywords=\"harry potter\"}"}
1 使用指定参数在最大数量的文档上执行我们的模型
返回的文档:
[{"id": "570724", "title": "The Story of Harry Potter", "score": 2.4261155},
{"id": "116972", "title": "Discovering the Real World of Harry Potter",
"score": 2.247846},
{"id": "672", "title": "Harry Potter and the Chamber of Secrets",
"score": 2.017499},
{"id": "671", "title": "Harry Potter and the Philosopher's Stone",
"score": 1.9944705},
{"id": "54507", "title": "A Very Potter Musical",
"score": 1.9833609}]
在列表 10.17 中,LTR 模型使用rerank_query参数中的关键词作为模型的输入,对所有语料库中的文档进行排序。由于请求中没有指定初始query参数,因此在搜索结果(所有文档)被 LTR 模型排序之前,不对集合应用匹配过滤器。尽管使用该模型对如此大量的文档进行评分会导致非平凡的延迟,但它允许我们在没有其他匹配参数的情况下直接测试模型。
注意在列表 10.17 中rerank_query参数中使用了“重新排序”这个术语。正如这个术语所暗示的,LTR 通常作为第二个排序阶段,在由更有效的算法(如 BM25 和/或初始布尔匹配)首先计算出的结果上发生。这是为了减少必须由更昂贵的 LTR 模型评分的文档数量。以下列表演示了执行基线搜索然后使用 LTR 模型重新排序前500个结果。
列表 10.18 使用模型对harry potter进行搜索和重新排序
request = {"query": "harry potter",
"query_fields": ["title", "overview"],
"return_fields": ["title", "id", "score"],
"rerank_query": "harry potter",
"rerank_count": 500,
"log": True}
response = ltr.search_with_model("movie_model", **request)
display(response["docs"])
特定于引擎的搜索请求(对于engine=solr):
{"query": "harry potter", #1
"fields": ["title", "id", "score"],
"limit": 5,
"params": {
"rq": "{!ltr reRankDocs=500 model=movie_model #2
↪efi.keywords=\"harry potter\"}", #2
"qf": ["title", "overview"], #1
"defType": "edismax"}} #1
1 首次通过 Solr 查询——一个简单的带有 BM25 排序的关键词查询
2 仅重新排序前 500 个文档
返回的文档:
[{"id": "570724", "title": "The Story of Harry Potter", "score": 2.4261155},
{"id": "116972", "title": "Discovering the Real World of Harry Potter",
"score": 2.247846},
{"id": "672", "title": "Harry Potter and the Chamber of Secrets",
"score": 2.017499},
{"id": "671", "title": "Harry Potter and the Philosopher's Stone",
"score": 1.9944705},
{"id": "54507", "title": "A Very Potter Musical", "score": 1.9833605}]
这个请求速度更快,并且在执行更便宜的初始 BM25 排序(在过滤的query之后)以及仅对前500个结果进行昂贵的基于 LTR 的重新排序时,仍然会产生相同的前几名结果。
10.6.2 关于 LTR 性能的说明
如您所见,构建一个真实的 LTR 模型需要许多步骤。让我们以对 LTR 系统中实际性能约束的一些额外思考来结束本章。
-
模型复杂性——模型越复杂,可能越准确。一个简单的模型可能更快、更容易理解,尽管可能不太准确。在这里,我们坚持使用一个非常简单的模型(一组线性权重)。想象一下复杂的深度学习模型——它会工作得怎么样?复杂性是否值得?它是否具有足够的泛化能力(或者它可能具有更强的泛化能力)?
-
重新排序深度——重新排序越深,可能找到的额外文档就越多,这些文档可能是隐藏的宝藏。另一方面,重新排序越深,模型在您的实时搜索引擎集群中评分结果所花费的计算周期就越多。
-
特征复杂性——如果你在查询时计算非常复杂的特征,它们可能会帮助你的模型。然而,它们会减慢评估和搜索响应时间。
-
特征数量——具有许多特征的模型可能会导致更高的相关性。然而,它将需要更多的时间来计算每个文档上的每个特征,所以问问自己哪些特征是至关重要的。许多学术 LTR 系统使用数百个。实用的 LTR 系统通常将这些特征减少到几十个。你几乎总是会看到相关性排名的递减回报,以及随着你继续添加更多特征而计算和延迟成本的上升,因此优先考虑要包含哪些特征是很重要的。
跨度编码器
跨度编码器是一种专门的机器学习排名模型。跨度编码器被训练来评分两段输入(通常是文本)的相关性,例如查询和文档。它们使用 Transformer 架构将这两段输入组合成一个单一表示,然后在搜索中使用这个表示来根据查询和文档在共享语义上下文中的解释来排名文档对查询的相关性。跨度编码器是排名分类器,就像其他 LTR 模型一样,但它们独特之处在于它们在大数据集上进行了预训练,并且通常只关注查询和文档之间的文本相似性,而不是其他特征,如流行度、时效性或用户行为。虽然它们可以在你的数据集上进行微调,但它们通常直接使用,因为它们已经在大量数据上进行了训练,并且可以很好地泛化到新的文本输入。
跨度编码器非常容易使用,通常也是开始使用机器学习排名而不必进行自己训练的最简单方式。跨度编码器通常运行较慢,因此通常不用于重新排序大量文档。在本章和下一章中,我们的重点是更灵活的模型,这些模型可以使用反射智能,包括基于用户判断和用户信号隐式判断的模型,但了解跨度编码器是很有好处的,因为它们是许多搜索团队的首选,尤其是在刚开始的时候。我们将在第 13.7 节中更详细地介绍跨度编码器,包括示例代码。
10.7 清洗并重复
恭喜!你已经完成了一个完整的 LTR 循环!尽管如此,像许多数据问题一样,你可能需要继续迭代问题。你总是可以做一些新的事情来提高。
在你的第二次迭代中,你可能需要考虑以下内容:
-
新和更好的特征——模型在哪些类型的查询或示例上表现不佳,例如在没有任何
title提及的title搜索中? (“星球大战”没有在《星球大战:帝国反击战》的标题中提及。哪些特征可以捕捉这些?)我们能从第一章到第九章的章节中吸取教训来构建更高级的特征吗? -
所有特征的训练数据覆盖范围——更多特征意味着更多的训练数据。随着你想要尝试的特征增加,你应该想知道你的训练数据是否在每个不同特征组合中都有足够的相关和不相关文档的示例。否则,你的模型将不知道如何使用特征来解决问题。
-
不同的模型架构——我们使用了一个相对简单的模型,该模型期望特征线性且独立地与相关性相关联,但相关性通常是非线性且多维的。一个搜索
ipad的购物者可能期望最新的苹果 iPad 发布,除非他们添加了“cable”这个词,使查询变为ipad cable。对于这个查询,购物者可能只想找到最便宜的电缆,而不是最新的。在这种情况下,可能会有“时效性”和“价格”特征根据特定的关键词组合激活,需要更复杂的模型架构。
在下一章中,我们将重点关注良好 LTR 的基础:出色的判断!
摘要
-
学习排序(LTR)构建了可以应用于所有搜索的泛化排序函数,使用稳健的机器学习技术。
-
LTR 特征通常对应于搜索查询。支持 LTR 的搜索引擎通常允许你在训练和后来应用排序模型时存储和记录特征。
-
我们在用于泛化相关性的特征选择上拥有巨大的自由度。特征可以是查询的属性(如术语数量),文档的属性(如流行度),或者查询和文档之间的关系(如 BM25 或其他相关性分数)。
-
要做好 LTR 并应用已知的机器学习技术,我们通常将相关性排序问题重新表述为传统的、点对点的机器学习问题。
-
SVMrank 在归一化特征值上创建简单的线性权重,这是你在 LTR 旅程中的良好开端。
-
要真正有用,我们需要我们的模型泛化到它所学习的内容之外。我们可以通过在测试数据集中留出一些判断并不在训练中使用它们来确认 LTR 模型泛化的能力。训练后,我们可以在之前未见过的测试数据集上评估模型,以确认模型的泛化能力。
-
一旦 LTR 模型加载到你的搜索引擎中,务必考虑性能(如速度)与相关性的权衡。现实世界的搜索系统都需要这两者。
第十一章:使用点击模型自动化学习排序
本章涵盖
-
自动化从用户行为信号(搜索、点击等)中重新训练学习排序
-
使用点击模型将用户信号转化为隐式 LTR 训练数据
-
克服用户倾向于点击搜索结果中排名更高的项目,而不考虑相关性的倾向
-
在推导隐式判断时处理点击次数较少的低置信度文档
在第十章中,我们一步步地讲解了如何训练一个学习排序(LTR)模型。就像一步步地了解汽车构造的机械原理一样,我们看到了 LTR 模型训练的底层结构和细节。在这一章中,我们将把 LTR 训练过程视为一个黑盒。换句话说,我们将不再深入 LTR 的内部,而是将 LTR 更像看作一辆自动驾驶汽车,微调其行程以到达最终目的地。
记住,LTR 的有效性依赖于准确无误的训练数据。LTR 训练数据描述了用户期望搜索结果如何被最优排序;它为我们输入 LTR 自动驾驶汽车的方向提供了指导。正如您将看到的,根据用户互动来确定相关性带来了许多挑战。如果我们能够克服这些挑战,并对我们的训练数据有很高的信心,那么我们可以构建自动化的学习排序:一个定期重新训练 LTR 以捕捉最新的用户相关性期望的系统。
由于训练数据在自动化 LTR 中如此关键,挑战不再仅仅是“我们应该使用什么模型/特征/搜索引擎?”而是更根本的,“用户从搜索中想要什么?”“我们如何将这转化为训练数据?”“我们如何知道这些训练数据是否有效?”通过提高我们对这些问题的答案的信心,我们可以将 LTR(再)训练自动化,如图 11.1 所示。

图 11.1 一个自动化的 LTR 系统从用户信号中自动学习和再训练。这有助于基于用户在多个查询中认为相关的实际内容来构建模型。
让我们简要地回顾自动化 LTR 过程中的每一步:
-
输入新目的地——我们将描述理想相关性的训练数据输入 LTR 系统,这些数据基于我们对用户行为信号的理解,例如搜索、点击和转化(本章涵盖)。
-
驶向目的地——我们的 LTR 系统使用提供的训练数据重新训练 LTR 模型(如第十章所述)。
-
我们到了吗?——模型真的在帮助用户吗?未来的模型或许应该探索其他路线(第十二章涵盖)?
自动化 LTR 会持续重复步骤 1-3 以自动优化相关性。搜索团队会监控自动化 LTR 的性能并在必要时进行干预。这是图 11.1 中的维护部分。在维护期间,我们会打开引擎盖来探索新的 LTR 功能和其它模型调整。维护也可能意味着重新审视步骤 1,以纠正我们对用户行为的理解并构建更可靠、更健壮的训练数据。毕竟,如果没有好的训练数据,我们可能完全遵循第十章的内容,但仍可能无法满足用户的需求。
本章通过关注步骤 1——输入新的目的地,开始对自动化 LTR 的探索。我们首先定义从用户点击中推导训练数据的任务。然后,我们将在本章的其余部分克服一些常见的搜索点击数据偏差和挑战。到本章结束时,你将能够构建从用户信号中提取的更可靠训练数据的模型。第十二章将通过观察模型与真实用户互动,使用主动学习和高斯技术克服更复杂的展示偏差,并将所有这些组件整合到一个最终的端到端自动化 LTR 系统中来完成我们的自动化 LTR 探索。
11.1(重新)从信号创建判断列表
我们提到,在从点击中创建 LTR 训练数据时需要克服偏差。然而,在我们深入探讨这些偏差之前,我们将探讨使用点击而不是手动标签作为 LTR 训练数据的含义。然后,在本节中,我们将尝试制作训练数据,回顾哪些做得好,哪些不好。这将为我们设置本章的其余部分,我们将探讨如何从这些结果中去除偏差(在第 11.2 节及以后)。
11.1.1 从信号生成隐式、概率性判断
让我们为如何使用行为信号作为 LTR 训练数据打下基础。然后我们将深入了解构建可靠判断列表的细节。
在第十章中,我们讨论了 LTR 训练数据,被称为判断列表或判断。这些判断包含了对给定查询潜在搜索结果相关性的标签或评分。在第十章中,我们以电影为例,用1(相关)或0(不相关)的评分来标记它们,如下面的例子所示。
列表 11.1 标记电影相关或不相关
# Judgment(grade, keywords, doc_id)
sample_judgments = [
# for 'social network' query
Judgment(1, "social network", 37799), # The Social Network
Judgment(0, "social network", 267752), # #chicagoGirl
Judgment(0, "social network", 38408), # Life As We Know It
Judgment(0, "social network", 28303), # The Cheyenne Social Club
# for 'star wars' query
Judgment(1, "star wars", 11), # Star Wars
Judgment(1, "star wars", 1892), # Return of the Jedi
Judgment(0, "star wars", 54138), # Star Trek Into Darkness
Judgment(0, "star wars", 85783), # The Star
Judgment(0, "star wars", 325553) # Battlestar Galactica
]
生成判断列表有许多技术,但这并不是关于判断列表及其众多应用的全面章节。相反,我们将专门关注 LTR 训练数据。因此,我们只会讨论从用户点击信号中生成的判断。我们称之为隐式判断,因为它们源自用户与搜索应用之间的交互,当用户搜索和点击时。这与显式判断形成对比,显式判断中评分者直接将搜索结果标记为相关或不相关。
隐式判断非常适合自动化 LTR(学习到排名),原因有以下几点:
-
近期性——我们有用户流量的直接访问权限,因此我们可以自动化训练今天的 LTR 模型,以满足最新的用户搜索期望。
-
数据更多,成本更低——设置一个任务来捕获显式判断,即使使用众包,也是耗时且成本高昂的,尤其是在大规模上。从我们正在收集的实时用户交互中推导出隐式判断,使我们能够利用现有的用户基础为我们完成这项工作。
-
捕获真实用例——隐式判断捕获了实际使用你的搜索应用的用户。与此相对比的是,在人工环境中,显式评分者会仔细思考,可能是不切实际地思考,关于选择最相关结果的抽象任务。
不幸的是,点击数据可能很嘈杂。我们不知道为什么用户点击了特定的搜索结果。此外,用户并不同质;有些人会将一个结果视为相关,而其他人则不然。搜索交互中也包含需要克服的偏见,这会在模型计算周围产生额外的不确定性,我们将在本章和下一章的后面详细讨论。
由于这些原因,点击模型不是进行二进制判断,而是创建概率判断。它不会只产生1(相关)或0(不相关)的等级,而是表示一个随机用户认为结果相关或不相关的概率(介于0.0和1.0之间)。例如,一个好的点击模型可能会将列表 11.1 中的判断重述为以下类似的内容。
列表 11.2 概率性地标记电影查询的相关性
# Judgment(grade, keywords, doc_id),
sample_judgments = [
Judgment(0.99, "social network", 37799), # The Social Network
Judgment(0.01, "social network", 267752), # #chicagoGirl
Judgment(0.01, "social network", 38408), # Life As We Know It
Judgment(0.01, "social network", 28303), # The Cheyenne Social Club
Judgment(0.99, "star wars", 11), # Star Wars
Judgment(0.80, "star wars", 1892), # Return of the Jedi
Judgment(0.20, "star wars", 54138), # Star Trek Into Darkness
Judgment(0.01, "star wars", 85783), # The Star
Judgment(0.20, "star wars", 325553) # Battlestar Galactica
]
注意列表 10.2 中的《星球大战》电影——grade变得更有趣了。《星球大战》现在有非常高的相关性概率(0.99)。续集《星球大战:绝地归来》的相关性概率略低。其他科幻电影(如《星际迷航:暗黑无界》和《银河系漫游指南》)的评分略高于0,因为《星球大战》系列的粉丝也可能喜欢这些电影。《星》与这些完全不相关——它是一部关于第一个圣诞节的儿童动画电影——因此它只获得了低0.01的相关性概率。
11.1.2 使用概率判断训练 LTR 模型
我们刚刚介绍了相关性等级可以是概率性的这一想法。现在让我们考虑如何将第十章的教训应用于训练一个模型,使用这些概率判断(介于0.0和1.0之间)而不是二进制判断。
通常,在训练模型时,你可能需要考虑以下选项:
-
量化评分——简单来说,您可以在训练之前设置任意截止值,将评分转换为可接受的格式。您可以将大于
0.75的评分视为相关(或1.00)。任何小于0.75的评分将被视为不相关(或0.00)。其他算法,如 LambdaMART,接受一系列评分,如1到4,并且这些算法也可以有离散的截止值,例如将小于0.25的评分赋予1.00,大于或等于0.25但小于0.5的评分赋予2.00,依此类推。使用这些算法,您可以创建 100 个这样的标签,将0.00赋予0,0.01赋予1,等等,直到将1赋予在训练前的100。 -
仅使用浮点判断——第十章中的 SVMRank 算法从不太相关的项目的特征中减去更相关项目的特征(反之亦然),并构建一个分类器来区分相关和不相关的项目。我们使用二元判断来做这件事,但没有任何东西阻止我们使用概率判断。在这里,如果认为《星球大战:绝地归来》(评分=0.80)比《星际迷航:暗黑无界》(评分=0.20)更相关,我们只需将《星球大战:绝地归来》标记为比《星际迷航:暗黑无界》更相关(将差异标记为
+1)。然后我们执行与第十章相同的成对减法,从《星际迷航:暗黑无界》中减去《星球大战:绝地归来》的特征,以创建一个完整的训练示例。
使用本章中的判断重新训练模型将主要重复第十章中的代码,所以我们将专注于训练点击模型的机制。我们包含了一个笔记本,其中包含一个完整的端到端 LTR 训练示例(见第 11.4 节),它将我们将在本章末尾得到的点击模型整合到您在第十章中已经探索过的 LTR 训练过程中。
是时候回到代码,看看我们的第一个点击模型了!
11.1.3 点击率:您的第一个点击模型
现在您已经看到了点击模型生成的判断格式以及这种格式如何集成到训练 LTR 模型中,让我们尝试构建一个简单的点击模型。之后,我们将退后一步,专注于一个更复杂、通用的点击模型,然后我们将探讨一些在处理查询和点击信号中固有的核心偏差。
TIP 如果您想更深入地了解这个主题,我们鼓励您阅读 Chuklin、Markov 和 Rijke 所著的《网络搜索点击模型》(Springer,2015 年)。
为了构建我们的点击模型,我们将回到 RetroTech 数据集,因为它方便地包含了用户点击信号。从这些信号中,我们还逆向工程了构建高质量判断所需的原始会话数据。我们将使用pandas库对会话数据进行表格计算。
在以下列表中,我们检查了电影《变形金刚:月黑之时》的样本搜索会话。这个原始会话信息是你的起点——从用户信号中开发判断列表所需的最基本信息。
列表 11.3 检查搜索会话
query = "transformers dark of the moon"
sessions = get_sessions(query) #1
print(sessions.loc[3]) #2
1 选择“transformers dark of the moon”查询的会话
2 检查展示给用户的单个搜索会话
输出:
sess_id query rank doc_id clicked
3 transformers dark of the moon 0.0 47875842328 False
3 transformers dark of the moon 1.0 24543701538 False
...
3 transformers dark of the moon 7.0 97360810042 True
...
3 transformers dark of the moon 13.0 47875841406 False
3 transformers dark of the moon 14.0 400192926087 False
列表 11.3 对应于一个单一的搜索会话,sess_id=3,针对查询transformers dark of the moon。这个会话包括查询、用户看到的排名结果以及每个结果是否被点击。这三个元素是构建点击模型所需的核心成分。
搜索会话将经常有所不同。另一个会话,即使几秒钟后,也可能向用户展示略有不同的排名。搜索索引可能已更改,或者可能已将新的相关性算法部署到生产中。我们鼓励你使用另一个sess_id重试列表 11.3,以比较会话。
让我们将这些数据转换为使用我们的第一个简单点击模型:点击率进行判断。
从点击率构建判断
我们将首先构建一个非常简单的点击模型,以便熟悉数据,然后我们可以退后一步,看看这个初步尝试的缺陷。这将使我们能够仔细思考本章其余部分中自动 LTR 生成的判断质量。
我们的第一个点击模型将基于点击率(CTR)。CTR 是搜索结果上的点击次数除以它在搜索结果中出现的次数。如果一个结果每次搜索引擎返回时都被点击,CTR 将是1。如果它从未被点击,CTR 将是0。听起来很简单——可能出什么问题呢?
我们可以查看查询transformers dark of the moon的每个结果,并考虑与返回doc_id的会话数量相关的点击。以下列表显示了计算和每个文档的 CTR 值。
列表 11.4 计算 CTR
def calculate_ctr(sessions):
click_counts = sessions.groupby("doc_id")["clicked"].sum()
sess_counts = sessions.groupby("doc_id")["sess_id"].nunique()
ctrs = click_counts / sess_counts
return ctrs.sort_values(ascending=False)
query = "transformers dark of the moon"
sessions = get_sessions(query, index=False)
click_through_rates = calculate_ctr(sessions)
print_series_data(click_through_rates, column="CTR")
输出:
doc_id CTR name
97360810042 0.0824 Transformers: Dark of the Moon - Blu-ray Disc
47875842328 0.0734 Transformers: Dark of the Moon Stealth Force E...
47875841420 0.0434 Transformers: Dark of the Moon Decepticons - N...
...
93624956037 0.0082 Transformers: Dark of the Moon - Original Soun...
47875841369 0.0074 Transformers: Dark of the Moon - PlayStation 3
24543750949 0.0062 X-Men: First Class - Widescreen Dubbed Subtitl...
在列表 11.4 中,对于所有查询transformers dark of the moon的sessions(根据列表 11.3),我们将每个doc_id的点击次数求和为click_counts。我们还计算了该文档的独特会话数量为sess_counts。最后,我们计算ctrs为click_counts / sess_counts,从而得到我们的第一个点击模型。我们看到文档 97360810042 的 CTR 最高,而 24543750949 的 CTR 最低。
前面的列表基于 CTR 输出了理想搜索结果。也就是说,如果我们使用这个 CTR 点击模型来训练 LTR 模型提供相关性判断,搜索引擎将产生这个排序作为最佳排名。在本章和下一章中,我们将经常通过视觉显示这个理想排名来理解点击模型是否构建了合理的训练数据(判断)。我们可以在图 11.2 中看到基于 CTR 的理想判断transformers dark of the moon。

图 11.2 按 CTR 排序的查询“transformers dark of the moon”的搜索结果
检查图 11.2 的结果,有几件事情很突出:
-
我们最高结果的 CTR(电影《变形金刚:月黑之时》的蓝光碟)似乎相当低(
0.0824,略好于下一个判断的0.0734)。我们可能预期蓝光碟的相关性等级要远高于其他结果。 -
电影《变形金刚:月黑之时》的 DVD 甚至都没有出现。它似乎与看似无关的电影和关于电影《月黑之时》的次要电子游戏远在下方。我们原本预期 DVD 的排名应该更高,可能和蓝光碟一样高,甚至更高。
但也许“transformers dark of the moon”只是一个奇怪的查询。让我们重复这个过程,这次针对图 11.3 中的dryer进行。

图 11.3 按 CTR 排序的查询dryer的搜索结果。我们注意到电影《独立者》这个奇怪的结果似乎并不相关。
在图 11.3 中,我们看到其他一些看起来很奇怪的结果:
-
前两个结果是衣物烘干机,这似乎是好的。
-
接下来是衣物烘干机的部件。嗯,好吧?
-
出现了一部名为《独立者》的电影。这似乎是完全随机的。为什么会被评价得这么高?
-
接下来是一个洗衣机配件,有点相关。
-
最后,我们看到吹风机,这显示了“dryer”一词的另一个潜在含义。
你认为 CTR 点击模型产生的判断如何?回想一下你在第十章中学到的内容。记住,这是你的 LTR 模型的基础,也是目标。你认为这些判断会导致一个最终投入生产时能够成功的良好 LTR 模型吗?
我们也鼓励你自己问一个更基本的问题:我们如何才能判断一个判断列表是否良好?我们的主观解释可能和点击模型中的数据一样有缺陷。我们将在第十二章中更深入地分析这个问题。对于本章,我们将让我们的直觉引导我们去发现可能的问题。
11.1.4 判断中的常见偏差
我们到目前为止已经看到,我们可以通过将产品点击次数除以该产品被搜索返回的次数来创建概率判断——这些判断的等级在 0.00 到 1.00 之间。然而,输出结果似乎有点不足,因为它包括了与变形金刚系列无关的电影。我们还看到一部电影被放在了搜索结果中,搜索词是dryer!
结果表明,搜索点击数据充满了偏差。在这里,我们将在探索 RetroTech 点击数据中的每个这些偏差之前,简要定义我们所说的“偏差”。
在点击模型中,偏差是原始用户点击数据与搜索结果的相关性无关的原因。相反,偏差定义了点击(或未点击)如何反映用户心理、搜索用户界面设计或噪声数据。我们可以将偏差分为两大类:非算法偏差和算法偏差。算法偏差是排名、显示和与搜索结果交互中固有的。非算法偏差是由于与搜索排名间接相关的原因发生的。
算法偏差可能包括以下内容:
-
位置偏差—用户点击排名较高的结果比排名较低的结果多。
-
自信偏差—信号数据很少的文档与数据量更多的文档对判断的影响相同。
-
展示偏差—如果搜索从未显示特定的结果,用户永远不会点击它们,因此点击模型将不知道它们是否相关。
另一方面,非算法偏差是以下这样的偏差:
-
吸引力偏差—一些结果看起来很有吸引力并产生点击(可能由于更好的图像或措辞选择),但结果却是垃圾或完全不相关。
-
性能偏差—用户放弃缓慢的搜索,分心,最终什么也不点击,或者只点击最早返回的结果。
由于这本书是关于人工智能驱动的搜索,我们将重点讨论搜索点击流数据中的算法偏差。本章将涵盖位置偏差和自信偏差,展示偏差将在第十二章中介绍。
但非算法偏差也很重要!搜索是一个复杂的生态系统,它超越了相关性排名。如果结果经常被点击,但后续行动如销售或其他转化没有发生,这可能不是一个排名问题——可能你有垃圾产品的问题。或者你可能有问题与产品页面或结账流程。当你发现限制因素实际上是用户体验、内容或搜索速度时,你可能会被要求提高“相关性”。
现在我们已经反思了我们的第一个点击模型,让我们努力克服第一个偏差。
11.2 克服位置偏差
在上一节中,我们看到了我们的第一个点击模型在行动:一个简单的点击率点击模型。这个模型将产品在搜索中被点击的次数除以它在顶部结果中出现的次数。我们看到了这是一个相当有缺陷的方法,并指出有多个可能导致偏差的原因。具体来说,我们指出了位置偏差、信心偏差和展示偏差作为我们点击模型中存在的三种算法偏差。是时候开始解决这些问题了!
在本节中,我们将重点关注这些算法偏差中的第一个,即位置偏差,深入探讨这个问题,并致力于设计一个旨在克服它的点击模型。
11.2.1 定义位置偏差
位置偏差存在于大多数搜索系统中。如果用户看到了搜索结果,他们往往会更倾向于选择排名较高的搜索结果,而不是排名较低的结果,即使那些较低的结果实际上更相关。Joachims 等人在其论文“评估 Web 搜索中点击和查询重构的隐式反馈的准确性”(www.cs.cornell.edu/people/tj/publications/joachims_etal_07a.pdf)中讨论了位置偏差存在的原因:
-
信任偏差—用户相信搜索引擎必须知道自己在做什么,因此他们更倾向于与排名较高的结果互动。
-
扫描行为—用户以特定的模式检查搜索结果,例如从上到下,并且通常不会探索他们面前的一切。
-
可见性—排名较高的结果更有可能在用户的屏幕上显示,因此用户需要滚动才能看到剩余的结果。
考虑到这些因素,让我们看看我们是否可以在 RetroTech 会话中检测到位置偏差。
11.2.2 RetroTech 数据中的位置偏差
RetroTech 数据集中存在多少位置偏差?如果我们能量化这一点,我们就可以考虑如何确切地解决这个问题。在我们考虑一个新的点击模型来克服这些偏差之前,让我们快速评估这个偏差。
通过查看所有查询的所有会话,我们可以计算每个排名的平均 CTR。这将告诉我们 RetroTech 点击数据中存在多少位置偏差。我们在以下列表中这样做。
列表 11.5 所有查询的搜索会话中的点击率按排名
sessions = all_sessions()
num_sessions = len(sessions["sess_id"].unique())
ctr_by_rank = sessions.groupby("rank")["clicked"].sum() / num_sessions
print(ctr_by_rank)
输出:
rank
0 0.249727
1 0.142673
2 0.084218
3 0.063073
4 0.056255
5 0.042255
6 0.033236
7 0.038000
8 0.020964
9 0.017364
10 0.013982
您可以在列表 11.5 中看到用户点击较高位置的情况更多。排名0的结果的点击率是0.25,其次是排名1的0.143,依此类推。
此外,当我们比较早期的 CTR 判断与每个产品在查询中的典型排名时,我们还可以看到位置偏差。如果存在位置偏差,那么我们判断的理想排名最终将类似于显示给用户的典型排名。我们可以通过平均每个会话中每个文档的排名来分析这一点,以查看它们出现在哪里。
下面的列表显示了transformers dark of the moon会话的典型搜索结果页面。
列表 11.6 检查transformers dark of the moon的排名
def calculate_average_rank(sessions):
avg_rank = sessions.groupby("doc_id")["rank"].mean()
return avg_rank.sort_values(ascending=True)
sessions = get_sessions("transformers dark of the moon")
average_rank = calculate_average_rank(sessions)
print_series_data(average_rank, "mean_rank")
输出:
doc_id mean_rank name
400192926087 13.0526 Transformers: Dark of the Moon - Original Soun...
97363532149 12.1494 Transformers: Revenge of the Fallen - Widescre...
93624956037 11.3298 Transformers: Dark of the Moon - Original Soun...
...
25192107191 2.6596 Fast Five - Widescreen - Blu-ray Disc
24543701538 1.8626 The A-Team - Widescreen Dubbed Subtitle AC3 - ...
47875842328 0.9808 Transformers: Dark of the Moon Stealth Force E...
在列表 11.6 中,一些文档,如 24543701538 和 47875842328,在这个查询的历史搜索结果中通常出现在顶部。由于位置偏差,它们将被点击得更多。典型的结果页面,如图 11.4 所示,与图 11.2 中的 CTR 排名有很大的重叠。

图 11.4 对于查询transformers dark of the moon的典型搜索结果页面。注意无关的电影,如The A-Team和Fast Five的出现。还要注意 Wii 游戏的排名很高。这些结果的高位置以及它们仅仅因为出现在列表中就获得更多点击的事实,解释了为什么 CTR 模型错误地认为这些是相关的。
不幸的是,CTR 主要受到位置偏差的影响。用户点击图 11.4 中的奇怪电影,是因为搜索引擎为这个查询返回了它们,而不是因为它们相关。如果我们仅仅基于 CTR 来训练 LTR 模型,我们就是在要求 LTR 模型优化用户已经看到和交互的内容。在自动化 LTR 时,我们必须考虑位置偏差。
接下来,让我们看看我们如何在更健壮的点击模型中克服位置偏差,该模型可以补偿位置偏差。
11.2.3 简化的动态贝叶斯网络:一个克服位置偏差的点击模型
你已经看到了位置偏差在实际操作中可能造成的危害!如果我们仅仅使用点击数据,我们将训练我们的 LTR 模型来强化用户已经看到的排名。是时候引入一个能够克服位置偏差的点击模型了。我们将从定义“检查”,一个在建模位置偏差中的关键概念开始。然后,我们将介绍一个特定的点击模型,该模型使用这个“检查”概念来调整原始点击数据以克服位置偏差。
如何通过“检查”事件克服位置偏差
基本的点击率(CTR)计算并没有真正考虑到用户是如何扫描搜索结果的。用户在决定点击一个或两个结果之前,可能只会考虑几个结果——这些结果受到位置的影响。如果我们能够捕捉到用户在点击之前有意识地考虑的结果,我们可能能够克服位置偏差。点击模型正是通过定义“检查”的概念来实现这一点的。在我们构建一个能够克服位置偏差的点击模型之前,我们将探讨这个概念。
什么是“检查”?你可能对“印象”很熟悉——当一个 UI 元素渲染在用户屏幕的可见部分时。在点击模型中,我们考虑的是“检查”,即用户有意识地考虑搜索结果的可能性。正如我们所知,用户往往无法注意到他们眼前的东西。你可能就是那个用户!图 11.5 捕捉了这个概念,将“印象”与“检查”进行了对比。

图 11.5 中,印象是视口中渲染的内容(显示器形状的方形),而检查是用户考虑的内容(带有眼睛的搜索结果)。建模用户检查的内容有助于正确地解释用户如何与搜索结果互动。
你可以在图 11.5 中看到,用户没有注意到第二位上的任天堂游戏,尽管它在他们的显示器上被渲染。如果用户没有检查它,点击模型不应该惩罚任天堂游戏的相关性。
为什么跟踪检查有助于克服位置偏差?检查是点击模型理解位置偏差的方式。另一种说法“位置偏差”是“我们认为用户是否检查搜索结果取决于其位置。”因此,正确建模检查是大多数点击模型的核心活动。一些点击模型,如基于位置的模型(PBM),试图确定所有搜索中每个位置上的检查概率。其他,如级联模型或,如我们很快将看到的,动态贝叶斯网络(DBN)模型,假设如果一个结果位于搜索页面的最后一个点击之上,那么它很可能是被检查过的。
对于大多数点击模型,顶部位置通常比底部位置的检查概率更高。这使得点击模型能够正确调整点击。经常检查并点击的项目会被奖励,并被认为更相关。那些被检查但没有点击的项目被认为不太相关。
为了使这一点更具体,让我们深入探讨一个使用检查来帮助克服位置偏差的动态贝叶斯网络点击模型。
定义简化动态贝叶斯网络
简化动态贝叶斯网络(SDBN)是更复杂的动态贝叶斯网络(DBN)点击模型的一个略微不准确版本。这些点击模型假设,在搜索会话中,用户检查一个文档的概率在很大程度上取决于它是否位于或高于最低点击的文档。
SDBN 的算法首先标记每个会话的最后一个点击,然后考虑每个会话中或高于此最后一个点击的每个文档为已检查。最后,它通过简单地用文档上的总点击数除以该文档的总检查数来计算相关性等级。因此,我们得到一种动态点击率(CTR),跟踪每个用户的搜索会话中他们可能检查结果的时间,并仔细利用这一点来评估用户对其相关性的评价。然后,我们使用这些相关性评价在会话中汇总以训练 SDBN 点击模型。
让我们逐步跟踪这个算法。我们首先在以下列表中标记每个会话的最后一个点击。
列表 11.7 标记每个会话中哪些结果被检查
def calculate_examine_probability(sessions):
last_click_per_session = sessions.groupby( #1
["clicked", "sess_id"])["rank"].max()[True] #1
sessions["last_click_rank"] = last_click_per_session #2
sessions["examined"] = \ #3
sessions["rank"] <= sessions["last_click_rank"] #3
return sessions
sessions = get_sessions("dryer")
probability_data = calculate_examine_probability(sessions).loc[3]
print(probability_data)
1 计算每个会话的 last_click_per_session,即每个会话中点击为真的最大排名。
2 标记每个会话的最后一个点击排名
3 将最后一个点击或以上的每个位置设置为 True(否则为 False)
输出(截断):
sess_id query rank doc_id clicked last_click_rank examined
3 dryer 0.0 12505451713 False 9.0 True
3 dryer 1.0 84691226727 False 9.0 True
3 dryer 2.0 883049066905 False 9.0 True
...
3 dryer 8.0 14381196320 True 9.0 True
3 dryer 9.0 74108096487 True 9.0 True
3 dryer 10.0 74108007469 False 9.0 False
3 dryer 11.0 12505525766 False 9.0 False
...
在列表 11.7 中,我们通过在 last_click_per_session 中存储它来找到 clicked 为 True 的最大排名。然后,我们将 last_click_rank 或以上的位置标记为在我们的 dryer 会话中已检查,正如你在 sess_id=3 的输出中可以看到的那样。
在每个会话更新后,我们将所有会话中每个文档的总点击和检查次数进行汇总。
列表 11.8 为此查询按 doc_id 汇总点击和检查次数
def calculate_clicked_examined(sessions):
sessions = calculate_examine_probability(sessions)
return sessions[sessions["examined"]] \
.groupby("doc_id")[["clicked", "examined"]].sum()
sessions = get_sessions("dryer")
clicked_examined_data = calculate_clicked_examined(sessions)
print_dataframe(clicked_examined_data)
输出(截断):
doc_id clicked examined name
12505451713 355 2707 Frigidaire - Semi-Rigid Dryer Ve...
12505525766 268 974 Smart Choice - 6' 30 Amp 3-Prong...
...
36172950027 97 971 Tools in the Dryer: A Rarities C...
...
883049066905 286 2138 Whirlpool - Affresh Washer Cleaner
883929085118 44 578 A Charlie Brown Christmas - AC3 ...
在列表 11.8 中,sessions[sessions["examined"]] 仅筛选出已检查的行。然后,对于每个 doc_id,我们计算每个文档的总 clicked 和 examined 计数。你可以看到一些结果,如 doc_id=36172950027,显然被检查了很多次,但用户点击次数相对较少。
最后,我们在以下列表中通过计算点击次数来完成 SDBN 算法。
列表 11.9 计算最终的 SDBN 评分
def calculate_grade(sessions):
sessions = calculate_clicked_examined(sessions)
sessions["grade"] = sessions["clicked"] / sessions["examined"]
return sessions.sort_values("grade", ascending=False)
query = "dryer"
sessions = get_sessions(query)
grade_data = calculate_grade(sessions)
print_dataframe(grade_data)
输出(截断):
doc_id clicked examined grade name
856751002097 133 323 0.411765 Practecol - Dryer Balls (2-Pack)
48231011396 166 423 0.392435 LG - 3.5 Cu. Ft. 7-Cycle High-Ef...
84691226727 804 2541 0.316411 GE - 6.0 Cu. Ft. 3-Cycle Electri...
...
12505451713 355 2707 0.131141 Frigidaire - Semi-Rigid Dryer Ve...
36172950027 97 971 0.099897 Tools in the Dryer: A Rarities C...
883929085118 44 578 0.076125 A Charlie Brown Christmas - AC3 ...
在列表 11.9 的输出中,文档 856751002097 被视为最相关,评分为 0.4118,即 323 次检查中的 133 次点击。
让我们重新审视我们的两个查询,看看现在对于 dryer 和 transformers dark of the moon 的理想结果看起来如何。图 11.6 展示了 dryer 的结果,而图 11.7 展示了 transformers dark of the moon 的结果。

图 11.6 根据 SDBN 的查询 dryer 的理想搜索结果。注意 SDBN 似乎促进了更多与洗衣相关的结果。

图 11.7 根据 SDBN 的查询 transformers dark of the moon 的理想搜索结果。我们现在已经展示了 DVD、蓝光电影和 CD 原声带。
如果我们主观地审视图 11.6 和 11.7,这两组判断似乎比之前的 CTR 判断更直观。在我们的 dryer 例子中,重点似乎在洗衣上。有一些配件(如烘干球)的得分与烘干机本身大致相同。
对于 transformers dark of the moon,我们注意到蓝光电影的评分非常高。我们还看到 DVD 和 CD 原声带在“暗月”的其它二级项目(如视频游戏)中排名更高。有些奇怪的是,原声带 CD 的排名高于电影 DVD——也许我们应该进一步调查这一点。
当然,正如我们之前所说的,我们现在正在使用我们的直觉。在第十二章中,我们将更客观地思考我们如何评估判断质量。
在我们的位置偏差得到更好的控制后,我们现在将转向微调我们的判断,以处理另一个关键偏差:置信度偏差。
11.3 处理置信度偏差:不要因为少数幸运点击而颠覆你的模型
在棒球比赛中,一个球员的击球率告诉我们他们在每次击球中得到的击球比例。一位伟大的职业球员的击球率大于 0.3。然而,考虑一下这位幸运的小联盟棒球球员第一次击球就击中球的情况。他们的击球率在技术上为 1.0!因此,我们可以得出结论,这位年轻的孩子是一个棒球天才,并且肯定会有一个伟大的棒球生涯。对吗?
不完全是这样!在本节中,我们将探讨这位幸运的小联盟球员的相关性方面。对于那些可能仅仅因为运气好,只被检查过几次,每次都导致点击的结果,我们该怎么办?这些结果可能不应该得到完美的 1.0 分。我们将在我们的数据中看到(并纠正)这个问题。
11.3.1 点击数据中的低置信度问题
让我们看看数据,看看低置信度数据点在哪里影响了训练数据。然后我们将看到我们如何补偿 SDBN 结果中的低置信度问题。为了定义这个问题,让我们看看transformers dark of the moon和另一个更罕见的查询的 SDBN 结果,看看常见的低置信度情况。
如果你还记得,根据 SDBN,变形金刚:月黑之时电影的原声带 CD 排名相当高,这有点可疑。当我们检查排名背后的原始数据时,我们可以看到可能存在的问题。在下面的列表中,我们重构了transformers dark of the moon的 SDBN 数据来调试这个问题,结合了列表 11.7-11.9。
列表 11.10 重新计算 SDBN 统计数据
query = "transformers dark of the moon"
sessions = get_sessions(query)
grade_data = calculate_grade(sessions)
print_dataframe(grade_data)
输出(截断):
doc_id clicked examined grade name
97360810042 412 642 0.641745 Transformers: Dark of the Moon -...
400192926087 62 129 0.480620 Transformers: Dark of the Moon -...
97363560449 96 243 0.395062 Transformers: Dark of the Moon -...
...
47875841406 80 626 0.127796 Transformers: Dark of the Moon A...
24543750949 31 313 0.099042 X-Men: First Class - Widescreen ...
47875842335 53 681 0.077827 Transformers: Dark of the Moon S...
在列表 11.10 的输出中,注意最上面的结果,蓝光电影(doc_id=97360810042),有更多的检查次数(642)比原声带 CD(doc_id=400192926087,129次检查)。鉴于蓝光有更多用户互动的机会,它的评分更可靠,因此它不太可能被噪声点击所主导。另一方面,CD 的检查次数要少得多。鉴于 CD 的数据有限,蓝光的相关性评分不应该更高吗?
通常情况下,这种状况甚至更为明显,尤其是在处理不太常见的查询时。无论你的搜索引擎接收多少查询,一些查询可能会被多次接收(头部查询),一些会被适度次数接收(躯干查询),而一些则非常罕见(长尾查询,或简称尾部查询)。以查询blue ray为例。你会注意到这实际上是“Blu-ray”的常见误拼。作为一个常见的错误,它可能将包含适度数量检查的文档与接收非常少检查的文档混合在一起。在下面的列表中,我们计算了blue ray的 SDBN 统计数据,它受到了这种数据稀疏问题的影响。
列表 11.11 对于稀疏数据的查询的 SDBN 判断
def get_sample_sessions(query):
sessions = get_sessions(query, index=False)
sessions = sessions[sessions["sess_id"] < 50050] #1
return sessions.set_index("sess_id")
sessions = get_sample_sessions("blue ray")
grade_data = calculate_grade(sessions)
print_dataframe(grade_data)
1 随机抽取几个会话来模拟典型的长尾案例
输出(截断):
doc_id clicked examined grade name
600603132872 1 1 1.000000 Blu-ray Disc Cases (10-Pack)
827396513927 14 34 0.411765 Panasonic - Blu-Ray Player
25192073007 8 20 0.400000 The Blues Brothers - Widescree...
...
25192107191 0 7 0.000000 Fast Five - Widescreen - Blu-r...
23942972389 0 15 0.000000 Verbatim - 10-Pack 6x BD-R Dis...
885170038875 0 5 0.000000 Panasonic - 9" Widescreen Port...
查看列表 11.11 的输出,我们发现了一些令人不安的情况。就像我们幸运的小联盟棒球选手最极端的情况一样,最相关的结果,文档 600603132872,在仅被一个用户检查后得到了1.0(完全相关)的评分!这个1.0的评分高于下一个结果,该结果的评分为0.411,基于34次检查。当你考虑到文档 600603132872 是一套蓝光案例,而 827396513927 是蓝光播放器时,这感觉更加令人不安。我们主观的解释可能会将播放器排在案例之上。第二结果被检查得更多,这不应该算作某种因素吗?
在这些示例中,我们所看到的是置信度偏差——当一个判断列表基于统计上不显著、虚假的事件有很多评分时。我们说这些检查次数很少的虚假事件具有低置信度,而那些检查次数更多的提供了更高水平的置信度。无论你的点击模型如何,你很可能有很多查询只有少量流量的情况。为了自动化 LTR,你需要调整你的训练数据生成,以考虑你对数据的置信度。
既然你已经看到了低置信度数据的影响,我们可以继续讨论在构建你的点击模型时可以应用的一些解决方案。
11.3.2 使用 beta 先验概率建模置信度
我们刚刚看到了过分重视低置信度数据所造成的一些问题。如果不根据你对数据的置信度调整你的模型,你将无法构建一个可靠的自动化 LTR 系统。我们本可以简单地过滤掉这些低置信度示例,但我们可以做些更智能的事情吗?在本节中,我们将介绍 beta 分布的概念,同时讨论如何保留所有点击流数据。但首先,让我们讨论为什么使用所有数据通常比简单地过滤掉低置信度示例更受欢迎。
我们是否应该过滤掉低置信度的判断?
在我们的点击模型中,我们是否应该仅仅移除低置信度示例?我们通常不推荐像那样丢弃数据点。
过滤训练数据,例如低于某些最小检查阈值的检查点数据,会减少你拥有的训练数据量。即使有一个合理的阈值,查询的文档通常是在幂律分布上被检查的。用户频繁检查一些文档,而大量文档则很少被检查。因此,阈值可能会移除过多的 LTR 示例,导致 LTR 模型错过重要的模式。即使有阈值,你仍将面临如何权衡中等置信度示例与高置信度示例的挑战,例如与之前提到的transformers dark of the moon查询。
我们主张保留低置信度示例,并仅根据置信度水平权衡所有示例。我们将使用计算的相关度等级的贝塔分布来完成这项工作。然后我们将应用这个解决方案来修复我们的 SDBN 点击模型判断。
使用贝塔分布来调整置信度
贝塔分布帮助我们根据概率而不是仅仅基于有偏见的出现来从我们的点击和检查中得出结论。然而,在我们直接使用贝塔分布进行判断之前,让我们首先通过我们之前直观的棒球击球率类比来检验贝塔分布的有用性。
在棒球中,一名球员的 0.295 击球率意味着当这名球员击球时,大约有 29.5%的几率他们会击中。但如果我们想知道“这名球员在九月雨天在芬威球场击球的击球率是多少”,我们可能几乎没有信息可用。这名球员可能只在这种条件下击球几次。也许他们在那种条件下 2 次尝试中击中了 2 次。我们会得出结论,他们在这些情况下的击球率是 2/3 或 0.67。现在我们知道这个结论是错误的:我们真的认为,基于只有 3 次击球的机会,我们就可以得出这名球员有 66.7%的不太可能击中的高概率吗?更好的方法是将 0.295 的一般击球率作为初始信念,随着我们逐渐获得更多关于“九月雨天在芬威球场击球”的数据,我们逐渐远离这个假设。
贝塔分布是一种用于管理信念的工具。它将一个概率,如击球率或判断等级,转换为两个值,a和b,这些值代表概率分布。a和b值可以这样解释:
-
a(成功次数)—我们观察到的击中次数,或者点击的检查次数 -
b(失败次数)—我们观察到的未击中次数,或者未点击的检查次数
使用贝塔分布,属性均值 = a / (a + b)成立,其中均值是初始点值,如击球率。给定一个均值,我们可以找到许多满足均值 = a / (a + b)的a和b值。毕竟,0.295 = 295 / (295 + 705),同样0.295 = 1475 / (1475 + 3525)等等。然而,每个都代表一个不同的贝塔分布。随着我们的进展,请记住这个属性。
让我们把这些部分放在一起,看看贝塔分布是如何防止我们从虚假点击(或击球)数据中得出结论的。
我们可以将我们对任何文档相关性等级的初始信念声明为0.125。这就像声明棒球运动员的击球率为 0.295 作为我们对他们表现的初始信念一样。我们可以使用 beta 分布来更新特定案例的初始信念,例如“9 月的雨天在芬威公园”或特定文档对搜索查询的相关性。
第一步是选择一个a和一个b来捕捉我们的初始信念。对于我们的相关性案例,我们可以选择许多满足0.125 = a / (a + b)的a和b值。假设我们选择a=2.5, b=17.5作为我们对没有点击的文档的相关性信念。绘制这个,我们会看到图 11.8 中的分布。

图 11.8 相关性等级为 0.125 的 beta 分布。平均值对应于我们的默认相关性等级。我们看到最可能的相关性等级分布。
我们现在可以观察到当我们看到文档的第一次点击,将文档的a增加到 3.5 时会发生什么。在图 11.9 中,我们有a=3.5, b=17.5。

图 11.9 在添加一次点击后,相关性等级的 beta 分布现在是0.1667。添加一次点击“拉动”概率分布稍微向一个方向移动,更新初始信念。
更新分布的平均相关性等级现在是3.5 / (17.5 + 3.5)或0.1667,在考虑到其第一次点击的情况下,有效地将初始信念稍微提高。如果没有 beta 分布,这篇文档将会有 1 次点击和 1 次查看,结果等级为1。
我们将起始点概率分布(所选的a和b)称为先验分布,或简称先验。这是我们对于会发生什么的初始信念。更新a和b后的特定案例(如文档)的分布是后验分布,或简称后验。这是我们更新的信念。
回想一下,我们之前提到可以选择许多初始的a和b值。这很重要,因为初始a和b的大小使我们的先验更弱或更强。我们可以选择任何满足a / (a + b) = 0.125的a和b值。但请注意,如果我们选择一个非常小的值a=0.25, b=1.75会发生什么。然后我们通过增加 1 来更新a。后验分布的新预期值是1.25 / (1.25 + 1.75)或约0.416。这仅仅是一个点击就能产生重大影响。相反,使用非常高的a和b值会使先验非常强,几乎不会变动。当你使用 beta 分布时,你将想要调整先验的大小,以便更新产生期望的效果。
现在你已经看到了捕捉 SDBN 等级的实用工具,让我们看看 beta 分布如何帮助我们解决 SDBN 置信度问题。
在 SDBN 点击模型中使用 beta 先验
让我们通过使用 beta 分布更新 SDBN 点击模型来结束本章。如果你使用其他点击模型,如本章前面提到的那些,你需要思考在这些情况下如何解决置信度问题。beta 分布可能在那里也是一个有用的工具。
如果你记得,SDBN 的输出是每个文档的clicks和examines的计数。在列表 11.12 中,我们从列表 11.11 继续,它计算了blue ray查询的 SDBN。我们将为我们的 SDBN 模型选择0.3作为先验等级。这是我们不知道文档信息时的默认等级——可能来自我们在判断中看到的典型等级。然后,我们将使用这个先验等级计算先验 beta 分布(prior_a和prior_b)。
列表 11.12 计算先验 beta 分布
def calculate_prior(sessions, prior_grade, prior_weight):
sessions = calculate_grade(sessions)
sessions["prior_a"] = prior_grade * prior_weight #1
sessions["prior_b"] = (1 - prior_grade) * prior_weight #1
return sessions
prior_grade = 0.3 #2
prior_weight = 100 #3
query = "blue ray"
sessions = get_sample_sessions(query)
prior_data = calculate_prior(sessions, prior_grade, prior_weight)
print(prior_data)
1 结果满足先验等级 = prior_a / (prior_a + prior_b)
2 默认的相关性等级
3 在先验中放置多少信心(先验权重 = a + b)
输出(截断):
doc_id clicked examined grade prior_a prior_b
600603132872 1.0 1.0 1.000000 30.0 70.0
827396513927 14.0 34.0 0.411765 30.0 70.0
25192073007 8.0 20.0 0.400000 30.0 70.0
885170033412 6.0 19.0 0.315789 30.0 70.0
...
在列表 11.12 中,使用100的权重,你可以确认prior_grade = prior_a / (prior_a + prior_b) 或 0.3 = 30 / (30 + 70)。这已经捕捉到了我们先验的初始概率分布。
在列表 11.13 中,我们需要计算后验分布和相应的相关性等级。我们通过增加点击(我们的“成功”)的prior_a和没有点击的检查(我们的“失败”)的prior_b来实现这一点。最后,我们计算一个更新的等级作为beta_grade。
列表 11.13 计算后验 beta 分布
def calculate_sdbn(sessions, prior_grade=0.3, prior_weight=100):
sessions = calculate_prior(sessions, prior_grade, prior_weight)
sessions["posterior_a"] = (sessions["prior_a"] + #1
sessions["clicked"]) #1
sessions["posterior_b"] = (sessions["prior_b"] + #2
sessions["examined"] - sessions["clicked"]) #2
sessions["beta_grade"] = (sessions["posterior_a"] / #3
(sessions["posterior_a"] + sessions["posterior_b"])) #3
return sessions.sort_values("beta_grade", ascending=False)
query = "blue ray"
sessions = get_sample_sessions(query)
bluray_sdbn_data = calculate_sdbn(sessions)
print(bluray_sdbn_data)
1 更新我们对文档相关性的信念,通过点击的数量从prior_a增加
2 更新我们对文档缺乏相关性的信念,通过没有点击的检查从prior_b增加
3 从后验 _a 和后验 _b 计算一个新的等级
输出(截断):
doc_id cl ex grade pr_a pr_b posterior_a posterior_b beta_grade
827396513927 14 34 0.411 30.0 70.0 44.0 90.0 0.328358
25192073007 8 20 0.400 30.0 70.0 38.0 82.0 0.316667
600603132872 1 1 1.000 30.0 70.0 31.0 70.0 0.306931
...
786936805017 1 14 0.071 30.0 70.0 31.0 83.0 0.271930
36725608511 0 11 0.000 30.0 70.0 30.0 81.0 0.270270
23942972389 0 15 0.000 30.0 70.0 30.0 85.0 0.260870
在列表 11.13 的输出中,列标题clicked、examined、prior a和prior_b为了节省空间被缩短为cl、ex、pr_a和pr_b。注意我们通过按beta grade排序的新理想查询结果blue ray。值得注意的是,我们的蓝光案例滑到了第三个最相关位置,单次点击并没有将等级推过0.3。
当我们在图 11.10 和 11.11 中重复对dryer和transformers dark of the moon的判断计算时,我们注意到顺序是相同的,但等级本身更接近先验的0.3,这取决于我们对数据的信心。

图 11.10 对dryer的 beta 调整后的 SDBN 结果。注意现在等级更紧密地集中在先验等级0.3周围,有些高于或低于这个先验。

图 11.11 transformers dark of the moon的 Beta 调整后的 SDBN 结果。在图 11.7 中,我们注意到尽管点击次数少于蓝光电影,音轨的相关性评分似乎异常高(0.48)。我们现在看到音轨的相关性更接近先前的0.4。
图 11.11 明显显示,与未建模置信度的 SDBN 判断(图 11.7)相比,对音轨的置信度较低。等级从0.4806降至0.4017。值得注意的是,CD 之后的 DVD 等级没有太大变化,只是从0.3951变为0.3673,因为我们对该观察结果的置信度更高。随着更多观察结果的到来,如果这种模式持续下去,CD 甚至可能降级排名。
大多数查询不会像 dryer 或 transformers dark of the moon 这样。它们更像 blue ray。为了有意义地处理这些查询进行 LTR,你需要能够处理这些“小数据”问题,例如具有较低的置信度。
我们开始拥有一个更合理的 LTR 自动化训练集,但仍有许多工作要做。在下一章中,我们将转向查看完整的搜索反馈循环。这包括处理展示偏差。回想一下,这是用户从未检查搜索从未返回给他们的偏差。我们如何向自动 LTR 反馈循环添加监督,以克服展示偏差并确保我们的模型——以及由此产生的判断——按预期工作?
在我们检查下一章中的这些主题之前,让我们再次查看从头到尾训练 LTR 模型,这样你可以实验你迄今为止学到的知识。
11.4 在 LTR 系统中探索你的训练数据
干得好!你已经完成了第十章和第十一章。你现在有了开发合理的 LTR 训练数据和训练 LTR 模型所需的一切。你很可能急于从你的工作中训练一个模型。为了避免在这里重复第十章中的大量代码,我们在书籍代码库的 ch11 文件夹中创建了一个“端到端自动学习排序”笔记本(4.end-to-end-auto-ltr.ipynb)。它将允许你在 RetroTech 数据(图 11.12)上实验 LTR。
在这个笔记本中,你可以微调内部 LTR 引擎——特征工程和模型创建,试图满足训练数据。你还可以探索改变此引擎自动输入的影响:训练数据本身。总之,这个笔记本包含了你迄今为止学到的所有步骤:
-
使用 SDBN 点击模型和 beta 先验将原始点击会话数据转换为判断
-
将 dataframe 转换为我们在第十章中使用的判断
-
加载一组 LTR 特征以用于搜索引擎的特征存储功能
-
从搜索引擎记录这些特征,然后对数据进行成对转换,以形成一个合适的训练集
-
在搜索引擎的模型存储中训练和上传模型
-
使用模型进行搜索和排序

图 11.12 探索完整 LTR 系统的笔记本。你可以对这个模型进行测试。
我们邀请您调整点击模型参数,并思考新的特征,以及到达最终 LTR 模型的不同方式,发现哪些似乎能产生最佳结果。在您进行这些调整时,请务必质疑您自己的主观假设与数据向您展示的内容之间的差异。
通过开箱即用的调整,我们将向您展示图 11.13,显示当前查询transformers dvd的搜索结果。在这里尝试不同的查询。您如何帮助模型更好地区分相关和不相关的文档?您遇到的问题是由于使用的训练数据引起的吗?还是由于构建模型使用的特征引起的?

图 11.13 我们的训练模型如何对transformers dvd进行排序。您认为您能在这方面做得更好吗?
在下一章中,我们将通过监控模型来最终确定自动化的 LTR 系统。最重要的是,我们将考虑如何克服展示偏差。即使在本章的调整之后,用户仍然只能对搜索显示的内容采取行动。因此,我们仍然有一个由当前相关性排名严重偏重的反馈循环。我们如何关注这个问题并克服它?在下一章中,我们将考虑这些问题,因为我们的 LTR 模型继续迭代地整合用户交互并主动展示更多有希望的结果。
摘要
-
如果我们能可靠地将用户点击数据转换为相关性判断,我们可以自动化学习排序(LTR)。然而,点击模型必须精心设计,以减少数据中的偏差,并确保自动化 LTR 系统在部署给真实用户时的可靠性。
-
学习到的(隐式)相关性判断列表可以插入到现有的 LTR 训练过程中,以替换或增强手动创建的判断。
-
在自动化的 LTR 模型中,原始点击通常存在问题,因为算法在如何对用户排名和展示搜索结果方面存在常见的偏差。
-
在可见的搜索结果中,位置偏差表明用户更喜欢排名靠前的结果。我们可以通过使用跟踪用户检查文档或搜索结果中位置的点击模型来克服位置偏差。
-
大多数搜索应用都有大量的虚假点击数据。当训练数据偏向这些虚假结果时,我们会产生信心偏差。我们可以通过使用贝塔分布来创建先验,并随着新观察数据的到来逐步更新,来克服信心偏差。
第十二章:通过主动学习克服排名偏差
本章涵盖
-
利用实时用户交互来收集对部署的 LTR 模型的反馈
-
使用实时用户进行 A/B 测试搜索相关性解决方案
-
使用主动学习来探索可能相关的结果,而不仅仅是顶部结果
-
在利用用户交互的同时探索其他可能相关的信息
到目前为止,我们的学习排名(LTR)工作一直在实验室进行。在前几章中,我们使用用户点击自动构建的训练数据构建了模型。在本章中,我们将把我们的模型带入现实世界,与(模拟的)实时用户进行测试!
回想一下,我们比较了自动 LTR 系统与自动驾驶汽车。在内部,汽车有一个引擎:第十章讨论的基于历史判断的端到端模型重新训练。在第十一章中,我们将我们的模型训练数据与自动驾驶汽车的方向进行了比较:我们应该优化什么来根据与搜索结果的先前交互自动学习判断?我们构建了训练数据并克服了点击数据中固有的关键偏差。
在本章中,我们的重点是将我们的排名模型从实验室转移到生产环境中。我们将部署并监控我们的模型,当它们接收用户流量时。我们将看到模型做得好的地方,并了解前两章的工作是否失败或成功。这意味着探索一种新的测试方法来验证我们的模型:A/B 测试。在A/B 测试中,我们将随机将实时用户分配到不同的模型,并检查业务结果(销售额等),以查看哪个模型表现最好。你可能在其他环境中熟悉 A/B 测试,但在这里我们将专注于自动 LTR 系统的含义。
实时用户不仅帮助我们验证系统,还帮助我们摆脱模型可能陷入的危险负面反馈循环,如图 12.1 所示。

图 12.1 展示了展示偏差的负面反馈循环。用户永远不会点击搜索引擎从未返回的内容,因此相关性模型永远无法超越当前模型的知识。
在图 12.1 中,我们的模型只能学习用户展示给他们的相关内容。换句话说,我们面临一个不幸的鸡生蛋问题:模型是根据用户认为相关的数据进行训练的,但用户认为相关的数据是基于模型展示给他们的。好的 LTR 尝试优化具有最多积极交互信号的结果,但用户只会点击他们面前的东西。当训练数据似乎毫无希望地偏向搜索引擎当前排名时,LTR 如何可能变得更好?这种从隐式推导的训练数据中反映出的先前显示结果的偏差被称为展示偏差。
在我们探索 A/B 测试之后,我们将使用主动学习来对抗本章剩余部分的展示偏差。一个主动学习系统是可以交互式地从用户那里收集新的标记数据以回答新问题的系统。在我们的情况下,我们的主动学习算法将确定导致排名偏差的盲点,提示用户与探索这些盲点的新结果进行交互,并将用户交互作为新的训练数据来纠正盲点。就像只有学习了一条次优路径的自动驾驶汽车一样,我们必须战略性地探索其他有希望的路径——在我们的情况下,是额外的搜索结果类型——以学习新的用户相关性模式。在图 12.2 中,我们看到自动化 LTR 循环增加了这种盲点探索。
在我们到达这个至关重要的主题之前,我们必须首先将第十章和第十一章所学的一切封装成几行代码。然后我们才能快速迭代,探索 A/B 测试和克服展示偏差。

图 12.2 自动化 LTR 与实时用户相遇。为了有用,我们的自动化 LTR 系统必须通过探索用户尚未看到的结果来克服展示偏差,以扩大训练数据覆盖范围。
12.1 几行代码中的我们的自动化 LTR 引擎
在我们开始进行 A/B 测试之前,让我们将第十章和第十一章的所有知识汇总到几个可重用的 Python 辅助函数中。首先,我们将定义一个函数,使我们能够使用简化的动态贝叶斯网络(SDBN)点击模型(第十一章的全部内容)从原始会话点击中重建训练数据。接下来,我们将创建一个同样简单的代码片段,使用该训练数据(第十章的全部内容)训练模型。在深入 A/B 测试和本章剩余部分克服展示偏差之前,我们将非常快速地总结这些函数。
12.1.1 将点击转换为训练数据(第十一章一行代码)
在第十一章中,我们将点击转换为训练数据,并探讨了 SDBN 点击模型,该模型可以克服用户点击搜索结果时的偏差。在探索本章中额外的偏差并自动化端到端 LTR 过程时,我们将重用第十一章的大部分代码。
作为提醒,我们的点击模型将原始点击转换为训练标签或评分,映射文档与关键词的相关性。我们需要构建训练数据的原始输入包括查询字符串、显示的结果排名、该位置的文档以及是否被点击。我们可以看到存储在这个数据框中的数据:
id sess_id query rank doc_id clicked
0 50002 blue ray 0.0 600603141003 True
1 50002 blue ray 1.0 827396513927 False
2 50002 blue ray 2.0 24543672067 False
3 50002 blue ray 3.0 719192580374 False
4 50002 blue ray 4.0 885170033412 True
给定这个输入,我们可以将第十一章的所有内容封装成一个可重用的函数,该函数用于计算我们的训练数据。回想一下,我们使用“判断列表”或“判断”来指代我们的训练数据。我们可以在以下列表中看到我们的判断计算。
列表 12.1 从会话生成训练数据(第十一章)
training_data = generate_training_data(sessions, prior_weight=10,
prior_grade=0.2)
display(training_data)
输出(截断):
query doc_id clicked examined grade beta_grade
blue ray 27242815414 42 42 1.000000 0.846154
827396513927 1304 3359 0.388211 0.387652
883929140855 140 506 0.276680 0.275194
885170033412 568 2147 0.264555 0.264256
24543672067 665 2763 0.240680 0.240534
...
transformers dvd 47875819733 24 1679 0.014294 0.015394
708056579739 23 1659 0.013864 0.014979
879862003524 23 1685 0.013650 0.014749
93624974918 19 1653 0.011494 0.012628
47875839090 16 1669 0.009587 0.010721
generate_training_data 函数接收所有用户搜索 sessions,以及 prior_weight,表示先验权重应该被赋予多强的权重(默认为 10),和 prior_grade,指定在没有证据时结果的相关性的默认概率(默认为 0.2)。参见第 11.3.2 节,了解这些值如何影响 SDBN 计算的复习。
让我们通过查看列表 12.1 简要回顾一下在第十一章中学到的内容。如您在输出中看到的那样,我们计算了一个 dataframe,其中每个查询-文档对都有相应的 clicked 和 examined 计数。点击就是它们听起来那样:这个产品为这个查询收到的原始点击总和。回想一下,examined 对应于点击模型认为用户注意到结果次数的数量。
grade 和 beta_grade 统计量是训练标签。这些对应于文档对于查询的相关性概率。回想一下,grade 简单地将 clicked 除以 examined:SDBN 点击模型的原始、首次实现。然而,我们在第十一章中了解到,最好考虑我们拥有的信息量(参见第 11.3 节)。我们不希望一个点击和一个查看 (1 / 1 = 1.0) 被计为像一百个点击和一百个查看 (100 / 100 = 1.0) 那样强烈。因此,beta_grade 对信息量更多的结果赋予更高的权重(更倾向于一百个点击的例子)。因此,当重新训练 LTR 模型时,我们将使用 beta_grade 而不是 grade。
这份数据是我们第十章中训练的 LTR 模型的训练数据。接下来,让我们看看我们如何轻松地使用这些训练数据,训练一个模型,并将其部署。
12.1.2 几个函数调用中的模型训练和评估
除了重新生成训练数据外,我们还需要在将其部署给实时用户之前重新训练我们的模型。在本节中,我们将探讨我们核心 LTR 模型训练引擎的便利函数。这将使我们能够在本章的其余部分快速实验模型。
我们将使用几行简单的代码来封装模型训练和离线评估。
列表 12.2 在几个特征上训练和评估模型
def train_and_evaluate_model(sessions, model_name, features, log=False):
training_data = generate_training_data(sessions)
train, test = split_training_data(training_data, 0.8)
train_and_upload_model(train, model_name, features=features, log=log)
evaluation = evaluate_model(test, model_name, training_data, log=log)
return evaluation
feature_set = [
ltr.generate_query_feature(feature_name="long_description_bm25",
field_name="long_description"),
ltr.generate_query_feature(feature_name="short_description_constant",
field_name="short_description",
constant_score=True)]
evaluation = train_and_evaluate_model(sessions, "ltr_model_variant_1",
feature_set)
display(evaluation)
ltr_model_variant_1 的评估:
{"dryer": 0.03753076750950996,
"blue ray": 0.0,
"headphones": 0.0846717500031762,
"dark of moon": 0.0,
"transformers dvd": 0.0}
在列表 12.2 的帮助下,让我们简要回顾一下在第十章中学到的内容。我们定义一个 feature_set,包含两个 LTR 特征:一个用于与 long_description 字段进行搜索,另一个用于与 short_description 字段进行搜索。我们必须仔细选择,希望找到能够有意义地预测相关性并且可以从列表 12.1 中的训练数据中学习到的特征。然后我们将 training_data 分割成 train 和 test 集合,并使用 train 集合来训练和上传模型。
但我们如何知道我们的模型是否成功地从训练数据中学习到了知识?在模型训练期间将判断分开并排除测试集,为评估训练好的模型保留了一些训练数据。你就像是一位教授给学生(这里的模型)进行期末考试。你可能会给学生提供许多样题来准备考试(训练集)。但为了看看学生是否真正学到了材料,而不是仅仅记住它,你会给他们一个包含不同问题的期末考试(测试集)。这有助于你在将学生送入现实世界之前评估学生是否理解了你之前教给他们的内容。
当然,课堂上的成功并不总是等同于现实世界的成功。将我们的模型推广到现实世界,在 A/B 测试中有真实用户的情况下,可能会显示出它的表现并不像我们希望的那样好!
最后,每个测试查询旁边的统计数字是什么?我们如何评估学生在测试查询上的成功?回想一下第十章,我们只是使用了精确度(相关查询的比例)。这个统计数字将前N个评分相加,然后除以N(对我们来说N = 10),这实际上是平均相关性评分。我们建议探索其他统计指标,用于模型训练和评估,这些指标倾向于正确获取前几位,例如折现累积收益(DCG)、归一化折现累积收益(NDCG)或期望倒数排名(ERR)。对于我们来说,我们将继续使用更简单的精确度统计指标。
仅从 12.2 列表中测试查询的相关性指标来判断,我们的模型在离线测试中表现相当糟糕。通过提高离线指标,我们应该在 A/B 测试中的真实用户中看到显著的改进。
12.2 对新模型进行 A/B 测试
在本节中,我们将模拟运行 A/B 测试,并将 12.2 列表的模型与在实验室中似乎表现更好的模型进行比较。我们将反思 A/B 测试的结果,这为我们完成了在第十一章中引入的自动 LTR 反馈循环奠定了基础。我们将通过反思哪些地方做得不好来结束,本章剩余部分将专注于添加“主动学习”,这是我们自动 LTR 反馈循环中缺失的关键部分。
12.2.1 对更好的模型进行测试
我们原始的 LTR 模型表现并不理想,正如我们在 12.2 列表的输出中所见。在本节中,我们将训练一个新的模型,一旦它看起来有希望,我们就会将其部署在与 12.2 列表中训练的模型进行的 A/B 测试中。
让我们看看以下改进后的模型。
列表 12.3 通过改变特征改进的新模型
feature_set = [
ltr.generate_fuzzy_query_feature(feature_name="name_fuzzy",
field_name="name"),
ltr.generate_bigram_query_feature(feature_name="name_bigram",
field_name="name"),
ltr.generate_bigram_query_feature(feature_name="short_description_bigram"
field_name="short_description")]
evaluation = train_and_evaluate_model(sessions, "ltr_model_variant_2",
feature_set)
display(evaluation)
对ltr_model_variant_2的评估:
{"dryer": 0.07068309073137659, #Before: 0.038
"blue ray": 0.0, # 0.0
"headphones": 0.06540945492120899, # 0.085
"dark of moon": 0.2576592004029579, # 0.0
"transformers dvd": 0.10077083021678328} # 0.0
在前面的列表中,我们定义了一个包含三个特征的feature_set:name_fuzzy,它对name字段执行模糊搜索;name_bigram,它在name字段上执行双词短语搜索;以及short_description_bigram,它在short_description字段上执行双词短语搜索。像之前一样,这个模型被训练、部署和评估。注意列表 12.3 的输出——在相同的测试查询集上,我们的模型似乎表现更好。这看起来很有希望!实际上,我们选择了一组似乎能更好地捕捉相关性文本匹配方面的特征。
聪明的读者可能会注意到,我们保持了与列表 12.2 相同的测试查询。我们有意这样做是为了清晰起见。这足以教授你基本的 AI 搜索技能。然而,在现实生活中,我们希望有一个真正的随机测试/训练数据集分割,以更好地评估模型的表现。我们甚至可以更进一步,进行交叉验证——在不同的测试/训练数据集分割上对多个模型进行重采样和训练,以确保模型能够很好地泛化,而不会过度拟合训练数据。如果你想要深入了解离线模型评估,我们推荐一本更通用的机器学习书籍,例如 Alexey Grigorev 的《Machine Learning Bootcamp》(Manning,2021 年)。
也许你的搜索团队认为列表 12.3 中训练的模型有潜力,并且足够好以部署到生产环境中。团队的希望很高,那么让我们看看当我们将模型部署到生产环境中进行进一步的用户评估时会发生什么。
12.2.2 在自动 LTR 的上下文中定义 A/B 测试
到第十一章结束时,我们已经开发了一个端到端的 LTR 重新训练过程:我们可以将用户信号输入生成点击模型,使用点击模型生成判断,使用判断来训练 LTR 模型,然后将 LTR 模型部署到生产环境中以收集更多信号以重启该过程。通过设置这个 LTR 重新训练循环,我们可以轻松部署有潜力的新排名模型。
尽管如此,我们实际上还没有将我们的 LTR 模型部署到生产环境中。我们只开发了理论模型。我们如何知道我们在实验室中构建的东西在现实世界中表现良好?处理真实的现实世界场景是相当不同的事情。
在本节中,我们将探讨与(模拟的)真实用户进行的 A/B 测试的结果。因为这是一本你本地运行的代码库的书,很遗憾我们无法让真实用户点击我们的应用程序。因此,我们将使用我们代码库内部模拟的“实时”用户流量。就我们的目的而言,这种流量模拟与真实用户交互足够相似,可以成功地展示主动学习过程。
我们将看到 A/B 测试如何成为我们自动 LTR 系统成功的最终仲裁者。它将使我们能够纠正离线自动 LTR 模型训练中的问题,以便反馈循环可以逐步变得更加可靠。
你可能已经了解 A/B 测试,但在这里我们将演示它们如何影响自动 LTR 系统。如图 12.3 所示,一个A/B 测试随机将用户分配到两个变体。每个变体包含一组独特的应用程序功能。这可能包括从不同的按钮颜色到新的相关性排名算法等任何内容。由于用户是随机分配到变体的,我们可以更可靠地推断哪个变体在选定的业务结果上表现最佳,例如销售、在应用程序上花费的时间、用户保留率,或者企业可能选择优先考虑的其他任何事情。
在运行 A/B 测试时,你通常会指定一个变体作为对照组,代表当前的默认算法。拥有一个对照组可以让你衡量其他模型的改进。同时进行多变量测试也很常见,其中同时测试多个变体或变体的组合。可以实施更高级的测试策略,如多臂老丨虎丨机测试,其中测试不断将实时流量转移到当前表现最佳的变体,或者基于信号的回溯测试,其中你使用历史数据来模拟 A/B 测试,以预测在向实时用户展示结果之前,离线最佳变体。

图 12.3 搜索 A/B 测试。搜索用户被随机分配到两个相关性算法(此处为两个 LTR 模型),并跟踪结果。
12.2.3 将更好的模型毕业到 A/B 测试
接下来,我们将部署我们的新颖模型ltr_model_variant_2,如列表 12.3 所示,进入 A/B 测试。然后我们将探讨测试结果的影响。期望很高,你的团队认为这个模型可能会让竞争对手大吃一惊:列表 12.2 中表现不佳的ltr_model_variant_1。
在本节中,我们将模拟一个 A/B 测试,将 1,000 名用户随机分配到每个模型。在我们的案例中,这些模拟用户有他们想要购买的具体商品。如果他们看到这些商品,他们就会进行购买并愉快地离开我们的商店。如果他们看不到,他们可能会四处浏览,但很可能会不购买就离开。当然,我们的搜索团队不知道用户希望购买什么——这些信息对我们来说是隐藏的。我们只看到点击和购买流,正如我们将看到的,这些受到展示偏差的严重影响。
在列表 12.4 中,我们有一个用户群体,他们通过搜索 transformers dvd 来寻找最新的 Transformers 电影。在我们的讨论中,我们将专注于这个单一查询。当然,在真实的 A/B 测试中,我们会查看完整的查询集,用户群体不会这么静态。然而,通过专注于一个查询,我们可以更具体地理解我们的 A/B 测试对自动化 LTR 的影响。对于更深入的 A/B 测试实验概述,我们推荐 David Sweet(Manning,2023)所著的《工程师的实验:从 A/B 测试到贝叶斯优化》一书。
对于列表 12.4 中 a_b_test 函数的每次运行,都会随机分配一个模型。然后,simulate_live_user_session 函数模拟用户使用查询和选定的模型进行搜索,浏览结果,可能点击并购买。我们不知道的是,我们的用户群体在他们的查询背后有隐藏的偏好,这些偏好由 simulate_live_user_session 模拟。我们运行 a_b_test 1,000 次,收集使用每个模型的用户的购买情况。
列表 12.4 对查询 transformers dvd 的模拟 A/B 测试
def a_b_test(query, model_a, model_b):
draw = random.random()
model_name = model_a if draw < 0.5 else model_b #1
purchase_made = simulate_live_user_session(query, #2
model_name) #2
return (model_name, purchase_made)
def simulate_user_a_b_test(query, model_a, model_b, number_of_users=1000):
purchases = {model_a: 0, model_b: 0}
for _ in range(number_of_users): #3
model_name, purchase_made = a_b_test(query, model_a, model_b) #3
if purchase_made: #4
purchases[model_name] += 1 #4
return purchases
results = simulate_user_a_b_test("transformers dvd",
"ltr_model_variant_1",
"ltr_model_variant_2")
display(results)
1 随机将每个用户分配给模型 a 或 b
2 模拟用户的搜索和购买行为
3 模拟测试的用户数量
4 统计每个模型的总购买数量
输出:
{"ltr_model_variant_1": 21,
"ltr_model_variant_2": 15}
正如列表 12.4 的输出所示,ltr_model_variant_2(我们的黄金学生)在这个 A/B 测试中实际上表现得更差!这怎么可能呢?它为什么会有如此好的离线测试指标性能,但在现实世界中表现不佳?
在本章的剩余部分,我们将深入探讨正在发生的事情,并尝试解决问题。因此,你将了解活用户如何提高你自动化 LTR 系统的准确性,让你有信心重新训练!
12.2.4 当“好”的模型变坏:我们可以从失败的 A/B 测试中学到什么
正如我们在列表 12.4 中所看到的,当我们的模型进入现实世界时,很多东西都可能发生变化。在本节中,我们将反思我们刚刚进行的 A/B 测试的影响,以确定下一步适当的行动。
当一个模型在实验室表现良好但 A/B 测试失败时,这意味着我们可能已经构建了一个“正确”的 LTR 模型,但我们构建的是错误的规范。我们需要纠正训练数据本身的问题:来自我们点击模型的判断。
但我们的基于点击模型的判断可能会出现哪些问题?我们在第十一章中看到了两个问题:位置偏差和置信度偏差。根据你的目标、用户体验和领域,可能会出现其他偏差。在电子商务中,用户可能会被吸引点击打折的商品,使数据偏向这些商品。在研究环境中,一篇文章可能在搜索结果中提供比另一篇文章更丰富的摘要。一些偏差模糊了“偏差”和该领域实际相关性之间的界限。例如,一个缺少图像的产品可能会得到更少的点击。它可能在技术上与另一个“相关”的产品相同,但用户可能会觉得缺少图像的产品不太可信,因此不会点击。这是偏差还是这个领域实际相关性的一个简单指标,其中产品可信度是一个因素?
为了做出更好的判断,我们应该忽略或降低点击的重要性,而应该使用其他行为信号吗?也许应该包括点击后的后续行动,比如点击“喜欢”按钮、将商品添加到购物车或点击“阅读更多”按钮?也许我们应该忽略用户在点击后立即点击后退按钮的“便宜”或意外点击?
考虑点击后的行为可能是有价值的。然而,我们必须问搜索排名对事件如购买或添加到购物车的影响有多强,或者它们是否归因于其他因素。例如,缺乏购买可能表明产品展示页面存在问题,或者复杂的结账流程存在问题,而不仅仅是特定查询的搜索结果的相关性。
我们可能会使用像总购买量这样的结果,在所有测试组的所有查询中汇总,来评估 A/B 测试。只要应用中的所有其他变量保持不变,除了排名算法,那么我们知道测试组之间购买量的任何显著差异必须是由我们改变的那一件事引起的。然而,在特定的查询到文档关系中,因果关系变得复杂。任何单一产品可能只有很少的购买(许多人看过 2000 美元的电视,但很少有人购买)。数据可能简单地缺乏足够的数量来知道购买是否仅与产品对查询的特定相关性有关。
考虑到搜索用户体验、领域和行为的所有变化,可能需要填满许多书籍,但仍会不足。搜索空间不断演变,新的与搜索结果交互的方式不断兴起和衰落。对于大多数情况,使用点击和标准点击模型就足够了。搜索界面中的点击已经被深入研究。然而,得出良好的判断既是艺术也是科学;你可能会发现对点击模型进行轻微修改,以考虑额外的信号对你的领域很重要,并且可能会在 A/B 测试中显著提高你的模型性能。你可以在完善你的点击模型上花费的时间,与你开发搜索引擎所花费的时间一样多。
然而,有一个普遍有害的训练数据问题挑战了所有点击模型:展示偏差。展示偏差发生在我们的模型无法从用户点击中学习相关内容,因为相关结果从未显示出来以便被点击。接下来,我们将深入研究这个难题,并学习如何通过同时优化模型已经学习到的内容和它们还需要学习的内容来克服这种偏差。
12.3 克服展示偏差:知道何时探索与何时利用
无论我们使用点击还是更复杂的信号,用户永远不会与那些他们看不到的东西互动。换句话说,在自动化的 LTR(学习到排名)背后,存在一个“先有鸡还是先有蛋”的问题:如果相关结果从未被原始的、调优不良的系统返回,那么基于点击的机器学习系统如何能够学会该结果是相关的呢?
在本节中,你将了解一种机器学习技术,该技术选择文档进行探索,尽管这些结果没有点击数据。我们自动化的 LTR 系统这个最后的缺失部分不仅帮助我们构建针对训练数据的优化模型,而且积极参与其自身的学习,以扩大可用训练数据的范围。我们将参与自身学习的系统称为主动学习系统。
图 12.4 展示了展示偏差。图右侧的项目可能对我们的查询具有可行性,但,没有流量,我们无法知道任何一方面。如果能够向这些结果发送一些流量,以了解用户是否认为它们是相关的,那将是很不错的。

图 12.4 查询dryer的展示偏差(与未探索的结果)
为了克服展示偏差,我们必须仔细平衡利用我们模型当前辛苦获得的知识和探索超出这些知识。这是探索与利用的权衡。探索让我们获得知识,扩大我们的点击模型覆盖范围,涵盖新的和不同类型的文档。然而,如果我们总是探索,我们就永远不会利用我们的知识。当我们利用时,我们优化的是我们目前知道表现良好的内容。利用对应于我们的当前 LTR 模型,该模型与我们的训练数据一致。知道如何系统地平衡探索和利用是关键,我们将在接下来的几节中讨论这一点,我们将使用为此目的构建的机器学习工具(高斯过程)进行讨论。
12.3.1 RetroTech 训练数据中的展示偏差
让我们先分析当前的训练数据,以了解情况。训练数据缺少哪些搜索结果?我们的知识在哪些方面不完整?另一种说法是“呈现偏差”,即可能相关的搜索结果被排除在训练数据之外:我们必须检测并对抗的盲点。一旦我们定义了这些盲点,我们就可以更好地纠正它们。这将为我们重新训练一个更稳健的模型奠定基础。
在列表 12.5 中,我们创建了一个名为explore_feature_set的新特征集,其中包含三个简单的特征:long_description_match、short_description_match和name_match,告诉我们是否发生了给定的字段匹配。这些对应于我们的模型已经学习到的特征。此外,我们还添加了一个has_promotion特征。如果产品正在促销并通过营销渠道推广,则该特征变为1.0。我们之前还没有探索这个特征;也许它是一个盲点?
列表 12.5 分析缺失的文档类型
def get_latest_explore_feature_set():
return [
ltr.generate_query_feature( #1
feature_name="long_description_match", #1
field_name="long_description", #1
constant_score=True), #1
ltr.generate_query_feature( #1
feature_name="short_description_match", #1
field_name="short_description", #1
constant_score=True), #1
ltr.generate_query_feature( #1
feature_name="name_match", #1
field_name="name", constant_score=True), #1
ltr.generate_query_feature( #2
feature_name="has_promotion", #2
field_name="has_promotion", value="true")] #2
def get_logged_transformers_judgments(sessions, features):
training_data = generate_training_data(sessions) #3
logged_judgments = generate_logged_judgments(training_data, #4
features, "explore") #4
logged_judgments = logged_judgments \ #5
[logged_judgments["query"] == "transformers dvd"] #5
return logged_judgments
explore_features = get_latest_explore_features()
logged_transformers_judgments = get_logged_transformers_judgments(sessions,
explore_features)
display(logged_transformers_judgments)
1 与已经用于训练 LTR 模型的字段相对应的特征。
2 我们正在探索的盲点新特征:促销
3 从当前原始会话构建 SDBN 判断
4 记录特征值并返回与特征值结合的 SDBN 判断
5 检查当前“transformers dvd”训练数据的属性
输出:
doc_id query grade long_desc*_match name_match
short_desc*_match has_promotion
----------------------------------------------------------------------------
97363560449 transformers dvd 0.34 0.0 0.0 1.0 0.0
97361312804 transformers dvd 0.34 0.0 0.0 1.0 0.0
97361312743 transformers dvd 0.34 0.0 0.0 1.0 0.0
97363455349 transformers dvd 0.34 0.0 0.0 1.0 0.0
...
708056579739 transformers dvd 0.01 1.0 1.0 1.0 0.0
879862003524 transformers dvd 0.01 1.0 1.0 1.0 0.0
93624974918 transformers dvd 0.01 0.0 0.0 1.0 0.0
47875839090 transformers dvd 0.01 1.0 0.0 1.0 0.0
我们可以在列表 12.5 的输出中看到训练数据知识的一些差距:
-
每个商品都包含一个名称匹配。
-
没有促销(
has_promotion=0)存在。 -
存在一系列
long_description_match和short_description_match的值。
直观地说,如果我们想扩展我们的知识,我们应该向搜索transformers dvd的用户展示一些完全不同于列表 12.5 输出的内容。这意味着向用户展示一个促销商品,可能是一个没有名称匹配的商品。换句话说,我们需要通过明确地多样化我们向用户展示的内容,远离训练数据中的内容,来让搜索跳出自己的回音室。唯一的问题是,我们愿意承担多大的风险来改善我们的知识?我们不想用随机产品覆盖搜索结果,只是为了拓宽我们的训练数据。
我们到目前为止所做的工作还没有系统化:我们只分析了一个查询,以查看缺少了什么。我们如何自动化这个过程?接下来,我们将讨论一种使用称为高斯过程的工具来自动化探索的方法。
12.3.2 超越临时性:用高斯过程深思熟虑地探索
高斯过程是一种统计模型,它做出预测并提供一个概率分布,捕捉该预测的确定性。在本节中,我们将使用高斯过程来选择探索的区域。在本章的后面部分,我们将创建一种更稳健的方法来寻找数据中的差距。
以实例为例:通过利用现有知识探索一条新河流
为了对高斯过程有一个直观的了解,让我们用一个现实生活中的探索具体例子。通过这个例子,你将能够更直观地思考我们如何可能在数学上进行探索与利用的权衡。
假设你是一位科学家,计划对荒野深处的一条很少被探索的河流进行考察。在你规划旅行时,你只有来自过去探险的零星河流深度观测数据,知道何时旅行是安全的。例如,一次观测显示四月河流深度为两米;另一次在八月,深度为一米。你希望为你的探险选择一个日期,以优化理想的河流条件(即不是雨季,也不是干旱期)。然而,你也是一个科学家——你希望在一年中尚未观测到的时间进行观测,以增加你对河流的了解。图 12.5 显示了全年进行的河流深度测量。

图 12.5 探索一条河流,随着我们远离过去的观测点,河流深度的不确定性逐渐增加。我们如何选择一个既安全又能最大限度地增加我们对河流深度了解的年份?
我们如何选择探险的日期呢?如果你在 4 月 14 日观测到河流深度为两米,你会猜测 4 月 15 日的深度将非常接近两米。在那个时间旅行可能会很愉快:你相信河流不会过度泛滥。然而,你不会从河流中获得很多知识。尝试在离这个观测几个月的 1 月去怎么样?1 月离 4 月太远,无法了解河流的预期深度。我们可能会在一年中危险的时间旅行,但我们几乎肯定会获得新的知识——可能比我们预期的要多得多!由于数据如此之少,在这个时间旅行可能会有太多的风险。
在图 12.5 中,我们看到基于相邻日期之间预期相关性的河流水平面的合理猜测(4 月 15 日和 14 日应该非常接近)。随着我们远离直接观测,我们的确定性降低,由扩大的灰色区域表示。
图 12.5 展示了一个高斯过程。它从数学上捕捉预测,以及我们对每个预测的不确定性。这与相关性排名有何关联?正如附近的日期有相似的河流水位,相似搜索结果在相关性上也会相似。考虑列表 12.5 中的探索特征。那些与 transformers dvd 有强烈名称匹配,未被推广,且没有简短/长描述匹配的,可能具有相似的相关性等级——所有都是适度相关。当我们远离这些常见的例子——也许添加推广项目——我们对我们的有根据的猜测变得越来越不确定。如果我们走得很远,远远超出框外,比如一个没有名称匹配但被推广的搜索结果,并且有强烈的简短/长描述字段匹配,我们的不确定性会非常高。就像考虑一月份旅行的科学家一样,我们几乎没有能力做出关于这些结果是否相关的良好猜测。向用户展示这些结果可能涉及太多的风险。
我们可以使用高斯过程来平衡利用现有的相关性知识与风险较高的探索以获得知识。高斯过程使用不完整的信息在可能的质量和获得的知识之间做出谨慎的权衡。例如,我们可以权衡理想的河流条件或可能的相关搜索结果,以换取更多关于河流条件或关于新类型搜索结果的相关性了解。我们可以谨慎地选择我们想要探索多远,以获得新的知识。
在我们的 transformers dvd 案例中,哪种搜索结果可能具有高收益,可能也是相关/安全的探索,同时也会最大限度地增加我们的知识?让我们训练一个高斯过程并找出答案!
训练和分析高斯过程
让我们动手看看高斯过程是如何工作的。我们将在 transformers dvd 查询上训练一个高斯过程。然后我们将使用它来生成最佳的探索候选者。您将看到我们如何评分这些探索候选者,以最大限度地减少我们的风险并增加我们获得知识的可能性。
在以下列表中,我们使用 sklearn 中的 GaussianProcessRegressor(也称为 gpr)来训练高斯过程。此代码创建了一个尝试预测 explore_feature_set 特征作为函数的相关性 grade 的高斯过程。
列表 12.6 在我们的训练数据上训练 GaussianProcessRegressor
from sklearn.gaussian_process import GaussianProcessRegressor
def train_gpr(logged_judgments, feature_names):
feature_data = logged_judgments[feature_names] #1
grades = logged_judgments["grade"] #2
gpr = GaussianProcessRegressor() #3
gpr.fit(feature_data, grades) #3
return gpr
1 使用来自列表 12.5 中 explore_feature_set 记录的特征
2 预测相关性等级
3 创建并训练 gpr 模型
一旦我们训练了一个 GaussianProcessRegressor,我们就可以用它来做出预测。记住,GaussianProcessRegressor 不仅预测一个值,它还返回该预测的概率分布。这有助于我们衡量模型的确定性。
在列表 12.7 中,我们生成了我们想要探索的可能特征值。在我们的河流探索示例中,这些值对应于我们科学家探险的可能探索日期。在我们的情况下,由于每个特征可以是0或1,我们查看每个可能的特征值作为候选。
列表 12.7 预测一组探索候选者
def calculate_prediction_data(logged_judgments, feature_names):
index = pandas.MultiIndex.from_product([[0, 1]] * 4, #1
names=feature_names) #1
with_prediction = pandas.DataFrame(index=index).reset_index()
gpr = train_gpr(logged_judgments, feature_names)
predictions_with_std = gpr.predict( #2
with_prediction[feature_names], return_std=True) #2
with_prediction["predicted_grade"] = predictions_with_std[0] #3
with_prediction["predicted_stddev"] = predictions_with_std[1] #3
return with_prediction.sort_values("predicted_stddev", ascending=True)
explore_features = get_latest_explore_features()
logged_transformers_judgments = get_logged_transformers_judgments(sessions,
explore_features)
feature_names = [f["name"] for f in explore_features]
prediction_data = calculate_prediction_data(logged_transformers_judgments,
feature_names)
display(prediction_data)
1 为我们想要探索的每个特征生成一个可能的值为 0 或 1 的候选矩阵
2 根据 gpr 概率分布预测候选者的等级和标准差
3 存储从 gpr 预测的等级和标准差
输出:
long_description_match name_match predicted_grade
short_description_match has_promotion prediction_stddev
0 0 1 0 0.256798 0.000004
1 0 1 0 0.014674 0.000005
1 1 1 0 0.014864 0.000007
0 1 1 0 0.022834 0.000010
1 0 1 1 0.018530 0.000010
0 0 1 1 0.161596 0.632121
1 1 1 1 0.014856 0.632121
在列表 12.7 的输出中,我们看到一个predicted_grade——gpr对示例相关性的合理猜测。我们还有prediction_stddev,它捕捉了图 12.5 中的灰色带——预测中的不确定性有多大。
我们在列表 12.7 的输出中注意到,对于前四个name_match=1的产品,标准差接近 0。换句话说,当name_match=1时,gpr往往更有信心。在这些观察之后,我们看到标准差急剧增加,因为我们缺乏这些初始名称匹配示例之外的大量知识。
输出开始显示出我们在列表 12.5 中直观检测到的展示偏差。我们发现关于名称匹配重要性的知识非常多,但关于其他情况的知识却很少。哪个案例值得与真实用户探索,同时还能最小化我们向用户展示搜索结果中完全陌生内容的风险?
在列表 12.8 中,我们使用一种称为“预期改进”的算法生成和评分探索候选者,该算法预测具有最高潜在收益的候选者。如果您想了解更多信息,我们建议阅读 Agnihotri 和 Batra 的文章“探索贝叶斯优化”(distill.pub/2020/bayesian-optimization)。
列表 12.8 计算探索的预期改进
def calculate_expected_improvement(logged_judgments, feature_names, theta=0.6):
data = calculate_prediction_data(logged_judgments, feature_names)
data["opportunity"] = (data["predicted_grade"] - #1
logged_judgments["grade"].mean() - theta) #1
#1
data["prob_of_improvement"] = (
norm.cdf(data["opportunity"] / #2
data["predicted_stddev"])) #2
data["expected_improvement"] = ( #3
data["opportunity"] * data["prob_of_improvement"] + #3
data["predicted_stddev"] * #3
norm.pdf(data["opportunity"] / #3
data["predicted_stddev"])) #3
return data.sort_values("expected_improvement", #4
ascending=False) #4
improvement_data = calculate_expected_improvement(
logged_transformers_judgments, feature_names)
display(improvement_data)
1 预测等级可能高于或低于典型等级吗?
2 考虑预测中的不确定性,我们改善平均值的概率
3 在概率、改进和改进幅度给定的情况下,可以获得的收益有多少。
4 对候选者进行排序,以显示最佳的探索候选者
输出:
long_description_match name_match opportunity expected_improvement
short_description_match has_promotion prob_of_improvement
0 0 0 1 -0.638497 0.234728 0.121201
0 1 0 1 -0.725962 0.213214 0.110633
0 0 0 0 -0.580755 0.232556 0.107853
1 1 0 1 -0.727500 0.204914 0.101653
0 1 0 0 -0.722661 0.181691 0.078549
预期改进越高,探索候选者的预测收益就越高。但算法如何量化这种潜在收益呢?要么是我们高度确信存在收益(标准差低且预测等级高),要么是我们知道存在高度不确定性,但预测等级仍然足够高,可以冒险。我们可以在列表 12.8 的以下代码中看到这一点:
data["expected_improvement"] = (
data["opportunity"] * data["prob_of_improvement"] +
(data["prediction_stddev"] * norm.pdf(data["opportunity"] /
data["prediction_stddev"])))
这个第一个表达式覆盖了一个更有把握的机会:
data["opportunity"] * data["prob_of_improvement"]
同时,这个第二个表达式(在+之后)覆盖了一个具有广泛变异性的未知机会:
data["prediction_stddev"] * norm.pdf(data["opportunity"] /
data["prediction_stddev"])
在第一个表达式中,你会注意到机会(我们期望获得多少)乘以改进发生的概率对应于对良好结果的信心。另一方面,第二个表达式更多地依赖于标准差。标准差和机会越高,被选中的可能性就越大。
我们可以使用一个名为theta的参数来校准我们的风险容忍度:这个值越高,我们越偏好标准差更高的候选者。高theta会导致opportunity向 0 减少。这会使评分偏向第二个表达式——未知、标准差更高的案例。
如果我们将theta设置得太高,我们的gpr在选择学习候选者时不会考虑它们是否可能对用户有用。如果theta太低,我们不会探索很多新的候选者,而会偏向于现有知识。高theta类似于图 12.5 中科学家在 1 月份承担高风险(如探索)的情况,而非常低的theta则对应于在风险规避时期(如 4 月中旬)旅行。因为我们使用这个算法来增强现有的 LTR 系统,所以我们选择了theta为0.6(略高)以获得更多的知识。
在列表 12.8 的输出中,我们看到gpr证实了我们的早期临时分析:我们应该向用户展示具有推广的商品。这些产品更有可能产生更多的知识,可能从赌博中获得高额回报。
现在我们已经确定了我们应该探索的产品类型,让我们从搜索引擎中收集产品以向用户展示。以下列表显示了我们可以如何选择用于探索的产品,这些产品我们可以在以后将其穿插到现有模型的搜索结果中。
列表 12.9 从搜索引擎中选择要探索的产品
def explore(query, logged_judgments, features): #1
feature_names = [f["name"] for f in features]
prediction_data = calculate_expected_improvement(logged_judgments,
feature_names)
explore_vector = prediction_data.head().iloc[0][feature_names] #2
return search_for_explore_candidate(explore_vector, query) #3
explore_features = get_latest_explore_features()
logged_judgments = get_logged_transformers_judgments(sessions,
explore_features)
exploration_upc = explore("transformers dvd", logged_judgments,
explore_features)["upc"]
print(exploration_upc)
1 根据提供的探索向量进行探索,并从该组中随机选择一个文档
2 根据 expected_improvement 提取最佳的探索候选者特征
3 搜索符合标准的候选者
输出:
826663114164 # Transformers: The Complete Series [25th Anniversary ... ]
在列表 12.9 中,我们选取了最佳的探索候选者——推广的商品,并发出查询以获取具有这些特性的文档。我们在这里省略了将候选者转换为查询(explore_query函数)的较低级翻译,但你可以想象,如果候选者中的has_promotion=1.0,那么我们会发出一个搜索任何具有推广(has_promotion=true)的商品的查询,以及其他特征的类似情况。
我们在列表 12.9 的输出中看到,对于查询transformers dvd,随机选择的用于探索的推广产品是 826663114164。这对应于Transformers: The Complete Series [25th Anniversary Matrix of Leadership Edition] [16 Discs] - DVD。有趣!
我们应该如何处理这份文档?这归结为一个设计决策。一个常见的做法是将它放入结果列表的第三个位置,这样用户仍然可以首先看到之前最相关的结果,同时也确保了探索结果获得高可见度。注意我们的 826663114164 文档位于第三个位置(rank=2.0)。
doc_id product_name sess_id query rank click
93624974918 Transformers: Revenge O... 100049 transformers dvd 0.0 False
879862003524 Razer - DeathAdder Tran... 100049 transformers dvd 1.0 False
826663114164 Transformers: The Compl... 100049 transformers dvd 2.0 False
708056579739 Nintendo - Transformers... 100049 transformers dvd 3.0 False
我们在附带的笔记本中模拟了许多类似的探索会话。由于我们没有实际的用户在使用我们的应用,模拟代码仅用于演示目的,以便我们可以在自动化的 LTR 系统中集成探索候选者进行主动学习。在实际的生产环境中,你会向真实用户展示结果,而不是模拟用户会话。
每个会话都会根据列表 12.9 添加一个随机的探索候选者,模拟添加的探索结果是否被点击,并将其附加到新的会话集合中:sessions_with_exploration。回想一下,这些会话作为我们需要计算 LTR 训练数据(第十一章中生成的基于 SDBN 的判断,如列表 12.1 所示)的输入。
最后,我们有了重新运行我们的自动化 LTR 训练循环所需的数据。我们将看到这些示例添加到我们的训练数据中会发生什么,以及我们如何将这次探索整合到整体自动化 LTR 算法中。
12.3.3 检查我们探索的结果
我们通过向(模拟的)真实用户展示一些非传统的搜索结果进行了探索。现在,我们有了新的会话附加到存储在 sessions_with_exploration 数据框中的原始会话数据。在本节中,我们将运行会话数据通过我们的自动化 LTR 函数以再生训练数据并训练模型。然后,我们将运行这个新模型进行 A/B 测试以查看结果。
如你所回忆的,我们的自动化 LTR 辅助工具可以使用 generate_training_data 函数再生训练数据。我们在列表 12.10 中这样做,但这次使用的是包含探索数据的增强会话。
列表 12.10 从新会话中再生 SDBN 判断
query = "transformers dvd"
sessions_with_exploration = generate_simulated_exploration_sessions(
query, sessions, logged_transformers_judgments, explore_features)
training_data_with_exploration = \
generate_training_data(sessions_with_exploration)
display(training_data_with_exploration.loc["transformers dvd"])
输出:
doc_id product_name click examined grade beta_grade
97360724240 Transformers: Revenge of... 43 44 0.977 0.833333
826663114164 Transformers: The Comple... 42 44 0.954 0.814815
97360722345 Transformers/Transformer... 46 55 0.836 0.738462
97363455349 Transformers - Widescree... 731 2113 0.345 0.345266
97361312804 Transformers - Widescree... 726 2109 0.344 0.343558
我们在列表 12.10 的输出中看到一个新的产品被包含在内。特别注意的是,826663114164 的添加,Transformers: The Complete Series [25th Anniversary Matrix of Leadership Edition] [16 Discs] - DVD。有趣的是,这部电影有 has_promotion=true,这意味着它是上一节中从新选择的探索候选者之一:
{"upc": "826663114164",
"name": "Transformers: The Complete Series [25th Anniversary ...] - DVD",
"manufacturer": "",
"short_description": "",
"long_description": "",
"has_promotion": True}
看起来用户被促销产品所吸引,所以让我们将 has_promotion 特征从探索特征集移动到我们的主模型中,并重新训练以查看效果。在下面的列表中,我们将训练一个包含这个新特征的模型以观察其效果。
列表 12.11 使用更新后的判断重建模型
promotion_feature_set = [
ltr.generate_fuzzy_query_feature(feature_name="name_fuzzy",
field_name="name"),
ltr.generate_bigram_query_feature(feature_name="name_bigram",
field_name="name"),
ltr.generate_bigram_query_feature(feature_name="short_description_bigram",
field_name="short_description"),
ltr.generate_query_feature(feature_name="has_promotion", #1
field_name="has_promotion", #1
value="true", #1
constant_score=True)] #1
evaluation = train_and_evaluate_model(sessions_with_exploration,
"ltr_model_variant_3",
feature_set)
display(evaluation)
1 将 has_promotion 添加到我们训练模型时使用的特征集
对 ltr_model_variant_3 的评估:
{"dryer": 0.12737002598513025, # Before: 0.071
"blue ray": 0.08461538461538462, # 0.0
"headphones": 0.12110565745285455, # 0.065
"dark of moon": 0.1492224251599605, # 0.258
"transformers dvd": 0.26947504217124457} # 0.101
哇!当将列表 12.11 与列表 12.3 的早期输出进行比较时,我们看到在大多数情况下,将推广产品添加到训练数据中在我们的离线测试评估中产生了显著的改进。特别是transformers dvd的精确度显著提高!如果我们发出对transformers dvd的搜索,我们会在数据中看到这一点。
列表 12.12 使用最新模型搜索transformers dvd
results = ltr.search_with_model("ltr_model_variant_3",
query="transformers dvd",
rerank_query="transformers dvd",
limit=5)["docs"]
display([doc["name"] for doc in results])
输出:
["Transformers/Transformers: Revenge of the Fallen: Two-Movie Mega Coll...",
"Transformers: Revenge of the Fallen - Widescreen - DVD",
"Transformers: Dark of the Moon - Original Soundtrack - CD",
"Transformers: The Complete Series [25th Anniversary Matrix of Leaders...",
"Transformers: Dark of the Moon Stealth Force Edition - Nintendo Wii"]
然而,我们知道看起来很好的测试结果并不总是能转化为现实世界。当我们重新运行列表 12.4 中的 A/B 测试时会发生什么?如果你还记得,我们创建了一个a_b_test函数,该函数随机为用户的搜索选择一个模型。如果结果包含用户秘密想要购买的项目,那么购买很可能会发生。如果我们使用这个函数来重新模拟 A/B 测试,我们看到我们的新模型似乎已经中了大奖!
列表 12.13 在新的ltr_model_variant_3模型上重新运行 A/B 测试
results = simulate_user_a_b_test(query="transformers dvd",
model_a="ltr_model_variant_1",
model_b="ltr_model_variant_3",
number_of_users=1000)
display(results)
输出:
{"ltr_model_variant_1": 21,
"ltr_model_variant_3": 145}
现在,我们看到新模型(ltr_model_variant_3)在 A/B 测试中显著优于旧模型(ltr_model_variant_1)。我们现在知道,我们的探索不仅帮助我们发现了训练数据中的理论差距,而且当我们在针对目标查询(transformers dvd)的真实世界场景中测试新模型时,它的表现比旧的“仅利用”模型要好得多。虽然我们在这章中关注了一个特定的查询,但同样的过程可以应用于许多查询和探索候选者,以继续使用主动学习自动优化你的 LTR 模型。
我们现在已经实施了一个自动化的 LTR 系统,它不仅从最新的用户信号中重新学习,而且还使用主动学习来探索可能对活跃用户相关的其他内容,然后收集相应的信号来衡量他们的反馈。这个主动学习过程有助于持续地消除训练数据中的盲点。
12.4 利用、探索、收集、清洗、重复:一个健壮的自动化 LTR 循环
当所有最终部分就绪时,我们看到探索新特性如何帮助我们克服展示偏差。特性探索和训练数据探索是相辅相成的,因为我们通过理解我们所缺乏的特性以及可能需要将其工程化到搜索中的特性,来学习我们的展示偏差。在本章中,我们使用了一个简单的“促销”例子,但还有哪些更复杂的功能可能会在你的训练数据中显示出盲点?在本节中,让我们通过增强第十一章中的自动化 LTR 算法来结束,不仅包括使用基于先前点击模型训练数据的模型训练,还包括探索训练数据当前范围之外的新主动学习方法。
我们新的自动探索自动化 LTR 算法可以总结为以下三个主要步骤:
-
利用—使用已知特性,并使用现有训练数据训练 LTR 模型以进行排名。
-
探索—生成假设的、“探索”特性,以消除训练数据盲点。
-
收集——使用部署的模型和训练好的
gpr模型,展示探索/利用搜索结果,并收集点击以建立判断。
我们可以通过将它们合并到列表 12.14 中,总结过去三章的内容,如以下所示。此列表将所有部分组合在一起(省略了一些内部细节)。我们在这个算法中的主要决策点是用于探索和利用的特征。我们还可以深入了解,更改选择的点击模型、LTR 模型架构以及我们的风险容忍度(theta参数)。
列表 12.14 总结完全自动化的 LTR 算法
def train_and_deploy_model(sessions, model_name, feature_set):
judgments = generate_training_data(sessions)
train, test = split_training_data(judgments, 0.8)
train_ranksvm_model(train, model_name, feature_set=feature_set)
def ltr_retraining_loop(latest_sessions, iterations=sys.maxsize,
retraining_frequency=60 * 60 * 24): #1
exploit_feature_set = get_exploit_feature_set() #2
train_and_deploy_model(latest_sessions, #2
"exploit", #2
exploit_feature_set) #2
for i in range(0, iterations): #3
judgments = generate_training_data(latest_sessions)
train, test = split_training_data(judgments)
if i > 0:
previous_explore_model_name = f"explore_variant_{i-1}"
exploit_model_evaluation = evaluate_model(test_data=test,
model_name="exploit", training_data=train)
explore_model_evaluation = evaluate_model(test_data=test,
model_name=previous_explore_model_name, training_data=train)
print(f"Exploit evaluation: {exploit_model_evaluation}")
print(f"Explore evaluation: {explore_model_evaluation}")
if is_improvement(explore_model_evaluation, #4
exploit_model_evaluation): #4
print("Promoting previous explore model")
train_and_deploy_model(latest_sessions,
"exploit",
explore_feature_set)
explore_feature_set = get_latest_explore_feature_set() #5
train_and_deploy_model(latest_sessions, #5
f"explore_variant_{i}", #5
explore_feature_set) #5
wait_for_more_sessions(retraining_frequency) #6
latest_sessions = gather_latest_sessions( #7
"transformers dvd", #7
latest_sessions, #7
explore_feature_set) #7
ltr_retraining_loop(sessions)
1 每天返回一次模型
2 在已知良好特征和当前训练数据上训练 LTR 模型
3 收集新会话并重复该过程
4 评估当前探索变体,如果它比当前探索模型更好,则提升它
5 假设新的特征以探索盲点
6 收集用户信号,直到重新训练模型的时间
7 收集新会话并重复该过程
在这个循环中,我们捕捉到一个更好的自动 LTR 过程。我们通过理论化可能隐藏在背后的特征来主动学习我们的训练数据的盲点。在让循环运行的同时,我们可以观察其性能并决定何时将“探索”特征提升到完整的生产“利用”特征集。当我们淘汰旧的点击数据时,我们还可以注意旧特征何时不再重要,以及何时由于趋势和季节性,新特征变得重要。我们的实现使用手动调优的利用和探索特征集来保持我们的搜索相关性团队对特征工程的控制,但你当然可以编写一个生成新特征的算法,或使用深度学习来发现潜在特征,或使用基于现有内容属性的某些其他方法。
总体而言,这些算法提供了一种稳健的机制,以全面考虑可能向用户展示的选项范围,从而接近理想的排名。它们让您可以选择新的特征来调查盲点,达到一个最大化用户在搜索结果中希望看到的关联算法。
摘要
-
在离线测试中表现良好表明我们的特征可以近似训练数据。然而,这并不能保证成功。A/B 测试可以向我们展示训练数据本身可能具有误导性的情况。
-
训练数据必须监控偏差并仔细纠正。
-
展示偏差是搜索中最有害的相关性问题之一。展示偏差发生在我们的模型无法从用户点击中学习相关内容,因为结果从未显示出来供点击。
-
我们可以通过使自动 LTR 过程成为寻找训练数据盲点的积极参与者来克服展示偏差。执行此操作的模型参与主动学习。
-
高斯过程是选择有前景的探索机会的一种方法。通过一组特征,我们可以找出训练中的缺失部分,并根据哪些项目可能为持续学习提供最有用的新数据点来选择新的项目展示给用户。我们可以通过不同的方式通过特征来描述数据,以寻找新的有趣盲点和调查领域。
-
当我们将利用现有知识与探索盲点相结合时,我们便拥有了一个更稳健、自动化的 LTR 系统——反映智能,它可以自动探索和利用特征,且几乎不需要内部维护。
第四部分 搜索前沿
嵌入和生成式 AI 的兴起对信息检索领域来说是一大福音。不仅大型语言模型(LLMs)和其他基础模型提供了理解和生成文本的新方法,而且它们也作为搜索引擎的完美补充。生成模型需要可靠的数据作为上下文(这是搜索引擎提供的),而搜索引擎需要解释和总结它们搜索到的数据(这是生成式 AI 模型提供的)。
在第四部分,我们将探索搜索的前沿。我们将探讨生成模型如何被用来改进搜索,以及搜索如何被用来增强生成模型。我们还将探讨 AI 和信息检索交叉领域的未来新兴趋势。
第十三章涵盖了基于嵌入的语义搜索,解释了 Transformers 的工作原理以及如何使用近似最近邻(ANN)和量化方法优化密集向量的语义搜索效率。第十四章演示了如何在您的数据上微调一个 LLM 并实现抽取式问答:对查询中的问题进行回答,这些答案是从搜索结果中提取的。
第十五章通过讨论 AI 驱动搜索中的新兴技术来总结。我们将探讨生成式搜索技术,展示用于动态搜索结果摘要的检索增强生成(RAG),使用生成模型生成合成训练数据,以及评估生成模型质量。我们最终将展示多模态搜索(跨文本和图像)和混合搜索(结合词汇和密集向量搜索),并探讨生成式 AI 时代的搜索未来。
第十三章:使用密集向量进行语义搜索
本章涵盖
-
使用 LLMs 的嵌入进行语义搜索
-
Transformers 简介及其对文本表示和检索的影响
-
使用 Transformer 模型构建自动补全
-
使用 ANN 搜索和向量量化来加速密集向量检索
-
使用双编码器和交叉编码器进行语义搜索
在本章中,我们将开始我们的密集向量搜索之旅,其中大型语言模型(LLMs)生成的超上下文向量将显著提高对查询、文档和搜索结果的解释。生成式 LLMs(如 OpenAI 的 ChatGPT 和许多其他商业和开源替代品)也能够使用这些向量生成新内容,包括查询扩展、搜索训练数据和搜索结果摘要,我们将在接下来的章节中进一步探讨。
LLMs(大型语言模型)的尖端技术每月(有时几乎是每日)都在变化,但即使是最好的通用模型,在特定任务上也可能被针对这些任务微调的其他较小模型所超越。在接下来的几章中,我们将讨论 LLMs 背后的概念以及如何在您的搜索应用中最佳地使用它们。在本章中,我们将介绍 Transformers,并讨论如何使用它们进行语义搜索和密集向量。在第十四章中,我们将讨论如何微调 LLM 以进行问答,在第十五章中,我们将讨论如何利用 LLMs 和其他基础模型进行生成式搜索。
我们的故事从你在 2.5 节中学到的内容开始:我们可以将上下文表示为数值向量,我们可以使用相似度度量来比较这些向量,看哪些更接近。在第二章中,我们展示了在密集向量上搜索的概念,这是一种称为密集向量搜索的技术,但我们的例子简单且人为(基于虚构的食物属性进行搜索)。在本章中,我们将提出问题:“我们如何将现实世界的非结构化文本转换为高维密集向量空间,该空间试图模拟文本表示的实际意义?”以及“我们如何使用这种知识表示来为高级搜索应用服务?”
13.1 通过嵌入表示意义
我们将使用语言翻译作为例子来理解我们所说的“密集向量”嵌入的含义。以下两句:“Hello to you!”(英语)和“Barev Dzes”(亚美尼亚语)。这两个表达大约具有相同的意义:每个都是问候,带有一些隐含的正式感。
计算上,为了成功回应“Hello to you!”这样的问候,机器必须同时理解提示的意义以及所有可能的理想回应,在相同的向量空间中。当答案确定后,机器必须通过从答案的向量表示中生成标签来表达它给一个人的信息。
这种意义的向量表示称为嵌入。嵌入在自然语言处理(NLP)任务中可以互换使用,并且可以进一步塑形以满足特定用例。我们在第九章中生成了来自 LLM(all -mpnet-base-v2)的嵌入,但我们忽略了关于嵌入如何工作的大多数细节。在本章中,我们将介绍从文本中获取嵌入的技术和工具,并将它们用于显著增强我们搜索引擎中的查询和文档解释。
自然语言处理
自然语言处理(NLP)是一套技术和工具,它将非结构化文本转换为机器可操作的数据。NLP 领域相当广泛,包括许多研究领域和待解决的问题(NLP 任务)。NLP-Progress 网站上维护了一个问题领域 comprehensive 列表(nlpprogress.com)。
我们将特别关注将自然语言处理(NLP)应用于信息检索,这是人工智能搜索的一个重要要求。
一个重要的要点需要提前指出:在我们提到的两个简短的英语和亚美尼亚问候语背后,有着深刻的文化细微差别。每一个都承载着丰富的历史,学习它们因此承载了这些历史的背景。这与我们在第五章中探讨的语义知识图谱的情况相同,但那些只使用了搜索引擎内文档的上下文作为它们的模型。Transformer 通常在更大的文本语料库上训练,从而从外部来源引入了更多的这种细微的上下文。
我们可以将人脑作为 Transformer 模型如何学习表示意义的类比。你们作为婴儿、儿童、青少年以及更久之前,是如何学习词语的意义的呢?你们被告知,并吸收了知识和其表现形式。教你们的人已经拥有了这种知识和表达能力。除了有人指出一只猫并对你们说“kitty”之外,你们还观看了电影和视频,然后转向文学和教学材料。你们阅读书籍、博客、期刊和信件。通过所有这些经历,你们将这些知识融入大脑,创建了一个概念及其相互关系密集的表示,使你们能够对它们进行推理。
我们能否将我们从语言能力中获得的内容传授给机器,并期待它们在查询时能够理解和合理地做出回应?请系好你们的帽子!
13.2 使用密集向量进行搜索
理解何时使用密集向量进行搜索而不是稀疏向量,需要了解如何处理和关联文本。本节简要回顾了与密集向量搜索相比,稀疏向量搜索是如何工作的。我们还将介绍最近邻搜索作为密集向量搜索中用于相似度的一种类型,与 BM25(用于稀疏向量搜索的最常见相似度函数)相比。
基于向量的最近邻搜索
也称为 KNN(k-最近邻),基于向量的最近邻搜索是将具有统一维度的数值向量索引到数据结构中,并使用查询向量搜索该数据结构以找到最近的k个相关向量的问题空间。我们在第三章中提到,有许多相似度度量用于比较数值向量:余弦相似度、点积、欧几里得距离等。在本章中,我们将使用余弦相似度(作为单位归一化向量上的点积实现)进行向量相似度比较。
13.2.1 稀疏向量的简要复习
稀疏向量搜索通常使用倒排索引实现。倒排索引就像你在任何教科书后面找到的那样——一个列出术语及其在源内容中位置的列表。为了有效地查找文本,我们通过处理和规范化标记到一个字典中,该字典包含对帖子(文档标识符和它们出现的位置)的引用来构建索引。结果数据结构是一个稀疏向量表示,允许快速查找这些标记。
在搜索时,我们对查询词进行分词和规范化,并使用倒排索引匹配文档命中以进行检索。然后我们应用 BM25 公式对文档进行评分并按相似度进行排序,正如我们在第 3.2 节中所述。
为每个查询词和文档特征应用分数可以为我们提供快速且相关的搜索,但这个模型受限于“查询词依赖”的相关性模型,其中检索和排序的是术语(及其规范化形式)。问题是它使用查询词字符串的存在(和计数)来搜索和排序,而不是这些字符串所代表的意义。因此,相关性分数仅在相对意义上有用,只能告诉你哪些文档与查询最匹配,但不能衡量任何文档是否是客观上的良好匹配。正如我们将看到的,密集向量方法可以提供更全局的相关性感知,并且可以在查询之间进行比较。
13.2.2 概念密集向量搜索引擎
当我们处理文档时,我们希望捕捉内容的意义,当搜索时,我们希望根据查询的意义和意图进行检索和排序。带着这个目标,我们处理文档以生成嵌入,然后将这些嵌入存储在搜索索引中。在搜索时,我们处理查询以获取嵌入,并使用这些查询嵌入来搜索索引中的文档嵌入。图 13.1 显示了此过程的简化图,我们将在第 13.4 节中对其进行扩展。

图 13.1 构建和搜索嵌入索引。内容从左侧处理并添加到索引中,用户查询索引以检索结果。
文档和查询的嵌入存在于同一个向量空间中。这一点非常重要。如果您将文档映射到一个向量空间,而将查询映射到另一个向量空间,您将会匹配苹果和橙子。为了有效地工作,嵌入必须属于同一个空间。
但“嵌入”究竟是什么,我们如何搜索它呢?嗯,嵌入是表示某些集合数量的维度的向量,代表信息。这些信息可以是查询、文档、单词、句子、图像或视频,或任何其他类型的信息。
嵌入范围和分块
在处理嵌入时,一个重要的工程任务是确定嵌入的正确粒度级别。嵌入可以用来表示单个单词、句子、段落或更大的文档。
在生成嵌入时,通常很有用将较大的文档分解成部分,并为每个部分生成一个单独的嵌入,这个过程称为分块。您可以通过句子、段落或其他概念边界来分块内容,甚至可以创建重叠的分块以确保分割文档的过程不会破坏分割之间的相关上下文。
如果您的搜索引擎支持多值向量场,您可以将多个嵌入索引到单个文档中,并根据其任何嵌入进行匹配。或者,您可以为每个块索引一个单独的文档,每个文档包含一个嵌入,并将原始文档 ID 作为字段存储,以便在索引的块文档匹配时返回。
对于非常大的块来说,很难完全由嵌入表示,就像对于非常小的块来说,很难包含嵌入所需的全部上下文一样,因此确定您应用程序的正确分块粒度可能是提高召回率的一个重要考虑因素。
由于嵌入表示为向量,我们可以使用余弦相似度(在第 2-3 章中进行了深入探讨)或另一种类似的距离测量来比较两个嵌入向量,并得到一个相似度分数。这允许我们将查询的向量与我们要搜索的内容中所有文档的向量进行比较。与查询向量最相似的文档向量被称为最近邻。图 13.2 用三个二维向量说明了这一点。
按照相似度从高到低的顺序,图 13.2 中显示的向量之间的余弦相似度如下:
-
cos(b, c) = 0.9762 -
cos(a, b) = 0.7962 -
cos(a, c) = 0.6459
从视觉和数学上都很明显,b和c彼此最接近,因此我们说b和c是这三个向量中最相似的。

图 13.2 在笛卡尔平面上绘制了三个向量(a、b 和 c)。使用 cosq函数说明了 a 和 b 之间,以及 b 和 c 之间的相似性。
我们可以轻松地将余弦相似度应用于任何长度的向量。在三维空间中,我们比较具有三个特征 [x, y, z] 的向量。在密集向量嵌入空间中,我们可能使用具有数百或数千维的向量。但无论维数的数量如何,公式都是相同的,如图 13.3 所示。

图 13.3 两个向量余弦相似度的公式
详见第 3.1 节,回顾使用这种余弦相似度计算来评分向量之间的相似度。在那里,我们探讨了计算向量之间的余弦相似度和点积的示例。然而,从余弦相似度的公式(图 13.3)中,你可以看到余弦值等于点积(a . b)除以向量长度的乘积(|a| x |b|)。这意味着如果我们能将向量a和b的特征归一化,使得每个向量的长度都是1(这个过程称为单位归一化),那么余弦相似度和点积是相等的:
1 = |a| = |b|
cos(a, b) = (a . b) / |a| x |b|
cos(a, b) = (a . b) / (1 x 1)
cos(a, b) = a . b
当一个向量的长度等于1时,它被称为单位归一化向量。但为什么我们会关心这样的向量归一化呢?好吧,实际上计算点积比计算余弦相似度要高效得多,因为不需要除以每个向量的模长(这需要使用勾股定理来计算每个向量特征的平方和的平方根)。由于余弦计算通常是评分大量文档时搜索中最昂贵的部分,因此在索引时间对向量进行单位归一化,并在搜索时间使用单位归一化查询向量与索引向量进行点积,可以显著加快搜索速度,同时提供相同的结果:
vector_a = [5.0, 3.0]
vector_b = [6.0, 2.0]
unit_vector_a #1
= unit_normalize(vector_a) #1
= unit_normalize([5.0, 3.0]) #1
= [5.0 / sqrt(5.0² + 3.0²), 3.0 / sqrt(5.0² + 3.0²)] #1
= [0.8575, 0.5145] #1
#1
unit_vector_b #1
= unit_normalize(vector_b) #1
= unit_normalize([6.0, 2.0]) #1
= [6.0 / sqrt(6.0² + 2.0²), 2.0 / sqrt(6.0² + 2.0²)] #1
= [0.9487, 0.3162] #1
cos(vector_a, vector_b) #2
= cos([5.0, 3.0], [6.0, 2.0]) #2
= (5.0 x 6.0 + 3.0 x 2.0) / #2
(sqrt(5.0² + 3.0²) x sqrt(6.0² + 2.0²)) #2
= 0.9762 #3
dot_product(unit_vector_a, unit_vector_b) #4
= dot_product([0.8575, 0.5145], [0.9487, 0.3162]) #4
= (0.8575 x 0.9487) + (0.5145 x 0.3162) #4
= 0.9762 #3
1 将向量 a 和 b 归一化为单位向量。所有索引向量在索引之前都会进行这种归一化处理。
2 完整的余弦相似度计算。注意分母对每个向量的平方和进行开方运算。
3 cos(vector_a, vector_b) = dot_product(unit_vector_a, unit_vector_b) = 0.9762
4 单位归一化向量的点积计算。注意没有分母,并且乘积特征权重的和要简单得多。
虽然从概念上我们仍在执行余弦相似度(由于单位归一化向量),但使用点积允许我们在查询时进行实质上更快的计算。由于这种优化是可能的,因此在生产中执行完整的余弦相似度计算在性能上并不是一个好主意。虽然有一些针对特定用例的好理由,你可能会选择执行余弦而不是点积,例如忽略或考虑向量的幅度(参见第 3.1.4 节以刷新记忆),但你几乎总是至少实现使用单位归一化向量和点积计算来执行余弦相似度,出于性能原因。为了强化最佳实践,当实现余弦相似度时,我们将一致地在所有剩余的代码列表中利用这种模式。
优化向量搜索的计算性能和成本
执行向量相似度计算可能在大规模下变得缓慢且计算成本高昂,因此了解如何进行正确的权衡以优化性能和成本是很重要的。
由于点积的计算速度比余弦相似度快得多,我们建议你始终通过索引单位归一化向量,然后在搜索时在文档向量和单位归一化查询向量之间执行点积计算来实现余弦相似度。此外,使用其他优化技术通常可以显著节省内存并大幅提高搜索时间:
-
使用近似最近邻(ANN)方法快速筛选出前N个结果进行排名,而不是所有文档(在第 13.5.3 节中介绍)
-
将向量量化(压缩)以减少表示向量中每个特征所需的位数(在第 13.7 节中介绍)
-
使用玛特罗什卡表示学习(MRL)仅索引和/或搜索嵌入的关键部分,同时仍然保持大部分召回率(在第 13.7 节中介绍)
-
使用更便宜的搜索算法或相似度度量标准过度请求有限数量的优化搜索结果,然后使用更昂贵的相似度度量标准重新排名前N个结果(在第 13.5.3 节和 13.7 节中介绍)
在能够执行密集向量搜索和最近邻相似度的情况下,下一个关键步骤是找出生成这些神秘嵌入的方法。
13.3 通过使用 Transformer 编码器获取文本嵌入
在本节中,我们将介绍 Transformer 及其如何表示意义。我们还讨论了它们如何被用来将这种意义编码到嵌入中。
13.3.1 什么是 Transformer?
Transformers 是一类深度神经网络架构,它们被优化为将意义编码为嵌入,并从嵌入中解码意义。基于文本的 Transformers 通过首先使用句子中的周围上下文(编码部分)将术语标签表示为密集向量,然后利用输出模型将向量转换为不同的文本表示(解码部分)来实现这一点。
这种方法的一个美妙特性是编码和解码之间的关注点分离。我们将利用这个特性,仅使用编码机制来获取嵌入,然后我们可以将其用作与任何解码步骤无关的意义的语义表示。
表示意义
回想一下第 13.1 节介绍中的英语和亚美尼亚问候语示例。使用专门为英语到亚美尼亚语言翻译设计的 Transformer 和数据集,可以训练一个模型将两个短语“Hello to you!”和“Barev Dzes”编码成几乎相同的密集向量。然后,这些向量可以被解码回文本,以翻译或重构更接近原始文本的内容。
让我们从理解 Transformer 编码模型的训练方式和它们最终学习到什么开始我们的 Transformer 之旅。为了理解 Transformer 背后的动机和机制,了解一些基础概念的历史是很重要的。
那一年是 1953 年。你发现自己在一个教室里,和其他 20 个学生一起,每个人坐在自己的桌子前。你的桌子上有一支铅笔和一张纸,上面写着句子 Q: I went to the ________ and bought some vegetables. 你已经知道该怎么做,你在空白处写下“store”。你瞥了一眼坐在你旁边桌子上的同学,他们写的是“market”。一阵铃声响起,答案被统计。最常见的答案是“store”,还有几个答案是“market”和“grocer”。这是完形填空测试。它的目的是测试阅读理解。
现在,你被带到了 1995 年。你坐在另一个教室里,学生们正在参加另一场考试。这次,你的试卷上有一段非常长的段落。看起来大约有 60 个单词长,有些复杂:
我们所在的是沼泽地区,靠近河流,随着河流的蜿蜒,离海有二十英里。我在一个难忘的阴雨下午接近傍晚时,似乎获得了对事物本质的第一印象。在这样的时刻,我确信这个长满荨麻的荒凉地方是教堂墓地。
在段落之后,列出了一个带有答案提示的问题:Q: How far away from the sea is the churchyard? A:________。你在空白处写下,“二十英里”。你刚刚完成了一打问题中的一个问题,这是纽约州教育委员会英语阅读理解测试的一部分。具体来说,这是测试你的注意力。
这两项测试是我们衡量书面语言理解能力的基础。要能够通过这些测试,你必须阅读,阅读,再阅读,并且还要多读一些。实际上,当大多数人在学校参加这些测试时,他们已经练习阅读大约 14 年了,并且积累了大量的背景知识。
这些理论构成了 LLMs(在大量文本上训练的自然语言处理模型)的基础——例如,整个网络 Common Crawl 数据集。
自然语言处理领域的重大突破在 2018 年由谷歌的研究人员(雅各布·德夫林等人)发表的一篇论文中达到顶峰,这篇论文的标题是 BERT:用于语言理解的深度双向变换器预训练,它利用了 Cloze 测试和 Transformer 中的注意力机制,在许多语言理解基准测试中达到了最先进的性能(arxiv.org/pdf/1810.04805)。
BERT 特别通过向自己展示 Cloze 测试来进行自我学习。训练方式是“自监督”,这意味着它是监督学习,但被构造成一个无监督任务。这是理想的,因为它不需要在初始模型预训练之前手动标记数据。你可以给它任何文本,它将自行进行测试。在训练环境中,Cloze 测试被称为 掩码语言模型。模型从一个更基本的嵌入(例如,使用众所周知的 word2vec 或 GloVe 库对词汇表中的每个单词进行嵌入)开始,并将随机删除句子中的 15%的标记进行测试。然后模型优化一个损失函数,这将导致 Cloze 测试的成功率更高。此外,在训练过程中,它使用周围的标记和上下文(注意力)。给定单个训练示例中的向量,训练后的输出向量是一个包含单词及其周围上下文深度学习表示的嵌入。
如果你对 Transformers 和 BERT 感兴趣,我们鼓励你通过阅读这篇论文来了解更多信息。然而,现在你需要了解的只是如何从 BERT 编码器中获取嵌入。基本概念如图 13.4 所示。

图 13.4 Transformer 编码器
在图 13.4 中,我们首先通过分词器处理文本。分词器将文本分割成词元,这些是词汇表中的预定义单词部分。这个词汇表在模型训练之前就已经建立。例如,术语“It’s”在分词过程中将被分割成三个词元:it,'和s。BERT 论文中使用的词汇表包含 30,000 个词元。BERT 还使用特殊的词元来表示句子的开始和结束:分别是[CLS]和[SEP]。一旦分词,标记流将被传递到 BERT 模型进行编码。编码过程随后输出一个tensor,这是一个向量数组(每个标记一个向量)。
13.3.2 公开可用的预训练 Transformer 模型
虽然 Transformer 使得构建最先进的语言模型成为可能,但拥有从头开始构建它们的知识和资源对于许多人来说可能是一个巨大的障碍。与 Transformer 一起工作的一个非常重要的方面是庞大的社区和开源工具集,这使得任何工程师都能快速开始使用这项技术。只需要一些 Python 知识和互联网连接即可。
通过这个过程从头开始训练的模型很大,大小从几百 MB 到几百 GB 不等,通常需要相似数量的 GPU 内存(VRAM)来快速运行它们。训练本身也需要大量的昂贵计算能力和时间,因此能够使用现有的模型作为起点提供了显著的优势。我们将在下一节中利用这个优势,开始应用这些模型之一来进行搜索。
13.4 将 Transformer 应用于搜索
在本节中,我们将构建一个高度准确的自然语言自动补全搜索,它将根据术语的前缀推荐更精确的相关关键词。我们将通过首先将语料库文本通过 Transformer 来获取嵌入索引来实现这一点。然后,我们将在查询时使用这个 Transformer 来获取查询嵌入,并在嵌入索引中搜索与最相似嵌入的k个最近文档。图 13.5 是一个展示此过程步骤的架构图。

图 13.5 使用 Transformer 编码向量进行端到端搜索的概念架构
我们有一个内容来源,一个最近邻索引,一种从 Transformer 中检索向量的方法,以及一个相似度公式。我们现在可以构建所有这些组件的管道来处理和索引内容,然后根据查询检索和排名文档。
13.4.1 使用 Stack Exchange 户外数据集
在第五章中,我们介绍了 Stack Exchange 的几个数据集。我们选择在这里使用另一个数据集,Stack Exchange 的户外数据集,有一个非常重要的原因:户外问题与答案领域的词汇和上下文在我们的 Transformer 模型中已经得到了很好的覆盖。具体来说,Wikipedia 在训练许多 Transformer 模型时被使用,Wikipedia 有一个专门关于户外内容的章节(en.wikipedia.org/wiki/Outdoor)。
注意:第 13-15 章中使用的户外示例,以及其他章节中提到的 Stack Exchange 数据集,均由 Stack Exchange 根据 CC-by-SA 4.0 许可证授权:creativecommons.org/licenses/by-sa/4.0。
以下列表展示了创建户外集合并随后索引户外问题和答案数据的过程。
列表 13.1 索引户外数据集
outdoors_collection = engine.create_collection("outdoors")
outdoors_dataframe = load_outdoors_data("data/outdoors/posts.csv")
outdoors_collection.write(outdoors_dataframe)
这是第 13.1 节中创建的outdoors集合的架构:
root
|-- id: *integer* (nullable = true)
|-- accepted_answer_id: *integer* (nullable = true)
|-- parent_id: *integer* (nullable = true)
|-- creation_date: *timestamp* (nullable = true)
|-- score: *integer* (nullable = true)
|-- view_count: *integer* (nullable = false)
|-- body: *string* (nullable = true)
|-- owner_user_id: *string* (nullable = true)
|-- title: *string* (nullable = true)
|-- tags: *array* (nullable = true)
| |-- element: *string* (containsNull = true)
|-- answer_count: *integer* (nullable = true)
|-- post_type: *string* (nullable = true)
|-- url: *string* (nullable = true)
索引的数据集包含代表问题和答案的文档,答案通过parent_id字段链接到其原始问题。每个文档都包含一个post_type字段,用于区分它是否包含“问题”或“答案”。
以下列表显示了一个问题帖子和其相关的答案。
列表 13.2 探索关于攀岩结的问题的帖子数据
[{"id": "18825",
"accepted_answer_id": 18826, #1
"body": "If I wanted to learn how to tie certain knots,
↪or learn about new knots and what they're used for,
↪what are some good resources to look up?",
"title": "What's a good resource for learning to tie knots for climbing?",
"post_type": "question"}, #2
{"id": "24440", #3
"parent_id": 18825, #3
"body": "Knots and Ropes for Climbers by Duane Raleigh is a fantastic
↪illustrated resource tailored specifically to climbers. The ABoK
↪is great, but a but beyond the pale of what the average rock...",
"post_type": "answer"}, #4
{"id": "18826", #5
"parent_id": 18825, #3
"body": "Animated Knots By Grog Arguably the best resource online for knot
↪tying is Animated Knots by Grog , it's used by virtually every avid
↪knot tyer I've known. They have excellent step-by-step animatio...",
"post_type": "answer"}] #4
1 文档 18826 被标记为问题的接受答案。
2 这是我们的问题文档。
3 这些文档是第一个文档(id=18825)中问题的答案。
4 这些是与问题相关的答案文档。
5 这是被标记为问题接受答案的文档(接受答案 _id=18826)。
在前面的列表中,第一个文档是与查询攀岩结最相关的问题。该问题有两个答案,通过每个答案上的parent_id字段链接回父问题。其中之一已被选为接受答案,通过在问题文档上设置accepted_answer_id字段(在本例中为 18826)来识别。
问题文档的body字段包含对问题的详细说明,而答案的body字段包含完整的答案。只有问题帖子有一个title,它是问题的摘要。其他几个字段(如view_count、answer_count和owner_user_id)在此省略,但在完整数据集中作为元数据字段提供,这有助于使用 BM25 与其他信号混合进行搜索相关性。
现在你已经熟悉了数据模型,让我们花点时间尝试一些查询,看看会返回什么类型的问题。以下列表搜索与常见查询匹配的问题。
列表 13.3 对攀岩 结运行基本的词汇搜索
def search_questions(query, verbose=False):
request = {"query": query,
"query_fields": ["title", "body"], #1
"limit": 5,
"return_fields": ["id", "url", "post_type", "title",
"body", "accepted_answer_id", "score"],
"filters": [("post_type", "question")],
"order_by": [("score", "desc"), ("title", "asc")]}
response = outdoors_collection.search(**request)
display_questions(query, response, verbose)
search_questions("climbing knots")
1 将查询与标题和正文字段匹配。
响应:
Query: climbing knots
Ranked Questions:
Question 21855: What are the four climbing knots used by Jim Bridwell?
Question 18825: What's a good resource for learning to tie knots for clim...
Question 18814: How to tie a figure eight on a bight?
Question 9183: Can rock climbers easily transition to canyoning?
Question 22477: Tradeoffs between different stopper knots
我们可以看到,这些对于这个词汇查询来说是有些相关的标题。但这仅仅是一个基本的关键词搜索。其他查询的表现远不如这个;例如,在下一条列表中的查询What is DEET显示了非常不相关的结果。
列表 13.4 基本词汇匹配可能导致不相关结果
search_questions("What is DEET?")
响应:
Query What is DEET?:
Ranked Questions:
Question 20403: What is bushcrafting?
Question 20977: What is "catskiing"?
Question 1660: What is Geocaching?
Question 17374: What is a tent skirt and what is its purpose?
Question 913: What is a buff?
这显示了传统词汇搜索如何失败于常见的自然语言用例。具体来说,倒排索引受到查询-术语依赖问题的困扰。这意味着查询中的术语被匹配为字符串与索引中的术语。这就是为什么你在列表 13.4 的结果中看到对what is的强匹配。查询的意义没有被理解,因此检索只能基于字符串匹配。
本章的其余部分将提供使用 Transformers 进行自然语言搜索所需的基本知识,而在第十四章中,我们将解决列表 13.4 中明显的问题-回答问题。
13.4.2 微调和语义文本相似度基准
使用预训练的 Transformer 模型通常不会为特定任务的提示产生最佳结果。这是因为初始训练是在没有特定用例或领域的通用语言环境中进行的。本质上,它是“未调优”的,以这种方式使用模型类似于在搜索引擎中索引内容而不进行相关性调优。
要实现 Transformers 的全部潜力,它们需要被微调以完成特定任务。这被称为微调——即通过在更适合目的的数据上训练预训练模型,以实现特定用例目标的过程。对于自动补全和语义搜索,我们感兴趣的是微调以完成文本相似度发现任务。
这将带我们来到语义文本相似度基准(STS-B)的训练和测试集(ixa2.si.ehu.eus/stswiki/)。这个基准包括语义相似和不相似的段落,并且它们被相应地标记。使用这个数据集,模型可以被微调以提高一组术语与语料库中许多段落之间的最近邻搜索的准确性,这将是本章中的用例。
在第十四章中,我们将微调我们自己的问题回答模型,以便你可以看到它是如何完成的。然而,在本章中,为了我们的目的,我们将使用一个已经包含为此任务预调优模型的工程:SBERT。
13.4.3 介绍 SBERT Transformer 库
SBERT,或称Sentence-BERT,是一种基于 Transformers 的技术和 Python 库,其基于这样一个理念:BERT 模型可以通过某种方式微调,使得两个语义相似的句子(而不仅仅是标记)在向量空间中应该表示得更近。具体来说,SBERT 会将一个句子中的所有 BERT 嵌入汇总成一个单一的向量。(汇总可以理解为它结合了这些值。)一旦 SBERT 汇总了这些值,它将通过使用一个专门用于学习优化 STS-B 任务的神经网络来训练句子之间的相似性。有关进一步的实施细节,请参阅 Nils Reimers 和 Iryna Gurevych 撰写的“Sentence-BERT”论文(arxiv.org/abs/1908.10084)。
接下来的列表将为您概述如何通过sentence_transformers Python 库使用 SBERT。我们将从导入名为roberta-base-nli-stsb-mean-tokens的预训练模型开始,该模型基于 RoBERTa 架构。将 RoBERTa 视为 BERT 的进化版和改进版,具有优化的超参数(配置设置)和轻微的技术修改,这有助于理解。
超参数
在机器学习中,超参数是指在训练之前可以更改的任何参数值,它们将改变学习过程并影响最终模型。
不幸的是,当你开始时,你通常不知道应该将超参数值设置为什么,因此你可能需要通过迭代和测量来学习优化的值。
在模型名称roberta-base-nli-stsb-mean-tokens中,我们还可以看到一些可能不认识的术语,包括“nli”和“mean-tokens”。NLI 代表自然语言推理(NLP 的一个子领域,用于语言预测),mean-tokens 指的是整个句子的标记化被汇总为一个标记嵌入数值的平均值。使用 mean-tokens 会返回一个整个句子的单一 768 维嵌入。
以下列表展示了如何导入sentence_transformers库,加载模型,并显示完整的网络架构。
列表 13.5:加载 RoBERTa SentenceTransformer模型
from sentence_transformers import SentenceTransformer
transformer = SentenceTransformer("roberta-base-nli-stsb-mean-tokens")
现在,PyTorch 的transformer对象包含了 Transformer 的神经网络架构以及所有模型权重。
加载我们的模型后,我们可以从文本中检索嵌入。这正是真正有趣的地方。我们可以将句子通过预训练模型传递到神经网络架构中,从而得到嵌入结果。在接下来的列表中,我们将对四个句子进行编码和评估。
列表 13.6 演示了如何将多个短语编码为密集向量嵌入。
列表 13.6:将短语编码为密集向量嵌入
phrases = ["it's raining hard", "it is wet outside", #1
"cars drive fast", "motorcycles are loud"] #1
embeddings = transformer.encode(phrases, convert_to_tensor=True) #2
print("Number of embeddings:", len(embeddings))
print("Dimensions per embedding:", len(embeddings[0]))
print("The embedding feature values of \"it's raining hard\":")
print(embeddings[0])
1 我们想要编码的四个句子。我们将将这些句子全部传递给单个批次进行编码。
2 只需调用 transformer.encode,sentence_transformers 的抽象就会为你完成所有繁重的工作。
响应:
Number of embeddings: 4
Dimensions per embedding: 768
The embedding feature values of "it's raining hard":
tensor( 1.1609e-01, -1.8422e-01, 4.1023e-01, 2.8474e-01, 5.8746e-01,
7.4418e-02, -5.6910e-01, -1.5300e+00, -1.4629e-01, 7.9517e-01,
5.0953e-01, 3.5076e-01, -6.7288e-01, -2.9603e-01, -2.3220e-01,
...
5.1413e-01, 3.0842e-01, -1.1862e-01, 5.9565e-02, -5.5944e-01,
9.9763e-01, -2.2970e-01, -1.3132e+00])
在前面的列表中,我们取每个句子并将其传递给编码器。这为每个句子生成一个张量。张量 是一个可以存储多维值的通用数据结构。标量(单个值)、向量(标量数组)、矩阵(向量数组)或甚至是多维矩阵(矩阵数组、矩阵的矩阵等)都是不同维度的张量示例。张量是由 Transformer 编码器(如 SBERT)在编码文本时产生的。对于我们的用例,列表 13.6 中的张量是一个包含 768 个维度的嵌入,表示为浮点数。
使用我们的嵌入,我们现在可以执行余弦相似度(单位归一化向量的点积)来查看哪些短语彼此最接近。我们将比较每个短语与每个其他短语,并按相似度排序,以查看哪些最相似。这个过程在列表 13.7 和 13.8 中逐步介绍。
我们将使用 PyTorch 内置库来计算点积来完成这些比较,这允许我们通过单个函数调用传递嵌入。然后我们可以对结果相似度进行排序,并查看哪些两个短语彼此最相似,哪些两个短语最不相似。以下列表计算了短语嵌入之间的相似度。
列表 13.7 比较所有短语
def normalize_embedding(embedding): #1
normalized = numpy.divide(embedding, numpy.linalg.norm(embedding))
return list(map(float, normalized))
normalized_embeddings = list(map(normalize_embedding, embeddings))
similarities = sentence_transformers.util.dot_score(normalized_embeddings,
normalized_embeddings)
print("The shape of the resulting similarities:", similarities.shape)
1 单位归一化嵌入以提高速度,因此点积等于余弦相似度
输出:
The shape of the resulting similarities: torch.Size([4, 4])
我们在列表 13.7 中打印相似度对象的形状,以查看我们进行了多少比较。形状是 4 x 4 ([4, 4]),因为我们有 4 个短语,每个短语与其他每个短语以及自身的相似度分数。所有相似度分数都在 0.0(最不相似)和 1.0(最相似)之间。这里包含形状是为了帮助展示比较多个短语复杂性。如果有 100 个短语,相似度形状将是 100 x 100。如果有 10,000 个短语,相似度形状将是 10,000 x 10,000。所以,随着你添加要比较的短语,计算和空间成本将随着复杂性的 n**² 增加,其中 n 是短语的数量。
在计算了我们的四个短语的相似度后,我们按顺序打印它们在以下列表中。
列表 13.8 按相似度排序并打印结果
def rank_similarities(phrases, similarities, name=None):
a_phrases = []
b_phrases = []
scores = []
for a in range(len(similarities) - 1): #1
for b in range(a + 1, len(similarities)): #2
a_phrases.append(phrases[a])
b_phrases.append(phrases[b])
scores.append(float(similarities[a][b])) #3
dataframe = pandas.DataFrame({"score": scores, "phrase a": a_phrases,
"phrase b": b_phrases})
dataframe["idx"] = dataframe.index #4
dataframe = dataframe.reindex(columns=["idx", "score",
"phrase a", "phrase b"])
return dataframe.sort_values(by=["score"], #5
ascending=False, #5
ignore_index=True) #5
dataframe = rank_similarities(phrases, similarities)
display(HTML(dataframe.to_html(index=False)))
1 将所有短语对附加到数据框中。
2 我们不会重复短语或附加短语相似度,因为它始终是 1.0。
3 获取每对分数。
4 添加索引列
5 对分数进行排序(ascending=False 以显示最高分数)。
响应:
idx score phrase a phrase b
0 0.669060 it's raining hard it is wet outside
5 0.590783 cars drive fast motorcycles are loud
1 0.281166 it's raining hard cars drive fast
2 0.280800 it's raining hard motorcycles are loud
4 0.204867 it is wet outside motorcycles are loud
3 0.138172 it is wet outside cars drive fast
我们现在可以看到,彼此最相似的短语是“it’s raining hard”和“it is wet outside”。我们还看到汽车和摩托车之间存在强烈的相似性。
两个最不相似的表达是“外面很湿”和“汽车开得快”。从这些例子中可以非常清楚地看出,这个语义编码过程正在起作用——我们可以将雨与外面很湿联系起来。密集的向量表示捕捉了上下文,尽管单词不同,但意义仍然存在。注意分数:前两个相似比较的分数大于 0.59,下一个最接近的比较分数小于 0.29。这是因为只有前两个比较看起来彼此相似,正如我们在自然语言理解(NLU)任务中感知的那样。作为聪明的人,我们可以将雨和湿(“天气”)分组,我们也可以将汽车和摩托车(“车辆”)分组。还有一点很有趣,当地面湿的时候,汽车可能开得更慢,这可能是最后一对相似度低的原因。
13.5 自然语言自动补全
既然我们知道我们的向量编码和相似性处理工作良好,现在是时候将这种嵌入技术应用于实际的搜索用例——自然语言自动补全!
在本节中,我们将展示在搜索时使用句子转换器的实际应用,以及一个基本的快速语义自动补全实现。我们将把迄今为止所学的内容应用到从户外数据集中提取概念。使用 spaCy(我们在第五章中使用的 Python NLP 库),我们将分块名词和动词以获取户外概念。我们将这些概念放入字典中并处理它们以获取它们的嵌入。然后,我们将使用字典在近似最近邻(ANN)索引中进行实时查询。这将使我们能够输入一个前缀或一个术语,并获取字典中存在的最相似的概念。最后,我们将按相似度顺序将这些概念展示给用户,演示智能的自然语言自动补全。
经验和测试表明,即使在大多数经过良好调整的搜索引擎建议器中,这种方法也表现得更好。我们将看到它产生的噪声更少,而且相似但拼写不同的术语也会自动包含在建议中。这是因为我们不是在比较关键字字符串;而是在比较嵌入,它们代表意义和上下文。这是在 1.2.4 节中介绍的“寻找事物,而不是字符串”的体现。
13.5.1 为我们的最近邻词汇获取名词和动词短语
使用我们在第五章中学到的知识,我们将编写一个简单的函数从语料库中提取概念。我们不会包括任何分类层次结构,也不会在这里构建一个完整的知识图谱。我们只想得到一个可靠的使用频率较高的名词和动词列表。
我们示例中的概念是人们通常搜索的重要的“事物”和“动作”。我们还需要理解数据集,这最好通过花时间审查概念以及它们之间的关系来实现。在构建任何搜索应用时,理解语料库至关重要,使用高级 NLP 技术也不例外。
以下列表展示了一种策略,它将为我们的词汇表提供合理的候选概念基线,同时从自动完成结果中去除显著的噪声。
列表 13.9 使用 spaCy Matcher 获取所需文本部分
nlp = spacy.load("en_core_web_sm") #1
phrases = [] #2
sources = [] #3
matcher = Matcher(nlp.vocab) #4
nountags = ["NN", "NNP", "NNS", "NOUN"] #5
verbtags = ["VB", "VBD", "VBG", "VBN", ) #6
"VBP", "VBZ", "VERB"] #6
matcher.add("noun_phrases", [[{"TAG": {"IN": nountags}, #7
"IS_ALPHA": True, #7
"OP": "+"}]]) #7
matcher.add("verb_phrases", [[{"TAG": {"IN": verbtags},
"IS_ALPHA": True, "OP": "+",
"LEMMA":{"NOT_IN":["be"]}}]]) #8
for doc, _ in tqdm.tqdm(nlp.pipe(yield_tuple(dataframe, #9
source_field="body", #9
total=total),) #9
batch_size=40, #9
n_threads=4, #9
as_tuples=True), #9
total=total):
matches = matcher(doc)
for _, start, end in matches: #10
span = doc[start:end] #10
phrases.append(normalize(span)) #10
sources.append(span.text) #10
concepts = {}
labels = {}
for i, phrase in phrases: #11
if phrase not in concepts: #11
concepts[phrase] = 0 #11
labels[phrase] = sources[i] #11
concepts[phrase] += 1 #11
1 加载英语 spaCy NLP 模型
2 语料库中所有标准化名词/动词短语(“概念”)
3 将原始文本标签标准化到概念中
4 使用 spaCy Matcher 将模式分块为概念标签
5 匹配名词的词性标签
6 匹配动词的词性标签
7 将名词短语匹配模式添加到 spaCy 分析管道中
8 添加动词短语匹配模式。您可以添加更多 NOT_IN 模式来排除其他“停用词”动词。
9 以 40 个文档的批次,使用 4 个线程处理每个户外问题的正文字段
10 获取所有名词和动词短语匹配,并将它们保留在源和短语列表中
11 通过词频聚合标准化概念
在前面的列表中,我们使用 spaCy Matcher 检测作为词性标签的模式。我们还明确地从动词概念中移除了“to be”动词的形式。在许多非有用情况下,“to be”动词被频繁使用,并经常使概念建议变得杂乱。我们可以通过移除其他噪声动词(如“have”和“can”)来进一步提高质量,但这里只是一个示例。此列表中还介绍了 spaCy 的语言管道(nlp.pipe)。pipe函数接受一个批次大小和要使用的线程数作为参数,然后以并行批次的流式处理文本(因此比为每个文档单独调用更快)。
使用列表 13.9 中的函数,我们现在可以获取概念列表。当在您的机器上运行此操作时可能需要一些时间,所以请耐心等待。以下列表返回了户外集合中最显著的概念和标签。
列表 13.10 生成语料库中最频繁的概念
collection = engine.get_collection("outdoors")
concepts, labels = get_concepts(collection, source_field="body",
load_from_cache=True)
topcons = {key: value for (key, value)
in concepts.items() if value > 5}
print("Total number of labels:", len(labels.keys()))
print("Total number of concepts:", len(concepts.keys()))
print("Concepts with greater than 5 term frequency:", len(topcons.keys()))
print(json.dumps(topcons, indent=2))
响应:
Total number of labels: 124366
Total number of concepts: 124366
Concepts with greater than 5 term frequency: 12375
{
"have": 32782,
"do": 26869,
"use": 16793,
...
"streamside vegetation": 6,
"vehicle fluid": 6,
"birdshot": 6
}
除了获取户外数据集的概念外,列表 13.10 还对整个数据集进行了过滤,只包括频率大于5的概念。过滤将限制来自语料库中不常出现的术语的噪声,例如拼写错误和我们不希望在自动完成场景中建议的罕见术语。
13.5.2 获取嵌入
我们将执行一个复杂的归一化操作,该操作将归一化类似相关的概念。但与算法归一化(如词干提取)不同,我们正在将数据归一化到一个包含 768 个特征维度的密集向量空间。与词干提取类似,这样做的目的是为了提高召回率(成功返回的相关文档的百分比)。但与使用词干提取器不同,我们正在寻找并将紧密相关的概念映射在一起。提醒一下,我们只对名词和动词短语进行归一化。忽略其他单词类似于停用词去除,但这没关系,因为我们希望尽可能简洁地提出类似的概念。我们还将更好地表示剩余短语的上下文和意义。因此,周围的非名词和非动词术语是隐含的。
现在我们已经有一个概念列表(来自上一节),我们将使用我们加载的model(在列表 13.5 中加载的 RoBERTa 句子转换器模型)来处理它们以检索嵌入。如果你没有 GPU,这可能需要一段时间,所以我们在第一次计算嵌入后,将它们持久化到“pickle 文件”(一个可以轻松存储和从磁盘加载的序列化 Python 对象)。如果你想要重新运行笔记本,你只需加载之前创建的 pickle 文件,而无需再花半小时重新处理原始文本。
超参数警告!minimum_frequency术语是一个超参数,在下面的列表中设置为大于五(>=6),以最小化罕见术语的噪声。在本章和下一章的其他列表中,我们将遇到更多的超参数,尤其是在我们进行微调时。在你浏览了本章中其余的列表之后,我们鼓励你回来更改minimum_frequency的值,看看它如何改变检索到的结果。你可能找到一个更适合且更准确的价值,比我们在这里得到的价值更好。
列表 13.11 检索我们的概念词汇的嵌入
def get_embeddings(texts, model, cache_name, ignore_cache=False):
... #1
embeddings = model.encode(texts)
... #1
return embeddings
minimum_frequency = 6 #2
phrases = [key for (key, tf) in concepts.items() if tf >= minimum_frequency]
cache_name = "outdoors_embeddings"
embeddings = get_embeddings(phrases, transformer,
cache_name, ignore_cache=False)
print(f"Number of embeddings: {len(embeddings)}")
print(f"Dimensions per embedding: {len(embeddings[0])}")
1 为了简洁起见,移除了缓存代码
2 这是一个超参数!我们正在忽略在整个语料库中出现的次数少于这个数字的术语。降低这个阈值可能会降低精确度,而提高它可能会降低召回率。
响应:
Number of embeddings: 12375
Dimensions per embedding: 768
从列表 13.11 中,你可以看到每个嵌入都是由我们的 12,375 个概念生成的。所有嵌入都具有来自相同密集向量空间相同的维度性,因此可以直接相互比较。
图 13.6 展示了这些嵌入在 3D 图中的样子以及它们之间的关系。
图中一些概念的相似度已被标注出来,以显示意义邻域。与“风”和“块”相关的概念说明了它们在向量空间中相对于彼此的位置。我们使用维度降低将每个嵌入的 768 维降低到 3 维(x,y,z),以便它们可以轻松绘制。维度降低是一种将具有许多特征的向量压缩为具有较少特征的向量的技术。在这次降低过程中,尽可能保持向量空间中的关系。
维度降低会丢失上下文
在执行维度降低时,会丢失很多上下文,因此图 13.6 中的可视化只是为了给你一个向量空间和概念相似性的直观感受,并不建议将概念降低到三维是一个理想的表示方法。

图 13.6 将概念嵌入映射到 3D 可视化的向量空间
使用从列表 13.11 计算出的嵌入,我们现在可以执行大规模比较,以查看哪些术语彼此之间更为紧密相关。我们将通过计算余弦相似度——每个单位归一化嵌入与每个其他单位归一化嵌入的点积来完成此操作。请注意,在这个例子中,我们限制了要比较的嵌入数量,因为随着嵌入数量的增加,所需的计算数量呈指数增长。如果您不确定我们的意思,让我们做一些快速的计算。每个嵌入具有 768 个浮点值。比较前250个嵌入的结果是250 × 250 × 768 = 48,000,000个浮点计算。如果我们比较完整的 12,375 个嵌入列表,那将是12,375 × 12,375 × 768 = 117,612,000,000个浮点计算。这不仅处理速度慢,而且还需要非常大的内存。
以下列表执行了前 250 个概念的暴力比较,以评估相似度得分是如何分布的。
列表 13.12 从词汇表头部探索相似度得分
normalized_embeddings = list(map(normalize_embedding, embeddings))
similarities = sentence_transformers.util.dot_score( #1
normalized_embeddings[0:250], #1
normalized_embeddings[0:250]) #1
comparisons = rank_similarities(phrases, similarities) #2
display(HTML(comparisons[:10].to_html(index=False)))
1 找到具有最高点积得分的成对概念
2 按照第 13.8 节列表中定义的相似度进行排名
响应:
idx score phrase a phrase b
31096 0.928151 protect protection
13241 0.923570 climbing climber
18096 0.878894 camp camping
...
7354 0.782962 climb climber
1027 0.770643 go leave
4422 0.768611 keep stay
如列表 13.12 所示,scores数据框现在包含所有相互比较的短语的排序列表,其中最相似的是“保护”和“保护”,点积相似度为0.928。
注意,250的索引是任意的,可以根据需要将更大的值用于可视化更多数据。记住我们在列表 13.7 中学到的:使用n个概念会产生形状为[n, n]的张量。这会产生列表 13.12 中示例的250 × 250 = 62500个相似度。
以下列表绘制了前 250 个概念比较相似度得分的分布。
列表 13.13 词相似度分布
from plotnine import *
candidate_synonyms = comparisons[comparisons["score"] > 0.0]
{
ggplot(comparisons, aes("idx", "score")) +
geom_violin(color="blue") +
scale_y_continuous(limits=[-0.4, 1.0],
breaks=[-0.4, -0.2, 0, 0.2, 0.4, 0.6, 0.8, 1.0])
}
列表 13.13 的输出显示在图 13.7 中,分布清楚地说明了哪些概念之间最相关。您可以看到,非常少的比较具有大于0.6的相似度得分,而绝大多数得分低于那个值。

图 13.7 展示了前 250 个概念之间通过点积相似度比较的得分分布。请注意,非常少的比较得分高于0.6,而大多数得分低于0.4(非常低的置信度)。
我们绘制了得分的分布,以便我们评估它们,并在查询时使用我们的直觉来选择一个基线相似度阈值(在列表 13.15 中稍后使用)。图 13.7 中的可视化非常有前景。由于大多数概念都被标记为不相似,我们可以可靠地选择一个足够高的数值作为质量建议的阈值(例如,本例中的0.6)。当我们进行搜索时的自动完成功能时,我们只对看到前五到十个建议的术语感兴趣,因此这个分布表明我们可以可靠地做到这一点。
13.5.3 ANN 搜索
在实现工作自动完成之前,我们还有一个更重要的问题要解决。问题是,在查询时,我们理想情况下不希望将每个搜索词与 12,375 个其他词进行比较。由于使用sentence_transformers .util.dot_score函数的维度和计算开销,这将是不高效的且速度慢。即使我们愿意计算所有文档的点积相似度,随着我们扩展到数百万个文档,这只会变得越来越慢,因此我们理想情况下只对有很高相似可能性的文档进行评分。
我们可以通过执行所谓的近似最近邻(ANN)搜索来实现这一目标。当给定一个向量时,ANN 搜索将有效地返回最相关的文档,而无需在整个语料库中计算嵌入相似度。ANN 搜索旨在以牺牲一些精度为代价,以换取改进的对数计算复杂度,以及内存和空间效率。
为了实现我们的 ANN 搜索,我们将使用索引时间策略,在专门的数据结构中预先存储可搜索的内容向量。将 ANN 搜索想象成密集向量搜索的“倒排索引”。
为了我们的目的,我们将使用分层可导航小世界(HNSW)图来索引和查询我们的密集向量。我们还将介绍第 13.7 节中基于聚类和哈希的其他方法,如产品量化(product quantization)和倒排文件索引(IVF)。HNSW 在其研究论文的摘要中进行了描述,论文标题为“使用分层可导航小世界图进行高效且鲁棒的近似最近邻搜索”,作者为 Yu. A. Malkov 和 D.A. Yashunin (arxiv.org/abs/1603.09320):
我们提出了一种基于可导航小世界图和可控层次结构(分层 NSW,HNSW)的近似 K 最近邻搜索的新方法……分层 NSW 逐步构建一个多层结构,该结构由存储元素嵌套子集的层次邻近图集(层)组成。
这意味着在构建索引时,HNSW 会将相似的向量聚类在一起。可导航小世界图通过将数据组织成邻域并使用可能的关系边连接这些邻域来工作。当对密集向量表示进行索引时,最合适的邻域及其潜在连接将被识别并存储在图数据结构中。
不同的 ANN 方法
在本章中,我们使用 HNSW 算法进行 ANN 搜索。HNSW 在召回率和查询吞吐量之间提供了很好的平衡,并且目前(截至本文撰写时)是最受欢迎的 ANN 方法之一。然而,存在许多其他 ANN 方法,包括像局部敏感哈希(LSH)这样的简单技术。LSH 将向量空间划分为哈希桶(代表向量空间中的邻域)并将每个密集向量编码(哈希)到这些桶中的一个。虽然与 LSH 相比,HNSW 的召回率通常要高得多,但 HNSW 依赖于您的数据来生成邻域,并且邻域可能会随时间推移而移动以更好地适应您的数据。LSH 的邻域(哈希)以数据无关的方式生成,这可以更好地满足一些需要分布式系统预先分片的使用案例。我们还将介绍基于聚类和哈希的其他方法,如产品量化(Product Quantization)和倒排文件索引(IVF),在第 13.7 节中。研究不同的 ANN 算法以找到最适合您应用程序的一个可能值得您去做。
当使用密集向量查询启动 HNSW 搜索时,它会找到查询的最佳聚类入口点并搜索最近的邻居。HNSW 实现了许多其他优化技术,我们鼓励您阅读论文以了解更多信息。
13.5.4 ANN 索引实现
对于我们的 ANN 搜索实现,我们将首先使用一个名为非度量空间库(NMSLIB)的库。这个库包括HNSW算法的规范实现。
我们选择这个库的原因不仅是因为它速度快,而且使用起来也很简单,并且需要非常少的代码。Apache Lucene 还包括一个具有原生 HNSW 支持的密集向量字段类型,这使得算法在 Solr、OpenSearch、Elasticsearch 和其他基于 Lucene 的引擎(如 MongoDB Atlas Search)中可用。HNSW 的实现还可在其他搜索引擎中找到,例如 Vespa.ai、Weaviate、Milvus 等。
NMSLIB 是健壮的、经过充分测试的,并且广泛用于 ANN 应用程序。NMSLIB 也适合展示 ANN 搜索的简单性,而不涉及实现的细节。还有许多其他 ANN 库可用,我们鼓励您调查在优秀的 ANN Benchmarks 网站上列出的其中一些:ann-benchmarks.com。
要开始使用 NMSLIB,我们只需导入库,初始化一个索引,将所有嵌入作为批次添加到索引中,然后提交。当以这种方式构建索引时,自动补全是一个理想的使用案例,因为词汇表很少更新。尽管 NMSLIB 和其他库可能在某些情况下遭受写入性能的损失,但这不会影响我们的读密集型自动补全应用程序。从实际的角度来看,我们可以将索引更新作为晚上或周末的工作离线进行,并在适当的时候部署到生产环境中。
下面的列表创建了一个包含所有 12,375 个嵌入的 HNSW 索引,然后执行了一个搜索类似“bag”这个术语的概念的示例搜索。
列表 13.14 使用 NMSLIB 进行 ANN 搜索
import nmslib
concepts_index = nmslib.init(method="hnsw", #1
space="negdotprod") #1
normalized_embeddings = list(map(normalize_embedding, embeddings))
concepts_index.addDataPointBatch(normalized_embeddings) #2
concepts_index.createIndex(print_progress=True) #3
ids, _ = concepts_index.knnQuery( #4
normalized_embeddings[25], k=10) #4
matches = [labels[phrases[i]].lower() for i in ids] #5
display(matches)
1 使用 negdotprod 度量空间中的 HNSW 图(距离函数是-1 * 点积)初始化一个新的索引。
2 所有嵌入都可以在一个批次中添加。
3 将索引提交到内存。在查询最近邻之前,必须完成此操作。
4 获取术语查询“bag”(嵌入 25)在我们的嵌入中的前 k 个最近邻。
5 查找每个术语的标签。
输出:
['bag', 'bag ratings', 'bag cover', 'bag liner', 'garbage bags', 'wag bags',
'bag cooking', 'airbag', 'paper bag', 'tea bags']
在创建并提交索引后,我们运行了一个小示例,比较了术语“bag”并查看返回的内容。有趣的是,所有这些术语都是下位词,这揭示了另一个理想的结果。我们希望在自动补全时向用户提供更精确的术语。这更有可能让用户有机会选择与他们特定的信息需求最相关的术语。
在确认索引正常工作后,我们现在可以构建一个简单的查询函数,该函数接受任何术语并返回顶级建议。SBERT 使用一种技术对相似术语进行编码,以生成相似的向量嵌入。重要的是,与大多数词汇自动补全实现不同,此函数接受任何查询,无论它是否已经在我们的字典中。我们首先获取查询并通过相同的 SBERT 编码器检索嵌入,该编码器用于索引我们的文档。有了这些嵌入,我们就可以从索引中访问最近邻。如果相似度得分大于0.75,我们将其视为匹配并包括作为建议。使用此函数,我们可以获取完整术语的建议,例如“mountain hike”,以及前缀,例如“dehyd”。
列表 13.15 显示了我们的自动补全semantic_suggest函数实现,该函数执行概念的高斯搜索。我们的查询可能不在字典中,但我们可以在需要时获取嵌入。我们将使用阈值dist>=0.75,只返回我们对其相似性有高度信心的相似术语。
选择一个好的相似度阈值
我们通过查看图 13.7 的分布得出了列表 13.15 的0.75阈值。这应该通过查看实际用户查询的结果质量来进一步调整。
注意:此函数可能在生产中引起 CPU 瓶颈,因此我们建议在规模上测量吞吐量并根据需要添加硬件。
列表 13.15 编码查询并返回k个最近邻概念
def embedding_search(index, query, phrases, k=20, #1
min_similarity=0.75): #1
matches = []
query_embedding = transformer.encode(query) #2
query_embedding = normalize_embedding(query_embedding)
ids, distances = index.knnQuery(query_embedding, k=k)
for i in range(len(ids)):
similarity = distances[i] * -1 #3
if similarity >= min_similarity: #4
matches.append((phrases[ids[i]], similarity))
if not len(matches):
matches.append((phrases[ids[1]], distances[1] * -1)) #5
return matches
def semantic_suggest(prefix, phrases):
matches = embedding_search(concepts_index, prefix, phrases)
print_labels(prefix, matches)
semantic_suggest("mountain hike", phrases)
semantic_suggest("dehyd", phrases)
1 为了说明目的,我们将 k 设置为 20。在实际应用中,这可能会设置在 5 到 10 之间。
2 获取查询的嵌入
3 将负点积距离转换为正点积
4 我们只返回相似度在 0.75 或更高的术语。
5 没有找到邻居!只返回原始术语
响应:
Results for: mountain hike
1.000 | mountain hike
0.975 | mountain hiking
0.847 | mountain trail
0.787 | mountain guide
0.779 | mountain terrain
0.775 | mountain climbing
0.768 | mountain ridge
0.754 | winter hike
Results for: "dehyd"
0.941 | dehydrate
0.931 | dehydration
0.852 | rehydration
...
0.812 | hydrate
0.788 | hydration pack
0.776 | hydration system
我们做到了!现在我们可以高效地提供基于 Transformer 嵌入和近似最近邻搜索的语义自动补全。
总体而言,使用此模型查询许多查询的结果质量相当令人印象深刻。但请注意,在将此类解决方案部署给真实客户之前,使用标记数据集来衡量成功至关重要。我们将在第十四章中演示使用标记数据来衡量和改进相关性的过程。
13.6 使用 LLM 嵌入进行语义搜索
使用我们迄今为止所学到的知识,我们现在将密集向量搜索提升到下一个层次:我们将在搜索时将查询嵌入作为召回步骤查询文档嵌入。
我们特别从自动补全开始作为我们的第一个实现,因为这对理解语言相似性的基础知识很有帮助。在向量空间中,对事物相似或不相似有强烈的直觉是至关重要的。否则,在使用嵌入时,你将无休止地追逐召回问题。为了培养这种直觉,我们只从匹配和评分长度只有几个单词的基本概念开始。
通过这个理解,我们现在将转向比较整个句子。我们将对标题执行语义搜索。记住,我们正在 Stack Exchange 户外数据集上搜索,因此文档标题实际上是贡献者提出的问题的摘要。作为额外的好处,我们可以使用上一节中的相同实现来搜索彼此相似的标题。
此函数将主要重复上一节中的编码和相似度函数。本节中的代码甚至更短,因为我们不需要提取概念。
下面是我们将遵循的步骤:
-
获取户外数据集中所有标题的嵌入。
-
使用嵌入创建一个 NMSLIB 索引。
-
获取查询的嵌入。
-
搜索 NMSLIB 索引。
-
显示最近邻标题。
13.6.1 获取标题及其嵌入
我们 NMSLIB 索引将由标题嵌入组成。我们使用与之前自动完成示例中完全相同的函数,但与转换概念不同,我们现在正在转换户外社区提出的所有问题的标题。以下列表显示了将标题编码为嵌入的过程。
列表 13.16 将标题编码为嵌入
outdoors_dataframe = load_dataframe("data/outdoors/posts.csv")
titles = outdoors_dataframe.rdd.map(lambda x: x.title).collect() #1
titles = list(filter(None, titles)) #1
embeddings = get_embeddings(titles, cache_name) #2
print(f"Number of embeddings: {len(embeddings)}")
print(f"Dimensions per embedding: {len(embeddings[0])}")
1 获取户外语料库中每个问题的标题
2 获取标题的嵌入(第一次运行时需要一点时间,直到它被缓存)
响应:
Number of embeddings: 5331
Dimensions per embedding: 768
我们已经将 5,331 个标题编码为嵌入,图 13.8 绘制了标题嵌入相似度分布。
将图 13.8 与图 13.7 中的概念相似度分布进行比较。注意形状和分数分布略有不同,这是由于标题和概念之间的差异。图 13.7 顶部有一个更长的“针”。这是因为标题更具体,因此与更广泛的名词和动词短语的关系会有所不同。

图 13.8 比较所有标题嵌入之间的相似度分数分布
13.6.2 创建和搜索最近邻索引
现在我们已经为语料库中所有问题标题生成了嵌入,我们可以轻松地创建最近邻索引。
列表 13.17 创建 ANN 标题嵌入索引
import nmslib
titles_index = nmslib.init(method="hnsw", space="negdotprod")
normalized_embeddings = list(map(normalize_embedding, embeddings))
titles_index.addDataPointBatch(normalized_embeddings)
titles_index.createIndex(print_progress=True)
使用我们新创建的索引,搜索变得简单!列表 13.18 显示了新的semantic_search函数,它实现了给定查询的标题的 ANN 搜索。这与我们为自动完成实现的列表 13.15 中的semantic_suggest非常相似——主要区别在于,这里的底层嵌入索引由title内容组成,而不是从body内容中提取的概念。
列表 13.18 对标题执行语义搜索
def semantic_search(query, phrases):
results = embedding_search(titles_index, query, phrases, #1
k=5, min_similarity=0.6) #1
print_labels(query, results)
semantic_search("mountain hike", titles)
1 来自列表 13.15 的 embedding_search
响应:
Results for: mountain hike
0.723 | How is elevation gain and change measured for hiking trails?
0.715 | How do I Plan a Hiking Trip to Rocky Mountain National Park, CO
0.698 | Hints for hiking the west highland way
0.694 | New Hampshire A.T. Section Hike in May? Logistics and Trail Condi...
0.678 | Long distance hiking trail markings in North America or parts the...
现在,让我们花点时间来反思这些结果。它们都是相关的吗?是的——它们都是与查询mountain hike绝对相关的所有问题。但是,这非常重要,它们是最相关的文档吗?我们不知道!我们不知道的原因是mountain hike几乎不提供任何上下文。因此,虽然标题在语义上都与查询相似,但我们没有足够的信息来确定它们是否是我们应该向用户展示的文档。
话虽如此,很明显,这种基于嵌入的搜索方法为我们的匹配和排名工具箱带来了有趣的新功能,提供了概念上关联结果的能力。这些结果是否更好取决于上下文。
到目前为止,我们通过手动实现密集向量搜索,依靠 NMSLIB 库来完成繁重的工作,但同时也向您展示了如何使用 ANN(HNSW)支持构建密集向量索引并对其进行查询。我们故意这样做是为了帮助您理解密集向量搜索的内部工作原理。然而,在您的生产系统中,您可能更倾向于使用您搜索引擎内置的密集向量搜索支持。在下一个列表中,我们将切换到使用我们的collection接口,通过配置的搜索引擎或向量数据库实现相同的语义搜索功能。
列表 13.19 使用配置的搜索引擎执行向量搜索
def display_results(query, search_results):
print_labels(query, [(d["title"], d["score"])
for d in search_results])
def index_outdoor_title_embeddings(): #1
create_view_from_collection(engine.get_collection("outdoors"),
"outdoors")
outdoors_dataframe = spark.sql("""SELECT id, title FROM outdoors
WHERE title IS NOT NULL""")
ids = outdoors_dataframe.rdd.map(lambda x: x.id).collect()
titles = outdoors_dataframe.rdd.map(lambda x: x.title).collect()
embeddings = list(
map(normalize_embedding, #2
get_embeddings(titles, cache_name))) #2
embeddings_dataframe = spark.createDataFrame(
zip(ids, titles, embeddings),
schema=["id", "title", "title_embedding"])
collection = engine.create_collection("outdoors_with_embeddings")
collection.write(embeddings_dataframe)
return collection
def semantic_search_with_engine(collection, query, limit=10): #3
query_vector = transformer.encode(query) #4
query_vector = normalize_embedding(query_vector) #4
request = {"query": query_vector,
"query_fields": ["title_embedding"],
"return_fields": ["title", "score", "title_embedding"],
"quantization_size": "FLOAT32", #5
"limit": limit}
response = collection.search(**request)
return response["docs"]
embeddings_collection = index_outdoor_title_embeddings()
query = "mountain hike"
search_results = semantic_search_with_engine(embeddings_collection, query)
display_results(query, search_results)
1 构建包含文档标题和嵌入的新集合
2 为所有文档计算归一化嵌入
3 通过对标题嵌入进行查询来返回文档
4 将字符串查询编码和归一化,然后构建成向量搜索请求。
5 嵌入的量化大小,在本例中为 32 位
响应:
0.723 | How is elevation gain and change measured for hiking trails?
0.715 | How do I Plan a Hiking Trip to Rocky Mountain National Park, CO
0.698 | Hints for hiking the west highland way
0.694 | New Hampshire A.T. Section Hike in May? Logistics and Trail Condi...
0.678 | Long distance hiking trail markings in North America or parts the...
每个搜索引擎或向量数据库都有自己的独特 API 来实现关键词搜索、向量搜索、混合关键词和向量搜索以及其他功能。列表 13.19 中的semantic_search_with_engine函数展示了使用一个与引擎无关的接口来查询您配置的搜索引擎,尽管您可能会发现直接使用您引擎的 API 进行某些操作在更高级用例中更加强大。
我们在本章早期使用了 NMSLIB 来帮助您更好地理解密集向量搜索的内部工作原理。然而,除非您正在做非常定制化的工作,否则您可能更希望使用您搜索引擎内置的、可扩展的密集向量搜索支持,而不是像使用 NMSLIB、FAISS(我们将在本章后面介绍)或 NumPy 这样的库手动本地实现。您会注意到列表 13.19 返回的结果与列表 13.18 中 NMSLIB 实现返回的结果完全相同。
使用密集向量相似度重新排序结果
在列表 13.18 中,我们选择了默认的min_similarity阈值,要求相似度分数为0.6或更高。检查图 13.8 中的标题相似度分布——您会改变这个数字使其不同于0.6吗?
您可以将min_similarity设置为低于0.6的值,以潜在地增加召回率,并将k设置为高于5的值作为重新排序窗口大小(例如,250)。然后,使用这个更大的结果集,您可以使用点积相似度进行重新排序。利用您在 10-12 章中学到的知识,您还可以将密集向量相似度作为特征(可能还有许多其他特征)纳入更复杂的排序学习模型中。
嵌入的相似度评分是成熟人工智能搜索堆栈中的许多功能之一。这种相似度将与个性化、学习排序和知识图谱结合使用,以提供强大的搜索体验。基于最近邻的密集向量搜索正迅速增长,并可能在某个时刻取代布尔匹配与 BM25 排名,成为搜索非结构化文本最常见的信息检索和排序技术。这两种方法——密集向量搜索和词汇搜索——是互补的,而结合两种方法的混合方法通常效果更好。
13.7 量化与表示学习以实现更高效的向量搜索
在上一节中,我们介绍了两个常用于加速密集向量搜索的概念:ANN 搜索和重排序。从概念上讲,ANN 是一种通过高效定位和过滤出最有可能与查询向量相似的前置向量,从而在查询时减少必要的向量相似度计算数量的方法。由于 ANN 搜索是对最佳结果的近似,因此通常使用更精确(且计算成本更高)的向量相似度计算对这些顶级潜在结果进行重排序,以恢复召回率和相关性,使其与非 ANN 优化的搜索相当。
表示和执行向量相似度计算所需的计算时间和内存量与被搜索向量的规模直接相关。在本节中,我们将介绍一些额外的技术来提高向量搜索的效率:
-
标量量化
-
二进制量化
-
产品量化
-
马特罗什卡表示学习
量化是一种用于减少数值数据表示(如嵌入向量)的内存占用和计算复杂度的技术。在嵌入的上下文中,量化涉及通过减少表示向量特征的位数来压缩它们。嵌入通常表示为浮点数(floats),默认情况下大小为 32 位(或 4 字节)。如果一个典型的向量嵌入有 1,024 个维度,这相当于 1,024 x 4 字节,即每个向量 4,096 字节(4 KB)。如果你有大量向量需要存储和搜索,这会迅速增加大量的内存和计算开销。
量化嵌入允许你以牺牲一些理想上很小量的召回率为代价,显著提高存储效率和查询速度,这对于大规模搜索系统至关重要。例如,你可以通过将每个特征转换为 8 位整数(Int8)来减少 32 位浮点数(Float32)向量的内存使用量 75%。以这种方式压缩每个维度的单个数值(标量)称为 标量量化。这通常可以在不显著影响召回率的情况下完成,并且当你有大量向量需要存储和搜索时特别有用。你甚至可以将每个特征量化到单个比特——一种称为 二进制量化 的技术——如果与使用更高精度向量在顶部-N 结果上重新排序的步骤结合使用,仍然可以保持相对较高的召回率。图 13.9 使用包含本书封面图像的向量(假设向量的维度代表图像中的像素)直观地展示了标量量化和二进制量化的概念。

图 13.9 数据量化:全精度、降低标量精度和二进制精度
在图中,你可以看到原始图像(未量化)、标量量化图像(使用减少的颜色调色板,映射到相似的范围但颜色/精度较少)和二进制量化图像(每个像素要么是黑色要么是白色)。你会注意到标量量化图像仍然保留了原始图像的大部分重要细节,而二进制量化版本仍然可以清楚地识别,尽管丢失了一些重要数据(标题中的某些字符和颜色)。
在本节中,我们将介绍标量量化、二进制量化和另一种称为乘积量化的第三种量化类型。我们还将介绍一种称为马特罗什卡表示学习的多层嵌入方法,它可以用来动态切换已生成的嵌入的精度级别,而无需额外的量化或重新训练。
13.7.1 标量量化
标量量化是量化最简单的形式,其中嵌入向量中的每个值独立映射到较低精度的表示。考虑以下两个向量:
[ -1.2345679, 2.2345679, 100.45679 ] #4 bytes = 32 bits
[ -1.234, 2.234, 100.44 ] #2 bytes = 16 bits
第一个向量是一个 32 位浮点表示,第二个是一个 16 位浮点表示,每个都四舍五入到最大可靠的精度。第二个向量需要的内存减少了 50%(2 字节对 4 字节),同时仍然表示大约相同的价值,只是精度较低。
这种降低精度是标量量化的一个简单例子,将更高精度的值映射到需要更少内存来存储和更少计算来处理的较低精度表示。
但如果我们想进一步压缩向量到单个字节(甚至几个比特)——我们还能做到这一点并保留大部分召回率吗?答案是肯定的,我们通常通过将浮点值范围映射到 Int8 来实现这一点,如图 13.10 所示。

图 13.10 从 Float32 到 Int8 的标量量化
在图中,顶部的曲线表示浮点数范围内的值分布。由于我们从 32 位量化到 8 位,我们将 Float32 值的范围映射到更小的范围-128 到 127(如果使用无符号整数,则为 0 到 255)。根据所使用的量化算法,通常会尝试尽可能充分地利用新的范围,通过钳位(限制范围到最小和最大值)以及利用原始向量中值的密度,将它们更均匀地映射到新的、量化的范围内。
让我们实现一个标量量化示例,看看这种优化对索引大小、召回率和搜索速度有什么影响。存在多个用于执行标量量化的库。您可以使用sentence_transformers.quantization模块,或者您的搜索引擎或语言模型可能内置了自己的量化实现。我们将利用 FAISS 库来构建我们的量化索引,并使用sentence_transformers库和 FAISS 的组合来构建我们的每个量化示例。FAISS(Facebook AI Similarity Search)是一个开源库,旨在高效地进行密集向量的相似性搜索和聚类。它与我们在本章早期用于语义搜索的 NMSLIB 库类似,但它有一些额外的功能,包括内置的量化支持。FAISS 在密集向量搜索的生产系统中得到广泛应用,并且是实现量化索引的一个很好的选择。
我们稍后会通过一个示例来展示如何使用所选搜索引擎的collection抽象进行搜索,但由于并非每个搜索引擎都支持所有量化模式,并且每个搜索引擎都有不同的开销和性能特征,我们将使用 FAISS 进行基准测试。
现在我们将构建一个具有全精度 Float32 嵌入的 FAISS 索引。然后我们将将其作为基准与各种量化嵌入索引进行比较,以比较索引大小、搜索速度和召回率。列表 13.20 显示了创建全精度 Float32 嵌入并将它们索引到 FAISS 索引中的代码。
列表 13.20 使用 FAISS 索引全精度嵌入
from sentence_transformers.quantization import quantize_embeddings
model = SentenceTransformer(
"mixedbread-ai/mxbai-embed-large-v1", #1
similarity_fn_name=SimilarityFunction.DOT_PRODUCT,
truncate_dim=1024) #2
def index_full_precision_embeddings(doc_embeddings, name):
index = faiss.IndexFlatIP(doc_embeddings.shape[1]) #3
index.add(doc_embeddings) #4
faiss.write_index(index, name) #5
return index
def get_outdoors_embeddings(model):
outdoors_dataframe = load_dataframe("data/outdoors/posts.csv")
post_texts = [post["title"] + " " + post["body"]
for post in outdoors_dataframe.collect()]
return numpy.array(
get_embeddings(post_texts, model, "outdoors_mrl_normed"))
doc_embeddings = get_outdoors_embeddings(model) #6
full_index = index_full_precision_embeddings( #7
doc_embeddings, "full_embeddings") #7
1 该模型创建支持所有即将到来的优化技术的嵌入。
2 原始嵌入将具有 1,024 个维度。
3 IndexFlatIP 是一个简单、未优化的索引,支持不同的嵌入格式。
4 将文档添加到索引中
5 将索引写入磁盘
6 为户外数据集生成嵌入
7 创建一个全精度(Float32)的 FAISS 索引
在这个列表中,我们使用 mixedbread -ai/mxbai-embed-large-v1 模型为户外数据集计算嵌入表示,该模型产生高质量的嵌入表示,与我们的所有量化技术都很好地配合工作,并且支持玛特罗什卡表示学习,我们将在本章后面探讨。然后我们将这些嵌入索引到一个完整精度(Float32)的 FAISS 索引中,我们将很快将其用作基准来评估各种量化技术的性能。
对于我们的基准测试,我们还需要将一些测试查询编码成嵌入表示,这将在列表 13.21 中展示。
列表 13.21 生成查询嵌入和完整索引基准
def get_test_queries():
return ["tent poles", "hiking trails", "mountain forests",
"white water", "best waterfalls", "mountain biking",
"snowboarding slopes", "bungee jumping", "public parks"]
queries = get_test_queries() #1
query_embeddings = model.encode(queries, #2
convert_to_numpy=True, #2
normalize_embeddings=True) #2
full_results = time_and_execute_search( #3
full_index, "full_embeddings", #3
query_embeddings, k=25) #3
display_statistics(full_results) #4
1 获取基准测试的测试查询
2 为每个查询生成嵌入表示
3 为完整精度(Float32)索引生成搜索时间、索引大小和召回率统计
4 显示基准测试统计信息
输出:
full_embeddings search took: 7.621 ms
full_embeddings index size: 75.6 MB
Recall: 1.0
在前面的列表中,我们定义了一个查询列表,我们将使用它来基准测试我们的完整精度索引与各种向量搜索优化策略。然后我们对列表 13.20 中的完整精度索引调用 time_and_execute_search 函数(为了简洁省略),然后将结果传递给 display_statistics 函数(同样为了简洁省略),该函数显示搜索时间、索引大小和召回率统计。
这为我们即将推出的量化(或优化)索引提供了一个比较基准。列表 13.22 展示了我们将使用来比较其他索引策略结果的两个附加函数的实现:evaluate_search 函数和 evaluate_rerank_search 函数。
列表 13.22 用于基准测试优化搜索方法的函数
def evaluate_search(full_index, optimized_index, #1
optimized_index_name, #1
query_embeddings, #1
optimized_query_embeddings, #1
k=25, display=True, log=False): #1
full_results = time_and_execute_search( #2
full_index, "full_embeddings", #2
query_embeddings, k=k) #2
optimized_results = time_and_execute_search( #2
optimized_index, #2
optimized_index_name, #2
optimized_query_embeddings, k=k) #2
if display:
display_statistics(optimized_results, full_results)
return optimized_results, full_results
def evaluate_rerank_search(full_index, optimized_index, #3
query_embeddings, #3
optimized_embeddings, #3
k=50, limit=25): #3
results, full_results = evaluate_search(
full_index,
optimized_index, None,
query_embeddings,
optimized_embeddings,
display=False, k=k)
doc_embeddings = get_outdoors_embeddings(model) #4
rescore_scores, rescore_ids = [], []
for i in range(len(results["results"])):
embedding_ids = results["faiss_ids"][i]
top_k_embeddings = [doc_embeddings[id] #5
for id in embedding_ids] #5
query_embedding = query_embeddings[i] #5
scores = query_embedding @ \ #5
numpy.array(top_k_embeddings).T #5
indices = scores.argsort()[::-1][:limit] #6
top_k_indices = embedding_ids[indices] #6
top_k_scores = scores[indices] #6
rescore_scores.append(top_k_scores) #6
rescore_ids.append(top_k_indices) #6
results = generate_search_results(rescore_scores, rescore_ids) #7
recall = calculate_recall(full_results["results"], results) #8
print(f"Reranked recall: {recall}")
1 从每个索引中检索前 25 个结果并进行比较
2 计算优化索引与完整精度索引的查询速度、索引大小和召回率
3 与 evaluate_search 相同,但请求 k=50 个结果(默认值)并使用完整精度嵌入重新排序这些结果
4 为户外数据集中的每个文档生成嵌入表示
5 在查询嵌入和前 k 个嵌入之间执行点积操作
6 按点积分数对结果进行排序以重新排序
7 辅助函数(省略)用于将 ID 和分数与其他文档字段结合,以返回
8 计算重新排序后的结果与完整精度索引的召回率
evaluate_search 函数在内部对完整精度索引和量化索引都调用 time_and_execute_search 函数,并将结果传递给 display_statistics 函数以比较和显示搜索时间、索引大小和召回率统计。
然后,evaluate_rerank_search 函数在重新排序使用全精度嵌入从量化索引中获取的顶级-N 结果后,再次计算召回率。虽然量化可以大幅减少内存和搜索时间,但我们会看到它也降低了召回率,这意味着一些应该返回的结果没有返回。但是,通过过度请求并使用全精度嵌入(通常在量化搜索后从磁盘加载,而不是与索引一起保留在内存中)重新排序仅顶级-N 结果,我们可以恢复大部分丢失的召回率。
我们将在每个后续列表中展示量化召回率和重新排序的量化召回率,以展示全精度搜索、量化搜索和重新排序的量化搜索之间的权衡。对于我们的第一个量化示例,让我们实现 Int8 标量量化。
列表 13.23 使用 FAISS 创建 Int8 量化嵌入索引
def index_int8_embeddings(doc_embeddings, name):
int8_embeddings = quantize_embeddings( #1
doc_embeddings, precision="int8") #1
print("Int8 embeddings shape:", int8_embeddings.shape)
index = faiss.IndexFlatIP(int8_embeddings.shape[1]) #2
index.add(int8_embeddings) #3
faiss.write_index(index, name) #4
return index
int8_index_name = "int8_embeddings"
int8_index = index_int8_embeddings(doc_embeddings, int8_index_name)
quantized_queries = quantize_embeddings( #5
query_embeddings, #5
calibration_embeddings=doc_embeddings, #5
precision="int8") #5
evaluate_search(full_index, int8_index, #6
int8_index_name, query_embeddings, #6
quantized_queries) #6
evaluate_rerank_search(full_index, int8_index, #7
query_embeddings, quantized_queries) #7
1 将文档嵌入量化为 Int8 精度
2 创建一个配置为期望嵌入形状的索引
3 将量化嵌入添加到索引中
4 将索引保存到磁盘,以便我们可以测量其大小
5 将查询嵌入量化为 Int8 精度
6 对搜索时间、索引大小和召回率进行基准测试
7 再次执行基准测试,允许使用全精度嵌入重新排序顶级结果
输出:
Int8 embeddings shape: (18456, 1024)
int8_embeddings search took: 9.070 ms (38.65% improvement)
int8_embeddings index size: 18.91 MB (74.99% improvement)
Recall: 0.9289
Reranked recall: 1.0
在列表 13.23 的输出中,我们看到 Int8 量化索引比全精度索引小 75%,这是有道理的,因为我们已经从 Float32 精度降低到 Int8 精度,这是从 32 位到 8 位的 ~75% 减少。同样,我们看到由于精度较低的数字处理效率更高(至少在某些系统上),总搜索时间得到了改善。请注意,所有这些基准测试的搜索速度都会因系统而异,每次运行而异,但索引大小基准测试应该始终相同。此外,请注意,我们索引中的文档数量相当少,量化带来的查询速度提升随着文档数量的增加可能会变得更加明显。
然而,最重要的数字是召回率。Int8 量化搜索保持了 92.89% 的召回率。这意味着我们实现了索引大小约 75% 的减少和搜索速度的显著提高,而只有 7.11% 的顶级 N=25 结果在量化搜索中丢失。正如本书封面图 13.9 的中间图像保持了原始图像的大部分重要细节一样,我们同样能够在使用 Int8 量化时在列表 13.23 中保留量化嵌入的高保真度。
重排序召回率 值为 1.0 进一步表明,仅通过请求前 N=50 个结果并使用原始全精度嵌入进行重排序,我们就能达到 100% 的召回率。当在密集向量搜索中使用量化时,这是一个常见的模式:首先使用量化索引进行初始搜索,以请求过多的结果(为了显著提高内存和速度),然后使用更高精度的嵌入(通常从磁盘上拉取,这样就不会影响您的索引和内存需求)对前 N 个结果进行重排序,以重新捕获丢失的召回率和排名精度。虽然这些改进令人印象深刻,但我们还可以进一步压缩,使用 4 比特(Int4)或更少。在下一节中,我们将看到当我们将每个维度压缩到单个比特位时会发生什么!
13.7.2 二进制量化
二进制量化是一种极端的量化形式,其中嵌入向量中的每个值都由单个比特位表示,将值简化为 0 或 1。这种方法类似于将图像转换为仅黑白(只有一种黑色,不是灰度),就像图 13.9 右侧的示例中那样。
使用简单的阈值将每个特征量化为单个比特位,可以为任何小于或等于 0 的特征分配比特 0,对于任何大于 1.0 的特征分配值为 1。如果特征值在嵌入向量中在正负值之间具有均匀分布,这种方法效果很好。然而,如果特征值在文档中分布不均匀,则使用每个特征的中值或类似的阈值来分配 0 或 1 作为二进制量化值,可以帮助更均匀地分布这些值。
图 13.11 展示了二进制量化的结果,其中仅保留了最基本的信息,类似于这本书封面的黑白版本。最左边的图像展示了使用简单的阈值,中间的图像表示使用非均匀阈值,该阈值根据原始嵌入中每个特征的相对值分布来分配值,而最右边的图像显示了直接从 Transformer 编码器模型中学习到的最优二进制表示。这种最后的“基于模型”的二进制量化看起来最好,因为值的选取在生成二进制表示时考虑了整个图像的上下文,而不仅仅是单个特征。这允许根据模型如何被训练来理解整个图像,对原始嵌入进行更有意义和更准确的编码,基于可用的二进制量化特征值。我们不会展示基于模型进行二进制量化的示例,因为我们正在尝试在不同量化级别上对相同的嵌入进行召回率基准测试,但值得记住的是,如果你的模型支持基于模型的二进制量化,它通常会比在模型已经以更高精度编码并返回它们之后对特征进行二进制量化提供更好的结果。

图 13.11 不同的二进制量化技术
现在我们使用 FAISS 实现二进制量化并基准测试结果。
列表 13.24 索引和基准测试二进制量化嵌入
def index_binary_embeddings(doc_embeddings,
binary_index_name):
binary_embeddings = quantize_embeddings( #1
doc_embeddings, #1
precision="binary").astype(numpy.uint8) #1
print("Binary embeddings shape:", binary_embeddings.shape)
index = faiss.IndexBinaryFlat( #2
binary_embeddings.shape[1] * 8) #2
index.add(binary_embeddings) #3
faiss.write_index_binary(index, binary_index_name) #4
return index
binary_index_name = "binary_embeddings"
binary_index = index_binary_embeddings(
doc_embeddings, binary_index_name)
quantized_queries = quantize_embeddings( #5
query_embeddings, #5
calibration_embeddings=doc_embeddings, #6
precision="binary").astype(numpy.uint8) #7
evaluate_search(full_index, binary_index, #8
binary_index_name, #8
query_embeddings, quantized_queries) #8
evaluate_rerank_search(full_index, binary_index, #8
query_embeddings, quantized_queries) #8
1 将文档嵌入量化为二进制(每个维度 1 位)
2 创建二进制嵌入索引
3 将所有文档嵌入添加到索引中
4 将索引写入磁盘
5 将查询嵌入量化为二进制
6 将文档嵌入提供给量化器以校准分配 0 或 1 的最佳阈值。
7 将每 8 个维度保存为 1 字节,编码为无符号 Int8
8 对全精度索引进行带和不带重新排序的基准测试
输出:
Binary embeddings shape: (18456, 128)
binary_embeddings search took: 1.232 ms (83.38% improvement)
binary_embeddings index size: 2.36 MB (96.87% improvement)
Recall: 0.6044
Reranked recall: 1.0
虽然二进制量化将索引大小和内存需求减少了 96.87%(从 32 位到 1 位)这一点并不令人意外,但令人惊讶的是,我们仅通过每个嵌入特征使用一个比特就能保留 60.44%的召回率。虽然 60%的召回率对于许多搜索用例来说可能并不充分,但请注意,当我们通过 2 倍增加请求量(N=50以找到前 25 个)并重新排序时,我们能够将测试查询的召回率恢复到 100%。在更大的数据集和查询数量中,你不太可能保持 100%的召回率,但即使仅使用每个特征的一个比特进行初始搜索就能接近完全召回,这也是一项令人印象深刻的成就。
在能够通过过度请求和重新排序而几乎达到 100%的召回率的同时实现如此极端的压缩,二进制量化推动了量化可能性的极限。你可能认为每个维度一个比特将是我们可以量化嵌入的极限,但在下一节中,我们将介绍一种允许我们进一步压缩的技术——产品量化。
13.7.3 产品量化
虽然标量量化二进制量化关注于降低嵌入中单个特征的精度,但产品量化(PQ)则专注于将整个嵌入量化为更内存高效的表示。它允许比二进制量化更深层次的压缩,使得 PQ 对于具有大量嵌入维度的大规模搜索应用尤其有益。
想象一下,你有一个很长的句子,你可以将其拆分成更短的短语;这样处理和加工每个短语就更容易了。同样,PQ 将向量空间划分为更小的区域(子向量),并对每个子向量单独进行量化。然后,PQ 将每个子向量区域进行聚类,以找到一组聚类中心,并最终为每个文档的每个子向量分配一个中心点 ID。每个文档的每个子向量对应的中心点 ID 列表(每个子向量一个)被称为 PQ 码,它是文档嵌入的量化表示。图 13.12 展示了 PQ 过程。

图 13.12 产品量化过程
量化过程从图形的顶层开始,并按顺序向下进行。首先,将d维度的原始向量空间划分为M个子向量空间。对于每个M个子向量空间,从每个原始嵌入中划分出相应的子向量。然后,在子空间内的所有子向量上执行聚类(通常使用k-means 算法),以创建一个由子空间内其质心向量标识的num_clusters个聚类列表。然后,每个质心被分配一个 ID,该 ID 记录在包含每个质心 ID 与其完整子向量映射的代码簿中。最后,每个文档的每个子空间的质心 ID 按顺序连接在一起,创建出PQ 码,这是每个文档的官方量化表示。
文档的 PQ 代码随后被索引,在查询时,查询嵌入被划分为相同的M个子空间,并计算该子空间中查询子向量与每个聚类中心之间的距离,并将其缓存在查找表中。由于每个文档的 PQ 代码映射到每个子空间中的特定聚类中心,我们可以通过查找缓存的查找表来获取查询子向量与文档子向量之间的近似距离。然后,可以使用内积或欧几里得距离等度量标准计算查询与每个文档之间的向量相似度得分,并按组合子空间中最小距离进行排序。例如,计算欧几里得距离只需要对每个子向量距离平方和的平方根。
像 FAISS 这样的库提供了 PQ 的现成高效实现。列表 13.25 展示了使用 PQ 构建索引的过程。
列表 13.25 构建和基准测试产品量化索引
def index_pq_embeddings(doc_embeddings, index_name, num_subvectors=16):
dimensions = doc_embeddings.shape[1] #1
M = num_subvectors #2
num_bits = 8 #3
index = faiss.IndexPQ(dimensions, M, num_bits) #4
index.train(doc_embeddings) #5
index.add(doc_embeddings) #6
faiss.write_index(index, index_name) #7
return index
pq_index_name = "pq_embeddings"
pq_index = index_pq_embeddings(doc_embeddings,
pq_index_name)
evaluate_search(full_index, pq_index, pq_index_name, #8
query_embeddings, query_embeddings) #8
evaluate_rerank_search(full_index, pq_index, #8
query_embeddings, #8
query_embeddings) #8
1 原始嵌入是 1024 维。
2 将嵌入划分为 M=16 个子向量(每个子向量为 64 维)
3 8 位=每个子向量最多 256 个聚类中心。
4 创建 PQ 索引
5 使用 k-means 聚类生成聚类中心
6 将所有 doc_embeddings 添加到索引中
7 将索引保存到磁盘,以便我们可以测量其大小
8 运行与全精度索引的基准测试
输出:
pq_embeddings search took: 2.092 ms (75.22% improvement)
pq_embeddings index size: 1.34 MB (98.22% improvement)
Recall: 0.3333
Reranked recall: 0.6800
你会立即注意到,PQ 索引的召回率显著低于我们在 33.33%召回率(未重排序)和 68%召回率(重排序后)时基准测试的标量和二进制量化类型。然而,你也会注意到,索引大小比原始全精度索引小 98.22%。显然,我们在这里有意做出了权衡,以超优化索引大小而牺牲召回率。然而,与标量和二进制量化技术不同,PQ 提供了调整这种权衡的杠杆,通过增加子向量的数量(M_subvectors)或存储更多精度的num_bits来提高召回率。例如,如果你将M_subvectors设置为 64,你会得到以下结果:
pq_embeddings search took: 4.061 ms (43.99% improvement)
pq_embeddings index size: 2.23 MB (97.05% improvement)
Recall: 0.5778
Reranked recall: 0.9911
这些结果现在与二进制量化结果更加接近。因此,主要收获是 PQ 的主要优势在于控制索引大小和召回率之间的权衡的灵活性,尤其是在你需要显著压缩嵌入时。
我们已经探讨了多种量化方法来减小索引嵌入的大小并加快搜索时间,同时保持相对于压缩水平的高召回率。在下一小节中,我们将通过引入实际上在原始嵌入表示中编码多个精度级别的嵌入来探索解决压缩与召回率权衡的不同方法。
13.7.4 马特罗什卡表示学习
马特罗什卡表示学习(MRL)是一种新颖的向量性能优化方法,它学习向量空间的层次表示,其中多个精度级别由向量空间内不同范围的维度编码。这允许灵活长度的表示,其中嵌入的较短部分可以近似完整嵌入的意义,只是精度较低。MRL 以俄罗斯套娃(马特罗什卡娃娃)命名,标志着要发现的其他层中的“层”精度。
作为 MRL 工作原理的概念性解释,想象一下,如果有人从未看过迪士尼动画电影《狮子王》,让你描述它。如果你熟悉这部电影,你可能会从一个非常高级的总结开始,然后如果那个人想要更多细节,再扩展你的描述。例如,你可以想象以下可能的回答:
-
这是一部关于狮子的迪士尼动画电影。
-
这是一部关于一只小狮子成长为国王的迪士尼动画电影。
-
这是一部关于名叫辛巴的小狮子成长为国王的迪士尼动画电影,他的父亲被叔叔杀害。
-
这是一部关于名叫辛巴的小狮子成长为国王的迪士尼动画电影,他在作为男孩逃离王国后,成年归来,从杀害他父亲的叔叔那里夺回王位。
注意,这些都在不同粒度级别上给出了故事的主要思想。如果你真的需要提供最准确描述,你会选择最详细的那个,但在现实中,在某些情况下,从更高层次的描述开始,并在需要时提供额外的详细级别可能更有效。
这种在不同层次粒度级别上揭示更多关于主题的概念是 MRL 背后的关键思想。这种技术允许向量具有渐进的准确性,这意味着你使用的向量越多,表示就越精确。为了说明这一点,让我们考虑图 13.13,它显示了这本书在不同像素化级别上的封面。

图 13.13 层次表示级别平衡准确性和压缩
如您所见,左上角的图像非常像素化,代表了原始封面的非常粗糙的近似。向右移动,然后到下一行,每个后续图像包含两倍多的维度,最终达到清晰和详细的表示。然而,这些维度是对先前维度的细化;它们并不是完全新的信息。换句话说,可以使用完整的向量,只使用向量的前半部分,只使用向量的前四分之一,等等,仍然可以得到原始向量的近似,只是精度较低。这与 MRL 嵌入的工作方式类似。
表 13.26 展示了使用 MRL 嵌入构建和基准测试 FAISS 索引。请注意,由于通过简单地截断原始嵌入的后续维度来实现低精度表示,因此对于 MRL 嵌入不需要特殊的索引策略。你只需将你要索引和搜索的密集向量场的维度数减少到你选择的 MRL 嵌入的维度数(每次减半并丢弃后半部分)。
表 13.26 展示了在不同阈值下基准测试 MRL 嵌入
def get_mrl_embeddings(embeddings, num_dimensions):
mrl_embeddings = numpy.array( #1
list(map(lambda e: e[:num_dimensions], embeddings))) #1
return mrl_embeddings
def index_mrl_embeddings(doc_embeddings, num_dimensions, mrl_index_name):
mrl_doc_embeddings = get_mrl_embeddings(doc_embeddings, num_dimensions)
print(f"{mrl_index_name} embeddings shape:", mrl_doc_embeddings.shape)
mrl_index = index_full_precision_embeddings( #2
mrl_doc_embeddings, mrl_index_name) #2
return mrl_index
print(f"Original embeddings shape:", doc_embeddings.shape)
original_dimensions = doc_embeddings.shape[1] #3
for num_dimensions in [original_dimensions//2, #4
original_dimensions//4, #5
original_dimensions//8]: #6
mrl_index_name = f"mrl_embeddings_{num_dimensions}"
mrl_index = index_mrl_embeddings(doc_embeddings,
num_dimensions,
mrl_index_name)
mrl_queries = get_mrl_embeddings(query_embeddings,
num_dimensions)
evaluate_search(full_index, mrl_index, mrl_index_name, #7
query_embeddings, mrl_queries) #7
evaluate_rerank_search(full_index, #8
mrl_index, query_embeddings, mrl_queries) #8
1 仅使用前 num_dimensions 维度
2 MRL 索引是一个标准索引,只是维度数减少了。
3 1024 维度
4 512 维度
5 256 维度
6 128 维度
7 基准 MRL 搜索
8 基准 MRL 搜索 + 重新排序
输出:
Original embeddings shape: (18456, 1024)
mrl_embeddings_512 embeddings shape: (18456, 512)
mrl_embeddings_512 search took: 3.586 ms (49.15% improvement)
mrl_embeddings_512 index size: 37.8 MB (50.0% improvement)
Recall: 0.7022
Reranked recall: 1.0
mrl_embeddings_256 embeddings shape: (18456, 256)
mrl_embeddings_256 search took: 1.845 ms (73.45% improvement)
mrl_embeddings_256 index size: 18.9 MB (75.0% improvement)
Recall: 0.4756
Reranked recall: 0.9689
mrl_embeddings_128 embeddings shape: (18456, 128)
mrl_embeddings_128 search took: 1.061 ms (84.35% improvement)
mrl_embeddings_128 index size: 9.45 MB (87.5% improvement)
Recall: 0.2489
Reranked recall: 0.64
在输出中,我们看到 70.22% 的召回率被编码到嵌入的前 50% 特征中,47.56% 被编码到前 25% 特征中,24.89% 被编码到前 12.5% 特征中。虽然相对于压缩的这种召回率可能不如一些早期的量化方法那么令人印象深刻,但它在嵌入表示中内置,可以随时(或不用)使用,无需特殊索引要求,提供了一些有用的灵活性。此外,正如我们将在下一小节中看到的,MRL 还可以与其他大多数方法结合。
13.7.5 结合多个向量搜索优化方法
在本章中,我们讨论了多种提高向量搜索效率的方法:
-
ANN 搜索用于过滤必须评分的文档数量,使其更可能获得高分
-
量化技术以减少嵌入的内存需求和处理时间
-
MRL 以减少找到初始搜索结果集所需的维度数
-
重新排序以在应用其他技术后更积极地提高召回率和排名
在实践中,你通常可以结合这些技术中的几个来达到你期望的性能水平。表 13.27 展示了一个最终示例,它在一个单一实现中结合了这些方法:使用倒排文件索引 (IVF) 的 ANN 以提高性能,二进制量化以提高性能和压缩,MRL 在原始维度的一半进行性能和压缩,以及重新排序两倍的结果数量以提高召回率和相关性排名。
表 13.27 结合 ANN、量化、MRL 和重新排序
def index_binary_ivf_mrl_embeddings(reduced_mrl_doc_embeddings,
binary_index_name):
binary_embeddings = quantize_embeddings( #1
reduced_mrl_doc_embeddings, #1
calibration_embeddings=reduced_mrl_doc_embeddings, #1
precision="binary").astype(numpy.uint8) #1
dimensions = reduced_mrl_doc_embeddings.shape[1] #2
quantizer = faiss.IndexBinaryFlat(dimensions) #2
num_clusters = 256 #3
index = faiss.IndexBinaryIVF( #3
quantizer, dimensions, num_clusters) #3
index.nprobe = 4 #3
index.train(binary_embeddings) #4
index.add(binary_embeddings) #4
faiss.write_index_binary(index, binary_index_name) #4
return index
mrl_dimensions = doc_embeddings.shape[1] // 2 #5
reduced_mrl_doc_embeddings = get_mrl_embeddings( #5
doc_embeddings, mrl_dimensions) #5
binary_ivf_mrl_index_name = "binary_ivf_mrl_embeddings"
binary_ivf_mrl_index = index_binary_ivf_mrl_embeddings(
reduced_mrl_doc_embeddings, mrl_dimensions,
binary_ivf_mrl_index_name)
mrl_queries = get_mrl_embeddings(query_embeddings, #6
mrl_dimensions) #6
quantized_queries = quantize_embeddings(mrl_queries, #7
calibration_embeddings=reduced_mrl_doc_embeddings, #7
precision="binary").astype(numpy.uint8) #7
evaluate_search(full_index, binary_ivf_mrl_index, #8
binary_ivf_mrl_index_name, #8
query_embeddings, quantized_queries) #8
evaluate_rerank_search( #9
full_index, binary_ivf_mrl_index, #9
query_embeddings, quantized_queries) #9
1 二进制量化:对文档嵌入应用量化
2 配置,以便索引知道文档嵌入是如何量化的
3 ANN:使用二进制量化的 IVF 索引进行 ANN 搜索
4 训练、添加文档并将组合索引保存到磁盘
5 MRL:获取降维文档嵌入
6 MRL:获取降维查询嵌入
7 二进制量化:对查询嵌入应用量化
8 对二进制 ANN、二进制量化和 MRL 嵌入进行基准测试
9 再次使用全精度嵌入进行基准测试,使用重新排序
输出:
binary_ivf_mrl_embeddings search took: 0.064 ms (99.09% improvement)
binary_ivf_mrl_embeddings index size: 1.35 MB (98.22% improvement)
Recall: 0.3511
Reranked recall: 0.7244
如列表所示,不同的嵌入优化方法可以以互补的方式结合,以实现您在查询速度、索引压缩和内存使用以及最终相关性排名之间期望的平衡。在这种情况下,通过结合 ANN、MRL、二进制量化和重新排序,我们实现了迄今为止最快的搜索时间(比全精度搜索快 99%)和最小的索引大小(比全精度搜索小 98%),同时在重新排序后仍保持超过 72%的召回率。
在支持的引擎中使用量化
我们使用 FAISS 创建了量化索引,以确保所有展示的 ANN 和量化方法都可以轻松地根据索引大小、搜索速度和召回率进行复制,而不依赖于您配置的默认engine。不同的搜索引擎对量化方法的支持程度不同,有的在引擎内部执行量化,有的则需要您在引擎外部进行量化并配置适当的量化格式(FLOAT32、INT8、BINARY等)。我们collection接口上的search方法通过接受一个quantization_size参数(在列表 11.19 中已展示)实现了对标量量化和二进制量化的支持,使用后者通过截断 MRL 嵌入在索引和搜索之前来实现。通过在您的搜索请求中添加rerank_query部分,也支持重新排序。例如:
{
'query': [0, ..., 1],
'query_fields': ['binary_embedding'],
'quantization_size': 'BINARY',
'order_by': [('score', 'desc')],
'limit': 25,
'rerank_query': {
'query': [-0.01628, ..., 0.02110],
'query_fields': ['full_embedding'],
'quantization_size': 'FLOAT32',
'order_by': [('score', 'desc')],
'rerank_count': 50
}
}
此示例实现了一个初始的二进制量化查询(根据您的引擎支持的程度),并在使用全精度(Float32)嵌入重新排序前 50 个结果后返回前 25 个结果。
通过结合各种 ANN、标量量化、二进制量化、乘积量化和 MRL 技术,可以显著提高您的向量搜索系统的效率。存在许多其他量化技术,并且还在不断开发中,但这个概述应该为您提供一个很好的起点,如果您正在寻找优化向量搜索使用的方法。通过实验这些技术并以不同的方式组合它们,您可以优化您的搜索系统,以实现速度、内存使用和召回率之间的期望平衡,尤其是在将重新排序作为最终步骤应用时。但是,基于嵌入的重新排序并不总是获取最相关最终搜索结果的最佳方式。在下一节中,我们将探讨一种通常更好的方法来执行顶级结果的最终重新排序:使用交叉编码器。
13.8 交叉编码器与双编码器比较
在本章中,我们使用基于 Transformer 的编码器构建了语义搜索来生成嵌入。我们的策略涉及通过将查询和文档分别编码成嵌入来找到它们之间的相似度,然后使用余弦相似度根据两个嵌入的相似度生成一个相关性分数。这种将每个输入单独编码成嵌入以便进行比较的编码器被称为双编码器。
与双编码器相比,交叉编码器将输入对一起编码并返回一个相似度分数。假设输入是查询和文档,交叉编码器将查询和文档连接起来并一起编码,返回一个衡量文档如何回答查询的相似度分数。
从功能上讲,交叉编码器仍在生成两个输入之间的相似度分数,但它可以以双编码器无法实现的方式捕获查询和文档之间的共享上下文。例如,一个双编码器可能会将查询“登山远足”放置在包含文本“第一次雪地远足”的文档附近,因为它们都与一个类似的概念——远足相关。但交叉编码器会将查询和文档都传递给编码器(一个 Transformer),然后通过其注意力机制识别出,虽然两个输入都是关于远足的,但文档是关于初学者雪地远足(可能第一次不会涉及山脉)而不是具体关于查询“登山远足”。通过使用查询的上下文来解释文档,交叉编码器因此可以对文档与查询匹配得有多好进行更细微的解释,而双编码器只能独立地解释查询和文档。
图 13.14 可视化了双编码器和交叉编码器的架构。

图 13.14 双编码器与交叉编码器。双编码器分别处理查询和文档输入,而交叉编码器将它们一起处理以生成相似度分数。
在图中,请注意以下关键特征:
-
双编码器将查询和文档分别编码成可以用于相似度函数(如余弦)比较的嵌入。
-
交叉编码器将查询一起编码以分配相似度分数。
-
实心箭头表示必须在查询时发生的数据处理,而条纹箭头表示可以在索引时执行并缓存的工作。请注意,双编码器只需要在查询时对查询进行一次编码,而交叉编码器必须在查询时对每个需要相似度分数的文档进行编码。
交叉编码器在查询时的计算成本比双编码器更高,但它们通常也比双编码器更准确,因为它们可以捕捉查询和文档上下文之间的相关交互。因此,交叉编码器通常用于从基于双编码器的向量搜索或词汇搜索的快速搜索中重新排序一小部分顶级结果,以提供更准确的顶级查询和文档对的相似度评分。
就像我们在第十章到第十二章中构建的学习到排名模型一样,交叉编码器是另一种形式的排名分类器——一种将输入分类为可能的相似度评分的模型。列表 13.28 展示了如何调用交叉编码器来重新排序搜索结果。我们将使用列表 13.19 中的相同初始查询,但我们将请求更多的搜索结果数量(limit=50),这样我们就可以为交叉编码器提供更多的重新排序选项。
列表 13.28 使用交叉编码器重新排序搜索结果
from sentence_transformers import CrossEncoder
cross_encoder = \ #1
CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2") #1
query = "mountain hike"
search_results = semantic_search_with_engine( #2
embeddings_collection, query, limit=50) #2
pairs_to_score = [[query, doc["title"]] #3
for doc in search_results] #3
cross_scores = cross_encoder.predict(pairs_to_score, #4
activation_fct=torch.nn.Sigmoid()) #5
reranked_results = rerank(search_results, cross_scores) #6
display_results(query, reranked_results[:10])
1 在类似的问题和答案数据集上训练的交叉编码器模型。
2 为每个文档生成一个查询+文档标题的配对进行评分
3 请求 50 个结果以提供足够的候选结果进行重新排序
4 调用交叉编码器对每对进行评分
5 可选的激活函数,用于将评分归一化到 0 到 1 之间
6 根据交叉编码器的评分更新相关性排名
响应:
0.578 | What constitutes mountain exposure when hiking or scrambling?
0.337 | ... hiking trails... in... Rocky Mountain National Park...
0.317 | Where in the US can I find green mountains to hike...?
0.213 | Appropriate terms... hiking, trekking, mountaineering...
0.104 | Camping on top of a mountain
0.102 | ... Plan a Hiking Trip to Rocky Mountain National Park, CO
0.093 | What considerations... for... a hiking ascent of Mount...
0.073 | Are there any easy hiking daytrips up mountains...
0.053 | First time snow hiking
0.049 | Advice for first Grand Canyon Hike for Eastern Hikers
虽然评分不是余弦相似度(因此不能直接与双编码器评分进行比较),但应用双编码器后,结果的质量似乎普遍有所提高。注意,现在与一般徒步旅行相比,更多文档与登山有关。
以本章整体来看,将交叉编码器与双编码器集成的常见模式如下:
-
使用 ANN、量化和表示学习技术的组合进行初始搜索(超级快)。
-
使用从磁盘加载的N个更高精度的向量重新排序中等数量的结果(数百或数千个),仅针对前N个结果(由于结果数量减少,因此速度快)。
-
取结果的前一页或两页,并使用交叉编码器重新排序以获得顶级结果的最佳排名(慢,但仅针对少量结果)。
你当然可以使用任何学习到排名模型进行最终的排名步骤。交叉编码器是最常部署的学习到排名模型之一(专注于文档内容),因为它们不需要显式建模的特征,而是从深度学习训练过程中自动学习语言和内容特征。你可以(并且应该)当然微调你的交叉编码器模型,利用第十章和第十一章中的技术,但许多人只是使用现成的预训练交叉编码器模型而不进行微调,因为它们在处理通用文本内容时往往具有很好的泛化能力。
通过本章所学的内容,你现在应该能够用你自己的内容做以下事情:
-
评估和选择一个与您的用例相匹配的现有微调 Transformer 编码器模型。
-
对您文档中的重要文本进行编码,并将它们添加到嵌入索引中,以便进行 ANN 搜索。
-
构建一个自动补全管道,以接受纯文本查询,并快速返回最相关的概念。
-
为您的产品添加一个强大的高召回率语义搜索。
-
通过结合 ANN、量化、MRL 和重排序技术,优化您密集向量搜索系统的性能。
-
使用双编码器对结果进行排序,并使用交叉编码器对这些搜索结果进行重排序,以提高搜索相关性排序。
密集向量搜索背后的技术仍有改进的空间,因为基于嵌入的排序可能计算密集,而 ANN 方法有非平凡的扩展权衡。利用倒排索引的稀疏项向量仍然更加高效且易于扩展。但向生产化这些密集向量搜索技术取得的巨大进步仍在继续,这是有充分理由的。不仅基于向量的搜索能够使文本上的语义搜索更好,而且它还使问答、结果摘要、图像搜索以及其他高级搜索用例(如检索增强生成(RAG))等前沿方法成为可能,所有这些内容我们将在接下来的章节中介绍。
摘要
-
密集向量搜索通过比较嵌入向量之间的距离(例如来自大型语言模型(LLMs))来对相关文档进行排序。
-
变换器使 LLM 能够将内容(查询、文档、句子等)的意义编码成向量,并且也能从编码的向量中解码意义。
-
语义搜索和其他使用案例,如语义自动补全,可以使用嵌入来实现。
-
近似最近邻(ANN)搜索是一种通过在执行查询与每个文档向量之间的昂贵相似度计算之前,过滤包含相似向量的文档来加速密集向量检索的技术。
-
稠密向量搜索可以通过结合诸如 ANN 搜索、量化、玛特罗什卡表示学习(MRL)嵌入以及结果的重请求和重排序等技术,在搜索速度、内存使用和最佳结果召回方面进行高度优化。
-
双编码器为查询和文档生成单独的嵌入,并支持高容量匹配和排序,而交叉编码器在查询时需要更多的计算,因此最好用于从双编码器或词汇搜索中重排序少量顶级结果。
第十四章:使用微调的大型语言模型进行问答
本章涵盖
-
使用 LLM 构建问答应用程序
-
为训练创建问答数据集
-
微调基于 Transformer 的 LLM
-
将基于深度学习的 NLP 管道集成到搜索结果中提取和排序答案
我们在第十三章中介绍了使用 Transformers 进行语义搜索的基本知识,因此我们现在准备尝试搜索中最难的问题之一:问答。
问答是返回搜索者查询答案的过程,而不仅仅是搜索结果列表。问答方法有两种类型:抽取式和抽象式。抽取式问答是从你的文档中找到问题的确切答案的过程。它返回包含用户问题可能答案的文档片段,这样用户就不需要筛选搜索结果。相比之下,抽象式问答是生成用户问题响应的过程,可以是多份文档的摘要,也可以直接从没有源文档的 LLM 生成。在本章中,我们将主要关注抽取式问答,将抽象式问答留到第十五章。
通过解决问答问题,你将完成三件事:
-
你将更好地理解你在第十三章开始学习的 Transformer 工具和生态系统。
-
你将学习如何将大型语言模型微调到特定任务。
-
你将把你的搜索引擎与高级自然语言技术合并。
在本章中,我们将向你展示如何直接回答问题并生成一个可工作的问答应用程序。我们将处理的查询类型包括单句的谁、什么、何时、何地、为什么和如何问题。我们还将继续使用上一章的 Stack Exchange 户外数据集。我们的目标是使用户能够提出以前未见过的问题,并得到简短的答案作为回应,从而消除用户需要阅读多个搜索结果以找到答案的必要性。
14.1 问答概述
传统搜索在查询响应时返回文档或页面的列表,但人们可能经常在寻找问题的快速答案。在这种情况下,我们希望避免人们不得不在文本块中挖掘答案,而答案在我们的内容中已经直接存在。
在本节中,我们将介绍问答任务,然后定义用于实现问答的检索器-阅读器模式。
14.1.1 问答模型的工作原理
让我们看看一个问答模型在实际中是如何工作的。具体来说,我们正在实现抽取式问答,它能在给定的文本中找到问题的最佳答案。例如,考虑以下问题:
Q: 什么是极简主义鞋?
提取式问题回答通过查看可能包含答案的大文档来工作,并为你识别答案。
让我们看看一份可能包含我们问题答案的文档。我们向模型提供了一个问题 什么是 极简主义 鞋? 以及以下文档文本(上下文):
There was actually a project done on the definition of what a minimalist shoe
is and the result was "Footwear providing minimal interference with the
natural movement of the foot due to its high flexibility, low heel to toe
drop, weight and stack height, and the absence of motion control and stability
devices". If you are looking for a simpler definition, this is what Wikipedia
says, Minimalist shoes are shoes intended to closely approximate barefoot
running conditions. 1 They have reduced cushioning, thin soles, and are of
lighter weight than other running shoes, allowing for more sensory contact
for the foot on the ground while simultaneously providing the feet with some protection from ground hazards and conditions (such as pebbles and dirt). Oneexample of minimalistic shoes would be the Vibram FiveFingers shoes which
look like this.
文档可以被分解成许多小的部分,称为 span,模型会提取最佳 span 作为答案。一个工作的问题回答模型会评估问题和上下文,并可能产生这个 span 作为答案:
A: 旨在紧密模拟赤脚跑步条件的鞋
但是模型是如何知道任何给定的 span 是否是答案的概率呢?我们可以尝试查看不同的 span,看看它们是否以某种方式代表答案,但这会非常复杂。相反,通过首先学习上下文中每个标记是否是答案的开始以及每个标记是否是答案的结束的概率,可以简化这个问题。因为我们只关注一个标记代表开始的概率,另一个标记代表结束的概率,所以问题更容易理解和解决。我们的标记被视为离散值,提取式问题回答模型被训练来学习一个 概率质量函数(PMF),这是一个函数,它给出了离散随机变量恰好等于某个值的概率。这与我们在第十一章中讨论的连续 beta 分布中测量的连续值不同,这些连续值用于概率分布。这两个之间的主要区别在于我们的标记是离散值。
使用这种策略,我们可以训练一个模型,该模型将学习两个概率质量函数——一个用于答案 span 的起始标记,另一个用于答案 span 的结束标记。你可能会注意到我们之前说模型“可能”产生之前的答案。由于使用不同数据和超参数训练的模型会给出不同的结果,因此针对特定问题的具体答案可以基于模型的训练参数而变化。
为了说明这是如何工作的,我们将从一个已经为提取式问答任务训练好的模型开始。该模型将输出分词是答案跨度开始或结束的概率。当我们确定最可能的答案开始和结束位置时,那就是我们的答案跨度。我们将使用的预训练模型是 deepset/roberta-base-squad2,由 Hugging Face 组织提供并由 Deepset 团队训练。我们将通过列表 14.1 到 14.3 中的模型和管道将问题和上下文传递给这个模型,以确定答案跨度开始和结束的概率,以及最终的答案。图 14.1 通过对问题和上下文输入进行 分词、编码 和 预测 最合适的答案跨度来演示此过程。

图 14.1 提取式问答预测过程
在图中,你可以看到问题和上下文首先组合成一对以进行分词。然后对这个对进行分词,获得模型的分词输入。模型接受这些输入,然后输出两个序列:第一个序列是上下文中每个分词是答案开始的概率,第二个序列是上下文中每个分词是答案结束的概率。然后开始和结束概率序列被组合起来以获得最可能的答案跨度。
下面的列表介绍了此过程的第一个步骤:分词。
列表 14.1 加载分词器和模型
from transformers import AutoTokenizer, AutoModelForQuestionAnswering
model_name = "deepset/roberta-base-squad2"
tokenizer = AutoTokenizer.from_pretrained(model_name)
model = AutoModelForQuestionAnswering.from_pretrained(model_name)
列表 14.1 中的模型名称是一个专门为提取式问答预训练的公开模型。有了模型和分词器准备就绪,我们现在可以传入一个问题和答案对,如下所示列表。响应将显示标记的数量等于开始和结束概率的数量。
列表 14.2 分词问题及其上下文
question = "What are minimalist shoes"
context = """There was actually a project done on the definition of what a
minimalist shoe is and the result was "Footwear providing minimal
interference with the natural movement of the foot due to its high
flexibility, low heel to toe drop, weight and stack height, and the absence
of motion control and stability devices". If you are looking for a simpler
definition, this is what Wikipedia says, Minimalist shoes are shoes intended
to closely approximate barefoot running conditions. 1 They have reduced
cushioning, thin soles, and are of lighter weight than other running shoes,
allowing for more sensory contact for the foot on the ground while
simultaneously providing the feet with some protection from ground
hazards and conditions (such as pebbles and dirt).
One example of minimalistic shoes would be the Vibram FiveFingers
shoes which look like this."""
inputs = tokenizer(question, context, add_special_tokens=True,
return_tensors="pt")
input_ids = inputs["input_ids"].tolist()[0]
outputs = model(**inputs)
start_logits_norm = normalize(outputs[0].detach().numpy())
end_logits_norm = normalize(outputs[1].detach().numpy())
print(f"Total number of tokens: {len(input_ids)}")
print(f"Total number of start probabilities: {start_logits_norm.shape[1]}")
print(f"Total number of end probabilities: {end_logits_norm.shape[1]}")
响应:
Total number of tokens: 172
Total number of start probabilities: 172
Total number of end probabilities: 172
输入是通过将问题和上下文一起分词获得的。输出是通过将输入通过模型进行正向传递获得的。outputs 变量是一个包含两个元素的列表。第一个元素包含开始概率,第二个元素包含结束概率。
图 14.2 通过视觉演示了上下文中每个分词是否可能是答案跨度开始的概率,而图 14.3 同样演示了每个分词是否可能是答案跨度结束的概率(较暗的突出显示表示更高的概率)。

图 14.2 分词是否为答案跨度开始的概率

图 14.3 分词是否为答案跨度结束的概率
注意,每个标记在其各自的索引处都有一个起始概率(在图 14.2 中)和一个结束概率(在图 14.3 中)。我们还对每个索引处的起始和结束概率进行了归一化,使其介于0.0和1.0之间,这使得它们更容易思考和计算。我们称这些起始和结束概率列表为logits,因为它们是统计概率的列表。
例如,对于第 17 个标记(_definition),该标记作为答案起始的概率约为0.37,而作为答案结束的概率约为0.20。由于我们已经对这两个列表进行了归一化,因此答案跨度的起始是start_logits_norm等于1.0的标记,而答案跨度的结束是end_logits_norm等于1.0的位置。
以下列表展示了如何在图 14.2 和 14.3 中生成标记列表,以及如何提取最终的答案跨度。
列表 14.3 从标记化上下文中识别答案跨度
start_tokens = []
end_tokens = []
terms = tokenizer.convert_ids_to_tokens(input_ids)
start_token_id = 0
end_token_id = len(terms)
for i, term in enumerate(terms):
start_tokens.append(stylize(term, [0, 127, 255], start_logits_norm[0][i]))
end_tokens.append(stylize(term, [255, 0, 255], end_logits_norm[0][i]))
if start_logits_norm[0][i] == 1.0:
start_token_id = i
if end_logits_norm[0][i] == 1.0:
end_token_id = i + 1
answer = terms[start_token_id:end_token_id]
display(HTML(f'<h3>{clean_token(" ".join(answer))}</h3>')) #1
display(HTML(f'<pre>{" ".join(start_tokens)}</pre>')) #2
display(HTML(f'<pre>{" ".join(end_tokens)}</pre>')) #3
1 在以下输出中显示的提取答案跨度
2 图 14.2 中显示的起始概率
3 图 14.3 中显示的结束概率
输出:
_shoes _intended _to _closely _approximate _bare foot _running _conditions
通过在训练过程中将起始和结束概率质量函数拟合到问答/上下文/答案三元组的集合中,我们创建了一个模型,该模型可以为新的问题和上下文提供最可能答案的概率。然后我们使用这个模型在列表 14.3 中进行概率搜索,以识别文本中最可能的答案跨度。
在实践中,它的工作方式如下:
-
我们选择一个最小和最大的跨度大小——跨度是一组连续的单词。例如,答案可能只有一个单词长,或者像之前的答案一样,它可能长达八个单词。我们需要提前设置这些跨度大小。
-
对于每个跨度,我们检查该跨度是否是正确答案的概率。答案是具有最高概率的跨度。
-
当我们检查完所有跨度后,我们展示正确的答案。
构建模型需要大量的问答/上下文/答案三元组和一种将这些三元组提供给模型以便进行计算的方法。这时就出现了 Transformer 编码器,你应该已经在第十三章中熟悉了它。我们首先使用一个产生密集向量的 LLM 对大量训练数据进行编码。然后我们训练一个神经网络来学习给定跨度编码回答问题的概率质量函数,使用正负训练示例。
我们将在本章后面部分看到如何微调问答模型,但首先我们需要解决一个非常重要的细节:当有人提问时,我们从哪里获取上下文?
14.1.2 检索器-阅读器模式
在阅读关于抽取式问答如何工作的时候,你可能想过“所以,对于每个问题查询,我需要检查整个语料库中每个片段的概率吗?”不!那会非常慢且不必要,因为我们已经有一种非常快且准确的方法来获取可能包含答案的相关文档:搜索。
我们真正要制作的是一个功能强大的文本高亮器。将整个问答系统想象成一种自动的参考图书馆。它知道哪个文档包含你的答案,然后阅读该文档的文本,以便能够指出确切的答案给你。
这被称为检索器-阅读器模式。这个模式使用一个组件来检索和排序候选文档(对搜索引擎运行查询)以及另一个组件来阅读最相关文档的片段并提取适当的答案。这与基于 Lucene 的搜索引擎(如 Solr、OpenSearch 或 Elasticsearch)中的高亮功能非常相似:统一的高亮器将找到包含分析查询词的最佳段落,并使用它们作为上下文。然后,它确定查询关键词在该上下文中的确切位置,以便向最终用户显示周围上下文。
我们将要构建一个类似于荧光笔的工具,但与显示包含用户查询关键词的上下文不同,我们的问答荧光笔将回答类似这样的问题:
Q: What are minimalist shoes?
A: shoes intended to closely approximate barefoot running conditions
让我们看看完整的背景。当我们问“什么是极简主义鞋?”时,我们首先使用检索器来获取最有可能包含答案的文档。在这种情况下,这个文档(在这里进行了摘要,但在 14.1.1 节中展示了全文)被返回:
There was actually a project done on the definition... this is what Wikipedia
says, Minimalist shoes are shoes intended to closely approximate barefoot
running conditions. 1 They have reduced cushioning, thin soles, ...
拿到文档后,阅读器会扫描它,并找到最有可能回答问题的文本。
除了使用花哨的 Transformer 来找到正确的答案之外,我们在基本搜索之外又迈出一步,我们将实际上使用问题/答案阅读器的置信度作为重新排序器。所以,如果我们不确定在检索步骤中哪个文档最有可能包含答案,我们将让读者浏览一大堆文档,并从中找出最好的答案。这“一大堆文档”将是我们重新排序的窗口,我们可以将其设置为任何大小。
然而,请注意,实时分析文档并不高效。我们不应该要求阅读器查看 100 个文档——这将花费太多时间。我们将将其限制在一个更小的数字,比如 3 或 5。限制阅读器窗口迫使我们确保我们的搜索引擎非常准确。结果必须是相关的,因为如果一个检索器在顶部 5 个窗口大小内找不到相关候选者,它将不会给阅读者提供任何有用的东西来工作。
检索器-读者有两个独立的问题,因此我们可以用其他东西替换我们的检索器。我们已经展示了如何使用具有开箱即用的排名(第三章中介绍的 BM25)的词汇搜索引擎,但你也可以尝试使用第十三章中介绍的密集向量索引。
在我们可以处理实时用户问题之前,我们还需要训练一个问答模型来从上下文中预测最佳答案。我们将逐步构建我们的问答应用程序的完整步骤如下:
-
使用我们的搜索引擎设置检索器—我们将为我们的示例使用一个简单的具有高召回率的查询作为候选答案。
-
适当整理和标记数据—这包括将数据放入正确的格式,并从基础预训练模型中获得我们答案的第一遍。然后我们将进行第一遍手动修正,并标记用于训练和测试的示例。
-
理解数据结构的细微差别—我们将使用一个现有的数据结构,它将以正确的格式表示我们的训练和测试数据,以便进行微调任务。
-
微调模型—使用我们在上一步中进行的修正,我们将训练一个微调的问答模型,以获得比基线更好的准确性。
-
在查询时使用模型作为读者—我们将把所有这些放在一起,让我们可以请求查询,从搜索引擎获取候选答案,从模型中读取/重新排序答案,并将它们作为响应展示。
图 14.4 显示了我们的检索器、读者和重新排序器整个架构的流程:
-
用户提出一个问题,并使用该问题在检索器(搜索引擎)中查询文档。
-
搜索引擎匹配和排序以获取对读者最相关的最高-k个文档。
-
原问题与每个检索到的最高-k上下文配对,并送入问答管道。
-
读者将问题/上下文对进行分词并编码成跨度,然后预测最可能的前-n个答案跨度,其概率作为评分。

图 14.4 提取式问答的检索器-读者模式
-
- 重新排序器按降序排列每个最高-n个答案跨度的得分。
-
- 重新排序器排序的得分最高的答案被接受,并展示给用户。
要完成所有这些,我们需要调整检索器,整理数据以训练读者模型,使用该数据微调读者模型,并构建一个重新排序器。关于调整检索器(搜索引擎)的策略,本书已经进行了详细的介绍,所以接下来,我们将整理数据。
14.2 构建问答训练数据集
在本节中,我们将创建一个我们可以用来训练我们的问答模型的数据库。这涉及几个步骤:
-
收集和清理数据集以适应我们的问答问题空间的内容
-
从现有的模型和语料库自动创建银集(一个需要进一步标记的半精炼数据集)
-
手动纠正银集以生成金集(一个我们可以用于训练的可信数据集)
-
将数据集分割用于训练、测试和验证微调模型
我们将从 Stack Exchange 户外数据集开始,因为它的数据已经非常适合问答应用。我们需要用于微调基础模型的问题/答案对。
户外数据集已经格式良好,并且以小型的问答块形式呈现。借助 Transformers 的力量,我们可以快速地使用现成的工具和模型构建解决方案。这比尝试从其他内容(如《伟大的期望》这本书)中构建问答数据集要容易得多。如果你正在处理长篇文本,例如书籍或长篇文档,你首先需要将文本分割成段落,并手动为这些段落设计问题。
金集和银集
在机器学习中,金集是一个准确标记的数据集,用于训练、测试和验证模型。我们将金集视为高度宝贵的资产,因为收集它们通常需要大量的手动工作。训练模型的准确性和可用性受限于金集的准确性和广度。因此,你花费更多时间来增长和验证你的金集,模型就会越好。
为了减少在标记数据时所需的一些努力,我们可以通过让机器尝试为我们生成标记数据集来节省时间。这个自动生成的标记数据集被称为银集,它可以防止我们必须从头开始。
银集不如金集可信。由于我们通过机器自动化的过程自动获得银集,而这些过程不如人类准确,因此会有错误。因此,理想情况下,应该通过手动审计和纠正来提高其准确性。使用银集来启动你的训练数据集可以在长期内节省大量时间和精神努力,并有助于你扩展你的训练数据整理。
14.2.1 收集和清理问答数据集
接下来是我们的第一步:让我们构建一个可以标记并用于训练模型的数据库集。对于这个数据集,我们需要包含答案的相关上下文的问题。列表 14.4 展示了如何在 pandas 数据框的行中获取问题和包含答案的上下文。我们需要构建两个查询:一个用于获取社区问题,另一个用于获取这些问题的被接受社区答案。我们只将使用有被接受答案的问题/答案对。我们将分别执行这两个查询并将它们合并在一起。
我们所使用的模型指的是从中提取答案作为上下文的内容。记住,我们不是在生成答案,我们只是在文本体中找到最合适的答案。
列表 14.4 从 Solr 提取训练问题
def get_questions():
question_types = ["who", "what", "when", #1
"where", "why", "how"] #1
questions = []
for type in question_types:
request = {"query": type,
"query_fields": ["title"],
"return_fields": ["id", "url", "owner_user_id",
"title", "accepted_answer_id"],
"filters": [("accepted_answer_id", "*")], #2
"limit": 10000}
docs = outdoors_collection.search(**request)["docs"]
questions += [document for document in docs #3
if document["title"].lower().startswith(type)] #3
return questions
1 窄化我们检索的问题类型范围
2 只检索有已接受答案的问题
3 只使用以问题类型开头的标题
在列表 14.4 中的问题列表之后,我们接下来需要获取与每个问题相关联的上下文。列表 14.5 返回一个包含以下列的数据框:id、url、question和context。我们将使用question和context在接下来的章节中为我们的问答模型生成训练和评估数据。
列表 14.5 搜索已接受答案的上下文
def get_answers_from_questions(questions, batch_size=500):
answer_ids = list(set([str(q["accepted_answer_id"]) #1
for q in questions])) #1
batches = math.ceil(len(answer_ids) / batch_size) #2
answers = {}
for n in range(0, batches): #3
ids = answer_ids[n * batch_size:(n + 1) * batch_size]
request = {"query": "(" + " ".join(ids) + ")",
"query_fields": "id",
"limit": batch_size,
"filters": [("post_type", "answer")],
"order_by": [("score", "desc")]}
docs = outdoors_collection.search(**request)["docs"]
answers |= {int(d["id"]): d["body"] for d in docs}
return answers
def get_context_dataframe(questions):
answers = get_answers_from_questions(questions) #4
contexts = {"id": [], "question": [], "context": [], "url": []}
for question in questions:
contexts["id"].append(question["id"])
contexts["url"].append(question["url"])
contexts["question"].append(question["title"]),
if question["accepted_answer_id"] in answers:
context = answers[question["accepted_answer_id"]]
else:
context = "Not found"
contexts["context"].append(context)
return pandas.DataFrame(contexts)
questions = get_questions() #5
contexts = get_context_dataframe(questions) #6
display(contexts[0:5])
1 获取所有不同答案 ID 的列表
2 计算需要进行的搜索请求数量
3 聚合所有答案
4 获取所有问题的答案数据
5 加载列表 14.4 中的问题。
6 加载每个问题的上下文。
输出:
id question context
4410 Who places the anchors that rock c... There are two distinct styl...
5347 Who places the bolts on rock climb... What you're talking about i...
20662 Who gets the bill if you activate ... Almost always the victim ge...
11587 What sort of crane, and what sort ... To answer the snake part of...
7623 What knot is this one? What are it... Slip knot It's undoubtably ...
我们鼓励您检查问题和上下文对的完整输出,以欣赏所使用的各种输入和语言。如果您想访问 Stack Exchange 户外网站并自行在 Jupyter 笔记本中探索源数据,我们还包含了原始 URL。
14.2.2 创建银集:从预训练模型自动标记数据
现在我们有了我们的数据集,我们必须对其进行标记。为了使训练工作,我们需要告诉模型在给定问题的上下文(文档)中正确的答案是什么。存在一个 LLM(大型语言模型)已经在这方面做得相当不错:deepset/roberta-base-squad2。这个模型是由 Deepset 公司使用 SQuAD2 数据集预训练的,并在他们的 Hugging Face 页面上免费提供(huggingface.co/deepset)。SQuAD 是斯坦福问答数据集,这是一个由成千上万的问答对组成的大型公共数据集。Deepset 团队从第十三章中介绍的 RoBERTa 架构开始,并基于此数据集对模型进行微调,以完成问答任务。
备注:熟悉 Hugging Face 网站(huggingface.co)是个好主意。Hugging Face 社区非常活跃,已经提供了数千个免费预训练模型,任何人都可以使用。
我们的策略是使用可用的最佳预训练模型首先尝试回答所有问题。我们将这些答案称为“猜测”,整个自动标记的数据集称为“银集”。然后我们将遍历银集猜测并自行纠正它们,以获得“金集”。
列表 14.6 展示了我们的问答函数,该函数使用 question-answering 类型的 Transformers 流程和 deepset/roberta-base-squad2 模型。我们使用这些来构建一个包含适当的分词器和目标设备(CPU 或 GPU)的流程。这为我们提供了将原始数据传入并获取银牌集合所需的一切,如图 14.5 所示。

图 14.5 从准备好的数据框中获取银牌集合和金牌集合。
在 Python 中,我们创建了一个名为 answer_questions 的函数,该函数接受我们从检索器中提取的上下文列表。该函数将每个问题和上下文通过流程运行以生成答案,并将其追加到列表中。我们不会假设它们实际上是答案,因为其中许多将是错误的(正如你打开文件时将看到的)。我们只有在经过人工审核后才会将某些内容计为 答案。这是将银牌集合升级为金牌集合的本质。
device(CPU 或 GPU)将根据你的 Docker 环境中是否有 GPU 自动选择。现在是一个好时机来提到,如果你在仅使用 CPU 的家用电脑或 Docker 配置上运行或训练这些模型,你可能需要等待一段时间才能完成所有数据的推理。如果你没有使用 GPU,你可以自由地跳过运行列表 14.6–14.7,因为我们已经提供了运行此笔记本数据集中后续列表所需的输出。
列表 14.6 生成银牌集合,以提取出我们之前在列表 14.5 中加载的问题和接受答案上下文对中最可能的答案。
列表 14.6 根据问题/上下文对生成答案。
from transformers import pipeline #1
import torch
import tqdm #2
def get_processor_device(): #3
return 0 if torch.cuda.is_available() else -1 #3
def answer_questions(contexts, k=10):
nlp = pipeline("question-answering", model=model_name, #4
tokenizer=model_name, device=device) #4
guesses = []
for _, row in tqdm.tqdm(contexts[0:k].iterrows(), total=k): #5
result = nlp({"question": row["question"], #6
"context": row["context"]}) #6
guesses.append(result)
return guesses
model_name = "deepset/roberta-base-squad2"
device = get_processor_device() #7
guesses = answer_questions(contexts, k=len(contexts))
display_guesses(guesses)
1 这是我们在图 14.1 中展示的流程。
2 tqdm 以进度条的形式打印操作进度。
3 如果可用,使用 GPU (CUDA) 处理;否则使用 CPU。
4 这是我们在图 14.1 中展示的流程。
5 tqdm 以进度条的形式打印操作进度。
6 为每个问题/上下文对获取答案(和置信度分数)。
7 如果可用,使用 GPU (CUDA) 处理;否则使用 CPU。
输出:
score start end answer
0.278927 474 516 a local enthusiast or group of enthusiasts
0.200848 81 117 the person who is creating the climb
0.018632 14 24 the victim
...
0.247008 227 265 the traditional longbow made from wood
0.480407 408 473 shoes intended to closely approximate barefoot run...
0.563754 192 232 a tube of lightweight, stretchy material
恭喜,我们现在已经获得了银牌集合!在下一节中,我们将对其进行改进。
推荐使用 GPU
随意将这些列表运行在你的个人电脑上,但请注意——其中一些在 CPU 上可能需要一段时间。例如,列表 14.6 的总执行时间在我们的测试中,当在 GPU 上运行时比在中端 CPU 上运行减少了大约 20 倍。
注意,本章后面的微调示例将显著受益于拥有 GPU。如果你无法访问 GPU 来运行这些列表,那没关系——我们已经训练了模型,并将其作为户外数据集的一部分包含在内。你可以跟随列表来了解模型是如何训练的,如果你没有 GPU 可用,你可以直接跳过运行它们。你也可以使用像 Google Colab 这样的免费服务,或者从云服务提供商那里租用带有 GPU 的服务器,这通常每小时只需几美元。
如果你想要了解更多关于 GPU 以及为什么它们更适合像训练模型这样的任务,我们推荐阅读 Robert Robey 和 Yuliana Zamora 的《并行与高性能计算》(Manning, 2021)。
14.2.3 人机交互训练:手动纠正银色集以生成黄金集
银色集 CSV 文件(question-answering-squad2-guesses.csv)被用作尝试回答问题的初步尝试。我们将使用它,结合人工介入的手动纠正和标记数据,将银色集精炼成黄金集。
注意:没有任何 Python 代码可以为你生成黄金集。数据必须由一个了解该领域的人(或者也许有一天是一个高度优化于进行相关性判断的 AI 模型)进行标记。所有后续列表都将使用这个黄金集。不过,我们已经为你标记了数据,给你一个喘息的机会。为了参考,标记大约 200 个由deepset/roberta-base -squad2模型产生的猜测大约需要 4 到 6 个小时。
自己标记数据将使你更深刻地体会到这个 NLP 任务的难度。我们强烈鼓励你标记更多文档并重新运行即将到来的微调任务。理解获取高质量数据所需的努力以及它对模型准确性的影响,这是只有通过经验才能学到的教训。
然而,在开始标记数据之前,我们需要有一个如何以及标记什么的计划。对于每一行,我们需要对其进行分类,并在必要时将正确的答案我们自己写入另一列。
这里是关键,如图 14.6 所示,这是我们用于class字段中所有标记行的标签:
-
-2= 这是一个负面示例(一个我们知道猜测是错误的示例)。 -
-1= 忽略这个问题,因为它太模糊或者我们缺少一些信息。例如,这是什么鸟?没有鸟的图片,我们无法回答,所以我们甚至不尝试。 -
0= 这是一个由人纠正的示例,以突出同一上下文中更好的答案范围。deepset/roberta-base -squad2给出的猜测是不正确或不完整的,所以我们更改了它。 -
1= 这是一个由deepset/roberta-base -base-squad2给出正确答案的示例,所以我们没有更改答案。 -
(空白)= 我们没有检查这一行,所以我们将忽略它。

图 14.6 标签类别的图例
你应该打开 outdoors_golden_answers.csv 文件,亲自查看行。理解我们标注为0和1的问题比例。你甚至可以尝试在 pandas 中打开文件,进行一些分析,以便熟悉黄金集。
14.2.4 格式化训练、测试和验证的黄金集
现在我们有了标注数据,我们几乎准备好训练我们的模型了,但首先我们需要将数据格式化为训练和评估流程的正确格式。一旦我们的数据格式正确,我们还需要将其分成训练、测试和验证集,以确保在训练模型时不会过拟合我们的数据。
将标注数据转换为标准化数据格式
Hugging Face 提供了一个名为datasets的库,我们将使用它来准备我们的数据。datasets库可以接受许多公开可用的数据集的名称,并提供一个用于处理它们的标准化接口。SQuAD2 数据集是可用的数据集之一,但由于我们的黄金集是自定义格式,我们首先需要将其转换为以下列表中所示的标准化datasets配置格式。
列表 14.7 将黄金数据集转换为 SQuAD 格式
from datasets import Dataset, DatasetDict
def get_training_data(filename):
golden_answers = pandas.read_csv(filename)
golden_answers = golden_answers[golden_answers["class"] != None]
qa_data = []
for _, row in golden_answers.iterrows():
answers = row["gold"].split("|")
starts = [row["context"].find(a) for a in answers]
missing = -1 in starts
if not missing:
row["title"] = row["question"]
row["answers"] = {"text": answers, "answer_start": starts}
qa_data.append(row)
columns = ["id", "url", "title", "question", "context", "answers"]
df = pandas.DataFrame(qa_data, columns=columns) \ #1
.sample(frac=1, random_state=0) #1
train_split = int(len(df) * 0.75) #2
eval_split = (int((len(df) - train_split) / 1.25) + #3
train_split - 1) #3
train_dataset = Dataset.from_pandas(df[:train_split])
test_dataset = Dataset.from_pandas(df[train_split:eval_split])
validation_dataset = Dataset.from_pandas(df[eval_split:]) #4
return DatasetDict({"train": train_dataset, #5
"test": test_dataset, #5
"validation": validation_dataset}) #5
datadict = get_training_data("data/outdoors/outdoors_golden_answers.csv")
model_path = "data/question-answering/question-answering-training-set"
datadict.save_to_disk(model_path)
1 随机排序所有示例
2 75%的示例将用于训练。这将给我们 125 个训练样本。
3 20%的示例将用于测试。我们从 train_split 中减去 1,以便在三个分割上有 125/32/10 条记录。
4 剩余的 5%的示例将用于验证保留。这将是有 10 个样本。
5 SQuAD 需要三组数据:训练、测试和验证
14.7 列表中的函数的第一部分将 CSV 加载到 pandas 数据框中,并进行一些预处理和格式化。一旦格式化,数据就被分成三部分并转换。
从 14.7 列表返回并保存的对象是一个数据集字典(一个datadict),它包含我们的三个训练、测试和验证部分。对于我们的数据表,在get_training_data中定义的分割下,我们有 125 个训练示例、32 个测试示例和 10 个验证示例。
使用测试集和保留的验证集避免过拟合
过拟合一个模型意味着你训练它只记住提供的训练示例。这意味着它无法很好地泛化以处理之前未见过的数据。
为了防止过拟合,我们需要将我们的数据集分成独立的训练、测试和验证部分,就像在 14.7 列表中所做的那样。测试和保留的验证集用于在模型训练后衡量其成功程度。当你从头到尾完成这个过程后,考虑标注更多数据,并在训练、测试和验证部分进行不同的分割,以查看模型的性能。
我们使用训练/测试分割来给模型训练提供一些数据,并给测试结果提供一些数据。我们迭代调整模型训练的超参数,以提高当模型应用于测试集时的准确性(使用损失函数来衡量)。
保留验证集是未见数据的现实世界代理,它直到最后才被检查。在训练和测试完成后,你通过将最终模型版本应用于保留示例来验证最终模型版本。如果这个分数远低于最终测试精度,那么你的模型已经过拟合。
备注:我们使用的示例数量相当少(125 个训练示例,32 个测试示例和 10 个保留验证示例),与用于客户系统数据微调相比。一般来说,目标是大约 500 到 2000 个标记的示例。有时你可以用更少的示例完成,但通常示例越多越好。这将需要相当多的时间投入,但这是值得的。
14.3 调整问答模型
现在,我们将通过使用我们的黄金集微调现有的deepset/roberta-base-squad2模型来获得更好的模型。
不幸的是,这个下一个笔记本在 CPU 上运行可能会相当慢。如果你正在使用一台具有 CUDA 功能并且可以配置 Docker 环境以使用 GPU 的机器上查看列表,那么你应该一切就绪!否则,我们建议你使用像 Google Colab 这样的服务,它提供免费运行 Jupyter 笔记本在 GPU 上的服务,或者另一个已经准备好 CUDA 设备的云计算或托管提供商。你可以直接从 Google Colab 加载笔记本并运行,除了我们的数据集外,无需其他依赖。在相关笔记本中 14.8 列表上方提供了一个链接。
小贴士:正如我们之前提到的,如果你不想麻烦设置一个兼容 GPU 的环境,你也可以在不运行它们的情况下跟随 14.8-14.13 列表,因为我们已经训练了模型并为你提供了使用。然而,如果你能的话,我们确实鼓励你努力获取 GPU 访问并自己训练模型,以便了解这个过程是如何工作的,并使你能够调整超参数。图 4.7 显示了 GPU 可以为语言模型训练等巨大并行计算提供什么样的加速。

图 14.7 一个 V100 GPU(通常与云提供商一起提供)有 640 个张量计算核心,而一个 4 核心 x86-64 CPU 有 4 个核心。单个 CPU 核心更强大,但大多数 GPU 的核心数量比 CPU 多两个到三个数量级。在进行数百万个模型参数的巨大并行计算时,这一点很重要。
我们需要做的第一件事是要求访问 GPU 设备。以下列表中的代码将初始化并返回可用处理器的设备 ID。如果配置了 GPU 并且可用,我们应该看到该设备的 ID。如果你在使用 Colab 并且有任何关于列表 14.8 的问题,你可能需要在设置中将运行时类型更改为GPU。
列表 14.8 检测和初始化 GPU 设备
def get_processor_type():
gpu_device = torch.device("cuda:0")
cpu_device = torch.device("cpu")
return gpu_device or cpu_device
def get_processor_device():
return 0 if torch.cuda.is_available() else -1
print("Processor: " + str(get_processor_type()))
print("Device id: " + str(get_processor_device()))
输出:
Processor: device(type='cuda', index=0)
Device id: 0
我们有一个 GPU(在这个列表输出中至少有一个)。在响应中,device(type='cuda', index=0)是我们所寻找的。如果你在运行列表时没有可用的 GPU,将返回device(type='cpu'),表示将使用 CPU 进行处理。如果你有多个可用的设备供笔记本使用,它将按递增的数字 ID 列出每个设备。你可以在训练过程中通过指定一个id(在我们的情况下,0)来访问设备。
在我们的设备准备就绪后,我们将加载并标记化我们在列表 14.7 中预先标记的数据集。
14.3.1 标记化和塑造我们的标记数据
模型训练器不识别单词;它识别存在于 RoBERTa 词汇表中的标记。我们在第十三章中介绍了标记化,当时我们将其用作将文档和查询编码为密集向量以进行语义搜索的初始步骤。同样,在我们使用它来训练模型之前,我们需要对问答数据集进行标记化。模型接受标记值作为输入参数,就像任何其他 Transformer 模型一样。
以下列表显示了我们在模型训练之前如何标记数据。
列表 14.9 标记化我们的训练集
# This function adapted from:
# https://github.com/huggingface/notebooks/blob/master/examples/
#question_answering.ipynb
# Copyright 2001, Hugging Face. Apache 2.0 Licensed.
from datasets import load_from_disk
from transformers import RobertaTokenizerFast
file = "data/question-answering/question-answering-training-set"
datadict = datasets.load_from_disk(file) #1
tokenizer = from_pretrained("roberta-base") #2
...
def tokenize_dataset(examples):
maximum_tokens = 384 #3
document_overlap = 128 #4
pad_on_right = tokenizer.padding_side == "right" #5
tokenized_examples = tokenizer( #6
examples["question" if pad_on_right #6
else "context"], #6
examples["context" if pad_on_right #6
else "question"], #6
truncation="only_second" if pad_on_right #6
else "only_first", #6
max_length=maximum_tokens, #6
stride=document_overlap, #6
return_overflowing_tokens=True, #6
return_offsets_mapping=True, #6
padding="max_length" #6
)
... #7
return tokenized_examples
tokenized_datasets = datadict.map( #8
tokenize_dataset, #8
batched=True, #8
remove_columns=datadict["train"].column_names) #8
1 从我们的黄金集加载我们在列表 14.7 中创建的 datadict
2 加载预训练的标记器(roberta-base)
3 这将是问题和上下文中标记的数量。
4 有时我们需要将上下文拆分成更小的块,因此这些块将重叠这么多标记。
5 为比模型输入大小短的问答对添加填充标记。
6 对每个示例执行标记化
7 对问题和中进行额外的处理以识别开始和结束位置。请参阅笔记本以获取完整的算法。
8 在我们的黄金数据集的每个示例上调用标记器
我们加载一个标记器(在roberta-base模型上训练),从磁盘加载我们的question-answering-training-set黄金集(data/question-answering/question-answering-training-set/),然后运行黄金集中的示例通过标记器以生成一个tokenized_datasets对象,我们将很快将其传递给模型训练器。
对于每个上下文,我们为每个张量生成一个具有特定数量的嵌入和每个嵌入特定数量的浮点数的张量列表。包含标记的张量的形状必须与我们提供给训练器和评估器的所有示例中的形状相同。我们通过窗口滑动技术实现这一点。
窗口滑动是一种技术,它涉及将长列表的 token 分割成许多子列表,但每个子列表在第一个之后都与前一个子列表共享一定数量的重叠 token。在我们的例子中,maximum_tokens定义了每个子列表的大小,而document_overlap定义了重叠。这种窗口滑动过程在图 14.8 中得到了展示。
图 14.8 展示了为了说明目的而设置的非常小的maximum_tokens(24)和document_overlap(8)数值,但实际的分词过程将上下文分割成具有128重叠的384个 token 的张量。
窗口滑动技术还利用填充来确保每个张量具有相同的长度。如果上下文最后一个张量中的 token 数量少于最大值(384),则将剩余的张量位置用空标记 token 填充,以确保最终张量的大小也是384。

图 14.8 展示了将一个上下文分割成相同形状的张量的滑动窗口技术
了解上下文是如何处理的很重要,因为它会影响准确性和处理时间。如果我们试图在长文档中识别答案,窗口滑动过程可能会降低准确性,尤其是如果maximum_tokens和document_overlap很小,从而过多地分割上下文。长文档也会被切割成多个张量,这些张量共同处理需要更长的时间。户外数据集中的大多数上下文都符合我们指定的最大值,但在选择maximum_tokens和document_overlap参数时,在其他数据集中考虑这些权衡是很重要的。
14.3.2 配置模型训练器
在我们训练模型之前,我们还需要进行最后一步:我们需要指定训练和评估将如何进行。
在训练我们的模型时,我们需要指定基础模型和训练参数(超参数),以及我们的训练和测试数据集。在配置模型训练器的超参数时,您需要了解以下关键概念:
-
轮数——训练器将遍历数据集的次数。更多的轮数有助于随着时间的推移加强上下文并减少损失。然而,过多的轮数可能会导致模型过拟合,当微调 Transformers 时,3 轮是一个常见的选择。
-
批大小——一次训练/评估的示例数量。较大的批大小可能会产生更好的模型。这个设置受 GPU 核心数量和可用内存的限制,但常见的做法是尽可能多地放入一个批次,以充分利用可用资源。
-
预热——在训练模型时,最初缓慢调整模型可能有所帮助,这样早期的示例就不会对模型学习到的参数产生过度影响。预热步骤允许模型(学习率)逐渐改进,这有助于防止训练器在早期示例上过度拟合。
-
衰减——权重衰减通过在每一步将每个权重乘以这个常数值来减少过拟合。通常使用 0.01 作为权重衰减,但如果模型快速过拟合,可以将其更改为更高的值;如果看不到足够的改进,可以将其更改为更低的值。
列表 4.10 展示了配置模型训练器。列表中我们指定的超参数(training_args)是 SQuAD2 默认使用的,但你可以随意调整它们以查看它们如何提高你自己的问答模型的质量。
当尝试选择最佳设置时,一种常见的技术是在这些超参数上执行网格搜索。网格搜索是一个自动遍历参数值并测试调整每个参数的不同组合如何提高训练模型质量的过程。如果你希望深入了解参数调整,我们提供了配套笔记本中的网格搜索示例,但现在我们将继续使用列表 14.10 中指定的超参数。
列表 14.10 初始化训练器和其超参数
from transformers import RobertaForQuestionAnswering, TrainingArguments,
Trainer, default_data_collator
model = RobertaForQuestionAnswering.from_pretrained(
"deepset/roberta-base-squad2")
training_args = TrainingArguments(
evaluation_strategy="epoch", #1
num_train_epochs=3, #2
per_device_train_batch_size=16, #3
per_device_eval_batch_size=64, #4
warmup_steps=500, #5
weight_decay=0.01, #6
logging_dir="data/question-answering/logs",
output_dir="data/question-answering/results")
trainer = Trainer(
model=model, #7
args=training_args, #8
data_collator=default_data_collator,
tokenizer=tokenizer,
train_dataset=tokenized_datasets["train"], #9
eval_dataset=tokenized_datasets["test"]) #10
1 评估每个 epoch 的损失
2 训练 epoch 的总数
3 训练过程中每个设备的批大小
4 评估的批大小
5 学习率调度器的预热步数
6 权重衰减的强度
7 实例化的 Hugging Face Transformers 模型用于训练
8 训练参数
9 指定训练数据集
10 指定评估数据集
14.3.3 执行训练和评估损失
在设置好所有超参数后,现在是时候训练模型了。以下列表运行了之前配置的训练器,返回了显示模型性能的训练输出,并保存了模型。
列表 14.11 训练和保存模型
trainer.train()
model_name = "data/question-answering/roberta-base-squad2-fine-tuned"
trainer.save_model(model_name)
输出:
[30/30 00:35, Epoch 3/3]
Epoch Training Loss Validation Loss Runtime Samples Per Second
1 No log 2.177553 1.008200 43.642000
2 No log 2.011696 1.027800 42.811000
3 No log 1.938573 1.047700 41.996000
TrainOutput(global_step=30, training_loss=2.531823984781901,
metrics={'train_runtime': 37.1978,
'train_samples_per_second': 0.806,
'total_flos': 133766734473216, 'epoch': 3.0})
损失函数是一个决策函数,它使用误差来给出一个量化的估计,说明模型有多糟糕。损失越低,模型质量越高。我们希望看到的是损失在每个 epoch 中逐渐减少,这表明模型随着更多训练而持续改进。我们在测试集上的验证损失从2.178降至2.012再降至1.939。这些数字都在以稳定的速度减小(没有大幅跳跃),这是一个好兆头。
这个新微调模型的总体训练损失为2.532,而在我们的测试集上的验证损失为1.939。考虑到我们的小微调数据集和超参数配置限制,1.939这样的验证损失相当不错。
14.3.4 保留验证和确认
我们如何知道我们训练好的模型能否成功用于现实世界的问答?嗯,我们需要将模型与我们的保留验证数据集进行测试。回想一下,保留验证集是我们列表 14.9 中的第三个数据集(只有 10 个示例)。
图 14.9 强调了保留验证集的目的。我们希望我们的保留集评估的损失与列表 14.11 中的1.939验证损失一样好。如果我们的保留损失结果更高,那将是一个红旗,表明我们可能有过拟合!让我们看看我们的模型在以下列表中的表现。

图 14.9 保留集:使用我们训练好的模型回答以前未见过的问答
列表 14.12 在保留示例上评估训练好的模型
evaluation = trainer.evaluate(eval_dataset=tokenized_datasets["validation"])
display(evaluation)
输出:
{"eval_loss": 1.7851890325546265,
"eval_runtime": 2.9417,
"eval_samples_per_second": 5.099,
"eval_steps_per_second": 0.34,
"epoch": 3.0}
测试我们的保留验证集的eval_loss为1.785看起来很棒。它甚至比训练和测试损失还要好。这意味着我们的模型运行良好,并且不太可能对训练或测试数据过拟合。
随意继续训练和改进模型,但我们将继续使用这个作为完全训练好的模型,并将其集成到我们的问答系统的读者中。
14.4 使用新的微调模型构建读者
现在我们读者的模型训练已完成,我们将将其集成到问答管道中,以生成我们最终的读者,可以从问题和上下文中提取答案。以下列表展示了我们如何将模型加载到transformers库提供的question-answering管道中。
列表 14.13 加载微调后的户外问答模型
device = get_processor_device()
model_name = "data/question-answering/roberta-base-squad2-fine-tuned"
nlp2 = pipeline("question-answering", model=model_name,
tokenizer=model_name, device=device)
在加载问答管道后,我们将从一些问题/上下文对中提取一些答案。让我们使用我们在 14.3.4 节中早期使用的 10 个文档的保留验证集。保留示例未用于训练或测试模型,因此它们应该是对我们模型在实际中表现如何的良好试金石。
在以下列表中,我们测试了我们的问答模型在保留验证集示例上的准确性。
列表 14.14 评估微调的问答模型
def answer_questions(examples):
answers = []
success = 0
for example in examples:
question = {"question": example["question"][0],
"context": example["context"][0]}
answer = nlp2(question)
label = example["answers"][0]["text"][0]
result = answer["answer"]
print(question["question"])
print("Label:", label)
print("Result:", result)
print("----------")
success += 1 if label == result else 0
answers.append(answer)
print(f"{success}/{len(examples)} correct")
datadict["validation"].set_format(type="pandas", output_all_columns=True)
validation_examples = [example for example in datadict["validation"]]
answer_questions(validation_examples)
输出:
How to get pine sap off my teeth
Label: Take a small amount of margarine and rub on the sap
Result: Take a small amount of margarine and rub on the sap
Why are backpack waist straps so long?
Label: The most backpacks have only one size for everyone
Result: The most backpacks have only one size for everyone
...
How efficient is the Altai skis "the Hok"?
Label: you can easily glide in one direction (forward) and if you try to
glide backwards, the fur will "bristle up"
Result: you can easily go uphill, without (much) affecting forward gliding performance
7/10 Correct
成功提取出 10 个正确答案中的 7 个是一个令人印象深刻的成果。恭喜你,你现在已经为现实世界的用例微调了一个 LLM!这完成了我们架构中的reader组件,但我们仍然需要将其与一个用于找到传递给reader的初始候选上下文的retriever结合。在下一节中,我们将把检索器(我们的搜索引擎)纳入,以最终确定端到端问答系统。
14.5 纳入检索器:使用问答模型与搜索引擎
接下来,我们将实现一个重新排序操作,使用读者置信度分数来对顶级答案进行排序。以下是我们在这次练习中将经历的步骤概述:
-
从针对高召回率调优的搜索集合中查询户外索引。
-
将我们的问题与顶级-K 文档结果配对,并使用问答 NLP 推理管道推断答案和分数。
-
按分数降序重新排序答案预测。
-
使用步骤 1 到 3 中创建的部分,返回正确的答案和顶级结果。
参考图 14.4 以刷新此应用程序流程。
14.5.1 步骤 1:查询检索器
在第一阶段检索中,我们的目标是召回率。具体来说,所有可能包含我们答案的相关文档是什么?我们依靠已经微调的搜索集合来提供召回率,以便我们可以将高质量的文档传递到我们的重新排序阶段。
以下列表实现了我们的 retriever 函数,该函数可以接受一个问题并返回一个初始的相关文档列表,这些文档可能作为答案的潜在上下文进行考虑。
列表 14.15 检索器函数,用于搜索相关答案
nlp = spacy.load("en_core_web_sm") #1
nlp.remove_pipe("ner")
def get_query_from_question(question):
words = [token.text for token in nlp(question)
if not (token.lex.is_stop or token.lex.is_punct)]
return " ".join(words)
def retriever(question):
contexts = {"id": [], "question": [], "context": [], "url": []}
query = get_query_from_question(question) #2
request = {"query": query,
"query_fields": ["body"],
"return_fields": ["id", "url", "body"],
"filters": [("post_type", "answer")], #3
"limit": 5}
docs = outdoors_collection.search(**request)["docs"]
for doc in docs:
contexts["id"].append(doc["id"])
contexts["url"].append(doc["url"])
contexts["question"].append(question)
contexts["context"].append(doc["body"])
return pandas.DataFrame(contexts)
example_contexts = retriever('What are minimalist shoes?')
display_contexts(example_contexts)
1 使用英语 spaCy NLP 模型
2 通过移除停用词并关注重要词性将问题转换为查询(请参阅笔记本中的实现)
3 仅获取答案文档(不是问题)
响应:
id question context
18376 What are minimalist shoes? Minimalist shoes or "barefoot" shoes are shoes...
18370 What are minimalist shoes? There was actually a project done on the defin...
16427 What are minimalist shoes? One summer job, I needed shoes to walk on a ro...
18375 What are minimalist shoes? The answer to this question will vary on your...
13540 What are minimalist shoes? Barefoot Shoes Also known as minimalist shoes,...
当我们将问题作为查询时,我们面临的一个问题是噪声。有很多文档包含“谁”、“什么”、“何时”、“何地”、“为什么”和“如何”等术语,以及其他停用词和不那么重要的词性。尽管 BM25 在排名函数中可能很好地将这些术语降级,但我们知道这些并不是用户搜索的关键术语,所以我们通过 get_query_from_question 函数移除它们以减少噪声。我们之前在 5 章和 13 章中介绍了使用 spaCy 进行词性标注,所以这里不再重复实现(你可以在笔记本中找到它)。
在搜索引擎返回了可能包含用户问题答案的良好文档集之后,我们现在可以将这些文档作为上下文传递给 reader 模型。
14.5.2 步骤 2:从读者模型中推断答案
现在,我们可以使用 reader 模型从每个顶级 N 环境中推断问题的答案。列表 14.16 实现了我们的通用 reader 接口,该接口接受步骤 1 中 retriever 的输出。retriever 的模型和管道加载过程与列表 14.13 中的过程相同,而 reader 的其余实现专门处理从传入的上下文中生成候选答案(以及每个答案的分数)。
列表 14.16 读者函数,结合我们的微调模型
from transformers import pipeline
device = get_processor_device()
model_name = "data/question-answering/roberta-base-squad2-fine-tuned"
qa_nlp = pipeline("question-answering", model=model_name, #1
tokenizer=model_name, device=device) #1
def reader(contexts):
answers = []
for _, row in contexts.iterrows(): #2
answer = qa_nlp({"question": row["question"], #2
"context": row["context"]}) #2
answer["id"] = row["id"] #3
answer["url"] = row["url"] #3
answers.append(answer) #2
return answers
1 使用我们的微调模型创建 spaCy 管道
2 调用读者管道从每个上下文中提取一个候选答案
3 返回每个答案找到的附加元数据
reader 根据我们的微调模型从每个上下文中返回一个答案,以及答案的 id、url 和 score。
14.5.3 步骤 3:重新排序答案
列表 14.17 展示了一个简单的函数,它通过按分数(来自“阅读器”模型的概率质量函数输出)对答案进行排序来重排答案。最上面的答案是可能性最高的,因此首先显示。你可以显示一个答案,或者显示阅读器返回的所有答案。实际上,有时提供多个选项给提问者并让他们决定可能是有用的。这增加了显示正确答案的机会,但同时也占据了浏览器或应用中展示答案的更多空间,因此可能需要用户体验上的权衡。
列表 14.17 重排器根据阅读器的分数排序
def reranker(answers):
return sorted(answers, key=lambda k: k["score"], reverse=True)
我们应该指出,你的重排器可能更复杂,可能包含多个条件模型,甚至尝试将多个答案组合在一起(例如,来自多个上下文的重叠答案)。就我们的目的而言,我们只需依赖最高分数。
14.5.4 步骤 4:通过组合检索器、阅读器和重排器返回结果
我们现在准备好组装我们问答(QA)系统的所有组件。困难的部分已经完成,因此我们可以将它们放入一个名为 ask 的函数中,该函数将接受一个查询并打印出答案。
列表 14.18 组合检索器、阅读器和重排器的 QA 函数
def ask(question):
documents = retriever(question)
answers = reader(documents)
reranked = reranker(answers)
print_answer(question, reranked)
ask('What is the best mosquito repellent?')
ask('How many miles can a person hike day?')
ask('How much water does a person need per day?')
响应:
What is the best mosquito repellent?
1116 DEET (0.606)
1056 thiamine (0.362)
569 Free-standing bug nets (0.158)
1076 Insect repellent is not 100% effective (0.057)
829 bear-spray (0.05)
How many miles can a person hike day?
17651 20-25 (0.324)
19609 12 miles (0.164)
19558 13 (0.073)
13030 25-35 (0.065)
4536 13 miles (0.022)
How much water does a person need per day?
1629 3 liters (0.46)
193 MINIMUM a gallon (0.235)
20634 0.4 to 0.6 L/day (0.207)
11679 4 litres (0.084)
11687 carry water (0.037)
这些结果看起来相当不错。请注意,在某些情况下,多个上下文可能会返回相同的答案。通常,这将是正确答案的强烈信号,因此这可能是一个考虑将其集成到您的重排中的信号。
看到使用这些现成模型在最少重新训练的情况下所能达到的结果质量真是令人惊叹。向 NLP 社区致敬,他们使这些开源工具、技术、模型和数据集免费且易于使用!
恭喜你,你已经成功实现了一个端到端问答系统,该系统能够从搜索结果中提取答案。你生成了一个银色答案集,看到了如何将它们改进为金色答案集,加载并微调了一个问答阅读器模型,并实现了检索器-阅读器模式,使用你的训练模型和搜索引擎。
然而,使用大型语言模型(LLMs),我们能够做的不仅仅是从搜索结果中提取答案。LLMs 可以经过微调以执行抽象式问答,生成在搜索结果中未见过的答案,这些答案是从多个来源综合而成的。它们还可以被训练来为用户总结搜索结果,甚至根据用户输入生成全新的内容(文本、图像等)。许多 LLMs 在涵盖如此广泛的人类知识(例如,已知互联网的大部分内容)的大量数据上进行了训练,因此它们通常能够出色地执行各种类似任务。这些基础模型,我们将在下一章中介绍,正在为 AI 和 AI 驱动的搜索的下一阶段发展铺平道路。
摘要
-
提取式问答系统通常遵循检索器-读者模式,其中检索器找到可能的上下文(文档),然后使用读者模型分析这些上下文以提取最可能的答案。
-
搜索引擎充当了一个出色的检索器,因为它专门设计用来接收查询并返回一系列按相关性排序的文档,这些文档可能作为查询的相关上下文。
-
读者模型分析文本片段以预测每个上下文中答案最可能的开始和结束位置,对所有选项进行评分以提取最可能的答案。
-
精心制作训练数据集是一项耗时的工作,但你可以使用预训练模型自动生成一组银色训练数据。然后,你可以调整银色数据集中的答案,与手动创建整个金色训练数据集相比,这样可以节省大量精力。
-
你可以使用训练、测试和保留验证数据集来微调预训练模型,并针对损失最小化函数进行优化。
第十五章:基础模型和新兴搜索范式
本章涵盖
-
检索增强生成(RAG)
-
用于结果摘要和抽象问答的生成搜索
-
整合基础模型、提示优化和评估模型质量
-
为模型训练生成合成数据
-
实现多模态和混合搜索
-
人工智能搜索的未来
大型语言模型(LLMs),就像我们在前两章中测试和微调的那些,在近年来人工智能搜索的进步中一直处于中心位置。您已经看到了一些关键方式,这些模型如何通过将这些内容映射到密集向量搜索的嵌入来提高搜索质量,从通过映射内容到嵌入以改善查询解释和文档理解,到帮助从文档中提取问题的答案。
但在未来的地平线上,有哪些额外的先进方法正在出现?在本章中,我们将介绍搜索和人工智能交叉领域的一些最新进展。我们将介绍基础模型如何被用来扩展人工智能搜索的新功能,如结果摘要、抽象问答、跨媒体类型的多模态搜索,甚至是搜索和信息检索的对话界面。我们将介绍新兴搜索范式的基础,如生成搜索、检索增强生成(RAG)以及正在重新定义我们即将接近人工智能搜索前沿的一些方式的新类别基础模型。
15.1 理解基础模型
一个基础模型是在大量广泛数据上预训练的模型,旨在在各种任务上具有普遍的有效性。大型语言模型(LLMs)是基础模型的一个子集,它们在非常大量的文本上进行了训练。基础模型也可以在图像、音频或其他来源上训练,甚至可以在包含许多不同输入类型的跨模态数据上训练。图 15.1 展示了基础模型的常见类别。

图 15.1 基础模型类型。LLMs 是几种基础模型类型之一。
视觉基础模型可以将图像映射到嵌入(就像我们在第十三章中将文本映射到嵌入一样),然后可以搜索以实现图像到图像的搜索。
可以使用文本和图像(或其他数据类型)构建多模态基础模型,然后它可以基于文本查询或基于上传的图像作为查询来实现基于文本的图像跨模态搜索。我们将在第 15.3.2 节中实现这种多模态文本和图像搜索。像 Stable Diffusion(一种文本到图像模型)这样的生成多模态模型也可以仅根据文本提示生成全新的图像。可以从图像和文本中学习的多模态基础模型也通常被称为视觉语言模型(VLMs)。
15.1.1 什么可以称为基础模型?
基础模型通常在涵盖众多主题的广泛数据上训练,以便它们在跨领域的泛化解释和预测方面表现出色。这些模型被称为“基础”模型,因为它们可以作为基础模型(或基础),然后可以更快地在特定领域或特定任务的训练集上进行微调,以更好地解决特定问题。
基础模型通常满足以下标准:
-
它们是大型模型,通常在大量数据上训练,通常具有数十亿或数万亿个参数。
-
它们是预训练的,使用显著的计算能力来得出要保存和部署(或微调)的模型权重。
-
它们具有可泛化性,适用于许多任务,而不是局限于特定任务。
-
它们是可适应的,使用提示从其训练模型中提取额外的上下文来调整其预测输出。这使得它们可以接受的查询类型非常灵活。
-
它们是自监督的,从原始数据中自动学习如何关联和解释数据,并将其表示为未来的使用。
我们已经在之前的章节中与几个基础模型合作过,包括 BERT,这是最早的基础模型之一,以及我们在第十三章中使用的 RoBERTa,我们用它来生成嵌入并在这些嵌入上执行语义搜索。Sentence Transformer 模型,如 SBERT(Sentence-BERT)和 SRoBERTa(Sentence-RoBERTa),是从 BERT 和 RoBERTa 基础模型微调的模型,以在语义文本相似度(STS)任务上表现出色。我们还在第十四章中微调了deepset/roberta-base-squad2模型;这是一个基于 RoBERTa 基础模型的模型,经过微调用于问答任务。技术上讲,SBERT、SRoBERTa 和deepset/roberta-base-squad2本身也是微调的基础模型,可以进一步用作更多微调的基础以生成额外的模型。
目前,基础模型的主导架构是 Transformer 模型,尽管可以使用循环神经网络(使用如 MAMBA 的架构),并且随着时间的推移,必然会出现更多的架构。大多数基于 Transformer 的模型可以用于生成嵌入向量或预测输出。
基础模型响应的强度反映了三个过程的质量:训练、微调和提示。
15.1.2 训练与微调与提示
训练(或预训练)是一个过程,在这个过程中,大量数据(通常是互联网的大部分)被用来学习基础模型深度神经网络中数十亿或数千亿参数的模型权重。这个过程有时可能非常昂贵,可能需要数月时间,并且由于计算和能源需求,可能花费数百万美元。这个过程设法将大量人类知识损失压缩到一个神经网络中,从该网络中可以后来解压缩出事实和关系(单词、语言学、关联等)。回想一下第 13.3 节,在文本上训练 Transformer 通常遵循一个自监督学习过程,该过程优化预测文本序列中掩盖的标记,以衡量文本的整体理解(第 13.3.1 节中描述的 Cloze 测试)。这种训练可以包括任何可能对模型知识库有益的特定数据集,例如计算机代码或特定领域的内 容(财务文件、学术论文、外语、多模态内容等)。
微调 是一个过程,通过这个过程,基础模型可以提供额外的领域特定数据或指令。例如,如果你想使模型遵循指令或表现得像一个人或聊天机器人,你可以通过反映这些行为的输入来训练模型。根据任务或架构以及计算和预算要求,微调有几种方法。某些类型的微调会改变模型的所有权重,这在领域调整需要非常具体且原始模型的任务相同的情况下是有帮助的。更高效或特定于任务的途径可能会保留所有现有权重,并在基础基础模型的神经网络中添加一个或多个额外的层。微调使这些新来源能够通过一个专注于特定数据或目标的较小训练过程,扩展原始模型的能力,以处理新的数据或模式。
提示 是向训练好的语言模型提供输入以获得输出的过程。提示是微调模型的最终“训练”步骤,但它发生在推理时间而不是训练时间。我们可以提供模型允许的尽可能多的上下文,这意味着提示可以被操纵来使用这些额外的上下文来影响输出。例如,考虑列表 15.1 和 15.2 中的查询当它们被发送到 OpenAI 的 GPT-4 语言模型时的输出。
列表 15.1 无提示工程查询
Query: "What is a unicorn?"
Response:
A unicorn is a mythical creature that is typically depicted as a horse
with a single, spiraled horn projecting from its forehead. Unicorns have
been a part of human mythology and folklore for centuries and are often
associated with purity, grace, and magic. They are said to possess healing
powers and to be able to bring good luck to those who encounter them.
While unicorns are not real animals, they continue to be a popular symbol
in modern culture, appearing in literature, art, and popular media.
列表 15.1 提供了一个关于独角兽的良好一般描述。与此相对比的是列表 15.2,它使用提示工程给基础模型赋予一个名为 AIPS Chat 的“聊天机器人”角色,然后告诉基础模型以拥有生物博士学位的人的身份进行回应。
列表 15.2 提示工程提供的额外上下文查询
Query: "You are a chatbot named AIPS Chat.
You have a Ph.D. in biology.
What is a unicorn?"
Response:
Hello! I am a chatbot named AIPS Chat, and I have a Ph.D. in biology. While
unicorns have been depicted in various cultures throughout history, they
are not real animals that exist in the natural world.
From a biological perspective, a unicorn can be described as a mythical
creature resembling a horse with a single, spiraled horn on its forehead.
However, since unicorns are purely fictional, there is no actual
scientific data or research that can be conducted on them.
It is important to note that while many animals possess unique
characteristics, such as the horn of a rhinoceros or the tusk of a
narwhal, these features are not magical and do not provide any special
abilities or powers to the animal possessing them.
In conclusion, while unicorns are a fascinating aspect of human
folklore and mythology, they do not have a basis in reality within the
field of biology.
第 15.2 列表中的查询,包括一个拥有生物学博士学位的聊天机器人的上下文,利用这个额外的上下文来告知其答案。如果我们能够访问语言模型的读写权限,我们可以使用由生物学博士和聊天机器人生成的输入和响应来微调它。在列表中,我们能够通过向模型提供提示来简单地从其已经训练好的模型中拉取那个上下文,从而实现类似的结果。微调通常会生成更好的答案,但提示更加灵活,因为您可以在推理时提供任何上下文,而微调则更适合于模型需要学习并在所有未来交互中表征的一般特征。
在提示中提供额外的上下文可能是获得最佳输出的关键。由于生成式基础模型是按顺序逐个预测序列中的下一个标记,因此强迫模型在响应中输出更多相关上下文可以导致模型生成更相关的输出。一个大型预训练模型能够实现少量样本学习(能够在提供两个或三个示例的情况下,无需进一步训练就能在特定上下文中学习)。例如,在第 15.2 列表中,我们看到通过在提示中添加“生物学”上下文,之后的答案中就包含了诸如“在自然世界中”、“从生物学角度来看”、“实际科学数据或研究”以及“在生物学领域”等短语。
训练中最困难且成本最高的部分是基础模型的初始生成。一旦训练完成,针对特定任务的微调就相对快速且成本低廉,通常可以使用一个相当小的训练集完成。如果我们将这些模型与人类学习进行比较,基础模型可能就像一个高中生,他们通常知道如何阅读和写作,并能回答有关数学、科学和世界历史的常识性问题。如果我们想让这个学生能够生成和解读财务报表(收入报表、现金流量报表和资产负债表),他们需要学习会计课程来掌握这些技能。然而,经过 18 年或更长时间的训练(生活经验和学校教育)后,学生可能在几个月内就能学会足够的会计知识来生成和解读财务报表。同样,对于基础模型来说,初始训练阶段耗时最长,为后续更快地学习更多知识和技能提供了基础。
15.2 生成式搜索
我们在构建人工智能搜索的过程中,大部分的旅程都集中在寻找与用户意图相匹配的结果、答案或行动。在第十四章中,我们甚至提取了特定问题的具体答案,使用了我们的检索-阅读模式。但如果我们的搜索引擎能够根据查询实时生成新的内容,而不是返回真实文档或提取的答案,会怎么样呢?
生成模型是能够根据输入提示生成新内容的模型。它们的输出可以是文本内容、从文本输入生成的图像、模拟特定人物或声音的音频,或者结合音频和视频的视频。可以生成文本来描述图像,或者相反,可以生成图像、音频或视频来以不同的方式“描述”文本。
我们正进入一个世界,在这个世界里,有人可以输入一个搜索查询,搜索引擎可以返回完全虚构的内容和图像,这些内容和图像是即时生成的,作为对用户查询或提示的响应。虽然对于像“在巴黎三天内,假设我真的喜欢美食、历史建筑和博物馆,但讨厌人群,一个最佳的日程安排是什么?”这样的查询来说这可能很神奇,但它也引入了严重的伦理考量。
搜索引擎是否应该为用户综合信息,而不是为他们提供现有内容进行解读?如果搜索引擎在政治或商业上存在偏见,并试图影响用户的思维或行为,会怎样?如果搜索引擎被用作审查的工具或传播宣传(如即时修改的图像或文本)来欺骗人们相信错误或采取危险行动,又会怎样?
生成式搜索的一些更常见的用例包括抽取式问答和结果摘要。而传统的搜索方法返回的是“十个蓝色链接”或与特定查询匹配的预定信息框,生成式搜索则侧重于根据动态分析搜索结果即时创建搜索响应。从左到右,图 15.2 展示了从这些传统搜索方法向生成式搜索的演变过程。

图 15.2 传统搜索方法(左)到生成式搜索(右)的频谱
在第十四章中介绍的抽取式问答,开始向生成式搜索转变,因为它分析搜索结果以返回直接的问题答案,而不是仅仅提供搜索结果或预定的答案。然而,这并不是完全的“生成式”搜索,因为它仍然只返回与搜索文档中呈现的答案完全一致的答案,没有任何额外的综合或新内容生成。
当我们继续向图 15.2 的右侧移动时,结果摘要是最先可以完全考虑为生成式搜索的技术。结果摘要,我们将在 15.2.2 节中介绍,是从搜索结果中提取其内容的过程。这对于用户来说非常有用,尤其是当他们正在研究一个主题而没有时间阅读所有结果时。用户不需要点击多个链接并阅读和分析每个结果,而是可以返回页面摘要(如果需要的话,包括引用),从而节省用户评估内容的时间。
抽象式问答与提取式问答非常相似,因为它们都试图回答用户的问题,但它通过分析搜索结果(如结果摘要)或简单地依靠底层基础模型来生成答案来实现。依赖基础模型更有可能导致虚构或幻觉的结果,因此通常有益于将搜索结果作为上下文学习提示的一部分。我们将在下一节中介绍这种检索增强生成的过程。
许多主要搜索引擎和初创公司已经将这些生成模型和交互式聊天会话整合到他们的搜索体验中,这为模型提供了访问互联网(或至少是搜索索引形式的互联网副本)的机会。这使得模型能够几乎实时地了解世界的信息。这意味着模型可能知道与他们互动的用户详细的公开信息,这可能影响结果,这也意味着任何人都可以通过制作以误导方式关联概念的网页来向这些模型注入恶意信息或意图。如果我们不小心,搜索引擎优化(SEO)领域可能会从试图提高网站在搜索结果中的排名转变为试图操纵 AI 向最终用户提供恶意答案。对于使用这些模型的人来说,应用批判性思维技能和验证这些模型的输出将是至关重要的,但不幸的是,这些模型可能如此吸引人,以至于许多人可能会被误导的输出所欺骗,除非采取重大的安全措施。
这些模型将继续进化,通过解释搜索结果来支持搜索者。虽然许多人可能梦想拥有一个基于 AI 的个人助理,但 AI 生成你每天消费的知识所涉及的伦理考量需要谨慎处理。
15.2.1 检索增强生成
使用搜索引擎或向量数据库查找可以提供给 LLM 作为上下文的有关联的文档的工作流程通常被称为检索增强生成(RAG)。
我们之前多次提到过 RAG,随着这本书的出版,它已经成为了一个热门词汇。这有一个非常好的原因:语言模型的训练是对训练数据的一种有损压缩,而模型无法在不丢失准确性的情况下忠实存储它们训练过的海量数据。它们的信息也只到上次训练时为止——在没有一些持续更新的外部数据源的情况下,模型很难对不断变化的信息做出决策。
无论语言模型可以接受多少上下文,将其作为所有训练信息的真理来源都是计算上不可行或至少成本高昂的。幸运的是,搜索引擎的整个目的就是为任何传入的查询找到相关的上下文。事实上,这本书的整个内容都是对 RAG 的检索部分的深入研究。在我们讨论本章的结果摘要、抽象问答和生成搜索时,我们正在触及 RAG 的生成部分。
要执行 RAG,大多数当前库都会将文档取来,将其拆分成一个或多个部分(块),然后将每个部分的嵌入索引到搜索引擎或向量数据库中。然后,在生成时,提示语言模型的程序将创建查询以找到执行下一个提示所需的信息补充,为这些查询生成嵌入,执行密集向量搜索以找到使用向量相似度(通常是余弦或点积)得分最高的部分,并将结果排序的部分连同提示一起传递给生成 AI 模型。
块化挑战
这种将文档拆分成部分的过程被称为块化,它既是必要的也是问题性的。这个问题可以理解为三个约束之间的张力:
-
向量数据库的限制——许多向量数据库被设计用来索引单个向量,但将整个文档总结成一个向量可能会丢失文档的很多特异性和上下文。它将整个文档的嵌入汇总成一个更模糊的摘要嵌入。
-
独立块之间的上下文丢失——为了保留每个部分(章节、段落、句子、单词或其他有用的语义块)的特异性,将文档拆分成许多单独的文档可能会导致块之间的上下文丢失。如果你将一个文档拆分成 10 个块,并独立生成嵌入,那么这些嵌入之间的共享上下文就会丢失。
-
许多块的计算复杂性——你拥有的块越多,你需要索引的向量就越多,你需要搜索的也越多。这可能会非常昂贵且缓慢,而且在你必须对同一初始文档上的多个匹配项进行加权,相对于其他文档上更少但更好的匹配项时,也可能很难管理跨块的结果的相关性。
在实施 RAG 用于生成式 AI 的早期阶段,许多人专注于将文档分块成多个单独的文档,以克服向量数据库的限制。可以使用良好的 ANN(近似最近邻)算法来管理计算复杂性,但在那种情况下,上下文在块之间的丢失仍然是问题,而且不使用更好的算法来建模嵌入,而是创建无限数量的重叠块,这显然是浪费的。
作为一种替代方法,几个搜索引擎和向量数据库(如 Vespa 和 Qdrant)已经支持多值向量字段,这使得创建任意数量的块成为可能,也可以在单个文档内创建重叠的块(这样单个句子或段落可以成为多个块的一部分,从而在块之间保留更多上下文)。这种多向量支持预计在未来几年将成为标准,以支持像 ColBERT 模型系列中引入的基于上下文的后期交互等新兴方法。
RAG 的未来
RAG 作为一个学科,仍处于起步阶段,但发展迅速。已经开发了许多库和框架来执行 RAG,但它们通常基于对信息检索过于简单的理解。当前的 RAG 实现往往完全依赖于语言模型和向量相似度来决定相关性,而忽略了本书中我们介绍的大多数其他 AI 驱动的检索技术。在用户意图的维度(见图 1.7)的背景下,这有三个影响:
-
内容上下文在概念上处理得很好(假设所选嵌入模型是针对查询-文档检索进行训练的),但不是基于特定关键词(由于总结多个关键词的向量的范围)。
-
领域上下文处理得和语言模型微调一样好。
-
用户上下文通常完全被忽略。
对于用户意图的内容理解维度(见第 1.2.5 节),使用与嵌入的上下文后期交互的具有前景的方法正在发展,它们最终可能克服对分块的需求。这些方法(如 ColBERT、ColBERTv2、ColPali 及其继任者)涉及为文档中的每个标记生成一个嵌入,但每个标记的嵌入都使用该标记在整个文档中的上下文(或至少是长距离的上下文)。这防止了上下文在块之间的丢失,并避免了将无限数量的块索引到引擎中的需要。这些方法对于 RAG 和检索来说比上一小节中提到的某些更简单的方法更有意义。我们预计在未来几年将看到类似方法的发展,并将显著提高召回率,超过当前的基线排名方法。
在本书中你学到的检索概念,关于用户意图的维度、反映智能和信号模型、语义知识图谱、学习排序和反馈循环(结合基于 LLM 的向量搜索),在大多数当前的 RAG 实现中领先数光年,这些实现只使用了一小部分。
话虽如此,RAG 的未来光明,预计在接下来的几年里,我们将看到解决当前挑战的更复杂方法,以及更好的检索和排序技术整合到 RAG 流程中。RAG 技术正在快速变化和演变,该领域为持续的创新和改进做好了准备。
在接下来的部分,我们将介绍使用 RAG 的最受欢迎的生成式搜索方法之一:结果摘要(带引用)。
15.2.2 使用基础模型进行结果摘要
在第三章中,我们提到搜索引擎主要负责三件事:索引、匹配和排序结果。第十三章已经展示了如何通过嵌入来增强索引、通过近似最近邻搜索来增强匹配以及通过在嵌入向量上进行相似度比较来增强结果的排序。但返回结果的方式往往与结果本身一样重要。
在第十四章中,我们展示了如何从包含这些答案的排序文档中提取答案,这可以比返回完整文档并强制用户逐个分析它们有显著的改进。但如果问题的答案需要分析文档呢?如果期望的答案实际上是结合来自多个文档的信息,形成一个统一、有良好来源的答案的分析结果呢?
直接从 LLM 生成的答案的一个问题是,它们是基于模型学习到的参数的统计概率分布创建的。尽管模型可能在大量数据源上进行了训练,但它并没有直接存储这些数据源。相反,模型被压缩成数据的损失表示。这意味着你不能指望 LLM 的输出能够准确反映输入数据——只能是一个近似值。
因此,基础模型因产生幻觉而闻名——生成包含错误事实或错误表述主题的响应。提示中提供的上下文也极大地影响了答案,以至于基础模型有时可能更多地反映了用户的词汇选择和偏见,而不是对问题的合法回答。虽然这在创造性工作中可能很有用,但它使得今天的基础模型在安全生成搜索引擎历史上依赖的事实性响应方面相当不可靠。为了用户真正信任结果,他们需要能够验证信息的来源。幸运的是,我们不必直接依赖基础模型来回答问题,而是可以将搜索引擎与基础模型结合起来,使用 RAG 创建混合输出。
使用此类基础模型来总结搜索结果是一种很好的方法,可以为你的搜索引擎注入更好的 AI 驱动的响应。让我们通过一个使用基础模型进行带有引用的搜索结果摘要的例子来了解一下。我们将使用 OpenAI 的 GPT-4 模型的输出,但你也可以从大多数当前的开源或许可 LLMs 中获得类似输出。
工作流程包括两个步骤:
-
执行搜索。这可能涉及将查询转换为嵌入并执行密集向量搜索,或使用我们讨论过的任何其他技术来找到最相关的结果。
-
构建一个提示,指导你的基础模型接收用户的查询,并阅读和总结从查询返回的一组搜索结果。
以下列表演示了一个示例提示,将步骤 1 的搜索结果组合成步骤 2 的提示。
列表 15.3 基础模型总结搜索结果的提示
Web search results:
[1] "A large language model, or LLM, is a deep learning algorithm that
can recognize, summarize, translate, predict and generate text and other
content based on knowledge gained from massive datasets."
URL: https://blogs.nvidia.com/blog/2023/
01/26/what-are-large-language-models-used-for/
[2] A large language model (LLM) is a language model consisting of a
neural network with many parameters (typically billions of weights or
more), trained on large quantities of unlabeled text using
self-supervised learning. LLMs emerged around 2018 and perform well at a
wide variety of tasks. This has shifted the focus of natural language
processing research away from the previous paradigm of training
specialized supervised models for specific tasks.
URL: https://en.wikipedia.org/wiki/Large_language_model
[3] "In this guide, we'll discuss everything you need to know about
Large Language Models (LLMs), including key terms, algorithms,
fine-tuning, and more. As ChatGPT has taken the internet by storm crossing
1 million users in its first 5 days, you may be wondering what machine
learning algorithm is running under the hood. While ChatGPT uses a
specific type of reinforcement learning called "Reinforcement Learning
from Human Feedback (RLHF)", at a high level it is an example of a
Large Language Model (LLM)."
URL: https://www.mlq.ai/what-is-a-large-language-model-llm/
Instructions: Using the provided web search results, write a comprehensive
reply to the given query. Make sure to cite results using
[number] notation after the reference. If the provided search
results refer to multiple subjects with the same name, write separate
answers for each subject.
Query: What is a large language model?
当将列表 15.3 的全部内容作为提示传递给语言模型时,我们得到了以下结果。
列表 15.4 对列表 15.3 的总结提示的响应
A large language model (LLM) is a deep learning algorithm that can
recognize, summarize, translate, predict, and generate text and other
content based on knowledge gained from massive datasets
[[1](https://blogs.nvidia.com/blog/2023/01/26/what-are-large-language-
models-used-for/)].
It consists of a neural network with many parameters (typically billions
of weights or more) and is trained on large quantities of unlabeled text
using self-supervised learning
[[2](https://en.wikipedia.org/wiki/Large_language_model)]. LLMs emerged
around 2018 and have performed well at a wide variety of tasks, shifting
the focus of natural language processing research away from the previous
paradigm of training specialized supervised models for specific tasks
[[2](https://en.wikipedia.org/wiki/Large_language_model)]. One example of
a LLM is ChatGPT, which uses a specific type of reinforcement learning
called "Reinforcement Learning from Human Feedback (RLHF)"
[[3](https://www.mlq.ai/what-is-a-large-language-model-llm/)].
此输出从搜索结果中提取关键细节以回答原始查询(“什么是大型语言模型?”),但以引用原始文章的方式进行。如果该答案过于冗长,可以在指令末尾添加额外的改进提示:“Be concise.” 这将导致以下输出:
...
Query: What is a large language model? Be concise.
列表 15.5 列表 15.3 的提示Be concise.的结果
A large language model (LLM) is a deep learning algorithm that uses neural
networks with billions of parameters, trained on massive unlabeled text
datasets for various language tasks [[2](https://en.wikipedia.org/
wiki/Large_language_model)][[1](https://blogs.nvidia.com/blog/2023/01/
26/what-are-large-language-models-used-for/)].
许多搜索引擎已经开始依赖基础模型来解释和引用此类搜索结果。通过添加额外的指令,如“避免含糊不清、有争议或离题”,“使用无偏见和新闻风格”,甚至“以五年级阅读水平写作”,你可以调整摘要的质量和语气,以满足搜索引擎的需求。
15.2.3 使用基础模型生成数据
除了在搜索结果之上添加合成和摘要层之外,这些模型的其他新兴用例之一是生成合成领域特定和任务特定的训练数据。
回顾我们之前已经探索过的众多基于 AI 的搜索技术,其中许多需要大量的训练数据来实现:
-
信号增强模型需要显示哪些用户查询与哪些文档相对应的用户信号数据。
-
用于自动化 LTR 的点击模型需要了解用户在搜索结果中点击和跳过的文档。
-
语义知识图谱需要一个数据索引来查找相关术语和短语。
但如果我们微调了一个基础模型来“生成关于缺失主题的文档”或“生成与每个文档相关的现实查询”,会怎么样?或者更好的是,如果我们根本不需要微调,而是可以构建一个提示来为文档生成优秀的查询呢?这样的数据可以用来生成合成信号,以帮助提高相关性。
列表 15.6 至 15.9 展示了我们如何通过将 LLM 与第十四章中的 Stack Exchange 户外集合的搜索相结合,使用提示来查找查询和相关性分数。
对于我们的练习,我们发现给提示提供几份文档比只提供一份文档更好,甚至更好的是返回相关文档(来自同一主题或来自先前查询的结果列表)。这一点很重要,因为每套文档中都有一些细分主题,所以拥有相关文档有助于返回更精细的查询而不是一般的查询。此外,尽管所有文档可能并不完全与原始查询相关(或可能存在噪声),但这仍然给 LLM 提供了一个几轮的机会来理解我们的语料库,而不是基于单个示例来建立其上下文。以下列表显示了使用 LLM 生成文档列表的候选查询所使用的模板。
列表 15.6 生成描述文档的查询的提示
What are some queries that should find the following documents? List at
least 5 unique queries, where these documents are better than others
in an outdoors question and answer dataset. Be concise and only
output the list of queries, and a result number in the format [n] for
the best result in the resultset. Don't print a relevance summary at
the end.
### Results:
{resultset}
以下列表显示了可以生成要插入到列表 15.6 中的提示的{resultset}的代码。使用示例查询what are minimalist shoes?,我们通过列表 14.15 中的retriever函数获取resultset。检索器为查询提供答案上下文。
列表 15.7 生成提示友好的文本搜索结果
example_contexts = retriever("What are minimalist shoes?")
resultset = [f"{idx}. {ctx}" for idx, ctx #1
in enumerate(list(example_contexts[0:5]["context"]))] #1
print("\n".join(resultset))
1 我们只想从检索器返回的前 5 个结果中获取索引和上下文信息,并且为每个结果添加 0 到 4 的索引前缀。
输出:
0\. Minimalist shoes or "barefoot" shoes are shoes that provide your feet
with some form of protection, but get you as close to a barefoot
experience as possible...
1\. There was actually a project done on the definition of what a minimalist
shoe is and the result was "Footwear providing minimal interference
with the natural movement of the foot due to its high flexibility,
low heel to toe drop, weight and stack height, and the absence of
motion control and stability devices". If you are looking for a
simpler definition, this is what Wikipedia says, Minimalist shoes are
shoes intended to closely approximate barefoot running conditions...
2\. One summer job, I needed shoes to walk on a rocky beach, sometimes in
the water, for 5 to 10 miles per day all summer. Stretchy neoprene
shoes were terrible for this- no traction and no support. So I used
regular sneakers. I chose a pair of cross country racing flats... The
uppers were extremely well ventilated polyester, so they drained very
quickly, and the thin material dried much faster than a padded sandal,
and certainly much faster than a regular sneaker of leather or cotton
would... The thing to look for is thin fabric that attaches directly
to the sole, with no rubber rim that would keep the water from
draining...
3\. ... It's not unhealthy to wear minimalist footwear, but on what terrain
your wear them could be bad for your body in the long run. Human
beings were never meant to walk or run exclusively on hard pavement
or sidewalks. Nor were we designed to clamber around on sharp rocks
at high elevations... If you're running on soft ground and you have
the foot strength, then there are plenty of arguments in favour of
minimalist shoes being better for you than other shoes, because it
brings your posture and gait back to what nature intended it to be.
If you're hiking in the mountains on uneven rocky terrain, especially
while carrying a heavy bag, then you'd be better off wearing a
supportive hiking boot...
4\. ... My favourite barefoot shoes are Vibram Five-Fingers , I wear either
my Mouri's or my KSO's at the beach. Vibram Five-Fingers 'Signa'
watersport shoes: The thin sole will be enough to protect your feet
from the lava sand...
然后,我们将这五个结果添加到列表 15.6 的提示底部,通过用列表 15.7 的响应替换{resultset}值来实现。我们使用完全替换后的最终提示,并将其传递给我们的 LLM,得到以下结果。
列表 15.8 LLM 响应将文档与生成查询相关联
resultset_text = "\n".join(resultset)
resultset_prompt = summarize_search_prompt.replace("{resultset}",
resultset_text)
generated_relevance_judgments = get_generative_response(resultset_prompt)
display(generated_relevance_judgments)
输出:
1\. What is the definition of a minimalist shoe?
2\. What are the characteristics of minimalist shoes?
3\. Which shoes are best for walking on rocky beaches?
4\. Are minimalist shoes suitable for all terrains?
5\. What are some recommended barefoot shoe brands?
Results:
1\. [1]
2\. [0]
3\. [2]
4\. [3]
5\. [4]
注意候选查询与每个相关结果中的措辞之间的细微差别。此外,请注意相关结果的顺序与输出中查询的顺序不同。文档 0 的上下文对于特定查询 什么是简约鞋的特点? 更相关,而文档 1 的上下文对于查询 什么是简约鞋的定义? 更相关。
注意,我们仅使用 pandas 索引位置作为上下文 ID,而不是文档的 ID。根据我们的经验,ID 可能会通过提供无关信息来混淆模型。根据您使用的 LLM,这可能需要通过一些代码进行逆向工程,就像我们用列表 15.7 中的现有 example_contexts 所做的那样。同时请注意,模型在列表 15.8 中改变了顺序(查询的顺序与判断结果的顺序不同),因此我们将在解析输出时考虑这一点。
以下列表显示了如何提取信息并为查询、文档和相关结果创建一个漂亮的 Python 字典。
列表 15.9 从 LLM 输出中提取成对判断
def extract_pairwise_judgments(text, contexts):
query_pattern = re.compile(r"\d+\.\s+(.*)") #1
result_pattern = re.compile(r"\d+\.\s+\[(\d+)\]") #1
lines = text.split("\n")
queries = []
results = []
for line in lines:
query_match = query_pattern.match(line)
result_match = result_pattern.match(line)
if result_match:
result_index = int(result_match.group(1))
results.append(result_index)
elif query_match:
query = query_match.group(1)
queries.append(query)
output = [{"query": query, "relevant_document": contexts[result]["id"]}
for query, result in zip(queries, results)]
return output
1 正则表达式用于查看哪一行属于哪个列表。如果您从模型中获得一些奇怪的输出,可以尝试更健壮的表达式。
接下来,我们将列表 15.8 的输出传递给列表 15.9 中的 extract_pairwise_judgments。
列表 15.10 由 LLM 生成的正面相关性判断
resultset_contexts = example_contexts.to_dict("records") #1
output = extract_pairwise_judgments(
generated_relevance_judgments,
resultset_contexts)
display(output)
1 示例上下文,来自列表 15.7,包含查询 '什么是简约鞋?' 的搜索结果。
响应:
{"query": "What is the definition of a minimalist shoe?",
"relevant_document": 18370}
{"query": "What are the characteristics of minimalist shoes?",
"relevant_document": 18376}
{"query": "Which shoes are best for walking on rocky beaches?",
"relevant_document": 18370}
{"query": "Are minimalist shoes suitable for all terrains?",
"relevant_document": 18375}
{"query": "What are some recommended barefoot shoe brands?",
"relevant_document": 13540}
将此过程应用于从客户查询中获取的数百个结果集,将生成大量相关查询-文档对。由于我们一次处理五个结果,因此每次向 LLM 发送单个提示时,我们也将生成五个新的正面成对判断。然后,您可以使用这些判断作为合成信号或作为训练数据来训练在早期章节中探讨的许多基于信号的模型。
15.2.4 评估生成输出
在前面的章节中,我们讨论了使用判断列表来衡量我们搜索算法质量的重要性。在第十章和第十一章中,我们手动生成判断列表,然后后来使用点击模型自动生成,用于训练和衡量 LTR 模型的质量。在第十四章中,我们生成了银集和金集来训练和衡量我们微调的 LLM 的提取式问答质量。
这些都是查询和结果对在很大程度上是确定性的搜索用例。然而,衡量生成模型输出的质量要困难得多。在本节中,我们将探讨一些生成用例,以了解如何克服这些挑战。
生成输出可能具有主观性。许多生成模型在调整温度参数时,给定相同的提示会产生不同的输出。温度是一个介于0.0和1.0之间的值,它控制输出中的随机性。温度越低,输出越可预测;温度越高,输出越具创造性。我们建议在评估和生成您的模型时始终将温度设置为0.0,以便您对其输出有更高的信心。然而,即使是0.0的温度也可能在运行之间对同一提示产生不同的响应,这取决于模型。
评估生成输出的核心方法很简单:构建和评估可以客观衡量的任务。然而,由于提示的输出可能不可预测,因此很难创建一个可以重复用于衡量您特定任务的不同提示的数据集。因此,我们通常依赖于已建立的指标来评估首先选择的模型的质量,然后再开始对其下游输出进行自己的评估。
不同的重要和常见指标针对不同的语言任务。每个指标通常都有一个排行榜,这是一个竞争风格的基准,通过在给定任务上的性能对模型进行排名。以下是一些常见的基准:
-
AI2 推理挑战(ARC)——一个由 7,787 个小学科学考试问题组成的多项选择题问答数据集。
-
HellaSwag——常识推理测试;例如,给出一个复杂推理问题,并附有多个选择题。
-
大规模多任务语言理解(MMLU)——一种用于衡量文本模型多任务准确性的测试。该测试涵盖 57 个任务,包括数学、历史、计算机科学、法律等。
-
TruthfulQA——衡量语言模型在生成问题答案时是否诚实。该基准包括 817 个问题,涵盖 38 个类别。作者设计了某些人类可能会由于错误信念或误解而给出错误答案的问题。
图 15.3 展示了 HellaSwag 问答测试的示例。

图 15.3 HellaSwag 示例问题和多项选择题,正确答案已突出显示
一些排行榜包含多个指标,如图 15.4 中的 Open LLM 排行榜。

图 15.4 HuggingFaceH4 开放大型语言模型排行榜空间(拍摄于 2024 年 3 月)
您应使用这些指标来决定为您的特定领域和任务使用哪种模型。例如,如果您的任务是抽象式问答,请考虑使用具有最佳 TruthfulQA 指标的模型。
警告 模型是受许可的,所以请确保你只使用与你的用例相匹配的许可证的模型。一些模型的许可证类似于开源软件(如 Apache 2.0 或 MIT)。但请注意,许多模型有商业限制(例如 LLaMA 模型及其衍生品或基于 OpenAI 的 GPT 等限制性模型输出的模型)。
一旦你选择了你的模型,你应该采取步骤在你的提示上评估它。要严谨,并跟踪你的提示及其表现。市场上出现了一些专门为此目的设计的工具,包括自动提示优化方法,但一个简单的电子表格就足够了。在下一节中,我们将构建我们自己的度量标准来用于新的任务。
15.2.5 构建自己的度量标准
让我们探讨如何客观地构建一个生成任务,这样你就可以创建一个数据集,并使用像精确度或召回率(在第 5.4.5 节中介绍)这样的度量标准来评估模型的准确性。我们将使用一个包含非结构化数据的提示来提取结构化信息。由于我们的结果将格式化得可预测,我们就可以根据人工标记的结果来衡量响应的准确性。
对于我们的示例,我们将测试一个生成模型的命名实体识别准确性。具体来说,我们将衡量来自 LLM 的生成输出是否正确地标记了人员、组织或地点类型的实体。
我们将从一篇新闻文章的以下文本片段中获取:
Walter Silverman of Brighton owned one of the most successful local Carvel
franchises, at East Ridge Road and Hudson Avenue in Irondequoit.
He started working for Carvel in 1952\. This is how it appeared in the
late 1970s/early 1980s.
[Alan Morrell, Democrat and Chronicle, May 29, 2023]
如果我们手动将此片段中的实体标记为<per>(人员)、<org>(组织)和<loc>(地点),我们将在以下列表中到达标记版本。
列表 15.11 带有实体标签的文章
<per>Walter Silverman</per> of <loc>Brighton</loc> owned one of the most
successful local <org>Carvel</org> franchises, at
<loc>East Ridge Road</loc> and <loc>Hudson Avenue</loc> in
<loc>Irondequoit</loc>. He started working for <org>Carvel</org> in
1952\. This is how it appeared in the late 1970s/early 1980s.
这是一个生成模型应该能够生成的格式,它接受文本并按照提示生成一个标记版本。但这个响应只有半结构化,带有临时标记。我们需要进一步处理它,我们可以编写一些 Python 代码来将实体提取到合适的 JSON 格式中。
列表 15.12 从生成输出中提取实体
def extract_entities(text):
entities = []
pattern = r"<(per|loc|org)>(.*?)<\/(per|loc|org)>"
matches = re.finditer(pattern, text)
for match in matches:
entity = {"label": match.group(1).upper(),
"offset": [match.start(), match.end() - 1],
"text": match.group(2)}
entities.append(entity)
return entities
entities = extract_entities(news_article_labelled)
display(entities)
输出:
[{"label": "PER", "offset": [0, 26], "text": "Walter Silverman"},
{"label": "LOC", "offset": [31, 49], "text": "Brighton"},
{"label": "ORG", "offset": [90, 106], "text": "Carvel"},
{"label": "LOC", "offset": [123, 148], "text": "East Ridge Road"},
{"label": "LOC", "offset": [154, 177], "text": "Hudson Avenue"},
{"label": "LOC", "offset": [182, 203], "text": "Irondequoit"},
{"label": "ORG", "offset": [229, 245], "text": "Carvel"}]
现在实体已经以结构化形式(作为 JSON)表示,我们可以使用列表来计算我们的度量标准。我们可以手动标记许多段落,或者使用第 14.2-14.3 节中的技术来自动化标记,使用模型构建一个银集,然后纠正输出以产生一个金集。
当你有了你的金集时,你可以尝试以下提示。
列表 15.13 标记的实体
For a given passage, please identify and mark the following entities:
people with the tag '<per>', locations with the tag '<loc>', and
organizations with the tag '<org>'. Please repeat the passage below
with the appropriate markup.
### {text}
在前面的提示中,{text}将被替换为你想要识别实体的段落。当模型生成响应时,它可以直接传递到列表 15.12 中的extract_entities函数。
然后可以将提取的实体输出与黄金集进行比较,以产生真实正例的数量TP(正确识别的实体)、假正例FP(不应识别但被错误识别的文本)和假负例FN(应该被识别但未被识别的实体)。
从这些数字中,您可以计算出以下内容:
-
Precision = (TP / ( TP + FP )) -
Recall = (TP / ( TP + FN)) -
F1 = (2 * (Precision * Recall) / (Precision + Recall))
许多在排行榜上列出的指标使用F1分数。
您有哪些生成任务?要富有创意,并思考如何使您的任务具有客观可衡量性。例如,如果您正在生成搜索结果摘要,您可以执行类似于命名实体识别的任务——检查摘要是否显示了给定搜索结果集中的重要文本片段,或者是否正确地引用了结果编号和标题。
15.2.6 算法提示优化
在第 15.2 节中,我们介绍了使用 LLM 提示生成合成数据、总结和引用 RAG 的搜索结果,以及量化生成数据质量的指标。我们讨论了在衡量模型质量时的这一内容,但有一个重要的观点我们略过了:提示的质量。
如果您还记得,我们有三种不同的方法来改进 LLM 输出:在训练期间改进模型(预训练)、微调模型或改进提示。预训练和微调是直接、程序化的神经网络模型优化,但到目前为止,我们将提示创建和改进(称为提示工程)视为需要人工干预的手动过程。
实际上,提示也可以像模型一样进行程序化微调。例如,DSPy(Declarative Self-improving Language Programs,Pythonically)库提供了一个框架,用于“编程”语言模型的使用,而不是手动提示它。功能上,这是通过定义从语言模型期望的输出格式(以及显示良好响应的训练数据)并让库优化提示的措辞以最佳实现该输出来完成的。
DSPy 使用四个关键组件来完成这种程序化提示优化:
-
签名——这些是简单的字符串,解释了训练过程的目标,以及预期的输入和输出。例如:
-
question → answer(问答) -
document → summary(文档摘要) -
context,question → answer(RAG)
-
-
模块——这些接受一个签名,并将其与提示技术、配置的 LLM 和一组参数相结合,以生成一个提示以实现期望的输出。
-
指标——这些是衡量模块生成的输出与期望输出匹配程度的度量。
-
优化器——这些用于通过迭代测试提示和参数的变体来训练模型,以优化指定的指标。
以这种方式编程语言模型的使用提供了四个关键优势:
-
它允许自动优化提示,当手动进行时可能是一个耗时且易出错的流程。
-
它允许优化提示以快速测试众所周知的模板(以及随着时间的推移新的技术或数据),以及提示的最佳实践,以及将多个提示阶段链接在一起形成一个更复杂的流水线。
-
它允许您在任何时候轻松切换 LLM,并“重新编译”DSPy 程序以重新优化新模型的提示。
-
它允许您将多个模块、模型和工作流程链接在一起形成更复杂的流水线,并优化流水线每个阶段的提示以及整个流水线。
与手动提示调整相比,这些优势使您的应用程序更加健壮且易于维护,并确保您的提示始终针对当前环境和目标进行微调和优化。一旦您编写了配置的 DSPy 流水线,只需运行优化器来编译它,此时每个模块的所有最优参数都已学习。想要尝试新的模型、技术或一组训练数据?只需重新编译您的 DSPy 程序,您就可以出发了,您的提示将自动优化以产生最佳结果。您可以在 DSPy 文档中找到许多用于实现 RAG、问答、摘要以及其他基于 LLM 提示任务的“入门”模板,文档地址为github.com/stanfordnlp/dspy。如果您正在构建一个非平凡的基于 LLM 的应用程序,我们强烈建议您选择这样的程序化方法来优化提示。
15.3 多模态搜索
现在,让我们探索由基础模型嵌入提供的一种最强大的功能:多模态搜索。多模态搜索引擎允许您使用另一种内容类型(也称为跨模态搜索)来搜索一种内容类型,或者同时搜索多种内容类型。例如,您可以使用文本、图像或结合文本和图像的混合查询来搜索图像。我们将在本节中实现这些用例中的每一个。
多模态搜索之所以成为可能,是因为可以为文本、图像、音频、视频和其他可以映射到重叠向量空间的内容生成向量嵌入。这使得我们能够在不需要任何特殊索引或转换的情况下,对任何内容类型进行任何其他内容类型的搜索。
多模态搜索引擎有可能彻底改变我们搜索信息的方式,消除之前阻止我们使用所有可用上下文来最好地理解传入查询和排名结果的障碍。
15.3.1 多模态搜索的常见模式
虽然多模态搜索涉及许多不同的数据类型都可以被搜索,但在这个部分我们将简要讨论目前最常见的数据模态:自然语言、图像、音频和视频。
自然语言搜索
在第十三章和第十四章中,我们使用了密集向量实现了语义搜索和问答。这两种技术都是自然语言搜索的例子。一个简单的查询,如shirt without stripes,将会让建立在倒排索引之上的每一个电子商务平台都感到困惑,除非手动添加特殊逻辑或集成到一个使用语义函数的知识图谱中,就像我们在第七章中实现的那样。
今天的最先进的 LLMs 在处理这些查询方面变得更加复杂,甚至能够解释指令并从不同来源综合信息以生成新的响应。许多构建传统数据库和 NoSQL 数据存储的公司正在积极集成密集向量支持作为一等数据类型,以利用所有这些主要进步。
虽然这些基于密集向量的方法不太可能完全取代传统的倒排索引、知识图谱、信号增强、排序学习、点击模型和其他搜索技术,但我们将会看到新的混合方法出现,这些方法将结合这些技术的最佳部分,以继续优化内容和查询理解。
图像搜索
正如你在第二章中看到的,图像是另一种非结构化数据形式,与文本并列。
传统上,如果你想在倒排索引中搜索图像,你会搜索图像的文本描述或标签。然而,使用密集向量搜索,搜索图像的视觉或概念相似性可以几乎像文本搜索一样容易实现。通过输入图像,使用视觉 Transformer 将其编码为嵌入向量,并索引这些向量,现在可以接受另一张图像作为查询,将其编码为嵌入,然后使用该向量搜索以找到视觉上相似的图像。
此外,通过训练一个同时包含图像及其文本描述的模型,现在可以通过输入图像或文本来进行多模态图像搜索。不再需要有一个标题和一个无法搜索的图像,现在可以使用卷积神经网络(CNNs)或视觉 Transformer 将图像编码到与文本共存的密集向量空间中。在同一个向量空间中,图像表示和文本表示,你可以搜索比标题更详细的图像版本,同时仍然可以使用文本来描述图像中的特征。
由于可以将图像和文本编码到相同的密集向量空间中,因此也可以反向搜索,使用图像作为查询来匹配包含与图像中出现的语言相似的任何文本文档,或者将图像和文本结合成一个混合查询以找到与文本和图像查询模态的最佳匹配的其他图像。我们将在第 15.3.2 节中通过一些这些技术的示例。
音频搜索
使用音频或搜索音频在历史上主要是一个文本到语音的问题。音频文件会被转换成文本并建立索引,音频查询也会被转换成文本并与文本索引进行搜索。语音转录是一个非常困难的问题。谷歌、苹果、亚马逊和微软的语音助手在处理短查询时通常表现很好,但对于想要集成开源解决方案的人来说,这种准确度在历史上一直难以实现。然而,最近在语音识别和语言模型结合以纠正语音误解错误方面的突破,正在将更好的技术带入市场。
需要记住的是,除了说话的词语之外,音频中还可以包含许多其他重要的声音。如果有人搜索“大声的火车”、“急流”或“爱尔兰口音”,这些都是他们希望在返回的音频结果中找到的品质。当多模态 Transformer 模型通过文本和音频映射到重叠的向量空间进行训练时,现在这使得这一切成为可能。
视频搜索
视频不过是序列图像的组合,这些图像被叠加并保持序列与音频同步。如果音频和图像都可以映射到与书面文本重叠的向量空间,这意味着通过索引视频的每一帧(或者可能每秒几帧,取决于所需的粒度),可以创建一个视频搜索引擎,允许通过文本描述搜索视频中的任何场景,通过音频片段搜索,或者通过图像搜索以找到最相似的视频。计算机视觉、自然语言处理和音频处理领域的深度学习模型正在趋同,只要这些不同类型的媒体都可以表示在重叠的向量空间中,搜索引擎现在可以搜索它们。
15.3.2 实现多模态搜索
在本节中,我们将实现最流行的多模态搜索形式之一:文本到图像搜索。我们将通过使用在互联网上的图像和文本数据对上联合训练的嵌入来实现这一点。这导致学习的潜在文本特征与学习的潜在图像特征处于相同的向量空间。这也意味着,除了执行文本到图像搜索外,我们还可以使用相同的嵌入模型根据任何传入图像的像素执行图像到图像的视觉搜索。我们还将展示一个在混合查询中结合模态的示例,同时搜索与文本查询 和 图像最匹配的结果。
我们将使用 CLIP 模型,这是一个可以在相同向量空间中理解图像和文本的多模态模型。CLIP 是由 OpenAI 开发的一个基于 Transformer 的模型,它在大量图像及其相关文本标题的数据集上进行了训练。该模型被训练来预测哪个标题与哪个图像相匹配,反之亦然。这意味着该模型已经学会了将图像和文本映射到相同的向量空间,以便相似的图像和相关的文本嵌入彼此靠近。
我们将回到第十章中使用的电影数据库(TMDB)数据集,以实现我们的多模态搜索示例。然而,在这种情况下,我们不是在电影的文本上搜索,而是在电影的图像上搜索。
在以下列表中,我们定义了计算归一化嵌入和构建电影集合的功能。该集合由电影的图像嵌入和电影元数据组成,包括标题、图像源 URL 和电影 TMDB 页面的 URL。
列表 15.14 索引缓存的电影嵌入
def normalize_embedding(embedding): #1
return numpy.divide(embedding, #1
numpy.linalg.norm(embedding,axis=0)).tolist() #1
def read(cache_name): #2
cache_file_name = f"data/tmdb/{cache_name}.pickle" #2
with open(cache_file_name, "rb") as fd: #2
return pickle.load(fd) #2
def tmdb_with_embeddings_dataframe(): #2
movies = read("movies_with_image_embeddings") #2
embeddings = movies["image_embeddings"]
normalized_embeddings = [normalize_embedding(e) #1
for e in embeddings] #1
movies_dataframe = spark.createDataFrame(
zip(movies["movie_ids"], movies["titles"],
movies["image_ids"], normalized_embeddings),
schema=["movie_id", "title", "image_id", "image_embedding"])
return movies_dataframe
embeddings_dataframe = tmdb_with_embeddings_dataframe() #3
embeddings_collection = engine.create_collection( #3
"tmdb_with_embeddings") #3
embeddings_collection.write(embeddings_dataframe) #3
1 在索引时对电影嵌入进行归一化,以便在查询时使用更高效的点积(相对于余弦)计算。
2 我们预先生成并缓存了电影嵌入,这样你就不必下载和处理所有图像。
3 构建包含图像嵌入的电影集合
由于 CLIP 模型是一个在文本和图像上联合训练的多模态模型,因此从文本或图像生成的嵌入可以用来搜索相同的图像嵌入。
使用归一化嵌入构建的索引后,剩下的工作就是执行对集合的向量搜索。以下列表显示了搜索图像所需的键函数,包括使用文本查询、图像查询或混合文本和图像查询。
列表 15.15 使用 CLIP 的多模态文本/图像向量搜索
device = "cuda" if torch.cuda.is_available() else "cpu" #1
model, preprocess = clip.load("ViT-B/32", device=device) #1
def movie_search(query_embedding, limit=8): #2
collection = engine.get_collection("tmdb_with_embeddings")
request = {"query_vector": query_embedding, #3
"query_field": "image_embedding", #3
"return_fields": ["movie_id", "title", #3
"image_id", "score"], #3
"limit": limit, #3
"quantization_size": "FLOAT32"} #4
return collection.search(**request)
def encode_text(text, normalize=True): #5
text = clip.tokenize([text]).to(device) #5
text_features = model.encode_text(text) #5
embedding = text_features.tolist()[0] #5
if normalize: #5
embedding = normalize_embedding(embedding) #5
return embedding #5
def encode_image(image_file, normalize=True): #6
image = load_image(image_file) #6
inputs = preprocess(image).unsqueeze(0).to(device) #6
embedding = model.encode_image(inputs).tolist()[0] #6
if normalize: #6
embedding = normalize_embedding(embedding) #6
return embedding #6
def encode_text_and_image(text_query, image_file): #7
text_embedding = encode_text(text_query, False)
image_embedding = encode_image(image_file, False)
return numpy.average((normalize_embedding( #8
[text_embedding, image_embedding])), axis=0).tolist() #8
1 加载预训练的 CLIP 模型和图像预处理程序
2 执行查询嵌入的向量搜索
3 使用查询嵌入构建搜索请求,以搜索索引图像嵌入
4 每个嵌入特征值使用的数据类型,在本例中为 32 位浮点数。
5 计算文本的归一化嵌入
6 计算图像的归一化嵌入
7 计算并组合图像和文本的归一化嵌入
8 将文本和图像向量平均以创建一个多模态查询
movie_search 函数遵循与第十三章中使用的类似的过程:获取一个查询向量并对具有嵌入的集合执行向量搜索。我们的 encode_text 和 encode_image 函数根据文本或图像计算归一化嵌入。encode_text_and_image 函数是两者的混合体,其中我们从文本和图像中生成嵌入,将它们单位归一化,并通过平均它们将它们汇总在一起。
在核心多模态嵌入计算到位后,我们现在可以实施一个简单的搜索界面来显示对传入的文本、图像或文本和图像查询的顶部结果。
列表 15.16 在文本和图像嵌入上进行多模态向量搜索
def search_and_display(text_query="", image_query=None):
if image_query:
if text_query:
query_embedding = encode_text_and_image(text_query, image_query)
else:
query_embedding = encode_image(image_query)
else:
query_embedding = encode_text(text_query)
display_results(movie_search(query_embedding), show_fields=False)
search_and_display 函数接受文本查询、图像查询或两者,然后检索嵌入并执行搜索。然后该函数以简单的网格显示顶部结果。图 15.5 显示了查询“在雨中唱歌”的示例输出:
search_and_display(text_query="singing in the rain")

图 15.5 对“在雨中唱歌”的文本查询
前四幅图像中的三幅来自电影《 Singin’ in the Rain》,所有图像都显示有人在雨中或打着伞,许多图像要么来自音乐剧,要么显示人们积极唱歌。这些结果展示了使用在文本和图像上训练的嵌入执行图像搜索的强大功能:我们现在能够搜索包含由单词表达的意义的像素的图片!
为了展示将文本映射到图像中的意义的一些细微差别,让我们运行同一查询的两个变体:superhero flying与superheroes flying。在传统的关键词搜索中,我们通常会丢弃复数,但在嵌入搜索的上下文中,尤其是在多模态搜索的上下文中,让我们看看这种细微差别如何影响结果。如图 15.6 所示,我们的搜索结果从单个超级英雄飞行的图像(或至少离地面很高)变为主要包含类似动作的超级英雄群体的图片。

图 15.6 “superhero flying”与superheroes flying查询之间的细微差别
这种跨模态(文本到图像)搜索令人印象深刻,但让我们也测试一下图像到图像搜索,以展示这些多模态嵌入的可重用性。图 15.7 显示了使用《回到未来》电影中著名的时光机器变形的德洛瑞安(DeLorean)汽车的图像进行图像搜索的结果。
search_and_display(image_query="chapters/ch15/delorean-query.jpg")

图 15.7 与德洛瑞安相似的汽车的图像到图像搜索
如你所料,大多数汽车实际上是著名的《回到未来》中的 DeLorean。其他汽车有类似的美学特征:相似的形状,类似的开门方式。我们不仅很好地匹配了汽车的特征,而且许多结果还反映了图像查询中的发光照明效果。
为了将我们的 DeLorean 搜索进一步推进,让我们尝试结合我们最后两个查询示例的多模态搜索。让我们对之前的 DeLorean 图像(图像模态)和文本查询superhero(文本模态)进行多模态查询。这个查询的结果如图 15.8 所示。
search_and_display(text_query="superhero",
image_query="chapters/ch15/delorean-query.jpg")

图 15.8 图像和文本查询的多模态搜索
真是令人兴奋!第一张图片是一位超级英雄(黑豹)站在一辆运动型汽车上,汽车灯光闪烁,与原始图片相似。现在的大部分结果都显示了电影中的英雄或主角,每个结果都包含运动型汽车,大多数图片都包含了从图像查询中获取的照明效果。这是一个很好的例子,说明了如何使用多模态嵌入来推断细微的意图,并允许以新的方式在多种类型的数据集中进行查询。
在文本和图像之间进行多模态搜索的影响是深远的。在电子商务搜索中,有人可以上传他们需要的零件的图片,立即找到它,即使不知道零件的名称,或者上传他们喜欢的服装或家具的图片,找到类似风格的款式。有人可以描述一个医疗问题,并立即找到反映症状或涉及的器官系统的图片。从第九章,你可能还记得我们从用户行为信号中学习物品和用户潜在特征的工作。这种行为是另一种模态,就像图像一样,可以学习并与文本和其他模态进行交叉训练,以增加搜索引擎的智能。正如我们将在第 15.5 节中进一步讨论的,许多类似的数据模态将继续在未来变得可搜索,帮助使搜索引擎在理解和查找相关内容和答案方面变得更加强大。
15.4 其他新兴的 AI 驱动搜索范式
在整本书中,你学习了大多数关键 AI 驱动搜索类别中的技术,例如信号处理和众包相关性、知识图谱、个性化搜索、学习排序和基于 Transformer 嵌入的语义搜索。
基础模型的出现以及将语言表示为密集向量的能力,在近年来彻底改变了我们思考和研究搜索引擎构建的方式。过去搜索主要是基于文本的,现在可以搜索任何可以编码为密集向量的内容。这包括对图像、音频、视频、概念或这些或其他模态的组合进行搜索。
此外,用户与搜索的交互方式也在以新的和开创性的方式不断发展。这包括提出问题、接收综合答案,这些答案是多个搜索结果的组合、生成解释搜索结果的新内容、生成图像、散文、代码、说明或其他内容。聊天机器人界面与对话中的上下文跟踪相结合,使得搜索任务的迭代优化成为可能,用户可以对响应提供实时反馈,并使 AI 能够作为代理进行搜索、综合和根据实时用户反馈优化搜索结果的相关性。
在我们 AI 驱动的搜索之旅的最后一部分,我们将探讨一些正在发展的趋势以及它们如何可能塑造搜索的未来。
15.4.1 对话和上下文搜索
我们已经花费了大量时间来介绍上下文搜索——在解释查询时理解内容上下文、领域上下文和用户上下文。随着搜索引擎变得更加对话化,个人上下文开始变得更加重要。例如,
User: "Please, take me home."
Phone: "I don't know where you live."
尽管有人将地址保存在名为“家”的书签下,并且他们的手机每天跟踪他们的位置(包括他们晚上睡觉的地方),但一些高度使用的数字助手仍然无法自动引入这种上下文,就像图 15.9 中所示的那样,并且需要在设置中明确配置您的家庭位置。这些不足之处预计在未来几年中随着个人助手从所有可用上下文中构建更加精确的个性化模型而消失。

图 15.9 数字助手需要从许多不同的数据源中提取信息,以构建强大的个性化模型
除了这些个性化上下文之外,还有一个越来越重要的上下文:对话上下文。以下是一个缺乏当前对话上下文意识的聊天机器人(虚拟助手)的例子:
User: "Take me to the plumbing store."
Phone: "Here is a list of stores that I found. [shares list]"
User: "Go to the one in East Rochester."
Phone: [Provides directions to the geographical center of East Rochester,
not the plumbing store it suggested in the last exchange]
良好的聊天机器人整合强大的搜索索引来支持他们回答问题和返回信息的能力,但许多聊天机器人仍然缺乏对先前对话上下文的短期和长期记忆。它们可能会独立地搜索每个查询,而不考虑先前的查询和响应。根据应用和查询类型,一些先前的对话上下文作为个性化模型的一部分有理由无限期地记住,例如某人的家庭地址、家庭成员的名字或常去的地方(医生、牙医、学校等)。然而,在许多情况下,仅在当前对话中的短期记忆就足够了。随着搜索体验变得更加对话化,对话上下文的短期和长期记忆变得更加重要。
为聊天环境训练的基础模型,如 OpenAI 的 ChatGPT,极大地提高了聊天机器人的对话能力。尽管它们是预训练模型且不实时更新,但可以向它们的提示中注入额外的上下文,使得完全有可能捕捉并添加个性化的上下文到未来的提示中,以改善它们满足每个用户的能力。
15.4.2 基于代理的搜索
正如我们在第七章讨论了管道对于最佳解释和表示用户查询意义的重要性一样,管道也是随着聊天机器人和基础模型交互出现的多步骤问题解决的一个基本组成部分。
在基于代理的搜索中,搜索引擎或其他 AI 界面被给出一个提示,然后可以生成新的提示或任务,将它们连接起来以实现预期的结果。解决用户的请求可能需要多个步骤,如生成新的任务(“现在去找到这个主题的前十大网站”或“解释你上一个答案是否正确”),对数据进行推理(“现在将每个网站提取的列表合并”,或“总结结果”),以及其他功能性步骤(“现在返回搜索结果”)。
未来,相当一部分网络流量将来自基于 AI 的代理搜索信息以用于其问题解决。这些代理在多大程度上将网络搜索引擎作为数据服务层,鉴于网络搜索引擎通常已经缓存了大部分网络内容,这一点尚待观察,但搜索引擎对于这些基于 AI 的代理来说是自然的启动点。像 Bing 这样的主要搜索引擎以及几家初创公司已经推出了多步骤搜索,涉及一定程度的迭代任务跟随以生成更深入研究的响应。这很可能是未来一段时间内增长的趋势。
15.5 混合搜索
在第二章和第三章中,我们介绍了两种不同的搜索范式:基于关键词的词汇搜索(基于稀疏向量表示,如倒排索引,通常使用 BM25 进行排序)和密集向量搜索(基于密集向量表示,通常使用余弦、点积或类似的基于向量的相似度计算进行排序)。在本节中,我们将展示如何结合这些不同范式的结果。
我们主要将词汇和向量搜索视为正交的搜索方法,但在实践中,你通常会通过将这两种方法作为混合搜索的一部分来获得最佳结果。混合搜索是将多个搜索范式的结果结合起来的过程,以提供最相关的结果。混合搜索通常涉及结合词汇和向量搜索的结果,尽管这个术语并不局限于这两种方法。
实现混合搜索有几种方法。一些搜索引擎直接支持在同一个查询中将词汇查询语法和向量搜索语法结合起来,有效地允许你用向量替换查询中的特定关键词或过滤器,并以任意复杂的方式组合向量相似度分数、BM25 分数和函数分数。一些搜索引擎不支持将向量搜索和词汇搜索作为同一个查询的一部分运行,而是支持混合搜索融合算法,这些算法允许你以深思熟虑的方式合并来自不同的词汇和向量查询的结果。在其他情况下,你可能在使用一个与你的词汇搜索引擎分开的向量数据库,在这种情况下,你可能需要使用类似的融合算法来在引擎外部组合这些结果。
15.5.1 互逆排名融合
在本节中,我们将演示一种流行的混合搜索算法,用于合并两组搜索结果,称为互逆排名融合(RRF)。RRF 通过根据文档在不同结果集中的相对排名来对文档进行排名,从而结合两个或多个搜索结果集。该算法很简单:对于每个文档,它将每个结果集中文档的排名的倒数相加,然后根据这个总和对文档进行排序。当两组搜索结果互补时,该算法特别有效,因为它将排名更高的文档排在更高位置,当它们在两个结果集中都排名较高时,排名最高。以下列表实现了 RRF 算法。
列表 15.17 用于组合多个搜索结果集的 RRF
def reciprocal_rank_fusion(search_results, k=None):
if k is None: k = 60
scores = {}
for ranked_docs in search_results: #1
for rank, doc in enumerate(ranked_docs, 1): #2
scores[doc["id"]] = (scores.get(doc["id"], 0) + #3
(1.0 / (k + rank))) #3
sorted_scores = dict(sorted(scores.items(), #4
key=lambda item: item[1], reverse=True)) #4
return sorted_scores #4
1 search_results是一个与不同搜索(词汇搜索、向量搜索等)相关联的排名文档集的列表。
2 ranked_docs是一个特定搜索的文档列表。
3 一个文档的分数通过在多个排名文档列表中存在以及在每个列表中的排名更高而增加。
4 按从高到低的 RRF 分数排序返回文档。
在这个函数中,一个任意列表(search_results)被传递进来,其中包含每个单独搜索的排名文档集。例如,search_results中可能有两个项目:
-
来自词汇搜索的排名文档
-
来自向量搜索的排名文档
每个文档的 RRF 分数是通过将每个搜索结果集(ranked_docs)中文档的倒数排名(1 / (k + rank))相加来计算的。最终的 RRF 分数随后会被排序并返回。
k参数是一个常数,可以增加以防止一个异常值在一个搜索结果中的排名很高。k值越高,赋予出现在多个排名文档列表中的文档的权重就越大,而不是赋予在给定排名文档列表中排名更高的文档的权重。默认情况下,k参数通常设置为60,根据研究表明这个值在实践中效果很好(plg.uwaterloo.ca/~gvcormac/cormacksigir09-rrf.pdf)。
我们已经将 RRF 逻辑集成到collection.hybrid_search函数中,我们将在列表 15.19 中调用它。然而,首先,让我们看看对于样本短语查询:“singin'” in the rain,词汇搜索与相应的向量搜索的初始结果是什么样的。
列表 15.18 独立运行词汇和向量搜索
over_request_limit = 15
base_query = {"return_fields": ["id", "title", "id", "image_id",
"movie_id", "score", "image_embedding"],
"limit": over_request_limit,
"order_by": [("score", "desc"), ("title", "asc")]}
def lexical_search_request(query_text):
return {"query": query_text,
"query_fields": ["title", "overview"],
"default_operator": "OR",
**base_query}
def vector_search_request(query_embedding):
return {"query": query_embedding,
"query_fields": ["image_embedding"],
"quantization_size": "FLOAT32",
**base_query}
def display_lexical_search_results(query_text):
collection = engine.get_collection("tmdb_lexical_plus_embeddings")
lexical_request = lexical_search_request(query_text)
lexical_search_results = collection.search(**lexical_request)
display_results(lexical_search_results, display_header= \
get_display_header(lexical_request=lexical_request))
def display_vector_search_results(query_text):
collection = engine.get_collection("tmdb_lexical_plus_embeddings")
query_embedding = encode_text(query_text)
vector_request = vector_search_request(query_embedding)
vector_search_results = collection.search(**vector_request)
display_results(vector_search_results, display_header= \
get_display_header(vector_request=vector_request))
query = '"' + "singin' in the rain" + '"'
display_lexical_search_results(query)
display_vector_search_results(query)
此列表将词汇短语查询“singin'” in the rain编码为嵌入,使用与列表 15.15 中相同的encode_text函数。然后,它对包含文本字段(用于词汇搜索)和 TMDB 数据集中一些电影的图像嵌入的tmdb_lexical_plus_ embeddings集合执行词汇搜索和向量搜索。词汇搜索和向量搜索的结果分别显示在图 15.10 和 15.11 中。

图 15.10 短语查询“singin'” in the rain的单个词汇搜索结果
在这个例子中,词汇搜索只返回一个结果:电影 Singin’ in the Rain。由于用户的查询是一个短语(引号内)查询,用于精确匹配标题名称,因此它完美匹配并找到了用户可能正在寻找的特定项目。图 15.11 显示了相同查询的对应向量搜索结果。

图 15.11 查询“singin'” in the rain的向量搜索结果
你会注意到,向量搜索结果通常显示包含“rain”或“singing”或类似概念的图像,如雨伞、天气和音乐剧。这是因为我们的向量搜索是在图像嵌入上进行的,所以结果是基于图像的视觉内容,而不是电影的文本内容。虽然这些图像在概念上比词汇搜索结果更好地匹配查询中的单词含义,但它们不包含词汇搜索结果中的精确标题匹配,因此理想的Singin’ in the Rain电影文档在顶部几个结果中缺失。
通过使用 RRF 结合这两组结果,我们可以得到两者的最佳结合:词汇搜索的精确标题匹配和向量搜索的概念相关图像。在下面的列表中,我们展示了如何通过调用collection.hybrid_search来组合这两组搜索结果。
列表 15.19 混合搜索函数
def display_hybrid_search_results(text_query, limit=8):
lexical_search = lexical_search_from_text_query(text_query)
vector_search = vector_search_from_embedding(encode_text(text_query))
hybrid_search_results = collection.hybrid_search(
[lexical_request, vector_request], limit=10,
algorithm="rrf", algorithm_params={"k": 60})
display_header = get_display_header(lexical_search, vector_search)
display_results(hybrid_search_results, display_header)
display_hybrid_search_results(query)
我们传递一个搜索数组来执行——在这种情况下,我们的lexical_search和vector_search来自列表 15.18。collection.hybrid_search函数内部默认使用 RRF 算法,k=60已设置,所以如果你想明确传递这些参数或更改它们,完整的语法是
collection.hybrid_search([lexical_search, vector_search], limit=10,
algorithm="rrf", algorithm_params={"k": 60})
该调用同时执行搜索并使用 RRF 结合结果,输出显示在图 15.12 中。

图 15.12 使用 RRF 对查询"singin' in the rain"进行的词汇和向量搜索混合搜索结果
这些结果展示了混合搜索如何经常提供两者的最佳结合:来自词汇搜索的精确关键词匹配和来自向量搜索的概念相关结果。向量搜索通常难以匹配精确名称、特定关键词、产品名称或 ID。同样,词汇搜索也会错过查询文本的概念相关结果。在这种情况下,最上面的结果是用户可能正在寻找的精确匹配(之前在向量搜索结果中缺失),但现在它已经补充了与用户查询概念上相似的其他结果(之前在词汇搜索中缺失)。
当然,如果你想向混合搜索函数传递超过两个搜索,你也可以这样做。例如,你可能想添加一个包含title或overview嵌入的额外向量字段,这样你不仅可以在文本内容上进行词汇搜索,在图像上进行向量搜索,还可以在文本内容上进行语义搜索。使用 RRF 与这三组搜索结果结合,可以将三种搜索方法的最佳之处结合到一组结果中。
除了克服关键词搜索和向量搜索各自的功能限制外,混合搜索通常能提供更好的整体搜索排名。这是因为像 RRF 这样的算法被设计成在多个搜索结果集中找到文档时给予最高的排名。当相同的项目从不同的搜索中作为相关内容返回时,融合算法可以利用这些结果集之间的这种一致性来提高这些项目的得分。这在搜索结果集中有一或多个集合包含无关文档时尤其有用,因为两个结果集之间的这种一致性可以帮助过滤掉噪声,并展示最相关的结果。列表 15.20 使用查询the hobbit演示了这一概念。
列表 15.20 对the hobbit进行的词汇、向量和混合搜索
query = "the hobbit"
display_lexical_search_results(query)
display_vector_search_results(query)
display_hybrid_search_results(query)
图 15.13 至 15.15 显示了列表 15.20 的结果。
在图 15.13 中,您将在前六个结果中看到五个相关结果(霍比特人 是 指环王 电影系列的一部分)。文本 “The Hobbit” 出现在前四个结果中的三个标题中。词汇搜索结果中的第五位有一个明显的不良结果,并且第六个文档之后的所有结果都是不良的,主要是匹配 title 和 overview 字段中的关键词如 “the” 和 “of”。
图 15.14 显示了相同查询的向量搜索结果。这些结果也显示了与 指环王 相关的五个相关结果,但词汇搜索中的一个相关结果缺失,向量搜索结果中返回了一个额外的相关电影。

图 15.13 查询 the hobbit 的词汇搜索结果

图 15.14 查询 the hobbit 的向量搜索结果
许多剩余的结果在概念上与查询相关,主要显示带有奇幻景观和魔法的电影。鉴于词汇搜索和向量搜索结果之间良好的结果重叠,以及不太相关结果之间的缺乏重叠,我们应该期待混合搜索结果相当不错。
事实上,在图 15.15 中,我们看到前五个结果都是相关的,而且前七个结果中有六个与 指环王 相关。我们已经从两个不同的搜索结果列表中(每个列表都缺失不同的文档并显示了一些不相关的结果)转变为一个最终的结果列表。

图 15.15 使用 RRF 对查询 the hobbit 进行词汇和向量搜索结果的混合搜索结果
RRF 使我们能够从两个列表中提取缺失的结果,并将仅出现在一个列表中的不相关结果向下推,真正强调了每种搜索范式(词汇与向量)的最佳品质,同时克服了各自的弱点。
15.5.2 其他混合搜索算法
虽然 RRF 是一种流行且有效的组合搜索结果的算法,但当然还有许多其他算法可以用于类似的目的。一种流行的算法是 相对分数融合 (RSF),它与 RRF 类似,但使用每个搜索结果集中文档的相对分数来组合结果。由于相关性分数通常在不同排名算法之间不可比较,因此每个查询模态的分数通常根据每个模态的最小和最大分数缩放到相同的范围(通常是 0.0 到 1.0)。然后使用加权平均将相对分数组合起来,权重通常根据每个模态在验证集上的相对性能设置。
另一种常见的组合搜索结果的方法是使用一种模态进行初始匹配,然后使用另一种模态重新排序结果。许多引擎都支持词法搜索和向量搜索作为独立的操作,但允许你运行一个查询版本(如词法查询),然后使用另一个版本(如向量查询)重新排序结果。
例如,如果你想确保匹配特定的关键词,同时提高与查询概念上相似的结果,你可能首先运行一个词法搜索,然后使用向量搜索重新排序结果。以下列表展示了这一概念。
列表 15.21 混合词法搜索与向量搜索重新排序
def lexical_vector_rerank(text_query, limit=10):
lexical_request = lexical_search_request(text_query)
vector_request = vector_search_request(encode_text(text_query))
hybrid_search_results = collection.hybrid_search(
[lexical_request, vector_request],
algorithm="lexical_vector_rerank", limit=limit)
header = get_display_header(lexical_request, vector_request)
display_results(hybrid_search_results, display_header=header)
lexical_vector_rerank("the hobbit")
在内部,这相当于一个正常的词法搜索,但随后使用相同(编码)查询的向量搜索分数对结果进行重新排序。从语法上讲,这些是列表 15.21 生成的搜索请求的关键部分:
{'query': 'the hobbit',
'query_fields': ['title', 'overview'],
'default_operator': 'OR',
...
'order_by': [('score', 'desc'), ('title', 'asc')],
'rerank_query': {
'query': [-0.016278795212303375, ..., -0.02110762217111629],
'query_fields': ['image_embedding'],
'quantization_size': 'FLOAT32', ...
'order_by': [('score', 'desc'), ('title', 'asc')], ...
}
}
使用向量搜索重新排序的词法搜索输出显示在图 15.16 中。在这种情况下,结果看起来非常类似于 RRF 结果。请注意,与图 15.10 不同,那里的词法查询默认操作符是AND,导致只有一个精确的词法匹配,而在这里,默认操作符是OR,因此向量搜索返回了更多结果以便重新排序。如果你想提高精确度并只返回更精确的词法匹配,可以将默认操作符设置为AND或在词法搜索请求中添加一个min_match阈值。

图 15.16 通过词法查询的向量重新排序的混合搜索
从功能上讲,词法搜索完全负责哪些文档返回,而向量搜索完全负责结果的顺序。如果你想要强调词法关键词匹配但具有更语义相关的排序,这可能是一个更好的选择。另一方面,像 RRF 或 RSF 这样的融合方法将提供一种更混合的方法,确保每个模态的最佳结果都能呈现出来。还有许多其他方法可以组合搜索结果,最佳方法将取决于具体用例以及每种搜索模态的相对优势和劣势。
15.6 上下文技术融合
就像推荐、聊天机器人和问答系统都是人工智能驱动的信息检索类型一样,许多其他技术也开始与搜索引擎融合。正在出现一些向量数据库,它们像搜索引擎一样处理多模态数据,并且许多不同的数据源和数据模式正被拉在一起,作为优化匹配、排序和数据推理的额外上下文。生成模型正在使基础理解能力得到提升,足以生成新的书面和艺术作品。
但大多数这些技术仍然以零散的方式集成到生产系统中。随着研究人员继续致力于构建通用人工智能、智能机器人和更智能的搜索系统,我们可能会看到技术不断汇聚,整合新的和不同的上下文,以构建对内容、领域和用户的更全面理解。图 15.17 展示了未来几年这种技术汇聚可能的样子。

图 15.17 上下文技术汇聚以提供更智能的结果
在图中,我们看到我们已经讨论过的文本、音频和视频模式,以及可能提供额外上下文的元数据模式。我们看到一个感官模式,这可能适用于配备传感器的物理设备网络或具有直接交互访问物理世界的机器人。我们还看到时间模式,因为查询和数据搜索的时间都可以影响结果的相关性。
正如 LLMs(大型语言模型)学习文本的领域和语言结构(参见第十三章)一样,基于地理和从现实世界交互中观察到的行为,基础模型也可以学习到额外的文化背景和习俗。
这其中许多可能看起来与传统的搜索,甚至今天的人工智能搜索相去甚远——确实如此!然而,搜索的目标是最好地满足用户的信息需求,随着更多重叠的技术从不同角度解决这个问题,我们看到搜索正成为一个关键的基础层,许多这些技术都将与之集成。
许多人工智能从业者已经认识到使用 RAG(Retrieval-Augmented Generation)来为生成式人工智能模型查找和提供实时上下文的重要性,而数据安全、数据准确性和模型大小等因素使得搜索引擎在未来很长一段时间内将成为基于知识的生成式人工智能系统的关键支撑。来自多个来源的大量数据需要被索引,以便生成式人工智能模型能够实时检索和使用。
将这些技术汇聚成端到端系统将能够比任何单一系统更好地解决这些人工智能搜索问题。无论这被标记为“机器人技术”、“人工智能”、“通用智能”、“人工智能搜索”还是其他完全不同的东西,还有待观察。但你所学习到的许多人工智能搜索技术已经并将继续成为下一代上下文推理引擎发展的核心。
15.7 以上所有内容,请!
在整本书中,我们深入探讨了构建人工智能搜索的核心概念。我们不仅涵盖了现代人工智能搜索相关性的理论背景,还通过代码示例,结合实际应用案例,展示了每个主题的实践应用。
我们所探讨的技术和算法并不适用于所有用例,但通常通过结合多种方法,你将提供更有效和相关的搜索体验。
尽管人工智能搜索领域正在快速发展,尤其是由于生成式基础模型和密集向量搜索方法的快速创新,但其核心原则保持不变。搜索引擎的职责是理解用户意图,并返回最能满足每个用户信息需求的内 容。这需要正确理解每次交互的内容上下文、用户上下文和领域上下文。除了从你的内容中学习语言模型和知识图谱外,如果你的搜索引擎能够通过用户与搜索引擎的交互(用户信号)隐式地学习,那么其能力将会得到极大的提升。
你已经学会了关键的人工智能搜索算法的工作原理,以及如何将这些算法实现并自动化到自学习搜索引擎中。无论你需要自动化构建信号增强模型、学习排序模型、点击模型、协同过滤模型、知识图谱学习,还是对基于深度学习的 Transformer 模型进行微调以增强语义搜索和问答,你现在拥有了实施世界级人工智能搜索引擎所需的知识和技能。我们期待看到你将构建出什么!
摘要
-
基础模型作为训练和可以后来针对特定领域或任务进行微调的基模型。
-
提示工程允许你向每个请求注入额外的上下文和数据,提供了一种对将返回请求内容进行实时微调的方法。基于 LLM 的应用程序理想情况下应该被编程来自动生成和优化提示,而不是需要手动提示验证和调整,这样它们可以自动处理模型和环境随时间的变化,同时仍然保持最佳性能。
-
搜索结果摘要和训练数据生成是基础模型可以帮助推动搜索引擎相关性改进的两个关键领域。
-
在多种数据类型上联合训练嵌入模型,可以启用强大的多模态搜索能力(文本到图像、图像到图像、混合文本加图像到图像等),扩展用户的表达能力和搜索引擎解释用户意图的能力。
-
基于人工智能的搜索随着大型语言模型、密集向量搜索、多模态搜索、对话式和上下文搜索、生成式搜索以及新兴的混合搜索方法的兴起而迅速发展。
-
实现人工智能搜索有许多技术,未来的最佳系统将是那些能够在混合搜索系统中有效应用多种相关方法的系统。
附录 A 运行代码示例
在您的“基于 AI 的搜索”之旅中,我们将通过大量代码和运行软件示例来展示本书中的技术。本附录向您展示如何轻松设置和运行附带的源代码,以便您可以在处理材料时实验实时运行的示例。我们将介绍如何打包本书的源代码,拉取和构建源代码,以及如何使用 Jupyter 笔记本和 Docker 来运行示例。
A.1 代码示例的整体结构
构建一个基于 AI 的搜索系统需要集成许多组件和库。对于我们的默认搜索引擎,我们将使用 Apache Solr,它内部依赖于 Apache ZooKeeper。您也可以用许多其他流行的搜索引擎和向量数据库替换 Solr——有关说明,请参阅附录 B。
对于重要的数据处理和机器学习任务,我们使用 Apache Spark。我们使用 Python 作为所有代码示例的编程语言,并依赖于许多 Python 库依赖项,以及其他系统依赖项(如 Java),其中一些系统需要这些依赖项。当然,我们还需要以用户友好的方式执行代码示例并查看结果,这通过使用 Jupyter 笔记本来实现。
为了让整个过程尽可能简单,我们不是安装几十个软件库和数百个依赖项来使所有这些工作正常,而是将本书的所有示例打包到 Docker 容器中,这些容器已经完全配置并准备好使用。这意味着在运行本书中的代码示例之前,您只需安装一个先决条件:Docker。
Docker 允许创建和运行微小的容器——这些是完全功能的虚拟机,仅运行一个轻量级的操作系统,其中已安装并配置了所有需要的软件和依赖项。这使得您可以在大多数操作系统(macOS、Windows、Linux)上运行代码,而无需配置任何特定于系统的依赖项。
一旦所有服务都启动,本书中的所有代码列表都将通过 Jupyter 笔记本提供,这些笔记本将作为运行和实验代码示例的接口,以查看生成的输出。
A.2 拉取源代码
本书附带的源代码可在 GitHub 上找到:github.com/treygrainger/ai-powered-search。要拉取代码,您可以使用已安装的 Git 客户端或在您首选的开发文件夹中打开命令行界面,并运行以下命令:
git clone https://github.com/treygrainger/ai-powered-search.git
您现在应该在当前目录中有一个名为 ai-powered-search/的新文件夹,其中包含本书的所有源代码。
如果您没有安装 Git 或无法通过前面的命令拉取代码,网站上也提供了一个选项,可以通过您的网络浏览器下载源代码的 zip 文件,然后您只需将其解压缩到您的开发文件夹中。
如果您愿意,可以重命名或移动 ai-powered-search/文件夹;在本书的其余部分,我们将简单地使用变量$AIPS_HOME来指代此目录。
A.3 构建和运行代码
如前所述,Docker 是您必须在系统上安装的一个关键依赖项,以构建和运行AI-Powered Search代码示例。我们不会在这里介绍此安装过程,因为它依赖于系统,并且会不时发生变化。请访问docker.com以获取下载和安装说明。我们还应该注意,除了 Docker 之外,还可以使用其他容器管理工具,如 Podman,但我们将不会在这里介绍这些工具。
一旦您安装了 Docker,请确保在您的命令行界面中将目录更改为$AIPS_HOME目录(cd $AIPS_HOME)。要构建和运行代码库,您只需运行以下命令:
docker compose up
注意,此命令等同于运行 docker compose up solr。本书的代码库支持多个搜索引擎和向量数据库,并且最后一个(可选)参数允许您指定您想要使用的引擎(默认为 Apache Solr)。例如,如果您想使用 OpenSearch,您将运行 docker compose up opensearch。有关其他支持搜索引擎的更多信息,请参阅附录 B。
提示:docker compose up命令在您的控制台前台运行,这允许您实时查看所有日志流,但这也意味着如果关闭控制台,所有容器都将停止。如果您想在不关闭或继续使用控制台的情况下在后台运行容器,可以传递-d或--detach参数(docker compose up -d)。如果您这样启动它,请确保在完成后明确运行docker compose --profile all down,以停止容器在后台无限期运行,消耗资源。
此命令第一次运行需要一段时间,因为它会拉取构建和运行本书所附软件所需的所有代码、操作系统和依赖项。然而,一旦命令完成,您将拥有所有必要的服务(Jupyter、Spark、ZooKeeper、Solr 等)在单独的 Docker 容器中运行。
现在,要开始,只需打开您的网络浏览器并访问 http://localhost:8888。这将重定向到欢迎屏幕,列出本书的所有章节及其对应的 Jupyter 笔记本(图 A.1)。您现在可以点击任何章节以启动其代码示例。

图 A.1 欢迎屏幕。一旦你看到这个,AI-Powered Search 容器就构建完成,Jupyter 笔记本正在运行。
默认情况下,Jupyter 笔记本被加载到 Jupyter Labs 环境中,这是一个用于处理、调试和导航笔记本的开发环境。它允许你同时打开和编辑多个笔记本(在单独的标签页中),并在你探索时在屏幕左侧导航和修改目录结构。
如果你只想在更简单的环境中跟随书籍,你可以选择访问 http://localhost:8888/notebooks 并导航目录表,逐个笔记本地跟随,随着你在书中前进。我们将在下一节中使用这个更简单的界面,尽管默认的 Jupyter Labs 环境在 http://localhost:8888\ 中提供了相同的功能(以及更多)。
A.4 使用 Jupyter
一旦你查看 welcome.ipynb 笔记本,你会在屏幕上看到几个数据单元,包括一个介绍消息、“健康检查”脚本以及包含书中可执行示例的各个笔记本的目录表。
如果你之前从未使用过 Jupyter,它是一个允许你在浏览器中混合标记(通常是说明和解释)和代码的工具,并且可以编辑、运行和与代码示例的输出进行交互。这使得学习变得更加容易,因为你不需要使用命令行工具,而是可以通过点击按钮与准备好的可执行示例完全交互。
你会注意到屏幕顶部附近有一个工具栏,允许你与笔记本中的内容部分(称为 单元)进行交互。你可以使用这些工具上下导航,停止和重新启动笔记本,或使用“运行”按钮按顺序执行每个单元。
在图 A.2 中,当健康检查代码单元被突出显示时点击“运行”将导致健康检查执行以确认所有 Docker 容器正在运行,并且其中运行的服务是健康的并且正在响应。

图 A.2 运行代码示例。在工具栏中点击“运行”将执行当前单元中的任何示例(如果有)并转到下一个单元。
图 A.3 显示了当一切按预期运行时你将看到的响应。

图 A.3 健康检查成功。如果一切运行正常,你应该看到这条消息。
在这一点上,你可以向下滚动到目录表,并逐章浏览笔记本。当然,由于示例背后的解释包含在书中,你可能更喜欢在阅读书籍的同时处理示例,以便在运行它们时拥有适当的背景知识。Jupyter 笔记本不是旨在作为独立示例,所以你可能需要将书籍放在附近以提供上下文。
所有 Jupyter 笔记本都设计为独立幂等的。这意味着,虽然笔记本中的所有步骤都需要按顺序执行以保证成功的结果,但你总是可以从任何笔记本的开始处重新开始,并且它将“重置”到执行后续步骤所需的预期结果。如果你在笔记本中遇到错误,只需回到页面上的第一个单元格并重新运行整个笔记本即可。
A.5 使用 Docker
虽然前几节中的所有内容都应该按预期工作,但当然有可能你在过程中会遇到问题。你可能会遇到的最大挑战是其中一个 Docker 容器,或者其中运行的服务失败。如果你正在更改某个服务的基础数据或配置,例如,你可能会将其置于不良状态。
当这种情况发生时,你总是可以拆毁你的容器并重新开始。为此,只需从 $AIPS_HOME 目录运行以下命令:
docker compose --profile all down && docker compose up
请记住,如果你对笔记本或代码进行了任何更改,当你运行 docker compose --profile all down 时,你已完成的工作将会丢失。一般来说,示例被设计为短暂的。如果你想跨容器重建保留你的工作,你可以修改 docker-compose.yaml 文件以将文件夹挂载到本地驱动器,或者使用标记为 external 的 Docker 数据卷,这将保持持久性。如果你计划在 Docker 文档中进行更改,请参考 Docker 文档,因为机制和 API 可能会不时更改。
如果你修改了代码示例或你的配置,你可能还需要重新构建你的 Docker 镜像。当你第一次运行 docker compose up 时,它将拉取或构建你的镜像并启动它们,但它不会在第一次构建后重建。为了在重新启动 Docker 容器之前重建一切,你可以运行以下命令:
docker compose up --build
如果你只想暂时停止正在运行的 Docker 容器并在以后恢复,你也可以运行 docker compose --profile all stop 来停止,然后在稍后运行 docker compose start 来恢复。如果你想在下次启动时保留所做的任何更改而不重置到笔记本的干净版本,这将非常有用。
这应该会给你运行所有 AI-Powered Search 中的笔记本和代码所需的一切。
附录 B 支持的搜索引擎和向量数据库
我们在整本书中一致地使用开源的 Apache Solr 搜索引擎作为默认的搜索引擎,但代码库中的所有算法都设计为可以与各种搜索引擎和向量数据库一起工作。为此,除了需要特定引擎语法来展示某个点的情况外,我们已实现使用通用 engine 接口来搜索功能,这使得您能够轻松地替换您首选的搜索引擎或向量数据库。在本附录中,我们将介绍支持引擎的列表、如何替换默认引擎以及如何在整本书中使用主要抽象(engine 和 collection)。
B.1 支持的引擎
支持的引擎列表将随着时间的推移而继续增长,但在出版时,以下引擎最初得到支持:
-
solr—Apache Solr -
opensearch—OpenSearch -
bonsai—Bonsai -
weaviate—Weaviate
要查看所有支持引擎的完整、最新列表,请访问 aipoweredsearch.com/supported-engines。
B.2 替换引擎
通常,当运行书籍的代码示例时,您一次只会与一个搜索引擎或向量数据库一起工作。要使用任何特定的引擎,您只需在启动 Docker 时指定该引擎的名称(如上所述)即可。
例如,您可以通过以下方式启动 OpenSearch:
docker compose up opensearch
这将启动运行 opensearch(或您指定的引擎)所需的任何必要的 Docker 容器,并将此引擎设置为书籍的 Jupyter 笔记本中的活动引擎,以便在所有代码示例中使用。
注意,一些引擎,如托管搜索和基于 API 的服务,不需要任何额外的本地 Docker 容器,因为它们的服务托管在其他地方。此外,一些引擎可能需要额外的配置参数,例如 API 密钥、远程地址/URL、端口等。这些参数可以在项目根目录的 .env 文件中设置。
如果您想在任何时候使用不同的引擎,您可以重新启动 Docker 容器并指定您想要使用的新引擎:
docker compose up bonsai
如果您想同时启动多个引擎进行实验,您可以在 docker compose up 命令的末尾提供您希望启动的引擎列表:
docker compose up solr opensearch weaviate
在您的 docker compose up 命令中引用的第一个引擎将被设置为 Jupyter 笔记本中的活动引擎,其他引擎则处于待机状态。
如果您想在您的实时 Jupyter 笔记本中切换到备用引擎之一(例如,切换到 opensearch),您可以通过在任何笔记本中运行以下命令在任何时候完成此操作:
import aips
aips.set_engine("opensearch")
请记住,如果您为当前未运行的引擎调用 set_engine,那么当您尝试使用该引擎时,如果该引擎仍然不可用,这将会导致错误。
您也可以通过运行以下命令在任何时候检查当前设置的引擎:
aips.get_engine().name
B.3 引擎和集合抽象
搜索引擎行业充满了各种术语和概念,我们已经在代码库中尽可能地抽象掉了大部分。大多数搜索引擎最初都是从词汇关键词搜索开始的,后来增加了对向量搜索的支持,而许多向量数据库最初也是从向量搜索开始的,后来又增加了词汇搜索。就我们的目的而言,我们只是将这些系统视为匹配和排名引擎,并且我们使用术语引擎来指代所有这些系统。
同样,每个引擎都有一个一个或多个逻辑分区或容器来添加数据。在 Solr 和 Weaviate 中,这些容器被称为collections;在 OpenSearch、Elasticsearch 和 Redis 中,这些被称为indexes;在 Vespa 中,这些被称为applications。在 MongoDB 中,原始数据存储在collection中,但随后可以复制到index中进行搜索。命名在其他引擎中也有进一步的差异。
为了正确的抽象,我们在代码库中始终使用术语collection,因此每个实现的引擎都有一个collection接口,通过该接口可以查询或添加文档。
在engine接口上的常见公共方法包括
-
engine.create_collection(collection_name)—创建新的集合 -
engine.get_collection(collection_name)—返回现有的集合
在collection接口上的常见公共方法包括
-
collection.search(**request)—执行搜索并返回结果。单个请求参数应作为 Python 关键字参数传递,例如collection.search(query="keyword"), limit=10。 -
collection.add_documents(docs)—将一系列文档添加到集合中 -
collection.write(dataframe)—将 Spark dataframe 中的每一行写入集合作为文档 -
collection.commit()—确保最近添加的文档被持久化并可供搜索
engine和collection接口还内部实现了用于书中所有数据集的模式定义和管理。
由于collection.write方法接受一个 dataframe,我们在从 CSV 或 SQL 等额外数据源加载数据时需要根据需要使用辅助工具:
-
collection.write(from_csv(csv_file))—将 CSV 文件中的每一行写入集合作为文档 -
collection.write(from_sql(sql_query))—执行 SQL 查询并将返回的每一行写入集合作为文档
从这些额外数据源加载不需要额外的引擎特定实现,因为任何可以映射到 Spark dataframe 的数据源都隐式支持。
B.4 添加对额外引擎的支持
虽然我们希望最终支持大多数主要搜索引擎和向量数据库,但您可能会发现您喜欢的引擎目前尚未得到支持。如果是这种情况,我们鼓励您为其添加支持,并向代码库提交一个拉取请求。engine 和 collection 接口被设计得易于实现,您可以使用默认的 solr 实现或任何其他已实现的引擎作为参考。
并非所有数据存储都完全支持 AI-Powered Search 中实现的所有功能。例如,纯向量数据库可能不支持词汇关键词匹配和排名,而某些搜索引擎可能不支持向量搜索。同样,某些专用功能可能仅在特定引擎中可用。
尽管默认的 solr 引擎支持书中实现的全部 AI-Powered Search 功能,但其他引擎可能需要采取变通方法、集成额外的库,或将某些功能委托给其他引擎以处理特定算法。例如,大多数引擎没有对语义知识图谱和文本标记的原生支持,因此许多引擎实现会将这些一次性功能委托给其他库。
我们希望 engine 和 collection 抽象能够让您轻松添加对您喜欢的引擎的支持,并可能将其贡献给本书的代码库,以惠及更广泛的 AI-Powered Search 读者和实践者。祝您搜索愉快!




浙公网安备 33010602011771号