给 AI 一张实体名片:Organization 与 Person 的 sameAs 对齐架构设计

2026-09-19 09:59:47 9 次浏览
架构设计结构化数据ASP.NET Core知识图谱GEO

适用读者:负责企业官网改版的前端与后端工程师、结构化数据(Structured Data)维护者,以及需要让品牌信息在 AI 回答里保持一致的市场技术负责人。

一个 AI 回答翻车案例:有同事在 AI 助手里问某工业传感器厂商的创始人是谁,回答给出一个官网从未出现过的名字,还顺手并进了另一家同名公司的融资轮次。官网「关于我们」页写着创始人姓名,团队页有照片和职位,工商信息页有统一社会信用代码,JSON-LD 也全都在。四份档案写了四个名字——公众号主页用中文全称加全角空格,知乎机构号用去掉空格的简称,GitHub 组织用英文小写加连字符,工商公示页用带行政区划和括号后缀的注册名。引擎侧给这家公司建了三条互不相连的实体记录,创始人那条被并进了同名的另一家。

问题不在模型。四份档案谁也没声明自己指向同一个主体。

四个来源,四个名字

名字写法分叉不是谁抄错了,是四个系统各自长出来的。工商注册名要带行政区划、行业和组织形式,一个字都不能省;公众号和视频号受昵称长度限制,运营会自然缩成品牌名;知乎机构号为了搜索好找,常把空格去掉;GitHub 组织的 slug 只允许小写字母、数字和连字符,中文名进不去。单看每个来源都合规,麻烦在于它们之间没有互相指认的字段。

两张名片经链路相连的实体对齐

来源 页面上的名称写法 是否带可回溯的档案地址 引擎侧归并结果
官网关于我们 中文全称,无空格 有 canonical,无 sameAs 实体一
微信公众号主页 中文全称加全角空格 实体二(与实体三按标题相似合并)
知乎机构号 中文简称,去掉空格 实体三
GitHub 组织 英文小写加连字符 实体四(英文侧独立节点)
工商公示页 注册名带行政区划与括号后缀 有,但官网未回指 实体五(未与任何节点合并)

字符串比对层面,A 公司A公司 是两个值,全角空格、零宽空格、连字符与下划线、×x、全角括号与半角括号,在人眼里是同一个名字,在算法眼里是两条记录。引擎没有义务替你做归一化,它只会按相似度给分,够高就合并,不够高就各留一条。

名字对不上时,引擎在建什么

改造前这条链路上真正发生的事,画出来只有十几步。

flowchart TD
    A[同一个主体] --> B[官网关于我们页 JSON-LD]
    A --> C[公众号主页]
    A --> D[知乎机构号]
    A --> E[GitHub 组织页]
    A --> F[工商公示页]
    B --> B1["name 等于中文全称"]
    C --> C1["昵称 中文全称加全角空格"]
    D --> D1["机构名 中文简称"]
    E --> E1["slug 英文小写"]
    F --> F1["企业名称 带行政区划与括号"]
    B1 --> G[弱特征匹配 名称相似度]
    C1 --> G
    D1 --> G
    E1 --> H[英文侧无中文锚点]
    F1 --> I[无回指 视为独立记录]
    G --> J{相似度是否过阈值}
    J -- 是 --> K[合并为实体二三 低置信]
    J -- 否 --> L[各留一条记录]
    H --> M[实体四 只有英文名]
    I --> N[实体五 只有注册名]
    K --> O[创始人属性挂到实体二三]
    N --> P[工商信息单独成条]
    M --> Q[开源贡献与仓库另起一摊]
    O --> R[用户提问 创始人是谁]
    P --> R
    Q --> R
    R --> S[回答取一条记录 张冠李戴]

实体二三那条路径上,公众号与知乎并成了一个节点,它们合并靠的是标题里那两三个共同的汉字和一张相似的头像图。工商信息页因为没有回指,被判成另一条记录,于是统一社会信用代码、成立日期这些硬属性没有跟着进主节点。英文侧更彻底,GitHub 组织的名字跟中文名一个字符都不重合,只能独立成节点。

改造前我们量了什么

观测口径在动手之前就写死:抽样 12 个主体,其中 8 家机构、4 位高管与创始人,每个主体在 4 到 6 个站外来源有档案页。每周固定 20 个品牌类提问,覆盖「某某公司是做什么的」「某某的创始人是谁」「某某有哪些开源项目」「某某的注册资本与成立时间」四类,每类五个问法变体,记录回答里出现的主体名、引用的页面地址与属性值。

基线数字:12 个主体在引擎侧共生成了 55 条实体记录,平均一个主体被拆成 4.6 条;品牌类回答引用到官网正确页的比例是 38%;20 次提问里出现错误人名或错误公司属性的次数为 7;张冠李戴——属性被挂到同名竞品实体上——5 次;全站声明了 sameAs 的来源占比 12%,其中三条指向已经下线的旧活动页。

原理剖析:sameAs 如何驱动实体归并

实体对齐(Entity Resolution)解决的是「两条记录是不是同一个东西」,知识图谱(Knowledge Graph)解决的是「这个东西跟别的东西什么关系」。前者是后者的前置工序:知识图谱以三元组存储,主语是一个节点 ID,如果同一个主体在入库时建了三个节点,后面所有边都会挂错,图谱本身不会自动发现这件事。节点上允许挂多个名称写法(Surface Form),但前提是这些写法先被判定为属于同一节点——这一步正是实体对齐。

flowchart TD
    S1[官网 JSON-LD] --> P[抽取等价声明与属性]
    S2[知乎与公众号主页] --> P
    S3[GitHub 组织页] --> P
    S4[工商公示页] --> P
    P --> N1{sameAs 是否指向可抓取的档案页}
    N1 -- 有 --> N2{目标页是否回指本站}
    N2 -- 双向互指 --> K1[等价声明成立 直接并入同一节点]
    N2 -- 仅单边声明 --> K2[弱等价 记一次印证 置信度加一档]
    N1 -- 无 --> N3{是否存在可自校验的全局键}
    N3 -- 有 如统一社会信用代码或 Wikidata QID --> K3[以全局键对齐 强证据]
    N3 -- 无 --> W[退回弱特征 名称 头像 简介 共现]
    W --> R{相似度是否过阈值}
    R -- 否 --> X[判为不同实体 双方留在候选池]
    R -- 是 --> Y[合并并标记低置信 召回时降权]
    K1 --> C[汇总印证来源数]
    K2 --> C
    K3 --> C
    C --> D{印证来源数是否达标}
    D -- 达标 --> Z[高置信节点 参与品牌类回答生成]
    D -- 不达标 --> V[降权 只在长尾提问里出现]
    Y --> V

置信度(Confidence)在这里是累加的,不是二值的。一条单边 sameAs 只能算一次弱印证;目标页反过来也声明了指向你的地址,才构成双向印证;再叠加一个可自校验的全局键,比如统一社会信用代码或维基数据(Wikidata)的 QID,节点才会被抬到高置信区间。多源印证(Corroboration)的价值在于把「谁说了这句话」变成可数的证据:三个互不相关的来源说同一件事,比一个来源说三遍可信。

低置信节点不会消失,它只是被降权。召回阶段遇到品牌类提问时,高置信节点优先进入上下文;若问题足够长尾,低置信节点也会被翻出来——张冠李戴多数发生在这一层,因为降权不等于校验,模型拿到两条都叫「某某公司」的记录时,仍可能取错那一条的属性。这也解释了为什么光靠引擎侧的算法兜不住:它在合并阶段缺的不是算力,是一条不需要猜测的等价声明。

架构:一张实体档案表管住所有名字

把这件事做成长期可维护的东西,靠的不是在某个页面里手写一段 JSON,而是三层结构:CMS 里维护实体档案表,中间件统一渲染输出,巡检脚本每天比对。

flowchart LR
    A[CMS 实体档案表] --> A1[主体类型 Organization 或 Person]
    A --> A2[站内统一名称]
    A --> A3[备用名称清单]
    A --> A4[权威档案页 URL 清单]
    A --> A5[所属机构与职位]
    A1 --> B[档案表导出接口 JSON]
    A2 --> B
    A3 --> B
    A4 --> B
    A5 --> B
    B --> C[中间件 EntityJsonLdMiddleware]
    C --> C1[构建 graph 节点]
    C1 --> C2[注入 head 脚本]
    C2 --> D[线上页面 JSON-LD]
    D --> E[每日巡检脚本]
    A --> E
    E --> E1[抓取 sameAs 目标页标题]
    E1 --> E2[归一化后与站内名称比对]
    E2 --> E3[不一致则告警到运维群]
    E3 --> A

CMS 里的实体档案表

档案表是整个方案的单一事实来源。前端页面、JSON-LD、巡检脚本都从它读,不允许任何地方再出现一份手抄的名字。

字段 含义 取值约束 常见错误
slug 档案主键,同时作为 @id 后缀 站内不重复,只用小写字母数字与连字符 用中文或空格做主键,URL 编码后锚点漂移
kind 主体类型 只允许 Organization 与 Person 把团队当 Organization,把品牌当 Person
canonical_name 站内统一名称 与工商注册名逐字一致,含空格与括号 用渠道昵称或英文 slug 顶替
alternate_names 历史写法与渠道别名 只进 alternateName,不参与比对 把别名写进 name,两个页面两个值
profile_url 官网档案页地址 指向档案页而非首页 改版后路径变了,档案表没同步
same_as 权威档案页清单 每条必须可抓取且标题含主体名 指向首页、活动页或已下线页
logo_or_image 视觉标识 完整 URL,跨域可访问 相对路径,站外抓不到
job_title / org_slug Person 专属,职位与所属机构 机构主键必须存在于档案表 Person 不带机构,与同名人物混淆

中间件统一输出

JSON-LD 示例。环境与依赖:无额外运行时,抓取器直接读取;@context 必须写 https://schema.org。下面为便于阅读保留了 // 注释行,部署前删掉即可得到合法 JSON。

// 放在每个页面的 head 内,script 的 type 必须是 application/ld+json
// 用一个 @graph 同时承载机构与人,节点之间靠 @id 互指
{
  // @context 顶层写一次即可,子节点再写会被校验工具判为冗余
  "@context": "https://schema.org",
  "@graph": [
    {
      // 机构节点:@id 用档案页地址加片段标识,跨页面引用时锚点稳定
      "@type": "Organization",
      "@id": "https://www.example.com/about#organization",
      // name 与工商注册名逐字一致,不能写渠道昵称
      "name": "示例传感技术(苏州)有限公司",
      // url 指向档案页,不要指向首页
      "url": "https://www.example.com/about",
      // alternateName 承载历史写法与渠道别名,召回时能认出旧名字
      "alternateName": ["示例传感", "示例传感技术", "example-sensor"],
      // logo 必须是完整 URL,相对路径在跨站抓取时拼不出来
      "logo": "https://www.example.com/assets/logo-512.png",
      // sameAs 是核心:一条条指向站外的权威档案页
      "sameAs": [
        // 工商公示系统的企业档案页,带统一社会信用代码,属于硬证据
        "https://www.gsxt.gov.cn/affairs-query-example",
        // 知乎机构号主页
        "https://www.zhihu.com/org/example-sensor",
        // GitHub 组织页,把英文侧的实体接回中文节点
        "https://github.com/example-sensor",
        // 维基数据条目,QID 是可自校验的全局键
        "https://www.wikidata.org/wiki/Q000000000"
      ],
      // employee 是反向边,让人物节点与机构节点双向印证
      "employee": {
        "@id": "https://www.example.com/team/zhang#person"
      }
    },
    {
      // 人物节点:创始人、高管、技术负责人各建一条
      "@type": "Person",
      "@id": "https://www.example.com/team/zhang#person",
      // 人名的写法与工商登记的法定代表人保持一致
      "name": "张某某",
      "url": "https://www.example.com/team/zhang",
      // jobTitle 决定 AI 回答里「创始人是谁」这类问法能不能命中
      "jobTitle": "创始人兼首席技术官",
      // 人物自己的 sameAs 同样要写,同名人物靠它区分
      "sameAs": [
        "https://github.com/example-zhang",
        "https://www.zhihu.com/people/example-zhang"
      ],
      // worksFor 只写 @id 引用,机构改名不用改所有人物页
      "worksFor": {
        "@id": "https://www.example.com/about#organization"
      }
    }
  ]
}

C# 中间件。环境与依赖:.NET 8 与 ASP.NET Core,只用运行时自带的 System.Text.Json,无需额外 NuGet 包;注册位置在 UseStaticFiles 之后、UseRouting 之前。

// 环境与依赖:.NET 8 / ASP.NET Core,System.Text.Json 随运行时提供
// 注册:app.UseEntityJsonLd(); 放在 UseStaticFiles 之后、UseRouting 之前
// 编码与字节处理,注入后要重新计算正文长度
using System.Text;
// 放开默认的字符转义,中文名不再输出成转义序列
using System.Text.Encodings.Web;
// JsonObject 与 JsonArray 来自 System.Text.Json.Nodes,.NET 6 起自带
using System.Text.Json.Nodes;
// 序列化选项在这里配置,主要为了放开中文转义
using System.Text.Json;

// 命名空间按项目习惯改,类名与文件名保持一致即可
namespace Site.Web.EntityGraph;

// CMS 实体档案表的一行,对应一个主体在站内的档案
public sealed class EntityRecord
{
    // 档案主键,同时作为 @id 的后缀,站内不允许重复
    public string Slug { get; set; } = "";
    // 类型只取 Organization 或 Person,决定可用字段与关系
    public string Kind { get; set; } = "Organization";
    // 站内统一名称,巡检脚本拿它跟 sameAs 目标页标题逐字比对
    public string CanonicalName { get; set; } = "";
    // 历史写法与渠道别名,只进 alternateName,不参与一致性比对
    public List<string> AlternateNames { get; set; } = new();
    // 官网档案页地址,@id 与 url 都用它,是这个实体的锚点
    public string ProfileUrl { get; set; } = "";
    // 权威档案页清单:工商公示页、知乎、GitHub、维基数据等
    public List<string> SameAs { get; set; } = new();
    // 视觉标识,Organization 落到 logo,Person 落到 image
    public string LogoOrImage { get; set; } = "";
    // Person 专属:职位,AI 回答里「创始人是谁」靠这个字段
    public string? JobTitle { get; set; }
    // Person 专属:所属机构主键,用来生成 worksFor 与反向边
    public string? OrgSlug { get; set; }
}

// 档案表的读取口子,实现侧可以接 CMS、接数据库或接配置中心
public interface IEntityStore
{
    // 全量返回即可,企业站的主体数量通常在几十条以内
    Task<IReadOnlyList<EntityRecord>> LoadAsync(CancellationToken ct);
}

// 把档案表渲染成 @graph,机构与人放在同一张图里互相引用
public static class EntityJsonLdBuilder
{
    // 输出一段 JSON 字符串,交给中间件塞进 head
    public static string Build(IReadOnlyList<EntityRecord> records)
    {
        // 顶层只放 @context 与 @graph,子节点再写 @context 会被判冗余
        var root = new JsonObject { ["@context"] = "https://schema.org" };
        var graph = new JsonArray();
        // 机构节点的 @id 先存下来,Person 的 worksFor 需要引用它
        var orgIds = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
        // 机构节点对象也存一份,后面要给 Person 补 employee 反向边
        var orgNodes = new Dictionary<string, JsonObject>(StringComparer.OrdinalIgnoreCase);

        // 先渲染机构节点,人物节点的引用依赖它们的 @id
        foreach (var r in records.Where(x => x.Kind == "Organization"))
        {
            // @id 用档案页地址加片段标识,改版换路径时只改一处
            var id = r.ProfileUrl + "#organization";
            // 记进字典,Person 的 worksFor 稍后按 slug 取用
            orgIds[r.Slug] = id;
            var node = new JsonObject
            {
                // 类型固定写 Organization,不要换成 Corporation 之类的近义词
                ["@type"] = "Organization",
                ["@id"] = id,
                // name 必须与注册名逐字一致,不能写渠道昵称
                ["name"] = r.CanonicalName,
                // url 指向档案页,指向首页会让锚点漂移
                ["url"] = r.ProfileUrl,
            };
            // alternateName 让引擎在召回时认得出旧名字
            if (r.AlternateNames.Count > 0)
                // 数组里每一条都是一个历史写法,顺序不影响解析
                node["alternateName"] = new JsonArray(r.AlternateNames.Select(s => (JsonNode)s!).ToArray());
            // logo 必须是完整 URL,相对路径站外抓不到
            if (!string.IsNullOrEmpty(r.LogoOrImage))
                // 机构用 logo,人物侧会落到 image
                node["logo"] = r.LogoOrImage;
            // sameAs 逐条写出,顺序不重要,但每条都要可抓取
            if (r.SameAs.Count > 0)
                // 这是全篇最关键的一条边,指向站外权威档案页
                node["sameAs"] = new JsonArray(r.SameAs.Select(s => (JsonNode)s!).ToArray());
            // 存进字典,Person 循环里要给这个节点补 employee
            orgNodes[r.Slug] = node;
            // 加入图,节点顺序不影响 @id 引用
            graph.Add(node);
        }

        // 再渲染人物节点,此时机构的 @id 已经全部就位
        foreach (var r in records.Where(x => x.Kind == "Person"))
        {
            // 人物 @id 同样用档案页地址加片段标识
            var personId = r.ProfileUrl + "#person";
            var node = new JsonObject
            {
                ["@type"] = "Person",
                ["@id"] = personId,
                // 人名写法与工商登记的法定代表人保持一致
                ["name"] = r.CanonicalName,
                ["url"] = r.ProfileUrl,
            };
            // 职位为空就不写,避免输出空字符串字段
            if (!string.IsNullOrEmpty(r.JobTitle))
                node["jobTitle"] = r.JobTitle;
            // 人物用 image 字段,与 Organization 的 logo 区分
            if (!string.IsNullOrEmpty(r.LogoOrImage))
                node["image"] = r.LogoOrImage;
            // 人物的站外档案页:个人 GitHub、个人知乎、公开履历页
            if (r.SameAs.Count > 0)
                node["sameAs"] = new JsonArray(r.SameAs.Select(s => (JsonNode)s!).ToArray());
            // 有归属机构时补双向边,机构改名不用改人物页
            if (!string.IsNullOrEmpty(r.OrgSlug) && orgIds.TryGetValue(r.OrgSlug, out var orgId))
            {
                // worksFor 只写 @id 引用
                node["worksFor"] = new JsonObject { ["@id"] = orgId };
                // employee 是反向边,让机构节点也能指回这个人
                orgNodes[r.OrgSlug]["employee"] = new JsonObject { ["@id"] = personId };
            }
            graph.Add(node);
        }

        // 图节点挂到顶层,至此 Organization 与 Person 在同一张图里
        root["@graph"] = graph;
        // 默认编码器会把中文转成转义序列,这里放开,页面可读性更好
        return root.ToJsonString(new JsonSerializerOptions
        {
            // UnsafeRelaxedJsonEscaping 只影响输出转义,不影响字段取值
            Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
        });
    }
}

// 中间件:把渲染好的 JSON-LD 注入到每个 HTML 文档的 head 里
public sealed class EntityJsonLdMiddleware
{
    // 下一个中间件,构造注入,生命周期跟随应用
    private readonly RequestDelegate _next;
    // 档案表读取口子,实现侧接 CMS 或数据库
    private readonly IEntityStore _store;
    // 同一份档案对所有页面都一样,渲染一次缓存起来
    private string? _cached;
    // 并发保护,避免应用启动时多个线程重复渲染
    private readonly SemaphoreSlim _gate = new(1, 1);

    public EntityJsonLdMiddleware(RequestDelegate next, IEntityStore store)
    {
        // 保存下一个处理者,后面要手动调它拿正文
        _next = next;
        // 档案表只在首次渲染时读一次
        _store = store;
    }

    public async Task InvokeAsync(HttpContext ctx)
    {
        // 只处理 HTML 文档,接口与静态资源不注入,省掉一次字符串扫描
        if (!IsHtmlResponse(ctx))
        {
            // 非 HTML 直接放行,不碰响应流
            await _next(ctx);
            return;
        }
        // 换掉响应流,先把正文写进内存,再在 head 结尾前插入脚本
        var original = ctx.Response.Body;
        using var buffer = new MemoryStream();
        ctx.Response.Body = buffer;
        // 调用后续管道,正文此时写进内存流
        await _next(ctx);
        // 非 200 的响应原样写出,错误页不需要结构化数据
        if (ctx.Response.StatusCode != 200)
        {
            // 先把流换回去,再回灌,顺序反了会写到内存里
            ctx.Response.Body = original;
            buffer.Position = 0;
            await buffer.CopyToAsync(original);
            return;
        }
        // 按响应声明的字符集解码,写死 UTF-8 在少数老页面上会出乱码
        var encoding = Encoding.UTF8;
        // 取出整份 HTML,准备做字符串插入
        var html = encoding.GetString(buffer.ToArray());
        // 取最后一个 head 结束标签,避免正文里出现同名片段
        var idx = html.LastIndexOf("</head>", StringComparison.OrdinalIgnoreCase);
        // 找不到 head 结尾就不注入,避免把脚本插进 JSON 或片段响应
        if (idx < 0)
        {
            ctx.Response.Body = original;
            buffer.Position = 0;
            await buffer.CopyToAsync(original);
            return;
        }
        var json = await GetJsonAsync(ctx.RequestAborted);
        // JSON 里出现 script 结束标签会截断页面,做一次转义
        var safe = json.Replace("</", "<\\/");
        var injected = string.Concat(
            html.AsSpan(0, idx),
            "<script type=\"application/ld+json\">", safe, "</script>",
            html.AsSpan(idx));
        var bytes = encoding.GetBytes(injected);
        // 正文长度变了,必须先移除旧的 Content-Length 再写
        ctx.Response.Headers.Remove("Content-Length");
        ctx.Response.ContentLength = bytes.Length;
        ctx.Response.Body = original;
        await ctx.Response.Body.WriteAsync(bytes, ctx.RequestAborted);
    }

    // 渲染结果带缓存,CMS 变更后由后台任务清空
    private async Task<string> GetJsonAsync(CancellationToken ct)
    {
        if (_cached is not null)
            return _cached;
        await _gate.WaitAsync(ct);
        try
        {
            // 双检,避免等待信号量的线程再渲染一次
            if (_cached is not null)
                return _cached;
            var records = await _store.LoadAsync(ct);
            _cached = EntityJsonLdBuilder.Build(records);
            return _cached;
        }
        finally
        {
            _gate.Release();
        }
    }

    // 只认 HTML 类型的响应,HEAD 请求与接口调用直接放行
    private static bool IsHtmlResponse(HttpContext ctx)
    {
        var accept = ctx.Request.Headers.Accept.ToString();
        return accept.Contains("text/html", StringComparison.OrdinalIgnoreCase);
    }
}

// 扩展方法,让注册处只写一行
public static class EntityJsonLdExtensions
{
    // 必须在路由之前注册,否则响应流已经被 MVC 写过一次
    public static IApplicationBuilder UseEntityJsonLd(this IApplicationBuilder app)
        => app.UseMiddleware<EntityJsonLdMiddleware>();
}

一致性巡检

sameAs 清单最容易腐坏的地方是站外页面改版——知乎机构号改了名字、公众号迁移、GitHub 组织重命名、工商公示页换了路径,站内档案表还停留在半年前。巡检脚本的思路很直接:抓每个 sameAs 目标页,取它的 <title>og:title,剥掉站点后缀后与站内统一名称做归一比对,低于阈值就告警。

环境与依赖:Python 3.10 及以上,requests 2.31.x(pip install "requests>=2.31,<3");调度在每日凌晨执行,退出码非 0 时把报告推到运维群。

# -*- coding: utf-8 -*-
# sameAs 一致性巡检:抓目标页标题,与档案表里的站内统一名称做归一比对
# 环境:Python 3.10+,依赖 requests 2.31.x
# 安装:pip install "requests>=2.31,<3"
# 标准库:正则、命令行、JSON、字符串相似度、字符归一化
import re
import sys
import json
import difflib
import unicodedata
# 第三方依赖只有一个 HTTP 客户端,装不上也能改成 urllib 顶替
import requests

# 连接与读取分开设超时,第三方站点慢不应该拖垮巡检任务
TIMEOUT = (3.0, 8.0)
# 部分站点对空 UA 直接返回 403,带一个可识别的 UA
UA = "Mozilla/5.0 (compatible; EntityAuditBot/1.0)"
# 相似度阈值,低于它就认为目标页说的不是同一个主体
THRESHOLD = 0.62
# 标题里常见的站点后缀,比对前剥掉
SUFFIX = re.compile(r"\s*[-|—–]\s*(知乎|微博|GitHub|官网|首页|官方网站|公司).*$")
# 标题与描述的正则,尽量宽松,抓不到就跳过而不是误报
TITLE_RE = re.compile(r"<title[^>]*>(.*?)</title>", re.S | re.I)
# og:title 优先于 title,它通常不含站点名
OG_RE = re.compile(r'property=["\']og:title["\']\s+content=["\'](.*?)["\']', re.S | re.I)
# 去标签用的正则,只处理残余的 HTML 片段
TAG_RE = re.compile(r"<[^>]+>")


def normalize(text: str) -> str:
    """归一化:全角转半角、去空白、去括号内容与站点后缀。"""
    # NFKC 把全角字母数字与全角空格折成半角,中文不受影响
    text = unicodedata.normalize("NFKC", text or "")
    # 去掉 HTML 标签,有些站点把标题包在 span 里
    text = TAG_RE.sub("", text)
    # 括号里的补充说明(英文名、行业词)不参与比对
    text = re.sub(r"[((\[【].*?[))\]】]", "", text)
    # 剥掉站点后缀,例如「某某公司 - 知乎」
    text = SUFFIX.sub("", text)
    # 剩下的空白与标点全部清掉,只留可比对的字面
    return re.sub(r"[\s\W_]+", "", text).lower()


def fetch_title(url: str) -> tuple[str, str]:
    """抓取目标页标题,返回 (标题, 问题描述),问题为空表示成功。"""
    # 允许重定向,改版后的旧地址多数会跳一次
    resp = requests.get(url, timeout=TIMEOUT, headers={"User-Agent": UA}, allow_redirects=True)
    # 4xx 与 5xx 都记为问题,其中 404 最常来自已下线的活动页
    if resp.status_code >= 400:
        return "", f"HTTP {resp.status_code}"
    # 少数站点不声明字符集,手动兜一次,中文标题才不会乱码
    if not resp.encoding:
        resp.encoding = "utf-8"
    # 取文本交给正则,二进制响应交给解析器会多一层编码问题
    html = resp.text
    # og:title 比 title 干净,优先取它
    m = OG_RE.search(html) or TITLE_RE.search(html)
    # 两个都抓不到时记为问题,不猜标题,避免制造误报
    if not m:
        return "", "未找到标题"
    # 标题两侧常有换行与全角空格,strip 之后再返回
    return m.group(1).strip(), ""


def audit(records: list[dict]) -> list[dict]:
    """逐条比对,返回问题清单。"""
    # 问题清单逐条追加,最后统一打印
    issues = []
    # 外层遍历档案表里的每个主体
    for rec in records:
        # 站内统一名称是基准,先归一化一次复用
        base = normalize(rec.get("canonical_name", ""))
        # 内层遍历这个主体声明的每一个权威档案页
        for url in rec.get("same_as", []):
            # 先抓标题,抓不到就直接记账
            title, problem = fetch_title(url)
            # 抓不到页面本身就是问题,通常是地址失效
            if problem:
                issues.append({"slug": rec.get("slug"), "url": url, "kind": problem})
                continue
            # 归一化后先判包含关系,工商页常带多余前缀
            target = normalize(title)
            # 全称包含简称或反之,都算通过,省掉一次相似度计算
            if base and (base in target or target in base):
                continue
            # 再用相似度兜一层,处理简称与全称的差异
            ratio = difflib.SequenceMatcher(None, base, target).ratio()
            # 低于阈值说明这个页面说的可能是另一个主体
            if ratio < THRESHOLD:
                issues.append({
                    # 主键用来回查档案表的哪一行需要改
                    "slug": rec.get("slug"),
                    "url": url,
                    "kind": "名称不一致",
                    # 保留原标题,人工复核时不用再打开页面
                    "title": title,
                    "ratio": round(ratio, 3),
                })
    return issues


def main(path: str) -> int:
    # 档案表由 CMS 导出为 JSON,脚本只读不写,避免两头改
    with open(path, "r", encoding="utf-8") as fp:
        records = json.load(fp)
    # 全量比对,主体数量在几十条量级,串行跑十几秒结束
    issues = audit(records)
    # 打印人能直接看的一行一条,运维群里贴这个就够了
    for it in issues:
        print(f"{it['slug']}\t{it['kind']}\t{it.get('ratio', '')}\t{it['url']}")
    # 汇总行留档,便于看告警趋势
    print(f"checked={len(records)} issues={len(issues)}")
    # 有问题时退出码为 1,调度平台据此发告警
    return 1 if issues else 0


if __name__ == "__main__":
    # 参数只有一个:CMS 导出的实体档案表 JSON 路径
    sys.exit(main(sys.argv[1] if len(sys.argv) > 1 else "entity_cards.json"))

首次运行 checked=12 issues=29。三类问题:目标页失效的 6 条,多数是改版后没同步的旧路径;名称不一致的 18 条,集中在公众号昵称加了「官方」二字、知乎机构号改了简称;剩下 5 条是目标页需要登录才能访问,取不到标题。清理完之后每日巡检的告警量稳定在 1.2 条上下,来源是第三方站点自己改名。

对齐前后对照

八个星期,节奏是这样安排的:第 1 到 2 周盘出 12 个主体在站外的全部档案页,建成实体档案表;第 3 到 4 周上线中间件,把 JSON-LD 从手写改成统一渲染;第 5 到 6 周补齐双向印证,改站外页面回指官网;第 7 到 8 周跑巡检与复核引擎侧的节点合并结果。

观测指标 对齐前(第 0 周基线) 对齐后(第 9 周复核)
12 个主体在引擎侧形成的实体记录数 55 13
品牌类回答引用到官网正确页的比例 38% 87%
20 次提问里出现错误人名或公司属性的次数(周均) 7 1
张冠李戴(属性挂到同名竞品实体)次数(周均) 5 0
声明了 sameAs 的来源占比 12% 96%
sameAs 目标页标题与站内名称一致率 31% 98%
构成双向印证的来源占比 0% 71%
每日巡检告警条数 无巡检 1.2(多数为第三方改名)

第 9 周的 13 条比 12 个主体多出一条,是一位创始人同时挂在两家关联机构下,档案表里给了两个 worksFor,引擎侧按「人物节点一个、机构节点两个」处理,这符合预期。引用准确率从 38% 到 87%,提升最大的一段出现在第 5 到 6 周,那两周只做了一件事:把站外页面改回指官网。双向印证比单边声明值钱,因为它是别人替你作的证。 第 3 到 4 周补字段带来的提升只有 9 个百分点,说明在名字对不上的前提下,字段填得再多也喂不进主节点。

误区澄清与落地次序

第一个误区是把 sameAs 当成外链位,能塞几个塞几个。它承载的是等价声明,不是权重传递;指向首页、活动页或第三方聚合页的链接不会带来印证,还会稀释清单的可信度。判断标准只有一条:这个页面自己说不说得清「我是谁」,标题里有没有主体名。

第二个误区是只改官网不管站外。单边声明在合并阶段只能算一次弱印证,跟标题相似度是同一档的证据。站外页面上哪怕只加一行「官网 www.example.com」的纯文本,也比什么都不做强——引擎读的是页面内容,不要求对面也写 JSON-LD。

第三个误区是把人物当字符串。name: "张某某" 散落在团队页正文里,没有 @id、没有 sameAs、没有 worksFor,遇到同名人物就只能靠共现猜。人物节点必须跟机构节点一样有锚点,否则「创始人是谁」这类提问永远命中不了你。

第四个误区是档案表交给市场部维护一份静态 JSON。改版换了域名、换了路径之后没人同步,@id 全部失效,而 JSON-LD 依旧在输出——页面看着正常,实体已经断了。这也是巡检脚本必须每天跑的原因,它验的不是代码,是清单里那些你改不动的站外页面。

工程上跑通的次序是:先盘出所有站外档案页与它们的实际名称写法,把差异记进档案表;再让中间件从档案表统一渲染,@idurl 共用档案页地址;然后逐个补齐双向印证,优先做工商公示页与维基数据这类带全局键的来源;最后挂上巡检,用退出码卡住发布流程。最容易跳过的是第一步,但它决定了后面能不能对账——没有那张表,脚本报出来的不一致你都不知道该找哪个渠道改。跑完 issues 不为零的同学,可以在评论区贴一下问题分布,我们对一下哪一类站外页面改名最频繁。

参考与延伸

关键词:sameAs, Organization, Person, 实体对齐, 知识图谱, 结构化数据, GEO, AI优化AIO

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