向量数据库实操:Milvus 与 Chroma 的 Collection、索引、相似度度量与标量过滤

0 阅读9分钟

1. 引言

向量数据库是 RAG、语义搜索、推荐系统和多模态检索的核心基础设施。相比传统数据库的精确匹配,向量数据库更擅长“按语义找最相似的内容”。但真正把它用起来,除了会存向量,还需要掌握四件事:

  • Collection / Partition 管理:数据如何组织、如何分区。
  • 索引类型选择:HNSW 与 IVF 各自适合什么场景。
  • 相似度度量:Cosine、Inner Product 等度量如何影响排序。
  • 标量过滤:如何在向量检索的同时按业务字段过滤。

本文以 Milvus 和 Chroma 为例,把上述知识点落到可直接运行的 Python 代码上。

2. 环境准备

2.1 安装依赖

pip install pymilvus chromadb

2.2 启动 Milvus

本地开发推荐使用 Milvus 官方提供的嵌入式脚本,它会同时启动 etcd、MinIO 和 Milvus Standalone:

curl -sfL https://raw.githubusercontent.com/milvus-io/milvus/master/scripts/standalone_embed.sh -o standalone_embed.sh
bash standalone_embed.sh start

默认地址为 localhost:19530。不再需要时执行:

bash standalone_embed.sh stop

2.3 启动 Chroma

Chroma 可以使用内存模式或持久化模式,不需要额外服务端。本文使用本地持久化目录:

import chromadb

client = chromadb.PersistentClient(path="./chroma_db")

3. Milvus Collection 与 Partition 管理

3.1 设计字段并创建 Collection

在 Milvus 中,Collection 类似关系型数据库的表。创建前需要先定义字段 Schema,尤其是向量字段的维度:

from pymilvus import (
    connections,
    Collection,
    CollectionSchema,
    FieldSchema,
    DataType,
)

connections.connect("default", host="localhost", port="19530")

fields = [
    FieldSchema(name="id", dtype=DataType.INT64, is_primary=True, auto_id=False),
    FieldSchema(name="title", dtype=DataType.VARCHAR, max_length=512),
    FieldSchema(name="category", dtype=DataType.VARCHAR, max_length=64),
    FieldSchema(name="publish_year", dtype=DataType.INT64),
    FieldSchema(name="embedding", dtype=DataType.FLOAT_VECTOR, dim=768),
]

schema = CollectionSchema(fields, description="技术文档集合")
collection = Collection(name="knowledge_docs", schema=schema)

这里要特别注意两点:

  • id 字段设置 is_primary=True,必须唯一。
  • FLOAT_VECTOR 必须指定 dim,且后续写入的所有向量维度必须严格一致。

3.2 数据写入

Milvus 的 insert 按列组织数据,列顺序需要与 Schema 定义顺序一致:

import numpy as np

# 生成 3 条 768 维测试向量
embeddings = np.random.rand(3, 768).tolist()

data = [
    [1, 2, 3],                                  # id
    ["如何优化 HNSW 索引", "Chroma 持久化实践", "Milvus 标量过滤"],  # title
    ["AI", "DB", "DB"],                         # category
    [2024, 2024, 2025],                         # publish_year
    embeddings,                                 # embedding
]

collection.insert(data)
collection.flush()

print(collection.num_entities)

3.3 Partition 分区管理

Partition 可以把一个 Collection 在物理上拆成多个逻辑区域,便于按业务隔离数据、提升写入与查询效率。例如可以按技术方向把文档拆到 ai_docsdb_docs

# 创建分区
collection.create_partition("ai_docs")
collection.create_partition("db_docs")

# 指定分区写入
collection.insert(data, partition_name="ai_docs")
collection.flush()

# 查看分区
print(collection.partitions)

# 直接获取某个分区对象
ai_partition = collection.partition("ai_docs")
print(ai_partition.num_entities)

一条数据如果没有指定 partition_name,会进入默认分区 _default

分区建议按业务含义划分,例如:按时间分月、按数据源、按租户等。分区数量不宜过度膨胀,否则查询路由会变复杂。

4. 索引类型:HNSW 与 IVF 怎么选

向量检索如果没有索引,只能进行暴力全量计算,数据量大时延迟不可接受。索引本质是在“搜索速度”和“召回精度”之间做权衡。

4.1 HNSW:图结构的近邻搜索

HNSW 通过多层图结构组织向量,查询时从高层快速定位到低层局部区域。它的优点是查询快、召回高、支持动态插入;缺点是比较吃内存,构建时间相对较长。

collection.create_index(
    field_name="embedding",
    index_params={
        "index_type": "HNSW",
        "metric_type": "COSINE",
        "params": {
            "M": 16,
            "efConstruction": 200
        }
    }
)

关键参数:

  • M:图中每个节点的最大连接数,通常取 4 到 64。越大越占内存,召回通常越高。
  • efConstruction:构建时的搜索宽度,通常取 100 到 500。
  • 检索时的 ef:查询阶段的搜索宽度,越大越慢但召回越高。

4.2 IVF:倒排聚类索引

IVF 先对全部向量做 KMeans 聚类,把空间划分成若干桶。查询时先定位到最接近的几个聚类中心,再只在这些桶内做精确计算。

collection.create_index(
    field_name="embedding",
    index_params={
        "index_type": "IVF_FLAT",
        "metric_type": "IP",
        "params": {
            "nlist": 128
        }
    }
)

关键参数:

  • nlist:聚类中心数量,通常取数据量平方根附近,例如 100 万条数据可以从 128 到 1024 尝试。
  • 检索阶段的 nprobe:搜索最近的多少个聚类中心,越大越慢但召回越高。

4.3 选择建议

场景推荐索引原因
数据量不大、追求高召回低延迟HNSW图搜索精度高,查询稳定
百万级以上、内存受限IVF_FLAT内存占用低,通过 nprobe 控制精度
快速原型AUTOINDEXMilvus 根据数据规模自动选择
数据量极大、需要磁盘支持DiskANN支持磁盘索引,降低内存压力

实际项目中,没有绝对最优解,建议先用小批量数据分别测试 HNSW 和 IVF,对比延迟、召回与内存占用后再上线。

5. 相似度度量:Cosine 与 IP

相似度度量决定了“什么叫相似”。同一个向量在不同的度量下,最近邻结果可能完全不同。

5.1 Cosine 余弦相似度

Cosine 计算两个向量之间的夹角余弦值,只关心方向,不关心长度。它非常适合文本 Embedding 这类长度差异较大的向量:

CosineSimilarity = (A · B) / (||A|| * ||B||)

在 Milvus 中配置:

index_params = {
    "index_type": "HNSW",
    "metric_type": "COSINE",
    "params": {"M": 16, "efConstruction": 200}
}

5.2 IP 内积

IP 计算两个向量的点积结果。它同时考虑方向和长度,适合两部分:

  • 向量已经做过 L2 归一化的场景。此时 IP 与 Cosine 等价,但计算更轻。
  • 推荐系统中,Embedding 的点积可以近似表示用户与物品的匹配分数。
index_params = {
    "index_type": "IVF_FLAT",
    "metric_type": "IP",
    "params": {"nlist": 128}
}

5.3 重要原则

  1. 度量方式必须与向量生成方式一致。如果你在模型侧做了归一化,IP 和 Cosine 差异不大;如果向量模长差异明显,建议优先 Cosine。
  2. 索引和查询阶段必须使用相同的 metric_type。否则会出现“建索引时用 Cosine,查询时用 IP”,导致排序错误。
  3. 对 IP 来说,分数越大越相似;对 L2 来说,距离越小越相似。Milvus 返回的 hit.distance 语义与 metric 相关,排序时不要统一按“距离小更好”理解。

6. 标量过滤 Scalar Filtering

真实业务里几乎不会只要“语义最相似的 10 条”,而是“在满足业务条件下最相似的 10 条”。例如:

查询 category 属于 AI 或 NLP,并且 publish_year 不小于 2024 的相似文档。

Milvus 使用 SQL-like 表达式完成标量过滤。

6.1 查询时通过 expr 过滤

query_vec = np.random.rand(1, 768).tolist()

results = collection.search(
    data=query_vec,
    anns_field="embedding",
    param={"metric_type": "COSINE", "params": {"ef": 64}},
    limit=10,
    expr="category in ['AI', 'NLP'] and publish_year >= 2024",
    output_fields=["title", "category", "publish_year"]
)

for hits in results:
    for hit in hits:
        print(f"id={hit.id}, score={hit.distance}")
        print(hit.entity.get("title"))

表达式中可以直接使用:

  • ==!=>=> 等比较操作。
  • andornot 逻辑组合。
  • inlike 等高级匹配。
  • 字符串需要使用单引号包裹。

6.2 只为标量过滤,不做向量检索

有时只想按条件查询属性,不关心向量距离,可以使用 query

rows = collection.query(
    expr="publish_year >= 2024",
    output_fields=["title", "category"],
    limit=20
)

for row in rows:
    print(row)

6.3 给标量字段建索引

数据量大时,可以在高频过滤字段上创建倒排索引:

collection.create_index(
    field_name="category",
    index_params={"index_type": "INVERTED"}
)

这样 category == 'AI' 这类过滤会被大幅加速。

7. Chroma 快速实操

Chroma 的 API 更轻量,适合原型验证和中小规模应用。它把 Collection、Embedding 和元数据过滤封装在一起。

7.1 创建 Collection 并指定度量方式

Chroma 通过 metadata={"hnsw:space": ...} 指定相似度度量,可选值包括 cosineipl2

import chromadb

client = chromadb.PersistentClient(path="./chroma_db")
collection = client.get_or_create_collection(
    name="tech_docs",
    metadata={"hnsw:space": "cosine"}
)

7.2 写入文档并携带元数据

collection.add(
    ids=["doc_1", "doc_2", "doc_3"],
    documents=[
        "如何优化 HNSW 索引",
        "Chroma 持久化与过滤",
        "Milvus 标量过滤示例"
    ],
    metadatas=[
        {"category": "AI", "publish_year": 2024},
        {"category": "DB", "publish_year": 2024},
        {"category": "DB", "publish_year": 2025}
    ]
)

Chroma 默认使用内置的 MiniLM Embedding 模型自动向量化,也可以传入 embeddings 参数使用自己的向量。

7.3 带标量过滤的检索

Chroma 使用字典形式的 where 完成标量过滤:

results = collection.query(
    query_texts=["向量索引怎么选"],
    n_results=2,
    where={"category": {"$eq": "AI"}}
)

print(results["documents"])
print(results["metadatas"])
print(results["distances"])

常见过滤操作符:

  • $eq:等于。
  • $ne:不等于。
  • $in:在给定列表内。
  • $gte:大于等于。
  • $lte:小于等于。

也可以对文档内容做词形过滤:

results = collection.query(
    query_texts=["索引优化"],
    n_results=2,
    where_document={"$contains": "HNSW"}
)

Chroma 没有像 Milvus 那样显式的 Partition API,通常通过创建不同 Collection,或者在元数据中增加分区字段来模拟分区能力。

8. 检索链路总览

下面这张图概括了从写入到检索的完整链路:

flowchart LR
  A["写入向量数据"] --> B["确定相似度度量"]
  B --> C["构建 HNSW / IVF 索引"]
  C --> D["执行 ANN 向量检索"]
  D --> E["应用标量过滤"]
  E --> F["返回业务结果"]

在 Milvus 中,向量检索和标量过滤可以在一次 search 调用中完成;在 Chroma 中,则在 querywhere 参数中体现。两者核心思路一致:先保证语义召回,再通过结构化条件约束结果集。

9. 常见坑与最佳实践

  1. 向量维度不一致:写入前必须统一 Embedding 模型和输出维度,否则插入或检索会直接报错。
  2. 度量方式不匹配:索引阶段和查询阶段必须使用相同 metric_type;归一化状态下 IP 才近似 Cosine。
  3. 忘记 load Collection:Milvus 创建索引后需要执行 collection.load(),否则无法检索。
  4. String 字段未指定 max_lengthDataType.VARCHAR 必须指定合理的 max_length,太小会写入失败。
  5. 过滤条件错误:字符串必须加单引号,例如 category == 'AI',不要写成 category == AI
  6. 参数照搬:HNSW 的 M 和 ef、IVF 的 nlist 和 nprobe 需要根据数据规模与召回要求调整,不要统一使用默认值。
  7. 元数据膨胀:Chroma 的 Metadata 会被持久化,高频过滤字段应保持结构简单,避免把大段文本塞进元数据。

10. 总结

本文从 Collection / Partition 管理、索引选择、相似度度量和标量过滤四个维度,分别用 Milvus 和 Chroma 做了实操演示。总结成一句话:

  • 数据组织看 Collection 和 Partition;
  • 性能看 HNSW / IVF 索引;
  • 排序语义看 Cosine / IP;
  • 业务约束看 Scalar Filtering。

向量数据库不是“存向量”就结束了,只有把这四层配置协调好,才能在生产环境稳定地跑出高召回、低延迟的检索结果。