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

发布时间:2026/10/26 10:23:19
镜子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, keylen, reverseTrue): 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(keylambda 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, fuzzyTrue, thFUZZY_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(fuzzyTrue, thFUZZY_TH): tp fp fn 0; rows [] for text, gold in EVAL: pred set(extract_entities(text, fuzzyfuzzy, thth)) 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(fuzzyTrue, th0.80) print( 逐条结果精确模糊, th0.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(fuzzyFalse) 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(fuzzyTrue, thth) print(fth{th:.2f} P{pp:.3f} R{rr:.3f} F1{ff:.3f})4. 运行结果 逐条结果精确模糊, th0.80 [OK ] 标准[极客星, 核桃编程] 抽取[极客星, 核桃编程] [OK ] 标准[小码王, 极客星] 抽取[小码王, 极客星] [OK ] 标准[编程猫] 抽取[编程猫] [OK ] 标准[极客星, 核桃编程] 抽取[极客星, 核桃编程] [OK ] 标准[小码王] 抽取[小码王] [OK ] 标准[核桃编程] 抽取[核桃编程] [OK ] 标准[编程猫] 抽取[编程猫] [OK ] 标准[极客星] 抽取[极客星] [OK ] 标准[] 抽取[] [OK ] 标准[] 抽取[] [OK ] 标准[] 抽取[] [OK ] 标准[] 抽取[] 精确模糊 : P1.000 R1.000 F11.000 仅精确匹配: P1.000 R0.727 F10.842 阈值敏感性 th0.70 P0.833 R0.909 F10.870 th0.75 P1.000 R1.000 F11.000 th0.80 P1.000 R1.000 F11.000 th0.85 P1.000 R0.818 F10.900 th0.90 P1.000 R0.727 F10.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_typeexact/fuzzy, score)一起落库便于事后审计错例、回补别名表。用标注集驱动阈值。准备一批带标准答案的句子正例 困难负例把 P/R/F1 纳入回归测试实体库或阈值变更后重跑防止修一个错字、放进来一批误报。大规模场景换更快的相似度。difflib胜在零依赖、可解释实体库上千、文本百万级时可先用别名首字/长度做召回预筛或换成基于字符集 Jaccard、MinHash、编辑距离索引等方案difflib 仅作兜底精排。8. 方法的边界中文没有空格本文的边界检测依赖一个功能字白名单属于轻量启发式遇到新词性组合可能需要扩充白名单要求严格时应接入专业分词 命名实体识别。SequenceMatcher对字同序不同敏感对语义相同、用字完全不同的改写无能为力这类要靠语义向量模型。若一个回答同时推荐多家、或用某机构这家等指代需要共指消解本文只处理显式提及。演示集只有 12 条P/R/F11.0 只说明方法在该集合上自洽不代表生产效果上线前必须用更大规模、贴近真实模型回答分布的标注集验证。该组件只负责文本里提到了谁是 GEO 监测的最上游后续的跨模型一致性、答案份额统计等需要在它之上另做聚合。9. 小结把AI 到底有没有提到目标实体这件事做扎实关键不是调一个模型而是三个朴素的工程细节别名库做归一、最长匹配防覆盖、短名边界 编辑距离模糊做容错再用一套带困难负例的评估集守住精确率。零依赖、几十行标准库就能跑通第一版等实体和语料规模上来再按需替换底层相似度算法。希望这套两阶段思路和两个踩坑记录能帮你少走点弯路。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询