- 后端
- 前端
- 人工智能
- RAG
- 知识图谱
- 知识管理
- 搜索引擎
【免费下载链接】utopia
World's first open-source enterprise world model.
导读:本文围绕 Utopia 的架构决策记录 0009-no-type-is-a-type,系统拆解「实体类型为空(type_id NULL)」这一设计如何成为默认起点:它移除九个内置占位类、把「尚未判定」从本体中彻底赶出去,同时用IS DISTINCT FROM修补 SQL 三值逻辑陷阱、用LEFT JOIN守住图查询、用entity_retypes.from_type_id可空保住撤销账本。读完你会掌握:空类型为何是受支持的一等状态、CONFUSABLE_TYPE_KEYS与owl:disjointWith在消解中的三层优先级、以及为什么「未分类实体允许同名共存」不是 bug 而是刻意为之的边界。
背景:九个内置类,一个「控制流混进本体」的旧世界
在 0009 落地之前,每个新建知识库(KB)都会播种九个实体类:person organization project metric dimension product event concept location。这组类有三个致命问题:
- 没有任何签名(signature)。决策 0008 已指出本体包的冷启动价值:schema.org 装进来之后,
Person/Organization/Product/Event/Project会按 key 被词汇表认领,内置集随即沦为占位符。 location把地理子树劈成两半。schema.org 叫它Place,key 对不上,于是City/AdministrativeArea挂在一个手工造的place之下——一个自造名字切断了两边的继承。concept最严重:它不是类型,是控制流。抽取管线强制要求它存在(报错文案是 "Ontology missing the 'concept' type"),把它当作「类型不在白名单里」的实体落点;同时类型消解把它当作候选池(entities_for_type_resolution(kb, DUMPING_GROUND, …))。一个「还没判出来」的状态,以类的形式躺在本体里,仿佛有人已经决定过它。
这是与 0010-no-relation-is-no-relation(关系侧移除related_to)、0011-a-mapping-is-not-a-fact(映射不是事实)同一条工作线的第三刀:控制流必须离开本体。0036 随后把这条线推到第四步——metric/dimension同样是控制流物种,见下文。
核心决策一:空知识库才是真正的空,type_id变成可空
0009 的第一条决策最根本:删掉全部九个类,不装包的知识库就是空的。
- 抽取出的实体一律
type_id NULL,事实与证据照常落库; - 之后装包、重跑类型消解即可补上类型——
entities_for_type_resolution现在以type_id IS NULL为筛选条件(见 crates/utopia-store/src/resolution.rs 附近对候选的说明),「先建 KB、后建模」成为受支持路径; - 创建 KB 的对话框会预勾选 schema.org,但默认不装任何包(0008 在 #580 起改为默认空基)。
为了让「没有类型」与「人明确说没有类型」可区分,落库新增entities.type_source(extracted/human/inferred)。这正是 0001 P4a 的治理语义:一个人拍板「此实体无类型」是一条人类决策,必须与引擎「还没判出来」分开记账,不能被类型消解的取样规则重新审判。
核心决策二:NULL <> uuid的静默陷阱,以及IS DISTINCT FROM的修复
决策二是全文最有工程味道的一节:entities.type_id可空化后,Rust 编译器会把每个消费方列出来(Option强制你处理),但 SQL 不会。
PostgreSQL 的三值逻辑里,NULL <> $2求值为NULL,在WHERE里当假处理——于是查询静默返回零行,且不抛任何错误。这条主路径上恰好撞了两处:
adopt_proposed_types(认领):带proposed_type的实体几乎全是还没判出类型的,NULL <> uuid让整批认领空转;retype_entities(逐实体改类):最常见的改类场景就是「无类型 → 有类型」,同样被滤光。
实测对比:<>选中 0 行,IS DISTINCT FROM选中 1 行。真正的成本是要把 26 条 SQL 字符串里的比较逐个重读,改动横跨 14 个文件,净变化+316 / -369。源码中这两处修复的注释都直接点名 0009:
- resolution.rs 的跨类型候选查询:「
IS DISTINCT FROM而不是<>:后者遇 NULL 返回 NULL,被 WHERE 当假,未分类实体会被整个漏掉(0009)」; - resolution.rs 的认领批次:「偏偏带着 proposed_type 的几乎全是还没判出类型的实体,整个认领功能会一声不响地空转」。
0010 的修订记录里有一句总结性判断,可作为本节的注脚:「SQL 对编译器不可见」在本仓库已经是第八次出现,所以只要 SQL 字符串变了,数据库测试就不是可选项。这也是IS DISTINCT FROM这类修复必须配上回归测试的原因。
核心决策三:未分类同名实体允许共存——这不是 bug
第三决策是一个被刻意记录的边界:唯一索引(kb_id, type_id, lower(canonical_name)) WHERE merged_into IS NULL挡不住两个未分类的「张三」,因为NULL ≠ NULL不成立。
这是有意为之:
- 0001 P0 已经允许同名不同类共存(两个「张伟」必须能分开存储);
- 未分类时我们掌握的信息更少,更没有理由合并。
所以两个无类型的同名实体天然可以并存,等类型消解或人工裁决去区分它们。决策记录明确写上「Recorded so nobody files it as a bug」——先把预期讲清楚,避免将来被当成缺陷上报。
核心决策四:图查询从JOIN改成LEFT JOIN,别让实体在画布上消失
决策四处理的是最隐蔽的数据丢失:节点查询原来用JOIN entity_types,内连接会让未分类实体从图中凭空消失,而它的事实还在——实体没了、事实挂着,这是最难被察觉的损坏。
- 节点查询与 Review 条目统一改为
LEFT JOIN; key与label保持 NULL;- 颜色与形状使用默认值(灰色圆点:画布必须收到一个可渲染的东西)。
同样的教训在关系侧 0010 复现:facts.predicate_id可空后,20 个内连接里编译器看不到任何一个,11 个改成LEFT JOIN加回退,另外 3 个被数据库抓住(r.label缺 COALESCE、CTE 外引用、陈旧rt.id)。两边合起来看,规则很一致:可空列出现后,读路径上的每一个内连接都是潜在的静默过滤器。
核心决策五:首次定类是一次「完成」,entity_retypes.from_type_id可空
决策五定义了类型消解对未分类实体的语义:类型消解把「选中的类在当前子树之外」当作重分类并交给人类;但一个未分类实体没有抽取判断可推翻,若按跨轴去裁决它,等于把每个实体都推到人面前。
于是:
entity_retypes.from_type_id可空——最常见的改类就是「无 → 有」;- 这张表是撤销的唯一依据(
unadopt_types靠它原样撤回,见 resolution.rs 的账本注释:「旧类型要从 CTE 里读,UPDATE … RETURNING给的是新值,而账本要记的是改之前那个」)。
在 crates/utopia-store/tests/human_type_decisions.rs 里,四条断言分别守住四条路径:类型消解取材不捞人拍过板的、本体长出类后的认领不覆盖人拍过板的、抽取升格不给「人说过就是没有类型」的实体安类型、retype_entities用现成actor参数区分human/inferred——这是 0009 与 0001 P4 的交叉点。
核心决策六:CONFUSABLE_TYPE_KEYS降级为兜底,disjointWith上位
决策六保留了硬编码易混表CONFUSABLE_TYPE_KEYS = ["organization", "project", "product"],但把它从主角降为兜底:
- 装 schema.org 后这三个 key 依然存在,来源是导入而非播种;
- 没装包时这一档根本不会命中,于是每个跨类型同名对都判
Disjoint——更严格,不会错合; - 治本方案是读本体里的
owl:disjointWith。
0016 B3 落地后,消解的判据变成三层(resolution.rs 的classify_type_drift与TypeDrift枚举):
- 本体声明优先:
declared_disjoint_from用一条递归查询收集「与提及类或其任一祖先声明互斥的类」,再沿子链展开到所有后代——Person ⟂ Organization一条声明,就让两边所有子类互相隔开(表结构见 migrations/0003_graph.sql 的entity_type_disjoint,双向各存一行);两条跨类型路径(类型漂移与包含关系)都在亲缘检查和硬编码表之前先看它,命中即Disjoint; - 类层级亲缘(#226):同支系(祖先、后代或共享非根祖先)的同名实体进 Review;
- 硬编码表兜底:什么都没声明时,才轮到
CONFUSABLE_TYPE_KEYS。
有专门测试守护这条优先级:a_declared_disjointness_keeps_names_apart.rs 验证「声明优先于前两层」——未声明时 organization vs project 照硬表进队列,声明后同名分开不进队列。
死胡同的代价:哨兵行的三次失败
决策记录里的「Dead ends」是理解这条设计的关键:
- 保留哨兵、改名为
_unclassified(初稿)。它本可以成立:key_from_iri从不产生下划线开头的 key(实测skos:Concept → concept、http://x#_Concept → concept、http://x#__unclass → unclassified,见 crates/utopia-ingest/src/ontology_rdf.rs 的实现与单测),命名空间对导入不可达。但改名只修碰撞、修不了泄漏:哨兵必须从每个消费方过滤——本体页、提示词、候选列表、图例、导出、统计——而任何一个忘记WHERE key NOT LIKE '\_%'的地方都会静默失败。命名约定替代类型保证,正是ontology_index.rs警告的那类缺陷(「self-heal, don't hang hooks」)。讽刺的是,0009 记下了一个具体的劫持路径:哨兵没有 IRI,导入会认领无 IRI 的占位符,skos:Concept将接管哨兵,所有未判定实体静默变成真正的skos:Concept。 - 「NULL 不会被忘记——SQL 和类型系统会告诉你」:只对了一半,SQL 那一半就是决策二。
修订史里的两次回摆:metric/dimension的教训
0009 的修订记录追加了两轮对metric/dimension的观察,它们是「控制流进本体」这一物种的活标本:
- 2026-09-02 发现显性成本:映射探索查询
entity_types.key IN ('metric','dimension'),缺失时continue——没装这两个类的 KB 静默产出零映射; - 2026-09-03 #231 先修症状:探索前把两者当内置类型创建;
- 2026-09-09 复盘承认「修了症状、留了错误」:
Metric/Dimension不是世界上的东西,而是列的种类。在展平的订单表上实测,探索把 40 个概念实体里的 28 个列名都登记成了这些类的实体,可用定义 0/18 命中。于是 0036-exploration-aligns-a-schema-to-the-ontology 决定让两者退役:概念变成真实类的属性、或属性之上的规则,映射只是「列如何成为该属性值」。
这印证了 0009 自己的论点:一个充当控制流的类,就该离开本体——先有concept,再有metric/dimension,同样还有关系侧的related_to/mapped_to。
落地全景:这条决策线的完整文件地图
| 关注点 | 位置 |
|---|---|
| 决策正文与修订史 | docs/decisions/0009-no-type-is-a-type.md |
| 关系侧孪生决策 | docs/decisions/0010-no-relation-is-no-relation.md |
| 空基冷启动 | docs/decisions/0008-ontology-packs-as-cold-start.md |
| 本体导入与治理(IRI/key、P4a) | docs/decisions/0001-ontology-import-and-governance.md |
metric/dimension退役 | docs/decisions/0036-exploration-aligns-a-schema-to-the-ontology.md |
消解实现:三层判据、IS DISTINCT FROM、候选查询 | crates/utopia-store/src/resolution.rs |
| 类型消解的预览与执行入口 | crates/utopia-server/src/type_resolution.rs |
entity_type_disjoint表 | migrations/0003_graph.sql |
key 派生规则(key_from_iri) | crates/utopia-ingest/src/ontology_rdf.rs |
| 声明互斥优先级的回归测试 | crates/utopia-store/tests/a_declared_disjointness_keeps_names_apart.rs |
| 人类决策与类型消解的四条守卫 | crates/utopia-store/tests/human_type_decisions.rs |
| 类型消解候选的等待轮次测试 | crates/utopia-store/tests/a_judged_entity_waits_its_turn.rs |
遗留问题:未分类实体的消解质量
决策记录最后留下两个开放问题:
- 未分类实体的消解:画像(profile)没有类型维度,
classify_type_drift少了一个判据。两个未分类同名实体会落到Recall档,靠画像相似度分层(可 ATTACH),是否够用尚未测试; metric/dimension的归宿:目前是「按需内置」。一个未来的「Utopia 语义层」包会把最后的内置类移出代码,变成带 IRI、可替换的可选包(0016 D2)。
小结
0009 用一个看似简单的「删类」动作,牵出了本仓库最系统的一轮防御性改造:SQL 三值逻辑(IS DISTINCT FROM)、读路径内连接(LEFT JOIN)、撤销账本的可空起点(entity_retypes.from_type_id)、消解判据的声明优先(disjointWith三层判据)。它的主旨可以浓缩为一句话:「还没决定」是数据的一种合法状态,不该伪装成一个被决定过的词;所有消费它的路径,都必须显式地准备好迎接 NULL。
- 后端
- 前端
- 人工智能
- RAG
- 知识图谱
- 知识管理
- 搜索引擎
【免费下载链接】utopia
World's first open-source enterprise world model.
相关推荐
Google Maps Java客户端社区贡献指南:如何参与开源项目开发
Google Maps Java客户端社区贡献指南:如何参与开源项目开发 想要为 Google Maps Java 客户端库贡献代码,却不知从何入手?😊 这份
后端开发工具无需后端,浏览器秒开地理空间数据库:PGlite几何类型全解析
无需后端,浏览器秒开地理空间数据库:PGlite几何类型全解析 地理空间应用的前端困局 你是否遇到过这些场景:地图应用加载缓慢、离线状态下无法查询位置数据、前端
数据库嵌入式数据库WebAssemblyHeadroom with_memory 一行集成:零延迟内联记忆提取的 Letta 式方案完整解析
Headroom with_memory 一行集成:零延迟内联记忆提取的 Letta 式方案完整解析 🧠 Headroom 的 with_memory 让任何
人工智能LLM 网关AI 应用
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考