/4,264 字/阅读约 15 分钟

什么才算「同一件事」:热点聚类的三轮试错,与把判同交给模型之后发生的事

把「同一件事的多方报道」聚成一条时间线,听起来是道相似度题,实际做下来:纯余弦区分不了「同一领域」和「同一件事」(语料两两余弦最大仅 0.706),加实体词会把 Arxiv 每日批次串成 10 条大簇,最后只能退回最保守的口径。v2 把判同交给模型,第一次评测就被教做人——模型错判时置信度同样是 0.95+,校准阈值救不了。而上线之后最反直觉的发现是:判同明明准了,缺的却是「有人跟它撞题」。

眠月眠月
joker1point

热点榜的第一名是这样的:3 家独立源在报道同一件事——官方原文、一家英文媒体、一家中文媒体,三个标题措辞完全不同,被聚成了一条时间线,最早一条与最新一条相差 14 小时。

先把这套方案用大白话说清楚。VDB-News 是一条自动追踪 AI 前沿的情报管道:它盯着 19 路信源(官方博客、Arxiv、几家科技媒体),每天持续收进文章——48 小时窗口里通常有一百多篇。而同一件事,往往有好几篇报道:官方发了通告,英文媒体跟进解读,中文站再转述一遍。不做处理,榜单和日报上同一件事就会反复刷屏。

所谓「聚类」(内部叫事件聚簇),就是把说同一件事的多篇报道合成一条事件。合成后的一条事件自带三样东西:一个主标题、一条按时间排好的来源清单(哪家、什么时候报的)、一个「N 个独立来源」的计数。所有事件再按热度排序——热度说穿了就是「多少家独立信源在说它、最近还在不在说」:越多人说、越新鲜,排得越高。你在 VDB-News 首页看到的那个「热点」榜单,背后就是这套东西。

方案落到工程上只有两步:第一步,用向量找出「可能相关」的候选——快、便宜,但经常认错,分不清「同一个领域」和「同一件事」;第二步,判断这几篇到底算不算同一件事——难、需要模型,但只有它能做最终裁决。这篇文章讲的全部是第二步:它让我们经历了三轮失败、一次保守上线、一轮被评测教做人的提示词返工,最后还发现了一个与算法无关的前提条件。

本文想记录的不是「我们最后做对了」,而是一路上三个反直觉的发现:

  1. 余弦相似度区分不了「同一领域」和「同一件事」——在我们的语料上,这两个分布的差距小到可以忽略;
  2. 模型判错的时候,置信度同样是 0.95+——它不是在犹豫,是自信地错,所以「校准阈值」不是解法;
  3. 判同准了之后,最常见的情况是没有东西可判——聚类有没有料,首先取决于你的信源里有没有人跟别人撞题。

一、起点:难的不是「找相似的」,而是「判同一件事」

体系里原来的去重做到 URL 哈希级——同一个链接不会重复入库,但同一件事被 N 家媒体报道,就是 N 篇文章。榜单和日报上,一条新闻并排出现三次。

参照系是开源项目 AIHOT,它最核心的机制正是这个:把「同一件事的多方报道」聚成一个事件、按独立信源数与时间衰减排热度。机制值得抄,但判同的口径必须自己做——因为「像」和「是」是两码事:

  • Gemma 4 released 和 Gemma 4 jailbroken——词面高度重叠,两件事;
  • Introducing Whisper 和 OpenAI 的语音识别模型 Whisper 简介——词面几乎不同,同一件事。

聚类的全部难度,都在这两句话里。

二、三轮试错:从余弦到「最笨」的口径

第一轮:纯余弦 ✗

最直觉的做法:算文章两两的向量余弦,超过阈值就合并。先做只读量化(590 篇文章、17 万对,秒级扫完):

指标值判读
两两余弦 · 最大0.706全库最高的一对都不算"很像"
两两余弦 · 中位0.433语料高度分散
「同一领域」对照对0.65 ~ 0.71与真同一件事完全重叠

最后一行是致命的:同领域的两篇文章(比如同一天上线的两篇不同论文)余弦就有 0.65~0.71——把阈值定到 0.82,同一件事永远合不了;定到 0.62,把不同的事搅在一起。"同一领域"和"同一件事"在这个语料里没有可分界面。

第二轮:余弦 + 标题实体词 ✗

更聪明一点的思路:光余弦不行,那就加上标题里的实体词重叠(把窗口内高频词按 df 过滤掉,留下有辨识度的词),两个信号都满足才合并。

实测结果很有教育意义:Arxiv 每日批次被串成了一个 10 条的大簇。一天几十篇论文、摘要风格雷同,attention / global / generation 这类词经过 df 过滤之后仍然"不够高频",于是把一批互不相关的论文串成了一件事。

失败原因摊开看很朴素:判"不同措辞的同一件事",和判"同领域的几件不同的事",在词与向量的层面用的是同一批信号。前者要泛化(换个说法也能认出来),后者要区分(词很像也要拆开),一套特征做不到两个方向。

第三轮:标题「近乎重复」才算同一件事 ✓(但能力很有限)

退到最保守的口径:只有标题几乎一致的重复报道(同题转载),才算同一件事;余弦只作"语义上不冲突"的旁证(0.62)。

判同规则就两条:

python
def title_similarity(a, b):
    ta, tb = normalize(a), normalize(b)        # 小写 + 去标点空白
    if len(short) >= 3 and short in long_:     # 短标题被长标题包含
        return 1.0                             # → 直接算同一件事
    return difflib.SequenceMatcher(None, ta, tb).ratio()   # 否则字面比值 ≥ 0.75

这个口径的代价必须说清楚:不同措辞的同一件事,v1 永远合不出来。它的价值在于先让系统上线、拿到真实数据,同时不给展示卡送错事件。

宁愿漏合并,也不送错事件

榜单位置有限,一个错误合并(两件事并成一件、时间线错乱)的伤害,大于两个漏合并(同一件事分两条、读者自己看得出)。所以 v1 的整个取向是 precision 优先——这在"宁可多召"的检索世界里是个反直觉的转向。

实现里还有两个只有踩过才写得出来的细节:

  • 排序要按归一化标题,而不是时间。贪心聚簇对"谁先来"敏感:「热点甲」「热点甲(跟进)」「热点甲(叠加)」这类同题系列,纯时间序会因为两两相似度不闭合而漏合并(实测踩过)。按归一化标题排序后同题自然相邻;事件的头条仍然取最新那篇。
  • 判同看"与簇内某个成员",而不是"与最像的成员"。余弦在小数第 4 位上的抖动,会让判同结果随数据随机抖动——标题明明是包含关系,只因"最像成员"是簇里另一篇而漏掉合并(也实测踩过)。

性能上倒是没有悬念:几百篇 × 每篇一条向量的相似度矩阵,用 numpy 矩阵乘是毫秒级(纯 Python 逐维乘加要上百秒——同样的数学,差两个数量级)。

三、v1 上线:热度不是数数

聚完簇,下一个问题是排序。热点榜的热度公式:

text
热度 = (独立源数 + 0.25 × 同源重复数 + 关注词加权) × 0.5^(距最近一次报道的小时数 ÷ 24)

三个设计决策各有一段理由:

  • 同源重复只值 0.25:同一家媒体连发三篇跟进,不等于三家都在说这件事。信源是"独立见证者"的计量单位,不是文章数。
  • 24 小时半衰期:一天前的热度减半、两天前剩四分之一——对齐 AIHOT 的口径。旧事件自然下沉,不需要人工清理。
  • 关注词加权:这是被语料逼出来的。信源以一手源为主的时候,"多方报道"太少,只看独立源数会让榜单退化成"最新 10 条"。加上"命中我关注方向"的加权后,榜单才真的在回答"我关心的方向最近有什么"。

关注词这一项上线当天就翻过车:兴趣画像里混进了 ai 这种两个拉丁字母的词,它几乎命中所有标题——加权变成了纯噪声,榜单第一名是一个跟关注方向毫无关系的仓库。修法不大但很讲究:不算词频,算词形——

python
def _useful_term(t):    # 「ai」「ml」不够格,「显卡」「asr」可以
    return cjk_count(t) >= 2 or len(t) >= 3

工程上还有几处"防呆"值得一提,它们都不影响正确性,但决定了系统能不能无人值守地活:

  • 每次重建都是窗口内全量重建(幂等,不做增量)——出问题直接重跑,不用修状态;
  • 事件身份 event_key 取最早那篇的 URL——只要事件还在 48 小时窗口内,反复重建时 key 稳定,趋势线才能续上;
  • 窗口为空时不动已有的榜——抓取刚启动、向量库异常都会造成空窗口,一次异常不该把首页清空。

四、v2:把判同交给模型,然后被评测教做人

v1 的能力上限卡在"同题转载"上。同一天,我们把赌注押到模型判同上——这也是 AIHOT 验证过的路线:向量粗筛候选对 → 一次调用批量判关系 → 按置信度归并。

关键设计是把判同从"像不像"变成四类关系:

关系定义是否合并
SAME_OCCURRENCE同一次真实发生(官方原文 + 各家报道)✅
SAME_STORY同一事件线的直接进展(上架、定价、回应)✅
UNRELATED不同的事——主体、产品、话题相同也算不同❌
ROUNDUP其中一方是汇总/盘点稿❌

UNRELATED 的定义是故意写重的——"主体、产品、话题相同也算不同的事",正是把 Arxiv 批次误合并的教训直接写进了提示词。

流程上:向量粗筛出跨簇候选对(相似度 ≥ 0.6,单轮最多 24 对)→ 每 8 对一批发给模型 → 只有 SAME_* 且置信度 ≥ 0.8 才真的合并。

评测两轮:0.850 → 1.000

判同是"模型任务",阈值不能拍脑袋。我们建了一张 20 对的标注集(覆盖四种关系与已知误判模式),扫了一遍置信度门槛:

轮次结果发现
第 1 次准确 0.850,门槛 0.8 下 FP=2三条:① 教程类(「用 GPT-6 做数据分析的 5 个技巧」)被并进了发布事件;② 我的标注和口径打架——"同一发布的后续评测"该算 SAME_STORY 还是 UNRELATED,标注集自己没想清;③ 模型错判时置信度也高(0.95+)
第 2 次准确 1.000,门槛 0.5~0.9 全部 FP=0提示词补三条规则 + 修正 2 条标注;两个 Arxiv 误合并案例被逐条判成 UNRELATED ✓

第 1 轮的第三条发现是这次评测里最值钱的一条:

置信度高不是保佑,是警报

模型错的那两对,置信度是 0.95 和 0.97——它不是"拿不准",是"自信地错"。这意味着校准阈值不是解法:门槛调到多高都会把它留下。要让模型在错的路上停下来,只有改两样东西——它看到的定义(提示词),和它对齐的目标(标注)。

第 2 轮的改动全部在提示词里(模型没换),比如补上了这几条:

  • 教程、经验分享、观点评论不算事件的进展——"即使它建立在被报道的那次发布之上";
  • 同一主体的两个不同事实(两个产品、两篇论文)互相 UNRELATED,但同一次发布的后续进展(上架、定价、回应)算 SAME_STORY;
  • 拿不准就压低 confidence,宁可低也不要硬判。

上线后的第一次真实运行:24 对候选 → 24 条 UNRELATED(置信度 0.99)→ 合并 0。这是正确结果——那一轮窗口里确实没有两篇在讲同一件事。每对判定都留了理由("一个是模型架构,一个是数据资源"),翻日志就能审计。

降级纪律:任何异常都返回「零合并」

没配 key、超时、解析失败、配额耗尽——判同层出现任何异常,都静默退回 v1 口径(只合同题转载),榜单永远可用。踩过的坑也记一笔:40 对塞一次调用会让线上等 3 分钟直接超时(本地 8 对约 16 秒),改成分批判 + 放宽超时;候选对和上一轮相同就命中缓存不发请求——榜单每 30 分钟重建,候选常常没变。

最后必须诚实标注一条局限:这张 20 对标注集是行为评测集(按已知误判模式构造的),不是线上分布的抽样——因为线上同一件事的多方报道太稀少,抽不出足量真实样本。"1.000 准确率"是在这张集子上的成绩,不代表线上无限样本下的水平。

五、最反直觉的一层:判同准了,没人跟它撞题

v2 上线后,我们盯着日志等"不同措辞的同一件事"被合并——等来的是另一个事实:

48 小时窗口、94 篇文章,可合并对数为 0。

不是质量差,是没有人跟别人撞题。信源池里 16 个源几乎全是一手源——Arxiv、官方博客、GitHub Release。同一件事的直接来源只有一个,媒体转载这一层根本不存在。"事件聚类"聚的是多方报道,而我们的语料里,每件事都只有一方在说。

于是补了第三层信源:媒体层。IT之家(中文)、TechCrunch AI、The Decoder(英文),三家都是先 curl 实测可达、且条目带摘要之后才进的配置。

效果当天就出来了。窗口从 94 篇涨到 128 篇,榜单第一名变成一个真正的多方事件:

text
OpenAI will start watermarking ChatGPT's text in the EU
  ├─ TechCrunch AI  (英文媒体)
  ├─ The Decoder    (英文媒体)
  └─ OpenAI News    (官方原文)
3 源 · 标签「爆」 · 三个标题措辞完全不同

三个标题没有一个字面相同——这正是 v1 的口径永远合不出来的形态,也正是 v2 存在的理由。

聚类系统的第一性问题

不是"相似度算得准不准",而是**"你的语料里有没有重复可聚"**。算法和模型都只是后半程;前半程由信源结构决定。在一个全是一手源的池子里,最好的聚类器和一个诚实的空榜没有区别。

六、旁边还有一条被数据否决的路线

事件聚合我们其实规划过一条更深的路:不只在榜单上聚,而是把"跨源同事件"识别做到检索与报告层——给每个事件一个跨天稳定的 ID,报告里 N 篇并成一条故事线,条目上挂"N 个来源报道"的徽章。

照惯例先做只读量化,不写实现:590 篇文章、173,755 对全量扫描(秒级、零 LLM 成本)。

结果:

  • URL 规范化撞车:0 组;
  • 跨源高相似对(≥ 0.85):0 对。

结论干脆:主线挂起。这个语料不构成"跨源事件"问题——扫描反而暴露了另一个真实需求(同一篇文章换 URL 转贴的"近重复",属去重、不属聚合),方向完全不同。

三天的实现计划,被半天的只读扫描否决掉了——先证明问题存在,再动手解决。负结果也是结果,而且是便宜的那种。

七、今天的榜单

写这篇文章时跑的最新一轮(生产库实况):

指标值
48h 窗口入榜文章21 篇 → 聚成 10 个事件
其中多源事件(≥2 独立源)5 个
榜首3 家独立源 · 热度 2.169 · 标签「爆」

榜首那条是「加州非营利组织新报告:ChatGPT 青少年模式并不适合青少年」——三家媒体、三个时间点,聚成一条时间线。这就是这套系统现在每天在做的事。

收尾:三句话

  1. 相似度 ≠ 同一件事。余弦在这个语料里连"同一领域"都分离不出来,只能当旁证。真正判同的是"这件事的唯一身份"——谁、做了什么、什么时候。
  2. 模型可以判同,但要用定义和对齐训练它,而不是用阈值筛它。它错误的方式是"自信地错"——错判与正确判定的置信度分布是重叠的,唯一的修法是改提示词、改标注,然后用评测集证明它变好了。
  3. 聚类的上限由信源决定。先保证语料里有"同一件事的多方报道",再谈算法——否则再准的判同器也只能对着一堆互不相干的一手源说"UNRELATED"。这是我们绕了一大圈才承认的前提。

如果只留一条:在语料里没有重复的时候,最好的聚类器,是一个诚实的空榜。