从 44 万个"幽灵"说起:基于Rust开发的OntoDB 把关系、图、向量、本体等多模态数据融进一个内核,与一次 HNSW 召回坍塌的完整排查
内容提要
复盘OntoDB(单LSM引擎支持12种数据模型)中227万化合物指纹HNSW索引召回异常:Recall@50低于Recall@10。排查发现三类根因:多样性剪枝使用严格小于号,在离散Tanimoto距离下大量并列被误剪;删除集合与向量状态未持久化,产生44万“幽灵”节点;导入分页断流、抽样偏差、列清单缺fp、版本选择bug叠加导致指纹丢失。修复后id-recall@10由0.844升至0.9784,节点数与SQL精确对齐。
延伸解读
离散距离下的剪枝边界:并列不是例外
文章指出,HNSW 多样性剪枝中的严格小于号在连续欧氏空间无碍,但面对 1024 位 Morgan 指纹的离散 Tanimoto 距离时,大量并列距离被误判为冗余而剪除。更隐蔽的是,建图初期邻居列表未满,剪枝不触发,早期 146 万向量质量良好;图饱和后增量插入反复触发剪枝,后期向量入边被切断,逐渐沦为孤点。这解释了 Recall@50 低于 Recall@10 的倒挂现象——候选集不完整,而非排序问题。
内存状态未持久化:44 万幽灵节点的来源
修复剪枝后召回仅回到 0.85,进一步排查发现 HNSW 图内节点数 2,083,259 与 SQL 侧有合法指纹的行数 1,638,397 相差 444,862。根因是 deleted_keys 和 doc_vectors 两个纯内存状态未随索引快照持久化,重启后清零,导致删除过滤失效、历史被删节点重新漏入搜索结果,且增量对账因“图里已有节点”而跳过核对。修复采用 restore 后全量对账并原地 prune,摘除幽灵节点耗时 23.1 秒,快照体积减少 27.6%。
四段失败链叠加:单一 bug 不会造成事故
指纹丢失的源头是四段失败叠加:导入用不完整 id 索引做 keyset 分页导致分区断流,漏写约 65~78 万行指纹;覆盖率验证仅抽查 7 个 id 和 CHEMBL1-9 前缀,全落在未漏写区域,形成假收口;chem 表批量导入列清单不含 fp 列,新版本覆盖旧行;pass-2 版本选择 bug 因层序反转和 seq 清零,将旧版本指纹索引进图,而活版本无 fp。任一段单独都不会出事故,叠加后精确产生 44.4 万差额。
版本权威来自层序而非 seq:一次设计取舍的验证
文章强调,索引的版本权威应来自 LSM-Tree 的物理层序(newest-first + first-seen-wins),而非 seq_no。层序是存储引擎维护、不可篡改的事实;seq 是元数据,可被重整、清零、碰撞。D 段病灶正是“用 seq 代替层序”导致读旧版本。这一取舍在事故中起到关键作用,修复方向也是 pass-2 改走层序优先。此外,凡输出顺序承载语义的路径,去重必须用保序实现,否则会打乱索引序,引发分页重复和召回下降。
Q&A
OntoDB 是怎么把关系、图、向量、本体等多种数据模型整合到一个内核里的?
OntoDB 基于一套 LSM-Tree 存储引擎,通过键前缀路由区分 12 种数据类型,不同前缀走不同的编解码、索引结构和查询算子,但共用同一份 WAL、同一套 compaction 和同一块 block cache。关系、图、向量、本体等模型共享同一套事务边界,不是多个库拼装,而是一份数据上开多种视图。
HNSW 索引出现 Recall@50 低于 Recall@10 的倒挂现象,一般说明什么问题?
在健康的 HNSW 上,固定 efSearch 时 Recall@k 应随 k 增大走平或逼近 1。倒挂意味着候选集本身不完整,排在前面的找得到,第 10 到第 50 名够不着,问题不在排序,而在图结构或可达性。
OntoDB 排查 HNSW 召回坍塌时,最终定位到哪几个根因?
定位到三类叠加根因:一是多样性剪枝使用严格小于号,在离散 Tanimoto 距离下大量并列被误剪;二是 deleted_keys 和 doc_vectors 两个内存状态未持久化,重启后产生 44 万“幽灵”节点;三是导入分页断流、抽样偏差、列清单缺 fp、版本选择 bug 四段失败链叠加导致指纹丢失。
HNSW 多样性剪枝里一行严格小于号为什么会导致召回下降?
在连续欧氏空间里两个距离完全相等的概率为 0,严格小于号不会出问题。但 Morgan 指纹是 1024 位二进制,Tanimoto 是离散值,并列是常态。严格小于号会把大量数值上并不冗余的新节点误判为冗余而剔除,且建图初期剪枝不触发,图饱和后增量插入反复切断入边,导致后期写入的向量沦为孤点。
44 万个“幽灵”节点是怎么产生的,后来怎么修复的?
deleted_keys 和 doc_vectors 两个纯内存状态没有进快照,重启后清零,导致删除过滤失效、历史被删节点重新漏进搜索结果,而增量对账又因图里已有节点直接跳过。修复方式是 restore 后全量对账,把图内节点与文档侧完整比对,标记 444,862 个幽灵节点并执行原地 prune,耗时 23.1 秒,快照从 959.8MB 瘦到 694.8MB。
修复后 HNSW 索引的召回指标恢复到什么水平?
修复后 id-recall@10 从 0.844 升至 0.9784,dist-recall@10 为 0.9964,dist-recall@50 为 0.9916,id-recall@50 为 0.9832,倒挂幅度从 0.044 收敛到 0.004,幽灵命中从 18/50 降到 0/88,图节点数与 SQL 精确对齐到 1,638,397。
这次排查暴露出的五个查询正确性 bug 有什么共同点?
五个 bug 包括 equality 等值点查返回双行、ghost 泄漏、pass-2 版本选反、NOT 谓词被当成列名、planner 的 Filter/Limit 形状错误。它们底层是同一个病:“版本合流必须走层序、谓词归属必须覆盖所有算子形态”这条纪律之前只在部分代码路径上落地。修复方式不是逐个打补丁,而是收敛到一个公共迭代器,实现整族免疫。
OntoDB 在排查后增加了哪些自动化监控手段来避免同类问题?
增加了 schema API 暴露向量索引状态(nodes、deleted_keys、ready 等),reconcile 日志分级告警(冷启动后非零即 WARN,占比超 1% 建议 COMPACT,超 10 万 ERROR),拆分独立探针脚本(probe_vec_nodes.py 等),启动门闩在对账完成前返回明确错误,以及 k8s 探针拆分 liveness 和 readiness。