主题
14.09-Qdrant实践
要点
- Qdrant 是 Rust 写的开源专用向量数据库,核心价值在过滤查询不降速、原生混合检索和灵活多租户
- 稠密 + 稀疏向量混合检索,RRF 融合兼顾语义相似性和关键词精确匹配
- Payload Index 让过滤查询在大数据量下仍保持亚毫秒级响应——这是 pgvector 做不到或代价很高的能力
- 部署方式:Docker(开发)、Kubernetes(生产)、Qdrant Cloud(托管)
- TypeScript SDK 完善,API 设计直观,迁移成本低
- 适合 pgvector 跑不动(过滤复杂、数据量超百万、多租户隔离需求明确)的场景
1. 什么时候选 Qdrant
你已经用 pgvector 跑通了 RAG 原型。什么时候该换专用向量数据库?看三个信号:
过滤查询变慢——当你的 WHERE 条件超过两个字段,数据量过百万后,pgvector 的向量索引和关系索引开始互相拖累,查询延迟飙到 100ms 以上。
需要混合检索——语义向量检索抓不准专有名词和编号,需要稠密 + 稀疏向量融合。pgvector 要做这件事得拼凑两套机制,效果不如原生支持。
多租户隔离变刚需——每个租户的数据必须严格隔离,不能靠代码层的 WHERE 条件来保证。pgvector 的行级安全(RLS)够用,但配置复杂,性能开销也不小。
三个信号中命中两个,就该看 Qdrant。没命中说明 pgvector 还能继续撑。
Qdrant 和 pgvector 的核心区别不在于「谁更快」,而在于设计目标不同。pgvector 是在关系数据库上加向量能力,向量只是数据类型之一;Qdrant 是专门为向量搜索设计的引擎,过滤、混合检索、多租户都是原生能力,不需要绕路。
完成这篇教程后,你能得到:
- 一个可运行的 Qdrant 环境(Docker 本地 + Cloud 生产配置)
- 一套完整的 Collection 设计和 Payload 索引策略
- 向量写入、查询、混合检索、多租户隔离的 TypeScript 实现
- 判断什么时候该换 Milvus 的依据
2. 环境搭建
2.1 Docker 启动(本地开发)
bash
docker run -d -p 6333:6333 -p 6334:6334 \
-v $(pwd)/qdrant_storage:/qdrant/storage \
qdrant/qdrant两个端口的用途:6333 是 REST API(调试和管理用),6334 是 gRPC API(生产写入用,吞吐量更高)。本地开发用 REST 就够了,生产环境建议切 gRPC。
启动后验证:
bash
curl http://localhost:6333/
# 预期输出:{"title":"qdrant","version":"..."}看到版本号就说明 Qdrant 已正常运行。如果连接拒绝,等 5-10 秒再试——Qdrant 启动需要加载存储引擎。
2.2 Docker Compose(团队协作 / 持久化)
yaml
# docker-compose.yml
services:
qdrant:
image: qdrant/qdrant
ports:
- "6333:6333"
- "6334:6334"
volumes:
- ./qdrant_storage:/qdrant/storage
environment:
- QDRANT__SERVICE__GRPC_PORT=6334
# 生产环境建议开启 API Key
# - QDRANT__SERVICE__API_KEY=your-secret-key
restart: unless-stoppedrestart: unless-stopped 让容器在系统重启后自动恢复,本地开发不容易忘记重启服务。
示例可用:上述配置够本地开发用。生产可用需要在生产环境加上 QDRANT__SERVICE__API_KEY 防止未授权访问,并用 Kubernetes 或 Qdrant Cloud 管理。
2.3 连接客户端
typescript
import { QdrantClient } from '@qdrant/js-client-rest'
const client = new QdrantClient({
url: process.env.QDRANT_URL ?? 'http://localhost:6333',
apiKey: process.env.QDRANT_API_KEY,
})
// 验证连接
const collections = await client.getCollections()
console.log('当前 Collection 数量:', collections.collections.length)安装依赖:pnpm add @qdrant/js-client-rest。生产环境如果写入量大(每秒上千条),把客户端切到 gRPC 协议——在构造函数里传 grpcPort: 6334,写入性能能提升 2-3 倍。
常见错误:如果报 UNAUTHORIZED,检查 QDRANT_API_KEY 是否和 Qdrant 启动时设置的一致。如果报 ECONNREFUSED,确认 Qdrant 容器已启动且端口没被占用。
3. Collection 设计与索引
3.1 创建 Collection
Collection 是 Qdrant 里的数据容器,相当于 PostgreSQL 的表。创建时需要确定向量维度、距离函数和可选的稀疏向量配置。
typescript
await client.createCollection('documents', {
vectors: {
size: 768, // 向量维度——必须和 embedding 模型输出匹配
distance: 'Cosine', // Cosine / Euclid / Dot
},
// 稀疏向量配置(用于混合检索,第 5 节会用到)
sparse_vectors: {
'text-sparse': {
index: { on_disk: true }, // 稀疏向量存磁盘,节省内存
},
},
// 优化器配置
optimizers_config: {
default_segment_number: 2, // 数据分段数,影响并发写入性能
memmap_threshold: 20000, // 超过此行数的向量存磁盘而非内存
},
})向量维度必须和 embedding 模型匹配——用 text-embedding-3-small 就是 1536 维,用 bge-m3 就是 1024 维。维度不对,写入直接报错。
距离函数的选择:
| 距离函数 | 适用场景 | 说明 |
|---|---|---|
| Cosine | 大多数文本 embedding | 衡量方向相似性,忽略向量长度 |
| Euclid | 图像特征、坐标数据 | 衡量空间距离 |
| Dot | 已归一化的向量 | 计算内积,速度最快 |
如果你不确定用哪个,选 Cosine。大多数文本 embedding 模型输出的向量都适合 Cosine 距离。
3.2 Payload 索引——过滤查询性能的关键
这是 Qdrant 和 pgvector 差距最大的地方。Payload 索引让 Qdrant 在过滤查询时不需要扫描全部数据,直接把过滤复杂度从 O(n) 降到 O(log n)。
typescript
// 为常用过滤字段创建索引
await client.createPayloadIndex('documents', {
field_name: 'document_id',
field_schema: 'keyword', // 精确匹配的字符串字段
})
await client.createPayloadIndex('documents', {
field_name: 'user_id',
field_schema: 'keyword',
})
await client.createPayloadIndex('documents', {
field_name: 'created_at',
field_schema: 'datetime', // 支持范围查询
})不加索引会怎样?每次带过滤条件的查询,Qdrant 都要扫描 Collection 里所有向量,先过滤再搜索。数据量过十万后,延迟会从几毫秒飙到几百毫秒。加了索引,过滤操作在索引上完成,向量搜索只在过滤后的子集上做,性能几乎不受数据量影响。
索引的代价:每个索引增加内存占用,写入时需要额外更新索引。只对查询过滤中真正用到的字段建索引——别给所有 payload 字段都加索引。
索引字段类型对照:
| payload 字段类型 | 索引 schema | 支持的查询 |
|---|---|---|
| 租户 ID、文档 ID、状态 | keyword | 精确匹配、IN 查询 |
| 创建时间、更新时间 | datetime | 范围查询(gte/lte/gt/lt) |
| 评分、权重 | float / integer | 数值范围查询 |
| 嵌套对象字段 | 用 . 连接路径,如 metadata.author | 精确匹配 |
4. 写入向量
4.1 单条写入
typescript
await client.upsert('documents', {
wait: true, // 同步等待写入完成——调试时设 true,批量写入时设 false 提速
points: [
{
id: 'doc-1-chunk-0', // 向量 ID,建议用业务 ID 拼接 chunk 序号
vector: embedding, // number[],维度必须和 Collection 配置一致
payload: {
text: 'chunk 内容',
document_id: 'doc-1',
document_title: '文档标题',
chunk_index: 0,
user_id: 'user-123',
tenant_id: 'tenant-456',
created_at: new Date().toISOString(),
},
},
],
})wait: true 让写入同步返回,调试时方便确认是否成功。批量写入时改成 false,吞吐量能翻倍——因为服务端不需要等每批数据落盘就返回响应。
4.2 批量写入
typescript
export async function upsertChunks(
client: QdrantClient,
collectionName: string,
chunks: Array<{
id: string
vector: number[]
payload: Record<string, unknown>
}>
): Promise<{ upserted: number; errors: number }> {
// Qdrant 单次请求建议 100-500 条,取决于 payload 大小
const BATCH_SIZE = 100
let upserted = 0
let errors = 0
for (let i = 0; i < chunks.length; i += BATCH_SIZE) {
const batch = chunks.slice(i, i + BATCH_SIZE)
try {
await client.upsert(collectionName, {
wait: false, // 批量写入关闭同步等待,提升吞吐
points: batch.map((chunk) => ({
id: chunk.id,
vector: chunk.vector,
payload: chunk.payload,
})),
})
upserted += batch.length
} catch (err) {
console.error(`批量写入失败 (offset=${i}):`, err)
errors += batch.length
}
}
return { upserted, errors }
}常见错误:单次写入超过 1000 条或 payload 总体积超过 200MB,Qdrant 会返回 413 Payload Too Large。分批写入是必须的,不是优化。函数返回 { upserted, errors } 让你知道哪些批次失败了,方便重试。
4.3 带稀疏向量的写入(混合检索准备)
稀疏向量和稠密向量写入方式不同,需要在 vector 字段里按名字区分:
typescript
await client.upsert('documents', {
wait: true,
points: [
{
id: 'doc-1-chunk-0',
vector: {
dense: denseEmbedding, // 768 维稠密向量(名称要和 Collection 的 vectors 配置匹配)
'text-sparse': sparseVector, // 稀疏向量(名称要和 sparse_vectors 配置匹配)
},
payload: { text: '...', document_id: 'doc-1' },
},
],
})稀疏向量怎么生成?稠密向量捕捉语义相似性(「汽车」≈「轿车」),稀疏向量捕捉精确匹配(「GPT-4o」就是「GPT-4o」)。两者互补,混合检索效果通常比单独用任何一种都好。
生成稀疏向量的常见方式:SPLADE 模型(语义稀疏,效果最好)、BM25 词频统计(轻量、不需要模型)。第 5 节会展示如何用这两种向量做混合查询。
5. 查询
Qdrant 的查询能力分三个层次:基础相似度查询、带过滤的查询、混合检索。
5.1 基础查询
typescript
const results = await client.query('documents', {
query: queryVector,
limit: 5,
with_payload: true, // 返回 payload 内容——不设则只返回 ID 和分数
})
// results.points 结构:
// [
// { id: 'doc-1-chunk-0', version: 1, score: 0.92, payload: { text: '...', ... } },
// { id: 'doc-2-chunk-3', version: 1, score: 0.87, payload: { text: '...', ... } },
// ...
// ]生产环境一定要加过滤条件(至少加 tenant_id),不要裸查全量数据——既浪费资源,又有数据泄露风险。
5.2 带过滤的查询
这是 Qdrant 最核心的能力。过滤条件和向量搜索一起执行,由 Payload Index 加速,不会退化为全量扫描。
typescript
const results = await client.query('documents', {
query: queryVector,
limit: 5,
filter: {
must: [
// AND 条件——全部满足
{ key: 'tenant_id', match: { value: 'tenant-456' } },
{ key: 'created_at', range: { gte: '2024-01-01T00:00:00Z' } },
],
should: [
// OR 条件——满足任一
{ key: 'document_id', match: { value: 'doc-1' } },
{ key: 'document_id', match: { value: 'doc-2' } },
],
must_not: [
// NOT 条件——排除
{ key: 'status', match: { value: 'archived' } },
],
},
})过滤语法速查:
typescript
// 精确匹配
{ key: 'status', match: { value: 'active' } }
// 多值匹配(IN)
{ key: 'category', match: { any: ['tech', 'news'] } }
// 数值范围
{ key: 'score', range: { gte: 0.5, lte: 1.0 } }
// 日期范围
{ key: 'created_at', range: { gte: '2024-01-01T00:00:00Z', lt: '2025-01-01T00:00:00Z' } }
// 嵌套字段
{ key: 'metadata.author', match: { value: 'alice' } }
// 全文匹配(需要对字段建全文索引)
{ key: 'content', full_text_match: { text: '关键词' } }验证你的过滤查询是否走了索引:在查询返回结果后,检查 score 的分布。如果过滤后返回的结果很少但耗时仍高,说明过滤条件可能没命中索引——回到第 3 节检查是否漏建了 Payload 索引。
5.3 混合检索(稠密 + 稀疏向量融合)
混合检索解决一个具体问题:纯语义向量检索会漏掉精确匹配。用户搜「API-7b2c 的返回值」,稠密向量可能找到「API 文档」这个大类,但找不到包含这个精确编号的 chunk。稀疏向量能补上这个缺口。
typescript
// 混合查询:稠密 + 稀疏,RRF 融合
const results = await client.query('documents', {
// 先分别检索,再融合
prefetch: [
{
query: denseQueryVector,
using: 'dense', // 使用稠密向量
limit: 20, // 每路取 Top 20
},
{
query: sparseQueryVector,
using: 'text-sparse', // 使用稀疏向量
limit: 20,
},
],
// 融合策略
query: {
fusion: 'rrf', // Reciprocal Rank Fusion
},
limit: 5, // 融合后取 Top 5
})两种融合策略怎么选:
| 策略 | 原理 | 适用场景 |
|---|---|---|
rrf(Reciprocal Rank Fusion) | 按排名融合,不看分数 | 两路分数量纲不同、不确定哪种更好时——大多数情况选这个 |
dbsf(Distributed Border Score Fusion) | 按分数融合 | 两路分数已归一化到同一尺度,且你确认分数差异有含义 |
prefetch 的 limit 设 20 是经验值:太小会漏掉某一路的好结果,太大会引入噪声并增加融合计算量。如果你的数据量很小(< 1000 条),可以调到 50;数据量大(> 100 万),降到 10 省计算。
5.4 Scroll 遍历
需要遍历全量数据时用 scroll——比如重建索引、批量更新 payload、导出数据。不要用 query 翻页,scroll 是专门为全量遍历设计的。
typescript
async function* scrollAll(collectionName: string) {
let offset: string | undefined
while (true) {
const result = await client.scroll(collectionName, {
limit: 100,
offset,
with_payload: true,
with_vector: true, // 遍历导出时需要带上向量
})
for (const point of result.points) {
yield point
}
if (!result.next_page_offset) break
offset = result.next_page_offset
}
}
// 使用:异步迭代器,按需加载
for await (const point of scrollAll('documents')) {
console.log(point.id, point.payload?.text?.slice(0, 50))
}常见错误:with_vector: true 会增加内存占用。如果只需要 payload 不需要向量(比如导出报表),设成 false。
6. 多租户隔离
多租户隔离有两种策略。选哪种取决于租户数量和数据隔离要求。
6.1 Payload 过滤(逻辑隔离)
所有租户的数据放在同一个 Collection,用 tenant_id 字段区分。查询时必须在 filter 里带上 tenant_id。
typescript
const results = await client.query('documents', {
query: queryVector,
filter: {
must: [{ key: 'tenant_id', match: { value: user.tenantId } }],
},
})优点:管理简单,一个 Collection 搞定。缺点:数据隔离依赖代码正确添加过滤条件。一旦某个查询忘了加 tenant_id,就会泄露其他租户的数据。
降低风险的做法:在 API 层封装查询入口,强制注入 tenant_id 过滤,不让业务代码直接调用 client.query。
6.2 Collection 隔离(物理隔离)
每个租户一个独立的 Collection。
typescript
function getCollectionName(tenantId: string): string {
return `documents_${tenantId}`
}
async function ensureCollection(client: QdrantClient, tenantId: string): Promise<void> {
const name = getCollectionName(tenantId)
const collections = await client.getCollections()
const exists = collections.collections.some((c) => c.name === name)
if (!exists) {
await client.createCollection(name, {
vectors: { size: 768, distance: 'Cosine' },
})
}
}优点:物理隔离,一个租户的 Collection 损坏不影响其他租户。删除租户数据时直接删 Collection,干净利落。缺点:Collection 数量多时(> 500),Qdrant 的管理开销增加,内存占用也上升。
6.3 怎么选
租户 < 100 → Collection 隔离,物理隔离更安全,管理开销可接受。
租户 100-10000 → Payload 过滤 + tenant_id 索引覆盖,配合 API 层强制注入过滤条件。
租户 > 10000 → Payload 过滤,但需要关注 Collection 的 Segment 数量和内存占用,可能需要升级到 Qdrant Cloud 或 Kubernetes 部署来水平扩展。
注意:Collection 隔离方案下,跨租户的查询(比如管理员搜索全部数据)需要遍历所有 Collection 合并结果,性能较差。如果你的业务需要这种场景,Payload 过滤方案更合适。
7. 监控与备份
7.1 健康检查
typescript
// src/routes/health.ts
app.get('/health/qdrant', async (c) => {
try {
const result = await client.getCollections()
return c.json({
status: 'ok',
collections: result.collections.length,
})
} catch (err) {
return c.json({ status: 'error', error: String(err) }, 503)
}
})健康检查不只验证 Qdrant 能不能连上,还要验证它能不能正常响应。getCollections() 是个轻量操作,能反映服务是否可用。
7.2 关键指标
Qdrant 暴露 Prometheus metrics 在 http://localhost:6333/metrics,接入 Prometheus + Grafana 即可监控。
必须监控的指标:
| 指标 | 含义 | 告警阈值 |
|---|---|---|
qdrant_rest_responses_total | REST 请求总数 | 骤降 50% 以上可能有故障 |
qdrant_rest_responses_duration_seconds | 请求延迟 P99 | P99 > 500ms 需要排查 |
qdrant_collections_total | Collection 数量 | 异常增长说明有泄漏 |
qdrant_points_count | 向量总数 | 和预期不符说明写入有问题 |
7.3 备份
方式一:Snapshot API(推荐,不停服)
bash
# 创建快照
curl -X POST http://localhost:6333/collections/documents/snapshots
# 返回:{"result":{"name":"documents-2026-08-03-...","created_at":"..."}}
# 从快照恢复
curl -X PUT http://localhost:6333/collections/documents/snapshots \
-H "Content-Type: application/json" \
-d '{"location":"file:///path/to/snapshot"}'Snapshot API 是增量快照,不会锁库,生产环境用这个。
方式二:Volume 备份(简单,需停服)
bash
docker compose stop qdrant
tar -czf qdrant-backup-$(date +%Y%m%d).tar.gz ./qdrant_storage
docker compose start qdrantVolume 备份是完整拷贝,简单但需要停服。本地开发环境用这个就够了。
生产环境建议:Snapshot API + 定时任务(每天凌晨全量快照,保留最近 7 天),恢复时从最近的快照开始。
8. 完整 Store 实现
把前面的能力封装成一个可复用的向量存储类。这是生产可用的基础实现——包含错误处理、批量操作和多租户过滤。
typescript
// src/services/rag/qdrant-store.ts
import { QdrantClient } from '@qdrant/js-client-rest'
import type { Filter } from '@qdrant/js-client-rest'
interface ChunkWithEmbedding {
id: string
content: string
embedding: number[]
metadata: Record<string, unknown>
}
interface SearchResult {
id: string
score: number
content: string
metadata: Record<string, unknown>
}
interface SearchOptions {
topK?: number
tenantId?: string
userId?: string
filters?: Filter
}
export class QdrantVectorStore {
private client: QdrantClient
constructor() {
this.client = new QdrantClient({
url: process.env.QDRANT_URL ?? 'http://localhost:6333',
apiKey: process.env.QDRANT_API_KEY,
})
}
/**
* 初始化 Collection——幂等操作,已存在则跳过
* dimensions 必须和 embedding 模型输出维度一致
*/
async initCollection(name: string, dimensions: number): Promise<void> {
const collections = await this.client.getCollections()
const exists = collections.collections.some((c) => c.name === name)
if (exists) return
await this.client.createCollection(name, {
vectors: { size: dimensions, distance: 'Cosine' },
})
// 为新 Collection 自动创建 tenant_id 索引,保证多租户过滤性能
await this.client.createPayloadIndex(name, {
field_name: 'tenant_id',
field_schema: 'keyword',
})
}
/**
* 批量写入向量——分批处理,返回成功和失败数量
*/
async upsertChunks(
collectionName: string,
chunks: ChunkWithEmbedding[]
): Promise<{ upserted: number; errors: number }> {
const BATCH_SIZE = 100
let upserted = 0
let errors = 0
for (let i = 0; i < chunks.length; i += BATCH_SIZE) {
const batch = chunks.slice(i, i + BATCH_SIZE)
try {
await this.client.upsert(collectionName, {
wait: false,
points: batch.map((chunk) => ({
id: chunk.id,
vector: chunk.embedding,
payload: {
text: chunk.content,
...chunk.metadata,
},
})),
})
upserted += batch.length
} catch (err) {
console.error(`[QdrantStore] 批量写入失败 (offset=${i}):`, err)
errors += batch.length
}
}
return { upserted, errors }
}
/**
* 相似度搜索——强制 tenant_id 过滤,防止跨租户数据泄露
*/
async search(
collectionName: string,
queryVector: number[],
options: SearchOptions = {}
): Promise<SearchResult[]> {
const { topK = 5, tenantId, userId } = options
// 构建过滤条件——tenantId 是必选项,防止数据泄露
const mustConditions: Filter['must'] = []
if (tenantId) {
mustConditions.push({ key: 'tenant_id', match: { value: tenantId } })
}
if (userId) {
mustConditions.push({ key: 'user_id', match: { value: userId } })
}
const results = await this.client.query(collectionName, {
query: queryVector,
limit: topK,
filter: mustConditions.length > 0
? { must: mustConditions }
: undefined,
with_payload: true,
})
return results.points.map((p) => ({
id: String(p.id),
score: p.score,
content: (p.payload as Record<string, unknown>)?.text as string ?? '',
metadata: (p.payload as Record<string, unknown>) ?? {},
}))
}
/**
* 按文档 ID 删除所有关联向量
*/
async deleteDocument(collectionName: string, documentId: string): Promise<void> {
await this.client.delete(collectionName, {
filter: {
must: [{ key: 'document_id', match: { value: documentId } }],
},
})
}
/**
* 健康检查——用于 /health/qdrant 路由
*/
async healthCheck(): Promise<{ ok: boolean; collections: number }> {
const result = await this.client.getCollections()
return { ok: true, collections: result.collections.length }
}
}和示例代码的区别:这个实现加了错误处理(批量写入不会因为一批失败全部中断)、类型安全(payload 用 as 显式转型)、以及 initCollection 的幂等性(重复调用不会报错)。生产环境可以直接用这个作为起点。
9. 验收与下一步
验收清单
完成本教程后,逐项检查:
- [ ] Qdrant 容器正常运行,
curl http://localhost:6333/返回版本号 - [ ] Collection 创建成功,向量维度与 embedding 模型匹配
- [ ] Payload 索引在
tenant_id、document_id、created_at上创建 - [ ] 单条写入和批量写入均成功,
upsertChunks返回的errors为 0 - [ ] 基础查询和带过滤查询均返回正确结果
- [ ] 混合检索(稠密 + 稀疏)能返回包含精确关键词的结果
- [ ] 多租户查询隔离验证——不同
tenant_id查询返回各自数据 - [ ] 健康检查路由
/health/qdrant返回status: ok - [ ] Snapshot 备份和恢复测试通过
下一步:什么时候看 Milvus
Qdrant 适合中等规模(单节点千万级向量以内)、需要复杂过滤和混合检索的场景。如果你遇到以下情况,可以继续看下一篇 Milvus 实践:
- 数据量超过 5000 万向量,单节点 Qdrant 内存不够
- 需要跨机房分布式部署,Qdrant 的分布式能力有限
- 需要 GPU 加速索引构建,Milvus 原生支持
下一篇讲 Milvus 实践——面向超大规模、需要分布式部署的向量数据库方案。