项目:玩具向量存储

项目目标

本项目不用第三方依赖,在一个严格 JSON 文件上实现最小向量存储,亲手验证以下因果关系:

  • embedding space 需要显式且不可混写的 contract;
  • point 必须由稳定 ID、vector 和可审计 payload 组成;
  • upsert 要区分创建、完全相同的重放、expected-current CAS 更新与同 revision 异内容冲突;
  • tenant、ACL、发布状态和业务 filter 必须在评分前生效;
  • 删除要绑定当前 source revision 和 delete event;即使 point 尚未索引也落 intent fence,tombstone 默认阻止迟到 upsert;
  • 多实例缓存状态在 no-op、search 和 summary 前验证磁盘 revision,变化时 fail-closed;
  • exact search 能提供小规模正确性基线;
  • 一个 JSON 文件即使原子替换,也远远不是生产数据库。

项目文件:

  • [[Knowledge/AI Agent Engineer/docs-CN/向量数据库/examples/toy_vector_store.py|toy_vector_store.py]]:实现与演示入口;
  • [[Knowledge/AI Agent Engineer/docs-CN/向量数据库/examples/test_toy_vector_store.py|test_toy_vector_store.py]]:41 项输入、数学、安全、CAS 生命周期、持久化 fence、资源边界、多实例 stale-read 和 CLI 测试。

使用前提

  • Windows 11、PowerShell 7;
  • Python 3.11 或兼容的稳定 Python 3;
  • 从 vault 根目录 X:\path\to\your-vault 运行;
  • 不需要网络、API key、虚拟环境或第三方包。

若要在真实项目中安装依赖,应先使用 venv + pip。本例只有标准库,为避免在 vault 生成 .venv 和 __pycache__,直接使用现有 Python 并设置 PYTHONDONTWRITEBYTECODE。

数据契约

Store contract

每个文件只容纳一个向量空间:

字段作用本项目约束
space_id空间逻辑身份非空稳定字符串
model向量来源演示中为手工向量
embedding_revision编码契约版本payload 必须完全一致
dimension向量维度1~100000 的整数
metric排序规则cosine、dot 或 euclidean
normalized是否要求单位向量为真时校验 L2 norm
dtype声明的存储类型float32 或 float64

脚本加载已有文件时会比较完整 contract。模型更新即使维度没变,也应使用新的 embedding_revision 或新空间;“维度相同”不足以证明语义坐标可比。

这只约束持久化 points。当前 search() 仍接收裸向量,只校验 dimension、finite、norm 与 store metric,没有携带或验证 query 的 contract signature/编码角色;来自另一模型但同维、同归一化形式的 query 仍可能被误用,不能称为端到端空间绑定。

Point 与 payload

每个 point 包含:

  • 稳定 id 与有限、非零、维度正确的 vector;
  • tenant_id、document_id、source_revision;
  • embedding_revision 和规范化正文的 content_sha256;
  • 去重排序的非空 ACL 群组;
  • draft、published 或 archived 状态。

source_revision 在本项目中是 opaque identity,只做相等性判断,绝不按字符串大小猜先后。非同值更新必须传入与当前 point 完全匹配的 expected_source_revision;相同 source revision 却出现不同 vector、payload 或 hash 时抛出 WriteConflictError。谁是合法 successor 由上游 canonical source/工作流决定,并且同一 point 跨完整生命周期不得复用旧 identity。

玩具库没有完整 generation/history ledger,无法自行证明 identity 从未使用过。若上游允许 r1 → delete → r2 → delete → r1,旧 delete(r1, d1) 可能再次匹配,这就是 ABA;本项目不能声称阻止所有历史旧事件。

查询端的 tenant 和 subject groups 应来自已认证身份。教学 API 直接接收它们,是为了展示过滤机制,不代表允许终端用户自由伪造。

持久化状态

顶层记录 schema_version、单调递增的 store_revision、contract、points 和 tombstones。Schema v2 tombstone 严格保存:

  • point_id、tenant_id;
  • deleted_source_revision;
  • 当前删除态用于重放比较的 delete_event_id;
  • deleted_at_store_revision。

解析器拒绝额外/缺失字段、重复 JSON key、NaN/Infinity、错误类型、重复 point ID,以及同一 ID 同时出现在 points 与 tombstones 的状态。Schema v1 tombstone 没有 source/delete fence,无法证明安全迁移结果,因此脚本明确拒绝自动升级,要求从 canonical source 重建 schema v2。

Tombstone 表示当前 deletion intent,不是 append-only 审计日志:resurrection 成功后会从当前状态移除它。生产系统必须在外部 lifecycle/audit ledger 永久保存 create/update/delete/resurrection 事件,并用不可复用 generation 或历史 fence 防 ABA。

单次提交先校验 points + tombstones <= 100000,再按真正的 UTF-8 bytes 校验序列化结果不超过 20 MiB;超限会在创建/替换临时文件和更新内存前失败。随后写到同目录临时文件,flush + fsync 后用 os.replace 替换,并清理临时文件。该过程降低“写到一半”的风险,但没有目录 fsync、WAL、跨记录事务、进程锁或真正的并发控制。

运行演示

使用唯一临时路径,并无论成功失败都清理:

$env:PYTHONDONTWRITEBYTECODE = '1'  # 禁止 Python 把 bytecode 缓存写进 vault。
$script = Join-Path (Get-Location) 'Knowledge\AI Agent Engineer\docs\向量数据库\examples\toy_vector_store.py'  # 从 vault 根目录拼出教学脚本的绝对路径。
$db = Join-Path $env:TEMP ("toy-vector-store-" + [guid]::NewGuid().ToString("N") + ".json")  # 为本次演示创建不会与现有文件冲突的临时数据库路径。
 
try {  # 无论 demo 成功或抛错,finally 都会执行清理。
    python -B -W error $script --db $db demo  # 运行严格 JSON 存储的完整教学生命周期演示。
    if ($LASTEXITCODE -ne 0) {  # 检查 Python 子进程退出码,不能把失败结果当成有效观察。
        throw "demo 失败,退出码:$LASTEXITCODE"  # 立即携带退出码终止,让失败可定位。
    }
}
finally {  # finally 会在异常路径也运行,避免临时文件遗留。
    Remove-Item -LiteralPath $db -Force -ErrorAction SilentlyContinue  # 只删除本代码刚生成的精确临时路径。
}

一次全新运行应满足下表,不要求复制动态生成的 contract hash:

观察项预期
a-1、a-2-initial、b-1created
a-2-updateupdated,point 数不增加
a-2-repeatunchanged,revision 不增加
alpha_before_delete按分数返回 a-1、a-2
beta 租户的 b-1从未出现在 alpha 结果中
删除 a-1 后只返回 a-2
最终 summaryrevision 5、2 个 point、1 个 tombstone

为什么仍有 2 个 point?因为 beta 的 b-1 还在库中,只是 alpha 无权看见。搜索结果为空或变少不能证明底层记录已删除。

从代码追踪一次生命周期

  1. 第一次 upsert a-2:ID 不存在,写入 point,结果为 created。
  2. 同一 ID 使用 source_revision=r2 和新向量,并显式提交 expected_source_revision=r1:CAS 匹配才替换旧 point,结果为 updated,总数仍不变。
  3. 完全相同的 r2 再投递:即使仍携带第一次请求的 expected_source_revision=r1,也先识别为相同重试,结果为 unchanged,不写磁盘也不增加 revision。这让“服务端成功、响应丢失”的重试可观察。
  4. 查询 alpha:先检查 tenant、status=published 和 ACL,再计算 cosine;beta 的更相似记录也不能越权进入候选。
  5. 删除 a-1:请求必须给出当前 expected_source_revision=r1 与唯一 delete_event_id;point 移除并生成绑定 alpha/source/event 的 tombstone。若 point 尚未 create/索引,首次 delete 仍写入相同 tombstone intent 并返回 true,从而阻止迟到的 r1 upsert。当前 tombstone 上只有 tenant、source、event 三者全同的重放才返回 false 且不增加 revision,任一部分冲突都失败。
  6. Tombstone 默认拒绝 upsert。测试中的 alpha 只有提交同时匹配 deleted_source_revision 与 delete_event_id 的 ResurrectionToken,且候选使用不同的新 source revision,才可显式 resurrect;beta 永远不能接管 ID。

ResurrectionToken 不是授权凭据

它只证明调用方基于哪个 tombstone fence 进行并发/重放控制,不证明调用者有权恢复数据。Delete-before-create 还能为未知 ID 建立 tombstone,若直接暴露给未认证调用方会被用于恶意 ID squatting。生产系统必须从可信身份和 canonical mapping 派生 tenant/source,并执行权限策略、审批与审计。

Exact search 的意义

本例逐条计算 cosine、dot 或负欧氏距离,按分数降序、point ID 稳定打破平局。这是 O(N) 的 exact search,不适合大规模低延迟服务,但很适合:

  • 验证距离方向、归一化和排序;
  • 生成 ANN top-k ground truth;
  • 验证过滤是在评分前生效;
  • 在小 fixture 上回归更新与删除行为。

业务 relevance 仍需人工或行为 gold;exact top-k 只证明“没有被 ANN 漏掉”,不证明候选回答了问题。

运行测试

测试在普通解释器和 python -O 下都必须通过,避免正确性依赖会被优化模式移除的 assert:

$env:PYTHONDONTWRITEBYTECODE = '1'  # 防止测试把解释器缓存写入课程目录。
$project = Join-Path (Get-Location) 'Knowledge\AI Agent Engineer\docs\向量数据库'  # 保存项目目录,使 unittest 的相对路径稳定。
 
Push-Location $project  # 临时切换到项目目录再执行相对 examples 路径。
try {  # 通过 finally 保证工作目录被恢复。
    python -B -W error -m unittest discover -s .\examples -p 'test_toy_vector_store.py' -v  # 正常模式下运行所有安全、生命周期与数学测试。
    if ($LASTEXITCODE -ne 0) {  # 外部 Python 的非零退出码表示至少一个用例失败。
        throw "普通模式测试失败,退出码:$LASTEXITCODE"  # 立即停止并保留能诊断的退出码。
    }
 
    python -B -O -W error -m unittest discover -s .\examples -p 'test_toy_vector_store.py' -v  # 在 -O + 严格 warning 模式复跑。
    if ($LASTEXITCODE -ne 0) {  # 该模式能发现依赖会被优化移除的裸 assert。
        throw "优化模式测试失败,退出码:$LASTEXITCODE"  # 报告精确失败模式而非继续执行。
    }
}
finally {  # 即使测试失败也必须恢复调用者的工作目录。
    Pop-Location  # 返回执行命令前的 PowerShell 位置。
}

测试覆盖:strict JSON、schema v1 拒绝自动迁移、contract、三种距离、维度与非有限值、单位向量、完全相同 upsert 重放、expected-current CAS、opaque revision、同 revision 异 vector/payload/hash 冲突、delete-before-create、迟到 upsert、stale delete、delete event 幂等条件、跨租户 ID 防御、tombstone/resurrection fence 重启持久化、总 record 与 UTF-8 byte 写入上限、ACL/status/filter、空权限 fail-closed、旧 store writer/no-op/search/summary 冲突、原子 LF JSON、CLI 输出与 -O 等价。

并发边界:为什么还不是事务

每个 ToyVectorStore 实例加载时记住 store_revision。提交、完全相同 upsert no-op、delete replay no-op、search 和 snapshot summary 都会先重读磁盘 revision;若另一个实例已提交,就抛出 WriteConflictError,而不是声称成功或返回旧 ACL/删除状态。调用方需要重新打开/reload 后再决定是否重试。

这只检测“检查开始前已经提交”的变化。“检查 revision”到实际评分、summary 构造或 os.replace 之间仍有 time-of-check/time-of-use 竞争,两个实例仍可能同时通过检查。因此本项目只承诺单进程教学和 fail-closed 演示,不承诺 snapshot isolation。生产实现需要数据库事务/比较并交换、可靠锁、WAL、崩溃恢复、明确读写确认和并发压力测试,不能靠本脚本外围加一个重试循环冒充完成。

与生产向量数据库的差距

本项目明确没有:

  1. ANN 索引、压缩、内存映射与容量控制;
  2. 多进程/多节点事务、WAL 与崩溃恢复;
  3. 身份认证、授权策略、传输/静态加密与审计日志;
  4. shard、replica、故障切换、扩缩容和读写一致性保证;
  5. 增量备份、时间点恢复、保留期、隔离恢复演练;
  6. schema migration、滚动升级和多版本客户端兼容;
  7. 生产级 payload index、混合检索、限流和资源配额;
  8. 指标、trace、告警、SLO 与事故响应;
  9. 不可复用 generation、永久 lifecycle/audit ledger、tombstone retention 与 purge policy;
  10. 带 contract signature/encoding role 的 query vector envelope 与服务端空间校验。

此外,20 MiB 指完整序列化状态的 UTF-8 bytes,100000 指 live points 与 tombstones 的总数;二者只是教学防护上限,不是容量承诺。单文件每次全量重写并在每次公开读取前重新解析,会随数据增长迅速变慢。

扩展任务

按顺序完成,每项先补测试再改实现:

任务 A:安全过滤

为日期范围或数值范围设计显式 schema 和 allow-list。拒绝未知字段、错误类型和空权限;增加“未授权 point 分数最高也不得出现”的回归测试。

任务 B:批量 CAS 与冲突队列

为每个 batch item 携带独立 expected_source_revision、event ID 和 input hash,返回逐项 created/updated/unchanged/conflict 结果。模拟部分成功与客户端超时,证明只重试未知项,并把同 revision 异内容送入冲突队列。说明为什么应用层 CAS 仍不能解决本 JSON 文件的跨进程 TOCTOU。

任务 C:导出、恢复与删除传播

设计带 checksum 的导出清单,在新临时目录恢复,核对 contract signature、point/tombstone 集合、ACL 和 gold queries。演示若漏掉 tombstone,已删除 point 如何被旧快照复活。

任务 D:Exact 与近似候选比较

故意只搜索一个候选子集,与 exact top-k 计算 ANN Recall@k;至少覆盖不同 top-k 和 filter 选择率。不要把这个简化算法称为可用 ANN 索引。

任务 E:迁移设计

复制一份 fixture 到新 space_id,改变 embedding revision,做双读比较和显式切换;证明旧空间与新空间向量不能直接混排。

任务 F:Generation 与永久生命周期账本

给每个 point 增加不可复用 generation/row version,把 create、update、delete 和 resurrection 写入 append-only ledger。构造 r1 → delete → r2 → delete → r1 ABA,证明旧 delete event 即使 source identity 再次相等也会因 generation/history fence 被拒绝;再设计 ledger retention、审计查询与 tombstone purge 的关系。

任务 G:Query 空间证明

定义 QueryVector(vector, contract_signature, encoding_role),让 search() 在距离计算前验证 signature 与当前 store contract 一致,并拒绝 document-role 冒充 query-role。增加“同维、单位范数但来自另一空间”的反例,证明 dimension/norm 检查不能代替空间身份。

项目验收清单

  • 我能解释每个 contract 字段,以及维度相同为何仍可能不兼容。
  • 完全相同的 upsert 是 no-op;非同值更新显式匹配 expected-current,同 revision 异内容冲突。
  • Tenant、ACL、status 和 allow-list filter 在计算相似度前生效。
  • 空权限 fail-closed,跨租户记录和 tombstone ID 不可接管。
  • Delete 早于 create 也落 source/event fence;当前 tombstone 默认阻止 upsert,显式 resurrection 同时匹配旧 source/event fence。
  • 我能解释 ResurrectionToken 只用于并发/重放控制,不替代授权。
  • 我能解释 source identity 不可复用、当前 tombstone、永久 audit ledger 与 ABA 防护的不同职责。
  • Stale store 在 no-op/search/summary 前 fail-closed;20 MiB 按 UTF-8 bytes、100000 按 point+tombstone 总数执行。
  • 我知道当前裸 query vector 没有 contract signature,尚未实现端到端空间绑定。
  • 普通模式与 -O 模式测试都通过且输出等价。
  • 临时文件、数据库、缓存和虚拟环境没有留在 vault。
  • 我能列出至少 8 项生产差距,不把项目包装为数据库产品。

自测

  1. 为什么 a-2-repeat 不增加 revision,比“重复写也成功”更容易审计?
  2. 为什么过滤 top-k 结果不能替代评分前的 ACL 约束?
  3. 为什么 normalized contract 必须拒绝非单位向量,而不能默默接受?
  4. 为什么 os.replace 不等于数据库事务或可靠灾备?
  5. 为什么 tombstone 要绑定 tenant、被删 source 与 delete event,且不能让另一 tenant 复用 ID?
  6. 为什么匹配 ResurrectionToken 不等于获得了恢复数据的授权?
  7. 为什么当前 tombstone 不能代替永久审计日志?identity 复用会怎样触发 ABA?
  8. 为什么读前 revision 检查仍不是 snapshot isolation?
  9. Exact top-k 与人工 relevance gold 分别回答什么问题?

答题后返回 [[Knowledge/AI Agent Engineer/docs-CN/向量数据库/00-目录|向量数据库目录]],并进入 [[Knowledge/AI Agent Engineer/docs-CN/语义搜索/00-目录|语义搜索]]。

参考资料

来源获取日期:2026-07-22。教学实现已在 Python 3.11 普通模式与 -O 的 -W error 模式执行验证;产品能力与默认值须按锁定版本复查。