一、先把结论放前面
一个 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
- 无 GC → 确定性延迟。 向量检索是长尾敏感的,p50 看不出问题而 p99 会跳一个数量级;没有运行时 GC,"驻留内存"这件事不至于在某次搜索中间突然变成一次停顿。
- 所有权模型 → 锁外计算、锁内交换。 491MB 的 HNSW 快照加载,我们早期把反序列化干在了写锁里,结果是几秒钟内所有并发搜索全部阻塞。修复后的结构是"锁外完成全部反序列化与校验,锁内只做一次指针交换"——而让这个结构成为纪律而不是自觉的,是借用检查器:你没法在持有
&mut的同时把数据再借出去算别的东西,临界区的边界在类型系统里就是可见的。 - 单二进制、零运行时 → 部署形态覆盖边缘。 客户拿到的是一个可执行文件,
--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 个点就下结论:
- 自检索 sanity check:用图中已存在的节点当查询,看它能否找回自己。结果 self-recall = 1.00(5000 样本下 0.9972)——图是连通且可导航的,排除"图结构崩坏"。
- 距离口径对照:Tanimoto 是离散距离,可能有大量并列。id 口径与 dist 口径各测一遍,两者只差约 0.018(几乎全是并列距离造成的)——排除"并列造成假象"。
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"——这才是真正的源头。结论是四段失败叠加,任一段单独都不会出事故:
- A · 导入用了不完整的 id 索引做 keyset 分页,导致分区断流,漏写约 65~78 万行的指纹;但导入日志显示的完成数约等于全表行数,当时以为已 100%。
- B · 覆盖率验证抽样偏差:
check_fp_coverage.py只抽查 7 个 id,refill_gaps.py只查 CHEMBL1-9 前缀——全落在未漏写区域,假收口。 - C · chem 表批量导入的 INSERT 列清单不含 fp 列,新写入的版本覆盖了有 fp 的旧行,产生无 fp 的最新版本。
- 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 持久化——不在本文宣称范围内,不做隐藏。)
评论区
写评论还没有评论