【学习笔记】AIHOT 拆解(四)聚簇与热度:article→fact→story 三层建模、三路关系判定与跨模型复核、48 小时独立参与者热度公式的完整实现
整理日期:2026-09-29 调研方式:完整克隆 [KKKKhazix/AIHOT],逐行精读
packages/backend/src/events/group.ts(864 行,全仓库最长单文件)、relate.ts(176 行判定规则与提示词组装)、hot.ts(213 行热度计算)、hot-read.ts(137 行读取层)、merge.ts,以及industry/prompts/group-*.md六个聚簇提示词与story-digest.md。行号与常数以 commit589f79e为准。 本系列:一·总览 · 二·信源与抓取 · 三·精选与评分 · 四·聚簇与热度(本篇) · 五·模型榜 · 六·技术栈 写作动机:总览篇说这两段代码「最有含金量」,本篇兑现这个判断——把 864 行 group.ts 的每条路径和那段热度 SQL 的每个子句拆开。这里的工程决策密度是全仓库最高的:几乎每个常数都配着一条实测注释。
一、太长不看
- 三层概念:文章(一篇报道)→ 事实(同一件真实发生的事)→ 事件(这件事加直接进展)。判定结局有 13 种:
same-url(同地址直达)、same-fact(并入已有事实)、new-fact-in-story(作为新事实挂进已有事件)、new-story(新开事件)、roundup(多话题盘点)、kept(修订保留原归属)、standalone(未过相关性闸门)、manual(人工已定)、historical(历史不建档)、signal/signal-native/signal-unmatched(讨论帖三态)、skipped。 - 主流程的顺序本身就是设计:人工优先 → 历史不建档 → 讨论帖走信号路径 → 已有归属的修订默认保留 → 未过预筛的不聚簇 → 同 URL 无条件直达 → 向量召回 → 批量判定 → 低置信合并换模型复核 → 行锁内重查人工状态 → 写入 → 事后合并/回收/重匹配。
- 三路判定是用数据换来的:源码注释原话——是非题加「倾向否」会拒掉一半的真合并(2026-09-28 在 370 对标注样本上实测)。四关系(SAME_OCCURRENCE / SAME_STORY / UNRELATED / ROUNDUP)+ 双方完整描述才让判定可用。
- 两层防错合并:单篇层面,判定说「同一件事」但召回余弦低于 0.85 的,换一家模型读这一对再确认;事件层面(consolidate),两事件的根事实要判定和复核两个模型反着读都认为是一个故事才合并,复核置信阈 0.75 的注释给出实测依据(精度 0.944 / 召回 0.962,对比 0.8 阈值是 0.964 / 0.896)。
- 人工永远赢,且赢得完整:人工归属在决策前查一次、写入事务的行锁内再查一次(「模型作答期间人工做的修改仍然获胜」);显式重组只清自动归属;被搬空的事件合并进目标让旧公开地址继续跳转。
- 讨论帖是三级漏斗:引用/回复的目标帖所在事件直达(零模型调用)→ 与已有事件 ≥0.92 余弦自动吸附(零调用)→ 介于 0.72–0.92 才进判定。原帖晚到时,48 小时内等待它的回复帖会被重新唤醒;新事实出现时,6 小时内没匹配上的讨论帖再试一次。
- 热度是一条 SQL:48 小时窗口内按参与者键(信源或 X 讨论组)去重,每人按最新证据时间做 24 小时半衰期衰减,求和;上榜需 ≥2 参与者且 ≥1 编辑性参与者。趋势对比剔除「抓取落后」的信源,小时快照带 incomplete 标记和事后修复。
二、主流程:decide() 的十二步
groupArticle() 只做一件事:调 decide(),成功后把报道从 regroup_pending 里放行(「决定过的报道重新成为别人的证据;失败的决定在到达这步之前就抛出,报道继续等待,重试会重新决定」)。decide() 的顺序(每步都可能提前返回):
- 人工归属/保持独立 →
manual。人工标注压倒一切; - 强制重组(
force或regroup_pending)→resetAutomatic():只删自动归属和热度证据,人工的留下; - 历史不建档 →
historical。isHistorical()(判重引擎导出的同一条规则):backfill 且(无发布时间或发现时已超 48 小时)——「会被正常分析,但不创建事件、不加热度」; - 讨论帖分流:非 editorial 参与方式(hot_signal)→
groupSignal(); - 既有归属保留 →
kept。修订默认留在原事实(「a revision keeps its membership unless an editor asks for a regroup」); - 相关性闸门:最新分析
relevance !== "pass"→standalone。没过预筛的资料不参与聚簇; - 同 URL 直达 →
same-url:同一 URL 的其他报道已在某事实里,直接并入,零模型调用; - 向量召回(下节);
- 批量判定(三路关系,一次调用判所有候选);
- 合并决策:判「同一件事」的候选按置信度排序,余弦 ≥ 0.85 直接收;低于 0.85 的换
groupReview模型复核(复核说 SAME_OCCURRENCE 收、说 SAME_STORY 且该候选是事件根则挂进事件);都没中再看「进展」(只挂根事实)和「盘点」(全部候选都说 ROUNDUP); - 行锁内写入:事务里先
SELECT ... FOR UPDATE锁文章行,重查人工状态,然后建事件/事实、挂成员(当事方且该事实无 primary 时占 primary 位)、记热度证据、落判定记录(含全部候选与置信度); - 事后处理:这篇报道硬绑定了多个事件 → 尝试
consolidate合并它们;搬空的事件重定向;X 帖唤醒等待中的回复(reclaimWaiting);新事实出现时重匹配附近讨论帖(rematchSignals)。
注意第 10 步的排序逻辑在 relate.ts:sameOccurrence() 按「置信度降序,召回相似度破平」排候选——模型的自信优先于向量相似度,相似度只在模型同等自信时起作用。
三、召回层:怎么找候选
- 窗口:
RECALL_DAYS = 14,且按发现时间(不是发布时间)计算——注释解释:「一张今天才被发现的旧页面,仍然会遇到它的同伴」。窗口内的池子排除「等待重组」的报道(它们不算证据,直到被重新决定)。 - 嵌入文本:
reportText(标题, 摘要前 300 字)——两侧(新报道与候选)用同一个函数生成,保证可比。 - 向量缓存:召回窗口的向量常驻 worker 进程(聚簇队列串行,所以单进程持有全窗口),30,000 条上限、超了整体清空;文本哈希变了才重嵌。另有一个
warmRecallWindow()任务在部署/重组前把缺的向量先补齐(「别让第一个聚簇任务把重试预算花在补课backlog上」),预算耗尽时按重试提示等待而不是失败。 - 无 embedding 回退:字符 bigram 的词法相似度(共享二元组 / 较小集合大小),阈值降到 0.25。测试和无 key 开发环境走这条路。
- 无条件候选(boost):新报道回复/引用的 X 帖所在的事实、同 URL 的事实——这两个关系不需要向量,相似度直接记 1。
- 候选视图(
candidateViews):每个事实给判定模型看的是代表稿(primary 优先、否则时间线最早)+ 事实已有报道数 + 是否事件根(rootFactOf:事件的「起始事实」= 最早可信报道所在的事实——注释提醒「事实 ID 不随时间排序,因为事件会合并、会有导入」)。
召回阈值与上限:RECALL_MIN_COSINE = 0.6、RECALL_TOP_FACTS = 10;讨论帖单独一套:SIGNAL_MIN_COSINE = 0.72、SIGNAL_AUTO_COSINE = 0.92、SIGNAL_TOP_FACTS = 4。
四、判定层:提示词与决策规则
group-definitions.md 把四关系定义得非常细(这是判定质量的地基),摘两条关键的:
- SAME_OCCURRENCE(同一次发生):「同一个主体在同一时间做的同一件具体的事。跨语言转述、不同媒体的不同侧重、细节多寡不同……都算;同一次发布里一并推出的多个型号、版本、变体、子产品,以及这次发布里的功能、价格、开放计划、系统卡/技术报告细节,也算;同一发布方同一天发布的同一系列多个成员按同一次发布处理」。
- SAME_STORY(同一事件的不同进展):「不是同一次发生,但围绕同一个具体发生有直接的先后关系:预告/传闻与正式发布;发布与之后的上架、接入、限免;发布与针对它的评测、实测、榜单成绩;事件与当事方回应、官方调查;同一产品同一发布周期内的连续官宣」。
group-method.md 的判定启发式只有一句,但是精髓:「拿不准 SAME_OCCURRENCE 和 SAME_STORY 时,问自己:如果两篇都是真的,世界上是发生了一件事,还是先后发生了两件有直接关系的事?」
判定调用本身(judgeBatch):temperature 0、maxTokens = 200 + 90 × 候选数——输出预算随候选数线性伸缩。响应解析的防御细节:模型跳过某个候选按 UNRELATED 处理(「verdicts by fact; a candidate the model skipped counts as UNRELATED」);候选编号 C1..Cn 回填时容忍大小写和空白。
五、防错合并的两道闸
第一道:单对复核。判定说 SAME_OCCURRENCE 但召回余弦 < CONFIRM_BELOW_COSINE(0.85)的候选,交 groupReview 能力的另一家模型重读这一对(group-pair 提示词,双向描述)。复核同意才写合并。模型注册表对这个能力的注释是「最好换一家模型」——用供应商差异对冲单模型的判定偏差。
第二道:事件合并(consolidate)。一篇报道若与多个事件的候选都「硬绑定」(SAME_OCCURRENCE 或 SAME_STORY 且置信度 ≥ TIE_MIN_CONFIDENCE 0.8),这些事件可能是「一个长出两个根的故事」。consolidate() 把各事件的根事实按时间排序,最老的做锚,其余逐个与锚比较:先判定模型读一遍,过了再让复核模型反着读(a→b 换成 b→a),两关都过且复核置信度 ≥ 0.75 才合并。0.75 这个数的注释是全仓库最好的「用数据定阈值」示范:
It reports 0.75 for most developments it agrees with: on the 370 reference pairs (2026-09-29) story-level precision is 0.944 and recall is 0.962 at 0.75, against 0.964 and 0.896 at 0.8, and on 170 real root pairs the extra merges read as right.
(复核模型对它同意的进展大多报 0.75:370 对参考样本上,事件级精度 0.944/召回 0.962 @0.75,对比 0.964/0.896 @0.8;在 170 对真实根上,多出来的那些合并读起来是对的——即 0.8 太紧,漏掉的比错进的多。)
配套保护:以多话题盘点起家的事件(roundup)永不合并也永不互链(「它不是一件事,是很多件事的清单」);合并失败不阻塞——报道自己的判定已写入,合并结果作为 best-effort 附在任务结果里。
互链而不是硬合:两事件保持独立但持续被报道绑定时(linkRelatedStories),需要至少 2 篇不同报道在召回窗口内硬绑定它们才互设 related 链接——注释:「单一绑定太常是答非所问的杂音」。这是个精度优先的折中:拿不准的关联降级为展示层链接,不进数据层。
六、人工优先的工程实现
这是我认为最值得整体抄走的一段:
- 决策前查:
manualDecision()查人工归属(fact_articles.manual)或「保持独立」覆盖(grouping_overrides); - 行锁内再查:写入事务先锁文章行,重新读人工状态——「模型作答期间做的解绑或其他人工决定获胜(detachFromFact 取同一把锁)」。模型调用可能耗时几十秒,这段时间里人工的干预不能被覆盖;
- 重组的语义:
resetAutomatic()只删自动归属和热度证据,人工归属留下;被搬空的事件mergeStoryInto目标事件,「旧公开地址继续跳转」——URL 稳定性是对外承诺; - 重组按发现顺序重放:等待重组的报道(
regroup_pending)对其他报道不算证据——「按发现顺序的重组看到的,就是实时聚簇当时会看到的」。重组不是重新洗牌,是可控重放。
每一次判定(含全部候选、关系、置信度、理由、回执号)都落 grouping_decisions——后台能看到「这篇为什么进了这个事件」,错的能改,改了不会再被模型翻案。
七、讨论帖信号路径
groupSignal() 三级漏斗(对 hot_signal 信源的帖子,如 X 上的反应):
- 引用/回复直达:帖子的回复/引用目标已在某事实里 → 直接挂为该事件的 signal 证据(
signal-native),零模型调用; - 近似自动吸附:与某事件召回余弦 ≥ 0.92 → 直接挂(
signal),零调用; - 判定:0.72–0.92 之间的进
group-signal提示词判定(「帖子是否在讨论某候选」——SAME_OCCURRENCE 是报道/转述、SAME_STORY 是直接反应)。反应的置信度门槛单独设:signalTarget()里 SAME_STORY 需 ≥ 0.8 才算数。
两个补偿机制解决「原帖晚于反应到达」的现实:
reclaimWaiting(48 小时):当一篇 X 帖被聚进事实时,把最近 48 小时内回复/引用它但当时没找到事件的帖子重新排队。源码注释给了原型案例:「Dan Shipper 的 ‘SONNET 5.5 IS OUT!’ 比 Anthropic 的官方帖早一分钟」(反应先到,原帖后到);rematchSignals(6 小时):新事实/新事件成立时,把最近 6 小时内signal-unmatched的帖子与新事实算余弦,≥ 0.72 的重新排队判定——「Techmeme 和反应帖常常先于第一篇报道」。
讨论帖只挂事件、绝不创建事件(recordSignal 只写 story_signals,不建 facts);一个 X 讨论组(signal_group_id)在热度里算一个参与者,组内吵一百层楼也只算一份注意力。
八、热度公式的完整实现
核心 SQL(总览篇引过骨架,这里补齐每个子句的语义):
WITH obs AS (
SELECT story_id, participant_key,
max(observed_at) AS last_at, -- 该参与者窗口内最新证据
max(observed_at) FILTER (WHERE observed_at <= :six_hours_ago) AS last_prev,
bool_or(kind = 'editorial') AS editorial,
bool_or(source_id = ANY(:behind)) AS behind -- 该参与者是否来自落后信源
FROM story_signals
WHERE observed_at > :at - interval '48 hours' AND observed_at <= :at
GROUP BY story_id, participant_key -- ★ 独立参与者去重
), agg AS (
SELECT story_id, count(*) AS participants,
sum(power(0.5, hours(:at - last_at) / 24.0)) AS heat, -- 24h 半衰期
coalesce(sum(... decay at :six_hours_ago ...), 0) AS heat_prev
FROM obs GROUP BY story_id)- 参与者键:
participantKey()=group:<signal_group_id>(X 讨论组整体算一个)或source:<id>(信源)。「一家媒体发十篇、X 上一个组吵一天,各算一份」; - 衰减锚点:每个参与者按自己最新证据的时间衰减(不是事件的首报时间)——持续在说的参与者保持满权重,说完就走的三天剩四分之一;
- 上榜门槛:
participants >= 2 && editorial_participants >= 1(纯讨论不进榜),取前 10 名。
趋势的诚实性(这是最细的部分):与 6 小时前比较涨幅时,若本事件有参与者来自「落后信源」(超过 3 个抓取周期、至少 90 分钟没抓成功),则只用全程被观察到的参与者子集算当前值和 6 小时前值——避免把「我自己没抓到新证据」误报成「热度在跌」。sourceClocks() 只统计定时抓取的信源(「推送型和手动型的信源看不出落后」)。
小时快照与修复:每小时存 story_heat_hourly,抓取落后的那一小时标 complete = false(事件页趋势图直接不画这个点),等信源追上来后由后续任务重算补齐——「值由携带源时间的 signals 决定,是确定性的,所以可修复」。
徽章规则:surge = 最近 6 小时新增参与者 ≥ 3 且占当前参与者 ≥ 50%(真爆发);new = 距首报 < 6 小时;rising = 排除 surge 后涨幅 > 15%。
代表稿与脸面:事件在热点榜上的代表报道按「当事方优先 → 已精选优先 → 分数」选;参与者头像列按「编辑性优先 → 信源分级 → 最近活跃」排前 40 个——连展示顺序都有明确规则,不靠默认排序。
九、批判与借鉴
- 串行是正确性约束也是吞吐上限:聚簇队列
localConcurrency: 1,理由(「同一新事实的两篇报道不能都创建它」)成立,但意味着新资料的处理速度上限 ≈ 单次判定延迟的倒数。窗口大了(比如把 14 天放宽)会直接顶到这个天花板。 - 常数群的经验性:0.6/0.72/0.85/0.92/0.8/0.75 六个阈值都有实测注释支撑,但都是在 AI 新闻语料上测的;换行业等于换分布,这些数要重新标——而聚簇的标注比评分更贵(要标「这两篇是不是同一件事」的成对样本)。
- 向量缓存的一致性代价:进程内缓存 30,000 条、超限整体清空,「成员的后续修订文本要等缓存翻新才被看到」——注释自己承认了这个延迟窗口。聚合器语境下可接受,但值得知道。
- 判定的调用成本被管理得很好但没被消除:每篇 editorial 报道至少一次批量判定(除非同 URL 直达),合并复核和事件合并再各加一层。152 条 ≈ 930 次调用的官方数字里,聚簇贡献了相当份额。
- 可抄的三件东西:行锁内复核人工状态(任何「模型慢决策 + 人快干预」的系统都适用);「等待决定的输入不作证据 + 按序重放」(可审计的重组语义);「boost 通道」——用确定性关系(同 URL、回复/引用)给概率系统兜底,能省下一大半模型调用。
附:与总览篇的差异说明
本篇是总览篇第五、六节的展开:总览给了聚簇与热度的骨架图和核心 SQL,本篇补齐了 decide() 主流程全序、召回/判定/复核的实现细节、信号路径与两个补偿机制、快照修复与徽章规则。常数两篇一致(14 天窗口、0.6/0.85/0.92、48h/24h、MIN_PARTICIPANTS 2)。