< 返回我的博客

ontodb 发表于 2026-10-21 11:17

一、先把结论放前面

一个 227 万条化合物指纹的 HNSW 索引,召回率不对。不是低,是低得反常:

Recall@10 ≈ 0.88
Recall@50 ≈ 0.85 ← k 越大,找回来的反而越少

在健康的 HNSW 上,固定 efSearch 时 Recall@k 应随 k 增大走平甚至逼近 1。倒挂意味着"排在前面的找得到,第 10 到第 50 名够不着"——候选集本身不完整,不是排序问题。

最终查出来的是一串叠加的缺陷:一行严格小于号、两个没进快照的内存状态、一条四段式的数据失败链。修复后 id-recall@10 从 0.844 回到 0.9784,dist-recall@10 0.9964,倒挂从 0.044 收敛到 0.004,图节点数与 SQL 侧精确对齐到 1,638,397。

这篇文章的主体就是这次排查。开始之前,先用一小节交代清楚"我们在什么东西上踩到了这个坑"。


二、OntoDB 是什么:一个内核装进 12 种数据模型

2.1 拼出来的多模,问题不在性能,在一致性

主流的多模方案本质是"多套存储 + 应用层编排":关系在 Postgres、图在 Neo4j、向量在 Milvus、时序在 InfluxDB、本体推理在 Jena,靠 ETL 和中间件对齐。

它的痛点很少出现在单点性能上,而出现在缝上:

  • 跨系统的写入无法组成一个事务,向量索引与关系数据之间必然存在一个"不一致窗口",而窗口里的查询结果是错的、且看不出来;
  • 同一个业务实体被拆到四五个系统,各存一份,靠 id 映射表勉强对齐;
  • 一次查询要串联多家系统,延迟是各家之和,可解释性在拼接处断掉;
  • 数据冷热度、安全策略、审计规则要在每一套系统上各配一遍。

我们的选择是不拼:把这 12 种数据模型做进一个 LSM 存储引擎、一套事务体系、和一个查询语言里。下面就是它实际的样子。

2.2 一个 LSM 引擎,12 种数据类型

核心的存储是一套 LSM-Tree,通过键前缀路由区分数据形态:不同前缀走不同的编解码、索引结构和查询算子,但共用同一份 WAL、同一套 compaction、同一块 block cache。前缀划分覆盖 12 种数据类型:

类别 数据类型 关键结构
基础 键值、文档(JSON/BSON)、全文(倒排+BM25+中英文分词)、多媒体元数据(SHA256 内容哈希) LSM 键值、倒排索引、内容哈希索引
高级 关系(结构化记录+事务)、图(属性图+遍历)、向量(HNSW 语义相似)、三元组(SPO 知识图谱) 见下文详解
时序空间 时序(TSM 列式 Delta/Gorilla 编码)、空间(R-Tree+GeoHash)、时空联合索引 TSM 列式块、四叉树
实时 流数据(CDC 变更捕获+连续查询)、事件(复杂事件处理 CEP) WAL 直出 CDC、事件窗口匹配

其中四种模型是这次踩坑的主角,也是 AI 场景里最常同时出现的四件套——它们的前缀命名空间和核心数据结构如下:

模型 存储形态 核心数据结构 键前缀
关系 结构化记录 LSM-Tree;二进制行零拷贝谓词过滤(在二进制数据上直接做过滤,无需反序列化,解析时间减少约 60%) 普通表键
图 属性图(顶点 + 边 + 属性) index-free adjacency,Vec<Vec<(u32, String)>> 内存邻接 __graph_v__ / __graph_e__
向量 HNSW 索引 + 向量载荷 内存 HNSW 图 + 快照持久化 + 增量补录 向量索引命名空间
本体 SPO 三元组 内核内 OWL 2 RL 推理引擎,含 inferred_from 溯源 __triple__

这四者共享同一套 WAL、同一套 compaction、同一套事务边界——不是"一个进程里装了四个库",而是"一份数据上开了四种视图"。

图引擎值得一提:字符串 ID 只映射一次,之后全链路走 u32 整数索引,取邻居就是数组下标访问,不做哈希、不比字符串:

id_to_idx: RwLock<HashMap<String, u32>>, // 只在这里做一次字符串映射
idx_to_id: RwLock<Vec<String>>,
adj_out: RwLock<Vec<Vec<(u32, String)>>>, // 出邻居
adj_in: RwLock<Vec<Vec<(u32, String)>>>, // 入邻居

k-hop 遍历的复杂度只与访问到的节点数相关,与图规模无关。遍历本身是 FIFO 队列 + 位图 visited,循环体内全是 u32 运算。企业版进一步换成 CSR 压缩存储(内存约省 50%),并用 Rayon 做并行邻居扩展。

本体层则是把 OWL 2 RL 推理引擎嵌进了存储内核:子类传播、等价类、传递性、对称性、逆属性、子属性等规则在写入时即触发(增量不动点约 50µs/事实),推导结果与原始事实在同一个事务边界内持久化,并记录来源规则与输入事实——inferred_from 让"这条结论从哪几条事实推出来的"可以被追问到具体的写入和规则触发点。对一个要给 Agent 用的数据库来说,可溯源不是加分项,是及格线。

2.3 把十二种模型缝在一起的两根线

第一根:统一实体锚点。 一个字符串同时充当全部模型的键:

EntityId = {namespace}::{class}::{pk}

于是"化合物 A 的关系行""化合物 A 的图顶点""化合物 A 的向量文档""化合物 A 的时序记录"指向的是同一个实体——零映射表、零 ETL、没有"对不齐"的可能。前缀里的 {namespace} 不是装饰,是内核级多租户隔离:本体定义、数据、索引、推理都在命名空间范围内生效,键前缀路由零开销,不会出现"应用层加 tenant_id 容易漏过滤"的问题。

第二根:同一套事务。 一次写入同时触碰多类结构,走的是同一套提交协议(三阶段锁:准备 → 预提交 → 提交,用来避免写锁与索引锁嵌套死锁)。这也是为什么后来那个"幽灵"问题只能靠一致性对账来解决——因为在我们的架构里,图、向量、关系本来就是同一份数据的不同视图,视图之间不对齐,是一种"内部矛盾",而不是"外部同步延迟"。

数据完整性的最后一道防线在块级:SSTable 每页带 CRC,读路径做哨兵校验,损坏数据被确定性拒绝而不是悄悄吐错值;配合 Raft 副本(企业版)可以自动从健康副本修复。这不是"防御性编程"的口号——后面的排查里会看到,我们自己就被一个"读旧版本"的数据层 bug 咬过。

2.4 一次写入,十二种模型同时就绪;活数据会自己冷

写入侧是原子联动:文档写入 → 图顶点/边 → 三元组 → 推理 → 向量 → 全文/时序/空间索引全部在同一事务内落盘。数据落地的那一刻,它同时是"可被图遍历的""可被向量检索的""可被本体推理的""可被全文检索的"。没有异步索引队列,也就没有"刚写完查不到"的中间态。

除了"写完即就绪",每条数据从落地开始就在按活数据(Live Data)机制运行:

  • 三级半衰期:高频热点 7 小时 → 中频温数据 70 天 → 低频冷数据 2 年
  • 三种衰减模型:指数(默认)、线性、对数
  • 访问自动激活:每次查询命中自动重置衰减时钟,累加价值得分
  • 向量索引联动:高价值数据的向量优先加载到内存,冷数据向磁盘下沉

传统 TTL 是"到点就死",不理解数据对用户的真实价值;活数据机制让数据"越用越活、不用自动降权"。对 Agent 的长期记忆来说,这比单纯的持久化更重要——记忆不是永恒的,但不该被一刀切清理。

2.5 查询语言也统一了:OntoQL

不是"一个进程里装四套查询解析器",而是单一查询语言 OntoQL融合关系投影/过滤/聚合、属性图遍历、向量近邻、时序窗口、空间谓词与本体推理;同时标准 SPARQL 1.1 查询(FILTER/OPTIONAL/UNION/EXISTS)也可直接使用。查询优化器会根据模态组合自动选择执行计划,比如"先用关系过滤缩小范围,再走向量近邻,最后在命中集上做图遍历"这类混合查询由优化器下推到存储层完成,不需要应用层拼装。

对"变化"的处理也是一等公民:SUBSCRIBE 原语支持订阅"某靶点家族新增相似分子""某子图边变化""某设备时序异常"这类条件,数据库主动推送,不需要外挂 Kafka 或应用层轮询;内核级 CDC 直接从 WAL 提取变更事件,Flink/Spark 可消费。

协议接入同样是多模的:PostgreSQL(5432)、MySQL(3306)、HTTP REST(7912)三套协议由内核直接解析——不是代理转发,而是协议级实现。现有应用不加中间层就能接入,原有 ORM、BI 工具可以继续用。

2.6 为什么是 Rust

  1. 无 GC → 确定性延迟。 向量检索是长尾敏感的,p50 看不出问题而 p99 会跳一个数量级;没有运行时 GC,"驻留内存"这件事不至于在某次搜索中间突然变成一次停顿。
  2. 所有权模型 → 锁外计算、锁内交换。 491MB 的 HNSW 快照加载,我们早期把反序列化干在了写锁里,结果是几秒钟内所有并发搜索全部阻塞。修复后的结构是"锁外完成全部反序列化与校验,锁内只做一次指针交换"——而让这个结构成为纪律而不是自觉的,是借用检查器:你没法在持有 &mut 的同时把数据再借出去算别的东西,临界区的边界在类型系统里就是可见的。
  3. 单二进制、零运行时 → 部署形态覆盖边缘。 客户拿到的是一个可执行文件,--data-dir 指过去就能起;从边缘服务器到云,跑的是同一份内核代码,不依赖 JVM/Python 运行时。

好,背景交代完了。下面进入正题。


三、一次 HNSW 召回坍塌的完整排查

3.1 现象:Recall@50 低于 Recall@10

数据规模:227 万条 Morgan 指纹(1024 位二进制,Tanimoto 距离)。现象就是开头那两行——Recall@10 ≈ 0.88,Recall@50 ≈ 0.85。

3.2 三次排除,排除掉三个错误假设

我们没急着调参,先做排除。而且用的是分层大样本(500 节点,按 fp popcount 五分位抽样),不是拍 25 个点就下结论:

  1. 自检索 sanity check:用图中已存在的节点当查询,看它能否找回自己。结果 self-recall = 1.00(5000 样本下 0.9972)——图是连通且可导航的,排除"图结构崩坏"。
  2. 距离口径对照:Tanimoto 是离散距离,可能有大量并列。id 口径与 dist 口径各测一遍,两者只差约 0.018(几乎全是并列距离造成的)——排除"并列造成假象"。
  3. efSearch 扫描:从 64 提到 800,召回只从 0.72 涨到 0.89 就见顶——排除"搜索深度不够"。

三次排除之后,剩下的可能性只有一个:真邻居压根不在可达子图上。边被系统性剪坏了,搜索再深也进不去。

3.3 根因一:一行严格小于号

问题出在 HNSW 的多样性剪枝启发式里:

// 对候选邻居 c,检查已被选中的邻居 r 是否"覆盖"了 c
// 若 r 到 c 比 q(新插入节点)到 c 更近,说明 c 由 r 代表即可 → c 冗余、可剪
for r in &selected {
if dist(r, c) < dist(q, c) { // ← 问题在这一行的边界语义
drop(c);
}
}

在连续欧氏空间里,两个距离完全相等的概率是 0,这行怎么写都不出问题。但 Morgan 指纹是 1024 位二进制,Tanimoto 是离散值——并列是常态,不是例外。于是大量数值上并不冗余的新节点,被系统性判为"冗余"剔除。

更隐蔽的是触发条件:

  • 建图初期邻居列表远没到上限,剪枝根本不会被触发——所以早期写入的 146 万向量质量是好的;
  • 图饱和之后,每次增量插入都会触发剪枝——后续写入的向量入边被反复切断,逐渐沦为孤点。

这解释了全部观测:早期批次正常、后期批次下滑、@50 倒挂、部分区域"几乎什么都搜不到"。

修复两处:并列时放行(不剪),以及保护刚接上的新节点(保证至少一条入边)。

3.4 根因二:44 万个"幽灵"

修完剪枝,召回只回到 0.85 左右。继续查,发现一个更严重的不一致:

HNSW 图内节点数 2,083,259
SQL 中有合法指纹的行数 1,638,397
差 444,862

大量节点存在于 HNSW 里,SQL 侧却查不到对应的指纹。抽查发现这些"幽灵"的 SMILES 都是正常药物分子(阿司匹林类似物那一类),本该有指纹,不是脏数据。

根因是两个纯内存状态没有进快照:

  • deleted_keys:哪些向量已被逻辑删除(本应随索引落盘);
  • doc_vectors:哪些 id 当前持有有效向量。

重启后这两个集合清零,于是删除过滤失效,历史被删节点重新漏进搜索结果;增量对账逻辑又因为"图里已经有这个节点"直接跳过,没去核对"它在文档侧其实已经没有指纹了"。搜索时的候选放大系数还写成了 k + 已删数量——44 万的删除量下,等于每次搜索都要扫全图。

泄漏路径的修复是 restore 后全量对账,把图内节点与文档侧做完整比对:

图有节点 + 文档有 fp → noted = 1,638,397 (重建 doc_vectors)
图有节点 + 文档无 fp / 行已删 → ghosts_marked = 444,862 (mark_deleted)
图无节点 + 文档有 fp → 补录
合计 = 2,083,259 = 图内节点数 ✓

随后执行原地 prune(非全量重建):保留拓扑、全层邻居重写、双向边补全、entry/max_layer 重算,摘除 444,862 个节点耗时 23.1 秒(重建路径是小时级),快照从 959.8MB 瘦到 694.8MB(−27.6%),over_fetch 从 8× 回落到 2×。

3.5 根因三:四段失败链,不是一个 bug

排查没有停在"对账修好了"。我们继续追"这些正常分子的 fp 为什么会变成 NULL"——这才是真正的源头。结论是四段失败叠加,任一段单独都不会出事故:

  1. A · 导入用了不完整的 id 索引做 keyset 分页,导致分区断流,漏写约 65~78 万行的指纹;但导入日志显示的完成数约等于全表行数,当时以为已 100%。
  2. B · 覆盖率验证抽样偏差:check_fp_coverage.py 只抽查 7 个 id,refill_gaps.py 只查 CHEMBL1-9 前缀——全落在未漏写区域,假收口。
  3. C · chem 表批量导入的 INSERT 列清单不含 fp 列,新写入的版本覆盖了有 fp 的旧行,产生无 fp 的最新版本。
  4. D · pass-2 版本选择 bug(w_seq == seq 匹配 + snapshot_sst_handles 层序 L6→L0 反转):向量 backfill 恰好跑在这个 bug 期间,层序从旧到新扫描、seq 被 clean_index 清零时读到了导入前的旧版本(有 fp),把旧版本指纹索引进图,而活版本无 fp——这就是 44.4 万差额的精确来源。

这里有一个值得单独说的设计教训。我们这套索引的版本权威来自 LSM-Tree 的物理层序(newest-first + first-seen-wins),而不是 seq_no。层序是存储引擎自己维护、不可篡改的事实;seq 是元数据,可以被重整、清零、碰撞。这个取舍在这次事故里救了我们——D 段的病灶正是"用 seq 代替层序"。

生产端三条修复:pass-2 改走层序优先;prune_ids 按 id 去重(保留与 doc_vectors 匹配的那个,防止同 id 双节点一起存活);chem 导入列清单补全 fp。覆盖率验证从 7 点抽查升级为"全量 COUNT + 分区分层抽样"。

排查过程中还顺带修掉 planner 一处结构性问题:remaining_filter 被包在 Limit 节点之上,计划形状是 Filter(Limit(SeqScan)),而 SQL 语义要求 Limit(Filter(SeqScan))——导致 WHERE ... LIMIT N 只返回 N−1 行,ORDER BY ... LIMIT 场景更是返回"错的行"而非"少的行"。

3.6 结果

最终验收采用 500 节点分层抽样(fp popcount 五分位 × 100)、GT 装载 1,638,397 条(与节点数逐位一致):

指标 修复前 修复后(500 分层)
id-recall@10 0.844 0.9784
dist-recall@10 0.848 0.9964
dist-recall@50 — 0.9916
id-recall@50 — 0.9832
倒挂幅度(@10 − @50) 0.044 0.004
self-recall(5000 样本) 1.00(小样本) 0.9972
幽灵命中(多查询点 TOP50) 18/50 0 / 88
快照体积 960MB 695MB(−27.6%)
over_fetch 放大 8× 2×
图节点数 2,083,259 1,638,397(精确对齐 SQL)

所有闸门(id≥0.968 / dist@10≥0.990 / dist@50≥0.985 / self≥0.995 / ghost=0 / 节点数精确)全部通过。

3.7 五个 bug,其实是同一类病

这一路共修掉 5 个查询正确性 bug:equality 等值点查返回双行、ghost 泄漏、pass-2 版本选反、NOT 谓词被当成列名、以及 planner 的 Filter/Limit 形状错误。

它们底层是同一个病:"版本合流必须走层序、谓词归属必须覆盖所有算子形态"这条纪律,之前只在部分代码路径上落地了。修复方式不是逐个打补丁,而是收敛到一个公共迭代器——整族免疫,而不是这一次修好。

顺带记一条通用规则:凡输出顺序承载语义的路径(range 扫描、keyset 分页、层序扫描),去重一律用保序实现。我们最初给 equality 加去重时用了 sort() + dedup(),打乱了 range 的索引序,导致分页重复扫描、装载数虚高到 1.97M、召回掉到 0.76——换成保序去重后立刻恢复。


四、排查之后:从"人工深挖"到"自动发现"

这次事故最值得反思的一点是:上述 5 个 bug,没有一个是监控发现的,全靠人工深挖。 修完 bug 只是第一步,第二步是让同类问题以后能被自动看见:

  • schema API 暴露向量索引状态:nodes / deleted_keys / ready / m / ef_construction,探针不再依赖日志格式。
  • reconcile 日志分级告警:冷启动首次对账允许个位数 ghost(快照窗口内写入属正常);冷启动后出现任何非零 → WARN;占比 > 1% 建议 COMPACT;> 10 万 → ERROR。
  • 独立探针拆分:probe_vec_nodes.py / probe_vec_ghost.py / probe_vec_self.py(秒级健康检查)/ probe_ready_gate.py(启动窗口负向探针),各自可跑。
  • 启动门闩:对账完成前向量查询返回明确错误,而不是静默返回脏结果——用约 28 秒的"等待可用"换"不返回错数据"。
  • k8s 探针拆分:liveness 立即返回 200,readiness 等待 ready=true,避免对账未完成时探针失败触发无限重启循环。

五、共建与邀约

OntoDB 核心内核以开源生态层 + 闭源企业能力双轨推进。我们想找的是:

  • 在真实场景里用得上"向量 + 图 + 本体 + 流"的团队——来仓库提 Issue 聊场景,哪怕是"你这个设计在我的场景下不成立"这种;
  • 愿意补 client SDK、文档、查询算子的同学——crates/ 下每个 crate 边界写得比较清楚,PR 门槛不高;
  • 想写点东西的人——欢迎写"我用 OntoDB 接了哪个 Agent、哪张知识图谱",踩坑文比赞美文受欢迎得多。

给评论区留一个我们确实没想透的问题:

在离散距离空间(二进制指纹、量化嵌入)里,HNSW 的多样性剪枝该怎么处理"并列距离"?并列放行,还是引入第二判据(比如按层序或 id 稳定排序)?你们的实现是怎么处理的,有没有踩过类似的坑?

仓库在这里,欢迎 clone、跑 Demo、开 Issue:

https://github.com/jianzhuguo/ontodb

# 前置:Rust 1.77+
git clone https://github.com/jianzhuguo/ontodb.git
cd ontodb
cargo build --release
./target/release/ontodb-server --data-dir ./data --http 0.0.0.0:7912


六、In Rust We Trust

谢谢 Rust 中文社区,谢谢各位Ruster,我们产品已经开源发布,现在轮到社区来用、来骂、来改、来共建。


*(本文性能数字与故障复盘均来自仓库基准报告与真实排查记录;最终验收采用 500 节点分层抽样与 5000 节点 self-recall 验证。未覆盖项——并发压测、kill-9 崩溃注入、千万级规模边界、*deleted_keys/doc_vectors 持久化——不在本文宣称范围内,不做隐藏。)

评论区

写评论

还没有评论

1 共 0 条评论, 1 页