Qdrant 是最快的向量搜索引擎之一,因此在寻找一个可以展示的演示时,我们想到了做一个带有完全语义搜索后端的搜索即输框。现在我们已经在我们的网站上有了一个语义/关键字混合搜索。但那个是用 Python 编写的,这会给解释器带来一些开销。自然,我想看看我使用 Rust 能有多快。
由于 Qdrant 本身不进行嵌入,我必须决定一个嵌入模型。之前的版本使用了 句子变换器 包,这又使用了基于 Bert 的 所有-MiniLM-L6-V2 模型。这个模型经过实战考验,能够以较快的速度提供合理的结果,因此我没有在这一方面进行实验,选择了一个 ONNX 版本 并在服务中运行。
工作流程如下:

这将在标记化和嵌入后发送一个 /collections/site/points/search POST 请求到 Qdrant,发送以下 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 函数使用 u64 变体的 PointId,它可以容纳八个字节,足够满足此使用需求。如果出现需要,可以将名称编码为 UUID,并对输入进行哈希。由于我知道我们的所有前缀都在 8 个字节以内,因此我决定暂时不采用这种方法。
recommend端点的工作原理大致与search_points相同,但不是搜索一个向量,而是搜索一个或多个点(您还可以提供搜索引擎将尽量避免在结果中出现的负例点)。它的建立是为了帮助驱动推荐引擎,省去将当前点的向量发送回Qdrant以寻找更多相似点的往返过程。然而,Qdrant更进一步,允许我们选择一个不同的集合来查找点,这使我们能够将prefix_cache集合与站点数据分开。因此在我们的例子中,Qdrant首先从prefix_cache中查找点,获取其向量并在site集合中搜索,使用来自缓存的预计算嵌入。API端点期望对/collections/site/points/recommend进行以下JSON的POST:
POST collections/site/points/recommend
{
"positive": [1936024932],
"limit": 5,
"with_payload": true,
"lookup_from": {
"collection": "prefix_cache"
}
}
现在我拥有了,秉承最佳Rust传统,一个快速的语义搜索。
为了演示这个,我使用了我们的 Qdrant 文档网站 的页面搜索,替换了我们之前的 Python 实现。因此,为了不只是说空话,这里有一个基准,展示了不同的查询,这些查询会测试不同的代码路径。
由于操作本身比网络要快得多,网络的变化无常会淹没大多数可测量的差异,因此我在本地对Python和Rust服务进行了基准测试。我在同一台配备16GB RAM、运行Linux的AMD Ryzen 9 5900HX上测量了两个版本。表格显示了平均时间和误差范围(毫秒)。我只测量了最多一千个并发请求。在此范围内,没有任何服务在更多请求下出现减速。我不认为我们的服务会受到DDOS攻击,因此我没有在更高负载下进行基准测试。
不再赘述,以下是结果:
| 查询长度 | 短 | 长 |
|---|---|---|
| Python 🐍 | 16 ± 4 毫秒 | 16 ± 4 毫秒 |
| 鲁斯特 🦀 | 1½ ± ½ 毫秒 | 5 ± 1 毫秒 |
Rust版本的性能始终优于Python版本,即使在少字符查询上也提供语义搜索。如果命中前缀缓存(如在短查询长度时),语义搜索的速度甚至可以比Python版本快十倍以上。一般的加速是由于Rust + Actix Web相对于Python + FastAPI的开销相对较低(即使后者已经表现出色),以及使用ONNX Runtime而不是SentenceTransformers进行嵌入。前缀缓存通过在不进行任何嵌入工作的情况下执行语义搜索,给Rust版本带来了真正的提升。
顺便提一下,虽然这里显示的毫秒差异对我们的用户来说可能相对无关紧要,因为他们的延迟将受到中间网络的主导影响,但在输入时,每毫秒的多或少都会对用户的感知产生影响。同时,搜索时输入的负载大约是普通搜索的三到五倍,因此服务将经历更多的流量。每个请求所需的时间减少意味着能够处理更多的请求。
任务完成!但是等等,还有更多!
优先考虑精确匹配和标题
为了提高结果的质量,Qdrant可以进行多次并行搜索,然后服务将结果按顺序放置,取最好的匹配项。扩展代码搜索:
- 标题中的文本匹配
- 文本在正文中匹配(段落或列表)
- 标题中的语义匹配
- 任何语义匹配
这些是通过按照上述顺序将它们放在一起,并在必要时进行去重而成的。

除了发送一个 search 或 recommend 请求外,还可以分别发送 search/batch 或 recommend/batch 请求。每个请求包含一个 "searches" 属性,该属性可以包含任意数量的搜索/推荐 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 个结果,通过点 ID 去重。现在还有一个最后的问题:查询可能足够短,以便采取推荐的代码路径,但仍然不在前缀缓存中。在这种情况下,顺序进行搜索将意味着服务和 Qdrant 实例之间的两次往返。解决方案是并发地启动两个请求,并获取第一个成功的非空结果。

虽然这意味着Qdrant向量搜索引擎的负载增加,但这并不是限制因素。在许多情况下,相关数据已经缓存在内存中,因此开销保持在可接受的范围内,并且在前缀缓存未命中时,最大延迟显著降低。
代码可在Qdrant github上获取
总结:Rust 非常快,推荐我们使用预计算的嵌入,批量请求很棒,人们可以在几毫秒内进行语义搜索。

