这个是继之前的文章的续篇:
ELSER 模型
在上一章中,你已经了解了如何扩展 Elasticsearch 索引,添加一个由机器学习模型生成的 embeddings 填充的 dense_vector 字段。该模型被安装在你的本地计算机上,embeddings 由 Python 代码生成,并在插入索引之前添加到文档中。
在本章中,你将学习另一种向量类型,即 sparse_vector,它用于存储 Elastic Learned Sparse EncodeR 模型(ELSER)的推理结果。该模型返回的 embeddings 是一组标签(更准确地说是特征)的集合,每个标签都有一个分配的权重。
在本章中,你还将使用一种处理机器学习模型的不同方法,即由 Elasticsearch 服务本身运行模型,并通过一个 pipeline 将生成的 embeddings 添加到索引中。
sparse_vector 字段
像你在上一章中使用的 dense_vector 字段类型一样,sparse_vector 类型可以存储机器学习模型返回的推理结果。dense 向量保存描述源文本的固定长度数字数组,而 sparse 向量存储特征到权重的映射。
让我们向索引添加一个 sparse_vector 字段。这是一种需要在索引映射中显式定义的类型。下面你可以看到更新后的 create_index() 方法版本,其中添加了一个名为 elser_embedding 的新字段,并使用了该类型。
class Search:
# ...
def create_index(self):
self.es.indices.delete(index='my_documents', ignore_unavailable=True)
self.es.indices.create(index='my_documents', mappings={
'properties': {
'embedding': {
'type': 'dense_vector',
},
'elser_embedding': {
'type': 'sparse_vector',
},
}
})
# ...
部署 ELSER 模型
如上所述,在这个示例中,Elasticsearch 将负责管理该模型,并在插入文档和搜索时自动执行它来生成 embeddings。
Elasticsearch 客户端提供了一组 API 端点,用于管理机器学习模型及其 pipeline。下面 search.py 中的 deploy_elser() 方法执行了几个步骤,用于下载并安装 ELSER v2 模型,并创建一个使用该模型填充上面定义的 elser_embedding 字段的 pipeline。
class Search:
# ...
def deploy_elser(self):
# download ELSER v2
self.es.ml.put_trained_model(model_id='.elser_model_2',
input={'field_names': ['text_field']})
# wait until ready
while True:
status = self.es.ml.get_trained_models(model_id='.elser_model_2',
include='definition_status')
if status['trained_model_configs'][0]['fully_defined']:
# model is ready
break
time.sleep(1)
# deploy the model
self.es.ml.start_trained_model_deployment(model_id='.elser_model_2')
# define a pipeline
self.es.ingest.put_pipeline(
id='elser-ingest-pipeline',
processors=[
{
'inference': {
'model_id': '.elser_model_2',
'input_output': [
{
'input_field': 'summary',
'output_field': 'elser_embedding',
}
]
}
}
]
)
配置 ELSER 需要执行几个步骤。首先,使用 Elasticsearch 的 ml.put_trained_model() 方法下载 ELSER。model_id 参数用于标识要下载的模型和版本(ELSER v2 可用于 Elasticsearch 8.11 及更高版本)。input 字段是该模型所需的配置。
模型下载完成后,需要对其进行部署。为此,需要使用 ml.start_trained_model_deployment() 方法,只需提供要部署的模型标识符即可。注意,这是一个异步操作,因此模型将在短时间后可供使用。
配置 ELSER 使用方式的最后一步是为它定义一个 pipeline。pipeline 用于告诉 Elasticsearch 如何使用该模型。一个 pipeline 会被赋予一个标识符以及一个或多个要执行的处理任务。上面创建的 pipeline 名为 elser-ingest-pipeline,并包含一个 inference 任务,这意味着每次添加文档时,模型都会针对 input_field 运行,并将输出添加到文档的 output_field 中。在这个示例中,summary 字段用于生成 embeddings,就像上一章中的 dense vector embeddings 一样。生成的 embeddings 将被写入上一节创建的 elser_embedding sparse vector 字段。
为了方便调用此方法,请在 app.py 中向 Flask 应用程序添加一个 deploy-elser 命令:
@app.cli.command()
def deploy_elser():
"""Deploy the ELSER v2 model to Elasticsearch."""
try:
es.deploy_elser()
except Exception as exc:
print(f'Error: {exc}')
else:
print(f'ELSER model deployed.')
你现在可以使用以下命令在 Elasticsearch 服务上部署 ELSER:
flask deploy-elser
(.venv) $ flask deploy-elser
Warning: You are sending unauthenticated requests to the HF Hub. Please set a HF_TOKEN to enable higher rate limits and faster downloads.
Loading weights: 100%|██████████████████████████████████████████████████████████| 103/103 [00:00<00:00, 14718.36it/s]
Connected to Elasticsearch!
{'cluster_name': 'elasticsearch',
'cluster_uuid': 'G5xn8TT3StGeUFd9WJMy5A',
'name': 'liuxgn.local',
'tagline': 'You Know, for Search',
'version': {'build_date': '2026-04-30T15:05:34.751113474Z',
'build_flavor': 'default',
'build_hash': '2e8528e92361c3399724226deaf2b46f933e925b',
'build_snapshot': False,
'build_type': 'tar',
'lucene_version': '10.4.0',
'minimum_index_compatibility_version': '8.0.0',
'minimum_wire_compatibility_version': '8.19.0',
'number': '9.4.0'}}
ELSER model deployed.
最后一个配置任务涉及将索引与 pipeline 关联,以便在向该索引插入文档时自动执行模型。这通过索引配置中的 settings 选项完成。下面是对 create_index() 方法的又一次更新,用于创建此关联:
class Search:
# ...
def create_index(self):
self.es.indices.delete(index='my_documents', ignore_unavailable=True)
self.es.indices.create(
index='my_documents',
mappings={
'properties': {
'embedding': {
'type': 'dense_vector',
},
'elser_embedding': {
'type': 'sparse_vector',
},
}
},
settings={
'index': {
'default_pipeline': 'elser-ingest-pipeline'
}
}
)
通过此更改,你现在可以重新生成索引,并完整支持 ELSER 推理结果:
flask reindex

语义查询
现在索引已经配备了 ELSER embeddings,可以修改 app.py 中的 handle_search() 函数来搜索这些 embeddings。目前,你将学习如何仅通过 ELSER 进行搜索,之后会重新加入之前的搜索方法,以创建一个组合解决方案。
要在搜索时使用 ELSER 推理结果,需要使用 text_expansion 查询类型。下面你可以看到使用此查询更新后的 handle_search() 函数:
@app.post('/')
def handle_search():
query = request.form.get('query', '')
filters, parsed_query = extract_filters(query)
from_ = request.form.get('from_', type=int, default=0)
results = es.search(
query={
'text_expansion': {
'elser_embedding': {
'model_id': '.elser_model_2',
'model_text': parsed_query,
}
},
},
size=5,
from_=from_,
)
return render_template('index.html', results=results['hits']['hits'],
query=query, from_=from_,
total=results['hits']['total']['value'])
text_expansion 查询接收一个键,其中包含要搜索字段的名称。在该键下面,model_id 用于配置搜索时使用的模型,而 model_text 定义要搜索的内容。注意,在这种情况下,不需要为搜索文本生成 embedding,因为 Elasticsearch 管理该模型,并且可以负责完成这一步。
在上面的 handle_search() 函数版本中,filters 没有被使用,并且 aggregations 被省略了。这些功能可以按照它们之前被整合到全文搜索解决方案中的方式重新添加回来。下面是更新后的 handle_search() 函数,它将 text_expansion 查询移动到了 bool.must 部分,将 filters 包含在 bool.filter 中,并像之前一样添加了 aggregations。
@app.post('/')
def handle_search():
query = request.form.get('query', '')
filters, parsed_query = extract_filters(query)
from_ = request.form.get('from_', type=int, default=0)
results = es.search(
query={
'bool': {
'must': [
{
'text_expansion': {
'elser_embedding': {
'model_id': '.elser_model_2',
'model_text': parsed_query,
}
},
}
],
**filters,
}
},
aggs={
'category-agg': {
'terms': {
'field': 'category.keyword',
}
},
'year-agg': {
'date_histogram': {
'field': 'updated_at',
'calendar_interval': 'year',
'format': 'yyyy',
},
},
},
size=5,
from_=from_,
)
aggs = {
'Category': {
bucket['key']: bucket['doc_count']
for bucket in results['aggregations']['category-agg']['buckets']
},
'Year': {
bucket['key_as_string']: bucket['doc_count']
for bucket in results['aggregations']['year-agg']['buckets']
if bucket['doc_count'] > 0
},
}
return render_template('index.html', results=results['hits']['hits'],
query=query, from_=from_,
total=results['hits']['total']['value'], aggs=aggs)
花一些时间尝试不同的搜索。你会注意到,与 dense vector embeddings 一样,当索引文档中没有出现完全匹配的词语时,由 ELSER 模型驱动的搜索效果比全文搜索更好。
混合搜索:结合全文搜索和 ELSER 结果
与上一节中的向量搜索一样,在本节中,你将学习如何使用 Reciprocal Rank Fusion 算法结合全文查询和语义查询的最佳搜索结果。
子搜索介绍
在最新的发布中,子搜索已经被 Retrievers 代替了。
实现混合全文搜索和 dense vector 搜索的方法是发送一个搜索请求,其中包含 query、knn 参数来请求这两种搜索,以及 rrf 参数将它们合并为一个结果列表。
当尝试使用相同方法来组合全文搜索和 sparse vector 搜索请求时,会出现一个问题,即两者都使用 query 参数。为了能够提供需要通过 RRF 算法合并的两个查询,需要包含两个 query 参数,而实现这一点的解决方案是使用子搜索(Sub-Searches)。
子搜索是一项目前处于技术预览阶段的功能。因此,Python Elasticsearch 客户端并不原生支持它。为了解决这个限制,可以修改 Search 类的 search() 方法,使其使用 body 参数发送搜索请求。下面你可以看到一个新的但相似的实现,它使用客户端的 body 参数发送搜索请求:
class Search:
# ...
def search(self, **query_args):
# sub_searches is not currently supported in the client, so we send
# search requests using the body argument
if 'from_' in query_args:
query_args['from'] = query_args['from_']
del query_args['from_']
return self.es.search(
index='my_documents',
body=json.dumps(query_args),
)
这个实现不需要对应用程序进行任何更改,因为它在功能上是等效的。唯一的区别是,search() 方法会在发送请求之前验证所有参数,而 body 是唯一的例外。无论客户端如何发送请求,服务器始终会验证请求。
使用这个版本,可以在 Search.search() 中使用 sub_searches 参数来发送多个搜索查询,如下所示:
results = es.search(
sub_searches=[
{
'query': { ... }, # full-text search
},
{
'query': { ... }, # semantic search
},
],
rank={
'rrf': {}, # combine sub-search results
},
aggs={ ... },
size=5,
from_=from_,
)
混合搜索实现
为了完成本节内容,让我们恢复全文搜索逻辑,并将其与本章前面介绍的语义搜索查询结合起来。
下面你可以看到更新后的 handle_search() 端点:
@app.post('/')
def handle_search():
query = request.form.get('query', '')
filters, parsed_query = extract_filters(query)
from_ = request.form.get('from_', type=int, default=0)
if parsed_query:
search_query = {
'sub_searches': [
{
'query': {
'bool': {
'must': {
'multi_match': {
'query': parsed_query,
'fields': ['name', 'summary', 'content'],
}
},
**filters
}
}
},
{
'query': {
'bool': {
'must': [
{
'text_expansion': {
'elser_embedding': {
'model_id': '.elser_model_2',
'model_text': parsed_query,
}
},
}
],
**filters,
}
},
},
],
'rank': {
'rrf': {}
},
}
else:
search_query = {
'query': {
'bool': {
'must': {
'match_all': {}
},
**filters
}
}
}
results = es.search(
**search_query,
aggs={
'category-agg': {
'terms': {
'field': 'category.keyword',
}
},
'year-agg': {
'date_histogram': {
'field': 'updated_at',
'calendar_interval': 'year',
'format': 'yyyy',
},
},
},
size=5,
from_=from_,
)
aggs = {
'Category': {
bucket['key']: bucket['doc_count']
for bucket in results['aggregations']['category-agg']['buckets']
},
'Year': {
bucket['key_as_string']: bucket['doc_count']
for bucket in results['aggregations']['year-agg']['buckets']
if bucket['doc_count'] > 0
},
}
return render_template('index.html', results=results['hits']['hits'],
query=query, from_=from_,
total=results['hits']['total']['value'], aggs=aggs)
正如你所记得的,extract_filters() 函数会查找用户在搜索提示中输入的 category 过滤器,并将剩余部分作为 parsed_query 返回。如果 parsed_query 为空,则表示用户只输入了一个 category 过滤器,在这种情况下,query 应该是一个简单的 match_all,并将所选 category 作为过滤条件。这部分逻辑在大条件判断的 else 部分中实现。
当存在搜索查询时,会按照上一节所示使用 sub_searches 选项来包含 multi_match 和 text_expansion 查询,同时使用 rank 选项请求将两个子搜索的结果合并为一个排序后的结果列表。为了完成查询,会提供 size 和 from_ 参数,以保持对分页的支持。
点击此处查看此版本的应用程序。
针对自托管的本地部署的 Elasticseasrch 集群,请参考代码 https://github.com/liu-xiao-guo/search-tutorial-v3
结论
恭喜你,你已经完成了搜索教程!
我们希望本教程为你提供了一个基础,让你可以开始使用 Elasticsearch 进行实验并创建自己的搜索解决方案!