用 @graph 把散落的 Schema 拼成实体图谱:制造业站点的结构化数据收敛方案
适用读者:已经给站点加过结构化数据、但发现 AI 回答里字段互相矛盾的后端工程师。示例以 .NET 8 生成 + Python 校验为主,任何能拼 JSON 的技术栈都适用。
有个做非标设备的老客户遇到一件怪事:官网上「服务区域」写得清清楚楚——华东与华南,四川的客户致电常熟厂区问能不能上门,AI 的回答却是「主要服务华东地区」。我们把这页的 JSON-LD 抠出来看,页面上有四段独立的 <script type="application/ld+json">:一段 Organization、一段 Product、一段 FAQPage、一段 BreadcrumbList。Organization 里 areaServed 是华东华南两条,Product 里带了一句 areaServed: 华东——因为写产品脚本的人顺手复制了一段模板。
四段脚本各自合法,单独校验都能过。问题出在 AI 引擎把它们当成了两个组织:一段说服务两个区域,一段说服务一个区域,谁对谁错没人知道,最后按哪段召回就用哪段。
多段独立 JSON-LD 为什么必然打架
结构化数据在页面上只负责"声明",真正做融合的是消费方。AI 引擎在处理页面时会先抽实体(Entity),再做实体对齐(Entity Resolution)——判断"这段说的是不是同一个东西"。对齐靠的是标识符:两个节点如果共享同一个稳定的 @id,就是同一实体,字段可以合并;没有 @id,就是两个陌生节点,各说各话。
我们那次事故里,两段脚本都没有 @id。引擎按名称做模糊匹配,Organization 的名字是"某某机械(常熟)有限公司",Product 的 brand 是"某某机械",名字不一致,直接判为两个实体。
这件事的教训不是"少写几段脚本",而是页面上的结构化数据需要一个统一的实体标识体系。JSON-LD 提供了这个能力:@graph 装多个节点,@id 给每个节点一个稳定标识,节点之间用 @id 互相引用。
graph LR
O["#organization<br/>Organization"]
W["#website<br/>WebSite"]
P["#product-gear-x<br/>Product"]
F["#faq-1<br/>Question"]
B["#breadcrumb<br/>BreadcrumbList"]
O -->|publisher| W
O -->|manufacturer| P
P -->|mainEntityOfPage| W
W -->|hasPart| F
B -->|itemListElement| W
P -->|isPartOf| O
这张图里的每个节点都是独立的对象,靠 @id 串起来。引擎读到任何一个节点,都能顺着引用把相关节点取全,不需要猜。
原理剖析:解析器怎么消费 @graph
理解消费过程,才能判断哪些字段写法是有意义的。
第一步,解析 <script> 里的 JSON,得到一棵对象树。@graph 只是一个容器,语义上等价于"这里面有一组节点"。
第二步,建立节点索引:以 @id 为键,把节点注册进一张表。这一步决定了引用能不能解开——@id 重复的两个节点会互相覆盖,谁最后被解析谁的字段生效,顺序取决于解析实现,不可依赖。
第三步,解析引用。字段值如果是 {"@id": "..."} 形式,表示指向另一个节点。解析器会用索引把它替换为实际节点,形成有向图。
第四步,做类型推断与合并。同一个 @id 出现在页面的多个位置时,字段会合并;合并遇到同名字段不同值时,多数实现取其中一个并丢弃另一个,不会报错,也不会告警。这就是我们那个 areaServed 冲突为什么静默丢失的原因。
| 写法 | 引擎看到的结果 | 风险 |
|---|---|---|
| 两段独立脚本,无 @id | 两个不相干实体 | 字段无法合并,容易矛盾 |
| 两段独立脚本,@id 相同 | 一个实体,字段合并 | 同名字段冲突时静默取一个 |
| 单个 @graph,节点用 @id 互引 | 一张完整实体图 | 引用写错会断链,需校验 |
| @graph 内含重复 @id | 节点被覆盖 | 覆盖顺序不可依赖,调试困难 |
由此得出三条硬约束:同一个 @id 在整页只出现一次;节点之间的引用必须指向真实存在的 @id;@id 一旦上线就不要随意改——改了等于换实体,历史积累的对齐关系会断。
@id 用带域名的完整 URL 比较稳妥,例如 https://www.example.com/#organization。用相对锚点(#organization)在单页内够用,但多页站点的同一实体应该共用同一个完整的域名地址,页面换成任何一个都在指同一个实体。
先把实体清单定下来,再谈图上怎么连
直接动手改脚本是最容易返工的做法。更稳的顺序是先在表格上把实体盘清楚:谁是什么类型、用什么 @id、数据从哪来。
| 实体 | Schema 类型 | @id | 数据来源 | 出现页面 |
|---|---|---|---|---|
| 企业主体 | Organization | https://www.example.com/#organization |
后台企业信息(单一来源) | 全站 |
| 站点 | WebSite | https://www.example.com/#website |
站点配置 | 全站 |
| 产品 | Product | https://www.example.com/product/gear-x#product |
产品库某条记录 | 产品详情页 |
| 常见问题 | Question | https://www.example.com/product/gear-x#faq-3 |
页面问答块 | 产品详情页 |
| 面包屑 | BreadcrumbList | 页面 URL + #breadcrumb |
路由层级 | 全站 |
这张表的作用是消灭"同一事实写两遍"。企业服务区域只在 Organization 上写一次,产品页不再重复声明——产品页需要引用时用 @id 指向组织节点。
盘完之后代码结构就清楚了:需要一个实体模型、一个把模型序列化成 @graph 的构建器、一个渲染到页面的位置(通常放在 <head> 末尾或 </body> 前)。
.NET 8 落地:构建器 + 统一注入
环境:.NET 8.0(SDK 8.0.300+),System.Text.Json,无需第三方包。思路是让每个页面产出一个 @graph,节点由构建器统一登记,重复 @id 在构建阶段就被拦下。
// EntityGraphBuilder.cs —— 实体图谱构建器
// 设计要点:实体按 @id 登记,重复登记直接抛异常,把冲突拦在开发阶段
public sealed class EntityGraphBuilder
{
// 用有序字典保存节点:输出顺序稳定,便于 diff 与快照测试
// 键就是 @id,所以这里天然完成了重复检测的准备工作
private readonly Dictionary<string, JsonNode> _nodes = new(StringComparer.Ordinal);
// 登记一个节点。同 @id 二次登记视为严重错误,不做静默覆盖
// 之所以要抛异常:静默覆盖会让"哪个字段生效"变成随机事件
public void Add(string id, string type, Action<JsonObject> fill)
{
if (_nodes.ContainsKey(id))
throw new InvalidOperationException($"实体 @id 重复登记: {id}");
// 节点两个必备字段:@id 定身份,@type 定语义
var node = new JsonObject
{
["@id"] = id, // 稳定标识:上线后不要改,改了等于换了实体
["@type"] = type, // 类型决定字段语义,Schema.org 词表里取值
};
fill(node); // 由调用方填充业务字段,构建器不管字段细节
_nodes[id] = node;
}
// 生成引用:用于节点之间互相指向,避免把同一份数据抄两遍
// 返回 {"@id": "..."} 形式,解析器会用索引把它换成实际节点
public static JsonObject Ref(string id) => new() { ["@id"] = id };
// 输出整页的结构化数据,外层包一个 @context 与 @graph
public string ToJsonLd()
{
// 外层结构固定为 @context + @graph 两个键
// @context 决定类型名的解析方式,缺了整段都读不懂
var root = new JsonObject
{
["@context"] = "https://schema.org", // 词表前缀,缺了类型名无法解析
["@graph"] = new JsonArray(_nodes.Values.Select(n => n.DeepClone()).ToArray()),
};
// 序列化选项:不缩进、中文不转义,这两个都直接影响页面体积与可读性
return root.ToJsonString(new JsonSerializerOptions
{
WriteIndented = false, // 上线用不缩进,减少页面体积
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, // 中文不被转义成 \uXXXX
});
}
}
页面侧只做一件事:把构建器产出的 JSON 写进 <script>,一个页面只有一段:
// _SchemaGraph.cshtml 局部视图(或 TagHelper 里直接输出)
// 位置建议:放在 </body> 之前,避免阻塞首屏渲染
@{
// 图谱在页面处理器里已经组装完成,这里只负责输出
var graph = ViewData["EntityGraph"] as EntityGraphBuilder;
}
@if (graph is not null)
{
// type 必须带 application/ld+json
// 写成 text/javascript 的话,引擎不会当成结构化数据来读
// 一段页面只输出一段:多段脚本等于把实体图的边界重新打散
<script type="application/ld+json">
@Html.Raw(graph.ToJsonLd())
</script>
}
填充组织与产品节点的地方,注意用引用而不是重复声明:
// 页面处理器里组装本页节点
// orgId 抽成常量:全站统一,新增页面直接复用,避免拼错
// 命名规则也一并写进团队文档:类型名小写 + 业务标识
var orgId = "https://www.example.com/#organization";
builder.Add(orgId, "Organization", node =>
{
node["name"] = "某某机械(常熟)有限公司";
node["url"] = "https://www.example.com/";
// 服务区域只在组织节点上声明一次,其他页面一律引用
// 这是本次改造消掉的第一个冲突源
node["areaServed"] = new JsonArray(
new JsonObject { ["@type"] = "AdministrativeArea", ["name"] = "华东" },
new JsonObject { ["@type"] = "AdministrativeArea", ["name"] = "华南" });
// logo 与 sameAs 用于实体消歧:告诉引擎这是哪个真实存在的组织
// sameAs 建议指到工商信息或行业目录的公开页面
});
// 产品节点用自己的 URL + #product 做 @id,范围限定到具体型号
builder.Add("https://www.example.com/product/gear-x#product", "Product", node =>
{
node["name"] = "齿轮箱 X 型";
node["model"] = "GX-320";
// 用 @id 引用组织节点,而不是再写一遍品牌与服务区域
node["manufacturer"] = EntityGraphBuilder.Ref(orgId);
// 参数用 PropertyValue 表达,键值对形式比长文本更容易被抽取
node["additionalProperty"] = new JsonArray(
new JsonObject { ["@type"] = "PropertyValue", ["name"] = "额定扭矩", ["value"] = "320 N·m" });
});
改造前后的量级差别值得看一眼:
| 指标 | 改造前 | 改造后 |
|---|---|---|
| 单页 JSON-LD 脚本段数 | 3~6 段 | 1 段 |
| 实体节点数(同一事实重复声明计一次) | 4~7 个 | 稳定为 3~5 个 |
| 有无实体标识 | 无 | 每个节点一个 @id |
| 字段冲突发现方式 | 上线后靠人工对比 AI 回答 | 构建阶段抛异常 |
| 新增页面时的改动 | 复制粘贴脚本模板 | 登记节点 + 引用已有 @id |
第三行是这次改造的核心收益:冲突从"事后发现"变成"构建期报错"。
校验:引用完整性不能靠眼睛
@graph 的风险点从"字段矛盾"换成了"引用断链"——引用一个不存在的 @id,多数校验工具不会报错,只是那个字段在消费端为空。
# 环境:Python 3.10+,仅标准库;抓取线上页面里的 JSON-LD 做引用完整性检查
# 用途:放进上线前检查或每日巡检,比人工肉眼比对可靠
# 退出码:0 通过,1 存在问题,可直接被 CI 或定时任务消费
import json
import re
import sys
import urllib.request
# 只认 application/ld+json;写成 text/javascript 的脚本不算结构化数据
SCRIPT_RE = re.compile(
r'<script[^>]+type=["\']application/ld\+json["\'][^>]*>(.*?)</script>',
re.S | re.I,
)
def collect_nodes(doc: dict) -> list:
"""把 JSON-LD 文档里所有节点摊平成一维列表,兼容带 @graph 与不带两种形态。"""
# 标准形态:外层是容器,节点都在 @graph 里
if "@graph" in doc:
return list(doc["@graph"])
# 历史脚本常常是单节点裸写,这里一并接受,避免误报
return [doc]
def walk(node, path=""):
"""递归遍历节点,产出 (路径, 键, 值),路径用于定位报错位置。"""
# 字典:逐键下钻,路径用点号拼接,报错时能直接定位到字段
if isinstance(node, dict):
for k, v in node.items():
yield f"{path}.{k}", k, v # 当前键值本身
yield from walk(v, f"{path}.{k}") # 继续下钻,覆盖嵌套对象
# 数组:用下标表示层级,避免多个元素共用一个路径
elif isinstance(node, list):
for i, v in enumerate(node):
yield from walk(v, f"{path}[{i}]")
def check(url: str) -> int:
# 超时给 15 秒:巡检脚本不能因为一个慢响应挂住整条流水线
html = urllib.request.urlopen(url, timeout=15).read().decode("utf-8", "ignore")
# 逐段解析;JSON 语法错误属于最严重的一类,直接判定失败
# 语法错误时后面的检查都没有意义,所以立即返回
nodes = []
for raw in SCRIPT_RE.findall(html):
try:
nodes.extend(collect_nodes(json.loads(raw)))
except json.JSONDecodeError as e:
print(f"[FAIL] JSON-LD 语法错误: {e}")
return 1
# 建立 @id 索引,同时记录重复:重复会导致字段被静默覆盖
# 索引本身就是后面判断"引用是否有效"的依据
ids, dups = set(), set()
for n in nodes:
if isinstance(n, dict) and "@id" in n:
if n["@id"] in ids:
dups.add(n["@id"])
ids.add(n["@id"])
# 引用检查:形如 {"@id": "..."} 的值都必须指向已登记的节点
broken = []
for n in nodes:
for path, key, val in walk(n):
if isinstance(val, dict) and set(val.keys()) == {"@id"}:
if val["@id"] not in ids:
broken.append(f"{path} -> {val['@id']}")
# 先报重复,再报断链,最后给一句总览便于日志检索
if dups:
print("[FAIL] 重复 @id:", *sorted(dups), sep="\n ")
if broken:
print("[FAIL] 引用断链:", *sorted(set(broken)), sep="\n ")
print(f"[INFO] 节点 {len(nodes)} 个,@id {len(ids)} 个")
# 有任一问题即返回非零,方便挂到 CI 上
return 1 if (dups or broken) else 0
if __name__ == "__main__":
# 默认检查首页,也可以从命令行传入具体产品页 URL
sys.exit(check(sys.argv[1] if len(sys.argv) > 1 else "https://www.example.com/"))
顺手补一句实践建议:把这段脚本挂到每天固定时间的巡检上,比等到 AI 回答出问题再查要省事得多。
sequenceDiagram
participant D as 开发提交
participant B as 构建阶段
participant P as 页面渲染
participant C as 巡检脚本
D->>B: 登记实体节点
B->>B: 检查 @id 是否重复
alt @id 重复
B-->>D: 构建失败,直接拦住
else 通过
B->>P: 输出单段 @graph JSON-LD
P->>C: 线上页面被抓取校验
C->>C: 检查引用完整性与重复 @id
C-->>D: 异常时告警
end
迁移节奏:不要一次换完
改动结构化数据最怕两种结果:一是旧脚本删早了,索引短期掉线;二是新旧混跑,引擎同时看到两套图,对齐又乱。我们分了四步走,每步留一周观察。
第一步,只给现有脚本补 @id,结构不动。这一步的目标是让引擎先建立"这些节点是同一实体"的认知,页面输出形态基本不变。
第二步,把重复的事实收敛到单一节点。服务区域、品牌名、联系方式这类字段只保留在组织节点上,其他页面改成引用。这一步之后页面上能同时看到旧写法与新写法,属于正常状态。
第三步,合并成单段 @graph,页面脚本段数从多段降为一段。
第四步,清理残留:删掉模板里的旧脚本、把校验脚本接进巡检、在 CI 里加一条"单页只允许一段 JSON-LD"的检查。
第二步和第三步之间不要压缩时间。我们试过一周内走完,结果搜索引擎的缓存里同时留着新旧两版页面,抓到的图时好时坏,日志里能看到同一 URL 返回的节点数在跳。
三个容易走偏的判断
以为节点越多越好。 实体图的价值在"关系清晰",不在"节点数量"。把页面上每段文字都做成节点,只会增加冲突面。判断标准很简单:这个实体是否有独立的、会被单独引用的身份。没有就作为字段,别做成节点。
以为 @graph 能解决内容问题。 结构化数据只影响"理解",不影响"质量"。内容空洞的页面把图谱做得再漂亮,也不会被引用为答案来源。这两件事要分开投入。
以为 @id 随便写个字符串就行。 @id 是实体的持久身份。用 #org、#organization、#company 三种写法指同一个组织,等于造了三个实体,前面所有的对齐工作都会白做。定好命名规则写进规范,新增页面照抄。
往后会怎么走
方向是从"页面上声明"走向"站点级实体库"。现在的做法是每个页面各自拼图,同一实体的字段散在各页模板里;接下来更合理的架构是实体数据集中维护、页面只是它的投影——企业主体、产品、服务这些实体在后台有各自独立的一条记录,页面渲染时按模板取用并生成 @graph。
这跟我们之前讨论过的内容镜像、Markdown 投影是同一个思路的延伸:内容与结构化数据都不应该散落在模板里。真动手时,我的建议还是从最痛的那个字段开始——先找出 AI 回答里与你官网不一致的那个事实,看它在几个节点上被写了几遍,再从那里开始收敛。改一处往往就能看到变化,比整站重构更容易验证。
你们的图谱里如果也有同名不同 @id 的实体,欢迎在评论区贴一下类型和字段,这类冲突的排查经验挺有共性。
参考与延伸
- Schema.org 官方词表与示例:https://schema.org/docs/documents.html
- JSON-LD 1.1 规范(@graph 与 @id 的语义定义):https://www.w3.org/TR/json-ld11/
- Google 结构化数据通用指南:https://developers.google.com/search/docs/appearance/structured-data/sd-policies
- Schema Markup Validator(官方校验工具):https://validator.schema.org/
关键词:GEO、AI优化AIO、JSON-LD、@graph 实体图谱、Schema.org、实体对齐、结构化数据治理、制造业 B2B