课程和大纲对不上号:educationalAlignment 把课程页对齐到能力标准的实验记录
「你们的 PM-201 冲刺课,到底覆不覆盖『需求优先级排序』这个考点?」
第 11 周的答疑群里有人贴了 AI 助手的回答截图:讲了三句课程卖点,收尾是"建议联系课程顾问确认"。课程页上大纲写得清清楚楚,第 3 章标题叫需求分级与排序,标准条目在另一个详情页里也挂着。人对得上号,机器对不上。
我们拿 24 门课做了 45 天对照实验,改动全在 Course 的 educationalAlignment 和几个相邻字段上。这篇记的是排查过程、写法和踩过的坑。
学员那一问,卡在哪儿
我们做的第一件事是把口径固定下来:整理 30 条学员常问的考点问题,每天用同一个 AI 搜索入口问一遍,连问 7 天,记录回答里有没有出现考点名、有没有给出课程页链接、有没有明确说"覆盖"或"不覆盖"。

结果是 30 条里只有 11 条能给出明确结论,剩下 19 条都在复述课程介绍。看页面源码就明白了:大纲是一组 div,课时标题是 span,第 3 章叫"需求分级与排序",而标准库里那一条叫"需求优先级排序(MoSCoW/RICE)"。两个名字不一样,全站没有一处把这两个词写在一起。
再看结构化数据,问题更直接——Course 节点里只有 name、description、provider,description 是一句"覆盖产品全生命周期,含真题精讲"这类的话。整页没有任何一个字段,把这门课和考点标准连起来。
根子在命名口径。教研按自己的习惯起章节名,标准体系按自己的编号起条目名,中间缺一根线。改文案解决不了这事儿,因为下次新增课程还会犯同样的错,得把线固化进数据结构。
AI 是怎么判断「覆盖」的:抽取与对齐这套机制
生成式搜索回答这类问题,大致走四步:抓取与分块、向量召回、拼装上下文、生成回答。结构化数据不在分块链路里,它走另一条旁路,这是理解后面所有改动的前提。
分块一般按 DOM 结构切。div 加 span 拼出来的大纲,切完可能一章断成三段,每段各自丢进向量库,段与段之间没有共同主语。召回时查询是"需求优先级排序",库里存的是"需求分级与排序",语义相近但不等价,未必排得进 top-k。就算召回了,模型拿到的也只是"课程包含第 3 章 需求分级与排序"这一句,它没法自己判断这等于标准里的哪一条。
JSON-LD 不一样。它整体被解析成一张实体—属性的事实表,不参与切分。Course 的 @id、teaches 指向的 DefinedTerm、AlignmentObject 的 targetUrl,都会变成这张表里可查询的条目。模型在生成前能查到"PM-201 → 对齐 → 需求优先级排序(MoSCoW/RICE)"这条三元组,回答就有依据了。
flowchart LR
Q[学员问:PM-201 覆盖需求优先级排序吗] --> R[检索:课程页与课时页]
R --> C[按 DOM 分块 + 向量召回]
C --> G[拼装上下文并生成]
J[(JSON-LD 事实层)] -.提供实体与考点对齐.-> G
S[(标准库条目页)] -.targetUrl 回指.-> J
G --> A[回答:覆盖,见第 3 章,附链接]
C -.大纲被切碎或命名不一致.-> B[回答:含糊,只复述卖点]
一句话概括这个机制:正文负责"像不像",结构化数据负责"是不是"。只有正文,模型能回答得像;有对齐数据,它才敢下判断。
有个反直觉的地方值得单独说。给 AI 的锚点越具体越好。targetUrl 指向一个能公开访问、页面上只讲一个考点条目的地址,比指向一个汇总页有用得多。我们把标准库从"一页列 40 条"拆成"一条一页"之后,同样的问题命中率又涨了一截。
educationalAlignment 能写什么,不能写什么
AlignmentObject 一共五个字段,含义如下。alignmentType 在 schema.org 上给的是推荐值集合,常见的是 teaches、assesses、requires 这几个,用来说明这次对齐是什么性质。
| 字段 | 期望类型 | 作用 | 我们这边的填法 |
|---|---|---|---|
| alignmentType | Text | 这次对齐的性质 | teaches / assesses / requires |
| educationalFramework | Text | 标准体系的名字 | PMCM-3.2、CEFR、ISTE |
| targetName | Text | 标准里那一条的名字 | 需求优先级排序(MoSCoW/RICE) |
| targetDescription | Text | 这一条具体要会做什么 | 能用 RICE 给需求打分并产出排序结论 |
| targetUrl | URL | 该条目的可访问地址 | 站内标准库条目页,必须公开 |
schema.org 在 educationalAlignment 的说明里有一句提醒:能用简单属性描述清楚的对齐,就别用 AlignmentObject。课程教某个考点用 teaches,课程会测某个考点用 assesses,入学前必须会的能力用 competencyRequired。这几个属性都从 LearningResource 继承到 Course 上,是合法且更省事的写法。
三者的区别在实际问答里差别很大。学员问"这门课讲不讲 RICE",走 teaches;问"这门课的考试考不考 RICE",走 assesses;问"我没学过 SQL 能不能上",走 competencyRequired。混在一起写,模型在生成时就会把"教"和"考"混着说,反而制造新的错误答案。
competencyRequired 和 teaches 的期望类型都包含 DefinedTerm,我们统一用 DefinedTerm 并给 @id,指向站内标准库条目页。这样同一个考点在 24 门课里引用的是同一个实体,改一次名字全站生效。
改造前的课程页长什么样
当时的 Course 节点是这样,字段一个不少,全是给人看的:
{
"@context": "https://schema.org", "@type": "Course",
"courseCode": "PM-201", "name": "产品经理认证冲刺课",
"description": "覆盖产品全生命周期,含真题精讲与案例拆解。",
"provider": {"@type": "Organization", "name": "示例教育机构",
"url": "https://example.edu"}
}
机器读到 description 只会得到一个字符串,里面"全生命周期"这种词没有对应的实体可查。页面上的大纲章节名在 HTML 里,JSON-LD 里一个字都没有,两边各说各的。
三段对齐怎么落到页面上
标准条目先变成实体
我们把改造拆成三段,每段只改一件事,方便出问题回滚。
第一段,标准条目先变成实体。站内开了 /standard/pmcm-3.2/{slug} 这一组条目页,每页只讲一个考点,页里放 DefinedTerm 加 inDefinedTermSet,@id 用带片段的形式。这一步做完,全站才有了一个可被反复引用的锚点池。
Course 上挂对齐数组
第二段,Course 上挂 educationalAlignment 数组,一条对齐指向一个条目页。数量要克制,我们给自己定的上限是每门课 8 条,超过就说明这门课该拆了。
教什么、测什么、进门要会什么,分开写
第三段,把 teaches、assesses、competencyRequired 补齐,"教什么、测什么、进门要会什么"三件事彻底分开写。课时层面用 Syllabus 挂 alignment,让模型能回答"第几章讲这个"。
flowchart TD
C[Course PM-201] --> EA[AlignmentObject]
EA -->|targetUrl| T[DefinedTerm 需求优先级排序]
T -->|inDefinedTermSet| DS[DefinedTermSet PMCM-3.2]
C -->|teaches| T2[DefinedTerm 指标拆解]
C -->|assesses| T4[DefinedTerm A/B 实验设计]
C -->|competencyRequired| T3[DefinedTerm SQL 基础查询]
C -->|syllabusSections| SY[Syllabus 第 3 章]
SY --> EA2[AlignmentObject]
EA2 -->|targetUrl| T
下面是生成这段结构的脚本。依赖 Python 3.10 及以上,不需要第三方库,输入是我们课程元数据表的一行。
# 依赖:Python 3.10+,只用标准库,无第三方包
# 输入:课程元数据表的一行记录 dict
# 输出:可直接塞进 script type=application/ld+json 的 Course 节点
# 约定:alignment_type 只放 teaches / assesses 两个值
from typing import Any
# 一个 AlignmentObject 对应标准库里的一条,粒度是考点不是章节
def build_alignment(a: dict[str, Any]) -> dict[str, Any]:
# targetName 抄标准原文,别用教研自己的简称
# targetDescription 补一句「会做什么」,模型常靠这句判断是否覆盖
# targetUrl 必须公开可访问,带登录态的后台页等于没写
# 五个字段缺一个,这条对齐在校验脚本里就会被拦下来
return {
"@type": "AlignmentObject",
# 下面这五个字段决定这条对齐能不能被机器读懂
"alignmentType": a["alignment_type"],
"educationalFramework": a["framework"],
"targetName": a["target_name"],
"targetDescription": a["target_desc"],
"targetUrl": a["target_url"],
}
# DefinedTerm 统一带 @id 指向站内条目页,方便跨课复用同一个实体
def as_defined_term(t: dict[str, Any]) -> dict[str, Any]:
# name 用标准名,alias 放教研和学员日常用的叫法
# 同义词放这里,正文写「需求分级」也能对上标准条目
term = {
"@type": "DefinedTerm",
# @id 用 #term 片段,与条目页上的实体是同一个
"@id": t["term_url"],
"name": t["name"],
# alternateName 可以是字符串,也可以是字符串数组
"alternateName": t.get("alias", []),
# inDefinedTermSet 指向标准体系的集合页
"inDefinedTermSet": t["set_url"],
}
return term
def build_course(row: dict[str, Any]) -> dict[str, Any]:
# 对齐数组:一条一个考点,超过 8 条说明这门课该拆了
aligns = [build_alignment(a) for a in row["alignments"]]
# 下面三行按 rel 字段把词表分成三堆,写错 rel 就整条丢失
# teaches 是课程产出,学员问「讲不讲 XX」走这个
teaches = [as_defined_term(t) for t in row["terms"] if t["rel"] == "teaches"]
# assesses 是考核项,学员问「考不考 XX」走这个
assesses = [as_defined_term(t) for t in row["terms"] if t["rel"] == "assesses"]
# competencyRequired 是入学门槛,不是课程产出,两者别混
required = [as_defined_term(t) for t in row["terms"] if t["rel"] == "required"]
# courseWorkload 用 ISO 8601 时长,别写「20 小时」这种自由文本
workload = row.get("workload", "PT20H")
return {
"@context": "https://schema.org",
"@type": "Course",
# @id 带 #course 片段,页面上其他实体就能引用它
"@id": f"https://example.edu/course/{row['code']}#course",
"courseCode": row["code"],
"name": row["name"],
# 对齐数组放在靠前位置,抓取端解析时更容易看到
"educationalAlignment": aligns,
"teaches": teaches,
"assesses": assesses,
# 门槛能力单独列,别并进 teaches
"competencyRequired": required,
"hasCourseInstance": {
"@type": "CourseInstance",
# courseMode 常用值:online、onsite、blended
"courseMode": "online",
# 时长统一从元数据表读,避免各课写法不一致
"courseWorkload": workload,
},
}
跑出来的 Course 片段长这样,注意 educationalAlignment 里每一项都是完整的五字段,teaches 与 competencyRequired 用的是同一个 DefinedTerm 池:
{
"@context": "https://schema.org", "@type": "Course",
"@id": "https://example.edu/course/PM-201#course",
"courseCode": "PM-201", "name": "产品经理认证冲刺课",
"educationalAlignment": [{
"@type": "AlignmentObject",
"alignmentType": "teaches",
"educationalFramework": "PMCM-3.2",
"targetName": "需求优先级排序(MoSCoW/RICE)",
"targetDescription": "能用 RICE 给需求打分并产出排序结论",
"targetUrl": "https://example.edu/standard/pmcm-3.2/requirement-prioritisation"
}],
"teaches": [{"@type": "DefinedTerm", "@id": "https://example.edu/standard/pmcm-3.2/requirement-prioritisation#term", "name": "需求优先级排序(MoSCoW/RICE)", "alternateName": ["需求分级", "需求排序"], "inDefinedTermSet": "https://example.edu/standard/pmcm-3.2#set"}],
"competencyRequired": [{"@type": "DefinedTerm", "@id": "https://example.edu/standard/pmcm-3.2/sql-basics#term", "name": "SQL 基础查询", "inDefinedTermSet": "https://example.edu/standard/pmcm-3.2#set"}]
}
有个取舍要提前决定:alignment 挂多少条。挂满看上去信息量大,实际会稀释。我们试过给 PM-201 挂 30 条,结果模型回答"覆盖 XX 吗"时开始乱点,因为候选太多反而分不清主次。收到 8 条主考点之后,回答反而更稳。
发课前跑一遍校验
改完不放校验,三个月后一定会烂掉。我们把下面这个脚本挂在课程发布的 CI 上,覆盖率不达标直接失败。依赖 Python 3.10 及以上与 requests,放在 CI 的 lint 阶段跑。
# 依赖:Python 3.10+、requests;作为课程发布流水线的 lint 步骤执行
# 用法:python check_align.py <课程页 URL> <大纲考点名...>
# 退出码 0 通过,非 0 阻塞发布
import json
import sys
import requests
# schema.org 对 alignmentType 的推荐值,我们只放行这三个
ALLOWED_TYPES = {"teaches", "assesses", "requires"}
# 覆盖率门槛:大纲里出现的考点,至少九成要有对齐条目
MIN_COVERAGE = 0.9
def fetch_ld(url: str) -> list[dict]:
# 只解析 application/ld+json,HTML 里的表格一律不看
# 拿整页 HTML,超时设短一点,免得卡住流水线
html = requests.get(url, timeout=15).text
out = []
# 按 ld+json 这个标记切一刀,比正则抠 script 块稳
for chunk in html.split('application/ld+json'):
# 截取一个 script 块的内容,脏数据直接跳过
seg = chunk.split('>', 1)[-1].split('</script', 1)[0].strip()
try:
out.append(json.loads(seg))
except json.JSONDecodeError:
# 页面上可能有多个 JSON-LD,坏一个不影响其他的
continue
return out
# 返回 0 表示这门课可以发布,返回 1 表示有硬伤
def check(course_url: str, outline_terms: list[str]) -> int:
# 取出页面上的 Course 节点,一个都没有直接判失败
nodes = [n for n in fetch_ld(course_url) if n.get("@type") == "Course"]
if not nodes:
print(f"[FAIL] {course_url} 没有 Course 节点")
return 1
errs = 0
for node in nodes:
# 收集已对齐的考点名,包括别名,用于算覆盖率
aligned = set()
# 一条对齐都没挂的课程,直接判失败不往下走
if not node.get("educationalAlignment"):
print(f"[FAIL] {node.get('courseCode')} 没有 educationalAlignment")
errs += 1
continue
for a in node.get("educationalAlignment", []):
# alignmentType 必须在白名单内
if a.get("alignmentType") not in ALLOWED_TYPES:
print(f"[FAIL] 非法 alignmentType: {a.get('alignmentType')}")
errs += 1
# targetUrl 必须返回 200,404 和 401 都算断链
r = requests.head(a.get("targetUrl", ""), timeout=10, allow_redirects=True)
if r.status_code != 200:
print(f"[FAIL] targetUrl 不可访问: {a.get('targetUrl')} -> {r.status_code}")
errs += 1
# targetName 与 targetDescription 缺一个就算不完整
if not (a.get("targetName") and a.get("targetDescription")):
print(f"[FAIL] 对齐条目字段不全: {a}")
errs += 1
aligned.add(a.get("targetName", ""))
# 大纲里写了但没对齐的考点,就是漏网之鱼
missing = [t for t in outline_terms if t not in aligned]
# 覆盖率按考点数算,不按对齐条数算
cov = 1 - len(missing) / max(len(outline_terms), 1)
if cov < MIN_COVERAGE:
print(f"[FAIL] 覆盖率 {cov:.0%} < {MIN_COVERAGE:.0%},漏了 {missing}")
errs += 1
return 1 if errs else 0
if __name__ == "__main__":
# 参数:课程页地址,后面跟大纲里的考点名
sys.exit(check(sys.argv[1], sys.argv[2:]))
发布前还会用一条 curl 做兜底,确认标准条目页没被权限挡住。这步在 shell 里跑,依赖 curl 与可公开访问的标准库:
# 发课前兜底自查,依赖 curl 与可公开访问的标准库
# 确认标准条目页可公开访问,401/403 都算没对齐
curl -s -o /dev/null -w "%{http_code}\n" https://example.edu/standard/pmcm-3.2/requirement-prioritisation
# 确认课程页里确实注入了 ld+json,输出应大于 0
curl -s https://example.edu/course/PM-201 | grep -c 'application/ld+json'
校验里最容易漏的是同义词。大纲正文写"需求分级",标准条目写"需求优先级排序",脚本按名字比对会判成未对齐。我们的做法是给 DefinedTerm 加 alternateName,脚本比对时把别名也算进去,两边就不再打架。
45 天对照实验的数据
实验从春季班开课前开始跑,12 门课做实验组改对齐,12 门课原样不动做对照组。问法固定那 30 条,每周采样一次,共 45 天。数据是我们自己的内部记录,样本小,看趋势比看单点数字有意义。
改造内容本身不多,两组的差异集中在下表这几项:
| 维度 | 对照组(12 门,未改) | 实验组(12 门,已改) |
|---|---|---|
| 大纲载体 | div 列表 + 一张章节图 | 同样 HTML,另加 Course JSON-LD |
| 考点命名 | 教研自己的叫法 | 标准名 + alternateName 同义词 |
| 标准出处 | 无 | targetUrl 指向公开条目页 |
| 教与考的区分 | 无 | teaches / assesses 分开 |
| 前置能力 | 正文一句话 | competencyRequired(DefinedTerm) |
| 发布校验 | 人工看一眼 | CI 脚本,覆盖率低于 90% 失败 |
问答层面的变化如下:
| 指标 | 改造前 | 45 天后 |
|---|---|---|
| 30 条问法的明确结论率 | 37% | 78% |
| 回答中出现具体考点名的比例 | 20% | 71% |
| 回答附带课程页链接的比例 | 43% | 83% |
| 回答含糊或答非所问的比例 | 46% | 12% |
| 从回答进入课程页后的报名转化 | 1.2% | 2.6% |
明确结论率翻倍主要来自对齐条目本身,链接率那一截更多来自标准条目页的拆分——条目页变成独立可抓取的页面之后,模型引用时有了明确的落点。转化率的提升我们不敢全算在结构化数据头上,同期还改了课程页的首屏,只能说方向一致。
一个没解决的遗留项:跨标准体系的对齐还是乱。同一门课同时对齐内部的 PMCM-3.2 和外部的一个能力框架时,模型偶尔会把两个体系的条目串成一条。我们暂时的处理是同一门课只对齐一个体系,跨体系映射放到标准库内部去做。
踩过的五个坑
targetName 直接照抄标准原文,学员搜索词对不上,这是第一坑。学员不会搜"需求优先级排序(MoSCoW/RICE)",会搜"需求怎么排优先级"。补 alternateName 之后才好转。
一个 Course 挂几十条 alignment,主次不分,是第二坑。第三坑是 targetUrl 指向要登录的内部页面,外部抓取只拿到 401,等于白写。第四坑是 alignmentType 与 competencyRequired 混用,模型把"要求先会 SQL"说成"这门课教 SQL",学员报名后才发现不教。
第五坑最隐蔽:改了 JSON-LD,大纲正文没同步。两边说法不一致时,模型不一定信结构化数据那份,它会把冲突当成噪声。我们把大纲正文和 alignment 条目放在同一个后台表单里生成,从源头堵住这个口子。
什么时候这套做法不划算
课程数量少于五门、考点标准本身还在频繁改的团队,先别上这套。标准条目页刚建好就改编号,targetUrl 全站失效,维护成本比收益高。这时候把大纲写成语义清楚的 HTML(h2/h3 分章、列表分点、章节名与标准名至少有一处并列出现)就够用,省事得多。
往后看,AI 搜索对教育类站点的事实核对会越来越依赖外部锚点。站内有自己的标准库、条目能一条一页公开访问、课程与考点之间有可机读的连线——这三件事做完,换哪个引擎都吃得到。纯靠正文关键词堆砌的做法,窗口期正在变短。
你们那边课程与考点的对齐是怎么管的,有没有比 DefinedTerm 更省事的做法?评论区聊聊,我这边把后续的跨体系映射方案整理出来再发一篇。
参考与延伸
- schema.org Course 类型定义(属性清单与继承关系):https://schema.org/Course
- schema.org AlignmentObject 五个字段说明:https://schema.org/AlignmentObject
- schema.org LearningResource,teaches / assesses / competencyRequired 的来源:https://schema.org/LearningResource
- 结构化数据通用规范与校验入口:https://developers.google.com/search/docs/appearance/structured-data/intro-structured-data
- schema.org 官方标记校验器:https://validator.schema.org/
GEO|educationalAlignment|AlignmentObject|结构化数据|AI搜索优化|课程页Schema|实体对齐