ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

基于Python+Django的招聘推荐系统设计与实现

基于Python+Django的招聘推荐系统设计与实现 不知道你有没有过这种体验在一家招聘平台上传简历后推荐给你的不是“熟悉Python”的运营岗就是“3年以上Java经验”的后端岗跟自己的技能树八竿子打不着。被这种体验折磨多了我就想着自己动手做一套招聘推荐系统。最终交付的是一个基于Python Django的完整项目包含源码、数据库和配套文档既能当课程设计/毕业设计也能作为以后接入真实招聘业务的可扩展原型。核心思路并不复杂把简历和职位拆成技能标签再用一套加权打分公式算匹配度最后按分数排序推荐给用户HR端则反过来看到最匹配的候选人列表。下面我把整个系统的设计过程、数据库结构、推荐引擎写法、踩坑记录都摊开讲一遍。无论你是准备拿这个题目做毕设还是想给自己的项目库加一个“推荐系统”级别的作品这篇内容基本够用了。1. 招聘推荐系统到底在解决什么问题1.1 求职者和HR各自面对的窘境先看求职者。我在做这个系统之前问过不少朋友大家吐槽最多的一句话是“搜职位靠关键词但写职位的人跟我想的根本不是一个词。”企业写“熟悉一种服务端框架”求职者搜“Django”两边对不上系统推荐自然稀烂。更麻烦的是很多推荐逻辑只看浏览记录和职位热度结果技能不匹配的职位也能推到首页。再看HR。一个稍微活跃点的岗位发布之后一天收几十份简历太正常了。HR在筛选页一页一页翻想按“技能吻合度”排序但绝大多数招聘后台只提供“发布时间”排序唯一能做的事就是关键词搜索。这种情况是典型的双边信息不匹配求职者找得辛苦HR筛得崩溃。所以这个项目在设计需求阶段就定了三个目标求职者端上传简历后能看到“为什么给我推这个岗位”也就是推荐结果可解释HR端发布职位后系统按匹配度倒序展示候选人后台管理公司、职位、简历、投递记录全部可维护方便做数据演示。这三点定下来后面所有表结构和代码就都有方向了。1.2 为什么选择Django而不是Spring Boot选型时我确实纠结过。Spring Boot在国内就业市场占有率很高写进简历也不难看但最后我还是选了Django主要基于三个原因。第一Django自带的ORM非常契合这种“表结构明确”的业务系统。招聘推荐的核心是用户表、职位表、简历表、投递关系表Django的迁移工具能让我在改模型时随时同步数据库开发效率高很多。第二Python生态对算法落地太友好了。推荐引擎要用到中文分词、关键词权重、向量计算Spring Boot里这些都得自己找Java库而Python这边jieba、sklearn现成可用。哪怕是最后计划上向量数据库Python的接入成本也比Java低一截。第三部署资料成熟。Django项目不管是直接用runserver跑起来演示还是上宝塔部署、配Nginx反向代理网上都有大量现成教程。对课程设计和毕业设计来说文档和部署这两块省下的时间是很可观的。1.3 为什么最终选了基于内容的推荐现在很多文章一上来就喊“协同过滤”“深度学习排序”但对于招聘这个场景我并不推荐起步就上协同过滤。原因很现实协同过滤依赖大量用户行为数据——浏览、投递、收藏、点击这些数据在项目初期几乎为零。一个刚注册的求职者没有任何行为记录协同过滤根本不知道他想要什么这就是典型的冷启动问题。我选的方案是“基于内容的匹配”也就是把简历文本和职位描述拆成特征然后算两者的相似度。它的特点是冷启动友好只要有简历内容和职位内容就能算分可解释性强可以明确告诉用户“因为你会Django而该职位要求Django所以推荐给你”不依赖用户量哪怕系统里只有10个职位也能对每个简历给出推荐结果。这套方案打底后面即使要引入协同过滤也只是在内容推荐结果之上做“补充排序”不会推翻整体架构。2. 数据库设计决定推荐效果上限的第一张牌2.1 七张核心表怎么划分项目里我把数据拆成了七个核心模型用户表、公司表、职位表、简历表、投递表、收藏表、推荐日志表。这里把主要字段列一下你打开源码对照看会清晰很多。表名重要字段用途userusername, password_hash, user_type, phone, email统一账号体系user_type区分求职者和HRcompanyname, industry, city, scale, description公司基础档案job_postcompany_id, title, city, salary_min, salary_max, experience_require, description, is_active招聘职位多对多关联技能标签resumeuser_id, title, city, years, expected_salary_min, expected_salary_max, skills, intro求职者简历applicationresume_id, job_id, status, created_at, updated_at投递关系status做成状态机favorite_jobresume_id, job_id, created_at收藏关系独立成表方便统计recommend_logresume_id, job_id, score, reason, is_clicked, created_at记录每次推荐结果用于后续效果分析这套表结构不算复杂但已经覆盖了招聘系统最核心的业务闭环用户维护简历HR发布职位求职者投递或收藏系统记录行为并产生新的推荐。推荐日志表很容易被忽略但它特别重要没有它你就永远不知道自己的推荐算法点击率是多少。2.2 技能标签到底用多对多还是JSON字段这是我在设计阶段纠结最久的一个点。技能标签无非两种存法建单独的SkillTag表职位和简历都跟它多对多关联或者直接在job_post表里面加一个skills字段塞JSON数组进去。第一种方案的好处是规范、可统计。“系统里有多少岗位需要Python”这种问题一条ORM就能查出来后台筛选、标签管理也顺手。缺点是查询时如果不注意预取容易产生N1的问题——每个职位都要再查一遍技能表。第二种方案写入简单但用起来很别扭。我想按技能统计岗位数量时要么用LIKE去模糊匹配JSON字符串要么在Python里全量遍历再过滤数据一多就卡。更麻烦的是JSON字段没法做外键约束职位技能的增删改查全都靠手写逻辑容易出错。最终我选了多对多表但配了一个关键优化查询职位列表时用prefetch_related(skills)一次把技能取出来。Django的Prefetch对象可以再筛选技能数量像“只取前5个标签”这种场景也能覆盖。2.3 避免N1查询的ORM写法好多初学者写Django查询习惯在一个循环里访问关联对象。比如for job in jobs: print(job.company.name) for skill in job.skills.all(): print(skill.name)这种写法在数据量小的时候看不出问题一旦职位量到几百条每一个职位都要额外发SQL去查公司和技能整个推荐接口能慢到怀疑人生。我调试的时候用Django Debug Toolbar看过一个推荐接口光SQL查询就发了三百多条。正确写法是jobs ( JobPost.objects .select_related(company) .prefetch_related(skills) .filter(is_activeTrue) )select_related解决外键的JOIN查询prefetch_related解决多对多的批量查询。加了这两个之后同样的推荐接口SQL数量会降到个位数。这属于非常基础但又非常实用的优化至少在招聘推荐这个场景里能解决80%的数据库查询性能问题。3. 推荐引擎核心代码打分、排序、兜底3.1 从简历和职位提取特征推荐引擎的第一步是把简历和职位的文本变成可计算的“特征向量”。我这边没上太复杂的深度学习模型用的是最稳的组合式特征。首先是“硬特征”——技能标签。简历表里存了用户勾选或从文本里抽取的技能职位表里也有HR填写的技能要求。这是最核心的匹配依据因为技能是高度结构化的不存在“Django”和“Python”算不算同类这种问题。其次是“软特征”——职位标题和描述里的关键词。以标题为例我会用jieba做关键词抽取import jieba.analyse job_title 高级Python/Django开发工程师 keywords jieba.analyse.extract_tags(job_title, topK5, withWeightTrue) print(keywords)输出类似[(python, 1.2), (django, 0.9), (开发, 0.6), (工程师, 0.5), (高级, 0.3)]。我还会把技能表的名称加入自定义词典避免“Django”被拆成“Django”和“框架”两个词。特征分好之后剩下的就交给打分公式。3.2 匹配度打分的实现打分公式是核心我给的是一套四段式加权分score 0.4 * 技能重合率 0.25 * 标题命中分 0.2 * 经验贴近分 0.15 * 职位热度分技能重合率不能只看“命中个数”否则技能多的人优势太大。我用的口径是简历技能集合与职位技能集合的交集数量除以职位技能集合的总数。意思是“这个职位要求10个技能你覆盖了5个重合率0.5”。标题命中是一个0或1的加分项如果简历里的任一技能直接出现在职位标题中说明这个职位大概率高度匹配。经验贴近分用1 - abs(简历年限 - 职位要求年限) / 5来计算年限差超过5年就归零。职位热度分则是职位浏览数和投递数的归一化结果主要用来打破同分的局面。完整函数我写成这样def compute_score(resume, job): resume_skills set(resume.skills.values_list(name, flatTrue)) job_skills set(job.skills.values_list(name, flatTrue)) if not job_skills: return 0 skill_ratio len(resume_skills job_skills) / len(job_skills) title_hit 1 if any(s in job.title for s in resume_skills) else 0 exp_diff abs(resume.years - job.experience_years) exp_score max(0, 1 - exp_diff / 5) hot_score job.hot_score() # view_count和application_count归一化到[0,1] score 0.4 * skill_ratio 0.25 * title_hit 0.2 * exp_score 0.15 * hot_score return round(score, 4)有朋友问我为什么不用余弦相似度那样不是更“算法”吗余弦相似度的问题在于它会把“熟悉”“掌握”“熟练”这类高频无意义词纳入计算真正能区分候选人的专业术语反而容易被稀释。用技能重合率做主导能保证推荐结果至少在“技术的交叠程度”上是说得通的。3.3 冷启动和空结果兜底推荐系统最怕的不是算不准而是一个结果都推不出来。我刚写完初版就遇到过这种尴尬简历里技能填得少数据库里职位技能又都对不上推荐列表直接为空。后面我加了两层兜底。第一层技能为空时的热门推荐。如果简历一个技能标签都没有说明没有任何可计算的特征此时直接按职位热度排序取同城的TopNfallback ( JobPost.objects .filter(cityresume.city, is_activeTrue) .order_by(-view_count, -created_at)[:10] )第二层技能存在但匹配度为0时的模糊兜底。我会先放宽条件只看城市和行业从同城职位里按“简历技能集与职位技能集是否有交集”去粗筛找不到再退回热门列表。这两层兜底的作用不只是“让页面不为空”更重要的是保证用户体验——一个完全空白的推荐列表会直接让用户认为系统坏了。3.4 推荐结果缓存如何设计推荐引擎每次都要遍历职位并打分虽然项目规模下不算慢但也没必要每次请求都重新算一遍。我直接用Django的缓存框架推荐结果按简历ID做了30分钟缓存from django.core.cache import cache def get_recommendations(resume_id): cache_key frec:resume:{resume_id} result cache.get(cache_key) if result is None: result recommend_jobs_for_resume(resume_id) cache.set(cache_key, result, timeout60 * 30) return result需要注意的是我缓存的是职位ID列表而不是推荐页面的完整JSON。原因很简单HR可能在半小时内更新职位标题、薪资或下线某个职位如果缓存了JSON用户看到的还是旧数据。缓存ID、查询的时候实时去数据库取才是“结果可过期但数据最新”的正确做法。一旦用户修改了简历、投递了职位、收藏了职位我会主动删掉对应的缓存key避免下次推荐把已投递的职位再推一遍。4. 把推荐服务融入Django Web系统4.1 项目目录与模块划分这个项目我没有把全部代码塞进一个app而是按业务边界拆成了多个应用。目录结构大致如下recruit/ ├── manage.py ├── requirements.txt ├── config/ # 项目配置、根路由 │ ├── settings.py │ ├── urls.py │ └── wsgi.py ├── apps/ │ ├── users/ # 用户、登录、注册 │ ├── jobs/ # 职位与公司 │ ├── resumes/ # 简历 │ ├── applications/ # 投递与收藏 │ └── recommend/ # 推荐引擎 ├── static/ ├── templates/ └── docs/这样划分的好处是推荐引擎只依赖resumes和jobs两个模块的模型通过接口对外提供推荐和匹配服务将来想换成ElasticSearch或者向量数据库只要能保持get_recommendations(resume_id)这个接口签名不变业务层完全不用动。4.2 推荐接口的编写推荐接口不复杂我给前端提供的是一个简单的JSON接口。基础版可以直接用Django原生视图from django.shortcuts import get_object_or_404 from django.http import JsonResponse def recommend_api(request, resume_id): resume get_object_or_404(Resume, pkresume_id) job_ids get_recommendations(resume_id) jobs ( JobPost.objects .filter(id__injob_ids, is_activeTrue) .select_related(company) ) data [] for j in jobs: data.append({ id: j.id, title: j.title, company: j.company.name, city: j.city, salary: f{j.salary_min}-{j.salary_max}K, score: j.recommend_score, }) return JsonResponse({code: 0, data: data})接口写好之后前端只需要根据当前用户找简历再拿着resume_id请求这个接口即可。如果后续要做分页可以把job_ids切片后再放进id__in避免一次加载太多职位。4.3 后台管理界面优化Django Admin默认界面虽然能用但直接交出去有点糙。我做了两个层面的优化。第一个层面是引入django-simpleui替换默认的Admin风格侧边栏菜单、页面布局看起来更像正式系统展示给老师或者领导时会加分不少。第二个层面是配置好每个模型的操作视图。比如职位管理我配置了这些admin.register(JobPost) class JobPostAdmin(admin.ModelAdmin): list_display (title, company, city, salary_min, salary_max, view_count, created_at) list_filter (city, is_active, company__industry) search_fields (title, description) list_per_page 20这里面的逻辑是HR最关心城市、薪资、状态所以列表页直接展示搜索框支持标题和描述模糊查询筛选条件里把行业也加进去方便做运营分析。我还加了一个自定义action批量下架过期职位。这样后台就不再是“为了有后台而后台”而是真正能支撑日常运营。5. 投递闭环和反馈回流推荐系统不是算完就结束5.1 投递状态机设计推荐只是开始用户看到职位之后还会投递、收藏、被查看、被邀约这整条链路才是系统真正的价值所在。我把投递状态定义成四个取值0已投递1HR已查看2已邀面试3不合适实现上就是一个普通的整数字段但前后端都按这套状态机展示。updated_at字段记录了状态变化时间后面做漏斗分析时能算出“从投递到查看的中间耗时”这个指标对HR端体验优化很有参考价值。5.2 用户行为怎么变成下一次推荐的权重如果只做一次性推荐那系统就是个“计算器”不算真正的推荐系统。我做了两个容易落地的反馈回流逻辑。第一用户主动投递某个职位之后我会把该职位关联的技能在当前简历画像中的权重加一分。这相当于把“投递行为”隐式解释为“用户认可这些技能方向”。用Django的F表达式写起来很简单from django.db.models import F for skill in job.skills.all(): profile, _ UserSkillProfile.objects.get_or_create( resumeresume, skillskill ) profile.weight F(weight) 1 profile.save()这样随着用户投递增多系统对他的技能偏好画像会越来越准。这也是将来接协同过滤时最好用的“隐性反馈”数据。第二投递成功的瞬间把推荐缓存删掉。原因很简单——用户已经投过的职位下次就不该再出现在推荐列表里否则会显得系统“很蠢”。5.3 城市、薪资等硬过滤和打分排序的分工推荐时最容易犯的一个错误是把所有条件都揉进打分公式里。比如“城市不匹配扣0.2分薪资不匹配扣0.3分”看起来很有道理但结果就是推荐一个薪资下限低于用户期望、或者远在不同城市的职位用户整体体验很差。我的做法是严格区分“硬性过滤”和“软性打分”硬性过滤条件城市、薪资范围、学历要求、职位是否下线。这些直接进SQL查询不满足就排除。软性评分条件技能重合度、标题命中、经验贴近度、热度加分。这些在候选集上打分排序。硬过滤时我常用这样一组查询jobs ( JobPost.objects .filter( is_activeTrue, cityresume.city, salary_max__gteresume.expected_salary_min, ) .select_related(company) )这样页面上展示的每一个职位都至少是“用户愿意去这个城市、薪资范围能接受”的职位然后再谈匹配度排序。6. 实测中踩过的坑环境、编码、性能排查6.1 mysqlclient安装失败与MySQL 8认证插件问题这个坑基本每个用Django连MySQL的人都会遇到。pip install mysqlclient在Windows上经常卡在mysql_config not found。我的解决方法是优先使用预编译的whl包或者直接改用PyMySQLpip install PyMySQL然后在项目的config/__init__.py里加一行import pymysql pymysql.install_as_MySQLdb()第二个坑是MySQL 8默认的认证插件是caching_sha2_password有些PyMySQL老版本会认证失败。最稳妥的方式是创建数据库用户时显式指定认证方式CREATE USER recruitlocalhost IDENTIFIED WITH mysql_native_password BY your_password; GRANT ALL PRIVILEGES ON recruit_db.* TO recruitlocalhost;我用宝塔面板部署时还遇到过端口没放行、数据库只能本机访问的问题。解决办法是把绑定地址改成内网地址而不是直接改成0.0.0.0让数据库暴露到公网安全性和可用性要兼顾。6.2 jieba分词与中文乱码推荐引擎用jieba处理职位描述时如果自定义词典没配置好“Django”这种词会被拆得七零八落。我的做法是在项目启动时加载技能标签到词典import jieba def load_skill_dict(): for skill in SkillTag.objects.values_list(name, flatTrue): jieba.add_word(skill)这样职位描述里出现“Django开发”时jieba会更倾向把“Django”作为一个完整词保留下来关键词抽取的结果会准很多。中文乱码在Windows下也很常见。排查思路是数据库连接统一UTF-8settings.py里设置好OPTIONS的charset参数源码文件头部声明编码控制台运行前临时设置PYTHONIOENCODINGutf-8。只要这三处一致基本不会再乱码。6.3 推荐慢的排查链路有段时间推荐接口在本地只要几十毫秒放到服务器上却要一两秒。我用Django Debug Toolbar看到两个问题。第一个是前面说的N1查询职位列表每行都触发技能和公司的重复查询。加上select_related和prefetch_related后大幅缓解这个前面已经讲过了。第二个问题是全表扫描。所有职位都进了打分循环哪怕用户只在上海、期望薪资20K以上但系统还是把北京、广州的职位都算了一遍。我的优化是用粗筛缩小候选集只用技能关键词做一次宽松匹配from django.db.models import Q q Q() for skill in resume_skills: q | Q(skills__name__icontainsskill) candidates ( JobPost.objects .filter(q) .filter(cityresume.city, is_activeTrue) .distinct() )粗筛之后再用打分函数在较小集合上精排接口耗时基本能控制在两百毫秒以内。这里的关键思路是“先在数据库里把候选范围缩小再用算法做精细排序”而不是在Python里暴力遍历全表。6.4 重复推荐和已投递职位过滤第一次联调时我发现一个很尴尬的情况用户都投递过某个职位了刷新推荐列表它还在第一屏。原因很简单——推荐引擎只看技能匹配没有关联投递记录。修复方案是在推荐查询里排除已投递和已收藏的职位ID。我用的Django写法是from django.db.models import Q applied_job_ids ( Application.objects .filter(resume_idresume_id) .values_list(job_id, flatTrue) ) recommendations [ job for job in candidates if job.id not in applied_job_ids ]如果有大量历史投递记录建议用SQL层面的EXISTS或者NOT EXISTS子查询而不是把ID拉到Python里判断。Django的~Q(id__inSubquery(...))也可以实现性能更好。7. 拿到源码之后启动步骤与二次开发方向7.1 本地快速启动流程如果你已经拿到了源码先不要急着复制粘贴进IDE我建议按下面顺序操作一遍能少踩很多环境坑。python -m venv venv venv\Scripts\activate # Windows pip install -r requirements.txt然后修改配置文件里的数据库连接执行迁移和数据初始化python manage.py migrate python manage.py loaddata initial_data.json python manage.py createsuperuser python manage.py runserver访问http://127.0.0.1:8000/admin登录后台先创建几家公司、职位、简历测试数据再到前台看推荐效果。细心一点的可以准备三份技能完全不同简历账号比如“纯Python”“PythonDjangoMySQL”“运维Linux方向”用来验证推荐结果是否真的随简历差异变化。7.2 如何扩展算法和检索能力如果只想交作业上面这些已经完全够用。但如果你想在项目里继续加亮点我有几个直接从基础版往上升级的建议。第一个方向是把推荐结果从“标签重合”升级为“语义向量”。用sentence-transformers把职位描述和简历文本转化成向量再存进向量数据库查询时按余弦相似度排序。这种做法的好处是能解决“同义词”问题比如职位写“后端工程师”简历写“服务端开发”标签匹配会直接算0分但语义向量能识别出它们是相关的。这个扩展不会推翻现有代码只需要在recommend模块里新增一个计算向量的方法即可。第二个方向是增加定时任务定时重建推荐缓存。用Django Command脚本加上系统的cron/schedule机制每天凌晨离线计算一次热门职位和轻量召回集白天用户请求时直接命中缓存响应速度会更好。第三个方向是加一份简单的A/B测试记录。目前系统里的recommend_log表已经存了推荐的分数和是否被点击基于这个数据可以统计不同权重参数下的点击率逐步调优公式里的系数。这个点写进文档或答辩里会显得很有“工程感”不再是简单展示一个固定算法。7.3 文档与答辩演示的组织建议如果是课程设计或毕业设计文档和演示往往是给分重点。我的建议是文档按这个顺序写需求分析、数据库设计、推荐算法设计、系统实现、测试结果、部署说明。其中“推荐算法设计”是核心把技能重合率公式、权重设置、兜底策略讲清楚老师能很快判断出你真的理解了这个系统。演示的时候建议准备三套账号一套技能标签丰富的后端工程师简历一套技能较少的运营简历一套完全空技能的简历。前两套展示推荐差异第三套展示冷启动兜底逻辑。这样既展示了常规功能又展示了异常处理能力比单纯跑通页面更有说服力。最后再分享一个我自己调试时的习惯每次改完推荐逻辑我都会拿同一份测试简历对比推荐列表的前三名并且把推荐岗位的技能重合率打印出来看。因为打分公式一旦多了很容易出现“听起来很科学但结果不合理”的情况这种问题只有靠真实数据才能暴露出来。这个项目如果只是应付作业跑通基础版就够了但你要是想在面试时拿出手建议把5.2节的行为回流部分也实现掉它会让你对推荐系统的理解直接上一个台阶。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进