大语言模型能聊天、能写代码,但它不记得你上周投进去的那份产品文档。把文本切成片段、转成向量、存进向量数据库,再在提问时把相关片段捞出来塞给模型——这套检索增强生成(RAG)的套路已经成了工程标配。ChromaDB 是目前最轻量的开源向量数据库之一,纯 Python 可跑,不需要 Docker 也不需要 GPU,适合从原型到中小规模生产。
下面从安装到完整检索链路,一步步把 ChromaDB 用起来。
为什么选 ChromaDB
向量数据库的选择不少:Pinecone 是托管服务、Milvus 偏重分布式、Qdrant 用 Rust 写性能好但部署稍复杂。ChromaDB 的定位很明确——开发者优先、本地优先:
pip install chromadb就能用,内置 DuckDB 做持久化,零外部依赖- 默认自带
all-MiniLM-L6-v2嵌入模型,不配 API Key 也能跑 - API 只有 Collection、Add、Query、Delete 几个核心动作,上手成本极低
- 支持
PersistentClient把数据落盘到本地目录,也支持Client纯内存模式做快速实验
代价是:没有分布式方案、没有内置权限控制、大规模高并发场景需要换更重的引擎。但在"先跑通再优化"的阶段,ChromaDB 是最省事的起点。
从零开始:建库、存文档、查向量
先装依赖:
pip install chromadb
ChromaDB 默认会调用 Sentence Transformers 下载嵌入模型,首次运行需要几秒加载。如果你网络受限,可以提前下载模型或换用 OpenAI 的嵌入接口——后面会提到。
创建 Collection 并添加文档
import chromadb
# 内存模式——适合快速实验,进程结束数据消失
client = chromadb.Client()
# 持久化模式——数据写入指定目录,下次启动还在
# client = chromadb.PersistentClient(path="./chroma_data")
collection = client.get_or_create_collection(
name="product_docs",
metadata={"hnsw:space": "cosine"} # 可选:指定距离度量,默认 L2
)
# 添加文档——ChromaDB 自动用内置模型生成嵌入
docs = [
"AcmeChat 支持最多 500 人同时在线的视频会议,延迟低于 200ms。",
"AcmeChat 的端到端加密基于 AES-256,密钥由会议发起者生成。",
"AcmeDocs 提供实时协同编辑,支持 Markdown 和富文本,历史版本保留 90 天。",
"AcmeDocs 的权限体系分三级:只读、评论、编辑,可按文件夹粒度配置。",
]
collection.add(
documents=docs,
ids=["doc1", "doc2", "doc3", "doc4"], # 每条文档必须唯一
metadatas=[ # 可选:附加元数据,用于过滤
{"product": "AcmeChat", "feature": "video"},
{"product": "AcmeChat", "feature": "security"},
{"product": "AcmeDocs", "feature": "collab"},
{"product": "AcmeDocs", "feature": "permission"},
]
)
print(f"已存入 {collection.count()} 条文档")
几个要点:
ids必须唯一,重复会报错;想覆盖用upsertmetadatas不是必填,但后续做条件过滤(比如只查某个产品的文档)时非常有用hnsw:space可选l2(默认)、cosine、ip,文本检索一般用cosine
查询最相关的文档片段
results = collection.query(
query_texts=["AcmeChat 的安全性怎么样?"],
n_results=2, # 返回最相关的 2 条
where={"product": "AcmeChat"}, # 只在 AcmeChat 的文档里找
)
for doc, dist in zip(results["documents"][0], results["distances"][0]):
print(f"[距离: {dist:.4f}] {doc}")
输出大致如下:
[距离: 0.32] AcmeChat 的端到端加密基于 AES-256,密钥由会议发起者生成。
[距离: 0.58] AcmeChat 支持最多 500 人同时在线的视频会议,延迟低于 200ms。
距离越小越相关。where 过滤在元数据上做精确匹配,适合"只查某类文档"的场景;如果需要更复杂的语义过滤,得在应用层做二次筛选。
换用 OpenAI 嵌入:更大模型,更准向量
内置的 MiniLM 模型够用但不够强——中文语义、长文本、专业术语的表现有限。换成 OpenAI 的 text-embedding-3-small 或 text-embedding-3-large,向量质量会明显提升,代价是每次调用要花钱、要联网。
import chromadb
import openai
openai_client = openai.OpenAI() # 自动读取 OPENAI_API_KEY 环境变量
def openai_embed(texts: list[str]) -> list[list[float]]:
resp = openai_client.embeddings.create(
model="text-embedding-3-small",
input=texts,
)
return [item.embedding for item in resp.data]
chroma_client = chromadb.PersistentClient(path="./chroma_openai")
# 自定义嵌入函数——告诉 ChromaDB 用我们的函数而不是内置模型
from chromadb.utils.embedding_functions import EmbeddingFunction
class OpenAIEmbedding(EmbeddingFunction):
def __call__(self, input: list[str]) -> list[list[float]]:
return openai_embed(input)
ef = OpenAIEmbedding()
collection = chroma_client.get_or_create_collection(
name="product_docs_openai",
embedding_function=ef,
)
# 后续 add / query 和之前完全一样,ChromaDB 会自动调用 OpenAIEmbedding
collection.add(
documents=docs,
ids=["doc1", "doc2", "doc3", "doc4"],
metadatas=[
{"product": "AcmeChat", "feature": "video"},
{"product": "AcmeChat", "feature": "security"},
{"product": "AcmeDocs", "feature": "collab"},
{"product": "AcmeDocs", "feature": "permission"},
]
)
results = collection.query(
query_texts=["AcmeChat 的安全性怎么样?"],
n_results=2,
where={"product": "AcmeChat"},
)
for doc, dist in zip(results["documents"][0], results["distances"][0]):
print(f"[距离: {dist:.4f}] {doc}")
注意:换嵌入函数后,同一个 Collection 里不能混用不同模型生成的向量——维度和语义空间不一致会导致检索结果混乱。如果要从 MiniLM 切到 OpenAI,建新 Collection 重新灌数据。
完整 RAG 链路:检索 + 生成
光查到相关片段还不够,最终目标是把片段塞给 LLM 生成有依据的回答。下面用一个最小可跑的完整链路演示:
import chromadb
import openai
# ---- 1. 初始化 ChromaDB ----
chroma_client = chromadb.PersistentClient(path="./chroma_data")
collection = chroma_client.get_collection(name="product_docs")
# ---- 2. 检索相关文档 ----
def retrieve_context(question: str, n: int = 3) -> str:
results = collection.query(
query_texts=[question],
n_results=n,
)
# 把多条文档拼成一段上下文
return "\n\n".join(results["documents"][0])
# ---- 3. 构造 Prompt 并调用 LLM ----
openai_client = openai.OpenAI()
def rag_answer(question: str) -> str:
context = retrieve_context(question)
prompt = f"""你是一个产品助手。请只根据以下上下文回答问题,如果上下文没有相关信息,就说"我不确定"。
上下文:
{context}
问题:{question}
回答:"""
resp = openai_client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": prompt}],
temperature=0.2, # 低温度,减少幻觉
)
return resp.choices[0].message.content
# ---- 4. 测试 ----
print(rag_answer("AcmeChat 最多支持多少人同时在线?"))
print(rag_answer("AcmeDocs 能编辑 Excel 吗?"))
预期输出:
AcmeChat 支持最多 500 人同时在线的视频会议。
我不确定。
关键设计点:
- Prompt 里明确要求"只根据上下文回答",配合低
temperature,能有效压制幻觉 - 检索条数
n需要权衡:太少可能漏信息,太多会稀释重点、增加 token 成本 - 如果文档很长,灌库前应该先做分段(chunk),每段 300-500 字,重叠 50 字左右,避免语义被截断
分段灌库的实用模式
真实项目里文档不会是四条短句,而是几十页的 PDF 或 Markdown。下面是一个简单的分段 + 灌库流程:
import chromadb
def chunk_text(text: str, chunk_size: int = 400, overlap: int = 50) -> list[str]:
chunks = []
start = 0
while start < len(text):
end = start + chunk_size
chunks.append(text[start:end])
start += chunk_size - overlap
return chunks
# 读取长文档
with open("product_handbook.md", "r", encoding="utf-8") as f:
full_text = f.read()
chunks = chunk_text(full_text)
client = chromadb.PersistentClient(path="./chroma_data")
collection = client.get_or_create_collection(name="handbook")
collection.add(
documents=chunks,
ids=[f"chunk_{i}" for i in range(len(chunks))],
metadatas=[{"source": "product_handbook.md", "chunk_index": i} for i in range(len(chunks))],
)
print(f"已分段存入 {len(chunks)} 个片段")
chunk_size 和 overlap 的取值要看文档类型:代码文档可以按函数边界切,自然语言文档按段落切更好。粗暴按字符数切是最简单的起点,后续可以换用语义分段工具(如 LangChain 的 RecursiveCharacterTextSplitter)。
什么时候该换更重的引擎
ChromaDB 适合这些场景:
- 原型验证、内部工具、文档量在十万条以内
- 单机部署、不需要多副本高可用
- 团队以 Python 为主,不想引入额外运维复杂度
当出现以下信号时,就该考虑迁移到 Milvus、Qdrant 或 Pinecone:
- 文档量超过百万,查询延迟明显上升
- 需要多节点部署、读写分离
- 需要细粒度权限、多租户隔离
- 嵌入维度超过 1536 且需要 GPU 加速索引构建
迁移成本不高——ChromaDB 的 get 和 query 返回的数据结构是标准 JSON,写个脚本把文档和元数据导出再灌到新库即可。嵌入向量可以重新生成(换模型时)或直接导出复用(同模型时)。
上手清单
pip install chromadb,跑一遍上面的最小示例,确认嵌入模型能下载- 用
PersistentClient把数据落盘,避免每次重启重新灌 - 先用内置嵌入模型跑通全链路,确认检索质量后再决定是否换 OpenAI
- 真实文档先分段再灌库,
chunk_size=400, overlap=50是一个可用的起点 - RAG Prompt 里加"只根据上下文回答"的约束,
temperature设低 - 监控检索距离分布——如果最相关的片段距离都大于 0.8,说明嵌入模型或分段策略需要调整