首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >AI 回答里的品牌提及怎么判:别名归一、否定语境和排名位置的解析实现

AI 回答里的品牌提及怎么判:别名归一、否定语境和排名位置的解析实现

原创
作者头像
用户12830378
发布于 2026-10-22 10:51:35
发布于 2026-10-22 10:51:35
1390
举报

做 AI 回答监测,大家讨论得多的是采样:问哪些问题、问几次、哪些平台。我自己踩坑最多的却是采样之后的那一步:拿到一段回答,怎么判断它「提到了某个品牌」,是好话还是坏话,排在第几。这一步的口径一变,提及率能差出好几个点,而且很难察觉。

下面是我现在用的解析规则,写成了一个 200 行左右的 Python 模块,只用标准库。代码里的品牌名都是虚构的,文中的真实数字来自我们拿自家品牌做的监测。

一、一组对不上的数

数据来源:我们在 MaxAEO 里拿自家品牌做的监测,40 个问题,8 个国内 AI 平台,每天按用户界面各问一遍。从 9 月 27 日到 10 月 19 日,共 23 天,拿到 7,352 条有效回答。

最粗的判法是:回答里只要出现品牌的任何写法(中英文名、旧名、域名),就算提到。这样数出来是 676 条。原有的判定结果是 651 条,差了 25 条。我逐条看了这 25 条,原因分成四类:

原因

条数

例子

品牌只出现在链接地址里

11

形如 [\-18](链接) 的角标,链接指向我们的博客文章,正文没提我们

句尾的来源角标

9

「……看趋势比看单周数字重要得多〔域名〕。」——某个平台把参考来源的域名直接拼在句子末尾,复制出来的纯文本就成了这样

只出现了改名前的旧名

4

「国内可用〔旧名〕、某某等」

原数据没给判定

1

那条回答缺判定字段

这四类都没有现成的答案,我是一类一类定的:

  • 只在链接里出现:不算提及,单独记成「被引用、未提及」。
  • 只有域名:不算提及,单独一栏,定期抽查。
  • 只有旧名:单独一栏。要不要并进提及率,看改名多久了。
  • 其余按品牌名判。

二、别名归一

BRANDS 里每个品牌的写法分三类:现用名、旧名、域名。分开写,匹配完才知道这次命中的是哪一类,好按上面的口径分别处理。

归一化做三件事:unicodedata.normalize("NFKC", ...) 把全角字母转成半角(模型偶尔会输出「JiaData」这种写法),统一转小写,合并连续空格。英文别名要加边界,不然 jiadata 会命中 jiadatax。别名里的空格我写成 [\s\-_]*,「Jia Data」「Jia-Data」「JiaData」就都能认出来。

中文里最常见的坑是名字互相包含。假设「甲数据」和「甲数据云」是两家公司,回答里出现「甲数据云」,直接拿「甲数据」去搜也会命中。find_hits() 的处理是:所有别名按长度倒序,先匹配长的,被长别名占用过的字符,短别名不能再用。

代码语言:python
复制
"""解析一条 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 里的,判成「被引用、未提及」。

代码语言:python
复制
# ---------- 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 条回答:

  • 648 条判为提到。原有判定是 651 条,两边重合 646 条。
  • 4 条只有旧名。
  • 13 条只有域名。
  • 7 条只在链接里。

只有域名的 13 条我都看了:

  • 10 条是句尾角标。
  • 3 条确实是在正文里提到我们:一条把域名当工具名,写进了「……、某某、〔域名〕、某某等第三方工具」这样的清单;一条写的是「数据综合自〔域名〕与……相关研究」;还有一条品牌名紧贴在角标「+1」后面,被英文边界规则挡掉了。

所以「只有域名」这一栏我没有直接丢,量不大,人工过一遍就行。

三、否定语境

提到了,也可能是在说坏话。最直接的判法是一句话里同时出现品牌名和否定词就判负面。我在同一批回答里试了一下,提到我们的回答中有 51 条会被这样判成负面。人工看了前 12 条,真在说我们局限的只有 4 条。另外 8 条错在两处。一种是否定词跟品牌无关,比如「比较〔品牌〕和其他平台,不建议只看覆盖几个平台」,否定的是一种做法。另一种是否定的主语是别人,比如「适合已经在用传统 SEO 工具的团队,但不是专门做 AI 问答监测,深度弱于 A、B」(原句里的品牌名换成了 A、B),说的是另一个工具不如 A、B,A、B 反而是强的一方。

tone() 是这么处理的:

  1. 按逗号、句号、分号切成小句,否定只在小句内部生效,不跨句。
  2. 小句里出现「不建议只看」「而不是」「不是简单」这类泛化说法,整句不判。
  3. 遇到「不适合」「只覆盖」「缺少」这类普通否定词,找它前面最近的那个品牌;前面没有,才往后找。
  4. 遇到「弱于」「不如」这类比较词,只把前面的品牌标成局限,后面的不管。
代码语言:python
复制
# ---------- 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

同一批回答用规则版重跑:

  • 被标出来的从 51 条降到 6 条。其中 5 条确实在说局限,比如「〔品牌〕不支持」「海外平台较弱」「〔品牌〕缺少该能力」。错判的 1 条是「优先验证〔品牌〕的归因能力——能否告诉你『为什么 AI 不推荐你』」,这里「不推荐」的对象是读者。
  • 召回差。前面人工挑出的 4 条里,规则版只抓到 1 条。漏掉的 3 条都是委婉说法:「可能不是最全的选择」「不是自动改站」「不是传统 SEO 工具……能力弱」。

所以规则版我只拿来做初筛,准确率还行,召回不够。现在的流程是:

  1. 规则先跑一遍;
  2. 提到品牌的回答,再交给模型按固定提示词判一次语气;
  3. 两边不一致的,人工看。

刚开始每周抽 50 条人工复核模型的判断,一致率稳了再少抽。

四、排名位置

「排第几」麻烦在回答的格式很杂。我统计了同一批数据里,品牌第一次出现的那一行是什么格式,样本是被判提及的 651 条回答:

品牌第一次出现在

条数

占比

正文段落

251

38.6%

无序列表(- / *)

227

34.9%

表格行

104

16.0%

标题行(###)

46

7.1%

带编号的行(1. / 1、)

23

3.5%

「有编号按编号」这条规则,只能覆盖 3.5% 的情况。大部分推荐清单写成无序列表或表格,还有不少品牌只在正文里顺带提一句。

rank() 分三步:

  1. 把连续的同类行合成一个块。同类行指表格行、编号行、列表行、标题行。所有标题行另外再合成一个块,因为标题之间通常隔着正文。
  2. 找推荐清单。在每个块里,按行取「这一行出现的第一个已知品牌」,当作这一行的主角。一个块里至少有两家已知品牌,才算推荐清单。目标品牌在清单里排第几,就是它的名次。
  3. 找不到推荐清单就退回正文,按品牌在全文第一次出现的先后排,依据记成 prose。

名次和依据一起返回。报表里「表格第 2」和「正文第 2」分开统计,不混在一起平均。

代码语言:python
复制
# ---------- 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:名次和依据。

只有域名的命中不参与排名。

代码语言:python
复制
# ---------- 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 条构造的句子试跑。每条对应前面讲的一种情况,品牌都是虚构的:

代码语言:python
复制
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 '-'}")

输出:

代码语言:plaintext
复制
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 兼容接口的扩展参数,用来开启搜索增强,具体以官方文档为准;模型名放在环境变量里。

代码语言:python
复制
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 里看到的不一定一样,这点要心里有数。这个模块只管解析,不管回答从哪来。

七、几个还没定的口径

  • 一条回答里品牌出现多次、语气相反,怎么算。现在是只要有一处被标成局限,就记局限。这样偏严,但不会漏掉负面信息。
  • 表格同一行出现两个品牌。现在只取第一个当这一行的主角,第二个不参与排名。对比类回答(「A 和 B 怎么选」)常有这种行,以后可能要单独处理。
  • 旧名什么时候并进提及率。改名之后,旧名在回答里还会出现:这 23 天里有 8 条回答用了旧名。先单列观察。

代码在 Python 3.11 下跑过。文中的真实数字,是用这个模块加几段统计脚本,对那 7,352 条回答算出来的。


作者在 MaxAEO 做 AI 搜索可见度监测,文中的口径是我们在那里踩坑后定下来的。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

目录
  • 一、一组对不上的数
  • 二、别名归一
  • 三、否定语境
  • 四、排名位置
  • 五、汇总和试跑
  • 六、接到采集上
  • 七、几个还没定的口径
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档