ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

内容质量分系统5.0:从12特征到6维正交与分位数归一化

内容质量分系统5.0:从12特征到6维正交与分位数归一化 上周三凌晨两点,我把博客质量分计算服务从 version 4.3 灰度切到 version 5.0,流量开关从 5% 一路推到全量。选这个时间点不是迷信,而是重算任务单次要吃掉接近四十分钟的 CPU,白天的流量峰值根本兜不住。推完我盯着分布曲线看了两个小时,预期的右偏没出现,反而中间段样本量涨了一大截——这个反常现象,恰好暴露了 4.x 里藏了很久的一个结构性问题,后面会细讲。博客质量分计算,说白了就是给每一篇已发布的内容打一个 0 到 100 的分数,回答一个很朴素的问题:这篇东西值不值得被更多流量分发。它往下游喂三个消费方:首页与频道排序、内容运营的人工筛选池、以及作者端的内容健康度提示。做推荐、做社区运营、做创作者激励的同学,大概率都绕不开这类打分系统,能迭代到 5.0,说明前面四轮攒下的坑足够写一篇完整复盘了。这篇东西适合两类人看:正在从零搭这套系统的人,以及系统已经跑起来了但分数越算越钝、说不清为什么的人。1. 5.0 要动的是分数骨架,不是继续堆特征1.1 4.x 的分数为什么越算越钝4.x 最后那版的打分配置里挂着 12 个特征,听起来很丰盛,但上线半年后我做了个简单的相关性检查,发现其中 7 个特征两两之间的 Spearman 相关系数超过 0.75。这意味着什么?意味着我花了 12 份工程成本去采集和计算,实际只拿到了 5 份有效信息量,剩下 7 份在反复给同一件事投票。最典型的是正文有效字数段落数图片数量这三个,它们本质上都在描述篇幅,长文天然三高,短文天然三低。特征高相关的直接后果是权重失真。当三个高度相关的特征各自被赋予 0.1 的权重时,它们合起来实际上拿到了 0.3 的影响,而你标定时以为只给了 0.1。人工调权重的时候根本感知不到这件事,因为每个权重单独看都很合理。分数越来越钝、排序区分度越来越差,根源往往就在这里,而不是模型不够先进。1.2 质量分是一把尺子,不是一杆秤这是 5.0 设计阶段我反复跟团队强调的一句话。秤称的是绝对重量,尺子量的是在这批样本里你排在哪个位置。质量分属于后者。同一篇文章,放在一个全是深度长文的池子里,它可能只是中等;放在一个以短讯为主的池子里,它可能相当突出。听起来像是缺陷,实际上这是刻意的选择。原因在于打分系统最终要服务的是排序,而排序只关心相对顺序。绝对分数在跨时间、跨品类比较时极不稳定:上周发的文和三个月前发的文,凭什么用同一把绝对刻度去衡量?平台内容生态的整体水平本身就在漂移,今天的中位数可能是去年偏上的水平。所以 5.0 把分数的语义明确定义为当前时间窗口内的相对位置,而不是内容的固有属性。接受这一点之后,时变归一化、分桶映射这些设计才顺理成章。1.3 5.0 的边界:明确不做什么一套打分系统最常见的失控方式是无边界扩张——运营想看违规风险,编辑想看选题热度,商务想看商业潜力,最后所有需求都往分数里塞,分数变成一个什么都想说、什么都说不清的复合材料。5.0 在文档第一页就写死了三条边界。第一,不承担合规判定。违规、侵权、敏感内容的识别由独立的风控链路负责,质量分只在通过风控的内容上计算,两者不互相污染。第二,不承担热度预测。热度是另一个时间尺度上的问题,质量分描述的是内容本身的完成度,一篇高质量内容可能因为选题冷门而毫无热度,这是正常的。第三,不做单篇的绝对裁决。分数只用于批次内排序和分桶,任何低于 X 分就下架的硬规则都不允许挂在质量分上——这是拿相对量当绝对量用,迟早出事。2. 打分维度重切:从 12 个特征收敛到 6 个正交维度2.1 共线性问题的处理思路处理共线性有两条路:一是做特征筛选,把冗余的删掉;二是做降维,用主成分或因子分析合成。我两条都试过,最后选了第三条——按语义重新切分维度,让每个维度对应一个独立的、能被人类理解的判断,然后每个维度内部再去聚合多个信号。这样做的好处是最终分数的每一个扣分项都能反查到具体原因,而不是模型说你这篇不行。具体做法是先列一张表,把所有候选信号写下来,然后按它回答的是哪个问题归类。回答信息够不够的归一类,回答是不是原创的归一类,回答读起来顺不顺的归一类。归完之后,如果两个维度的信号高度重合,说明语义切分没切干净,继续拆。最终稳定下来的是六个维度,每一个都有明确的、互不重叠的定义。2.2 六个维度的口径定义六个维度分别是信息密度、原创程度、结构可读性、时效价值、互动质量、规范程度。逐一说明口径,这部分是打分逻辑的地基,含糊了后面全白搭。信息密度回答的是单位篇幅里承载了多少有效信息,注意是单位篇幅,不是总篇幅。所以原始信号里既有正文有效字符数,也有段落数、有效段落占比、以及去掉停用词后的实词覆盖率。一篇五千字里有四千五百字是铺垫的长文,信息密度会明显低于一篇两千字全是干货的文。这一维度是全篇权重最高的,因为它和读者能不能获得价值最直接相关。原创程度与库内已有内容的相似度反向相关。计算方式是拿正文的段落级向量与近 90 天入库内容做近邻检索,取相似度最高的若干段的均值作为重复度,再用 1 减去。这里必须做段落级而不是全文级,因为全文相似度对洗稿、拼稿几乎无感,而段落级近邻能抓到整段搬运这种最常见的手法。结构可读性由标题层级是否完整、段落长度分布是否合理、代码块与图片是否与正文配套、以及长段落占比共同决定。我特意把段落长度方差放进来,因为一个全是两三行短段的排版,和全是八百字无分段的长墙,读起来都很难受,方差能同时捕捉这两个极端。时效价值处理的是内容与当前时间的关系,但它不等于越新越好。技术类内容的半衰期可能是 180 天,而行业资讯类可能只有 7 天。所以这一维度实际是发布时长经过品类半衰期校正后的值,具体公式后面讲衰减时展开。互动质量是去噪后的互动信号。原始收藏、评论、完读率都会被行为去重和异常检测处理一遍,处理后的值才进这一维度。没去噪的互动数据是最容易被污染的输入,凡是可见可刷的指标都会有人去刷。规范程度管的是错别字比例、标点混用、外链密度、以及是否使用了大量无意义占位符。它权重不高,但作为一道门槛存在:规范程度低于阈值的,总分会被硬性压顶,防止排版混乱的内容靠着信息密度高冲到前排。2.3 各维度的原始信号与归一化方式把上面这些落实到工程配置,大致是下面这张表。归一化方式统一用分位数映射,理由下一节讲。维度主要原始信号归一化方式权重区间信息密度有效字符数、实词覆盖率、有效段落占比分位数映射0.28 - 0.34原创程度段落级近邻相似度、跨站重复片段占比1 减相似度后分位数映射0.20 - 0.26结构可读性标题层级完整度、段落长度方差、图码占比分位数映射0.12 - 0.18时效价值发布时长、品类半衰期校正指数衰减后分位数映射0.08 - 0.14互动质量去噪收藏、去噪评论、完读率对数后分位数映射0.10 - 0.16规范程度错别字率、标点异常、外链密度阈值截断后分位数映射0.04 - 0.08权重给的是一个区间而不是固定值,是因为不同内容品类的最优权重分布不一样。技术深度类内容里信息密度的权重可以顶到 0.34,而资讯类内容时效价值的权重会明显上浮,信息密度则要下调,否则短讯类内容会被系统性压低。3. 归一化与权重标定:把手调换成可复现的流程3.1 为什么放弃 min-max,改用分位数归一4.x 用的是 min-max 归一化,公式简单,但它有个致命弱点:完全由极值决定。某天有人发了一篇十万字的长篇连载,信息密度的最大值被瞬间拉高,全站所有其他内容的归一化结果被整体压低,分数分布肉眼可见地左移。关键参数里出现这种单点敏感的设计,线上一定会反复出问题。分位数映射换了个思路:不关心最大值是多少,只关心你排在什么位置。取最近 7 天的全量特征分布,计算 P1 到 P99 的 99 个分位点存成一张映射表,打分时用二分查找定位。原始值低于 P1 的映射为 0,高于 P99 的映射为 1,中间线性插值。这样做的结果是:极端值的影响被限制在端点,分布整体形状稳定,而且跨天可比——今天 0.8 分和昨天 0.8 分,都表示进入了前 20%,语义一致。代价是计算多了一次查表,以及需要维护分位点表。查表开销在微秒级,可以忽略;分位点表每天凌晨重算一次,落库,打分服务启动时加载进内存,增量更新时热替换。这里有个坑我踩过:热替换必须用双缓冲,新表加载完再切指针,不能先清空再填充,否则中间会有一段窗口期所有内容都映射到 0。这个事故后面会详细复盘。import bisect class QuantileMapper: def __init__(self, breakpoints): # breakpoints 长度 101, 对应 P0..P100 self.bp breakpoints def transform(self, x): if x self.bp[0]: return 0.0 if x self.bp[-1]: return 1.0 idx bisect.bisect_left(self.bp, x) lo, hi self.bp[idx - 1], self.bp[idx] if hi lo: return (idx - 1) / 100.0 ratio (x - lo) / (hi - lo) return (idx - 1 ratio) / 100.03.2 权重从专家打分迁移到排序学习权重怎么定,这件事 4.x 时代一直靠拍脑袋加人工微调,结果就是每次改权重都像开盲盒。5.0 换成了两条腿走路:先用专家打分冷启动,再用线上行为数据做排序学习微调。冷启动阶段,找了五位有多年内容编辑经验的同事,给出 800 篇样本,每人做两两对比标注——这两篇里哪篇质量更高。不问绝对分,只问相对,因为人对相对顺序的判断远比绝对分数可靠。收集到大约六千组有效 pair 之后,用 LambdaRank 训练一个排序模型。注意这个模型不是直接用来打分的,它的用途是输出各维度的贡献系数,也就是权重。# 排序学习输出的是各维度对排在前面的贡献, 归一化后即权重 import lightgbm as lgb train lgb.Dataset(X_pair, labellabels, groupgroup_sizes) params { objective: lambdarank, metric: ndcg, ndcg_eval_at: [5, 10], learning_rate: 0.05, num_leaves: 31, } booster lgb.train(params, train, num_boost_round300) gain booster.feature_importance(importance_typegain) weights gain / gain.sum()用 pairwise 而不是 pointwise 的回归,原因很实在:绝对质量分的人工标注一致性极差。同一篇文章,不同编辑给的绝对分能差 30 分;但问这两篇哪篇好,一致性立刻上到 85% 以上。排序学习吃的就是这种高一致性信号。拿到特征增益之后归一化,就是各维度的权重。整个过程可复现,换一批标注数据能重算,不用再开会吵架。3.3 对数刻度与等级映射基础分算出来是 0 到 100 的连续值,但直接拿连续分做分发决策是不合适的。原因在于质量分的分布是明显右偏的——大量内容集中在 40 到 70 之间,真正的高分尾部和低分尾部都很薄。在这种分布上做阈值切分,阈值稍微一动,受影响的样本量就会剧烈变化,策略非常不稳定。解决办法是先做对数拉伸再分桶。具体是把基础分转成百分位位置,然后按固定的百分位切点分档:前 5% 为 S 档,5% 到 20% 为 A 档,20% 到 55% 为 B 档,55% 到 85% 为 C 档,其余为 D 档。这样每一档的样本占比是稳定可控的,不管内容生态整体水平怎么涨,各档的相对结构不变。下游策略挂在档位而不是原始分上,稳定性好了不止一个量级。注意:分桶切点要和业务方一起定,并且写进配置中心,不能散落在代码里。我见过切点硬编码在三个不同服务里的情况,改一次要发三次版。4. 计算链路:离线批与准实时增量怎么配合4.1 特征快照表与幂等重算打分链路最容易出问题的环节不是算法,是数据一致性。5.0 的做法是把特征计算和分数计算彻底解耦。特征计算产出的是特征快照表,每行包含内容 ID、特征名、特征值、计算时间、特征版本号。分数计算只读快照表,不直接读业务库。这样做带来两个好处。一是重算安全:想换权重重新打分,直接读同一份快照重跑,结果完全可复现,不会因为业务库数据在这期间发生变化而得到不同结果。二是可追溯:某个分数异常,可以直接查到它用的是哪一版特征、哪一个时间点的值,排查效率天差地别。-- 特征快照表按天分区, 内容 ID 特征名 特征版本 作为唯一键 CREATE TABLE feature_snapshot ( content_id BIGINT, feature_name VARCHAR(64), feature_value DOUBLE, feature_version VARCHAR(16), computed_at DATETIME, dt DATE ) PARTITION BY RANGE (TO_DAYS(dt));为了做幂等,表上加了唯一键,重跑时用INSERT ... ON DUPLICATE KEY UPDATE,同一批数据跑十遍结果一致。别小看这个约束,没有它的时候,重跑任务产生的重复行会让聚合结果翻倍,分数直接虚高。4.2 触发时机:事件驱动加定时兜底什么时候重新算分,这是个需要算计的问题。全量重算成本太高,一天一次可以接受,但新发布的内容等不起一天。所以采用了事件驱动为主、定时兜底为辅的策略。事件驱动监听几个关键变更:内容首次发布、正文被编辑、评论数跨过阈值、被举报或申诉、作者等级变更。这些事件进消息队列,消费端做防抖聚合——同一篇内容 5 分钟内的多次变更合并成一次计算任务。防抖这一步很有必要,作者连续修改排版的场景非常常见,不防抖的话一条内容能被重复计算几十次。定时兜底每 6 小时跑一次增量扫描,覆盖那些事件丢失或者没被监听到的情况。凌晨的全量重算负责刷新分位点表和做全量重打分。三层配合下来,新内容从发布到有分数平均延迟在 90 秒以内,P99 不超过 8 分钟。4.3 存储选型与缓存策略分数的读写特征很明确:写少读多,读要求极致低延迟,并且需要支持按分数范围做 Top-N 查询。基于这几个特征,选型如下。实时分数放 Redis 的 ZSet,member 是内容 ID,score 是质量分。这样按分数取 Top-N 就是一条ZREVRANGE,按分数区间取也只是一条ZRANGEBYSCORE,天然贴合需求。历史分数和分档结果落 ClickHouse,用于离线分析和回溯。维度定义、权重配置、分桶切点这些低频变更的配置落 MySQL,加一层本地缓存,变更时通过配置中心推送失效。缓存穿透单独说一下。内容 ID 是自增的,有人拿不存在的 ID 遍历接口的话,缓存层会一路打到 Redis 再打到后端。处理方式是在 Redis 里对不存在的 ID 写一个短 TTL 的空值占位,同时在上游做参数校验,ID 超出当前最大值的直接拒绝。这两个措施上去之后,后端 QPS 掉了一半多。缓存空值占位的 TTL 别设太长,30 到 60 秒足够。设太长的话,新发布内容在首次计算完成前会一直读到空值,出现明明发了文但分数一直是 0的反馈。5. 防刷与去噪:指标一旦可见就会被优化5.1 刷分的几种典型手法与抑制策略任何可见的评分体系都会被优化,这是无法回避的。上线三个月内我观察到的刷分手法大致有这么几类,以及对应的处理方式。互赞互评是最常见的一种,一批账号互相给对方的文章点赞收藏。识别方法不是看单次行为,而是构建账号之间的互动图,用社区发现算法找出互动高度内聚、对外几乎无互动的小圈子,圈内产生的互动在计算时按折扣系数计入。这个方法有个前提:正常用户的互动行为是分散的,内聚度指标能拉开明显差距。短时间集中收藏是另一种,常见于发完文之后在群里喊一声。处理方式是滑动窗口速率限制:统计一篇内容发布后 10 分钟内的收藏数,如果这个值超过了它后续 24 小时收藏总量的某个比例,就对早期这部分收藏做降权。逻辑是正常的内容消费曲线应该是长尾的,而不是一上来就冲顶然后归零。标题党走的是另一条路——不去刷指标,而是提高点击率。识别方式是算标题与正文的语义相似度,标题包含正文根本没涉及的概念时,相似度会明显偏低。这个信号不直接扣总分,而是送到人工复核池,避免误伤那些用了修辞手法的正常标题。批量生成的内容靠困惑度和模板重复度来抓。机器批量产出的文本,句长分布异常均匀,段落开头的句式高度趋同。把段首句的 n-gram 重复率作为信号接入,能拦掉相当一部分。5.2 时间衰减:只作用于该作用的地方4.x 的一个重大设计失误,是把时间衰减直接乘在总分上。当时的设计意图是让老内容慢慢退场,结果把一大批长青内容打进了冷宫——那些讲基础概念的深度文,写了三年依然有人在搜、有人在读,但在衰减公式下分数被压到 D 档,搜索流量掉了三成。5.0 把衰减的适用范围收窄了,只作用于时效价值这一个维度,不碰基础质量分。基础的六个维度里,信息密度、原创程度、结构可读性、规范程度这四项本质上与时间无关,一篇三年前写的高质量教程,信息密度不会因为时间流逝而降低。时效价值维度单独做指数衰减,不同品类用不同半衰期:HALF_LIFE { tutorial: 540, # 教程类, 约一年半 deep_dive: 720, # 原理深挖类, 约两年 news: 7, # 资讯类, 一周 tool_review: 180, # 工具评测, 半年 } def time_score(age_days, category): hl HALF_LIFE.get(category, 180) return 0.5 ** (age_days / hl)分发策略那边则完全不看质量分的时间属性,而是用另一个独立的热度信号去控制新鲜内容的曝光。两个信号分开,各自管各自的事,互不干扰。改完之后,长青内容的搜索流量回升到衰减前的水平,而新内容的曝光也没受影响。6. 上线前的校准:怎么判断这个分数是准的6.1 人工标注集怎么建才算靠谱没有校准的打分系统等于没有。校准的第一步是建一套人工标注集。5.0 用的标注集规模是 1200 篇,按品类分层抽样,保证技术、行业、生活等大类都有足够样本,并且刻意把高低分两端多抽一些,避免中间段过密导致区分度评估失真。标注方式是五档主观评分,外加一个是否愿意推荐给同事的二值判断。后者看起来冗余,实际很有用——五档评分容易被还行不错这种模糊判断稀释,而愿不愿意推荐是一个更硬的选择,能有效拉开档位差距。标注由三位编辑独立完成,取中位数,标注一致性低于阈值的样本直接剔除。评估指标主要看两个:分数与人工档位的 Spearman 相关系数,以及按分数排序后的 NDCG20。经验值是 Spearman 达到 0.6 以上、NDCG20 达到 0.85 以上,才算达到上线标准。低于这个数值说明维度设计或者权重标定有问题,得回去改,不能硬上。6.2 灰度阶段盯哪些指标离线指标达标只是入场券,线上表现才是真的。灰度阶段我把观测指标分成三组。第一组是分布健康度。每天看分数分布的 PSI(群体稳定性指标),超过 0.1 就要告警,说明当日分布和基准分布出现了显著漂移。同时看各档位的样本占比,任何一档占比偏离设定值超过 3 个百分点,就要查是不是归一化表出了问题。第二组是区分度。核心看分桶后的点击率单调性——如果 S 档的点击率反而不如 A 档,说明分数没有真正区分出内容价值,要么是维度选错了,要么是互动数据被污染了。单调性是从业务侧验证打分有效性的最直接证据。第三组是稳定性。同一篇内容连续三天重算,分差应该控制在 ±2 以内。这个测试专门用来抓特征抖动和归一化表漂移。我上线前跑了一轮,发现有个品类的分差能到 8 分以上,顺着查下去发现是那个品类的近邻检索库更新频率和其他品类不一致,导致重复度特征每天跳变。这种问题离线评估根本看不出来。检查项观测指标通过标准分布稳定性日度 PSI 0.1档位结构各档样本占比偏离 3 个百分点区分度分桶 CTR 单调性严格单调稳定性同内容三日重算分差绝对值 2可解释性单篇维度贡献可反查100% 可查冷启动新内容出分延迟P99 8 分钟6.3 必须留一条人工覆写通道这条是经验,不是理论。不管你的分数算得多准,总会遇到分数和常识明显冲突的个案——某篇被广泛引用的经典内容因为篇幅短、互动少而被压到 C 档,某个刚发布的重要声明因为时效维度还没起来而分数很低。这种时候如果只能改权重,那就是拿大炮打蚊子,而且改完影响全站。5.0 加了一张覆写表,运营可以对指定内容做分数锁定或者档位提升,必须有审批记录和有效期。覆写记录同时会回流到标注集,作为后续权重标定的补充样本。这条通道上线后,因为个别内容分数不合理而来的投诉量下降非常明显。7. 那些没写进设计文档的坑7.1 三个真实的线上事故复盘第一个事故:分位点表热替换导致全站分数归零。当时新表加载实现是先清空内存中的旧表,再逐条写入新表,中间有大约 90 毫秒的空窗口。偏偏那 90 毫秒里来了一批打分请求,拿到空表之后所有特征都映射到 0,这些内容的分数被写成 0 并缓存了 30 分钟。表现就是一批内容在半小时内从首页消失。修复方案是双缓冲,两条独立的数组,新表填充完成后原子切换引用。这类问题只要涉及内存中的共享配置,都要按这个模式处理。第二个事故:特征版本混算导致分数集体抖动。新增了一个特征之后,部分内容的快照表里有新特征值,部分没有,而聚合逻辑对缺失特征做了补零处理。补零在一部分特征上等价于打低分,导致新旧特征覆盖到的那部分内容分数系统性偏低。修复方案是在快照表上强约束特征版本,聚合时只取同一版本号的记录,版本不一致的直接重算而不做补零。第三个事故:时间衰减乘总分打垮长青内容。这个前面提过,但值得再强调一遍它的排查过程。当时的告警是搜索侧长尾流量下降,查了两天才定位到是打分链路的问题。教训是:任何乘在全量分数上的系数,都要先问一句这个东西对所有品类都成立吗。7.2 几条复盘下来的工程习惯跑完这一轮,有几条习惯我打算在后续版本里固化下来。第一,分数必须可解释,单篇内容的每一个维度贡献值都要能反查,这不仅是为了排查问题,也是为了让运营同事能理解分数的含义,减少无谓的争论。第二,任何共享配置的内存更新都用双缓冲加原子切换,没有例外。第三,特征计算必须版本化,宁可重算也不要补默认值。第四,上线前一定要跑稳定性测试——同一批内容连续多次重算,分差超出阈值的直接打回。还有一条是我个人体会最深的:这套系统真正的难点从来不在算法,而在数据口径的一致性。六个维度的定义写清楚只需要一页纸,但保证六个维度在生产环境里每一天都按同一口径算出来,需要的是大量的监控、校验和对账。我现在的习惯是每个维度都配一个每日对账任务,把当天算出的分布和前一日的做对比,偏离超过阈值就告警。看起来笨,但正是这个笨办法,让我在 5.0 灰度期间提前发现了两个特征采集的隐性 bug。
返回列表