跳转至

第2章 向量检索数据库

学习目标

  • 了解 Qdrant、Milvus、PGVector 三类向量数据库的架构特点与适用场景
  • 掌握向量数据库的核心操作:集合管理、向量插入、相似度查询
  • 学会将向量库与 OpenAI SDK 集成,构建端到端的语义检索
  • 理解距离度量(余弦/欧氏/点积)的取舍
  • 掌握向量库性能调优的关键参数与实践

问题导入

某初创公司用 Python 列表存了 50 万条文档向量,每次检索都遍历全部计算余弦相似度,响应时间从最初的 100ms 涨到 8 秒,且内存占用飙升到 12GB。团队尝试用 NumPy 矩阵运算优化,速度有所提升但仍无法支撑百万级数据,且每次重启都要重新加载全部向量到内存。更麻烦的是,他们无法对"只检索某部门文档"这类过滤需求做高效处理。

问题的本质是:向量检索需要专门的存储与检索引擎。向量数据库专为高维向量的近似最近邻搜索(ANN)设计,提供持久化、过滤、分布式扩展与高可用能力。本章对比三种主流向量库,并以 Qdrant 为例讲解完整操作。

核心概念

1. 三类向量数据库概览

维度 Qdrant Milvus PGVector
实现语言 Rust Go/C++ C(PG 扩展)
部署形态 单机/Docker/K8s 分布式集群 复用 PostgreSQL
索引类型 HNSW(默认) IVF/HNSW/DiskANN HNSW/IVFFlat
过滤能力 强(payload 过滤) 强 SQL WHERE
规模上限 千万级 十亿级 百万级
事务一致性 否 否 是(PG 事务)
学习成本 低 中高 低(会 SQL 即可)
适用场景 中小团队、性能敏感 超大规模、生产集群 已有 PG、需事务

2. 选型决策树

  • 数据量 < 100 万,且已有 PostgreSQL → PGVector,复用现有运维体系
  • 数据量 100 万-1 亿,追求查询延迟与开发体验 → Qdrant
  • 数据量 > 1 亿,需要分布式与多副本 → Milvus
  • 需要强事务(向量与业务数据强一致) → PGVector
  • 需要复杂 payload 过滤与多租户 → Qdrant 或 Milvus

3. 距离度量

向量库通常支持三种距离:

  • 余弦相似度(Cosine):衡量方向相似性,对向量长度不敏感,是文本 Embedding 的默认选择。Qdrant 中 Distance.COSINE。
  • 欧氏距离(Euclid):衡量空间绝对距离,对向量长度敏感,适合图像 Embedding。Distance.EUCLID。
  • 点积(Dot):兼顾方向与模长,当向量已归一化时等价于余弦。Distance.DOT。

文本 RAG 场景默认选 Cosine。

4. 集合(Collection)管理

向量库以"集合"组织数据,类似关系库的表。创建集合时需指定:

  • 向量维度(必须与 Embedding 模型输出一致,如 1536)
  • 距离度量
  • 索引类型与参数(HNSW 的 M、ef_construction)
  • 是否启用量化(Quantization)以压缩存储

5. 与 OpenAI SDK 集成

标准集成模式:用 OpenAI 生成 Embedding → 用向量库客户端写入/查询 → 把检索结果拼回 Prompt。注意 Embedding 模型与入库时保持一致,否则检索质量崩塌。

原理图

flowchart LR
    subgraph 写入路径
        Doc[文档分块] --> Embed1[OpenAI Embedding API]
        Embed1 --> Upsert[qdrant.upsert]
        Upsert --> Coll[(Qdrant Collection<br/>vectors + payload)]
    end

    subgraph 查询路径
        Query[用户问题] --> Embed2[OpenAI Embedding API]
        Embed2 --> Search[qdrant.search]
        Search --> Filter{payload 过滤}
        Coll --> Filter
        Filter --> TopK[Top-K 候选]
        TopK --> Prompt[拼装 Prompt]
        Prompt --> LLM[LLM 生成]
    end

    subgraph 存储层
        Coll --> HNSW[HNSW 索引]
        Coll --> Payload[Payload 存储<br/>doc_id/text/metadata]
        Coll --> Quant[标量量化<br/>压缩内存]
    end
图:向量数据库在 RAG 中的写入与查询路径

示例代码

以 Qdrant 为例的完整集合管理、插入与查询:

import os
from qdrant_client import QdrantClient
from qdrant_client.models import (
    Distance,
    VectorParams,
    PointStruct,
    Filter,
    FieldCondition,
    MatchValue,
)
from openai import OpenAI

os.environ.setdefault("OPENAI_API_KEY", "sk-xxx")
oai = OpenAI()

# 生产环境改为 QdrantClient(host="localhost", port=6333)
qdrant = QdrantClient(":memory:")

COLLECTION = "articles"
DIM = 1536


def create_collection() -> None:
    """创建集合,1536 维 + 余弦距离 + HNSW 索引。"""
    if qdrant.collection_exists(COLLECTION):
        qdrant.delete_collection(COLLECTION)
    qdrant.create_collection(
        collection_name=COLLECTION,
        vectors_config=VectorParams(size=DIM, distance=Distance.COSINE),
        # HNSW 参数(Qdrant 默认 M=16, ef_construct=100,可显式调整)
        hnsw_config={"m": 16, "ef_construct": 100},
    )


def embed(text: str) -> list[float]:
    return oai.embeddings.create(
        model="text-embedding-3-small", input=text
    ).data[0].embedding


def insert_points(points: list[dict]) -> None:
    """批量插入向量与 payload。"""
    structs = [
        PointStruct(
            id=p["id"],
            vector=embed(p["text"]),
            payload={
                "text": p["text"],
                "source": p["source"],
                "category": p["category"],
            },
        )
        for p in points
    ]
    qdrant.upsert(collection_name=COLLECTION, points=structs)


def search(query: str, top_k: int = 3, category: str | None = None) -> list[dict]:
    """语义检索,可选按 category 过滤。"""
    query_filter = None
    if category:
        query_filter = Filter(
            must=[FieldCondition(key="category", match=MatchValue(value=category))]
        )
    results = qdrant.search(
        collection_name=COLLECTION,
        query_vector=embed(query),
        limit=top_k,
        query_filter=query_filter,
        with_payload=True,
    )
    return [
        {"score": r.score, "text": r.payload["text"], "source": r.payload["source"]}
        for r in results
    ]


if __name__ == "__main__":
    create_collection()
    insert_points(
        [
            {"id": 1, "text": "Qwen3 是阿里开源大模型。", "source": "wiki", "category": "model"},
            {"id": 2, "text": "Llama 3.3 由 Meta 发布。", "source": "wiki", "category": "model"},
            {"id": 3, "text": "Python 是动态类型语言。", "source": "blog", "category": "lang"},
        ]
    )
    print("全库检索:", search("阿里开源模型"))
    print("按分类过滤:", search("阿里开源模型", category="lang"))

运行结果说明:全库检索返回 Qwen3 片段得分最高;按 category=lang 过滤后只返回 Python 片段,体现了 payload 过滤的价值。

性能调优要点

# 1. 批量插入(减少网络往返)
qdrant.upsert(collection_name=COLLECTION, points=batch, wait=False)

# 2. 启用标量量化压缩内存(牺牲极小精度换 4x 内存节省)
from qdrant_client.models import ScalarQuantization, ScalarQuantizationConfig
qdrant.update_collection(
    collection_name=COLLECTION,
    quantization_config=ScalarQuantization(
        scalar=ScalarQuantizationConfig(type="int8", quantile=0.99, always_ram=True)
    ),
)

# 3. 查询时调 ef_search(Qdrant 中用 params 控制)
results = qdrant.search(
    collection_name=COLLECTION,
    query_vector=qv,
    limit=10,
    search_params=SearchParams(hnsw_ef=128, exact=False),
)

企业案例

案例 1:选型对比实践

某电商团队在选型时对三款库做了基准测试(100 万 1536 维向量): - PGVector:建索引慢,查询 50-200ms,但能直接 JOIN 商品表做复杂过滤 - Qdrant:查询 5-15ms,payload 过滤灵活,Docker 部署 10 分钟搞定 - Milvus:查询 8-20ms,分布式扩展能力强,但运维复杂度高

最终选 Qdrant,因为 100 万规模下单机够用且开发体验最佳。

案例 2:多租户知识库

某 SaaS 厂商用 Qdrant 服务 200 个企业客户,每个客户数据隔离。方案是为每个 tenant_id 创建独立集合,配合 payload 过滤实现二级隔离。单实例支撑 200 集合、总计 3000 万向量,查询 P99 < 30ms。

案例 3:从 PGVector 迁移到 Milvus

某视频平台随着向量规模从 50 万涨到 2 亿,PGVector 查询从 30ms 涨到 800ms 且索引构建耗时数天。迁移到 Milvus 集群(3 节点 + DiskANN 索引)后,查询稳定在 20ms,索引构建 4 小时完成。

常见问题

Q1:向量维度选多少? A:由 Embedding 模型决定。OpenAI text-embedding-3-small 是 1536 维,3-large 是 3072 维。可用 dimensions 参数降维(如 1536→256)以省存储,但召回会略降。

Q2:Qdrant 单机能存多少向量? A:1536 维 + 余弦,1 亿向量约需 60GB 内存(开启 int8 量化后约 15GB)。超出后用分片集群。

Q3:向量库和关系库怎么协同? A:向量库存检索向量和 payload 摘要;关系库存完整业务数据。检索拿到 doc_id 后去关系库 JOIN 出完整字段,避免向量库承载过多业务数据。

Q4:HNSW 查询为什么有时比 IVF 快很多? A:HNSW 在图上贪心游走,复杂度约 O(log N);IVF 需扫描 nprobe 个簇的所有向量。但 HNSW 内存占用更高,是典型的空间换时间。

面试题

1. Qdrant、Milvus、PGVector 如何选型? 参考答案:百万级以下且已有 PG 选 PGVector;百万到亿级选 Qdrant,开发体验与性能平衡;十亿级以上或需分布式选 Milvus。需强事务选 PGVector,需复杂过滤选 Qdrant/Milvus。

2. 为什么文本检索默认用余弦相似度? 答题要点:文本 Embedding 关注语义方向而非向量模长,余弦只比较方向,对归一化后的向量等价于点积。欧氏距离会受模长影响,可能让长文本与短文本相似度偏低。

3. 解释 HNSW 的 ef_search 与 ef_construction 区别。 参考答案:ef_construction 是构建索引时候选邻居池大小,影响索引质量与构建耗时,建好后固定;ef_search 是查询时候选池大小,在线可调,越大召回越高、查询越慢。

4. 向量库如何实现增量更新而不停服? 答题要点:HNSW 支持在线 upsert/insert;更新用"先删后插";删除按 payload 过滤。Qdrant/Milvus 都支持在线写入与查询并发,无需停服。

练习题

  1. 三库对比实验:用相同的 10 万条数据分别在 Qdrant、PGVector、Milvus 上建索引,对比建索引耗时、查询 P99、内存占用。
  2. 过滤查询实现:在 Qdrant 中实现"只检索 source=wiki 且 category in [model, lang] 的块"。
  3. 量化对比:对同一集合分别用未量化、int8 量化、binary 量化,对比内存占用与 Recall@10 差异。

小结

向量数据库是 RAG 的存储与检索引擎。本章对比了 Qdrant(中小团队首选)、Milvus(超大规模首选)、PGVector(已有 PG 场景首选)三类方案,并以 Qdrant 为例演示了集合管理、向量插入、过滤查询与性能调优。生产环境推荐 Qdrant 作为起步方案,配合 HNSW 索引与 int8 量化平衡速度与成本。下一章将进入搜索策略,探讨稠密、稀疏与混合检索。