数据库基础体系 · 第 63/139 篇。文章以各产品官方稳定版本的公开语义为准;示例会明确引擎、事务与部署边界。

Qdrant 实战:Collection、Payload 过滤、HNSW、分片和快照

Qdrant 是面向向量检索的数据库。它保存的核心对象不是关系数据库中的“行”,而是一个带有唯一 ID、向量和 Payload 的 point

point = {
  id: 1001,
  vector: [0.12, -0.03, 0.88, ...],
  payload: {
    "tenant_id": "acme",
    "language": "zh",
    "document_id": "doc-17",
    "chunk_index": 3
  }
}

其中:

  • Collection:向量集合,类似于关系数据库中的表,但它同时定义向量维度、距离函数、索引和分片等属性。
  • Vector:用于相似度搜索的数值数组。
  • Payload:与向量绑定的 JSON 元数据,用于过滤、返回结果和构建 Payload 索引。
  • HNSW:主要的近似最近邻向量索引。
  • Shard:Collection 在分布式部署中的数据分片。
  • Snapshot:用于备份和恢复 Collection 数据及其相关状态的快照。

本文使用 Qdrant 1.x 的公开稳定 API 语义。不同发行版本可能增加新的请求字段或兼容旧接口;实际部署应以目标版本的 OpenAPI 定义和官方文档为准。


一、先建立 Qdrant 的对象模型

1. Collection 不只是“向量表”

创建 Collection 时,至少需要确定:

  1. 向量维度;
  2. 距离函数;
  3. 向量存储方式;
  4. HNSW 等索引参数;
  5. 是否分片以及副本数量;
  6. Payload 是否建立字段索引。

例如,创建一个使用 4 维 Cosine 向量的 Collection:

curl -X PUT 'http://localhost:6333/collections/articles' \
  -H 'Content-Type: application/json' \
  -d '{
    "vectors": {
      "size": 4,
      "distance": "Cosine"
    }
  }'

请求成功后,Collection 的基本配置会包含:

{
  "result": true,
  "status": "ok",
  "time": 0.01
}

这里的 "size": 4 表示每个向量必须恰好有 4 个分量。下面的写入会失败:

{
  "id": 1,
  "vector": [0.1, 0.2, 0.3]
}

因为维度是 3,而 Collection 要求维度为 4。

1.1 距离函数决定“相似”的含义

常见距离函数包括:

  • Cosine:根据向量夹角衡量相似性,常用于文本 Embedding;
  • Dot:计算点积;
  • Euclid:计算欧氏距离;
  • 某些版本还支持 Manhattan 等距离类型。

Cosine 相似度为:

cos(x,y)=xyxy\operatorname{cos}(x,y)= \frac{x\cdot y}{\|x\|\|y\|}

其中:

  • xxyy 是两个向量;
  • xyx\cdot y 是点积;
  • x\|x\|y\|y\| 是向量的 L2 范数。

例如:

x=(1,0),y=(2,0)x=(1,0),\quad y=(2,0)

虽然两个向量长度不同,但方向相同,因此:

cos(x,y)=1\operatorname{cos}(x,y)=1

而:

z=(0,1)z=(0,1)

xx 垂直,因此:

cos(x,z)=0\operatorname{cos}(x,z)=0

这解释了为什么文本 Embedding 常用 Cosine:它更关注语义方向,而不是向量绝对长度。不过,是否应该归一化向量,必须与 Embedding 模型和所选距离保持一致,不能只因为“Cosine 一定要先归一化”而机械处理。

1.2 单向量、命名向量和多向量

最简单的 Collection 使用单个向量:

{
  "vectors": {
    "size": 4,
    "distance": "Cosine"
  }
}

如果一个 point 同时保存文本向量和图像向量,可以使用命名向量:

curl -X PUT 'http://localhost:6333/collections/multimodal' \
  -H 'Content-Type: application/json' \
  -d '{
    "vectors": {
      "text": {
        "size": 768,
        "distance": "Cosine"
      },
      "image": {
        "size": 512,
        "distance": "Cosine"
      }
    }
  }'

查询时需要指定使用哪个向量名称。否则,查询语义是不完整的:同一个 point 可能有多个不同空间中的向量。


二、写入 Point:向量、Payload 与更新语义

2.1 Upsert 一个完整 Point

curl -X PUT \
  'http://localhost:6333/collections/articles/points?wait=true' \
  -H 'Content-Type: application/json' \
  -d '{
    "points": [
      {
        "id": 1,
        "vector": [0.90, 0.10, 0.00, 0.00],
        "payload": {
          "tenant_id": "acme",
          "document_id": "doc-001",
          "chunk_index": 0,
          "language": "zh",
          "published_at": 1710000000,
          "tags": ["database", "qdrant"],
          "access_level": 3
        }
      },
      {
        "id": 2,
        "vector": [0.10, 0.90, 0.00, 0.00],
        "payload": {
          "tenant_id": "acme",
          "document_id": "doc-002",
          "chunk_index": 0,
          "language": "en",
          "published_at": 1711000000,
          "tags": ["vector"],
          "access_level": 1
        }
      }
    ]
  }'

这里的 wait=true 表示请求在服务端完成该操作后再返回。若不使用 wait=true,服务端可能先返回一个操作 ID,调用方再通过操作接口查询状态。

这不是关系数据库意义上的多语句事务。下面两个请求:

  1. 写入 point;
  2. 更新另一个 point 的 Payload;

不会自动组成一个跨请求的 ACID 事务。中间任一步失败,都需要应用层决定如何重试、补偿或恢复。

2.2 ID 的类型和幂等写入

Qdrant point ID 通常可以使用整数或 UUID。相同 Collection 中,ID 用于定位 point。

再次 upsert 相同 ID 时,写入具有覆盖或更新效果,具体取决于请求内容和接口:

  • 使用 upsert 写入完整 point,适合重建或覆盖;
  • 使用 Payload 更新接口,只修改 Payload;
  • 使用向量更新接口,只修改向量。

因此,批量导入时可以把稳定的业务主键映射成 point ID,使重试具有幂等性。例如:

业务文档 chunk ID: acme/doc-001/0
稳定映射后的 UUID: 由应用预先计算

如果每次重试都生成随机 ID,写入失败重试可能产生重复数据,这不是 Qdrant 自动能够判断的业务重复。


三、Payload:过滤不是“向量相似度的附加说明”

Payload 是 JSON 元数据,但在检索中承担两个不同职责:

  1. 返回业务字段;
  2. 在向量检索之前或过程中限制候选集合。

例如,RAG 检索通常不能只问“哪个向量最接近”,还必须满足:

tenant_id = 当前租户
language = zh
published_at >= 某个时间

如果只用向量相似度搜索,然后在应用层过滤,可能发生严重问题:

  • 前 10 个结果大部分属于其他租户;
  • 过滤后只剩 1 个结果;
  • 为了补足数量而盲目扩大 limit;
  • 扩大 limit 后仍无法保证权限隔离;
  • 在高并发下额外传输大量无效结果。

因此,租户、权限、状态等安全边界应该进入 Qdrant 的查询过滤条件,而不能只依赖应用层事后过滤。

3.1 基本过滤条件

使用 /points/query 查询向量,并限制 tenant_idlanguage

curl -X POST \
  'http://localhost:6333/collections/articles/points/query' \
  -H 'Content-Type: application/json' \
  -d '{
    "query": [0.88, 0.12, 0.00, 0.00],
    "limit": 5,
    "with_payload": true,
    "filter": {
      "must": [
        {
          "key": "tenant_id",
          "match": {
            "value": "acme"
          }
        },
        {
          "key": "language",
          "match": {
            "value": "zh"
          }
        }
      ]
    }
  }'

must 中的条件是逻辑 AND。一个 point 必须同时满足:

payload.tenant_id == "acme"
AND
payload.language == "zh"

返回结果通常包含 point ID、相似度分数和请求要求返回的 Payload,例如:

{
  "result": {
    "points": [
      {
        "id": 1,
        "version": 3,
        "score": 0.9951,
        "payload": {
          "tenant_id": "acme",
          "document_id": "doc-001",
          "language": "zh"
        }
      }
    ]
  },
  "status": "ok"
}

不同距离函数下 score 的数值意义不同,不能把所有 Collection 的分数直接横向比较。

3.2 must、must_not 和 should

一个更完整的过滤器可以表达:

  • 必须是某个租户;
  • 必须是中文;
  • 不能是已删除内容;
  • 标签中至少包含某个值;
  • 或者满足多个候选条件之一。

示例:

{
  "must": [
    {
      "key": "tenant_id",
      "match": {
        "value": "acme"
      }
    }
  ],
  "must_not": [
    {
      "key": "deleted",
      "match": {
        "value": true
      }
    }
  ],
  "should": [
    {
      "key": "tags",
      "match": {
        "value": "database"
      }
    },
    {
      "key": "tags",
      "match": {
        "value": "vector"
      }
    }
  ]
}

语义可以理解为:

tenant_id = acme
AND deleted != true
AND (tags 包含 database OR tags 包含 vector)

对于复杂嵌套结构,Qdrant 还提供 nested 条件,用于保证多个条件匹配同一个数组对象。这个区别非常重要。

假设 Payload 为:

{
  "diet": [
    {"food": "meat", "likes": true},
    {"food": "vegetable", "likes": false}
  ]
}

如果分别对 diet[].fooddiet[].likes 做普通条件,可能得到“数组中分别存在满足条件的元素”的语义;如果业务要求同一个对象同时满足:

food = meat AND likes = true

就应使用嵌套过滤表达这一约束,而不能把两个字段条件简单拼接后假定它们必然作用于同一个数组元素。

3.3 数值、范围和存在性条件

时间戳和数值字段通常使用范围条件:

{
  "key": "published_at",
  "range": {
    "gte": 1710000000,
    "lt": 1720000000
  }
}

常见边界含义:

  • gt:大于;
  • gte:大于等于;
  • lt:小于;
  • lte:小于等于。

判断字段是否存在,可以使用 is_emptyis_null 等条件。具体字段和语义应以目标版本支持的 Filter API 为准,尤其要区分:

  • 字段不存在;
  • 字段存在但值为空数组;
  • 字段存在且值为 JSON null

这三个状态在数据清洗不严格时可能并不等价。

3.4 Payload 字段索引

Payload 默认是 JSON 数据,但不代表每个字段都已经建立适合过滤的索引。对高频过滤字段创建 Payload 索引:

curl -X PUT \
  'http://localhost:6333/collections/articles/index' \
  -H 'Content-Type: application/json' \
  -d '{
    "field_name": "tenant_id",
    "field_schema": "keyword"
  }'

再为数值字段创建索引:

curl -X PUT \
  'http://localhost:6333/collections/articles/index' \
  -H 'Content-Type: application/json' \
  -d '{
    "field_name": "published_at",
    "field_schema": "integer"
  }'

Payload 索引的作用不是让向量相似度计算更精确,而是让过滤条件能够更高效地定位或排除候选。常见字段类型包括关键词、整数、浮点数、布尔值、地理位置、文本和 UUID 等;字段类型应与实际数据一致。

例如把 tenant_id 建成数值索引,却写入 "acme",会导致索引无法按预期工作,甚至造成写入或查询错误。

3.5 过滤与权限边界

向量检索中的 Payload 过滤有一个安全边界:

应用层过滤 ≠ 数据库层访问控制

正确的调用链应类似:

认证服务确定 tenant_id
        ↓
应用构造固定的 must 过滤条件
        ↓
Qdrant 只返回该租户的候选
        ↓
应用执行重排、引用拼装和最终展示

不应让客户端直接提交任意 tenant_id,然后把它原样放入查询。租户条件应由可信的服务端上下文注入。


四、HNSW:Qdrant 如何加速近似最近邻搜索

4.1 Flat 搜索为什么会慢

设 Collection 中有 NN 个向量,每个向量维度为 dd。最直接的 Flat 搜索做法是:

  1. 计算查询向量与每个向量的距离;
  2. 维护距离最小或相似度最大的前 kk 个结果。

其主要计算量近似为:

O(Nd)O(Nd)

如果有 1 亿个向量,即使单次距离计算很快,也需要扫描大量数据。

Flat 搜索的优点是结果精确;缺点是随着 NN 增长,延迟和计算成本线性增加。HNSW 的目标是在不扫描全部向量的情况下,快速找到高质量候选。

4.2 HNSW 的图结构

HNSW 的全称是 Hierarchical Navigable Small World。它把向量组织成多层图:

  • 最底层包含全部或大部分节点;
  • 上层节点更稀疏;
  • 上层用于快速跨越向量空间;
  • 底层用于局部精细搜索。

查询过程可以抽象为:

  1. 从最高层的入口节点开始;
  2. 查看当前节点的邻居;
  3. 如果某个邻居比当前节点更接近查询向量,就移动到该邻居;
  4. 当前层无法继续改善时,下降到下一层;
  5. 在底层维护一个候选集合;
  6. 返回距离最优的前 kk 个点。

这个过程不是穷举,因此属于近似最近邻搜索。它可能漏掉真实的精确近邻。

4.3 一个简化的搜索过程

假设底层图中有节点 ABCD,查询向量为 Q

A 的邻居:B、C
B 的邻居:A、D
C 的邻居:A、D
D 的邻居:B、C

距离如下:

dist(Q, A) = 0.80
dist(Q, B) = 0.40
dist(Q, C) = 0.55
dist(Q, D) = 0.10

A 开始:

  1. 当前为 A,查看 BC
  2. B 的距离 0.40 小于 A 的 0.80,移动到 B
  3. 查看 B 的邻居,发现 D 的距离为 0.10;
  4. 移动到 D
  5. D 的邻居没有更近节点,搜索结束。

如果候选宽度足够大,D 会被保留并返回。如果候选宽度太小,算法可能在某一步过早丢弃通往 D 的路径。

这说明 HNSW 有两个不同概念:

  • 图的连通质量:由构建参数影响;
  • 查询时探索范围:由搜索参数影响。

4.4 关键参数:m、ef_construct 和 hnsw_ef

m

m 表示图中节点连接的邻居数量上限附近的控制参数。它增大后通常会:

  • 提高图的连通性;
  • 可能提高召回率;
  • 增加索引内存;
  • 增加索引构建和更新成本。

ef_construct

ef_construct 控制构建 HNSW 图时搜索候选的范围。它增大后通常会:

  • 找到质量更好的邻接关系;
  • 提高索引质量;
  • 延长构建时间;
  • 增加构建阶段资源消耗。

hnsw_ef

hnsw_ef 是查询时的候选宽度。它增大后通常会:

  • 提高召回率;
  • 增加查询延迟和 CPU 使用;
  • 允许更充分地探索图。

可以这样创建 Collection:

curl -X PUT 'http://localhost:6333/collections/articles_hnsw' \
  -H 'Content-Type: application/json' \
  -d '{
    "vectors": {
      "size": 768,
      "distance": "Cosine"
    },
    "hnsw_config": {
      "m": 16,
      "ef_construct": 200,
      "full_scan_threshold": 10000
    }
  }'

查询时指定搜索参数:

curl -X POST \
  'http://localhost:6333/collections/articles_hnsw/points/query' \
  -H 'Content-Type: application/json' \
  -d '{
    "query": [0.01, 0.02, 0.03],
    "limit": 10,
    "params": {
      "hnsw_ef": 128
    }
  }'

上面的向量只是示意。真实请求中必须传入 768 维向量。

4.5 HNSW 不是“永远比 Flat 快”

当候选集合很小,或者过滤条件已经把数据缩小到很少的点时,Flat 扫描反而可能更合适。Qdrant 会根据 Collection 和索引配置在某些场景下使用全扫描;full_scan_threshold 就是与这一选择相关的配置项之一。

因此:

数据量小 + 过滤选择性高

不一定适合强行使用 HNSW。

4.6 exact 查询和召回率验证

为了验证近似搜索是否漏召回,可以使用精确搜索参数:

{
  "query": [0.9, 0.1, 0.0, 0.0],
  "limit": 10,
  "params": {
    "exact": true
  }
}

然后将 exact=true 的结果作为基准,与普通 HNSW 结果比较。

若精确结果集合为 EE,HNSW 结果集合为 HH,目标数量为 kk,则 Recall@k 可以计算为:

Recall@k=EHE\operatorname{Recall}@k = \frac{|E \cap H|}{|E|}

当两个结果都包含 kk 个点时,也常写为:

Recall@k=EHk\operatorname{Recall}@k = \frac{|E \cap H|}{k}

例如:

精确结果 E = {1, 2, 3, 4, 5}
HNSW 结果 H = {1, 2, 3, 6, 7}

则:

|E ∩ H| = 3
Recall@5 = 3 / 5 = 0.6

这比只观察接口返回的 score 更能说明索引是否满足业务要求。

4.7 Payload 过滤与 HNSW 的结合

带过滤条件的向量搜索并不等价于:

先找全局最相似的 10 个
再删除不符合过滤条件的点

真正需要的目标是:

TopK({pp 满足过滤条件})\operatorname{TopK} \left( \{p \mid p \text{ 满足过滤条件}\} \right)

也就是只在满足 Payload 条件的集合中选择 Top-K。

Qdrant 会结合 Payload 索引、Segment 和 HNSW 搜索策略处理过滤。过滤条件选择性、字段索引是否存在、候选集合大小,都会影响实际路径。

如果租户过滤条件没有索引,或者过滤条件极其稀疏,搜索可能需要检查大量节点才能找到足够的合法结果。表现通常是:

  • 查询延迟高;
  • 返回数量少于 limit
  • 增大 hnsw_ef 后延迟明显增加;
  • exact=true 与近似搜索的结果差异较大。

调大 hnsw_ef 只能增加探索范围,不能修复错误的租户条件、错误的 Payload 类型或缺失的业务数据。


五、从写入到查询:一个可运行的最小流程

下面使用 Python 的 HTTP 客户端语义展示端到端过程。为了减少依赖,也可以直接改成 curl

先安装:

pip install requests

完整示例:

import requests

BASE = "http://localhost:6333"
COLLECTION = "demo_articles"

def qdrant(method, path, **kwargs):
    response = requests.request(
        method,
        BASE + path,
        timeout=30,
        **kwargs,
    )
    response.raise_for_status()
    return response.json()

# 1. 创建 Collection
qdrant(
    "PUT",
    f"/collections/{COLLECTION}",
    json={
        "vectors": {
            "size": 4,
            "distance": "Cosine",
        }
    },
)

# 2. 建立高频过滤字段索引
qdrant(
    "PUT",
    f"/collections/{COLLECTION}/index",
    json={
        "field_name": "tenant_id",
        "field_schema": "keyword",
    },
)

qdrant(
    "PUT",
    f"/collections/{COLLECTION}/index",
    json={
        "field_name": "language",
        "field_schema": "keyword",
    },
)

# 3. 写入 points
qdrant(
    "PUT",
    f"/collections/{COLLECTION}/points",
    params={"wait": "true"},
    json={
        "points": [
            {
                "id": 1,
                "vector": [1.0, 0.0, 0.0, 0.0],
                "payload": {
                    "tenant_id": "acme",
                    "language": "zh",
                    "title": "Qdrant Collection",
                },
            },
            {
                "id": 2,
                "vector": [0.0, 1.0, 0.0, 0.0],
                "payload": {
                    "tenant_id": "acme",
                    "language": "en",
                    "title": "Vector Search",
                },
            },
            {
                "id": 3,
                "vector": [0.8, 0.2, 0.0, 0.0],
                "payload": {
                    "tenant_id": "other",
                    "language": "zh",
                    "title": "Other Tenant",
                },
            },
        ]
    },
)

# 4. 只查询 acme 租户的中文内容
result = qdrant(
    "POST",
    f"/collections/{COLLECTION}/points/query",
    json={
        "query": [0.95, 0.05, 0.0, 0.0],
        "limit": 10,
        "with_payload": True,
        "filter": {
            "must": [
                {
                    "key": "tenant_id",
                    "match": {"value": "acme"},
                },
                {
                    "key": "language",
                    "match": {"value": "zh"},
                },
            ]
        },
    },
)

for point in result["result"]["points"]:
    print(point["id"], point["score"], point.get("payload"))

预期只会返回 ID 为 1 的 point。虽然 ID 为 3 的向量也接近查询向量,但它属于 other 租户,不能出现在结果中。

这个例子同时说明了三个边界:

  1. 向量相似度决定排序;
  2. Payload 过滤决定候选资格;
  3. 应用层拿到的是已经过滤后的结果,而不是先拿全局结果再自行删除。

六、分片:Collection 如何扩展到多个节点

6.1 Shard 的含义

Shard 是 Collection 的一个数据分区。一个 Collection 可以由多个 shard 组成,每个 shard 保存一部分 point 及其向量索引、Payload 和相关段数据。

在分布式部署中,常见数据流如下:

客户端写入
   ↓
Qdrant 集群路由
   ↓
根据 point ID 或 sharding key 选择目标 shard
   ↓
目标 shard 的一个或多个副本写入

查询流程则通常是:

客户端查询
   ↓
请求发送到协调节点或集群节点
   ↓
向相关 shard 发起查询
   ↓
每个 shard 返回本地 Top-K
   ↓
协调端合并并截取全局 Top-K

最后一步很关键。假设有两个 shard,查询要求 limit=3

Shard A 返回:A1(0.99), A2(0.95), A3(0.90)
Shard B 返回:B1(0.98), B2(0.97), B3(0.80)

合并后全局结果应为:

A1(0.99), B1(0.98), B2(0.97)

如果每个 shard 只返回 1 个结果,协调端就可能无法得到正确的全局 Top-3。因此分布式查询必须使用足够的本地候选,Qdrant 会负责相应的查询协调,但 limit、过滤选择性和近似搜索参数仍会影响结果质量和延迟。

6.2 创建带分片的 Collection

创建时可以指定初始 shard 数量:

curl -X PUT 'http://localhost:6333/collections/distributed_articles' \
  -H 'Content-Type: application/json' \
  -d '{
    "vectors": {
      "size": 768,
      "distance": "Cosine"
    },
    "shard_number": 4,
    "replication_factor": 2,
    "write_consistency_factor": 1
  }'

这些字段的概念分别是:

  • shard_number:Collection 初始划分的 shard 数量;
  • replication_factor:每个 shard 的副本数量;
  • write_consistency_factor:一次写入需要多少个副本确认后才算满足写入一致性要求。

replication_factor=2 并不表示写入只保存两份逻辑数据,而表示每个 shard 有两个副本。副本可以提高故障容忍能力,但也会增加存储和写入成本。

write_consistency_factor=1 表示一个副本确认即可满足该写入一致性条件;这提高了可用性和写入速度,但不等于“所有副本此刻已经完成相同状态”。

6.3 point 如何路由到 shard

默认情况下,Qdrant 可以根据 point ID 对 shard 进行路由。这样同一个 ID 的 point 会稳定地落到相应 shard。

在多租户系统中,也可能希望以租户或业务分区作为 custom sharding key。例如:

tenant_id = acme  → 一组固定 shard
tenant_id = beta  → 另一组固定 shard

这样可以让带有 tenant_id 的查询只访问相关分片,减少 fan-out。但 custom sharding 需要在 Collection 创建和写入、查询时使用相应的 sharding key 语义;不能仅仅在 Payload 中写入 tenant_id,就认为 Qdrant 会自动按该字段分片。

需要区分:

Payload 过滤:限制哪些 point 可以参与结果
Sharding key:决定请求访问哪些 shard

前者是检索条件,后者是数据路由。两者可以配合,但不是同一个机制。

6.4 分片查询的故障路径

考虑一个四分片、每个分片两副本的 Collection:

Shard 0: Replica A、Replica B
Shard 1: Replica A、Replica B
Shard 2: Replica A、Replica B
Shard 3: Replica A、Replica B

如果 Shard 2 的一个副本宕机:

  • 另一个副本仍可提供服务;
  • 查询可能继续完成;
  • 可用性取决于集群是否能找到健康副本以及当前一致性设置。

如果 Shard 2 的全部副本都不可用:

  • 全局查询可能失败;
  • 或者只能返回不完整结果,取决于具体请求和集群状态;
  • 即使其他三个 shard 正常,也不能把结果称为完整的全局 Top-K。

这也是为什么“节点在线”不能简单等价于“Collection 完整可用”:必须检查每个 shard 的副本状态和 Collection 的健康状态。

6.5 分片数量不是越多越好

增加 shard 数量可以:

  • 将数据和索引分散到更多节点;
  • 提高并行处理能力;
  • 降低单个 shard 的数据规模。

但也会增加:

  • 查询 fan-out;
  • shard 间结果合并成本;
  • 副本管理复杂度;
  • 小数据量 Collection 的固定开销;
  • 迁移和恢复时间。

如果一个 Collection 只有几万条数据,却创建了大量 shard,每次查询都可能需要协调多个小分片,通常得不到收益。


七、快照:保存什么、如何恢复、恢复后要验证什么

7.1 Snapshot 的作用

Snapshot 用于:

  • 灾难恢复;
  • 集群迁移;
  • 测试环境复制;
  • 在升级前保留可回滚数据;
  • 将某个 Collection 复制到另一个 Qdrant 实例。

快照不是普通查询结果。它应包含恢复 Collection 所需的数据和配置状态,而不是只保存 point ID 或 Payload。

同时要区分:

Snapshot 备份 ≠ 实时复制
Snapshot 备份 ≠ 跨请求事务

快照是某个时点的备份状态;它不会自动把之后发生的写入同步到另一个实例。

7.2 创建 Collection 快照

可以请求创建指定 Collection 的快照:

curl -X POST \
  'http://localhost:6333/collections/articles/snapshots'

创建过程可能是异步操作,返回结果中可能包含操作状态或快照信息。然后列出已有快照:

curl \
  'http://localhost:6333/collections/articles/snapshots'

返回内容通常会列出快照名称、创建时间和文件大小等信息。不要只根据 HTTP 200 判断备份已经可恢复;应确认返回的操作已经完成,并且快照文件确实存在。

下载快照:

curl -L \
  'http://localhost:6333/collections/articles/snapshots/<snapshot-name>' \
  -o articles.snapshot

其中 <snapshot-name> 必须替换为列举接口返回的实际名称。

生产环境不应把快照只保存在 Qdrant 所在磁盘上。因为:

数据库节点损坏 + 本地快照同时损坏

时,备份没有灾难恢复价值。应将快照复制到独立的持久化存储,并记录:

  • Collection 名称;
  • Qdrant 版本;
  • 向量模型及维度;
  • 距离函数;
  • 创建时间;
  • 快照校验值;
  • 是否包含该时点之前的全部写入。

7.3 从快照恢复

恢复到一个实例时,通常需要:

  1. 准备目标 Qdrant;
  2. 确认目标 Collection 名称和恢复策略;
  3. 将快照放到目标可访问的位置;
  4. 调用 Collection snapshot recovery API;
  5. 等待恢复操作完成;
  6. 校验 Collection、point 数量、Payload 和查询结果。

恢复接口支持本地文件或远程位置等方式,具体请求格式随 Qdrant 版本和部署方式可能不同。典型的远程恢复请求形态如下:

curl -X PUT \
  'http://localhost:6333/collections/articles/snapshots/recover' \
  -H 'Content-Type: application/json' \
  -d '{
    "location": "http://backup-server.example.com/articles.snapshot"
  }'

这里的 URL 必须满足目标 Qdrant 进程的网络访问条件。以下情况会导致恢复失败:

  • 目标实例无法访问该 URL;
  • 备份服务需要认证,但没有提供认证信息;
  • 快照文件损坏;
  • 目标 Collection 已存在且恢复策略不允许覆盖;
  • Qdrant 版本或存储格式不兼容。

不要直接把示例 URL 当成可用地址。恢复前应先从目标机器或目标容器内部测试网络连通性,并记录恢复操作 ID。

7.4 快照恢复后的验证

恢复成功不等于业务数据已经正确。至少应验证:

curl 'http://localhost:6333/collections/articles'

检查:

  • Collection 是否存在;
  • 向量配置的维度和距离函数是否正确;
  • Collection 是否处于可用状态;
  • point 数量是否符合预期;
  • Payload 索引是否存在。

然后随机抽取若干 point:

curl -X POST \
  'http://localhost:6333/collections/articles/points/scroll' \
  -H 'Content-Type: application/json' \
  -d '{
    "limit": 3,
    "with_payload": true,
    "with_vector": false
  }'

再用一个已知向量执行查询,对比:

  • Top-K 的 ID;
  • Payload 字段;
  • 过滤结果;
  • 分数范围;
  • HNSW 近似查询与 exact=true 查询的差异。

如果这是 RAG 数据层,还应继续检查:

chunk 是否仍能映射到原文
document_id 是否存在
引用位置是否有效
tenant_id 是否正确
删除状态是否正确

快照恢复的是 Qdrant 的数据,不一定恢复应用数据库、对象存储中的原文、Embedding 模型文件或文档版本表。RAG 系统的可恢复性必须覆盖这些外部依赖。

7.5 分布式 Collection 的快照边界

在分布式部署中,Collection 由多个 shard 和副本组成。备份策略必须明确:

  • 是对整个 Collection 做逻辑备份;
  • 还是在某个节点上创建本地 Collection 快照;
  • 是由 Qdrant 集群管理备份;
  • 还是由云服务提供统一备份。

不能简单地创建某个节点上的本地文件,然后假定它自动代表整个集群的完整一致状态。分片和副本的快照范围、恢复流程以及是否需要每个节点参与,应以目标部署形态的官方文档为准。


八、并发、操作状态和“写入成功”的含义

8.1 wait=true 不是事务提交

Qdrant 的写入接口可以同步等待操作完成,也可以异步提交。同步等待的语义近似:

服务端已处理该操作,然后返回结果

它不表示:

多个 API 请求已经组成一个可回滚事务

例如:

请求 1:写入向量
请求 2:写入 Payload
请求 3:更新外部文档状态

如果请求 3 失败,Qdrant 不会自动回滚请求 1 和请求 2。应用必须设计:

  • 幂等 ID;
  • 可重试操作;
  • 失败补偿;
  • 数据校验;
  • 外部状态机。

8.2 写入、索引构建和查询并发

Qdrant 的数据会经历写入、段合并和索引构建等内部阶段。写入完成后,查询可以使用数据,但索引可能仍在构建或优化。

因此,性能测试需要区分:

  1. 刚写入后的冷状态;
  2. 索引已经构建后的稳定状态;
  3. Segment 合并期间;
  4. 重启或恢复后的状态;
  5. Payload 索引已经完成后的状态。

如果刚导入大量数据就立刻测延迟,测到的可能是索引构建竞争,而不是稳定查询性能。

8.3 重试与重复请求

网络超时并不一定表示服务端没有执行请求。客户端若在超时后直接重试:

  • 使用稳定 point ID 的 upsert 通常可以避免重复 point;
  • 使用随机 ID 的写入可能产生重复;
  • Payload 增量更新必须确认是否幂等;
  • 批量操作应记录业务批次号,便于审计和校验。

对于重要写入,应保存 Qdrant 返回的操作状态,并在重试前查询操作结果,而不是只根据客户端连接异常做判断。


九、常见失败表现和诊断方法

9.1 结果为空或少于 limit

可能原因:

  1. Payload 条件确实没有匹配数据;
  2. 字段类型不一致,例如写入字符串却用数值范围过滤;
  3. must 条件过于严格;
  4. 过滤字段路径写错;
  5. 指定了错误的租户或分片 key;
  6. 数据尚未写入完成;
  7. 分布式 Collection 某个 shard 不可用;
  8. 查询结果被业务层再次过滤。

诊断步骤:

先执行无过滤查询
→ 再只加 tenant_id
→ 再增加 language
→ 再增加时间和状态条件

这样可以逐步定位是哪一个条件导致结果消失。

9.2 HNSW 结果与精确结果差异很大

先用同一个查询执行:

{
  "params": {
    "exact": true
  }
}

并与普通查询对比。如果差异大,再检查:

  • hnsw_ef 是否过小;
  • HNSW 是否仍处于构建或优化阶段;
  • Collection 是否刚完成大批量导入;
  • 过滤字段是否有索引;
  • 向量维度、模型和距离函数是否一致;
  • 是否错误地把不同模型生成的向量写入同一 Collection。

不要只通过增加 limit 判断召回率。召回率应使用精确结果作为基准测量。

9.3 查询慢但数据量并不大

常见原因包括:

  • Payload 过滤字段没有建立索引;
  • 查询要求返回完整 Payload,导致网络和序列化成本高;
  • with_vector=true 返回了大向量;
  • hnsw_ef 设置过高;
  • 过滤条件选择性极低;
  • 多 shard fan-out 后的合并成本较高;
  • 磁盘索引或 Payload 配置导致随机 I/O;
  • 后台索引构建和 Segment 合并占用 CPU。

如果只需要标题和文档 ID,不要返回完整 Payload:

{
  "with_payload": [
    "document_id",
    "chunk_index",
    "title"
  ],
  "with_vector": false
}

9.4 删除后仍然“看见”旧数据

需要区分:

  • 删除请求是否真正完成;
  • 查询是否命中了不同的 point ID;
  • 是否存在副本同步延迟;
  • 应用层是否缓存了旧结果;
  • RAG 服务是否缓存了旧文档;
  • Payload 中的 deleted 标志是否与实际删除混用。

如果采用软删除:

{
  "deleted": true
}

那么所有检索都必须加入:

{
  "must_not": [
    {
      "key": "deleted",
      "match": {
        "value": true
      }
    }
  ]
}

否则“已删除”只是一个字段,不会自动使 point 从查询中消失。


十、在 RAG 中组合全文、向量、过滤和重排

Qdrant 最适合作为 RAG 数据层中的向量检索和元数据过滤组件之一,而不是自动替代全文检索、权限系统和引用系统。

一个典型流程是:

用户问题
  ↓
查询 Embedding
  ↓
Qdrant 向量检索 + Payload 过滤
  ↓
可选:全文检索或稀疏检索
  ↓
融合多个结果集
  ↓
重排模型
  ↓
按 document_id 合并相邻 chunk
  ↓
生成带引用的答案

10.1 向量检索不等于全文检索

向量检索擅长语义相似,例如:

“如何备份向量数据库”

可能找到:

“创建 Collection Snapshot 并恢复数据”

全文检索则擅长精确匹配:

Qdrant
HNSW
write_consistency_factor

在技术文档、错误码和 API 参数场景中,精确词匹配通常不可替代。混合检索的结果需要融合,而不是把向量分数和 BM25 分数直接相加,因为两者的数值空间通常不同。

10.2 重排和引用不由 HNSW 自动完成

HNSW 返回的是向量空间中的近邻,不保证:

  • 文档片段在上下文中连续;
  • 结果覆盖多个必要事实;
  • 引用位置准确;
  • 不同 chunk 不来自同一篇文档;
  • 最适合生成模型使用。

应用通常需要使用 Payload 中的:

document_id
chunk_index
page
section
source_url

对结果做去重、相邻片段合并和引用拼装。缺少这些字段时,即使向量检索准确,也很难构造可靠引用。


十一、容量、精度和内存之间的取舍

HNSW 主要解决检索速度问题,但不会消除向量本身的内存成本。

若有 NN 个向量,每个向量维度为 dd,使用 32 位浮点数,原始向量内存约为:

MvectorN×d×4 bytesM_{\text{vector}} \approx N \times d \times 4\ \text{bytes}

例如:

N = 10,000,000
d = 768

则仅原始向量就约为:

107×768×4=30,720,000,000 bytes10^7 \times 768 \times 4 = 30,720,000,000\ \text{bytes}

约为 30.72 GB,尚未包括:

  • HNSW 图;
  • Payload;
  • Payload 索引;
  • Segment 元数据;
  • WAL;
  • 副本;
  • 操作系统和进程开销。

因此,生产容量规划不能只看“向量数量 × 维度”。如果使用磁盘存储向量或索引,还需要用真实数据和真实查询压测,因为降低内存占用通常会增加 I/O 敏感性和延迟波动。

与其他索引的关系可以概括为:

方法 主要特征
Flat 精确,计算量高,适合小规模或基准
HNSW 低延迟、高召回的常用近似索引,内存和构建成本较高
IVF 先聚类再搜索部分簇,依赖簇数和探测簇数量
PQ 压缩向量,节省内存,但会引入量化误差

Qdrant 中选择 HNSW 参数时,真正要测量的是:

Recall@k
P50/P95/P99 延迟
构建时间
内存峰值
写入吞吐
过滤条件下的召回率

只测无过滤查询,无法代表多租户 RAG 的实际表现。


十二、一个可落地的检查顺序

当一个 Qdrant Collection 出现问题时,可以按以下因果顺序检查:

Collection 配置
  ↓
向量维度和距离是否正确
  ↓
point 是否成功写入
  ↓
Payload 类型和字段路径是否正确
  ↓
过滤逻辑是否符合预期
  ↓
Payload 索引是否存在
  ↓
HNSW 是否构建完成
  ↓
exact 与近似结果差异
  ↓
分片和副本健康状态
  ↓
应用层缓存、重排和引用拼装

其中任何一层错误,都可能表现成“向量搜索不准”。例如:

  • 向量模型不一致,是数据问题;
  • tenant_id 条件错误,是过滤问题;
  • hnsw_ef 过小,是索引搜索参数问题;
  • shard 副本不可用,是部署问题;
  • 返回结果正确但引用错误,是 RAG 应用层问题。

把这些问题统称为“Embedding 不好”会掩盖真正原因。

Qdrant 的核心使用逻辑可以归纳为:

Collection 定义向量空间和数据组织方式
Point 保存向量及其 Payload
Payload Filter 限制合法候选
HNSW 加速近似最近邻搜索
Shard 扩展数据和查询处理能力
Snapshot 提供备份与恢复路径

真正的生产系统还需要把这些机制与租户权限、Embedding 版本、全文检索、重排、引用和外部原文存储统一设计;只有这样,向量检索结果才不仅“相似”,而且可控、可恢复、可解释。


系列导航与关联阅读

官方资料

本文依据数据库官方文档重新梳理;正文、示例与生产检查清单由 WR BLOG 编写。