← 返回文章列表

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 表保存 hashapp_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。

更可靠的删除流程

生产系统不应把删除实现成两个无关联的函数调用。一个可操作的方案是:

  1. 为每条数据建立稳定的 tenant_id + source_id + chunk_id 标识,所有查询和删除都强制带租户条件。
  2. 在业务数据库先写入删除任务或 tombstone,而不是立刻丢掉数据源映射。
  3. 后台任务幂等删除向量、原文对象、缓存和派生索引。
  4. 删除后按 tenant 和 source 重新查询,确认可见记录为零,再把任务标记为完成。
  5. 对失败任务重试,并定期扫描没有数据源记录的孤立向量。
  6. 单独定义日志、快照和备份的保留周期。在线删除不能自动清除离线备份。

如果业务要求“物理不可恢复”,还要依赖存储介质加密、密钥销毁、数据库压缩和备份过期策略。向量数据库返回零条记录,只能证明数据不再通过当前索引可见。

Embedding 也不是安全边界

Embedding 通常不能像压缩包那样直接解码回原文,但它保留了大量语义和统计信息,也存在模型反演与成员推断研究。更现实的问题是,大多数向量数据库会同时保存原始文本或可读 payload。

因此,系统设计不能把“向量难以反解”当作脱敏。权限、租户过滤、删除任务和备份周期仍然要按原始数据的敏感级别处理。

Embedchain 的实现很好地说明了向量删除的基本方法:写入时建立可追踪元数据,删除时按元数据过滤。它也说明了另一件事:能够发出 delete 请求,与能够证明数据生命周期闭合,是两回事。

参考源码

  • Embedchain 国内入口
  • 数据写入与删除:embedchain/embedchain.py
  • 分块标识生成:embedchain/chunkers/base_chunker.py
  • 向量库适配器:embedchain/vectordb/