如果你打过招聘软件,大概对“技能”这个词不会陌生。JD上写着“精通Java”“熟悉MySQL”“了解Linux”,你把自己的技能一填,系统开始给你推职位。但背后有一个很少有人讲的环节:这些技能是怎么被结构化的?怎么判断“精通”和“熟悉”的边界?怎么知道“会Python”和“会爬虫”之间是什么关系?
我拿到“skills”这个题目时,想的就是这件事。它不是某一个单纯功能,而是一整套“技能分类与推荐引擎”的实践:把职位描述、个人简历、课程目录这些非结构化文本,全部转成可计算的技能网络。整个项目从0到1做完之后,我收获最大的不是某个算法调得多好,而是对“结构化信息”这件事本身的理解。这篇文章会把完整的思路、设计、实现过程、踩过的坑都摊开来讲,适合正在做人力数据化、教育产品、或者垂直领域知识图谱的开发者参考。
1. 整体设计思路:为什么要把技能变成一个“可计算的网络”
1.1 技能的本质是非结构化信息
先说一个我在项目开始前的困惑:技能信息到底该怎么抽象?
你打开任何一份简历,上面写的是“熟练使用Spring Boot”“有三年数据分析经验”。这句话对人是友好的,但对程序来说,这就是一行无规律的短文本。职位描述也一样,“要求熟悉分布式系统设计”——哪个分布式?什么程度算熟悉?这些信息天然是非结构化的。如果直接拿去做搜索、匹配、推荐,只能做关键词级别的粗糙匹配,根本谈不上理解。
我决定先把技能从自然语言里抽出来,再给它们建立关系。这是整个项目的地基。第一步是技能抽取,第二步是技能关联,第三步是技能推荐。这三步走完之后,你输入一份简历,系统不仅能识别你拥有哪几项技能,还能推断出你大概能胜任什么方向的工作;你输入一个目标岗位,系统能告诉你还缺哪几项技能,以及最快的学习路径是什么。
1.2 三种常见设计范式,我为什么选了技能图谱
我们在前期调研时梳理过三种做法。
第一种是“技能关键词库匹配”,也就是建一个几千个技能词的字典,拿到文本直接查,命中就算有。这个方案最简单,但没有关系建模,只能回答“有没有”,不能回答“有多强”和“和什么相关”。
第二种是“基于统计的向量化”,把技能扔进Word2Vec或者BERT里生成向量,然后算相似度推荐。这个方案适合做语义理解,但解释性很差,你不知道系统为什么推荐这个技能,也不好向用户解释。
第三种是“技能图谱”,也就是把技能当成节点,把技能之间的前置依赖、相关关系、包含关系变成边,存进图数据库。这个方案重一点,但非常可控,能解释,能回溯,后期扩展性也最好。
我选了第三种。原因很简单:这个系统最终要面向用户解释推荐理由,一本正经地问你“因为你掌握了A,而A是B的前置技能,所以推荐你学B”,这种解释才站得住脚。
1.3 整体架构拆解
整个项目分四层:
- 数据层:采集职位描述、课程大纲、职业百科关键词和相关字段,清洗后统一入库。
- 抽取层:用词典匹配加语义模型做技能识别,输出结构化的技能标签。
- 关系层:构建技能间的依赖关系、相似关系、包含关系,形成技能图谱。
- 应用层:能力评估、职位匹配、学习路径推荐。
这套架构并不复杂,但每一层都有各自的难点。下面我会按实际开发顺序,把每一层的细节和实现讲透。
2. 技能分类与等级体系是怎么定的
2.1 技能分类的三级结构
开始建库之前,第一件事是定义技能的分类结构。我参考了几种公开的职业分类方法,最终落地为三级结构:领域、技能族、技能项。
打个比方:领域是“编程语言”,技能族是“JVM系语言”,技能项就是“Java”“Kotlin”“Scala”。再比如领域是“数据分析”,技能族是“数据可视化”,技能项是“Matplotlib”“Tableau”“PowerBI”。
这个三级结构的价值在于,它让技能既能细粒度地被识别,又能按大类做聚合统计。用户简历里写了“用Python做数据分析”,系统能同时判定他拥有技能项“Python数据分析”,并自动归属到领域“数据分析”、技能族“编程语言与数据处理”。聚合之后的数据,可以用于生成行业技能热力图、岗位技能需求分析这类更高层的产品能力。
2.2 技能等级的划分
“了解”“熟悉”“精通”这三个词在JD里出现的频率极高,但不同人写出来含义完全不一样。系统里必须有一套统一的量化标准。
我最后采用了五级能力模型,和很多职业晋升体系中的层级是近似的:
- L1:知道概念,能说出是什么,没见过实际应用。
- L2:在指导下完成过简单任务,能看懂别人写的代码。
- L3:能独立完成常规任务,遇到问题能自己定位并解决。
- L4:能指导他人,能设计模块级方案,解决过复杂问题。
- L5:能定义方法论,引领技术方向,能跨团队推动落地。
这里有个很关键的实操心得:等级不能靠用户自己填,一定要靠系统去推断。你让用户选“我的Java水平是L4”,大多数人的选择都会失真。我们后来改成通过行为数据来推断等级,比如项目经历里写了“主导设计XX系统”,就映射为L4级技能;写了“参与XX模块开发”,默认给L3;只写了“了解XX技术”,给L2。总之,宁可保守,不要激进。
2.3 如何保证分类体系的开放性
任何预定义的分类体系都会过时。技术领域更新极快,今天冒出一个新框架,明天又出一个新范式。如果技能树是手工维护的,根本跟不上节奏。
我的方案是把技能库设计成“种子词+动态扩展”的模式。预置几千个核心种子词,每个种子词关联一段权威描述文本。当系统识别到一个从未出现在库里的技能词时,把它放进“待验证”池,每周由运营人员审核。审核通过后,系统自动根据这个词所属的上下文,赋予它一个候选领域和技能族,再由人工最终确认。这样既保证了扩展速度,又保证了质量。
3. 技能抽取:从非结构化文本到结构化标签
3.1 抽取方案选型
技能抽取是整个流程里最贴近NLP的一步。我试过三种方案。
只用正则和词典匹配,速度最快,但召回率低,遇到“SpringBoot”和“Spring Boot”这种写法差异就傻了。比如“C++”这种技能词汇大小写不敏感处理就成问题。
用预训练模型做序列标注,准确率高,但我们当时没有足够的标注数据,自己标了两千条发现各种边界问题还是很难处理。
最后我选的是“混合管道”:先用语义实体识别抽候选词,再用技能词典做归一化,最后用一套规则对结果做清洗和修正。实践下来,这个组合在召回率和准确率之间取到了相对均衡的位置。
3.2 实体识别部分怎么做
实体识别部分我没有用太重的模型,当时用的是开源的中文预训练模型,在此基础上下游接了一个序列标注头,专门用来标技能实体。训练数据来自人工标注的两千条职位描述,标注规范很简单:凡是描述某项能力的词,全部标为技能实体。
这里有一个非常实用的细节:训练数据里一定要包含各种技能词的变体。比如“熟悉Linux操作系统”和“Linux环境下的运维经验”,表面上句子结构不同,但我们需要让模型学到“Linux”本身是个技能实体,不管它前面是“熟悉”还是“环境”。这个处理直接决定了后来模型在上文变化的场景下能不能稳定识别。
3.3 词典归一化怎么做
模型抽出来的是原始字符串,五花八门。这时候就要用词典做归一化。我的做法是给每一个技能项建一个别名表:
- “Java”的别名:JAVA、java、JavaSE
- “Spring Boot”的别名:SpringBoot、spring-boot、SB(这里不要用,太容易误判)
- “K8s”的别名:Kubernetes、Kube、K8S
别名表的维护是个长期工作,上线第一个月几乎每天都有人反馈“XX技能没识别出来”,我每天的工作就是把新出现的别名加进表里。后来我设置了一个半自动化的流程:对被模型识别为技能实体但未命中的词,按周汇总,做最高频排序,人工审核后批量加入别名表。
3.4 清洗规则
清洗规则处理的是三类常见问题:
第一类是领域限定。比如“销售技能”在其他场景下可能确实是一种技能,但在技术岗位的语境里,我们要把它过滤掉。我们的做法是按岗位类型维护一个“允许出现的顶级领域”列表,抽取结果必须落在这些领域里,否则丢弃。
第二类是程度词污染。比如“精通Linux”抽出来可能带着“精通”两个字,这时候要把程度词剥离,只保留技能名。如果保留程度词,归一化的时候就会产生大量冗余标签。
第三类是组合词的拆分。“数据分析与挖掘”这种复合词,要不要拆成“数据分析”和“数据挖掘”两个技能?我的答案是拆,并且把它们设为关联关系。原因是用户如果只掌握了其中一部分,直接给一个过大的标签会虚高他的能力评分。
4. 技能图谱构建:建立技能之间的关系
4.1 关系类型的定义
技能之间不是孤立的点,它们之间有非常丰富的关系。我最终定义了四种关系:
- 包含关系:A包含B,比如“大数据”包含“Hadoop”。
- 前置依赖关系:A依赖B,比如要学“机器学习”,最好先学“线性代数”和“Python”。
- 相似关系:A和B高度相似,比如“Elasticsearch”和“Solr”。
- 组合关系:A和B经常一起出现,比如“Spring Boot”和“MyBatis”。
这四种关系覆盖了技能推荐场景里绝大多数需求。前置依赖关系用于生成学习路径,相似关系用于扩展现有技能范围,组合关系用于岗位技能包推荐。
4.2 用图数据库实现
关系建模之后,存储自然选图数据库。我们当时用的方案比较稳妥,选了开源界最通用的Neo4j。每个技能创建一个节点,每种关系创建一个边。技能节点上有属性:技能ID、技能名、领域、技能族、等级、热度、权威描述。
这里有一个需要强调的设计点:技能节点和用户实体要分离。虽然很多图数据库教程会直接给用户创建节点,然后连到技能节点上,但我不建议这么做。原因在于用户状态是高频变动的,技能节点是相对低频稳定的。把两者放一起,每一次用户状态更新都会引起图的一小部分重算。后来我把用户数据留在关系型数据库里,只在需要做路径推荐时,把用户当前技能集映射到图里的技能节点上做查询。这样清爽很多。
4.3 自动化抽取关系的方法
关系不会自己长出来,怎么半自动地建关系,是个很实际的问题。
包含关系和前置依赖关系,我用了两种来源。一种是从权威的课程大纲里扒,比如某门“机器学习导论”课程一共八章,每章都是一个小主题,我就能推断这些章节主题都是“机器学习”的子技能,且章节之间存在一定的学习次序。另一种是用BERT句向量的相似度,把课程大纲和技能库里的技能描述做匹配,命中且相似度超过阈值的,就建立包含关系。
前置依赖关系的抽取更麻烦一点。我当时的做法是:在预处理阶段,提取课程描述里的条件句式,比如“需要先掌握X”的X,归为前置技能。但说实话,这个方法产出的关系比较稀疏。后来我改用了一个更实用的思路:间接用“共现关系”来推导。比如在一个教学计划里,学期靠前的课程技能,大概率是学期靠后课程技能的前置。这个规则看起来粗糙,但实际用起来很有效。
4.4 热度的计算
技能的“热度”我定义为一个月度值,代表这个技能在最近一个月内的市场需求总量。它的算法很简单:统计当月所有职位描述中该技能的出现次数,再做一个月环比。热度值的作用有两个:一是推荐时做加权,热度高的技能在同等条件下优先推荐;二是可以做技能趋势分析,比如某个技能热度突然上涨,很可能就是新风口来了。
5. 实操落地:从零构建一个技能推荐子系统
5.1 数据准备与清洗
整个系统最重要的资产就是数据。我建议从以下来源收集:主流招聘网站的公开职位描述、公开课程平台的课程大纲、职业百科的技能条目。这三个来源覆盖了“市场需求”“学习路径”“通用分类”三个维度,互为补充。
数据清洗阶段的几个要点:
文本去重非常关键。同一个职位在不同平台会被抓取很多次,去重逻辑不只是“完全一样”去重,而是“标题+公司+职责描述前50个字符一致”则视为同一条。这个逻辑能有效去掉大量重复数据。
还有很坑的一点是编码问题。技能描述文本里常见各种特殊符号,比如“C#”中的井号、各种引号、全角半角混乱。统一转成半角,并把不可见字符全部过滤掉。这一步不做,后面技能抽取经常会出现莫名其妙的空结果。
5.2 技能抽取核心代码详解
我用了Python来做整条处理管道。抽取部分核心代码如下,这部分内容可以直接复用:
import re import jieba from skills_dict import SkillNormalizer from ner_model import SkillEntityRecognizer def extract_skills(text: str, normalizer: SkillNormalizer, ner: SkillEntityRecognizer): # 第一步:实体识别,得到候选技能词表 entities = ner.predict(text) # 第二步:规则清洗,过滤掉过长的词和纯数字 cleaned = [] for ent in entities: ent = ent.strip() if len(ent) > 20: continue if re.fullmatch(r'\d+', ent): continue cleaned.append(ent) # 第三步:词典归一化,把变体统一为规范技能名 normalized = [] for ent in cleaned: std_name = normalizer.normalize(ent) if std_name is not None and std_name not in normalized: normalized.append(std_name) return normalized这段代码的逻辑很简单,但工程上非常结实。实体识别模型跑一次大概需要几十毫秒,词典归一化是毫秒级,整体吞吐量完全够用。如果你不需要NLP模型,甚至可以直接用jieba分词加词典匹配代替实体识别,效果会差一些,但做原型足够了。
5.3 构建技能图谱的核心代码
图谱入库我写了独立的Python脚本,读取清洗后的技能关系表,批量写进Neo4j:
from neo4j import GraphDatabase class SkillGraphBuilder: def __init__(self, uri, user, password): self.driver = GraphDatabase.driver(uri, auth=(user, password)) def create_skill_node(self, skill): cypher = """ MERGE (s:Skill {id: $id}) SET s.name = $name, s.domain = $domain, s.family = $family, s.hot = $hot """ with self.driver.session() as session: session.run(cypher, **skill) def create_relation(self, from_id, to_id, rel_type): cypher = """ MATCH (a:Skill {id: $from_id}), (b:Skill {id: $to_id}) MERGE (a)-[r:%s]->(b) """ % rel_type with self.driver.session() as session: session.run(cypher, from_id=from_id, to_id=to_id)用MERGE而不是CREATE,是图数据库实践的基本功。重复跑脚本不会产生重复节点。
5.4 学习路径推荐算法的实现
推荐学习路径,本质是在技能图谱上寻找“从当前节点到目标节点”的最短依赖路径。
def find_learning_path(graph, current_skills, target_skill, max_depth=4): # 用BFS找当前技能到目标技能的最短依赖路径 queue = [] visited = set() for skill in current_skills: queue.append((skill, [])) visited.add(skill) while queue: node, path = queue.pop(0) if node == target_skill: return path if len(path) >= max_depth: continue neighbors = graph.get_skill_neighbors(node, relation="DEPENDS_ON") for nb in neighbors: if nb not in visited: visited.add(nb) new_path = path + [nb] queue.append((nb, new_path)) return []这段BFS代码在技能规模是几千个节点时完全够用。如果图谱扩到几十万节点,再考虑AStar或者双向BFS。这里顺便说一下,依赖关系是有方向的:从“Java基础”到“Spring Boot”是正向依赖,反过来走就是回溯路径,语义完全不同。做推荐时一定要区分方向,不能把反向路径推给用户。
推荐结果的排序,我会在路径长度相同的情况下,优先选择热度高的技能路径。这个逻辑能解决一个实际问题:很多技能图谱工具推出来的路径虽然正确,但学完发现市面上根本没这个岗位需求。加上热度权重之后,推出来的路径至少是学和用能对上的。
6. 评估系统效果:推荐到底准不准
6.1 人工标注评测集
推荐系统做完之后,光看例子没有说服力,我给整个评测做了两轮人工标注。
第一轮是“技能抽取准确率”评测。我随机抽样了300条职位描述,让两个标注员独立标注其中出现的技能,然后拿标注结果和系统抽取结果做对比。最后算出来精确率和召回率都在85%左右。这个数字在相似项目里算中等偏上。遗留下来的主要错误集中在“工具名”和“方法论”的边界上,比如“敏捷开发”到底算不算技能、要不要抽取,不同标注员理解不一致。
第二轮是“路径推荐可用性”评测。这里不是量化指标,而是让五位有技术背景的人去看推荐路径,打分判断这条路是否合理。结果发现的问题是:推荐的路径偏“稳”,缺少一些跳跃性的组合。比如有人当前会“Python基础”,想学“深度学习”,BFS推荐的路径一定会把“机器学习基础”放进去。这看起来完美,但现实里很多人的学习路径是有跳跃的,直接从“Python基础”跳到“深度学习”也能学会。后来我调整了策略:只有在用户技能图谱中找不到跳过的依据时,才强制推荐前置依赖。
6.2 线上效果与反馈闭环
系统上线后,最直接的反馈是用户学习完成率提升了。我对比过跑推荐和没跑推荐的用户数据,跑了推荐的用户,学习路径完成率有明显提升。这说明即使推荐只是多提供了一条参考路径,也比把用户丢在技能列表里让他们自己选要强得多。
反馈闭环也很重要。系统会给用户推技能,但用户学完没有,学了之后有没有用上,这些数据一定要回传。我的做法很简单:学习平台课程完成事件和用户手动标记“已掌握”按钮,都回写到技能档案里。这样每次做新推荐时,都有最新的数据基础。没做反馈闭环之前,用户学了五门课,技能档案里还是什么都没有,等于系统的推荐永远基于陈旧状态,体验非常割裂。
7. 常见问题与排查经验实录
7.1 技能名歧义,这是最大的坑
实际跑数据之后我发现,很多技能名本身是有歧义的。
瓜分这个项目时间最多的,就是“Python”这个技能。“Python”是编程语言,但“Python爬虫”是一个技能组合,“Python数据分析”又是另一个技能族。单纯把“Python”抽取出来,会丢失大量有效信息。我最终的方案是:在词典里增加组合技能词识别,优先匹配组合词。先把“Python数据分析”“Python爬虫”这类复合技能抽取出来,再抽取单独的“Python”。这样既保留了细粒度信息,也不会丢失粗粒度标签。
还有更阴间的,“C++”和“C#”这种带符号的技能名,在NLP模型里经常被破坏成“C”加符号。我做了一个特殊符号保护机制:在文本预处理阶段,把“C++”“C#”“.NET”这些特殊写法整体替换为占位符,等抽取完成后再替换回来。这个机制虽然简单,但确实彻底解决了带符号技能名的丢失问题。
7.2 数据稀疏和冷启动问题
任何推荐系统的冷启动问题,技能推荐也一样躲不过。
新技能入库时没有热度数据,没有用户学习记录,也没有足够的课程关联。怎么办?我的处理方案是“相关性拉新”:当一个新技能进入图谱,自动找出相似技能中的热门技能,赋一个新技能的初始热度。比如“Rust”入库时,从“C++”和“Go”这两个类似技能的当前热度按比例折算,作为“Rust”的初始热度。这样新技能不会突然消失在没有数据的角落里。
用户侧冷启动是另一回事。新用户进来,技能档案完全空白,这时候推荐什么?我的经验是:不做个性化推荐,先做调查问卷式的引导。让用户选自己的岗位方向,比如前端开发、后端开发、数据分析、运维,然后系统直接把该方向的热门技能包推荐给用户,并标注“该岗位下热度最高的十项技能”。等用户产生了前几条技能确认数据之后,再切换到个性化推荐。这个过渡非常顺滑,用户反馈也明显更好。
7.3 技能抽取结果不稳定的排查思路
如果你发现同一个技能在文本里时而抽取出来、时而又没抽出来,可以按这个顺序排查:
先看是不是同义词问题,比如“K8s”和“Kubernetes”大概率是别名没加全,这种处理最简单。再看是不是上下文问题,“Spring”在Java语境里是Spring框架,但“Spring”在季节语境里完全不是技能。这种歧义词需要在词典里限定时出现的前置词汇。最后看是不是模型输入被截断导致的长文本尾部丢失,这种情况要提高文本分片策略,避免把技能实体截到两个分片里。
实践中最容易被忽略的就是文本分片。职位描述经常超过模型的最大输入长度,我当时用了一个很蠢的滑动窗口:前窗口和后窗口重叠50个字符。惊奇的是一些关键技能,不是被模型认错了,而是恰好被窗口切断而丢失了。后来我改成按句子分片,以固定长度切割但保证句子完整,这个问题就基本消失了。
7.4 关系数据噪声的过滤
手动建关系时还好说,自动化抽取的关系噪声会比较明显。
最常见的错误关系是“包含关系”被错误抽成“前置依赖关系”。比如课程大纲里第一章是“Linux基础”,第二章是“网络基础”,本意是“第一章和第二章是两个独立主题”,但自动抽关系时可能把它们解释成“网络基础依赖Linux基础”,这就不一定正确了。解决方法是:抽完关系后加一道验证关卡。我拿技能图谱里每个“前置依赖”关系对,做一次人工抽检,如果错误率超过5%,就把这种自动抽关系的方式权重调低,改为仅提供候选关系,由人工确认后再入图谱。
8. 一些可以继续深挖的扩展方向
我做完这个系统后,又意识到好几个后续能直接长出来的功能。
第一个是“团队技能画像”。现在只有个人技能图谱,如果把一个团队的成员技能聚合起来,就能得到团队的技能覆盖矩阵。管理者可以看到团队在哪项技能上是短板,在哪个方向可以承接新项目。这个功能在人力盘点场景里非常有用。
第二个是“岗位技能匹配度可视化”。我们后来在职位推荐页加了一个环形图,展示“用户当前技能”和“目标岗位需要技能”的重合度、差距、超出三个维度。效果出乎意料地好,用户非常直观地理解了自己为什么适合或者不适合这个岗位。很多时候,推荐算法不需要做到百分之百精准,但需要让用户看懂推荐的逻辑。
第三个是把“技能图谱”和“项目实战经历”打通。用户简历里的项目经历不应该是纯文本,而是可以解析成“项目场景+所用技能列表”。这样系统就可以反推:用户在当前岗位方向上的项目积累量是多少,是否有足够的实战验证。这是很多招聘系统想做到但没做好的事。
我自己的体会是,“skills”这个题目最大的价值不是技术有多难,而是它对信息结构的抽象要求非常高。你面对的不是某个具体的代码问题,而是一个熵值极高的领域。把非结构化的技能语言变成结构化的图谱,这个过程会逼着你不断思考分类体系、关系建模、容错机制,这些经验对任何一个做信息处理的开发者来说都是很值钱的。如果让我再选一次架构,我可能还是会走技能图谱这条路,但会把关系自动抽取的验证环节走得更扎实。