0

Qdrant 1.16 - 分层多租户 & 磁盘高效向量搜索

Abdon Pijpelink

·

2025 年 11 月 19 日

Qdrant 1.16 - Tiered Multitenancy & Disk-Efficient Vector Search

Qdrant 1.16.0 已发布! 让我们来看看这个版本的主要功能

分层多租户: 一种改进的多租户方法,允许您在单个集合中组合小型和大型租户,并能够将不断增长的租户升级到专用分片。

ACORN:一种新的搜索算法,在存在多个弱选择性过滤器的情况下,提高过滤向量搜索的质量。

内联存储:一种新的 HNSW 索引存储模式,将向量数据直接存储在 HNSW 节点内,从而实现高效的基于磁盘的向量搜索。

此外,1.16 版本引入了一个新的条件更新 API,便于将嵌入模型迁移到新版本。并且,此版本通过新的 text_any 条件和 ASCII 折叠支持改进了 Qdrant 的全文搜索功能。

使用租户升级的分层多租户

Section 1

多租户是 SaaS 应用程序的常见要求,其中多个客户(租户)共享相同的数据库实例。在 Qdrant 中,当实例在多个用户之间共享时,您可能需要按用户分区向量。这样做是为了让每个用户只能访问自己的向量,而不能看到其他用户的向量。在 Qdrant 中实现多租户有两种主要方法

  • 基于有效负载的多租户,当您有大量小型租户时效果很好。这几乎不会造成任何开销。恰恰相反:带有租户有效负载过滤器的查询可能比全面搜索更快。
  • 基于分片的多租户,专为少数大型租户设计。当每个租户需要隔离和专用资源时,这种方法效果很好。通过分片隔离租户可以防止经典的邻居噪音问题,即单个高流量租户可能迫使集群为所有人进行扩展,从而增加成本并降低小型租户的性能。但是,当您有大量小型租户时,基于分片的多租户不是一个好的解决方案,因为每个分片都会产生一些开销。

实际使用模式通常介于这两种用例之间。通常会有少数大型租户和大量小型租户。您甚至可能有随着时间增长的租户,从小型开始,最终变得足够大以需要专用资源。

在 1.16 版本中,Qdrant 现在可以通过一项名为分层多租户的新功能有效地结合这两种多租户方法。

分层多租户的主要原则是

  • 用户定义的分片允许您在集合中创建命名分片。它使您能够将大型租户隔离到自己的分片中。多租户集合可以由一个用于小型租户的共享“回退”分片和多个用于大型租户的专用分片组成。
  • 回退分片 - 一种特殊的路由机制,允许 Qdrant 将请求路由到专用分片(如果存在)或共享回退分片。这使得请求保持统一,无需知道租户是专用的还是共享的。
  • 租户升级 - 一种机制,可以在租户足够大时将其从共享回退分片“升级”到自己的专用分片。此过程基于 Qdrant 的内部分片传输机制,这使得升级对应用程序完全透明。在升级过程中支持读写请求。
Tiered multitenancy with tenant promotion

分层多租户与租户升级

要使用分层多租户,在使用共享回退分片和专用分片设置集合后,在插入或查询点时,提供分片键选择器

ACORN - 过滤向量搜索改进

Section 2

为了增强向量搜索的可扩展性和速度,Qdrant 采用了基于图的索引结构,称为HNSW(分层可导航小世界)。虽然传统的 HNSW 主要用于未过滤的搜索,但 Qdrant 通过实现可过滤的 HSNW 索引解决了这一限制。这种创新方法通过与索引有效负载值对应的附加边扩展了 HNSW 图。这使得 Qdrant 即使在过滤选择性很高的情况下也能保持搜索质量,而不会在搜索过程中引入任何运行时开销。

即使使用可过滤的 HNSW 图,在某些情况下搜索结果的质量也会显著下降。当您使用高基数过滤器的组合时,这可能会发生,从而导致 HNSW 图变得断开连接。由于可能存在大量的组合,预先为每种可能的过滤器组合构建附加链接是不切实际的。可过滤 HNSW 可能失效的另一种情况是,在事先不知道过滤条件的情况下。

为了解决这些限制,在 1.16 版本中,我们引入了对 ACORN 的支持,它基于论文 ACORN:对向量嵌入和结构化数据进行高性能和谓词无关的搜索中描述的 ACORN-1 算法。启用 ACORN 后,Qdrant 不仅遍历 HNSW 图中的直接邻居(第一跳),而且如果直接邻居已被过滤掉,还会检查邻居的邻居(第二跳)。这种增强以牺牲性能为代价提高了搜索精度,尤其是在应用多个低选择性过滤器时。

没有 ACORN(左)和有 ACORN(右)的过滤和 HNSW 图。

您可以通过可选的查询时 acorn 参数,按查询启用 ACORN。这在索引时不需要任何更改。

基准测试

设置:一个包含 5,000,000 个维度为 96 的向量(deep-image-96 数据集的子集)的单个段。两个有效负载字段,每个字段有 5 个可能的值,均匀分布。在搜索过程中,我们对两个有效负载字段应用两个过滤器,导致大约 4% 的向量通过过滤器。

搜索参数精度延迟
ef=64 + ACORN97.20%13.86 毫秒
ef=6453.34%1.25 毫秒
ef=12861.77%1.46 毫秒
ef=25667.58%2.27 毫秒
ef=51271.13%3.89 毫秒

何时使用 ACORN?

启用 ACORN 允许 Qdrant 探索 HNSW 图中更多的节点,从而评估更多的向量。这会带来一些运行时开销,不应在每个查询上启用。

为了帮助您选择何时使用 ACORN,请参阅以下决策矩阵

用例使用 ACORN 吗?效果影响
无过滤器HNSW无开销
单个过滤器HNSW + 有效负载索引无开销
多个过滤器,高选择性HNSW + 有效负载索引无开销
多个过滤器,低选择性HNSW + 有效负载索引 + ACORN一些开销,更好的质量

Section 3

将向量搜索引擎部署到生产环境通常需要在性能和成本之间取得平衡。HNSW 索引的基于 RAM 的存储和基于磁盘的存储之间的决策就是一个很好的权衡示例。HNSW 被设计为内存中的索引结构。遍历 HNSW 图涉及大量随机访问读取,这在 RAM 中速度很快,但在磁盘上速度很慢。

例如,查询 100 万个向量,HNSW 参数 m 设置为 16,ef 设置为 100,大约需要 1200 次向量比较。这在 RAM 中没问题,但在磁盘上速度很慢,每次随机访问读取可能需要长达 1 毫秒,甚至在使用 HDD 而不是 SSD 时更长。

然而,基于磁盘的存储具有我们可以利用的属性,以减少随机访问读取的数量:分页读取。磁盘设备通常一次读取一个完整的页面(4KB 或更多)数据。传统的基于树的数据结构,如 B 树,已有效地利用了此属性。然而,在像 HNSW 索引这样的基于图的结构中,将连接的节点分组到页面中并不简单,因为每个节点可能与其他节点具有任意数量的连接。

通过 Qdrant 1.16 版本,您可以通过一项名为内联存储的新功能利用分页读取。内联存储允许将量化向量数据直接存储在 HNSW 节点内。这提供了更快的读取访问,但代价是额外的存储空间。

可以通过将集合的 HNSW 配置 inline_storage 选项设置为 true 来启用内联存储。它需要启用量化。

没有内联存储的存储布局。完整向量、量化向量和 HNSW 图分开存储。HNSW 图节点只包含邻居 ID。

它是如何工作的?在 HNSW 搜索的单次迭代中,使用量化向量对当前节点的邻居进行评分,以便将它们添加到搜索队列中。如果没有内联存储,这将导致 1+hnsw_m 次磁盘读取。

启用内联存储的单个 HNSW 图节点。

启用内联存储后,量化向量直接嵌入到 HNSW 图节点中,与邻居 ID 并列。在单次搜索迭代中,邻居 ID 及其量化向量从几个连续的页面中读取,只需一次磁盘读取。

此外,原始的非量化向量也嵌入到相同的图节点中。原始向量用于在搜索过程中执行隐式重新评分,从而消除了通常在搜索之后执行的单独重新评分步骤。

请注意,量化需要启用才能使内联存储高效工作。如果没有量化,原始向量的大小会太大而无法放入 HNSW 节点。一个 HNSW 图每个节点有 M0 = M * 2 = 32 个连接(默认)。如果每个向量约为 1024 x 4 字节 = 4KB,则每个节点需要存储 M0 x 4KB = 128KB。但是,每个页面只有 4KB。

使用较小的数据类型和量化显著减小了每个向量的大小,从而可以将其内联存储。当将内联存储与 float16 数据类型和量化结合使用时,评估 HNSW 图中的一个节点只需要从磁盘读取两个页面,而不是进行 32 次随机访问读取。这代表着对传统方法的重大改进,但代价是额外的存储空间。

基准测试

基准测试设置:1,000,000 个向量(LAION 512d CLIP 嵌入的子集),2 位量化,float16 数据类型。

设置容器内存限制已使用的总存储空间QPS平均精度
内联存储,低 RAM(新)430 MiB4992 MiB21186.92%
无内联存储,低 RAM430 MiB1206 MiB2053.32%
无内联存储,索引和量化向量在 RAM 中530 MiB1206 MiB33453.32%

基准测试表明,内联存储不仅将低 RAM 设置的搜索性能提高了数量级,而且由于搜索过程中的隐式重新评分,还提供了更好的精度。搜索性能与中等 RAM 设置相当,后者有足够的内存来将 HNSW 索引和量化向量保存在 RAM 中。

全文搜索增强

Section 4

虽然 Qdrant 主要是一个向量搜索引擎,但许多应用程序需要向量搜索和传统全文搜索的组合。因此,我们正在不断增强我们的全文搜索功能。在 1.16 版本中,我们引入了两个新功能,以改善 Qdrant 中的全文搜索体验。

在 1.16 版本之前,Qdrant 支持两种在文本字段中搜索多个搜索词的方法:搜索所有搜索词的 text 条件,以及搜索精确短语匹配的 phrase 条件。

然而,没有方便的方法来搜索以匹配提供的查询词中的至少一个。您必须在客户端自行分词多词查询,并使用多个 match 条件构建复杂的布尔条件

{
  "should": [
    { "match": { "text": "apple" } },
    { "match": { "text": "banana" } },
    { "match": { "text": "cherry" } }
  ]
}

在 1.16 版本中,我们添加了一个新的text_any 条件来简化此用例。现在,Qdrant 可以内部处理分词和匹配,而不是构建复杂的布尔条件。

text_any 条件匹配包含任何查询词的文本字段。换句话说,即使文本字段只包含一个查询词,它也被视为匹配。

{
  "match": {
    "text_any": "apple banana cherry"
  }
}

使用 text_any 条件的一个很好的例子是在电子商务应用程序中,用户通常使用多个关键字搜索产品。通过将向量查询与一系列越来越宽松的全文过滤器结合使用,即使他们的初始搜索词过于严格,您也可以确保用户获得相关结果。

QUERY = {
  "text": "best smartphone ever",
  "model": "sentence-transformers/all-MiniLM-L6-v2"
}

batch_query = [
  {
    "query": QUERY,
    "filter": {
      "must": {
        "key": "description",
        "match": { "text": "5G 5000mAh OLED" }
      }
    }
  },
  { # fallback to at least one token
    "query": QUERY,
    "filter": {
      "must": {
        "key": "description",
        "match": { "text_any": "5G 5000mAh OLED" }
      }
    }
  },
  { # fallback to just similarity
    "query": QUERY,
    "filter": null
  }
]

ASCII 折叠 - 改进多语言文本搜索

许多拉丁语使用变音符号(重音符号)来表示字母的不同发音或含义。例如,法语中的字母“é”与“e”发音不同,并且可以改变单词的含义。用户在搜索带有变音符号的术语时,可能不会始终在查询中包含这些符号,这可能会导致错过匹配。

解决此问题的一种方法是将带有变音符号的字符规范化为其基本 ASCII 等效项,此过程称为 ASCII 折叠。例如,“café”变为“cafe”,“naïve”变为“naive”。这种规范化允许更灵活和更具包容性的搜索结果,从而提高多语言文本的搜索召回率。

社区成员eltu 的开源贡献在 1.16 版本中为 Qdrant 的全文搜索功能添加了ASCII 折叠支持。启用后,Qdrant 会自动规范化文本字段和搜索词,例如通过删除变音符号。

要启用 ASCII 折叠,请在创建全文有效负载索引时ascii_folding 选项设置为 true

条件更新

Section 5

Qdrant 中的点更新是幂等的,这意味着多次应用相同的更新与应用一次具有相同的效果。当两个客户端尝试并发更新同一点时,这可能会导致问题,因为最后一次更新将覆盖任何先前的更新。考虑以下事件序列

  1. 客户端 A 读取点 P。
  2. 客户端 B 读取点 P。
  3. 客户端 A 修改点 P 并将其写回 Qdrant。
  4. 客户端 B 修改点 P(基于过时数据)并将其写回 Qdrant,无意中覆盖了客户端 A 所做的更改。

为了解决这个问题,Qdrant 1.16 引入了对条件更新的支持。通过条件更新,您可以指定一个条件,以更新过滤器的形式,该条件必须满足才能应用更新。如果条件不满足,Qdrant 将拒绝更新,从而防止意外覆盖。

例如,您可以向您的点添加一个 version 字段来跟踪更改。更新点时,您可以指定 version 字段必须与预期值匹配的条件。如果另一个客户端在此期间修改了点并增加了 version,则更新将被拒绝

PUT /collections/{collection_name}/points
{
    "points": [
        {
            "id": 1,
            "vector": [0.05, 0.61, 0.76, 0.74],
            "payload": {
                "city": "Berlin",
                "price": 1.99,
                "version": 3
            }
        }
    ],
    "update_filter": {
        "must": [
            {
                "key": "version",
                "match": {
                    "value": 2
                }
            }
        ]
    }
}
from qdrant_client import QdrantClient, models

client = QdrantClient(url="https://:6333")

client.upsert(
    collection_name="{collection_name}",
    points=[
        models.PointStruct(
            id=1,
            vector=[0.05, 0.61, 0.76, 0.74],
            payload={
                "city": "Berlin",
                "price": 1.99,
                "version": 3,
            },
        ),
    ],
    update_filter=models.Filter(
        must=[
            models.FieldCondition(
                key="version",
                match=models.MatchValue(value=2),
            ),
        ],
    ),
)
import { QdrantClient } from "@qdrant/js-client-rest";

const client = new QdrantClient({ host: "localhost", port: 6333 });

client.upsert("{collection_name}", {
  points: [
    {
      id: 1,
      vector: [0.05, 0.61, 0.76, 0.74],
      payload: {
        city: "Berlin",
        price: 1.99,
        version: 3
      },
    }
  ],
  updateFilter: {
    must: [
      {
        key: "version",
        match: {
          value: 2
        }
      }
    ]
  }
});
use qdrant_client::qdrant::{PointStruct, UpsertPointsBuilder, Filter, Condition};
use qdrant_client::{Payload, Qdrant};
use serde_json::json;

let client = Qdrant::from_url("https://:6334").build()?;

let points = vec![
    PointStruct::new(
        1,
        vec![0.05, 0.61, 0.76, 0.74],
        Payload::try_from(json!({
            "city": "Berlin", 
            "price": 1.99,
            "version": 3
        })).unwrap(),
    )
];

client
    .upsert_points(
        UpsertPointsBuilder::new("{collection_name}", points)
            .wait(true)
            .update_filter(Filter::must([Condition::matches("version", 2)]))
    ).await?;
import java.util.Map;

import static io.qdrant.client.ConditionFactory.match;
import static io.qdrant.client.PointIdFactory.id;
import static io.qdrant.client.ValueFactory.value;
import static io.qdrant.client.VectorsFactory.vectors;

import io.qdrant.client.QdrantClient;
import io.qdrant.client.QdrantGrpcClient;
import io.qdrant.client.grpc.Common.Filter;
import io.qdrant.client.grpc.Points.PointStruct;
import io.qdrant.client.grpc.Points.UpsertPoints;

QdrantClient client =
    new QdrantClient(QdrantGrpcClient.newBuilder("localhost", 6334, false).build());

client
    .upsertAsync(
        UpsertPoints.newBuilder()
            .setCollectionName("{collectionName}")
            .addPoint(
                PointStruct.newBuilder()
                    .setId(id(1))
                    .setVectors(vectors(0.05f, 0.61f, 0.76f, 0.74f))
                    .putAllPayload(Map.of("city", value("Berlin"), "price", value(1.99)))
                    .build())
            .setUpdateFilter(Filter.newBuilder().addMust(match("version", 2)).build())
            .build())
    .get();
using Qdrant.Client;
using Qdrant.Client.Grpc;
using static Qdrant.Client.Grpc.Conditions;

var client = new QdrantClient("localhost", 6334);

await client.UpsertAsync(
    collectionName: "{collection_name}",
    points: new List<PointStruct>
    {
        new PointStruct
        {
            Id = 1,
            Vectors = new[] { 0.05f, 0.61f, 0.76f, 0.74f },
            Payload = { 
                ["city"] = "Berlin",
                ["price"] = 1.99,
                ["version"] = 3
            }
        }
    },
    updateFilter: Match("version", 2)
);
import (
    "context"

    "github.com/qdrant/go-client/qdrant"
)

client, err := qdrant.NewClient(&qdrant.Config{
    Host: "localhost",
    Port: 6334,
})

client.Upsert(context.Background(), &qdrant.UpsertPoints{
    CollectionName: "{collection_name}",
    Points: []*qdrant.PointStruct{
        {
            Id:      qdrant.NewIDNum(1),
            Vectors: qdrant.NewVectors(0.05, 0.61, 0.76, 0.74),
            Payload: qdrant.NewValueMap(map[string]any{
                "city": "Berlin", "price": 1.99, "version": 3}),
        },
    },
    UpdateFilter: &qdrant.Filter{
        Must: []*qdrant.Condition{
            qdrant.NewMatchInt("version", 2),
        },
    },
})

请注意,用于条件更新的字段的名称和类型完全由您决定。应用程序可以使用时间戳(假设时钟同步)或任何其他适合其数据模型的单调递增值来代替 version

此机制在涉及嵌入模型迁移的场景中特别有用,在这种场景中,需要解决常规应用程序更新和后台重新嵌入任务之间的冲突。

Embedding model migration in blue-green deployment

蓝绿部署中的嵌入模型迁移

Web UI 视觉升级

Section 6

Web UI 是 Qdrant 用于管理部署和集合的用户界面。它使您能够创建和管理集合、运行 API 调用、导入示例数据集并通过交互式教程了解 Qdrant 的 API。

在 1.16 版本中,我们对 Web UI 进行了改造,采用了全新的外观和改进的用户体验。新设计具有以下增强功能

  • 一个新的欢迎页面,提供对教程和参考文档的快速访问。
  • 集合管理器中重新设计的点、可视化和图视图,通过以更紧凑的格式呈现数据,使处理数据变得更容易。
  • 在教程中,代码片段现在内联执行,从而释放屏幕空间以提高可用性。

Qdrant Web UI

荣誉提及

Section 7

有关 1.16 版本中所有更改的完整列表,请参阅更新日志

升级到 1.16 版本

Engage

在 Qdrant Cloud 中,转到您的集群详细信息屏幕,然后从下拉列表中选择 1.16 版本。升级可能需要几分钟。

我们建议逐个版本升级,例如 1.14->1.15->1.16。

1.16 版本有一些小的 API 更改。有关详细信息,请参阅语言客户端发行说明。

参与

Engage

我们很乐意听取您对此版本的想法。如果您有任何问题或反馈,请加入我们的Discord 或在GitHub 上创建问题。

免费开始使用 Qdrant

开始使用