[AI/向量存储] 深度解析:Chroma 向量数据库如何基于 SQLite 实现向量存储
0 序
缘起: DB-GPT => Chroma
- 两年前开始、至近期多次使用、研究 DB-GPT 这个 Data-Agent 开源项目。
- DB-GPT 项目基于 Chroma 向量数据库做 Data-Agents
- Chroma 向量数据库自 0.4.0 开始,基于 SQL Lite 构建,将其作为元数据库。
[DataAgents] DB-GPT 源码剖析 - 博客园/数据知音
查看 本地嵌入式向量存储 chroma 的信息 - 查看 Chroma 的 嵌入式 SQL Lite 数据库



1 深度解析:Chroma 向量数据库如何基于 SQLite 实现向量存储
1.0 产品介绍
Chroma 是一个嵌入式(embedded)、零配置、面向开发者的开源向量数据库。它的设计哲学可以概括为三句话:能用一个进程内库解决的问题,就不要引入独立服务;能用一个文件跑通的部署,就不要让用户配集群;元数据这种需要事务与索引的活儿,就交给 SQLite 这种久经考验的嵌入式引擎,而 ANN 这种吃 CPU/内存的活,就交给专门的 HNSW 库。
本文基于 Chroma
main分支当前真实源码(浅克隆自 https://github.com/chroma-core/chroma )逐文件核对,重点阅读了chromadb/db/impl/sqlite.py、chromadb/migrations/下的建表 SQL、chromadb/segment/impl/vector/local_persistent_hnsw.py、chromadb/segment/impl/metadata/sqlite.py、chromadb/execution/executor/local.py与chromadb/__init__.py。文中凡标注「已查证」处,均能在这些源码文件中找到对应行号或建表语句;标注「一方称/历史版本」处仅作背景说明,未在本次源码核对中复现。
1.1 整体存储架构
Chroma 在 0.4 之后引入了 Segment(段)架构。一个 Collection 在物理上被拆成两类 Segment:
- Metadata Segment(作用域 METADATA):元数据、过滤条件、ID 映射、操作日志偏移,全部落在 SQLite。
- Vector Segment(作用域 VECTOR):HNSW 图索引与原始向量,落在磁盘上的独立二进制文件(hnswlib 持久化格式)+ 一个 pickle 元数据文件。
两者通过一个统一的 写入日志(embeddings_queue 表,也在 SQLite 里) 解耦:写入端只往日志表追加记录,两个 Segment 作为消费者各自把日志物化到自己的存储结构里。
关键结论(已查证):
- SQLite 文件固定名为
chroma.sqlite3,路径为{persist_directory}/chroma.sqlite3(见chromadb/db/impl/sqlite.py中SqliteDB.__init__:self._db_file = self._settings.require("persist_directory") + "/chroma.sqlite3")。 - 每个 VECTOR Segment 在磁盘上对应一个以 segment UUID 命名的子目录:
os.path.join(self._persist_directory, str(self._id))(见local_persistent_hnsw.py的_get_storage_folder())。 - 持久化目录的典型布局如下(已查证的文件/目录均来自源码):
./chroma/
├── chroma.sqlite3 # 所有元数据 + 写入日志(单文件)
├── <segment-uuid-1>/ # 某个 Collection 的向量段
│ ├── index_metadata.pickle # id↔hnsw label 映射等
│ └── <hnswlib 自己生成的持久化文件> # HNSW 图与向量二进制
├── <segment-uuid-2>/ # 另一个 Collection 的向量段
│ ├── index_metadata.pickle
│ └── ...
└── ...
1.2 SQLite 层:元数据与系统记录
1.2.1 SQLite 在 Chroma 里到底存什么
按职责,chroma.sqlite3 里其实装了三类东西,分别由三组 migration 管理(见 chromadb/db/impl/sqlite.py 的 _migration_imports:embeddings_queue、sysdb、metadb):
| 职责 | 对应 migration 目录 | 作用 |
|---|---|---|
| 系统目录(SysDB) | chromadb/migrations/sysdb/ |
Collection、Segment、Tenant、Database 等"目录表" |
| 元数据/标量数据(Metadb) | chromadb/migrations/metadb/ |
每条 embedding 的 ID、metadata KV、seq_id 水位、FTS5 全文索引 |
| 写入日志(EmbeddingsQueue) | chromadb/migrations/embeddings_queue/ |
所有写操作的追加日志,vector 在这里以 BLOB 落地 |
1.2.2 实际表结构(已查证,逐表抄自 migration SQL)
SysDB 目录表(sysdb/00001-collections.sqlite.sql、00002-segments.sqlite.sql、00004-tenants-databases.sqlite.sql、00007-collection-config.sqlite.sql、00008-maintenance-log.sqlite.sql):
-- Collection 本身(00001,后经 00004 改造为多租户版本)
CREATE TABLE collections (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
topic TEXT NOT NULL,
dimension INTEGER, -- 00003 加入
database_id TEXT NOT NULL REFERENCES databases(id) ON DELETE CASCADE,
config_json_str TEXT, -- 00007 加入:HNSW 参数等配置
UNIQUE (name, database_id)
);
-- Collection 级 metadata(00001)
CREATE TABLE collection_metadata (
collection_id TEXT REFERENCES collections(id) ON DELETE CASCADE,
key TEXT NOT NULL,
str_value TEXT, int_value INTEGER, float_value REAL,
PRIMARY KEY (collection_id, key)
);
-- Segment 注册(00002):记录每个 Collection 拆成了哪些段
CREATE TABLE segments (
id TEXT PRIMARY KEY,
type TEXT NOT NULL, -- 如 sqlite / hnsw_local_persisted
scope TEXT NOT NULL, -- metadata / vector
topic TEXT,
collection TEXT REFERENCES collections(id)
);
CREATE TABLE segment_metadata ( /* 同 collection_metadata 结构,挂 segment_id */ );
-- 多租户(00004)
CREATE TABLE tenants (id TEXT PRIMARY KEY);
CREATE TABLE databases (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
UNIQUE (tenant_id, name)
);
-- 运维日志(00008,记录 vacuum 等)
CREATE TABLE maintenance_log (
id INT PRIMARY KEY, timestamp INT NOT NULL, operation TEXT NOT NULL
);
-- 迁移账本(sqlite.py 中 setup_migrations 建)
CREATE TABLE migrations (
dir TEXT NOT NULL, version INTEGER NOT NULL, filename TEXT NOT NULL,
sql TEXT NOT NULL, hash TEXT NOT NULL,
PRIMARY KEY (dir, version)
);
Metadb(每条 embedding 的元数据)(metadb/00001-embedding-metadata.sqlite.sql、00004-metadata-indices.sqlite.sql、00005-max-seq-id-int.sqlite.sql):
-- 注意:这里没有 vector 列!只有 ID 与日志偏移
CREATE TABLE embeddings (
id INTEGER PRIMARY KEY,
segment_id TEXT NOT NULL,
embedding_id TEXT NOT NULL, -- 用户传入的 str id
seq_id BLOB NOT NULL, -- 在 embeddings_queue 中的消费水位
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (segment_id, embedding_id)
);
-- 每条 embedding 的 metadata KV(EAV 模型)
CREATE TABLE embedding_metadata (
id INTEGER REFERENCES embeddings(id),
key TEXT NOT NULL,
string_value TEXT, int_value INTEGER, float_value REAL,
PRIMARY KEY (id, key)
);
-- 每个 Segment 已消费到的日志水位
CREATE TABLE max_seq_id (
segment_id TEXT PRIMARY KEY,
seq_id BLOB NOT NULL
);
-- 全文检索(document 字段)
CREATE VIRTUAL TABLE embedding_fulltext USING fts5(id, string_value);
-- 00004:为 where 过滤建复合索引
CREATE INDEX embedding_metadata_int_value ON embedding_metadata (key, int_value) WHERE int_value IS NOT NULL;
CREATE INDEX embedding_metadata_float_value ON embedding_metadata (key, float_value) WHERE float_value IS NOT NULL;
CREATE INDEX embedding_metadata_string_value ON embedding_metadata (key, string_value) WHERE string_value IS NOT NULL;
EmbeddingsQueue(写入日志,vector 在这里以 BLOB 存)(embeddings_queue/00001-embeddings.sqlite.sql):
CREATE TABLE embeddings_queue (
seq_id INTEGER PRIMARY KEY, -- 单调递增日志偏移
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
operation INTEGER NOT NULL, -- 0=ADD 1=UPDATE 2=UPSERT 3=DELETE
topic TEXT NOT NULL, -- 由 tenant+collection 生成
id TEXT NOT NULL,
vector BLOB, -- ★ 原始向量在这里以 BLOB 落地
encoding TEXT, -- 编码格式
metadata TEXT -- JSON 序列化后的 metadata
);
写入代码见 chromadb/db/mixins/embeddings_queue.py:插入列明确为 (operation, topic, id, vector, encoding, metadata),vector 经 encode_vector() 编码后作为 BLOB 参数绑定。
1.2.3 一个重要细节:向量在 SQLite 里确实以 BLOB 出现,但只在"日志"里
很多人误以为 Chroma 的向量存在 SQLite 的 embeddings 表里——这在当前版本是不成立的。embeddings 表(metadb)只有 embedding_id + seq_id,没有向量列。真正承载原始向量的 BLOB 在 embeddings_queue.vector 这个写前日志(WAL 风格的 append log)里。向量被元数据段和向量段消费之后,权威的 ANN 表示就搬到了磁盘上的 HNSW 二进制文件中;日志本身随后可被 purge(见 SqlEmbeddingsQueue.purge_log,以及 chromadb.utils.vacuum 与 maintenance_log 表)。
1.2.4 事务与并发
chromadb/db/impl/sqlite.py 中 TxWrapper 在开启事务时执行 PRAGMA foreign_keys = ON、PRAGMA case_sensitive_like = ON,并通过线程局部栈管理嵌套事务:进入顶层时 BEGIN;,退出时 commit()/rollback()。持久化模式下用 PerThreadPool(每线程一个连接),内存模式下用 file::memory:?cache=shared 共享缓存串多线程(源码注释引用了 sqlite.org 的 shared-cache 文档)。
1.3 向量索引层:HNSW 与持久化
1.3.1 用的是什么 ANN 库
当前默认 PersistentClient 走的是 Python 版 hnswlib 绑定。证据见 chromadb/segment/impl/vector/local_persistent_hnsw.py:
import hnswlib
...
index = hnswlib.Index(space=self._params.space, dim=dimensionality)
段类型注册见 chromadb/segment/impl/manager/local.py:
SEGMENT_TYPE_IMPLS = {
SegmentType.SQLITE: "chromadb.segment.impl.metadata.sqlite.SqliteMetadataSegment",
SegmentType.HNSW_LOCAL_MEMORY: "chromadb.segment.impl.vector.local_hnsw.LocalHnswSegment",
SegmentType.HNSW_LOCAL_PERSISTED:
"chromadb.segment.impl.vector.local_persistent_hnsw.PersistentLocalHnswSegment",
}
备注(已查证):仓库里同时存在
chromadb.api.rust.RustBindingsAPI与RustClient()工厂(chromadb/__init__.py),即 Chroma 正在把 HNSW 用 Rust 重写;但默认的PersistentClient仍走上述 Python + hnswlib 路径。下文所述持久化机制针对默认路径。
1.3.2 HNSW 索引文件长什么样
PersistentLocalHnswSegment 把索引写到 {persist_directory}/{segment_id}/ 目录,该目录里有两类东西:
-
hnswlib 自己的持久化文件:通过
index.init_index(..., is_persistent_index=True, persistence_location=self._get_storage_folder())或index.load_index(folder, is_persistent_index=True, ...)打开。hnswlib 在这个目录下以 mmap 方式管理 HNSW 图与向量数组;close_persistent_index()/open_file_handles()显式管文件句柄(get_file_handle_count()返回hnswlib.Index.file_handle_count + 1,那 +1 就是 pickle 文件)。 -
index_metadata.pickle:Python pickle 序列化的PersistentData对象,字段包括(已查证,见类定义):dimensionalitytotal_elements_addedid_to_label: Dict[str, int]用户 ID → hnswlib 内部整数 labellabel_to_id: Dict[int, str]hnswlib label → 用户 IDid_to_seq_id: Dict[str, SeqId]
加载时用了
SafeUnpickler(白名单反序列化,防 CWE-502 任意代码执行)。
1.3.3 写入时的三级结构:日志 → 批 → HNSW
local_persistent_hnsw.py 实现了一个分层缓冲:
- 新记录先进入内存里的
BruteForceIndex(暴力扫描层,容量batch_size,默认 100,见hnsw_params.py)。 - 攒够
batch_size条后,一次性add_items批量喂给 hnswlib(源码注释:"crossing the python/c++ boundary is expensive")。 - 每累计
sync_threshold条(默认 1000)触发一次_persist():index.persist_dirty()把 hnswlib 脏页刷到自己的二进制文件;- 把
id_to_label / label_to_id / id_to_seq_id / dimensionalitypickle 到index_metadata.pickle; - 在同一个 SQLite 事务里
INSERT OR REPLACE INTO max_seq_id。
默认 HNSW 参数(hnsw_params.py,已查证):space=l2、construction_ef=100、search_ef=100、M=16、num_threads=cpu_count()、resize_factor=1.2、batch_size=100、sync_threshold=1000。
1.3.4 查询时的"分层合并"
因为新写的数据可能还在 BruteForce 层、还没并进 HNSW 图,query_vectors() 会同时查两层再归并:
# 过采样:把被 update/delete 污染的位置也多取一些
hnsw_k = k + self._curr_batch.update_count + self._curr_batch.delete_count
...
bf_results = self._brute_force_index.query(query)
hnsw_results = super().query_vectors(hnsw_query)
# 再按距离归并,并过滤掉尚未从持久化索引里物理删除的 id
这解释了为什么 Chroma 的写入是"近实时"的:在两次 _persist() 之间,最新数据在暴力层里是可见的。
1.3.5 新旧版本差异
- 当前版本(0.4+,Segment 架构,已查证):SQLite 与独立索引二进制文件并存。SQLite 存目录、metadata、写入日志、水位;向量与 HNSW 图存在
{persist_directory}/{segment_id}/下的 hnswlib 二进制文件 + 一个 pickle 映射文件。chroma.sqlite3单文件只装元数据和日志,不装 HNSW 图本身。 - 历史版本(0.3.x 及更早,一方称/历史版本):在 Segment 架构引入之前,Chroma 采用"一个 SQLite 文件装一切"的设计,HNSW 索引整体被序列化后作为 BLOB 字段写回 SQLite 的 embeddings 表。本次核对的
main分支已看不到该实现;仅从local_persistent_hnsw.py中"max_seq_id 从 pickle 文件迁移到 SQLite 的max_seq_id表"的注释可以旁证:在向新架构迁移的过程中,原本住在 pickle 文件里的状态被逐步搬进了 SQLite。具体旧表结构以 0.3.x tag 源码为准,本文不展开。 - Rust 重写路线(已查证为存在,未深入):
RustClient通过chromadb_rust_bindings.pyi暴露 Rust 实现的 API,存储布局可能进一步演进;默认PersistentClient不受影响。
1.4 查询与写入流程
1.4.1 查询流程:先 SQLite 过滤,再 ANN,再回表取 metadata
本地执行器 chromadb/execution/executor/local.py 的 knn() 方法(已查证)逻辑非常清晰:
def knn(self, plan: KNNPlan) -> QueryResult:
prefiltered_ids = None
# 1) 有 where / where_document / user_ids 时,先让 SQLite metadata 段算出候选 id 集合
if plan.filter.user_ids or plan.filter.where or plan.filter.where_document:
records = self._metadata_segment(...).get_metadata(
where=..., where_document=..., ids=..., include_metadata=False)
prefiltered_ids = [r["id"] for r in records]
# 2) 把候选 id 作为 allowed_ids 下推给 HNSW 查询
if prefiltered_ids is None or len(prefiltered_ids) > 0:
query = VectorQuery(vectors=..., k=..., allowed_ids=prefiltered_ids, ...)
knns = self._vector_segment(...).query_vectors(query)
# 3) 对命中的 id 再回 SQLite 取 metadata / document / uri 做投影
hydrated = self._metadata_segment(...).get_metadata(ids=result_ids, include_metadata=True)
也就是说,Chroma 默认是 pre-filter(先元数据过滤,再 ANN),而不是 ANN 跑完再在 Python 里硬过滤。allowed_ids 被传进 hnswlib 层,在图遍历阶段就剔除不在白名单里的节点。
1.4.2 写入流程:日志优先,Segment 异步物化
一致性保证(已查证):
- 写日志是单条 SQLite 事务,ACID 由 SQLite 兜底。
- 元数据段和向量段各自从
max_seq_id水位开始订阅,崩溃重启后从上次水位继续消费,不会丢数据。 _persist()里 hnswlib 刷盘、pickle 写盘、max_seq_id写库三步是顺序执行的;max_seq_id用事务写。因此崩溃窗口内最多丢失"尚未 persist 但已在 SQLite 日志里"的增量——这部分在重启后会从embeddings_queue重新物化。- 写路径用
WriteRWLock,读路径用ReadRWLock,进程内并发读多写单。
1.5 能力与边界
1.5.1 优势(已查证 + 工程常识)
- 嵌入式、零配置:
PersistentClient(path="./chroma")一行代码起库,不依赖外部服务、不依赖 Docker、不依赖 ZooKeeper/Kafka。 - 单文件/单目录部署:
chroma.sqlite3+ 若干段目录,备份就是拷目录,迁移就是拷目录。 - 元数据 ACID:collection/embedding 元数据、where 过滤、FTS5 全文检索、多租户与 database 隔离,全部跑在 SQLite 上,有成熟的 SQL 优化器和索引(见 1.2.2 的三个复合索引)。
- 写日志解耦:
embeddings_queue作为 append-only log,让"建图"这种重活可以批量、异步、分层地做,同时保留崩溃恢复能力。 - 开发者友好:
where/where_document直接翻译成 SQL,include投影、limit/offset、分页都是 SQL 原生能力。
1.5.2 边界与局限
- 并发写入受限:本质上仍是单进程嵌入式库,SQLite 写锁串行化写,hnswlib 层也用进程内读写锁。多进程同时写同一个
chroma.sqlite3会撞锁;官方PersistentClient文档注释自己也提示"close() 对避免 SQLite 文件锁问题很重要"(chromadb/api/client.pyclose docstring)。 - 规模上限:HNSW 常驻内存(向量 + 图),单机百万级向量是甜区,亿级以上需要官方的 Chroma Server / 分布式部署模式,而那已经不是"SQLite 嵌入式"故事了。
- 无原生分布式:嵌入式版本没有分片、一致性复制、跨节点选举;横向扩展要切到 Chroma Server(后端可以换 PostgreSQL 等)或云端托管版。
- 写入放大与 compaction:日志在 SQLite 里,向量会被双写(日志 BLOB 一份、hnswlib 文件一份);删除/更新是软删除(在 BruteForce 层标记),需要定期
chromadb.utils.vacuum或chroma.sqlite3的VACUUM回收(源码里专门有maintenance_log表记录 vacuum)。 - pickle 元数据文件的兼容性:
index_metadata.pickle是 Python 序列化格式,跨语言(如 Rust 重写路线)时需要迁移工具。
1.5.3 与 Milvus / Qdrant / Weaviate 的本质区别
| 维度 | Chroma(嵌入式默认模式) | Milvus / Qdrant / Weaviate |
|---|---|---|
| 部署形态 | 进程内库,单目录 | 独立服务(容器/集群),gRPC/REST |
| 元数据存储 | 嵌入式 SQLite(单文件,ACID) | 自研存储 + etcd/元数据服务 / 专用元数据层 |
| 向量存储 | hnswlib 二进制文件 + mmap,单机 | 自研向量引擎(Milvus 的 segregated storage、Qdrant 的 segment+memmap、Weaviate 的 LSM 风格 segment) |
| 分布式 | 嵌入式版本无;Server 模式另说 | 原生分片+副本,横向扩展 |
| 一致性模型 | SQLite 事务 + 进程内锁 | Raft 等分布式共识 |
| 典型规模 | 原型、应用内 RAG、单机万~千万级 | 生产级,千万~亿级以上 |
zo那个叫:Chroma 把"数据库该有的东西"(事务、索引、过滤、并发控制)交给已经成熟的嵌入式 SQLite,把"ANN 该有的东西"(图索引、向量检索)交给 hnswlib,自己只做粘合与段管理——这是它轻量、好懂、易嵌入的根本原因,也是它和那些"自研一整套分布式存储引擎"的向量数据库最本质的分野。
Y 推荐文献
- [AI/嵌入式/向量存储] Chroma:AI 原生的轻量级向量检索基础设施 - 博客园/千千寰宇
- [嵌入式数据库/RDBMS] SQLite 3.x: 全球部署最广泛的零配置、单文件、嵌入式 ACID 关系型数据库引擎 - 博客园/千千寰宇
- [数据库] 嵌入式数据库RockdsDB - 博客园/千千寰宇
X 参考文献
- chromadb 源码仓库 - GitHub
- chromadb/db/impl/sqlite.py - 单文件 SQLite 后端与 chroma.sqlite3 路径
- chromadb/migrations/sysdb - collection/segment/tenant/database 建表 SQL
- chromadb/migrations/metadb - embeddings/embedding_metadata/max_seq_id/FTS5 建表 SQL
- chromadb/migrations/embeddings_queue - 写入日志表(vector BLOB)
- chromadb/segment/impl/vector/local_persistent_hnsw.py - HNSW 持久化段与 index_metadata.pickle
- chromadb/segment/impl/vector/hnsw_params.py - HNSW 默认参数
- chromadb/segment/impl/metadata/sqlite.py - 元数据过滤段
- chromadb/execution/executor/local.py - knn 执行器(pre-filter → ANN → 回表)
- chromadb/init.py - EphemeralClient / PersistentClient 工厂
- chromadb/db/mixins/embeddings_queue.py - 写入日志插入与消费
- Chroma 官方文档
- [hnswlib - GitHub](
本文链接: https://www.cnblogs.com/johnnyzen
关于博文:评论和私信会在第一时间回复,或直接私信我。
版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!
日常交流:大数据与软件开发-QQ交流群: 774386015 【入群二维码】参见左下角。您的支持、鼓励是博主技术写作的重要动力!

浙公网安备 33010602011771号