做 AI 回答监测,大家讨论得多的是采样:问哪些问题、问几次、哪些平台。我自己踩坑最多的却是采样之后的那一步:拿到一段回答,怎么判断它「提到了某个品牌」,是好话还是坏话,排在第几。这一步的口径一变,提及率能差出好几个点,而且很难察觉。
下面是我现在用的解析规则,写成了一个 200 行左右的 Python 模块,只用标准库。代码里的品牌名都是虚构的,文中的真实数字来自我们拿自家品牌做的监测。
数据来源:我们在 MaxAEO 里拿自家品牌做的监测,40 个问题,8 个国内 AI 平台,每天按用户界面各问一遍。从 9 月 27 日到 10 月 19 日,共 23 天,拿到 7,352 条有效回答。
最粗的判法是:回答里只要出现品牌的任何写法(中英文名、旧名、域名),就算提到。这样数出来是 676 条。原有的判定结果是 651 条,差了 25 条。我逐条看了这 25 条,原因分成四类:
原因 | 条数 | 例子 |
|---|---|---|
品牌只出现在链接地址里 | 11 | 形如 |
句尾的来源角标 | 9 | 「……看趋势比看单周数字重要得多〔域名〕。」——某个平台把参考来源的域名直接拼在句子末尾,复制出来的纯文本就成了这样 |
只出现了改名前的旧名 | 4 | 「国内可用〔旧名〕、某某等」 |
原数据没给判定 | 1 | 那条回答缺判定字段 |
这四类都没有现成的答案,我是一类一类定的:
BRANDS 里每个品牌的写法分三类:现用名、旧名、域名。分开写,匹配完才知道这次命中的是哪一类,好按上面的口径分别处理。
归一化做三件事:unicodedata.normalize("NFKC", ...) 把全角字母转成半角(模型偶尔会输出「JiaData」这种写法),统一转小写,合并连续空格。英文别名要加边界,不然 jiadata 会命中 jiadatax。别名里的空格我写成 [\s\-_]*,「Jia Data」「Jia-Data」「JiaData」就都能认出来。
中文里最常见的坑是名字互相包含。假设「甲数据」和「甲数据云」是两家公司,回答里出现「甲数据云」,直接拿「甲数据」去搜也会命中。find_hits() 的处理是:所有别名按长度倒序,先匹配长的,被长别名占用过的字符,短别名不能再用。
"""解析一条 AI 回答:提到了哪些品牌、怎么提到的、语气、排第几。"""
import re, unicodedata
from dataclasses import dataclass, field
# ---------- 1. 别名表 ----------
# names: 现用名(含中英文、简称);old: 改名前的旧名;domains: 官网域名
BRANDS = {
"甲数据": {"names": ["甲数据", "JiaData", "Jia Data"], "old": ["甲分析"], "domains": ["jiadata.example"]},
"甲数据云": {"names": ["甲数据云"], "old": [], "domains": []}, # 另一家公司,名字包含「甲数据」
"乙监测": {"names": ["乙监测", "YiWatch"], "old": [], "domains": ["yiwatch.example"]},
}
def norm(s):
s = unicodedata.normalize("NFKC", s) # 全角转半角:JiaData -> JiaData
return re.sub(r"[ \t]+", " ", s).lower()
def _alias_pattern(alias):
p = re.escape(norm(alias)).replace(r"\ ", r"[\s\-_]*") # Jia Data / Jia-Data / jiadata
if re.fullmatch(r"[a-z0-9.\-_ ]+", norm(alias)):
p = rf"(?<![a-z0-9]){p}(?![a-z0-9])" # jiadata 不命中 jiadatax
return p
def build_alias_index(brands):
"""展开成 (别名, 品牌, 类型) 列表,按别名长度倒序,长的先匹配"""
idx = []
for b, conf in brands.items():
for kind in ("names", "old", "domains"):
for a in conf[kind]:
idx.append((a, b, kind))
idx.sort(key=lambda x: len(norm(x[0])), reverse=True)
return [(re.compile(_alias_pattern(a)), b, kind) for a, b, kind in idx]匹配之前还要先清一遍文本:Markdown 链接的地址剥掉,只留锚文字;各家平台的引用角标去掉,比如 【turn0search5】、[^3]、[1,2]。剥出来的 URL 单独留着,品牌域名只出现在 URL 里的,判成「被引用、未提及」。
# ---------- 2. 先把链接和角标拿掉 ----------
MD_LINK = re.compile(r"\[([^\]]*)\]\((?:https?://)?[^)\s]+\)")
BARE_URL = re.compile(r"https?://[^\s<>()\[\]【】\"',。;、]+")
CITE_MARK = re.compile(r"【[^】]{0,40}】|\[\^?\\?-?\d{1,3}\]|\[\d+(?:,\s*\d+)*\]")
def clean(text):
urls = BARE_URL.findall(text) + re.findall(r"\]\(((?:https?://)?[^)\s]+)\)", text)
text = MD_LINK.sub(lambda m: m.group(1), text)
text = BARE_URL.sub(" ", text)
text = CITE_MARK.sub("", text)
return norm(text), [norm(u) for u in urls]
@dataclass
class Hit:
brand: str
kind: str # names / old / domains
start: int
end: int
def find_hits(text, index):
"""最长优先,命中过的位置不再给更短的别名用"""
taken = [False] * len(text)
hits = []
for pat, brand, kind in index:
for m in pat.finditer(text):
if any(taken[m.start():m.end()]):
continue
for i in range(m.start(), m.end()):
taken[i] = True
hits.append(Hit(brand, kind, m.start(), m.end()))
return sorted(hits, key=lambda h: h.start)拿这套规则重跑那 7,352 条回答:
只有域名的 13 条我都看了:
所以「只有域名」这一栏我没有直接丢,量不大,人工过一遍就行。
提到了,也可能是在说坏话。最直接的判法是一句话里同时出现品牌名和否定词就判负面。我在同一批回答里试了一下,提到我们的回答中有 51 条会被这样判成负面。人工看了前 12 条,真在说我们局限的只有 4 条。另外 8 条错在两处。一种是否定词跟品牌无关,比如「比较〔品牌〕和其他平台,不建议只看覆盖几个平台」,否定的是一种做法。另一种是否定的主语是别人,比如「适合已经在用传统 SEO 工具的团队,但不是专门做 AI 问答监测,深度弱于 A、B」(原句里的品牌名换成了 A、B),说的是另一个工具不如 A、B,A、B 反而是强的一方。
tone() 是这么处理的:
# ---------- 3. 语气:只认「主语是这个品牌」的否定 ----------
GENERIC = ["不建议只看", "而不是", "不是简单", "不只是", "不仅", "无论"]
NEG = ["不推荐", "不建议选", "不适合", "不支持", "只覆盖", "没有覆盖", "缺少", "缺乏", "较弱", "存疑", "谨慎选择"]
COMPARE = ["弱于", "不如", "逊于", "低于"] # 「X 弱于 Y」:X 是弱的一方,Y 不算负面
CLAUSE_SPLIT = re.compile(r"[。!?!?;;\n,,]")
def tone(text, hits):
"""返回 {品牌: 'limit'},只标负面/局限,其余当中性"""
out = {}
pos = 0
for clause in CLAUSE_SPLIT.split(text):
start, end = pos, pos + len(clause)
pos = end + 1
inside = [h for h in hits if start <= h.start < end]
if not inside or any(g in clause for g in GENERIC):
continue
for cue in NEG + COMPARE:
i = clause.find(cue)
if i < 0:
continue
before = [h.brand for h in inside if h.end - start <= i]
after = [h.brand for h in inside if h.start - start >= i + len(cue)]
if cue in COMPARE:
target = before[-1:]
else:
target = before[-1:] or after[:1]
for b in target:
out[b] = "limit"
return out同一批回答用规则版重跑:
所以规则版我只拿来做初筛,准确率还行,召回不够。现在的流程是:
刚开始每周抽 50 条人工复核模型的判断,一致率稳了再少抽。
「排第几」麻烦在回答的格式很杂。我统计了同一批数据里,品牌第一次出现的那一行是什么格式,样本是被判提及的 651 条回答:
品牌第一次出现在 | 条数 | 占比 |
|---|---|---|
正文段落 | 251 | 38.6% |
无序列表( | 227 | 34.9% |
表格行 | 104 | 16.0% |
标题行( | 46 | 7.1% |
带编号的行( | 23 | 3.5% |
「有编号按编号」这条规则,只能覆盖 3.5% 的情况。大部分推荐清单写成无序列表或表格,还有不少品牌只在正文里顺带提一句。
rank() 分三步:
prose。名次和依据一起返回。报表里「表格第 2」和「正文第 2」分开统计,不混在一起平均。
# ---------- 4. 排名:先找「推荐块」,再数先后 ----------
TABLE_ROW = re.compile(r"^\s*\|(.+)\|\s*$")
NUMBERED = re.compile(r"^\s*(?:#{1,6}\s*)?(?:\*\*)?\s*(\d{1,2})\s*[.、))]")
BULLET = re.compile(r"^\s*[-*•]\s+")
HEADING = re.compile(r"^\s*#{1,6}\s+")
def rank(text, hits, brand):
"""返回 (名次, 依据)。名次 = 该品牌在同一个块里是第几个出现的已知品牌"""
lines = text.split("\n")
offsets, p = [], 0
for ln in lines:
offsets.append(p)
p += len(ln) + 1
def brands_in(i):
s, e = offsets[i], offsets[i] + len(lines[i])
seen = []
for h in hits:
if s <= h.start < e and h.kind != "domains" and h.brand not in seen:
seen.append(h.brand)
return seen
# 连续的同类行算一个块:表格行 / 编号行 / 列表行 / 标题行
def kind_of(ln):
if TABLE_ROW.match(ln):
return "table"
if NUMBERED.match(ln):
return "numbered"
if BULLET.match(ln):
return "bullet"
if HEADING.match(ln):
return "heading"
return None
blocks, cur, cur_kind = [], [], None
for i, ln in enumerate(lines):
k = kind_of(ln)
if k and (k == cur_kind or (k == "heading" and cur_kind == "heading")):
cur.append(i)
else:
if cur:
blocks.append((cur_kind, cur))
cur, cur_kind = ([i], k) if k else ([], None)
if cur:
blocks.append((cur_kind, cur))
# 标题之间隔着正文,单独再收一遍:所有标题行合成一个块
heads = [i for i, ln in enumerate(lines) if HEADING.match(ln)]
if heads:
blocks.append(("heading", heads))
for k, idxs in blocks:
order = []
for i in idxs:
for b in brands_in(i)[:1]: # 每行只取第一个品牌,算这一行的「主角」
if b not in order:
order.append(b)
if len(order) >= 2 and brand in order: # 块里至少两家已知品牌才算推荐清单
return order.index(brand) + 1, k
order = []
for h in hits:
if h.kind != "domains" and h.brand not in order:
order.append(h.brand)
return (order.index(brand) + 1, "prose") if brand in order else (None, None)这个算法的毛病是只认识别名表里的品牌。回答里出现了表里没有的品牌,算出来的名次就会比实际靠前。所以竞品要尽量列全;列不全,名次就只能当相对参考,跨时期比较时别名表也不能动。
analyze() 把上面几步串起来,输出四样东西:
mentioned:每个品牌命中的是哪一类写法;url_only:只在链接里出现的品牌;tone:语气;rank:名次和依据。只有域名的命中不参与排名。
# ---------- 5. 汇总 ----------
@dataclass
class Result:
mentioned: dict = field(default_factory=dict) # 品牌 -> names / old / domain_only
url_only: set = field(default_factory=set)
tone: dict = field(default_factory=dict)
rank: dict = field(default_factory=dict)
def analyze(answer, index=None, brands=BRANDS):
index = index or build_alias_index(brands)
text, urls = clean(answer)
hits = find_hits(text, index)
res = Result()
for b in brands:
kinds = {h.kind for h in hits if h.brand == b}
if "names" in kinds:
res.mentioned[b] = "names"
elif "old" in kinds:
res.mentioned[b] = "old"
elif "domains" in kinds:
res.mentioned[b] = "domain_only" # 只有域名,多半是来源角标,单列不算提及
elif any(d in u for u in urls for d in brands[b]["domains"]):
res.url_only.add(b)
named = [h for h in hits if h.kind != "domains"]
res.tone = tone(text, named)
for b, k in res.mentioned.items():
if k != "domain_only":
res.rank[b] = rank(text, hits, b)
return res用 11 条构造的句子试跑。每条对应前面讲的一种情况,品牌都是虚构的:
from mention import analyze
samples = {
"A 全角+括号别名": "推荐 JiaData(甲数据),上手快。",
"B 只在链接里": "提及率的算法可以参考这篇[对比](https://www.jiadata.example/blog/rate)。",
"C 来源角标": "看趋势比看单周数字重要得多jiadata.example。周报首页放 6 项。",
"D 名字被包含": "甲数据云的私有化部署做得不错。",
"E 旧名": "国内可用甲分析、乙监测等工具。",
"F 泛化否定": "比较甲数据和其他平台,不建议只看覆盖几个平台。",
"G 比较句": "适合已经在用传统 SEO 工具的团队,但深度弱于乙监测、甲数据。",
"H 真否定": "如果主要看国内大模型,甲数据只覆盖海外平台,不适合。",
"I 表格排名": "| 工具 | 特点 |\n|---|---|\n| 乙监测 | 覆盖广 |\n| 甲数据 | 有原文回放 |",
"J 编号排名": "1. **乙监测**:老牌\n2. **甲数据**:按天采样\n3. 其他",
"K 正文顺序": "常见的有乙监测,也有人用甲数据。",
}
for k, t in samples.items():
r = analyze(t)
print(f"{k:<10} 提及={r.mentioned} 仅链接={r.url_only or '-'} 语气={r.tone or '-'} 排名={r.rank or '-'}")输出:
A 全角+括号别名 提及={'甲数据': 'names'} 仅链接=- 语气=- 排名={'甲数据': (1, 'prose')}
B 只在链接里 提及={} 仅链接={'甲数据'} 语气=- 排名=-
C 来源角标 提及={'甲数据': 'domain_only'} 仅链接=- 语气=- 排名=-
D 名字被包含 提及={'甲数据云': 'names'} 仅链接=- 语气=- 排名={'甲数据云': (1, 'prose')}
E 旧名 提及={'甲数据': 'old', '乙监测': 'names'} 仅链接=- 语气=- 排名={'甲数据': (1, 'prose'), '乙监测': (2, 'prose')}
F 泛化否定 提及={'甲数据': 'names'} 仅链接=- 语气=- 排名={'甲数据': (1, 'prose')}
G 比较句 提及={'甲数据': 'names', '乙监测': 'names'} 仅链接=- 语气=- 排名={'甲数据': (2, 'prose'), '乙监测': (1, 'prose')}
H 真否定 提及={'甲数据': 'names'} 仅链接=- 语气={'甲数据': 'limit'} 排名={'甲数据': (1, 'prose')}
I 表格排名 提及={'甲数据': 'names', '乙监测': 'names'} 仅链接=- 语气=- 排名={'甲数据': (2, 'table'), '乙监测': (1, 'table')}
J 编号排名 提及={'甲数据': 'names', '乙监测': 'names'} 仅链接=- 语气=- 排名={'甲数据': (2, 'numbered'), '乙监测': (1, 'numbered')}
K 正文顺序 提及={'甲数据': 'names', '乙监测': 'names'} 仅链接=- 语气=- 排名={'甲数据': (2, 'prose'), '乙监测': (1, 'prose')}对一下:A 的全角写法认出来了;B 只在链接里,进了「仅链接」,没算提及;C 的句尾角标判成 domain_only;D 的「甲数据云」没被当成「甲数据」;E 的旧名单列成 old;F 的泛化否定没误判;G 里「弱于」后面的两家都没被标负面;H 的真否定标出来了;I、J 按块内顺序给名次,依据分别是表格和编号;K 没有清单,按正文先后排。
解析和采集是分开的。任何 OpenAI 兼容的接口拿到回答后,都可以直接喂给 analyze()。我这边接的是混元。enable_enhancement 是混元 OpenAI 兼容接口的扩展参数,用来开启搜索增强,具体以官方文档为准;模型名放在环境变量里。
import json, os, sys
from openai import OpenAI
from mention import analyze, build_alias_index, BRANDS
client = OpenAI(api_key=os.environ["HUNYUAN_API_KEY"],
base_url=os.getenv("HUNYUAN_BASE_URL", "https://api.hunyuan.cloud.tencent.com/v1"))
index = build_alias_index(BRANDS)
for q in [l.strip() for l in open(sys.argv[1], encoding="utf-8") if l.strip()]:
resp = client.chat.completions.create(
model=os.getenv("HUNYUAN_MODEL", "hunyuan-turbos-latest"),
messages=[{"role": "user", "content": q}],
extra_body={"enable_enhancement": True}, # 混元的扩展参数:开启搜索增强
)
r = analyze(resp.choices[0].message.content or "", index)
print(json.dumps({"q": q, "mentioned": r.mentioned, "url_only": sorted(r.url_only),
"tone": r.tone, "rank": r.rank}, ensure_ascii=False))API 的回答和用户在 App 里看到的不一定一样,这点要心里有数。这个模块只管解析,不管回答从哪来。
代码在 Python 3.11 下跑过。文中的真实数字,是用这个模块加几段统计脚本,对那 7,352 条回答算出来的。
作者在 MaxAEO 做 AI 搜索可见度监测,文中的口径是我们在那里踩坑后定下来的。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。