索引与增量更新
本节目标
把 metadata、关键词、向量和图索引当成版本化、可重放、可对账的派生投影;用状态 hash、outbox、幂等消费者和发布指针安全处理新增、更新、删除、重处理和重建。
Canonical store 与派生投影
canonical store 保存可解释 revision、来源、权限与处理状态;检索投影为查询优化:
- metadata store:ID、版本、tenant、ACL、状态、时间;
- lexical/full-text:精确词、代码、专名和短语;
- vector:语义近邻;
- optional graph:显式实体与关系;
- cache:高频 query/candidate/answer 的短期副本。
任何投影损坏都应能从 canonical revision + 版本化 pipeline/config 重建。不要反向从向量猜原文,也不要让只存在于某个索引的 ACL 成为唯一权限事实。
依赖图决定失效范围
source revision
└─ parsed elements (parser/config)
└─ chunks (chunker/config)
├─ lexical projection (tokenizer/index schema)
└─ embeddings (model/preprocessing)
└─ vector projection (metric/index params)判定示例:
| 变化 | 需要重算 |
|---|---|
| content hash 不变、ACL 不变 | no-op 或只推进来源 checkpoint |
| 源内容/结构变化 | 该文档的解析及全部下游 |
| chunker version 变化 | chunks、embeddings、lexical/vector 下游 |
| embedding model 变化 | embeddings 与 vector projection |
| tokenizer/index schema 变化 | lexical projection |
| ACL/policy 变化 | 所有可执行权限投影与缓存;必要时立即阻断 |
| 删除 | 所有在线投影、缓存和后续保留流程 |
每层保存父 ID、算法/模型/config version 和 hash,才能只重算真正受影响部分。
从 canonical 事务到 outbox
在一个事务内写 canonical revision/current 状态与 outbox event,确保二者同生共死。事件至少包含唯一 event ID、tenant/document key、对象内 event version、kind 和 revision pointer。消费者:
- 按对象内顺序处理或识别旧事件;
- 先核对 event 的 tenant/document、revision 指针和 current 状态;被更新或删除取代的 event 可标记为已消费,但不得再物化搜索副本;
- 用 event/revision ID 和唯一约束幂等写投影;
- 投影完整后再原子切换 published pointer;
- 成功后标记事件完成;失败记录安全错误并重试/隔离;
- 周期性对账 pending event 与 canonical/published 状态,并从 canonical/投影实际正文重新计算 hash;不能只比较两个可能同时陈旧或被伪造的 hash 字段。
“指针最终不指向旧 revision”不足以证明删除传播正确:若 worker 先把过期 event 写进搜索表,它仍可能在物理存储、备份或错误的内部查询中残留。教学项目因此只为仍为 current_revision_id 的 upsert 建立搜索投影,并对 outbox revision 的跨 tenant/document 身份不一致 fail closed;这仍不替代真实消息总线的顺序与并发证明。
Debezium 的 Outbox Event Router 文档把唯一 event ID 用于消费端去重,并用 aggregate key 作为消息键以保持 Kafka 分区内顺序。具体消息系统仍可能重复投递;业务消费者不能省略幂等。
SQLite UPSERT 只在唯一约束冲突时执行 DO UPDATE/NOTHING,不是自动的业务幂等。你仍要选对 conflict target,并验证 state/event version;外键在 SQLite 连接上也需显式启用。
发布、回滚与并发窗口
小范围更新可为每个文档维护 published revision;全索引迁移可构建新 generation/alias,完成计数、ACL、删除和查询抽样后切换读别名。旧 generation 保留短期回滚窗口,再按保留策略删除。
不要把“worker 返回成功”当发布条件。至少验证:
- active canonical 对象是否都有合格 published projection;
- published pointer 是否指向真实投影与正确 tenant;
- chunk/embedding 数量是否在预期范围;
- ACL 缺失/空授权、墓碑未传播、旧版本候选是否为零;
- 代表性 query、引用和越权测试是否通过;
- 新旧 generation 差异是否可解释。
若多个 worker 并发,要定义对象分区、锁/乐观版本、重试和 transaction isolation。SQLite 同时只允许一个 writer;本库单连接演示不能证明分布式并发正确。
删除与重建
删除事件先让查询 fail closed,再异步清除各投影。重建任务必须读取当前 active canonical 集合与 tombstone/保留状态,不能把历史 revision 全部重新发布。
全量重建期间的增量事件可采用:固定 snapshot + 记录 watermark 后追赶、双写新旧 generation,或由变更日志从 snapshot 位点回放。无论哪种,都要证明切换时没有漏更新或复活删除对象。
SQLite FTS5 的教学边界
FTS5 是 SQLite 的全文搜索虚拟表,支持 tokenizer、BM25 等功能,也有 external content/contentless 模式。官方文档专门列出 external content 的一致性陷阱:应用必须维护内容表与 FTS 索引同步。
本项目没有启用 FTS5,只用 instr() 模拟一个可见性投影,目的是让版本/ACL/事务容易验证。它不代表性能、中文 tokenization、排序质量或生产选型。后续在 语义搜索 与 向量数据库 再评测检索实现。
常见错误与排查
- 更新只新增 chunks:旧版本仍可搜索,产生重复或错误引用。
- 只靠任务成功率对账:成功任务可能写错 tenant/ACL 或漏对象。
- 索引 schema 无版本:滚动升级 reader/writer 不兼容。
- 提交消息 offset 早于下游成功:失败记录可能永久丢失。
- 宣称 exactly once:忽略 producer、broker、consumer 和业务写入的不同边界。
- 重建读取全部历史 revision:会复活删除或过期内容。
- 把 SQLite 演示外推到分布式并发:缺少真实锁、隔离和故障测试。
练习
- 为 source/parser/chunker/model/ACL 六类变化写局部失效矩阵。
- 画出“canonical + outbox 同事务,投影 + published pointer 同事务”的两个提交边界。
- 模拟 worker 在写完一半 chunks 后失败,设计幂等重试和对账。
- 为全索引重建选择 snapshot/watermark 方案,说明如何避免漏增量和复活墓碑。
自测
- 我能区分 canonical 事实与 metadata/lexical/vector/cache 投影。
- 我能按依赖版本决定最小重算范围。
- Outbox consumer 有唯一事件、对象顺序、幂等和失败重放。
- 发布前后有数量、ACL、删除和 query 对账。
- 我知道 SQLite/FTS5 示例不能证明生产规模与中文检索质量。
参考资料与下一步
- Debezium Outbox Event Router
- SQLite UPSERT
- SQLite Transactions
- SQLite Foreign Key Support
- SQLite FTS5
来源获取日期:2026-07-22。下一步:评测、运营与增量项目。