这篇解决什么问题
做生成式引擎优化(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.8425. 结果解读
(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 到底有没有提到目标实体"这件事做扎实,关键不是调一个模型,而是三个朴素的工程细节:别名库做归一、最长匹配防覆盖、短名边界 + 编辑距离模糊做容错,再用一套带困难负例的评估集守住精确率。
零依赖、几十行标准库就能跑通第一版;等实体和语料规模上来,再按需替换底层相似度算法。希望这套两阶段思路和两个踩坑记录,能帮你少走点弯路。