事情从一条凌晨三点的报警短信开始。502 Bad Gateway、容器重启、然后又 502。我那个跑在免费容器上的 RAG 应用,已经连续第四天在半夜崩溃。Docker 日志显示 OOM Killed——但内存占用看起来并没有那么离谱。真正的问题藏得比想象中更深。

ChromaDB 在低内存环境下的表现

ChromaDB 是一个出色的向量数据库,主打简单易用、Pythonic API,一度是 RAG 开发者的首选。它的默认架构如下:

# 初始化方式极其简洁
import chromadb
client = chromadb.PersistentClient(path="./chroma_data")
collection = client.create_collection("my_docs")

# 一行插入向量
collection.add(
    embeddings=[[0.1, 0.2, ...]],  # 1536-dim
    documents=["这是一段文档内容..."],
    ids=["doc_1"]
)

但简洁的背后有一个关键细节:ChromaDB 默认使用 SQLite3 作为元数据存储后端。向量索引则使用 hnswlib。在 512MB 的免费容器中,这个组合简直是灾难性的:

为什么 SQLite3 作为默认后端在容器中不可靠

这不是 SQLite 的错——它是地球上测试最充分的数据库之一。问题是容器化的文件系统 + SQLite 的并发模型 = 定时炸弹

免费容器的内存 limit(512MB)如何影响向量检索

512MB 听起来不少,但对于一个同时跑着 FastAPI、ChromaDB、嵌入模型的容器来说,实际情况是:

进程内存分布(典型时刻):
├── FastAPI + Uvicorn        ~120 MB
├── Sentence-Transformers    ~180 MB  (all-MiniLM-L6-v2)
├── ChromaDB (SQLite+hnsw)   ~150 MB
├── Python 解释器 + 库       ~ 60 MB
└── 可用余量                  ~  2 MB   ← 任何查询都触发 OOM

问题不在于"内存不够",而在于向量相似度搜索是一个典型的 burst 操作。一次 top-10 检索的内存 spike 可能是空闲时的 2-3 倍,因为 hnswlib 在遍历图时需要分配候选集缓冲区。你以为自己有 50MB 富余,实际上一次查询就能吃掉 80MB。

切换到 Qdrant 的迁移过程

最初我尝试了 Qdrant——它支持磁盘索引(mmap),理论上可以在低内存环境下优雅降级:

# Qdrant 的磁盘优化模式
# qdrant_config.yaml
storage:
  optimizers:
    memmap_threshold_kb: 10000  # 超过 10MB 就 mmap
  wal:
    wal_capacity_mb: 32

# Docker 启动
docker run -p 6333:6333 \
  -v ./qdrant_storage:/qdrant/storage \
  qdrant/qdrant

迁移过程本身很顺畅——ChromaDB 到 Qdrant 的数据导出只需要遍历 collections 然后批量 upsert。但跑了两周后发现,Qdrant 在 512MB 容器里的"优雅降级"更像是"优雅地变慢":mmap 索引的查询延迟从 2ms 飙升到 200ms+,并且频繁触发缺页中断(page fault),实际上还是在和内存做斗争。

嵌入模型的本地化部署方案(ONNX Runtime)

另一个内存大户是嵌入模型。原方案用的 sentence-transformers 走 PyTorch 推理,光是框架本身就要吃 ~80MB。换成 ONNX Runtime 后效果立竿见影:

# 导出为 ONNX
from optimum.onnxruntime import ORTModelForFeatureExtraction
model = ORTModelForFeatureExtraction.from_pretrained(
    "sentence-transformers/all-MiniLM-L6-v2",
    export=True
)
model.save_pretrained("./onnx_model")

# 推理时使用 ONNX Runtime
import onnxruntime as ort
session = ort.InferenceSession("model.onnx")
# 内存占用: ~90MB vs PyTorch ~180MB

ONNX 版本省了近一半内存,而且冷启动速度从 8 秒降到 2 秒——对于频繁重启的免费容器来说,这个差异意味着更少的请求被丢弃。

最终方案:轻量化的 FAISS + 嵌入式 SQLite

经过三轮技术选型的折腾,最终落地的架构出奇地简单:

# 最终技术栈
├── FAISS (IndexFlatIP)       # 暴力内积搜索,内存 ~8MB
├── SQLite (嵌入式,只存元数据) # 无 WAL,关闭同步
├── ONNX Runtime              # 嵌入推理,~90MB
└── FastAPI                   # 单进程,1 worker
── 总计内存占用                # ~380MB,余量 130MB

关键设计决策:

# Python 示例:分离式架构
class LightweightVectorStore:
    def __init__(self, dim=384):
        self.index = faiss.IndexFlatIP(dim)      # 内积相似度
        self.id_map = []                          # faiss_id → doc_id
        self.db = sqlite3.connect(":memory:")    # 内存数据库

    def search(self, query_vec, k=10):
        scores, indices = self.index.search(query_vec, k)
        doc_ids = [self.id_map[i] for i in indices[0]]
        return self._fetch_docs(doc_ids), scores[0]

这套方案上线后,容器稳定运行超过 60 天无重启。查询延迟稳定在 5-15ms 范围,P99 不超过 30ms。虽然向量量级上不去(上限大约 5 万条),但对于个人 RAG 应用来说绰绰有余。


这次复盘让我深刻体会到:在资源受限的环境中,数据库的"默认配置"往往是最危险的配置。ChromaDB 的默认设置针对的是开发环境——一台至少有 4GB 内存的 MacBook。当同样的代码被塞进 512MB 的容器,每一个隐藏的假设都会变成一次凌晨三点的报警。选型不是选最强的,而是选在这个约束下能活下来的