• 文章
  • 边输入边进行语义搜索
返回实用示例

边输入边进行语义搜索

Andre Bogus

·

2023 年 8 月 14 日

Semantic Search As You Type

Qdrant 是目前速度最快的向量搜索引擎之一,因此在寻找一个可以展示的演示时,我们萌生了做一个带有完全语义搜索后端的边输入边搜索框的想法。现在我们的网站上已经有了语义/关键词混合搜索。但那个是用 Python 编写的,这会带来一些解释器的开销。自然地,我想看看使用 Rust 能达到多快的速度。

由于 Qdrant 本身不进行向量化,我必须选择一个向量模型。之前的版本使用了 SentenceTransformers 包,该包又使用了基于 Bert 的 All-MiniLM-L6-V2 模型。这个模型经过实战检验,能以较快的速度提供不错的结果,所以我没有在这方面进行实验,而是取了一个 ONNX 版本 并在服务中运行。

工作流程如下所示

Search Qdrant by Embedding

在分词和向量化之后,这将向 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 个字母),但我也可以解析日志来获取最常见的搜索词,并稍后将它们添加到缓存中。

Qdrant Recommendation

实现这一点需要设置 prefix_cache 集合,其中的点以其 point_id 作为前缀,以其 vector 作为向量,这使我们可以在不进行搜索或索引的情况下进行查找。prefix_to_id 函数目前使用 PointIdu64 变体,它可以容纳八个字节,足以满足此用途。如果需要,可以将名称编码为 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 可以并行进行多次搜索,然后服务将结果按顺序排列,选取最匹配的前几个结果。扩展后的代码搜索:

  1. 标题中的文本匹配
  2. 正文(段落或列表)中的文本匹配
  3. 标题中的语义匹配
  4. 任意语义匹配

将它们按上述顺序组合在一起,必要时去重。

merge workflow

除了发送 searchrecommend 请求外,还可以分别发送 search/batchrecommend/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 实例之间有两次往返。解决方案是并发启动这两个请求,并获取第一个成功的非空结果。

sequential vs. concurrent flow

虽然这意味着 Qdrant 向量搜索引擎的负载会增加,但这并非限制因素。相关数据在许多情况下已在缓存中,因此开销保持在可接受的范围内,并且前缀缓存未命中时的最大延迟显著降低。

代码可在 Qdrant GitHub 上获取

总之:Rust 速度很快,recommend 允许我们使用预计算的向量,批量请求很棒,并且可以在短短几毫秒内完成语义搜索。

此页面有帮助吗?

感谢您的反馈!🙏

很抱歉听到这个消息。😔 您可以在 GitHub 上编辑此页面,或创建 GitHub Issue。