5. 向量数据库:Chroma 落地
这是什么:向量数据库 = 专门存向量、并能按相似度快速捞出最相关几条的数据库;本章主角是 Chroma。
为什么用:上一章的向量全放在内存里,程序一关就没了,文档一多检索也慢下来。换个专门的数据库,这些麻烦一次解决。
如何用:本章用 Chroma 建库、写入、查询,再搞懂「距离 ≠ 相似度」和按元数据过滤这两件实用功能。
5.1 为什么不用 NumPy 硬扛
| 场景 | NumPy 数组 | 向量数据库 |
|---|---|---|
| 几百条 | ✅ 一秒搞定 | 杀鸡用牛刀 |
| 几万条 | 每次查询都要算全量距离,越来越慢 | 建索引,只查最近邻 |
| 几百万条 | 基本没法用 | 这才是它的主场 |
| 持久化 | 得自己 pickle | 自带落盘 |
| 按来源过滤 | 得自己写循环 | where 一行搞定 |
选型:本地小规模用 Chroma;上了生产、向量上百万,再考虑 Milvus / Qdrant / pgvector 等。先把 RAG 的逻辑跑通,换库是最后一步。
5.2 安装与建库
pip install chromadb
Chroma 支持本地持久化,一个目录就是一个库:
import chromadb
client = chromadb.PersistentClient(path="./chroma_data")
collection = client.get_or_create_collection(
name="kb",
configuration={"hnsw": {"space": "cosine"}}, # 指定余弦空间
)
space有三个值:cosine(余弦)、l2(欧氏)、ip(内积)。建集合时定好,之后不能改——想换就换个名字重建。
5.3 写入与查询
# chunks 是第 3 章切出来的块,vectors 是第 4 章算出的向量
collection.add(
ids=[f"c{i}" for i in range(len(chunks))], # 每块的唯一 ID
documents=[c["text"] for c in chunks], # 原文
embeddings=vectors, # 向量
metadatas=[c["metadata"] for c in chunks], # 来源等标签
)
print(collection.count()) # 看看存进去多少条
res = collection.query(
query_embeddings=[query_vec],
n_results=3,
include=["documents", "metadatas", "distances"],
)
for doc, meta, dist in zip(res["documents"][0], res["metadatas"][0], res["distances"][0]):
print(f"{1 - dist:.3f} {doc[:30]} ← {meta['source']}")
你会看到(示例):
0.812 差旅住宿的报销标准为每晚 500 元…… ← handbook.md
0.674 报销需在出差结束后 30 天内提交…… ← handbook.md
0.401 年会将于每年 12 月在…… ← handbook.md
5.4 距离 ≠ 相似度
Chroma 返回的 distances 是距离(越小越相似),不是相似度。换算规则要知道:
| 空间 | distances 的含义 |
换算成余弦相似度 |
|---|---|---|
cosine |
1 − 余弦相似度 |
1 − dist |
l2(默认) |
平方欧氏距离 ‖a−b‖² |
需先归一化,再 1 − dist/2 |
ip |
1 − 内积 |
向量归一化后等于 1 − dist |
所以第 4 章那条公式在这里直接用上了:
# 用了 cosine 空间
similarity = 1 - dist
# 用了默认 l2 空间,且向量已归一化
similarity = 1 - dist / 2
忘了这层换算,就会把「距离小」当成「相似度低」,排序全反。
5.5 按元数据过滤
真实场景常常要先限定范围再检索——「只查《报销制度》这份文件里的内容」:
res = collection.query(
query_embeddings=[query_vec],
n_results=3,
where={"source": {"$eq": "handbook.md"}},
)
# 还能查原文里包含某个词的块
res = collection.query(
query_embeddings=[query_vec],
n_results=3,
where_document={"$contains": "差旅"},
)
常用的条件运算符:$eq(等于)、$ne(不等于)、$gt / $gte / $lt / $lte(数值比较)、$in(在列表里)。
where是先过滤再检索,比「全捞出来再自己筛」快得多,也准得多。
5.6 持久化与重建
# 同一个 path 再开,数据还在
client2 = chromadb.PersistentClient(path="./chroma_data")
print(client2.get_or_create_collection(name="kb").count()) # 数量不变
# 增量更新:用 upsert,ID 相同就覆盖
collection.upsert(ids=["c0"], documents=["新的内容"], embeddings=[new_vec])
# 删除
collection.delete(ids=["c0"])
重建索引的正规做法(第 4 章的坑):
import shutil
shutil.rmtree("./chroma_data", ignore_errors=True) # 删库
client = chromadb.PersistentClient(path="./chroma_data") # 重开
collection = client.get_or_create_collection(name="kb", configuration={"hnsw": {"space": "cosine"}})
5.7 常见翻车现场
| 报错 / 现象 | 原因 | 修法 |
|---|---|---|
DuplicateIDError |
同一个 ID add 了两次 | 改用 upsert,或给 ID 加唯一后缀 |
InvalidDimensionException |
写入和查询的维度不一致 | 两边都检查 len(embedding) |
| 相似度全是负数 | 用 l2 空间却按 1 - dist 换算 |
改用 cosine 空间,或按 5.4 的公式换算 |
where 过滤没生效 |
过滤字段没写进 metadatas |
建库时把要过滤的字段存进去 |
集合换不了 space |
空间在建集合时就定死了 | 换个集合名重建 |
5.8 动手改
- 把第 3、4 章的代码接上 Chroma:切分 → 向量化 → 入库 → 查询,打印 Top-3
- 给每块加上
doc_id元数据,然后只查某一个doc_id下的内容 - 故意用错维度写入,看它报什么错——记住这个报错的样子
5.9 小结
- 向量数据库解决三件事:持久化、快速最近邻检索、元数据过滤
- Chroma 本地持久化只要一个目录,
get_or_create_collection建库 distances是距离不是相似度,换算规则必须记牢(cosine空间下1 - dist)where先过滤再检索,比捞出来自己筛准得多- 换嵌入模型或改切分策略,都要删库重建
下一章:检索优化,让「找得到」变成「找得准」 🐸