设备页、案例页、公司页被 AI 搜索当成三个孤岛:用 @id 接成一个实体网络
适用读者:负责制造企业官网或 B2B 产品站的后端工程师(示例用 ASP.NET Core,其他语言可对照思路)、正在推生成式引擎优化(Generative Engine Optimization, GEO)的技术负责人、同时维护设备型号页、行业案例页、公司介绍页三类页面模板的开发者。不需要 SEO 背景,但要能改服务端代码、能往页面 head 里注入 JSON-LD。
去年 8 月给一家做数控机床的客户做 GEO 诊断,翻 Perplexity 的引用记录时发现一件很别扭的事:AI 介绍那台主力立式加工中心,全程没提厂家是谁;介绍厂家,又只字未提那台投了三年推广费的主力机型。三类页面各自都能被 AI 搜索引用,但拼不成一个完整的实体。
内容没毛病,缺的是语义层的"引线"。这篇只讲一件事:用 Schema.org 的 @id——一个 IRI 形式的实体标识符——把三类页面接成跨页面实体网络。about+mentions、sameAs、isBasedOn 这些字段的语义之前已经写过多篇,这里不重复展开,聚焦 @id 引用机制本身:IRI 怎么命名、@graph 里骨架节点怎么摆、跨页面怎么串联。
先泼冷水:AI 引擎不会替你做实体合并
很多人的直觉是"AI 这么聪明,同一家公司总能猜出来吧"。实测恰恰相反。AI 爬虫拿到的是一页页孤立的 HTML,实体对齐依赖页面里显式的机器可读信号。没有 @id,引擎只能靠字符串匹配公司名;公司名在设备页是全称、在案例页是简称、在 About 页混着英文名,匹配链就断了。

6 月我们做过一个对照:同一个站,三份 JSON-LD 语法全部合规,但设备页用公司全称、案例页用简称。Perplexity 生成的回答里,这两个名字各说各的,一看就是两个实体。引擎的实体对齐不是语义猜测,是标识符对齐,标识符得你自己给。
原理剖析:@id 的跨页面归并机制
先说 JSON-LD 层的规则。@id 是节点的身份标识。同一份文档里,两个节点只要 @id 相同,解析器就把它们合并成一个节点,字段取并集,这是 JSON-LD 1.1 规范里写得明明白白的行为。跨文档时,因为 IRI 在全网范围内不重复,合规的解析器同样可以把不同 URL 上抓到的同 @id 节点归并到一起。
AI 引擎侧的处理链路大致四步:抓取页面、抽取 JSON-LD 里的实体与关系、按 @id 做对齐归并、生成回答时按实体取材而不是按页面取材。下面这条时序图就是符合各家公开文档描述的流程:
sequenceDiagram
participant Bot as AI 爬虫
participant Idx as 实体索引
Bot->>Idx: 抓 /products/vmc850,登记 @id=…/products/vmc850/#device
Bot->>Idx: 抓 /cases/c0231,读到 about 指向同一个设备 @id
Idx->>Idx: 把案例的成交背景挂到设备实体上
Bot->>Idx: 抓 /about,登记 @id=…/#organization
Idx->>Idx: 由 brand 引用补上设备到公司的边
Note over Idx: 三个 URL 归并成两个实体、一组关系边
还有一个容易忽略的点:骨架节点(stub)不是可有可无的。有些引擎的解析器只处理单页文档,不会为了补全引用再回源抓一遍。所以每个页面的 @graph 里,被引用的实体要放一个只带 @id、@type、name 的骨架节点,把图在本页内部先连通;跨页面的归并交给 IRI 本身完成。原则就一条:完整定义只出现一次,其他地方只留 @id。
@id 用 IRI 而不是普通字符串,还有一层含义:规范上它不要求可访问、不保证能解引用,但实践里我们一律用"真实页面 URL + 锚点"的形式。这样既让不认识 JSON-LD 的兜底解析器也能顺着 URL 找到页面,出问题时人也方便直接打开排查。
动手前,先把 IRI 命名规矩定死
@id 一旦被引擎收录,改一次就是一次实体分裂:老 IRI 和新 IRI 会被当成两个实体,各自在索引里漂一阵子。所以命名规矩要当成接口契约来定,我们站上收敛成三条:
- 路径段用系统主键(产品编号、案例编号),不用 SEO 友好的中文别名,URL 改版不影响 @id;
- 锚点后缀标明实体类型(#device、#case、#organization),一个页面将来挂多个实体时不打架;
- 全站 @id 只允许 https 一个 scheme,www 与非 www 只留一种写法,301 重定向必须先于 JSON-LD 变更上线。
三类页面在实体网络里各自扮演什么角色,一张表说清楚:
| 页面 | 主节点(完整定义现场) | 引用的实体(只留 @id) |
|---|---|---|
| 设备型号页 /products/{型号} | Product 设备本体,字段写全 | 公司实体(brand 引用 + Organization 骨架) |
| 行业案例页 /cases/{编号} | Article 案例正文,字段写全 | 设备实体、公司实体(about/author 引用 + 骨架) |
| 公司 About 页 /about | Organization 公司本体,字段写全 | 不引用,是全网的引用终点 |
改造后的实体网络长这样:
flowchart LR
P1["设备型号页 /products/vmc850"] -->|主节点 @id| D(("设备实体<br/>…/products/vmc850/#device"))
P2["行业案例页 /cases/c0231"] -->|about 引用 @id| D
P2 -->|author 引用 @id| O(("公司实体<br/>…/#organization"))
P3["公司 About 页 /about"] -->|主节点 @id| O
D -->|brand 引用 @id| O
style D fill:#eef7ee
style O fill:#eef7ee
对应到代码,就是把所有 IRI 收进一个静态类,任何页面不允许手写 @id 字符串。下面是完整的注册表写法。
// 依赖:.NET 8(System.Text.Json 为框架自带,无需第三方包)
// 环境:ASP.NET Core 8 MVC,Razor 布局页统一注入 <script type="application/ld+json">
namespace EquipmentSite.Schema;
// 全站实体 IRI 注册表:所有 @id 只在这里定义,别的文件不许出现裸字符串
public static class EntityIri
{
// 主域做前缀:@id 是全局 IRI,要求全网不重复且长期稳定
public const string Base = "https://www.example-equipment.com/";
// 公司实体锚点:全站只有这一处定义,三类页面统一引用
public const string Organization = Base + "#organization";
// 设备实体:路径段用产品编号,页面 URL 改版时 @id 不跟着漂移
public static string Device(string modelNo) => $"{Base}products/{modelNo}/#device";
// 案例实体:编号与后台主键一致,排查时方便和数据库对账
public static string Case(string caseNo) => $"{Base}cases/{caseNo}/#case";
// 规矩:新增实体先来这里登记,再写页面;注销实体要保留 IRI 并做 301
}
设备型号页:主节点写全,引用只留 @id
设备页是设备实体的"定义现场",主节点把字段写完整,公司作为 brand 引用只留 @id。JSON-LD 输出收敛到一个基类加几个节点类:
// JSON-LD 节点基类:@ 开头的字段名用转义标识符或特性映射都可以,
// 团队约定统一走 JsonPropertyName,全文检索 "@id" 时不用考虑转义写法
public abstract class LdNode
{
// 实体 IRI:值一律来自 EntityIri,禁止手写字符串
[JsonPropertyName("@id")]
public string Id { get; set; } = "";
// 实体类型:Product / Organization / Article
[JsonPropertyName("@type")]
public string Type { get; set; } = "";
}
// 只留引用的节点:不带任何描述字段,语义上等于一个指针
public sealed class IdRef
{
// 只有 @id 一个字段,AI 引擎沿 IRI 找完整定义
[JsonPropertyName("@id")]
public string Id { get; set; } = "";
}
// 骨架节点:被引用实体在本页的最小登记,只有身份没有细节
public sealed class StubNode : LdNode
{
// name 必须与定义现场一致,否则同一 @id 出现冲突字段
[JsonPropertyName("name")] public string Name { get; set; } = "";
}
public sealed class DeviceNode : LdNode
{
// 正式型号名,是设备实体在本站的权威叫法
[JsonPropertyName("name")] public string Name { get; set; } = "";
// 产品编号,方便引擎与 sku 类检索对上
[JsonPropertyName("sku")] public string Sku { get; set; } = "";
// 一段式摘要,控制在一百字左右
[JsonPropertyName("description")] public string Description { get; set; } = "";
// brand 指回公司实体,靠 @id 完成跨实体连线
[JsonPropertyName("brand")] public IdRef Brand { get; set; } = new();
}
// 文档根:每个页面输出一个 @graph
public sealed class GraphDoc
{
// 固定值,JSON-LD 的版本锚点
[JsonPropertyName("@context")] public string Context { get; set; } = "https://schema.org";
// 装主节点与骨架节点的数组
[JsonPropertyName("@graph")] public List<object> Graph { get; set; } = new();
}
// 控制器:设备型号页的实体图输出
public IActionResult DeviceDetail(string modelNo)
{
// 序列化选项:默认 Encoder 会把中文转成 \uXXXX,日志里没法直接读,换成宽松模式
var opt = new JsonSerializerOptions { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping };
// 型号不存在直接 404,避免给爬虫喂空 @id 的残缺图
var device = _repo.GetByModelNo(modelNo)
?? throw new HttpRequestException($"型号 {modelNo} 不存在");
// 新建文档:@context 固定 schema.org,@graph 装本页实体
var doc = new GraphDoc();
// 主节点:字段在这里写完整,本页就是设备实体的定义现场
doc.Graph.Add(new DeviceNode
{
Id = EntityIri.Device(modelNo), // 主节点的正式 IRI
Type = "Product",
Name = device.ModelName,
Sku = device.ModelNo,
Description = device.Summary,
});
// 公司骨架节点:保证单页解析也能看到 brand 的落点
doc.Graph.Add(new StubNode
{
Id = EntityIri.Organization,
Type = "Organization",
Name = device.VendorName, // 必须与 About 页注册名保持一致
});
// Razor 视图里一行输出即可
ViewBag.JsonLd = JsonSerializer.Serialize(doc, opt);
return View(device);
}
案例页与公司页:引用靠 @id 串起来
案例页的主节点是案例本身。案例在 schema.org 里没有官方类型,我们用 Article 承载,让 about 与 author 两个属性各自指向设备和公司的 @id(这两个属性的取舍之前文章讲过,这里只看 @id 怎么接)。写法上和设备页共用同一套类:
// 案例页:主节点是案例,另外两个是纯骨架,全靠 @id 连图
// ArticleNode 的 About/Author 属性类型与 IdRef 同构,定义从略
public IActionResult CaseDetail(string caseNo)
{
// 与设备页共用同一套序列化选项
var opt = new JsonSerializerOptions { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping };
// 案例不存在同样直接 404
var c = _repo.GetCase(caseNo) ?? throw new HttpRequestException("案例不存在");
var doc = new GraphDoc();
// 主节点:案例本体,字段在这里写完整
doc.Graph.Add(new ArticleNode
{
Id = EntityIri.Case(caseNo), // 案例实体的正式 IRI
Type = "Article",
Headline = c.Title,
DatePublished = c.PublishedAt.ToString("yyyy-MM-dd"),
// about 与 author 都只塞 @id,AI 引擎沿 IRI 去找完整定义
About = new IdRef { Id = EntityIri.Device(c.ModelNo) },
Author = new IdRef { Id = EntityIri.Organization },
});
// 设备骨架:name 必须与设备页主节点的正式型号名一致
doc.Graph.Add(new StubNode
{
Id = EntityIri.Device(c.ModelNo),
Type = "Product",
Name = c.DeviceName,
});
// 公司骨架:与 About 页注册名一致
doc.Graph.Add(new StubNode
{
Id = EntityIri.Organization,
Type = "Organization",
Name = c.VendorName,
});
ViewBag.JsonLd = JsonSerializer.Serialize(doc, opt);
return View(c);
}
公司 About 页反过来:Organization 是主节点,logo、contactPoint、地址这些字段在这里写完整;设备和案例不出现在它的 @graph 里——公司实体的定义现场只有这一处,别处只留引用。三类页面各管各的定义现场,谁也不替谁复制数据,这也是后面不出现字段冲突的前提。
上线前,先用脚本把图走一遍
发布到 staging 后,我们用一个小脚本逐页自检,两个断言就能拦掉大部分事故:不允许出现注册表之外的野 @id;被引用的实体必须在本页 @graph 里有落点。
# 依赖:pip install pyld requests
# 环境:本地 Python 3.11,对 staging 环境逐页抓取自检,URL 列表放 txt 里逐行传入
import json, re, sys, requests
from pyld import jsonld
BASE = "https://www.example-equipment.com/"
def check(url):
html = requests.get(url, timeout=10).text
blocks = re.findall(r'<script type="application/ld\+json">(.*?)</script>', html, re.S)
assert blocks, f"{url} 没有 JSON-LD"
for raw in blocks:
doc = json.loads(raw)
# flatten 会按 @id 归并节点,归并结果里能看到所有实体和引用边
flat = jsonld.flatten(doc)
ids = {n["@id"] for n in flat if "@id" in n}
# 校验 1:不允许出现注册表之外的野 @id
wild = [i for i in ids if not i.startswith(BASE)]
assert not wild, f"野 @id:{wild}"
# 校验 2:公司实体要么完整定义、要么以骨架出现在本页,
# 防止只有引用没有落点,单页解析器连不成图
assert any(i.endswith("#organization") for i in ids), f"{url} 缺公司实体节点"
for line in open(sys.argv[1], encoding="utf-8"):
# 逐行读 URL 清单,一行一个页面,建议覆盖三类模板各几条
check(line.strip())
改造前后,我们观察到什么
改造 7 月中旬上线,观测到 9 月初,约 7 周。样本只有一个站,数据仅供定性参考:
| 观察项 | 改造前(5-6 月) | 改造后(8-9 月) |
|---|---|---|
| Perplexity 回答里"设备+公司"同框出现 | 8 周里 1 次 | 7 周里 11 次 |
| 引用设备时给出的公司名 | 全称/简称/英文名随机出现 | 统一为 About 页注册名 |
| GPTBot 抓设备页后 48 小时内跟进抓 About 页 | 偶发 | 每周 3-5 组连续抓取 |
| Bing 索引里 About 页快照的更新间隔 | 20 天以上 | 约 5-7 天 |
| 问"XX 加工中心是哪家做的"能否答对 | 答不出或答错 | 第 3 周起稳定答对 |
第 9 天才在 Perplexity 的一次回答里第一次看到引用链生效,比我们预想的慢。实体索引的更新周期明显以周计,改完头几天看不到变化很正常,别急着回滚。
三个我亲手踩过的坑
坑一:http/https 双写导致实体分裂。 7 月底 sitemap 里混进了两条 http 开头的老链接,@id 基于配置项拼接,跟着输出了 http 版。一周内 Bing 的实体面板里同时存在两个名字相同的公司实体,互相抢事实。修复是把 EntityIri.Base 收敛成单一来源,CI 里加了一条 grep:@id 拼接结果里出现 http:// 直接让构建失败。
坑二:骨架节点带了矛盾字段。 案例页的设备骨架图省事,name 用了案例标题里的旧款名,和设备页主节点的正式型号名对不上。引擎对同一 @id 的冲突字段怎么处理是黑盒,稳妥做法是骨架节点只放 @id、@type、name,且 name 必须和定义现场一致。我们把冗余字段的取值改成实时查产品表,宁可多一次查询。
坑三:@id 跟着页面 URL 一起改版。 8 月市场部想给设备页换 SEO 友好的中文路径,幸好 @id 里的路径段是产品编号,只改了页面路由,实体标识一根汗毛没动。如果当初 @id 直接用页面 URL,这次改版就是一次全站实体迁移,前面 7 周的索引积累要清零重来。
误区澄清与趋势判断
两个常被问到的问题。一是"@id 必须是能打开的网页吗"——规范上不要求,IRI 是标识不是地址;但可解引用的 IRI 对引擎更友好,排查也更方便,没有理由不用。二是"写了 @id 引用,引用马上就涨吗"——不会,实体索引按周更新,看引用率的变化趋势,别盯单日数据。
趋势上,AI 搜索正在从页面级索引走向实体级索引,回答引用的单位是实体和它关系网上的事实,而不是某篇孤立的页面。对设备厂商这种多类页面、长决策链的 B2B 场景,@id 实体网络是投入产出比很划算的一类 GEO 改造:不新增页面、不大改内容,只把语义层的引用关系补齐。改造期间遇到归并异常,或者引擎侧行为和预期对不上的,欢迎评论区贴日志交流。
参考与延伸
- JSON-LD 1.1 规范(W3C):节点与 @id 的合并语义 — https://www.w3.org/TR/json-ld11/
- Schema.org Thing 属性定义(@id 所依附的根类型)— https://schema.org/Thing
- Schema.org Product 类型 — https://schema.org/Product
- Google 搜索中心:结构化数据工作原理 — https://developers.google.com/search/docs/appearance/structured-data/intro-structured-data
关键词:GEO、AI 搜索、@id 实体对齐、JSON-LD、Schema.org、ASP.NET Core、实体网络