这个是继上一篇文章 “Elasticsearch:Serarch tutorial - 使用 Python 进行搜索 (二)” 的续篇。在今天的文章中,本节将向你介绍一种不同的搜索方式,利用机器学习 (ML) 技术来解释含义和上下文。
在机器学习中,嵌入是表示现实世界对象(例如单词、句子、图像或视频)的向量(数字数组)。 这些嵌入的有趣特性是,表示相似或相关的现实世界实体的两个嵌入也会共享一些相似性,因此可以比较嵌入,并且可以计算它们之间的距离。
当具体考虑搜索应用程序时,在向量空间中执行嵌入搜索往往会找到与概念更相关的结果,而不是与搜索提示中输入的确切关键字相关的结果。
在本教程的这一部分中,你将学习如何使用免费的机器学习模型生成嵌入,然后你将使用 Elasticsearch 的想量数据库支持来存储和搜索这些嵌入。 最后,你还将结合想量和全文搜索结果,并创建一个强大的混合搜索解决方案,提供两种方法的优点。
在本节中,你将了解可用于生成文本嵌入的最方便的选项之一,该选项基于 SentenceTransformers框架。
当您探索并熟悉嵌入的使用时,建议使用 SentenceTransformers ,因为此框架下可用的模型可以安装在您的计算机上,无需 GPU 即可表现良好,并且可以免费使用。
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 中尝试此操作。
from sentence_transformers import SentenceTransformer
model = SentenceTransformer('all-MiniLM-L6-v2')
(.venv) $ python3
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
Type "help", "copyright", "credits" or "license" for more information.
>>> from sentence_transformers import SentenceTransformer
>>> model = SentenceTransformer('all-MiniLM-L6-v2')
>>>
第一次执行此操作时,模型将被下载并安装在你的虚拟环境中,因此调用可能需要一些时间才能返回。 一旦安装了模型,实例化它应该不会花很长时间。
模型实例化后,你现在就可以生成嵌入了。 为此,请将源文本传递给 model.encode() 方法:
embedding = model.encode('The quick brown fox jumps over the lazy dog')
结果是一个包含构成嵌入的所有数字的数组。 你还记得,所选模型生成的嵌入有 384 个维度,因此这是 embedding 数组的长。
Elasticsearch 提供对存储和检索向量的全面支持,这使其成为处理嵌入的理想数据库。
在本教程的全文搜索一章中,你学习了如何创建包含多个字段的索引。 当时有人提到,Elasticsearch 在很大程度上可以根据数据本身自动确定每个字段使用的最佳类型。 尽管 Elasticsearch 8.11 能够自动映射某些向量类型,但在本章中,你将显式定义此类型,以此作为了解有关 Elasticsearch 中类型映射的更多信息的机会。
与索引中每个字段关联的类型是在称为映射的过程中确定的,该过程可以是动态的或显式的。 本教程的全文搜索部分中创建的映射都是由 Elasticsearch 动态生成的。
Elasticsearch 客户端提供 get_mapping 方法,该方法返回对给定索引有效的类型映射。 如果你想自行探索这些映射,请启动 Python shell 并输入以下代码:
from app import es
es.es.indices.get_mapping(index='my_documents')
从这里你可以看到 created_on 和 updated_at 字段自动被识别为 date 字段类型,而其他每个字段都被识别为 text 类型。 当尝试决定类型时,Elasticsearch 首先检查数据的类型,这有助于为字段分配数字、布尔和对象类型。 当字段数据是字符串时,它还会尝试查看数据是否与日期模式匹配。 如果需要,还可以针对数字启用基于模式的字符串检测。
文本(text)字段具有带有 keyword 条目的字段定义。 这称为子字段,是在适当时可用的替代或辅助类型。 在 Elasticsearch 中,动态输入的文本字段被赋予 keyword子字段。 你已经使用 category.keyword 子字段对给定类别执行精确搜索。 为了避免添加子字段,可以给出 text 或 keyword 的显式映射,然后这将是主要且唯一的类型。
让我们向索引添加一个新字段,其中将存储每个文档的嵌入。
显式映射的结构与 Elasticsearch 客户端的 get_mapping() 方法返回的响应的 mappings 键匹配。 仅需要给出需要显式键入的字段,因为映射中未包含的任何字段将继续像以前一样动态键入。
下面你可以看到 Search 类的 create_index() 方法的新版本,添加了一个名为 embedding 的显式类型字段。 在 search.py 中替换此方法:
search.py
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',
}
}
})
正如你所看到的,embedding 字段被赋予了 dense_vector 类型,这是存储嵌入时的适当类型。 稍后你将了解另一种类型的向量,即 sparce_vector,它在其他类型的语义搜索应用程序中很有用。
dense_vector 类型接受一些参数,所有这些参数都是可选的。
在上一节中,你学习了如何使用 SentenceTransformers 框架和全 MiniLM-L6-v2 模型生成嵌入。 现在是时候将模型集成到应用程序中了。
首先,可以在 Search 类构造函数中实例化模型:
search.py
# ...
from sentence_transformers import SentenceTransformer
# ...
class Search:
def __init__(self):
url = f"https://{elastic_user}:{elastic_password}@{elastic_endpoint}:9200"
self.model = SentenceTransformer('all-MiniLM-L6-v2')
self.es = Elasticsearch(url, ca_certs = "./http_ca.crt", verify_certs = True)
client_info = self.es.info()
print('Connected to Elasticsearch!')
pprint(client_info.body)
正如你在本教程的全文搜索部分中回想的那样,Search 类具有 insert_document() 和 insert_documents() 方法,用于分别将单个和多个文档插入到索引中。 这两种方法现在需要生成与每个文档对应的嵌入。
下一个代码块显示了这两个方法的新版本,以及返回嵌入的新 get_embedding() 辅助方法。
class Search:
# ...
def get_embedding(self, text):
return self.model.encode(text)
def insert_document(self, document):
return self.es.index(index='my_documents', document={
**document,
'embedding': self.get_embedding(document['summary']),
})
def insert_documents(self, documents):
operations = []
for document in documents:
operations.append({'index': {'_index': 'my_documents'}})
operations.append({
**document,
'embedding': self.get_embedding(document['summary']),
})
return self.es.bulk(operations=operations)
修改后的方法将新的 embedding 字段添加到要插入的文档中。 嵌入是从每个文档的 summary 字段生成的。 一般来说,嵌入是从句子或短段落生成的,因此在这种情况下,summary 是一个理想的使用字段。 其他选项是名称字段,其中包含文档的 name,或者可能是文档 body 的前几句话。
通过这些更改,可以重建索引,以便它存储每个文档的嵌入。 要重建索引,请使用以下命令:
flask reindex
(.venv) $ flask reindex
Connected to Elasticsearch!
{'cluster_name': 'elasticsearch',
'cluster_uuid': 'SXGzrN4dSXW1t0pkWXGfjg',
'name': 'liuxgm.local',
'tagline': 'You Know, for Search',
'version': {'build_date': '2023-11-04T10:04:57.184859352Z',
'build_flavor': 'default',
'build_hash': 'd9ec3fa628c7b0ba3d25692e277ba26814820b20',
'build_snapshot': False,
'build_type': 'tar',
'lucene_version': '9.8.0',
'minimum_index_compatibility_version': '7.0.0',
'minimum_wire_compatibility_version': '7.17.0',
'number': '8.11.0'}}
Index with 15 documents created in 100 milliseconds.
如果你需要提醒,flask reindex 命令是在 app.py 中的 reindex() 函数中实现的。 它调用 Search 类的 reindex() 方法,该方法又调用 create_index(),然后将 data.json 文件中的所有数据传递给 insert_documents()。
通过运行上面的命令,我们可以在 Kibana 中进行查看:
k 最近邻 (kNN) 算法对密集向量类型的字段执行相似性搜索。 这种类型的搜索更合适地称为 “近似 kNN”,接受向量或嵌入作为搜索项,并查找索引中接近的条目。 这是
在本节中,你将学习如何使用上一节中创建的文档嵌入来运行 kNN 搜索。
在本教程的全文搜索部分中,你了解了传递给 Elasticsearch 客户端的 search() 方法的查询选项。 当搜索向量时,使用 knn 选项。
下面你可以在 app.py 中看到新版本的 handle_search() 函数,它对用户在搜索表单中输入的查询运行 kNN 搜索。
@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(
knn={
'field': 'embedding',
'query_vector': es.get_embedding(parsed_query),
'num_candidates': 50,
'k': 10,
}, size=5, from_=from_
)
return render_template('index.html', results=results['hits']['hits'],
query=query, from_=from_,
total=results['hits']['total']['value'])
在此版本的函数中,查询选项已替换为 knn。 用于分页的 size 和 from_ 选项保持不变,函数和 index.html 模板中的其他所有内容也与以前相同。
knn 搜索选项接受许多配置搜索的参数:
根据上面代码中使用的设置,将返回 10 个最佳匹配结果。
欢迎你尝试这个新版本的应用程序。 以下是两个很好的例子,可以让你了解这种类型的搜索有多么有用:
搜索查询(如本教程全文部分中所定义)允许用户在搜索文本的任何位置使用语法 “category:
幸运的是,knn 选项还支持过滤。 filter 选项实际上接受相同类型的过滤器,因此可以将过滤器直接插入到 knn 查询中,与 extract_filters() 函数返回的过滤器完全相同:
@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(
knn={
'field': 'embedding',
'query_vector': es.get_embedding(parsed_query),
'k': 10,
'num_candidates': 50,
**filters,
}, size=5, from_=from_
)
return render_template('index.html', results=results['hits']['hits'],
query=query, from_=from_,
total=results['hits']['total']['value'])
此版本的 handle_search() 函数具有与全文搜索版本相同的功能,使用向量量搜索而不是基于关键字的搜索来实现。
在下一节中,你将学习如何组合这两种不同搜索方法的结果。
你现在已经看到了两种不同的方法来搜索文档集合,每种方法都有其独特的优点。 如果其中一种方法满足你的需求,那么你不需要任何其他方法,但在许多情况下,每种搜索方法都会返回其他方法可能会错过的有价值的结果,因此最好的选择是提供组合结果集。
对于这些情况,Elasticsearch 提供了倒数排名融合 (RRF),这是一种将两个或多个列表的结果合并到一个列表中的算法。
Elasticsearch 将 RRF 算法集成到搜索查询中。 考虑以下示例,其中包含分别请求全文和向量搜索的 query 和 knn 部分,以及将它们组合成单个结果列表的 rrf 部分。
self.es.search(
query={
# full-text search query here
},
knn={
# vector search query here
},
rank={
"rrf": {}
}
)
虽然 RRF 在没有任何配置的情况下工作得相当好,但可以调整一些参数以提供最佳结果。 请查阅文档以详细了解这些内容。
为了使组合搜索能够从全文搜索和向量搜索方法返回结果,必须恢复之前在 handle_search() 函数中使用的全文搜索逻辑。 要实现混合搜索策略,search() 方法必须接收查询和 knn 参数,每个参数请求一个单独的查询。 还添加了如上所示的排名部分,以将结果合并到单个排名列表中。
这是实现混合搜索策略的 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 = {
'must': {
'multi_match': {
'query': parsed_query,
'fields': ['name', 'summary', 'content'],
}
}
}
else:
search_query = {
'must': {
'match_all': {}
}
}
results = es.search(
query={
'bool': {
**search_query,
**filters
}
},
knn={
'field': 'embedding',
'query_vector': es.get_embedding(parsed_query),
'k': 10,
'num_candidates': 50,
**filters,
},
rank={
'rrf': {}
},
size=5,
from_=from_,
)
return render_template('index.html', results=results['hits']['hits'],
query=query, from_=from_,
total=results['hits']['total']['value'])
在此版本中,结合了每种搜索方法的最佳结果。 单击此处查看包含这些更改的完整应用。
git clone https://github.com/liu-xiao-guo/search-tutorial-2
在接下来的文章里,我们将继续介绍使用 Elastic 所提供的 ESLER 稀疏向量模型来进行语义搜索。请阅读文章 “Elasticsearch:Search tutorial - 使用 Python 进行搜索 (四)”。