☰
用TF-IDF和Flask从零搭建新闻推荐系统实战
2026/10/2 14:38:22 网站建设 项目流程

推荐系统在多数人的直觉里,已经被深度学习、排序模型、向量召回这些词包围了。但真到落地的时候,我反而经常把TF-IDF这种“老古董”放在第一版。最近我用Flask从零到一搭了一套新闻推荐系统,整体链路非常清晰:对新闻语料做中文分词,用TF-IDF特征提取算法把每篇文章转成高维文本向量,再通过余弦相似度找到内容最相近的若干篇,最后用Flask包一个HTTP接口对外提供服务。整套东西在一台普通开发机上就能跑起来,几千篇新闻规模下,效果完全能打,而且每个环节的问题都能被清晰地解释。

这篇文章适合两类人看:一是Flask学完基础语法后想做个完整项目的人,二是刚接触推荐系统、想搞清楚内容推荐底层逻辑的新手。我会把为什么选TF-IDF、数据从哪里来、中文分词怎么处理、相似度怎么算、接口怎么封装,以及我实际踩过的坑全部写清楚。

1. 我先说清楚:为什么新闻推荐能用TF-IDF做基线

推荐系统本质上是个匹配问题:把“用户可能喜欢的东西”和“现存的内容”进行匹配。匹配的前提是“内容可计算”,而TF-IDF就是让计算机“读懂”新闻的一种最朴素、最稳定的方式。很多人上来就学DSSM、双塔模型,却忽略了文本理解才是推荐系统的地基。

1.1 新闻场景和电商场景的推荐逻辑不一样

拿电商商品推荐来对比:商品有类目、品牌、价格、销量这些结构化字段,用户行为数据密集,协同过滤很容易找到“买了A的人还买了B”。但新闻是纯文本内容,而且用户行为非常稀疏——一个人一天可能只点三五条新闻,而且新闻的生命周期很短,今天的热点明天就没人看了。协同过滤在新闻冷启动阶段几乎瘫痪,新入库的文章没有点击数据,就无法被推荐出去。

新闻推荐更适合“以内容定相似”的思路:给定一条新闻,找出语料里“写的是同一件事”的其他新闻。这种基于内容相似度的匹配不依赖用户行为,新闻一入库就能参与推荐,天然规避了冷启动问题。

1.2 TF-IDF在这套系统里到底起了什么作用

TF-IDF全称是“词频-逆文档频率”,它在推荐系统里扮演的角色很简单:把一段自然语言文本变成一个向量,让两条新闻的相似程度可以用数学来度量。

拆开看,TF是“这个词在当前文章里出现得多不多”,IDF是“这个词在全部新闻里是否稀有”。两者相乘得到每个词的权重。比如“人工智能”在一篇新闻里出现8次,而在整个语料里只有少数文章提到它,这个词的TF-IDF权重就很高;反过来,“记者”“报道”这样的词出现再频繁,会因为IDF很低而被压制。

把一条新闻里所有词的权重按顺序排列成一个向量,这篇新闻就有了固定的“坐标”。两条新闻的向量越接近,内容就越相似。这套逻辑对推荐系统入门者来说,比一上来就背神经网络的损失函数要好理解得多。

1.3 这套baseline适合你的项目吗

说实话,TF-IDF方案不适合所有场景。但如果你满足以下条件,它比上深度模型更划算:

  • 语料规模在几万条以内,单台机器可以完成计算;
  • 业务目标是“相似内容推荐”而非“猜用户喜欢什么新话题”;
  • 模型结果需要可解释,想让运营或编辑知道“为什么推荐这篇文章”。

我见过不少团队,数据量就几千条,非要去跑一个几十层的神经网络。训练出来的结果不一定比TF-IDF好,还难调试、难维护。先从简单的baseline跑起来,拿到一个可视化的、可向业务方解释的结果,远比直接上豪华配置重要。

2. 动手前的三件套:数据、中文分词和相似度度量的选择

写代码之前,有三件事必须先定下来:数据从哪来、中文文本怎么切成词、用什么公式算相似度。这三件事决定了整个项目的质量,也是网上大多数教程一笔带过的地方。

2.1 数据获取:不要求大,但要求干净

做个人项目时,很多人卡在第一步:没有新闻数据。我的建议是不要一上来就追求百万级数据集,几十条到几百条的干净样例就足以跑通流程。常见的数据来源有两种:用公开的数据集做实验,或者自己写脚本抓取RSS源。用RSS抓取时,务必先看目标网站的robots约定,控制请求频率,仅用于个人学习,别把别人服务器搞挂了。

数据清洗也很重要。新闻文本里最常见的脏数据是重复内容和广告噪音。保存到本地时,我习惯用一个元组结构存放:

news_items = [ { "news_id": 1, "title": "中国人工智能产业规模持续扩大,多家企业发布新模型", "content": "(正文内容)", "category": "科技" }, ]

news_id必须是稳定的整数编号,后面所有索引操作都依赖它。别小看这一步,项目做到后面你就会发现,ID和行号映射混乱是最大的坑之一。

2.2 中文分词跳不过去,直接用jieba

英文文本天然用空格分隔单词,但中文句子是连成一片的。“中国人工智能发展迅速”这句话,计算机不知道是“中国/人工智能/发展/迅速”,也不知道是“中国/人工/智能/发展/迅速”。所以在计算TF-IDF之前,必须先分词。

分词工具我选了jieba。虽然现在有很多更复杂的分词模型,但jieba足够轻量,效果在新闻场景下完全够用。核心用法就这么几行:

import jieba def chinese_tokenizer(text): words = [w.strip() for w in jieba.lcut(text) if w.strip()] return words

jieba.lcut返回一个分词列表,顺手把空白字符过滤掉。后面把这个函数作为tokenizer参数传给TfidfVectorizer,就能无缝衔接。

2.3 相似度为什么首选余弦,而不是欧氏距离

两个向量之间的相似度,常见选择有欧氏距离、曼哈顿距离、余弦相似度等。我做新闻相似推荐时直接锁定余弦相似度,原因很实际:新闻文本长短差异大,一条标题型短讯可能只有几十个字,一篇深度报道可能几千字。TF-IDF向量维度一样,但向量的模长差异很大。

余弦相似度只看两个向量之间的夹角,不看模长,这在文本场景里等价于“两个文本集中在哪些词上”,而不是“两边总共用了多少词”。欧氏距离对文本长度太敏感,非常容易被长文本带偏。做一个简单类比:两个人在不同房间里,各自追求不同方向的事业目标,欧氏距离会觉得“他们离得远”,但余弦相似度会觉得“他们方向一致”。新闻推荐要的是“方向一致”,所以选余弦。

3. 核心实现:TF-IDF向量化与推荐函数的完整代码

现在进入正题。项目主流程分三步:构建TF-IDF矩阵、计算相似度矩阵、写推荐函数。我先贴一个完整可跑的建模脚本,再逐段拆解里面的设计选择。

3.1 用TfidfVectorizer加jieba完成中文向量化

这里是项目最核心的代码,也是我反复调整过的地方:

import jieba import numpy as np from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.metrics.pairwise import cosine_similarity # 示例语料,真实项目请替换成你自己的新闻库 news_data = [ {"news_id": 1, "title": "中国人工智能产业规模持续扩大,多家企业发布新模型"}, {"news_id": 2, "title": "人工智能在医疗影像识别中的应用取得新进展"}, {"news_id": 3, "title": "足球联赛决赛精彩纷呈,冠军球队捧杯庆祝"}, {"news_id": 4, "title": "人工智能写作工具引发内容创作行业热议"}, ] contents = [item["title"] for item in news_data] def chinese_tokenizer(text): words = [w.strip() for w in jieba.lcut(text) if w.strip()] return words vectorizer = TfidfVectorizer( tokenizer=chinese_tokenizer, stop_words=["的", "了", "是", "在", "和", "及", "等"], min_df=1, max_df=0.8, ) tfidf_matrix = vectorizer.fit_transform(contents) print(tfidf_matrix.shape) # (4, 若干特征词)

有几个细节必须说明白。tokenizer=chinese_tokenizer表示sklearn不再用默认的英文空格分词,直接用我定义的中文分词函数。min_df=1表示一个词至少要在1篇文档中出现才保留,max_df=0.8表示如果某个词在超过80%的文档里都出现,就认为区分度太低,直接丢弃。这两个参数能自动过滤掉一部分废话词。

3.2 算相似度矩阵,但要小心内存

接下来用cosine_similarity计算两两之间的相似度:

similarity_matrix = cosine_similarity(tfidf_matrix, tfidf_matrix)

这个矩阵的形状是“新闻条数x新闻条数”。第i行第j列的值表示第i篇和第j篇新闻的余弦相似度,取值范围在0到1之间,对角线必然为1(自己和自己完全相似)。如果新闻数量只有几百条,这个矩阵可以放心全量计算;但如果到了几万条,矩阵元素会膨胀到几亿个浮点数,内存直接爆炸。这个问题我会在后面的坑里专门展开。

3.3 推荐函数:排序加排除自身

有了相似度矩阵,推荐逻辑就是“取某一行,按分数从高到低排,去掉自己,取前N个”。我在项目里写成了这样:

def recommend(news_id, top_n=5): scores = list(enumerate(similarity_matrix[news_id])) scores.sort(key=lambda x: x[1], reverse=True) results = [] for idx, score in scores[1: top_n + 1]: results.append({ "news_id": news_data[idx]["news_id"], "title": news_data[idx]["title"], "score": round(float(score), 4), }) return results

注意for idx, score in scores[1: top_n + 1]这个切片。因为排序后第一位必然是它自己,相似度是1,所以要从索引1开始跳过自己。这一步不处理的话,推荐结果第一条永远是当前新闻本身,属于低级但很常见的错误。

4. 推荐接口的Flask封装与运行细节

算法部分跑通之后,下一步是让推荐能力可以被外部调用。Flask在这里的价值是轻量、零依赖、几十行代码就能起一个稳定的API服务。

4.1 把建模脚本和Web服务分离

很多人喜欢把所有代码塞在一个文件里,小程序没关系,但项目一旦涉及模型加载、数据更新,就非常难受。我用的目录结构是:

news_recommender/ ├── build_model.py # 训练TF-IDF并保存模型文件 ├── app.py # Flask Web服务 ├── models/ │ ├── vectorizer.pkl │ ├── tfidf_matrix.pkl │ └── similarity_matrix.npy ├── data/ │ └── news.db └── requirements.txt

build_model.py负责从数据库读取新闻、分词、向量化、计算相似度、保存模型文件。app.py只负责加载模型、处理请求、返回JSON。分离之后,模型要更新就重跑一次build_model.py,不用重启Web服务逻辑。

4.2 Flask API怎么设计才顺手

我的推荐接口设计如下:

from flask import Flask, request, jsonify import numpy as np import joblib app = Flask(__name__) # 服务启动时一次性加载模型,避免每个请求重复加载 vectorizer = joblib.load("models/vectorizer.pkl") tfidf_matrix = joblib.load("models/tfidf_matrix.pkl") similarity_matrix = np.load("models/similarity_matrix.npy") news_list = [] # 真实项目里从数据库读入 @app.route("/api/recommend", methods=["GET"]) def recommend_api(): news_id = request.args.get("news_id", type=int) top_n = request.args.get("top_n", default=5, type=int) if news_id is None: return jsonify({"code": 1, "message": "缺少news_id参数"}), 400 if news_id < 0 or news_id >= similarity_matrix.shape[0]: return jsonify({"code": 1, "message": "news_id不存在"}), 404 scores = list(enumerate(similarity_matrix[news_id])) scores.sort(key=lambda x: x[1], reverse=True) results = [] for idx, score in scores[1: top_n + 1]: results.append({ "news_id": int(news_list[idx]["news_id"]), "title": news_list[idx]["title"], "score": round(float(score), 4), }) return jsonify({"code": 0, "data": results}) if __name__ == "__main__": app.run(host="0.0.0.0", port=5000, debug=False)

接口为什么用GET而不是POST?因为这里的推荐请求是幂等的——同一个news_id和top_n永远返回相同结果,传参也简单,浏览器地址栏直接能测。若以后要传用户历史行为序列,数据量大且复杂时再考虑POST。

浏览器验证方式非常直接:启动服务后,访问这个地址就能看到JSON结果。

http://127.0.0.1:5000/api/recommend?news_id=1&top_n=3

4.3 部署前的模型序列化:pkl和npy的选择

模型文件用joblib存取是标准做法,比pickle更高效。相似度矩阵是纯Numpy数组,直接存成.npy,加载速度快,也不用考虑跨版本的兼容性问题。这里有个容易被忽略的细节:joblib.load("models/tfidf_matrix.pkl")加载出来的对象是稀疏矩阵,计算时最好保持稀疏格式,别随手调用.toarray()转成稠密矩阵,否则几百MB内存瞬间被吃光。只有在打印调试时我才会转稠密矩阵看一眼。

5. 跑通之后我踩过的四个大坑

代码跑起来只是开始。我把项目从“能演示”调到“能稳定用”的过程中,踩了四个实实在在的坑,每个都花了不少时间排查。这里完整复盘给你。

5.1 停用词没处理干净,推荐结果词不达意

第一版我图省事,停用词表只写了“的、了、是、在、和”。结果推荐出来的结果经常莫名其妙:一篇写人工智能的新闻,推荐的却是另一篇“记者在北京报道……”的新闻,因为两篇都高频出现“记者”和“报道”这类词。这类词在每篇新闻里都有,IDF本来就低,但在样本量少的时候依然能混进高权重区间。

解决方案是准备一份稍微像样的中文停用词表,并且按新闻场景补充“记者”“报道”“昨日”“今天”“编辑”这类词。注意,停用词表不是越多越好,删过头会把一些有实际含义的词也过滤掉,影响推荐细粒度。我建议先用默认表跑一版,打印出每条新闻的top高频词,看着不顺眼的再加进停用词表。

5.2 全量相似度矩阵把内存吃爆了

这是我吃过的最大教训。我用cosine_similarity(tfidf_matrix, tfidf_matrix)计算全矩阵,几千条新闻时毫无感觉,等把语料扩展到两万条,发现进程直接把16G内存吃光,机器卡死。原因很简单:两万乘两万的矩阵,每个浮点数占8字节,大概是1.6万个浮点乘以粒度,实际内存高达约3GB。这还没算稀疏矩阵转稠密时的峰值占用。

最优解是不要预先计算全矩阵,而是对单条推荐请求实时计算一行相似度:

def recommend_row(news_id, top_n): row_vector = tfidf_matrix[news_id] sim_scores = cosine_similarity(row_vector, tfidf_matrix).flatten() candidate_ids = np.argsort(sim_scores)[::-1][1: top_n + 1] return candidate_ids

这样每个请求只算一行的相似度,内存开销从N乘以N降到N,在万级语料下完全可接受。做产品原型时,我通常先用全矩阵是为了调试方便,上线前再切换成按行实时计算。

5.3 新新闻入库了,模型还是看不到它

业务方不断有新闻入库,但推荐结果里永远没有新文章。因为这个TF-IDF模型是一次性训练的:fit_transform确定了整个词表,新文本如果不在当时的语料里,就无法参与相似度计算。这不是代码bug,而是方案本身的特性。

解决思路有两个层面。低频更新场景下,每天定时重跑一次build_model.py,重新训练并更新模型文件。高频更新场景下,要用增量方案:新文章入库时,直接用已经训练好的vectorizer.transform()把新文本转成向量,再追加到tfidf_matrix中。注意这里只能用transform,绝不能用fit_transform,否则整个词表会重建,前面所有向量都得跟着重算。

new_tfidf = vectorizer.transform([new_content]) tfidf_matrix = np.vstack([tfidf_matrix.toarray(), new_tfidf.toarray()])

但这里有个业务决策问题:vectorizer本身也需要定期用全量语料重训练,才能捕捉新的热点词。所以实践中一般是:增量方法保证当天新闻能上线,每天晚上再全量重建保持词表新鲜。

5.4 news_id和矩阵行号错位,推荐结果满盘皆输

这个坑最隐蔽,也最致命。数据库里的news_id是自增主键,可能从1000开始;而矩阵的行号是从0开始的数组索引。如果直接把数据库的news_id当作矩阵行号使用,轻则推荐错几条,重则数组越界崩服务。

正确做法是先在内存里维护一个id_to_index映射字典,所有查询先通过映射转换成矩阵行号,返回结果时再反向映射回数据库ID。我在第一版就吃过这个亏,推荐出来的“相关新闻”完全对不上号。排查下来发现,问题不在算法,而在ID映射关系上。这个教训也说明,推荐系统里索引管理比算法本身更容易出bug。

6. 怎么从“相似内容推荐”升级到“个性化推荐”

到此为止,系统能做的是“看到一篇新闻,找到相似文章”。但如果要做“每个用户看到不同的推荐”,还需要在相似内容之上叠加用户行为信息。这并不复杂,TF-IDF框架还能继续复用。

6.1 最简单的用户画像:点击历史均值向量

思路是把用户最近点击过的若干新闻向量取平均,得到用户的“兴趣向量”,再用这个向量和全库新闻计算余弦相似度:

def build_user_profile(clicked_ids, id_to_index): rows = [id_to_index[i] for i in clicked_ids] profile_vector = np.asarray(tfidf_matrix[rows].mean(axis=0)).flatten() return profile_vector def recommend_for_user(profile_vector, top_n=10): scores = cosine_similarity([profile_vector], tfidf_matrix).flatten() top_indices = np.argsort(scores)[::-1][:top_n] return top_indices

这个方法的好处是几乎不增加复杂度,一行mean就把用户历史行为编码进去了。缺点也很明显:它假设用户兴趣是固定且平均的,如果用户今天想看科技,明天想看体育,平均向量会变得含混不清。但这仍是“从内容推荐到个性化推荐”的最佳第一步,因为逻辑简单,随时可以叠加。

6.2 用召回加精排的思路优化计算效率

当语料量达到百万级,即便是按行计算余弦相似度,全量扫一遍也不划算。这时可以用两层结构:召回层先用倒排索引等粗筛方式,从全库挑出几百个候选,再对候选做精确的TF-IDF余弦排序。倒排索引的朴素实现并不复杂:每个词对应一个包含该词的新闻ID列表,查询时取目标新闻的top权重词,收集这些词的文档列表做并集,得到候选集合。这样把计算量从“全库N”降到了“候选数量”。

到了这一步,基本上就是工业级推荐系统的雏形了:召回负责快,精排负责准。

6.3 什么时候该放弃TF-IDF,换语义模型

TF-IDF有个天然缺陷:它只能识别“词面相同”的内容,识别不了“语义相似但不同词”的句子。比如“欧冠决赛”和“欧洲冠军联赛巅峰对决”,词面几乎没有交集,TF-IDF判定它们不相似,但人一眼就知道说的是同一件事。所以当你的新闻语料里充满这种同义表达,或者业务方明确要求“跨词面语义召回”时,就该考虑用句向量模型加向量索引来替换或补充TF-IDF。

不过请记住:即使换了模型,也不需要把TF-IDF这套链路推翻。用户的兴趣向量、倒排索引召回思路、Flask API封装,全都原样保留。TF-IDF的价值在于,它把推荐系统的完整骨架训练了一遍。

我现在做任何推荐项目,第一版都会先跑一套TF-IDF基线,把推荐结果打印出来人工看几遍。这套流程能让我在十分钟之内判断语料质量、停用词配置和相似度逻辑是否合理。等基线结果稳定了,再决定要不要往深度模型迁移。做技术选型时,先有baseline,再谈优化,这条经验比任何模型都值钱。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询