用 @graph 把散落的 Schema 拼成实体图谱:制造业站点的结构化数据收敛方案

2026-09-17 11:41:00 14 次浏览
JSON-LD@graphSchema.org实体对齐结构化数据

适用读者:已经给站点加过结构化数据、但发现 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

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