把 AI 回答的来源列表拆成三层:引用位置、URL、域名

0 阅读11分钟

AI 回答的来源列表解析,指的是把一条回答末尾给出的引用清单,拆成引用位置、链接、域名三层,再落成可复算记录的过程。

本文的数字来自笔者所在团队自建的一套 AI 可见度监测库,项目 56,不对应任何对外产品。

结论先放在这里:带被引链接的 815 条记录、按链接去重后的 273 个网页、按域名归并后的 140 个站点,是三个不能互相替代的数(数据源:监测库 citations 表 · 查询日 2026-10-18 · 样本:8959 条抓取记录里带 URL 的 815 条 · 口径:只统计回答里给出网址的引用)。同一批原始记录,换个分母就能拼出另一个结论,所以我把三层分开存、分开报。

问:为什么不能直接数「同一条链接被 AI 引了几次」? **答:**一次回答可以带多个来源,同一条链接又会在不同回答里重复出现。你要的分母是回答数、记录数还是网页数,得先定下来再动手数。

问:三层里最难做的是哪一层? **答:**引用位置。链接和域名都有成熟的字符串处理办法,位置只能靠解析器把来源段切对,切错一次,后面所有跟位置有关的判断都跟着错。

问:域名归并之后能回答什么? **答:**只能回答「哪个站点被引得多」。它不回答「哪篇文章被引得多」,也不回答「哪一家机构被提到」。

来源列表不是一份现成的数据

把一条回答存下来之后,真正难处理的是它的来源部分。我见过的形态至少有四种:有序编号列表、无序短横线列表、带 Markdown 链接的列表、以及只写域名不带协议头的纯文本。有的平台把来源收在正文末尾,前面挂一个「参考来源」或「出处」的标签;有的平台不给独立来源段,只在正文句子里内联标注;还有的平台两者都有,内联一次、末尾再列一次。

这些形态的差别看起来只是排版,实际决定了解析器从哪里开始切。我第一版的做法是对整条回答跑一遍网址正则,把找到的地址都当引用。结果导出的来源条目明显偏多,多出来的那部分几乎都是正文里举例子用的网址,或者回答里引用的第三方页面。分母一脏,后面算出来的任何比例都不可信。

现在的做法是先切段、再逐条取。切段的规则很朴素:在回答里找最后一个来源类标记词,只处理它后面的内容。这样正文里出现的网址不会被误收进来源层。代价是标记词认不出来的时候整条回答的来源会被漏掉,所以我在解析记录里留了一个「来源段是否识别成功」的布尔字段,漏掉的那些可以单独捞出来补规则,而不是混进统计里。

三层各自存什么

三层不是三个步骤,而是三列同时存在的字段。同一条来源在三列上各有各的值,归并动作只作用在自己的那一列上。

层存什么例子归并动作归并之后会丢掉什么
引用位置来源在列表里的序号,以及正文里是否内联标注过第 2 条来源、只在列表出现不归并,原样保留不同平台的列表长度不同,序号不能跨平台直接比较
URL原始字符串,以及归一化之后的字符串带 utm_source 的原串、去掉跟踪参数后的地址去协议差异、去片段、去跟踪参数、去末尾斜杠参数里可能带着平台、时间、渠道这类信息
域名主机名,以及归并后的站点名m.example.org 归到 example.org主机名转小写、按站点归并同一域名下的不同作者、不同栏目被抹平

三列的用途也不一样。URL 层回答「这个页面被引了多少次」,域名层回答「哪个站点被引得多」,引用位置层回答「同一条链接通常出现在来源列表的什么位置」。这三句话听起来接近,实际是三个方向的问题,混在一起写,报告里就会出现互相打架的句子。

引用位置这一层最容易被省掉,因为它看起来没有分析价值。我的判断相反:位置是唯一能区分「主推来源」和「兜底来源」的字段。一条链接出现在来源列表第一条和出现在第十条,对写作者的含义完全不同,而在只存 URL 和域名的表里,这两种情况长得一模一样。

解析器:先切段,再逐条取三层

下面这段在 3 条构造样本上跑,输出是当次真实结果。样本的作用是让你看清解析出的三层长什么样,不是生产库的读数。

import re

ANSWERS = [
    {"platform": "甲", "text": "结论略。\n\n参考来源:\n1. https://www.example.com/a/1?utm_source=chat&utm_medium=app\n2. https://news.example.net/b/2\n3. example.org/c"},
    {"platform": "乙", "text": "结论略。\n\n来源:\n- https://example.com/a/1\n- https://m.example.org/c/"},
    {"platform": "丙", "text": "结论略,正文里提到 example.net/b/2 但没给链接。\n\n引用:\n1) https://www.example.com/a/1?from=share&id=33"},
]

MARKER = re.compile(r"(?:参考来源|参考资料|来源|出处|引用)\s*[::]?\s*")
ITEM = re.compile(r"^(?:[-*+]|\d+[.、)])\s*(.+)$")
URL = re.compile(r"https?://[^\s))>\]]+")

def split_list(text):
    """只在最后一个来源标记之后切列表,正文里出现的网址不进来源层。"""
    ms = list(MARKER.finditer(text))
    if not ms:
        return []
    tail = text[ms[-1].end():]
    out = []
    for line in tail.splitlines():
        m = ITEM.match(line.strip())
        if m:
            out.append(m.group(1).strip())
    return out

def to_row(platform, pos, raw):
    m = URL.search(raw)
    if m:
        url = m.group(0)
    else:
        md = re.search(r"\]\((https?://[^)]+)\)", raw)
        url = md.group(1) if md else "https://" + raw.strip("[]() ")
    host = re.sub(r"^(https?://)", "", url).split("/")[0].lower()
    return {"platform": platform, "pos": pos, "raw": url, "host": host}

rows = []
for a in ANSWERS:
    for i, raw in enumerate(split_list(a["text"]), 1):
        rows.append(to_row(a["platform"], i, raw))

for r in rows:
    print(f'{r["platform"]}  pos={r["pos"]}  host={r["host"]:<20} raw={r["raw"]}')
print(f"\n来源条目 {len(rows)} 条|涉及主机名 {len({r['host'] for r in rows})} 个")

当次输出:

甲  pos=1  host=www.example.com      raw=https://www.example.com/a/1?utm_source=chat&utm_medium=app
甲  pos=2  host=news.example.net     raw=https://news.example.net/b/2
甲  pos=3  host=example.org          raw=https://example.org/c
乙  pos=1  host=example.com          raw=https://example.com/a/1
乙  pos=2  host=m.example.org        raw=https://m.example.org/c/
丙  pos=1  host=www.example.com      raw=https://www.example.com/a/1?from=share&id=33

来源条目 6 条|涉及主机名 5 个

输出里有两处值得单独看。丙那条回答的正文里出现了 example.net/b/2,但它没有出现在来源列表里,所以解析结果里没有这条,这是切段规则在起作用。另一处是丙的来源序号从 1 开始,说明位置是按单条回答重新计数的,不是全局计数,跨回答的位置值不能直接相加。

解析失败的情形我也留了记录,主要有三类:来源标记词被正文里的普通用词命中、编号跨行导致列表被切断、短链没有协议头被当成纯文本。这三类目前都靠「来源段是否识别成功」这个字段兜底,漏掉的回答单独排队补规则,不混进统计。

同一链接在不同平台会长成不同样子

同一个页面被不同 AI 平台引用时,地址往往不是同一个字符串。常见差异有四类:跟踪参数(utm_*、from、spm 这类)、跳转域(平台自己的短链或中转地址)、移动版子域(m.、wap.)、以及大小写和末尾斜杠。前两类是平台加上的,后两类是站点自己就有的写法。

归一化的边界,我最后定成两条:跟踪参数剥掉,其余参数保留。原因是有些站点的内容 ID 就在参数里,?id=33 和 ?id=34 是两个不同的页面,把所有查询串一律剥掉,会把不同文章合并成一个。这一点我早期写错过:当时为了省事,? 之后全部丢弃,结果两篇不同文章的页面在表里变成了同一条,去重数偏低,而当时我没发现。

域名层的归并我放在最后做,因为它不可逆。主机名转小写、去掉 www.、按站点名归并,这三步做完,原始主机名就只留在 URL 层里了。下面这段在同一个 6 条样本上跑,把三级去重一起算出来。

import re
from collections import Counter

ROWS = [
    ("甲", 1, "https://www.example.com/a/1?utm_source=chat&utm_medium=app"),
    ("甲", 2, "https://news.example.net/b/2"),
    ("甲", 3, "https://example.org/c"),
    ("乙", 1, "https://example.com/a/1"),
    ("乙", 2, "https://m.example.org/c/"),
    ("丙", 1, "https://www.example.com/a/1?from=share&id=33"),
]
TRACK = re.compile(r"^(utm_\w+|from|share_\w+|spm|ref)$", re.I)

def normalize(url):
    url = url.strip().lower()
    url = re.sub(r"^http://", "https://", url)
    url = url.split("#")[0]
    head, _, qs = url.partition("?")
    keep = [p for p in qs.split("&") if p and not TRACK.match(p.split("=")[0])]
    head = re.sub(r"/index\.(html|php)$", "/", head)
    head = re.sub(r"^https://www\.", "https://", head)
    head = head.rstrip("/") or head
    return head + ("?" + "&".join(sorted(keep)) if keep else "")

def host_of(url):
    return re.sub(r"^https?://", "", url).split("/")[0]

def root_of(host):
    parts = host.split(".")
    return ".".join(parts[-2:]) if len(parts) >= 2 else host

norm = [normalize(r[2]) for r in ROWS]
urls = Counter(norm)
sites = Counter(root_of(host_of(u)) for u in norm)

print(f"来源条目 {len(ROWS)} 条 -> 归一后网页 {len(set(norm))} 个 -> 主机名 {len({host_of(u) for u in norm})} 个 -> 站点 {len(sites)} 个")
print("\n同一个网页被几条回答引:")
for u, n in urls.most_common():
    print(f"  {n} 次  {u}")
print("\n站点级被引次数(按次数降序):")
for s, n in sites.most_common():
    print(f"  {s:<18} {n} 条")
print("\n原始串与归一后不一致的那几条:")
for _, _, raw in ROWS:
    if normalize(raw) != raw:
        print(f"  {raw}\n    -> {normalize(raw)}")

当次输出:

来源条目 6 条 -> 归一后网页 5 个 -> 主机名 4 个 -> 站点 3 个

同一个网页被几条回答引:
  2 次  https://example.com/a/1
  1 次  https://news.example.net/b/2
  1 次  https://example.org/c
  1 次  https://m.example.org/c
  1 次  https://example.com/a/1?id=33

站点级被引次数(按次数降序):
  example.com        3 条
  example.org        2 条
  example.net        1 条

原始串与归一后不一致的那几条:
  https://www.example.com/a/1?utm_source=chat&utm_medium=app
    -> https://example.com/a/1
  https://m.example.org/c/
    -> https://m.example.org/c
  https://www.example.com/a/1?from=share&id=33
    -> https://example.com/a/1?id=33

注意 example.org/c 和 m.example.org/c 在网页这一层是两个,到站点这一层才合成一个。这段代码里的站点归并用的是「取最后两段」的简化写法,真实环境要接公共后缀表,否则 co.uk 这类后缀会把站点名截错。简化写法只够演示层级关系。

去重口径:815、273、140 为什么必须分开报

生产库上跑同一套逻辑,得到的就是本文开头那三个数。

本段数据口径 数据源:自建 AI 可见度监测库 citations 表(项目 56)| 查询日:2026-10-18 样本:8959 条抓取记录,其中带被引链接 815 条 | 口径:只统计回答里给出 URL 的引用;链接级按归一化后地址去重,域名级按主机名归并,两级分开报、不相加 局限:单一项目的观测,不是行业抽样;仅覆盖 11 家 AI 平台且样本分布不均;库是活库,以上为 2026-10-18 快照

815 条记录落到 273 个网页,平均每个网页被引约 3.0 次。这个平均数是均摊出来的假象,真实分布非常偏:被引 6 次及以上的域名有 25 个,合计 601 次,占全部被引次数的 73.7%;只被引过 1 次的域名有 62 个。前 10 个域名合计 469 次,占 57.5%;前 30 个域名合计 623 次,占 76.4%。

这四个数放在一起才说明问题。只看域名总数 140,你会以为信源很分散;加上前 10 占 57.5% 这一条,才知道实际高度集中。反过来,只看前 10 的合计,你又不知道长尾有多长,62 个只被引一次的域名补上了这一块。

分母的坑在这几个数之间特别容易踩。815 是记录数,273 是网页数,140 是域名数,三个数的分母各不相同,任何两个相除得到的比例都要写清是拿谁做分子。我见过把 273 除以 140 当成「平均每站多少篇」的写法,那个数没有意义,因为一个网页可能被多个域名承载,一个域名也可能对应多个网页,两边不是同一个集合。

域名归并会丢掉什么

归并是为了让「同一家站点」只出现一次,代价是抹掉了域名层以下的差异。我把丢掉的东西分五类写出来,用的时候心里有数。

移动版与主站被并成一个站点。m.example.org 和 example.org 归并之后,你看不出移动入口被引了多少,而移动端来源在内容分发上的含义和主站并不一样。

同一域名下的不同作者被抹平。聚合类平台一个域名下面有大量作者页,域名层只能告诉你「这家平台被引得多」,说不出「是哪位作者的内容被引」。

跳转域会把真实落地站点盖掉。平台用短链或中转地址时,域名层统计到的是跳跃域,不是最终页面所在的站点。我的处理是先按落地页统计一次,再按原始域统计一次,两份结果都留着。

企业信息类平台一个域名下挂着很多机构页。域名层统计出「这家企业信息平台被引了很多次」,但回答里真正被提到的是哪一家机构,域名层看不出来,得回 URL 层的具体页面。

子域本身可能承载独立含义。有些站点的企业子域是单独发布的,归并到根域之后,多个客户站点挤在同一个域名里,站点层的被引次数会被放大。

一句话概括:域名层回答的是「哪个站点被引得多」,不回答「哪篇文章被引得多」,更不回答「谁被提到」。要回答后两个问题,得回 URL 层和引用位置层。

可自检清单:三步

这套流程不依赖特定工具,拿你自己的库跑一遍就能对上。

第一步,先定分母。把抓取记录总数、带被引链接的记录数、去重后的网页数、归并后的站点数四个数并排拉出来,确认哪个数配哪个比例。这一步做完,后面无论报什么,都能说清分母是谁。

第二步,切准来源段。对每条回答先找来源标记,只处理标记之后的内容,同时记录「来源段是否识别成功」。抽十条人工核对,看解析出来的条目和原回答的来源列表是否一一对应,对不上就补规则。

第三步,先归一再归并,两级各报一次。归一化只剥约定的跟踪参数,其余参数保留;域名归并放在最后,且保留原始主机名。两级数字分开报,不相加,也不互相换算。

三步的顺序不能乱:分母定了才有意义,来源段切对了数据才干净,归一在前归并在后,中间任何一步提前,后面都要返工。

边界与不承诺

所有读数都来自一套自建监测库的自身观测,不是行业抽样,也不是对任何平台的评价。样本覆盖 11 家 AI 平台,但分布不均:引用样本较多的六家是元宝 206、知乎直答 176、DeepSeek 163、智谱清言 119、豆包 83、百度AI 31(查询日 2026-10-18);其余几家样本少于 20 条,只登记、不解读。

我只统计回答里给出网址的引用。只在正文里提到站名、没有给链接的情况属于另一套口径,两组不合并、不相加。监测库是活库,同一个查询日重跑,存量和派生指标都可能变,所以跨批次比较要谨慎,我没有把不同批次的数量并列着报的习惯。

这套解析能支撑的是「哪些页面被引、被哪个平台引、在来源列表的第几位」,推不出「做了某个动作所以被引」。它也不回答曝光、流量、转化,更不回答将来会怎样。文中每个数字都带着数据源、查询日、口径和样本量,没带条件的数字我不写。真要拿它做判断,请以你自己跑出来的读数为准。

本文部分内容包含 AI 辅助创作,文中代码与数据经人工实测与复核。