这篇文章是继之前文章 "Elasticsearch:搜索教程 - 全文搜索(一)"。在继续这篇文章之前,你必须仔细阅读之前的那篇文章。
Embedding 简介
在 Machine Learning 中,embedding 是一个 vector(数字数组),用于表示现实世界中的对象,例如 words、sentences、images 或 videos。
embedding 的一个重要特性是:如果两个 embeddings 表示的是相似或相关的现实世界实体,那么它们本身也会具有一定的相似性。因此,可以对 embeddings 进行比较,并计算它们之间的距离。
从 search application 的角度来看,在 vector space 中搜索 embeddings,通常能够找到与概念相关的结果,而不仅仅是与搜索提示中输入的精确 keywords 匹配的结果。
在本教程的这一部分中,你将学习如何使用免费提供的 machine learning models 生成 embeddings,然后使用 Elasticsearch 的 vector database 功能来存储和搜索这些 embeddings。
在本部分的最后,你还将学习如何将 vector search 与 full-text search 的结果结合起来,构建一个功能强大的 hybrid search 解决方案,从而兼顾这两种方法的优势。
生成 Embedding
在本节中,你将学习目前用于生成 text embeddings 最方便的方法之一,它基于 SentenceTransformers framework。
在你探索并熟悉 embeddings 的使用过程中,推荐使用 SentenceTransformers。该 framework 提供的 models 可以安装到你的计算机上,无需 GPU 也能获得不错的性能,并且可以免费使用。
安装 SentenceTransformers
SentenceTransformers framework 以 Python package 的形式提供。
请确保你的 Python virtual environment 已激活,然后在 terminal 中运行以下命令安装该 framework:
go
`pip install sentence-transformers`AI写代码
与往常一样,每当你向项目添加新的 dependencies 时,最好更新你的 requirements file:
go
`pip freeze > requirements.txt`AI写代码
选择一个 Model
下一项任务是决定使用哪个 machine learning model 来生成 embeddings。
documentation 中提供了一份 pretrained models 列表。由于 SentenceTransformers 是一个非常流行的 framework,因此除了官方提供的 models 外,还有许多由其他研究人员创建的兼容 models。要查看所有可用的 models,可以访问 HuggingFace 上带有 SentenceTransformers 标签的 models。
对于本教程来说,不需要过度纠结 model 的选择,因为任何 model 都能够满足需求。
SentenceTransformers documentation 对其 pretrained models 给出了如下说明:
"
all-*models 使用所有可用的训练数据(超过 10 亿个 training pairs)进行训练,旨在作为通用模型。all-mpnet-base-v2model 提供最佳质量,而all-MiniLM-L6-v2的速度快 5 倍,同时仍能提供良好的质量。"
这段说明表明,all-MiniLM-L6-v2 model 在速度和质量之间取得了不错的平衡,因此我们将使用这个 model。
请在表格中找到这个 model,然后点击 "info" 图标查看它的相关信息。

关于所选 model,还有一个值得了解的重要细节,那就是它生成的 embeddings 的长度,换句话说,就是生成的 vectors 包含多少个数字(dimensions)。
这一点很重要,因为它会直接影响所需的存储空间。
对于 all-MiniLM-L6-v2,生成的 vectors 具有 384 个 dimensions。
加载 Model
下面的 Python 代码演示了如何加载该 model。你可以在 Python shell 中尝试运行这段代码。
ini
`
1. from sentence_transformers import SentenceTransformer
2. model = SentenceTransformer('all-MiniLM-L6-v2')
`AI写代码
第一次执行此操作时,模型会被下载并安装到你的虚拟环境中,因此调用可能需要一些时间才能返回。一旦模型安装完成,实例化它应该不会花费太长时间。
生成 Embeddings
模型实例化后,你现在已经可以生成一个 embedding。要执行此操作,将源文本传递给模型.encode() 方法:
ini
`embedding = model.encode('The quick brown fox jumps over the lazy dog')`AI写代码
结果是一个包含组成该 embedding 的所有数字的数组。正如你所记得的,所选模型生成的 embeddings 具有 384 个维度,因此这就是 embedding 数组的长度。
在 Elasticsearch 中存储 Embeddings
Elasticsearch 完全支持存储和检索向量,这使其成为处理 embeddings 的理想数据库。
字段类型
在本教程的全文搜索章节中,你已经学习了如何创建一个包含多个字段的索引。当时提到,Elasticsearch 大多数情况下可以根据数据本身自动确定每个字段最适合使用的类型。尽管 Elasticsearch 8.11 能够自动映射某些向量类型,但在本章节中,你将显式定义此类型,以此作为学习 Elasticsearch 中类型映射更多知识的机会。
检索类型映射
索引中每个字段关联的类型是通过一个称为映射(mapping)的过程确定的,该过程可以是动态的,也可以是显式的。本教程全文搜索部分创建的映射都是由 Elasticsearch 动态生成的。
Elasticsearch 客户端提供了一个 get_mapping 方法,它会返回某个给定索引当前生效的类型映射。如果你想自行探索这些映射,请启动一个 Python shell,并输入以下代码:
ini
`
1. from app import es
2. es.es.indices.get_mapping(index='my_documents')
`AI写代码
get_mapping() 方法返回的响应是一个字典,其中包含索引中每个字段的信息。为了方便起见,下面是本教程全文搜索部分创建的 my_documents 索引中这些信息的格式化结构:
bash
`
1. {
2. "my_documents": {
3. "mappings": {
4. "properties": {
5. "category": {
6. "type": "text",
7. "fields": {
8. "keyword": {
9. "type": "keyword",
10. "ignore_above": 256
11. }
12. }
13. },
14. "content": {
15. "type": "text",
16. "fields": {
17. "keyword": {
18. "type": "keyword",
19. "ignore_above": 256
20. }
21. }
22. },
23. "created_on": {
24. "type": "date"
25. },
26. "name": {
27. "type": "text",
28. "fields": {
29. "keyword": {
30. "type": "keyword",
31. "ignore_above": 256
32. }
33. }
34. },
35. "rolePermissions": {
36. "type": "text",
37. "fields": {
38. "keyword": {
39. "type": "keyword",
40. "ignore_above": 256
41. }
42. }
43. },
44. "summary": {
45. "type": "text",
46. "fields": {
47. "keyword": {
48. "type": "keyword",
49. "ignore_above": 256
50. }
51. }
52. },
53. "updated_at": {
54. "type": "date"
55. },
56. "url": {
57. "type": "text",
58. "fields": {
59. "keyword": {
60. "type": "keyword",
61. "ignore_above": 256
62. }
63. }
64. }
65. }
66. }
67. }
68. }
`AI写代码收起代码块
从这里你可以看到,created_on 和 updated_at 字段被自动设置为 date 类型,而其他所有字段都被设置为 text 类型。在尝试决定类型时,Elasticsearch 首先检查数据的类型,这有助于它为字段分配 numeric、boolean 和 object 类型。当字段数据是字符串时,它还会尝试查看数据是否匹配日期模式。如果需要,也可以为数字启用基于模式的字符串检测。
text 字段具有一个带有 keyword 条目的 fields 定义。这称为子字段,是一种在适当情况下可用的替代类型或辅助类型。在 Elasticsearch 中,动态类型的 text 字段会被赋予一个 keyword 子字段。你已经使用 category.keyword 子字段来对给定类别执行精确搜索。为了避免添加子字段,可以提供一个显式的 text 或 keyword 映射,然后该类型将成为主要且唯一的类型。
向索引添加向量字段
让我们向索引添加一个新字段,用于存储每个文档的 embedding。
显式映射的结构与 Elasticsearch 客户端的 get_mapping() 方法返回的响应中的 mappings 键相匹配。只需要提供需要显式指定类型的字段,因为映射中未包含的任何字段仍会像之前一样被动态设置类型。
下面你可以看到 Search 类中 create_index() 方法的新版本,它添加了一个名为 embedding 的显式类型字段。在 search.py 中替换此方法:
python
`
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. })
`AI写代码
正如你所看到的,embedding 字段被赋予了 dense_vector 类型,这是存储 embeddings 时合适的类型。稍后你将学习另一种向量类型 sparse_vector,它在其他类型的语义搜索应用中很有用。
dense_vector 类型接受几个参数,所有参数都是可选的。
- dims:将要存储的向量的大小。从 8.11 版本开始,当第一个文档被插入时,维度会被自动分配。
- index:必须设置为 True,以表示这些向量应该被索引用于搜索。这是默认值。
- similarity:比较向量时使用的距离函数。最常见的两个是 dot_product 和 cosine。点积效率更高,但它要求向量已经归一化。默认值是 cosine。
向文档添加 Embeddings
在上一节中,你已经学习了如何使用 SentenceTransformers 框架和 all-MiniLM-L6-v2 模型生成 embeddings。现在是时候将该模型集成到应用程序中了。
首先,可以在 Search 类的构造函数中实例化该模型:
python
`
1. # ...
2. from sentence_transformers import SentenceTransformer
4. # ...
6. class Search:
7. def __init__(self):
8. self.model = SentenceTransformer('all-MiniLM-L6-v2')
9. self.es = Elasticsearch(cloud_id=os.environ['ELASTIC_CLOUD_ID'],
10. api_key=os.environ['ELASTIC_API_KEY'])
11. client_info = self.es.info()
12. print('Connected to Elasticsearch!')
13. pprint(client_info.body)
15. # ...
`AI写代码
正如你在本教程的全文搜索部分中记得的那样,Search 类具有 insert_document() 和 insert_documents() 方法,分别用于向索引中插入单个文档和多个文档。这两个方法现在需要生成与每个文档对应的 embeddings。
下面的代码块展示了这两个方法的新版本,以及一个新的 get_embedding() 辅助方法,该方法返回一个 embedding。
python
`
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)
`AI写代码
修改后的方法向要插入的文档添加了新的 embedding 字段。该 embedding 是根据每个文档的 summary 字段生成的。通常情况下,embeddings 是从句子或短段落中生成的,因此在这种情况下,summary 是一个理想的使用字段。其他可选项包括 name 字段,它包含文档的标题,或者文档正文中的前几句话。
完成这些更改后,可以重新构建索引,使其为每个文档存储一个 embedding。要重新构建索引,请使用以下命令:
arduino
`flask run`AI写代码
当我们重新运行上面的命令后,这次 flask 的启动比较慢。这是因为它需要下载模型 all-MiniLM-L6-v2。
go
`flask reindex`AI写代码
bash
`
1. (.venv) $ flask reindex
2. Warning: You are sending unauthenticated requests to the HF Hub. Please set a HF_TOKEN to enable higher rate limits and faster downloads.
3. Loading weights: 100%|██████████████████████████████████████████████████████████| 103/103 [00:00<00:00, 30637.07it/s]
4. Connected to Elasticsearch!
5. {'cluster_name': 'elasticsearch',
6. 'cluster_uuid': 'G5xn8TT3StGeUFd9WJMy5A',
7. 'name': 'liuxgn.local',
8. 'tagline': 'You Know, for Search',
9. 'version': {'build_date': '2026-04-30T15:05:34.751113474Z',
10. 'build_flavor': 'default',
11. 'build_hash': '2e8528e92361c3399724226deaf2b46f933e925b',
12. 'build_snapshot': False,
13. 'build_type': 'tar',
14. 'lucene_version': '10.4.0',
15. 'minimum_index_compatibility_version': '8.0.0',
16. 'minimum_wire_compatibility_version': '8.19.0',
17. 'number': '9.4.0'}}
18. reindexing ...
19. Creating the index ...
20. Index with 15 documents created in 205 milliseconds.
`AI写代码
如果你有 Kibana 在运行,那么你可以检查最新的 mapping:
bash
`GET my_documents/_mapping`AI写代码

如果你需要提醒一下,flask reindex 命令是在 app.py 文件中的 reindex() 函数里实现的。它调用 Search 类的 reindex() 方法,而该方法依次调用 create_index(),然后将 data.json 文件中的所有数据传递给 insert_documents()。
k 近邻(kNN)搜索
k 近邻(kNN)算法会对 dense_vector 类型的字段执行相似度搜索。这种搜索更准确地称为"近似 kNN",它接受一个向量或 embedding 作为搜索词,并在索引中查找接近该向量的条目。
在本节中,你将学习如何使用上一节中创建的文档 embeddings 执行 kNN 搜索。
knn 查询
在本教程的全文搜索部分中,你已经学习了传递给 Elasticsearch 客户端 search() 方法的 query 选项。在搜索向量时,则使用 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. },
14. size=5,
15. from_=from_
16. )
17. return render_template('index.html', results=results['hits']['hits'],
18. query=query, from_=from_,
19. total=results['hits']['total']['value'])
`AI写代码
在这个版本的函数中,query 选项被替换为 knn。用于分页的 size 和 from_ 选项保持不变,函数中的其他所有内容以及 index.html 模板也与之前相同。
knn 搜索选项接受多个用于配置搜索的参数:
- field:要搜索的索引字段。该字段必须具有 dense_vector 类型。
- query_vector:用于搜索的 embedding。它应该是根据搜索文本生成的 embedding。
- 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 变量没有被使用,因此 category 过滤器会被忽略。
幸运的是,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. },
15. size=5,
16. from_=from_
17. )
18. return render_template('index.html', results=results['hits']['hits'],
19. query=query, from_=from_,
20. total=results['hits']['total']['value'])
`AI写代码
聚合也可以很好地用于 kNN 查询,因此也可以将它们重新添加回来:
css
`
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. },
15. aggs={
16. 'category-agg': {
17. 'terms': {
18. 'field': 'category.keyword',
19. }
20. },
21. 'year-agg': {
22. 'date_histogram': {
23. 'field': 'updated_at',
24. 'calendar_interval': 'year',
25. 'format': 'yyyy',
26. },
27. },
28. },
29. size=5,
30. from_=from_
31. )
32. aggs = {
33. 'Category': {
34. bucket['key']: bucket['doc_count']
35. for bucket in results['aggregations']['category-agg']['buckets']
36. },
37. 'Year': {
38. bucket['key_as_string']: bucket['doc_count']
39. for bucket in results['aggregations']['year-agg']['buckets']
40. if bucket['doc_count'] > 0
41. },
42. }
43. return render_template('index.html', results=results['hits']['hits'],
44. query=query, from_=from_,
45. total=results['hits']['total']['value'], aggs=aggs)
`AI写代码
这个版本的 handle_search() 函数具有与全文搜索版本相同的功能,只不过使用向量搜索代替了基于关键词的搜索。
在下一节中,你将学习如何结合这两种不同搜索方法的结果。
混合搜索:结合全文搜索和 kNN 结果
现在你已经了解了两种不同的文档集合搜索方法,每种方法都有其独特的优势。如果其中一种方法符合你的需求,那么你不需要其他方法,但在很多情况下,每种搜索方法都会返回另一种方法可能遗漏的有价值结果,因此最佳选择是提供一个组合后的结果集。
针对这些情况,Elasticsearch 提供了 Reciprocal Rank Fusion,这是一种将两个或多个列表中的结果合并为一个列表的算法。
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. )
`AI写代码
虽然 RRF 在不进行任何配置的情况下对较短的结果列表效果相当好,但有一些参数可以调整,以提供最佳结果。请查阅文档以详细了解这些参数。
RRF 实现
要启用一个同时返回全文搜索和向量搜索方法结果的组合搜索,需要恢复之前在 handle_search() 函数中使用的全文搜索逻辑。为了实现混合搜索策略,search() 方法必须同时接收 query 和 knn 参数,每个参数分别请求一个独立的查询。还需要添加上面所示的 rank 部分,以将结果合并为一个排序列表。
下面是实现混合搜索策略的 handle_search() 函数版本:
css
`
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. aggs={
41. 'category-agg': {
42. 'terms': {
43. 'field': 'category.keyword',
44. }
45. },
46. 'year-agg': {
47. 'date_histogram': {
48. 'field': 'updated_at',
49. 'calendar_interval': 'year',
50. 'format': 'yyyy',
51. },
52. },
53. },
54. size=5,
55. from_=from_,
56. )
57. aggs = {
58. 'Category': {
59. bucket['key']: bucket['doc_count']
60. for bucket in results['aggregations']['category-agg']['buckets']
61. },
62. 'Year': {
63. bucket['key_as_string']: bucket['doc_count']
64. for bucket in results['aggregations']['year-agg']['buckets']
65. if bucket['doc_count'] > 0
66. },
67. }
68. return render_template('index.html', results=results['hits']['hits'],
69. query=query, from_=from_,
70. total=results['hits']['total']['value'], aggs=aggs)
`AI写代码收起代码块
使用这个版本后,每种搜索方法的最佳结果都会被合并。点击此处查看包含这些更改的完整应用程序。
正对自签名的 Elasticsearch 集群,请参考代码 GitHub - liu-xiao-guo/search-tutorial-v2 · GitHub
