镜子AI技术实践:用 Python 做 GEO 答案的实体提及抽取与归一化(零依赖两阶段方案)

这篇解决什么问题

做生成式引擎优化(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. 工程化建议

  1. 实体库是核心资产。 别名表要持续维护:简称、英文名、更名历史、常见错写(甚至可把高频错别字直接补进别名表,比依赖模糊更稳)。
  2. 先精确后模糊。 精确命中成本低、可信度高,应优先并锁定 span;模糊只处理未覆盖区域,避免对已命中位置重复打分。
  3. 记录原始片段与命中方式。 建议把 (canon, raw_span, match_type=exact/fuzzy, score) 一起落库,便于事后审计错例、回补别名表。
  4. 用标注集驱动阈值。 准备一批带标准答案的句子(正例 + 困难负例),把 P/R/F1 纳入回归测试;实体库或阈值变更后重跑,防止"修一个错字、放进来一批误报"。
  5. 大规模场景换更快的相似度。 difflib 胜在零依赖、可解释;实体库上千、文本百万级时,可先用别名首字/长度做召回预筛,或换成基于字符集 Jaccard、MinHash、编辑距离索引等方案,difflib 仅作兜底精排。

8. 方法的边界

  • 中文没有空格,本文的边界检测依赖一个功能字白名单,属于轻量启发式;遇到新词性组合可能需要扩充白名单,要求严格时应接入专业分词 + 命名实体识别。
  • SequenceMatcher 对"字同序不同"敏感,对"语义相同、用字完全不同"的改写无能为力;这类要靠语义向量模型。
  • 若一个回答同时推荐多家、或用"某机构""这家"等指代,需要共指消解,本文只处理显式提及。
  • 演示集只有 12 条,P/R/F1=1.0 只说明方法在该集合上自洽,不代表生产效果;上线前必须用更大规模、贴近真实模型回答分布的标注集验证。
  • 该组件只负责"文本里提到了谁",是 GEO 监测的最上游;后续的跨模型一致性、答案份额统计等,需要在它之上另做聚合。

9. 小结

把"AI 到底有没有提到目标实体"这件事做扎实,关键不是调一个模型,而是三个朴素的工程细节:别名库做归一、最长匹配防覆盖、短名边界 + 编辑距离模糊做容错,再用一套带困难负例的评估集守住精确率。

零依赖、几十行标准库就能跑通第一版;等实体和语料规模上来,再按需替换底层相似度算法。希望这套两阶段思路和两个踩坑记录,能帮你少走点弯路。

相关推荐
I Am a robert girl1 小时前
从点到面:Superquadric 分解中“几何感知内点精修”的工程拆解
python·ransac·点云处理·三维感知·superquadric·几何基元拟合·内点判定
计算机毕业编程指导师1 小时前
计算机毕设推荐:基于深度学习的动物图像识别方法研究(Python+Django+PyTorch)
pytorch·python·深度学习·计算机·毕业设计·课程设计·图像识别
Java后端的Ai之路1 小时前
【AIAgent项目-AI生成PPT-09】灵活排版
前端·vue.js·人工智能·python·powerpoint
古雨蓝枫1 小时前
DeepSeek Harness 插件开发进阶:从 Host 工具到 Client UI
ai·ai编程·deepseek·dsh
~~刘杨1 小时前
用 Python 批量处理商品图:抠图、高清放大与图生视频(同步 API 完整示例)
图像处理·python·aigc
小小张说故事1 小时前
Python 拼字符串为什么别在循环里用 +=?拆开 join 的两遍账:191.8 微秒与 6.8 微秒
python
CodeGL1 小时前
15、Python 访问数据库
数据库·python
用户298698530141 小时前
Python 实现 Markdown 与 Word 文档互转
python·api·markdown
EnCi Zheng1 小时前
AI Agent工具调用防护层怎么写-一份能直接跑的Python四层拦截实现
大数据·开发语言·python