YAOTU INSIGHTS

Chroma 向量数据库表结构详解:从 SQLite 到 HNSW 索引的底层逻辑

Chroma 向量数据库表结构详解:从 SQLite 到 HNSW 索引的底层逻辑
上周末我在给一个内部文档问答项目做向量化检索几十份 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( nameemployee_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 / databasestable 名称tenants、databasesChroma 参考了多租户数据库模型。tenants表保存租户信息默认会有一条类似default_tenant的记录。databases表保存库信息一条记录会挂在某个tenant_id下。初次使用时通常会生成default_database。表名核心字段常见含义tenantsid, name租户databasesid, name, tenant_id某个租户下的库关联关系是一个租户下可以有多个数据库一个数据库可以被很多集合使用。普通开发者平时根本不需要碰这两张表。但要注意如果你在同一个PersistentClient路径里用tenant和database参数创建多租户环境那么集合的归属关系就会拉长成“租户-库-集合”查询时如果不指定正确上下文可能找不到原来的集合。3.2 集合相关collections / collection_metadatatable 名称collections、collection_metadatacollections表是面向用户的核心入口。我们调用get_or_create_collection(nameemployee_policy)最终就是在collections表里插入一行记录集合 ID、名称、所属数据库 ID 等信息。collection_metadata表存储的是这个集合级别的元数据键值对。例如我在创建集合时传入的{hnsw:space: cosine}就会以 key-value 的形式写入这里。Chroma 的元数据是类型化存储的通常会有bool_value、int_value、float_value、string_value这样的列读取时再按类型还原。表名常见字段说明collectionsid, name, database_id, dimension, configuration_json集合主表collection_metadataid, collection_id, key, string_value, int_value, float_value, bool_value集合级元数据 KV这里有个很多新手踩过的细节Chroma 里collection.metadata不是你传什么就原封不动存成 JSON。它会被拆成一行一行的键值记录放进collection_metadata表。所以如果你用 SQL 直接改collections表里的字段往往不会生效因为真正读到的是元数据表里的值。3.3 索引段相关segments / segment_metadatatable 名称segments、segment_metadataChroma 内部对每个集合会创建“段”segment段是用来组织索引和存储的最小逻辑单元。你可以理解成集合是业务概念段是存储和索引概念。创建集合后segments表里通常至少会出现两类记录向量段VECTOR负责管理高维向量的索引构建和查询元数据段METADATA负责管理 embedding 对应的 metadata 过滤。在 Chroma 的代码里向量段的类型名一般会包含hnsw因为默认的近似最近邻索引是 HNSW。segment_metadata表和collection_metadata类似存储的是段级元数据。例如 HNSW 的索引参数、算法配置都有可能出现在这里。表名常见字段说明segmentsid, collection_id, type, scope, configuration_json段主表segment_metadataid, segment_id, key, value段级元数据需要特别强调的是你在持久化目录index/[segment_id]下看到的二进制文件和segments表里的记录是对应的。vector 数据写入后Chroma 把这个向量段相关的索引写进了文件。所以如果你要手动备份或迁移只复制chroma.sqlite3是不够的必须把整个index目录一起带走。3.4 向量与元数据落库embeddings / embedding_metadatatable 名称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的原因。表名常见字段说明embeddingsid, segment_id, collection_id, embedding, seq_id向量主体embedding_metadataid, embedding_id, key, string_value/int_value...每个向量的元数据 KV如果你直接在 SQLite 里查看embeddings表会看到embedding这一列是一大段难以阅读的二进制或序列化内容。不要尝试手动改它Chroma 和 HNSW 索引文件的同步关系非常微妙一行改动很容易造成查询结果和索引不一致。3.5 状态与版本辅助表max_seq_id / migrationstable 名称max_seq_id、migrationsChroma 使用 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 内部会先做文本向量化然后再走写入链路。简化后的流程大致是根据nameemployee_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 怎么安全地查看自己库里有哪些集合和数据量很多“专家”会告诉你直接写 SQLSELECT 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 需要定义字段 schemaChroma 的 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 之外还生成那么多辅助表是为了让查询效率和灵活性达到平衡。你要做的不是研究怎么绕过它而是学会在它正常工作时不添乱、出问题时能准确判断是集合配置、元数据过滤、索引损坏还是版本迁移的问题。把这个能力练出来后面的向量数据库之路会顺畅很多。