从 44 万个"幽灵"说起:基于Rust开发的OntoDB 把关系、图、向量、本体等多模态数据融进一个内核,与一次 HNSW 召回坍塌的完整排查

💡 原文中文,约9100字,阅读约需22分钟。
📝

内容提要

复盘OntoDB(Rust多模数据库)227万化合物指纹HNSW索引召回异常:Recall@50低于@10。排查发现三类根因:多样性剪枝使用严格小于号,离散Tanimoto距离大量并列导致新节点被误剪;删除集合等内存状态未持久化,产生44万“幽灵”节点;导入分页断流、抽样偏差、列清单缺fp、版本选择bug叠加导致指纹丢失。修复后id-recall@10由0.844升至0.9784,节点数与SQL精确对齐。

🔎

延伸解读

离散距离空间暴露了HNSW剪枝的边界缺陷

文章指出,HNSW多样性剪枝中的严格小于号在连续欧氏空间几乎不会触发问题,但Morgan指纹的Tanimoto距离是离散值,并列是常态。这导致大量数值上并不冗余的新节点被误剪,且建图初期不触发、图饱和后每次增量插入都触发,使后期写入的向量逐渐沦为孤点。这提醒读者:在二进制指纹、量化嵌入等离散距离场景中,剪枝条件的边界语义需要专门审视,不能直接沿用连续空间的默认实现。

内存状态未持久化是“幽灵节点”的直接来源

deleted_keys和doc_vectors两个纯内存集合没有进入快照,重启后清零,删除过滤失效,历史被删节点重新漏进搜索结果;增量对账又因“图里已有节点”跳过核对,最终积累44.4万个图内存在但SQL侧无指纹的幽灵节点。文章用全量对账把图节点与文档侧逐项比对,再以原地prune摘除,耗时23.1秒,快照缩小27.6%。这说明索引的删除与有效向量状态必须与图结构一同持久化,否则重启即产生不一致。

四段失败链说明数据导入的“假收口”风险

指纹丢失并非单一bug,而是四段叠加:keyset分页用了不完整id索引导致分区断流漏写约65~78万行;覆盖率脚本只抽查7个id和CHEMBL1-9前缀,全落在未漏写区域;chem批量导入列清单缺fp列,新版本覆盖旧行;pass-2版本选择bug在层序反转时读到导入前旧版本,把旧指纹索引进图。任一段单独都不会出事故,但叠加后精确产生44.4万差额。文章据此把验证从7点抽查升级为全量COUNT加分区分层抽样,值得数据导入流程借鉴。

五个查询正确性bug收敛到同一纪律

文章复盘共修掉5个查询正确性bug:equality等值点查返回双行、ghost泄漏、pass-2版本选反、NOT谓词被当成列名、planner的Filter/Limit形状错误。作者认为底层是同一类病——“版本合流必须走层序、谓词归属必须覆盖所有算子形态”这条纪律此前只在部分代码路径落地。修复方式不是逐个打补丁,而是收敛到公共迭代器。文章还提醒,凡输出顺序承载语义的路径,去重必须用保序实现,否则会打乱索引序并引发分页重复扫描。

❓

Q&A

OntoDB 是什么?它和常见的多模数据库方案有什么不同?

OntoDB 是一个用 Rust 开发的多模数据库,把关系、图、向量、本体等 12 种数据模型做进同一个 LSM 存储引擎、一套事务体系和一种查询语言(OntoQL)里。与主流“多套存储 + 应用层编排”的方案不同,它不靠 ETL 和中间件拼接,而是让同一份数据上开出多种视图,共享 WAL、compaction 和事务边界,从而避免跨系统的不一致窗口。

HNSW 召回率出现 Recall@50 低于 Recall@10 的倒挂,可能是什么原因?

文章排查发现,倒挂意味着候选集本身不完整,而非排序问题。根因有三类叠加:一是多样性剪枝用了严格小于号,在离散 Tanimoto 距离下大量并列导致新节点被误剪;二是 deleted_keys 和 doc_vectors 两个内存状态未持久化,重启后产生 44 万“幽灵”节点;三是导入分页断流、抽样偏差、列清单缺 fp、版本选择 bug 四段失败链导致指纹丢失。

为什么一行严格小于号会导致 HNSW 剪枝出错?

在连续欧氏空间里两个距离完全相等的概率为 0,严格小于号不会出问题;但 Morgan 指纹是 1024 位二进制,Tanimoto 距离是离散值,并列是常态。剪枝启发式中用 dist(r, c) < dist(q, c) 判断冗余,会把大量数值上并不冗余的新节点系统性判为冗余剔除。建图初期邻居列表未满不触发剪枝,图饱和后每次增量插入都触发,导致后期写入的向量入边被反复切断,逐渐沦为孤点。

44 万个“幽灵”节点是怎么产生的?如何修复?

幽灵节点源于 deleted_keys(逻辑删除集合)和 doc_vectors(有效向量集合)两个纯内存状态没有进快照,重启后清零,导致删除过滤失效、历史被删节点重新漏进搜索结果,而增量对账又因“图里已有节点”跳过核对。修复方式是 restore 后全量对账:图有节点且文档有 fp 则重建 doc_vectors,图有节点但文档无 fp 则 mark_deleted,图无节点但文档有 fp 则补录;随后执行原地 prune 摘除 444,862 个节点,耗时 23.1 秒,快照从 959.8MB 瘦到 694.8MB。

修复后召回率提升了多少?验收指标有哪些?

修复后 id-recall@10 从 0.844 升至 0.9784,dist-recall@10 从 0.848 升至 0.9964,dist-recall@50 为 0.9916,id-recall@50 为 0.9832,倒挂幅度从 0.044 收敛到 0.004。幽灵命中从 18/50 降为 0/88,快照体积从 960MB 降至 695MB(−27.6%),over_fetch 从 8× 回落到 2×,图节点数从 2,083,259 精确对齐到 1,638,397。所有闸门(id≥0.968 / dist@10≥0.990 / dist@50≥0.985 / self≥0.995 / ghost=0 / 节点数精确)全部通过。

这次排查暴露了哪些设计教训和通用规则?

文章总结了几条教训:版本权威应来自 LSM-Tree 的物理层序(newest-first + first-seen-wins)而非 seq_no,因为层序不可篡改而 seq 可被重整清零;凡输出顺序承载语义的路径(range 扫描、keyset 分页、层序扫描),去重必须用保序实现,不能用 sort()+dedup() 打乱索引序;5 个查询正确性 bug 底层是同一类病,应收敛到公共迭代器实现整族免疫,而非逐个打补丁。此外,监控未能发现任何 bug,需通过 schema API 暴露索引状态、reconcile 日志分级告警、独立探针和启动门闩来让同类问题自动可见。

🏷️

标签

➡️

继续阅读