---
title: "从 44 万个\"幽灵\"说起：基于Rust开发的OntoDB 把关系、图、向量、本体等多模态数据融进一个内核，与一次 HNSW 召回坍塌的完整排查 - Angryecho"
description: "一、先把结论放前面 一个 227 万条化合物指纹的 HNSW 索引，召回率不对。不是低，是低得反常： Recall@10 ≈ 0.88 Recall@50 ≈ 0.85 ← k 越大，找回来的反而越少 在健康的 HNSW 上，固定 efSearch 时 Recall@k 应随 k 增大走平甚至逼近 1。"
canonical_url: "https://www.angryecho.com/c/rustcc/p/P2TBBiPf77OLTV2APLPPm"
meta:
  "og:description": "一、先把结论放前面 一个 227 万条化合物指纹的 HNSW 索引，召回率不对。不是低，是低得反常： Recall@10 ≈ 0.88 Recall@50 ≈ 0.85 ← k 越大，找回来的反而越少 在健康的 HNSW 上，固定 efSearch 时 Recall@k 应随 k 增大走平甚至逼近 1。"
  "og:image": "https://www.angryecho.com/_og/d/c_Post%2Ctitle_~5ZCQ5qe9572R%2Cdescription_~5ZCQ5qe95LiA5LiL77yM5bCx5LiK5ZCQ5qe9572R44CC%2CpostId_P2TBBiPf77OLTV2APLPPm%2Crevision_~MjAyNi0xMC0yN1QwMjo1Nzo0OS4yMjMzNTBa%2Cp_Ii9jL3J1c3RjYy9wL1AyVEJCaVBmNzdPTFRWMkFQTFBQbSI%2Cs_hnhrq7OzejW1Nynl.png"
  "og:image:height": "630"
  "og:image:type": image/png
  "og:image:width": "1200"
  "og:title": "从 44 万个\"幽灵\"说起：基于Rust开发的OntoDB 把关系、图、向量、本体等多模态数据融进一个内核，与一次 HNSW 召回坍塌的完整排查"
---

> Index of every page on this site: [llms.txt](https://www.angryecho.com/llms.txt)

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

## 一、先把结论放前面

一个 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` *持久化——不在本文宣称范围内，不做隐藏。）*

排序方式 :

### **成为第一条评论！**

Nobody's responded to this post yet. Add your thoughts and get the conversation going.

```json
{"@context":"https://schema.org","@graph":[{"@id":"https://www.angryecho.com/c/rustcc/p/P2TBBiPf77OLTV2APLPPm#post","@type":"DiscussionForumPosting","commentCount":0,"dateModified":"2026-10-27T02:57:49.223Z","datePublished":"2026-10-23T15:32:00.000Z","description":"一、先把结论放前面 一个 227 万条化合物指纹的 HNSW 索引，召回率不对。不是低，是低得反常： Recall@10 ≈ 0.88 Recall@50 ≈ 0.85 ← k 越大，找回来的反而越少 在健康的 HNSW 上，固定 efSearch 时 Recall@k 应随 k 增大走平甚至逼近 1。","headline":"从 44 万个\"幽灵\"说起：基于Rust开发的OntoDB 把关系、图、向量、本体等多模态数据融进一个内核，与一次 HNSW 召回坍塌的完整排查","isPartOf":"https://www.angryecho.com/c/rustcc","mainEntityOfPage":"https://www.angryecho.com/c/rustcc/p/P2TBBiPf77OLTV2APLPPm","text":"一、先把结论放前面\n\n一个 227 万条化合物指纹的 HNSW 索引，召回率不对。不是低，是低得反常：\n\nRecall@10 ≈ 0.88\nRecall@50 ≈ 0.85 ← k 越大，找回来的反而越少\n\n在健康的 HNSW 上，固定 efSearch 时 Recall@k 应随 k 增大走平甚至逼近 1。倒挂意味着\"排在前面的找得到，第 10 到第 50 名够不着\"——候选集本身不完整，不是排序问题。\n\n最终查出来的是一串叠加的缺陷：一行严格小于号、两个没进快照的内存状态、一条四段式的数据失败链。修复后 id-recall@10 从 0.844 回到 0.9784，dist-recall@10 0.9964，倒挂从 0.044 收敛到 0.004，图节点数与 SQL 侧精确对齐到 1,638,397。\n\n这篇文章的主体就是这次排查。开始之前，先用一小节交代清楚\"我们在什么东西上踩到了这个坑\"。\n\n\n\n二、OntoDB 是什么：一个内核装进 12 种数据模型\n\n2.1 拼出来的多模，问题不在性能，在一致性\n\n主流的多模方案本质是\"多套存储 + 应用层编排\"：关系在 Postgres、图在 Neo4j、向量在 Milvus、时序在 InfluxDB、本体推理在 Jena，靠 ETL 和中间件对齐。\n\n它的痛点很少出现在单点性能上，而出现在缝上：\n\n\n\n\n\n跨系统的写入无法组成一个事务，向量索引与关系数据之间必然存在一个\"不一致窗口\"，而窗口里的查询结果是错的、且看不出来；\n\n\n\n同一个业务实体被拆到四五个系统，各存一份，靠 id 映射表勉强对齐；\n\n\n\n一次查询要串联多家系统，延迟是各家之和，可解释性在拼接处断掉；\n\n\n\n数据冷热度、安全策略、审计规则要在每一套系统上各配一遍。\n\n我们的选择是不拼：把这 12 种数据模型做进一个 LSM 存储引擎、一套事务体系、和一个查询语言里。下面就是它实际的样子。\n\n2.2 一个 LSM 引擎，12 种数据类型\n\n核心的存储是一套 LSM-Tree，通过键前缀路由区分数据形态：不同前缀走不同的编解码、索引结构和查询算子，但共用同一份 WAL、同一套 compaction、同一块 block cache。前缀划分覆盖 12 种数据类型：\n\n\n\n\n\n\n\n类别\n\n\n\n数据类型\n\n\n\n关键结构\n\n\n\n\n\n基础\n\n\n\n键值、文档（JSON/BSON）、全文（倒排+BM25+中英文分词）、多媒体元数据（SHA256 内容哈希）\n\n\n\nLSM 键值、倒排索引、内容哈希索引\n\n\n\n\n\n高级\n\n\n\n关系（结构化记录+事务）、图（属性图+遍历）、向量（HNSW 语义相似）、三元组（SPO 知识图谱）\n\n\n\n见下文详解\n\n\n\n\n\n时序空间\n\n\n\n时序（TSM 列式 Delta/Gorilla 编码）、空间（R-Tree+GeoHash）、时空联合索引\n\n\n\nTSM 列式块、四叉树\n\n\n\n\n\n实时\n\n\n\n流数据（CDC 变更捕获+连续查询）、事件（复杂事件处理 CEP）\n\n\n\nWAL 直出 CDC、事件窗口匹配\n\n其中四种模型是这次踩坑的主角，也是 AI 场景里最常同时出现的四件套——它们的前缀命名空间和核心数据结构如下：\n\n\n\n\n\n\n\n模型\n\n\n\n存储形态\n\n\n\n核心数据结构\n\n\n\n键前缀\n\n\n\n\n\n关系\n\n\n\n结构化记录\n\n\n\nLSM-Tree；二进制行零拷贝谓词过滤（在二进制数据上直接做过滤，无需反序列化，解析时间减少约 60%）\n\n\n\n普通表键\n\n\n\n\n\n图\n\n\n\n属性图（顶点 + 边 + 属性）\n\n\n\nindex-free adjacency，Vec\u003CVec\u003C(u32, String)>> 内存邻接\n\n\n\n__graph_v__ / __graph_e__\n\n\n\n\n\n向量\n\n\n\nHNSW 索引 + 向量载荷\n\n\n\n内存 HNSW 图 + 快照持久化 + 增量补录\n\n\n\n向量索引命名空间\n\n\n\n\n\n本体\n\n\n\nSPO 三元组\n\n\n\n内核内 OWL 2 RL 推理引擎，含 inferred_from 溯源\n\n\n\n__triple__\n\n这四者共享同一套 WAL、同一套 compaction、同一套事务边界——不是\"一个进程里装了四个库\"，而是\"一份数据上开了四种视图\"。\n\n图引擎值得一提：字符串 ID 只映射一次，之后全链路走 u32 整数索引，取邻居就是数组下标访问，不做哈希、不比字符串：\n\nid_to_idx: RwLock\u003CHashMap\u003CString, u32>>, // 只在这里做一次字符串映射\nidx_to_id: RwLock\u003CVec\u003CString>>,\nadj_out: RwLock\u003CVec\u003CVec\u003C(u32, String)>>>, // 出邻居\nadj_in: RwLock\u003CVec\u003CVec\u003C(u32, String)>>>, // 入邻居\n\nk-hop 遍历的复杂度只与访问到的节点数相关，与图规模无关。遍历本身是 FIFO 队列 + 位图 visited，循环体内全是 u32 运算。企业版进一步换成 CSR 压缩存储（内存约省 50%），并用 Rayon 做并行邻居扩展。\n\n本体层则是把 OWL 2 RL 推理引擎嵌进了存储内核：子类传播、等价类、传递性、对称性、逆属性、子属性等规则在写入时即触发（增量不动点约 50µs/事实），推导结果与原始事实在同一个事务边界内持久化，并记录来源规则与输入事实——inferred_from 让\"这条结论从哪几条事实推出来的\"可以被追问到具体的写入和规则触发点。对一个要给 Agent 用的数据库来说，可溯源不是加分项，是及格线。\n\n2.3 把十二种模型缝在一起的两根线\n\n第一根：统一实体锚点。 一个字符串同时充当全部模型的键：\n\nEntityId = {namespace}::{class}::{pk}\n\n于是\"化合物 A 的关系行\"\"化合物 A 的图顶点\"\"化合物 A 的向量文档\"\"化合物 A 的时序记录\"指向的是同一个实体——零映射表、零 ETL、没有\"对不齐\"的可能。前缀里的 {namespace} 不是装饰，是内核级多租户隔离：本体定义、数据、索引、推理都在命名空间范围内生效，键前缀路由零开销，不会出现\"应用层加 tenant_id 容易漏过滤\"的问题。\n\n第二根：同一套事务。 一次写入同时触碰多类结构，走的是同一套提交协议（三阶段锁：准备 → 预提交 → 提交，用来避免写锁与索引锁嵌套死锁）。这也是为什么后来那个\"幽灵\"问题只能靠一致性对账来解决——因为在我们的架构里，图、向量、关系本来就是同一份数据的不同视图，视图之间不对齐，是一种\"内部矛盾\"，而不是\"外部同步延迟\"。\n\n数据完整性的最后一道防线在块级：SSTable 每页带 CRC，读路径做哨兵校验，损坏数据被确定性拒绝而不是悄悄吐错值；配合 Raft 副本（企业版）可以自动从健康副本修复。这不是\"防御性编程\"的口号——后面的排查里会看到，我们自己就被一个\"读旧版本\"的数据层 bug 咬过。\n\n2.4 一次写入，十二种模型同时就绪；活数据会自己冷\n\n写入侧是原子联动：文档写入 → 图顶点/边 → 三元组 → 推理 → 向量 → 全文/时序/空间索引全部在同一事务内落盘。数据落地的那一刻，它同时是\"可被图遍历的\"\"可被向量检索的\"\"可被本体推理的\"\"可被全文检索的\"。没有异步索引队列，也就没有\"刚写完查不到\"的中间态。\n\n除了\"写完即就绪\"，每条数据从落地开始就在按活数据（Live Data）机制运行：\n\n\n\n\n\n三级半衰期：高频热点 7 小时 → 中频温数据 70 天 → 低频冷数据 2 年\n\n\n\n三种衰减模型：指数（默认）、线性、对数\n\n\n\n访问自动激活：每次查询命中自动重置衰减时钟，累加价值得分\n\n\n\n向量索引联动：高价值数据的向量优先加载到内存，冷数据向磁盘下沉\n\n传统 TTL 是\"到点就死\"，不理解数据对用户的真实价值；活数据机制让数据\"越用越活、不用自动降权\"。对 Agent 的长期记忆来说，这比单纯的持久化更重要——记忆不是永恒的，但不该被一刀切清理。\n\n2.5 查询语言也统一了：OntoQL\n\n不是\"一个进程里装四套查询解析器\"，而是单一查询语言 OntoQL融合关系投影/过滤/聚合、属性图遍历、向量近邻、时序窗口、空间谓词与本体推理；同时标准 SPARQL 1.1 查询（FILTER/OPTIONAL/UNION/EXISTS）也可直接使用。查询优化器会根据模态组合自动选择执行计划，比如\"先用关系过滤缩小范围，再走向量近邻，最后在命中集上做图遍历\"这类混合查询由优化器下推到存储层完成，不需要应用层拼装。\n\n对\"变化\"的处理也是一等公民：SUBSCRIBE 原语支持订阅\"某靶点家族新增相似分子\"\"某子图边变化\"\"某设备时序异常\"这类条件，数据库主动推送，不需要外挂 Kafka 或应用层轮询；内核级 CDC 直接从 WAL 提取变更事件，Flink/Spark 可消费。\n\n协议接入同样是多模的：PostgreSQL（5432）、MySQL（3306）、HTTP REST（7912）三套协议由内核直接解析——不是代理转发，而是协议级实现。现有应用不加中间层就能接入，原有 ORM、BI 工具可以继续用。\n\n2.6 为什么是 Rust\n\n\n\n\n\n无 GC → 确定性延迟。 向量检索是长尾敏感的，p50 看不出问题而 p99 会跳一个数量级；没有运行时 GC，\"驻留内存\"这件事不至于在某次搜索中间突然变成一次停顿。\n\n\n\n所有权模型 → 锁外计算、锁内交换。 491MB 的 HNSW 快照加载，我们早期把反序列化干在了写锁里，结果是几秒钟内所有并发搜索全部阻塞。修复后的结构是\"锁外完成全部反序列化与校验，锁内只做一次指针交换\"——而让这个结构成为纪律而不是自觉的，是借用检查器：你没法在持有 &mut 的同时把数据再借出去算别的东西，临界区的边界在类型系统里就是可见的。\n\n\n\n单二进制、零运行时 → 部署形态覆盖边缘。 客户拿到的是一个可执行文件，--data-dir 指过去就能起；从边缘服务器到云，跑的是同一份内核代码，不依赖 JVM/Python 运行时。\n\n好，背景交代完了。下面进入正题。\n\n\n\n三、一次 HNSW 召回坍塌的完整排查\n\n3.1 现象：Recall@50 低于 Recall@10\n\n数据规模：227 万条 Morgan 指纹（1024 位二进制，Tanimoto 距离）。现象就是开头那两行——Recall@10 ≈ 0.88，Recall@50 ≈ 0.85。\n\n3.2 三次排除，排除掉三个错误假设\n\n我们没急着调参，先做排除。而且用的是分层大样本（500 节点，按 fp popcount 五分位抽样），不是拍 25 个点就下结论：\n\n\n\n\n\n自检索 sanity check：用图中已存在的节点当查询，看它能否找回自己。结果 self-recall = 1.00（5000 样本下 0.9972）——图是连通且可导航的，排除\"图结构崩坏\"。\n\n\n\n距离口径对照：Tanimoto 是离散距离，可能有大量并列。id 口径与 dist 口径各测一遍，两者只差约 0.018（几乎全是并列距离造成的）——排除\"并列造成假象\"。\n\n\n\nefSearch 扫描：从 64 提到 800，召回只从 0.72 涨到 0.89 就见顶——排除\"搜索深度不够\"。\n\n三次排除之后，剩下的可能性只有一个：真邻居压根不在可达子图上。边被系统性剪坏了，搜索再深也进不去。\n\n3.3 根因一：一行严格小于号\n\n问题出在 HNSW 的多样性剪枝启发式里：\n\n// 对候选邻居 c，检查已被选中的邻居 r 是否\"覆盖\"了 c\n// 若 r 到 c 比 q（新插入节点）到 c 更近，说明 c 由 r 代表即可 → c 冗余、可剪\nfor r in &selected {\n if dist(r, c) \u003C dist(q, c) { // ← 问题在这一行的边界语义\n drop(c);\n }\n}\n\n在连续欧氏空间里，两个距离完全相等的概率是 0，这行怎么写都不出问题。但 Morgan 指纹是 1024 位二进制，Tanimoto 是离散值——并列是常态，不是例外。于是大量数值上并不冗余的新节点，被系统性判为\"冗余\"剔除。\n\n更隐蔽的是触发条件：\n\n\n\n\n\n建图初期邻居列表远没到上限，剪枝根本不会被触发——所以早期写入的 146 万向量质量是好的；\n\n\n\n图饱和之后，每次增量插入都会触发剪枝——后续写入的向量入边被反复切断，逐渐沦为孤点。\n\n这解释了全部观测：早期批次正常、后期批次下滑、@50 倒挂、部分区域\"几乎什么都搜不到\"。\n\n修复两处：并列时放行（不剪），以及保护刚接上的新节点（保证至少一条入边）。\n\n3.4 根因二：44 万个\"幽灵\"\n\n修完剪枝，召回只回到 0.85 左右。继续查，发现一个更严重的不一致：\n\nHNSW 图内节点数 2,083,259\nSQL 中有合法指纹的行数 1,638,397\n差 444,862\n\n大量节点存在于 HNSW 里，SQL 侧却查不到对应的指纹。抽查发现这些\"幽灵\"的 SMILES 都是正常药物分子（阿司匹林类似物那一类），本该有指纹，不是脏数据。\n\n根因是两个纯内存状态没有进快照：\n\n\n\n\n\ndeleted_keys：哪些向量已被逻辑删除（本应随索引落盘）；\n\n\n\ndoc_vectors：哪些 id 当前持有有效向量。\n\n重启后这两个集合清零，于是删除过滤失效，历史被删节点重新漏进搜索结果；增量对账逻辑又因为\"图里已经有这个节点\"直接跳过，没去核对\"它在文档侧其实已经没有指纹了\"。搜索时的候选放大系数还写成了 k + 已删数量——44 万的删除量下，等于每次搜索都要扫全图。\n\n泄漏路径的修复是 restore 后全量对账，把图内节点与文档侧做完整比对：\n\n图有节点 + 文档有 fp → noted = 1,638,397 （重建 doc_vectors）\n图有节点 + 文档无 fp / 行已删 → ghosts_marked = 444,862 （mark_deleted）\n图无节点 + 文档有 fp → 补录\n合计 = 2,083,259 = 图内节点数 ✓\n\n随后执行原地 prune（非全量重建）：保留拓扑、全层邻居重写、双向边补全、entry/max_layer 重算，摘除 444,862 个节点耗时 23.1 秒（重建路径是小时级），快照从 959.8MB 瘦到 694.8MB（−27.6%），over_fetch 从 8× 回落到 2×。\n\n3.5 根因三：四段失败链，不是一个 bug\n\n排查没有停在\"对账修好了\"。我们继续追\"这些正常分子的 fp 为什么会变成 NULL\"——这才是真正的源头。结论是四段失败叠加，任一段单独都不会出事故：\n\n\n\n\n\nA · 导入用了不完整的 id 索引做 keyset 分页，导致分区断流，漏写约 65~78 万行的指纹；但导入日志显示的完成数约等于全表行数，当时以为已 100%。\n\n\n\nB · 覆盖率验证抽样偏差：check_fp_coverage.py 只抽查 7 个 id，refill_gaps.py 只查 CHEMBL1-9 前缀——全落在未漏写区域，假收口。\n\n\n\nC · chem 表批量导入的 INSERT 列清单不含 fp 列，新写入的版本覆盖了有 fp 的旧行，产生无 fp 的最新版本。\n\n\n\nD · pass-2 版本选择 bug（w_seq == seq 匹配 + snapshot_sst_handles 层序 L6→L0 反转）：向量 backfill 恰好跑在这个 bug 期间，层序从旧到新扫描、seq 被 clean_index 清零时读到了导入前的旧版本（有 fp），把旧版本指纹索引进图，而活版本无 fp——这就是 44.4 万差额的精确来源。\n\n这里有一个值得单独说的设计教训。我们这套索引的版本权威来自 LSM-Tree 的物理层序（newest-first + first-seen-wins），而不是 seq_no。层序是存储引擎自己维护、不可篡改的事实；seq 是元数据，可以被重整、清零、碰撞。这个取舍在这次事故里救了我们——D 段的病灶正是\"用 seq 代替层序\"。\n\n生产端三条修复：pass-2 改走层序优先；prune_ids 按 id 去重（保留与 doc_vectors 匹配的那个，防止同 id 双节点一起存活）；chem 导入列清单补全 fp。覆盖率验证从 7 点抽查升级为\"全量 COUNT + 分区分层抽样\"。\n\n排查过程中还顺带修掉 planner 一处结构性问题：remaining_filter 被包在 Limit 节点之上，计划形状是 Filter(Limit(SeqScan))，而 SQL 语义要求 Limit(Filter(SeqScan))——导致 WHERE ... LIMIT N 只返回 N−1 行，ORDER BY ... LIMIT 场景更是返回\"错的行\"而非\"少的行\"。\n\n3.6 结果\n\n最终验收采用 500 节点分层抽样（fp popcount 五分位 × 100）、GT 装载 1,638,397 条（与节点数逐位一致）：\n\n\n\n\n\n\n\n指标\n\n\n\n修复前\n\n\n\n修复后（500 分层）\n\n\n\n\n\nid-recall@10\n\n\n\n0.844\n\n\n\n0.9784\n\n\n\n\n\ndist-recall@10\n\n\n\n0.848\n\n\n\n0.9964\n\n\n\n\n\ndist-recall@50\n\n\n\n—\n\n\n\n0.9916\n\n\n\n\n\nid-recall@50\n\n\n\n—\n\n\n\n0.9832\n\n\n\n\n\n倒挂幅度（@10 − @50）\n\n\n\n0.044\n\n\n\n0.004\n\n\n\n\n\nself-recall（5000 样本）\n\n\n\n1.00（小样本）\n\n\n\n0.9972\n\n\n\n\n\n幽灵命中（多查询点 TOP50）\n\n\n\n18/50\n\n\n\n0 / 88\n\n\n\n\n\n快照体积\n\n\n\n960MB\n\n\n\n695MB（−27.6%）\n\n\n\n\n\nover_fetch 放大\n\n\n\n8×\n\n\n\n2×\n\n\n\n\n\n图节点数\n\n\n\n2,083,259\n\n\n\n1,638,397（精确对齐 SQL）\n\n所有闸门（id≥0.968 / dist@10≥0.990 / dist@50≥0.985 / self≥0.995 / ghost=0 / 节点数精确）全部通过。\n\n3.7 五个 bug，其实是同一类病\n\n这一路共修掉 5 个查询正确性 bug：equality 等值点查返回双行、ghost 泄漏、pass-2 版本选反、NOT 谓词被当成列名、以及 planner 的 Filter/Limit 形状错误。\n\n它们底层是同一个病：\"版本合流必须走层序、谓词归属必须覆盖所有算子形态\"这条纪律，之前只在部分代码路径上落地了。修复方式不是逐个打补丁，而是收敛到一个公共迭代器——整族免疫，而不是这一次修好。\n\n顺带记一条通用规则：凡输出顺序承载语义的路径（range 扫描、keyset 分页、层序扫描），去重一律用保序实现。我们最初给 equality 加去重时用了 sort() + dedup()，打乱了 range 的索引序，导致分页重复扫描、装载数虚高到 1.97M、召回掉到 0.76——换成保序去重后立刻恢复。\n\n\n\n四、排查之后：从\"人工深挖\"到\"自动发现\"\n\n这次事故最值得反思的一点是：上述 5 个 bug，没有一个是监控发现的，全靠人工深挖。 修完 bug 只是第一步，第二步是让同类问题以后能被自动看见：\n\n\n\n\n\nschema API 暴露向量索引状态：nodes / deleted_keys / ready / m / ef_construction，探针不再依赖日志格式。\n\n\n\nreconcile 日志分级告警：冷启动首次对账允许个位数 ghost（快照窗口内写入属正常）；冷启动后出现任何非零 → WARN；占比 > 1% 建议 COMPACT；> 10 万 → ERROR。\n\n\n\n独立探针拆分：probe_vec_nodes.py / probe_vec_ghost.py / probe_vec_self.py（秒级健康检查）/ probe_ready_gate.py（启动窗口负向探针），各自可跑。\n\n\n\n启动门闩：对账完成前向量查询返回明确错误，而不是静默返回脏结果——用约 28 秒的\"等待可用\"换\"不返回错数据\"。\n\n\n\nk8s 探针拆分：liveness 立即返回 200，readiness 等待 ready=true，避免对账未完成时探针失败触发无限重启循环。\n\n\n\n五、共建与邀约\n\nOntoDB 核心内核以开源生态层 + 闭源企业能力双轨推进。我们想找的是：\n\n\n\n\n\n在真实场景里用得上\"向量 + 图 + 本体 + 流\"的团队——来仓库提 Issue 聊场景，哪怕是\"你这个设计在我的场景下不成立\"这种；\n\n\n\n愿意补 client SDK、文档、查询算子的同学——crates/ 下每个 crate 边界写得比较清楚，PR 门槛不高；\n\n\n\n想写点东西的人——欢迎写\"我用 OntoDB 接了哪个 Agent、哪张知识图谱\"，踩坑文比赞美文受欢迎得多。\n\n给评论区留一个我们确实没想透的问题：\n\n\n\n在离散距离空间（二进制指纹、量化嵌入）里，HNSW 的多样性剪枝该怎么处理\"并列距离\"？并列放行，还是引入第二判据（比如按层序或 id 稳定排序）？你们的实现是怎么处理的，有没有踩过类似的坑？\n\n仓库在这里，欢迎 clone、跑 Demo、开 Issue：\n\nhttps://github.com/jianzhuguo/ontodb\n\n# 前置：Rust 1.77+\ngit clone https://github.com/jianzhuguo/ontodb.git\ncd ontodb\ncargo build --release\n./target/release/ontodb-server --data-dir ./data --http 0.0.0.0:7912\n\n\n\n六、In Rust We Trust\n\n谢谢 Rust 中文社区，谢谢各位Ruster，我们产品已经开源发布，现在轮到社区来用、来骂、来改、来共建。\n\n\n\n*（本文性能数字与故障复盘均来自仓库基准报告与真实排查记录；最终验收采用 500 节点分层抽样与 5000 节点 self-recall 验证。未覆盖项——并发压测、kill-9 崩溃注入、千万级规模边界、*deleted_keys/doc_vectors 持久化——不在本文宣称范围内，不做隐藏。）","url":"https://www.angryecho.com/c/rustcc/p/P2TBBiPf77OLTV2APLPPm","author":{"@type":"Person","name":"ontodb","url":"https://www.angryecho.com/u/R4C290b772f-0334-4d3"},"interactionStatistic":[{"@type":"InteractionCounter","interactionType":"https://schema.org/LikeAction","userInteractionCount":0},{"@type":"InteractionCounter","interactionType":"https://schema.org/DislikeAction","userInteractionCount":0},{"@type":"InteractionCounter","interactionType":"https://schema.org/ViewAction","userInteractionCount":0},{"@type":"InteractionCounter","interactionType":"https://schema.org/ShareAction","userInteractionCount":0}]}]}
```

## Sitemap

See the full [sitemap](https://www.angryecho.com/sitemap.md) for all pages.
