Qdrant 是目前速度最快的向量搜索引擎之一,因此在寻找一个可以展示的演示时,我们萌生了做一个带有完全语义搜索后端的边输入边搜索框的想法。现在我们的网站上已经有了语义/关键词混合搜索。但那个是用 Python 编写的,这会带来一些解释器的开销。自然地,我想看看使用 Rust 能达到多快的速度。
由于 Qdrant 本身不进行向量化,我必须选择一个向量模型。之前的版本使用了 SentenceTransformers 包,该包又使用了基于 Bert 的 All-MiniLM-L6-V2 模型。这个模型经过实战检验,能以较快的速度提供不错的结果,所以我没有在这方面进行实验,而是取了一个 ONNX 版本 并在服务中运行。
工作流程如下所示
在分词和向量化之后,这将向 Qdrant 发送一个 /collections/site/points/search
POST 请求,发送以下 JSON 数据
POST collections/site/points/search
{
"vector": [-0.06716014,-0.056464013, ...(382 values omitted)],
"limit": 5,
"with_payload": true,
}
即使避免了网络往返,向量化仍然需要一些时间。正如优化中常说的那样,如果你不能更快地完成工作,一个好的解决方案是完全避免工作(请不要告诉我的雇主)。这可以通过预计算常用前缀并计算它们的向量,然后将它们存储在一个 prefix_cache
集合中来实现。现在 recommend
API 方法可以在不进行任何向量化的情况下找到最佳匹配。目前,我使用较短的前缀(最多包含 5 个字母),但我也可以解析日志来获取最常见的搜索词,并稍后将它们添加到缓存中。
实现这一点需要设置 prefix_cache
集合,其中的点以其 point_id
作为前缀,以其 vector
作为向量,这使我们可以在不进行搜索或索引的情况下进行查找。prefix_to_id
函数目前使用 PointId
的 u64
变体,它可以容纳八个字节,足以满足此用途。如果需要,可以将名称编码为 UUID,对输入进行哈希。由于我知道我们所有的前缀都在 8 个字节以内,目前我决定不采用这种方法。
recommend
端点的工作方式大致与 search_points
相同,但 Qdrant 不搜索向量,而是搜索一个或多个点(您还可以提供否定示例点,搜索引擎将尝试在结果中避免这些点)。它最初是为了帮助驱动推荐引擎而构建的,省去了将当前点的向量发送回 Qdrant 以查找更多相似点的往返过程。然而,Qdrant 更进一步,允许我们选择不同的集合来查找点,这使我们可以将 prefix_cache
集合与站点数据分开存放。因此,在我们的案例中,Qdrant 首先从 prefix_cache
中查找点,获取其向量,然后在 site
集合中搜索该向量,使用缓存中预计算的向量。API 端点期望将以下 JSON 数据 POST 到 /collections/site/points/recommend
POST collections/site/points/recommend
{
"positive": [1936024932],
"limit": 5,
"with_payload": true,
"lookup_from": {
"collection": "prefix_cache"
}
}
秉承 Rust 的优良传统,我现在拥有了一个超高速的语义搜索。
为了演示它,我使用了我们 Qdrant 文档网站 的页面搜索,替换了我们之前的 Python 实现。因此,为了不仅仅是空口说白话,这里有一个基准测试,展示了针对不同代码路径的不同查询。
由于操作本身远快于网络,而网络的无常特性会掩盖大多数可测量的差异,因此我在本地对 Python 和 Rust 服务都进行了基准测试。我在同一台装有 16GB 内存、运行 Linux 的 AMD Ryzen 9 5900HX 机器上测量了这两个版本。下表显示了平均时间和以毫秒为单位的误差范围。我只测量了高达一千个并发请求。在这个范围内,任何服务都没有显示出随着请求增多而减慢。我不认为我们的服务会遭受 DDoS 攻击,所以我没有在更大负载下进行基准测试。
事不宜迟,结果如下
查询长度 | 短 | 长 |
---|---|---|
Python 🐍 | 16 ± 4 毫秒 | 16 ± 4 毫秒 |
Rust 🦀 | 1.5 ± 0.5 毫秒 | 5 ± 1 毫秒 |
Rust 版本始终优于 Python 版本,即使对于短字符查询也能提供语义搜索。如果命中前缀缓存(如在短查询长度中),语义搜索的速度甚至可以比 Python 版本快十倍以上。总体的加速得益于 Rust + Actix Web 相对于 Python + FastAPI(尽管后者表现也很出色)相对较低的开销,以及使用 ONNX Runtime 代替 SentenceTransformers 进行向量化。前缀缓存通过在不进行任何向量化工作的情况下进行语义搜索,为 Rust 版本带来了显著提升。
此外,虽然这里显示的毫秒级差异对我们的用户来说可能相对较小,他们的延迟主要取决于中间的网络,但在输入时,每多或少一毫秒都可能影响用户感知。此外,边输入边搜索产生的负载是普通搜索的三到五倍,因此服务将经历更多流量。每请求耗时更少意味着能够处理更多请求。
任务完成!但等等,还有更多!
优先精确匹配和标题
为了提高结果质量,Qdrant 可以并行进行多次搜索,然后服务将结果按顺序排列,选取最匹配的前几个结果。扩展后的代码搜索:
- 标题中的文本匹配
- 正文(段落或列表)中的文本匹配
- 标题中的语义匹配
- 任意语义匹配
将它们按上述顺序组合在一起,必要时去重。
除了发送 search
或 recommend
请求外,还可以分别发送 search/batch
或 recommend/batch
请求。每个请求都包含一个 "searches"
属性,其中包含任意数量的 search/recommend JSON 请求。
POST collections/site/points/search/batch
{
"searches": [
{
"vector": [-0.06716014,-0.056464013, ...],
"filter": {
"must": [
{ "key": "text", "match": { "text": <query> }},
{ "key": "tag", "match": { "any": ["h1", "h2", "h3"] }},
]
}
...,
},
{
"vector": [-0.06716014,-0.056464013, ...],
"filter": {
"must": [ { "key": "body", "match": { "text": <query> }} ]
}
...,
},
{
"vector": [-0.06716014,-0.056464013, ...],
"filter": {
"must": [ { "key": "tag", "match": { "any": ["h1", "h2", "h3"] }} ]
}
...,
},
{
"vector": [-0.06716014,-0.056464013, ...],
...,
},
]
}
由于查询是在批量请求中完成的,因此没有任何额外的网络开销,计算开销也非常小,但结果在许多情况下会更好。
唯一的额外复杂性是将结果列表平铺并获取前 5 个结果,按 point ID 去重。现在还有一个最终问题:查询可能短到采用 recommend 代码路径,但仍然不在前缀缓存中。在这种情况下,顺序进行搜索意味着服务和 Qdrant 实例之间有两次往返。解决方案是并发启动这两个请求,并获取第一个成功的非空结果。
虽然这意味着 Qdrant 向量搜索引擎的负载会增加,但这并非限制因素。相关数据在许多情况下已在缓存中,因此开销保持在可接受的范围内,并且前缀缓存未命中时的最大延迟显著降低。
代码可在 Qdrant GitHub 上获取
总之:Rust 速度很快,recommend 允许我们使用预计算的向量,批量请求很棒,并且可以在短短几毫秒内完成语义搜索。