我先说一个结论新闻推荐系统听起来是个很唬人的东西实际上在算法选择正确的前提下一天时间真的能搭出一个能用的版本。这个项目我用 Flask 做 Web 层TF-IDF 做特征提取走通了“新闻文本 → 向量化 → 相似度匹配 → 推荐结果”的整条链路。没有协同过滤没有用户画像没有深度学习甚至没有神圣的 priority queue就靠 TF-IDF 这一个经典算法把推荐这件事讲明白了。这套方案适合谁适合刚接触推荐系统的同学、想给个人博客加一个“相关阅读”功能的开发者以及需要在最短时间内做技术 Demo 的团队。踩过一遍坑之后我可以负责任地说这个组合在文本强相关的内容推荐里性价比极高。1. 项目整体设计与思路拆解1.1 为什么偏偏是 Flask TF-IDF选 Flask 的理由其实很朴素它是我能想到的“让推荐算法快速暴露成 Web 服务”的最短路径。微框架意味着你不需要被 Django 那种重型的目录结构、ORM 和 Admin 后台绑住手脚。一个 app.py 文件就能把服务跑起来想加路由就加装饰器想换算法就改函数没有框架层面的心智负担。TF-IDF 的选择我是在“实用性”和“教学性”之间权衡之后定的。它算得上文本特征提取算法里最经典、最可控的一个。当你需要跟别人解释“为什么推荐这篇新闻而不是那篇”的时候TF-IDF 的每一个维度都是有含义的哪些词是这篇新闻独有的、哪些词是全库常见的全都能倒推出来。这种可解释性是深度学习向量表示暂时没法给你的。还有一个很重要的点TF-IDF 对算力的要求极低。我在一台 4 核 8G 的普通云服务器上做过压测处理 2000 篇新闻的相似度计算单次请求耗时在 50 到 150 毫秒之间完全够个人项目或者小团队内部工具用。这也意味着你不需要引入 Redis 缓存、消息队列或者向量数据库先把核心链路跑通后续再按需加组件才是正确姿势。1.2 推荐系统的最小闭环长什么样很多人一听“推荐系统”就想到淘宝首页那种千人千面的复杂架构但新闻推荐的最小闭环其实可以很朴素有一批新闻文本数据。把所有新闻文本做分词、去停用词、清洗。用 TF-IDF 算法把每篇新闻转换成一个向量。用户点击了一篇新闻系统拿这篇新闻的向量去和库里其他所有新闻的向量算相似度。按相似度从高到低排序返回 Top N 篇。这个流程里的核心逻辑就是“内容相似即推荐”属于基于内容的推荐Content-based Recommendation。它的优点是不需要任何用户的历史行为数据新用户来了就有推荐结果没有冷启动问题这在新闻场景里是很大的优势。我见过有人在这个闭环里塞了一堆组件用户画像、行为埋点、AB 实验平台、实时计算引擎……结果核心推荐逻辑反而没人讲清楚。这次我刻意砍掉所有非必要组件只保留上面的五步。事实证明越简单的链路越容易定位问题。1.3 数据怎么构造没有现成数据集怎么办做这个项目之前我手头并没有一个现成的新闻语料库。很多初学者会卡在这一步觉得没有数据就没法开始。其实有两个办法可以快速解决数据问题。第一个办法是爬虫。用 requests 加 BeautifulSoup 去抓取几个新闻站点的 RSS 或者列表页取标题、正文、发布时间、分类字段存成 JSON 或者 CSV。注意控制爬取频率我给你一个硬建议每请求一次至少间隔 1 到 2 秒单次总量控制在几百篇以内做技术验证完全够了。第二个办法是手工构造结构化数据。你可以从公开数据集网站下载新闻数据集也可以像我这次一样从几大门户的公开 RSS 里人工挑选 100 篇左右按“标题正文”格式组织成 JSON。不需要贪多100 篇就能把 TF-IDF 的区分度体现出来而且调试起来更方便——每篇新闻你都能记得大概内容方便验证推荐结果准不准。我的建议是第一版先用小数据集跑通全流程再考虑扩充语料。数据量小的时候你能肉眼判断推荐结果是否合理方便调试数据量大了以后反而只能靠指标评估了。2. 核心细节解析与实操要点2.1 TF-IDF 原理拆开讲它在算什么TF-IDF 的全称是 Term Frequency-Inverse Document Frequency中文叫词频-逆文档频率。很多人第一次接触这个概念会被公式吓到我拆开说。词频TF某个词在这篇新闻里出现了多少次。为了防止长文本占便宜通常会除以该新闻总词数得到标准化后的词频。比如“世界杯”在一篇 500 词的新闻里出现了 5 次TF 就是 5/500 0.01。逆文档频率IDF某个词在所有新闻里出现得多不多。分母是包含该词的文档总数再取对数。如果一个词只在 2 篇新闻里出现而总新闻数是 100 篇那么 IDF 就很大如果一个词在 98 篇新闻里都出现IDF 就很小甚至趋近于零。把 TF 和 IDF 相乘得到的就是 TF-IDF 值。它的直觉含义是一个词在当前新闻里出现得越多同时在全库出现得越少那么它对这篇新闻的“代表性”就越强。“世界杯”这个词如果在 100 篇体育新闻里 80 篇都出现它的区分度就低反而是“越位”“任意球”这些词能帮你判断一篇新闻更偏向体育的哪个细分方向。回到代码层面我用的是 scikit-learn 的 TfidfVectorizer它把分词、统计、标准化、IDF 计算、向量输出全部封装好了几行代码就能拿到底层稀疏矩阵。2.2 中文分词绕不过去的一道坎英文文本有天然的空格分割中文没有。所以做新闻推荐的第一件事就是分词。这里我踩过一个坑一开始贪图省事直接用 jieba.cut 把整段新闻切词然后丢给 TfidfVectorizer。结果发现 stop_words 参数传中文词列表根本不起作用后来才意识到 TfidfVectorizer 默认用的是空格分词直接喂给它的是一整段没有空格的汉字它的分词器会按照 byte 切效果惨不忍睹。正确的做法是先用 jieba 分词再用空格把词拼接起来最后再把处理后的文本交给 TfidfVectorizer。这个顺序不能反。我给你们一个标准的处理函数import jieba # 停用词表我用的哈工大停用词表精简版 stopwords set() with open(stopwords.txt, r, encodingutf-8) as f: for line in f: stopwords.add(line.strip()) def cut_and_clean(text): words jieba.lcut(text) # 过滤纯空白、单个字符、停用词 result [w for w in words if w.strip() and len(w) 1 and w not in stopwords] return .join(result)这段代码看起来简单但“len(w) 1”这个过滤条件非常关键。单字在新闻文本里大多是噪声比如“的”“了”“中”“为”即使不在停用词表里也几乎不携带语义信息。我愿意多过滤一层因为最终向量的维度会更干净。还有一个细节jieba 的分词结果里英文和数字会被单独切出来。如果你希望“iPhone 16”这种词不被拆开需要往 jieba 里加自定义词典。基础版本不需要但如果你做的是科技新闻推荐建议把常见科技产品词加进 jieba 的自定义词典里推荐效果提升非常明显。2.3 相似度计算余弦相似度的直觉理解文本向量化之后相似度怎么算最常用的是余弦相似度因为新闻文本长度差异很大欧式距离会被文本长度带偏余弦相似度则关注向量的方向而不是模长。用生活化类比两个文本向量的夹角越小说明它们在高维空间里的方向越一致。就像两个人聊天的风格都偏向“技术 开源 编程”哪怕一个人话多、一个人话少他们的话题方向还是接近的。代码实现有两种方式。一是直接对查询向量和全库向量矩阵做矩阵乘法这种方式写起来很省事from sklearn.metrics.pairwise import cosine_similarity # query_vector 是一行, tfidf_matrix 是 N 行 scores cosine_similarity(query_vector, tfidf_matrix).flatten()二是嫌 sklearn 太重的话用 numpy 手写一个计算函数import numpy as np def cos_sim(a, b): dot np.dot(a, b) norm_a np.linalg.norm(a) norm_b np.linalg.norm(b) if norm_a 0 or norm_b 0: return 0.0 return dot / (norm_a * norm_b)两种我都测过结果一致。手写版本更灵活适合你想对分数做二次加工的场景sklearn 版本更规范代码更少。我实际项目里用的是手写版本因为我想额外处理“标题权重更高的字段”自定义拼接向量比较方便。3. 实操过程与核心环节实现3.1 环境准备与项目目录规划先说环境。Python 版本建议 3.9 以上我用的是 3.10。需要装的库就四个flask、jieba、scikit-learn、pandas。装的过程中如果遇到 scikit-learn 编译很慢的情况建议直接用预编译的 wheel 包或者用 Anaconda 环境别在源码编译上浪费时间。我当时用 pip 安装花了两分钟属于正常范围。项目目录我建议这样规划news_recommender/ ├── app.py # Flask 主入口 ├── recommender.py # 推荐算法核心逻辑 ├── data/ │ └── news.json # 新闻数据 ├── stopwords.txt # 停用词表 ├── templates/ │ └── index.html # 前端展示页 └── requirements.txt为什么把推荐逻辑单独拆成 recommender.py因为 Flask 路由里的代码越少越好你不想每次调试推荐算法都要去路由层找问题。app.py 只负责创建 Flask 实例、注册路由、调用 recommender 的函数这样一个清晰的分离后续你要是想把这个推荐模块改成命令行工具或者接入 API 网关直接 import 就行。在写任何代码之前先把 requirements.txt 建出来flask2.0 jieba0.42.1 scikit-learn1.0 pandas1.33.2 数据加载与 TF-IDF 向量构建我用一个 news.json 文件存储新闻数据格式大概是这样的[ { id: 1, title: Python 成为年度最受欢迎编程语言, content: 据最新开发者调查显示Python 在多个维度上排名第一……, category: 科技, publish_time: 2025-01-10 10:30:00 }, { id: 2, title: 央行发布最新货币政策报告, content: 央行今日发布报告指出将保持流动性合理充裕……, category: 财经, publish_time: 2025-01-10 11:00:00 } ]加载和预处理的核心代码如下import json import pandas as pd import jieba from sklearn.feature_extraction.text import TfidfVectorizer class NewsRecommender: def __init__(self, json_pathdata/news.json): self.df self.load_data(json_path) self.corpus self.df[title] self.df[content] self.processed self.corpus.apply(cut_and_clean) self.vectorizer TfidfVectorizer() self.tfidf_matrix self.vectorizer.fit_transform(self.processed)这里有个设计要点我把标题和正文做了拼接但拼接时的空格很重要。分词后的文本是用空格隔开的词序列TfidfVectorizer 会按空格切分所以加载原始文本时用 把标题和正文连起来就能保证标题里的词和正文里的词处于同一个“文档”里同时标题里的词权重也自然被 TF-IDF 计算到了。构建 TF-IDF 矩阵是整个项目离线阶段的核心工作。TfidfVectorizer 会自己完成小写化、去除标点、词频统计、IDF 计算、L2 归一化这些步骤。fit_transform 之后得到一个稀疏矩阵形状是 (新闻数, 词汇数)。如果你的新闻数是 100词汇量可能在 1000 到 3000 之间这个规模的矩阵在内存里完全不是问题。我在 measure 阶段发现如果数据量超过 1 万篇fit_transform 的时间会从毫秒级涨到秒级。这个是在预期内的。真要扛大数据可以先把文本预处理做好再用增量式 IDGIncremental Document Frequency方案但那个是后话第一版不用想太多。3.3 Flask 路由设计与推荐接口推荐算法跑通之后Flask 层就很简单了。我设计了两个路由/返回新闻列表页面展示所有新闻的标题和摘要。/recommend/int:news_id返回这篇文章的详情以及基于这篇文章的 Top 5 推荐。核心代码是这样的from flask import Flask, render_template, request, jsonify app Flask(__name__) recommender NewsRecommender() app.route(/) def index(): news_list recommender.df[[id, title, category]].to_dict(records) return render_template(index.html, news_listnews_list) app.route(/news/int:news_id) def news_detail(news_id): current_news recommender.df[recommender.df[id] news_id] recommendations recommender.get_recommendations(news_id, top_n5) return render_template(news_detail.html, newscurrent_news.iloc[0], recommendationsrecommendations) app.route(/api/recommend/int:news_id) def api_recommend(news_id): recommendations recommender.get_recommendations(news_id, top_n5) return jsonify(recommendations)推荐函数 get_recommendations 的逻辑是根据 news_id 找到对应新闻的向量索引然后计算它和所有新闻的余弦相似度排除自身排序后取 Top N。def get_recommendations(self, news_id, top_n5): idx self.df.index[self.df[id] news_id][0] query_vec self.tfidf_matrix[idx] scores cosine_similarity(query_vec, self.tfidf_matrix).flatten() # 排除自身 scores[idx] -1 top_indices scores.argsort()[-top_n:][::-1] results [] for i in top_indices: results.append({ id: int(self.df.iloc[i][id]), title: self.df.iloc[i][title], category: self.df.iloc[i][category], score: round(float(scores[i]), 4) }) return results我发现一个容易被忽略的细节scores[idx] -1这行不能省。如果不把当前文章自己的分数排除它永远是相似度最高的因为和自身完全一致余弦相似度是 1.0那么第一条推荐结果永远是自己这个 Bug 在初版我没注意看起来结果是“推荐了自己”很尴尬。这里补充一个我的偏好接口返回里带上 score 分数方便调试。你可以肉眼判断推荐结果是否合理如果推荐的第 1 名分数是 0.9 以上说明和原文高度相关如果在 0.3 以下说明推荐结果基本是“矮子里拔高个”这时候你要么增加语料要么调整文本预处理逻辑。我见过很多项目不返回分数导致效果差了也不知道怎么排查。3.4 前端页面快速搭建前端我用的方案是 Flask 默认的 Jinja2 模板配合 Bootstrap CDN 做样式。不需要前后端分离不需要 Vue/React第一版目标是能点能看能用就行。news_detail.html 里推荐结果的展示核心片段如下h2相关推荐/h2 ul classlist-group {% for item in recommendations %} li classlist-group-item a href/news/{{ item.id }} span classbadge bg-secondary{{ item.category }}/span {{ item.title }} span classtext-muted相似度: {{ item.score }}/span /a /li {% endfor %} /ul这里有个小设计显示相似度的分数。虽然在用户界面上普通用户不理解“相似度 0.878”是什么意思但作为开发者这个数值在你调试阶段极其有用。你一眼就能看出哪些推荐排在上面以及它们和原文的相关程度这比每次去日志里翻分数效率高得多。我还做了“点击新闻进入详情页详情页底部展示推荐列表”的单页交互没有做复杂的前端路由。从产品视角看这个形态已经能用从技术视角看模板渲染 服务端计算所有逻辑都在后端部署也很直接。4. 常见问题与排查技巧实录4.1 中文分词与编码的经典坑这个项目的读者里十个人估计有八个人会碰到中文编码问题。具体表现是JSON 文件里的中文读进来变成乱码或者 Flask 页面渲染出来的中文是乱码。排查思路很简单JSON 文件读取时指定encodingutf-8Flask 的render_template理论上会自动处理 UTF-8但如果你的 HTML 模板里忘了写meta charsetutf-8浏览器就会用默认编码解析中文必乱。这两个点我都踩过。另一个隐蔽的问题是 jieba 的默认词典在有些 Linux 环境下路径包含空格或者权限问题导致第一次调用分词时报错。解决办法是确保 jieba 是正常 pip 安装的不要在 root 路径手动解压 dict.txt。如果确实有自定义词典需求也建议用jieba.initialize()提前初始化。4.2 数据量太小导致推荐结果不理想用 100 篇新闻测试时有一个常见现象推荐结果的相似度分数普遍偏低Top 1 的分数可能只有 0.3 到 0.4。这不是算法写错了而是语料库太小、主题太少导致所有新闻向量的方向比较分散很难出现高分相似。换句话说这是“数据量限制下的正常结果”不是代码 Bug。遇到这种情况我建议先把数据扩充到 500 篇以上再评价效果。如果你不想找数据有一个变通方案缩小特征范围。比如只对标题做 TF-IDF因为标题里的词更精炼而且新闻标题的用词往往已经能体现核心主题。我试过只对标题做推荐相似度通常会比“标题正文”更高。原因很好理解正文里有很多无效描述反而稀释了“标题关键词”的权重。另一个优化方向是对正文做截断只取前 300 字。新闻导语通常就是全文的精炼版正文后半段更多是背景信息用来做相似度计算反而干扰更大。实测下来截断后相似度分数的区分度明显更好。4.3 相似度计算的性能优化当我尝试用 2000 篇新闻去做单次推荐时发现每次请求大约耗时 300 毫秒对一个内部工具来说勉强能接受但已经能感到卡顿。原因是我每次请求都重新计算查询向量和全库的余弦相似度这等于做了一次全量矩阵乘法。第一版优化把 TF-IDF 矩阵预先加载到内存中查询时只取一行向量做乘法这样能少一次 IO。第二版优化相似度计算结果可以按新闻做缓存。用户点击比较集中的前几十篇新闻它们的推荐结果被多次请求的概率很高加一个简单的字典缓存能省掉 70% 以上的重复计算。更好的设计是每天定时从新闻库生成一遍“每篇新闻的 Top N 推荐”存到 JSON 或者数据库里在线请求时直接读缓存这样在线服务的耗时可以压到 10 毫秒以内。我的项目最终就是这么干的。原理上这叫“离线计算、在线读取”是推荐系统工程化的基本思路。我还试过用 FaissFacebook 开源的向量检索库来做近似的最近邻搜索。对于几千条数据规模Faiss 的优势不明显启动和建索引的复杂度反而增加了数据量到十万级别Faiss 才真正值得引入。所以对于目前阶段的这个项目不做这个优化是对的。5. 部署与后续扩展的正确姿势5.1 用 Gunicorn 部署 Flask 应用的几个注意点开发环境下flask run足够但上线部署要换 Gunicorn因为 Flask 自带的开发服务器是单进程、单线程扛不住真实流量。我的部署命令是gunicorn -w 4 -b 0.0.0.0:8000 app:app-w 4表示开 4 个 worker 进程。但这里有个坑每个 worker 都会独立加载一次 NewsRecommender也就是说 4 个 worker 就会有 4 份 TF-IDF 矩阵在内存里。2000 篇新闻的矩阵很小这个问题不显现但当你把语料库扩展到 10 万篇4 份矩阵直接把内存吃满。应对办法有两个一是把数据加载改成单例模式只在主进程加载worker 通过 fork 继承这样所有子进程共享操作系统层面的内存页二是干脆减少 worker 数量新闻推荐是 IO 密集型和 CPU 密集型的混合场景2 个 worker 往往比 4 个更稳妥。我实测下来4 核机器上开 3 个 worker 效果最好具体数字因机器而异不用迷信默认值。还有一个很容易忽略的细节线上部署时要把 debug 模式彻底关掉因为 Flask 的调试器会暴露内部代码和变量安全风险非常大。正确的姿势是设置环境变量FLASK_ENVproduction并且在 Gunicorn 启动命令里不带--reload选项。5.2 从 TF-IDF 到向量化推荐的扩展方向如果你做完这个项目觉得推荐效果还不错下一步可以向两个方向深挖。第一个方向是把 TF-IDF 替换成 Word2Vec / Doc2Vec 或者 BERT 这类深度文本表示方法。我记得最初把 TF-IDF 换成 Doc2Vec 时疫情新闻和体育新闻的区分度明显提升因为 Doc2Vec 考虑了词序和上下文推荐结果更“语义化”。代价是模型文件增大、推理耗时增加、部署复杂。在动手迁移之前建议先评估当前 TF-IDF 方案的准确率是否真的不够用。第二个方向是引入用户行为数据做协同过滤。TF-IDF 负责“内容相似”协同过滤负责“不同用户都看了什么”两者结合往往效果更好。这也是目前主流新闻推荐系统的基本架构。你可以接一个简单的埋点记录用户在页面上的点击次数然后做一个 User-Based 或 Item-Based 协同过滤用加权平均的方式融合 TF-IDF 推荐结果。我个人在实际操作中的一个体会是别急着上复杂模型。先用 TF-IDF 跑通闭环记录瓶颈在哪里再去针对性地升级某个环节这个顺序能帮你少走很多弯路。最后再分享一个小技巧在 index.html 里加一个“随机按钮”每次点击随机展示一条新闻。这个功能看起来不起眼但它让你在调试推荐效果时特别方便——不用每次手动换不同的新闻一键随机看不同的推荐列表快速发现哪些新闻的推荐结果有问题。这个小工具我到现在还在用算是这个项目里的隐藏彩蛋。