课程页只写大纲不够:teaches、educationalLevel 与 competencyRequired 的课程实体深描实战

2026-09-20 01:19:07 5 次浏览
GEO在线教育Schema.orgJSON-LD结构化数据AI搜索

适用读者:职业教育、知识付费、企业培训站点里负责结构化数据的前端或后端工程师;已经给课程页打过 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 是怎么切的,我挺好奇别人家怎么处理跨期课程的技能对齐。

参考与延伸

GEO, AI搜索, Course Schema, JSON-LD, Schema.org, 结构化数据, 课程被 AI 推荐, AI优化AIO

🤖
本内容由 AI 辅助生成,经人工校对审核;部分素材、资料来源于公开网络,仅作个人观点分享与交流使用,无任何商业侵权意图。若内容、图片、文字涉及您的合法著作权、版权权益,请联系本人,核实后将第一时间删除、修改相关内容。