原文:
annas-archive.org/md5/b1cbbd444ec98b4386abe602865c1d22
译者:飞龙
前言
我们生活在数字时代,数据的创建变得越来越容易。根据《福布斯》报道,我们每天生成 2.5 quintillion 字节的数据,而这些数据来自各种来源。它可以是 Instagram 上的图片、Telegram 上的语音消息、视频、文本,甚至是它们的组合。这在互联网刚刚起步时显然不是这样。尽管数据创建的容易度发生了显著变化,但我们依然在使用相同的搜索技术。尽管数据生成爆炸式增长,我们的搜索技术却没有得到太多更新。
神经搜索是改变这一现状的方法。它利用我们当前所处的机器学习(ML)时代,运用最新的 AI 研究来提供创新的搜索技术。然而,尽管这是一项不错的技术,但它也带来了许多挑战。神经搜索是一个全新的概念,这意味着所需的知识和技术也都是新的。为了有效地部署神经搜索应用,负责此工作的工程师需要具备丰富的技能,从工程学到 Dev-Ops,再到机器学习的背景知识。
这正是 Jina AI 想要解决的问题。Jina 是一个开源解决方案,旨在使 AI 和神经搜索更加民主化,使许多开发者能够轻松拥有一个完整的端到端神经搜索应用,而无需具备机器学习、云计算和后端工程的背景。它特别为 Python 开发者设计,帮助他们充分发挥最新神经搜索技术的潜力。在本书中,我们将探索搜索的基础知识,从传统搜索到神经搜索。通过这些知识,我们将通过动手实践,创建一个完整的神经搜索应用。
本书适用人群
如果你是一位对使用现代软件架构构建任何类型的搜索系统(如文本、问答、图像、音频、PDF、3D 模型等)感兴趣的机器学习、深度学习(DL)或 AI 工程师,这本书适合你。本书同样适合对使用最先进的深度学习技术构建任何类型的搜索系统感兴趣的 Python 工程师。
本书涵盖的内容
第一章,神经网络与神经搜索,将介绍神经搜索是什么及其用途。你将看到它的实际应用概述、面临的挑战以及如何克服这些挑战。
第二章,介绍向量表示基础,将讲解机器学习中的向量概念。它还将介绍构建在向量表示基础上的常见搜索算法及其优缺点。
第三章,系统设计与工程挑战,将涵盖搜索系统设计的基础知识。在这一章中,您将学习与搜索相关的核心概念,如索引和查询,并学习如何使用这些概念保存和检索信息。
第四章,学习 Jina 基础,将涵盖实现自己搜索引擎所需的步骤。在这一章中,您将详细了解 Jina 的核心概念及其总体设计,并了解它们之间如何相互连接。
第五章,多模态搜索,将介绍多模态和跨模态搜索的概念,您可以结合文本、图像、音频和视频等多种模态,构建最先进的搜索系统。
第六章,使用 Jina 的基础实践示例,将涵盖基于前几章学到的概念,构建 Jina 神经搜索框架的适合初学者的现实应用。
第七章,探索 Jina 的高级应用场景,将通过前几章学到的概念,介绍 Jina 神经搜索框架的高级应用。重点讲解如何通过现实世界的例子来解释神经搜索中的挑战性概念。
为了从本书中获得最大收益
为了充分利用本书,您需要用 Python 编写程序,并具备机器学习(ML)和深度学习(DL)的知识。最好还要有信息检索和搜索问题的基本了解。
本书涉及的软件/硬件 | 操作系统要求 |
---|---|
Python 3.7 | 配备 WSL 的 Windows、macOS 或 Linux |
Jina 3.7 | |
DocArray 0.13 |
如果您使用的是本书的数字版,我们建议您自己输入代码,或通过本书 GitHub 仓库中的链接访问代码(下一节中会提供链接)。这样做有助于避免因复制粘贴代码而可能出现的错误。
下载示例代码文件
您可以从 GitHub 下载本书的示例代码文件,网址是github.com/PacktPublishing/Neural-Search-From-Prototype-to-Production-with-Jina
。如果代码有更新,GitHub 仓库中的代码将会得到更新。
我们还提供了来自我们丰富书籍和视频目录中的其他代码包,您可以在github.com/PacktPublishing/
查看!快来看看吧!
下载彩色图片
我们还提供了一个包含本书中使用的截图和图表的彩色 PDF 文件。您可以在这里下载:packt.link/minUU
使用的约定
本书中使用了多种文本约定。
文本中的代码
:表示文本中的代码词汇、数据库表名、文件夹名称、文件名、文件扩展名、路径名、虚拟网址、用户输入和 Twitter 用户名。举个例子:“要通过 UI 在 Web 浏览器中与多模态应用程序进行交互,你可以使用index.xhtml
HTML 文件,该文件位于static
文件夹中。”
一段代码如下所示:
- name: keyValueIndexer
uses:
jtype: KeyValueIndexer
metas:
workspace: ${{ ENV.HW_WORKDIR }}
py_modules:
- ${{ ENV.PY_MODULE }}
needs: segment
- name: joinAll
needs: [textIndexer, imageIndexer, keyValueIndexer]
当我们希望你关注代码块的某一部分时,相关行或项目将以粗体显示:
- name: keyValueIndexer
uses:
jtype: KeyValueIndexer
metas:
workspace: ${{ ENV.HW_WORKDIR }}
py_modules:
- ${{ ENV.PY_MODULE }}
needs: segment
- name: joinAll
needs: [textIndexer, imageIndexer, keyValueIndexer]
任何命令行输入或输出将按如下方式书写:
<jina.types.arrays.document.DocumentArray length=3 at 5701440528>
{'id': '6a79982a-b6b0-11eb-8a66-1e008a366d49', 'tags': {'id': 3.0}},
{'id': '6a799744-b6b0-11eb-8a66-1e008a366d49', 'tags': {'id': 2.0}},
{'id': '6a799190-b6b0-11eb-8a66-1e008a366d49', 'tags': {'id': 1.0}}
粗体:表示新术语、重要词汇或屏幕上出现的词汇。例如,菜单或对话框中的词汇通常以粗体显示。举个例子:“从管理面板中选择系统信息。”
提示或重要说明
如下所示。
联系我们
我们欢迎读者的反馈。
一般反馈:如果你对本书的任何内容有疑问,请通过电子邮件联系我们,邮箱地址为 customercare@packtpub.com,并在邮件主题中提到书名。
勘误:虽然我们已尽一切努力确保内容的准确性,但错误难免发生。如果你在本书中发现了错误,我们将非常感激你能报告给我们。请访问 www.packtpub.com/support/errata 并填写表单。
盗版:如果你在互联网上发现任何非法的我们作品的副本,无论何种形式,我们将非常感激你能提供地址或网站名称。请通过 copyright@packt.com 联系我们,并附上相关材料的链接。
如果你有兴趣成为作者:如果你在某个主题上有专业知识,并且对写书或为书籍做贡献感兴趣,请访问authors.packtpub.com。
分享你的想法
一旦你阅读完 神经搜索 - 从原型到生产使用 Jina,我们很想听听你的想法!请点击这里直接访问亚马逊书评页面并分享你的反馈。
你的评论对我们和技术社区来说都非常重要,帮助我们确保提供优质的内容。
第一部分:神经搜索基础介绍
在本部分中,你将了解什么是神经搜索以及它的用途。你将看到其实际应用的概述,面临的挑战以及如何克服这些挑战。以下章节包括在此部分中:
-
第一章,神经网络与神经搜索
-
第二章,向量表示基础介绍
-
第三章,系统设计与工程挑战
第一章:神经网络与神经搜索
搜索一直是所有信息系统中的关键组成部分;将正确的信息提供给正确的用户是至关重要的。这是因为用户查询(如一组关键词)不能完全代表用户的信息需求。传统上,符号搜索被开发出来,允许用户进行基于关键词的搜索。然而,这类搜索应用被局限于基于文本的搜索框中。随着深度学习和人工智能的最新发展,我们可以将任何类型的数据编码为向量,并衡量两个向量之间的相似度。这使得用户能够用任何类型的数据创建查询,并获得任何类型的搜索结果。
在本章中,我们将回顾信息检索和神经搜索的相关重要概念,同时探讨神经搜索为开发者带来的好处。在介绍神经搜索之前,我们将首先介绍基于传统符号的搜索的缺点。接着,我们将探讨如何利用神经网络构建跨/多模态搜索,这将包括其主要应用。
在本章中,我们将重点介绍以下几个主题:
-
传统搜索与神经搜索
-
搜索中的机器学习
-
神经搜索的实际应用
技术要求
本章具有以下技术要求:
-
硬件:具有至少 4 GB 内存的台式机或笔记本电脑;建议使用 8 GB 内存
-
操作系统:类似 Unix 的操作系统,如 macOS,或任何基于 Linux 的发行版,如 Ubuntu
-
编程语言:Python 3.7 或更高版本,以及 Python 包管理器或 pip
传统搜索与神经搜索
本节将引导你了解符号搜索系统的基本原理、不同类型的搜索应用及其重要性。接下来会简要描述符号搜索系统的工作原理,并提供一些用 Python 编写的代码。最后,我们将总结传统符号搜索与神经搜索的优缺点。这将帮助我们理解神经搜索如何更好地弥合用户意图与检索文档之间的差距。
探索各种数据类型和搜索场景
在当今社会,政府、企业和个人每天通过各种平台产生大量数据。我们生活在大数据时代,文本、图像、视频和音频文件等在社会中发挥着重要作用,并且是日常任务完成的重要组成部分。
一般而言,数据分为三种类型:
-
结构化数据:指以二维表格结构表达和实现的数据。结构化数据严格遵循特定的数据格式和长度规格,主要通过关系型数据库进行存储和管理。
-
非结构化数据: 这种数据既没有规律或完整的结构,也没有预定义的数据模型。通过使用数据库中常见的二维逻辑表来表示数据,这种数据难以得到有效管理。非结构化数据包括办公文档、文本、图片、超文本标记语言(HTML)、各种报告以及所有格式的图像、音频和视频信息。
-
半结构化数据: 这种数据介于结构化数据和非结构化数据之间。它包括日志文件、可扩展标记语言(XML)和JavaScript 对象表示法(JSON)。半结构化数据不符合与关系数据库或其他数据表相关的数据模型结构,但它包含相关的标签,可以用来分隔语义元素,以便对记录和字段进行分层。
搜索索引广泛用于在庞大的数据集中搜索非结构化和半结构化数据,以满足用户的信息需求。根据文档集合的层级和应用,搜索可以进一步分为三种类型:网络搜索、企业搜索和个人搜索。
在网络搜索中,搜索引擎首先需要对数以亿计的文档进行索引。然后,搜索结果会以高效的方式返回给用户,同时系统也在不断优化。网络搜索应用的典型例子包括 Google、Bing 和百度。
除了网络搜索之外,作为软件开发工程师,你很可能会遇到企业和个人搜索操作。在企业搜索场景中,搜索引擎对企业的内部文档进行索引,以服务企业的员工和客户,例如公司内部的专利搜索索引,或音乐平台如 SoundCloud 的搜索索引。
如果你正在开发一个电子邮件应用,并计划允许用户搜索历史邮件,那么这就构成了典型的个人搜索示例。本书主要关注企业和个人类型的搜索操作。
重要提示
确保你理解搜索与匹配之间的区别。搜索通常是在组织成非结构化或半结构化格式的文档中进行的,而匹配(如 SQL 类似的查询)则发生在结构化数据上,例如表格数据。
关于不同的数据类型,模态的概念在搜索系统中起着重要作用。模态指的是信息的形式,如文本、图像、视频和音频文件。跨模态搜索(也称为跨媒体搜索)是指通过探索不同模态之间的关系,并利用某种模态样本,来检索具有相似语义的不同模态样本。
例如,当我们在电子邮件收件箱应用中输入一个关键词时,我们可以通过单模搜索——逐字搜索文本,找到相应的邮件。当你在页面中输入一个关键词进行图片检索时,搜索引擎会通过跨模态搜索,将文本作为依据返回相关的图片。
当然,单模搜索不仅限于逐字搜索文本。像 Shazam 这样的应用,在 App Store 中非常流行,它帮助用户识别音乐,并在短时间内返回歌曲的标题。这可以视为单模搜索的应用。在这里,模态的概念不再指文本,而是指音频。在 Pinterest 上,用户可以通过图片搜索定位相似的图片,模态指的是图片。同样,跨模态搜索的范围远不止通过文本搜索图片。
让我们从另一个角度来考虑这个问题。我们能否进行跨多个模态的搜索呢?当然,答案是“可以!”想象一下这样的搜索场景:用户上传一张衣物的照片,并希望找到相似类型的衣服(我们通常称这种应用为“shop the look”),同时在搜索框中输入描述衣物的段落,以提高搜索的准确性。这样,我们的搜索关键词跨越了两种模态(文本和图片)。我们称这种搜索场景为多模态搜索。
现在我们已经掌握了模态的概念,接下来我们将详细阐述符号搜索系统的工作原理、优缺点。到本节结束时,你将理解,符号搜索系统无法处理不同的模态。
传统搜索系统是如何工作的?
作为开发者,你可能曾经使用过 Elasticsearch 或 Apache Solr 来构建网页应用中的搜索系统。这两个广泛使用的搜索框架是基于 Apache Lucene 开发的。我们以 Lucene 为例,介绍搜索系统的组成部分。假设你打算在成千上万的文本文件(txt
)中搜索一个关键词,你会如何完成这个任务?
最简单的解决方案是遍历从路径中获取的所有文本文件,读取这些文件的内容。如果文件中包含该关键词,文档名称将被返回:
# src/chapter-1/sequential_match.py
import os
import glob
dir_path = os.path.dirname(os.path.realpath(__file__))
def match_sequentially():
matches = []
query = 'hello jina'
txt_files = glob.glob(f'{dir_path}/resources/*.txt')
for txt_file in txt_files:
with open(txt_file, 'r') as f:
if query in f.read():
matches.append(txt_file)
return matches
if __name__ == '__main__':
matches = match_sequentially()
print(matches)
这段代码通过遍历当前目录下所有扩展名为.txt
的文件,并依次打开这些文件,来实现最简单的搜索功能。如果查询使用的关键词hello
或jina
存在,匹配的文件名将被打印出来。虽然这些代码允许你进行基本的搜索,但其过程有很多缺陷:
-
可扩展性差:在生产环境中,可能有数百万个文件需要检索。与此同时,检索系统的用户期望在最短的时间内获得检索结果,这对搜索系统的性能提出了严格的要求。
-
缺乏相关性度量:该代码帮助您实现最基本的布尔检索,即返回匹配或不匹配的结果。在现实世界的场景中,用户需要一个评分来衡量来自搜索系统的相关性程度,且搜索结果会按降序排列,相关性更高的文件会优先返回给用户。显然,前述代码片段无法实现这一功能。
为了解决这些问题,我们需要对待检索的文件进行索引。索引是指将文件类型转换为允许快速搜索并跳过对所有文件的连续扫描的过程。
作为我们日常生活中的重要部分,索引类似于查阅字典或访问图书馆。我们将使用最广泛使用的搜索库 Lucene 来说明这个概念。
Lucene Core (lucene.apache.org/
)是一个 Java 库,提供强大的索引和搜索功能,以及拼写检查、命中高亮显示和高级分析/分词能力。Apache Lucene 为搜索和索引性能设定了标准。它是 Apache Solr 和 Elasticsearch 的搜索核心。
在 Lucene 中,在所有待检索文件集合加载完毕后,您可以从这些文件中提取文本,并将它们转换为 Lucene 文档,这些文档通常包含文件的标题、正文、摘要、作者和 URL。
接下来,您的文件将由 Lucene 的文本分析器进行分析,通常包括以下过程:
分词器:这会将原始输入段落分割成不能进一步分解的词素。
分解 复合 词:在德语等语言中,由两个或多个词素组成的单词被称为复合词。
拼写修正:Lucene 允许用户进行拼写检查,以提高检索的准确性。
同义词分析:这使得用户可以手动在 Lucene 中添加同义词,以提高搜索系统的召回率(注意:准确率和召回率将稍后详细说明)。
词干提取与词形还原:前者通过去除词汇的后缀来派生词根(例如,play作为词根是从单词plays、playing和played中派生出来的),而后者则帮助用户将单词转换为基本形式,例如,is、are和been都可以转换为be。
让我们尝试使用NLTK预处理一些文本。
重要提示
NLTK 是一个领先的平台,用于构建与人类语言数据交互的 Python 程序。它提供了一个易于使用的接口,访问 50 多个语料库和词汇资源。
首先,通过以下命令安装一个名为nltk
的 Python 包:
pip install nltk
python -m nltk.downloader 'punkt'
我们预处理文本Jina is a neural search framework built with cutting-edge technology called deep learning
:
import nltk
sentence = 'Jina is a neural search framework built with cutting-edge technology called deep learning'
def tokenize_and_stem():
tokens = nltk.word_tokenize(sentence)
stemmer = nltk.stem.porter.PorterStemmer()
stemmed_tokens = [stemmer.stem(token) for token in
tokens]
return stemmed_tokens
if __name__ == '__main__':
tokens = tokenize_and_stem()
print(tokens)
这段代码使我们能够对一个句子进行两项操作:分词和词干提取。每个操作的结果分别打印出来。原始输入字符串被解析为 Python 中的字符串列表,最终每个解析后的词元被词形还原为其基本形式。例如,cutting
和called
分别被转换为cut
和call
。有关更多操作,请参阅 NLTK 的官方文档(www.nltk.org/
)。
在使用 Lucene 文档处理文件后,清理过的文件将被索引。通常,在传统的搜索系统中,所有文件都使用倒排索引进行索引。倒排索引(也称为文档列表文件或倒排文件)是一种索引数据结构,它将内容(如单词或数字)映射到其在数据库文件或文档集中的位置。
简单来说,倒排索引由两部分组成:术语词典和文档列表。
词元、它们的 ID 和文档频率(即该词元在整个待检索文档集合中出现的频率)存储在术语词典中。所有词元的集合称为词汇表。词典中的所有词元按字母顺序排列。
在文档列表中,我们保存了词元 ID 和该词元出现的文档 ID。假设在上述示例中,查询关键词hello jina
中的词元jina
在整个文档集合中出现了三次(分别出现在1.txt
、3.txt
和11.txt
中),那么该词元为jina
,文档频率为 3。同时,这三个文本文件的名称1.txt
、3.txt
和11.txt
将被保存在文档列表中。随后,文本文件的索引就完成了,如下图所示:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/nrl-sch/img/Figure_1.1_B17488.jpg
图 1.1 – 倒排索引的数据结构
当用户发出查询时,用于查询的关键词通常比待检索的文档集合要短。Lucene 可以对这些关键词执行相同的预处理操作(如分词、分解和拼写纠正)。
处理后的词元通过倒排索引中的术语词典映射到文档列表,从而可以快速找到匹配的文件。最后,Lucene 的打分机制开始工作,并根据向量空间模型对每个相关文件进行打分。我们的索引文件存储在倒排索引中,可以表示为一个向量。
假设我们的查询关键词是jina
,我们将其映射到倒排索引的向量中,并在该词元未出现在文件中时用-
表示;此时可以获得查询向量[-,'jina',-,-,...]
。这就是我们在传统搜索引擎中以向量空间模型表示一个查询的方式。
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/nrl-sch/img/Figure_1.2_B17488.jpg
图 1.2 – 向量空间模型中的术语出现
接下来,为了得出排名,我们需要对空间向量模型的词汇进行数值表示。通常,tf-idf被认为是一种简单的方法。
使用此算法,我们赋予任何相对频繁出现的词汇更高的权重。如果一个词在多个文档中多次出现,我们认为该词代表性较弱,因此它的权重会再次降低。如果该词在文档中没有出现,它的权重为 0\。
在 Lucene 中,更常用的算法是bm-25,它进一步优化了 tf-idf。在数值计算后,向量表示如下:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/nrl-sch/img/Figure_1.3_B17488.jpg
图 1.3 – 向量空间表示
如上图所示,由于词汇a出现过于频繁,它出现在文档 1 和文档 2 中,并且权重较低。而Jina这个相对不常见的词(出现在文档 2 中)则被赋予了更高的权重。
在查询向量中,由于查询关键词只有一个词,Jina,它的权重被设为 1,其他未出现的词汇权重设为 0。然后,我们将查询向量与文档向量按元素相乘并将结果相加,从而获得每个文档对应查询关键词的分数。随后,进行倒序排序,按照分数从高到低排序,最终将排序后的文档返回给用户。
简而言之,如果查询使用的关键字在特定文件中出现得更频繁,而在词汇文件中出现得较少,那么它的相对分数会更高,并且会以更高的优先级返回给用户。当然,Lucene 也会根据文件的不同部分赋予不同的权重。例如,文件的标题和关键词的得分权重通常会高于正文部分。鉴于本书讨论的是神经搜索,关于这一方面不会再进一步展开。
传统搜索系统的优缺点
在前一节中,我们简要回顾了传统的符号搜索。也许你已经注意到,我们之前介绍的 Lucene,以及基于 Lucene 的搜索框架,如 Elasticsearch 和 Solr,都是基于文本检索的。这在基于文本搜索的应用场景中有不少优势:
成熟的技术:由于研究和开发始于 1999 年,基于 Lucene 的搜索系统已经存在超过 20 年,并且在各种网页应用中得到了广泛应用。
易于集成:作为用户,网页应用的开发人员无需深入理解 Elasticsearch、Solr 或 Lucene 的操作逻辑;只需少量代码即可将高性能、可扩展的搜索系统集成到网页应用中。
完善的生态系统:得益于 Elastic 公司运营,Elasticsearch 显著扩展了其搜索系统功能。目前,它不仅是一个搜索框架,还配备了用户管理、RESTful 接口、数据备份与恢复、以及包括单点登录、日志审计等在内的安全管理功能。同时,Elasticsearch 社区也贡献了各种插件和集成。
同时,您可能已经意识到,无论是基于 Lucene 的 Elasticsearch 还是 Solr,都有无法避免的缺陷。
在前面一节中,我们介绍了模态的概念。基于 Lucene 构建的 Elasticsearch,本质上无法支持跨模态和多模态搜索选项。让我们稍作回顾 Lucene 的工作原理,因为 Lucene 为用户日常使用的大多数搜索系统提供了支持。当文本首先被预处理时,搜索关键字必须是文本。当要检索的数据集经过预处理和索引时,相应的索引结果也是存储在倒排索引中的文本。
通过这种方式,基于 Lucene 的搜索平台只能依赖文本模态进行数据检索。如果要检索的对象是图片、音频或视频文件,传统搜索系统如何找到它们呢?其实很简单,采用了两种主要方法:
人工标注和添加元数据:例如,当用户上传一首歌曲到音乐平台时,他们可能会手动标记作者、专辑、音乐类型、发布时间等数据。这样做确保了用户可以通过文本来检索音乐。
周围文本的假设:如果一张图片在没有用户标注的情况下出现在一篇文章中,传统搜索系统会假设该图片与其周围的文本有较强的关联。因此,当用户查询的关键字与图片周围的文本匹配时,图片就会被检索到。
这两种方法的本质是将非文本模态的文档转换为文本模态,从而有效利用当前的检索技术。然而,这一模态转换过程要么依赖大量的人工标注,要么以查询准确性为代价,从而大大削弱了用户的搜索体验。
同样,这种搜索模式将用户的搜索习惯局限于关键词搜索,无法扩展到真正的跨模态甚至多模态搜索。为了更深入地理解这个问题,我们可以使用向量空间来表示一段文字的关键词,并使用另一个向量空间来表示待检索的文本。然而,由于当时我们不得不依赖传统搜索系统的技术限制,我们无法使用空间向量来表示一段音乐、图像或视频。同样,也无法将不同模态的两个文档映射到同一个空间向量中以比较它们的相似性。
随着(统计)机器学习技术的研究和发展,越来越多的研究人员和工程师开始通过使用机器学习算法来增强他们的搜索系统。
用于搜索的机器学习
作为一项跨学科的任务,神经搜索已超越了信息检索的边界。它需要对机器学习、深度学习的概念有一个基本的了解,并理解如何应用这些技术来改进搜索任务。在本节中,我们将简要介绍机器学习以及它如何应用于搜索系统。
理解机器学习与人工智能
机器学习是指一种技术,通过使计算机学习数据的内在规律,并获取新的经验和知识,从而提高计算机的智能,使其能够以类似人类自然的方式做出决策。
由于各行业对数据处理和分析效率的需求不断增加,大量的机器学习算法应运而生。统计机器学习算法的概念主要是指通过数学和统计方法解决优化问题的步骤和过程。
根据不同的数据和模型需求,选择并使用适当的机器学习算法,以更高效的方式解决实际问题。机器学习在许多领域取得了巨大的成功,如自然语言理解、计算机视觉、机器翻译和专家系统。可以说,是否具备学习功能,已经成为判断一个系统是否具备智能的标志。
Hinton 等人(2006)提出了深度学习(深度学习/深度神经网络)的概念。2009 年,Hinton 将深度神经网络引入了专注于语音的学者中。因此,2010 年,这一研究领域在语音识别方面取得了显著突破。在接下来的 11 年中,卷积神经网络(CNNs)被广泛应用于图像识别领域,并取得了显著成就。
神经网络的三位创始人 LeCun、Bengio 和 Hinton(2015 年)在《自然》杂志上发表了一篇名为深度学习的综述文章。这表明深度神经网络不仅被学术界接受,而且在工业领域也得到了广泛应用。此外,2016 年和 2017 年,深度学习迎来了全球范围的扩展。AlphaGo 和 AlphaZero 在谷歌经过短期学习后发明,并以压倒性胜利战胜了世界排名前三的围棋选手。科大讯飞推出的智能语音系统,识别准确率超过 97%,处于全球人工智能的前沿;谷歌和特斯拉等公司开发的自动驾驶系统,已经达到了路测的里程碑。这些成就再次揭示了神经网络的价值与魅力。
机器学习已经应用于各个行业,那么或许我们可以问自己:能否将机器学习应用到搜索应用中?答案是“可以”。在接下来的部分,我们将简要概述不同类型的机器学习以及搜索如何从中受益。
机器学习与排序学习
想象一下这样一个场景,你打算训练一个能够根据收集到的与本地房地产信息和价格相关的数据,评估新公寓或房屋价格的模型。这是机器学习中最重要的任务之一:回归。
在深度学习技术普及之前,数据分析师需要清洗这些数据,利用业务逻辑进行特征工程,并设计房地产价格预测器的特征,例如建筑面积、建造时间、房屋或公寓的类型,以及周围房屋或公寓的平均价格等。
特征工程完成后,原始数据将被用于形成一个类似于 Excel 的二维数据表。横轴代表每个房产记录,纵轴代表每个特征。数据通常会再次分为两到三部分:大多数数据用于模型训练,而少量数据用于模型评估。
接下来,机器学习工程师将从机器学习工具包中选择一个或多个适合的算法,进行模型训练,并评估模型在测试数据上的表现。最后,表现最佳的模型将被部署到生产环境中,为客户提供服务。
再想象一个场景,你收集了许多来自社交网络的地标图片。当用户上传一张新的地标图片时,你希望你的系统能够自动识别该地点的名称。这是机器学习中的另一个重要任务:分类。
在传统机器学习和计算机视觉领域,采用一些特征,如 SIFT、SURF 和 HOG,来开发视觉词袋(BoW),通过它建立该照片的向量表示。此外,模型被用来预测分类。如今,深度学习作为模型,无需特征工程即可从图像中提取视觉特征。
让我们花点时间看一下我们的两个例子。在预测房价(公寓)价格的训练过程中,模型通过特征工程进行训练。所有的训练数据都是实际值,即房价(公寓价格)和地标名称都有文档记录。这样的任务统称为机器学习中的监督式学习。
由于我们可以通过监督式机器学习进行数据的回归分析和分类,那么是否可以将监督式机器学习应用到搜索中呢?答案是肯定的,当然可以。
假设我们的任务是优化搜索系统,目标是预测用户点击文档的概率,并将预测点击率更高的文档首先返回给用户。这就是学习排序(第一阶段)和神经信息检索(第二阶段)。学习排序的概念(基于统计机器学习)由学术界在 1990 年代初提出,经过近 20 年的发展后,在 2010 年深度学习的出现导致其进入低谷,神经信息检索达到顶峰。
就像预测公寓(或房屋)价格,或地标识别一样,工程师们在收集数据后首先进行数据工程。常见的特征包括文档标题/正文中查询关键词的数量、文档标题/正文中包含查询关键词的百分比、tf-idf 评分、bm-25 评分等。因此,传统搜索系统的最终得分作为训练模型时的数值特征。
在实际场景中,微软的 Bing 搜索平台设计了自己的Microsoft Learning to Rank Datasets,其中包含 136 个特征。此外,他们还发布了一场学习排序的竞赛,呼吁使用这些数据集作为预测网页匹配度的基本训练模型。之后,经过训练的模型被应用到 Bing 搜索的生产环境中,搜索效果得到了某种程度的提升。
与此同时,谷歌、雅虎和百度等搜索公司也进行了大量的研究,并将其部分研究成果部分投入到生产环境中。
在企业和个人搜索领域,Elastic 开发了名为 ElasticSearch LTR 的学习排序插件,可以插入到你的 ES 驱动的搜索系统中。作为用户,你仍然需要使用一个熟悉的机器学习框架来设计特征、训练学习排序模型、评估模型性能和选择模型。Elasticsearch 对学习排序的支持可以插件化地加入现有的搜索系统,并基于模型输出获得新的预测排名分数。虽然机器学习可以用来为多模态数据设计模型,但 Elasticsearch 更侧重于文本到文本的搜索。图 1.4 展示了学习排序如何在搜索系统中工作。
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/nrl-sch/img/Figure_1.4_B17488.jpg
图 1.4 – 学习排序
本书将重点介绍由深度神经网络驱动的搜索,即神经信息检索。
神经信息检索的优势在于用户不需要自行设计特征。通常,我们利用两个独立的深度学习模型(神经网络)作为特征提取器,分别从查询和文档中提取向量。然后,我们使用如余弦相似度等度量标准来衡量两个向量之间的相似性。在这个阶段,神经网络驱动的搜索已经成为工业应用案例中非常有前景的技术。在下一节中,我们将介绍一些神经网络驱动搜索的潜在应用。
神经搜索驱动的实际应用
上一节提供了关于稠密向量的表示和原理的概述。本节将重点介绍这些向量的应用。在我们的日常工作和学习中,所有文件都会有一个独特的模态,例如文本、图像、音频或视频文件等。如果任何模态的文档都可以通过稠密向量表示,并映射到相同的向量空间中,那么就可以比较跨模态的相似性。这也使得我们能够使用一种模态来搜索另一种模态中的数据。
这一场景首次在电子商务领域得到广泛应用,举例来说就是常见的图像搜索。在该领域的主要应用之一是获取产品照片,然后在网上和线下寻找相关或相似的产品。
电子商务搜索主要包括以下几个步骤:
-
预处理
-
特征提取与融合
-
大规模相似性搜索
在预处理过程中,可能首先会采用调整大小、归一化和语义****分割等技术来处理图像。调整大小和归一化使输入图像能够与预训练神经网络的输入格式匹配。语义分割则具有去除图像背景噪声,仅保留产品本身的功能。当然,我们需要预训练一个神经网络路径用于特征提取,稍后将详细讲解。同样,如果待检索的电商产品数据集中存在大量噪声,例如时尚照片背景中有大量建筑物、行人等,就需要训练一个语义分割模型,帮助我们准确从照片中提取产品轮廓。
在特征提取过程中,通常使用深度学习的全连接(FC)层作为特征提取器。深度学习的常见骨干网络模型有 AlexNet、VGGNet、Inception 和 ResNet。这些模型通常会在大规模数据集(如 ImageNet 数据集)上进行预训练,以完成分类任务。随后,通过迁移学习使用电商领域的数据集,以使特征提取器适用于该领域,例如时尚类产品的特征提取。目前,以深度学习技术为核心的特征提取器可以视为全局特征提取器。在一些应用中,传统的计算机视觉特征,如 SIFT 或 VLAD,常用于提取局部特征,并与全局特征融合,以增强向量表示。全局特征将把预处理后的图像转换为密集的向量表示。
当用户基于图像进行查询时,查询所用的关键词也是一张图像。系统将生成该图像的密集向量表示。然后,用户可以通过将待查询图像的密集向量与库中所有图像的向量进行比较,从而找到最相似的图像。理论上这是可行的。然而,实际上,随着商品数量的快速增加,可能会有数千万个已索引图像的密集向量。因此,逐对比较向量将无法满足用户对检索系统快速响应的需求。
因此,大规模相似性搜索技术,如产品量化,通常用于将待搜索的向量划分为多个桶,并通过最小化召回率在桶之间进行快速匹配,从而大大加速向量匹配过程。因此,这项技术通常被称为近似最近邻,或ANN 检索。常用的 ANN 库包括由 Facebook 维护的 FAISS 和由 Spotify 维护的 Annoy。
同样,在电子商务场景中通过图像搜索图像,也适用于其他场景,比如旅游景点检索(通过旅游景点的图片快速定位该景点或类似旅游景点的其他图片),或名人检索(用于寻找名人的照片并检索其图片)。在搜索引擎领域,存在许多此类应用,这些应用统称为反向图像搜索。
另一个有趣的应用是问答系统。基于神经网络的搜索系统在构建不同任务的问答(QA)系统时可能非常强大。首先,当前可用的问答对作为训练数据集,用于开发文本的预训练模型。当用户输入问题时,预训练模型被用来将问题编码为一个密集的向量表示,在现有的答案库的密集向量表示中进行相似度匹配,快速帮助用户找到问题的答案。其次,许多问答系统,如 Quora、StackOverflow 和知乎,已经拥有大量先前提出的问题。当用户想要提问时,问答系统首先判断该问题是否已经有人提过。如果是,用户将被建议点击并查看类似问题的答案,而不是重复提问。这也涉及相似度匹配,通常称为去重或释义识别。
与此同时,在现实世界中,许多尚未探索的应用可以通过神经信息检索来实现。例如,如果你使用文本搜索未标记的音乐,就需要将文本和音乐的表示映射到相同的向量空间中。然后,视频中场景出现的时间可以通过图像来定位。相反,当用户观看视频时,可以检索视频中出现的产品并完成购买。或者,可以对专业数据进行深度学习检索,比如源代码检索、DNA 序列检索等!
本章学习的新术语
-
传统搜索:主要应用于文本检索。通过查询和文档中一组标记出现的加权分数来衡量相似度。
-
索引:将文件转换为可以快速搜索并跳过连续扫描所有文件的过程。
-
搜索:对用户查询和文档库中索引的文档进行相似度分数计算,并返回前 k 个匹配结果的过程。
-
向量空间模型:一种以数字形式表示文档的方法。VSM 的维度是所有文档中不同标记的数量。每个维度的值是每个术语的权重。
-
TF-IDF:词频-逆文档频率(TF-IDF)是一种算法,旨在反映某个单词在一组需要被索引的文档中的重要性。
-
机器学习:这是一种技术,通过使计算机学习数据分布并获得新的经验和知识,教计算机以类似人类的方式做出决策。
-
深度神经网络:深度神经网络(DNN)是一种具有多个输入层和输出层之间的层次结构的人工神经网络(ANN),旨在预测、分类或学习数据的紧凑表示(密集向量)。
-
神经搜索:与符号搜索不同,神经搜索利用 DNN 生成的表示(密集向量),并衡量查询向量与文档向量之间的相似度,根据特定的度量标准返回前 k 个匹配项。
概述
在这一章中,你已经了解了搜索和匹配的关键概念。我们还介绍了传统搜索与基于神经网络的搜索之间的区别。我们看到,神经网络可以帮助我们解决传统搜索无法解决的问题,例如跨模态或多模态搜索。
神经网络能够将不同类型的信息编码到一个共同的嵌入空间,并使不同的信息片段具有可比性,这也是深度学习和神经网络有潜力更好地满足用户信息需求的原因。
我们介绍了几种基于深度学习搜索系统的可能应用,例如,时尚或旅游领域的基于视觉的产品搜索,或基于文本的问答搜索和文本去重。还有更多应用有待探索!
你现在应该理解神经搜索背后的核心思想:神经搜索能够将任何类型的数据编码成富有表现力的表示,也就是嵌入。创建高质量的嵌入对深度学习驱动的搜索应用至关重要,因为它决定了最终搜索结果的质量。
在下一章中,我们将介绍嵌入的基础知识,例如如何将信息编码为嵌入,如何衡量不同嵌入之间的距离,以及我们可以用来编码不同数据模态的一些最重要的模型。
第二章:引入向量表示基础
向量和向量表示是神经搜索的核心,因为向量的质量决定了搜索结果的质量。在本章中,您将了解机器学习(ML)中向量的概念。您将看到常见的搜索算法,它们使用向量表示,以及它们的优缺点。
本章将涵盖以下主要主题:
-
在机器学习中引入向量
-
衡量两个向量之间的相似度
-
局部与分布式表示
本章结束时,您将对如何将每种类型的数据表示为向量以及为什么这一概念是神经搜索核心有一个扎实的理解。
技术要求
本章有以下技术要求:
-
一台最低配置为 4GB RAM 的笔记本电脑(推荐 8GB 或更多)
-
安装有版本 3.7、3.8 或 3.9 的 Python,并且运行在类似 Unix 的操作系统上,如 macOS 或 Ubuntu
在机器学习中引入向量
文本是记录人类知识的重要手段。截止 2021 年 6 月,主流搜索引擎如 Google 和 Bing 索引的网页数量已达到 24 亿,并且大多数信息以文本形式存储。如何存储这些文本信息,甚至如何从存储库中高效地检索所需信息,已成为信息检索中的一个重要问题。解决这些问题的第一步在于以计算机能够理解的格式表示文本。
随着基于网络的信息日益多样化,除了文本,网页还包含大量的多媒体信息,如图片、音乐和视频文件。这些文件在形式和内容上比文本更加多样,满足了用户从不同角度的需求。如何表示和检索这些类型的信息,以及如何从互联网上海量的数据中准确找到用户所需的多模态信息,也是搜索引擎设计中需要考虑的一个重要因素。为了实现这一点,我们需要将每个文档表示为其向量表示。
向量是具有大小和方向的对象,就像你在学校学到的那样。如果我们能够使用向量表示我们的数据,那么我们就能够通过角度来衡量两条信息的相似性。更具体地说,我们可以这样说:
-
两条信息被表示为向量
-
两个向量都从原点[0, 0]开始(假设为二维)
-
两个向量形成一个角度
图 2.1说明了两个向量之间的关系及其角度:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/nrl-sch/img/Figure_2.1_B17488.jpg
图 2.1 – 向量表示示例
vec1和vec2有相同的方向,但长度不同。vec2和vec3长度相同,但方向相反。如果角度为 0 度,则两个向量完全相同;如果向量的角度为 180 度,则两个向量完全相反。我们可以通过角度来衡量两个向量的相似度:角度越小,两个向量越接近。这个方法也称为余弦相似度。
实际上,余弦相似度是最常用的相似度度量之一,用于确定两个向量的相似度,但并不是唯一的方法。我们将在测量两个向量之间的相似度章节中更详细地探讨它以及其他相似度度量。在此之前,你可能会想知道我们如何将原始信息(如文本或音频)编码成数值向量。在这一部分,我们将实现这一目标。
我们将通过使用Python和NumPy 库深入探讨余弦相似度的细节。此外,我们还将介绍其他相似度度量,并在以下小节中简要讨论局部和分布式向量表示。
使用向量表示数据
让我们从最常见的场景开始:表示文本信息。
首先,我们来定义特征向量的概念。假设我们想为维基百科(英语版)构建一个搜索系统。截至 2022 年 7 月,英语维基百科有超过 650 万篇文章,包含超过 40 亿个单词(18 万个唯一单词)。我们可以将这些唯一单词称为维基百科的词汇表。
这篇维基百科文集中的每篇文章都应该被编码为一系列数值,这被称为特征向量。为此,我们可以将 650 万篇文章编码为 650 万个索引的特征向量,然后使用相似度度量(如余弦相似度)来衡量编码后的查询特征向量与索引的 650 万个特征向量之间的相似度。
编码过程涉及找到一个最优函数,将原始数据转换为其向量表示。那么我们如何实现这一目标呢?
我们首先从最简单的方法开始:使用位向量。位向量意味着向量中的所有值将是 0 或 1,具体取决于单词的出现情况。假设我们遍历词汇表中的所有唯一单词;如果该单词出现在特定文档d中,那么我们将该唯一单词位置的值设置为 1,否则为 0。
让我们回顾一下在第一章,神经网络与神经搜索一节中介绍的内容,假设我们有两个文档:
-
doc1
= Jina 是一个神经搜索框架 -
doc2
= Jina 是用名为深度学习的前沿技术构建的
-
如果我们将这两个文档合并,我们将得到如下的词汇表(唯一单词):
vocab = 'Jina is a neural search framework built with cutting age technology called deep learning'
-
假设前述变量
vocab
是我们的词汇表,在经过预处理(分词和词干提取)后,我们得到如下的令牌列表:vocab = ['a', 'age', 'built', 'call', 'cut', 'deep', 'framework', 'is', 'jina', 'learn', 'neural', 'search', 'technolog', 'with']
请注意,上述词汇表已经按字母顺序排序。
-
要将
doc1
编码成向量表示,我们遍历doc1
中的所有单词,并创建位向量:import nltk doc1 = 'Jina is a neural search framework' doc2 = 'Jina is built with cutting age technology called deep learning' def tokenize_and_stem(doc1, doc2): tokens = nltk.word_tokenize(doc1 + doc2) stemmer = nltk.stem.porter.PorterStemmer() stemmed_tokens = [stemmer.stem(token) for token in tokens] return sorted(stemmed_tokens) def encode(vocab, doc): encoded = [0] * len(vocab) for idx, token in enumerate(vocab): if token in doc: encoded[idx] = 1 # token present in doc return encoded if __name__ == '__main__': tokens = tokenize_and_stem(doc1, doc2) encoded_doc1 = encode(vocab=tokens, doc=doc1) print(encoded_doc1)
上述代码块将doc1
编码为位向量。在encode
函数中,我们首先创建了一个充满 0 的 Python 列表;该列表的长度与词汇表的大小相同。然后,我们遍历词汇表,检查文档中单词的出现情况。如果单词存在,我们就将编码向量的值设置为1
。最后,我们得到如下结果:
>>> [1, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 1, 1, 0, 0]
通过这种方式,我们成功地将文档编码成了其位向量表示。
重要提示
你可能注意到,在前面的示例中,位向量的输出包含大量的 0 值。在实际应用中,随着词汇表的大小不断增大,向量的维度变得非常高,编码文档中的大多数维度将被填充为 0,这对于存储和检索非常低效。这也叫做稀疏向量。一些 Python 库,例如 SciPy,具有强大的稀疏向量支持。一些深度学习库,如 TensorFlow 和 PyTorch,内置了稀疏张量支持。同时,Jina 原始数据类型支持 SciPy、TensorFlow 和 PyTorch 的稀疏表示。
到目前为止,我们已经学到,向量是一个既有大小又有方向的对象。我们还成功地使用位向量创建了两个文本文档的最简单形式的向量表示。现在,了解这两个文档的相似度将会非常有趣。我们将在下一节中深入学习这一点。
衡量两个向量之间的相似性
衡量两个向量之间的相似性在神经搜索系统中至关重要。一旦所有文档都被索引为向量表示,给定用户查询时,我们对查询执行相同的编码过程。最后,我们将编码后的查询向量与所有编码后的文档向量进行比较,以找出最相似的文档。
我们可以继续上一节中的示例,尝试衡量doc1
和doc2
之间的相似性。首先,我们需要运行脚本两次,分别编码doc1
和doc2
:
doc1 = 'Jina is a neural search framework'
doc2 = 'Jina is built with cutting age technology called deep learning'
然后,我们可以为它们生成向量表示:
encoded_doc1 = [1, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 1, 1, 0, 0]
encoded_doc2 = [1, 1, 1, 1, 1, 1, 0, 1, 1, 0, 1, 0, 0, 1, 1]
由于编码结果的维度始终与词汇表的大小相同,因此问题已转化为如何衡量两个向量表示之间的相似度:encoded_doc1
和encoded_doc2
。
重要提示
上述的encoded_doc1
和encoded_doc2
的向量表示具有 15 维。我们很容易将 1D 数据可视化为一个点,2D 数据为一条线,3D 数据也能可视化,但对于高维数据就不容易了。实际上,我们可能会进行降维,将高维向量降至 3D 或 2D 以便绘制它们。最常见的技术叫做t-sne。
想象两个编码后的向量表示可以绘制在二维向量空间中。我们可以如下方式可视化encoded_doc1
和encoded_doc2
:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/nrl-sch/img/Figure_2.2_B17488.jpg
图 2.2 – 余弦相似度
然后,我们可以通过它们的角度来衡量encoded_doc1
和encoded_doc2
之间的相似度,具体来说,就是余弦相似度。余弦定理告诉我们:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/nrl-sch/img/Formula_2.1_B17488.jpg
假设p表示为[x1, y1],q表示为[x2, y2];那么,前述公式可以改写为:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/nrl-sch/img/Formula_2.2_B17488.jpg
由于余弦相似度也适用于高维数据,上述公式可以再次改写为:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/nrl-sch/img/Formula_2.3_B17488.jpg
基于公式,我们可以计算encoded_doc1
和encoded_doc2
之间的余弦相似度,如下所示:
import math
def compute_cosine_sim(encoded_doc1, encoded_doc2):
numerator = sum([i * j for i, j in zip(encoded_doc1,
encoded_doc2)])
denominator_1 = math.sqrt(sum([i * i for i in
encoded_doc1]))
denominator_2 = math.sqrt(sum([i * i for i in
encoded_doc2]))
return numerator/(denominator_1 * denominator_2)
如果我们打印出encoded_doc1
和encoded_doc2
之间的相似度结果,得到如下:
>>> 0.40451991747794525
在这里,我们得到了两个编码向量之间的余弦相似度,约等于0.405。在搜索系统中,当用户提交查询时,我们将查询编码为其向量表示。我们已经将所有文档(我们想要搜索的文档)各自离线编码为向量表示。通过这种方式,我们可以计算查询向量与所有文档向量之间的相似度得分,进而生成最终的排名列表。
重要提示
上述代码演示了如何计算余弦相似度。该代码没有经过优化。实际上,你应该始终使用 NumPy 对向量(NumPy 数组)执行向量化操作,以获得更高的性能。
超越余弦相似度的度量
尽管余弦相似度是最常用的相似度/距离度量,但也有一些其他常用的度量方法。我们将在本节中介绍另外两种常用的距离函数,即欧几里得距离和曼哈顿距离。
重要提示
相似度度量衡量两个文档之间的相似程度。另一方面,距离度量衡量两个文档之间的差异。在搜索场景中,你总是希望获得与查询最匹配的前 k 个结果。因此,如果你使用的是相似度度量,始终从排名列表中获取前 k 项。另一方面,在使用距离度量时,始终从排名列表中获取最后 k 项,或者反转排名列表并获取前 k 项。
与计算余弦相似度(通过测量两个向量之间的角度)不同,欧几里得距离通过计算两个数据点之间的线段长度来衡量相似度。例如,考虑下图中的两个二维文档:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/nrl-sch/img/Figure_2.3_B17488.jpg
图 2.3 – 欧几里得距离
如在图 2.3中所示,之前我们使用了vec1
和vec2
之间的角度来计算它们的余弦相似度。而对于欧几里得距离,我们的计算方式有所不同。vec1
和vec2
的起点都是 0,终点分别为p
和q
。现在,这两个向量之间的距离为:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/nrl-sch/img/Formula_2.4_B17488.jpg
另一个距离度量叫做p
(位于(p1, p2))和q
(位于(q1, q2)),这两个向量之间的距离为:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/nrl-sch/img/Formula_2.5_B17488.jpg
如在图 2.4中所示,超平面已被分割成小块。每个块的宽度为 1,高度也为 1。p
和q
之间的距离变为 4:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/nrl-sch/img/Figure_2.4_B17488.jpg
图 2.4 – 曼哈顿距离
还有许多其他的距离度量,比如汉明距离和角度距离,但我们不会在这里深入探讨它们,因为余弦和欧几里得距离是最常用的相似度度量。这也引出了一个有趣的问题:我应该使用哪种距离/相似度度量来使向量相似度计算更有效?答案是视情况而定。
首先,这取决于你的任务和数据。但一般来说,在执行文本检索及相关任务时,余弦相似度将是你的首选。它已经广泛应用于诸如衡量两篇编码文本文档之间的相似度等任务。
深度学习模型也可能会影响你选择的相似度/距离度量。例如,如果你应用了度量学习技术来微调你的机器学习模型,以优化特定的相似度度量,那么你可能会坚持使用你优化过的相似度度量。更具体地说,注意以下几点:
-
你可以使用孪生神经网络,基于欧几里得距离优化输入对(查询和文档),从而得到一个新的模型。
-
在提取特征时,最好使用欧几里得距离作为相似度度量。
-
如果你的向量具有极高的维度,可能需要考虑将欧几里得距离转换为曼哈顿距离,因为它能够提供更强的鲁棒性。
重要提示
在实际应用中,不同的人工神经网络(ANN)库可能会使用不同的距离度量作为默认配置。例如,Annoy 库鼓励用户使用角度距离来计算向量距离,这是一种欧几里得距离的变种。关于 ANN 的更多内容将在第三章《系统设计与工程挑战》中介绍。
将数据编码为向量表示的方法有多种。一般来说,这可以分为两种形式:局部表示和分布式表示。上述数据编码为向量表示的方法可以归类为局部表示,因为它将每个唯一单词视为一个维度。
在接下来的章节中,我们将介绍最重要的局部表示和分布式表示算法。
局部和分布式表示
在本节中,我们将深入探讨 局部表示 和 分布式表示。我们将介绍这两种不同表示的特点,并列出用于编码不同数据模态的最广泛使用的局部和全局表示。
局部向量表示
作为经典的文本表示方法,局部表示仅利用向量中表示某个单词的 不相交维度。不相交维度意味着向量的每个维度代表一个单一的标记。
当只使用一个维度时,这被称为 one-hot 表示。One-hot 意味着单词被表示为一个长向量,向量的维度是要表示的单词总数。大多数维度为 0,而只有一个维度的值为 1。不同的单词具有维度为 1 的值并不会重复。如果这种表示方式以稀疏的方式存储,即根据维度为 1 为每个单词分配一个数字 ID,那么它将更加简洁。
One-hot 还意味着在假设所有单词彼此独立的情况下,不需要额外的学习过程。这保持了表示单词的向量之间的正交性,因此具有很强的区分能力。通过最大熵、支持向量机、条件随机场和其他机器学习算法,one-hot 表示在文本分类、文本聚类和词性标注等多个方面具有显著效果。对于关键字匹配主导的特定检索应用场景,基于 one-hot 表示的词袋模型仍然是主流选择。
然而,one-hot 表示忽略了单词之间的语义关系。此外,当表示一个包含 N 个单词的词汇表 V 时,one-hot 表示需要构建一个维度为 N 的向量。这导致了参数爆炸和数据稀疏性的问题。
另一种局部表示方法被称为 词袋模型,或 比特向量表示,我们在本章前面已经介绍过。
作为一种向量表示方法,词袋模型将文本视为单词集合,仅记录单词是否出现在文本中,而忽略文本中的单词顺序和语法。基于单词的 one-hot 表示,词袋模型将文本表示为由 0 和 1 组成的向量,并为位操作提供了很好的支持。该方法可以在检索场景中进行常规查询处理。由于它仍然保持了单词之间的正交性,因此在文本分类等任务中表现良好。现在,我们将使用一种名为 scikit-learn 的 Python 机器学习框架 构建一个位向量表示:
from sklearn.feature_extraction.text import CountVectorizer
corpus = [
'Jina is a neural search framework for neural search',
'Jina is built with cutting edge technology called deep
learning',
]
vectorizer = CountVectorizer(binary=True)
X = vectorizer.fit_transform(corpus)
print(X.toarray())
输出如下所示:
>>> array([[0, 0, 0, 0, 0, 1, 1, 1, 1, 0, 1, 1, 0, 0],
[1, 1, 1, 1, 1, 0, 0, 1, 1, 1, 0, 0, 1, 1]])
基于词袋(位向量)模型,词袋表示算法考虑了词语在文本中的出现频率。因此,不同单词对应的词袋编码特征值不再是 0 或 1,而是该单词在文本中出现的频率。一般来说,单词在文本中出现得越频繁,它对文本的贡献就越重要。为了获得表示,你只需要在前面的实现中将 binary=False
设置即可:
from sklearn.feature_extraction.text import CountVectorizer
corpus = [
'Jina is a neural search framework for neural search',
'Jina is built with cutting edge technology called deep
learning',
]
vectorizer = CountVectorizer(binary=False)
X = vectorizer.fit_transform(corpus)
print(X.toarray())
从以下输出中,你可以发现已考虑了词频。例如,由于 neural
这个词出现了两次,编码结果的值增加了 1
:
>>> array([[0, 0, 0, 0, 0, 1, 1, 1, 1, 0, 2, 2, 0, 0],
[1, 1, 1, 1, 1, 0, 0, 1, 1, 1, 0, 0, 1, 1]])
最后但同样重要的是,我们有一个最常用的局部表示方法,称为词频-逆文档频率(tf-idf)表示法。
tf-idf 是一种常见的信息检索和数据挖掘表示方法。词 i 在文本 j 中的 TF-IDF 值如下所示:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/nrl-sch/img/Formula_2.6_B17488.jpg
这里,ni, j 表示词 i 在文本 j 中出现的频率;|d_j | 表示文本中词的总数;|D| 表示语料库中的词汇数量,而 https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/nrl-sch/img/Formula_2.7_B17488.png 表示包含词 i 的文档数量。通过考虑词语在文本中的出现频率,TF-IDF 算法通过计算词的 IDF 来进一步考虑词在整个文本中的普遍重要性。也就是说,词语在文本中出现得越频繁,它在其他部分的文本中出现的频率就越低。这表明,词语对当前文本的重要性越大,它的权重就越高。此算法的 scikit-learn 实现如下:
from sklearn.feature_extraction.text import TfidfVectorizer
corpus = [
'Jina is a neural search framework for neural search',
'Jina is built with cutting edge technology called deep
learning',
]
vectorizer = TfidfVectorizer()
X = vectorizer.fit_transform(corpus)
print(X.toarray())
Tf-Idf 加权编码结果如下所示:
>>> array([[0., 0., 0., 0., 0., 0.30134034, 0.30134034, 0.21440614, 0.21440614, 0.,0.60268068, 0.60268068, 0., 0. ],
[0.33310232, 0.33310232, 0.33310232, 0.33310232, 0.33310232, 0., 0\. , 0.23700504, 0.23700504, 0.33310232, 0., 0., 0.33310232, 0.33310232]])
到目前为止,我们已经介绍了局部向量表示。接下来的章节,我们将深入探讨分布式向量表示、它为何需要以及常用的算法。
分布式向量表示
尽管文本的局部表示在文本分类和数据召回等任务中具有优势,但它存在数据稀疏性的问题。
具体而言,如果语料库有 100,000 个不同的标记,向量的维度将变为 100,000。假设我们有一个包含 200 个标记的文档。为了表示这个文档,向量中只有 200 个条目不为零。由于词汇表的标记没有出现在文档中,所有其他维度仍然得到零值。
这给数据存储和检索带来了巨大挑战。因此,一个自然的想法是获取文本的低维稠密向量,称为文本的 分布式表示。
在本节中,首先描述了单模态(如文本、图像和音频)的分布式表示;然后,介绍了多模态联合学习的分布式表示方法。我们还会选择性地介绍几种重要的表示学习算法,基于数据模态,即文本、图像、音频和跨模态表示学习。让我们先看看基于文本的算法。
在下表中,我们列出了用于编码不同数据模态的一些选定模型:
模型 | 模态 | 领域 | 应用 |
---|---|---|---|
BERT | 文本 | 密集检索 | 文本到文本搜索,问答 |
VGGNet | 图像 | 基于内容的图像检索 | 图像到图像搜索 |
ResNet | 图像 | 基于内容的图像检索 | 图像到图像搜索 |
Wave2Vec | 声音 | 基于内容的音频检索 | 音频到音频搜索 |
CLIP | 文本和图像 | 跨模态检索 | 文本到图像搜索 |
表 1.1 – 可用作不同输入模态编码器的选定模型
基于文本的算法
因为文本携带着重要信息,文本的分布式表示在搜索引擎中起着重要作用,并且在学术界和工业界得到广泛研究。考虑到我们拥有大量未标记的文本数据(如维基百科),在涉及基于文本的算法时,我们通常对大语料库进行无监督预训练。
基于类似单词具有类似上下文的信念,Mikolov 等人提出了 word2vec 算法,其中包括两个简单的神经网络模型用于学习:Continuous-Bag-of-Words (CBOW) 和 skip-gram (SG) 模型。
具体来说,CBOW 模型用于推导单词的表示,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/nrl-sch/img/WT.png,使用其周围的词,例如单词前两个和后两个。例如,给定维基百科文档中的一个句子,我们随机屏蔽该句子内的一个标记。我们尝试通过其周围的标记预测屏蔽的标记:
doc1 = 'Jina is a neural [MASK] framework'
在前述文档中,我们屏蔽了标记搜索,并试图预测被屏蔽的标记u的向量表示,通过周围标记的表示之和,https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/nrl-sch/img/Formula_2.8_B17488.png,并计算u与https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/nrl-sch/img/Formula_2.8_B174881.png之间的点积。在训练时,我们将选择一个标记y,以最大化点积:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/nrl-sch/img/Formula_2.10_B17488.jpg
重要说明
需要注意的是,在训练之前,我们将随机初始化向量值。
另一方面,SG 试图从当前标记预测周围标记的向量表示。CBOW 和 SG 之间的区别在图 2.5中有所说明:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/nrl-sch/img/Figure_2.5_B17488.jpg
图 2.5 – CBOW 和 SG(来源:Efficient estimation of word representations in vector space)
这两个模型都用于通过最大化整个语料库上的目标函数的对数似然来学习词表示。为了减轻由输出层 softmax 函数造成的大量计算负担,Mikolov 等人创建了两种优化方法,即分层 softmax和负采样。传统的深度神经网络将每个下一个词预测为一个分类任务。这个网络必须有许多输出类作为唯一的标记。例如,在预测英文维基百科中的下一个词时,类的数量超过 160,000。这是极其低效的。分层 softmax 和负采样用分层层替换了平坦的 softmax 层,该层以词作为叶子,并通过分类判断两个标记是否为真对(语义上相似)或假对(独立标记),将多类分类问题转换为二元分类问题。这大大提高了词嵌入的预测速度。
在预训练之后,我们可以给这个 word2vec 模型一个标记,得到所谓的词嵌入。这个词嵌入由一个向量表示。一些预训练的word2vec
向量被表示为 300 维的词向量。维度远远小于我们之前介绍的稀疏向量空间模型。因此,我们也将这些向量称为密集向量。
在诸如word2vec和GloVe的算法中,一个词的表示向量在训练后通常保持不变,并且可以应用于下游应用,例如命名实体识别。
然而,相同单词在不同上下文中的语义可能会有所不同,甚至可能有显著不同的含义。2019 年,谷歌宣布了双向编码器表示的转换器(BERT),这是一个基于转换器的神经网络,用于自然语言处理。BERT 使用转换器网络表示文本,并通过掩蔽语言模型获取文本的上下文信息。此外,BERT 还使用下一个句子预测(NSP)来增强文本关系的表示,并在许多文本表示任务中取得了良好的效果。
类似于 word2vec,BERT 已经在 Wikipedia 数据集和其他一些数据集(如 BookCorpus)上进行了预训练。它们组成了一个超过 30 亿标记的词汇表。BERT 还在不同的语言(如英语和德语)以及多语言数据集上进行了训练。
BERT 可以通过预训练和微调范式在大量语料库上进行训练,而不需要任何标注。在预测过程中,待预测的文本再次被输入到训练好的网络中,以获得包含上下文信息的动态向量表示。在训练过程中,BERT 根据一定的比例替换原始文本中的词语,并利用训练模型做出正确的预测。BERT 还会添加一些特殊字符,如[CLS]
和[SEP]
,以帮助模型正确判断两个输入句子是否是连续的。再一次,我们有doc1
和doc2
,如下所示;doc2
是doc1
的下一个句子:
-
doc1
= Jina 是一个神经搜索框架 -
doc2
= Jina 是基于深度学习的前沿技术构建的
在预训练期间,我们将两个文档视为两个句子,并表示文档如下:
doc = '[CLS] Jina is a neural [MASK] framework [SEP] Jina is built with cutting edge technology called deep learning'.
在文本输入后,BERT 的输入由三种类型的向量组成,即[MASK]
标记。根据 BERT 论文的作者(BERT: Pre-training of Deep Bidirectional Transformers for Language Understanding)的描述,大约 15%的标记会被掩盖(Jacob 等人)。
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/nrl-sch/img/Figure_2.6_B17488.jpg
图 2.6 – BERT 输入表示。每个输入嵌入是三个嵌入的总和
在预训练阶段,由于我们使用 NSP 作为训练目标,第二个句子中大约 50%是“真实”的下一个句子,而另外 50%的句子是从语料库中随机选择的,这意味着它们不是紧接着第一个句子的句子。这帮助我们提供正负样本对以改善模型的预训练。BERT 的目标函数是正确预测被掩盖的词汇以及判断下一个句子是否是正确的句子。
如前所述,预训练 BERT 后,我们可以针对特定任务微调该模型。BERT 论文的作者在不同的下游任务上微调了预训练模型,如问答和语言理解,并在 11 个下游数据集上取得了最先进的性能。
基于视觉的算法
随着互联网的快速发展,互联网中的信息载体日益多样化,图像提供了各种视觉特征。许多研究人员期望将图像编码为向量进行表示。最广泛使用的图像分析模型架构被称为卷积神经网络(CNN)。
一个 CNN 接收形状为(Height
, Width
, Num_Channels
)的图像作为输入(通常是一个三通道的 RGB 图像或一个单通道的灰度图像)。图像将通过多个卷积层之一进行处理。这一过程使用一个内核(或滤波器)并在输入上滑动,图像变成一个抽象的激活图。
在多个卷积操作之一之后,激活图的输出将通过一个池化层。池化层对特征图中的一小群神经元应用最大值或均值操作,这被称为最大池化和均值池化。池化层可以显著降低特征图的维度,将其转换为更紧凑的表示。
通常,多个卷积层和一个池化层的组合被称为卷积块。例如,三个卷积层加一个池化层构成一个卷积块。在卷积块的末尾,我们通常会应用展平操作,以获取图像数据的向量表示。
在下面的截图中,我们展示了一个精美设计的 CNN 模型,名为 VGG16。如图所示,它由五个卷积块组成,每个卷积块包含两到三个卷积层和一个最大池化层。在这些块的末尾,激活图被展平为一个特征向量:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/nrl-sch/img/Figure_2.7_B17488.jpg
图 2.7 – VGG16 由五个卷积块组成,并通过 softmax 分类头生成分类结果
值得一提的是,VGG16 是为 ImageNet 分类设计的。因此,在激活图展平为特征向量后,它会连接到两个全连接层(稠密层)和一个 softmax 分类头。
在实际应用中,我们将移除 softmax 分类头,将该分类模型转换为嵌入模型。给定一张输入图像,这个嵌入模型会输出一个展平的特征图,而不是图像中物体的分类结果。此外,与 VGGNet 相比,ResNet 是一个更复杂但更常用的视觉特征提取器。
除了文本和图像,音频搜索也是一种重要的搜索应用,例如,用于识别短片中的音乐或搜索具有相似风格的音乐。在下一节中,我们将列出该方向上的几种深度学习模型。
基于声学的算法
给定一系列声学输入,深度学习驱动的算法在声学领域产生了巨大影响。例如,它们已广泛应用于文本转语音任务。给定一段音乐作为查询,寻找相似(或相同)的音乐在音乐应用中是常见的需求。
一种最新的基于音频数据训练的最先进算法叫做wave2vec 2.0。与 BERT 类似,wave2vec 是以无监督方式训练的。通过一段音频数据,在预训练过程中,wave2vec 会遮掩音频输入的部分内容,并尝试学习这些被遮掩的部分。
wave2vec 与 BERT 之间的主要区别在于音频是一种连续信号,没有明确的标记切分为 tokens。Wave2vec 将每个 25 毫秒长的音频视为一个基本单元,并将每个 25 毫秒的基本单元输入到 CNN 模型中,以学习单元级的特征表示。然后,部分输入被遮掩,并输入到类似 BERT 的 Transformer 模型中,以预测被遮掩的输出。训练目标是最小化原始音频与预测音频之间的对比损失。
值得一提的是,对比(自监督)预训练也广泛应用于文本或图像的表征学习。例如,给定一张图像作为输入,我们可以稍微增强图像内容,生成同一图像的两个视图:尽管这两个视图看起来不同,但我们知道它们来自同一张图像。
这种自监督对比学习已经广泛应用于表征学习:即在给定任何类型输入的情况下,学习出一个好的特征向量。当将模型应用于特定领域时,仍然建议提供一些标注数据,以通过额外的标签对模型进行微调。
超越文本、视觉和声学的算法
在现实生活中,存在多种信息载体。除了文本、图像和语音,视频、动作甚至蛋白质都包含了丰富的信息。因此,许多尝试已经被做出来以获取向量表示。DeepMind 的研究人员开发了AlphaFold和AlphaFold2算法。基于传统特征,如氨基酸序列,AlphaFold 算法可以用于获取蛋白质表达向量并计算其在空间中的三维结构,极大地提高了蛋白质分析领域的实验效率。
此外,在 2021 年,GitHub 推出了 Copilot,帮助程序员自动完成代码。在此之前,OpenAI 开发了Codex模型,它能够将自然语言转换为代码。基于 Codex 的模型架构,GitHub 利用其开源的 TB 级代码库大规模训练该模型,并完成了 Copilot 模型,帮助程序员编写新代码。Copilot 还支持多种编程语言的生成和补全,如 Python、JavaScript 和 Go。在搜索框中,如果我们想进行代码搜索或评估两段代码的相似性,可以使用 Codex 模型将源代码编码为向量表示。
前述操作大多专注于文本、图像或音频的单独编码,因此编码后的向量空间可能会有显著差异。为了将不同模态的信息映射到相同的向量空间,OpenAI 的研究人员提出了 CLIP 模型,该模型能够有效地将图像映射到文本。具体来说,CLIP 包括一个图像编码器和一个文本编码器。在输入图像和多个文本后,CLIP 同时对它们进行编码,并希望找到与每张图像最匹配的文本。通过在大规模数据集上训练,CLIP 能够获得图像和文本的优秀表示,并将它们映射到相同的向量空间。
总结
本章介绍了向量表示的方法,这是搜索引擎运作中的一个重要步骤。
首先,我们介绍了向量表示的重要性及其使用方法,接着讨论了局部和分布式向量表示算法。在分布式向量表示方面,介绍了文本、图像和音频的常用表示算法,并总结了其他模态和多模态的常见表示方法。因此,我们发现,与稀疏向量相比,密集向量表示方法通常包含相对丰富的上下文信息。
在构建可扩展的神经搜索系统时,创建一个能够将原始文档编码为高质量嵌入的编码器非常重要。这个编码过程需要快速执行,以减少索引时间。在搜索时,必须应用相同的编码过程,并在合理的时间内找到排名靠前的文档。在下一章中,我们将利用本章的思想,构建创建可扩展神经搜索系统的思维导图。
进一步阅读
-
Devlin, Jacob 等人。“Bert:用于语言理解的深度双向变换器的预训练。”arXiv 预印本 arXiv:1810.04805(2018 年)。
-
Simonyan, Karen 和 Andrew Zisserman。“非常深的卷积网络用于大规模图像识别。”arXiv 预印本 arXiv:1409.1556(2014 年)。
-
He, Kaiming 等人。“深度残差学习用于图像识别。”IEEE 计算机视觉与模式识别会议论文集,2016 年。
-
Schneider, Steffen 等人. “wav2vec: 用于语音识别的无监督预训练。” arXiv 预印本 arXiv:1904.05862 (2019)。
-
Radford, Alec 等人. “从自然语言监督中学习可转移的视觉模型。” 国际机器学习会议。PMLR, 2021。
第三章:系统设计与工程挑战
理解机器学习(ML)和深度学习的概念是至关重要的,但如果你希望构建一个由人工智能(AI)和深度学习驱动的高效搜索解决方案,你还需要具备生产工程能力。有效地部署机器学习模型需要的软件工程和 DevOps 等技术领域中的能力。这些能力被称为MLOps。特别是对于一个需要高可用性和低延迟的搜索系统,这一点尤为重要。
在本章中,你将学习设计搜索系统的基础知识。你将理解索引和查询等核心概念,并了解如何使用它们来保存和检索信息。
在本章中,我们将特别讨论以下主要内容:
-
索引与查询
-
评估神经搜索系统
-
构建神经搜索系统中的工程挑战
本章结束时,你将全面了解将神经搜索投入生产时可能遇到的能力和困难。你将能够评估何时使用神经搜索以及哪种方法最适合你自己的搜索系统。
技术要求
本章的技术要求如下:
-
配备至少 4 GB RAM 的笔记本电脑;建议使用 8 GB。
-
在类 Unix 操作系统(如 macOS 或 Ubuntu)上安装了版本为 3.7、3.8 或 3.9 的 Python。
本章的代码文件可以在github.com/PacktPublishing/Neural-Search-From-Prototype-to-Production-with-Jina
找到。
引入索引和查询
在本节中,您将通过两个重要的高层任务来构建搜索系统:
-
索引:这是收集、解析和存储数据的过程,以便于快速和准确的信息检索。这包括添加、更新、删除和读取待索引的文档。
-
查询:查询是解析、匹配和排名用户查询,并将相关信息返回给用户的过程。
在神经搜索系统中,索引和查询都由一系列任务组成。我们将深入探讨索引和查询的各个组成部分。
索引
索引是搜索系统中的一个重要过程。它构成了核心功能,因为它有助于高效地检索信息。索引将文档缩减为其中包含的有用信息。它将术语映射到包含相关信息的相应文档中。在搜索系统中找到相关文档的过程,本质上与查阅字典的过程相同,其中索引帮助你有效地查找单词。
在介绍详细内容之前,我们先通过以下问题来了解我们的当前状况:
-
索引管道的主要组件有哪些?
-
什么内容可以被索引?
-
我们如何进行增量索引,以及如何实现快速索引?
如果你不知道这些问题的答案,不用担心,继续阅读!
在索引管道中,我们通常有三个主要组件:
-
对于
text/plain
,我们可能需要一个分词器和词干提取器,如在第一章中介绍的那样,神经网络与神经搜索。如果我们想要索引一种image/jpeg
格式的图像,我们可能需要一个组件来调整大小或将输入图像转换为神经网络所期望的格式。这高度依赖于你的任务和输入数据。 -
编码器:在神经搜索系统中,编码器与神经网络是相同的。这个神经网络将你的预处理输入作为向量表示(嵌入)。在这一步之后,每个由文本、图像、视频甚至 DNA 信息组成的原始文档应该被表示为一组数值向量。
-
索引器(用于存储):在索引阶段,索引器,也称为存储索引器,将从编码器产生的向量存储到存储设备中,如内存或数据库。这包括关系型数据库(如 PostgresSQL)、NoSQL(如 MongoDB),甚至更好的是,向量数据库,如 Elasticsearch。
需要注意的是,每个索引任务是独立的,它可以从不同的角度有所不同。例如,如果你在电子商务环境中构建一个多模态搜索引擎,你的目标是创建一个能够同时接受文本和图像作为查询,找到最相关产品的搜索系统。在这种情况下,你的索引可能有两个路径:
-
文本信息应该经过预处理,并使用基于文本的预处理器和编码器进行编码。
-
同样,图像数据应该经过预处理,并使用基于图像的预处理器和编码器进行编码。
你可能会想知道,什么可以被索引。任何东西,只要你有编码器并且数据可以被编码。一些常见的可索引数据类型包括文本、图像、视频和音频。正如我们在前面的章节中讨论的,你可以对源代码进行编码,以构建源代码搜索系统,或者对基因信息进行编码,构建一个围绕基因信息的搜索系统。下面的图示说明了一个索引管道:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/nrl-sch/img/Figure_3.1_B17488.jpg
图 3.1 – 一个简单的索引管道将文档作为输入,并应用预处理和编码。最后,将编码后的特征保存到存储中
现在,我们进入一个重要的话题:增量索引。首先,让我们讨论一下什么是增量索引。
理解增量索引
增量索引是任何搜索系统中的一个关键特性。考虑到我们要索引的数据集合可能每天都会发生显著变化,我们无法每次在数据发生小的变化时都重新索引整个数据集合。
通常,有两种常见的做法来执行索引任务,如下所示:
-
实时索引:对于任何发送到集合中的数据,索引器会立即将文档添加到索引中。
-
定时索引:对于任何发送到集合中的数据,调度程序触发索引任务并执行索引工作。
上述做法各有优缺点。在实时索引中,用户可以立即获取新添加的文档(如果匹配的话),但也会消耗更多的系统资源,并可能引入数据不一致。然而,在定时索引的情况下,用户不能实时访问新添加的结果,但它的错误率较低,且更易于管理。
你选择的索引策略取决于你的任务。如果任务是时间敏感的,最好使用实时索引。否则,设置一个定时任务并在某个时间增量索引数据会是一个不错的选择。
提速索引
在进行神经搜索索引任务时,另一个关键问题是索引的速度。传统的符号搜索系统只处理文本数据,而神经搜索系统的输入可以是三维的(高度 * 宽度 * 颜色通道),如 RGB 图像,或者是四维的(帧 * 高度 * 宽度 * 颜色通道),如视频。这类数据可以通过不同的模态进行索引,这会显著放慢数据预处理和编码过程。
通常,我们可以使用几种策略来提升索引速度。以下是其中一些策略:
-
预处理器:对某些数据集应用特定的预处理操作可以显著提高索引速度。例如,如果你要索引高分辨率图像,最好将它们缩小尺寸。
-
GPU 推理:在神经搜索系统中,编码占用了大部分的索引时间。更具体地说,给定一个预处理文档,使用深度神经网络将文档编码成向量是需要时间的。通过利用 GPU 实例进行编码,这一过程可以得到极大的改善。由于 GPU 具有更高的带宽内存和 L1 缓存,GPU 非常适合用于机器学习任务。
-
横向扩展:在单台机器上索引大量数据会使得过程变得缓慢,但如果将数据分布到多台机器上并进行并行索引,速度可以大大提高。例如,下面的图示展示了为管道分配更多编码器:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/nrl-sch/img/Figure_3.2_B17488.jpg
图 3.2 – 使用三台编码器并行利用 GPU 推理快速进行索引
值得一提的是,如果您来自文本检索背景,构建符号搜索的倒排索引时也需要考虑索引压缩的问题。在神经搜索系统中,情况不再完全相同:
-
首先,编码器将文档作为输入并将文档内容编码成一个 N 维向量(嵌入)。因此,我们可以将编码器本身视为一个压缩函数。
-
第二,压缩密集向量最终会牺牲向量的质量。通常,较高维度的向量能够带来更好的搜索结果,因为它们能更好地表示被编码的文档。
在实践中,我们需要在维度和内存使用之间找到一个平衡点,以便将所有向量加载到内存中进行大规模相似性搜索。在接下来的部分中,我们将深入探讨查询部分,这将使您能够了解如何进行大规模相似性搜索。
查询
当涉及到查询流水线时,它与索引流水线有很多组件重叠,但有一些修改和额外的组件,如排序器。在此阶段,流水线的输入是单个用户查询。典型查询任务有四个主要组件:
-
预处理器:此组件类似于索引流水线中的预处理器。它将查询文档作为输入,并对输入应用与索引流水线相同的预处理器。
-
编码器:编码器将预处理后的查询文档作为输入,并产生向量作为输出。需要注意的是,在跨模态搜索系统中,您的索引编码器可能与查询步骤中的编码器不同。这将在第七章,深入探讨 Jina 的高级使用案例中解释。
-
索引器:这个索引器,更好地称为搜索索引器,接收编码器生成的向量作为输入,并在所有索引文档上进行大规模相似性搜索。这被称为近似最近邻(ANN)搜索。我们将在接下来的部分详细解释这个概念。
-
排序器:排序器接收查询向量和每个集合项的相似度分数,按降序生成一个排名列表,并将结果返回给用户。
索引和查询的一个主要区别在于索引(在大多数情况下)是离线任务,而查询是在线任务。具体来说,当我们引导一个神经搜索系统并创建一个查询时,系统会返回一个空列表,因为此时尚未索引任何内容。在将搜索系统暴露给用户之前,我们应该预先索引数据集中的所有文档。这种索引是在离线状态下进行的。
另一方面,在查询任务中,用户发送一个查询给系统,并期望立即获得匹配结果。所有的预处理、编码、索引搜索和排名应该在等待时间内完成。因此,这是一个在线任务。
重要提示
实时索引可以视为一个在线任务。
与索引不同,在查询时,每个用户将单个文档作为查询发送给系统。预处理和编码只需要很短的时间。另一方面,在索引存储中查找相似项成为一个关键的工程挑战,这会影响神经搜索系统的性能。为什么会这样?
例如,您已经预先索引了十亿个文档,在查询时,用户将一个查询发送给系统,文档随后被预处理并编码为向量(嵌入)。给定查询向量,您现在需要在 100 万个向量中找到前 N 个相似向量。如何实现这一点?通过计算向量之间的距离逐个进行相似性搜索可能需要很长时间。与其如此,我们执行 ANN 搜索。
重要提示
当我们谈论 ANN 搜索时,我们是在考虑百万级/十亿级的搜索。如果你想构建一个玩具示例并搜索几百或几千个文档,普通的线性扫描就足够快了。在生产环境中,请按照下一节将介绍的选择策略进行操作。
ANN 搜索
如其名称所示,ANN 搜索是不同因素之间的权衡:准确性、运行时间和内存消耗。与暴力搜索相比,它确保了运行时间用户可以接受,同时牺牲了某些程度的精度/召回率。它能够达到多快的速度?给定十亿个 100 维向量,它可以适配到内存为 32 GB 的服务器上,响应时间为 10 毫秒。在深入了解 ANN 搜索的细节之前,让我们先看一下下面的图示:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/nrl-sch/img/Figure_3.3_B17488.jpg
Figure 3.3 – ANN cheat sheet (source: Billion-scale Approximate Nearest Neighbor Search, Yusuke Matsui)
上面的图示说明了如何根据您的搜索系统选择 ANN 库。在图示中,N表示您StorageIndexer 中文档的数量。不同的 N 数量可以通过不同的 ANN 搜索库进行优化,例如 FAISS
(github.com/facebookresearch/faiss
) 或 NMSLIB
(github.com/nmslib/nmslib
)。与此同时,由于您很可能是 Python 用户,Annoy
库 (github.com/spotify/annoy
) 提供了一个用户友好的接口,并具有合理的性能,足以应对百万级向量搜索。
上述库是基于不同算法实现的,其中最流行的包括 KD-Tree、局部敏感哈希 (LSH) 和 产品量化 (PQ)。
KD-Tree 遵循一个迭代过程来构建树。为了简化可视化,我们假设数据只包含两个特征,f1(x 轴)和 f2(y 轴),其形态如下:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/nrl-sch/img/Figure_3.4_B17488.jpg
图 3.4 – KD-Tree,样本数据集以进行索引
KD-Tree 的构建从选择一个实际的特征并为该特征设置阈值开始。为了说明这个概念,我们从手动选择 f1 和特征阈值 0.5 开始。为此,我们得到如下的边界:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/nrl-sch/img/Figure_3.5_B17488.jpg
图 3.5 – KD-Tree 构建迭代 1
从 图 3.5 中可以看出,特征空间已经通过我们首次选择的 f1 阈值 0.5 分为两部分。那么它是如何反映在树中的呢?在构建索引时,我们实际上是在创建一棵二叉搜索树。我们首次选择 f1 阈值 0.5 成为根节点。给定每个数据点,如果 f1 大于 0.5,它将被放置在节点的右侧。否则,如 图 3.6 所示,我们将其放置在节点的左侧:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/nrl-sch/img/Figure_3.6_B17488.jpg
图 3.6 – KD-Tree 构建迭代 2
我们从前面的树继续。在第二次迭代中,我们定义规则为:给定 f1 > 0.5,选择 f2 阈值为 0.5。如前面的图所示,我们现在根据新规则再次拆分特征空间,这也体现在我们的树上:我们在图中创建了一个新节点 f2-0.5(none 节点仅用于可视化;我们尚未创建此节点)。如下图所示:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/nrl-sch/img/Figure_3.7_B17488.jpg
图 3.7 – KD-Tree 构建迭代 N(最后一次迭代)
如 图 3.7 所示,整个特征空间已被分割成六个区间。与之前相比,我们新增了三个节点,其中包括两个叶节点:
-
之前的 none 被一个实际的节点 f2-0.65 替代;这个节点基于阈值 0.65 对 f2 的空间进行了拆分,且仅在 f1<0.5 时发生。
-
当 f2<0.65 时,我们进一步通过阈值 0.2 对 f1 进行拆分。
-
当 f2>0.65 时,我们进一步通过阈值 0.3 对 f1 进行拆分。
为此,我们的树有三个叶节点,每个叶节点可以构造两个区间(小于/大于阈值),总共有六个区间。此外,每个数据点可以放入其中一个区间。然后,我们完成了 KD 树的构建。需要注意的是,构建 KD 树可能并不简单,因为你需要考虑一些超参数,例如如何设置阈值或应该创建多少个区间(或者停止标准)。在实践中,并没有固定的规则。通常,可以使用均值或中位数来设置阈值。区间的数量可能会高度依赖于结果的评估和微调。
在查询时,给定一个用户查询,它可以被放置到特征空间中的某个区间。我们可以计算查询与区间内所有项目之间的距离,将它们作为最近邻候选项。我们还需要计算查询与其他区间之间的最小距离。如果查询向量与其他区间的距离大于查询向量与最近邻候选项之间的距离,我们可以通过修剪树的叶节点来忽略该区间内的所有数据点。否则,我们也将该区间内的数据点视为最近邻候选项。
通过构建 KD 树,我们不再需要计算查询向量与每个文档之间的相似性。只有某些数量的区间应该被视为候选项。因此,搜索时间可以大大减少。
实际上,KD 树受到维度灾难的困扰。将其应用于高维数据时会很棘手,因为每个特征我们总是创建多个阈值,因此需要搜索的区间非常多。局部敏感哈希(LSH)可能是一个很好的替代算法。
LSH 的基本思想是相似的文档共享相同的哈希码,并且它的设计旨在最大化冲突。更具体地说:给定一组向量,我们希望有一个哈希函数能够将相似的文档编码到相同的哈希桶中。然后,我们只需在桶内查找相似的向量(无需扫描所有数据)。
让我们从 LSH 索引构建开始。在索引时,我们首先需要创建随机的超平面(平面)来将特征空间分割成区间。
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/nrl-sch/img/Figure_3.8_B17488.jpg
图 3.8 – 使用随机超平面构建 LSH 索引
在图 3.8中,我们创建了六个超平面。每个超平面能够将我们的特征空间分割成两个区间,可以是左/右或上/下,这些区间可以用二进制代码(或符号)表示:0 或 1。这被称为一个区间的索引。
让我们尝试获取右下角区间的索引(该区间内有四个点)。该区间位于以下几个点:
-
在plane1的右侧,所以位置 0 的符号为 1。
-
plane2的右侧,因此位置 1 的符号为 1
-
plane3的右侧,因此位置 2 的符号为 1
-
plane4的右侧,因此位置 3 的符号为 1
-
plane5的底部,因此位置 4 的符号为 0
-
plane6的底部,因此位置 5 的符号为 0
因此,我们可以将右下角的桶表示为 111100。如果我们迭代这个过程,并为每个桶注释一个桶索引,我们将得到一个哈希映射。哈希映射的键是桶索引,而哈希映射的值是桶内数据点的 ID。
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/nrl-sch/img/Figure_3.9_B17488.jpg
图 3.9 – 带桶索引的 LSH 索引构建
在 LSH 的顶部进行搜索是简单的。直观地说,给定一个查询,你可以直接搜索其所在桶中的所有数据点,或者你可以搜索其相邻的桶。
如何搜索其相邻的桶?看一下图 3.9。桶索引以二进制代码表示;相邻的桶与其自身的桶索引相比只有 1 位差异。显然,你可以将桶索引之间的差异视为一个超参数,并通过更多相邻的桶进行搜索。例如,如果你将超参数设置为 2,意味着你允许 LSH 搜索 2 个相邻的桶。
为了更好地理解这一点,我们将查看 LSH 的 Annoy 实现,即带有随机投影的 LSH。给定由深度神经网络生成的向量列表,我们首先执行以下操作:
-
随机初始化一个超平面。
-
计算法线(垂直于超平面的向量)与各个向量的点积。对于每个向量,如果值为正,我们生成一个二进制代码 1,否则为 0。
-
我们生成 N 个超平面并迭代这个过程 N 次。最后,每个向量将由一组 0 和 1 组成的二进制向量表示。
-
我们将每个二进制代码视为一个桶,并将所有具有相同二进制代码的文档保存在同一个桶中。
以下代码块展示了一个带有随机投影的 LSH 简单实现:
pip install numpy
pip install spacy
spacy download en_core_web_md
我们将两段句子预处理为桶:
from collections import defaultdict
import numpy as np
import spacy
n_hyperplanes = 10
nlp = spacy.load('en_core_web_md')
# process 2 sentences using the model
docs = [
nlp('What a nice day today!'),
nlp('Hi how are you'),
]
# Get the mean vector for the entire sentence
assert docs[0].vector.shape == (300,)
# Random initialize 10 hyperplanes, dimension identical to embedding shape
hyperplanes = np.random.uniform(-10, 10, (n_hyperplanes, docs[0].vector.shape[0]))
def encode(doc, hyperplanes):
code = np.dot(doc.vector, hyperplanes.T) # dot product vector with norm vector
binary_code = np.where(code > 0, 1, 0)
return binary_code
def create_buckets(docs, hyperplanes):
buckets = defaultdict()
for doc in docs:
binary_code = encode(doc, hyperplanes)
binary_code = ''.join(map(str, binary_code))
buckets[binary_code] = doc.text
return buckets
if __name__ == '__main__':
buckets = create_buckets(docs, hyperplanes)
print(buckets)
通过这种方式,我们将数百万个文档映射到多个桶中。在搜索时,我们使用相同的超平面来编码搜索文档,获取二进制代码,并在同一桶内查找相似的文档。
在 Annoy 实现中,搜索速度依赖于两个参数:
-
search_k
:该参数表示你希望从索引中返回的前k
个元素。 -
N_trees
:该参数表示你想从中搜索的桶的数量。
显然,搜索运行时非常依赖于这两个参数,用户需要根据自己的使用场景来微调这些参数。
另一个流行的 ANN 搜索算法是 PQ。在我们深入讨论 PQ 之前,理解什么是 量化 非常重要。假设你有一百万个文档需要索引,并且你为所有文档创建了 100 个 中心点。量化器是一个能够将向量映射到中心点的函数。你可能会觉得这个想法很熟悉。实际上,K-means 算法就是一个能够帮助你生成这种中心点的函数。如果你不记得了,K-means 的工作原理如下:
-
随机初始化
k
个中心点。 -
将每个向量分配给其最近的中心点。每个中心点代表一个聚类。
-
基于所有分配的均值计算新的中心点,直到收敛。
一旦 K-means 收敛,我们就会得到 K 个聚类,以便为所有需要索引的向量生成聚类。对于每个需要索引的文档,我们创建文档 ID 和聚类索引之间的映射。在搜索时,我们计算查询向量与中心点的距离,并得到最接近的聚类,然后在这些聚类内找到最接近的向量。
这种量化算法具有相对较好的压缩比。你不需要线性扫描所有向量来找到最接近的向量;你只需要扫描由量化器生成的某些聚类。另一方面,如果中心点的数量较少,搜索时的召回率可能会非常低。这是因为有太多边界情况无法正确分配到正确的聚类中。另外,如果我们将中心点的数量简化为一个很大的数字,我们的 K-means 操作将需要很长时间才能收敛。这将成为离线索引和在线搜索时间的瓶颈。
PQ 的基本思想是将高维向量分割成子向量,如下所示的步骤所示:
-
我们将每个向量分割成 m 个子向量。
-
对于每个子向量,我们应用量化。为此,我们为每个子向量分配一个唯一的聚类 ID(该子向量与其中心点的最近聚类)。
-
对于整个向量,我们有一个聚类 ID 列表,可以作为整个向量的词典。词典的维度与子向量的数量相同。
下图展示了 PQ 算法:给定一个向量,我们将其切分成低维的子向量并应用量化。为此,每个量化后的子向量都会得到一个代码:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/nrl-sch/img/Figure_3.10_B17488.jpg
图 3.10 – 产品量化
在搜索时,我们再次将高维查询向量分割成子向量,并生成一个词典(桶)。我们计算每个子向量与集合内每个向量的余弦相似度,并将子向量级别的相似度得分求和。然后根据向量级别的余弦相似度对最终结果进行排序。
实际上,FAISS 有一个高性能的 PQ(以及超越 PQ)实现。更多信息,请参考文档(github.com/facebookresearch/faiss/wiki
)。
现在我们已经学习了神经搜索的两个基本任务——索引和查询。在接下来的部分,我们将介绍神经搜索系统评估,使你的神经搜索系统更加完整并具备生产就绪的能力。
评估神经搜索系统
评估神经搜索系统的有效性在你设定了基准之后是至关重要的。通过监控评估指标,你可以立即了解系统的表现如何。通过深入分析查询,你还可以进行失败分析,并学习如何改进系统。
在本节中,我们将简要概述最常用的评估指标。如果你想对这个主题有更详细的数学理解,我们强烈建议你阅读《信息检索中的评估》(nlp.stanford.edu/IR-book/pdf/08eval.pdf
)。
通常,考虑到搜索任务的差异,我们可以将搜索评估分为两类:
-
未排名结果的评估:这些指标广泛用于一些检索或分类任务,包括精度、召回率和 F 值。
-
排名结果的评估:这些指标主要用于典型的搜索应用,因为结果是有序的(已排名)。
首先,让我们从精度、召回率和 F 值开始:
- 在典型的搜索场景中,精度的定义如下:
精度 = (检索到的相关文档数)/(检索到的文档总数)
这个思路很简单。假设我们的搜索系统返回了 10 篇文档,其中 7 篇是相关的,那么精度就是 0.7。
需要注意的是,在评估过程中,我们关注的是前k
个检索结果。就像前面提到的例子,我们评估的是前 10 个结果中的相关文档。这通常被称为精度,例如Precision@10。它同样适用于我们将在本节稍后介绍的其他指标,比如 Recall@10、mAP@10 和 nDCG@10。
- 类似地,召回率的定义如下:
召回率 = (检索到的相关文档数)/(相关文档总数)
例如,如果我们在系统中搜索cat
,并且我们知道系统中有 100 张与猫相关的图片已经被索引,且返回了 80 张图片,那么召回率就是 0.8。它是一个衡量搜索系统执行完整性的评估指标。
重要提示
召回率是评估 ANN 算法性能最重要的评估指标,因为它描述了在所有查询中,找到的真实最近邻的比例。
重要提示
准确率可以作为典型机器学习任务的一个良好指标,例如分类。但是对于搜索任务来说情况不同,因为大多数搜索任务的数据集都是偏斜的/不平衡的。
作为搜索系统设计师,您可能已经注意到这两个数值是相互权衡的:随着 K 值的增加,我们通常会期望精确度降低,但召回率提高,反之亦然。您可以决定优化精确度或召回率,或者将这两个数值作为一个评估指标进行优化,即 F1-Score。
- F1-Score 定义如下:
F1-Score = (2 * 精确度 * 召回率) / (精确度 + 召回率)
它是精确度和召回率的加权调和平均值。实际上,更高的召回率通常会伴随较低的精确度。假设你正在评估一个排序列表,并且你关心的是前 10 个项目被检索出来(并且在整个集合中有 10 个相关文档):
文档 | 标签 | 精确度 | 召回率 |
---|---|---|---|
Doc1 | 相关 | 1/1 | 1/10 |
Doc2 | 相关 | 2/2 | 2/10 |
Doc3 | 无关 | 2/3 | 2/10 |
Doc4 | 无关 | 2/4 | 2/10 |
Doc5 | 相关 | 3/5 | 3/10 |
Doc6 | 无关 | 3/6 | 3/10 |
Doc7 | 无关 | 3/7 | 3/10 |
Doc8 | 相关 | 4/8 | 4/10 |
Doc9 | 无关 | 4/9 | 4/10 |
Doc10 | 无关 | 4/10 | 4/10 |
表 3.1 – 前 10 个文档的精确度召回率
表 3.1 显示了给定二进制标签下,不同层次的精确度和召回率。
熟悉了精确度后,我们可以继续计算 平均精度(AP)。这个指标将帮助我们更好地理解搜索系统对查询结果排序的能力。
具体来说,给定前面排名的列表,aP@10
如下:
aP@10 = (1/1 + 2/2 + 3/5 + 4/8) / 10 = 0.31
请注意,在计算 aP 时,只考虑相关文档的精确度。
现在,aP 已经针对一个特定的用户查询进行了计算。然而,为了更稳健地评估搜索系统的性能,我们希望将多个用户查询作为测试集进行评估。这就是 aP@k
,然后我们将所有查询的 aP 平均,得到 mAP 得分。
mAP 是在给定有序排名列表的情况下,最重要的搜索系统评估指标之一。要对搜索系统进行 mAP 评估,通常需要遵循以下步骤:
-
编写一个查询列表,以便很好地代表用户的信息需求。查询的数量取决于您的情况,例如 50、100 或 200。
-
如果您的文档已经有标签来指示相关性程度,直接使用这些标签来计算每个查询的 平均精度(aP)。如果文档中没有与每个查询相关的内容,我们需要专家注释或池化来访问相关程度。
-
通过对查询列表计算 mAP,方法是取平均值的 aP。正如之前提到的,如果你没有针对排名文档的相关性评估,一种常用的技术叫做池化。这要求我们设置多个搜索系统(例如三个)进行测试。对于每个查询,我们收集这三个搜索系统返回的前 K 个文档。然后,由人工标注员评判所有 3 * K 个文档的相关性程度。之后,我们将所有不在这个池中的文档视为不相关,而池中的所有文档视为相关。然后,搜索结果可以在这些池的基础上进行评估。
此时,尽管 mAP 在评估一个排序列表,但精度的定义性质仍然忽略了搜索任务的一些本质:精度是基于二元标签进行评估的,要么是相关,要么是无关。它并没有反映查询与文档之间的相关性。归一化折扣累积增益(nDCG)则用于评估搜索系统在相关性程度上的表现。
nDCG 可以对每个文档进行多个等级的评分,例如不相关、相关或高度相关。在这种情况下,mAP 不再适用。
例如,给定三种相关性程度(不相关、相关和高度相关),这些相关性差异可以表示为用户通过获取每个文档所能获得的信息增益。对于高度相关的文档,可以赋予增益值3,相关的文档可以赋予增益值1,不相关的文档增益值设为0。然后,如果一个高度相关的文档排名高于不相关的文档,用户可以获得更多的增益,这被称为累积增益(CG)。下表展示了基于搜索系统返回的前 10 个排名文档,我们得到的信息增益:
文档 | 标签 | 增益 |
---|---|---|
Doc1 | 高度相关 | 3 |
Doc2 | 相关 | 1 |
Doc3 | 不相关 | 0 |
Doc4 | 不相关 | 0 |
Doc5 | 相关 | 1 |
Doc6 | 不相关 | 0 |
Doc7 | 不相关 | 0 |
Doc8 | 高度相关 | 3 |
Doc9 | 不相关 | 0 |
Doc10 | 不相关 | 0 |
表 3.2 – 带有信息增益的前 10 个文档
在前面的表格中,系统返回了前 10 个排名文档给用户。根据相关性程度,我们将 3 作为高度相关文档的增益,1 作为相关文档的增益,0 作为不相关文档的增益。CG 是前 10 个文档的所有增益之和,如下所示:
CG@10 = 3 + 1 + 1 + 3 = 8
但考虑一下搜索引擎的本质:用户从上到下浏览排序列表。所以,从本质上讲,排名靠前的文档应该比排名靠后的文档有更多的增益,这样我们的搜索系统才会尽量把高度相关的文档排在更高的位置。因此,在实践中,我们会根据文档的位置来惩罚增益。请看下面的示例:
文档 | 标签 | 增益 | 折扣增益 |
---|---|---|---|
Doc1 | 高度相关 | 3 | 3 |
Doc2 | 相关 | 1 | 1/log2 |
Doc3 | 无关 | 0 | 0 |
Doc4 | 无关 | 0 | 0 |
Doc5 | 相关 | 1 | 1/log5 |
Doc6 | 无关 | 0 | 0 |
Doc7 | 无关 | 0 | 0 |
Doc8 | 高度相关 | 3 | 3/log8 |
Doc9 | 无关 | 0 | 0 |
Doc10 | 无关 | 0 | 0 |
表 3.3 – 前 10 名文档的增益和折扣增益
在前面的表格中,根据文档的增益和其排名位置,我们通过将增益除以一个因子来稍微惩罚增益。在这种情况下,它是排名位置的对数。增益的总和称为折扣累积增益(DCG):
DCG@10 = 3 + 1/log2 + 1/log5 + 3/log8 = 6.51
在我们开始计算 nDCG 之前,理解理想 DCG 的概念非常重要。它简单来说就是我们能够实现的最佳排名结果。在前面的例子中,如果我们看前 10 个位置,理想情况下,排名列表应该包含所有高度相关的文档,并且增益为 3。因此,iDCG 应如下所示:
iDCG@10 = 3 + 3/log2 + 3/log3 + 3/log4 + 3/log5 + 3/log6 + 3/log7 + 3/log8 + 3/log9 + 3/log10 = 21.41
最终,最终的 nDCG 如下所示:
nDCG = DCG/iDCG
在我们前面的例子中,得到了以下结果:
nDCG@10 = 6.51/21.41 = 0.304
值得一提的是,尽管 nDCG 非常适合评估一个能够反映相关度的搜索系统,相关性本身却会受到诸如搜索上下文和用户偏好等不同因素的影响。在现实世界的场景中进行这种评估并非易事。在接下来的章节中,我们将深入探讨这些挑战的细节,并简要介绍如何解决它们。
构建神经搜索系统的工程挑战
现在,您应该注意到,神经搜索系统的最重要构建模块是编码器和索引器。编码帖子质量直接影响最终的搜索结果,而索引器的速度决定了神经搜索系统的可扩展性。
与此同时,这仍然不足以让您的神经搜索系统准备好投入使用。许多其他问题也需要考虑。第一个问题是:您的编码器(神经模型)是否与您的数据分布一致?对于刚接触神经搜索系统的新人,使用例如在 ImageNet 上训练的 ResNet 等预训练深度神经网络,快速搭建一个搜索系统是非常简单的。然而,如果您的目标是基于特定领域构建神经搜索系统,例如时尚产品图片搜索,那么它的效果可能不会令人满意。
在我们真正开始创建编码器并设置搜索系统之前,有一个重要的主题是将迁移学习应用于您的数据集并评估匹配结果。这意味着采用预训练的深度学习模型,例如 ResNet,去掉其头部层,冻结预训练模型的权重,并在模型末尾附加一个新的嵌入层,然后在您的领域中的新数据集上进行训练。这可能会极大地提升搜索性能。
除此之外,在一些基于视觉的搜索系统中,仅依赖编码器可能不足够。例如,许多基于视觉的搜索系统严重依赖物体检测器。在将完整图像发送到编码器之前,应先将其发送到物体检测器,提取图像的有意义部分(并去除背景噪声)。这可能会提高嵌入质量。同时,一些基于视觉的分类模型也可以用作硬过滤器来丰富搜索上下文。例如,如果您正在构建一个神经搜索系统,允许人们在以图像作为查询的情况下搜索相似的汽车,那么预训练的品牌分类器可能会很有用。更具体地说,您可以预训练一种汽车品牌分类器来识别不同的汽车品牌基于图像,并将识别应用于索引和搜索管道。一旦基于视觉的搜索完成,您可以将识别出的品牌作为硬过滤器来筛选出其他品牌的汽车。
同样地,对于基于文本的搜索,当用户提供关键词作为查询时,直接应用基于嵌入的相似性搜索可能不足够。例如,您可以在索引和查询管道中创建一个命名实体识别(NER)模块来丰富元数据。
对于像谷歌、必应或百度这样的基于网络的搜索引擎,自动完成查询非常普遍。在您的索引和搜索管道中添加一个由深度神经网络驱动的关键词提取组件,以提供类似的用户体验也许会非常有趣。
总结起来,要构建一个可用于生产的神经搜索系统,设计一个功能完备的索引和查询管道是非常具有挑战性的,考虑到搜索是如此复杂的任务。设计这样的系统本身就很具有挑战性,更不用说工程基础设施了。幸运的是,Jina 已经可以帮助您解决大部分最具挑战性的任务。
摘要
在本章中,我们讨论了构建神经搜索系统的基本任务,即索引和查询管道。我们深入研究了它们,并介绍了编码和索引等最具挑战性的部分。
你应该具备索引和查询的基础构建模块的基本知识,比如预处理、编码和索引。你还应该注意到,搜索结果的质量很大程度上取决于编码器,而神经搜索系统的可扩展性则很大程度上取决于索引器和索引器背后的最流行算法。
由于你需要构建一个可投入生产的搜索系统,你会意识到单纯依赖基础构建模块是不够的。因为搜索系统的实现非常复杂,总是需要设计并添加你自己的构建模块到索引和查询管道中,以提供更好的搜索结果。
在下一章,我们将开始介绍 Jina,这个帮助你构建神经搜索系统的最流行框架。你会发现,Jina 已经为你解决了最困难的问题,它能让你作为神经搜索系统工程师/科学家的工作变得更加轻松。
第二部分:Jina 基础介绍
在这一部分,你将了解 Jina 是什么以及它的基本组件。你将理解它的架构,以及它如何被用来在云端开发深度学习搜索。接下来的章节包括以下内容:
-
第四章,学习 Jina 的基础
-
第五章,多模态搜索
第四章:学习 Jina 基础
在上一章中,我们学习了神经搜索,现在我们可以开始思考如何使用它,以及我们需要采取的步骤来实现自己的搜索引擎。然而,正如我们在之前的章节中看到的,为了实现一个端到端的搜索解决方案,需要花费时间和精力收集所有必要的资源。Jina 在这方面可以提供帮助,因为它会处理许多必要的任务,让你可以专注于实现的设计。
在这一章中,你将理解 Jina 的核心概念:文档、文档数组、执行器和 流程。你将详细了解它们,并理解它们的整体设计及其如何相互连接。
我们将涵盖以下主要主题:
-
探索 Jina
-
文档
-
文档数组
-
执行器
-
流程
在本章结束时,你将对 Jina 中的惯用法有一个扎实的理解,了解它们是什么,以及如何使用它们。你将利用这些知识,稍后构建适用于任何类型模态的搜索引擎。
技术要求
本章有以下技术要求:
-
一台至少拥有 4 GB RAM 的笔记本电脑,理想情况下是 8 GB
-
在类似 Unix 的操作系统上安装 Python 3.7、3.8 或 3.9,例如 macOS 或 Ubuntu
探索 Jina
Jina 是一个框架,帮助你在云端使用最先进的模型构建深度学习搜索系统。Jina 是一种基础设施,让你可以仅专注于你感兴趣的领域。这样,你就不需要参与构建搜索引擎的每一个方面。这包括从预处理数据到在需要时启动微服务等。神经搜索的另一个好处是你可以搜索任何类型的数据。以下是一些使用不同数据类型进行搜索的示例:
-
图像到图像搜索
-
文本到图像搜索
-
问答搜索
-
音频搜索
构建你自己的搜索引擎可能非常耗时,因此 Jina 的核心目标之一是减少从零开始构建一个搜索引擎所需的时间。Jina 采用分层设计,让你只专注于你需要的特定部分,其余的基础设施会在后台处理。例如,你可以直接使用预训练的 机器学习(ML)模型,而不必自己构建它们。
由于我们生活在云计算时代,利用去中心化工作所能提供的力量是有意义的,因此将你的解决方案设计为在云上分布式运行是非常有用的,分片、异步化和 REST 等功能已经完全集成并可以开箱即用。
正如我们之前所说,Jina 帮助你减少构建搜索引擎所需时间和精力的另一种方式是使用最新的最先进的 ML 模型。你可以通过以下两种方式之一来利用这一点:
-
使用 Jina 的即插即用模型之一
-
在有特定应用场景时,从头开始开发你自己的模型,或者如果 Jina Hub 上还没有合适的模型可用。
使用这些选项,你可以选择使用预定义模型,或者如果你的需求没有被涵盖,可以实现你自己的模型。
正如你所想,这意味着后台有很多组件在工作。你学得越多,掌握应用程序的能力就越强,但首先,你需要理解 Jina 的基本组件,我们将在接下来的章节中讨论这些组件:
-
文档
-
文档数组
-
执行器
-
流程
文档
在 Jina 中,文档是你可以使用的最基本的数据类型。它们是你希望使用的数据,并且可以用于索引和/或查询。你可以用任何你需要的数据类型来创建文档,例如文本、GIF、PDF 文件、3D 网格等等。
我们将使用文档来进行索引和查询,但由于文档可以是任何类型和大小,因此我们可能需要在使用之前将其拆分。
作为类比,想象一个文档就像一块巧克力。巧克力有几种类型:白巧克力、黑巧克力、牛奶巧克力等等。同样,文档也可以有多种类型,例如音频、文本、视频、3D 网格等等。另外,如果我们有一块大巧克力,可能会在吃之前把它分成小块。类似地,如果我们有一个大的文档,在建立索引之前,应该把它分成小块。
这就是文档在 Python 代码中的表现形式:
from jina import Document
document = Document()
正如你所看到的,创建文档所需要做的就是从 Jina 导入它,并像处理任何其他 Python 对象一样创建它。这是一个非常基础的示例,但在实际应用中,你会遇到更复杂的情况,因此我们需要添加一些属性,接下来我们将看到这些属性。
文档属性
每个文档可以有不同的属性,这些属性属于四个主要类别:
-
内容:这是指文档的实际内容。例如,文本或其嵌入向量。
-
元数据:这是关于文档本身的信息。例如,它的 ID 以及是否有标签。
-
递归:这告诉我们文档是如何被分割的。例如,它的匹配项,或是否被分成了若干块。
-
相关性:这是指文档的相关性,例如其分数。
这些类别由各种属性组成,具体列在下面的表格中:
类别 | 属性 |
---|---|
内容属性 | .buffer , .blob , .text , .uri , .content , .embedding |
元数据属性 | .id , .parent_id , .weight , .mime_type , .content_type , .tags , .modality |
递归属性 | .chunks , .matches , .granularity , .adjacency |
相关性属性 | .score , .evaluations |
表 4.1 – 文档类别及其属性
我们稍后会更详细地了解这些属性,但首先,先来看看如何设置它们。
设置和取消设置属性
表 4.1 中的属性是我们可以与文档一起使用的可能属性。假设我们希望文档包含文本 hello world
。我们可以通过设置其 text
属性来做到这一点:
from jina import Document
document = Document()
document.text = 'hello world'
如果我们想取消设置它,可以如下操作:
document.pop('text')
在许多实际场景中,我们需要处理多个属性,且也可以同时取消设置其中的多个属性:
document.pop('text', 'id', 'mime_type')
访问标签中的嵌套属性
在 Jina 中,每个文档包含标签,这些标签持有类似映射的结构,可以映射任意值:
from jina import Document
document = Document(tags={'dimensions': {'height': 5.0, 'weight': 10.0}})
document.tags['dimensions'] # {'weight': 10.0, 'height': 5.0}
如果你想访问嵌套字段,可以使用属性名,并用符号 __
连接。例如,如果你想访问 weight
标签,可以这样做:
from jina import Document
document = Document(tags={'dimensions': {'height': 5.0, 'weight': 10.0}})
Document.tags__dimensions__weight #10
构建文档
要构建一个文档,你需要为其填充属性,让我们来看看这些属性。
内容属性
每个文档都需要包含一些关于它自身的信息,从原始二进制内容到文本信息。我们可以在下表中看到文档可以包含的详细信息:
属性 | 描述 |
---|---|
doc.buffer | 文档的原始二进制内容 |
doc.blob | 图像/音频/视频文档的 ndarray |
doc.text | 文档的文本信息 |
doc.uri | 文档 URI 可以是本地文件路径、以 http 或 https 开头的远程 URL,或者是数据 URI 方案 |
doc.content | 这可以是之前提到的任何属性(buffer、blob、text、uri) |
doc.embedding | 文档的嵌入 ndarray |
表 4.2 - 内容属性
有两种方法可以为文档分配 内容 类型。如果你确切知道内容类型,可以明确地使用 text
、blob
、buffer
或 uri
属性进行分配。如果你不知道类型,可以使用 .content
,它将根据内容的类型自动为文档分配一个类型。例如,请看以下示例:
from jina import Document
import numpy as np
document1 = Document(content='hello world')
document2 = Document(content=b'\f1')
document3 = Document(content=np.array([1, 2, 3]))
document4 = Document(content=
'https://static.jina.ai/logo/core/notext/light/logo.png')
在这个示例中,以下内容适用:
-
document1
将有一个text
字段。 -
document2
将有一个buffer
字段。 -
document3
将有一个blob
字段。 -
document4
将有一个uri
字段。
内容将自动分配给 text
、buffer
、blob
或 uri
其中之一。当未显式设置时,id
和 mime_type
属性会自动生成。这意味着你可以显式指定文档的 ID 和类型(mime_type
),否则它们将自动生成。
doc.content 的独占性
在 Jina 中,每个文档只能包含一种类型的内容:text
、buffer
、blob
或 uri
。如果先设置 text
,再设置 uri
,则会清空 text
字段。
在下图中,你可以看到内容可能具有的不同类型,以及每个文档只能有一种类型。
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/nrl-sch/img/Figure_4.1_B17488.jpg
图 4.1 – 文档中的可能内容类型
让我们看看如何在代码中设置文档的 content
属性:
doc = Document(text='hello world')
doc.uri = 'https://jina.ai/' #text field is cleared, doc
#has uri field now
assert not doc.text # True
doc = Document(content='https://jina.ai')
assert doc.uri == 'https://jina.ai' # True
assert not doc.text # True
doc.text = 'hello world' #uri field is cleared, doc has
#text field now
assert doc.content == 'hello world' # True
assert not doc.uri # True
你可以看到如何在文档中设置每种类型的属性,但如果你为一个文档分配不同的值,只有最后一个属性会有效。
doc.content 的转换
现在你已经看到了 Jina 中不同的可能属性,你可能会想,有时将一种类型的 doc.content
转换为另一种类型会很有用。例如,如果你有一个文档及其路径(uri
),但你需要它是文本格式的,你可以使用这些预制的转换函数轻松地切换内容类型:
doc.convert_buffer_to_blob()
doc.convert_blob_to_buffer()
doc.convert_uri_to_buffer()
doc.convert_buffer_to_uri()
doc.convert_text_to_uri()
doc.convert_uri_to_text()
doc.convert_image_buffer_to_blob()
doc.convert_image_blob_to_uri()
doc.convert_image_uri_to_blob()
doc.convert_image_datauri_to_blob()
如你所见,所有这些方法都将帮助你将数据从一种类型转换为另一种类型,但所有这些类型都需要转换为向量嵌入。让我们来看看嵌入到底是什么,以及为什么我们在神经搜索中使用它们。
设置嵌入属性
嵌入是文档的高维表示,是神经搜索中的关键元素。嵌入是数据的向量格式表示。这就是为什么神经搜索可以用于任何类型的数据,无论是图像、音频、文本等等。数据将被转换为向量(嵌入),而这些向量将被用于神经搜索。因此,类型并不重要,因为神经搜索最终只处理向量。
由于我们正在处理向量,因此使用已经建立的支持嵌入的库(如 NumPy)非常有用,这样你就可以例如将任何 NumPy ndarray
作为文档的嵌入,然后使用这些库提供的灵活性:
import numpy as np
from jina import Document
d1 = Document(embedding=np.array([1, 2, 3]))
d2 = Document(embedding=np.array([[1, 2, 3], [4, 5, 6]]))
元数据属性
除了内容属性外,你还可以有元数据属性:
属性 | 描述 |
---|---|
doc.tags | 用于存储文档的元信息 |
doc.id | 表示唯一文档 ID 的十六进制摘要 |
doc.parent_id | 表示文档父级 ID 的十六进制摘要 |
doc.weight | 文档的权重 |
doc.mime_type | 文档的 MIME 类型 |
doc.content_type | 文档的内容类型 |
doc.modality | 文档的模态标识符,例如图像、文本等 |
表 4.3 – 元数据属性
要创建文档,你可以在构造函数中分配多个属性,如下所示:
from jina import Document
document = Document(uri='https://jina.ai',
mime_type='text/plain',
granularity=1,
adjacency=3,
tags={'foo': 'bar'})
从字典或 JSON 字符串构建文档
还有一种选择是直接从 Python 字典或 JSON 字符串构建文档。如果你已经将文档信息存储在这些格式中,你可以方便地使用以下示例创建文档:
from jina import Document
import json
doc = {'id': 'hello123', 'content': 'world'}
doc1 = Document(d)
doc = json.dumps({'id': 'hello123', 'content': 'world'})
doc2 = Document(d)
解析未识别的字段
如果字典/JSON 字符串中的字段无法识别,它们将自动放入document.tags
字段。如下面的示例所示,foo
不是一个已定义的属性(表 4.3),因此它将被自动解析到 tags
字段中:
from jina import Document
doc1 = Document({'id': 'hello123', 'foo': 'bar'})
你可以使用field_resolver
将外部字段名映射到文档属性:
from jina import Document
doc1 = Document({'id': 'hello123', 'foo': 'bar'},
field_resolver={'foo': 'content'})
从其他 Documents 构建 Document
如果你想复制一个 Document,以下是几种方法:
-
浅拷贝:将一个 Document 对象赋值给另一个 Document 对象将创建一个浅拷贝:
from jina import Document doc = Document(content='hello, world!') doc1 = doc assert id(doc) == id(doc1) # True
-
copy=True
:doc1 = Document(doc, copy=True) assert id(doc) == id(doc1) # False
-
部分拷贝:你可以根据另一个源 Document 部分更新一个 Document:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/nrl-sch/img/Figure_4.2_B17488.jpg
你可以使用前三种方法中的任何一种来复制一个 Document。
从 JSON、CSV、ndarray 等文件类型构建 Document
jina.types.document.generators
模块允许你从常见的文件类型(如 JSON
、CSV
、ndarray
和文本文件)构建 Document。
以下函数将创建一个 Document 的生成器,每个 Document
对象对应原始格式中的一行/一列:
导入方法 | 描述 |
---|---|
from_ndjson() | 该函数从基于行的 JSON 文件中生成一个 Document。每一行是一个 Document 对象。 |
from_csv() | 该函数从 .csv 文件中生成一个 Document。每一行是一个 Document 对象。 |
from_files() | 该函数从 glob 文件中生成一个 Document。每个文件都是一个 Document 对象。 |
from_ndarray() | 该函数从 ndarray 中生成一个 Document。每一行(根据轴的不同)是一个 Document 对象。 |
from_lines() | 该函数从 JSON 和 CSV 的行中生成一个 Document。 |
表 4.4 – 构建 Document 的 Python 方法
使用生成器有时能节省内存,因为它不会一次性加载/构建所有 Document 对象。
现在你已经了解了什么是 Document,以及如何创建一个 Document。你可以通过填充个别内容来创建它,或者如果你已有 JSON 文件,也可以通过复制来创建。
DocumentArray
Jina 中的另一个强大概念是 insert
、delete
、construct
、traverse
和 sort
。DocumentArray 是 Executor 的一等公民,作为其输入和输出。我们将在下一节讨论 Executors,但现在,你可以将它们看作 Jina 处理文档的方式。
构建一个 DocumentArray
你可以像使用 Python 列表一样构建、删除、插入、排序和遍历 DocumentArray
。你可以通过不同的方式来创建这些:
from jina import DocumentArray, Document
documentarray = DocumentArray([Document(), Document()])
from jina import DocumentArray, Document
documentarray = DocumentArray((Document() for _ in range(10))
from jina import DocumentArray, Document
documentarray1 = DocumentArray((Document() for _ in range(10)))
documentarray2 = DocumentArray(da)
就像普通的 Document 一样,DocumentArray 也支持以下不同的方法:
类别 | 属性 |
---|---|
类似 Python 列表的接口 | __getitem__ 、__setitem__ 、__delitem__ 、__len__ 、insert 、append 、reverse 、extend 、__iadd__ 、__add__ 、__iter__ 、clear 、sort, shuffle 、sample |
持久化 | save 、load |
神经搜索操作 | match 、visualize |
高级获取器 | get_attributes 、get_attributes_with_docs 、traverse_flat 、traverse |
表 4.5 – DocumentArray 属性
通过 save()/load() 实现持久化
当然,也会有一些情况,你希望将 DocumentArray 中的元素保存以便进一步处理,你可以通过两种方式将所有元素保存到 DocumentArray 中:
-
以 JSON 行格式
-
以二进制格式
若要以 JSON 行格式保存,你可以执行以下操作:
from jina import DocumentArray, Document
documentarray = DocumentArray([Document(), Document()])
documentarray.save('data.json')
documentarray1 = DocumentArray.load('data.json')
若要以二进制格式存储,它会更快且生成更小的文件,你可以执行以下操作:
from jina import DocumentArray, Document
documentarray = DocumentArray([Document(), Document()])
documentarray.save('data.bin', file_format='binary')
documentarray1 = DocumentArray.load('data.bin', file_format='binary')
基本操作
和任何其他对象一样,你可以对 DocumentArray 执行基本操作,包括以下内容:
-
访问元素
-
排序元素
-
过滤元素
让我们详细了解这些。
访问元素
你可以通过索引、ID 或切片索引来访问 DocumentArray 中的 Document,如下所示:
from jina import DocumentArray, Document
documentarray = DocumentArray([Document(id='hello'),
Document(id='world'), Document(id='goodbye')])
documentarray[0]
# <jina.types.document.Document id=hello at 5699749904>
documentarray['world']
# <jina.types.document.Document id=world at 5736614992>
documentarray[1:2]
# <jina.types.arrays.document.DocumentArray length=1 at
# 5705863632>
根据你的使用场景,可以随意使用这些选项的任意变体。
排序元素
因为DocumentArray
是MutableSequence
的子类,你可以使用内置的 Python 函数sort
来对 DocumentArray 中的元素进行排序。例如,如果你想就地排序元素(不进行复制),并按降序使用tags[id]
值,可以执行以下操作:
from jina import DocumentArray, Document
documentarray = DocumentArray(
[
Document(tags={'id': 1}),
Document(tags={'id': 2}),
Document(tags={'id': 3})
]
)
documentarray.sort(key=lambda d: d.tags['id'],
reverse=True)
print(documentarray)
上述代码将输出以下内容:
<jina.types.arrays.document.DocumentArray length=3 at 5701440528>
{'id': '6a79982a-b6b0-11eb-8a66-1e008a366d49', 'tags': {'id': 3.0}},
{'id': '6a799744-b6b0-11eb-8a66-1e008a366d49', 'tags': {'id': 2.0}},
{'id': '6a799190-b6b0-11eb-8a66-1e008a366d49', 'tags': {'id': 1.0}}
过滤元素
你可以使用 Python 的内置filter
函数来过滤DocumentArray
对象中的元素:
from jina import DocumentArray, Document
documentarray = DocumentArray([Document() for _ in range(6)])
for j in range(6):
documentarray[j].scores['metric'] = j
for d in filter(lambda d: d.scores['metric'].value > 2, documentarray):
print(d)
这将输出以下内容:
{'id': 'b5fa4871-cdf1-11eb-be5d-e86a64801cb1', 'scores': {'values': {'metric': {'value': 3.0}}}}
{'id': 'b5fa4872-cdf1-11eb-be5d-e86a64801cb1', 'scores': {'values': {'metric': {'value': 4.0}}}}
{'id': 'b5fa4873-cdf1-11eb-be5d-e86a64801cb1', 'scores': {'values': {'metric': {'value': 5.0}}}}
你还可以通过以下方式,从过滤结果中构建一个DocumentArray
对象:
from jina import DocumentArray, Document
documentarray = DocumentArray([Document(weight=j) for j in range(6)])
documentarray2 = DocumentArray(d for d in documentarray if d.weight > 2)
print(documentarray2)
这将输出以下结果:
DocumentArray has 3 items:
{'id': '3bd0d298-b6da-11eb-b431-1e008a366d49', 'weight': 3.0},
{'id': '3bd0d324-b6da-11eb-b431-1e008a366d49', 'weight': 4.0},
{'id': '3bd0d392-b6da-11eb-b431-1e008a366d49', 'weight': 5.0}
到此为止,你已经学习了如何创建存储多个文档的 Documents 和 DocumentArrays(作为一个列表)。但是,你实际上能用这些做什么呢?如何将它们用于神经网络搜索?这时,Executor 就发挥作用了。接下来的部分我们将讨论 Executor。
Executors
Executor代表 Jina Flow 中的处理组件。它对 Document 或 DocumentArray 执行单个任务。你可以将 Executor 看作 Jina 的逻辑部分。Executors 是执行各种任务的主体。例如,你可以有一个用于从 PDF 文件中提取文本的 Executor,或者为 Document 编码音频的 Executor。它们处理 Jina 中的所有算法任务。
由于 Executor 是 Jina 的主要组成部分,它们执行所有算法任务,因此你创建 Executor 时,可以使其更易于与他人共享,这样他人就可以重用你的工作。同样,你也可以在自己的代码中使用别人预构建的 Executor。事实上,这完全可行,因为 Executor 在市场中是可以轻松获得的,在 Jina 中这个市场叫做 Jina Hub (hub.jina.ai/
)。你可以浏览各种解决不同任务的 Executor,只需选择对你有用的 Executor 并在代码中使用它。当然,可能你需要的任务 Executor 还没有在 Jina Hub 中构建好,在这种情况下,你将需要创建自己的 Executor。这在 Jina Hub 中很容易实现。让我们深入了解如何做到这一点。
创建 Executor
创建 Executor 最好使用 Jina Hub,它将生成一个向导,指导你完成整个过程。要启动此过程,请打开控制台并输入以下命令:
jina hub new
这将触发一个向导,指导你完成 Executor 的创建并要求你提供一些关于它的细节:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/nrl-sch/img/Figure_4.3_B17488.jpg
图 4.3 – 通过 CLI 创建 Executor
经过向导后,你的 Executor 就准备好了。现在,让我们更详细地了解 Executors。
Executors 通过带有 @requests
装饰器的函数就地处理 DocumentArrays。我们将此装饰器添加到我们希望在 Executors 中使用的任何函数。当创建 Executor 时,应牢记三个原则:
-
它应该是
jina.Executor
类的子类。 -
它必须是一个带有
shared
状态的函数集合。它可以包含任意数量的函数,函数名也可以任意。 -
被
@requests
装饰的函数将根据它们的on=端点
被调用。我们将在以下示例中看到这些端点可能是什么。
这是一个非常基本的 Python Executor,帮助你更好地理解最后这个概念:
from jina import Executor, requests
class MyExecutor(Executor):
@requests
def foo(self, **kwargs):
print(kwargs)
你的 Executor 名称可以是你想要的任何名称,但需要记住的重要一点是,每个新的 Executor 应该是 jina.Executor
的子类。
构造函数
如果你的 Executor 不包含初始状态,则不需要实现构造函数(__init__
)。但如果你的 Executor 有 __init__
,它需要在函数签名中携带 **kwargs
,并在函数体内调用 super().__init__(**kwargs)
:
from jina import Executor
class MyExecutor(Executor):
def __init__(self, foo: str, bar: int, **kwargs):
super().__init__(**kwargs)
self.bar = bar
self.foo = foo
方法装饰器
@requests
装饰器定义了何时调用一个函数。它有一个 on=
关键字,用于定义端点。我们还没有谈到 Flow。我们将在下一节中讲解它,但现在,可以将 Flow 想象成一个管理器。每当我们的 Executor 需要被调用时,@requests
装饰器会将信息发送到 Flow。这是为了向 Flow 通信何时以及在什么端点调用该函数。
你可以像这样使用装饰器:
from jina import Executor, Flow, Document, requests
class MyExecutor(Executor):
@requests(on='/index')
def foo(self, **kwargs):
print(f'foo is called: {kwargs}')
@requests(on='/random_work')
def bar(self, **kwargs):
print(f'bar is called: {kwargs}')
f = Flow().add(uses=MyExecutor)
with f:
f.post(on='/index', inputs=Document(text='index'))
f.post(on='/random_work',
inputs=Document(text='random_work'))
f.post(on='/blah', inputs=Document(text='blah'))
在这个示例中,我们有三个端点:
-
on='/index'
:此端点将触发MyExecutor.foo
方法。 -
on='/random_work'
:此端点将触发MyExecutor.bar
方法。 -
on='/blah'
:此端点不会触发任何方法,因为没有函数绑定到MyExecutor.blah
。
Executor 绑定
现在我们已经看到了如何创建 Executors,并了解了 @requests
装饰器,你可能会想知道可以使用哪些类型的绑定与 @requests
。
默认绑定
使用普通的 @requests
装饰器装饰的类方法是所有端点的默认处理程序。这意味着它是未找到的端点的回退处理程序。让我们来看一个例子:
from jina import Executor, requests
class MyExecutor(Executor):
@requests
def foo(self, **kwargs):
print(kwargs)
@requests(on='/index')
def bar(self, **kwargs):
print(kwargs)
在这个示例中,定义了两个函数:
-
foo
-
bar
这里的 foo
函数成为默认方法,因为它没有 on=
关键字。如果我们现在使用一个未知的端点,例如 f.post(on='/blah', ...)
,它会调用 MyExecutor.foo
,因为没有 on='/blah'
端点。
多重绑定
要将方法绑定到多个端点,你可以使用 @requests(on=['/foo', '/bar'])
。这样,f.post(on='/foo', ...)
或 f.post(on='/bar', ...)
都可以调用这个函数。
无绑定
没有 @requests
绑定的类在 Flow 中不起作用。请求会直接通过而不进行任何处理。
现在你已经了解了执行器是什么,并且知道为什么与其他开发人员共享它们是有用的。你还学会了如何查找已发布的执行器以及如何发布自己的执行器。现在让我们看看如何将你迄今为止学到的概念结合起来。
Flow
现在你已经了解了文档(Documents)和执行器(Executors)是什么,以及如何使用它们,我们可以开始讨论 Flow,这是 Jina 中最重要的概念之一。
可以把 Flow 看作 Jina 中的一个管理器;它负责处理应用程序中所有要运行的任务,并以文档(Documents)作为输入和输出。
创建 Flow
在 Jina 中创建 Flow 非常简单,和在 Python 中创建任何其他对象一样。比如,这就是如何创建一个空的 Flow:
from jina import Flow
f = Flow()
为了使用 Flow,最好始终将其作为上下文管理器打开,就像在 Python 中打开文件一样,可以使用 with
函数:
from jina import Flow
f = Flow()
with f:
f.block()
注意
Flow 遵循懒加载模式:除非你使用 with
函数打开它,否则它不会实际运行。
向流程中添加执行器
要向你的 Flow 添加元素,只需要使用 .add()
方法。你可以根据需要添加任意数量的元素。
.add()
方法用于向 Flow 对象中添加执行器。每个 .add()
实例会添加一个新的执行器,这些执行器可以作为本地线程、本地进程、远程进程、Docker 容器内甚至远程 Docker 容器中的进程运行。你可以像这样添加任意数量的执行器:
from jina import Flow
flow = Flow().add().add()
通过 uses 定义执行器
你可以使用 uses
参数来指定你正在使用的执行器。uses
参数支持多种类型的值,包括类名、Docker 镜像和(内联)YAML。因此,你可以通过以下方式添加执行器:
from jina import Flow, Executor
class MyExecutor(Executor):
...
f = Flow().add(uses=MyExecutor)
可视化 Flow
如果你想可视化你的 Flow,可以使用 .plot()
函数。这将生成一个包含可视化流程的 .svg
文件。为此,只需在 Flow 末尾添加 .plot()
函数,并使用你希望的 .svg
文件标题:
from jina import Flow
f = Flow().add().plot('f.svg')
上述代码片段将生成如下图形和相应的 Flow:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/nrl-sch/img/Figure_4.4_B17488.jpg
图 4.4 – 流程示例
注意
在 Jupyter Lab/Notebook 中,Flow 对象会自动渲染,无需调用 .plot()
。
你也可以使用 CRUD 方法(index, search, update, delete),这些只是带有on='/index', on='/search'
的糖语法形式的 post。它们列在以下清单中:
-
index = partialmethod(post, '/index')
-
search = partialmethod(post, '/search')
-
update = partialmethod(post, '/update')
-
delete = partialmethod(post, '/delete')
因此,综合前述概念,一个最小的工作示例需要创建一个从基本 Executor 类扩展出来的 Executor,并且可以与你的 Flow 一起使用:
from jina import Flow, Document, Executor, requests
class MyExecutor(Executor):
@requests(on='/bar')
def foo(self, docs, **kwargs):
print(docs)
f = Flow().add(name='myexec1', uses=MyExecutor)
with f:
f.post(on='/bar', inputs=Document(), on_done=print)
就是这样!现在你有一个最小的工作示例,并且已经掌握了 Jina 的基础知识。在接下来的章节中,我们将看到更多高级用法,但如果你已经学会了 Document、DocumentArray、Executor 和 Flow 的概念,那么你已经准备好了。
总结
本章介绍了 Jina 中的主要概念:Document、DocumentArray、Flow 和 Executor。现在,你应该对这些概念是什么、它们为什么重要以及它们如何相互关联有一个概览。
除了理解构建搜索引擎时 Document、DocumentArray、Flow 和 Executor 的重要性理论之外,你还应该能够创建一个简单的 Document 并分配其对应的属性。完成本章后,你还应该能够创建自己的 Executor 并启动一个基本的 Flow。
你将在下一章节中使用所有这些知识,学习如何将这些概念整合在一起。
第五章:多重搜索模态
得益于深度学习和人工智能,我们可以将任何类型的数据编码为向量。这使得我们能够创建一个搜索系统,使用任何类型的数据作为查询,并返回任何类型的数据作为搜索结果。
本章将介绍日益受到关注的多模态搜索问题。你将看到不同的数据模态以及如何处理它们。你将了解如何将文本、图像和音频文档转换为向量,并如何独立于数据模态实现搜索系统。你还将看到多模态和跨模态概念之间的差异。
本章将涵盖以下主要内容:
-
如何表示不同数据类型的文档
-
如何编码多模态文档
-
跨模态和多模态搜索
本章结束时,你将对跨模态和多模态搜索的工作原理有一个扎实的理解,并且会发现,在 Jina 中处理不同模态的数据是多么容易。
技术要求
本章有以下技术要求:
-
配备至少 4 GB RAM 的笔记本电脑(建议 8 GB 或更多)
-
在 Unix-like 操作系统(如 macOS 或 Ubuntu)上安装3.7、3.8或3.9版本的 Python
引入多模态文档
在过去十年中,各种类型的数据,如文本、图像和音频,在互联网上迅速增长。通常,不同类型的数据与一条内容相关联。例如,图像通常也有文本标签和说明来描述内容。因此,内容具有两种模态:图像和文本。带字幕的电影片段有三种模态:图像、音频和文本。
Jina 是一个数据类型无关的框架,让你可以处理任何类型的数据,并开发跨模态和多模态搜索系统。为了更好地理解这一点,首先展示如何表示不同数据类型的文档,然后再展示如何在 Jina 中表示多模态文档,是有意义的。
文本文档
在 Jina 中表示一个文本文档是相当简单的。你只需使用以下代码即可:
from docarray import Document
doc = Document(text='Hello World.')
在某些情况下,一个文档可能包含成千上万的词语。但一个包含成千上万字的长文档很难进行搜索;一些更细粒度的分段会更好。你可以通过将一个长文档分割成较小的块来实现这一点。例如,让我们通过使用!
标记来分割这个简单的文档:
from jina import Document
d = Document(text='नमस्ते दुनिया!你好世界!こんにちは世界!Привет мир!')
d.chunks.extend([Document(text=c) for c in d.text.split('!')])
这会在原始文档下创建五个子文档,并将它们存储在.chunks
下。为了更清楚地看到这一点,你可以通过d.display()
来可视化,输出结果如下图所示:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/nrl-sch/img/Figure_5.01_B17488.jpg
图 5.1 – 一个包含分块级子文档的文本文档示例
你还可以通过使用.texts
属性打印出每个子文档的文本属性:
print(d.chunks.texts)
这将输出以下内容:
['नमस्ते दुनिया', '你好世界', 'こんにちは世界', 'Привет мир', '']
这就是你需要了解的关于在 Jina 中表示文本文档的所有内容!
图像文档
与文本数据相比,图像数据更加通用且容易理解。图像数据通常只是一个ndim=2
或ndim=3
且dtype=uint8
的数组。该 ndarray 中的每个元素表示在某个位置、某个通道上的一个像素值,范围在0
到255
之间。例如,一张 256x300 的彩色 JPG 图像可以表示为一个[256, 300, 3]
的 ndarray。你可能会问,为什么最后一维是 3
。这是因为它表示每个像素的 R
、G
和 B
通道。有些图像有不同数量的通道。例如,一个带透明背景的 PNG 图像有四个通道,其中额外的通道表示透明度。灰度图像只有一个通道,表示亮度(衡量黑白比例的值)。
在 Jina 中,你可以通过指定图像 URI 来加载图像数据,然后使用文档 API 将其转换为 .tensor
。作为示例,我们将使用以下代码加载一张 PNG 格式的苹果图像(如 图 5.2 所示):
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/nrl-sch/img/Figure_5.02_B17488.jpg
图 5.2 – 存储在本地文件 apple.png 中的示例 PNG 图像
from docarray import Document
d = Document(uri='apple.png')
d.load_uri_to_image_tensor()
print(d.content_type, d.tensor)
你将获得以下输出:
tensor [[[255 255 255]
[255 255 255]
[255 255 255]
...
现在,图像内容已被转换为文档的 .tensor
字段,接下来可以用于进一步处理。可以使用一些帮助函数来处理图像数据。你可以调整图像大小(即降采样/上采样)并进行归一化处理。你可以将 .tensor
的通道轴切换以满足某些框架的要求,最后,你可以将所有这些处理步骤链在一行代码中。例如,图像可以进行归一化,并且颜色轴应该放在前面,而不是最后。你可以使用以下代码执行这些图像变换:
from docarray import Document
d = (
Document(uri='apple.png')
.load_uri_to_image_tensor()
.set_image_tensor_shape(shape=(224, 224))
.set_image_tensor_normalization()
.set_image_tensor_channel_axis(-1, 0)
)
print(d.content_type, d.tensor.shape)
你还可以使用以下方法将.tensor
重新转换为 PNG 图像文件:
d.save_image_tensor_to_file('apple-proc.png', channel_axis=0)
请注意,由于我们刚刚进行的处理步骤,通道轴现在已切换到0
。最后,你将得到如 图 5.3 所示的结果图像:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/nrl-sch/img/Figure_5.03_B17488.jpg
图 5.3 – 调整大小和归一化后的结果图像
音频文档
作为存储信息的重要格式,数字音频数据可以是片段、音乐、铃声或背景噪音。它通常以.wav
和.mp3
格式出现,其中声音波形通过在离散时间间隔进行采样来数字化。要将.wav
文件作为文档加载到 Jina 中,你可以简单地使用以下代码:
from docarray import Document
d = Document(uri='hello.wav')
d.load_uri_to_audio_tensor()
print(d.content_type, d.tensor.shape)
你将看到以下输出:
tensor [-0.00061035 -0.00061035 -0.00082397 ... 0.00653076 0.00595093 0.00631714]
如前面的示例所示,来自.wav
文件的数据被转换为一维(单声道)ndarray,其中每个元素通常预计位于[-1.0, +1.0]的范围内。你完全不局限于使用 Jina 本地方法进行音频处理。这里有一些命令行工具、程序和库,你可以用来更高级地处理音频数据:
-
FFmpeg (
ffmpeg.org/
):这是一个免费的开源项目,用于处理多媒体文件和流。 -
Pydub (
github.com/jiaaro/pydub
):这是一个使用简单易用的高级接口操作音频的库。 -
Librosa (
librosa.github.io/librosa/
):这是一个用于音乐和音频分析的 Python 包。
多模态文档
到目前为止,你已经学习了如何在 Jina 中表示不同的数据模态。然而,在现实世界中,数据通常以结合多种模态的形式出现,例如视频,通常包括至少图像和音频,以及字幕形式的文本。现在,了解如何表示多模态数据是非常有趣的。
一个 Jina 文档可以通过块进行垂直嵌套。将不同模态的数据放入块中的子文档是直观的。例如,你可以创建一个时尚产品文档,包含两种模态,包括一张服装图片和产品描述。
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/nrl-sch/img/Figure_5.04_B17488.jpg
图 5.4 – 一个包含两种模态的时尚产品文档示例
你可以通过使用以下代码轻松完成:
from jina import Document
text_doc = Document(text='Men Town Round Red Neck T-Shirts')
image_doc = Document(uri='tshirt.jpg').load_uri_to_image_tensor()
fashion_doc = Document(chunks=[text_doc, image_doc])
现在,示例中的时尚产品(如图 5.4所示)表示为一个 Jina 文档,文档中有两个块级文档,分别表示产品的描述和服装图片。你还可以使用fashion_doc.display()
生成可视化,如图 5.5所示:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/nrl-sch/img/Figure_5.05_B17488.jpg
图 5.5 – 一个包含两个块级文档的时尚产品文档示例
重要提示
你可能会认为不同的模态对应不同种类的数据(在这个例子中是图像和文本)。然而,这并不完全准确。例如,你可以通过从不同角度搜索图像或为给定段落文本搜索匹配的标题来进行跨模态搜索。因此,我们可以认为模态与某种特定数据分布相关,数据可能来自该分布。
到目前为止,我们已经学习了如何表示单一的文本、图像和音频数据,以及如何将多模态数据表示为 Jina 文档。在接下来的部分,我们将展示如何获取每个文档的嵌入。
如何对多模态文档进行编码
在定义了不同类型数据的文档之后,下一步是使用模型将文档编码为向量嵌入。正式来说,嵌入是文档的多维表示(通常是一个[1, D]
的向量),旨在包含文档的内容信息。随着深度学习方法的不断进展,甚至通用模型(例如,在 ImageNet 上训练的 CNN 模型)也可以用来提取有意义的特征向量。在接下来的部分,我们将展示如何为不同模态的文档编码嵌入。
文本文档编码
为了将文本文档转换为向量,我们可以使用 Sentence Transformer 提供的预训练 Bert 模型(www.sbert.net/docs/pretrained_models.xhtml
),如下所示:
from docarray import DocumentArray
from sentence_transformers import SentenceTransformer
da = DocumentArray(...)
model = SentenceTransformer('all-MiniLM-L6-v2')
da.embeddings = model.encode(da.texts)
print(da.embeddings.shape)
结果是,在输入的DocumentArray
中,每个文档在完成.encode(...)
后,将拥有一个 384 维的稠密向量空间作为嵌入。
图像文档编码
对于图像文档的编码,我们可以使用来自 Pytorch 的预训练模型进行嵌入。作为示例,我们将使用ResNet50网络(arxiv.org/abs/1512.03385
)进行图像对象分类,图像数据来自torchvision(pytorch.org/vision/stable/models.xhtml
):
from docarray import DocumentArray
import torchvision
da = DocumentArray(...)
model = torchvision.models.resnet50(pretrained=True)
da.embed(model)
print(da.embeddings.shape)
通过这种方式,我们已经成功地将图像文档编码为其特征向量表示。生成的特征向量是神经网络的输出激活(一个包含 1,000 个元素的向量)。
重要说明
你可能已经注意到,在前面的示例中,我们使用了.embed()
来进行嵌入。通常,当DocumentArray
设置了.tensors
时,你可以使用此 API 来编码文档。在使用 GPU 时,可以指定.embed(..., device='cuda')
。设备名称标识符取决于你使用的模型框架。
音频文档编码
为了将音频片段编码为向量,我们选择了 Google Research 的VGGish模型(arxiv.org/abs/1609.09430
)。我们将使用来自torchvggish(github.com/harritaylor/torchvggish
)的预训练模型来获取音频数据的特征嵌入:
import torch
from docarray import DocumentArray
model = torch.hub.load('harritaylor/torchvggish', 'vggish')
model.eval()
for d in da:
d.embedding = model(d.uri)[0]
print(da.embeddings.shape)
每个音频片段返回的嵌入是一个大小为K x 128 的矩阵,其中K是对数梅尔频谱中的示例数,粗略对应音频的秒数。因此,每个 4 秒的音频片段在数据块中由四个 128 维向量表示。
我们现在已经学习了如何为不同模态的文档编码嵌入。接下来的部分,我们将向您展示如何使用多模态进行数据搜索。当试图寻找一些无法仅通过单一模态表示的数据时,这种方法非常有用。例如,您可能会使用图像搜索来寻找具有文本性质的数据。
跨模态与多模态搜索
既然我们已经知道如何处理多模态数据,现在可以描述跨模态和多模态搜索。在此之前,我想先描述单模态(单一模态)搜索。一般来说,单模态搜索意味着在索引和查询时处理单一模态的数据。例如,在图像搜索检索中,返回的搜索结果也是基于给定图像查询的图像。
到目前为止,我们已经知道如何将文档内容编码为特征向量以创建嵌入。在索引中,每个包含图像、文本或音频内容的文档可以表示为嵌入向量并存储在索引中。在查询中,查询文档也可以表示为一个嵌入,之后可以通过一些相似度分数(如余弦相似度、欧几里得距离等)来识别相似文档。图 5.6展示了搜索问题的统一匹配视角:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/nrl-sch/img/Figure_5.06_B17488.jpg
图 5.6 – 搜索问题的统一匹配视角示意图
更正式地说,搜索可以被视为构建一个匹配模型,该模型计算输入查询文档与搜索中文档之间的匹配度。通过这种统一的匹配视角,单模态、多模态和跨模态搜索在架构和方法论上相互之间的相似性更为明显,具体体现在以下技术:将输入(查询和文档)嵌入为分布式表示,结合神经网络组件表示不同模态数据之间的关系,并以端到端的方式训练模型参数。
跨模态搜索
在单模态搜索中,搜索被设计为处理单一数据类型,因此在处理不同数据类型的输入时,其灵活性较差,容易受到限制。超越单模态搜索,跨模态搜索的目标是以一种类型的数据作为查询,检索另一类型的相关数据,例如图像-文本、视频-文本和音频-文本的跨模态搜索。例如,如图 5.7所示,我们可以设计一个基于简短文本描述作为查询来检索图像的文本到图像搜索系统:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/nrl-sch/img/Figure_5.07_B17488.jpg
图 5.7 – 从标题中寻找图像的跨模态搜索系统
最近,随着多模态数据的快速增长,跨模态搜索引起了广泛关注。随着多模态数据的增长,用户有效且高效地搜索感兴趣的信息变得越来越困难。迄今为止,已经提出了各种方法来搜索多模态数据。然而,这些搜索技术大多是基于单一模态的,将跨模态搜索转化为基于关键词的搜索。这可能会很昂贵,因为需要人工编写这些关键词,而且多模态内容的信息并不总是可以获得。我们需要寻找另一种解决方案!跨模态搜索旨在识别不同模态之间的相关数据。跨模态搜索的主要挑战是如何衡量不同模态数据之间的内容相似性。为了解决这个问题,提出了各种方法。一种常见的方法是从不同模态生成特征向量,将它们映射到相同的潜在空间中,从而使新生成的特征可以应用于距离度量的计算。
为了实现这一目标,通常可以利用两种常见的深度度量学习架构(Siamese 网络和三元组网络)。它们都共享一个思想,即不同的子网络(可能共享或不共享权重)同时接收不同的输入(Siamese 网络的正负对,三元组网络的正对、负对和锚点文档),并尝试将它们各自的特征向量投影到一个共同的潜在空间,在那里计算对比损失,并将误差传播到所有子网络。
正对是指语义相关的物体对(图像、文本或任何文档),预计它们在投影空间中应该保持接近。另一方面,负对是指应该分离的文档对。
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/nrl-sch/img/Figure_5.08_B17488.jpg
图 5.8 – 深度度量学习过程的模式图,使用三元组网络和锚点
如图 5.8所示,这是一个图像与文本之间的跨模态搜索示例,用于提取图像特征的子网络是ResNet50架构,权重在 ImageNet 上预训练,而对于文本嵌入,使用的是预训练Bert模型中的一个隐藏层的输出。最近,提出了一种新的深度度量学习预训练模型——对比语言-图像预训练(CLIP),它是一个在多种图像-文本对上训练的神经网络。它通过文本片段和来自互联网的图像对来帮助学习自然语言中的视觉概念。它可以通过在相同的语义空间中编码文本标签和图像来执行零样本学习,并为这两种模态创建一个标准嵌入。使用 CLIP 风格的模型,图像和查询文本可以映射到相同的潜在空间,从而可以使用相似性度量进行比较。
多模态搜索
与单模态和跨模态搜索相比,多模态搜索旨在使多模态数据作为查询输入。搜索查询可以由文本输入、图像输入和其他模态的输入组合而成。将不同模态的信息结合起来以提高搜索性能是直观的。想象一个电子商务搜索场景,采用两种类型的查询信息:图像和文本。例如,如果你在搜索裤子,图像将是裤子的照片,而文本则可能是“紧身”和“蓝色”。在这种情况下,搜索查询由两种模态(文本和图像)组成。我们可以将这种搜索场景称为多模态搜索。
为了支持多模态搜索,实践中广泛使用两种方法来融合多个模态进行搜索:早期融合(将来自多个模态的特征作为查询输入)和晚期融合(在最后阶段融合来自不同模态的搜索结果)。
具体来说,早期融合方法将从不同模态数据中提取的特征进行融合。如图 5.9所示,来自不同模型的两种不同模态(图像和文本)特征被输入到融合算子中。为了简单地结合特征,我们可以使用特征拼接作为融合算子来生成多模态数据的特征。另一种融合选择是将不同模态投影到一个共同的嵌入空间中。然后,我们可以直接将不同模态数据的特征相加。
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/nrl-sch/img/Figure_5.09_B17488.jpg
图 5.9 – 早期融合,多模态特征作为查询输入的融合
在合并特征后,我们可以使用为单模态搜索设计的相同方法来解决多模态搜索问题。这种特征融合方法存在一个显著的局限性:在用户查询的上下文中,很难定义各种模态的重要性之间的正确平衡。为了克服这一局限性,可以训练一个端到端神经网络来建模联合多模态空间。然而,建模这个联合多模态空间需要复杂的训练策略和经过充分标注的数据集。
实际上,为了应对上述缺点,我们可以简单地使用后期融合方法,按模态分离搜索,然后融合来自不同模态的搜索结果,例如,通过线性组合每个文档所有模态的检索得分。尽管后期融合已被证明是稳健的,但它也存在一些问题:如何为模态分配适当的权重并非一个简单的问题,而且还存在主要模态问题。例如,在文本-图像多模态搜索中,当用户仅根据视觉相似性评估结果时,文本得分的影响可能会恶化最终结果的视觉质量。
这两种搜索模式的主要区别在于,跨模态搜索中,单一文档与嵌入空间中的向量之间存在直接映射关系,而在多模态搜索中并不成立,因为两个或更多文档可能会合并成一个向量。
总结
本章介绍了多模态数据的概念,以及跨模态和多模态搜索问题。首先,我们介绍了多模态数据以及如何在 Jina 中表示它。接着,我们学习了如何使用深度神经网络从不同模态的数据中获取向量特征。最后,我们介绍了跨模态和多模态搜索系统。这解锁了许多强大的搜索模式,并使得理解如何使用 Jina 实现跨模态和多模态搜索应用变得更加容易。
在下一章中,我们将介绍一些基本的实用示例,解释如何使用 Jina 实现搜索应用。
第三部分:如何使用 Jina 进行神经搜索
在这一部分中,您将使用迄今为止所学的所有知识,并逐步指导您如何为不同的模态构建搜索系统,无论是文本、图像、音频,还是跨模态和多模态。以下章节包含在这一部分中:
-
第六章,Jina 的基本实用示例
-
第七章,探索 Jina 的高级使用案例
第六章:使用 Jina 构建实际示例
本章中,我们将使用 Jina 的神经搜索框架构建简单的现实应用。基于前几章学到的概念,我们将探讨如何利用 Jina 创建有价值的应用。
我们将学习 Jina 框架的实际应用,并了解如何利用它们快速构建和部署复杂的搜索解决方案。我们将带您逐步了解基于 Jina 构建的三个不同应用的代码,并查看上一章中学到的不同组件如何协同工作以创建一个搜索应用。
本章我们将介绍以下三个示例,帮助您开始使用 Jina 构建应用:
-
问答聊天机器人
-
时尚图像搜索
-
多模态搜索
本章的目标是通过构建实际示例,帮助您入门并理解 Jina 神经搜索框架的潜力。这是踏入神经搜索领域并构建最先进搜索解决方案的绝佳起点。
技术要求
要跟随本章讨论的应用代码,您可以克隆 GitHub 上的代码库,地址为 https://github.com/jina-ai/jina/tree/master/jina/helloworld。
开始使用问答聊天机器人
问答聊天机器人是一个随 Jina 安装包提供的预构建示例。为了亲身体验 Jina 的强大功能并快速入门,您可以直接从命令行运行问答聊天机器人示例,而无需深入代码。问答聊天机器人使用的是来自 Kaggle 的公共 Covid 问答数据集(https://www.kaggle.com/datasets/xhlulu/covidqa),该数据集包含 418 对问答(https://www.kaggle.com/xhlulu/covidqa)。
请按照以下说明设置开发环境并运行问答聊天机器人示例:
-
第一步是从Python 软件包索引(PyPI)安装 Jina 库以及所需的依赖项:
pip install "jina[demo]"
-
之后,只需输入以下命令即可启动您的应用:
jina hello chatbot
输入此命令后,您将在您的命令行界面(CLI)中看到以下文本:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/nrl-sch/img/Figure_6.01_B17488.jpg
图 6.1 – 问答聊天机器人命令行
如果您的屏幕上显示了相同的命令行文本,说明您已经成功启动了问答聊天机器人示例。现在,是时候打开用户界面(UI)并与聊天机器人互动了。
默认情况下,系统会打开一个简单的聊天界面,允许您与问答聊天机器人进行对话。如果页面没有自动打开,您可以通过访问 jina/helloworld/chatbot/static
来打开 index.xhtml
文件。
您将看到以下网页,默认情况下或打开 index.xhtml
文件后都会显示:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/nrl-sch/img/Figure_6.02_B17488.jpg
图 6.2 – 问答聊天机器人界面
你已经成功启动了问答聊天机器人应用程序;现在可以尽情玩耍,享受其中的乐趣。你可以向聊天机器人询问任何与 Covid 相关的事实、数据或问题,并看到魔法的实现!
浏览代码
现在让我们来看一下应用程序背后的逻辑,看看 Jina 的框架是如何将所有组件连接起来,生成一个功能齐全的问答聊天机器人应用程序的。
为了查看代码并了解在安装 Jina 后,如何通过不同的组件共同作用来启动这个应用程序,进入聊天机器人目录,按照 jina/helloworld/chatbot
路径操作。这个目录是包含聊天机器人示例代码的主要目录:
└── chatbot
├── app.py
├── my_executors.py
├── static/
以下是你将在聊天机器人目录中看到的文件:
-
app.py
:这是应用程序的主入口点/大脑。 -
my_executors.py
:该文件负责所有后台处理。它包含应用程序背后的逻辑,我们在 Jina 术语中称之为 执行器。它包含多个执行器,用于转换、编码和索引数据。 -
static
:这个文件夹包含所有前端代码,负责在网页浏览器中渲染聊天机器人界面,帮助你与聊天机器人应用程序进行互动。
我们将在接下来的小节中详细了解这些文件的功能。
app.py
app.py
文件是示例应用程序的入口点。当你输入 jina hello chatbot
命令时,控制权会转到这个文件。它是应用程序的主要入口点,负责启动应用程序的 UI 并运行后台代码。
app.py
文件执行以下任务,确保多个组件协同工作,产生预期的结果。
它首先做的事情是通过以下代码从 my_executors.py
文件中导入所需的执行器:
from my_executors import MyTransformer, MyIndexer
这两个执行器都派生自 Jina 的基类 Executor
:
-
MyTransformer
执行器负责编码和转换数据。 -
MyIndexer
执行器用于索引数据。
我们将在讨论 my_executors.py
文件时,详细了解这两个执行器的功能。
Flow
允许你以执行器的形式添加编码和索引,在聊天机器人示例中,我们使用以下执行器。你可以使用以下代码来创建一个流程并将这些执行器添加到其中:
from jina import Flow
flow = (
Flow(cors=True)
.add(uses=MyTransformer)
.add(uses=MyIndexer)
)
这是一个简单的流程,只有两个执行器。对于具有多个执行器的复杂流程,Jina 提供了通过不同的名称区分每个执行器的功能(例如,通过使用 name
参数,你可以为你的执行器起一些非常酷的名字)。然后,它允许你可视化流程,以了解数据如何在不同组件之间流动。让我们通过在现有代码中添加一行来可视化这个流程:
from jina import Flow
flow = (
Flow(cors=True)
.add(name='MyTransformer', uses=MyTransformer)
.add(name='MyIndexer', uses=MyIndexer)
.plot('chatbot_flow.svg')
)
运行前面的代码将生成以下SVG
文件,用于可视化聊天机器人流程:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/nrl-sch/img/Figure_6.3_B17488.jpg
图 6.3 – 聊天机器人流程
注意
由于我们想通过浏览器调用流程,因此在 Flow 中启用跨源资源共享(https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS)非常重要(cors=True
)。
一旦我们准备好流程,就可以开始深入探讨app.py
文件中的hello_world
函数,它将不同来源的内容汇集在一起,并为你提供一个查询端点(后端端点),你可以通过这个端点与聊天机器人应用程序进行交互:
hello_world
函数首先创建一个workspace
目录来存储已索引的数据,并确保导入所需的依赖项。
注意
要运行此示例,我们需要两个主要的依赖项/Python 库:torch
和transformers
。
-
在继续代码之前,使用以下命令安装依赖项:
-
pip install torch
-
pip install transformers
-
安装这些依赖项后,接下来我们继续hello_world
函数的编写。
- 下一步是从 Kaggle 下载数据。为此,我们将使用
download_data
函数,该函数基本上利用urllib
库从给定的 URL 抓取并保存数据。
urllib
模块接受url
和filename
作为目标,并下载数据。你可以参考以下代码查看我们如何设置目标:
targets = {
'covid-csv': {
'url': url_of_your_data,
'filename': file_name_to_be_fetched,
}
}
将目标变量传入download_data
函数,将会下载数据并将其保存为.csv
文件,保存在同一工作目录中的一个随机文件夹里。
-
现在我们已经拥有了索引数据所需的所有基本组件,我们将使用前一步下载的数据,并使用之前创建的流程进行索引。索引将遵循以下逻辑:
-
它将使用
MyTransformer
执行器通过计算相应的嵌入来编码和转换数据。 -
它将使用
MyIndexer
执行器通过/index
端点索引数据,并打开/search
端点以查询并与聊天机器人进行交互。
-
以下是索引数据并创建搜索端点与聊天机器人进行交互的代码:
with f:
f.index(
DocumentArray.from_csv(
targets['covid-csv']['filename'],
field_resolver={'question': 'text'}
),
show_progress=True,)
url_html_path = 'file://' + os.path.abspath(
os.path.join(os.path.dirname(
os.path.realpath(__file__)),'static/index.xhtml'
)
)
try:
webbrowser.open(url_html_path, new=2)
except:
pass
finally:
default_logger.info(
f'You should see a demo page opened in your
browser,'f'if not, you may open {url_html_path}
manually'
)
if not args.unblock_query_flow:
f.block()
在前面的代码中,我们使用上下文管理器打开流程和数据集,并将数据以'question': 'text'
对的形式发送到索引端点。对于这个示例,我们将使用网页浏览器与聊天机器人进行交互,这需要通过port_expose
参数在特定端口上配置并提供流程,使用 HTTP 协议,使浏览器能够向流程发起请求。最后,我们将使用f.block()
保持流程打开,以便进行搜索查询,并防止它退出。
my_executors.py
聊天机器人示例的另一个关键组件是my_executors.py
文件,它包含了应用程序的逻辑元素,也称为执行器。该文件包含两个不同的执行器,我们将详细讨论这两个执行器。
MyTransformer 执行器
MyTransformer
执行器执行以下任务:
-
它从
sentence-transformers
库加载了预训练的句子变换器模型。 -
它接收用户的参数并设置模型参数(例如
model name
/path
)和pooling strategy
,获取与模型对应的分词器,并根据用户的偏好设置设备为cpu
/gpu
:class MyTransformer(Executor): """Transformer executor class """ def __init__( self, pretrained_model_name_or_path: str = 'sentence-transformers/paraphrase-mpnet-base-v2', pooling_strategy: str = 'mean', layer_index: int = -1, *args, **kwargs, ): super().__init__(*args, **kwargs) self.pretrained_model_name_or_path = pretrained_model_name_or_path self.pooling_strategy = pooling_strategy self.layer_index = layer_index self.tokenizer = AutoTokenizer.from_pretrained( self.pretrained_model_name_or_path ) self.model = AutoModel.from_pretrained( pretrained_model_name_or_path, output_hidden_states=True ) self.model.to(torch.device('cpu'))
-
在设置完这些参数之后,它会计算文本数据的嵌入,并将文本数据/问答编码为键值对形式的嵌入映射。
-
编码通过
sentence-transformers
模型(默认使用paraphrase-mpnet-base-v2
)执行。我们按批次获取文档的文本属性,然后计算嵌入,之后将这些嵌入设置为每个文档的嵌入属性。 -
MyTransformer
执行器只公开一个端点encode
,每当我们请求流程时(无论是查询还是索引),都会调用该端点。该端点为索引或查询文档创建嵌入,以便搜索端点可以使用相似度分数来确定给定查询的最接近匹配项。
让我们来看一下主聊天机器人应用程序中MyTransformer
执行器的encode
函数的简化版本:
@requests
def encode(self, docs: 'DocumentArray', *args, **kwargs):
with torch.inference_mode():
if not self.tokenizer.pad_token:
self.tokenizer.add_special_tokens({'pad_token':
'[PAD]'})
self.model.resize_token_embeddings(len(
self.tokenizer.vocab))
input_tokens = self.tokenizer(
docs[:, 'content'],
padding='longest',
truncation=True,
return_tensors='pt',
)
input_tokens = {
k: v.to(torch.device('cpu')) for k,
v in input_tokens.items()
}
outputs = self.model(**input_tokens)
hidden_states = outputs.hidden_states
docs.embeddings = self._compute_embedding(
hidden_states, input_tokens)
MyIndexer 执行器
MyIndexer
执行器执行以下任务:
-
它使用一个文档存储(在我们的例子中是 SQLite),该存储包含所有
DocumentArray
的文档。与外部存储的DocumentArray
在外观和操作上几乎相同于常规的内存中的DocumentArray
,但它使得处理更加高效,并且允许更快速的检索。 -
执行器公开了两个端点:
index
和search
。index
端点负责接收文档并对其进行索引,而search
端点负责遍历已索引的DocumentArray
以找到与用户查询相关的匹配项。 -
search
端点使用match
方法(这是与DocumentArray
相关联的内置方法),该方法通过余弦相似度返回查询文档的最接近匹配项。
让我们来看一下主聊天机器人应用程序中MyIndexer
执行器的简化代码版本:
class MyIndexer(Executor):
"""Simple indexer class """
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.table_name = 'qabot_docs'
self._docs = DocumentArray(
storage='sqlite',
config={
'connection': os.path.join(
self.workspace, 'indexer.db'),
'table_name': self.table_name,
},
)
@requests(on='/index')
def index(self, docs: 'DocumentArray', **kwargs):
self._docs.extend(docs)
@requests(on='/search')
def search(self, docs: 'DocumentArray', **kwargs):
"""Append best matches to each document in docs
:param docs: documents that are searched
:param parameters: dictionary of pairs
(parameter,value)
:param kwargs: other keyword arguments
"""
docs.match(
self._docs,
metric='cosine',
normalization=(1, 0),
limit=1,
)
这两个执行器是聊天机器人应用程序的构建模块,结合这两个执行器使我们能够创建一个互动的智能聊天机器人后端。要通过 UI 在网页浏览器中与聊天机器人互动,您可以使用static
文件夹中提供的 HTML 模板。默认情况下,运行应用程序会打开一个包含聊天机器人 UI 的网页;如果没有自动打开,您可以从static
文件夹中打开index.xhtml
文件。
在这一部分中,我们查看了用于 Covid-19 数据集的问答聊天机器人应用程序背后的代码。该应用程序是一个文本到文本的搜索引擎,使用 Jina 框架创建。根据您的使用场景,相同的逻辑可以用于创建各种文本搜索应用程序。
在下一部分中,我们将探讨如何扩展对非结构化数据类型(如图像)的搜索能力,并了解 Jina 的神经搜索如何使构建图像到图像的搜索引擎变得更加容易,使用时尚图像搜索示例。
理解时尚图像搜索
时尚图像搜索是 Jina 安装包中另一个预构建的示例,您可以像运行问答聊天机器人示例一样直接从命令行启动,而无需深入代码。
时尚图像搜索示例使用了 Zalando 文章图片的著名Fashion-MNIST数据集(https://github.com/zalandoresearch/fashion-mnist),该数据集包含 60,000 个训练样本和 10,000 个测试样本。每个样本都是一个 28x28 的灰度图像,并且与 10 个类别中的一个标签相关联,类似于原始的 MNIST 数据集。
每个训练和测试集样本都会分配以下标签之一:
标签 | 描述 |
---|---|
0 | T 恤/上衣 |
1 | 长裤 |
2 | 套头衫 |
标签 | 描述 |
3 | 连衣裙 |
4 | 外套 |
5 | 凉鞋 |
6 | 衬衫 |
7 | 运动鞋 |
8 | 包 |
9 | 踝靴 |
表 6.1 – 时尚数据集标签和描述
在上一部分中,我们从 PyPI 安装了jina[demo]
库,该库处理了运行此示例所需的所有依赖项:
-
让我们进入命令行并运行时尚图像搜索示例:
jina hello fashion
-
输入此命令后,您将在命令行界面(CLI)上看到以下文本:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/nrl-sch/img/Figure_6.04_B17488.jpg
图 6.4 – 时尚图像搜索命令行界面
如果您的屏幕在命令行上显示相同的文本,说明您已成功启动了时尚图像搜索示例,现在可以打开用户界面并开始使用该应用程序了。
默认情况下,将打开一个简单的网页,展示从测试集中的随机样本作为查询的图像,以及从训练数据中检索到的结果。在后台,Jina 会下载Fashion-MNIST数据集,并通过索引流程对 60,000 个训练图像进行索引。之后,它会从测试集中随机选择未见过的图像作为查询,并请求 Jina 检索相关的结果。
如果页面没有自动打开,您可以打开位于*/demo.xhtml
路径下的demo.xhtml
文件。您将看到以下网页,无论是默认打开,还是手动打开下载的demo.xhtml
文件后显示:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/nrl-sch/img/Figure_6.05_B17488.jpg
图 6.5 – 时尚图像搜索网页界面
你可以在前面的图中看到,Jina 如何在从测试集中随机选择的图像查询中,出色地找到相关的搜索结果。
导航代码
现在让我们来了解一下应用程序背后的逻辑,看看 Jina 的框架是如何将所有组件结合起来创建一个图像搜索应用程序的。
安装 Jina 后,按照 jina/helloworld/fashion
路径进入聊天机器人目录。这个目录包含了时尚图像搜索示例的代码:
└── fashion
├── app.py
├── my_executors.py
├── helper.py
├── demo.xhtml
以下是你将在时尚目录中看到的文件:
-
app.py
:类似于上一节讨论的应用程序。 -
my_executors.py
:类似于上一节讨论的应用程序。 -
helper.py
:这个文件包含了辅助逻辑函数,用于模块化逻辑代码块并将其保持在单独的文件中。 -
demo.xhtml
:这个文件包含了所有前端代码,负责在网页浏览器中呈现聊天机器人界面,帮助你与聊天机器人应用程序进行互动。
app.py
app.py
文件是示例应用程序的入口点;一旦你输入 jina hello fashion
命令,控制会跳转到这个文件。这是应用程序的主要入口点,执行所有主要任务以启动应用程序的前端和后端。
app.py
文件执行以下任务,以确保多个组件协同工作,产生所需的应用程序。
它首先通过以下代码从 my_executors.py
文件导入所需的执行器:
from my_executors import MyEncoder, MyIndexer
所有这些执行器都继承自 Jina 的基础 Executor
类:
-
MyEncoder
负责转换和编码数据。 -
MyIndexer
用于对数据进行索引;索引完成后,它托管一个/search
端点用于查询数据。
当我们谈论 my_executors.py
文件时,我们将详细了解所有这些执行器的功能。这个示例的流程包含了上述执行器。
你可以使用以下代码来创建并可视化流程:
from jina import Flow
flow = (
Flow()
.add(name='MyEncoder', uses=MyEncoder, replicas=2)
.add(name='MyIndexer', uses=MyIndexer)
.plot('fashion_image_flow.svg')
)
运行代码将生成以下流程图,展示数据如何在应用程序的不同组件之间流动:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/nrl-sch/img/Figure_6.6_B17488.jpg
图 6.6 – 时尚图像搜索流程
在前面的代码中,replicas
参数被设置为 2
,使得 MyEncoder
执行器将输入数据流分成两个不同的执行器,以便更快地处理和编码。
一旦流程准备好,就可以深入探讨 app.py
文件中的 hello_world
函数,它将不同来源的内容整合在一起。hello_world
函数执行以下任务:
-
它在根文件夹中创建一个
workspace
目录,用于存储索引的数据。 -
它创建了一个
targets
字典,将数据的 URL 与数据将被保存的本地文件名关联起来。它将训练数据保存在index
和index-label
文件中,将测试数据保存在query
和query-label
文件中:targets = { 'index-labels': { 'url': args.index_labels_url, 'filename': os.path.join(args.workdir, 'index-labels'), }, 'query-labels': { 'url': args.query_labels_url, 'filename': os.path.join(args.workdir, 'query-labels'), }, 'index': { 'url': args.index_data_url, 'filename': os.path.join(args.workdir, 'index-original'), }, 'query': { 'url': args.query_data_url, 'filename': os.path.join(args.workdir, 'query-original'),}, }
-
之后,它将
targets
变量传递给download_data
函数并下载Fashion-MNIST数据集。download_data
函数使用urllib
包从给定的 URL 下载数据,并遍历字典保存训练集和测试集的数据及标签。 -
它创建了流程并添加了
MyEncoder
和MyIndexer
执行器。 -
它使用上下文管理器打开流程,并使用索引流程为训练数据中的所有图像创建嵌入向量,从而对数据进行索引。
-
它还包括真实标签(labels)与查询图像一起,这使我们能够评估模型的性能。
-
索引数据后,它调用
search
函数,随机抽取 128 张未见过的图像作为查询,并返回每个查询图像的前 50 个最相似图像。 -
最后,我们使用
write_html
函数,通过demo.xhtml
文件在网页浏览器中渲染前端:with f: f.index(index_generator(num_docs=targets['index'] ['data'].shape[0], target=targets), show_progress=True, ) groundtruths = get_groundtruths(targets) evaluate_print_callback = partial(print_result, groundtruths) evaluate_print_callback.__name__ = 'evaluate_print_callback' f.post( '/search, query_generator(num_docs=args.num_query, target=targets), shuffle=True, on_done= evaluate_print_callback, parameters={'top_k': args.top_k}, show_progress=True, ) #write result to html write_html(os.path.join(args.workdir, 'demo.xhtml'))
my_executors.py
时尚图片搜索示例的另一个关键组件是my_executors.py
文件。它由三个不同的执行器组成,这些执行器在流程中协同工作,创建了一个端到端的应用体验。
MyEncoder 执行器
MyEncoder
执行器执行以下任务:
-
它在索引和查询流程中都被使用。它接收来自各自生成器函数的索引数据和查询数据。它使用奇异值分解(SVD)对传入的数据进行编码。
-
在构造函数中,它创建了一个形状为(
784,64
)的随机矩阵,并应用 SVD 来获取oth_mat
。 -
在
encode
函数中,它从 docs 数组(Jina 中的DocumentArray
)中获取内容,将图像堆叠在一起,提取单通道内容,并调整图像的形状,以便准备获取嵌入向量。 -
在下一步中,我们将
content
矩阵与oth_mat
(SVD 的结果)一起使用,以获得嵌入向量。 -
然后,它将每个文档张量与相应的嵌入向量关联,并将张量转换为统一资源标识符(URI)(一个长字符串,作为图像的等效表示),以便进行标准化表示,然后将张量弹出。
-
它在循环中对所有图像重复相同的过程,以对整个数据集进行编码:
class MyEncoder(Executor): """ Encode data using SVD decomposition """ def __init__(self, **kwargs): super().__init__(**kwargs) np.random.seed(1337) # generate a random orthogonal matrix H = np.random.rand(784, 64) u, s, vh = np.linalg.svd(H, full_matrices=False) self.oth_mat = u @ vh @requests def encode(self, docs: 'DocumentArray', **kwargs): """Encode the data using an SVD decomposition :param docs: input documents to update with an embedding :param kwargs: other keyword arguments """ # reduce dimension to 50 by random orthogonal # projection content = np.stack(docs.get_attributes('content')) content = content[:, :, :, 0].reshape(-1, 784) embeds = (content / 255) @ self.oth_mat for doc, embed, cont in zip(docs, embeds, content): doc.embedding = embed doc.content = cont doc.convert_image_tensor_to_uri() doc.pop('tensor')
MyIndexer 执行器
MyIndexer
执行器执行以下任务:
-
它的构造函数创建了一个
workspace
目录来存储已索引的数据。 -
它托管一个
index
端点,该端点接收文档作为输入,并将它们组织到workspace
文件夹中。 -
它还托管了
search
端点,提供给定查询的最佳匹配结果。它接受文档和top-k
作为参数,执行余弦相似度匹配以找到top-k
结果:class MyIndexer(Executor): """ Executor with basic exact search using cosine distance """ def __init__(self, **kwargs): super().__init__(**kwargs) if os.path.exists(self.workspace + '/indexer'): self._docs = DocumentArray.load(self.workspace + '/indexer') else: self._docs = DocumentArray() @requests(on='/index') def index(self, docs: 'DocumentArray', **kwargs): """Extend self._docs :param docs: DocumentArray containing Documents :param kwargs: other keyword arguments """ self._docs.extend(docs) @requests(on=['/search', '/eval']) def search(self, docs: 'DocumentArray', parameters: Dict, **kwargs): """Append best matches to each document in docs :param docs: documents that are searched :param parameters: dictionary of pairs (parameter,value) :param kwargs: other keyword arguments """ docs.match( self._docs, metric='cosine', normalization=(1, 0), limit=int(parameters['top_k']), ) def close(self): """ Stores the DocumentArray to disk """ self._docs.save(self.workspace + '/indexer')
helper.py
helper.py
文件提供了支持 app.py
文件中逻辑元素的辅助函数。它实现了关键函数,如 index_generator
和 query_generator
,我们在 app.py
文件中使用它们来索引和查询数据。让我们一起了解这两个函数的作用。
index_generator()
该函数使用以下步骤为训练数据生成索引标签:
-
该生成器将遍历所有 60,000 个文档(图像),并逐个处理每个文档,使其准备好进行索引。
-
它从字典中提取 28x28 图像并将其反转,以便适合在网页浏览器中显示。
-
它将黑白图像转换为 RGB 图像,然后将图像转换为 Jina 的内部数据类型
Document
。 -
然后,它将标签 ID 与文档关联,并将其作为索引数据返回。
以下是 index_generator()
函数的代码:
def index_generator(num_docs: int, target: dict):
"""
Generate the index data.
:param num_docs: Number of documents to be indexed.
:param target: Dictionary which stores the data
paths
:yields: index data
"""
for internal_doc_id in range(num_docs):
# x_blackwhite.shape is (28,28)
x_blackwhite=
255-target['index']['data'][internal_doc_id]
# x_color.shape is (28,28,3)
x_color = np.stack((x_blackwhite,) * 3, axis=-1)
d = Document(content=x_color)
d.tags['id'] = internal_doc_id
yield d
query_generator()
这与 index_generator
函数类似,遵循相同的逻辑生成查询数据,稍作修改。它从数据集中获取一定数量的随机文档(根据 num_docs
参数的值),以生成查询数据。以下是 query_generator()
函数的代码:
def query_generator(num_docs: int, target: dict):
"""
Generate the query data.
:param num_docs: Number of documents to be queried
:param target: Dictionary which stores the data paths
:yields: query data
"""
for _ in range(num_docs):
num_data = len(target['query-labels']['data'])
idx = random.randint(0, num_data - 1)
# x_blackwhite.shape is (28,28)
x_blackwhite = 255 - target['query']['data'][idx]
# x_color.shape is (28,28,3)
x_color = np.stack((x_blackwhite,) * 3, axis=-1)
d = Document(
content=x_color,
tags={
'id': -1,
'query_label': float(target['query-labels']
['data'][idx][0]),
},
)
yield d
demo.xhtml
为了在网页浏览器中查看查询结果,应用程序使用 demo.xhtml
文件来渲染前端。默认情况下,运行应用程序会打开一个网页,显示查询图像及其搜索结果;如果没有打开,你可以打开 demo.xhtml
文件,该文件将出现在启动时生成的随机文件夹中。
在本节中,我们了解了 Jina 框架如何通过利用最先进的深度学习模型,使构建图像数据类型的搜索应用程序变得非常高效。相同的功能将扩展到其他数据类型,如音频、视频,甚至是 3D 网格,您将在第七章中学习到更多内容,探索 Jina 的高级用例。
接下来,我们将了解如何结合两种数据类型创建一个多模态搜索,轻松提升产品或平台的搜索体验。我们将深入探讨多模态搜索示例,该示例使用 people-image 数据集,其中包含 image-caption 对,构建一个搜索应用程序,允许你同时使用图像和文本进行查询。
使用多模态搜索
多模态搜索是 Jina 安装中另一个预构建的示例,你可以直接通过命令行运行,无需进入代码。
本示例使用 Kaggle 的公共人物图像数据集(https://www.kaggle.com/ahmadahmadzada/images2000),其中包含 2,000 个图像-标题对。这里的数据类型是多模态文档,包含多种数据类型,如包含文本和图像的 PDF 文档。Jina 使你能够以相同的方式轻松构建多模态数据类型的搜索:
-
要从命令行运行此示例,你需要安装以下依赖项:
-
pip install transformers
-
pip install torch
-
pip install torchvision
-
pip install “jina[demo]”
-
-
安装所有依赖项后,只需键入以下命令即可启动应用:
jina hello multimodal
-
输入此命令后,你将在命令行界面看到以下文本:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/nrl-sch/img/Figure_6.07_B17488.jpg
图 6.7 – 多模态搜索命令行
如果你的屏幕在命令行上显示相同的文本,这意味着你已经成功启动了 Jina 多模态示例;现在,打开 UI 并与应用进行交互吧。
默认情况下,将会打开一个包含查询和结果部分的 UI,允许你通过文本和图像进行查询,并以相同的形式获取结果。如果页面没有自动打开,你可以通过进入 jina/helloworld/multimodal/static
目录来手动打开 index.xhtml
文件。
你将看到以下网页,默认情况下或通过打开 index.xhtml
文件后可见:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/nrl-sch/img/Figure_6.08_B17488.jpg
图 6.8 – 多模态搜索界面
你已经成功启动了多模态示例应用,现在是时候与它互动并玩得开心了。
浏览代码
现在,让我们来了解应用背后的逻辑,看看 Jina 的框架如何将所有组件结合起来,形成一个功能完善的多模态搜索应用。
安装 Jina 后,按照 jina/helloworld/multimodal
路径进入聊天机器人目录。这是主目录,包含多模态搜索示例的代码:
└── multimodal
├── app.py
├── my_executors.py
├── flow_index.yml
├── flow_search.yml
├── static/
以下是你将在多模态目录中看到的文件。我们将详细介绍它们的功能:
-
app.py
:与之前的应用类似。 -
my_executors.py
:与之前的应用类似。 -
static
文件夹:此文件夹包含所有前端代码,负责在网页浏览器上呈现 UI,帮助你与应用进行交互。 -
flow_index.yml
:此文件包含索引流程的 YAML 代码,在我们第一次索引数据时运行。 -
flow_search.yml
:此文件包含搜索流程的 YAML 代码,每次我们向应用发送查询时都会运行该流程。
该应用使用 MobileNet 和 MPNet 模型来索引图像-标题对。索引过程在 CPU 上大约需要 3 分钟。然后,它会打开一个网页,你可以在其中查询多模态文档。我们还准备了一个 YouTube 视频(https://youtu.be/B_nH8GCmBfc),带你一步步体验这个演示。
app.py
当你输入jina hello multimodal
命令时,应用程序的控制权会转到app.py
文件。app.py
文件执行以下任务,确保多模态搜索应用的所有组件相互协作,从而产生期望的结果。
它做的第一件事是导入所需的库。之后,控制权转到hello_world()
函数,该函数包含脚本的主要逻辑。hello_world()
函数使用mkdir
命令创建一个随机目录来存储工件,如下载的数据。然后,它检查确保所有所需的 Python 库都已安装并导入。
注意
要运行此示例,我们需要三个主要的依赖项/Python 库:torch
、transformers
和torchvision
。
以下是理解app.py
文件功能的步骤:
-
请检查上述所有依赖项是否在你的 Python 环境中正确安装。
-
在检查这些依赖项是否正确安装后,
hello_world()
函数会调用download_data()
函数,从 Kaggle 获取并下载数据。download_data()
函数使用urllib
包来获取并保存来自给定 URL 的数据。urllib
将 URL 和文件名作为目标并下载数据。你可以参考以下代码查看我们是如何设置目标的:targets = { 'people-img: { 'url': url_of_the_data, 'filename': file_name_to_be_fetched, } }
将targets
变量传入download_data()
函数将会下载数据,并将其保存在hello_world
函数开始时创建的随机文件夹中。接着,它从 YAML 文件加载索引流程,并将图像元数据传递给该流程:
# Indexing Flow
f = Flow.load_config('flow-index.yml')
with f, open(f'{args.workdir}/people-img/meta.csv', newline='') as fp:
f.index(inputs=DocumentArray.from_csv(fp),
request_size=10, show_progress=True)
f.post(on='/dump', target_executor='textIndexer')
f.post(on='/dump', target_executor='imageIndexer')
f.post(on='/dump',
target_executor='keyValueIndexer')
-
同样,它随后从 YAML 文件加载搜索流程,并设置为从 HTML 前端获取输入查询:
# Search Flow f = Flow.load_config('flow-search.yml') # switch to HTTP gateway f.protocol = 'http' f.port_expose = args.port_expose url_html_path = 'file://' + os.path.abspath( os.path.join(cur_dir, 'static/index.xhtml')) with f: try: webbrowser.open(url_html_path, new=2) except: pass # intentional pass finally: default_logger.info( f'You should see a demo page opened in your browser,'f'if not, you may open {url_html_path} manually' ) if not args.unblock_query_flow: f.block()
在前面两个代码片段中,我们使用上下文管理器打开流程。对于这个示例,我们将使用 Web 浏览器与应用程序进行交互。它需要配置并通过 HTTP 协议在特定端口上提供该流程,使用port_expose
参数。在最后,我们使用f.block()
方法保持流程处于开放状态,以便接收搜索查询并防止它退出。
my_executors.py
如果app.py
是这个示例的大脑,那么my_executors.py
文件则包含了作为执行器的神经元,驱动核心逻辑的运作。
该多模态示例包含两种数据模态:图像和文本,分别存储在文档的tags
和uri
属性中。为了处理这两种数据模态,在索引时,我们需要使用以下执行器分别对它们进行预处理、编码和索引。
Segmenter 执行器
Segmenter
执行器以文档为输入,并将其拆分为两个块:图像块和文本块。文本块将包含纯文本数据,而图像块(在代码中我们称之为chunk_uri
)包含图像的 URI。然后,我们将它们都添加到文档块中,并将其发送到预处理阶段,如下所示:
class Segmenter(Executor):
@requests
def segment(self, docs: DocumentArray, **kwargs):
for doc in docs:
text = doc.tags['caption']
uri={os.environ["HW_WORKDIR"]}/
people-img/{doc.tags["image"]}'
chunk_text = Document(text=text,
mime_type='text/plain')
chunk_uri = Document(uri=uri,
mime_type='image/jpeg')
doc.chunks = [chunk_text, chunk_uri]
doc.uri = uri
doc.convert_uri_to_datauri()
TextCrafter
执行器
对于文本块的预处理,我们使用TextCrafter
执行器,它以文本块为输入,并返回一个扁平化的可遍历文档序列,如下所示:
class TextCrafter(Executor):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@requests()
def filter(self, docs: DocumentArray, **kwargs):
filtered_docs = DocumentArray(
d for d in docs.traverse_flat(['c']) if
d.mime_type == 'text/plain'
)
return filtered_docs
ImageCrafter
执行器
同样,对于图像块的预处理,我们使用ImageCrafter
执行器,它以图像块为输入,并返回一个扁平化的可遍历文档序列:
class ImageCrafter(Executor):
@requests(on=['/index', '/search'])
def craft(self, docs: DocumentArray, **kwargs):
filtered_docs = DocumentArray(
d for d in docs.traverse_flat(['c']) if
d.mime_type == 'image/jpeg'
)
target_size = 224
for doc in filtered_docs:
doc.convert_uri_to_image_blob()
doc.set_image_blob_shape(shape=(target_size,
target_size))
doc.set_image_blob_channel_axis(-1, 0)
return filtered_docs
TextEncoder
执行器
在预处理步骤之后,文本块的预处理数据进入TextEncoder
执行器作为输入,并产生文本嵌入作为输出。我们使用DocVectorIndexer
执行器将结果以嵌入的形式持久化。让我们从TextEncoder
的构造函数代码开始,了解它的工作原理:
class TextEncoder(Executor):
"""Transformer executor class"""
def __init__(
self,
pretrained_model_name_or_path: str=
'sentence-transformers/paraphrase-mpnet-base-v2',
pooling_strategy: str = 'mean',
layer_index: int = -1,
*args,
**kwargs,
):
super().__init__(*args, **kwargs)
self.pretrained_model_name_or_path =
pretrained_model_name_or_path
self.pooling_strategy = pooling_strategy
self.layer_index = layer_index
self.tokenizer = AutoTokenizer.from_pretrained(
self.pretrained_model_name_or_path
)
self.model = AutoModel.from_pretrained(
self.pretrained_model_name_or_path,
output_hidden_states=True
)
self.model.to(torch.device('cpu'))
为了计算嵌入,它使用预训练的sentence-transformers/paraphrase-mpnet-base-v2
模型,采用'mean'
池化策略。让我们看一下compute_embedding()
函数的代码:
def _compute_embedding(self, hidden_states: 'torch.Tensor', input_tokens: Dict):
fill_vals = {'cls': 0.0,'mean': 0.0,'max': -np.inf,'min':
np.inf}
fill_val = torch.tensor(
fill_vals[self.pooling_strategy],
device=torch.device('cpu')
)
layer = hidden_states[self.layer_index]
attn_mask =
input_tokens['attention_mask']
.unsqueeze(-1).expand_as(layer)
layer = torch.where(attn_mask.bool(), layer,
fill_val)
embeddings = layer.sum(dim=1) / attn_mask.sum(dim=1)
return embeddings.cpu().numpy()
然后,它使用encode()
函数将嵌入存储在文档的doc.embeddings
属性中:
@requests
def encode(self, docs: 'DocumentArray', **kwargs):
with torch.inference_mode():
if not self.tokenizer.pad_token:
self.tokenizer.add_special_tokens({
'pad_token': '[PAD]'})
self.model.resize_token_embeddings(len(
self. tokenizer.vocab))
input_tokens = self.tokenizer(
docs.get_attributes('content'),
padding='longest',
truncation=True,
return_tensors='pt',
)
input_tokens = {
k: v.to(torch.device('cpu')) for k, v in
input_tokens.items()
}
outputs = self.model(**input_tokens)
hidden_states = outputs.hidden_states
docs.embeddings = self._compute_embedding(
hidden_states, input_tokens)
ImageEncoder
执行器
同样,图像块的预处理数据进入ImageEncoder
执行器作为输入,并产生嵌入作为输出。我们使用DocVectorIndexer
执行器将结果以嵌入的形式持久化。让我们通过查看代码,了解ImageEncoder
的工作原理:
class ImageEncoder(Executor):
def __init__(
self,
model_name: str = 'mobilenet_v2',
pool_strategy: str = 'mean',
channel_axis: int = -1, *args, **kwargs,
):
super().__init__(*args, **kwargs)
self.channel_axis = channel_axis
self.model_name = model_name
self.pool_strategy = pool_strategy
self.pool_fn = getattr(np, self.pool_strategy)
model = getattr(models,
self.model_name)(pretrained=True)
self.model = model.features.eval()
self.model.to(torch.device('cpu'))
它使用预训练的mobilenet-v2
模型生成嵌入。为了预处理图像,它使用'mean'
池化策略,计算图像中所有像素的平均值来生成嵌入:
def _get_features(self, content):
return self.model(content)
def _get_pooling(self, feature_map: 'np.ndarray') -> 'np.ndarray':
if feature_map.ndim == 2 or self.pool_strategy is None:
return feature_map
return self.pool_fn(feature_map, axis=(2, 3))
@requests
def encode(self, docs: DocumentArray, **kwargs):
with torch.inference_mode():
_input = torch.from_numpy(docs.blobs.astype('float32'))
_features = self._get_features(_input).detach()
_features = _features.numpy()
_features = self._get_pooling(_features)
docs.embeddings = _features
在最后,encode
函数将嵌入存储在文档的doc.embeddings
属性中。
DocVectorIndexer
执行器
现在,让我们来看一下DocVectorIndexer
执行器,它将TextEncoder
和ImageEncoder
的编码结果持久化到索引中。这里,我们有两种不同的数据模态(文本和图像),所以我们需要将索引结果分别存储在两个不同的文件中。DocVectorIndexer
执行器会处理这些,它将索引后的文本嵌入存储在text.json
文件中,将图像嵌入存储在image.json
文件中,我们将在flow_index.yml
文件中将其作为index_file_name
使用。让我们看看DocVectorIndexer
的代码,了解它的具体工作原理:
class DocVectorIndexer(Executor):
def __init__(self, index_file_name: str, **kwargs):
super().__init__(**kwargs)
self._index_file_name = index_file_name
if os.path.exists(self.workspace +
f'/{index_file_name}'):
self._docs = DocumentArray.load(
self.workspace + f'/{index_file_name}')
else:
self._docs = DocumentArray()
@requests(on='/index')
def index(self, docs: 'DocumentArray', **kwargs):
self._docs.extend(docs)
@requests(on='/search')
def search(self, docs: 'DocumentArray', parameters: Dict,
**kwargs):
docs.match(
self._docs,
metric='cosine',
normalization=(1, 0),
limit=int(parameters['top_k']),
)
@requests(on='/dump')
def dump(self, **kwargs):
self._docs.save(self.workspace +
f'/{self._index_file_name}')
def close(self):
"""
Stores the DocumentArray to disk
"""
self.dump()
super().close()
它使用DocumentArray
将所有文档直接存储在磁盘上,因为我们有大量文档。它托管两个不同的端点来索引数据并打开'search'
流。它使用余弦相似度得分来查找相关文档。
KeyValueIndexer 执行器
除了DocVectorIndexer
来持久化嵌入外,我们还创建了一个KeyValueIndexer
执行器,以帮助块(文本块和图像块)找到它们的父/根文档。让我们看一下代码,以更详细地了解其功能:
class KeyValueIndexer(Executor):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if os.path.exists(self.workspace + '/kv-idx'):
self._docs = DocumentArray.load(self.workspace +
'/kv-idx')
else:
self._docs = DocumentArray()
@requests(on='/index')
def index(self, docs: DocumentArray, **kwargs):
self._docs.extend(docs)
@requests(on='/search')
def query(self, docs: DocumentArray, **kwargs):
for doc in docs:
for match in doc.matches:
extracted_doc = self._docs[match.parent_id]
extracted_doc.scores = match.scores
new_matches.append(extracted_doc)
doc.matches = new_matches
@requests(on='/dump')
def dump(self, **kwargs):
self._docs.save(self.workspace +
f'/{self._index_file_name}')
def close(self):
"""
Stores the DocumentArray to disk
"""
self.dump()
super().close()
它像DocVectorIndexer
一样使用DocumentArray
将所有文档直接存储在磁盘上。
它托管两个不同的端点来索引数据并打开搜索流。在搜索逻辑中,给定一个文档,它会遍历树查找其根/父文档。
WeightedRanker 执行器
最后,当两个块都找到了它们的父节点时,我们使用WeightedRanker
执行器聚合得分,生成最终输出。
让我们看一下代码,以更详细地了解其功能:
-
它打开一个搜索端点,将文本块和图像块的结果合并,以计算最终的相似度得分,我们将使用这个得分来确定结果:
class WeightedRanker(Executor): @requests(on='/search') def rank( self, docs_matrix: List['DocumentArray'], parameters: Dict, **kwargs ) -> 'DocumentArray': """ :param docs_matrix: list of :class:`DocumentArray` on multiple requests to get bubbled up matches. :param parameters: the parameters passed into the ranker, in this case stores :param kwargs: not used (kept to maintain interface) """ result_da = DocumentArray() for d_mod1, d_mod2 in zip(*docs_matrix): final_matches = {} # type: Dict[str, Document]
-
您可以预先分配
weight
参数,以确定哪种模态(文本或图像)对计算最终相关性得分的贡献更大。如果您将文本块的权重设置为2
,图像块的权重设置为1
,则文本块将在最终相关性得分中贡献更高的分数。 -
最终的相似度得分是通过将两个模态的余弦相似度*权重相加,然后按降序排序来计算的:
for m in d_mod1.matches: relevance_score = m.scores['cosine'].value * d_mod1.weight m.scores['relevance'].value = relevance_score final_matches[m.parent_id] = Document( m, copy=True) for m in d_mod2.matches: if m.parent_id in final_matches: final_matches[m.parent_id].scores[ 'relevance' ].value = final_matches[m.parent_id].scores['relevance'] .value + ( m.scores['cosine'].value * d_mod2.weight ) else: m.scores['relevance'].value = ( m.scores['cosine'].value * d_mod2.weight ) final_matches[m.parent_id] = Document(m, copy=True) da = DocumentArray(list(final_matches.values())) da.sorted(da, key=lambda ma: ma.scores['relevance'].value, reverse=True) d = Document(matches=da[: int(parameters['top_k'])]) result_da.append(d) return result_da
我们已经看过了执行器如何协同工作以生成结果。现在让我们看看这些执行器是如何在索引和搜索流中进行排列和使用的。
flow_index.yml
正如您已经知道的,Jina 提供了两种创建和使用流的方法。第一种是使用原生 Python,第二种是使用 YAML 文件创建流并在主app.py
文件中调用它。现在,我们将查看如何通过利用我们在上一节中讨论的单个执行器组件来创建flow_index.yml
文件。
flow_index.yml
文件使用我们在my_executors.py
文件中定义的不同执行器,并将它们排列以生成索引流。让我们通过 YAML 代码详细了解它:
-
它从
Segmenter
执行器开始,该执行器将文档分割为文本和图像块:jtype: Flow version: '1' executors: - name: segment uses: jtype: Segmenter metas: workspace: ${{ ENV.HW_WORKDIR }} py_modules: - ${{ ENV.PY_MODULE }}
-
之后,我们有两个不同的管道,一个用于文本,另一个用于图像。文本管道使用
TextCrafter
执行器预处理数据,使用TextEncoder
执行器进行编码,然后使用DocVectorIndexer
进行索引:- name: craftText uses: jtype: TextCrafter metas: py_modules: - ${{ ENV.PY_MODULE }} - name: encodeText uses: jtype: TextEncoder metas: py_modules: - ${{ ENV.PY_MODULE }} - name: textIndexer uses: jtype: DocVectorIndexer with: index_file_name: "text.json" metas: workspace: ${{ ENV.HW_WORKDIR }} py_modules: - ${{ ENV.PY_MODULE }}
-
图像管道使用
ImageCrafter
执行器预处理数据,使用ImageEncoder
执行器进行编码,然后使用DocVectorIndexer
进行索引:- name: craftImage uses: jtype: ImageCrafter metas: workspace: ${{ ENV.HW_WORKDIR }} py_modules: - ${{ ENV.PY_MODULE }} needs: segment - name: encodeImage uses: jtype: ImageEncoder metas: py_modules: - ${{ ENV.PY_MODULE }} - name: imageIndexer uses: jtype: DocVectorIndexer with: index_file_name: "image.json" metas: workspace: ${{ ENV.HW_WORKDIR }} py_modules: - ${{ ENV.PY_MODULE }}
-
在将文本和图像分别索引到
text.json
和image.json
文件后,我们使用KeyValueIndexer
将两个索引器连接起来:- name: keyValueIndexer uses: jtype: KeyValueIndexer metas: workspace: ${{ ENV.HW_WORKDIR }} py_modules: - ${{ ENV.PY_MODULE }} needs: segment - name: joinAll needs: [textIndexer, imageIndexer, keyValueIndexer]
flow_search.yml
类似于flow_index.yml
文件,我们也有一个flow_search.yml
文件,它定义了多模态示例应用程序的搜索/查询流程。让我们看一下 YAML 代码,详细了解其功能:
-
它以文本和图像的形式接收输入,并通过执行器管道分别处理它们。对于文本输入,它使用
TextCrafter
执行器对数据进行预处理,接着使用TextEncoder
执行器对文本数据进行编码,最后通过DocVectorIndexer
进行索引:jtype: Flow version: '1' with: cors: True executors: - name: craftText uses: jtype: TextCrafter metas: py_modules: - ${{ ENV.PY_MODULE }} - name: encodeText uses: jtype: TextEncoder metas: py_modules: - ${{ ENV.PY_MODULE }} - name: textIndexer uses: jtype: DocVectorIndexer with: index_file_name: "text.json" metas: workspace: ${{ ENV.HW_WORKDIR }} py_modules: - ${{ ENV.PY_MODULE }}
-
对于图像输入,它使用
ImageCrafter
执行器对数据进行预处理,接着使用ImageEncoder
执行器对图像数据进行编码,最后通过DocVectorIndexer
进行索引:- name: craftImage uses: jtype: ImageCrafter metas: workspace: ${{ ENV.HW_WORKDIR }} py_modules: - ${{ ENV.PY_MODULE }} needs: gateway - name: encodeImage uses: jtype: ImageEncoder metas: py_modules: - ${{ ENV.PY_MODULE }} - name: imageIndexer uses: jtype: DocVectorIndexer with: index_file_name: "image.json" metas: workspace: ${{ ENV.HW_WORKDIR }} py_modules: - ${{ ENV.PY_MODULE }}
-
然后,它将
TextIndexer
和ImageIndexer
的结果传递给WeightedRanker
执行器,后者计算最终的相关性得分并生成输出:- name: weightedRanker uses: jtype: WeightedRanker metas: workspace: ${{ ENV.HW_WORKDIR }} py_modules: - ${{ ENV.PY_MODULE }} needs: [ textIndexer, imageIndexer ] - name: keyvalueIndexer uses: jtype: KeyValueIndexer metas: workspace: ${{ ENV.HW_WORKDIR }} py_modules: - ${{ ENV.PY_MODULE }} needs: weightedRanker
要通过用户界面在网页浏览器中与多模态应用程序交互,可以使用index.xhtml
文件,该文件位于static
文件夹中。运行应用程序应该默认打开 HTML 文件,但如果没有打开,可以从static
文件夹中手动打开index.xhtml
文件。
总结
在本章中,我们已经讲解了如何将之前章节中学习的所有组件和概念组合在一起。我们引导你完成了使用 Jina 构建不同数据类型的基本搜索示例的过程,包括文本到文本搜索、图像到图像搜索以及多模态搜索,后者结合了文本和图像。本章中学习的内容将作为构建模块,用于第七章,探索 Jina 的高级用例,在该章节中,你将学习如何使用 Jina 构建高级示例。
在下一章中,我们将继续沿着相同的路线,展示如何利用目前所学的内容,使用 Jina 构建高级搜索应用程序。