向量数据库选型与工程实践

Qdrant vs Milvus vs Weaviate vs pgvector 全维度对比、索引算法原理、混合搜索与生产部署指南

引言

向量数据库是 RAG(检索增强生成)系统的核心基础设施。它解决的根本问题是:给定一个查询向量,如何从数十亿向量中快速找到最相似的 K 个结果。暴力搜索的时间复杂度是 O(N),在百万级数据集上已经不可接受。向量数据库通过近似最近邻(ANN)索引算法,将搜索复杂度降低到 O(log N) 甚至 O(1)。

本文系统对比主流向量数据库,深入解析索引算法原理,并给出生产部署的工程实践。

核心概念

向量相似度度量

余弦相似度 (Cosine Similarity):
  sim(A, B) = A·B / (|A| x |B|)
  范围: [-1, 1]
  适用: 文本语义检索(对向量长度不敏感)

内积 (Inner Product / Dot Product):
  sim(A, B) = A·B = sum(a_i * b_i)
  范围: (-inf, +inf)
  适用: 已归一化的向量(等价于余弦相似度)

欧氏距离 (Euclidean / L2):
  dist(A, B) = sqrt(sum((a_i - b_i)^2))
  范围: [0, +inf]
  适用: 图像特征、空间位置检索

索引算法家族

算法 类型 时间复杂度 空间开销 精度 适用场景
Flat/Brute 精确搜索 O(N) O(1) 100% 小数据集 (<10K),基线对比
IVF 倒排索引 O(N/K) O(N) 中等数据集,内存受限
HNSW 图索引 O(log N) O(N x M) 极高 大数据集,延迟敏感
ScaNN 量化+分区 O(sqrt(N)) O(N/4) 超大数据集,吞吐优先
DiskANN 磁盘索引 O(log N) 磁盘为主 十亿级数据集,成本敏感

索引算法深度解析

HNSW(Hierarchical Navigable Small World)

HNSW 是目前最广泛使用的 ANN 索引算法,核心思想是构建多层跳表式的图结构:

Layer 3 (最稀疏):  [A] ─────────────── [F]
                     │                   │
Layer 2:           [A] ── [C] ────── [F] ── [H]
                     │     │           │     │
Layer 1:           [A] [B] [C] [D] [E] [F] [G] [H]
                     │  │   │   │   │   │   │   │
Layer 0 (最稠密):  所有节点全连接(受限于 M 参数)

搜索过程(查找最近邻 of query Q):
  1. 从 Layer 3 的入口点 A 开始
  2. 在当前层贪心搜索最近节点
  3. 到达当前层的局部最优后,下降到下一层
  4. 在 Layer 0 进行精细搜索

关键参数:

M (最大连接数):
  - 控制图的连通度
  - 越大 -> 精度越高,索引越大,构建越慢
  - 推荐值: 16-64 (默认 16)

ef_construction (构建时搜索宽度):
  - 构建索引时的搜索列表大小
  - 越大 -> 图质量越高,构建越慢
  - 推荐值: 100-500 (默认 200)

ef_search (查询时搜索宽度):
  - 搜索时的候选列表大小
  - 越大 -> 精度越高,延迟越高
  - 推荐值: 50-500 (根据精度要求调整)

IVF(Inverted File Index)

IVF 先将向量空间聚类,搜索时只扫描最近的几个聚类:

构建阶段:
  1. 用 K-Means 将 N 个向量分成 nlist 个聚类
  2. 每个向量归入最近的聚类中心

搜索阶段:
  1. 找到 query 最近的 nprobe 个聚类中心
  2. 只在这 nprobe 个聚类内做精确搜索

  nlist = sqrt(N)    (经验值)
  nprobe = nlist/10  (精度/速度平衡)

IVF + PQ(乘积量化)

PQ 将高维向量切分为子空间,每个子空间独立量化,实现 32x-64x 压缩:

原始向量 (768 维, FP32):
  [v1, v2, v3, ..., v768]  = 3072 bytes

PQ 量化 (M=96 子空间, nbits=8):
  将 768 维切成 96 个 8 维子空间
  每个子空间用 1 字节编码 (256 个码本)
  压缩后: 96 bytes  (32x 压缩)

主流数据库对比

四大向量数据库全维度对比

维度 Qdrant Milvus Weaviate pgvector
语言 Rust Go + C++ Go C (PostgreSQL 扩展)
架构 单节点/分布式 分布式(云原生) 单节点/分布式 嵌入 PostgreSQL
索引 HNSW + 量化 IVF/HNSW/DiskANN/GPU HNSW + PQ IVF/HNSW
标量过滤 原生(高效) 原生 原生 SQL WHERE
混合搜索 稀疏+稠密原生 稀疏+稠密原生 BM25+向量原生 需手动组合
多租户 Collection 级别 Partition 级别 Tenant 级别 Schema 级别
最大向量 数十亿(分布式) 数百亿 数十亿 数千万
延迟 (p99) <10ms <20ms <15ms <50ms
运维复杂度 高(依赖 etcd/MinIO) 极低(复用 PG)
许可证 Apache 2.0 Apache 2.0 BSD-3 PostgreSQL
云托管 Qdrant Cloud Zilliz Cloud Weaviate Cloud Supabase/Neon

选型决策树

开始
  │
  ├─ 已有 PostgreSQL,向量规模 < 500 万?
  │   └─ YES → pgvector(零额外运维)
  │
  ├─ 需要十亿级+分布式?
  │   └─ YES → Milvus(但运维复杂度高)
  │
  ├─ 需要内置 BM25 混合搜索?
  │   └─ YES → Weaviate 或 Qdrant
  │
  ├─ 延迟敏感 + 运维简单?
  │   └─ YES → Qdrant(Rust 高性能,单二进制)
  │
  └─ 需要 GPU 加速索引构建?
      └─ YES → Milvus(GPU-IVF/GPU-CAGRA)

Qdrant 工程实践

部署与集合创建

from qdrant_client import QdrantClient, models

# Connect to Qdrant
client = QdrantClient(url="http://localhost:6333")

# Create collection with HNSW index
client.create_collection(
    collection_name="documents",
    vectors_config=models.VectorParams(
        size=1536,                            # OpenAI text-embedding-3-small
        distance=models.Distance.COSINE,
        on_disk=False,                        # Keep vectors in RAM
        hnsw_config=models.HnswConfigDiff(
            m=16,                             # Max connections per node
            ef_construct=200,                 # Build-time search width
            full_scan_threshold=10000,        # Switch to brute force below this
        ),
        quantization_config=models.ScalarQuantization(
            scalar=models.ScalarQuantizationConfig(
                type=models.ScalarType.INT8,  # 4x memory reduction
                quantile=0.99,                # Clip outliers
                always_ram=True,              # Keep quantized vectors in RAM
            ),
        ),
    ),
    # Sparse vectors for hybrid search
    sparse_vectors_config={
        "bm25": models.SparseVectorParams(
            modifier=models.Modifier.IDF,
        ),
    },
    # Optimized WAL configuration
    optimizers_config=models.OptimizersConfigDiff(
        indexing_threshold=20000,
        memmap_threshold=50000,
    ),
)

数据写入与检索

import numpy as np
from qdrant_client import models

# Batch upsert with payload
points = [
    models.PointStruct(
        id=doc["id"],
        vector={
            "": embedding,               # Dense vector (default name)
            "bm25": models.SparseVector(  # Sparse vector for BM25
                indices=sparse_indices,
                values=sparse_values,
            ),
        },
        payload={
            "title": doc["title"],
            "content": doc["content"],
            "category": doc["category"],
            "created_at": doc["created_at"],
            "tenant_id": doc["tenant_id"],
        },
    )
    for doc, embedding, sparse_indices, sparse_values in batch
]

client.upsert(
    collection_name="documents",
    points=points,
    wait=True,
)

# Hybrid search: dense + sparse with RRF fusion
results = client.query_points(
    collection_name="documents",
    prefetch=[
        # Dense vector search
        models.Prefetch(
            query=query_embedding,
            using="",
            limit=20,
        ),
        # Sparse BM25 search
        models.Prefetch(
            query=models.SparseVector(
                indices=query_sparse_indices,
                values=query_sparse_values,
            ),
            using="bm25",
            limit=20,
        ),
    ],
    # Reciprocal Rank Fusion
    query=models.FusionQuery(fusion=models.Fusion.RRF),
    # Metadata filtering
    query_filter=models.Filter(
        must=[
            models.FieldCondition(
                key="tenant_id",
                match=models.MatchValue(value="tenant_001"),
            ),
            models.FieldCondition(
                key="category",
                match=models.MatchAny(any=["tech", "science"]),
            ),
        ],
    ),
    limit=10,
    with_payload=True,
    score_threshold=0.5,
)

pgvector 工程实践

基础配置

-- Enable extension
CREATE EXTENSION IF NOT EXISTS vector;

-- Create table with vector column
CREATE TABLE documents (
    id          BIGSERIAL PRIMARY KEY,
    title       TEXT NOT NULL,
    content     TEXT,
    category    TEXT,
    tenant_id   TEXT NOT NULL,
    embedding   vector(1536),    -- OpenAI embedding dimension
    created_at  TIMESTAMPTZ DEFAULT NOW()
);

-- HNSW index (recommended for most cases)
CREATE INDEX idx_documents_embedding_hnsw
ON documents
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 200);

-- Partial index for multi-tenant isolation
CREATE INDEX idx_documents_tenant_embedding
ON documents
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 200)
WHERE tenant_id = 'tenant_001';

-- Set search parameters
SET hnsw.ef_search = 100;

混合搜索实现

-- Hybrid search: vector similarity + full-text + metadata filter
WITH semantic AS (
    SELECT
        id,
        title,
        content,
        1 - (embedding <=> $1::vector) AS vector_score
    FROM documents
    WHERE tenant_id = $2
    ORDER BY embedding <=> $1::vector
    LIMIT 20
),
fulltext AS (
    SELECT
        id,
        title,
        content,
        ts_rank(
            to_tsvector('english', content),
            plainto_tsquery('english', $3)
        ) AS text_score
    FROM documents
    WHERE tenant_id = $2
      AND to_tsvector('english', content) @@ plainto_tsquery('english', $3)
    LIMIT 20
)
-- RRF fusion
SELECT
    COALESCE(s.id, f.id) AS id,
    COALESCE(s.title, f.title) AS title,
    COALESCE(s.content, f.content) AS content,
    COALESCE(1.0 / (60 + s.rank), 0) +
    COALESCE(1.0 / (60 + f.rank), 0) AS rrf_score
FROM (SELECT *, ROW_NUMBER() OVER (ORDER BY vector_score DESC) AS rank FROM semantic) s
FULL OUTER JOIN (SELECT *, ROW_NUMBER() OVER (ORDER BY text_score DESC) AS rank FROM fulltext) f
    ON s.id = f.id
ORDER BY rrf_score DESC
LIMIT 10;

生产部署最佳实践

索引参数调优

HNSW 参数调优指南:

数据规模     M     ef_construction   ef_search   召回率目标
< 10K        8     100               50          99%+
10K-100K     16    200               100         98%+
100K-1M      16    256               200         97%+
1M-10M       32    300               300         96%+
10M-100M     48    400               400         95%+
> 100M       64    500               500         考虑分片

内存估算公式 (HNSW):
  memory ≈ num_vectors x (dim x 4 + M x 2 x 8 + overhead)

  示例: 1000 万 x 1536 维:
  ≈ 10M x (1536 x 4 + 16 x 2 x 8 + 64)
  ≈ 10M x 6464 bytes
  ≈ 60 GB

监控指标

指标 正常范围 告警阈值 含义
p99 搜索延迟 <10ms >50ms 索引退化或内存不足
召回率 >95% <90% 索引参数需调优
内存使用率 <80% >90% 需要扩容或开启量化
写入吞吐 >1000 qps <100 qps 索引重建阻塞
段数量 <50 >200 需要手动触发合并

数据生命周期管理

# Qdrant: Time-based data expiration with payload index
from qdrant_client import models
from datetime import datetime, timedelta

# Create payload index on timestamp field
client.create_payload_index(
    collection_name="documents",
    field_name="created_at",
    field_schema=models.PayloadSchemaType.DATETIME,
)

# Delete expired documents (older than 90 days)
cutoff = (datetime.now() - timedelta(days=90)).isoformat()

client.delete(
    collection_name="documents",
    points_selector=models.FilterSelector(
        filter=models.Filter(
            must=[
                models.FieldCondition(
                    key="created_at",
                    range=models.DatetimeRange(lt=cutoff),
                ),
            ],
        ),
    ),
)

总结

  1. pgvector 适合起步:如果已有 PostgreSQL 且数据量在千万级以下,pgvector 是零额外运维成本的安全选择。
  2. Qdrant 适合中大规模:Rust 实现的高性能,运维简单(单二进制),原生混合搜索,适合大多数 RAG 场景。
  3. Milvus 适合超大规模:十亿级向量分布式检索,GPU 加速索引,但运维复杂度高。
  4. 索引选择首选 HNSW:在绝大多数场景下,HNSW 提供了最好的精度/延迟平衡。
  5. 混合搜索是趋势:纯语义搜索的精度瓶颈需要 BM25 + 向量的融合来突破,RRF 是最简单有效的融合策略。

Maurice | maurice_wen@proton.me