AI 搜索跨区乱推荐之后:eligibleRegion 区域可售标注的 30 天引用对照

2026-09-22 01:22:58 1 次浏览
GEOAI搜索eligibleRegionSchema.org电商架构

适用读者:电商、O2O、家电家居这类「同一件货在不同地方能不能卖不一样」的站点,前端或后端同学;商品页已经有结构化数据,但区域限制只写在页面文案里。 也被「AI 助手把没售后的型号推给了用户」的客服工单逼过,想找一条能批量改造的路子。 读完能自己搭一张区域字典表,把区域可售关系批量渲染成 Offer 上的字段,并用一套可复跑的采样办法验证 AI 回答有没有变准。

商品详情页上明明写着「本型号售后覆盖省份:广东、广西、福建、海南、湖南、江西」,AI 助手却把这款推给了哈尔滨的用户,还补了一句「全国联保」。这事儿的根子不在模型,在于我们从来没用机器可读的方式告诉过它「哪儿能卖、哪儿不能卖」。那行覆盖省份是图片旁边的小字,爬虫抓下来只是一段普通文本,模型抽取时不知道它是约束条件,自然就当背景信息处理掉了。

客服先发现的,不是我们

三月第二周,客服组长在群里甩了十几条同类型工单:用户在 AI 搜索里问「XX 型号在哈尔滨能不能买到、坏了去哪儿修」,助手答得挺完整,价格、参数、购买链接都有,唯独区域这栏是编的。我们这边的实际情况是:三千多个 SKU 里有 412 个型号只在 6 个省放了售后网点,另外有 37 个型号属于不出口系列,海外订单一律不接。

主题配图

第一反应是去改文案,把「售后覆盖省份」那行放大、加红、挪到参数表上面。改完一周,工单从每周 27 单降到每周 21 单,用处不大。原因也简单:文案再显眼,对抽取端来说还是一句话,模型要判断这句话是不是硬约束,得先理解「售后覆盖」和「可售」之间是什么关系,这一步在真实的检索-生成链路里没人替它做。

后来才想明白,这件事属于生成式引擎优化(Generative Engine Optimization, GEO)里最基础的那一层:把业务约束写成结构化断言,而不是写成给人看的句子。

先讲机制:AI 在哪一步把区域信息丢了

要修就得知道断点在哪。一次「我在哈尔滨能不能买这款」的回答,从我们的页面到用户眼睛,中间大致是三段。

flowchart TD
    A[用户提问 我在哈尔滨能买这款吗] --> B[AI 检索到商品页]
    B --> C{页面里有没有机器可读的区域约束}
    C -- 只有文案 --> D[按标题和销量猜一个答案]
    D --> E[用户下单 发现本地没售后 投诉]
    C -- 有 eligibleRegion --> F[取出可售区域集合]
    F --> G{用户所在区域在不在集合里}
    G -- 在 --> H[推荐并给出本地售后网点]
    G -- 不在 --> I[明确回答不可购 并推替代型号]
    H --> J[工单下降]
    I --> J

第一段是检索,引擎按 query 找候选页面,这一步不看约束;第二段是抽取,把页面里的事实抽成若干条可引用的断言,这一步决定了「覆盖广东广西」能不能变成一条带主语、带取值范围的断言;第三段是生成,模型拿这些断言去回答,如果第二段没抽到区域断言,它只能拿商品名、价格、销量去凑一个听起来合理的答案——模型不会主动承认自己不知道。

所以改造点只能落在第二段。给 Offer 挂上 eligibleRegion,等于直接把断言喂进去:这条报价只在集合 A 内有效。模型不需要推断,读字段就行。这也是为什么我们后面 16 天的采样里,「明确说不可购」的比例能涨到六成多——它不是学会了谨慎,是拿到了可以引用的否定事实。

三种取值分别该用在什么场景

eligibleRegion 的官方定义允许三种取值,我们三种都试过,差别不小。

取值类型 写法要点 适用场景 实测踩到的坑
Country name 写 ISO 国家名或中文国名 国家级的出口管制、跨境不发货 只写国家时,国内省份差异完全表达不了
AdministrativeArea name 写省/州名,必要时补 containedInPlace 省级别的售后覆盖、区域定价 中文省名要不要带「省」字,得和页面文案保持一致
GeoShape box / circle / polygon 描述经纬度范围 同城配送圈、门店三公里达 坐标串太长,同一页面里堆多了会撑大模板

ineligibleRegion 是反向写法,语义上等于「除这些区域外都可售」。我们给不出口的 37 个型号用了 ineligibleRegion,因为它们的排除对象是国家,数量少而明确;反过来,只覆盖 6 个省的型号用 eligibleRegion 白名单更安全——白名单漏一个地区只是少卖一单,黑名单漏一个地区就是一单投诉。

选择顺序:能枚举就枚举,枚举不动再用几何形状。 GeoShape 的表达力最强,但 AI 抽取端对一串经纬度做「哈尔滨在不在框里」的判断,准确率明显低于直接匹配省名。

区域字典表怎么建

三千多个 SKU 不可能一条条手写 JSON-LD,落地的做法是先建一张区域字典表,让运营填表,脚本来渲染。

字段 含义 示例
sku 商品编码,和商品表主键一致 AC-2201-W
region_mode allow 白名单 / deny 黑名单 allow
region_type province / country / geo province
region_codes 区域代码集合,逗号分隔 GD,GX,FJ,HI,HN,JX
delivery_info 送达时效与不发货说明,输出成 shippingDetails 48h,偏远地区加时
warranty_note 售后网点覆盖说明,同步渲染到可见文案 仅覆盖华南六省

字典表由运营在内部后台维护,改一次触发一次重渲染。这里有个容易忽略的点:可见文案和结构化字段必须同源。我们第一版脚本只改了 JSON-LD,页面上的小字没动,结果采样时出现「AI 说不可购,页面上写着全国联保」的打架情况,用户反而更懵。第二版把 warranty_note 同时渲染进 DOM 和 JSON-LD,这类矛盾就没了。

批量输出的写法

渲染脚本用 Python 写的,跑在发布流水线里,每次全量重渲 3400 个商品页的 JSON-LD 片段,耗时大概 40 秒。下面这段是核心逻辑,环境是 Python 3.11,只用了标准库。

# 环境:Python 3.11,仅用标准库 json / csv / pathlib
# 用途:读区域字典表 CSV,为每个 SKU 生成 Offer 上的区域字段
import csv, json
from pathlib import Path

# 省份代码到中文省名的映射,name 要和页面可见文案完全一致
# 这一层是刻意保留的:模型匹配的是字符串,不是行政区划编码
PROVINCE = {
    "GD": "广东省", "GX": "广西壮族自治区", "FJ": "福建省",
    "HI": "海南省", "HN": "湖南省", "JX": "江西省",
}

# 国家代码映射,出口管制场景用 Country 类型
COUNTRY = {"US": "United States", "JP": "Japan", "DE": "Germany"}


def build_region(rows):
    # rows:字典表里某个 SKU 的全部行
    # 返回 (允许区域列表, 排除区域列表),二者只填一个
    allow, deny = [], []
    for r in rows:
        codes = [c for c in r["region_codes"].split(",") if c]
        # province 走 AdministrativeArea,country 走 Country
        if r["region_type"] == "province":
            node = lambda c: {"@type": "AdministrativeArea", "name": PROVINCE[c]}
        else:
            node = lambda c: {"@type": "Country", "name": COUNTRY[c]}
        target = allow if r["region_mode"] == "allow" else deny
        target.extend(node(c) for c in codes)
    return allow, deny


def build_offer(sku, rows, price):
    # allow 与 deny 由上面那步算好,这里只做装配
    allow, deny = build_region(rows)
    # 基础报价节点,价格从商品主库传入
    offer = {"@type": "Offer", "price": price, "priceCurrency": "CNY"}
    # 白名单优先,两个字段不同时出现,避免语义打架
    if allow:
        offer["eligibleRegion"] = allow
    # 只有出口管制类才走黑名单
    if deny:
        offer["ineligibleRegion"] = deny
    return offer


# 读表:运营后台导出的 CSV,UTF-8 带 BOM 时用 utf-8-sig
rows = list(csv.DictReader(open("region_dict.csv", encoding="utf-8")))
# 按 sku 分组,一个 SKU 可能既有省份行又有国家行
grouped = {}
for r in rows:
    grouped.setdefault(r["sku"], []).append(r)

out = {}
for sku, rs in grouped.items():
    # 价格从商品主库读,这里用固定值示意
    out[sku] = build_offer(sku, rs, "3299.00")
# 落盘成片段文件,模板层按 sku 取值后 include 进页面
Path("region_offers.json").write_text(
    json.dumps(out, ensure_ascii=False, indent=2), encoding="utf-8"
)

模板层把片段拼进 Product.offers,最终长这样。注意 // 开头的行是讲解用注释,真实输出里由脚本剔除,不要直接发给浏览器。

// 输出位置:商品页 head 末尾的 application/ld+json 脚本块
// 说明:// 行仅用于讲解,发布前由渲染器 strip 掉
{
  // 固定上下文,声明这批字段按 schema.org 词汇解释
  "@context": "https://schema.org",
  "@type": "Product",
  // sku 与商品主库对齐,排障时靠它反查
  "sku": "AC-2201-W",
  "name": "壁挂式空调 2201 冷暖型",
  "offers": {
    "@type": "Offer",
    "price": "3299.00",
    "priceCurrency": "CNY",
    // 白名单写法:只在六个省可售
    // 省名带不带「省」字要和页面可见文案一致
    "eligibleRegion": [
      // AdministrativeArea 是省级粒度,比 Country 更贴售后场景
      { "@type": "AdministrativeArea", "name": "广东省" },
      { "@type": "AdministrativeArea", "name": "广西壮族自治区" },
      { "@type": "AdministrativeArea", "name": "福建省" },
      { "@type": "AdministrativeArea", "name": "海南省" },
      { "@type": "AdministrativeArea", "name": "湖南省" },
      { "@type": "AdministrativeArea", "name": "江西省" }
    ],
    // 送达信息单独挂在 shippingDetails 上,别塞进 region 字段
    "shippingDetails": {
      "@type": "OfferShippingDetails",
      "deliveryTime": {
        // 单位默认为天,minValue / maxValue 表示区间
        "@type": "ShippingDeliveryTime",
        // 出库耗时:当天到次日
        "handlingTime": { "@type": "QuantitativeValue", "minValue": 0, "maxValue": 1 },
        // 在途耗时:一到三天
        "transitTime": { "@type": "QuantitativeValue", "minValue": 1, "maxValue": 3 }
      }
    }
  }
}

上线后别急着看数据,先确认页面真的吐出来了。我们用一条 curl 加 grep 做冒烟,混在 CI 里跑:

# 环境:CI 容器 Debian 12,curl 8.5
# 冒烟:确认商品页里确实带了 eligibleRegion 字段
curl -s "https://example.com/p/AC-2201-W" | grep -c "eligibleRegion"
# 输出应 >= 1,为 0 说明模板没拼上或缓存没刷新

30 天对照是怎么跑的

窗口总共 30 天:改造前 14 天做基线采样,第 15 天全量上线,之后 16 天做标注期采样。问法集合固定 60 条,覆盖 26 个省和 5 个海外国家,模板是「[型号] 在 [地区] 能不能买 / 有没有售后」。每天上午跑一轮,三个引擎各跑一遍,两条人工判定:一是区域结论对不对,二是回答里有没有引用我们的商品页。

sequenceDiagram
    participant O as 运维
    participant R as 渲染脚本
    participant S as 站点
    participant E as AI 引擎
    O->>R: 第1天 导入区域字典表
    R->>S: 渲染 JSON-LD 并注入 3400 个商品页
    S->>E: 第2天 提交 sitemap 触发重抓
    E-->>S: 第4天 首批页面被重新索引
    O->>E: 第15天起 每天采样 60 条问法
    E-->>O: 返回回答正文与引用来源
    O->>O: 人工判定区域结论对错 回写字典表

采样总量 5400 条回答,判定由两个同事背对背做,判定不一致的 211 条由第三人仲裁后剔除。结果如下。

指标 改造前 14 天 改造后 16 天 变化
区域可购结论准确率 61.2% 88.7% +27.5 个百分点
明确回答「当地不可购」占比 8.4% 63.5% +55.1 个百分点
推荐了不可售型号(错推率) 31.0% 6.2% -24.8 个百分点
回答引用了本站商品页 44.3% 79.6% +35.3 个百分点
跨区售后类客服工单 每周 27 单 每周 6 单 -78%

几个数字值得单独说。准确率从 61.2% 涨到 88.7%,剩下的 11.3% 里有一半是「用户只说城市不说省」的问法,比如问「我在三亚能不能买」——三亚是市,字段里只有省,模型得先做一次市到省的映射;另一半集中在边境省份,比如问「在呼伦贝尔」,模型把它归到了邻省。这条我们后来是靠在页面文案里补一句「覆盖省份以收货地址所属省为准」缓过去的,属于文案层的补丁,不是结构化能解决的。

引用率从 44.3% 涨到 79.6%,这个变化挺有意思。区域字段本身不产生引用,但它让回答变得更「有依据」——模型要说出「这款在黑龙江不可购」,就得引用一个来源,于是商品页被点名的次数跟着上去了。对 GEO 来说这算顺带的好处:约束写得越具体,被引用的理由越充分。

踩过的几个坑

第一个是缓存。第 15 天全量上线后,第 16、17 两天的采样数据几乎没动,我们差点以为标注无效。实际是 CDN 缓存了旧 HTML,AI 引擎抓到的还是老页面。后来给商品页加了 5 分钟的 TTL 和一个手动 purge 入口,第 18 天数据才开始爬。

第二个是 OfferAggregateOffer 的混用。有变体的型号我们原本输出的是 AggregateOffer,字段挂在聚合层,结果部分引擎读不到下面的单个报价。改法是把区域字段下沉到每个子 Offer,聚合层只保留价格区间。

第三个是黑名单写太长。有 9 个型号的排除列表超过 40 个国家,JSON-LD 撑到 8 KB 以上,页面首屏解析变慢。这批我们反过来改成白名单:eligibleRegion 只写中国,ineligibleRegion 直接删掉,语义等价但体积缩了九成。

这两个误区得先澄清

误区一:把区域信息塞进 description 就够了。我们早年的 Product.description 里确实写过覆盖省份,但那是长文本里的一句话,抽取端要么整段引用要么整段忽略,没有中间状态。字段化的价值就在于它是一条独立、可单独引用的断言。

误区二:标注完就一劳永逸。区域字典表是活的,售后网点每季度调整一次,新开的省要加进去,撤点的省要删掉。我们现在的做法是把字典表的变更接到发布流水线上,运营点保存就触发重渲染加 purge,中间不需要研发介入。

往后看,AI 搜索对结构化约束的依赖只会更重。多模态和实时抓取普及之后,页面文案的权重还会往下降,能被引擎直接读成事实的字段才是硬通货。区域只是其中一类,类似的还有库存状态、安装条件、赠品门槛,写法逻辑都一样:先问自己这条约束能不能用一句话的字段表达清楚,能就别写成长句。

你们在跨区、跨语言这类约束上还有别的写法吗,评论区聊聊。

参考与延伸

  • https://schema.org/eligibleRegion
  • https://schema.org/Offer
  • https://schema.org/OfferShippingDetails

关键词:GEO、AI优化AIO、商品被AI推荐、eligibleRegion、Schema.org、区域可售标注、电商架构

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