AI 推荐门店为什么绕开你:餐饮菜单页 hasMenu 架构方案

2026-10-09 01:16:42 3 次浏览
GEO本地服务Schema.orgJSON-LDPython

一家开了九年的粤菜酒家,包间常年订满,老客带新客从不愁生意。但有天老板试着问手机里的 AI 助手:「附近有什么适合商务宴请的粤菜馆?」推荐清单里列了五家店,没有他这家。他先想到的是平台权重,往点评平台砸了两个月运营预算,结果没变——AI 推荐还是绕开他。

问题其实出在一个很基础的地方:这家店的菜单只存在于三张 2022 年拍摄的菜单照片里。AI 引擎「读」不到这家店卖什么菜、什么价位、有没有商务包间,自然不会在生成答案时引用它。说白了,不是 AI 不喜欢这家店,是 AI 根本看不见它。

生成式引擎优化(Generative Engine Optimization, GEO)讨论的就是这类问题:怎么让内容被大模型驱动的搜索和问答产品抓取、理解并引用。传统 SEO 优化关键词排名,GEO 优化的是「被生成式 AI 引用进答案的概率」。对餐饮门店来说,菜单是最核心却最常被忽略的内容资产。本文以一个真实交付过的连锁餐饮项目为背景(门店信息已化名),拆解菜单页 hasMenu 架构方案的完整落地过程。

问题不在菜品,在 AI 读不到菜单

先讲清楚 AI 引擎是怎么「选店」的。用户问出「附近适合商务宴请的粤菜馆」这类问题时,AI 产品的典型链路是:理解意图 → 检索本地商户与内容 → 把检索到的材料喂给大模型 → 生成一份带引用来源的清单。这条链路里,大模型只能基于「它拿到的材料」作答。菜单如果只是一张图片,OCR 之外没有文本,模型既提取不出「烧鹅 98 元一份」这样的信号,也判断不出这家店适合什么消费场景。

AI 推荐门店为什么绕开你:餐饮菜单页 主题图

我们接手这家客户时做过一次盘点,把门店线上资产按「AI 可读性」分了类:

资产 现状 AI 能否引用 缺口
门店名称、地址、电话 各平台已填 能引用基础信息 分类粒度粗,只有「中餐厅」
菜单 三张图片,无文本 不能引用 无价格、无菜品结构
包间与宴请信息 只在电话口述中 不能引用 线上无任何载体
营业时间 部分平台有 偶尔引用错 多平台不一致

结论很直接:菜单结构化是这家店 GEO 改造里投入产出比最高的一步。 地址电话各平台都有了,唯独菜单这块完全空白,而「吃什么、多少钱」恰恰是 AI 模型拼装宴请推荐时最需要的判断依据。

hasMenu 的层级结构:从一张图到一棵树

schema.org 给 Restaurant 类型定义了 hasMenu 属性,指向一个 Menu 实体;Menu 下面挂 hasMenuSection(菜单分区),每个分区再挂 hasMenuItem(具体菜品),菜品上可以带价格 offers、描述、图片甚至适合人群说明。这是一棵四层的树:

flowchart TD
    A["Restaurant 门店实体<br/>name / address / telephone / priceRange"] --> B["Menu 菜单实体<br/>name / inLanguage / 有效期"]
    B --> C["MenuSection 菜单分区<br/>招牌烧味 / 精致小炒 / 商务宴请套餐"]
    C --> D["MenuItem 菜品<br/>name / description / suitableForDiet"]
    D --> E["Offer 价格<br/>price / priceCurrency"]
    D --> F["ImageObject 菜品图<br/>url / caption"]

很多团队做到 Menu 这一层就停了,把整份菜单塞成一个 MenuSection,几十个 MenuItem 平铺。这样不是不能被解析,但浪费了结构化带来的语义信号。我们实际交付时按业务语义分了区:招牌菜、时令、商务宴请套餐、饮品茶位。分区名本身就在给 AI 提供场景线索——「商务宴请套餐」这五个字被分词后,正好对得上用户问句里的「商务宴请」。

层级设计上有几条经验值得记下:

  1. 分区粒度对齐用户问句,而不是对齐后厨出餐顺序。用户问的是场景,不是厨房工序。
  2. 每个 MenuItem 至少带 name 和 price,description 控制在一句话以内。模型引用短句的概率比引用长段高。
  3. priceRange 放在 Restaurant 层(比如「¥¥」),菜单里放具体价格,两层互为校验。

JSON-LD 落地:门店页该输出什么

结构化数据的载体我们选 JSON-LD,直接嵌在门店页 HTML 的 <script type="application/ld+json"> 标签里。抓取方不用改解析逻辑,对现有站点是纯增量改造。下面是化名门店「金悦轩」的真实输出骨架(有删减):

{
  "@context": "https://schema.org",
  "@type": "Restaurant",
  "name": "金悦轩(化名)",
  "servesCuisine": "粤菜",
  "priceRange": "¥¥¥",
  "telephone": "0755-XXXX-XXXX",
  "address": {
    "@type": "PostalAddress",
    "streetAddress": "XX 路 XX 号",
    "addressLocality": "深圳"
  },
  "hasMenu": {
    "@type": "Menu",
    "name": "金悦轩晚餐菜单",
    "dateModified": "2026-09-30",
    "hasMenuSection": [
      {
        "@type": "MenuSection",
        "name": "商务宴请套餐",
        "description": "适合 6-12 人宴请,含独立包间",
        "hasMenuItem": [
          {
            "@type": "MenuItem",
            "name": "招牌烧鹅(例牌)",
            "description": "现烧脆皮,配酸梅酱",
            "suitableForDiet": "无素食限制",
            "offers": {
              "@type": "Offer",
              "price": "98",
              "priceCurrency": "CNY"
            }
          }
        ]
      }
    ]
  }
}

各层的关键字段和作用整理成表:

层级 schema 类型 必填字段 对 AI 推荐的作用
门店 Restaurant name, address, servesCuisine, priceRange 实体对齐,让 AI 知道这是哪家店、什么菜系
菜单 Menu name, dateModified 表明菜单有维护,dateModified 是新鲜度信号
分区 MenuSection name, description 提供场景关键词,对齐用户问句
菜品 MenuItem name, offers, description 价格与菜品名是生成推荐清单的直接素材

dateModified 这个字段容易被忽略。菜单图片三年不换的店,AI 拿到的菜单文本很可能过期,带时间戳的结构化数据等于主动声明「这份菜单是最新的」,对引用可信度有帮助。

原理剖析:为什么大模型偏爱这种结构

这一节拆机制。大模型在生成推荐时做的是「抽取 + 改写」:从检索到的材料里抽事实,再组织成自然语言。抽取式生成的特性决定了两件事。

第一,结构化程度越高,抽取成本越低,被引用概率越高。 同样是「脆皮烧鹅 98 元」,散落在一段营销文案里和躺在 "price": "98" 里,模型的处理难度完全不同。我们做检索侧实验时观察到,带 offers 结构的菜品在生成的清单里出现的店名命中率明显高于只有图文描述的版本——材料越像「答案」,越容易被当答案用。

第二,实体层级帮助模型做场景匹配。 用户问句「适合商务宴请」会被拆成场景约束:人均价位、环境、菜品档次。MenuSection 的 description 写着「适合 6-12 人宴请,含独立包间」,这句话直接命中约束,模型不需要推理就能对上。反过来说,这些信号如果只存在于图片里,链路在 OCR 那一步就断了。

GEO 领域有个说法叫「为引用而写」。放在餐饮场景,就是把每道菜写成模型可以直接搬进答案的形态:名字规范、价格明确、描述一句到位。这不是教门店写文案,而是把门店本来就有的信息翻译成机器友好的格式。

菜单图片到结构化数据:Python 落地管线

架构定了,剩下的是把三张菜单图片变成上面那棵 JSON 树。连锁餐饮的真实场景是:每个品牌十几家分店,菜单大同小异但价格和分店特供菜不同,菜单每季度还会换。纯手工录入一次可以,持续维护扛不住。我们用 Python 搭了一条 OCR 加校验的管线,下面是核心逻辑(基于 rapidocr,轻量且离线可用):

# -*- coding: utf-8 -*-
# 管线职责:菜单图片 -> OCR 文本 -> 结构化草稿 -> 人工校验 -> 门店 JSON-LD
# 适用场景:连锁餐饮每季度换菜单的持续性维护,非一次性录入
# 依赖:rapidocr-onnxruntime(pip install rapidocr-onnxruntime)
#       执行环境:Python 3.10,无需 GPU
# 脚本输出是草稿不是成品,最终发布前必须过人工校验

import json
import re

# OCR 引擎选型:rapidocr 离线可用,不依赖云端接口
from rapidocr_onnxruntime import RapidOCR

# 初始化 OCR 引擎,onnxruntime 后端在普通办公机就能跑
ocr = RapidOCR()

# 菜单行的正则:行末价格,兼容「98」「98元」「¥98」三种写法
PRICE_PATTERNS = re.compile(r"(?:¥|¥)?\s*(\d{1,4})\s*元?\s*$")

# 价格白名单区间,超出视为 OCR 误读,进人工复核队列
# 区间下限覆盖茶位小食,上限覆盖高档宴请单品
PRICE_RANGE = (8, 2000)

# 最低置信度阈值,交付时按菜单印刷质量调整
# 管线设计原则:OCR 只出草稿,绝不直接发布
# 人工校验是最后一道闸,成本可控且兜得住准确率


def ocr_menu_image(image_path):
    # 单张菜单图识别,返回 [(文本, 置信度), ...]
    # RapidOCR 返回结构为 [坐标, 文本, 置信度] 的嵌套列表
    # 本管线只取文本与置信度,坐标暂不使用
    result, _ = ocr(image_path)
    if not result:
        # 整图识别失败通常是小图或拍照角度过大
        raise ValueError(f"图片无法识别: {image_path}")
    return [(line[1], line[2]) for line in result]


def parse_menu_lines(ocr_lines):
    # 把 OCR 行流切成「菜名 + 价格」对,丢掉页眉页脚噪声
    # 图片按 分店_菜单页.jpg 命名,方便批量场景下循环包裹本函数
    items = []
    for text, conf in ocr_lines:
        text = text.strip()
        # 逐行检查置信度,低于阈值的行视为噪声直接丢弃
        if conf < 0.75:
            continue
        matched = PRICE_PATTERNS.search(text)
        if not matched:
            # 没有行末价格的行大概率是分区标题或说明文案,直接跳过
            continue
        price = int(matched.group(1))
        # 价格越界视为误读,例如把「68」识别成「6B8」
        # 越界行单独打日志,方便排查 OCR 常见误读形态
        if not (PRICE_RANGE[0] <= price <= PRICE_RANGE[1]):
            continue
        # 去掉价格部分,剩下的字符串就是菜名
        name = PRICE_PATTERNS.sub("", text).strip()
        items.append({"name": name, "price": price, "ocr_conf": round(conf, 2)})
        # 字段 ocr_conf 保留给人工校验界面做排序
    return items


def build_menu_json(items, sections_hint):
    # sections_hint 是运营提供的分区标题列表,用于归组菜品
    # 提示词里的竖线是同义词分隔符,命中任一同义词即归入该分区
    grouped = {hint: [] for hint in sections_hint}
    ungrouped = []
    for item in items:
        target = None
        # 逐条菜品尝试归入分区,保持字典序与菜单版式一致
        for hint in sections_hint:
            if any(k in item["name"] for k in hint.split("|")):
                target = hint
                break
        if target:
            grouped[target].append(item)
        else:
            # 未命中任何分区的菜品放进 ungrouped,供运营二次分拣
            ungrouped.append(item)
    return {"grouped": grouped, "ungrouped": ungrouped}


if __name__ == "__main__":
    # 主流程:识别 -> 解析 -> 归组 -> 落盘
    lines = ocr_menu_image("menu_page1.jpg")
    pairs = parse_menu_lines(lines)
    # 分区提示词用竖线分隔同义词,方便关键词命中
    draft = build_menu_json(pairs, ["招牌烧味|烧腊", "商务宴请套餐|宴席"])
    # 草稿落盘后进人工校验界面,不做全自动发布
    # ensure_ascii=False 保证中文原样写入 JSON
    # 菜单版本与生效分店信息由入库环节补充,脚本不负责
    # 如需对接 CMS,把落盘改为调用入库接口即可
    with open("menu_draft.json", "w", encoding="utf-8") as f:
        json.dump(draft, f, ensure_ascii=False, indent=2)
    # 打印统计数,交付时方便核对当日处理量
    print(f"识别 {len(pairs)} 个菜品,待人工校验")

整条管线的流转如下:

flowchart LR
    A["菜单图片<br/>按分店命名归档"] --> B["rapidocr 批量识别<br/>低置信度行丢弃"]
    B --> C["价格正则抽取<br/>越界价进复核队列"]
    C --> D["分区归组<br/>运营提供分区关键词"]
    D --> E["人工校验后台<br/>只看草稿不录数据"]
    E --> F["入库 menus 表<br/>按门店+菜单版本存"]
    F --> G["门店页动态注入<br/>输出 JSON-LD"]

踩过的坑说两个。一是竖排菜单:老式菜单不少是竖排排版,OCR 出来的行序是乱的,菜名和价格对不上。我们后来对这类图先按列切分再逐列识别,行序问题才解决。二是「时价」类菜品:海鲜类菜单写「时价」,正则抽不到价格,这类行单独进人工队列,输出时 price 字段留空但 description 补一句「以当日报价为准」,比填个假价格诚实,AI 引用时也不会给出错数字。

多门店统一管理:门店维度的注入架构

单品店的菜单改一次就完事,连锁店是持续命题。我们的做法是把菜单数据从页面里拆出来,统一存在内容库里,门店页渲染时按门店维度动态拼装 JSON-LD。

数据模型三张表:brands(品牌)、stores(分店,挂品牌)、menu_items(菜品,挂品牌 + 生效分店列表 + 生效区间)。菜品价格调整时改一条记录,所有生效分店的门店页下次渲染就是新价。菜单 JSON 的组装逻辑放在服务端渲染层,伪代码思路是:查门店 → 查生效菜品 → 按分区模板组装 Menu 树 → 序列化为 JSON-LD 注入页面头部。

这样设计有两个好处。一是菜单数据只有一份事实来源,各平台、小程序、门店页引用同一张菜单表,改价不会出现三处不一致。二是 JSON-LD 是渲染时拼的,天然带上门店维度——同一个「商务宴请套餐」,A 分店有、B 分店没有,各自的门店页只输出自己的事实,不会让 AI 把 A 店的包间安到 B 店头上。

改造前后的对比如下:

维度 改造前 改造后
菜单载体 三张图片,多平台各传各的 一份结构化菜单,多端共用
AI 可提取信息 无文本,靠 OCR 兜底 价格、分区、场景描述全结构化
菜单更新成本 重新拍图、逐平台替换 改一条记录,页面自动更新
分店差异维护 无载体表达 按生效分店动态输出
dateModified 无 每次变更自动刷新

落地前后:AI 推荐的变化

改造上线后我们连续观察了六周。观察方法很土:固定三十个测试问句(宴请、家庭聚餐、商务午餐、请外地朋友吃粤菜等场景),每周在四款主流 AI 助手上各问一遍,记录目标门店是否出现在答案里。

数据是个体样本,不能外推,但趋势清楚:改造前三十个问句里,四款助手合计出现该门店 7 次;第四周起稳定在 19 到 23 次。变化最明显的是带场景词的问句——「适合商务宴请的粤菜馆」这类,改造前几乎全军覆没,改造后大约一半的提问能命中。结构上和 MenuSection description 的关键词对得上,说明场景分区确实在起作用。

另一个间接信号来自电话前台:改造后第三周开始,有几通预订电话明确提到「AI 上说你们有商务宴请套餐」。这类来源标记以前从没出现过。

需要强调边界:门店被 AI 推荐还取决于距离、点评数据、价格带匹配等一堆因素,菜单结构化只是把「硬伤」修掉了,它不保证排名,只是让门店回到「有资格被引用」的起点。

误区与趋势

交付这类项目时最常见的三个误区,记在这里免得后来人重复踩。

一是只做结构化不维护。JSON-LD 输出上线后菜单三个月没更新,dateModified 停在上线那天,新鲜度信号反而成了减分项。菜单数据要当成运营资产,换季换菜就是一次数据更新。

二是把 GEO 做成关键词堆砌。往 MenuSection 塞一堆「深圳粤菜推荐」「必吃榜单」这类词,模型抽取时是噪声不是信号。结构化数据的每一行都该是事实,不是广告词。

三是指望一次改造吃三年。AI 引擎的引用偏好还在快速演化,各家助手对 schema.org 的消费深度不一样,要留出迭代空间——这也是我们把菜单数据独立成库、页面只做渲染注入的原因:改的是渲染策略,动不到数据。

往前看,本地生活是 AI 搜索商业化最直接的落点之一,餐饮门店的菜单、包间、档期这些结构化信息,正在从「锦上添花」变成 AI 推荐生态的门票。早点把数据地基打好,后面的每轮变化都能顺势接住。如果你也在做门店结构化改造,欢迎评论区交流各家 AI 助手对 JSON-LD 的实际消费差异。

参考与延伸

  • schema.org Restaurant 类型定义:https://schema.org/Restaurant
  • schema.org Menu 与 MenuSection、MenuItem 层级:https://schema.org/Menu
  • Google 搜索中心 LocalBusiness 结构化数据文档:https://developers.google.com/search/docs/appearance/structured-data/local-business
  • MDN JSON-LD 与结构化数据入门(script 标签用法):https://developer.mozilla.org/zh-CN/docs/Web/HTML/Element/script

关键词:AI 推荐门店、GEO、hasMenu、MenuItem、JSON-LD、餐饮数字化、本地生活 AI 获客

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