ApFramework 标志
发布日期

LinkML 与 OWL 实践:从 PostgreSQL 整理业务领域模型

作者
  • 作者头像
    姓名
    Shoukai Huang
    身份
    Software Architect · Indie Developer
    Twitter
LinkML 与 OWL 本体建模实践:从 PostgreSQL 关系表整理领域模型并生成本体关系图的概念插画
从关系表到领域模型与本体关系图

数据库里已经有表、有外键、有数据,为什么还要整理一份领域模型?这次探索从一个很具体的问题开始:“远山制造的项目里有哪些员工?”

项目表能关联员工,项目参与表也能关联员工,任务表还可以找到执行人。三条路径都成立,查出的却是三种关系。如果没有把“负责项目”“参与项目”“执行任务”分别定义,模型即使写出可以运行的 SQL,也可能回答了另一个问题。

我们用七张 PostgreSQL 表走一遍建模过程:先认识 LinkML,再把表结构、样本和业务说明整理成 domain.yaml,最后用官方生成器得到 domain.owl.ttl。过程中会用到 LLM,但每一次命名、约束和关系选择都需要说明依据。最终还要打开生成文件,检查那些看起来已经完成的转换。

1. 背景:为什么有了数据库,还需要领域模型

1.1 一个业务问题,三条数据路径

在项目101“设备知识平台”中,陈晨是项目负责人,林晓、王宁等人有参与记录,部分任务则分别交给其中的员工执行。任务2004“上线评审”尚未指派。

假设把问题解释为“谁是负责人”,连接路径是:

SELECT p.name AS project, e.name AS owner
FROM projects AS p
JOIN customers AS c ON c.id = p.customer_id
JOIN employees AS e ON e.id = p.owner_id
WHERE c.name = '远山制造';

若解释为“谁有参与记录”,路径就变了:

SELECT p.name AS project, e.name AS participant,
       m.role_code, m.allocation_pct, m.joined_on, m.left_on
FROM projects AS p
JOIN customers AS c ON c.id = p.customer_id
JOIN project_memberships AS m ON m.project_id = p.id
JOIN employees AS e ON e.id = m.employee_id
WHERE c.name = '远山制造';

这两段 SQL 用于说明连接含义,第二段没有筛选有效时点。如果问的是“当前参与”,还需要确定一个日期,并检查 joined_on <= 日期,以及退出日期为空或不早于该日期。是否将退出当天算作有效参与,也必须有约定。

因此,建模输入至少包含四组材料:表结构、具体事实、业务定义和待确认的问题。外键能告诉我们两个值怎样关联,业务定义进一步说明这种关联意味着什么。

1.2 这次沿哪条路线工作

从数据库材料到LinkML模型、OWL以及实例数据的两条工作路线

图1:本案例工作流程示意。模型生成和实例映射分别进行,交汇处仍需核对标识、类和属性。

这里有两条相关但独立的路线。

模型路线从 DDL、样本和规则开始。人工与 LLM 整理概念、关系和约束,形成 LinkML schema,再由 gen-owl 生成 OWL。实例路线从数据库读取具体记录,通过映射形成 JSON 或 RDF。只有两条路线在名称、URI、取值表示等方面对齐,实例才能按预期使用模型。

本文重点检查五件事:

问题对应的检查材料
自动逆向能保留哪些结构?DDL、逆向草稿、差异表
LLM依据什么提出模型?输入包、提示词、建模决策和疑问清单
关系含义有没有写清楚?三种员工关系、Participation及其属性
LinkML怎样生成OWL?YAML、完整命令、日志、TTL片段
生成结果是否符合预期?文件解析、结构核对、实例表示与约束偏差

《为 AI Agent 构建企业语义层:从可信查询到受控行动》讨论了对象、关系、时间口径和合法查询路径。本篇先把其中的对象与关系落实到一份可读、可生成的模型,后续查询映射另行展开。

2. 认识 LinkML 与相关工具

2.1 LinkML:用一份模型表达共同的数据定义

LinkML提供建模语言及配套工具链。模型通常写成 YAML,可以描述类、属性、数据类型、关系、枚举和约束,再生成不同技术框架中的表示。

例如,“项目有一个负责人,负责人是员工”可以先写在模型里。需要 JSON 数据约束时生成 JSON Schema,需要本体表示时生成 OWL。不同目标语言的表达能力和执行方式不同,每一种输出还需单独检查,不能假定所有声明都被原样保留。

使用 LinkML 不需要先启动关系数据库。我们可以先设计 schema,再按模型组织数据;也可以像本例一样,面对已经存在的表,整理一份更贴近业务的模型。关系数据库是这次的输入来源,而非 LinkML 的前提。

2.2 LinkML schema:文件中的每一层在做什么

schema 描述一类数据允许怎样组织,实例则是一条具体记录。“员工有姓名、邮箱和所属部门”属于 schema;“林晓属于平台组”属于实例。

先看本例会反复使用的构件:

构件含义本例写法
classes定义类Employee、Project
slots定义可复用的属性多个类共用的id、name
attributes在类内声明属性Project.owner
range属性值的类型或目标类decimal、Employee
required声明数据中该项必需项目负责人必填
multivalued声明可有多个值数据集中的员工集合
identifier声明标识属性id
enums定义允许值项目状态
is_a继承父类定义员工继承NamedEntity
abstract将类标记为抽象共享父类

attributes中的属性同样属于 LinkML 的 slot 建模机制。将公共字段放入顶层 slots,可以在多个类中复用;只在一个类附近定义的属性,可以直接写在该类的 attributes 下。

模型文件开头还有一组元信息:

id: https://example.org/work/schema
name: work_domain
prefixes:
  linkml: https://w3id.org/linkml/
  ex: https://example.org/work/
imports:
  - linkml:types
default_prefix: ex
default_range: string

id是schema本身的标识,name是模型名称。prefixes将短前缀关联到URI,imports引入标准类型定义。default_range: string意味着没有另行指定范围的属性默认按字符串处理。因此,日期、金额、整数和对象关系需要显式声明。

ex:employee/2是使用前缀的紧凑标识,可以展开为https://example.org/work/employee/2。这里的URI用于稳定地标识对象,不要求访问这个地址时一定能打开网页。员工表中的整数2需要通过映射规则变成这个标识,不能指望生成器猜出业务对象的URI方案。

2.3 从官网 Person 示例动手

官方第一篇教程用一个Person类说明最小工作流。以下先保留它的简单结构,建立工具直觉。

在终端中新建目录和虚拟环境:

mkdir linkml-tutorial
cd linkml-tutorial
python3 -m venv venv
source venv/bin/activate
pip install linkml
linkml-convert --help

后续文件放在这个目录。安装命令没有固定版本;若用于可复现实验,应另外记录实际安装版本。本文业务案例的固定版本列在第4.7节,不能与此处的一般安装步骤混用。

创建personinfo.yaml:

id: https://w3id.org/linkml/examples/personinfo
name: personinfo
prefixes:
  linkml: https://w3id.org/linkml/
  personinfo: https://w3id.org/linkml/examples/personinfo
imports:
  - linkml:types
default_range: string
default_prefix: personinfo

classes:
  Person:
    attributes:
      id:
      full_name:
      aliases:
      phone:
      age:

这里并没有给age指定整数范围,也没有将aliases声明为多值属性。名字本身不会自动改变字段定义,当前它们都按缺省字符串处理。这个最小schema也没有把id标为identifier,不要把这个入门例子误读成完整业务模板。

再创建data.yaml:

id: ORCID:1234
full_name: Clark Kent
age: "32"
phone: 555-555-5555

这次看到了具体数据。age写成字符串,与当前schema一致。运行:

gen-json-schema personinfo.yaml
linkml-validate -s personinfo.yaml data.yaml
linkml-convert -s personinfo.yaml data.yaml -t ttl

第一个命令生成JSON Schema。官网输出中的属性片段如下,保留了age的字符串或空值类型:

"age": {
  "type": ["string", "null"]
}

第二个命令校验实例,官网预期是没有验证问题。若在实例中额外加入:

made_up_field: hello

官方教程说明,验证会报告这个字段不被允许。这样就能观察到schema如何约束输入。

第三个命令将实例转成Turtle。官网输出使用匿名节点,片段如下:

@prefix personinfo: <https://w3id.org/linkml/examples/personinfo> .

_:c14n0 a personinfo:Person ;
    personinfo:age "32" ;
    personinfo:full_name "Clark Kent" ;
    personinfo:id "ORCID:1234" ;
    personinfo:phone "555-555-5555" .

a是rdf:type的简写,表示这个节点属于Person。匿名节点的具体标签不应被视作稳定业务标识。

这几段输出来自官网,不是本文写作时的本机执行结果。它们已经能帮助我们分清工具任务:生成器接收模型,验证器读取模型与数据,转换器则把具体数据换一种表示。

模型生成、实例验证与实例转换的三条独立管线

图2:schema生成JSON Schema或OWL;数据实例和schema分别进入验证或转换流程。三者的输入输出不同。

2.4 OWL、RDF、Turtle:目标文件包含什么

RDF用“主语—谓语—宾语”组织信息。例如,某个项目的负责人是员工1:

@prefix ex: <https://example.org/work/> .

<https://example.org/work/project/101>
    ex:owner <https://example.org/work/employee/1> .

这是一条具体事实。ex:owner是谓语,两个完整URI分别标识项目和员工。

OWL提供表达类、属性和逻辑公理的语言。下面声明一个类:

@prefix ex: <https://example.org/work/> .
@prefix owl: <http://www.w3.org/2002/07/owl#> .

ex:Project a owl:Class .

两段都能写成Turtle。Turtle只是文本语法,.ttl扩展名本身不能说明文件里放的是实例还是本体。.owl.ttl是常用命名习惯,便于提示文件主要包含OWL表示。

在《本体语言与应用:从描述逻辑到OWL》中,类、对象属性、个体和公理被放在同一个车辆案例里解释。本文沿用“先看写下了什么,再看它能够保证什么”的阅读方式;形式语义以W3C OWL 2 Primer及规范为依据。

2.5 gen-owl:把模型生成为什么样的本体

gen-owl是LinkML工具链中的OWL生成器。最小调用是:

gen-owl my_schema.yaml

输入是schema,输出描述schema的OWL表示。它可以把类转换为OWL类,把关系转换为属性,把部分类型与数量声明表达为逻辑限制。本例后面会看到Employee、Participation、employee以及“值属于Employee、恰好一个值”等结构。

如果数据库里有六名员工,运行上面的命令并不会自动连接数据库、读取六行,再生成六名员工的事实。抽取和实例转换需要另外完成。

OWL生成器文档还提供元类、类型对象、原生URI及枚举表示等选项。不同调用方式和版本的默认值应分别核对;本例使用显式参数,并以实际输出作为判断依据。

2.6 生态里还有哪些工具

工具具体用途本例怎样使用
LinkML Runtime / SchemaView在程序中读取和查看schema,例如类与属性定义自编脚本据此读取模型
Schema Automator从已有数据或结构引导形成schema草稿,支持SQL等来源用SQL Python API反射七张表
gen-json-schema将schema生成为JSON Schema官网Person入门示例
linkml-validate按给定schema校验实例官网Person入门;未用于本例全量验证
linkml-convert在模型指导下转换实例表示官网Person转Turtle示例
gen-owl将schema生成为OWL本例两份schema分别生成
OntoGPT按给定schema从自由文本抽取结构化内容,并支持本体对齐介绍用途,本例未引入

Schema Automator的定位是辅助起步和增强schema。自动导入减少结构录入工作,业务关系仍需整理。

OntoGPT的典型输入是LinkML schema和自由文本:schema规定抽取目标,文本提供内容。它不构成本例“DDL自动变成本体”的必要步骤。这里直接在当前对话中借助LLM整理模型,没有再接一个模型API。

另外,RDFLib用于读取和检查RDF;WebVOWL和Protégé用于阅读本体。它们是配套工具,不属于把业务含义写进schema的替代步骤。可视化阅读留在下一篇展开。

3. 案例数据:先看表、记录和规则

3.1 七张表如何连接

案例数据库名为linkml_demo,保存一个小型项目管理场景。

表业务内容行数
departments部门及直接上级4
employees员工、部门、直属经理6
customers客户组织2
project_statuses项目状态代码与名称3
projects项目、客户、负责人、预算与日期4
project_memberships项目参与、角色、比例和有效期9
tasks项目任务、执行人和截止日8

共36行。状态表的三行稍后建模为枚举,其他六类记录合计33个业务实体。这两个计数有不同口径。

七张表的完整字段分组及三种项目员工关系路径

图3:上部按业务分组列出七张表及完整字段;下部将负责人、参与员工和任务执行人拆成独立外键路径。下部重复出现的表名是路径引用,不是新增表。

3.2 三张核心表的真实结构

项目表保存客户、负责人及生命周期:

CREATE TABLE projects (
 id integer PRIMARY KEY,
 name text NOT NULL,
 customer_id integer NOT NULL REFERENCES customers(id),
 owner_id integer NOT NULL REFERENCES employees(id),
 status_code text NOT NULL REFERENCES project_statuses(code),
 budget numeric(12,2) NOT NULL CHECK (budget >= 0),
 start_date date NOT NULL,
 end_date date,
 CHECK (end_date IS NULL OR end_date >= start_date)
);

owner_id非空,引用员工;budget以数值保存,不能为负;结束日期允许为空,但若填写就不能早于开始日期。DDL还没有解释“负责人是否必须同时拥有参与记录”。

参与表把多对多关系保存成独立记录:

CREATE TABLE project_memberships (
 id integer PRIMARY KEY,
 project_id integer NOT NULL REFERENCES projects(id),
 employee_id integer NOT NULL REFERENCES employees(id),
 role_code text NOT NULL CHECK (role_code IN ('lead','engineer','analyst')),
 allocation_pct integer NOT NULL CHECK (allocation_pct BETWEEN 1 AND 100),
 joined_on date NOT NULL,
 left_on date,
 UNIQUE (project_id, employee_id, joined_on),
 CHECK (left_on IS NULL OR left_on >= joined_on)
);

这张表不只是两个外键。角色、投入、加入和退出日期都属于某次参与。唯一约束允许同一员工在不同开始日期有多次参与记录,不能简单压成员工与项目之间的一条无属性边。

任务表中的执行人可以为空:

CREATE TABLE tasks (
 id integer PRIMARY KEY,
 project_id integer NOT NULL REFERENCES projects(id),
 assignee_id integer REFERENCES employees(id),
 title text NOT NULL,
 status_code text NOT NULL CHECK (status_code IN ('todo','doing','done')),
 estimated_hours numeric(6,2) NOT NULL CHECK (estimated_hours >= 0),
 due_date date NOT NULL
);

这里外键只保证执行人存在于员工表。执行人是否在任务截止日参与该项目,需要额外规则和代码检查。

其余表的关键关系也会进入模型:员工的department_id指向部门,manager_id指向另一名员工;部门的parent_id指向直接上级部门。DDL禁止直接指向自身,但不能据此声称已经排除了所有多层循环。

3.3 把样本放到眼前

员工数据如下。经理列保留员工ID,便于与外键对应。

ID姓名部门经理ID入职日期
1陈晨研发部空2020-03-01
2林晓平台组12021-06-15
3王宁平台组12022-02-10
4赵敏数据部空2020-08-01
5周宇数据部42023-07-01
6李悦研发部12024-01-15

部门层级为“示例科技→研发部→平台组”,数据部直属示例科技。“研发部有哪些员工”若要包含平台组,就要明确层级展开规则;只连接员工的直接部门会得到另一种答案。

四个项目如下:

ID项目客户负责人状态预算(元)起止日期
101设备知识平台远山制造陈晨active8000002026-01-01至2026-12-31
102客户数据治理星海零售赵敏active4500002026-04-01至2026-11-30
103指标体系一期星海零售赵敏completed1800002025-06-01至2025-12-31
104供应链语义集成远山制造陈晨planned6500002027-01-01起,结束未定

九条参与记录是理解本例最重要的数据:

记录ID项目员工角色投入比例加入日期退出日期
1001101陈晨lead30%2026-01-01空
1002101林晓engineer60%2026-01-01空
1003101王宁engineer80%2026-02-01空
1004101周宇analyst20%2026-04-01空
1005102赵敏lead40%2026-04-01空
1006102周宇analyst70%2026-04-01空
1007102林晓engineer40%2026-05-01空
1008103赵敏lead30%2025-06-012025-12-31
1009103周宇analyst60%2025-06-012025-12-31

若以2026-11-05为教学示例的观察日期,林晓的两条记录都已开始且未退出,投入分别为60%和40%。这个日期用于解释有效期条件,不代表重跑了保存的查询。这只能说明样本中的情况。表注释明确没有约束跨项目投入合计,不能从恰好100%推导出一条全局规则。

任务数据完整列出如下,待处理状态的代码定义见后文完整YAML:

ID项目任务执行人状态预估工时截止日期
2001101建立设备模型林晓done322026-08-15
2002101接入设备主数据王宁doing482026-11-10
2003101核对设备术语周宇待处理162026-11-20
2004101上线评审空待处理82026-12-15
2005102客户字段语义对齐周宇doing242026-11-05
2006102编写质量校验林晓待处理202026-11-18
2007103指标验收赵敏done122025-12-20
2008104确认供应链概念范围空待处理162027-01-15

项目状态、参与角色、任务状态使用不同代码集合。虽然都有类似“状态”的名字,也不能合并成一个含义模糊的字段。

3.4 事实、规则和疑问分别保留

整理输入时,建议建立这样一张表:

内容依据当前处理
项目负责人必填owner_id NOT NULL及项目说明Project.owner必需
任务可以暂不指派可空外键及样本2004、2008Task.assignee可选
投入范围1至100DDL的CHECK单条参与记录范围
退出不早于加入DDL的CHECK跨字段日期规则,另行检查
执行人应在截止日参与项目案例验证代码中的规则标为本例应用检查,并确认业务认可
owner必须等同于lead样本碰巧一致,未有约束待确认,不能自动增加
所有人跨项目投入合计≤100%没有依据,注释明确未约束不加入模型

把最后两项先留下来,比生成一份看起来毫无疑问的YAML更有用。后来发现口径变化时,也能知道需要改的是哪条决定。

4. 从表和规则整理领域模型

4.1 先准备给 LLM 的材料

如果只贴七张表并说“生成本体”,LLM需要猜很多事情:类名沿用表名还是改成业务词,状态是词表对象还是枚举,投入比例是单项目还是全局约束,任务能否没有执行人。

本例把材料分成四段交给模型:

建模目标
为项目管理数据整理一个可审阅的LinkML模型,随后生成OWL。
模型要区分负责人、参与者和任务执行人。

结构材料
完整DDL、主外键、NOT NULL、CHECK、唯一约束及表列注释。

事实材料
员工、项目、参与和任务样本;包含未指派任务、结束参与记录。

业务说明
字段单位、关系含义、有效期约定、已确认规则及待确认问题。

输入包中还要写明:生成的是模型候选,不直接改数据库;每条新增关系或约束需要有来源;未知内容列为问题。这样才能判断LLM整理得好不好,而不只判断YAML是否漂亮。

4.2 自动逆向先给出一份结构起点

本例也使用Schema Automator的官方SQL Python API读取数据库。核心调用可以归纳为:

from schema_automator.importers.sql_import_engine import SqlImportEngine
from linkml_runtime.dumpers import yaml_dumper

engine = SqlImportEngine()
try:
    schema = engine.convert(connection_url)
finally:
    if engine.engine is not None:
        engine.engine.dispose()

yaml_dumper.dump(schema, to_file="database-import.yaml")

connection_url由连接环境构造,避免将密码写进文章或结果文件。完整脚本使用SQLAlchemy的URL对象,反射linkml_demo。这个步骤需要数据库可访问;读取已有YAML生成OWL则不需要重新连接数据库。

导入后,七张表形成七个类,列形成属性。与人工业务模型相比,本次保存的草稿有这些差异:

数据库输入此次导入观察后续处理
project_memberships保留表中心的名称和字段解释为Participation
DATE观察到范围为string人工明确为date
NUMERIC观察到范围为string金额、工时明确为decimal
必填及CHECK等约束没有完整表达业务模型所需信息对照DDL逐项整理
标准类型引用原始草稿缺标准类型导入prepared副本补linkml:types

原始文件保留为database-import.yaml,补类型导入的副本为database-import-prepared.yaml。后者只是让类型引用更完整,没有自动修复业务含义或所有约束。

这组结果限定于本例的Schema Automator 0.5.7及调用配置。命令行入口当时遇到EML单位换算相关依赖错误,因此改用官方Python API。API完成反射,不意味着CLI故障已解决。

4.3 第一轮提示词:先要求依据,再输出 YAML

下面是一份可以复用的教学提示词。它是依据本案例材料整理的教学输入,本轮没有调用LLM执行该提示词;现有domain.yaml来自此前建模过程,不能据此声称它是该提示词的模型输出。为了让该提示词本身可执行,使用时要在同一上下文中实际附上完整DDL、样本和业务说明,而不能只贴表结构。

你协助我整理项目管理领域的LinkML模型。模型随后用于生成OWL,
也会作为人工审阅业务概念与关系的材料。

输入材料:
1. 上文七张表的完整DDL与注释;
2. 员工、项目、参与记录和任务样本;
3. 下列业务说明及待确认问题。

已确认说明:
- Project.owner表示项目负责人。
- Participation.employee表示某次参与的员工。
- Task.assignee表示具体任务执行人,允许为空。
- Participation需要保存角色、投入比例、加入与退出日期。
- allocation_pct单位为%,范围1至100,仅约束单条参与记录。
- budget_cny单位为人民币元;estimated_hours单位为小时。
- 部门关系表示直接上级,不默认定义传递推理。
- 允许缺省标量范围继承顶层name slot。Task.title可映射为Task.name,但不得同时保留两个独立字段。
- ProjectStatus、ParticipationRole、TaskStatus的枚举建模是本案例候选方案,不代表DDL本身已证明它们在业务上不可扩展;请将此点列为建模选择。
- NamedEntity与Dataset是模型组织选择,不是数据库表;请逐项检查继承后的属性是否适用于各子类。

请分五部分输出:
A. 业务类、关系与术语候选,每项注明DDL、注释或业务说明依据。
B. 表和字段到模型元素的映射表,包括类型、必填性及重命名。
C. 歧义和待确认问题;材料不足时明确写未知。
D. 一份完整domain.yaml,包含前缀、linkml:types、枚举、类和slot。
E. 可用schema表达的声明,以及需数据库或应用代码检查的规则。

模型组织要求:
- 将Department、Employee、Customer、Project、Participation、Task作为业务类候选;逐项说明为何将project_memberships抽象为Participation。
- NamedEntity为可选的抽象复用方案。特别检查继承的name是否会错误要求Participation提供名称;如会,给出最小调整,不要为了沿用草稿而牺牲语义。
- Dataset仅作为实例集合容器候选,六类业务记录分别为列表;说明它不是DDL中的表,也不是OWL业务概念。
- 对象标识使用ex:employee/2等稳定CURIE,说明数据库ID到CURIE的映射方式。
- 区分项目状态、参与角色、任务状态三个候选枚举,并指出使用枚举是本案例设计选择,是否适合需由业务维护方式确认。

不要根据样本增加全局投入≤100%的规则。
不要假定owner与lead角色必然相同。
不要把外键都重命名为“负责”,不要删除关系上的有效期和比例。
不要执行数据库操作。先输出候选,不声称已经验证或生成OWL。

这里仍有一部分预先约定的模型组织方式,但它不应变成必须服从的答案:提示词明确允许NamedEntity和Dataset作为候选,也要求检查继承影响。对照当前schema后,Participation没有继承NamedEntity,因此name不是它的必填属性;若照提示词的旧写法让Participation继承NamedEntity,name会随继承进入其诱导slot,且为必需,这与“Participation只有id,不强加name”冲突。小规模SchemaView对照确认了这一点。用于早期探索时,还可以要求两三种候选设计,并比较参与记录作为实体和直接关系各自会保留或丢失什么。

4.4 第二轮提示词:专门找模型里的问题

初稿生成后,可以再给一次不同任务:

请审查上面的domain.yaml,不要直接重写全文。

逐项对照DDL、样本和业务说明,输出:
1. 遗漏的字段、错误类型、错误必填性及关系角色混淆;
2. 没有依据却新增的规则;
3. 标识、前缀、引用和枚举是否自洽;
4. NamedEntity与Dataset是否被误认为业务表;
5. 哪些约束无法仅靠当前YAML完成实例检查;
6. 与已有RDF实例的class_uri/slot_uri及枚举表示可能出现的差异。

每个问题给出位置、依据、影响与最小修改建议。
无法由输入确定的内容列为待确认,不自行决定。
不要把“可以解析”写成“业务正确”,不要声称运行了任何工具。

第二轮依然是辅助审查。把它用于已存在的domain.yaml时,模型可能会指出继承语义、字段遗漏、URI差异或生成器映射问题;这些是审查候选,不等于已证实缺陷。小规模实测中,现有YAML通过LinkML 1.12.1元模型校验;SchemaView显示Participation诱导slot包含必需的id、project、employee、role、allocation_pct、joined_on以及可选left_on,不含name。用参数化gen-owl生成到stdout时,能看到相应关系限制与枚举类,也出现schema前缀从HTTPS映射到HTTP的警告和共享project属性歧义警告。金额/工时范围中decimal与非负integer交集也再次出现。YAML能否被工具读取要实际解析;生成的OWL是否与预期一致要检查输出。元模型通过不等于业务正确,生成命令成功也不等于语义无损;模型自己写下的“已验证”不构成执行证据。

4.5 人工审核:Participation 为什么要保留下来

林晓的两次项目参与以及独立的项目负责人关系

图4:依据样本重绘的关系实体示意。角色、比例和日期属于某条参与记录,项目负责人另行定义。

林晓是一名员工,但参与1002和参与1007是两条不同记录。前者连到项目101,投入60%;后者连到项目102,投入40%。若只写“林晓参与项目101、102”,两个比例及对应日期就失去清晰的附着位置。

因此把project_memberships整理为Participation,由它关联项目和员工,再保存关系上的属性。模型决策可以写成下面这样:

输入模型决定理由
projects.owner_idProject.owner → Employee对应负责人
project_memberships.employee_idParticipation.employee → Employee对应某次参与的员工
tasks.assignee_idTask.assignee → Employee,可选对应任务执行人
参与表上的角色、比例、日期保留在Participation它们描述参与过程
状态代码表三行ProjectStatus枚举本例将其视为有限取值
tasks.titleTask.name在模型中统一可命名业务对象
部门、员工等共有id/nameNamedEntity抽象父类复用定义

将状态表转为枚举是一项设计选择。如果状态本身有维护流程、多个版本、额外属性或可扩展词表,也可能更适合独立建模。这里的三个值足够简单,因此采用枚举。

NamedEntity这个名字曾让人误解。它只是本例自定义的抽象父类,共享标识和名称,不是自然语言处理中命名实体识别的结果,也没有对应第八张业务表。Dataset同样用于组织数据交换,不代表额外业务对象。

4.6 最终 domain.yaml

下面完整列出案例保存的domain.yaml。它表达主要字段与选定的模型声明,数据库中的邮箱唯一、参与组合唯一和跨字段日期先后等规则没有全部编码到这里。保留外部词汇映射,后面再观察它们在不同实例映射与生成参数下的表现。

id: https://example.org/work/schema
name: work_domain
prefixes:
  linkml: https://w3id.org/linkml/
  ex: https://example.org/work/
  schema: https://schema.org/
default_prefix: ex
default_range: string
imports:
  - linkml:types

enums:
  ProjectStatus:
    permissible_values:
      planned: {description: 计划中}
      active: {description: 进行中}
      completed: {description: 已完成}
  ParticipationRole:
    permissible_values:
      lead: {description: 项目负责人角色}
      engineer: {description: 工程师}
      analyst: {description: 分析师}
  TaskStatus:
    permissible_values:
      todo: {description: 待处理}
      doing: {description: 进行中}
      done: {description: 已完成}

slots:
  id:
    identifier: true
    required: true
    range: uriorcurie
  name:
    required: true
    slot_uri: schema:name

classes:
  NamedEntity:
    abstract: true
    slots: [id, name]
  Department:
    is_a: NamedEntity
    description: 内部组织单元;parent_department 表示直接上级,不预设传递推理。
    attributes:
      parent_department: {range: Department}
  Employee:
    is_a: NamedEntity
    class_uri: schema:Person
    attributes:
      email: {required: true, pattern: '^.+@.+\..+$'}
      department: {range: Department, required: true}
      manager: {range: Employee}
      hired_on: {range: date, required: true}
  Customer:
    is_a: NamedEntity
    class_uri: schema:Organization
    attributes:
      industry: {required: true}
  Project:
    is_a: NamedEntity
    attributes:
      customer: {range: Customer, required: true}
      owner: {range: Employee, required: true}
      status: {range: ProjectStatus, required: true}
      budget_cny: {range: decimal, minimum_value: 0, required: true}
      start_date: {range: date, required: true}
      end_date: {range: date}
  Participation:
    description: 带角色、投入比例及有效期的员工参与项目关系,须保留为独立实体。
    slots: [id]
    attributes:
      project: {range: Project, required: true}
      employee: {range: Employee, required: true}
      role: {range: ParticipationRole, required: true}
      allocation_pct: {range: integer, minimum_value: 1, maximum_value: 100, required: true}
      joined_on: {range: date, required: true}
      left_on: {range: date}
  Task:
    is_a: NamedEntity
    attributes:
      project: {range: Project, required: true}
      assignee: {range: Employee}
      task_status: {range: TaskStatus, required: true}
      estimated_hours: {range: decimal, minimum_value: 0, required: true}
      due_date: {range: date, required: true}
  Dataset:
    tree_root: true
    attributes:
      departments: {range: Department, multivalued: true, inlined_as_list: true}
      employees: {range: Employee, multivalued: true, inlined_as_list: true}
      customers: {range: Customer, multivalued: true, inlined_as_list: true}
      projects: {range: Project, multivalued: true, inlined_as_list: true}
      participations: {range: Participation, multivalued: true, inlined_as_list: true}
      tasks: {range: Task, multivalued: true, inlined_as_list: true}

budget_cny把货币单位写入名称,allocation_pct把百分比范围写入模型。end_date与left_on允许缺失,但日期的先后关系没有仅凭这两个字段定义自动得到验证。

identifier: true用于标识建模。它不保证生成器一定给出owl:hasKey,更不意味着任意数据库唯一约束都被翻译。本例的OWL没有生成这样的键公理。

Dataset下的六个多值列表规定一种实例组织方式;SchemaView确认六个slot均为多值且采用列表内联。它不是从DDL推导出的实体,也不表示数据库里有一张Dataset表。林晓和参与1002在抽取的JSON中可写成:

{
  "employees": [
    {
      "id": "ex:employee/2",
      "name": "林晓",
      "email": "lin@example.test",
      "department": "ex:department/4",
      "manager": "ex:employee/1",
      "hired_on": "2021-06-15"
    }
  ],
  "participations": [
    {
      "id": "ex:participation/1002",
      "project": "ex:project/101",
      "employee": "ex:employee/2",
      "role": "engineer",
      "allocation_pct": 60,
      "joined_on": "2026-01-01"
    }
  ]
}

这是为说明结构而摘取的数据切片,不是自足的完整数据集:它引用的部门、经理和项目需要在完整实例集合中存在。对象标识由映射代码构造,例如员工整数2变为ex:employee/2,任务的title映射成模型里的name。

4.7 读取实例并用反例检查

早期实例抽取环境与后面的OWL生成环境不同,保存版本如下:

用途保存的环境
数据抽取、有限验证、原始RDF映射Python 3.12.13;LinkML Runtime 1.3.2;psycopg2-binary 2.9.13;RDFLib 7.6.0
SQL结构导入、OWL生成LinkML 1.12.1;LinkML Runtime 1.12.1;Schema Automator 0.5.7;SQLAlchemy 2.1.6;RDFLib 7.6.0

run.py用SchemaView读取模型,再通过显式SQL抽取六类业务记录。数据库连接采用只读事务和REPEATABLE READ,随后由自编代码执行有限验证与RDF映射。读取schema本身不会自动检查所有记录。

先从项目根目录进入demos/postgres-ontology。下面两套环境都在该案例目录建立,抽取依赖对应requirements.txt,生成工具对应requirements-tools.txt。抽取前需要已有linkml_demo及种子数据,并配置libpq使用的PGHOST、PGPORT、PGUSER;认证按本地PostgreSQL设置完成,密码不写入命令示例。

cd demos/postgres-ontology
python3 -m venv .venv
.venv/bin/python -m pip install -r requirements.txt
# 连接既有linkml_demo,读取数据并写本地结果文件
.venv/bin/python run.py

这会写实例与报告,不能把它称为“不产生文件的只读检查”。其数据库访问是只读的。保存结果为33个实体、192条RDF三元组,正常数据没有报出该验证器覆盖范围内的错误。

反例通过深拷贝已抽取数据再独立修改构造,没有把坏数据写回数据库:

反例修改保存报告中的反馈
项目101预算改为-1budget_cny below minimum
参与1001投入改为150allocation_pct above maximum
任务2001执行人改为不存在的员工999dangling reference assignee=ex:employee/999
删除员工1的名称missing name
项目状态改为unknowninvalid enum status=unknown
项目结束日改为2020-01-01end_date before start_date
任务2001改由存在但未参与项目的员工6执行assignee not a member on due date

第七项说明了普通外键以外的业务检查。员工6真实存在,因此“员工引用有效”仍不足够。验证代码还要用任务所属项目、截止日期和参与有效期判断资格。

这里可以声称七组反例被自编验证器检出,但不能声称官方完整LinkML validator或OWL reasoner完成了验证。邮箱唯一、参与组合唯一等DDL约束,也没有在这段Python中逐项重新实现。

4.8 运行 gen-owl

本例工具环境通过配套requirements-tools.txt固定依赖;该文件还包含直接wheel依赖,不宜简化成只安装一个包。复现时在案例目录中建立独立环境:

python3 -m venv .venv-tools
.venv-tools/bin/python -m pip install -r requirements-tools.txt
.venv-tools/bin/gen-owl --help

如果只想从已有domain.yaml生成OWL,无须再次读取数据库。下面的命令依据保存的参数整理,输出到新文件,便于与已有产物比较:

.venv-tools/bin/gen-owl \
  --no-metaclasses \
  --no-type-objects \
  --use-native-uris \
  --default-permissible-value-type owl:Class \
  --no-add-ols-annotations \
  --skip-vacuous-local-range-axioms \
  --skip-vacuous-min-zero-cardinality-axioms \
  --consolidate-cardinality-axioms \
  --skip-abstract-class-as-unionof-subclasses \
  -f ttl domain.yaml \
  > domain.generated.owl.ttl 2> domain.generated.owl.log

这些是案例实参,不是对所有安装版本默认值的描述。关键选项可以分组理解:

选项组本例目的
--no-metaclasses、--no-type-objects不额外启用元模型实例及类型对象表示
--use-native-uris使用本地模型URI;实际输出需核对外部映射
--default-permissible-value-type owl:Class将枚举取值表示为类
三个skip及consolidate相关选项控制冗余限制、基数合并和抽象类表达
-f ttl输出Turtle

保存的包装脚本generate-ontology.py执行两路生成:逆向草稿到database-import.owl.ttl,人工领域模型到domain.owl.ttl。脚本还重新反射数据库,所以完整运行它需要数据库连接。

本例记录中-o没有产生预期文件,Turtle实际出现在stdout,脚本因此显式保存stdout并另存stderr。这是该版本和运行的观察,不能概括为所有版本的-o都不可用。

生成日志也不是空的:共享project属性产生了歧义警告,外部schema命名空间出现重映射警告。应保留日志,并回到输出确认URI和属性结构,而不是只看退出码。

4.9 打开结果:类、继承和属性

先看一个简单的类与继承片段。以下由当前TTL摘取并整理顺序,省略其他公理:

@prefix ex: <https://example.org/work/> .
@prefix owl: <http://www.w3.org/2002/07/owl#> .
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .

ex:Employee a owl:Class ;
    rdfs:subClassOf ex:NamedEntity .

这表示Employee是OWL类,也是NamedEntity的子类。对项目参与员工,实际输出包含两个独立限制:

ex:Participation a owl:Class ;
    rdfs:subClassOf
        [ a owl:Restriction ;
          owl:allValuesFrom ex:Employee ;
          owl:onProperty ex:employee ],
        [ a owl:Restriction ;
          owl:cardinality 1 ;
          owl:onProperty ex:employee ] .

第一项是全称限制:一个Participation的所有employee值都属于Employee。它只规定值的类型,本身不要求至少有一个值。第二项的cardinality 1另外表达恰好一个值。

LinkML必填员工关系生成的两个OWL限制及其解释

图5:依据生成文件重绘的限制对照。两个限制分别负责类型和数量,缺少显式数据时不能直接按数据库必填错误处理。

这也不是一条全局rdfs:range声明;它是Participation类上的局部限制,读TTL时要跟着subClassOf与onProperty看作用位置。

OWL采用开放世界假设。某条记录没有显式写出员工边,可以解释为尚不知道具体员工,而不自动产生“缺字段”报错。两个不同IRI也不天然保证表示不同个体。因此,不能将这段公理当成数据库NOT NULL、外键和唯一性约束的直接替身。

4.10 枚举输出为什么与字符串实例不一样

本次参数将枚举取值表示为类,项目状态的实际结构整理如下:

ex:ProjectStatus a owl:Class ;
    owl:unionOf (
        <https://example.org/work/ProjectStatus#planned>
        <https://example.org/work/ProjectStatus#active>
        <https://example.org/work/ProjectStatus#completed>
    ) .

<https://example.org/work/ProjectStatus#active> a owl:Class ;
    rdfs:subClassOf ex:ProjectStatus .

原始实例把状态写成ex:status "active",但新本体里对应的是对象属性。这两种表示需要对齐。本例后来的实例文件创建值节点:

<https://example.org/work/project/101>
    ex:status <https://example.org/work/project/101/value/status> .

<https://example.org/work/project/101/value/status>
    a <https://example.org/work/ProjectStatus#active> .

项目先连到一个状态值节点,该节点再声明为active类的实例。只把字符串换成类IRI作为宾语,并不能自动得到相同含义。

当前取值类没有声明彼此互斥,也没有使用owl:oneOf列出个体。类并集结构不能直接当作封闭的字符串白名单校验。若希望使用另一种枚举表示,应先选择目标语义,再调整生成配置及实例映射。

4.11 中文标签、计数和实际检查

中文阅读主要依靠rdfs:label。例如:

ex:Participation rdfs:label "项目参与记录"@zh,
                            "Participation"@en .

这种处理不要求把URI改成中文。标签后处理为51个目标各补充中英文两条标签,合计102条,代码断言所有非标签三元组保持不变。

已有文件的计数如下:

文件或内容保存及核读结果计数说明
逆向草稿OWL382条三元组7个命名类,72个restriction
业务领域OWL521条三元组含标签、限制、RDF列表等结构
业务OWL命名类20个8个类(含抽象父类和容器)+3个枚举父类+9个取值类
业务OWL限制节点55个29个全称限制、21个恰好基数、5个最大基数
原始实例192条三元组33个业务主体
已有对齐实例246条三元组增加21个枚举值节点类型与33条显式id

历史标签前曾记录469条。数量变化用于追溯文件,不用于评判模型质量。同一条业务记录可以贡献多条事实,而OWL中的匿名限制和RDF列表也会展开成多条三元组。Turtle换行或缩进本身不会增加三元组。

不连接数据库也能独立检查当前文件。以下脚本只解析文件并打印计数,不写结果:

from pathlib import Path
from rdflib import Graph
from rdflib.namespace import RDF, OWL

root = Path(".")  # 在案例目录运行
for filename in (
    "database-import.owl.ttl",
    "domain.owl.ttl",
    "instances.ttl",
    "instances-aligned.ttl",
):
    graph = Graph().parse(root / filename, format="turtle")
    restrictions = set(graph.subjects(RDF.type, OWL.Restriction))
    print(filename, len(graph), len(restrictions))

结构检查能证明指定模式在图中存在。例如Employee的父类关系和Participation的员工限制能找到。但完整OWL-DL profile合规、逻辑一致性和推理结果,需要专门工具与任务;本例没有执行这些检查。

4.12 生成后仍然发现了两个具体问题

第一个问题出在金额与工时。YAML写的是decimal且最小值为0,实际生成的数据范围却包含下面的结构:

owl:allValuesFrom [
    a rdfs:Datatype ;
    owl:intersectionOf (
        xsd:decimal
        [ a rdfs:Datatype ;
          owl:onDatatype xsd:integer ;
          owl:withRestrictions ( [ xsd:minInclusive 0 ] ) ]
    )
] .

这里摘取的是预算对应限制的内部片段,需要沿外层owl:onProperty ex:budget_cny定位。工时也有相同形式。

交集的一侧是decimal,另一侧却是非负integer。按数据范围解释,本次版本、参数与输出文件中的这项限制会排除0.50这样的非整数值,比目标“非负decimal”更窄。当前样本金额与工时都恰好是整数数值,没有暴露这个区别。若只看样本能否读入,或者只检查最小值0是否存在,就容易漏掉问题。

因此,目前不能声称金额约束被无损转译。下一步应增加0.50等边界输入,定位具体版本的生成行为,选择修复生成映射或其他合适表达,再重新验证。本文保留原始证据,没有悄悄改写OWL来制造通过结果。

第二个问题出在实例对齐。旧RDF将员工类型写成https://schema.org/Person,名称写成https://schema.org/name。本次原生URI配置生成了本地ex:Employee和ex:name。

已有对齐脚本处理了员工与客户类型、枚举值节点和显式id,但24个名称仍使用外部属性,ex:name的数量为0。也就是说,文件名虽然叫instances-aligned.ttl,名称仍没有完成对齐。由于开放世界假设,这不直接等同于已经证明逻辑不一致;但它确实没有按本地属性提供显式名称事实。修复时应在生成配置、模型URI映射与实例谓词之间选择一致方案,而非不加判断地把所有外部属性改成本地属性。

本体的外部类型注释还涉及http://schema.org/,旧实例则用https://schema.org/。这两个IRI不能当成同一个字符串;skos:exactMatch也不能直接当成owl:equivalentClass。检查时需要逐条看实际URI及映射公理,不能仅凭页面名称相似就合并。

这两项发现让产物边界更清楚:当前有可解析的模型和一组结构证据,但生成映射与实例对齐仍有明确待修项。

5. 案例总结:模型已经写清楚了什么

5.1 回到最初的问题

“远山制造的项目里有哪些员工”现在可以拆开确认:要找负责人,就使用Project.owner;要找参与者,就使用Participation.employee;要找任务执行人,就沿Task.assignee。若还加上“当前”,需要给出观察日期和参与有效期口径。

这种区分来自表、注释、样本和业务讨论。LLM帮助整理候选类、关系和YAML,人工确认含义;LinkML负责将已经写下的模型交给工具处理。数据库仍保存原始事实,实例抽取与映射则有自己的规则。

5.2 哪些步骤有证据,哪些还没有完成

解析、结构检查、实例验证和OWL推理的验证范围对照

图6:本案例检查范围示意。各项回答不同问题,不是一条“前一项通过就保证后一项”的流水线。

层面当前证据能得出的结论
SQL结构与样本DDL、种子数据、保存的抽取结果输入范围和关系来源可追溯
业务模型8类、3枚举及具体建模决定三种员工关系已分别表达
模型可读性SchemaView读取及保存生成链路文件可被相应环境处理
自编实例验证正常数据及七组反例报告仅覆盖代码实现的规则
OWL解析与结构TTL解析、类与限制核对指定结构存在
生成语义保真发现decimal数据范围偏差仍需修复和补充边界测试
实例与本体对齐已调整类型、枚举和id;名称尚有遗漏尚不能称完整对齐
官方全量实例验证未执行无完整validator结论
OWL-DL检查及reasoner未执行无profile合规或推理通过结论

5.3 把这套过程带到自己的数据库

真正需要随案例保留的是一组相互对应的材料:输入DDL和业务说明、模型决策与疑问、完整YAML、生成命令和日志、OWL文件、实例映射以及检查报告。以后模型变更时,才能追溯某条限制从哪里来,以及它在哪个阶段发生了变化。

如果换成自己的业务,先选一个关系容易混淆的小场景。把几个相关表、代表记录和已知例外放在一起,要求LLM给出带依据的模型候选,再逐项审核。schema生成之后,至少抽几组类型、基数、枚举和边界数值,沿输出文件看一遍。涉及时间或跨记录计算的规则,要明确由哪个系统执行。

本例最终交付了domain.yaml、domain.owl.ttl、两路生成记录及结构检查材料,同时留下金额范围和名称对齐两个待处理问题。下一篇从这份OWL出发,用WebVOWL阅读类和关系,再回到Turtle查看图形界面未完整显示的限制。

参考资料

  1. LinkML概览
  2. LinkML Tutorial 01:创建第一个schema
  3. JSON Schema生成器
  4. LinkML数据转换
  5. LinkML OWL生成器
  6. Schema Automator
  7. OntoGPT文档
  8. OWL 2 Primer
  9. OWL 2 Direct Semantics
  10. OWL 2 Structural Specification
  11. 本体语言与应用:从描述逻辑到OWL
  12. 为AI Agent构建企业语义层:从可信查询到受控行动

案例依据为随文整理的合成DDL、种子数据、领域模型、抽取与生成脚本及其保存报告。官网Person示例与本地案例结果分别注明;概念图均据这些材料重绘,不是运行截图。