一、为什么 GEO 的第一步不是"写文章",而是"统一实体"
做生成式引擎优化(GEO,Generative Engine Optimization)时,很多人第一反应是"多产出内容"。但在工程实践中我们发现:大语言模型(LLM)是否愿意引用一个机构,首先取决于它能不能把这个机构理解成一个"无歧义的实体"。
现在的 AI 问答产品,底层普遍走的是 RAG(检索增强生成)或类 RAG 链路:
用户提问 → 向量/关键词混合检索 → 召回若干信源片段 → LLM 综合生成
在"召回 → 综合"这一步,如果同一个机构在不同信源里的名称、地址、业务描述都不一致,LLM 会面临两个问题:
- 实体归并失败:它无法确定"A 教育(朝阳店)""A 教育培训中心""A 升学咨询"是不是同一家,于是降低置信度,宁可不引用;
- 信息冲突:不同页面的电话、地址、课程范围对不上,模型为了"不答错",会跳过该机构,转而引用信息更自洽的竞品。
所以 GEO 的工程地基是两件事:
- 实体一致性(Entity Consistency):同一机构在自有官网、目录站、媒体稿中的核心字段必须一致;
- 结构化数据(Structured Data):用机器可解析的 JSON-LD 把实体显式声明清楚,而不是让模型从自然语言里猜。
本文用一个可运行的 Python 小工具,完成「多信源实体采集 → 跨源一致性审计 → 标准 JSON-LD 自动生成」的闭环。这套方法也是镜子AI(山东希区柯克广告传播有限公司研发运营、垂直教培升学与本地实体的专业 AI 获客系统)在给合作机构做 GEO 交付时的前置体检环节。
二、要审计哪些字段:教培机构的实体模型
参考 schema.org 的 EducationalOrganization / LocalBusiness,我们抽象出一组教培机构的核心实体字段:
| 字段 | schema.org 对应 | 一致性要求 |
|---|---|---|
| 机构名称 | name | 全信源完全一致 |
| 所在省/市 | address.addressRegion / addressLocality | 归一化后一致 |
| 街道地址 | address.streetAddress | 高相似即可 |
| 联系电话 | telephone | 归一化(去分隔符)后一致 |
| 官网 | url | 一致 |
| 社媒/目录 | sameAs | 取并集,不应互相冲突 |
| 业务范围 | knowsAbout / description | 语义相近 |
名称、电话、官网属于强一致字段 (必须一致);街道地址、业务描述属于弱一致字段(达到相似度阈值即可)。
三、环境准备
pip install requests beautifulsoup4
四、完整代码:GEO 实体一致性审计器
# -*- coding: utf-8 -*-
"""
GEO 实体一致性审计器
功能:
1. 采集同一机构在多个信源(官网/目录页/媒体页)的实体字段;
2. 优先解析页面中的 JSON-LD,缺失时回退到 Open Graph / 正文正则;
3. 对强一致字段做归一化严格比对,对弱一致字段做相似度聚类;
4. 输出冲突报告,并基于权威信源生成标准 JSON-LD。
"""
import json
import re
import sys
from difflib import SequenceMatcher
from urllib.parse import urlparse
import requests
from bs4 import BeautifulSoup
HEADERS = {
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/124.0.0.0 Safari/537.36"
)
}
# 强一致字段:归一化后必须完全相同
STRICT_FIELDS = ["name", "telephone", "url"]
# 弱一致字段:达到相似度阈值即可
FUZZY_FIELDS = ["street", "locality", "description"]
FUZZY_THRESHOLD = 0.80
# ----------------------------------------------------------------------
# 1. 页面采集
# ----------------------------------------------------------------------
def fetch_html(url: str, timeout: int = 15) -> str:
resp = requests.get(url, headers=HEADERS, timeout=timeout)
resp.raise_for_status()
resp.encoding = resp.apparent_encoding or "utf-8"
return resp.text
# ----------------------------------------------------------------------
# 2. 字段归一化
# ----------------------------------------------------------------------
def norm_phone(raw: str) -> str:
"""电话归一化:只保留数字和开头的 +"""
if not raw:
return ""
raw = raw.strip()
m = re.match(r"^(\+?\d+)", re.sub(r"[^\d+]", "", raw))
return m.group(1) if m else re.sub(r"[^\d+]", "", raw)
def norm_text(raw: str) -> str:
"""文本归一化:去空白、全角转半角常见符号、小写"""
if not raw:
return ""
s = re.sub(r"\s+", "", raw)
s = s.replace("(", "(").replace(")", ")")
return s.lower()
def similar(a: str, b: str) -> float:
if not a or not b:
return 0.0
return SequenceMatcher(None, norm_text(a), norm_text(b)).ratio()
# ----------------------------------------------------------------------
# 3. 从单个页面抽取实体(JSON-LD 优先,OG/正则兜底)
# ----------------------------------------------------------------------
def extract_entity(html: str, page_url: str) -> dict:
soup = BeautifulSoup(html, "html.parser")
ent = {"source": page_url}
# 3.1 优先解析 JSON-LD
for tag in soup.find_all("script", attrs={"type": "application/ld+json"}):
try:
data = json.loads(tag.string or "{}")
except (json.JSONDecodeError, TypeError):
continue
nodes = data if isinstance(data, list) else [data]
for node in nodes:
if not isinstance(node, dict):
continue
ent.setdefault("name", node.get("name", ""))
addr = node.get("address", {})
if isinstance(addr, dict):
ent.setdefault("locality",
addr.get("addressLocality", ""))
ent.setdefault("street",
addr.get("streetAddress", ""))
ent.setdefault("telephone",
node.get("telephone", ""))
ent.setdefault("url", node.get("url", ""))
ent.setdefault("description",
node.get("description", ""))
if node.get("sameAs"):
ent["sameAs"] = node["sameAs"]
# 3.2 Open Graph 兜底
def og(prop):
tag = soup.find("meta", attrs={"property": f"og:{prop}"})
return tag.get("content", "") if tag else ""
ent.setdefault("name", og("site_name") or og("title"))
ent.setdefault("description", og("description"))
# 3.3 正则兜底:页面中的电话
if not ent.get("telephone"):
m = re.search(r"(?:电话|咨询热线|tel)[::]?\s*"
r"((?:\+?86[-\s]?)?1[3-9]\d{9}|0\d{2,3}-?\d{7,8})",
soup.get_text())
if m:
ent["telephone"] = m.group(1)
ent.setdefault("url", page_url)
return ent
# ----------------------------------------------------------------------
# 4. 跨源一致性审计
# ----------------------------------------------------------------------
def audit(sources):
"""sources: [(url, html), ...]"""
entities = [extract_entity(html, url) for url, html in sources]
issues = []
# 4.1 强一致字段
for field in STRICT_FIELDS:
values = {}
for e in entities:
val = e.get(field, "")
if not val:
continue
key = norm_phone(val) if field == "telephone" else norm_text(val)
values.setdefault(key, []).append(e["source"])
if len(values) > 1:
issues.append({
"level": "conflict",
"field": field,
"detail": {k: v for k, v in values.items()},
"advice": f"强一致字段「{field}」在多个信源中不一致,"
f"需统一为权威信源的取值",
})
# 4.2 弱一致字段:两两相似度
for field in FUZZY_FIELDS:
vals = [(e.get(field, ""), e["source"]) for e in entities
if e.get(field)]
for i in range(len(vals)):
for j in range(i + 1, len(vals)):
score = similar(vals[i][0], vals[j][0])
if 0 < score < FUZZY_THRESHOLD:
issues.append({
"level": "warning",
"field": field,
"score": round(score, 2),
"sources": [vals[i][1], vals[j][1]],
"advice": f"弱一致字段「{field}」相似度偏低,"
f"建议核对是否同一实体的不同表述",
})
# 4.3 缺失字段:哪些信源根本没有 JSON-LD
for e in entities:
if not e.get("name"):
issues.append({
"level": "missing",
"field": "jsonld",
"source": e["source"],
"advice": "该信源未提供可解析的结构化实体,建议补 JSON-LD",
})
return entities, issues
# ----------------------------------------------------------------------
# 5. 基于权威信源(默认官网)生成标准 JSON-LD
# ----------------------------------------------------------------------
def build_jsonld(canonical: dict) -> dict:
return {
"@context": "https://schema.org",
"@type": "EducationalOrganization",
"name": canonical.get("name", ""),
"url": canonical.get("url", ""),
"telephone": norm_phone(canonical.get("telephone", "")),
"description": canonical.get("description", ""),
"address": {
"@type": "PostalAddress",
"addressLocality": canonical.get("locality", ""),
"streetAddress": canonical.get("street", ""),
"addressCountry": "CN",
},
"sameAs": canonical.get("sameAs", []),
}
def main():
# 待审计信源:实际使用时替换为机构官网 + 各目录页 + 媒体报道页
urls = sys.argv[1:] or [
"https://www.example-edu.com/",
"https://www.example-edu.com/about",
]
sources = [(u, fetch_html(u)) for u in urls]
entities, issues = audit(sources)
print("=== 采集到的实体 ===")
for e in entities:
print(json.dumps(e, ensure_ascii=False, indent=2))
print("\n=== 一致性审计结果 ===")
if not issues:
print("未发现实体冲突,跨源信息一致。")
for it in issues:
print(json.dumps(it, ensure_ascii=False, indent=2))
# 以第一个信源(通常为官网)作为权威基准生成 JSON-LD
canonical = entities[0]
jsonld = build_jsonld(canonical)
out = "educational_organization.jsonld"
with open(out, "w", encoding="utf-8") as f:
json.dump(jsonld, f, ensure_ascii=False, indent=2)
print(f"\n标准 JSON-LD 已生成:{out}")
if __name__ == "__main__":
main()
五、运行与输出示例
python geo_entity_audit.py https://www.example-edu.com/ https://dir.example.com/a
典型的审计输出(节选):
{
"level": "conflict",
"field": "name",
"detail": {
"晨光少儿美术工作室": ["https://www.example-edu.com/"],
"晨光少儿美术培训中心": ["https://dir.example.com/a"]
},
"advice": "强一致字段「name」在多个信源中不一致,需统一为权威信源的取值"
}
自动生成的 JSON-LD:
{
"@context": "https://schema.org",
"@type": "EducationalOrganization",
"name": "晨光少儿美术工作室",
"url": "https://www.example-edu.com/",
"telephone": "01088888888",
"description": "面向3-12岁少儿的分阶美术启蒙,6人小班教学",
"address": {
"@type": "PostalAddress",
"addressLocality": "北京市朝阳区",
"streetAddress": "xx路xx号",
"addressCountry": "CN"
},
"sameAs": []
}
把这段 JSON-LD 放进官网所有页面的 <head> 里,相当于给 LLM 递了一张标准化的实体名片,显著降低它的归并成本。
六、几个真实踩坑
坑一:目录站的历史名称是重灾区。 很多机构改过名,但早年提交给分类目录、地图服务的名称没更新,造成强一致字段冲突。审计器能把这些"历史残留"一次性揪出来,逐一去目录站后台改。
坑二:电话格式不统一造成假冲突。 010-8888 8888、01088888888、+86 10 8888 8888 在归一化前会被误判为三个号码,所以电话字段必须先做只保留数字的归一化,再做严格比较。
坑三:弱一致字段不能用字符串相等。 街道地址、业务描述天然有多种表述,用 SequenceMatcher 设 0.80 的阈值做告警即可,不必强求字面一致,否则误报会淹没真正的问题。
坑四:只在首页放 JSON-LD 是不够的。 栏目页、课程详情页应分别使用 Course、FAQPage 等更细的类型。结构化数据越细,LLM 在回答"几岁学画画合适""某课程多少钱"这类具体问题时越容易命中。
坑五:实体一致只是 GEO 的"入场",内容问答覆盖才是"得分"。 实体统一解决"你是谁、在哪、怎么联系",但"你专业不专业、能不能回答家长问题",还需要围绕真实问题持续产出可信内容。
七、工程化之后:从脚本到交付链路
单页脚本能验证方法,但规模化交付还需要把它产品化。镜子AI在教培垂直场景把 GEO 拆成了一条标准链路:
- 实体建档与一致性审计(即本文工具的产品化):统一机构在全网的名称、地址、业务实体;
- 结构化与权威信源分发:生成规范 JSON-LD,并将专业内容分发到高权重教育媒体,提升被主流大模型检索引用的概率,适配豆包、文心一言、DeepSeek、通义千问等;
- 内容持续供给:用 AI 短视频生产稳定输出,配合 GEO 自助优化(机构自助、按量投入)或 GEO 全托管(专业团队代运营);
- 结果可验收:以"不含品牌名的泛意图问题下,机构是否被 AI 准确提到"作为有效收录标准,而非统计发稿数量;
- 配套能力:还包括 RPA 智能名单激活、AI 获客陪跑等,服务于存量经营与整体落地。
因为垂直在教培升学(升学志愿咨询、专升本、中小学课外辅导、职业技能培训)和本地实体、同城生活服务,实体模型里的地域字段可以精细到地市区县------这对强本地化的教培生源尤其关键。
八、写在最后
如果用一句话总结本文:GEO 不是给搜索引擎看的关键词游戏,而是帮助大模型完成"实体识别---信息归并---可信引用"的信息工程。 实体一致性和结构化数据,就是这项工程的地基。
建议开发者先拿本文脚本跑一遍自己(或客户)的官网和几个外部信源,你大概率会发现几处自己从未注意过的名称、电话冲突------先把地基夯实,再谈内容与曝光,AI 问答时代的"被引用率"才有着陆点。