Skip to content

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-stopped

restart: 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)按分数融合两路分数已归一化到同一尺度,且你确认分数差异有含义

prefetchlimit 设 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_totalREST 请求总数骤降 50% 以上可能有故障
qdrant_rest_responses_duration_seconds请求延迟 P99P99 > 500ms 需要排查
qdrant_collections_totalCollection 数量异常增长说明有泄漏
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 qdrant

Volume 备份是完整拷贝,简单但需要停服。本地开发环境用这个就够了。

生产环境建议: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 }
  }
}

和示例代码的区别:这个实现加了错误处理(批量写入不会因为一批失败全部中断)、类型安全(payloadas 显式转型)、以及 initCollection 的幂等性(重复调用不会报错)。生产环境可以直接用这个作为起点。

9. 验收与下一步

验收清单

完成本教程后,逐项检查:

  • [ ] Qdrant 容器正常运行,curl http://localhost:6333/ 返回版本号
  • [ ] Collection 创建成功,向量维度与 embedding 模型匹配
  • [ ] Payload 索引在 tenant_iddocument_idcreated_at 上创建
  • [ ] 单条写入和批量写入均成功,upsertChunks 返回的 errors 为 0
  • [ ] 基础查询和带过滤查询均返回正确结果
  • [ ] 混合检索(稠密 + 稀疏)能返回包含精确关键词的结果
  • [ ] 多租户查询隔离验证——不同 tenant_id 查询返回各自数据
  • [ ] 健康检查路由 /health/qdrant 返回 status: ok
  • [ ] Snapshot 备份和恢复测试通过

下一步:什么时候看 Milvus

Qdrant 适合中等规模(单节点千万级向量以内)、需要复杂过滤和混合检索的场景。如果你遇到以下情况,可以继续看下一篇 Milvus 实践:

  • 数据量超过 5000 万向量,单节点 Qdrant 内存不够
  • 需要跨机房分布式部署,Qdrant 的分布式能力有限
  • 需要 GPU 加速索引构建,Milvus 原生支持

下一篇讲 Milvus 实践——面向超大规模、需要分布式部署的向量数据库方案。

基于 MIT 协议开源