一个本地 RAG 项目,业务数据总共 75MB,磁盘上的 `_indices` 目录却累计到 48.6GB,膨胀 648 倍;两天之内,版本清单里堆出 1517 个写版本。这不是文件损坏,也不是数据库写坏了,而是三个"各自合理"的机制叠在一起的结果。本文从机制层面拆解它为什么会发生,以及官方 API 给出的根治路径。
一、现象:膨胀集中在 `_indices`,且随写操作持续增长
LanceDB 的表在磁盘上是目录形态:数据文件、`_versions` 里的版本清单(manifest),以及 `_indices` 索引目录。观察到的两条规律:应用每重启一次,`_indices` 就涨一截;业务写入越频繁,涨得越快。75MB 的数据配 48.6GB 的索引,比例悬殊到让人怀疑是不是哪个进程在乱写文件,但把三层机制逐一拆开看,每一步都发生在官方语义之内——这也是它隐蔽的原因,日志里没有任何一行"错误"。
二、机制其一:createIndex 不幂等,重调一次就多一份
`createIndex` 的语义是"新建一份索引",而不是"存在即复用"。即使同名索引已经存在,再次调用不会报错、不会去重,而是实打实再生成一份新索引——在 75MB 的数据规模上,一份索引约 33MB。旧的那份并不会立刻消失,而是作为历史版本的资产继续留在 `_indices` 里。
这就是"不幂等":同样的输入,执行两次,磁盘状态就变两次。多数开发者凭直觉认为建索引是幂等操作,于是放心地把它塞进初始化流程,这为后面两层机制埋下了伏笔。
三、机制其二:启动即建的 ensure-index 充当放大器
典型的应用代码会写一段"确保索引存在"的逻辑(ensure-index):启动时检查索引在不在,不在就建。问题出在检查环节一旦有疏漏——比如只判断了表是否存在、没判断索引是否存在,或者异常分支把判断结果吞掉——这段逻辑就退化成"每次启动都无条件建索引"。
应用每天重启十几次,每次 +33MB,一天就是几百 MB。单看这一层,膨胀速度尚可忍受,真正的大头来源在第三层。
四、机制其三:每个写版本都携带一份 FTS 索引快照
Lance 采用 copy-on-write 的版本化设计:每次写操作生成一个新版本,新版本的清单会引用一份当时的索引快照——包括 FTS(全文检索)索引。只要旧版本没有被清理,它引用的索引快照就一直躺在 `_indices` 里。
本项目两天产生 1517 个写版本,每个版本背后都跟着一份快照,而清理任务从未运行,于是线性堆积、只进不出。这一层也解释了膨胀为何与写入频率强相关:写入越活跃,快照生成越快,与重启次数反而不算强绑定。
三层叠加的效果:重启建新索引 × 每版本一份快照 × 从不回收 = 648 倍膨胀。任何一层单独看都不致命,叠起来就是 48.6GB。
五、官方 API 根治:optimize 的两个参数是关键
官方提供的清理入口是 `table.optimize()`,但它有两个默认行为必须了解,否则清理等于空转:
const result = await table.optimize({ // 把保留期设为"此刻":回收此刻之前的所有历史版本及其索引快照 cleanupOlderThan: new Date(), // 连同未通过校验的孤儿索引文件一起删除 deleteUnverified: true, }); console.log(result.metrics.bytesRemoved); // 实测一次回收 48.5GB坑一:默认保留期 7 天。不传 `cleanupOlderThan` 时,optimize 只清理 7 天之前的历史版本。而本例 1517 个版本全部产生于两天之内,全都落在保留期里,实测返回的 `bytesRemoved` 为 0,等于什么都没清。要一次性回收存量,必须显式把时间戳传成 `new Date()`。
坑二:`deleteUnverified` 默认不删。校验不过的索引文件(孤儿文件)默认被跳过,只有显式传 `true` 才会回收。漏掉这个参数,往往清完还剩一大块删不掉的残余。
另有一条教训值得单独立牌:手删 `_indices` 目录行不通。版本清单里记录着索引的 UUID,目录被手动删掉后 UUID 仍在,下次访问触发校验失败,轻则报错、重则表打不开。清理必须走官方 API,让 manifest 与磁盘状态保持一致。
六、长效机制:建前判断 + 三道闸定期维护
治标之后要治本,两道防线。
其一是建前判断,从源头消灭重复建索引:
const indices = await table.listIndices(); const exists = indices.some((idx) => idx.name === "embedding_idx"); if (!exists) { await table.createIndex("embedding_idx", { // 向量列与索引类型配置,按项目实际情况填写 }); }先用 `listIndices` 拿到当前索引名列表,确认目标索引不存在才调用 `createIndex`,把"不幂等"挡在门外。
其二是定期 maintenance 任务,用三道闸控制 optimize 的执行时机,避免与业务写入互相干扰:
- 无写入进程:确认当前没有活跃的写连接占用这张表;
- 静默期:安排在凌晨等低峰时段执行;
- 体积比阈值:`_indices` 体积与数据体积之比超过设定值(例如 3 倍)才触发,避免空跑。
生产环境中保留期建议沿用默认 7 天,给回滚留出余地,只把阈值闸门做严;一次性救援场景才用 `new Date()` 全量回收。这样磁盘占用会稳定在一个可预期的水位,而不是一路涨到某个深夜把 C 盘塞满。
参考文章
存储与索引的治理思路讲完了。扩展阅读两篇:一篇盘点了智能客服自动回复的五种实现方案,一篇讲本地知识库的问答质量建设,是这套存储治理所服务的上游场景:
- 智能客服自动回复怎么做:2026 年 5 种方案全景盘点
- 本地知识库问答:让客服机器人答得更准