课程页只写大纲不够:teaches、educationalLevel 与 competencyRequired 的课程实体深描实战
适用读者:职业教育、知识付费、企业培训站点里负责结构化数据的前端或后端工程师;已经给课程页打过 Course 类型的 JSON-LD,但只填了 name、description、provider 三件套,想让 AI 搜索在回答「这门课适合什么水平」「学完能做什么」时能准确引用的人。
上周三下午,我们把一个职业教育课程站的 40 门课页面做了一轮字段补全,Course 结构化数据从三件套扩到完整深描。第二天用同一批问题去问几个 AI 搜索入口——「这门课适合什么水平的人」「学完能做什么」——30 次提问里,回答中出现具体技能词的次数从 7 次变成 19 次。页面文案一个字没动,变的只有 JSON-LD 里那几十行。
只填三件套,引擎就只能复述你的简介
Course 的 name、description、provider 只回答了两件事:这门课叫什么、谁开的。用户真正会问的那三类——我够不够格上、学完能干什么、学完拿什么凭证——在三件套里一个字段都对不上。

生成式引擎优化(Generative Engine Optimization, GEO)的语境下,检索侧做的不是关键词命中,而是属性级对齐(property-level alignment):把「零基础能学吗」这种口语问法解析到 competencyRequired 槽位,把「学完能做什么」解析到 teaches 槽位。槽位里有值,回答就引用值;槽位空着,引擎只能退回去抓 description 里的自然语言片段,抓不准就给你一句含糊的「该课程内容涵盖数据分析全流程」。
我们改之前那批页面的实际表现很典型:问「适合零基础吗」,回答复述了页面上的「从入门到精通」;问「学完能做什么」,回答复述「掌握数据分析全流程」。两句都没错,也都没用。
四个字段各自补哪一块
深描这件事没有一个字段能包打天下,四个属性各自对应一类提问。下面这张表是我们改站时的对照,左边是改之前的写法,右边是现在的写法。
| 字段 | 浅描写法(改之前) | 深描写法(现在) | 它接住哪类提问 |
|---|---|---|---|
| teaches | 不填,技能全塞进 description | DefinedTerm 数组,6-9 条动作型技能 | 学完能做什么、会哪些工具 |
| educationalLevel | 「入门」「零基础友好」 | 受控词表里的等级值 | 这门课适合什么水平 |
| competencyRequired | 不填 | 可检查的前置动作清单 | 我够不够格上、要不要先学别的 |
| educationalCredentialAwarded | 不填或写「结业证」 | EducationalOccupationalCredential 实体 | 学完有什么凭证 |
| occupationalCategory | 不填 | 对齐职业分类码 + 职业名 | 学完能往哪个岗位走 |
teaches 列的是技能,不是章节名
最容易写错的就是这个。我们第一版直接把大纲章节搬了进去,「第 3 章 数据清洗」「第 4 章 可视化」——引擎读不出任何技能信号,因为章节名只在你的站内导航语境里有意义。
改成动作 + 工具 + 对象之后,形态是「用 pandas 处理缺失值与异常值」「用 SQL 写多表关联与窗口函数查询」。这类短语是离散的、可枚举的,也和岗位描述里的技能项写法高度重叠。
educationalLevel 别自己造词
这个字段我们踩过一次:站点内部用了「零基础」「进阶」「高阶」三档,直接写进去,结果同一门课在不同页面出现三种写法,实体内部自己打架。
后来统一映射成受控词表,页面上的营销话术和结构化数据里的等级值彻底分开。
| 站点页面话术 | educationalLevel 落地值 | 判定依据 |
|---|---|---|
| 零基础可学、从入门到精通 | Beginner | 不要求任何前置技能 |
| 有一定基础、进阶提升 | Intermediate | 要求 1-3 项前置能力 |
| 高阶实战、进阶专题 | Advanced | 要求完整前置技能链 |
| 职业认证冲刺班 | Professional | 面向持证或在岗人员 |
competencyRequired 写的是门槛,不是目标
跟 teaches 的区别要记牢:teaches 是出口,学完你有什么; competencyRequired 是入口,来之前你得会什么(对应 schema.org 的能力要求,competency requirement)。
写门槛时我们定了三条:写可检查的动作,不写「有一定的编程基础」;写清楚程度,比如「能独立配置 Python 虚拟环境并安装依赖包」;不写否定式,「无需任何基础」这种话引擎很难当信号用,不如直接写最低那条要求。
凭证和职业分类要对齐外部词表
educationalCredentialAwarded 建议用 EducationalOccupationalCredential 实体而不是裸字符串,把 credentialCategory 和发证方带上。occupationalCategory 我们对齐的是 O*NET-SOC 职业码,站内维护了一张「职业名 → 分类码」的映射表,改脚本时直接从表里取,不让运营手填。
原理与机制剖析:技能词是怎么进到回答里的
这一节拆一下引擎侧的处理链路,理解了这个,字段该怎么写就不用死记。
flowchart LR
A[课程页 HTML] --> B[JSON-LD 解析为 Course 实体]
B --> C{属性槽位填充检查}
C -->|teaches 有值| D[技能词进入候选引用池]
C -->|teaches 为空| E[退化为 description 语义匹配]
D --> F[用户提问意图解析]
E --> F
F --> G[槽位对齐: 水平/门槛/产出]
G --> H[生成回答并引用实体属性值]
三个机制值得单独说。第一是槽位优先:意图解析出「适合什么水平」之后,引擎先查 educationalLevel 槽位,有值就用值,没值才回落到段落语义匹配,回落的成功率明显低一截。
第二是可抽取性。teaches 里的短语是短、离散、带工具名的,从长句 description 里抽一个技能词要过一层语义切分,从数组里取一条是直接取值。我们抽查时看到的现象是:回答里的技能词基本和 teaches 条目逐字对应,很少是 description 的改写。
flowchart TD
T1[teaches 条目: 用 SQL 写窗口函数查询] --> T2[与岗位技能描述做词汇重叠]
T2 --> T3[重叠度高: 判定为可引用事实]
T3 --> T4[进入回答语料并保留原措辞]
T5[description 长句] --> T6[需语义切分与改写]
T6 --> T7[改写后措辞漂移, 引用概率下降]
第三是一致性。teaches 写「零基础起步」而 competencyRequired 写「需具备 Python 基础」时,实体自相矛盾,我们有一批 3 门课就是这么被跳过的,回答里直接不引用结构化数据。这是校验器查不出来的问题,得靠人看。
四十门课的批量改造怎么做的
40 门课一页页手改不现实,我们写了个一次性脚本。环境:Python 3.10,只依赖标准库(json、csv、re、pathlib),在课程站导出的 jsonld 目录下执行。运营先在 CSV 里补齐四列:teaches_list(分号分隔)、level_internal、prereq_list、soc_code,脚本负责映射和写回。
# env: Python 3.10 / 仅标准库 / 在课程站导出的 jsonld 目录执行
import json, csv, pathlib
# 站内话术到受控等级词的映射,页面文案与结构化数据彻底解耦
LEVEL_MAP = {
"零基础": "Beginner",
"进阶": "Intermediate",
"高阶": "Advanced",
"认证冲刺": "Professional",
}
# 运营填的技能串切分,顺手去掉首尾空格和空项
def split_items(raw):
# 分号是约定的分隔符,逗号容易和技能名里的顿号混
return [x.strip() for x in (raw or "").split(";") if x.strip()]
# 导出目录下每门课一个 json 文件,文件名即课程 slug
root = pathlib.Path("./jsonld")
# CSV 由运营维护,列头是 slug / teaches_list / level_internal / prereq_list / soc_code
rows = list(csv.DictReader(open("./course_meta.csv", encoding="utf-8")))
# 用课程 slug 做主键,避免同名课程互相覆盖
meta = {r["slug"]: r for r in rows}
# 只统计真正写回的文件数,方便和运营表对账
changed = 0
# 逐门课处理,缺元数据的不猜不写
for f in root.glob("*.json"):
data = json.loads(f.read_text(encoding="utf-8"))
slug = f.stem
if slug not in meta:
# 运营没填的行直接跳过,宁可留空也不写猜测值
continue
m = meta[slug]
# teaches 用 DefinedTerm,termCode 由 slug + 序号生成,便于跨期对齐
data["teaches"] = [
{"@type": "DefinedTerm", "name": t, "termCode": f"{slug}-S{i:02d}"}
for i, t in enumerate(split_items(m["teaches_list"]), 1)
]
# 等级值从映射表取,取不到就整门课跳过并打日志
lv = LEVEL_MAP.get(m["level_internal"].strip())
if not lv:
print("skip level:", slug)
continue
data["educationalLevel"] = lv
# 门槛清单拼成一句话,空门槛必须显式写一条最低要求
prereq = split_items(m["prereq_list"])
data["competencyRequired"] = ";".join(prereq) if prereq else "能独立安装并运行 Python 环境"
# 职业分类码由映射表提供,不让运营手填
if m["soc_code"].strip():
data["occupationalCategory"] = m["soc_code"].strip()
# indent=2 保留可读性,ensure_ascii=False 让中文直接落盘不转义
f.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
changed += 1
# 数量对不上就说明有课被跳过了,需要回查运营表
print("changed:", changed, "/", len(meta))
脚本跑完是 40 门里改了 37 门,3 门因为等级列填的是「零基础/进阶皆可」被脚本拦下,第二天运营重新判定后补跑。回头看,这个「取不到就跳过」的分支是必要的,猜测值写进去比空着更糟。
完整实体的 JSON-LD 长什么样
下面是改完之后单门课的实体,删掉了 CourseInstance 相关的部分——那些历史文章讲过,这里只留课程本身的属性深描。环境:任意支持 JSON-LD 的 CMS 模板,注释行仅作讲解用,上线前用压缩工具去掉。
{
// @context 固定指向 schema.org,漏了整段不生效
"@context": "https://schema.org",
// @type 必须是 Course,写成 Product 会让课程类提问整体落空
"@type": "Course",
// name 与页面 H1 保持一致,引擎做实体对齐时会比对
"name": "数据分析师就业实训营",
// description 只写一句话定位,技能细节交给 teaches,别在这里堆
"description": "面向转行人群的 12 周数据分析实训,含 4 个真实业务项目。",
// provider 用 Organization 实体,别只写字符串,方便挂靠机构主页
"provider": {
"@type": "Organization",
"name": "某职业教育站点",
"sameAs": "https://example.com"
},
// teaches 是出口技能清单,6-9 条,每条一个动词加工具加对象
"teaches": [
{
"@type": "DefinedTerm",
"name": "用 SQL 编写多表关联与窗口函数查询",
// termCode 用于站内去重和跨期课程对齐
"termCode": "da-sql-002"
},
{
"@type": "DefinedTerm",
// 单条控制在 20 字上下,写太长被引擎截断后语义会漂移
"name": "用 pandas 完成缺失值与异常值处理",
"termCode": "da-pd-001"
}
],
// educationalLevel 用受控词,不写零基础可学这类营销话术
"educationalLevel": "Beginner",
// competencyRequired 是入口门槛,写可检查的动作
"competencyRequired": "能独立配置 Python 虚拟环境并安装依赖包;会用 Excel 制作透视表",
// 结业凭证用实体表达,带上类别和发证方
"educationalCredentialAwarded": {
"@type": "EducationalOccupationalCredential",
// credentialCategory 用 certificate / diploma / degree 这类受控值
"credentialCategory": "certificate",
"name": "数据分析师实训结业证书"
},
// occupationalCategory 对齐 O*NET-SOC 职业码,接住岗位类提问
"occupationalCategory": "15-2051.00 Data Scientists"
// 开课时间与价格属于 CourseInstance,不要塞进 Course 本体
}
改完怎么验收,别只看校验器
结构化数据校验器只验语法和必填项,teaches 写得对不对它不管。我们自己加了三道。
- 属性完整度自查:扫一遍全站 JSON-LD,统计四个字段的填充率,低于 90% 就回流给运营补。
- 人工抽查:30 个问题 × 3 个 AI 搜索入口,记录回答里是否出现技能词、等级词,用表格记命中次数。这是我们内部的抽查口径,不是什么行业统计,只用来看趋势。
- 一致性复查:teaches 与 competencyRequired 对照读一遍,发现互相矛盾的当场改,这一步机器替代不了。
抽查时还有个意外发现:teaches 一开始我们填到 15 条,命中反而不稳,砍到 6-9 条之后回答里出现的技能词更集中。条数不是越多越好,塞太满等于稀释了每条的权重。
这些坑我们踩过,以及下一步
误区一,把大纲当 teaches。章节名在你的站里有意义,在引擎那边等于没有信号。误区二,educationalLevel 写「零基础可学」,那不是水平等级,是招生话术。误区三,觉得 competencyRequired 空着比填错安全——空值等于没有信号,引擎照样答不上来,反倒是填一条明确的最低门槛更划算。
趋势上,Course 实体的 teaches 会越来越依赖外部技能词表。现在写的是自由文本,下一步大概率要变成指向 ESCO、O*NET 这类技能库 URI 的引用,跨站能对齐的技能词才是资产,各写各的自由文本迟早会被折价。我们内部已经在做职业名到分类码的映射表,就是给这一步铺路。
顺带说一句,做 GEO 时别把力气全花在写更长的大纲文案上,把实体属性填对,性价比高得多。如果你也在改课程站的结构化数据,评论区聊聊你的 teaches 是怎么切的,我挺好奇别人家怎么处理跨期课程的技能对齐。
参考与延伸
- Course 类型官方定义:https://schema.org/Course
- teaches 属性说明:https://schema.org/teaches
- educationalLevel 属性说明:https://schema.org/educationalLevel
- competencyRequired 属性说明:https://schema.org/competencyRequired
GEO, AI搜索, Course Schema, JSON-LD, Schema.org, 结构化数据, 课程被 AI 推荐, AI优化AIO