结业证书在 AI 回答里查无此证:EducationalOccupationalCredential 的架构设计与落地
适用读者:职业培训机构、在线教育平台的后端与数据同学(就是管课程中心表结构的那批人),以及负责页面结构化数据、希望课程信息在 AI 问答里被准确引用的内容工程同学。 不需要任何语义网基础,读完可以直接对着自己库里的课程表动手改。
招生页上印着「学完可考 XX 认证」,学员把课程链接丢进 AI 助手问一句「这门课有没有证书」,得到的回答是「页面未发现明确的证书信息,建议联系客服确认」。证书是真的,发证机构官网能查到编号,只是它躺在课程详情的富文本里,是一行加粗的 p 标签。这事儿我们排查了三周,最后发现根因不在文案,而在数据模型里压根没有「证书」这个东西。
现象:证书存在,AI 却说没有
第二周的时候,客服那边转过来一批学员反馈,前后 23 条,内容高度一致:AI 说我们不带证书,但招生老师明明承诺过。课程中心一共 1286 门课,其中带认证的有 381 门,占比不算低,不是边缘 case。

我们做的第一件事是复现。挑了 12 门确定带认证的课程,用三家不同的 AI 产品各问一遍「这门课结业有证书吗」,一共 36 次提问,31 次的回答是「未提及」或「不确定」,只有 5 次答对,而且答对的那 5 次里,模型引用的都是第三方论坛帖子的二手描述,不是我们的页面。
接着扒自己的页面。证书信息确实在 HTML 里,位置在 <div class="course-detail"> 的第 7 段,长这样:「学完并通过考核后可申请 XX 认证(证书由 XX 协会颁发,工本费另付)」。人眼一眼能看到,机器不好办。同一门课的详情页里还同时出现了「结业证书由平台颁发」和「XX 认证需另外报名考试」两种说法,靠得很近,语义还互相打架。
| 排查动作 | 做了什么 | 看到的结果 |
|---|---|---|
| 人工复现 | 12 门课 × 3 个 AI 产品,共 36 次提问 | 31 次回答「未提及/不确定」,5 次答对且引用的是第三方来源 |
| 页面取证 | 抓课程详情页 HTML,定位证书文案 | 证书只在富文本第 7 段,无独立字段、无结构化标记 |
| 数据侧核对 | 查课程中心表结构 | 课程表 47 个字段,没有一个和证书相关 |
| 一致性检查 | 比对招生话术与富文本 | 同页出现两种互相冲突的证书说法 |
到这一步结论已经清楚了:证书在我们的系统里是一段文字,不是一个实体。运营每次改文案都要手工同步三个地方(课程页、App 端、招生 PDF),同步漏了就出现矛盾表述。人看得出哪句算数,模型看不出。
底层机制:AI 凭什么回答「有」还是「没有」
要把这事儿改对,得先弄清楚 AI 是怎么得出结论的。生成式引擎优化(Generative Engine Optimization, GEO)讨论的就是这个层面:怎么让你的内容在生成式引擎的答案链条里被检索到、被抽取成事实、被当成可信来源引用。它和传统 SEO 的差别在于,传统 SEO 关心的是「排第几」,GEO 关心的是「能不能被抽成一条可用的断言」。
一次典型的 AI 问答,在拿到你的页面之后大致走三步。
第一步是切块与检索。 页面正文会被切成几百字一块的片段做向量化,用户问「有没有证书」时,召回的是语义相近的片段。富文本里的证书那句话,和前后关于退费规则、课时安排的段落一起被切进同一个块,噪声很大,召回分数被稀释。
第二步是事实抽取。 模型从召回的片段里抽取结构化事实。这里的关键是有没有显式的实体边界:如果页面里有一块标记清楚的「这是证书,名称是 XX,发证方是 XX 协会,类别是职业资格认证」,模型可以直接把它抽成三元组;如果只有一句自然语言,模型要同时做指代消解和歧义判断,出错率明显上升。我们那 31 次失败回答,绝大多数就是卡在这一步——不是没召回到,是召回到了但不敢断言。
第三步是生成与引用。 模型在没有可信结构化事实支撑时,倾向于输出保守表述(「未提及」「建议咨询」),而不是猜。这解释了为什么错误回答不是乱说,而是清一色的「不确定」。对机构来说这比答错更难受,因为「不确定」等于把流量推给了客服,也等于把解释权让给了第三方帖子。
一句话记住:富文本给的是线索,结构化数据给的是断言。 AI 缺的不是信息,是不敢下判断的依据。
把证书建成实体:EducationalOccupationalCredential 的四个关键字段
Schema.org 里有一个专门的类型叫 EducationalOccupationalCredential,用来描述教育或职业类的证书、学位、认证。把它用起来,等于给模型一个明确的实体边界。
四个字段在我们这次改造里起了决定性作用。
| 字段 | schema.org 期望类型 | 我们实际灌的值 | 对 AI 抽取的影响 |
|---|---|---|---|
credentialCategory |
Text 或 URL | professional certification(职业资格认证) |
决定模型怎么归类。填了它,模型不会把「结业证明」和「职业资格认证」混为一谈 |
educationalLevel |
Text 或 URL | advanced(进阶)/ beginner(入门) |
回答「这个证含金量如何」「零基础能考吗」这类追问时的依据 |
recognizedBy |
Organization / Person | 发证协会的完整 Organization 节点 |
回答「证书谁认」的核心依据,填了才能被引用为权威来源 |
competencyRequired |
Text 或 URL | 具体能力项,如「能独立完成 XX 方案设计」 | 支撑「考这个要会什么」这类长尾问题的回答 |
recognizedBy 值得单独说一句。它期望的是一个 Organization 实体,不是一个字符串。我们一开始偷懒填了协会名字的文本,校验工具没报错,但 AI 回答「证书由谁颁发」时仍然含糊。换成完整的 Organization 节点、带上协会官网 url 之后,回答才稳定下来。嵌套实体比纯文本字符串携带的信息量高一个量级,这是这轮改造里最省事的一处修改。
Course 怎么和证书连:两条边别搞混
课程到证书的关联,schema.org 给的是 Course 上的两个属性:educationalCredentialAwarded(学业类证书)和 occupationalCredentialAwarded(职业资格类)。课程开班信息走 hasCourseInstance 指向 CourseInstance,CourseInstance 可以再用 @id 反向指回课程,这样同一个课程实体在一份 JSON-LD 里只出现一次,不重复嵌套。
flowchart LR
A["Course 课程实体"] --> B["hasCourseInstance"]
B --> C["CourseInstance 某一期班"]
C -. "instanceOf / @id 反向指回" .-> A
A --> D["educationalCredentialAwarded 结业证明"]
A --> E["occupationalCredentialAwarded 职业资格认证"]
D --> F["EducationalOccupationalCredential"]
E --> F
F --> G["credentialCategory"]
F --> H["educationalLevel"]
F --> I["recognizedBy 指向 Organization"]
F --> J["competencyRequired"]
这里有个坑要提醒:instanceOf 在不同词汇版本里的支持程度不一致,有些解析器认,有些直接忽略。稳妥做法是同时保留 hasCourseInstance 的正向嵌套和 @id 引用,即使反向属性被忽略,实体关系也不会断。
还有一个命名上的坑。我们内部 API 里有个老字段叫 courseCredential,用了四年。它不是 schema.org 的正式属性,正式属性是上面那两个。改造时我们没有删它,而是在序列化层做了一次映射:老字段继续对内服务,对外输出 JSON-LD 时映射到 educationalCredentialAwarded 或 occupationalCredentialAwarded,按 credentialCategory 的值决定落哪一个。别为了迁就历史字段名去造一个 schema.org 里不存在的属性,机器读不到,等于白写。
数据模型改造:证书独立成表,课程多对多
数据库这边的改动不大,但要想清楚一件事:证书是不是应该跟着课程走。
答案是否定的。同一张证书会被多门课覆盖(我们有 7 门课都导向同一张 XX 认证),一门课也可能产出两张证(平台结业证明 + 协会认证)。课程表里加一个 credential_text 字段是省事,但改一次文案要动几十行记录,而且没法回答「哪些课能考这张证」这类反向查询。所以我们把证书拆成了独立实体表,中间用关联表做多对多。
-- 环境:MySQL 8.0 / InnoDB / utf8mb4,课程库 course_center
-- 说明:证书独立成表,与课程多对多;关联表上带有效期和业务状态
-- 改造背景:原来证书只是课程详情富文本里的一段话,无法单独维护
-- 证书实体表:一张证书在这里只有一行,改文案只改一处
CREATE TABLE credential (
-- 自增主键,只在库内使用,不对外暴露
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
-- 对外用这个编码拼 JSON-LD 里的 @id,保证全站同一个标识
credential_code VARCHAR(64) NOT NULL COMMENT '证书内部编码,对外用作 @id 锚点',
-- 证书全称,招生页和 JSON-LD 都取这个字段,避免两边文案不一致
name VARCHAR(255) NOT NULL COMMENT '证书全称',
-- 对应 schema.org 的 credentialCategory,决定模型怎么归类
category VARCHAR(64) NOT NULL COMMENT '对应 credentialCategory,如 professional certification',
-- 对应 educationalLevel,允许为空,为空时 JSON-LD 里不输出这个键
edu_level VARCHAR(32) DEFAULT NULL COMMENT '对应 educationalLevel,如 beginner/advanced',
-- 发证机构外键,序列化时展开成 Organization 节点
issuer_org_id BIGINT UNSIGNED DEFAULT NULL COMMENT '发证机构,指向 organization 表',
-- 能力项用 JSON 存数组,对应 competencyRequired
competency JSON DEFAULT NULL COMMENT '对应 competencyRequired,能力项数组',
-- status 为 0 的证书不对外输出 JSON-LD,防止下架证书仍被引用
status TINYINT NOT NULL DEFAULT 1 COMMENT '1 上架 0 下架,下架的证书不输出 JSON-LD',
-- 两个时间戳用于排查「什么时候改的」,方便回溯线上回答
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
-- credential_code 建唯一索引,避免同一张证书被录两遍
PRIMARY KEY (id),
UNIQUE KEY uk_credential_code (credential_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='证书实体表';
-- 课程与证书的关联表:多对多,边上带业务属性
CREATE TABLE course_credential (
-- 课程主键,来自 course_center.course
course_id BIGINT UNSIGNED NOT NULL COMMENT '课程主键',
-- 证书主键,来自 credential.id
credential_id BIGINT UNSIGNED NOT NULL COMMENT '证书主键',
-- 决定序列化时落到 educationalCredentialAwarded 还是 occupationalCredentialAwarded
award_type ENUM('educational','occupational') NOT NULL COMMENT '决定落到哪个 schema.org 属性',
-- 是否必须通过考核才能拿到,用于消解页面上「学完即可」类模糊表述
is_required TINYINT NOT NULL DEFAULT 0 COMMENT '是否必须通过考核才能拿到',
-- 是否需另外付费,这是页面上矛盾表述的根源,必须显式存下来
extra_fee TINYINT NOT NULL DEFAULT 0 COMMENT '是否需另外付费,1 表示工本费另付',
-- 合作有效期,过期后自动从 JSON-LD 摘除,不依赖人工操作
valid_from DATE DEFAULT NULL COMMENT '合作有效期起,过期后不再对外输出',
valid_to DATE DEFAULT NULL COMMENT '合作有效期止',
-- 联合主键保证同一门课同一张证同一类别只录一条
PRIMARY KEY (course_id, credential_id, award_type),
-- 反向索引,支撑「哪些课能考这张证」的查询
KEY idx_credential_course (credential_id, course_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='课程与证书关联表';
-- 取数示例:查某门课当前有效、且已上架的全部证书
-- 有效期内(valid_to 为空或大于等于今天)才算数,避免过期合作被 AI 引用
-- 这条 SQL 对应渲染层 build_course 里的 creds 入参
SELECT c.credential_code, c.name, c.category, r.award_type, r.extra_fee
FROM course_credential r
JOIN credential c ON c.id = r.credential_id
WHERE r.course_id = 10086
AND c.status = 1
AND (r.valid_to IS NULL OR r.valid_to >= CURDATE());
extra_fee 这个字段是冲突的产物。前面说过,同一个页面上既写「学完可考」又写「工本费另付」,模型读起来是矛盾的。把它变成关联表上的一个布尔字段之后,JSON-LD 里可以明确带上「需另付费」的说明,矛盾自然消解。把自然语言里的限定条件拆成字段,是这类改造里收益最高的一步。
| 对比项 | 改造前 | 改造后 |
|---|---|---|
| 证书存哪儿 | 课程详情富文本,一行加粗 p |
credential 表独立实体,381 张证书各一行 |
| 课程与证书关系 | 无关系,靠文案描述 | course_credential 多对多,含有效期与付费标志 |
| 改一次文案成本 | 改 3 个端,平均耗时 40 分钟,常漏改 | 改 1 行数据,5 分钟内三端同步 |
| 反向查询(哪些课能考这张证) | 全库 LIKE 扫富文本,约 90 秒 |
关联表索引查询,约 30 毫秒 |
| AI 问答命中率(12 门课 × 3 产品复测) | 5/36 | 34/36 |
| 矛盾表述 | 同页两种说法 | extra_fee / is_required 字段显式消歧 |
JSON-LD 输出长什么样
下面是渲染层拼 JSON-LD 的构建函数,依赖 Python 3.11,只用标准库,没有第三方包。
# 环境:Python 3.11,仅标准库,无第三方依赖
# 功能:把课程 + 证书实体拼成 schema.org 的 JSON-LD 片段
# 位置:渲染层 course_page/render.py,页面输出前调用
import json
# 站点前缀,用于给每个实体生成稳定的 @id 锚点
# 换域名时只改这一处,别在代码里硬拼 URL
BASE = "https://example.edu"
def build_credential(row: dict) -> dict:
"""把一行证书记录转成 EducationalOccupationalCredential 节点。"""
# row 来自 credential 表 JOIN course_credential 的结果,已过滤过期项
# @id 用内部编码,保证同一张证书在全站任何页面都是同一个标识
node = {
# 类型名很长,拼错一个字母机器就整块忽略,建议写成常量
"@type": "EducationalOccupationalCredential",
"@id": f"{BASE}/credential/{row['credential_code']}#cred",
"name": row["name"],
# credentialCategory 决定模型怎么归类,别留空
"credentialCategory": row["category"],
}
# educationalLevel 允许为空,为空就不输出这个键,避免输出空字符串
if row.get("edu_level"):
node["educationalLevel"] = row["edu_level"]
# recognizedBy 必须是 Organization 实体,不是字符串,这一步最关键
# 我们第一版填的纯文本,校验没报错但 AI 答不出「谁颁发的」
if row.get("issuer_name"):
node["recognizedBy"] = {
"@type": "Organization",
"name": row["issuer_name"],
# 带上协会官网,模型才会把它当成可引用的权威来源
# url 为空时宁可不输出,也不要输出空串
"url": row.get("issuer_url", ""),
}
# competencyRequired 支持文本或 URL,我们直接灌能力项数组
if row.get("competency"):
node["competencyRequired"] = row["competency"]
# 需另付费这个限定条件显式带出来,消掉页面上的矛盾表述
if row.get("extra_fee"):
node["description"] = "考核通过后需另行缴纳工本费方可领取"
return node
def build_course(course: dict, creds: list) -> str:
"""组装课程节点,并按 award_type 挂到对应的属性上。"""
# 课程主体:name 与 provider 是最容易被 AI 引用的两个字段
data = {
"@context": "https://schema.org",
"@type": "Course",
# 课程自己的 @id,供 CourseInstance 反向指回
"@id": f"{BASE}/course/{course['id']}#course",
"name": course["title"],
"provider": {"@type": "Organization", "name": course["org_name"]},
}
# 开班信息单独成节点,用 @id 反向指回课程,避免课程实体重复嵌套
if course.get("instance"):
inst = {
"@type": "CourseInstance",
# 每期班一个 @id,换期不会覆盖历史数据
"@id": f"{BASE}/course/{course['id']}/instance/{course['instance']['code']}#inst",
# blended 表示线上线下混合,也可填 onsite / online
"courseMode": course["instance"].get("mode", "blended"),
# 反向指回:部分解析器认 instanceOf,不认的也能靠 @id 关联
"instanceOf": {"@id": data["@id"]},
}
# hasCourseInstance 期望数组,即使只有一期也用列表包一层
data["hasCourseInstance"] = [inst]
# 学业类证书和职业资格类证书要分开挂,混在一起会让模型归类出错
# 这两行是本次改造的核心:原来证书是富文本,现在是两条独立边
edu = [build_credential(c) for c in creds if c["award_type"] == "educational"]
occ = [build_credential(c) for c in creds if c["award_type"] == "occupational"]
# 没有对应类型的证书时不要输出空数组,空数组会让部分解析器报警
if edu:
data["educationalCredentialAwarded"] = edu
if occ:
data["occupationalCredentialAwarded"] = occ
# ensure_ascii=False 保留中文,别让中文变成 \uXXXX 转义
# indent=2 只是方便人工排查,线上可以去掉缩进省流量
return json.dumps(data, ensure_ascii=False, indent=2)
输出的 JSON-LD 长这样。下面是注解版(每行 // 只为了说明,实际放进页面时删掉),直接塞进页面的 <script type="application/ld+json"> 里。
{
// @context 固定写 schema.org 的地址,大小写别改
"@context": "https://schema.org",
// @type 声明这是一个课程实体
"@type": "Course",
// @id 给课程一个全站稳定锚点,供 CourseInstance 反向引用
"@id": "https://example.edu/course/10086#course",
"name": "数据治理工程师实战课",
// provider 是办学主体,回答「谁开的课」时就靠它
"provider": { "@type": "Organization", "name": "示例培训中心" },
// hasCourseInstance 挂开班信息,一个课程可以有多期
"hasCourseInstance": [
{
"@type": "CourseInstance",
// 每期班也有自己的 @id,方便单独被引用
"@id": "https://example.edu/course/10086/instance/2026S3#inst",
// courseMode 说明授课形式,blended 表示线上线下混合
"courseMode": "blended",
// 反向指回课程实体,避免课程信息重复嵌套两遍
"instanceOf": { "@id": "https://example.edu/course/10086#course" }
}
],
// 学业类证书走这个属性,比如平台自己发的结业证明
"educationalCredentialAwarded": [
{
"@type": "EducationalOccupationalCredential",
"@id": "https://example.edu/credential/GRAD-10086#cred",
"name": "数据治理工程师结业证明",
// 类别写成结业完成,别写成职业资格,两者差别很大
"credentialCategory": "course completion",
"educationalLevel": "advanced"
}
],
// 职业资格类证书走这个属性,由外部机构颁发
"occupationalCredentialAwarded": [
{
"@type": "EducationalOccupationalCredential",
// 同一张认证在多门课页面上都是这个 @id,机器能识别为同一实体
"@id": "https://example.edu/credential/DGA-CERT#cred",
"name": "数据治理助理工程师认证",
// professional certification 明确归类为职业资格
"credentialCategory": "professional certification",
"educationalLevel": "advanced",
// 发证机构必须是 Organization 实体,带官网地址
"recognizedBy": {
"@type": "Organization",
"name": "示例行业协会",
"url": "https://example.org"
},
// 能力项数组,支撑「考这个要会什么」类提问
"competencyRequired": ["能独立完成数据资产盘点", "能编写数据质量规则"],
// 费用限定条件显式写出来,消掉与正文的矛盾
"description": "考核通过后需另行缴纳工本费方可领取"
}
]
}
上线后怎么验,以及别让结构化数据反过来坑你
结构化数据一旦对外输出,就变成了承诺。我们加了一条校验流水线,卡在发布前面。
sequenceDiagram
participant O as 运营后台
participant DB as 课程中心
participant R as 页面渲染层
participant V as 校验流水线
participant AI as AI 问答与抓取
O->>DB: 录入证书并绑定课程
DB->>R: 输出证书实体字段
R->>V: 提交 JSON-LD 片段
V->>V: 类型与必填字段校验
V->>V: 与富文本做一致性比对
V-->>O: 不一致则阻断发布并告警
V->>AI: 校验通过后发布页面
AI-->>V: 抓取并抽取事实
一致性比对这条是我们踩坑踩出来的。上线第五天,有一门课的合作到期,运营在富文本里删了证书那段,忘了改关联表,结果页面正文说没有、JSON-LD 里还说有。AI 采信了结构化数据,回答了有,学员报名后拿不到证,投诉直接升级。正文和结构化数据打架时,模型更信结构化数据,所以下架、过期、改名这三件事必须做到两边同步,我们在 valid_to 到期时自动把证书从 JSON-LD 里摘掉,不依赖人工。
验证效果也别只靠人工问。我们把那 36 条提问固化成了回归集,每次发版跑一遍,记录回答是否命中证书名称、是否说出发证机构、是否提到工本费。改完之后命中从 5/36 涨到 34/36,剩下 2 次是因为提问里带了「包过吗」这种夸张表述,模型走了合规风控分支,属于正常。
容易踩的几个坑
recognizedBy只填字符串:校验工具不报错,但模型答不出「谁颁发的」,等于这个字段白写。必须嵌套Organization。- 两类证书混挂到一个属性上:结业证明和职业资格认证语义差很远,混在
educationalCredentialAwarded里会让模型把「有结业证」当成「有职业资格」,这是最容易引发投诉的一处。 - 过期合作没摘干净:结构化数据的优先级高于正文,漏摘比漏写更危险。用
valid_to做自动摘除,别指望人工记得住。 - 自己造属性名:内部叫
courseCredential没问题,对外输出必须映射回 schema.org 的正式属性,否则机器读不到。 - 中文被转义:
json.dumps默认ensure_ascii=True,中文全变成\uXXXX,虽然能被解析,但排查时很难读,也偶发解析兼容问题。改成False。
收尾
在 GEO 语境下,课程有没有证书不是文案问题,是实体问题。这轮改造真正花时间的不是写代码,是和运营一起把 381 张证书的历史文案逐条拆成字段,前前后后两周。拆完之后意外收获是招生话术也统一了,因为矛盾的地方在数据录入时就暴露了。
往后看, Course 这套词汇还在扩,证书与岗位能力、与薪资区间的关联迟早会进标准。现在把证书建成独立实体、把关联做成多对多,后面不管是接新的 schema.org 属性,还是给 AI 搜索语境下的问答补更多维度的事实,改的都只是序列化那一层,不用再动表。这就是实体建模的好处:多花两周,省掉以后每次改需求都动表结构。
踩过类似坑的,评论区聊聊你们是怎么处理富文本与结构化数据打架的。
参考与延伸
- Schema.org
EducationalOccupationalCredential类型定义:https://schema.org/EducationalOccupationalCredential - Schema.org
Course类型定义(含hasCourseInstance、证书相关属性):https://schema.org/Course - Schema.org 结构化数据入门(JSON-LD 语法与校验说明):https://developers.google.com/search/docs/appearance/structured-data/intro-structured-data
关键词:GEO、AI 搜索、EducationalOccupationalCredential、Schema.org、JSON-LD、课程结构化数据、职业培训