用 ChromaDB 存向量、喂上下文:给 LLM 加一层可检索的记忆

2026-04-14 24 预计阅读时间:1 分钟
来源:realpython.com AI 摘要 原文链接

免责声明:本文为 AI 摘要整理,建议结合原文阅读。摘要可能省略上下文、版本差异或边界条件,不作为官方说明。

预计阅读时间:12 分钟

大语言模型能聊天、能写代码,但它不记得你上周投进去的那份产品文档。把文本切成片段、转成向量、存进向量数据库,再在提问时把相关片段捞出来塞给模型——这套检索增强生成(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 必须唯一,重复会报错;想覆盖用 upsert
  • metadatas 不是必填,但后续做条件过滤(比如只查某个产品的文档)时非常有用
  • hnsw:space 可选 l2(默认)、cosineip,文本检索一般用 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-smalltext-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_sizeoverlap 的取值要看文档类型:代码文档可以按函数边界切,自然语言文档按段落切更好。粗暴按字符数切是最简单的起点,后续可以换用语义分段工具(如 LangChain 的 RecursiveCharacterTextSplitter)。

什么时候该换更重的引擎

ChromaDB 适合这些场景:

  • 原型验证、内部工具、文档量在十万条以内
  • 单机部署、不需要多副本高可用
  • 团队以 Python 为主,不想引入额外运维复杂度

当出现以下信号时,就该考虑迁移到 Milvus、Qdrant 或 Pinecone:

  • 文档量超过百万,查询延迟明显上升
  • 需要多节点部署、读写分离
  • 需要细粒度权限、多租户隔离
  • 嵌入维度超过 1536 且需要 GPU 加速索引构建

迁移成本不高——ChromaDB 的 getquery 返回的数据结构是标准 JSON,写个脚本把文档和元数据导出再灌到新库即可。嵌入向量可以重新生成(换模型时)或直接导出复用(同模型时)。

上手清单

  1. pip install chromadb,跑一遍上面的最小示例,确认嵌入模型能下载
  2. PersistentClient 把数据落盘,避免每次重启重新灌
  3. 先用内置嵌入模型跑通全链路,确认检索质量后再决定是否换 OpenAI
  4. 真实文档先分段再灌库,chunk_size=400, overlap=50 是一个可用的起点
  5. RAG Prompt 里加"只根据上下文回答"的约束,temperature 设低
  6. 监控检索距离分布——如果最相关的片段距离都大于 0.8,说明嵌入模型或分段策略需要调整

相关推荐