新闻推荐系统Python实现:从相似度计算到多路召回的完整工程链路
发布时间:2026/10/13 4:52:05 锦皓数字建站

简介这是一份基于Python的新闻推荐系统完整项目面向初学推荐系统的学生和需要快速搭建新闻推荐原型的开发者以真实可运行的代码和数据集演示了推荐系统的工程实现涵盖数据处理、特征构建、模型调用与结果输出等环节。压缩包共一百八十一个文件整体约十点三二兆内含三十五个Python源码文件、一百零二个文本与说明材料、三十二个编译缓存、七个概率模型参数文件以及csv数据集和Eclipse工程配置README文档还对项目结构与运行方式做了说明。资源已有四千零七十余人学习下载适合用于课程设计、毕设参考或日常自学借助其中附带的.p概率模型参数、预处理文本与示例数据可以快速理解新闻推荐链路中从文本解析到候选生成、排序和结果输出的完整过程尤其是对希望研究推荐系统代码组织的开发者按py源码、数据文件和模型参数相互配合的方式能更直观地掌握工程落地要点。1. 为什么新闻推荐比电商推荐更容易翻车一份能跑通的 Python 参考实现做新闻推荐的同行应该都有同感算法本身不难难的是把离线算好的相似度特征送进线上接口还要在冷启动和热点爆发时都稳住。这份基于 python 的新闻推荐系统把特征计算、相似度召回、热度兜底、曝光去重、排序输出串成了完整工程链路适合一个人维护的小型新闻场景。我拆完之后的结论是它的召回和过滤层写得比排序层扎实拿来改造成生产级推荐系统比从零搭要省很多事。下面按我拆包的顺序把相似度计算、多路召回、去重、排序和几个常见坑挨个过一遍。2. 相似度计算与候选集生成离线特征怎么避免实时重算新闻推荐最常见的第一版做法是把用户最近点击的文章换成词向量再和全量文章算相似度。这套代码也是这么做的但它把“相似度计算”放在离线侧线上只读结果这个设计决定了整个系统的性能和稳定性上限。2.1 相似度计算的三个动作分词、词频、余弦核心函数是cosine_sim输入是两个词频字典输出 0 到 1 的相似度。它不依赖外部模型标准库加一个math就能跑。# similarity.py import math from collections import Counter def cosine_sim(vec_a: dict, vec_b: dict) - float: 计算两个词频字典的余弦相似度 common set(vec_a) set(vec_b) dot sum(vec_a[w] * vec_b[w] for w in common) norm_a math.sqrt(sum(v * v for v in vec_a.values())) norm_b math.sqrt(sum(v * v for v in vec_b.values())) if norm_a 0 or norm_b 0: return 0.0 return dot / (norm_a * norm_b) def build_vector(token_list): 把分词结果转成词频向量 counter Counter(token_list) return dict(counter)逻辑说明cosine_sim先找两个词频字典共有的词计算点积再分别算两个向量的模长最后做除法。分母为零时直接返回 0避免向量全空导致除零异常。参数说明build_vector接收的是已经分好词的列表。如果输入没有分词可以先按标点和空格简单拆分。实测下来中文新闻按字符切分的效果不如按词切分建议至少用 jieba 的精确模式跑一遍。cosine_sim返回值不需要归一化因为后续排序阶段还会做加权组合。2.2 热度兜底从冷启动到候选集闭合任何基于内容的推荐都绕不开冷启动。新用户没有行为记录就生成不了词向量这时候要靠热点新闻兜底。代码里用了一个基于点击率和时效窗口的兜底函数。# fallback.py def hot_fallback(news_pool, now_ts, window24 * 3600, top_n20): 返回最近窗口内点击率最高的新闻补足候选集 scored [] for nid, item in news_pool.items(): click_rate item.get(clicks, 0) / max(item.get(views, 1), 1) age now_ts - item.get(publish_ts, now_ts) if age window: scored.append((nid, click_rate)) scored.sort(keylambda x: x[1], reverseTrue) return [nid for nid, _ in scored[:top_n]]逻辑说明click_rate是点击率而不是点击量防止高曝光新闻靠总量碾压。window是小时窗口超过时效的文章直接跳过保证兜底候选是近期内容。参数说明window86400意味着只统计发布不超过 24 小时的内容top_n20控制最坏情况下的候选数量。你要是觉得热点太快可以把 window 调到 12 小时适合突发新闻为主的场景。2.3 为什么离线算相似度而不是线上实时算我看代码里有同学注释问“为什么不在推荐接口里现算相似度”这在数据量小的时候没问题但新闻场景下有几个隐藏成本全量相似度矩阵的规模是文章数的平方日更一千条一个月就是约 9 亿个相似度对在线计算每次请求都要重新加载用户向量和全量文章向量响应时间不可控新闻批量入库的时间集中在几个时段离线批量算完写缓存线上只查更利于复用这套代码采用离线特征文件加内存加载的方式启动时一次性读入之后推荐链路不碰原始文本这也是我认为它工程上最对的地方。3. 多路召回与热度兜底让新新闻有出头机会只靠相似度召回有个明显毛病用户点过什么就一直推什么内容越推越窄。新闻推荐的用户需求是发散型的——早上看科技资讯中午可能就想看娱乐八卦。多路召回就是为了解决这个窄化问题。3.1 四路召回类别、关键词、热度、新鲜度这个包的召回层用配置列表管理多路策略每一路有自己的权重和数量上限。# recall.py RECALL_ROUTES [ {name: sim_top, weight: 0.50, top_n: 20, enable: True}, {name: cat_match, weight: 0.25, top_n: 15, enable: True}, {name: hot_fallback,weight: 0.15, top_n: 10, enable: True}, {name: fresh_boost, weight: 0.10, top_n: 5, enable: True}, ] def multi_recall(user_profile, news_pool, recent_hot_ids, now_ts): 多路召回返回路名到新闻ID列表的映射 result {} for route in RECALL_ROUTES: if not route[enable]: continue name route[name] if name sim_top: user_vec user_profile.get(vector, {}) scored [(nid, cosine_sim(user_vec, item[vector])) for nid, item in news_pool.items()] result[name] [nid for nid, _ in sorted(scored, keylambda x: x[1], reverseTrue)[:route[top_n]]] elif name cat_match: user_cats user_profile.get(cats, []) result[name] [nid for nid, item in news_pool.items() if item.get(cat) in user_cats][:route[top_n]] elif name hot_fallback: result[name] hot_fallback(news_pool, now_ts, top_nroute[top_n]) elif name fresh_boost: fresh_ids [nid for nid, item in news_pool.items() if now_ts - item.get(publish_ts, now_ts) 6 * 3600] result[name] fresh_ids[:route[top_n]] return result逻辑说明multi_recall对每一路召回独立计算最后把所有路的候选合并去重。sim_top走用户向量和文章向量的余弦相似度cat_match直接比对用户常读类别hot_fallback和fresh_boost都承担冷启动和时效性补充。参数说明weight并不是召回阶段用来过滤的而是到排序阶段才参与加权。top_n控制每路召回进排序池的数量上限避免某个策略一次塞进几百条拖死排序层。3.2 召回合集的判定什么时候需要二次兜底召回并不是“每路拿几条拼一起”就完了还要做一个闭合判定如果最终候选集少于目标数说明当前用户的画像太稀疏需要扩大相似度排序的截断范围。这个逻辑在代码里是单独写的。def ensure_candidate_pool(recall_map, min_pool_size30): 候选不足 30 条时从 sim_top 的剩余结果里补齐 merged [] seen set() order [sim_top, cat_match, hot_fallback, fresh_boost] for route in order: for nid in recall_map.get(route, []): if nid not in seen: merged.append(nid) seen.add(nid) if len(merged) min_pool_size: return merged # 此时可以标记该用户为冷启动用户 return merged[:min_pool_size]逻辑说明ensure_candidate_pool不只是去重它把候选是否够数当成冷启动的标志。如果最终不满 30 条后续排序层会启用纯热度排序模式。参数说明min_pool_size30比较保守。如果新闻池质量高可以压到 20要是想给排序层多点选择空间调到 50 也行只是接口响应时间会相应增加。3.3 召回参数与业务场景的对应不同新闻站点的内容密度差异很大参数不能一套走到底。这里列一下我试用时的参数参考。场景sim_topcat_matchhot_fallbackfresh_boostmin_pool_size综合资讯门户3020151040垂直科技站201010525突发事件为主105302050前面两行权重可以不动主要调 top_n。第三行说明一下突发新闻场景下热度和新鲜度优先于个性化因为用户打开新闻客户端就是为了第一时间知道发生了什么。4. 过滤与去重曝光控制为什么不能只做一次召回阶段之后代码里依次做了三件事黑白名单过滤、曝光去重、时效性校验。这个顺序很关键——先过滤掉不该推荐的再排除用户已经看过的最后确认内容没过期。4.1 过滤规则的优先级拉黑、曝光、时效# filter.py def apply_filters(rec_list, user_id, exposed_ids, config): 按优先级返回最终可推荐列表 if config.get(blacklist) and user_id in config[blacklist]: return [] filtered [nid for nid in rec_list if nid not in exposed_ids] if config.get(max_age_hours): deadline config[now_ts] - config[max_age_hours] * 3600 filtered [nid for nid in filtered if config[news_pool][nid][publish_ts] deadline] return filtered逻辑说明apply_filters先查黑名单再做曝光过滤最后做时效性过滤。黑名单判断放在第一步如果用户被拉黑直接返回空列表省掉后面无意义的计算。参数说明exposed_ids是用户已经看过的新闻 ID 集合来源有三种方式内存 Set、Redis Set、数据库表看部署环境选。max_age_hours不传就不做时效过滤传了就以当前时间减去配置的小时数作为截止线。4.2 曝光去重的三种容器内存 Set、Redis、滑动窗口曝光去重看起来简单实际实现里有个细节——曝光列表要不要限长。生产环境里一个活跃用户一个月会产生几百条曝光记录全堆在内存里不仅占空间而且历史记录对近期的推荐决策几乎没帮助。容器优点缺点适用规模Python Set实现简单无外部依赖进程重启丢失不可共享单机小规模Redis Set可共享TTL 可控多一次网络 IO多实例部署滑动窗口数组只保留最近 N 条需要自己维护窗口对实时性要求高资源包里默认用的是 Python Set因为单机场景最省事。如果你要部署多实例一定要换成 Redis否则会出现 A 实例推了用户看过的新闻、B 实例不知道的情况。4.3 规则开关与配置项代码把过滤规则做成了可配置项这样线上出问题时不需要改代码改配置就行。配置项默认值说明blacklist[]用户黑名单支持按 user_id 精确拉黑max_age_hours72超过 72 小时的新闻默认不推荐exposed_limit100曝光记录最多保留 100 条dedup_modelocal_set可选 local_set / redis_set / windowexposed_limit100是代码里一个比较合理的默认值。我测过 50 和 200 两组50 会轻微增加重复曝光200 会拖慢过滤速度。100 是收益和成本的平衡点。5. 排序层与接口层把 Pipeline 接成可服务的函数召回到了这一步所有候选新闻都是“可能有兴趣”排序层才是决定用户到底看到什么的地方。这套代码的排序层没有用复杂模型而是把多个维度分数做加权融合好处是线上可解释、可调参。5.1 排序函数公式权重可替换# rank.py def rank(rec_list, sim_scores, hot_scores, fresh_scores, weights): 加权融合排序返回排序后的新闻ID列表 final [] for nid in rec_list: total 0.0 total weights.get(sim, 0.5) * sim_scores.get(nid, 0.0) total weights.get(hot, 0.3) * hot_scores.get(nid, 0.0) total weights.get(fresh, 0.2) * fresh_scores.get(nid, 0.0) final.append((nid, total)) final.sort(keylambda x: x[1], reverseTrue) return [nid for nid, _ in final]逻辑说明rank对每个候选 ID 分别取出三个维度的预估分数乘以对应权重后累加最后降序排序返回。权重和的取值不要求严格等于 1但最好控制在 0.9 到 1.1 之间这样不同版本的分数可以横向比较。参数说明三个权重里sim是用户画像相关的个性化权重hot是社会共识权重fresh是时效性兜底权重。如果你想替换成更复杂的模型比如 LR 或 XGBoost只需要替换这个函数的内部实现输入输出协议保持不变。5.2 接口层接收用户 ID、限制返回条数接口层用 FastAPI 实现核心是/recommend路径接收 user_id 和 limit 两个参数。# app.py from fastapi import FastAPI app FastAPI() recommender load_recommender() # 启动时加载离线特征 app.get(/recommend) def recommend(user_id: str, limit: int 10): 推荐接口返回新闻ID列表 user_profile recommender.get_profile(user_id) recall_map recommender.multi_recall(user_profile) merged_ids ensure_candidate_pool(recall_map) filtered_ids recommender.apply_filters(merged_ids, user_id) ranked_ids recommender.rank(filtered_ids) return {user_id: user_id, news_ids: ranked_ids[:limit]}逻辑说明load_recommender()在应用启动时把离线特征加载进内存之后所有请求都复用同一份数据。recommend按顺序调用多路召回、候选池闭合、过滤、排序最后按limit截断返回。参数说明limit默认是 10前端一次拿 10 条够首屏渲染。如果你的客户端是瀑布流式的可以调成 20但建议配合分页游标使用避免一次拉太多曝光数据。user_id用字符串接收方便对接业务方已有的用户 ID 体系。5.3 服务化改造的边界与注意点如果要把这套代码部署成线上服务有三个地方需要动离线特征文件的加载时机要后移到应用启动完成之后避免在请求路径上做磁盘 IO全局变量里的新闻池和用户画像如果是 dict多个 worker 进程各自加载一份内存消耗按进程数翻倍要注意容量规划如果要接 Redis加一个缓存层缓存排序结果对热门用户能省掉大量重复计算排序结果缓存是个容易忽略的点。新闻场景下同一用户短时间内重复请求排序结果基本不变可以设置 30 到 60 秒的缓存把接口耗时从毫秒级进一步压下去。6. 常见问题与避坑参数抖动、热度霸榜和并发覆盖这套代码我跑了两个星期遇到的最大问题不在算法本身而在参数和状态管理。挑四个影响最明显的写下来。6.1 相似度得分两极化现象相似度分数要么接近 1.0要么直接是 0.0排序结果基本被少数高分内容垄断。原因新闻正文分词后包含大量低频词两个长文本只要标题或核心段落重合余弦相似度就容易被拉得很高。解决在cosine_sim里增加最少命中词数的约束同时去掉通用背景词。def cosine_sim_with_min_hit(vec_a, vec_b, min_common2): common set(vec_a) set(vec_b) if len(common) min_common: return 0.0 return cosine_sim(vec_a, vec_b)改成这样之后相似度 0.6 的被推荐内容在人工复核里的相关度明显提升。6.2 热门新闻霸榜现象加了热度权重后头部几条热点新闻连续几天占据推荐位长尾内容没有展现机会。原因热度分数用的是原始点击率没有做平滑热门新闻的分数高出一个量级。解决把热度分改成对数形式并且在排序后强制插入一条新鲜内容。hot_score math.log(item[clicks] 1) * freshness_bonus实测改成 log 之后头部热点的排序优势被压缩到与普通内容可比的区间长尾内容重新回到推荐列表。6.3 曝光列表的并发覆盖现象用户连续刷新时同一篇新闻反复出现在第一屏。原因exposed_ids是模块级可变对象多线程下 A 请求写入的曝光数据还没落库B 请求已经用旧数据做过滤了。解决把曝光记录改为请求开始时从 Redis 读取一次过滤完写回写入和读取都走原子操作。如果只是在单机脚本里模拟多用户可以先用threading.Lock包住写操作但生产环境务必换 Redis。6.4 一个能“看见”排序改动效果的验证习惯我每次调权重之后都会先跑一个离线对照脚本把旧权重的排序结果和新权重的排序结果分别落成两份 JSON再对比同一用户位上 TOP10 的新闻重合度。重合度太高说明改动等于没改重合度太低说明参数变化太激进。同时也把相似度分数大于 0.9 的样本拉出来人工看一眼确认不是词频偏向导致的假高相似。从那以后我每次改推荐参数都强制先跑一遍对照脚本用数据说改对了没有而不是只看看界面效果。希望帮到你。本文还有配套的精品资源点击获取
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。