← 返回文章列表
022 — AI · #ChromaDB #LangChain #RAG #Embedding #踩坑 · 2026-08-17 · 8 MIN READ

ChromaDB 在一台老服务器上的三座大山:sqlite3、不落盘、Embedding 400

做这个博客的 RAG 后端时,向量库的选型理由是「省事」:文章几十篇、chunk 几百个,ChromaDB 嵌入式本地持久化,不用起服务,不用维护数据库。

想法很美好。实际上 ChromaDB 成了整个后端踩坑密度最高的组件——三个坑连成一串,每一个的报错都和真实原因隔着一层。这篇按踩坑顺序一节讲一个。

第一座山:sqlite3 版本太低,启动即拒

代码部署到 ECS,pip install 顺利完成,第一次启动 uvicorn 直接炸:

plaintext
RuntimeError: Your system has an unsupported version of sqlite3.
Chroma requires sqlite3 >= 3.35.0.

服务器是 Alibaba Cloud Linux,系统自带的 sqlite3 低于 3.35。麻烦在于:Python 的 sqlite3 模块是编译时绑定系统 sqlite 的,pip install --upgrade sqlite3 是不存在的——它不是 PyPI 包,是标准库。

正道是升级系统 sqlite 并重编 Python,但系统 Python 3.6.8 不能动(yum/dnf 都依赖它),我装的 3.11 也不想自己编译。绕路方案是 pysqlite3-binary:一个自带新版 sqlite 静态编译进去的 wheel,装上之后用它冒充标准库:

python
# blog-agent/app/__init__.py —— 必须早于任何 chromadb 导入
import sqlite3 as _stdlib_sqlite3

if _stdlib_sqlite3.sqlite_version_info < (3, 35, 0):
    import pysqlite3 as _pysqlite3
    import sys as _sys
    _sys.modules["sqlite3"] = _pysqlite3
    _sys.modules["sqlite3.dbapi2"] = _pysqlite3.dbapi2

原理是 sys.modules 替换:后续任何模块 import sqlite3,拿到的都是 pysqlite3。放在 app/__init__.py 顶部,保证它在 chromadb 被 import 之前执行。检测版本足够就什么都不做,所以这段补丁在新系统上是无害空操作,可以一直留在代码里。requirements.txt 里对应一行:

plaintext
pysqlite3-binary==0.5.4  # 老系统 sqlite3 < 3.35 时顶替标准库

也有人用 sitecustomize.py 做同样的注入,效果一样;我选 app/__init__.py 是因为它对「哪些进程生效」更可控——只有我们这个应用被 patch,不污染系统上别的 Python 进程。

第二座山:索引「建好了」,重启后清零

sqlite3 解决之后,索引脚本跑通了,data/chroma/ 目录下也有文件。然后重启服务,智能体突然什么都检索不到,count() 返回 0。

这就是最阴的一种 bug:写入时没有任何报错,只是没落盘

根因在依赖组合上。我用的是 langchain_community.vectorstores.Chromachromadb==0.5.5。chromadb 0.5.x 之后持久化语义变了,persist_directory 参数的行为依赖包装层怎么合并配置;langchain_community 那个旧封装在这个版本组合下,写入实际进了内存态 client,新进程去读磁盘上的 collection,空空如也。

当时控制台里其实一直飘着一条 LangChainDeprecationWarning,说 langchain_community 的 Chroma 已废弃、请迁移到 langchain-chroma。我看见了,心想「warning 而已,功能正常就行」——结果就是它。这条警告翻译过来其实是:这个封装已经没人维护了,它和新版 chromadb 的兼容问题不会有人修

修复是换成官方维护的集成,并且显式构造 PersistentClient,不把持久化行为交给包装层的默认值:

python
# blog-agent/app/core/rag.py
from langchain_chroma import Chroma

client = chromadb.PersistentClient(
    path=self._persist_directory,
    settings=ChromaSettings(anonymized_telemetry=False),
)
self._store = Chroma(
    collection_name=COLLECTION_NAME,
    embedding_function=self._get_embedding(),
    client=client,
    collection_metadata={"hnsw:space": "cosine"},
)

改完之后做了一次真正的验收:写入 → 杀进程 → 重启 → 检索。数据还在,才算修完。「重启后数据还在」从此进了上线检查清单,不再靠「目录里有文件」判断。

教训一句话:DeprecationWarning 不是噪音,是事故的预约通知。尤其是数据类组件,废弃封装 + 快速演进的主库,出问题只是早晚。

第三座山:Embedding 接口报 400

向量库能存了,开始灌数据,scripts/index_docs.py 一跑,DashScope 返回 400:

plaintext
input.contents is neither str nor list of str

我传的分明是字符串列表。把 langchain-openai 的实际请求体打出来才看明白:它默认开启了一个「保护」——用 tiktoken 把文本预切成 token 数组,以便精确控制上下文长度。也就是说发到服务端的 input 不是 ["文本一", "文本二"],而是 [[101, 2034, ...], [3550, ...]]

OpenAI 官方接口接受 token 数组,但我走的是阿里百炼的 OpenAI 兼容模式dashscope.aliyuncs.com/compatible-mode/v1),这个「兼容」只兼容到字符串和字符串数组,token 数组直接 400。

解法在 embeddings.py 里,两个参数:

python
# blog-agent/app/core/embeddings.py
return OpenAIEmbeddings(
    model=settings.embedding_model,
    api_key=settings.dashscope_api_key,
    base_url=DASHSCOPE_COMPAT_URL,
    check_embedding_ctx_length=False,  # 关掉 tiktoken 预切分,发原文
    chunk_size=10,                     # 兼容模式单次批量有限,调小分批
)

check_embedding_ctx_length=False 让 langchain 把原文直接发出去;chunk_size=10 把每批的文本条数压到兼容模式能接受的范围。改完全量索引一次跑通。

这个坑的通用价值在于:「OpenAI 兼容接口」是一个程度副词,不是一个布尔值。各家的兼容实现都有缩水的地方,参数级差异(比如这个 token 数组)文档里往往不写,只能靠实际请求验证。接入兼容端点时,第一件事就是把真实请求体打出来看一眼,别信类型签名。

复盘

三个坑连起来看,有一条共同的暗线:每一层都在替我做决定。系统替我决定了 sqlite3 的版本,langchain 的旧封装替我决定了持久化行为,langchain-openai 替我决定了请求体的编码方式。这些默认决定在本机、在 OpenAI 官方接口、在新系统上都成立,换到「老系统 + 国产兼容接口」的组合上全部失效。

所以现在我的原则变成:关键路径上的默认行为,要么显式写出来(像 PersistentClient 那样),要么验证一遍(像打印请求体那样)。「它能跑」和「我知道它为什么能跑」之间,隔着的就是下次排障要花的两天。

Comments