上周末我在给一个内部文档问答项目做向量化检索,几十份 Markdown 切片后全部丢进 Chroma。数据量上来后,我习惯性打开持久化目录里的 SQLite 文件看了一眼,结果被里面密密麻麻的表结构吓了一跳:我只是 add 了一个 collection,为什么会有这么多张表?它们每个都是干嘛的?相互之间怎么关联?如果直接改某张表会不会把整个索引弄坏?
这篇文章就是冲着解决这些问题来的。我会结合一次完整的 Chroma 向量数据库实战,从项目场景、代码落库,到表结构逐张拆解、关联关系串讲,最后把我在实操里踩过的坑一并交底。不管你是第一次接触向量数据库,还是已经用 Chroma / Milvus 做过 RAG 项目,都应该能在这里找到点有用的东西。
1. 向量数据库实战前,先对齐几个核心概念
1.1 从“关键词匹配”到“语义相似”的转变
传统关系型数据库和 Elasticsearch 这类全文检索引擎,擅长处理的是精确匹配和词法匹配。比如你搜“怎么申请年假”,它会把文档里包含“申请”“年假”这些词的内容找出来。但实际业务问题往往不按关键词出牌,用户会说“我想休息两天”“公司规定里有没有休假政策”,这时候关键词匹配就很难召回同一份文档。
向量数据库解决的是“语义相似”的问题。它先把文本、图片、音视频这类非结构化数据交给 Embedding 模型转换成向量,再通过向量距离(余弦相似度、欧氏距离等)判断两个内容在语义上是否接近。这里的核心不是“有没有出现同样的词”,而是“两个向量在空间里的方向是否一致”。
1.2 Embedding 模型、向量索引和向量数据库的关系
很多人第一次上手 Chroma 时会混三个东西:Embedding 模型负责“把内容变成向量”,向量索引负责“让高维向量能被高速近似查找”,向量数据库负责“把向量、元数据、集合管理、持久化、API 整合到一起”。
你可以简单类比成:Embedding 是厨师切好的菜,向量索引是排好队的冷柜,向量数据库则是整个餐厅的后厨管理系统。日常开发里我们不需要亲手写 HNSW 算法,也不需要自己维护索引文件,这些脏活都由 Chroma、Milvus 这类产品包掉了。但如果你完全不懂底层表结构,一旦遇到“数据加了为什么查不到”“为什么文件占空间异常大”这类问题,排查起来就会很痛苦。
1.3 Chroma 在向量数据库生态里的位置
当前社区里热门向量数据库不少,常见的有 Chroma、Milvus、Qdrant、Weaviate、Pinecone 等。Chroma 最大的优势是轻量和本地优先,pip 安装后直接可以跑,非常适合个人知识库、AI 产品原型、本地小规模 RAG 场景。Milvus 则更偏生产级,支持分布式、海量数据,有独立的协调组件和存储层,部署运维复杂度也更高。
我做这个项目的时候选择 Chroma,原因主要有三条:
- 数据量控制在几十万条之内,单机内存和磁盘足够覆盖;
- 操作简单,集合、添加、查询的 API 对新手非常友好;
- 持久化只依赖一个 SQLite 文件和索引目录,便于审计和搬移。
当然,轻量也意味着底层“黑盒”成分更多。所以如果你也想把它用到真实项目里,理解它落盘后的表结构,其实是绕不开的一道功课。
2. 真实项目里,Chroma 落库后到底变成什么样
2.1 最小可运行的添加集合示例
先说一个最小示例。我的持久化路径是./kb_data,所有数据都会落在那个目录里。
pip install chromadbimport chromadb client = chromadb.PersistentClient(path="./kb_data") collection = client.get_or_create_collection( name="employee_policy", metadata={"hnsw:space": "cosine"}, ) collection.add( ids=["doc-001", "doc-002"], documents=[ "员工每年享有15天带薪年假,需提前一周在系统提交申请。", "离职交接流程包括归还设备、转移权限、完成知识库文档更新。", ], metadatas=[ {"category": "leave", "owner": "hr"}, {"category": "offboarding", "owner": "it"}, ], ) print(collection.count())运行后你会看到集合数量是 2。这个案例很常规,但打开持久化目录,能看到一大堆平时不会注意的产物。
2.2 持久化目录里有哪些“居民”
用tree kb_data或者直接打开目录,正常情况下会看到类似下面的结构:
kb_data/ ├── chroma.sqlite3 └── index/ └── xxx-xxxx-xxx/ ├── data.level0 ├── header.bin └── ...这里的chroma.sqlite3是 Chroma 的元数据和业务数据主库,各种“表”都住在里面。index/目录里则是真正干活的 HNSW 向量索引文件,目录名一般跟某个内部segment的 ID 对应。
很多人会有一个误解,以为添加集合后“多出的好多表”都是集合产生的。其实准确来说,Chroma 初始化持久化目录时就会把基础表建好,集合、索引这些是往这些表里插入记录。
2.3 用 sqlite3 直接查看表清单
在项目目录里执行:
sqlite3 kb_data/chroma.sqlite3 ".tables"不同版本看到的表名会稍有差异,但主干通常包括下面的一组:
tenants databases collections collection_metadata segments segment_metadata embeddings embedding_metadata max_seq_id migrations看到这一串后,建议先把敬畏心收起来。它不是让你写 SQL 去人工读写业务数据的,而是一套内部状态编排。Chroma 查询、删除、更新时靠这些表协同完成事务和索引同步。
3. 逐张拆解:Chroma 的每张表到底在干嘛
3.1 租户与库:tenants / databases
table 名称:tenants、databases
Chroma 参考了多租户数据库模型。tenants表保存租户信息,默认会有一条类似default_tenant的记录。databases表保存库信息,一条记录会挂在某个tenant_id下。初次使用时通常会生成default_database。
| 表名 | 核心字段(常见) | 含义 |
|---|---|---|
| tenants | id, name | 租户 |
| databases | id, name, tenant_id | 某个租户下的库 |
关联关系是:一个租户下可以有多个数据库,一个数据库可以被很多集合使用。
普通开发者平时根本不需要碰这两张表。但要注意:如果你在同一个PersistentClient路径里用tenant和database参数创建多租户环境,那么集合的归属关系就会拉长成“租户-库-集合”,查询时如果不指定正确上下文,可能找不到原来的集合。
3.2 集合相关:collections / collection_metadata
table 名称:collections、collection_metadata
collections表是面向用户的核心入口。我们调用get_or_create_collection(name="employee_policy"),最终就是在collections表里插入一行,记录集合 ID、名称、所属数据库 ID 等信息。
collection_metadata表存储的是这个集合级别的元数据键值对。例如我在创建集合时传入的{"hnsw:space": "cosine"},就会以 key-value 的形式写入这里。Chroma 的元数据是类型化存储的,通常会有bool_value、int_value、float_value、string_value这样的列,读取时再按类型还原。
| 表名 | 常见字段 | 说明 |
|---|---|---|
| collections | id, name, database_id, dimension, configuration_json | 集合主表 |
| collection_metadata | id, collection_id, key, string_value, int_value, float_value, bool_value | 集合级元数据 KV |
这里有个很多新手踩过的细节:Chroma 里collection.metadata不是你传什么就原封不动存成 JSON。它会被拆成一行一行的键值记录,放进collection_metadata表。所以如果你用 SQL 直接改collections表里的字段,往往不会生效,因为真正读到的是元数据表里的值。
3.3 索引段相关:segments / segment_metadata
table 名称:segments、segment_metadata
Chroma 内部对每个集合会创建“段”(segment),段是用来组织索引和存储的最小逻辑单元。你可以理解成:集合是业务概念,段是存储和索引概念。
创建集合后,segments表里通常至少会出现两类记录:
- 向量段(VECTOR):负责管理高维向量的索引构建和查询;
- 元数据段(METADATA):负责管理 embedding 对应的 metadata 过滤。
在 Chroma 的代码里,向量段的类型名一般会包含hnsw,因为默认的近似最近邻索引是 HNSW。
segment_metadata表和collection_metadata类似,存储的是段级元数据。例如 HNSW 的索引参数、算法配置,都有可能出现在这里。
| 表名 | 常见字段 | 说明 |
|---|---|---|
| segments | id, collection_id, type, scope, configuration_json | 段主表 |
| segment_metadata | id, segment_id, key, value | 段级元数据 |
需要特别强调的是,你在持久化目录index/[segment_id]下看到的二进制文件,和segments表里的记录是对应的。vector 数据写入后,Chroma 把这个向量段相关的索引写进了文件。所以如果你要手动备份或迁移,只复制chroma.sqlite3是不够的,必须把整个index目录一起带走。
3.4 向量与元数据落库:embeddings / embedding_metadata
table 名称:embeddings、embedding_metadata
这是业务数据真正“安身”的地方。embeddings表每一行对应一个向量记录,往往包含 ID、集合 ID、段 ID、序号、向量数据等字段。如果你之前添加了 100 条文档切片,这张表里就至少会有 100 条向量记录。
embedding_metadata表则保存每个向量对应的业务元数据。例如我上面示例里{"category": "leave", "owner": "hr"}这些内容,会被拆进这张表。还有一个细节:如果你用documents参数而不是metadatas参数传文本,Chromadb 内部其实会用chroma:document这个特殊的元数据 key 把原始文档内容存下来。这也就是为什么你在某些版本的 metadata 表里能看到一个 key 叫chroma:document的原因。
| 表名 | 常见字段 | 说明 |
|---|---|---|
| embeddings | id, segment_id, collection_id, embedding, seq_id | 向量主体 |
| embedding_metadata | id, embedding_id, key, string_value/int_value... | 每个向量的元数据 KV |
如果你直接在 SQLite 里查看embeddings表,会看到embedding这一列是一大段难以阅读的二进制或序列化内容。不要尝试手动改它,Chroma 和 HNSW 索引文件的同步关系非常微妙,一行改动很容易造成查询结果和索引不一致。
3.5 状态与版本辅助表:max_seq_id / migrations
table 名称:max_seq_id、migrations
Chroma 使用 WAL 或类似思想保证写入顺序和增量同步。max_seq_id表保存了每个段当前已经写入到哪条序号,下次写入时从这里继续累计。这保证了即使程序重启,也不会出现用旧序号覆盖新数据的问题。
migrations表则是数据库结构版本管理表。Chroma 升级时会执行一系列迁移脚本,更新表结构或必要数据,迁移记录就存在这张表里。如果某次升级了一半、或者你从旧版本直接拷贝数据文件到新版本,很可能因为迁移状态不一致导致启动失败。
这两张表看起来“无足轻重”,但千万不要为了清空数据而顺手删掉。清空max_seq_id可能导致 Chroma 认为后续写入还是旧事件,进而出现无法解释的数据丢失或重复问题。
4. 表之间的关联关系是怎么串起来的
4.1 从“租户-库-集合”到“集合-段-向量”的导航链
如果把主要表的主外键关系用文字拉出来,关系链是这样的:
tenants (1) -> databases (N) databases (1) -> collections (N) collections (1) -> segments (N) collections (1) -> embeddings (N) segments (1) -> embeddings (N) embeddings (1) -> embedding_metadata (N)这个关系链看着复杂,但逻辑上是逐层下钻的:
- 先确定你用的是哪个租户和库;
- 库下面有哪些集合;
- 集合下面有哪些索引段;
- 向量数据挂在段下,同时也能通过集合直接筛选;
- 每个向量又有自己的元数据表。
你可以把集合想象成“文件夹”,把段想象成“分卷压缩包”,把向量记录想象成压缩包里的文件。日常操作面向文件夹,但真正影响检索的是压缩包内部的一致性。
4.2 一次 add 操作,到底写了哪些表
拿最开始那段代码来拆解一次collection.add()。我用的是默认 embedding 函数,所以 Chroma 内部会先做文本向量化,然后再走写入链路。简化后的流程大致是:
- 根据
name="employee_policy"在collections表中找到集合记录; - 从
segments表里找到这个集合对应的向量段; - 对每条文本生成向量;
- 在
embeddings表写入一条向量记录,字段包括集合 ID、段 ID、向量数据等; - 把
documents里的文本和metadatas里的业务属性写进embedding_metadata; - 更新
max_seq_id中对应段的序号; - 将向量同步到
index/目录下的 HNSW 索引文件。
这个过程并不是“只往一张大表里塞 JSON”,而是把集合信息、向量信息、元数据、索引状态分散到不同的表和文件中。这样设计的好处是查询时可以分头优化:先用 HNSW 索引快速召回候选向量,再回到 SQLite 里做元数据过滤,不需要全量扫描。
4.3 查询时它反过来怎么用表
执行collection.query(query_text="年假")时,链路大致是反向的:
- 从
collections定位集合; - 从
segments找到向量段; - 读入或复用
index/下的 HNSW 索引文件; - 将查询文本转成向量后,在索引文件里做 ANN 搜索;
- 得到一批候选 embedding ID;
- 使用 embeddings / embedding_metadata 表里的信息,对候选做元数据过滤、距离计算和排序;
- 返回最终结果。
理解这条链路后,你就明白为什么“改 SQLite 里的表”是危险操作了:查询结果并不只依赖embeddings表,还依赖index/目录下的 HNSW 索引。如果你绕过 Chroma 直接往 SQLite 插入一条向量,而索引文件没有同步,这条向量在搜索时大概率不会被召回。
5. 表相关实操排查与备份技巧
5.1 怎么安全地查看自己库里有哪些集合和数据量
很多“专家”会告诉你直接写 SQL:
SELECT id, name FROM collections; SELECT count(*) FROM embeddings; SELECT key, string_value FROM embedding_metadata LIMIT 20;这些查询用来审计完全没问题。我在排查自己数据时也经常这样看。需要提醒的是:查询只读数据是安全的,但修改表内容或者直接 DELETE 记录必须避免。数据不一致造成的 bug,往往比业务代码 bug 更难查。
如果你只是想看“集合里有多少条数据”,不要依赖这个:
collection.count()这个 API 读的是 Chroma 内部的状态,比你自己数embeddings表记录数可靠得多。实际排查时如果二者对不上,优先怀疑自己手动改过库,或者数据库版本迁移出了问题。
5.2 备份 Chroma 数据时最容易犯的错
我项目最早做备份时,只复制了chroma.sqlite3,结果恢复后一个集合都查不出来,后来才发现索引文件丢了。
正确的备份姿势是:
- 先正常关闭所有写入和查询的客户端;
- 复制整个持久化目录,包括
chroma.sqlite3和index/; - 如果怀疑文件被占用,可以先对目录做一次快速 rsync,等待无写入窗口后再复制一次,保证一致性。
临时导出少量数据可以使用collection.get(include=["documents","metadatas","embeddings"]),但全量迁移建议直接冷备目录。
5.3 表多了、目录大了怎么清理
如果你删除了测试用的集合,磁盘空间却依然很大,不要慌。Chroma 删除集合时会同步删除 SQLite 里的记录,并清理对应 segment 的索引文件。但 SQLite 文件本身不会马上把物理空间还给操作系统,这属于数据库正常表现。
可以定期做 VACUUM,但不是普通操作,更推荐直接重建持久化目录再导入数据。对生产数据来说,最稳妥的清理方案是:用代码导出需要保留的数据,删除旧目录,重建新目录,再重新写入。不要尝试手动清理segments表或collections表里的残留记录,很容易牵连出索引文件和元数据不一致。
5.4 升级后不可打开数据库怎么办
出现类似“数据库 migration 失败”的报错时,通常是migrations表记录的版本,比代码期望的版本旧或新。我的建议是:
- 先看看
migrations表里记录的版本号; - 确认备份是否存在;
- 不要直接用旧版本强行打开新版本创建的数据目录;
- 如果生产环境,先搭一套同版本环境做验证再升级。
6. 向前一步:Chroma 与 Milvus 的集合表设计差异
6.1 Chroma 简单但不意味着可以乱来
Chroma 的优点是开箱即用,把文档、集合、向量、元数据封装得比较简单。但这种简单只体现在 API 层,底层该有的一致性、缓存、索引同步一样不少。理解它的表结构,不是为了绕开 API,而是为了在遇到玄学问题时知道从哪里下手。
如果你准备用它承载生产流量,至少要做到:明确集合名与 embedding 模型版本、设置合适的 distance 类型、定期冷备整个持久化目录、所有写操作都走官方 API,绝不直接改库。
6.2 Milvus 里的 collection、partition、segment 和 Chroma 有哪些对应点
再往上看 Milvus,会发现概念有不少相似之处,但又更复杂:
- Chroma 的 collection 对应 Milvus 的 collection,但 Milvus 的 collection 需要定义字段 schema;
- Chroma 的 segment 是一个内部逻辑索引单元;Milvus 里的 segment 是数据实际落盘的最小单位,随时间增长会自动合并或分裂;
- Milvus 还有 partition 概念,可以在一个 collection 下按业务维度做物理分区,让查询只扫部分分区,类似在 Chroma 里用 metadata 过滤,但 Milvus 把它放到存储层;
- Milvus 的元数据存储通常依赖 etcd 和对象存储,不只是一两个 SQLite 文件。
如果你的数据量上涨到需要多节点、需要动态扩缩容、需要熔断和运维可观测性,那时候再迁 Milvus 比较合适。如果数据量在百万条以内、又希望本地跑轻量服务,Chroma 其实已经很能打了。
6.3 从 Chroma 迁移到 Milvus 时,表结构思维要怎么换
我自己做过一次把 Chroma 数据迁移到 Milvus 的验证,最直接的体感是:
- 原来在 Chroma 里不用显式定义字段类型,到 Milvus 里要先把 collection schema 写清楚;
- Chroma 的
metadata是一个宽松 KV,迁到 Milvus 后最好映射成具体字段,否则过滤查询性能会不太好; - Chroma 的持久化是单机文件,Milvus 至少要考虑对象存储、消息队列、索引节点等多个组件;
- 从 RAG 业务层看,接口差异不大,都是 add 和 query,但底层表结构已经完全不是一个物种。
如果你目前只是做学习项目,不建议一上来就上 Milvus。先把 Chroma 的集合和表结构弄明白,知道向量数据从写入到检索经过了哪些环节,会让你在后续选型和迁移时更有底气。
最后说一个我自己的体会:向量数据库的表结构再复杂,真正要守住的核心还是“数据与索引一致”。Chroma 之所以在 SQLite 之外还生成那么多辅助表,是为了让查询效率和灵活性达到平衡。你要做的,不是研究怎么绕过它,而是学会在它正常工作时不添乱、出问题时能准确判断是集合配置、元数据过滤、索引损坏还是版本迁移的问题。把这个能力练出来,后面的向量数据库之路会顺畅很多。