🐸 青蛙编程网
首页 / AI / 检索增强 RAG / 5. 向量数据库

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 动手改

  1. 把第 3、4 章的代码接上 Chroma:切分 → 向量化 → 入库 → 查询,打印 Top-3
  2. 给每块加上 doc_id 元数据,然后只查某一个 doc_id 下的内容
  3. 故意用错维度写入,看它报什么错——记住这个报错的样子

5.9 小结

  • 向量数据库解决三件事:持久化、快速最近邻检索、元数据过滤
  • Chroma 本地持久化只要一个目录,get_or_create_collection 建库
  • distances 是距离不是相似度,换算规则必须记牢(cosine 空间下 1 - dist)
  • where 先过滤再检索,比捞出来自己筛准得多
  • 换嵌入模型或改切分策略,都要删库重建

下一章:检索优化,让「找得到」变成「找得准」 🐸

📥 下载本章代码

ZIP 内含本章全部代码文件与说明,解压即用,与教程内容同步生成。