1. 为什么要把考公考编资料做成一个推荐系统——这套系统的出发点
先交代一下背景。去年上半年,一个做职业规划类内容运营的朋友找我聊过一次,说他们手里攒了大概几千份考公考编相关的学习资料,从行测分模块的讲义、申论范文合集,到事业单位公共基础知识的思维导图、教师招聘学科真题,品类非常杂。资料多不是坏事,坏的是用户根本不知道从哪一份开始看。
这其实是考公考编学习场景里一个很典型的结构性矛盾:资料生产端极其活跃,几乎每周都有新的名师课、新版教材、押题卷出来;而用户端的学习路径却高度依赖“别人告诉我该看什么”——问上岸的人、看论坛经验帖、加群求资源。结果就是大量的优质资料躺在网盘里没人翻,大量用户又在重复搜索同样的关键词。
我当时就在想,如果把“资料”当成电商里的“商品”,“学习者”当成“用户”,做一个基于Python的考公考编学习课程资料推荐系统,让Django负责业务管理、Flask负责推荐服务,是不是能把这个矛盾解开一些。事实证明方向是对的,但真正动手之后才发现,考公考编这个垂直场景的推荐系统和普通的内容推荐系统差别非常大,很多通用玩法在这里根本跑不通。
这篇文章就围绕我实际开发这套系统的过程展开,重点讲三件事:为什么这个场景需要一套专门的推荐逻辑,Django和Flask在系统里怎么分工才合理,以及从用户画像到混合召回、从冷启动到质量治理,整个链路里最容易被忽略的细节。如果你也在做类似的垂直学习资料平台、在线课程推荐系统,或者只是对Python Web开发和推荐算法的落地结合感兴趣,这篇文章应该能给你一些参考。
2. Django和Flask的分工:为什么一套系统要用两个框架
很多人看到“django-flask基于python”这个描述会下意识问:一个项目为什么要同时用两个框架,是不是多此一举?其实不是。这套系统里Django和Flask承担的角色完全不同,拆开用反而比二选一更合理。
2.1 主业务与推荐服务分走的理由
Django在我看来最适合做的是“重业务、重后台、多表单”的服务端。考公考编资料推荐系统的核心业务包括用户注册登录、资料上传与管理、课程分类、收藏与学习记录、后台的编辑审核,这些功能Django做起来几乎是零成本。尤其是Django自带Admin后台,对于资料运营团队来说,意味着不需要额外开发一套管理界面,直接就能进行资料录入、上下架、分类调整,这大大缩短了项目从设计到可用状态的周期。
Flask则更适合承担推荐服务。推荐引擎本质上是一个比较独立的计算模块,它需要加载用户特征矩阵、资料特征矩阵,实时计算相似度,或者从Redis里取缓存结果返回。如果用Django来干这件事,也不是不行,但Django对请求的处理链路相对重,中间件、CSRF、ORM这些机制在纯计算接口场景下都是多余的开销。Flask足够轻量,我可以把所有的推荐逻辑封装成独立的服务,对外只暴露一个HTTP接口,任何客户端都能调用。
另外一个很现实的原因是依赖隔离。推荐计算部分会用一些比较独立的数据处理相关依赖,而Django项目本身还会用其他很多库,混在一个进程里,依赖版本一旦冲突,排查起来非常痛苦。分开两个服务之后,各自的依赖环境互不干扰,部署时也是两个独立的进程,扩容时只需要把Flask服务多挂几个实例就行。
2.2 两个服务的边界划分与数据流
在系统里,Django和Flask的数据流是这样设计的:
用户端的请求先打到Django。比如用户登录后进入“推荐”页面,Django的业务视图发现这个请求需要推荐结果,就通过内部网络调用Flask服务的推荐接口,把user_id传过去。Flask根据用户特征和资料特征算出一批候选资料ID,返回给Django,再由Django从自己的数据库里查出这些ID对应的资料详情,组装成页面返回给用户。
那么“算出一批候选资料ID”靠的是什么?Flask服务里会维护一套周期性更新的特征数据。Django数据库里的用户行为表(收藏、点击、做题记录)会定期同步给Flask,Flask基于这些行为重新计算用户画像和物品相似度矩阵,计算结果缓存到Redis里。用户在页面上触发推荐请求时,Flask优先读Redis,没有缓存再实时计算。
数据库层面的边界同样分得很清楚。Django作为数据主库,保存用户表、资料表、行为表、标签表。Flask不直接操作Django的数据库,它只读同步过来的特征快照。这样做的好处是Flask在计算时可以随便用内存数据结构,不怕影响主业务,而且一旦推荐算法的数据格式要调整,只需要改Flask一侧,Django完全无感。
2.3 关于选型的补充思考
如果你只是做一个几百人的小内部系统,用户量不大,资料量不超过一千份,那么直接用Django单框架加上一个推荐函数就够了,完全没必要拆两个服务。拆服务解决的是扩展性问题,但也引入了额外的部署成本和通信开销。
反过来,如果你预见系统要服务几千甚至几万人,用户行为数据会持续增长,推荐算法要迭代,那就值得一开始就按这种拆分方式来设计。我实际开发中还考虑过微服务方案,但最终放弃了,因为考公考编资料推荐的核心链路还不复杂,微服务的注册发现、熔断降级那套东西在这里属于过度设计。Django加Flask两个服务加一个Redis,已经能覆盖全部需求。
3. 推荐算法怎么在资料场景落地:从用户画像到混合召回
很多人以为推荐系统的核心是算法,其实真正的核心是特征。算法可以很简单,但特征工程做得好不好,直接决定了推荐结果能不能看。考公考编资料推荐尤其如此。
3.1 用户画像:用最少的数据描绘学习偏好
普通的内容推荐系统,用户画像通常靠大量隐式行为来刻画:浏览记录、停留时长、点击率。但考公考编学习类产品有一个特点,就是用户在初始阶段的行为极度稀疏——他可能注册了一天,就收藏了两份资料,其余时间都在看页面,但没有产生任何反馈。
所以我在这套系统里把用户画像分成两个层次。
第一层是显式画像。注册时让用户选择自己正在备考的目标:国考、省考、事业单位、教师招聘,再选择当前的备考阶段:基础、强化、冲刺。这两个信息非常关键,因为它直接决定了用户应该优先看到什么。国考的用户和教师招聘的用户,资料需求的重合度其实很低,一个要行测申论,一个要教育综合和学科知识,如果不对目标做区分,推荐系统起步就是错的。
第二层是隐式画像。用户使用系统过程中产生的行为会被打上标签权重:收藏一份资料加2分,完整阅读加1分,点击投喂资料加0.5分,直接跳过则给对应标签减分。每个用户最终会形成一个维度较宽但值稀疏的偏好向量,里面的维度代表不同的资料主题标签,比如“逻辑推理”“申论大作文”“常识时政”“教育学心理学”等等。
这两层画像是并行的,显式画像决定推荐的大方向,保证哪怕一个新用户也能得到相对匹配的内容;隐式画像在用户行为积累后逐步修正,让推荐越来越贴合个人的实际水平。
3.2 内容画像:给每份资料打标签
给资料打标签这件事,比给用户画像还要重要。你无法给一份连基本标签都不清晰的资料匹配用户。我在系统里为每份资料设计了一套结构化的标签体系:
- 考试类型:国考、省考、事业单位、教师招聘、军队文职等;
- 科目大类:行测、申论、公共基础知识、教育综合、学科专业课等;
- 子主题标签:比如行测下面的逻辑推理、数量关系、资料分析、言语理解,申论下面的归纳概括、对策分析、大作文等;
- 资料形态:视频课、讲义、真题卷、模拟题、思维导图、时政汇总等;
- 难度等级:基础、进阶、拔高;
- 时效年份:因为考公考编资料时效性极强,时政、政策法规每年都在变,年份是必须考虑的维度;
- 文本关键词:从资料标题和简介里提取出的关键词集合。
这些标签一部分来自资料录入时人工指定,一部分通过自然语言处理的方式从文本中自动提取。自动提取我用了基于TF-IDF的关键词抽取,方法不复杂,几百行代码就能搞定,但对资料标题、简介的清洗要求比较高,要去掉“最新”“重磅”“提分秘籍”这类营销词,只保留有实际含义的内容词。
3.3 混合召回与排序:内容推荐为主、协同过滤补充
推荐流程我采用的是业界通用的“召回加排序”两层结构,但回收策略和通用推荐有明显区别。
第一路召回是基于内容标签的匹配召回。把用户的偏好向量的标签权重和资料自身标签做匹配,算一个基础得分。这个策略在这个场景下是主力。原因是考公考编学习资料的用户行为数据通常比较稀疏,协同过滤很难发挥作用,而基于内容的匹配完全不依赖历史行为,只要有标签就能工作。
第二路召回是协同过滤召回。用的方法是基于用户的协同过滤。找一个与当前用户行为最相似的几个用户,把这几个用户收藏过但当前用户没见过的资料拉出来作为候选。对于老用户来说,这路召回往往能发现一些标签体系覆盖不到的惊喜内容。
排序阶段做的事情比较简单但很重要:把两路召回的结果合到一起,按综合得分排序。综合得分的计算公式大概是:
综合得分 = 内容匹配得分 * 0.6 + 协同过滤得分 * 0.3 + 资料质量分 * 0.1
其中资料质量分来自系统的质量评估模型,后面会详细展开。如果用户是行为很少的新用户,协同过滤得分没有意义,此时会把权重临时调整为内容匹配权重0.8、质量分0.2,全部靠内容匹配兜底。
这里我想特别强调一下,不要迷信协同过滤。网上很多教程会告诉你协同过滤是推荐算法的主流,但那是针对有海量评分数据的场景说的。考公考编学习资料平台,用户一天能看三五份资料就算高频了,不产生评分只产生弱反馈,构建出来的用户相似度矩阵往往非常不稳定。我在这套系统里把协同过滤定位成“补充手段”而不是“主力算法”,实践证明这个定位是务实的。
3.4 多样性控制:防止推荐列表越推越窄
还有一个特别容易踩的坑是推荐结果的同质化。内容推荐做得越精准,越容易出现一个恐怖的结果:召回列表里全是同一个子主题下的资料。比如用户收藏了三分言语理解的资料,系统就给他推二十份言语理解,把数量关系和资料分析全忽略了。
这是学习类产品的大忌。学生备考需要的是一个完整的知识体系,不是在一个点上无限加深。针对这个问题,我在排序之后加了一个最大边际相关性(MMR)重排逻辑。核心思想很简单:每次从候选列表里选一个综合得分最高的资料放进最终推荐列表,但下一个资料的最终分要减去一个惩罚项,惩罚项和它跟已选资料的相似度成正比。如果候选资料和前面已经选中的某份资料太像,即使它本身得分很高,也会被压制。
实际效果非常明显,加了MMR之后,推荐的推荐列表从“全是同一个模块”变成了“一个大模块配两三个其他模块”,用户的学习数据明显更健康,页面停留和收藏行为都有提升。
4. 冷启动与数据稀疏:这类系统最头疼的两个坑
冷启动是所有推荐系统都躲不开的问题,但考公考编资料推荐系统的冷启动比电商推荐还要难处理。电商用户一进来就有浏览行为,算法很快能学到偏好;而备考用户进来后的核心行为是“找资料”,不是“逛”,行为产生得慢且少。新系统、新用户、新资料三个层面的冷启动问题,我一个一个解决。
4.1 新用户冷启动:注册问卷加热门榜兜底
新用户冷启动的核心是“不能让用户面对空白推荐页”。我在注册流程里加了一个轻量问卷:选择备考目标和当前阶段,再勾选几个自己目前最薄弱的科目。这个问卷控制在十秒以内完成,不会让用户觉得烦。
但这还不够。用户选择的标签只是起点,不一定是真实需求。所以系统在推荐列表中间,会固定插入一部分基于群体行为的热门资料。热门榜的计算不能只看总收藏量,那样永远都是那些老资格资料霸榜。我的做法是给资料加一个时间衰减因子,用最近七天的收藏和点击量按时间加权计算热度,让新资料也有机会冒头。
新用户的混合策略是:前十条推荐按“显式画像匹配占60%、七天热度占30%、质量分占10%”来排序,前三条里固定安排一条热门资料。等用户攒了一些行为信号之后,再逐渐替换成个性化推荐。
4.2 新资料冷启动:题目匹配加人工质控快速起量
新资料冷启动更难。一份新上传的申论范文合集,没有用户行为,协同过滤根本不会碰它,内容匹配也只能靠标签硬凑。如果系统对它有标签标注错误,推荐出去一次没有点击,后面几乎就没有曝光机会了。
新资料的破局方案可以说相当务实:靠人工质控来保证新内容质量。新资料上传后,会进入一个待推荐池,运营人员需要审核标签是否正确、内容是否完整、是不是旧版本翻新,审核通过后给它一个基础质量分。这份资料在初始状态下的质量分通常比普通资料略高一点,保证它在冷启动阶段能被内容召回选中,获得几次曝光。
同时系统会在曝光时给新资料较长的一段观察期。观察期内不根据点击率来降权,因为新资料曝光少,数据不稳定,这时候直接参考点击率会误杀好内容。等资料累计曝光超过一百次,数据相对可信了,才正式进入常规排序体系。
4.3 数据稀疏下的协同过滤陷阱
这里必须单独提醒一下数据稀疏这个问题。在这个系统的开发初期,我犯过一个很典型的错误:试图用标准版的协同过滤算法同时解决新用户和新资料的问题,结果两边都救不回来。
数据稀疏时算出来的用户相似度是不可信的。假设系统里只有几十个有过收藏行为的用户,随机两个用户收藏重合的概率本来就低,一旦有两次重合,算法就会认为他们是“高度相似”,这是典型的偶然误差被放大。我一度在这个思路上花了两周时间,效果却差到没法上线,后来直接把同类用户的最小行为阈值设置得非常保守,行为少于二十条的用户根本不参与相似用户计算,才算把这个毒点排除掉。
如果你的项目数据量也不大,我建议你尽早认清现实:在这个阶段,基于内容的推荐才是主线,协同过滤只能是锦上添花,不要本末倒置。
5. 资料数据来源与质量治理:推荐系统烂数据等于白做
推荐系统的上限其实是由数据质量决定的。算法再好,推给用户一堆过期的时政热点、讲错知识点的资料、或清晰度极差的扫描版真题,用户很快会对整个系统失去信任。考公考编学习资料领域尤其如此,资料的正确性和时效性直接影响用户的备考结果,这个责任很重。
5.1 采集层:公开资料整理与字段结构化
系统初期的资料数据来自几个张渠道:运营团队手工整理的公考机构公开课目录、社群收集的公开分享资料、以及结构化爬虫抓取的公开学习资源。这里要说明一下,爬虫只抓取允许公开访问且版权清晰的页面,涉及版权问题的内容一律人工确认后才是收录,这是底线。
原始数据非常脏。同一份资料可能在不同渠道有不同标题,比如“2024省考行测真题”“行测真题2024最新版”其实是同一个东西。资料的简介也经常是营销文案,充斥着“上岸”“秒杀”“绝密”这类没有实际意义的词。所以进入系统之前,每份资料都经过了字段结构化流程:把标题、简介、科目、考试类型、适用阶段、资料格式等拆成独立字段,存入Django管理的资料主表。
5.2 去重与时效性校验
去重是资料治理里最耗时的一环。我试过很多方法,最终用的是“标题相似度加内容指纹”组合方案。标题相似度用编辑距离计算,判断两个标题是不是同一个资料的不同叫法;内容指纹则是提取资料简介和开篇文本的特征哈希,如果两份资料的内容指纹一致,基本可以断定是同一份内容。
时效性校验则更偏规则。公考类资料时间敏感性高,系统里有一张时效规则表,记录各类资料的“保质期”。时政类的资料有效期通常设置得比较短,比如超过半年的基本不再推荐给冲刺阶段用户;而基础理论的讲义有效期可以长一些,两年内的都算可用。资料状态在过期前一星期会自动提醒运营人员复核,确认是否下架或更新。
5.3 质量评分与人工质控流程
质量分是排序公式里的一个重要因子,由三部分构成:人工基础分、用户反馈分、使用稳定性分。
人工基础分在资料进入系统时由运营人员打分,主要看四个方面:内容完整性、讲解清晰度、时效性、排版可用性。每一项一到五分,加权求和后得到一个基础质量分。
用户反馈分是动态的,根据用户的收藏率、完整阅读率、以及点击后是否迅速离开来修正。一份资料如果被很多人收藏且阅读完成度高,反馈分会往上走;如果点击很高但几乎都是点进去就退出来,说明标题和内容货不对板,反馈分就要往下压。
使用稳定性分则比较细。有些资料平时评分都不错,但只是在某个阶段特别受追捧,过了那个考试节点评分就掉下来,如果在评分里不做平滑处理,推荐列表会在考试前后剧烈波动。我采取的做法是对所有反馈分做滑动窗口平均,窗口期七天,避免单日极端数据干扰整体评分。
人工质控流程的核心是“重要资料必须过两次审核”。新上传资料由运营初审,超过七百条用户反馈后由二审复核标签和内容质量。这套流程跑起来之后,资料池的整体质量维持在一个很稳的水准上,推荐系统的表现也随之变得稳定。
6. 系统实测数据与迭代记录:从雏形到能用的关键节点
不拿数据说话不算完。这套系统从开发到上线试运行大约用了两个半月,中间经历过三次比较大的迭代,每次迭代都有明确的数据指标做参照。下面把关键节点的数据记录和决策过程整理出来。
6.1 离线评估结果
系统开发完毕后,我先对推荐算法做了一轮离线评估。当时整理了过去三个月的用户行为日志,包括收藏、点击、全文阅读等行为,随机划分成训练集和测试集,用两个指标来衡量推荐效果:推荐列表里被用户实际点击的比例(命中率)和用户点击过的资料被推荐出来的比例(召回率)。
第一次全链路线下评估结果如下:
| 策略组合 | 命中率 | 说明 |
|---|---|---|
| 纯热门推荐 | 约7% | 作为基线对照 |
| 仅内容匹配 | 约12% | 依赖标签体系的准确性 |
| 内容匹配加协同过滤 | 约13% | 提升有限,数据稀疏所致 |
| 混合召回加MMR重排 | 约16% | 完整方案 |
混合召回加MMR重排对比纯热门推荐,命中率提升了一倍多。同时我单独对推荐列表多样性做了统计,同样的用户数据下,纯内容推荐的推荐列表里子主题重复率约为45%,加上MMR之后降到了22%左右,多样性改善非常明显。
6.2 在线数据表现
离线评估只能作参考,真正要看的还是线上数据。系统上线试用阶段,有大约两千名用户参与了测试使用,我统计了两个周期的线上指标。
- 第一周期(上线前两周):用户平均每日浏览资料数约为4.6份,推荐位的资料收藏率约为6.8%,注册后三天内持续回访的用户比例约为18%;
- 第二周期(算法优化后):用户平均每日浏览资料数上升到6.1份,推荐位收藏率提升到9.3%,三天回访率提升到26%。
收藏率从6.8%到9.3%的跨度,看起来数字不大,但在学习资料场景里已经算明显改善。这也证明了优化推荐质量对提升用户粘性是有直接作用的。后来运营团队反馈,用户私信说“推荐的资料和自己在备考的目标比较接近”的比例明显增多,算是一种软性的效果佐证。
6.3 上线后推翻的几次设计
有一类教训必须单独拿出来说:有一些设计在开发时自己觉得很有道理,上线后却不得不推翻。
第一次推翻是“进度跟踪型推荐”。最初设想过根据用户的学习进度来推荐下一步资料,比如用户看到行测逻辑推理的第三节课,就给他推荐第四节课。听起来合理,但实际操作中发现用户根本不会按系统的课程顺序学习,他们常常跳着看,进度跟踪很难精确。最后改成了按知识模块推送整个主题的合集,允许用户自行选择从哪份资料看起,反而数据更好。
第二次推翻是“相似资料关联推荐”。在资料详情页放一个“看过的用户还看了”的模块,这个设计在电商里很常见,但学习资料场景不适用于此,用户看资料是为了解决问题,不是为了逛店,推荐出一堆相似资料只会让用户觉得“又来一份一样的”,实际点击率一直不高。后来把这一块改成“相关主题的补强资料”,比如用户看了一份言语理解的真题,就推荐给他一份言语理解的技巧总结,内容互补而并非重复,效果才算扭转回来。
7. 踩坑清单:给准备复刻这个项目的人提个醒
整个项目做下来,踩过的坑可以写一张很长的单子。挑几个最有代表性的放在这里,如果你也准备用Python加Django或Flask做类似的推荐系统,大概率能帮你少走一些弯路。
第一个坑是Flask和Django的代码不加隔离地塞进同一个项目里。我一开始图省事,直接在一个仓库里同时挂着Django管理后台和Flask接口,两个框架都访问同一个数据库和同一个Redis,看起来节省了服务间通信的功夫,实际上带来了很多隐藏的依赖冲突。开发后期我果断把它们拆成两个独立服务部署,才彻底摆脱了这种纠缠。经验就是:框架的分层不只是代码层面的,服务进程层面的隔离越早做越省心。
第二个坑是推荐接口的缓存策略。最开始Flask服务没有做缓存,每个用户请求都要实时读Redis加算相似度,高峰时期接口响应速度直接掉到一秒以上,页面加载慢得让人抓狂。后来加了按用户维度的推荐结果缓存,缓存时间设成十五分钟,把用户特征变化造成的推荐抖动控制在一个可以接受的范围内。注意,缓存时间不能设太长,否则用户当天收藏了一份资料,推荐列表要等到第二天才更新,这种体验也很糟糕。
第三个坑是“伪热门”资料的干扰。热门榜时间衰减权重设得不够,导致某段时间突然爆火的资料霸榜两周,而真正符合目标用户需求的中小热门资料被挤出曝光位。这个问题的本质是热度算法参数没有调好,后来把衰减周期缩短到三天,头部资料的热度自然回落,长尾资料重新得到曝光,推荐列表的分布才恢复正常。
第四个坑是资料表设计时把标签字段做成了逗号分隔的字符串存进单列。开发期这样写很方便,但到了推荐阶段要按标签频率计算用户偏好向量,发现这列几乎没法高效处理,最后只能重新拆表,把标签做成标准的多对多关联,才算解决问题。这种基础数据结构的坑越到后期代价越大,一开始就应该规范设计。
还有一个容易被忽略的细节:日志。推荐系统一定要从第一天就记录完整的推荐日志,包括某次推荐给某个用户哪几份资料、背后的召回条目和得分。否则出了问题想排查,连当时推荐的是什么都不知道,只能瞎猜。我在项目后期靠日志定位过至少三次推荐异常,都是靠之前埋好的日志数据快速定位的。
最后再分享一个小技巧。如果你也打算自己动手搭这么一个系统,先从最简版本开始,别一上来就把用户画像、协同过滤、MMR、质量模型全部一起做。我的经验是先把“按考试类型过滤加标签匹配”这个最朴素的推荐做出来,让用户在页面上真实用起来,把资料标签和数据结构的坑踩平,再逐步叠加优化策略。这样每一步都在已有反馈的基础上迭代,问题能及时暴露,效率反而最高。
这套系统目前的稳定运行状态是:Django负责所有业务逻辑和管理后台,Flask独立部署负责推荐计算,每天定时同步用户特征,Redis承担缓存。对我个人来说,最大的收获其实不只是把推荐算法跑通了,而是完整走了一遍从数据治理、画像建模、召回排序到线上验证的闭环。考公考编垂直领域的资料推荐没有太多现成方案可以参考,能趟出一条适合自己的路,这个过程本身就很有价值。