简介:基于Python实现简历智能推荐算法,是一套面向课程设计、毕业设计以及NLP与机器学习初学者的完整项目资源。该项目聚焦招聘场景中的简历与职位描述自动匹配,涵盖文本清洗、去停用词、词形还原等NLP预处理步骤,以及TF-IDF、词嵌入等特征工程方法;模型部分对比朴素贝叶斯、SVM、决策树、CNN、Bi-LSTM等常用算法,并给出超参数调优、交叉验证的实践思路,同时融入在线学习与协同过滤以提升推荐准确性。压缩包共337个文件,含19个Python源码、17个pkl模型文件、hdf5与checkpoint训练权重,以及HTML结果展示页、Markdown说明文档和毕业论文Word稿,资源目录按源码、模型、文档分层,整体大小127.43MB,方便从数据处理到模型部署全流程参考。已有485人学习浏览,适合希望快速搭建简历推荐原型、理解NLP与机器学习落地细节的读者。
1. 简历智能推荐的第一课:先让机器“看明白”简历,再谈算法
一个很反直觉的结论:简历智能推荐算法这类项目,真正决定成败的往往不是推荐模型,而是前面那段没人愿意仔细做的文本解析和词库建设。我见过太多同学装好 scikit-learn,调了两天参,最后发现中文分词把“Java开发”切成了“Java / 开发”,把“Vue”当成一句英文滤掉了,推荐结果自然一言难尽。这里说的基于 Python 的简历智能推荐算法,本质上是一个“岗位 JD 当查询、简历库当文档”的检索排序问题:输入一份岗位描述,从几百几千份简历里找出最匹配的 Top N,并且让 HR 一眼知道“为什么推荐他”。适合正在做招聘系统、内部人才库的开发者,也适合拿来做实训课设的中级 Python 学员。这篇会把链路拆开,从语料准备讲到相似度计算,附上能直接跑的最小代码和参数边界。
2. 整体链路与技术选型:为什么简历推荐的核心是“职位语义空间”
2.1 先把推荐问题拆成“召回、粗排、精排”的轻量版
商品推荐系统的经典三段式,在简历推荐里完全可以做轻量映射。召回阶段的任务是把明显不合适的简历过滤掉,比如岗位要求在北京,简历期望城市却在杭州,这一层用简单规则就能完成,没必要让算法介入。粗排阶段才是算法的主场:对通过召回的简历做文本相似度计算,给出一个初步分数。精排阶段要叠加业务字段,比如工作年限、学历、当前状态等,把算法分数和业务规则混合排序。
这个拆法有一个现实原因:简历库的规模通常不会太大。内部招聘系统里几千份简历、招聘平台里几十万份简历,单机内存都能扛得住,不需要一上来就上 Spark、上向量数据库。最朴素的方案往往是维护成本最低的。你在做技术选型时,先问自己一个问题:这份简历推荐是给谁用的?HR 在招聘后台点开一个岗位,系统要在几百毫秒内返回候选名单,这就决定了初版算法必须够快、够简单、够容易解释。
2.2 为什么第一版选“TF-IDF + 余弦相似度”而不是神经网络
简历推荐常见的算法路线有这么几条:纯规则匹配、TF-IDF 向量加余弦相似度、Word2Vec 平均向量、BERT/BGE 这类预训练向量模型。纯规则匹配最简单,简历里出现“Python”就给 2 分,出现“Java”就给 3 分,但它的天花板很低,因为简历文本的措辞千变万化,“熟悉 Linux 脚本”和“掌握 Shell 编程”词面完全不同,意思却一样。
Word2Vec 需要语料训练,几十份简历根本训不出像样的词向量;用公开词向量又经常遇到未登录词。BERT/BGE 这类模型效果确实好,但需要 GPU 推理服务,普通开发机跑批量打分,延迟和内存都吃紧,第一版就上重模型会让项目周期拉长好几倍。所以 TF-IDF 加余弦相似度是简历推荐最靠谱的基线方案:几行代码能跑通,结果可解释,后期要升级也只是把向量化那一层替换成深度模型,上层排序逻辑不用改。
有个点需要提前说明:TF-IDF 解决的是“词面相似”,它分不清“三年 Java 后端”和“三年 Java 前端”的差别。这个问题要靠后文的技能命中特征和业务字段精排来补,不是换模型能一步到位的。
2.3 五段式流水线:从原始简历到推荐列表的完整路径
我一般会把简历推荐系统拆成五个阶段:解析、清洗、特征化、相似度计算、精排输出。解析解决“读文件”的问题,把 PDF、DOCX、纯文本里的内容抽出来;清洗解决“脏数据”的问题,去掉电话、邮箱、无意义的换行符;特征化解决“变成向量”的问题,包括中文分词、停用词过滤、TF-IDF 向量化;相似度计算负责给每一份简历打分;精排负责叠加经验年限、城市这些业务规则。
搭建环境这一步没有太多玄学,用 Python 3.9 以上的版本,在虚拟环境里把必要依赖装好即可。如果你还在纠结 IDE,用 VSCode 把 Python 环境配置好,能跑通import jieba就够了,不一定要用 PyCharm 全家桶。
# 创建虚拟环境并安装依赖 python -m venv .venv source .venv/bin/activate # Windows 下用 .venv\Scripts\activate pip install jieba scikit-learn pandas pypdf说明一下:jieba负责中文分词,scikit-learn提供 TF-IDF 向量化和余弦相似度计算,pypdf用来解析 PDF 简历文本,pandas用于后续的数据回看和结果分析。安装时如果遇到网络慢的问题,可以换成国内镜像源,但不要把依赖版本锁得太死,这几个库的接口在常用版本里差别不大。装完后先跑一个最小的冒烟测试:
# 验证环境是否可用的冒烟测试 import jieba from sklearn.feature_extraction.text import TfidfVectorizer print(jieba.lcut("熟悉Python和爬虫开发")) vec = TfidfVectorizer() X = vec.fit_transform(["熟悉Python", "熟悉Java"]) print(X.shape) # 正常应该输出 (2, 3)这里如果输出(2, 3),说明环境和两个核心库都正常。不要小看这一步,环境问题占简历推荐项目初期踩坑的三分之一,提前确认能省很多时间。
3. 简历语料与技能词库:把原始简历变成可计算的文档集
3.1 数据从哪里来:合规收集与字段设计是第一步
简历推荐的第一步不是写算法,而是准备好一批能用的简历文本。这里要特别强调合规问题:简历属于个人敏感信息,不能从公开网络随意抓取,也不能拿公司真实简历库直接做实验。常见做法有三种。第一,用自己或同事脱敏后的模拟数据,字段全部替换成假名;第二,用公开的简历样例数据集;第三,和业务方确认后,使用内部投递库中已经授权用于算法实验的简历子集。
我见过不少项目一上来就想做采集、做可视化大屏,其实这些都是后话。先把数据通路打通,才有资格谈效果。简历数据在进入算法之前,至少要包含下面这些字段:
| 字段 | 含义 | 示例 | 是否进入算法特征 |
|---|---|---|---|
| resume_id | 简历唯一 ID | R0001 | 否 |
| name | 姓名(脱敏) | 张** | 否 |
| phone | 电话(脱敏) | 138****0000 | 否 |
| 邮箱(脱敏) | zh**@xx.com | 否 | |
| skill_text | 技能描述原文 | 熟悉Python、Flask | 是 |
| exp_year | 工作年限 | 3 | 是(精排) |
| city | 期望城市 | 北京 | 是(精排) |
| intent | 求职意向岗位 | Python后端 | 是(召回过滤) |
字段设计的原则是“算法字段和展示字段分离”。skill_text是给向量化用的原始文本,exp_year和city是给精排用的结构化字段。不要在早期就把学历、薪资期望、到岗时间全部塞进特征里,特征越少越容易排查问题。
脱敏处理建议在数据入库时就完成,正则替换是最快的方式:
# 简历脱敏:把手机号和邮箱替换成占位符 import re def desensitize(text: str) -> str: text = re.sub(r"(?<!\d)1[3-9]\d{9}(?!\d)", "[手机号]", text) text = re.sub(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}", "[邮箱]", text) return text sample = "联系电话13812340000,邮箱zhangsan@example.com" print(desensitize(sample))正则里(?<!\d)和(?!\d)是负向断言,避免把 12 位号码从中间截断,也避免把身份证号里的连续数字误判成手机号。这一步处理完的数据才能进入后续的解析流程。真实项目中脱敏逻辑比这个复杂,可能还要处理微信号、QQ 号、座机等,但核心思路是一致的:先替换敏感信息,再做任何计算。
3.2 技能词库构建:按“岗位族”组织,而不是按原始字符串匹配
技能词库是简历推荐里最容易被低估的部分。很多初版方案直接把技能做成一个列表,简历文本里in一下就完事。这样做的直接问题是词面变体太多,一个简单的“前端框架”可以有“Vue”“vue.js”“Vue3”“vue3”四种写法,靠字符串匹配要么漏掉,要么误伤。
我的做法是给技能词库分三层。第一层是岗位族,比如python、java、frontend、data;第二层是技能主词,比如Python、Spring Boot、Vue;第三层是别名表,把常见写法归并到主词下。这样做的好处是推荐结果可以带出“这个候选人的技能属于哪个岗位族”,而不仅仅是“他命中了一个词”。
实际构建词库时,有三条路径可以并行。第一,从历史岗位 JD 里提取高频技术词,人工复核后入库;第二,从公开的技术栈清单里整理一份常用词表;第三,从推荐结果的反例里回收,比如某次误推是因为把“docker”当成了“doc”,这个词就该进词库修正。词库本身不追求大而全,覆盖率 80% 比准确率 99% 重要,因为漏词会导致简历特征稀疏,误词会把推荐结果带偏。
3.3 用脚本快速造模拟简历:保证可复现的实验基础
没有正式数据时,可以写一个小的数据生成脚本,造出 200 份结构可控的简历。这样做的目的不是骗自己,而是让后续每一个模块都有确定的输入输出,能跑单元测试。
# 生成模拟简历数据,保证实验可复现 import json import random SKILL_POOL = { "python": ["python", "flask", "fastapi", "pandas", "numpy"], "java": ["java", "spring", "mybatis", "kafka"], "frontend": ["vue", "react", "webpack", "typescript"], "data": ["sql", "spark", "hive", "clickhouse"], } def make_resume(idx: int) -> dict: major = random.choice(list(SKILL_POOL.keys())) skills = set(SKILL_POOL[major]) # 每个候选人再随机搭配 1~2 个其他岗位族技能,制造关联噪音 for _ in range(random.randint(1, 2)): other = random.choice(list(SKILL_POOL.keys())) skills |= set(random.sample(SKILL_POOL[other], k=1)) return { "resume_id": f"R{idx:04d}", "skill_text": ", ".join(sorted(skills)), "exp_year": random.randint(0, 12), "city": random.choice(["北京", "上海", "广州", "深圳", "杭州"]), "intent": major, } random.seed(20240701) # 固定种子保证每次生成结果一致 resumes = [make_resume(i) for i in range(200)] with open("mock_resumes.json", "w", encoding="utf-8") as f: json.dump(resumes, f, ensure_ascii=False, indent=2) print(f"已生成 {len(resumes)} 份模拟简历")参数说明:random.seed固定下来,保证每次跑出的数据集完全一致,否则后续调参时换了一批数据,很难判断效果变化是来自代码还是来自随机波动。SKILL_POOL里故意让技能之间有一定交叉,比如 Python 岗位的人可能也会一点 SQL,这更接近真实场景——纯粹的一对一技能匹配反而是最容易做的情况。intent字段记录真实求职意向,后面可以用来评估推荐结果的准确率。
4. 从简历文本到特征向量:解析、分词、技能命中与 TF-IDF 向量化
4.1 先把简历读成干净的纯文本:PDF、DOCX 与边界情况
简历文件格式五花八门,第一版建议只支持纯文本,格式有限但能快速打通流程。如果你需要用真实简历做实验,优先处理 TXT 和 DOCX,PDF 留到后面再说,因为 PDF 解析的坑尤其多。
下面这段代码负责把原始文本洗成干净的语料:
# 简历文本清洗:去空行、去噪声、统一换行 import re def clean_text(raw: str) -> str: # 按行处理,去掉首尾空白,过滤掉只剩下标点的空行 lines = [] for line in raw.splitlines(): line = line.strip() if len(line) > 1: # 过滤单个字符的无意义行 lines.append(line) return "\n".join(lines) raw = """ 张三 熟悉 Python、Flask,有 3 年后端开发经验。 电话:13812340000 """ print(clean_text(raw))逻辑说明:先按splitlines()切分原始文本,再逐行strip()去掉首尾空格,最后把长度小于等于 1 的行直接丢掉,因为简历里经常出现单独的空格行、制表符行,这些内容进向量化只增加噪音。len(line) > 1这个阈值不能设得太高,中文简历里有的标题就两个字,比如“教育”“项目”,过滤掉了会影响语义完整度。
PDF 解析建议单独封装一个函数,用pypdf的extract_text()方法提取文本。但要记住两个边界。第一,扫描版 PDF 没有文本层,提取结果是空字符串,这时候必须走 OCR,成本高且容易出错;第二,双栏排版的 PDF 提取出来的文本顺序是错乱的,左栏读一半跳到右栏,语义完全断裂。所以初版对 PDF 的策略是:能提取就用,提取后检查字符数,少于 200 字的直接打标人工补录,不要硬喂给算法。
4.2 中文分词的两个关键坑:专有名词保护与自定义词典
分词是简历文本特征化的核心环节。jieba默认词典对通用中文支持尚可,但遇到技术名词就很吃力。我见过最典型的例子是“Three.js”被切成“Three / js”,而“js”是停用词,直接丢掉,技术栈信息就没了。解决这种问题只有一个可靠路径:把技能词库灌进jieba的用户词典。
# 加载技能词库为 jieba 用户词典 import jieba def build_userdict(skills: list[str], out_path: str = "skill_dict.txt") -> None: """把技能词写成语料格式,加载进 jieba。""" with open(out_path, "w", encoding="utf-8") as f: for skill in skills: # 格式:词 词频 词性,词频给 10 表示保持词的独立性 f.write(f"{skill} 10 nz\n") jieba.load_userdict(out_path) skills = ["python", "flask", "three.js", "vue", "spring boot"] build_userdict(skills) text = "熟悉three.js和spring boot开发" print(jieba.lcut(text)) # 正确输出应包含 ['three.js', 'spring boot'],而不是被切开词频参数给 10 是一个经验值,表示这个词在语料中很常见,强制保留。词性标注用nz,代表“其他专名”。这里的关键是技能的写法要和简历里的写法完全一致,技能词库里有“three.js”简历里写“threejs”就失效了,所以别名表很重要。另外要注意:jieba.load_userdict必须在第一次分词之前调用,如果你在交互式环境里已经分过一次词,需要重启内核再加载。
分词之后,技能命中就是一次普通的字符串匹配了。我习惯在向量化之前先算一套技能命中向量,用来给后续精排做证据支撑:
# 基于技能词库做命中检测,返回命中列表 def hit_skills(text: str, skills: set[str]) -> list[str]: text_lower = text.lower() hits = [] for skill in skills: # 统一转小写再判断,避免大小写不一致导致漏匹配 if skill.lower() in text_lower: hits.append(skill) return hits print(hit_skills("熟悉 Flask 和 Vue", {"flask", "vue", "django"}))这个函数看起来简单,但很容易写错:忘记转小写是常见翻车点;用==而不是in是另一个高频翻车点,会把“Python开发”里的“python”漏掉。hit_skills的输出要保留到后续步骤,它既是推荐结果的解释材料,也是技能特征的一个维度。
4.3 文本向量化的三种方案:全文本 TF-IDF、技能 OneHot、两者拼接
分词完成后,进入特征向量化环节。处理文本向量的主流思路是把整份简历文本作为特征来源;走在前面做结构化的会先抽取技能,再字典匹配。这里把两条路径放一起看,更适合初版对比选型。
| 方案 | 特征来源 | 优点 | 缺点 | 适用阶段 |
|---|---|---|---|---|
| 全文本 TF-IDF | 分词后的完整简历 | 不需要额外标注,常见语义能体现 | 同义词、否定词处理差 | 第一版基线 |
| 技能 OneHot | 手工构造技能命中向量 | 可解释性强,召回准确 | 漏词即漏特征,无泛化 | 配合基线使用 |
| 向量拼接 | TF-IDF 叠加技能命中 | 兼顾语义与业务知识 | 向量维度大,调参成本高 | 数据量上来后 |
TF-IDF 在简历推荐里有一个必须开的参数:sublinear_tf=True,因为它能把词频的爆炸式增长压缩成对数增长。一份简历里“Python”出现 10 次和出现 3 次,对语义的重要性差别远没有 10 比 3 那么大。
from sklearn.feature_extraction.text import TfidfVectorizer jd_texts = ["招聘Python后端工程师,熟悉Flask优先"] resume_texts = ["熟悉Python和Flask开发", "三年Java开发经验"] # 两份简历一份 JD,组合后统一 fit,保证同一套词表 all_texts = jd_texts + resume_texts vectorizer = TfidfVectorizer( max_features=5000, ngram_range=(1, 2), sublinear_tf=True, stop_words=["熟悉", "开发", "经验"], ) X = vectorizer.fit_transform(all_texts) jd_vec = X[0] resume_vecs = X[1:] print("特征矩阵形状:", X.shape)参数选择逻辑如下。max_features=5000限制词表大小,防止简历库大起来后内存爆炸;ngram_range=(1, 2)把相邻两个词的组合也纳入特征,比如“大数据开发”这种三字词在 unigram 下会被切碎,但在 bigram 下能保留“开发”;stop_words用于去掉“熟悉”“开发”这类对区分度没有帮助的通用词。
技能命中向量的做法是直接用MultiLabelBinarizer,把每份简历命中的技能列表转成向量,和 TF-IDF 向量横向拼接。拼接的时候用scipy.sparse.hstack,注意保持稀疏矩阵格式,不要转成 dense,否则简历量过万之后内存直接拉满。
5. 用余弦相似度实现 Top-N 推荐:最小可运行的推荐代码
5.1 相似度计算与最近邻:从 JD 到简历的余弦距离
简历推荐的核心计算其实很简单:把 JD 的向量和每份简历的向量做余弦相似度,取 Top-N。scikit-learn提供了cosine_similarity,直接对矩阵操作,不用自己写循环。
import numpy as np from sklearn.metrics.pairwise import cosine_similarity # jd_vec: 1 x D 矩阵, resume_vecs: N x D 矩阵 sim_matrix = cosine_similarity(jd_vec, resume_vecs) sim_scores = sim_matrix[0] # 拿到一份 JD 对应所有简历的分数 top_n = 10 min_score = 0.10 # 相似度低于阈值的简历不推,避免冷启动硬凑 top_indices = np.argsort(sim_scores)[::-1] # 从高到低排序 candidates = [] rank = 0 for idx in top_indices: if sim_scores[idx] < min_score: break # 后面的分数只会更低,直接截断 candidates.append((idx, float(sim_scores[idx]))) rank += 1 if rank >= top_n: break for idx, score in candidates: print(f"简历 {idx}: 相似度 {score:.4f}")这段代码里要重点理解np.argsort()返回的是索引数组而不是分数本身,所以[::-1]是把索引按分数降序排列。min_score阈值要有,否则系统没有任何可推荐简历时会硬推一份相似度只有 0.02 的候选人,HR 看到推荐质量差,对整个系统的信任度会大幅下降。对于全零向量,余弦相似度定义为 0,这时候它也会排到队伍后面,不会误导排序。
这里有个性能细节:cosine_similarity一次计算全部 pairwise 相似度。简历量上万时,这个矩阵会占很大内存,可以在计算前先用技能召回粗筛一遍,只对粗筛后的几百份简历计算相似度。
5.2 排序融合:把相似度、工作年限、城市意愿组合成最终排序
纯相似度排行的最大问题是把业务规则全部丢弃了。一份简历技术栈和 JD 完全匹配,但候选人已经 12 年经验且期望在成都,JD 要求 3 年经验且在北京,这种简历排第一就是笑话。我的习惯是把相似度作为基础分,再叠加经验匹配分和城市匹配分。
# 最终排序:相似度 + 业务字段加权 def final_score(sim: float, exp_year: float, jd_exp: float, city_match: bool, w_sim: float = 0.7, w_exp: float = 0.2, w_city: float = 0.1) -> tuple: # 经验分:0~1,JD 要求 3 年,候选人 3 年得满分,超过 6 年不再加分 if jd_exp == 0: exp_score = 1.0 else: exp_score = max(0.0, 1 - abs(exp_year - jd_exp) / max(jd_exp, 1)) exp_score = min(exp_score, 1.0) city_score = 1.0 if city_match else 0.0 score = w_sim * sim + w_exp * exp_score + w_city * city_score return score, exp_score, city_score # 假设某候选人 score, exp_score, city_score = final_score( sim=0.45, exp_year=2, jd_exp=3, city_match=True ) print(f"综合分={score:.4f}, 经验分={exp_score:.2f}, 城市分={city_score:.2f}")参数说明:w_sim=0.7、w_exp=0.2、w_city=0.1是一组常见初始值,具体权重应该根据业务反馈调整。如果岗位对城市要求极严格,干脆把w_city提到 0.3;如果岗位是远程办公,w_city可以直接置零。经验分这里用的是1 - 相对误差,意思是候选人经验越接近 JD 要求得分越高,超出的部分会受到惩罚。
这里有一个容易踩坑的点:abs(exp_year - jd_exp)对经验超过要求的候选人不友好,10 年经验对 3 年经验的岗位也许不是减分项。所以在真实项目里我经常把经验打分改成阶梯式:0 ~ jd_exp 之间线性加分,超过 jd_exp 之后封顶为 1.0,超出再多也不加分、不扣分。需要实际跑一次大批量推荐再决定用哪种经验打分函数,不要拍脑袋定。
5.3 结果解释:给 HR 一个“为什么推这个人”的回放
推荐系统的信任度取决于解释能力。黑匣子式推荐在商品场景还能忍,在招聘场景完全不行——HR 需要向业务部门说明推荐理由。所以每一条推荐结果要能回放出:匹配了哪些技能、相似度多少、经验分多少、城市是否匹配。
# 生成推荐结果的解释信息 def explain_match(jd_text: str, resume_text: str, sim: float, exp_year: int, jd_exp: int, city_ok: bool) -> dict: hits = hit_skills(resume_text, {"flask", "vue", "python", "java"}) _, exp_score, city_score = final_score(sim, exp_year, jd_exp, city_ok) return { "similarity": round(sim, 4), "hit_skills": hits, "exp_score": round(exp_score, 2), "city_match": city_ok, "reason": f"技能匹配{'、'.join(hits) or '无'},经验分{exp_score:.2f}" } print(explain_match( "招聘Flask后端", "熟悉Flask和Python,三年经验", 0.52, 3, 3, True ))逻辑说明:explain_match把命中的技能、经验分、城市匹配情况汇总成一个 JSON 字典,前端可以直接展示。这也是为什么在特征工程阶段要保留hit_skills的输出——它既是特征,也是解释材料。推荐系统做到这里,才算从“能跑”变成“能用”。
6. 简历推荐调参避坑:5 个高频翻车现场与排查清单
6.1 “小程序开发”被分词器切得七零八落,技能命中率直线下降
现象:简历里写“熟悉小程序开发”,分词结果是“小 / 程序 / 开发”,hit_skills里预设的“小程序”永远匹配不上。
原因:jieba默认词表里没有“小程序”这个词,把“小程序”切成“小”和“程序”。技术名词只要不在词表里,就一定会被切碎。
解决:把“小程序”“小程序开发”一起加进用户词典,词频设 10。更稳妥的做法是,把技能词库和jieba用户词典统一管理,技能词库里新增一个词时自动同步到用户词典,不要人工维护两份表。
6.2 PDF 简历提取出来是乱码或整段空白,喂进算法全是噪音
现象:用pypdf提取某些 PDF 简历,得到的文本要么是乱码,要么是空白,要么是顺序错乱的段落。
原因:PDF 有两种形态,文本型和扫描型。文本型 PDF 如果用了非标准字体编码,extract_text()会返回乱码;扫描型 PDF 本身没有文本层,只能 OCR。双栏排版的简历即使提取成功,阅读顺序也是错的。
解决:在解析层直接做体检,提取字数少于 200 的文本直接标注“解析失败”,不进推荐队列。后续有三条路可以走:一是用带 OCR 能力的解析库;二是要求候选人上传 PDF 的同时填一份结构化表单;三是把解析失败的简历转入人工补录流程。初版不要为了兼容所有 PDF 而把链路搞复杂。
6.3 相似度推荐被热门简历霸榜,冷门候选人永远排不进去
现象:每次推荐 Top 10 里总是那么几份简历,换一个 JD 结果也差不多,这些简历的共同点是技能词特别全、文本特别长、关键词密度特别高。
原因:TF-IDF 本身偏向长文本和热词,一份写了 50 个技能的简历天然比只写 5 个核心技能的简历更容易和任意 JD 相似。这不是算法 bug,是数据分布的问题。
解决:推荐前先做粗筛召回,只对符合“技术栈主方向”的简历计算相似度,而不是全库遍历。技能命中数少于 1 的直接跳过,热门简历单日推荐次数超过上限后降权。实现逻辑很简单,在候选池里加一个hit_count判断即可。
6.4 “3 年 Java 后端”被推给“3 年 Java 前端”岗位
现象:文本相似度把“后端”和“前端”当成相关词,因为两份简历都大量出现“Java”“三年”“开发”,推荐结果和技术方向南辕北辙。
原因:TF-IDF 无法理解“后端”和“前端”是互斥关系,它只知道这两个词经常同时出现在同一批文档里。
解决:岗位族硬过滤不能省。JD 明确要求后端岗位族时,通过技能词库判断简历的主要方向,如果主要方向是前端,直接不进候选池。这一步必须发生在相似度计算之前,不要指望模型自己学会这个行业常识。数据量足够大之后,可以把“后端”从背景词改成判别词,但那需要大量标注数据,不是第一版要干的事。
| 排查项 | 检查方法 | 判断标准 |
|---|---|---|
| 分词结果 | 随机抽 20 条简历打印分词列表 | 核心技能词保持完整 |
| 相似度分布 | 打印全部相似度的直方图 | 大多数集中在 0~0.3,个别 >0.5 |
| 技能命中覆盖率 | 统计命中技能的简历占比 | 低于 60% 说明词库覆盖不足 |
| 推荐结果重复率 | 统计 10 个 JD 的 Top 10 重复度 | 重复率过高需要检查粗筛逻辑 |
6.5 改一次技能词库要重启整个服务,线上验证迭代慢
现象:词库里加了一个新技能词,要改代码、重启服务、重新加载数据,一顿操作下来小半天没了。
原因:把技能词库写死在 Python 常量里,或者加载逻辑放在模块顶层,导致每次改动都要动代码。
解决:把技能词库抽成独立配置文件,用 JSON 或 YAML 存放,服务启动时加载,并提供接口支持定时热刷新。加词不需要改算法代码,只改配置,然后触发一次缓存的失效。这个改动看起来很基础,但它决定了后续迭代速度,值得在最开始就做对。
7. 上线前怎么验证推荐质量:NDCG 与历史回看
推荐系统最怕的是自我感觉良好。相似度看着挺高,候选人好像也对口,但 HR 是否真的愿意邀约,机器不知道。所以在写第一版推荐算法的时候,就应该把评估指标也写出来。
我常用的是 NDCG,它能同时衡量“推荐的人对不对”和“对的人排得靠不靠前”。计算它需要一份标注数据:从历史投递记录里,把 HR 标记过“已邀约”或“已入职”的简历当成正样本,把没被处理的简历当成负样本。然后对比推荐系统的排序结果和真实结果。
import numpy as np def ndcg_at_k(ranked_ids: list[str], relevant_ids: set[str], k: int = 10) -> float: """计算前 k 个推荐结果的 NDCG。""" dcg = 0.0 for i, rid in enumerate(ranked_ids[:k]): rel = 1 if rid in relevant_ids else 0 dcg += (2 ** rel - 1) / np.log2(i + 2) ideal_rels = min(len(relevant_ids), k) idcg = sum(1.0 / np.log2(i + 2) for i in range(ideal_rels)) return dcg / idcg if idcg > 0 else 0.0 # 示例:推荐序列中第 0 位命中,真实相关简历有 2 份 print(ndcg_at_k(["R001", "R002", "R003"], {"R001", "R004"}, k=3))这段代码里ranked_ids是推荐算法输出的结果序列,relevant_ids是由 HR 行为数据标记出的正样本集合。NDCG 的值域是 0 到 1,越接近 1 说明又准又靠前。我自己的习惯是:每次改完分词、词库、权重之后,固定跑一遍同一份测试集,看 NDCG 是否下降,如果下降就要回滚,不要相信“这次改动应该没问题”的直觉。
这类项目的价值不在于用了多前沿的模型,而在于能稳定地产出可解释的推荐结果。一个几百份简历的团队内部招聘工具,用 TF-IDF 加余弦相似度加业务规则的精排,效果已经足够上场;等简历量真的到了几十万,再换向量模型也不迟——上一层的排序接口不用动,只替换特征提取这一层。做简历推荐算法这几年来,我最大的收获是:凡是没评估指标就上线的推荐系统,最终都会变成黑匣子,出了问题谁也说不清是数据的问题还是权重的问题。我自己每次改完参数都要用同一份数据跑回归,就怕哪天线上悄悄翻车还不自知,希望帮到你。
本文还有配套的精品资源,点击获取