Embedchain 向量数据生命周期分析:索引、删除与一致性边界
拆解 source_hash、doc_id 和向量过滤删除机制,分析 Embedchain 在事务、租户隔离与残留数据上的一致性边界。

向量数据库常被说成“只能相似检索,不能把向量还原成原文”。接下来很容易出现一个误解:既然不能反读原文,系统删除记忆时怎么知道该删哪些向量?
答案与向量能不能反解无关。RAG 系统不会靠反解向量定位数据,它会在写入时保存 ID 和元数据,删除时按这些标识过滤记录。
本文分析的是已归档的 Embedchain,源码固定在 35cc15d30d6b783e0aa5bc70bf4ba7baa21d3dd5。原来的 embedchain/embedchain 地址已经重定向到 Mem0,旧实现保存在 mem0ai/embedchain-archive。
一份数据写入了不止一个地方
App.add() 收到 URL、文本或文件后,大致经过下面几步:
数据源
-> Loader 读取内容
-> Chunker 切分文本
-> Embedder 生成向量
-> 向量库保存文本、向量和元数据
-> SQL 表保存数据源记录
Embedchain 对传给 add() 的 source 计算 MD5,得到 source_hash。Loader 根据内容生成 doc_id,Chunker 再为每个分块生成 SHA-256 chunk_id。如果配置了 app_id,它还会进入文档 ID、分块 ID和向量元数据。
一条向量记录并不只有浮点数组,它通常还包含原始分块文本以及类似下面的元数据:
{
"app_id": "support-bot",
"hash": "af7c...",
"doc_id": "support-bot--91ad...",
"url": "https://example.test/refund-policy"
}
与此同时,ec_data_sources 表保存 hash、app_id、数据类型、原始 source 和自定义 metadata。由此可见,“向量不可逆”不是隐私保证:系统为了检索和管理,往往直接把原始分块也保存在向量库 payload 中。
删除靠的是 source hash
App.add() 会返回 source_hash,调用方需要保留它。删除时执行:
source_id = app.add("退款申请可以在订单完成后七天内提交", data_type="text")
app.delete(source_id)
当前源码中的 delete() 分两步:
self.db_session.query(DataSource).filter_by(
hash=source_id,
app_id=self.config.id,
).delete()
self.db_session.commit()
self.db.delete(where={"hash": source_id})
第一步删除 SQL 中的数据源记录,第二步让向量数据库按 metadata 中的 hash 删除全部分块。Chroma、Qdrant、Pinecone 等适配器会把这个字典转换成各自的过滤表达式。
所以删除链路不需要读取向量,更不需要恢复原文。source_hash 相当于数据源与所有分块之间的索引键。
一个最小验证
使用默认 Chroma 后端时,可以围绕同一个 hash 在删除前后查询。下面省略模型问答,只检查存储层:
from embedchain import App
app = App()
source_id = app.add(
"退款申请可以在订单完成后七天内提交",
data_type="text",
)
before = app.db.get(where={"hash": source_id})
print("before:", len(before["ids"]))
app.delete(source_id)
after = app.db.get(where={"hash": source_id})
print("after:", len(after["ids"]))
正常情况应该看到删除前至少有一个分块,删除后为零。随后重启进程并再次查询,才能证明结果不是内存缓存造成的假象。这个实验验证的是当前 collection 的可见记录,不代表磁盘旧页、快照和备份已经物理擦除。
这段代码是针对归档项目的复现实验,本次写作环境没有安装旧版 Chroma 与模型依赖,因此没有把预期计数冒充成实测输出。下面的一致性结论来自 App.delete() 及各向量库适配器的调用顺序,可以不依赖模型推理直接从源码确认。
“不会持久性污染”这个结论并不成立
Embedchain 的实现存在几个很具体的一致性问题。
首先,SQL 和向量库之间没有共同事务。delete() 先提交 SQL 删除,再调用向量库。如果第二步失败,数据源索引已经消失,向量分块却仍然可检索。调用方手里如果没有保留 source_id,后续清理会更困难。
可以用一个很小的故障注入暴露这个顺序:
source_id = app.add("temporary memory", data_type="text")
real_delete = app.db.delete
def failed_delete(where):
raise RuntimeError("vector store is unavailable")
app.db.delete = failed_delete
try:
app.delete(source_id)
except RuntimeError:
pass
finally:
app.db.delete = real_delete
remaining = app.db.get(where={"hash": source_id})
print("orphan chunks:", len(remaining["ids"]))
其次,SQL 删除条件包含 hash + app_id,向量删除却只有 hash。两个 App 如果共享 collection,并写入相同 source,它们得到的 hash 也相同。一个 App 删除数据时,向量过滤有可能命中另一个 App 的分块。虽然分块 ID 带有 app_id 前缀,但这里的删除操作没有使用分块 ID。
第三,不同向量库适配器的失败行为并不统一。Pinecone 适配器捕获删除异常后只打印错误并返回,外层 App.delete() 仍会继续记录“Successfully deleted”。这会把失败表现成成功。
最后,reset() 也不是租户级清理。SQL 只删除当前 app_id,而部分向量库的 reset() 会删除并重建整个 collection 或 index。如果多个 App 共用同一 collection,重置一个 App 可能影响其他 App。
更可靠的删除流程
生产系统不应把删除实现成两个无关联的函数调用。一个可操作的方案是:
- 为每条数据建立稳定的
tenant_id + source_id + chunk_id标识,所有查询和删除都强制带租户条件。 - 在业务数据库先写入删除任务或 tombstone,而不是立刻丢掉数据源映射。
- 后台任务幂等删除向量、原文对象、缓存和派生索引。
- 删除后按 tenant 和 source 重新查询,确认可见记录为零,再把任务标记为完成。
- 对失败任务重试,并定期扫描没有数据源记录的孤立向量。
- 单独定义日志、快照和备份的保留周期。在线删除不能自动清除离线备份。
如果业务要求“物理不可恢复”,还要依赖存储介质加密、密钥销毁、数据库压缩和备份过期策略。向量数据库返回零条记录,只能证明数据不再通过当前索引可见。
Embedding 也不是安全边界
Embedding 通常不能像压缩包那样直接解码回原文,但它保留了大量语义和统计信息,也存在模型反演与成员推断研究。更现实的问题是,大多数向量数据库会同时保存原始文本或可读 payload。
因此,系统设计不能把“向量难以反解”当作脱敏。权限、租户过滤、删除任务和备份周期仍然要按原始数据的敏感级别处理。
Embedchain 的实现很好地说明了向量删除的基本方法:写入时建立可追踪元数据,删除时按元数据过滤。它也说明了另一件事:能够发出 delete 请求,与能够证明数据生命周期闭合,是两回事。
参考源码
- Embedchain 国内入口
- 数据写入与删除:
embedchain/embedchain.py - 分块标识生成:
embedchain/chunkers/base_chunker.py - 向量库适配器:
embedchain/vectordb/