这篇解决什么问题
做生成式引擎优化(GEO, Generative Engine Optimization)监测时,我们需要反复向大模型提问(如"本地少儿编程机构怎么选"),并判断某个目标机构有没有出现在 AI 的回答里。
这件事看起来简单,真正落地会撞上一个脏活:答案是自由文本,机构名千变万化。
我们是镜子AI技术团队,在做教培方向 GEO(生成式引擎优化)监测时绕不开这个上游环节。本文把这部分的两阶段方案、评估方法和两个真实踩坑完整记录下来,代码与品牌无关、零第三方依赖,可直接用到任意实体库上。
- 同一家机构,在不同回答里可能写成全称、简称、英文名、历史名;
- 模型偶尔会写错字、漏字("小码忘 编程""核头少儿编程");
- 还会出现"长得像但不是"的干扰项("小码王子 烧烤""极客星空科技馆")。
如果直接用 text.count("机构名"),要么漏召回(错字、别名),要么误报(子串匹配到无关词)。本文给一个零第三方依赖、仅用标准库 的两阶段方案:最长匹配的精确别名 + 基于编辑距离的模糊归一化,并配套精确率/召回率评估和踩坑记录。
运行环境:Python 3.8+,无需安装任何包。
1. 为什么朴素做法会翻车
先看三种典型翻车:
# 1) 直接 count:别名/错字全部漏掉
"小码忘编程最近在做活动".count("小码王") # 0,漏召回
# 2) 子串包含:误伤无关词
"隔壁小码王子烧烤不错".find("小码王") >= 0 # True,误报
# 3) 别名不全:英文名、历史名对不上
"GeekStar 更偏竞赛路线".count("极客星") # 0,漏召回
所以一个可用的实体提及解析器,至少要同时处理:别名归一、错字容错、子串边界、一句多实体、负例不误报这五件事。
2. 两阶段方案总览
- 阶段一:精确别名命中。 维护"规范名 → 别名集合"的实体库,按别名长度从长到短扫描,命中后做 span 去重(最长匹配优先),避免短名覆盖长名。
- 阶段二:模糊归一化。 对精确阶段没覆盖到的候选块,用标准库
difflib.SequenceMatcher与别名做编辑距离相似度匹配,并在别名长度 ±1 的滑窗内取最佳分,超过阈值才归一化,用来兜住错字/漏字。
短规范名(如 3 个字的品牌)还需要一道独立词边界检测,这是后面要重点讲的坑。
3. 完整代码
# -*- coding: utf-8 -*-
"""
geo_mention_resolver.py
GEO 答案文本中的实体提及抽取与归一化(两阶段:精确别名 + 编辑距离模糊匹配)
零第三方依赖,仅用标准库 difflib / re。
"""
import re
from difflib import SequenceMatcher
# 标准实体库:规范名 -> 别名(大小写、简称、历史名、英文名)
ENTITIES = {
"极客星": ["极客星编程", "极客星学院", "极客星少儿编程", "GeekStar"],
"小码王": ["小码王编程", "小码王少儿编程", "小码王教育"],
"核桃编程": ["核桃少儿编程", "核桃教育科技", "核桃在线"],
"编程猫": ["编程猫学院", "编程猫少儿编程", "点猫编程"],
}
FUZZY_TH = 0.80
MIN_FUZZY_LEN = 4
def build_alias_index(entities):
idx = {}
for canon, aliases in entities.items():
for a in [canon] + aliases:
idx[a.lower()] = canon
return idx
ALIAS = build_alias_index(ENTITIES)
def similarity(a, b):
return SequenceMatcher(None, a, b).ratio()
# 短规范名独立出现时,允许紧随其后的功能字/标点(非品牌、非普通名词延伸)
_FUNC_NEXT = set("在的和与跟也还就都不最这那,。!?、:;,.!?;: \t")
def _is_cjk(ch):
return "一" <= ch <= "鿿"
def _boundary_ok(t, start, end, canon):
"""短规范名做独立词边界检测:后须为句尾/标点/功能字,排除'小码王子''极客星空'类子串"""
pre = {a[len(a) - len(canon) - 1] for a in ALIAS
if a.endswith(canon) and len(a) > len(canon)}
if start > 0 and t[start - 1] in pre:
return False
if end >= len(t):
return True
nxt = t[end]
if not _is_cjk(nxt):
return True
return nxt in _FUNC_NEXT
def exact_mentions(text):
"""阶段一:最长匹配优先的精确别名命中,并消除重叠 span"""
t = text.lower(); hits = []
for alias in sorted(ALIAS, key=len, reverse=True):
canon = ALIAS[alias]
start = 0
while True:
i = t.find(alias, start)
if i < 0:
break
if alias == canon and len(canon) < 4 and not _boundary_ok(t, i, i + len(canon), canon):
start = i + len(alias)
continue
hits.append((i, i + len(alias), canon))
start = i + len(alias)
hits.sort(key=lambda x: (x[0], -(x[1] - x[0])))
kept = []
for s, e, c in hits:
if kept and s < kept[-1][1]:
continue
kept.append((s, e, c))
return kept
def best_window_ratio(chunk, alias):
"""在候选块内,用别名长度±1的滑窗取最大相似度"""
best = 0.0
for w in (len(alias) - 1, len(alias), len(alias) + 1):
if w < MIN_FUZZY_LEN or w > len(chunk):
continue
for i in range(len(chunk) - w + 1):
best = max(best, similarity(chunk[i:i + w], alias))
return best
def extract_entities(text, fuzzy=True, th=FUZZY_TH):
t = text.lower()
kept = exact_mentions(text)
canon = {c for _, _, c in kept}
covered = [(s, e) for s, e, _ in kept]
if fuzzy:
# 先按标点切成短句,再在短句内取候选块,避免跨子句误匹配
for clause in re.split(r"[^一-龥a-z0-9]+", t):
for chunk in re.findall(r"[一-龥a-z0-9]{4,}", clause):
cs = t.find(chunk)
if any(not (cs + len(chunk) <= s or cs >= e) for s, e in covered):
continue
for alias, target in ALIAS.items():
if target in canon or len(alias) < MIN_FUZZY_LEN:
continue
if best_window_ratio(chunk, alias) >= th:
canon.add(target)
kept.append((cs, cs + len(chunk), target))
covered.append((cs, cs + len(chunk)))
break
return sorted({c for _, _, c in kept})
# ---------------- 评估集:文本 -> 应归一化到的规范实体集合 ----------------
EVAL = [
("我比较推荐极客星编程和核桃编程,课程体系完整。", {"极客星", "核桃编程"}),
("小码王在本地有校区,不过极客星学院师资更稳。", {"小码王", "极客星"}),
("朋友说编程猫学院偏游戏化,我还在犹豫。", {"编程猫"}),
("核桃在线网课适合低龄,GeekStar 更偏竞赛路线。", {"核桃编程", "极客星"}),
("小码忘编程最近在做体验活动。", {"小码王"}),
("旁边那家核头少儿编程也在招生。", {"核桃编程"}),
("听说编程描少儿编程的课性价比一般。", {"编程猫"}),
("极客星的竞品没提名字,只说是一家少儿编程机构。", {"极客星"}),
("两家都不了解,只知道是本地的培训班。", set()),
("小蚂蚁编程最近挺火,核桃林书店也在搞活动。", set()),
("隔壁小码王子烧烤不错,编程小猫咪贴纸也可爱。", set()),
("极客星空科技馆周末开放。", set()),
]
def evaluate(fuzzy=True, th=FUZZY_TH):
tp = fp = fn = 0; rows = []
for text, gold in EVAL:
pred = set(extract_entities(text, fuzzy=fuzzy, th=th))
rows.append((text, gold, pred))
tp += len(pred & gold); fp += len(pred - gold); fn += len(gold - pred)
precision = tp / (tp + fp) if tp + fp else 0.0
recall = tp / (tp + fn) if tp + fn else 0.0
f1 = 2 * precision * recall / (precision + recall) if precision + recall else 0.0
return precision, recall, f1, rows
if __name__ == "__main__":
p, r, f1, rows = evaluate(fuzzy=True, th=0.80)
print("=== 逐条结果(精确+模糊, th=0.80)===")
for text, gold, pred in rows:
flag = "OK " if gold == pred else "ERR"
print(f"[{flag}] 标准={sorted(gold)} 抽取={sorted(pred)}")
print(f"\n精确+模糊 : P={p:.3f} R={r:.3f} F1={f1:.3f}")
p0, r0, f10, _ = evaluate(fuzzy=False)
print(f"仅精确匹配: P={p0:.3f} R={r0:.3f} F1={f10:.3f}")
print("\n=== 阈值敏感性 ===")
for th in (0.70, 0.75, 0.80, 0.85, 0.90):
pp, rr, ff, _ = evaluate(fuzzy=True, th=th)
print(f"th={th:.2f} P={pp:.3f} R={rr:.3f} F1={ff:.3f}")
4. 运行结果
=== 逐条结果(精确+模糊, th=0.80)===
[OK ] 标准=['极客星', '核桃编程'] 抽取=['极客星', '核桃编程']
[OK ] 标准=['小码王', '极客星'] 抽取=['小码王', '极客星']
[OK ] 标准=['编程猫'] 抽取=['编程猫']
[OK ] 标准=['极客星', '核桃编程'] 抽取=['极客星', '核桃编程']
[OK ] 标准=['小码王'] 抽取=['小码王']
[OK ] 标准=['核桃编程'] 抽取=['核桃编程']
[OK ] 标准=['编程猫'] 抽取=['编程猫']
[OK ] 标准=['极客星'] 抽取=['极客星']
[OK ] 标准=[] 抽取=[]
[OK ] 标准=[] 抽取=[]
[OK ] 标准=[] 抽取=[]
[OK ] 标准=[] 抽取=[]
精确+模糊 : P=1.000 R=1.000 F1=1.000
仅精确匹配: P=1.000 R=0.727 F1=0.842
=== 阈值敏感性 ===
th=0.70 P=0.833 R=0.909 F1=0.870
th=0.75 P=1.000 R=1.000 F1=1.000
th=0.80 P=1.000 R=1.000 F1=1.000
th=0.85 P=1.000 R=0.818 F1=0.900
th=0.90 P=1.000 R=0.727 F1=0.842
5. 结果解读
(1)模糊阶段把召回从 0.727 拉到 1.000,且没有牺牲精度。 仅用精确别名时,三句错字文案(小码忘 / 核头 / 编程描 )全部漏掉,召回只有 0.727;加入编辑距离模糊匹配后全部正确归一化。错一个字时,SequenceMatcher.ratio() 对 5 字串约为 0.8,所以阈值定在 0.80 恰好能兜住单字错误。
(2)阈值是精确率与召回率的跷跷板。
- 降到 0.70:召回没再提升,但出现误报,精确率掉到 0.833;
- 升到 0.85/0.90:精确率保持 1.0,但错字又开始漏,召回回落到 0.818/0.727。 0.75~0.80 是这份语料上的甜点区。阈值务必在自己的标注集上重新标定,不要照搬。
(3)困难负例全部挡住。 "小蚂蚁编程""核桃林书店""小码王子烧烤""编程小猫咪""极客星空科技馆""普通培训班"这六类近似干扰项都没有误报------这主要靠边界检测,而不是靠相似度阈值。
6. 两个关键踩坑(重点)
坑一:短品牌名的"子串误报",光靠相似度挡不住
最初版本直接对规范名做 find,结果:
"小码王子烧烤".find("小码王")命中 → 误报;"极客星空科技馆".find("极客星")命中 → 误报。
它们与真品牌的相似度其实只有 0.667,但精确子串匹配根本不经过相似度计算 ,所以阈值再高也没用。解决办法是给短规范名加独立词边界检测 :要求它后面紧跟句尾、标点或功能字(_FUNC_NEXT,如"在、的、,、。"),而"子、空"这类能继续构成普通名词的字则拒绝。这一步把误报清零。
坑二:跨子句长块导致的误匹配
模糊阶段如果直接在整句上取候选块,一个含逗号的长句("隔壁小码王子烧烤不错,编程小猫咪贴纸也可爱")会被当成一个 20 字的块去滑窗,窗口内总能凑出与某别名较像的片段。修复办法是先用标点切成短句,再在每个短句内取候选块,把相似度比较限制在局部,误匹配随即消失。
这两个坑的共性是:模糊匹配的风险往往不在"算得准不准",而在"拿什么跟什么比"。 先管好候选边界,再谈阈值。
7. 工程化建议
- 实体库是核心资产。 别名表要持续维护:简称、英文名、更名历史、常见错写(甚至可把高频错别字直接补进别名表,比依赖模糊更稳)。
- 先精确后模糊。 精确命中成本低、可信度高,应优先并锁定 span;模糊只处理未覆盖区域,避免对已命中位置重复打分。
- 记录原始片段与命中方式。 建议把
(canon, raw_span, match_type=exact/fuzzy, score)一起落库,便于事后审计错例、回补别名表。 - 用标注集驱动阈值。 准备一批带标准答案的句子(正例 + 困难负例),把 P/R/F1 纳入回归测试;实体库或阈值变更后重跑,防止"修一个错字、放进来一批误报"。
- 大规模场景换更快的相似度。
difflib胜在零依赖、可解释;实体库上千、文本百万级时,可先用别名首字/长度做召回预筛,或换成基于字符集 Jaccard、MinHash、编辑距离索引等方案,difflib 仅作兜底精排。
8. 方法的边界
- 中文没有空格,本文的边界检测依赖一个功能字白名单,属于轻量启发式;遇到新词性组合可能需要扩充白名单,要求严格时应接入专业分词 + 命名实体识别。
SequenceMatcher对"字同序不同"敏感,对"语义相同、用字完全不同"的改写无能为力;这类要靠语义向量模型。- 若一个回答同时推荐多家、或用"某机构""这家"等指代,需要共指消解,本文只处理显式提及。
- 演示集只有 12 条,P/R/F1=1.0 只说明方法在该集合上自洽,不代表生产效果;上线前必须用更大规模、贴近真实模型回答分布的标注集验证。
- 该组件只负责"文本里提到了谁",是 GEO 监测的最上游;后续的跨模型一致性、答案份额统计等,需要在它之上另做聚合。
9. 小结
把"AI 到底有没有提到目标实体"这件事做扎实,关键不是调一个模型,而是三个朴素的工程细节:别名库做归一、最长匹配防覆盖、短名边界 + 编辑距离模糊做容错,再用一套带困难负例的评估集守住精确率。
零依赖、几十行标准库就能跑通第一版;等实体和语料规模上来,再按需替换底层相似度算法。希望这套两阶段思路和两个踩坑记录,能帮你少走点弯路。