[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 数据库

image

image

image

1 深度解析:Chroma 向量数据库如何基于 SQLite 实现向量存储

1.0 产品介绍

Chroma 是一个嵌入式(embedded)、零配置、面向开发者的开源向量数据库。它的设计哲学可以概括为三句话:能用一个进程内库解决的问题,就不要引入独立服务;能用一个文件跑通的部署,就不要让用户配集群;元数据这种需要事务与索引的活儿,就交给 SQLite 这种久经考验的嵌入式引擎,而 ANN 这种吃 CPU/内存的活,就交给专门的 HNSW 库。

本文基于 Chroma main 分支当前真实源码(浅克隆自 https://github.com/chroma-core/chroma )逐文件核对,重点阅读了 chromadb/db/impl/sqlite.pychromadb/migrations/ 下的建表 SQL、chromadb/segment/impl/vector/local_persistent_hnsw.pychromadb/segment/impl/metadata/sqlite.pychromadb/execution/executor/local.pychromadb/__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 作为消费者各自把日志物化到自己的存储结构里。

flowchart TB subgraph Client["应用进程(PersistentClient)"] API["Collection API<br/>add / query / get"] end subgraph SQLite["chroma.sqlite3(单文件 SQLite 库)"] direction TB SYS["SysDB 系统表<br/>collections / segments / tenants / databases<br/>collection_metadata / segment_metadata<br/>maintenance_log / migrations"] QUEUE["写入日志表<br/>embeddings_queue<br/>(seq_id, operation, topic, id,<br/>vector BLOB, encoding, metadata)"] META["Metadb 元数据表<br/>embeddings(id, segment_id, embedding_id, seq_id)<br/>embedding_metadata(id, key, str/int/float_value)<br/>max_seq_id<br/>embedding_fulltext (FTS5)"] end subgraph Disk["持久化目录下的段子目录 {persist_directory}/{segment_id}/"] direction TB PKL["index_metadata.pickle<br/>id_to_label / label_to_id<br/>id_to_seq_id / dimensionality"] HNSW["hnswlib 持久化索引文件<br/>(HNSW 图 + 向量,mmap 打开)"] BF["内存中的 BruteForce 批<br/>(最近 batch_size 条未落盘增量)"] end API -->|写| QUEUE API -->|query where| META QUEUE -->|订阅通知 物化| META QUEUE -->|订阅通知 批量建图| HNSW HNSW -.持久化映射.-> PKL API -->|query ANN| HNSW API -->|query ANN| BF META -->|prefiltered_ids| HNSW

关键结论(已查证)

  • SQLite 文件固定名为 chroma.sqlite3,路径为 {persist_directory}/chroma.sqlite3(见 chromadb/db/impl/sqlite.pySqliteDB.__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_importsembeddings_queuesysdbmetadb):

职责 对应 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.sql00002-segments.sqlite.sql00004-tenants-databases.sqlite.sql00007-collection-config.sqlite.sql00008-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.sql00004-metadata-indices.sqlite.sql00005-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.vacuummaintenance_log 表)。

1.2.4 事务与并发

chromadb/db/impl/sqlite.pyTxWrapper 在开启事务时执行 PRAGMA foreign_keys = ONPRAGMA 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.RustBindingsAPIRustClient() 工厂(chromadb/__init__.py),即 Chroma 正在把 HNSW 用 Rust 重写;但默认的 PersistentClient 仍走上述 Python + hnswlib 路径。下文所述持久化机制针对默认路径。

1.3.2 HNSW 索引文件长什么样

PersistentLocalHnswSegment 把索引写到 {persist_directory}/{segment_id}/ 目录,该目录里有两类东西:

  1. 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 文件)。

  2. index_metadata.pickle:Python pickle 序列化的 PersistentData 对象,字段包括(已查证,见类定义):

    • dimensionality
    • total_elements_added
    • id_to_label: Dict[str, int]   用户 ID → hnswlib 内部整数 label
    • label_to_id: Dict[int, str]   hnswlib label → 用户 ID
    • id_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()
    1. index.persist_dirty() 把 hnswlib 脏页刷到自己的二进制文件;
    2. id_to_label / label_to_id / id_to_seq_id / dimensionality pickle 到 index_metadata.pickle
    3. 在同一个 SQLite 事务里 INSERT OR REPLACE INTO max_seq_id

默认 HNSW 参数(hnsw_params.py,已查证):space=l2construction_ef=100search_ef=100M=16num_threads=cpu_count()resize_factor=1.2batch_size=100sync_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.pyknn() 方法(已查证)逻辑非常清晰:

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 层,在图遍历阶段就剔除不在白名单里的节点。

flowchart TD Q["query(query_embeddings, n_results, where=..., include=...)"] --> V{"where / where_document / ids 非空?"} V -- 否 --> ANN["直接 HNSW + BruteForce 层查询 top-k"] V -- 是 --> SQL["SQLite: 查 embedding_metadata + FTS5<br/>WHERE key=? AND int_value=? ...<br/>返回 prefiltered_ids"] SQL --> EMPTY{"prefiltered_ids 为空?"} EMPTY -- 是 --> EMPTYR["直接返回空结果集"] EMPTY -- 否 --> ANN ANN --> MERGE["合并 HNSW 层与 BruteForce 层结果<br/>按距离归并, 剔除待删除 id"] MERGE --> HYDRATE["SQLite 回表: 按 id 取 metadata/document/uri"] HYDRATE --> OUT["组装 QueryResult 返回"]

1.4.2 写入流程:日志优先,Segment 异步物化

flowchart TD A["add / upsert / update / delete"] --> B["SqlEmbeddingsQueue.submit_embedding<br/>BEGIN 事务"] B --> C["INSERT INTO embeddings_queue<br/>(seq_id, operation, topic, id, vector BLOB, encoding, metadata)<br/>COMMIT"] C --> D["_notify_all: 通知本 topic 的所有订阅者"] D --> E1["SqliteMetadataSegment 消费日志<br/>写入 embeddings / embedding_metadata / max_seq_id"] D --> E2["PersistentLocalHnswSegment 消费日志<br/>进入 BruteForce 批"] E2 --> F{"批满 batch_size?"} F -- 是 --> G["批量 add_items 进 hnswlib 图"] F -- 否 --> H["暂留 BruteForce 层"] G --> I{"累计写入 ≥ sync_threshold?"} H --> I I -- 是 --> J["_persist():<br/>1) hnswlib.persist_dirty() 写二进制<br/>2) pickle id↔label 映射<br/>3) 事务写 max_seq_id"] I -- 否 --> K["保持脏状态, 下次再刷"]

一致性保证(已查证)

  • 写日志是单条 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.py close docstring)。
  • 规模上限:HNSW 常驻内存(向量 + 图),单机百万级向量是甜区,亿级以上需要官方的 Chroma Server / 分布式部署模式,而那已经不是"SQLite 嵌入式"故事了。
  • 无原生分布式:嵌入式版本没有分片、一致性复制、跨节点选举;横向扩展要切到 Chroma Server(后端可以换 PostgreSQL 等)或云端托管版。
  • 写入放大与 compaction:日志在 SQLite 里,向量会被双写(日志 BLOB 一份、hnswlib 文件一份);删除/更新是软删除(在 BruteForce 层标记),需要定期 chromadb.utils.vacuumchroma.sqlite3VACUUM 回收(源码里专门有 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 推荐文献

X 参考文献

posted @ 2026-09-13 13:24  千千寰宇  阅读(5)  评论(0)    收藏  举报