Elasticsearch:Serarch tutorial - 使用 Python 进行搜索 (三)

这个是继上一篇文章 "Elasticsearch:Serarch tutorial - 使用 Python 进行搜索 (二)" 的续篇。在今天的文章中,本节将向你介绍一种不同的搜索方式,利用机器学习 (ML) 技术来解释含义和上下文。

向量搜索

嵌入 (embeddings) 简介

在机器学习中,嵌入是表示现实世界对象(例如单词、句子、图像或视频)的向量(数字数组)。 这些嵌入的有趣特性是,表示相似或相关的现实世界实体的两个嵌入也会共享一些相似性,因此可以比较嵌入,并且可以计算它们之间的距离。

当具体考虑搜索应用程序时,在向量空间中执行嵌入搜索往往会找到与概念更相关的结果,而不是与搜索提示中输入的确切关键字相关的结果。

在本教程的这一部分中,你将学习如何使用免费的机器学习模型生成嵌入,然后你将使用 Elasticsearch 的想量数据库支持来存储和搜索这些嵌入。 最后,你还将结合想量和全文搜索结果,并创建一个强大的混合搜索解决方案,提供两种方法的优点。

生成嵌入

在本节中,你将了解可用于生成文本嵌入的最方便的选项之一,该选项基于 SentenceTransformers框架。

当您探索并熟悉嵌入的使用时,建议使用 SentenceTransformers ,因为此框架下可用的模型可以安装在您的计算机上,无需 GPU 即可表现良好,并且可以免费使用。

安装SentenceTransformers

SentenceTransformers 框架作为 Python 包安装。 确保你的 Python 虚拟环境已激活,然后在终端上运行以下命令来安装此框架:

pip install sentence-transformers

与往常一样,每当你向项目添加新的依赖项时,最好更新你的需求文件:

pip freeze > requirements.txt

选择模型

下一个任务是决定用于嵌入生成的机器学习模型。 文档中有预训练模型的列表。 由于 SentenceTransformers 是一个非常流行的框架,因此也有一些由与该框架不直接相关的研究人员创建的兼容模型。 要查看可用模型的完整列表,你可以检查 HuggingFace 上的 SentenceTransformers 标签。

就本教程而言,无需过度考虑模型选择,因为任何模型就足够了。 SentenceTransformers 文档包含以下有关其预训练模型的注释:

all-* 模型根据所有可用训练数据(超过 10 亿个训练对)进行训练,并被设计为通用模型。 all-mpnet-base-v2 模型提供最佳质量,而 all-MiniLM-L6-v2 速度快 5 倍,但仍提供良好的质量。

这似乎表明他们的全 MiniLM-L6-v2 模型是一个不错的选择,在速度和质量之间提供了良好的折衷,所以让我们使用这个模型。 在表中找到该模型,然后单击 "info" 图标以查看有关它的一些信息。

关于您选择的模型,需要注意的一个有趣的细节是生成的嵌入的长度,或者换句话说,生成的向量将具有多少个数字或维度。 这很重要,因为它直接影响你需要的存储量。 在全 MiniLM-L6-v2 的情况下,生成的向量具有 384 维。

加载模型

以下 Python 代码演示了如何加载模型。 你可以在 Python shell 中尝试此操作。

ini 复制代码
1.  from sentence_transformers import SentenceTransformer
2.  model = SentenceTransformer('all-MiniLM-L6-v2')
python 复制代码
1.  (.venv) $ python3
2.  Python 3.11.6 (v3.11.6:8b6ee5ba3b, Oct  2 2023, 11:18:21) [Clang 13.0.0 (clang-1300.0.29.30)] on darwin
3.  Type "help", "copyright", "credits" or "license" for more information.
4.  >>> from sentence_transformers import SentenceTransformer
5.  >>> model = SentenceTransformer('all-MiniLM-L6-v2')
6.  >>> 

第一次执行此操作时,模型将被下载并安装在你的虚拟环境中,因此调用可能需要一些时间才能返回。 一旦安装了模型,实例化它应该不会花很长时间。

生成嵌入

模型实例化后,你现在就可以生成嵌入了。 为此,请将源文本传递给 model.encode() 方法:

ini 复制代码
embedding = model.encode('The quick brown fox jumps over the lazy dog')

结果是一个包含构成嵌入的所有数字的数组。 你还记得,所选模型生成的嵌入有 384 个维度,因此这是 embedding 数组的长。

在 Elasticsearch 中存储嵌入

Elasticsearch 提供对存储和检索向量的全面支持,这使其成为处理嵌入的理想数据库。

字段类型

在本教程的全文搜索一章中,你学习了如何创建包含多个字段的索引。 当时有人提到,Elasticsearch 在很大程度上可以根据数据本身自动确定每个字段使用的最佳类型。 尽管 Elasticsearch 8.11 能够自动映射某些向量类型,但在本章中,你将显式定义此类型,以此作为了解有关 Elasticsearch 中类型映射的更多信息的机会。

检索类型映射

与索引中每个字段关联的类型是在称为映射的过程中确定的,该过程可以是动态的或显式的。 本教程的全文搜索部分中创建的映射都是由 Elasticsearch 动态生成的。

Elasticsearch 客户端提供 get_mapping 方法,该方法返回对给定索引有效的类型映射。 如果你想自行探索这些映射,请启动 Python shell 并输入以下代码:

ini 复制代码
1.  from app import es
2.  es.es.indices.get_mapping(index='my_documents')

从这里你可以看到 created_on 和 updated_at 字段自动被识别为 date 字段类型,而其他每个字段都被识别为 text 类型。 当尝试决定类型时,Elasticsearch 首先检查数据的类型,这有助于为字段分配数字、布尔和对象类型。 当字段数据是字符串时,它还会尝试查看数据是否与日期模式匹配。 如果需要,还可以针对数字启用基于模式的字符串检测。

文本(text)字段具有带有 keyword 条目的字段定义。 这称为子字段,是在适当时可用的替代或辅助类型。 在 Elasticsearch 中,动态输入的文本字段被赋予 keyword子字段。 你已经使用 category.keyword 子字段对给定类别执行精确搜索。 为了避免添加子字段,可以给出 text 或 keyword 的显式映射,然后这将是主要且唯一的类型。

将 vector 字段添加到索引

让我们向索引添加一个新字段,其中将存储每个文档的嵌入。

显式映射的结构与 Elasticsearch 客户端的 get_mapping() 方法返回的响应的 mappings 键匹配。 仅需要给出需要显式键入的字段,因为映射中未包含的任何字段将继续像以前一样动态键入。

下面你可以看到 Search 类的 create_index() 方法的新版本,添加了一个名为 embedding 的显式类型字段。 在 search.py 中替换此方法:

search.py

ruby 复制代码
1.  class Search:
2.      # ...

4.      def create_index(self):
5.          self.es.indices.delete(index='my_documents', ignore_unavailable=True)
6.          self.es.indices.create(index='my_documents', mappings={
7.              'properties': {
8.                  'embedding': {
9.                      'type': 'dense_vector',
10.                  }
11.              }
12.          })

正如你所看到的,embedding 字段被赋予了 dense_vector 类型,这是存储嵌入时的适当类型。 稍后你将了解另一种类型的向量,即 sparce_vector,它在其他类型的语义搜索应用程序中很有用。

dense_vector 类型接受一些参数,所有这些参数都是可选的。

  • dims:将存储的向量的大小。 从版本 8.11 开始,插入第一个文档时会自动分配尺寸。
  • index:必须设置为 True 以指示应为向量建立索引以进行搜索。 这是默认设置。
  • similarity:比较向量时使用的距离函数。 最常见的两个是点积和余弦。 点积效率更高,但需要对向量进行归一化。 默认值为余弦。

向文档添加嵌入

在上一节中,你学习了如何使用 SentenceTransformers 框架和全 MiniLM-L6-v2 模型生成嵌入。 现在是时候将模型集成到应用程序中了。

首先,可以在 Search 类构造函数中实例化模型:

search.py

python 复制代码
1.  # ...
2.  from sentence_transformers import SentenceTransformer

4.  # ...

6.  class Search:
7.      def __init__(self):
8.          url = f"https://{elastic_user}:{elastic_password}@{elastic_endpoint}:9200"
9.          self.model = SentenceTransformer('all-MiniLM-L6-v2')
10.          self.es = Elasticsearch(url, ca_certs = "./http_ca.crt", verify_certs = True) 
11.          client_info = self.es.info()
12.          print('Connected to Elasticsearch!')
13.          pprint(client_info.body)

正如你在本教程的全文搜索部分中回想的那样,Search 类具有 insert_document() 和 insert_documents() 方法,用于分别将单个和多个文档插入到索引中。 这两种方法现在需要生成与每个文档对应的嵌入。

下一个代码块显示了这两个方法的新版本,以及返回嵌入的新 get_embedding() 辅助方法。

ruby 复制代码
1.  class Search:
2.      # ...

4.      def get_embedding(self, text):
5.          return self.model.encode(text)

7.      def insert_document(self, document):
8.          return self.es.index(index='my_documents', document={
9.              **document,
10.              'embedding': self.get_embedding(document['summary']),
11.          })

13.      def insert_documents(self, documents):
14.          operations = []
15.          for document in documents:
16.              operations.append({'index': {'_index': 'my_documents'}})
17.              operations.append({
18.                  **document,
19.                  'embedding': self.get_embedding(document['summary']),
20.              })
21.          return self.es.bulk(operations=operations)

修改后的方法将新的 embedding 字段添加到要插入的文档中。 嵌入是从每个文档的 summary 字段生成的。 一般来说,嵌入是从句子或短段落生成的,因此在这种情况下,summary 是一个理想的使用字段。 其他选项是名称字段,其中包含文档的 name,或者可能是文档 body 的前几句话。

通过这些更改,可以重建索引,以便它存储每个文档的嵌入。 要重建索引,请使用以下命令:

flask reindex
python 复制代码
1.  (.venv) $ flask reindex
2.  Connected to Elasticsearch!
3.  {'cluster_name': 'elasticsearch',
4.   'cluster_uuid': 'SXGzrN4dSXW1t0pkWXGfjg',
5.   'name': 'liuxgm.local',
6.   'tagline': 'You Know, for Search',
7.   'version': {'build_date': '2023-11-04T10:04:57.184859352Z',
8.               'build_flavor': 'default',
9.               'build_hash': 'd9ec3fa628c7b0ba3d25692e277ba26814820b20',
10.               'build_snapshot': False,
11.               'build_type': 'tar',
12.               'lucene_version': '9.8.0',
13.               'minimum_index_compatibility_version': '7.0.0',
14.               'minimum_wire_compatibility_version': '7.17.0',
15.               'number': '8.11.0'}}
16.  Index with 15 documents created in 100 milliseconds.

如果你需要提醒,flask reindex 命令是在 app.py 中的 reindex() 函数中实现的。 它调用 Search 类的 reindex() 方法,该方法又调用 create_index(),然后将 data.json 文件中的所有数据传递给 insert_documents()。

通过运行上面的命令,我们可以在 Kibana 中进行查看:

k 最近邻 (kNN) 搜索

k 最近邻 (kNN) 算法对密集向量类型的字段执行相似性搜索。 这种类型的搜索更合适地称为 "近似 kNN",接受向量或嵌入作为搜索项,并查找索引中接近的条目。 这是

在本节中,你将学习如何使用上一节中创建的文档嵌入来运行 kNN 搜索。

knn 查询

在本教程的全文搜索部分中,你了解了传递给 Elasticsearch 客户端的 search() 方法的查询选项。 当搜索向量时,使用 knn 选项。

下面你可以在 app.py 中看到新版本的 handle_search() 函数,它对用户在搜索表单中输入的查询运行 kNN 搜索。

ini 复制代码
1.  @app.post('/')
2.  def handle_search():
3.      query = request.form.get('query', '')
4.      filters, parsed_query = extract_filters(query)
5.      from_ = request.form.get('from_', type=int, default=0)

7.      results = es.search(
8.          knn={
9.              'field': 'embedding',
10.              'query_vector': es.get_embedding(parsed_query),
11.              'num_candidates': 50,
12.              'k': 10,
13.          }, size=5, from_=from_
14.      )
15.      return render_template('index.html', results=results['hits']['hits'],
16.                             query=query, from_=from_,
17.                             total=results['hits']['total']['value'])

在此版本的函数中,查询选项已替换为 knn。 用于分页的 size 和 from_ 选项保持不变,函数和 index.html 模板中的其他所有内容也与以前相同。

knn 搜索选项接受许多配置搜索的参数:

  • field:索引中要搜索的字段。 该字段必须具有密集向量类型。
  • query_vector:要搜索的嵌入。 这应该是从搜索文本生成的嵌入。
  • num_candidates:每个分片要考虑的候选文档数量。 Elasticsearch 从每个分片中检索这么多候选者,将它们组合成一个列表,然后找到最接近的 "k" 个作为结果返回。
  • k:要返回的结果数。 该数字对性能有直接影响,因此应尽可能小。 此选项中传递的值必须小于 num_candidates。

根据上面代码中使用的设置,将返回 10 个最佳匹配结果。

欢迎你尝试这个新版本的应用程序。 以下是两个很好的例子,可以让你了解这种类型的搜索有多么有用:

  • 搜索 "holiday"(英式英语,相当于美式英语中的 "vacation")时,kNN 搜索会返回文档 "Vacation Policy" 作为顶部结果,即使单词 "holiday" 本身没有出现在文档中。
  • 搜索 "cats and dogs" 或任何其他与宠物相关的术语会将 "Office Pet Policy" 文档作为顶部结果,即使文档摘要未提及任何特定宠物。

在 kNN 查询中使用过滤器

搜索查询(如本教程全文部分中所定义)允许用户在搜索文本的任何位置使用语法 "category:" 来请求使用特定类别。 app.py 中的 extract_filters() 函数负责查找这些过滤表达式并将其与搜索查询分离。 在上一节中的 handle_search() 函数版本中,没有使用 filters 变量,因此类别过滤器被忽略。

幸运的是,knn 选项还支持过滤。 filter 选项实际上接受相同类型的过滤器,因此可以将过滤器直接插入到 knn 查询中,与 extract_filters() 函数返回的过滤器完全相同:

ini 复制代码
1.  @app.post('/')
2.  def handle_search():
3.      query = request.form.get('query', '')
4.      filters, parsed_query = extract_filters(query)
5.      from_ = request.form.get('from_', type=int, default=0)

7.      results = es.search(
8.          knn={
9.              'field': 'embedding',
10.              'query_vector': es.get_embedding(parsed_query),
11.              'k': 10,
12.              'num_candidates': 50,
13.              **filters,
14.          }, size=5, from_=from_
15.      )
16.      return render_template('index.html', results=results['hits']['hits'],
17.                             query=query, from_=from_,
18.                             total=results['hits']['total']['value'])

此版本的 handle_search() 函数具有与全文搜索版本相同的功能,使用向量量搜索而不是基于关键字的搜索来实现。

在下一节中,你将学习如何组合这两种不同搜索方法的结果。

混合搜索:结合全文和 kNN 结果

你现在已经看到了两种不同的方法来搜索文档集合,每种方法都有其独特的优点。 如果其中一种方法满足你的需求,那么你不需要任何其他方法,但在许多情况下,每种搜索方法都会返回其他方法可能会错过的有价值的结果,因此最好的选择是提供组合结果集。

对于这些情况,Elasticsearch 提供了倒数排名融合 (RRF),这是一种将两个或多个列表的结果合并到一个列表中的算法。

RRF 的运作方式

Elasticsearch 将 RRF 算法集成到搜索查询中。 考虑以下示例,其中包含分别请求全文和向量搜索的 query 和 knn 部分,以及将它们组合成单个结果列表的 rrf 部分。

markdown 复制代码
1.  self.es.search(
2.      query={
3.          # full-text search query here
4.      },
5.      knn={
6.          # vector search query here
7.      },
8.      rank={
9.          "rrf": {}
10.      }
11.  )

虽然 RRF 在没有任何配置的情况下工作得相当好,但可以调整一些参数以提供最佳结果。 请查阅文档以详细了解这些内容。

RRF 实施

为了使组合搜索能够从全文搜索和向量搜索方法返回结果,必须恢复之前在 handle_search() 函数中使用的全文搜索逻辑。 要实现混合搜索策略,search() 方法必须接收查询和 knn 参数,每个参数请求一个单独的查询。 还添加了如上所示的排名部分,以将结果合并到单个排名列表中。

这是实现混合搜索策略的 handle_search() 版本:

ini 复制代码
1.  @app.post('/')
2.  def handle_search():
3.      query = request.form.get('query', '')
4.      filters, parsed_query = extract_filters(query)
5.      from_ = request.form.get('from_', type=int, default=0)

7.      if parsed_query:
8.          search_query = {
9.              'must': {
10.                  'multi_match': {
11.                      'query': parsed_query,
12.                      'fields': ['name', 'summary', 'content'],
13.                  }
14.              }
15.          }
16.      else:
17.          search_query = {
18.              'must': {
19.                  'match_all': {}
20.              }
21.          }

23.      results = es.search(
24.          query={
25.              'bool': {
26.                  **search_query,
27.                  **filters
28.              }
29.          },
30.          knn={
31.              'field': 'embedding',
32.              'query_vector': es.get_embedding(parsed_query),
33.              'k': 10,
34.              'num_candidates': 50,
35.              **filters,
36.          },
37.          rank={
38.              'rrf': {}
39.          },
40.          size=5,
41.          from_=from_,
42.      )
43.      return render_template('index.html', results=results['hits']['hits'],
44.                             query=query, from_=from_,
45.                             total=results['hits']['total']['value'])

在此版本中,结合了每种搜索方法的最佳结果。 单击此处查看包含这些更改的完整应用。

bash 复制代码
git clone https://github.com/liu-xiao-guo/search-tutorial-2
相关推荐
Elastic 中国社区官方博客7 分钟前
使用 Elastic AI Assistant for Search 和 Azure OpenAI 实现从 0 到 60 的转变
大数据·人工智能·elasticsearch·microsoft·搜索引擎·ai·azure
Karoku0666 小时前
【企业级分布式系统】ELK优化
运维·服务器·数据库·elk·elasticsearch
上优8 小时前
uniapp 选择 省市区 省市 以及 回显
大数据·elasticsearch·uni-app
jwolf210 小时前
Elasticsearch向量搜索:从语义搜索到图搜图只有一步之遥
elasticsearch·搜索引擎·ai
你可以叫我仔哥呀11 小时前
ElasticSearch学习笔记三:基础操作(一)
笔记·学习·elasticsearch
hummhumm12 小时前
第 25 章 - Golang 项目结构
java·开发语言·前端·后端·python·elasticsearch·golang
java1234_小锋15 小时前
Elasticsearch中的节点(比如共20个),其中的10个选了一个master,另外10个选了另一个master,怎么办?
大数据·elasticsearch·jenkins
Elastic 中国社区官方博客15 小时前
Elasticsearch 开放推理 API 增加了对 IBM watsonx.ai Slate 嵌入模型的支持
大数据·数据库·人工智能·elasticsearch·搜索引擎·ai·全文检索
我的运维人生15 小时前
Elasticsearch实战应用:构建高效搜索与分析平台
大数据·elasticsearch·jenkins·运维开发·技术共享
Mephisto.java20 小时前
【大数据学习 | Spark】Spark的改变分区的算子
大数据·elasticsearch·oracle·spark·kafka·memcache