课程和大纲对不上号:educationalAlignment 把课程页对齐到能力标准的实验记录

2026-09-23 01:21:37 0 次浏览
GEOschema.orgJSON-LD结构化数据PythonAI搜索

「你们的 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。混在一起写,模型在生成时就会把"教"和"考"混着说,反而制造新的错误答案。

competencyRequiredteaches 的期望类型都包含 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 里每一项都是完整的五字段,teachescompetencyRequired 用的是同一个 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,等于白写。第四坑是 alignmentTypecompetencyRequired 混用,模型把"要求先会 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|实体对齐

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