夜雨聆风学习资料网

ARTICLE · 1333686

Chroma 源码拆解

Chroma 源码拆解

4 个 API 的背后,是 34 万行 Rust:Chroma 源码拆解

上一篇结尾我预告说,要拆一个"三千行代码的向量数据库"。这句话说错了。

那是 Chroma 早期 Python 版本的印象。现在这个仓库光 Rust 就有 340,396 行、762 个文件、41 个 workspace crate。而它对外的核心 API,官方 README 写得很直白:只有 4 个函数——建集合、加数据、查询、按 ID 取。

  • 开源地址:https://github.com/chroma-core/chroma
  • 许可证:Apache-2.0
  • 版本:Rust 核心 0.15.0,Python 客户端 1.6.0
  • 规模:约 3 万 stars,Rust 34 万行

4 个 API 和 34 万行之间的落差,就是这篇要拆的东西:一个"给 AI 用的数据库",到底把复杂度藏在了哪里。

它到底是什么

Chroma 自称"the open-source data infrastructure for AI"。翻成工程语言:它存三样东西——向量、原文、元数据,并且支持这三样混在一起查。

你给它一段文本,它帮你算 embedding、建索引;你查询时可以用向量找相似、用 where 过滤元数据、用 where_document 做全文匹配,最后按距离排序返回。单机 pip install chromadb 就能跑,也可以起服务端,或者用它的云服务。

真正值得看的是它的架构选择:它没有把"数据库"做成一个大程序,而是拆成了日志、块存储、索引、元数据四套独立系统,用对象存储当底座。

为什么要从 Python 搬到 Rust

早期 Chroma 是 Python + SQLite + hnswlib,原型体验极好:一个进程、一个文件、没有依赖。但要做云,这套东西会撞上三堵墙。

第一堵是存算分离。云上的数据必须放对象存储(S3),计算节点要能随时扩缩。SQLite 是本地文件,天然做不到。

第二堵是写入与索引解耦。HNSW 是图结构,插入要改邻居指针,天然不适合"来一条写一条"。要么牺牲写吞吐,要么牺牲读一致性。

第三堵是多副本与故障恢复。单机 SQLite 没有 WAL 复制、没有 quorum、没有崩溃后的一致性校验。

Rust 重写不是为了性能数字好看,是为了能同时拥有这四样东西:对象存储、日志复制、异步 compaction、多语言绑定。代价是你现在看到的 34 万行。

代码怎么组织

41 个 crate 看着吓人,按数据路径分其实就五层。

Chroma 的 41 个 Rust crate 怎么分层

  1. 接入层
    :frontend 用 axum 暴露 /api/v2,frontend-core 处理集合管理,api-types 定义请求响应。
  2. 执行层
    :worker 里再分 orchestration(编排 DAG)和 operators(单个算子),外加 compaction。
  3. 存储抽象
    :segment 定义"段",blockstore 提供 Arrow 块文件,wal3 提供日志。
  4. 检索结构
    :index 里是 HNSW、SPANN、全文、稀疏向量,distance 算距离,quantization 做压缩。
  5. 底座
    :sysdb 存集合和段的元数据,storage 抽象 S3 与本地文件,cache、memberlist 做缓存和成员发现。

读代码时有一个很实用的顺序:先看 rust/types/src/collection.rs 搞清数据模型,再看 rust/worker/src/execution/ 搞清一次查询怎么被拆成算子,最后才进 rust/index 和 rust/blockstore 看底层。反过来读会陷进细节。

一个关键设计:一个 collection 等于三个段

这是理解 Chroma 的钥匙。你在客户端看到的是一个 collection,服务端存的是三个独立的 segment。

定义在 rust/types/src/collection.rs:392。三个段各管一摊:

  • vector_segment
    :只存向量和 ID 的映射,底下挂 HNSW 或 SPANN 索引。
  • metadata_segment
    :存元数据的倒排,负责 where 过滤和全文检索。
  • record_segment
    :存原始文档和完整记录,查询最后一步回表用它。

为什么要拆?因为三者的更新频率和存储格式完全不同。向量索引要批量构图,元数据要频繁更新,原文要按行读。混在一个文件里,任何一方变化都会拖累另外两方。拆开之后,可以各自选格式、各自 compaction、各自扩容。

代价是查询要跨三个段做拼接,这就是后面调用链里那些 merge 算子存在的原因。

核心机制一:过滤不是"查完再筛"

最容易被误解的一点:带 where 的向量查询,不是先取 top-k 再过滤。那样一旦过滤条件苛刻,可能前 100 个都不符合,结果直接空掉。

Chroma 的做法是先把"允许的 ID 集合"算出来,再让 HNSW 在这个集合里走图。

第一步,FilterOperator 在 rust/worker/src/execution/operators/filter.rs:51。它的输入是日志、元数据段、记录段,输出是位图:

SignedRoaringBitmap 就是 RoaringBitmap 加了"排除"语义。元数据条件先查倒排拿到一批 offset,多个条件之间做交并差;文档的 $contains 走 n-gram 或全文索引;ID 条件再求一次交。整个过程都在位图上做集合运算,不碰向量。

第二步,把位图转成 offset 列表,传进 HNSW 查询。rust/index/src/hnsw.rs:270:

底层 hnswlib 的 query 支持 allowed/disallowed 两个列表,走图时直接跳过不在集合里的节点。这比"多取一些再过滤"稳得多:召回率不会随过滤强度崩塌。

人话类比:不是先让所有候选人排队再查户口,而是先把不合格的人挡在门外,再让剩下的人比距离。

核心机制二:把 Arrow 当 KV 用

blockstore 是 Chroma 最像"自研数据库"的部分。它要解决的问题是:如何在对象存储上存一张可随机读、可范围扫、还能增量写的表。

答案是三层结构:不可变块 + 稀疏索引 + 可变 delta。

不可变块用 Arrow IPC 格式,列式存储,天然适合批量读。稀疏索引在 rust/blockstore/src/arrow/sparse_index.rs:64,核心是一个 BTreeMap:

它记录"每个块的起始 key 是哪个、块 ID 是什么"。查一个 key 时,先在 BTreeMap 里二分找到目标块,再去对象存储读那一个块。这就是 LSM 的思路,只是值从字节变成了 Arrow RecordBatch。

增量写先落在内存里的 delta,读的时候 delta 优先、块其次。真正落盘发生在 commit(rust/blockstore/src/arrow/blockfile.rs:118):

空 delta 直接从稀疏索引里摘掉,非空的转成新块、更新计数。commit 之后产生的是不可变文件,可以直接上传对象存储,也可以被多个副本共享。

人话类比:像一本活页笔记本。已经写好的页不再改,新内容先写在便签上;便签攒够了,换成新的一页插进目录,旧便签扔掉。

核心机制三:WAL3,在 S3 上做预写日志

有了块存储还不够。用户 add 之后,数据要立刻能查到,不能等 compaction 跑完。这段窗口由日志兜住。

wal3 是第三代 WAL,专门为对象存储设计。它没有"追加写一个文件"这种奢侈,因为 S3 不支持原地追加。它的做法是:每次写入生成一个 fragment,同时推进 manifest。

manifest 是一个很小的原子指针,记录"这个日志当前由哪些 fragment 组成"。fragment 写成功不算数,manifest 推进成功才算持久化——这就是 commit point。

多副本靠 quorum。rust/wal3/src/lib.rs:48 导出了 write_quorum,rust/wal3/src/interfaces/repl/fragment_manager.rs:357 调用它,再用 process_quorum_results 判断是否达到最小副本数。更细的一点是 setsum:每个 fragment 带一个集合校验和,副本之间不仅要比"写成功",还要比"内容一致"。

人话类比:不是往一个本子上续写,而是每页单独复印几份放进不同抽屉,再用一张目录卡声明"这一版由第几页到第几页组成"。目录卡换了,才算改版成功。

核心机制四:大到单机放不下时,用 SPANN

HNSW 的内存开销随数据量线性涨。十亿级向量塞不进一台机器,Chroma 的答案是 SPANN。

rust/index/src/spann/types.rs:322 定义了 SpannIndexWriter。它先用一个小 HNSW 存"聚类中心",每个中心挂一条 posting list(倒排列表),列表里是该簇的向量 ID。查询时先找最近的若干中心,再只扫这几条 posting list。

为了省内存,向量会被量化。rust/index/src/quantization/multi_bit.rs 实现的是 4-bit RaBitQ:把向量切成网格点,用"射线游走"找与残差余弦相似度最高的格点。1-bit 版本更狠,只存符号位。量化后的码可以直接算近似距离,用来粗排,精排再回原始向量。

这套东西和 HNSW 是并存的:单机小集合用 HNSW,分布式大集合用 SPANN。选哪条路由 collection 的配置决定。

一次查询和一次写入,走的是两条路

查询路径可以概括成"两段编排、四个算子"。

一次 collection.query() 的调用链

前端收到请求后,KnnFilterOrchestrator(rust/worker/src/execution/orchestration/knn_filter.rs:191)先跑第一段:拉日志、做过滤,得到候选位图。然后 KnnOrchestrator 按 shard 并行跑第二段:每个分片同时查 KnnLogOperator(未物化日志,暴力扫)和 KnnHnswOperator(已建索引,走图),两路结果由 KnnMergeOperator 归并(rust/worker/src/execution/operators/knn_merge.rs:43),最后排序、回表、返回。

写入路径则是另一个故事。

一条 add() 的数据流

数据先写日志服务拿到单调 offset,再由 WAL3 复制成多份 fragment、推进 manifest。此时读路径已经能查到它——因为 FetchLogOperator 会读日志。compaction 在后台把日志物化成 Arrow block 和 HNSW 索引,更新 sysdb 里的段元数据,旧文件交给 GC。

这就是"写入快、索引慢"的来源:写入只碰日志,索引构建是异步的。代价是同一份数据在一段时间内存在两份(日志和索引),查询要把两路结果合并。

取舍清单

设计
换来了什么
付出了什么
三段拆分
各自选格式、各自扩容
查询要跨段拼接、多几次 merge
过滤先行
高选择性过滤下召回稳定
过滤本身要扫元数据倒排
Arrow 块存储
列式压缩、批量读、对象存储友好
小写入放大、依赖 compaction
WAL3 + quorum
写入低延迟、故障可恢复
副本成本、manifest 竞争
SPANN + 量化
单机放不下也能查
近似距离、需要精排补偿
Rust 重写
内存安全、多语言绑定、性能可控
34 万行、贡献门槛高

边界

它适合:RAG 的知识库、语义搜索、推荐召回、需要"向量 + 元数据 + 全文"混查的场景。单机模式足够跑原型,服务端模式能上生产。

它不适合:强事务的 OLTP、复杂 join、需要精确一致性的账务类数据。它是检索系统,不是关系数据库。

运维成本是真实的。自建服务端意味着你要管对象存储、元数据库、worker、compaction。单机模式没有这些,但也没有水平扩展。

版本还在快速变。Rust 核心 0.15、客户端 1.6,接口和内部结构都还在动。读源码时以你 clone 到的那一版为准。

怎么跑起来

最省事的路径是嵌入模式,不需要起服务:

想跑服务端:

想读源码,建议先 cargo build -p chroma-frontend,再从 rust/frontend/src/server.rs 的路由表往里跳。41 个 crate 不用全看,按"接入 → 编排 → 段 → 索引"这条线走一遍就够建立地图了。

为什么值得读

向量数据库这个品类,很多产品的差异在"封装了哪个索引库"。Chroma 的差异在"怎么把索引库塞进一个能水平扩展的存储系统"。

它把 HNSW 当成一个可替换的索引实现,真正自研的是三段模型、Arrow 块存储和 WAL3。这三样决定了它能不能上云,而 HNSW 只决定单机快不快。

如果你只想知道"怎么存向量、怎么算相似度",看 rust/index 就够。如果你想看"一个检索系统怎么从单机原型长成分布式服务",那 34 万行里真正值得读的,是 blockstore 和 wal3。

下一篇写 memfree:一个跨会话记忆层,看它怎么决定"什么该记住、什么该忘"。

· · ·

「热门开源项目拆解」系列持续更新,拆的是代码,不是新闻稿。觉得有用可以点个关注。

回首页,搜索你想要的文档

在首页搜索框输入关键词,快速定位资料

文库首页搜索指引 立即搜索

相关学习资料