欢迎!
本实践教程将教你如何使用 Elasticsearch 构建一个完整的搜索解决方案。在本教程中,你将学习:
- 如何对数据集执行全文关键词搜索,并可选择使用过滤条件。
- 如何使用机器学习模型生成、存储和搜索密集向量嵌入。
- 如何使用 ELSER 模型生成和搜索稀疏向量。
- 如何使用 Elastic 的 Reciprocal Rank Fusion( RRF )算法,将上述方法的搜索结果进行融合。
本教程最重要的一点是,它将向你展示如何在一个运行于你自己计算机上的项目中实现所有这些功能,并且整个过程通过循序渐进的小步骤完成。
你将学习的示例使用 Python 编写,但其中的概念是通用的,可以应用于你喜欢的任何编程语言或技术栈。
为了充分利用本教程,我们建议你跟着教程一起操作,并运行所有示例。
前提条件
要完成本教程,你需要安装以下组件:
- 一个 Elasticsearch 安装环境,可以使用我们托管的 Elastic Cloud 服务(包含免费试用期),或者在你自己的计算机上运行一个自托管服务。安装说明请参阅上面的 "安装 Elasticsearch" 部分。如果你想使用 Elastic Cloud, 请参考我之前录制的视频。在本展示中,我讲使用本地部署的 Elastic Stack 来进行展示。
- 一个 Python 解释器。请确保使用较新的版本,例如 Python 3.8 或更高版本。
本教程假定你此前没有 Elasticsearch 或搜索相关主题的知识,但希望你至少具备以下技术的基础了解(初学者水平即可):
- Python 开发
- Python 的 Flask Web 框架
- 操作系统中的命令提示符或终端应用程序
项目设置
提示:在本教程中,你将使用一个基于 Python Flask Web 框架的小型应用程序。以下各节将提供帮助你在自己的计算机上设置并运行该应用程序的说明。完成本节内容时,你需要在操作系统的终端或命令提示符窗口中进行操作。
下载入门应用程序
点击下面的链接下载搜索入门应用程序。
markdown
`
1. $ pwd
2. /Users/liuxg/python/tutorial
3. $ ls
4. search-tutorial-starter.zip
`AI写代码
为你的项目选择一个合适的父目录,例如你的"文档"目录,并将压缩包内容解压到该目录中。解压后会生成一个名为 search-tutorial 的目录,其中包含多个子目录和文件。
markdown
`
1. $ pwd
2. /Users/liuxg/python/tutorial
3. $ tree -L 3
4. .
5. ├── search-tutorial
6. │ ├── LICENSE
7. │ ├── README.md
8. │ ├── app.py
9. │ ├── data.json
10. │ ├── requirements.txt
11. │ ├── static
12. │ │ └── elastic-logo.svg
13. │ └── templates
14. │ ├── base.html
15. │ ├── document.html
16. │ └── index.html
17. └── search-tutorial-starter.zip
`AI写代码

安装 Python 依赖项
在终端中,切换到上一节创建的 search-tutorial 目录。
bash
`cd search-tutorial`AI写代码
遵循 Python 最佳实践,你现在将创建一个虚拟环境,这是一个专门用于此项目的私有 Python 环境。请使用以下命令完成此操作:
go
`python3 -m venv .venv`AI写代码
该命令会在一个名为 .venv(点号加 venv)的目录中创建 Python 虚拟环境。你也可以将命令中的 .venv 替换为任何你喜欢的名称。请注意,在某些 Python 安装环境中,你可能需要使用 python 而不是 python3 来启动 Python 解释器。
下一步是激活虚拟环境。激活后,该虚拟环境将成为当前终端会话使用的 Python 环境。如果你使用的是基于 UNIX 的操作系统,例如 Linux 或 macOS,可以使用以下命令激活虚拟环境:
bash
`source .venv/bin/activate`AI写代码
如果你是在 Microsoft Windows 计算机上的 WSL 环境中工作,上面的激活命令同样适用。但如果你使用的是 Windows 命令提示符或 PowerShell,则激活命令有所不同:
r
`.venv\Scripts\activate`AI写代码
当虚拟环境被激活后,命令行提示符会发生变化,以显示环境名称:
go
`(.venv) $ _`AI写代码
注意:如果你以前没有使用过虚拟环境,需要记住激活命令不是永久性的,只适用于输入该命令的终端会话。如果你打开第二个终端窗口,或者在前一天关闭计算机后重新回来继续本教程的工作,你需要再次执行激活命令。
配置 Python 环境的最后一步是安装入门应用程序所需的一些软件包。确保虚拟环境已在上一步中激活,然后运行以下命令来安装这些依赖项:
go
`pip install -r requirements.txt`AI写代码
运行应用程序
此时,你应该可以使用以下命令启动应用程序:
arduino
`flask run`AI写代码
要确认应用程序正在运行,请打开浏览器并访问 http://localhost:5001。

注意:在这个早期阶段,应用程序还只是一个空壳。你可以在搜索框中输入内容并请求搜索(如果你愿意),但响应始终会显示没有结果。在接下来的部分中,你将学习如何将一些内容加载到 Elasticsearch 索引中并执行搜索。
Flask 应用程序被配置为以开发模式运行。当它检测到源文件发生变化时,它会自动重新启动自身以应用这些更改。你可以在继续本教程的同时,让应用程序保持运行状态,而不关闭此终端会话;当你进行更改时,应用程序会自动重新启动以更新。
全文搜索
设置 Elasticsearch Python 客户端
提示:在本节中,你将安装用于 Python 的 Elasticsearch 客户端库,并使用它连接到 Elasticsearch 服务。
安装
Elasticsearch 客户端库是一个使用 pip 安装的 Python 软件包。确保你之前创建的虚拟环境已激活,然后运行以下命令来安装客户端:
go
`pip install elasticsearch`AI写代码
运行完上面的命令后,我们可以使用如下的命令来查看 elasticsearch 包的版本:
go
`pip list | grep elasticsearch`AI写代码
markdown
`
1. (.venv) $ pip list | grep elasticsearch
2. elasticsearch 9.4.1
`AI写代码
为了避免任何潜在的不兼容问题,请确保你安装的 Elasticsearch 客户端库版本与你正在使用的 Elasticsearch stack 版本匹配。
始终建议保持 requirements.txt 文件与所有依赖项同步更新,因此现在是更新此文件以包含新安装的软件包的好时机。在终端中运行以下命令:
go
`pip freeze > requirements.txt`AI写代码
连接到 Elasticsearch
要创建与 Elasticsearch 服务的连接,必须使用适当的连接选项创建一个 Elasticsearch 对象。
在你的代码编辑器中,在 search-tutorial 目录下创建一个新的 search.py 文件。search.py 文件将用于定义所有搜索功能。将搜索功能单独放在一个文件中的想法是,这样以后你可以轻松地提取此文件,并将其添加到你自己的项目中。
在 search.py 中输入以下代码以添加一个 Search 类:
python
`
1. import json
2. from pprint import pprint
3. import os
4. import time
6. from dotenv import load_dotenv
7. from elasticsearch import Elasticsearch
9. load_dotenv()
12. class Search:
13. def __init__(self):
14. self.es = Elasticsearch() # <-- connection options need to be added here
15. client_info = self.es.info()
16. print('Connected to Elasticsearch!')
17. pprint(client_info.body)
`AI写代码
这里有很多内容需要解释。导入语句之后立即调用的 load_dotenv() 函数来自 python-dotenv 软件包。该软件包用于处理 .env 文件,这些文件用于存储密码和密钥等配置变量。load_dotenv() 函数会读取存储在 .env 文件中的变量,并将它们作为环境变量导入 Python 进程。
Search 类包含一个构造函数,该构造函数会创建 Elasticsearch 客户端类的实例。所有与 Elasticsearch 服务通信的客户端逻辑都位于这里。请注意,目前这一行代码是不完整的,因为需要包含适用于你的服务的连接选项。你将在下面了解哪些选项适用于你的情况。创建完成后,Elasticsearch 对象会存储在名为 self.es 的实例变量中。
为了确保客户端对象可以与你的 Elastic Cloud 部署通信,会调用 info() 方法。该方法会向服务发起请求以获取基本信息。如果此调用成功,则可以认为你已经建立了有效的服务连接。
然后,该方法会打印一条状态消息,表示连接已经建立,并使用 Python 中的 pprint 函数以易于阅读的格式显示服务返回的信息。
注意:你可能已经注意到,Python 标准库中的 json 软件包已在此文件中导入,但尚未使用。不要删除此导入,因为后续会使用该软件包。
要完成 Search 类的构造函数,需要为 Elasticsearch 对象提供适当的连接选项。以下子章节将告诉你 Elastic Cloud 和 Docker 安装方式分别需要哪些选项。
连接到 Elastic Cloud 部署/自托管 Elasticsearch
如果你按照说明创建了 Elastic Cloud 部署,则需要知道该部署的 Cloud ID 和你的 API Key。由于这些是敏感值,直接将它们包含在应用程序代码中并不是一个好主意。相反,创建一个 .env(读作 dot-env)文件,在其中安全地存储这些密钥。
打开你喜欢的代码编辑器,在 search-tutorial 项目目录中创建一个名为 .env 的新文件(不要忘记开头的点号)。在此文件中输入以下内容:
ini
`
1. ELASTIC_CLOUD_ID="paste your Cloud ID here"
2. ELASTIC_API_KEY="paste your API Key here"
`AI写代码
注意:如果你计划将此项目提交到源代码控制仓库,则应确保不要包含 .env 文件,以防止你的 Elastic 账户凭据泄露。
如果使用 git,请在你的 .gitignore 文件末尾添加以下行(如果还没有该文件,则创建一个新的文件):
bash`.env`AI写代码
针对我们的情况,我们使用本地部署的 Elastic Stack。我们定义如下的变量:
ini
`
1. ES_URL="https://localhost:9200"
2. ES_API_KEY="cmt6d3Q1OEJNQ0tpMDFvZnYwQ2Q6ZHVsRzZkWnBPVnVuSDdRV2dBUkpJZw=="
`AI写代码
我们可以按照如下的步骤来获取 ES_API_KEY:




在 .env 文件中输入你的凭据后,返回到 search.py 中的 Search 类构造函数,并按照如下方式编辑第一行:
ini
`
1. class Search:
2. def __init__(self):
3. self.es = Elasticsearch(cloud_id=os.environ['ELASTIC_CLOUD_ID'],
4. api_key=os.environ['ELASTIC_API_KEY'])
5. # ...
`AI写代码
针对我们的自托管的 Elasticsearch:
python
`
1. import json
2. from pprint import pprint
3. import os
4. import time
5. import urllib3
7. from dotenv import load_dotenv
8. from elasticsearch import Elasticsearch
10. urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
12. load_dotenv()
15. class Search:
16. def __init__(self):
17. self.es = Elasticsearch(os.environ['ES_URL'],
18. api_key=os.environ['ES_API_KEY'],
19. verify_certs=False,
20. ssl_show_warn=False)
21. client_info = self.es.info()
22. print('Connected to Elasticsearch!')
23. pprint(client_info.body)
`AI写代码
测试连接
此时,你已经准备好连接到你的 Elasticsearch 服务。要执行此操作,请确保你的 Python 虚拟环境已激活,然后输入 python 启动 Python 交互式会话。你应该会看到熟悉的 >>> 提示符,你可以在其中输入 Python 语句。
按如下方式导入 Search 类:
sql
`from search import Search`AI写代码
接下来,实例化新的类:
ini
`es = Search()`AI写代码
你应该会看到一条连接成功消息,随后是客户端 info() 方法返回的信息。除了标识符和版本号的差异外,输出应该类似如下内容:
sql
`
1. (.venv) $ python
2. Python 3.11.8 (main, Jan 14 2026, 14:34:30) [Clang 17.0.0 (clang-1700.6.3.2)] on darwin
3. Type "help", "copyright", "credits" or "license" for more information.
4. >>> from search import Search
5. >>> es = Search()
6. Connected to Elasticsearch!
7. {'cluster_name': 'elasticsearch',
8. 'cluster_uuid': 'G5xn8TT3StGeUFd9WJMy5A',
9. 'name': 'liuxgn.local',
10. 'tagline': 'You Know, for Search',
11. 'version': {'build_date': '2026-04-30T15:05:34.751113474Z',
12. 'build_flavor': 'default',
13. 'build_hash': '2e8528e92361c3399724226deaf2b46f933e925b',
14. 'build_snapshot': False,
15. 'build_type': 'tar',
16. 'lucene_version': '10.4.0',
17. 'minimum_index_compatibility_version': '8.0.0',
18. 'minimum_wire_compatibility_version': '8.19.0',
19. 'number': '9.4.0'}}
`AI写代码
如果你遇到错误,请确保在使用 Elastic Cloud deployment 时,已在 .env 文件中输入正确的凭据;如果使用的是 self-hosted deployment,请确保按照说明在你的计算机上运行 Elasticsearch Docker container。
将 Elasticsearch 集成到 Flask Application
本节的最后一步是将目前完成的工作集成到你之前安装的小型 Flask application 中。目标是在 application 启动时自动创建与 Elasticsearch 的连接。
要实现这一点,请在代码编辑器中打开 app.py 。在现有唯一的 imports 下面,为 search.py module 添加一个 import statement:
sql
`from search import Search`AI写代码
然后找到创建 app 变量的那一行,并紧接着创建新的 Search 类实例:
ini
`es = Search()`AI写代码
就这样!现在 application 已经有了一个可以在需要时使用的 es object。
如果你仍然在 terminal 中运行 Flask application,那么在保存文件后,你应该会立即看到 application 重新加载。由于重新加载,Search class constructor 打印的连接信息应该会出现,并且从现在开始,每次 application 重启时都会继续显示。
如果你还没有运行 Flask application,现在是启动它的好时机。在 terminal 窗口中切换到 project directory,激活 Python virtual environment,然后使用以下命令启动 application:
arduino
`flask run`AI写代码
为了帮助你在遇到错误时进行排查,下面提供了一个包含已集成 Elasticsearch client 的完整 app.py 副本:
kotlin
`
1. import re
2. from flask import Flask, render_template, request
3. from search import Search
5. app = Flask(__name__)
6. es = Search()
9. @app.get('/')
10. def index():
11. return render_template('index.html')
14. @app.post('/')
15. def handle_search():
16. query = request.form.get('query', '')
17. return render_template(
18. 'index.html', query=query, results=[], from_=0, total=0)
21. @app.get('/document/<id>')
22. def get_document(id):
23. return 'Document not found'
`AI写代码
我们可以在运行 flask 的 terminal 中看到如下的信息:

创建一个 Elasticsearch 索引
Elasticsearch 中有两个非常重要的概念:document 和 index。你可以阅读文章 "Elasticsearch 中的一些重要概念: cluster, node, index, document, shards 及 replica" 以了解更多。
document 是一组 fields 及其对应 values 的集合。要使用 Elasticsearch,你需要先将数据组织成 document,然后将所有 document 添加到一个 index 中。你可以将 index 看作是一个 document 集合,它以一种经过高度优化的格式存储,以便高效地执行搜索。
如果你使用过其他数据库,可能知道许多数据库都需要定义 schema,本质上就是描述要存储的所有 fields 及其 types。Elasticsearch 的 index 可以根据需要配置 schema,但也可以根据数据自动推导 schema。在本节中,你将让 Elasticsearch 自行推导 schema,这对于 text、numbers 和 dates 等简单数据类型来说效果很好。稍后,在介绍更复杂的数据类型后,你将学习如何提供显式的 schema 定义。
创建 Index
下面演示如何使用 Python client library 创建一个 Elasticsearch index:
ini
`self.es.indices.create(index='my_documents')`AI写代码
在这个示例中,self.es 是 Elasticsearch class 的一个实例,在本教程中,它存储在 search.py 中的 Search class 内。
一个 Elasticsearch deployment 可以存储多个 index,每个 index 都通过一个名称来标识,例如上面示例中的 my_documents。
index 也可以删除:
ini
`self.es.indices.delete(index='my_documents')`AI写代码
如果你尝试创建一个名称已经被现有 index 使用的 index,将会收到一个错误。
有时,如果 index 已存在,则自动删除旧的 index 后再重新创建会更加方便。这在 application 开发过程中尤其有用,因为你很可能需要多次重新生成 index。
让我们在 search.py 中添加一个 create_index() 辅助方法。请在代码编辑器中打开该文件,并在文件底部添加以下代码,保留现有内容不变:
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')
`AI写代码
create_index() 方法首先删除名为 my_documents 的 index。ignore_unavailable=True 选项可防止在找不到该 index 时调用失败。该方法中的下一行会使用相同的名称创建一个全新的 index。
本教程中的示例 application 只需要一个 Elasticsearch index,因此将 index 名称硬编码为
my_documents。对于使用多个 index 的更复杂 application,你可以考虑将 index 名称作为参数传入。
向 Index 添加 Documents
在 Elasticsearch Python client library 中,document 使用由键/值对组成的 dictionary 表示。值为 string 的 fields 会自动建立索引,以支持全文搜索和 keyword 搜索。除了 string 外,你还可以使用 numbers、dates 和 booleans 等其他 field types,它们同样会建立索引,以便高效执行过滤等操作。你还可以构建复杂的数据结构,例如将某个 field 设置为 list,或设置为包含多个子项的 dictionary。
了解更多关于 Elasticsearch types 的信息
要向 index 中插入一个 document,需要使用 Elasticsearch client 的 index() 方法。例如:
dart
`
1. document = {
2. 'title': 'Work From Home Policy',
3. 'contents': 'The purpose of this full-time work-from-home policy is...',
4. 'created_on': '2023-11-02',
5. }
6. response = es.index(index='my_documents', body=document)
7. print(response['_id'])
`AI写代码
index() 方法的返回值是 Elasticsearch 服务返回的响应。该响应中最重要的信息是一个键名为 _id 的项,它表示 document 插入到 index 时分配给它的唯一标识符。这个标识符可用于检索、删除或更新该 document。
现在你已经知道如何插入 document,接下来继续在 search.py 中构建一组实用的辅助方法,为 Search class 添加一个新的 insert_document() 方法。请将以下方法添加到 search.py 的底部:
python
`
1. class Search:
2. # ...
4. def insert_document(self, document):
5. return self.es.index(index='my_documents', body=document)
`AI写代码
该方法接收调用方传入的 Elasticsearch client 和一个 document,并将该 document 插入到 my_documents index 中,然后返回服务返回的响应。
注意 :本教程不会介绍这些操作,但 Elasticsearch client 也支持修改和删除 document。有关所有可用操作,请参阅 Python library 文档中的 Elasticsearch class reference。
从 JSON 文件导入 Documents
在创建一个新的 Elasticsearch index 时,你通常需要导入大量 document。本教程提供的 starter project 包含一个 data.json 文件,其中存放了一些 JSON 格式的数据。在本节中,你将学习如何将该文件中的所有 document 导入到 index 中。
data.json 中包含的 document 结构如下:
-
name:document 标题 -
url:托管在外部站点上的 document URL -
summary:document 内容的简短摘要 -
content:document 正文 -
created_on:创建日期 -
updated_at:更新日期(如果 document 从未更新,则可能不存在) -
category:document 的类别,可以是github、sharepoint或teams -
rolePermissions:角色权限列表
现在建议你在编辑器中打开 data.json,先熟悉一下即将使用的数据。
从本质上来说,导入大量 document 与在 for 循环中导入单个 document 并没有区别。要导入 data.json 文件中的全部内容,可以像下面这样实现:
python
`
1. import json
2. from search import Search
3. es = Search()
4. with open('data.json', 'rt') as f:
5. documents = json.loads(f.read())
6. for document in documents:
7. es.insert_document(document)
`AI写代码
虽然这种方法可以工作,但它的可扩展性并不好。如果需要插入大量 document,你就必须向 Elasticsearch 服务发起同样数量的请求。
遗憾的是,每次 API 调用都会带来一定的性能开销,而且服务还设置了速率限制,无法在极短时间内处理大量请求。因此,最佳做法是使用 Elasticsearch 服务的 bulk 插入功能,它允许在一次 API 调用中向服务提交多个操作。
下面展示的 insert_documents() 方法应添加到 search.py 的底部。该方法使用 bulk() 方法,通过一次调用插入所有 document:
python
`1. def insert_documents(self, documents):
2. operations = []
3. for document in documents:
4. operations.append({'index': {'_index': 'my_documents'}})
5. operations.append(document)
6. return self.es.bulk(operations=operations)`AI写代码
该方法接收一个 document 列表。它不会逐个添加 document,而是先组装一个名为 operations 的列表,然后将该列表传递给 bulk() 方法。
对于每个 document,都会向 operations 列表中添加两个条目:
-
一条描述要执行操作的记录,将操作设置为
index,并指定 index 的名称。 -
document 的实际数据。
在处理 bulk 请求时,Elasticsearch 服务会从头开始遍历 operations 列表,并依次执行其中请求的操作。
重新生成 Index
在完成本教程的过程中,你需要多次重新生成 index。为了简化这一操作,请在 search.py 中添加一个 reindex() 方法:
python
`
1. class Search:
2. # ...
4. def reindex(self):
5. self.create_index()
6. with open('data.json', 'rt') as f:
7. documents = json.loads(f.read())
8. return self.insert_documents(documents)
`AI写代码
该方法将前面创建的 create_index() 和 insert_documents() 方法组合在一起,因此只需一次调用,就可以删除旧的 index(如果存在),然后创建一个新的 index,并重新导入所有 document。
注意:当需要索引大量 document 时,最佳做法是将 document 列表拆分为多个较小的批次,并分别导入每个批次。
为了让该方法更方便调用,我们将通过 Flask command 对外暴露它。请在代码编辑器中打开 app.py,并在文件底部添加以下函数:
python
`
1. @app.cli.command()
2. def reindex():
3. """Regenerate the Elasticsearch index."""
4. response = es.reindex()
5. print(f'Index with {len(response["items"])} documents created '
6. f'in {response["took"]} milliseconds.')
`AI写代码
@app.cli.command() decorator 会告诉 Flask framework 将这个函数注册为一个自定义命令,因此它可以通过 flask reindex 来执行。命令名称取自函数名,这里添加 docstring 是因为 Flask 会将其显示在 --help 帮助信息中。
reindex() 函数返回的响应(实际上就是 Elasticsearch client bulk() 方法返回的响应)包含了一些有用的信息,可用于生成一条友好的状态消息。
其中:
-
response['took']表示此次调用耗时,单位为毫秒。 -
response['items']是一个列表,包含每个操作各自的执行结果。虽然这个列表本身通常不会直接使用,但它的长度可以用来统计成功插入的 document 数量。
现在可以运行 flask --help 查看效果。请确保 Python virtual environment 已激活(如果当前 terminal 仍在运行 Flask application,可以再打开一个新的 terminal 窗口)。
在帮助信息的末尾,你应该会看到新增的 reindex 命令,它会与 Flask framework 提供的其他命令一起列为可用命令:
markdown
`
1. Commands:
2. reindex Regenerate the Elasticsearch index.
3. routes Show the routes for the app.
4. run Run a development server.
5. shell Run a shell in the app context.
`AI写代码
sql
`
1. (.venv) $ pwd
2. /Users/liuxg/python/tutorial/search-tutorial
3. (.venv) $ flask --help
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. Usage: flask [OPTIONS] COMMAND [ARGS]...
20. A general utility script for Flask applications.
22. An application to load must be given with the '--app' option, 'FLASK_APP'
23. environment variable, or with a 'wsgi.py' or 'app.py' file in the current
24. directory.
26. Options:
27. -e, --env-file FILE Load environment variables from this file. python-
28. dotenv must be installed.
29. -A, --app IMPORT The Flask application or factory function to load, in
30. the form 'module:name'. Module can be a dotted import
31. or file path. Name is not required if it is 'app',
32. 'application', 'create_app', or 'make_app', and can be
33. 'name(args)' to pass arguments.
34. --debug / --no-debug Set debug mode.
35. --version Show the Flask version.
36. --help Show this message and exit.
38. Commands:
39. reindex Regenerate the Elasticsearch index.
40. routes Show the routes for the app.
41. run Run a development server.
42. shell Run a shell in the app context.
`AI写代码
现在,当你想生成一个干净的 index 时,只需要运行:
go
`flask reindex` AI写代码
bash
`
1. (.venv) $ pwd
2. /Users/liuxg/python/tutorial/search-tutorial
3. (.venv) $ flask reindex
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. Index with 15 documents created in 0 milliseconds.
`AI写代码
你如果能访问 Kibana,那么你可以通过如下的命令来检查:
bash
`GET my_documents/_search`AI写代码

搜索基础知识
现在你已经创建了一个 Elasticsearch index,并向其中加载了一些 document,接下来可以实现全文搜索。
搜索将如何工作
让我们快速回顾一下本教程 application 中搜索解决方案的工作方式。当 Flask application 运行时,你可以访问 [http://localhost:5001](http://localhost:5001 "http://localhost:5001") 打开主页,页面如下所示:

渲染此页面的代码实现在 app.py 文件中:
kotlin
`
1. @app.get('/')
2. def index():
3. return render_template('index.html')
`AI写代码
这是一个非常简单的 endpoint,用于渲染一个 HTML template。在 Flask applications 中,templates 位于 templates 子目录中,因此你可以在那里找到这个 application 包含的该 template 以及其他 templates。
让我们查看 templates/index.html 文件中搜索 field 的实现。以下是该 template 的相关部分:
ini
`
1. <form method="POST" action="{{ url_for('handle_search') }}">
2. <div class="mb-3">
3. <input type="text" class="form-control" autofocus>
4. </div>
5. </form>
`AI写代码
你可以看到,这是一个 HTML form,其中包含一个名为 query 的 text 类型 field。该 form 的 method attribute 设置为 POST,这会告诉 browser 使用 POST request 提交此 form。action attribute 设置为 Flask application 中 handle_search endpoint 对应的 URL。当提交 form 时,handle_search() 函数将会执行。
当前 handle_search() 的实现如下:
csharp
`
1. @app.post('/')
2. def handle_search():
3. query = request.form.get('query', '')
4. return render_template('index.html', query=query, results=[], from_=0,
5. total=0)
`AI写代码
该函数从 Flask 的 request.form dictionary 中获取用户在 text field 中输入的文本,并将其存储在 query local variable 中。然后,该函数会渲染 index.html template,但这一次它会传入一些额外的 arguments,以便页面可以显示搜索结果。
template 接收的四个 arguments 如下:
-
query:用户在 form 中输入的 query 文本。 -
results:搜索结果列表。 -
from_:第一个结果的从零开始的索引。 -
total:结果总数。
由于搜索功能尚未实现,目前传递给 render_template() 函数的 arguments 表示没有找到任何结果。
现在的任务是实现一个全文查询,并传递实际结果,使 index.html 页面能够显示这些结果。
Elasticsearch Queries
Elasticsearch service 使用基于 JSON format 的 Query DSL(Domain Specific Language,领域特定语言)来定义 queries。
Python 的 Elasticsearch client 提供了一个 search() 方法,用于提交 search query。让我们在 search.py 中添加一个使用该方法的 search() 辅助方法:
python
`
1. class Search:
2. # ...
4. def search(self, **query_args):
5. return self.es.search(index='my_documents', **query_args)
`AI写代码
该方法使用 index 名称调用 Elasticsearch client 的 search() 方法。query_args 参数会捕获传递给该方法的所有 keyword arguments,然后将它们继续传递给 es.search() 方法。这些 arguments 将用于让调用方指定搜索内容。
Match Queries
Elasticsearch Query DSL 提供了许多不同的方式来查询 index。通过查看文档中的各个子章节,你可以熟悉支持的不同 query 类型。搜索 text 这一非常常见的任务,在 Full-Text queries 部分进行了介绍。
在第一个搜索实现中,让我们使用 Match query。下面是一个使用该 query 的示例:
bash
`
1. GET /_search
2. {
3. "query": {
4. "match": {
5. "name": {
6. "query": "search text here"
7. }
8. }
9. }
10. }
`AI写代码
上面的示例以类似原始 HTTP request 的格式给出。熟悉这种格式非常有用,因为它在 Elasticsearch 文档以及 Elasticsearch API Console 中被广泛使用。
幸运的是,将这种格式转换为使用 Python client library 的调用非常简单。下面是与上述示例等价的 Python 代码:
markdown
`
1. es.search(
2. query={
3. 'match': {
4. 'name': {
5. 'query': 'search text here'
6. }
7. }
8. }
9. )
`AI写代码
在将 API Console 示例转换为 Python 时,请记住,query body 中的顶层 keys 必须转换为 Python 调用中的 keyword arguments。这些示例也没有指定 index,而在执行 Python 调用时需要提供 index。
通过查看 query structure,你应该可以推断出这是哪种类型的 search。该调用请求对名为 name 的 field 执行 match query,要搜索的文本是 search text here。
这种 query 风格很容易集成到本教程的 application 中。打开 app.py 并找到 handle_search() 方法。将当前版本替换为下面的新版本:
ini
`
1. @app.post('/')
2. def handle_search():
3. query = request.form.get('query', '')
4. results = es.search(
5. query={
6. 'match': {
7. 'name': {
8. 'query': query
9. }
10. }
11. }
12. )
13. return render_template('index.html', results=results['hits']['hits'],
14. query=query, from_=0,
15. total=results['hits']['total']['value'])
`AI写代码
新版本 endpoint 中第二行对 es.search() 的调用,会调用前面在 search.py 中添加的 search() 方法,而该方法又会调用 Elasticsearch client 的 search() 方法。
你能推断出这个 query 会执行什么操作吗?这是一个与上面示例类似的 match query。要搜索的 field 是 name,该 field 包含你在上一节中创建的 my_documents index 中 document 的标题。要搜索的文本是用户在网页搜索 field 中输入的内容,该内容存储在 query local variable 中。
搜索响应中包含结果的部分是 response['hits']。这是一个包含几个 keys 的 object,其中有两个在当前实现中比较重要:
-
response['hits']['hits']:搜索结果列表。 -
response['hits']['total']:可用结果的总数量。结果数量存储在一个名为value的子 key 中,因此实际获取结果总数的表达式是results['hits']['total']['value']。注意,当结果数量非常大时,总结果数量可能是一个近似值。有关详细信息,请参阅 response body 文档。
这个新版本 endpoint 中对 render_template() 的调用,会将结果列表传递给 template 的 results 参数,并将结果总数量传递给 total 参数。query 参数仍然接收之前的 query string,而 from_ 仍然硬编码为 0,因为它将在稍后添加分页功能时实现。
至此,application 已经实现了第一个版本的全文搜索。
返回你的 web browser,访问 [http://localhost:5001](http://localhost:5001 "http://localhost:5001") 打开 application。如果由于任何原因 Flask application 没有运行,请先重新启动 application。
输入搜索文本,例如 policy 或 work from home,你将看到相关结果。下面展示了搜索 work from home 时的结果:

你从 starter application 下载的 index.html template 已经包含了渲染搜索结果所需的全部逻辑。如果你对此感兴趣,下面是该 template 中用于渲染结果列表的部分:
less
`
1. {% for result in results %}
2. <p>
3. {{ from_ + loop.index }}. <b><a href="{{ url_for('get_document', id=result._id) }}">{{ result._source.name }}</a></b>
4. <br>
5. {{ result._source.summary }}
6. <br>
7. <small>
8. Category: {{ result._source.category }}.
9. Last updated: {{ result._source.updated_at | default(result._source.created_on) }}.
10. {% if result._score %}<i>(Score: {{ result._score }})</i>{% endif %}
11. </small>
12. </p>
13. {% endfor %}
`AI写代码
从这段代码中可以注意到,返回结果相关的数据可以通过 _source key 获取。还有一个 _id field,其中包含分配给该结果的唯一标识符。
每个结果关联的 score 可以通过 _score 获取。score 用于衡量相关性,score 越高表示与 query text 的匹配程度越高。默认情况下,结果会按照 score 从高到低排序返回。Elasticsearch 中的 score 使用 Okapi BM25 algorithm 计算。
如果你希望更深入地了解本节介绍的主题,可以参考以下链接:
检索单个结果
你可能已经注意到,index.html template 会将每个搜索结果的标题渲染为一个 link。该 link 指向 starter Flask application 中预先实现的第三个也是最后一个 endpoint,名为 get_document。
当前提供的实现会返回一个硬编码的 "Document not found" 文本,因此在使用 application 时,如果点击任何搜索结果,你会看到这个内容。
为了正确渲染单个 document,让我们在 search.py 中添加一个 retrieve_document() 辅助方法,并使用 Elasticsearch client 的 get() 方法:
kotlin
`
1. @app.get('/document/<id>')
2. def get_document(id):
3. return 'Document not found'
`AI写代码
你可以看到,该 endpoint 对应的 URL 包含 document id,并且每个搜索结果渲染的 link 也将 id 包含在对应的 URL 中。因此,现在只需要将这个简单的实现替换为一个能够检索 document 并进行渲染的实现即可。
将该 endpoint 替换为以下更新版本:
ini
`
1. @app.get('/document/<id>')
2. def get_document(id):
3. document = es.retrieve_document(id)
4. title = document['_source']['name']
5. paragraphs = document['_source']['content'].split('\n')
6. return render_template('document.html', title=title, paragraphs=paragraphs)
`AI写代码
这里使用了 search.py 中的 retrieve_document() 方法来获取请求的 document。然后渲染 document.html ,其中 title 来自 name field,而正文内容则来自 content,并以段落列表的形式显示。
尝试运行更多 queries,并点击搜索结果。现在你应该可以查看完整内容了。
搜索多个 Fields
在使用 application 一段时间后,你可能已经注意到很多 queries 都不会返回结果。正如你所记得的,目前搜索功能只针对每个 document 的 name field 实现,而该 field 存储的是 document 标题。
Document 还包含 summary 和 content fields,这些 field 中有更长的文本,也非常适合用于搜索,但目前这些 field 会被忽略。
在本节中,你将学习另一个常见的全文搜索 query:Multi-match。该 query 可以请求在 index 的多个 fields 中执行搜索。
以下是文档中的 multi-match query 示例:
bash
`
1. GET /_search
2. {
3. "query": {
4. "multi_match" : {
5. "query": "this is a test",
6. "fields": [ "subject", "message" ]
7. }
8. }
9. }
`AI写代码
让我们以这个示例为基础,扩展 handle_search() endpoint,使其能够针对 name、summary 和 content fields 的组合执行 multi-match queries。下面是更新后的 endpoint 代码:
ini
`
1. @app.post('/')
2. def handle_search():
3. query = request.form.get('query', '')
4. results = es.search(
5. query={
6. 'multi_match': {
7. 'query': query,
8. 'fields': ['name', 'summary', 'content'],
9. }
10. }
11. )
12. return render_template('index.html', results=results['hits']['hits'],
13. query=query, from_=0,
14. total=results['hits']['total']['value'])
`AI写代码
通过这个修改,现在可以搜索的文本内容大幅增加,以至于某些 queries 返回的结果数量可能会超过默认返回的最大 10 个结果。
在下一章中,你将学习如何通过 pagination 处理较长的结果列表。
分页(Pagination)
对于 application 来说,处理大量结果通常是不现实的。因此,API 和 web services 使用 pagination controls,让 application 可以按较小的块或 pages 请求结果。
你可能已经注意到,Elasticsearch 默认不会返回超过 10 个结果。可以在 search request 中提供可选的 size 参数来修改这个最大数量。下面的示例请求最多返回 5 个 search results:
arduino
`
1. results = es.search(
2. query={
3. 'multi_match': {
4. 'query': query,
5. 'fields': ['name', 'summary', 'content'],
6. }
7. }, size=5
8. )
`AI写代码
要访问其他 pages 的结果,可以使用 from_ 参数,它表示从完整结果列表中的哪个位置开始返回结果(由于 from 是 Python 中的保留 keyword,因此使用 from_)。
下面的示例获取第二页的 5 个结果:
arduino
`
1. results = es.search(
2. query={
3. 'multi_match': {
4. 'query': query,
5. 'fields': ['name', 'summary', 'content'],
6. }
7. }, size=5, from_=5
8. )
`AI写代码
让我们将 size 和 from_ 集成到 app.py 中的 handle_search() endpoint:
ini
`
1. @app.post('/')
2. def handle_search():
3. query = request.form.get('query', '')
4. from_ = request.form.get('from_', type=int, default=0)
5. results = es.search(
6. query={
7. 'multi_match': {
8. 'query': query,
9. 'fields': ['name', 'summary', 'content'],
10. }
11. }, size=5, from_=from_
12. )
13. return render_template('index.html', results=results['hits']['hits'],
14. query=query, from_=from_,
15. total=results['hits']['total']['value'])
`AI写代码
这里将 page size 硬编码为 5(你可以根据需要使用其他任意数字)。from_ argument 假设作为提交 form 中的一个额外 field 提供,但该 field 被认为是可选的,如果不存在,则默认值为 0。
index.html 中提供的 search form 没有 from_ field,因此普通搜索始终会从第一个结果开始。
template 会显示当前展示的结果范围以及结果总数。下面展示了如何使用 template expressions 实现这一功能:
erlang
`
1. <div class="col-sm-auto my-auto">
2. Showing results {{ from_ + 1 }}-{{ from_ + results|length }} out of {{ total }}.
3. </div>
`AI写代码
template 还包含用于显示 pagination buttons 的逻辑,以便在结果列表中向前或向后移动。
下面是 "Previous results" button 的实现:
ini
`
1. {% if from_ > 0 %}
2. <div class="col-sm-auto my-auto">
3. <a href="javascript:history.back(1)" class="btn btn-primary">← Previous page</a>
4. </div>
5. {% endif %}
`AI写代码
正如你所看到的,只有当 from_ 大于零时,"Previous page" button 才会被渲染到页面中。该 button 的实现使用了 browser 的 history API 来返回上一页。
"Next page" button 的实现更加有趣:
ini
`
1. {% if from_ + results|length < total %}
2. <div class="col-sm-auto my-auto">
3. <form method="POST">
4. <input type="hidden" {{ query }}">
5. <input type="hidden" {{ from_ + results|length }}">
6. <button type="submit" class="btn btn-primary">Next page →</button>
7. </form>
8. </div>
9. {% endif %}
`AI写代码
这个 button 实际上并不是一个独立的 button,而是一个完整的 form,除了 button 本身之外,还包含两个隐藏 fields。
该 form 与主 search form 类似,但包含了可选的 from_ field,并将其调整为指向下一页结果。当点击这个 button 时,Flask application 会从这个备用 form 接收到一个 search request,该 request 使用相同的 text query,但包含一个非零的 from_ value。
通过这个简单而巧妙的 pagination 实现,你将能够在多个 pages 的结果之间进行导航。
过滤器(Filters)
许多 application 需要让用户能够以补充 search queries 的方式自定义 queries。在本章中,你将学习 filtering,这是一种允许指定 search query 仅在 index 中满足特定条件的 document 子集中执行的技术。
介绍 Boolean Queries
在实现 filters 之前,你需要理解 Elasticsearch 中 compound queries 的实现方式。
compound query 允许 application 组合两个或多个单独的 queries,使它们一起执行,并在适当情况下返回组合后的结果集。在 Elasticsearch 中创建 compound queries 的标准方式是使用 Boolean query。
Boolean query 作为两个或多个单独 queries 或 clauses 的 wrapper。有四种不同的方式来组合 queries:
-
bool.must:该 clause 必须匹配。如果提供多个 clauses,则所有 clauses 都必须匹配(类似于 AND 逻辑操作)。 -
bool.should:在没有must的情况下,至少一个 clause 应该匹配(类似于 OR 逻辑操作)。当与must结合使用时,每个匹配的 clause 都会提升 document 的 relevance score。 -
bool.filter:只有匹配该 clause(或多个 clauses)的 document 才会被视为 search result 候选项。 -
bool.must_not:只有不匹配该 clause(或多个 clauses)的 document 才会被视为 search result 候选项。
正如你可能已经从上面的介绍中猜到的,Boolean queries 涉及相当多的复杂性,并且可以通过多种方式使用。
在本章中,你将学习如何将前几章中实现的 multi-match full-text search clause 与一个 filter 结合起来,该 filter 会将结果限制为某一类 document。回顾一下,本教程使用的数据集中包含一个 category field,其值可以是 sharepoint、teams 或 github。
向 Query 添加 Filter
当前教程 application 中实现的 multi-match query 使用以下结构:
arduino
`
1. {
2. 'multi_match': {
3. 'query': "query text here",
4. 'fields': ['name', 'summary', 'content'],
5. }
6. }
`AI写代码
要添加一个将搜索限制到特定 category 的 filter,需要将 query 扩展如下:
bash
`
1. {
2. 'bool': {
3. 'must': [{
4. 'multi_match': {
5. 'query': "query text here",
6. 'fields': ['name', 'summary', 'content'],
7. }
8. }],
9. 'filter': [{
10. 'term': {
11. 'category.keyword': {
12. 'value': "category to filter"
13. }
14. }
15. }]
16. }
17. }
`AI写代码
让我们详细了解这个 query 中新增的 components。
首先,multi_match query 已经被移动到了 bool.must clause 中。bool.must clause 通常用于定义基础 query。注意 ,must 接受一个 queries 列表,因此当需要时,可以将多个基础级别的 queries 组合在一起。
Filtering 通过 bool.filter section 实现,其中使用了一种新的 query 类型:term query。对于 filter 来说,使用 match 或 multi_match query 并不是一个好方法,因为这些是 full-text search queries。对于 filtering,query 必须针对每个 document 返回绝对的 true 或 false,而不是像 match queries 那样返回 relevance score。
[term](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-term-query.html "term") query 会对给定 field 中的某个 value 执行精确搜索。这类 query 适用于搜索 identifiers、labels、tags,或者像本例中的 categories。
这种 query 不适合用于经过 full-text search indexing 的 fields。String fields 默认会被设置为 [text](https://www.elastic.co/guide/en/elasticsearch/reference/current/text.html "text") 类型,并且在 indexing 前,其内容会经过分析(analyzed)并拆分成独立的 words。Elasticsearch 会为 string fields 分配一个 secondary type:[keyword](https://www.elastic.co/guide/en/elasticsearch/reference/current/keyword.html "keyword"),该类型会将 field 内容作为整体进行 indexing,因此更适合使用 term query 进行 filtering。
通过在 query 的 filter 部分使用 category.keyword 这个 field name,可以使用该 field 的 keyword 类型版本,而不是默认的 text 类型。
指定 Filter
在实现带 filter 的 query 之前,需要先添加一种方式,让最终用户输入所需的 filter。
本教程采用的解决方案是在 search query 文本中查找 category:<category-name> 模式。
让我们在 app.py 中添加一个名为 extract_filters() 的函数,用于查找 filter expressions:
python
`
1. def extract_filters(query):
2. filters = []
4. filter_regex = r'category:([^\s]+)\s*'
5. m = re.search(filter_regex, query)
6. if m:
7. filters.append({
8. 'term': {
9. 'category.keyword': {
10. 'value': m.group(1)
11. }
12. }
13. })
14. query = re.sub(filter_regex, '', query).strip()
16. return {'filter': filters}, query
`AI写代码
该函数接收用户输入的 query,并返回一个 tuple,其中包含在 query 中找到的 filters,以及移除 filters 后修改过的 query。
为了查找 filter pattern,该函数使用了 regular expression。该函数的设计考虑了未来扩展更多 filters 的可能性。
当找到一个 filter 时,filters list 会被扩展,添加对应的 filter expression。在本例中,该 expression 基于前面讨论过的 term query。
为了更好地理解该函数的工作方式,请启动一个 Python session(首先确保 virtual environment 已激活),然后运行以下代码:
python
`
1. from app import extract_filters
2. extract_filters('this is the search text category:sharepoint')
`AI写代码
bash
`
1. (.venv) $ pwd
2. /Users/liuxg/python/tutorial/search-tutorial
3. (.venv) $ python
4. Python 3.11.8 (main, Jan 14 2026, 14:34:30) [Clang 17.0.0 (clang-1700.6.3.2)] on darwin
5. Type "help", "copyright", "credits" or "license" for more information.
6. >>> from app import extract_filters
7. Connected to Elasticsearch!
8. {'cluster_name': 'elasticsearch',
9. 'cluster_uuid': 'G5xn8TT3StGeUFd9WJMy5A',
10. 'name': 'liuxgn.local',
11. 'tagline': 'You Know, for Search',
12. 'version': {'build_date': '2026-04-30T15:05:34.751113474Z',
13. 'build_flavor': 'default',
14. 'build_hash': '2e8528e92361c3399724226deaf2b46f933e925b',
15. 'build_snapshot': False,
16. 'build_type': 'tar',
17. 'lucene_version': '10.4.0',
18. 'minimum_index_compatibility_version': '8.0.0',
19. 'minimum_wire_compatibility_version': '8.19.0',
20. 'number': '9.4.0'}}
21. >>> extract_filters('this is the search text category:sharepoint')
22. ({'filter': [{'term': {'category.keyword': {'value': 'sharepoint'}}}]}, 'this is the search text')
`AI写代码
该函数返回的 tuple 应该是:
arduino
`{'filter': [{'term': 'category.keyword': {'value': 'sharepoint'}}]}, 'this is the search text'`AI写代码
实现带 Filter 的 Search
剩下需要做的是修改 handle_search() function,使其发送一个更新后的 query,该 query 会将 full-text search expression 与 filter 结合起来(如果用户提供了 filter)。
下面是该 function 的新版本:
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. query={
9. 'bool': {
10. 'must': {
11. 'multi_match': {
12. 'query': parsed_query,
13. 'fields': ['name', 'summary', 'content'],
14. }
15. },
16. **filters
17. }
18. },
19. size=5,
20. from_=from_
21. )
22. return render_template('index.html', results=results['hits']['hits'],
23. query=query, from_=from_,
24. total=results['hits']['total']['value'])
`AI写代码
现在,query 已经修改为发送一个 bool expression,并且 search expression 被移动到了其下的 must section 中。
extract_filters() function 返回 query 中的 filter 部分,并且返回格式正好是发送给 Elasticsearch 所需的形式,因此它也被插入到 query dictionary 中,同样位于顶层 bool key 下。
尝试搜索类似 work from home category:sharepoint 的 query,可以看到只返回指定 category 中的 documents。
Range Filters
除了 term filter 之外,Elasticsearch 还支持多种 filters。其中另一个常用的 filter 是 range filter,它可以用于 numbers 和 dates。
让我们添加一个 year filter,用于根据 document 最后更新时间的年份限制搜索结果,该时间存储在 updated_at field 中。
下面是更新后的 extract_filters() function,它同时查找 category:<category> 和 year:<yyyy> 两种 filters:
python
`
1. def extract_filters(query):
2. filters = []
4. filter_regex = r'category:([^\s]+)\s*'
5. m = re.search(filter_regex, query)
6. if m:
7. filters.append({
8. 'term': {
9. 'category.keyword': {
10. 'value': m.group(1)
11. }
12. },
13. })
14. query = re.sub(filter_regex, '', query).strip()
16. filter_regex = r'year:([^\s]+)\s*'
17. m = re.search(filter_regex, query)
18. if m:
19. filters.append({
20. 'range': {
21. 'updated_at': {
22. 'gte': f'{m.group(1)}||/y',
23. 'lte': f'{m.group(1)}||/y',
24. }
25. },
26. })
27. query = re.sub(filter_regex, '', query).strip()
29. return {'filter': filters}, query
`AI写代码
这个版本添加了第二个 regular expression,用于在 query string 中查找 year:yyyy。
它会为 updated_at field 创建一个 range filter,并将 range 的下限和上限设置为冒号后提供的年份。该年份通过 regular expression match 中的 m.group(1) 捕获。
这里存在一个小问题,因为 updated_at field 包含完整的 dates,而这个 filter 只需要查看年份。幸运的是,当 range filter 与 date field 一起使用时,range 的边界可以通过 date math 进行扩展。
添加到 range 的 gte(下限)和 lte(上限)参数中的 ||/y suffix 表示给定值是一个年份,该年份必须被补全为完整 date,才能与 field 中的值进行比较。
通过这个修改,你可以使用类似 year:2020 work from home 的 query,只查看指定年份中的结果。
Query 也可以同时包含两个 filters,例如:
sql
`year:2020 category:teams work from home`AI写代码

Match-all Query
在进入下一个主题之前,尝试只在 search query text field 中输入一个 filter,例如:
category:github
遗憾的是,这不会返回任何 results,但预期行为应该是返回所有匹配指定 category 的 results。
实际发生的情况是,extract_filters() function 返回一个 tuple,其中第一个元素包含 filter(s),第二个元素是一个空 query string。
multi_match query 接收到空字符串,并返回一个空的 results list,因为没有内容可以匹配空字符串。
为了解决这个特殊情况,当 search text 为空时,可以将 multi_match query 替换为 match_all。
下面版本的 handle_search() function 添加了实现这一逻辑的代码。请更新 app.py 中的 function。
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. size=5,
31. from_=from_
32. )
33. return render_template('index.html', results=results['hits']['hits'],
34. query=query, from_=from_,
35. total=results['hits']['total']['value'])
`AI写代码
通过这个版本,你可以请求所有匹配某个 category 的 documents。
注意 ,所有返回的 results 都会带有相同的 score:1.0。这是因为没有 search terms 可以用于计算 scores。

Faceted Search(分面搜索)
在本节中,你将学习一种由 filters 衍生而来的 pattern,称为 faceted search,它在 search implementations 中被广泛使用。
其思想是让用户运行一个 query,然后在显示 results 的同时,为用户提供一个 suggested filters 列表。
下面的 screenshot 展示了一个左侧 sidebar,其中包含 application 当前实现的两个 filters 对应的 facets。

下面是 faceted search results 的详细信息。
注意,每个 entry 都会被渲染为一个可点击的 link,该 link 会将 filter 添加到当前 search 中。每个 facet 还会显示它所包含的 results 数量。

Term Aggregations
在 Elasticsearch 中,faceted search 是通过 aggregations feature 实现的。
其中一种支持的 aggregations 会根据某些 criteria 将 search results 划分到 buckets 中。每个 bucket 的列表(每个 bucket 包含它所包含的 documents 数量)将用于渲染 facets sidebar。
最简单的一种 bucket aggregation 是根据每个 keyword 定义 bucket。这种类型称为 [terms aggregation](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-bucket-terms-aggregation.html "terms aggregation"),非常适合用于为 category field 创建 buckets。
下面是 application 中的 search request 扩展后的版本,用于请求 category aggregations:
ini
`
1. results = es.search(
2. query={
3. 'bool': {
4. **search_query,
5. **filters
6. }
7. },
8. aggs={
9. 'category-agg': {
10. 'terms': {
11. 'field': 'category.keyword',
12. }
13. },
14. },
15. size=5,
16. from_=from_
17. )
`AI写代码
唯一的变化是添加了 aggs option。
每个 aggregation 都会被赋予一个 name,在本例中是 category-agg。
terms aggregation 表示应该根据 keyword 进行 filtering。与 filters 类似,category field 必须指定为 category.keyword,这样才能使用与该 field 关联的 keyword sub-type。
带有 aggregations 的 request 的 response 会包含一个 aggregations field,其中包含 aggregated results。
下面是上述示例 request 可能返回的 response:
json
`
1. {
2. "aggregations": {
3. "category-agg": {
4. "buckets": [
5. { "key": "sharepoint", "doc_count": 7 },
6. { "key": "teams", "doc_count": 3 },
7. { "key": "github", "doc_count": 2 },
8. ],
9. // other fields not used in this tutorial are omitted
10. }
11. }
12. }
`AI写代码
教程 application 中包含的 index.html template 已经设计好了,可以在左侧 sidebar 中渲染 aggregations,而在此之前该 sidebar 一直是空的。
为了让 template logic 保持简单,需要将上面 response 中的数据转换为具有以下结构的 dictionary:
markdown
`
1. {
2. "Category": {
3. "sharepoint": 7,
4. "teams": 3,
5. "github": 2
6. }
7. }
`AI写代码
下面的 listing 展示了如何将 Elasticsearch aggregation format 转换为上述简化后的 dictionary,以及如何将转换后的 dictionary 发送给 template 进行渲染:
ini
`
1. results = es.search(
2. # ...
3. )
4. aggs = {
5. 'Category': {
6. bucket['key']: bucket['doc_count']
7. for bucket in results['aggregations']['category-agg']['buckets']
8. },
9. }
10. return render_template('index.html', results=results['hits']['hits'],
11. query=query, from_=from_,
12. total=results['hits']['total']['value'],
13. aggs=aggs)
`AI写代码
如果你感兴趣,index.html 中包含以下 logic,用于渲染 aggs dictionary:
xml
`
1. {% for agg in aggs %}
2. <h6 class="mt-3">{{ agg }}</h6>
3. {% for key, count in aggs[agg].items() %}
4. <form method="POST">
5. <input type="hidden" {{ agg|lower }}:{{key}} {{ query }}">
6. <button type="submit" class="btn btn-link btn-sm"{% if aggs[agg]|length == 1 %} disabled{% endif %}>{{ key }} ({{ count }})</button>
7. </form>
8. {% endfor %}
9. {% endfor %}
`AI写代码
这个实现使用了与渲染 next 和 previous pagination buttons 类似的思路。
每个 facet 都会被渲染为一个 form,其中包含一个 hidden field,用于定义添加了对应 filter 后的 query。
例如,一个 sharepoint category facet 会向当前 query 添加:
go
`category:sharepoint`AI写代码
作为一个仅用于美观的细节,每个 facet 中的 submit button 会以 link 的样式进行渲染。
Year Aggregations
上一节中用于 categories 的 term aggregations 不适用于之前构建的 year filter,因为正如你所记得的,index 并没有将 years 单独作为 keywords 存储。
相反,每篇 article 的更新时间由 updated_at field 定义,该 field 存储的是完整 date。
在可用的大量 bucket aggregations 中,[date histogram](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-bucket-datehistogram-aggregation.html "date histogram") 是最适合这个使用场景的 aggregation。
下面是更新后的 aggregations request:
bash
`1. results = es.search(
2. query={
3. 'bool': {
4. **search_query,
5. **filters
6. }
7. },
8. aggs={
9. 'category-agg': {
10. 'terms': {
11. 'field': 'category.keyword',
12. }
13. },
14. 'year-agg': {
15. 'date_histogram': {
16. 'field': 'updated_at',
17. 'calendar_interval': 'year',
18. 'format': 'yyyy',
19. },
20. },
21. },
22. size=5,
23. from_=from_
24. )`AI写代码
这里可以看到,在 aggs field 中添加了第二个 aggregation。
这个 aggregation 的类型是 date_histogram,并且 interval 被设置为 year,这样创建的 buckets 每个都代表一个 year。
format option 用于配置每个 bucket name 使用的格式,在本例中应该只包含 year。
现在,response 中的 aggregations field 将包含两个 sections:
json
`
1. {
2. "aggregations": {
3. "category-agg": {
4. "buckets": [
5. { "key": "sharepoint", "doc_count": 7 },
6. { "key": "teams", "doc_count": 3 },
7. { "key": "github", "doc_count": 2 },
8. ],
9. // fields not used in this tutorial are omitted
10. },
11. "year-agg": {
12. "buckets": [
13. { "key_as_string": "2018", "doc_count": 6 },
14. { "key_as_string": "2019", "doc_count": 1 },
15. { "key_as_string": "2020", "doc_count": 1 },
16. { "key_as_string": "2021", "doc_count": 1 },
17. { "key_as_string": "2022", "doc_count": 2 },
18. { "key_as_string": "2023", "doc_count": 1 },
19. ],
20. // fields not used in this tutorial are omitted
21. }
22. }
23. }
`AI写代码
这个第二个 aggregation 还有一个小问题。
每个 bucket 中包含的 key field 没有实际用途,因为对于 date interval aggregations 来说,它使用的是 millisecond units。
不过幸运的是,aggregation 中 format option 指定格式后的 date 会被提供在 key_as_string field 中。
下面是如何计算包含所有 facets 的 aggs dictionary:
less
`
1. aggs = {
2. 'Category': {
3. bucket['key']: bucket['doc_count']
4. for bucket in results['aggregations']['category-agg']['buckets']
5. },
6. 'Year': {
7. bucket['key_as_string']: bucket['doc_count']
8. for bucket in results['aggregations']['year-agg']['buckets']
9. if bucket['doc_count'] > 0
10. },
11. }
`AI写代码
除了在 year facets 中使用 key_as_string 替代 key 之外,还添加了一个 conditional,用于排除包含零个 documents 的 buckets,因为显然没有必要将这些 buckets 用作 filters。
至此,faceted search implementation 已经完成。
下面是完整的 handle_search() function implementation:
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. aggs={
31. 'category-agg': {
32. 'terms': {
33. 'field': 'category.keyword',
34. }
35. },
36. 'year-agg': {
37. 'date_histogram': {
38. 'field': 'updated_at',
39. 'calendar_interval': 'year',
40. 'format': 'yyyy',
41. },
42. },
43. },
44. size=5,
45. from_=from_
46. )
47. aggs = {
48. 'Category': {
49. bucket['key']: bucket['doc_count']
50. for bucket in results['aggregations']['category-agg']['buckets']
51. },
52. 'Year': {
53. bucket['key_as_string']: bucket['doc_count']
54. for bucket in results['aggregations']['year-agg']['buckets']
55. if bucket['doc_count'] > 0
56. },
57. }
58. return render_template('index.html', results=results['hits']['hits'],
59. query=query, from_=from_,
60. total=results['hits']['total']['value'], aggs=aggs)
`AI写代码收起代码块
本教程中包含的 faceted search implementation 是以简单性为设计目标的。
Elasticsearch 中的 aggregations 具有许多尚未介绍的功能和可能性,因此请务必查看 documentation,以了解该 feature 所提供的全部能力。
恭喜你,你已经完成了本教程的 Full-Text Search section!
点击此处查看目前为止 tutorial search application 的完整状态。
针对自托管本地部署的 Elasticsearch 集群,完整的代码请在地址 github.com/liu-xiao-gu... 进行查看!
