做一个知识推荐平台这件事,最早是我在整理自己书签收藏夹时冒出来的念头。收藏了上千篇技术文章,真正回头看的没几篇,想找某篇看过的内容更是抓瞎——这个现象背后藏着一个典型的信息过载问题。后来我在某公司实习时接触过几条资讯类产品的推荐链路,才发现推荐系统的实现并没有想象中那么玄乎,用 Python 生态里的 Django 完全可以独立落地一套够用的知识推荐平台。这个项目围绕“内容管理 + 用户行为 + 推荐算法”三条主线展开,前后花了两周业余时间完成核心功能,部署后稳定运行至今。这篇文章会把整个设计思路、算法选型、代码实现和踩过的坑完整记录下来,想自己搭一个类似系统的朋友可以直接照着做。
整个项目从需求上看其实很清晰:需要一个能管理知识内容(文章、教程、笔记)的后台,一个能让用户注册、浏览、收藏、评分、评论的前台,再加上一个能根据用户历史行为推荐知识的引擎。技术栈选型也很明确,后端用 Django 4.2,数据库用 MySQL(开发环境用 SQLite 也没问题),前端用 Bootstrap 5 加上简单的模板渲染,推荐算法初期用基于物品的协同过滤和基于内容的 TF-IDF 标签匹配做混合。下面按模块拆开讲。
1. 项目整体设计与思路拆解
1.1 为什么选 Django 而不是其他框架
选 Django 不是因为它是“最火的”,而是因为它在这个场景下确实合适。知识推荐平台的核心是内容管理 + 用户体系 + 数据统计,这三样正是 Django 的强项。Django 自带的 Admin 后台可以直接当作内容管理后台用,省掉了单独开发一套后台管理系统的工作量。用户认证、Session 管理、CSRF 防护这些安全相关的功能,框架也都内置了,不需要自己造轮子。
我对比过 Flask 和 FastAPI。Flask 足够轻,但用户认证、Admin、ORM、迁移这些都要自己组合,做一个小项目可能不明显,一旦模块多起来,光是把这些基础设施对齐就要花不少时间。FastAPI 更适合前后端分离、高并发的接口场景,但知识推荐平台这种以服务端渲染为主、SEO 有一定要求(让搜索引擎能抓取文章页)的项目,Django 的模板系统反而更顺手。Django 的 ORM 在查询和事务处理方面也稳定,配合 Django Debug Toolbar 排查性能问题很方便。
补充一句:Django 的“全家桶”风格看起来不像微框架那么灵活,但做内容型产品正好合适。如果后期要拆分微服务,Django 也能提供 REST Framework 写出标准接口,不存在被框架绑死的问题。
1.2 平台的功能模块怎么划分
我一开始把功能划得比较细,后来发现过度设计反而是负担,最后收敛成四个大模块:
- 用户模块:注册、登录、退出、个人中心、密码重置。Django 自带的 auth 应用覆盖了绝大多数需求,只需要扩展一个 Profile 模型来存推荐画像相关的字段。
- 内容模块:文章(知识条目)的发布、编辑、分类、标签、Markdown 渲染、搜索。后台用 Django Admin,前台是文章详情页和列表页。
- 行为模块:记录用户的浏览、收藏、点赞、评分、评论行为。推荐算法依赖的就是这些行为数据,所以这个模块的埋点设计很关键。
- 推荐模块:离线更新相似度矩阵 + 在线实时候选推荐。核心逻辑是物品协同过滤,辅以内容标签相似度,最后经过规则过滤和排序输出给用户。
模块划分的原则是“高内聚、低耦合”。行为模块只负责数据记录,不关心推荐算法怎么用;推荐模块只读行为数据,不直接操作用户表。这样后期替换推荐算法时,只改推荐模块,不影响其他部分。
1.3 推荐策略的取舍:从简单到可用的进化
很多人一上来就想上深度学习模型、知识图谱,我建议先克制住。对于中小型知识平台,数据量通常在万级到十万级,这时候用基于物品的协同过滤(Item-based Collaborative Filtering)加上基于内容的推荐(Content-based)混合,性价比最高。
选物品协同过滤而不是用户协同过滤,原因很实际:用户的行为矩阵非常稀疏,而且用户数量是在增长的,每次都要对全量用户算相似度,计算量会越来越不可控。物品相对稳定,文章数量虽然也在增长,但增速远低于用户,物品相似度矩阵可以离线计算、定时更新,在线时只需要查表就能完成推荐。
基于内容的推荐则是为了弥补协同过滤的冷启动缺陷——新文章没有任何人看过,协同过滤给不出结果,但通过 TF-IDF 提取标签和关键词,能把它和用户历史上喜欢的内容做匹配,保证新内容也有曝光机会。
混合策略的规则很简单:用户有足够历史行为时以协同过滤为主;用户是新用户(行为少于阈值)时以内容推荐为主;两者都有输出时,按得分加权融合,再加一些规则:排除已删除的、排除用户已点不感兴趣的、对最近 3 天的新内容给予加权。
2. 数据模型与用户行为采集
2.1 核心数据表设计
数据模型是这类型项目的骨架,我踩过反复改表的坑,所以先把模型设计说清楚。核心表有六张:用户表(Django 内置 auth.User)、用户画像表、文章表、分类表、标签表、行为表。
# knowledge/models.py 核心模型(节选) from django.db import models from django.contrib.auth.models import User class Category(models.Model): name = models.CharField(verbose_name="分类名", max_length=64, unique=True) slug = models.SlugField(verbose_name="URL标识", max_length=64, unique=True) class Tag(models.Model): name = models.CharField(verbose_name="标签名", max_length=32, unique=True) class Article(models.Model): title = models.CharField(verbose_name="标题", max_length=200) content = models.TextField(verbose_name="正文(支持Markdown)") summary = models.CharField(verbose_name="摘要", max_length=500, blank=True) category = models.ForeignKey(Category, on_delete=models.SET_NULL, null=True) tags = models.ManyToManyField(Tag, related_name="articles") author = models.ForeignKey(User, on_delete=models.CASCADE) view_count = models.PositiveIntegerField(verbose_name="浏览量", default=0) like_count = models.PositiveIntegerField(verbose_name="点赞数", default=0) favorite_count = models.PositiveIntegerField(verbose_name="收藏数", default=0) status = models.CharField( verbose_name="状态", max_length=16, choices=[("draft", "草稿"), ("published", "已发布"), ("removed", "已下架")], default="published", ) created_at = models.DateTimeField(verbose_name="创建时间", auto_now_add=True) updated_at = models.DateTimeField(verbose_name="更新时间", auto_now=True) class Meta: ordering = ["-created_at"] indexes = [models.Index(fields=["category", "created_at"])] class UserProfile(models.Model): user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="profile") interested_tags = models.ManyToManyField(Tag, blank=True, verbose_name="兴趣标签") recommended_count = models.PositiveIntegerField(verbose_name="累计推荐曝光数", default=0) created_at = models.DateTimeField(auto_now_add=True) class UserBehavior(models.Model): ACTION_CHOICES = [ ("view", "浏览"), ("favorite", "收藏"), ("like", "点赞"), ("unlike", "不感兴趣"), ("rating", "评分"), ("comment", "评论"), ] user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="behaviors") article = models.ForeignKey(Article, on_delete=models.CASCADE, related_name="behaviors") action = models.CharField(verbose_name="行为类型", max_length=16, choices=ACTION_CHOICES) value = models.FloatField(verbose_name="行为得分", default=0.0) extra = models.JSONField(verbose_name="扩展数据", null=True, blank=True) created_at = models.DateTimeField(verbose_name="行为时间", auto_now_add=True) class Meta: indexes = [models.Index(fields=["user", "action"]), models.Index(fields=["article", "action"])]行为表的value字段是融合不同行为的关键。我把不同行为映射成不同权重:浏览 1 分、点赞 2 分、收藏 3 分、评论 4 分、评分按用户打的分数(1-5)计算,不感兴趣直接记 -10 分。这个权重不是拍脑袋定死的,后期可以根据推荐效果调整,所以专门留了extra字段存扩展信息。
2.2 行为埋点与特征计算
行为数据的准确性直接决定推荐效果。我见过不少项目在埋点这一步偷懒,结果推荐引擎再聪明也是垃圾进垃圾出。埋点设计上要注意几个地方:
- 浏览量一定要去重。普通页面刷新也会触发 view 事件,如果不控制,高刷新页面会掩盖用户的真实偏好。我在前端用 SessionStorage 存 30 分钟内的浏览记录,同一篇文章 30 分钟内重复浏览不重复上报 view;后端也做了一个防抖校验,确认 IP + User-Agent + 文章 ID 组合不能在两分钟内重复记录。
- 收藏和点赞是强信号。这两个行为是用户主动表达兴趣,行为权重远高于浏览。所以它们的
value权重给到 2-3,明显高于浏览的 1。 - 不感兴趣是负向信号。这个很容易被忽略。用户点了“不感兴趣”之后,如果推荐列表里还反复出现同一类文章,体验会非常差。我在推荐过滤逻辑里专门处理了这个负向反馈。
特征计算的逻辑,我在项目里用一个定时任务,每天凌晨跑一次:
- 对每篇文章计算
行为热度= sum(各行为次数 × 对应权重),再除以文章年龄天数 + 1,形成热度分。 - 对每个用户计算短期兴趣(最近 7 天的行为标签分布)和长期兴趣(全部历史行为的标签分布),短期兴趣权重更高。
- 更新物品相似度矩阵,只保留每篇文章 Top 50 的相似物品,控制矩阵体积。
2.3 数据准备的几个容易忽略的细节
数据准备阶段有三个坑值得单独提醒:
第一,文章的标签数据不能光靠人工打。平台初期内容少还好,内容一多人工就维护不过来。我在发布文章时加了一个自动打标签的流程:用 jieba 分词 + TF-IDF 提取正文关键词,取 top 5 作为候选标签,发布者可以编辑确认。这样初期标签覆盖率能做到 90% 以上。
第二,时间衰减很重要。协同过滤算法如果不对时间做处理,三个月前的热门老文章会一直霸榜,新内容得不到推荐机会。我在热度公式里加了时间衰减因子,让热度随时间呈指数衰减:hot_score = base_score * pow(0.95, age_days)。指数衰减参数可以自己调,0.95 大概是 14 天后热度减半。
第三,推荐字典表(热度表、相似度矩阵)不能只放内存。知识平台重启之后内存就清空了,后面再让用户等首次计算也太慢。我把计算好的矩阵存到 MySQL 的 JSON 字段里,启动时懒加载,计算完一个版本就整体替换,保证接口响应稳定。
3. 推荐算法的核心实现
3.1 基于物品的协同过滤
基于物品的协同过滤,核心思路是“如果用户喜欢 A,那么和 A 相似的物品 B 也应该推荐给用户”。关键在怎么定义“相似”——不是看内容文本长得像不像,而是看“哪些物品被同一批用户同时喜欢/同时浏览”。行为模式越接近的两个物品,相似度越高。
相似度计算我用的是余弦相似度,公式是:
sim(i, j) = 同时喜欢 i 和 j 的用户数 / sqrt(喜欢 i 的用户数 * 喜欢 j 的用户数)实际代码里,我先把行为数据整理成一个user -> item set的倒排表,再计算物品对共现次数。代码写出来大概这样:
# recommender/algorithm.py 物品协同过滤(节选) from collections import defaultdict def build_item_similarity(behaviors, min_support=2): """ behaviors: list of (user_id, article_id, score) min_support: 物品对至少需要多少共同行为用户,才能认为相似 """ # 1. 构建 用户 -> 物品 集合 user_items = defaultdict(set) for user_id, article_id, score in behaviors: if score > 0: user_items[user_id].add(article_id) # 2. 统计物品被多少用户喜欢 item_user_count = defaultdict(int) for user_id, items in user_items.items(): for item in items: item_user_count[item] += 1 # 3. 统计物品对共现次数 co_count = defaultdict(int) for user_id, items in user_items.items(): items = list(items) for i in range(len(items)): for j in range(i + 1, len(items)): a, b = items[i], items[j] if a == b: continue # 用排序保证 pair 唯一 if a > b: a, b = b, a co_count[(a, b)] += 1 # 4. 计算余弦相似度 sim_matrix = defaultdict(dict) for (a, b), count in co_count.items(): if count < min_support: continue sim = count / ((item_user_count[a] * item_user_count[b]) ** 0.5) if sim >= 0.01: sim_matrix[a][b] = sim sim_matrix[b][a] = sim # 5. 每篇文章只保留 Top 50 相似物品 top_n = {} for item, sims in sim_matrix.items(): top_sims = sorted(sims.items(), key=lambda x: x[1], reverse=True)[:50] top_n[item] = top_sims return top_nmin_support参数值得说一下。如果某两个物品只被同一个用户同时看过,它们的“相似”很可能只是巧合,直接放进相似矩阵会产生噪声推荐。设置min_support=2意味着至少有两个用户同时消费过这两个物品,才认为它们相似。这个参数要根据数据量调整,数据量大可以提到 3-5,数据量小就保持 1-2。
推荐得分计算也很直观。用户对物品 i 的预测分,就是把用户历史行为物品集合 H 里的每个物品 h,与目标物品 i 的相似度加权求和:
def recommend_for_user(user_fav_items, similarity_matrix, top_k=20, exclude_items=None): scores = defaultdict(float) for h_item in user_fav_items: # 只在历史物品的相似列表里查找 for sim_item, sim_score in similarity_matrix.get(h_item, []): if sim_item in user_fav_items: continue scores[sim_item] += sim_score * behavior_weight(h_item) ranked = sorted(scores.items(), key=lambda x: x[1], reverse=True)[:top_k] if exclude_items: ranked = [(i, s) for i, s in ranked if i not in exclude_items] return ranked这里behavior_weight(h_item)根据用户和这个历史物品的行为类型加权:收藏权重最高,浏览略低,不感兴趣的直接把相关候选砍掉。用物品协同过滤的好处在这里体现——在线推荐只需要查表加权重求和,性能很好。
3.2 基于内容的推荐(TF-IDF 标签匹配)
基于内容的推荐主要解决新物品的冷启动问题。新文章没有行为记录,物品协同过滤算不出相似度,但文章本身有标题、正文、分类、标签,通过这些内容特征可以判断它和用户兴趣是否匹配。
我用两种特征:一是标签特征,二是 TF-IDF 关键词特征。标签特征简单直接:用户画像里存了兴趣标签,文章也有标签,重合度越高越推荐。TF-IDF 特征更细粒度,能捕捉到标签没覆盖到的关键词。
# recommender/algorithm.py 基于内容的推荐(节选) import jieba from sklearn.feature_extraction.text import TfidfVectorizer def extract_tags_from_text(text, top_n=5): words = jieba.lcut(text) # 过滤停用词、单字、纯数字等 words = [w.strip() for w in words if len(w.strip()) > 1 and not w.strip().isdigit()] # 为了简单,这里直接统计词频选 top N freq = defaultdict(int) for w in words: freq[w] += 1 sorted_words = sorted(freq.items(), key=lambda x: x[1], reverse=True)[:top_n] return [w for w, _ in sorted_words] def content_recommend(user_tags, candidate_articles): """ user_tags: 用户兴趣标签集合 candidate_articles: [(article_id, tags_list, summary)] 返回 [(article_id, score)] """ scored = [] for article_id, tags, summary in candidate_articles: tag_hit = len(set(tags) & set(user_tags)) tag_score = tag_hit / max(len(set(tags)), 1) # 标签覆盖率 keyword_score = 0.0 if summary: summary_keywords = extract_tags_from_text(summary, top_n=3) keyword_score = len(set(summary_keywords) & set(user_tags)) / 5.0 score = 0.7 * tag_score + 0.3 * keyword_score if score > 0: scored.append((article_id, score)) scored.sort(key=lambda x: x[1], reverse=True) return scored[:20]这段实现里有个技巧:标签覆盖率用len(set(tags) & set(user_tags)) / max(len(set(tags)), 1),意思是“文章标签里有多少比例是用户感兴趣的”,而不是简单的“命中一个就加分”。这样能避免那种标签堆砌了二十个、只命中一个的文章排到前面。
3.3 混合推荐与实时评分调整
混合推荐我采用了加权融合加规则过滤的两层结构。
第一层融合:
- 如果用户历史行为物品数
behavior_count >= 10,协同过滤结果占比大,取score = 0.7 * 协同过滤分 + 0.3 * 内容匹配分(协同过滤分要归一化到同一个量级,我用sim_score / max_score做归一化)。 - 如果
behavior_count < 10,说明协同过滤可信度不足,取score = 0.3 * 协同过滤分 + 0.7 * 内容匹配分。 - 如果用户行为物品数为 0(纯新用户),直接走内容推荐和热门推荐。
第二层规则过滤:
def build_recommend_list(request, user, limit=20): user_fav_items, behavior_map = load_user_behaviors(user) behavior_count = len(user_fav_items) # 1. 候选召回 if behavior_count >= 10: item_cf_candidates = recommend_for_user( user_fav_items, item_sim_matrix, limit=60, exclude_items=set(user_fav_items) ) content_candidates = content_recommend( user_profile.interested_tags, recent_published_articles(), ) candidates = fuse_recommendations(item_cf_candidates, content_candidates, weight=(0.7, 0.3)) elif behavior_count > 0: item_cf_candidates = recommend_for_user( user_fav_items, item_sim_matrix, limit=60, exclude_items=set(user_fav_items) ) content_candidates = content_recommend( user_profile.interested_tags, recent_published_articles(), ) candidates = fuse_recommendations(item_cf_candidates, content_candidates, weight=(0.3, 0.7)) else: candidates = hot_articles(limit=60) # 热门兜底 # 2. 过滤: 排除不感兴趣、已删除、已下架 candidates = filter_uninterested(candidates, user) # 3. 排序: 新内容加权, 不喜欢压权 candidates = apply_novelty_weight(candidates, days=3, boost=1.2) return candidates[:limit]实时评分调整是指用户一旦产生新的行为(比如点了“不感兴趣”),要能很快反映在下一次推荐里。我没有上消息队列那套重东西,而是利用 Django 的缓存:用户下拉推荐列表时先检查 Redis 里有没有这个用户的行为更新标记,如果有就直接对推荐结果做一次微调,去掉被标记为不感兴趣的物品、降低同类内容排序。实测效果不错,延迟控制在几十毫秒内。
4. 核心功能的实操落地
4.1 推荐接口的视图实现
推荐核心逻辑写好后,剩下的工作就是把它接到 Django 视图上。我用了两个入口:
- 文章详情页:展示“相关推荐”和“猜你喜欢”。
- 首页:直接展示个性化推荐流。
# recommendations/views.py(节选) from django.views.generic import TemplateView from django.contrib.auth.decorators import login_required from django.utils.decorators import method_decorator @method_decorator(login_required(login_url="/accounts/login/"), name="dispatch") class HomeFeedView(TemplateView): template_name = "home.html" def get_context_data(self, **kwargs): ctx = super().get_context_data(**kwargs) user = self.request.user article_ids = build_recommend_list(self.request, user, limit=20) ctx["recommend_articles"] = ( Article.objects.filter(id__in=article_ids, status="published") .select_related("author", "category") .prefetch_related("tags") ) return ctxselect_related和prefetch_related一定要用。展示文章卡片时,要展示作者名、分类名、标签列表和摘要,如果不预加载,每张卡片都会触发几条 SQL 查询,20 篇文章可能产生几十条 SQL,N+1 查询问题就来了。用了之后,首页加载的 SQL 从 60 多条降到了 6 条,首屏时间快了差不多一半。
4.2 前端渲染与交互
前端我用的 Django 内置模板 + Bootstrap 5。没有做前后端分离,原因前面说过——这个项目更偏内容展示,服务端渲染对 SEO 友好,文章详情页能被搜索引擎收录,对知识平台来说是非常重要的流量来源。
模板设计的重点在推荐卡片上。卡片要展示文章标题、摘要、分类、标签、作者、发布时间和热度,让用户在 2 秒内判断要不要点进去。我踩过的坑是摘要不能直接summary[:100],因为摘要可能包含 Markdown 语法、HTML 标签,直接切片会把格式切烂。我写了一个clean_summary过滤器:
# templatetags/custom_filters.py(节选) from django import template import re register = template.Library() @register.filter def clean_summary(text, length=120): # 去掉 Markdown 图片语法、代码块、链接语法等 text = re.sub(r"!\[.*?\]\(.*?\)", "", text) text = re.sub(r"```.*?```", "", text, flags=re.DOTALL) text = re.sub(r"\[(.*?)\]\((.*?)\)", r"\1", text) text = re.sub(r"[#>*`~\-]", "", text) text = text.replace("\n", " ").strip() if len(text) > length: return text[:length] + "..." return text前端交互方面,收藏、点赞、不感兴趣这三个按钮用 Ajax 提交,不刷新页面。用户点击“不喜欢”之后,前端立刻把该卡片移出列表,同时向后端发一个unlike行为事件。这个反馈路径一定要走通,否则用户表达了负面情绪但推荐没感知,推荐质量会一直上不去。
4.3 部署上线与性能优化
部署我用的是常见组合:Nginx + Gunicorn + PostgreSQL 其实也行,但我这次用 MySQL,数据量不大所以压力不大。Django 侧需要注意以下几个点:
DEBUG=False之后静态文件要交给 Nginx 托管,否则页面会没有任何样式。ALLOWED_HOSTS要正确配置,否则请求会被拦截。- 数据库连接池在 Django 里默认每个线程一个连接,并发不高没问题,但配合 Gunicorn 多 worker 时,要留意最大连接数别超过数据库限制。
- 缓存一定要用上。首页推荐流按用户缓存,缓存时间设 5 分钟,Redis 里 key 是
rec:{user_id},过期后重建。文章详情页按 URL 缓存 10 分钟,显著降低数据库压力。
性能优化这块,我实测最有效的三招:第一,模板 Fragment Cache,文章卡片 HTML 片段缓存起来;第二,数据库索引,行为表的(user_id, article_id)联合索引和数据表的(category, created_at)索引加上之后,推荐查询从秒级降到几十毫秒;第三,把协同过滤矩阵做成定时的离线程,线上只查表,不实时计算相似度。
这三点做完之后,200 并发压测下接口平均响应时间从 900ms 降到 260ms,效果很明显。
5. 常见问题与排查技巧实录
5.1 冷启动与数据稀疏问题
冷启动主要有两个来源:新用户没有历史行为,新文章没有用户行为。我在前面已经讲了对应的策略——新用户走内容推荐 + 热门兜底,新文章走基于内容的推荐。但实际运行中,我遇到了一个更隐蔽的问题:新用户的内容推荐效果也差。
原因在于,用户注册后我第一次获取兴趣标签的方式是让用户自己选几个感兴趣的话题,但很多人注册时随便选,选了不更新,导致内容推荐一直用错误的兴趣画像。后来我在注册流程后加了一个“引导式冷启动”:用户注册后,不做手机验证、不强制选标签,先让他随便看三篇系统的热门文章,点赞或收藏一篇之后,系统从这篇文章身上提取标签作为初始兴趣。这个做法让新用户的次日留存明显提高了。
数据稀疏问题体现在物品共现矩阵上。当平台文章数量到了几万,但用户日均活跃行为不够多时,物品相似度矩阵里很多物品对没有共现值,推荐池变浅。我的解法是降低min_support并扩大行为窗口,把行为数据的统计窗口从最近 30 天扩大到最近 90 天。这样虽然行为“老”了一些,但矩阵覆盖率上来了,推荐召回率有明显改善。
5.2 性能瓶颈与缓存策略
推荐流接口是性能瓶颈的集中地,我遇到过两个经典问题。
第一个是热门文章接口被压垮。有一个“大家都在看”的热门模块,所有人都请求同一个接口,数据库每次都要排序。解决办法是把热门列表缓存到 Redis,每 15 分钟重建一次。改动很小,但数据库压力立刻降了一个量级。
第二个是推荐流接口响应慢在了模板渲染上。那时候还没加 Fragment Cache,每张卡片要渲染标签、作者信息、摘要,模板渲染 PHP 式的嵌套也躲不掉。加了两级缓存之后才稳定:整页用 Cache Page,失效条件是有新的推荐结果;卡片级用 Fragment Cache,让模板片段能独立复用。两级缓存配合 Redis,推荐流接口响应稳定在 200ms 以内。
5.3 踩坑记录与问题速查表
下面这个表是我实际开发中整理的问题速查表,都是这个类型项目里容易踩的坑:
| 问题现象 | 根因 | 解决办法 |
|---|---|---|
| 首页推荐流里反复出现同一类文章 | 没有负反馈过滤 | 记录 unlike 行为,过滤时将 unlike 过的文章及同标签文章降权 |
| 新文章曝光率极低 | 相似度矩阵基于历史行为,新物品没有共现值 | 内容推荐权重提升 + 新文章 3 天加权系数 |
| 行为表越来越大,查询越来越慢 | 没加索引 | 对(user_id, action)和(article_id, action)建联合索引,定期清理过期行为 |
| 登录用户页面打开慢 | N+1 查询 | select_related + prefetch_related 优化 |
| 推荐结果整体偏老 | 时间衰减因子没加 | 热度公式引入指数时间衰减 |
| 用户点"不喜欢"还是被推荐同款 | 行为未实时生效 | 用缓存标记 + 二次过滤,用户操作后立即调整候选 |
| Django Admin 管理文章列表非常卡 | 每行渲染会查询外键关联数据 | list_select_related 配置好外键字段 |
| 相似度矩阵重建时 CPU 飙高 | 全量计算没有分批 | 分片计算 + 定时任务错峰执行 |
一个额外的实战经验:推荐系统的评估不能只看点击率。我一开始只看点击率优化,结果推荐流里全是标题党的娱乐性内容,偏离了知识平台的核心目标。后来我加了一个“深度阅读率”指标——30 秒以上的阅读才算有效阅读,用这个指标来评估推荐质量,又做了一轮基于深度阅读行为的权重调整,推荐内容才回到正轨。
5.4 代码层面容易踩的几个小坑
除了性能和技术架构,代码层面我也总结出几个容易出问题的地方。第一个是行为写入的幂等性。前端 Ajax 请求有重复提交风险,同一个用户同一篇文章同一行为类型,如果两次写入行为表,推荐引擎的统计就失真了。我在行为表加了约束:
class Meta: constraints = [ models.UniqueConstraint( fields=["user", "article", "action"], name="unique_user_article_action" ) ]但注意:view浏览行为不应该完全去重,因为用户会多次访问同一篇文章。所以我分开设计:浏览行为不限制重复次数,而收藏、点赞、不感兴趣使用唯一约束,重复提交时返回已有记录,不做叠加。
第二个是管理员在 Admin 后台改文章内容时,推荐矩阵不会实时更新。如果不做处理,会出现用户看到推荐列表里有一篇文章点进去已经是“已下架”状态。我在视图层的过滤条件里加了status="published",推荐列表拿到候选 ID 后再查一次状态,保证不推已下架内容。
第三个是相似度矩阵更新过程中的并发问题。如果每天更新矩阵时会话还没结束,用户请求来了读到的是旧矩阵还是新矩阵,直接决定推荐结果是否漂移。我用一个简单的版本号机制:第一次计算时生成新版本号,计算完成后把版本号更新到缓存里,请求一律读取当前版本号对应的矩阵。这样做避免用户在一次会话中被推荐出来风格完全不同的内容。
6. 写在最后的经验体会
整个项目从需求梳理到部署上线,我最大的感受是一个知识推荐系统的难点不在模型多高级,而在数据链路的完整度。很多人一上来就调算法参数,忽略了对行为数据的采集、清洗和建模,结果算法再花哨,数据质量差也白搭。我在这次开发里至少有 60% 的精力花在行为埋点、数据索引、缓存搭建这些“看起来不太酷”的工作上,但恰恰是这些基础工作决定了推荐系统能不能真正跑起来。
另一个体会是,推荐系统一定要有反馈闭环。用户点开、阅读、收藏、不喜欢,每个动作都要进入数据链路并快速影响下一次推荐结果。哪怕你的算法只是简单的热度排序,只要反馈闭环是通的,系统也会越用越准。相反,算法再复杂,如果用户的负反馈没进系统,推荐就会越做越偏。
如果后续再扩展这个项目,我会优先做三件事:一是引入更丰富的行为数据,比如阅读时长、滚动深度、鼠标停留位置等隐性反馈;二是给“深度阅读”建一个单独信号源,增加高质量长文推荐权重;三是把推荐理由展示给用户,比如“推荐理由:因为你收藏了《Django 性能优化实战》”,让用户理解推荐来源,也更容易产生信任感。这些改动比换一个更复杂的模型成本低,但用户感知的提升会更直接。
最后再分享一个操作习惯:我给自己的项目写了一套自动化测试,重点覆盖推荐算法的边界情况——空行为用户、只有浏览行为的用户、只有不感兴趣行为的用户、新文章、极端温度和冷启动用户。每次调整算法后跑一遍测试,能保证迭代过程中不损坏已有功能。这个习惯,是这次开发里我个人觉得最值的一笔投入。