[工程]:智能招聘--一个可组装的人才智能推荐系统

很早以前在招聘网站上找工作,简历是一栏一栏填出来的。过了许多年,网站有了"简历模板导入",每次仍要花很多时间:严格按照字段格式,把内容逐项输入到对应的位置,等于换了个界面把简历再抄一遍。当时就想:简历里明明已经写了这些信息,为什么不能一键导入、自动识别关键信息?

这个疑问一直没有像样的答案,根源在于传统平台只在文本 层面处理简历------读出来的是自由文本,没有统一的技能、没有可比的年限,匹配只能退化为关键词包含。这个项目就是冲着这个问题来的:先把任意格式的简历变成结构化的能力档案,再让岗位需求与能力之间做可计算、可解释的匹配。导入只是入口,可组装的架构和四个匹配能力才是主体。

这是一份工程实录,不是方案构想:10 个后端服务 + 1 个 React 前端 + 1 个纯类型契约包,Node 与 Python 混栈,当前阶段没有数据库、没有消息队列,所有业务数据都在进程内存里。本文讲四件事:模块按什么边界拆 、模块之间靠什么对齐 、简历解析 / 人才推荐 / 相似搜索 / 岗位匹配四个能力各自怎么实现 、471 个单元测试和两级端到端验收如何兜底。文中所有端口、参数、代码与测试数字均取自仓库当前实现,已知限制一并列出。


先看运行页面

  • 首页
  • 相似性搜索
  • 推荐工作台

0. 先讲清楚:它和传统招聘网站不是同一类东西

传统招聘网站(以及大多数 HR SaaS 的人才库)本质是一个带筛选的列表页:

  • 求职者按平台模板填表单,或上传 PDF 后靠人工补录;
  • HR 用关键词 + 下拉筛选找简历,命中的是"文本里有没有出现这个词";
  • 推荐是"买过这个岗位的人还看了"这类行为协同;
  • 结果是一个排序列表,说不出为什么排第一。

这套系统换了一条路径:先把人变成结构化的能力档案,再让岗位需求与能力之间做可计算的匹配。

维度 传统招聘网站 本系统
建档入口 平台模板 / 固定格式简历 任意格式文档:docx、PDF、HTML、Markdown、XML、TXT、CSV、XLSX
数据形态 简历原文 + 少量标签 结构化档案:技能带等级与年限、逐段工作与项目经历、可计算快照
找人方式 关键词包含匹配 语义 + 技能 + 关系 + 规则 四路召回后融合
相似推荐 行为协同(看过的人) 能力相似:以人搜人 / 以 JD 搜人 / 以技能搜人
匹配依据 关键词重合度 四分量加权:语义、技能等级、经验、上下文,含惩罚项
输出 一个分数或一个排名 分数 + 证据链:命中哪些必备技能、缺什么、差距多少
数据所有权 单体应用内共享读写 每个字段只有一个服务可写,其余只读投影

下面四个能力是这套系统的立足点,后文逐个展开实现:

  1. 不限格式导入------HR 手里的简历是什么格式就能传什么,系统自己负责解析建档;
  2. 人才智能推荐------给一个岗位,召回一批人,并且排得有理有据;
  3. 相似性搜索------给一个人、一段 JD 或一组技能,找出能力结构相近的人;
  4. 岗位 × 简历匹配------把"岗位要求"和"人的能力"放在同一个尺度上算分,选出最佳人选。

1. 总体架构

1.1 一张图看清组成

11 个进程(10 个后端 + 1 个前端)+ 1 个纯类型契约包。没有数据库、没有消息队列、没有缓存中间件------这不是"简化版",而是当天要验证的命题是"模块边界和契约是否成立",中间件会把这个问题掩盖掉。

11 个进程的启停由 scripts/dev-all.mjs 统一托管,几个细节值得记录:

  • 端口预检 :启动前逐个探测端口,被占用立即列出并退出,不覆盖旧进程;端口探测用 TCP 连接而非 bind 试探,才能正确识别监听 0.0.0.0 的服务;
  • 按依赖顺序拉起:taxonomy → profile → identity → vector/similarity → search → recommend/explain → import → bff → web;上游未就绪时下游启动不失败,但相关接口返回 502/503;
  • Python 环境隔离 :启动子进程前主动删除 PYTHONHOME / PYTHONPATH,避免外部注入的解释器路径把 Python 3.12 指到不存在的目录(报 No module named 'encodings');
  • 两种健康状态 :监听 up 只说明端口有进程,健康 ok 才说明 /health 返回 2xx。两者不一致 = 进程活着但未就绪,去读 .tmp/logs/<服务名>.log;
  • 优雅停止 :Ctrl+C 对全部子进程发 SIGTERM,3 秒后对残留进程 SIGKILL。

1.2 三条不可协商的架构原则

原则一:模块边界由数据所有权决定。

不是"简历相关的放一起",而是先列数据所有权矩阵,再倒推服务边界:

数据 唯一可写 其他模块
人员档案、技能等级、经历、项目 profile-svc search / vector 只读投影
技能本体、分类树、技能别名 taxonomy-svc 全员只读
推荐结果、反馈、缓存版本 recommend-svc bff 只读
解析产物、导入批次与错误报告 import-svc 通过 profile 写入档案
向量索引 vector-svc 其他只读

这条规则有个直接推论:没有任何模块能绕过 profile-svc 改档案 。search 与 vector 各自维护只读投影,靠 person.changed 事件刷新,而不是反过来直接查档案库。

还有一条红线:任何模块不得跨 schema JOIN。数据同步只允许两种方式------"事件 → 本地投影"或"API 批量取数"。跨模块直查对方的表,会把两个模块的表结构锁死在一起。

原则二:跨模块只有一套类型。

所有 DTO、Zod Schema、错误码、事件定义都在 packages/contracts。改一个字段,npm run typecheck 会在 7 个 workspace 里同时报错------把"联调时才发现"提前到"编译时就知道"。

原则三:每个模块必须能脱离其他模块活着。

硬性要求:每个后端服务有自己的 Fake 适配器和测试入口,不依赖其他服务的真实实例。做不到这一点的模块,说明它的边界划错了。

1.3 一次"推荐最佳人选"的调用链

explain similarity search vector recommend bff HR(web) explain similarity search vector recommend bff HR(web) #mermaid-svg-PFF06je3nyFMhmSq{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-PFF06je3nyFMhmSq .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-PFF06je3nyFMhmSq .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-PFF06je3nyFMhmSq .error-icon{fill:#552222;}#mermaid-svg-PFF06je3nyFMhmSq .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-PFF06je3nyFMhmSq .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-PFF06je3nyFMhmSq .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-PFF06je3nyFMhmSq .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-PFF06je3nyFMhmSq .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-PFF06je3nyFMhmSq .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-PFF06je3nyFMhmSq .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-PFF06je3nyFMhmSq .marker{fill:#333333;stroke:#333333;}#mermaid-svg-PFF06je3nyFMhmSq .marker.cross{stroke:#333333;}#mermaid-svg-PFF06je3nyFMhmSq svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-PFF06je3nyFMhmSq p{margin:0;}#mermaid-svg-PFF06je3nyFMhmSq .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-PFF06je3nyFMhmSq text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-PFF06je3nyFMhmSq .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-PFF06je3nyFMhmSq .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-PFF06je3nyFMhmSq .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-PFF06je3nyFMhmSq .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-PFF06je3nyFMhmSq #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-PFF06je3nyFMhmSq .sequenceNumber{fill:white;}#mermaid-svg-PFF06je3nyFMhmSq #sequencenumber{fill:#333;}#mermaid-svg-PFF06je3nyFMhmSq #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-PFF06je3nyFMhmSq .messageText{fill:#333;stroke:none;}#mermaid-svg-PFF06je3nyFMhmSq .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-PFF06je3nyFMhmSq .labelText,#mermaid-svg-PFF06je3nyFMhmSq .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-PFF06je3nyFMhmSq .loopText,#mermaid-svg-PFF06je3nyFMhmSq .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-PFF06je3nyFMhmSq .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-PFF06je3nyFMhmSq .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-PFF06je3nyFMhmSq .noteText,#mermaid-svg-PFF06je3nyFMhmSq .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-PFF06je3nyFMhmSq .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-PFF06je3nyFMhmSq .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-PFF06je3nyFMhmSq .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-PFF06je3nyFMhmSq .actorPopupMenu{position:absolute;}#mermaid-svg-PFF06je3nyFMhmSq .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-PFF06je3nyFMhmSq .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-PFF06je3nyFMhmSq .actor-man circle,#mermaid-svg-PFF06je3nyFMhmSq line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-PFF06je3nyFMhmSq :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} par A 向量召回 B 技能召回 C 关系召回 D 规则召回 单路超时 300ms,任一路失败只丢该路 GET /api/jobs/7/matches?limit=20 POST /recommend/jobs/7 POST /search/ann(JD 文本) personIds 回表补快照(按 ANN 顺序保序) skills 过滤(必备技能) keyword = 岗位公司 城市 + 求职状态 加权 RRF 融合 → 去重 → 候选 ≤200 五条硬过滤(技能/年限/学历/到岗/在流程中) POST /score/batch 精算四分量 MMR 重排 + 公司/职级配额 + 探索位 Top20(rrfScore / 命中通道 / recallStats) POST /explain/batch 每人证据链、差距项、摘要 回表补人员快照 可解释的候选人列表

三个细节值得注意:召回阶段并行且短超时 (任一路超时不影响整体,只是少一路证据);精算与解释分离 (recommend 只负责排序,explain 只负责翻译,两者可分别演进);打分分两层(召回融合用的是只看名次的 RRF,精算才动用四分量模型,两者解耦)。


2. 契约:模块之间唯一的通信语言

2.1 contracts 包里有什么

复制代码
packages/contracts/src/
  common.ts     分页、ID、时间戳等基础 Schema
  enums.ts      学历、求职状态、隐私等级等枚举
  person.ts     档案、技能、经历、项目、草稿(Draft)
  taxonomy.ts   分类、技能、别名、关联
  search.ts     筛选条件、相似搜索三模式、分面
  match.ts      匹配结果、证据、反馈
  explain.ts    解释请求与证据结构
  identity.ts   用户、角色、掩码策略
  import.ts     导入批次、行错误、文档预览
  vector.ts     向量与 ANN 的请求响应
  events.ts     person.changed 等事件
  errors.ts     错误码枚举 + AppError
  openapi.ts    由 Schema 生成 OpenAPI
  compat.ts     版本兼容

2.2 契约不只是类型,是"可校验的边界"

ts 复制代码
// packages/contracts/src/search.ts
export const PeopleFilterSchema = z.object({
  keyword: z.string().max(100).optional(),
  categoryIds: z.array(IdSchema).max(50).optional(),
  city: z.array(z.string().max(64)).max(20).optional(),
  educationLevel: z.array(EducationLevelSchema).optional(),
  yearsExp: YearsRangeSchema.optional(),
  skills: z.array(SkillFilterSchema).max(20).optional(),
  jobStatus: z.array(JobSeekerStatusSchema).optional(),
  completenessMin: z.number().int().min(0).max(100).optional(),
  /** 调用方可见的隐私上限:超过该等级的档案不出现在结果中 */
  maxPrivacyLevel: PrivacyLevelSchema.optional(),
  /** 白名单召回:仅返回这些 personId,用于向量等外部召回通道回表补全快照 */
  personIds: z.array(IdSchema).max(200).optional(),
  excludePersonIds: z.array(IdSchema).max(200).optional(),
});

这段 Schema 里有三个设计决策值得说:

  1. maxPrivacyLevel 放在筛选条件里,而不是调用方参数里------隐私是检索语义的一部分。越权不是"过滤结果",而是"根本不进候选集"。
  2. personIds 白名单:向量召回只知道 id,需要 search 回表补全快照。没有这个字段,BFF 就得自己查 N 次档案。
  3. 所有数组都有上限 (20 / 50 / 200)。契约层限流,比在业务代码里散落 if (arr.length > 200) 可靠。

2.3 错误码按业务域分段

错误码不是随手起的整数,而是按域分段,任何服务返回的错误都能从号段直接定位到归属模块:

号段 归属 示例
1000--1999 通用/网关 1001 参数非法、1002 未认证、1003 无权限、1004 限流
2000--2999 档案 2001 档案不存在、2002 重复档案、2003 字段校验失败
3000--3999 检索 3001 筛选条件非法、3002 深分页不支持、3003 结果不存在
4000--4999 推荐 4002 召回全部超时、4003 算法服务不可用、4004 岗位非招聘中
5000--5999 导入 5001 文件格式不支持、5002 字段映射缺失、5004 任务不存在
6000--6999 权限 6001 字段越权、6002 数据范围不足
7000--7999 本体/分类 7001 技能不存在、7002 名称或别名重复、7003 层级成环

每类错误码在契约里有到 HTTP 状态码的固定映射(如 5002 → 422、4002 → 503),各服务的异常处理只是查表,不各自发明。

2.4 事件契约:统一信封与投递语义

模块间的异步通知只有一种格式:

json 复制代码
{
  "eventId": "01J9F3K2M8Q7",
  "eventType": "person.updated",
  "version": 1,
  "occurredAt": "2026-10-24T08:00:00Z",
  "producer": "profile-svc",
  "traceId": "abc-123",
  "payload": { "personId": 1024, "changedFields": ["skills"] }
}

当前存在的事件:person.created / person.updated / person.deleted(profile → search、vector)、skill.updated(taxonomy → 全员)、match.created 与 feedback.recorded(recommend)。

投递语义是至少一次 :消费方必须按 eventId 幂等;生产方用 Outbox 模式------事务内写业务表 + outbox 表,由独立 relay 发送。消费侧还有一条版本规则:事件按 (personId, version) 去重,乱序到达时只接受较新的 version。

2.5 契约怎么改:兼容规则先讲清楚

变更类型 是否允许 处理方式
新增可选字段 允许 消费者忽略未知字段
新增枚举值 谨慎 消费方必须有 default 分支,否则 CI 拦截
字段改名/删除 不允许 需新增字段 + 双写过渡一个版本
路径变更 不允许 新增版本前缀 /v2,旧版保留 1 个迭代
事件 payload 结构变更 谨慎 升 version,消费方按版本分流

2.6 契约如何跨语言生效

4 个算法服务是 Python,没法 import TS 类型。所以契约支持导出快照:

bash 复制代码
npm run snapshot:check   # 契约与快照不一致则失败

这不是形式主义。实施中期新增 projects / experiences 字段时,快照落后于契约,snapshot:check 直接失败------如果没有它,Python 侧会一直用旧结构运行而且不报错。

2.7 一个端点,三种入参:discriminated union

相似搜索支持三种输入,但只有一个入口:

ts 复制代码
export const SimilarSearchRequestSchema = z.discriminatedUnion('mode', [
  PersonSimilarRequestSchema,   // mode: 'person' ------ 以人搜人
  JdSimilarRequestSchema,       // mode: 'jd'     ------ 以 JD 搜人
  SkillsSimilarRequestSchema,   // mode: 'skills' ------ 以技能搜人
]);

discriminatedUnion 让 TypeScript 在 switch (req.mode) 里自动收窄字段,同时保证"jd 模式绝对拿不到 personId"。如果用一个大对象 + 全部可选字段,非法组合只能靠运行时 if 兜底。


3. 十二个模块:契约、独立运行、独立测试

这一节按统一模板过一遍每个模块:职责 → 对外契约 → 只写什么 → 怎么单跑 → 怎么单测 → 一个设计要点。

单跑与单测的命令都是可直接执行的(Node 用 workspace,Python 用 pytest):

bash 复制代码
npm run dev -w @hr/profile-svc                                  # 单跑一个 Node 服务
cd services/similarity-svc && PYTHONPATH=. python3 -m pytest -q  # 单测一个 Python 服务
npm test -w @hr/search-svc                                      # 单测一个 Node 服务

M00 contracts --- 契约与共享内核

  • 职责:类型、Schema、错误码、事件、OpenAPI 生成。
  • 不做:任何业务逻辑、任何 I/O。
  • 测试:29 个用例,覆盖 Schema 边界(越界 ID、空数组、非法枚举、discriminated union 收窄)与错误码映射。
  • 要点:这是唯一被所有模块依赖、却谁都不依赖的模块。它必须保持"零副作用",否则会变成隐形的单体。

M01 taxonomy --- 分类与技能本体

  • 契约 :GET /categories、GET /skills、GET /skills/:id/related、POST /skills/resolve、POST /skills。
  • 只写:分类树、技能本体、技能别名。
  • 测试:31 个用例。
  • 要点 :/skills/resolve 是导入链路的枢纽。简历里的技能写法五花八门(ABAP、SAP-ABAP、abap 开发),导入时统一走 resolve 拿 skillId,表外技能自动入词表并回填名称,而不是丢弃。这一步决定了人才库里技能维度能不能聚合。

M02 profile --- 人员档案服务

  • 契约 :GET/POST /persons、POST /persons/batch、GET/PATCH/DELETE /persons/:id、POST /persons/snapshots、GET /internal/snapshots。
  • 只写:档案全字段(技能等级、经历、项目、状态、隐私等级)与版本号。
  • 测试:36 个用例。
  • 要点 :PATCH 带 version 做乐观锁,冲突返回 409 而不是静默覆盖------批量导入与人工编辑并发时,这个字段避免了"刚录的简历被旧草稿覆盖"。POST /persons/snapshots 支持批量取快照,是推荐与相似结果的回表通道(避免 N+1)。

M03 identity --- 身份、权限与审计

  • 契约 :POST /auth/login、POST /auth/refresh、GET /me、POST /authz/check、GET /authz/mask-policy、POST/GET /audit-logs。
  • 只写:用户、角色、掩码策略、审计日志。
  • 测试:25 个用例。
  • 要点 :掩码策略是字段级的(手机号、薪资、期望),不是简单的接口开关。当前它尚未接入 BFF,这是已知欠账------这也解释了为什么 README 明确写"不可公网暴露"。

M04 vector --- 向量化与 ANN 检索

  • 契约 :POST /embed/profile、POST /embed/query、POST /search/ann、POST /rebuild、GET /tasks/{task_id}。
  • 只写:向量索引文件与重建任务。
  • 测试:13 个用例(偏索引一致性与重建幂等)。
  • 要点 :
    • /rebuild 是异步任务而非同步接口------全量重建耗时不可控,同步会让调用方超时。任务 id 让前端能轮询进度。
    • 默认零依赖 :未配置 EMBEDDING_URL 时自动降级为确定性哈希嵌入,响应带 degraded=true。降级只表示"本次用了哈希向量",不是错误,调用方不需要中断链路。这样无 GPU、无网络的环境也能端到端跑通,测试结果完全确定。
    • 档案文本模板顺序固定(职位 → 行业/城市/经验/学历 → 技能 → 履历 → 项目),空字段整行省略;顺序一变,历史向量就不可比。模板版本号与模型版本一起参与幂等键,文本哈希没变就跳过重算。
    • 配置 VECTOR_INDEX_PATH 后每次写入原子落盘(临时文件 + os.replace),重启自动恢复;索引文件损坏时按空索引启动并告警,不阻塞服务。

M05 similarity --- 相似度内核

  • 契约 :POST /score、POST /score/batch、POST /calibrate、GET /weights/default。
  • 只写:无状态(纯计算),不持有业务数据。
  • 测试 :85 个用例,全系统最多。
  • 要点 :纯函数 + 无副作用 + 同输入必得同输出 。这是它能有 85 个测试、能被 recommend 复用、还能做性质测试与黄金快照的前提。批接口 score/batch 让精算阶段只走一次网络往返(上限 2000 对,1000 对本机耗時 < 50ms)。算法细节见第 8 节。
  • 契约 :POST /search/people、POST /search/facets、POST /search/similar、POST /internal/index/invalidate、GET/POST /saved-searches。
  • 只写:检索投影、保存的搜索条件。
  • 测试:50 个用例。
  • 要点 :三种相似模式的排序打分在 search 进程内完成 (加权 Jaccard + 城市/行业微调,见 7.2),一次请求不需要跨服务往返;岗位匹配的四分量精算才交给 M05。两层打分各管一件事:相似度只回答"像不像","能不能看到"由 search 的隐私与状态过滤决定。

M07 recommend --- 推荐编排

  • 契约 :GET /jobs、GET /jobs/{job_id}、POST /recommend/jobs/{job_id}、POST /feedback、GET /algo/version。
  • 只写:推荐结果缓存、反馈记录。
  • 测试:78 个用例。
  • 要点 :编排层不实现算法细节,只做召回调度、融合、过滤、约束、排序 。四路召回单路超时 300ms,任何一路失败只损失该路证据,不阻断整体------这是"可用性优先于完整性"的显式选择。召回全部失败(M06 不可用)时返回 4002,不返回空列表伪装成功。

M08 explain --- 可解释生成

  • 契约 :POST /explain、POST /explain/batch、POST /explain/jobs/{job_id}、POST /explain/validate。
  • 只写:无状态。
  • 测试:73 个用例。
  • 要点 :/explain/validate 用来校验"解释与分数是否自洽"------命中的必备技能数量必须能推出分数量级,防止解释层与打分层漂移。这是解释可信度的自动检查 。它不重算分数,只做"合并 + 补全 + 分级 + 去重 + 截断",M07 传入的 reasons 视为已确认事实------两处各算一遍必然打架。

M09 import --- 文档解析与批量导入

  • 契约 :POST /imports/documents/preview、POST /imports/documents/commit、POST /imports/preview、POST /imports、GET /imports/:id、GET /imports/:id/errors、POST /imports/:id/retry、GET /imports/templates/:type。
  • 只写:导入批次、行级错误、解析产物。
  • 测试:39 个用例。
  • 要点 :preview 与 commit 严格两段式 。预览只解析不入库,HR 能先看字段对不对;commit 才写档案。失败行落错误报告、可单独重试,而不是整批回滚。

M10 bff --- 聚合层

  • 契约 :/api/dashboard、/api/people、/api/people/:id、/api/similar、/api/jobs、/api/jobs/:id/matches、/api/matches/:id/feedback、/api/imports/*。
  • 只写:无(纯聚合),仅 60 秒的分类与技能名缓存。
  • 测试:12 个用例(主要覆盖聚合与降级)。
  • 要点 :前端永远只面对 BFF,不直连任何下游。下游挂了返回的是"局部降级"而不是 5xx。

M11 web --- 前端应用

  • 路由 :/(工作台)、/people、/people/new、/people/:id、/people/:id/edit、/jobs/:id/match、/match、/search/similar、/import。
  • 测试:浏览器 E2E(工作台、档案 CRUD、三模式相似搜索、岗位匹配反馈、批量导入、404)。
  • 要点 :前端不做业务判断------分数怎么来的、缺哪些技能,全部由后端给出结构化结果,前端只负责呈现。换一套 UI 不需要重写规则。

3.x 独立性的验收标准

一个模块算"独立",要同时满足六条:

标准 检验方式
能单独启动 npm run dev -w @hr/xxx-svc 后 /meta 返回自身模块号与 ready
能单独测试 不依赖其他服务真实实例,测试里用 Fake 适配器
能单独替换 只要满足契约,换实现不影响调用方
能单独演进 版本号独立(如 m05-v1.0.0),算法升级不改接口
契约不漂移 请求/响应/事件的 Schema 快照无意外变更,snapshot:check 通过
数据不越界 静态扫描无跨 schema 表名引用,写操作全部走唯一属主

/meta 返回 { module: 'M02', service: 'profile-svc', title: '人员档案服务', status: 'ready' },让"这个端口上跑的到底是谁"一眼可查------11 个进程最容易搞混的就是这点。

Fake 适配器是"能单独测试"的落点。 每个外部依赖都定义成端口(Protocol/Interface),生产与测试各给一个实现:

端口 生产实现 测试实现
ProfileRepository PostgreSQL(规划中) InMemoryProfileRepo
EventPublisher Outbox + 总线 InMemoryBus(同步派发,便于断言)
VectorPort M04 HTTP HashVectorPort(确定性哈希向量)
TaxonomyPort M01 HTTP StaticTaxonomy(固定词表)
EmbeddingModel 远程模型 FakeEmbedding(确定性向量)
Clock 系统时间 FixedClock(冻结时间)

在 Fake 之上还有一层黄金测试(golden test):M05 用 30 组固定输入 → 固定输出快照,M07 用固定候选池锁定 20 条输出的完整顺序。权重调整导致分数变化时,必须显式更新快照并在提交说明里写明原因------分数的每次变化都是被记录的,不是悄悄发生的。


4. 组装:BFF 把 6 个下游服务拼成一个产品

4.1 聚合不是转发

以档案详情页为例,一次请求需要五类数据:

ts 复制代码
const [maps, similar] = await Promise.all([
  taxonomy().catch(() => emptyMaps),                       // 技能名 / 分类名
  gateway.post('search', '/search/similar', {...})          // 相似人才
           .catch(() => ({ items: [] })),
]);
  • 并行:串行会让详情页延迟从 120ms 涨到 400ms+。
  • 每个下游都有 catch:相似度服务挂了,页面仍然显示档案,只是"相似人才"区域为空。
  • taxonomy 有 60 秒缓存:技能名映射几乎不变,详情页每次都用到。

工作台更进一步,用 Promise.allSettled:

ts 复制代码
const [people, jobs, algo, imports] = await Promise.allSettled([...]);

四个模块任一失败,页面显示其余三个 + 该模块的降级提示。"部分可用"比"整页白屏"对运维友好得多 。降级状态会显式暴露:GET /api/dashboard 返回 partial 字段,页面顶部出现黄色告警条------停掉 explain 服务再刷新推荐页,推荐列表仍在、分数正常,只是没有解释文案,且 partial=true。

4.2 组装后才出现的能力

单模块测试通过不代表组装正确。真正只有拼起来才成立的能力有三个:

  1. 导入 → 建档 → 可被检索 :import 解析完写 profile,profile 发 person.changed,search 与 vector 刷新投影。链路断在事件这一步时,表现是"导入成功但搜不到人"。
  2. 推荐 → 解释 → 展示:recommend 只给 id 和分数,explain 补证据,BFF 再回表补快照。三者缺一,HR 看到的就是裸分数。
  3. 相似搜索 → 分面筛选:相似度给"像不像",search 给"可不可见",BFF 合并。

这三条都有端到端测试兜底:scripts/e2e-api.mjs 用 82 个断言验证接口编排,scripts/e2e-ui.mjs 用 34 个用例走完真实浏览器路径(见第 9 节)。


5. 特色一:不限格式导入,HR 传什么就收什么

5.1 传统网站为什么卡在这里

很早以前在招聘网站上找工作,简历是一栏一栏填出来的。过了许多年,网站有了"简历模板导入",印象里每次仍要花很多时间:需要严格按照字段格式,把内容逐项输入到对应的位置------等于换了个界面,把简历再抄一遍。当时就在想:简历里明明已经写了这些信息,为什么不能一键导入、自动识别关键信息?

多年后动手做这套系统,这个疑问变成了第一个要兑现的能力。而它迟迟没有被做好的原因,主流招聘平台的实现方式已经说明了一半。

主流招聘平台的做法是"提供模板 → 候选人按模板填 → 系统按模板存"。一旦简历不是这个模板(Word 排版件、PDF、猎头发的 Markdown、学校就业网的 HTML),就只能人工摘录。

卡点不在于"读不出文字",而在于读出来之后仍是自由文本:没有统一的技能 ID、没有可比的年限、没有分段的项目经历,后续所有匹配都退化成关键词包含。

本系统的做法:把任意格式压成"纯文本 → 结构化草稿 → 可入库档案",中间每一步都有确定性校验。

5.2 六层管线

#mermaid-svg-NeaG9kpl7mSl1eYb{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-NeaG9kpl7mSl1eYb .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-NeaG9kpl7mSl1eYb .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-NeaG9kpl7mSl1eYb .error-icon{fill:#552222;}#mermaid-svg-NeaG9kpl7mSl1eYb .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-NeaG9kpl7mSl1eYb .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-NeaG9kpl7mSl1eYb .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-NeaG9kpl7mSl1eYb .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-NeaG9kpl7mSl1eYb .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-NeaG9kpl7mSl1eYb .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-NeaG9kpl7mSl1eYb .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-NeaG9kpl7mSl1eYb .marker{fill:#333333;stroke:#333333;}#mermaid-svg-NeaG9kpl7mSl1eYb .marker.cross{stroke:#333333;}#mermaid-svg-NeaG9kpl7mSl1eYb svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-NeaG9kpl7mSl1eYb p{margin:0;}#mermaid-svg-NeaG9kpl7mSl1eYb .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-NeaG9kpl7mSl1eYb .cluster-label text{fill:#333;}#mermaid-svg-NeaG9kpl7mSl1eYb .cluster-label span{color:#333;}#mermaid-svg-NeaG9kpl7mSl1eYb .cluster-label span p{background-color:transparent;}#mermaid-svg-NeaG9kpl7mSl1eYb .label text,#mermaid-svg-NeaG9kpl7mSl1eYb span{fill:#333;color:#333;}#mermaid-svg-NeaG9kpl7mSl1eYb .node rect,#mermaid-svg-NeaG9kpl7mSl1eYb .node circle,#mermaid-svg-NeaG9kpl7mSl1eYb .node ellipse,#mermaid-svg-NeaG9kpl7mSl1eYb .node polygon,#mermaid-svg-NeaG9kpl7mSl1eYb .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-NeaG9kpl7mSl1eYb .rough-node .label text,#mermaid-svg-NeaG9kpl7mSl1eYb .node .label text,#mermaid-svg-NeaG9kpl7mSl1eYb .image-shape .label,#mermaid-svg-NeaG9kpl7mSl1eYb .icon-shape .label{text-anchor:middle;}#mermaid-svg-NeaG9kpl7mSl1eYb .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-NeaG9kpl7mSl1eYb .rough-node .label,#mermaid-svg-NeaG9kpl7mSl1eYb .node .label,#mermaid-svg-NeaG9kpl7mSl1eYb .image-shape .label,#mermaid-svg-NeaG9kpl7mSl1eYb .icon-shape .label{text-align:center;}#mermaid-svg-NeaG9kpl7mSl1eYb .node.clickable{cursor:pointer;}#mermaid-svg-NeaG9kpl7mSl1eYb .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-NeaG9kpl7mSl1eYb .arrowheadPath{fill:#333333;}#mermaid-svg-NeaG9kpl7mSl1eYb .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-NeaG9kpl7mSl1eYb .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-NeaG9kpl7mSl1eYb .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-NeaG9kpl7mSl1eYb .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-NeaG9kpl7mSl1eYb .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-NeaG9kpl7mSl1eYb .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-NeaG9kpl7mSl1eYb .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-NeaG9kpl7mSl1eYb .cluster text{fill:#333;}#mermaid-svg-NeaG9kpl7mSl1eYb .cluster span{color:#333;}#mermaid-svg-NeaG9kpl7mSl1eYb div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-NeaG9kpl7mSl1eYb .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-NeaG9kpl7mSl1eYb rect.text{fill:none;stroke-width:0;}#mermaid-svg-NeaG9kpl7mSl1eYb .icon-shape,#mermaid-svg-NeaG9kpl7mSl1eYb .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-NeaG9kpl7mSl1eYb .icon-shape p,#mermaid-svg-NeaG9kpl7mSl1eYb .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-NeaG9kpl7mSl1eYb .icon-shape .label rect,#mermaid-svg-NeaG9kpl7mSl1eYb .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-NeaG9kpl7mSl1eYb .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-NeaG9kpl7mSl1eYb .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-NeaG9kpl7mSl1eYb :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} HR 确认
上传文件
格式探测

魔数优先
文本抽取

八种解析器
清洗归一

可读性/编码/截断
模型抽取

JSON 草稿
确定性解析

标签/年限/分节
互校合并
预览草稿
commit 写档案

层 关键约束
探测 文件魔数 > 扩展名 > 内容嗅探;docx/xlsx 靠 ZIP 内部结构区分
抽取 每种格式一个解析器(共 8 种:docx / pdf / html / markdown / xml / txt / xlsx / csv),统一输出 { text, pages?, warnings }
清洗 不可读内容拒绝、编码归一(如 GB18030 显式解码)、单文档上限 20 万字符
模型 硬性规则:只用原文明确出现的信息,缺失输出 null,禁止推测
确定性 标签字段、年限取最大、技能等级、工作与项目分节
合并 模型漏的补、模型多的留、冲突以原文可验证者为准

5.3 外发脱敏:文本给模型之前先过一道

调用 LLM 抽取前,maskPii 会把手机号、邮箱、证件号、IM 号替换为占位符,模型返回后再本地还原。预览响应里的 piiMasked 字段记录替换了多少片段。

这一步的必要性很直接:简历里必然有联系方式,而这些文本会被发到外部模型端点。脱敏放在抽取管线内部而不是调用方,调用方(BFF、前端)想漏都漏不掉。

5.4 为什么必须有确定性层

模型抽取出问题时,架构要能兜住。实测同一份 docx 连续两次预览:

字段 第一次 第二次(同一模型,同一文件)
项目经历 18 条 0 条
技能 40 条 20 条
工作年限 19 年(正文) 10 年(抄了模板里的"10年以上")
摘要 结构化改写 整段原文照抄

如果正确性全押在模型上,第二天下一次调用就可能字段全空。所以引入了与模型无关的 resume-facts.ts(确定性解析)+ resume-merge.ts(互校合并):

  • 模型擅长理解非结构化描述,规则擅长稳定抽取确定性字段;
  • 冲突时以原文可验证的那个为准("工作年限 10年以上" vs 正文"19年" → 取 19);
  • 降级时给出 degraded: true 与 warnings,不静默填充。

修复后同一文件:9.7 秒解析出姓名、出生年份、19 年经验、3 个项目、3 段经历、10 个技能;无模型的降级态也能产出 3 个项目与 3 段经历。

5.5 模型链路的实测数据

配置 OpenAI 兼容端点后的实测口径(2026-10,deepseek-flash,LLM_MAX_TOKENS=2048):

场景 输入 耗时 结果
单份 Markdown(2 人) 261 字 7--24s 2 位候选人,薪资区间与原文一致
三份混格式 docx + html + 英文 pdf 7s 3 人,degraded=false
超长文档分段 31,692 字(超过 24,000 上限) 17s 自动分 2 段,2 人无重复
坏 PDF + 正常 txt --- 7s 坏文件单独失败,txt 正常出 1 人

两个工程约束来自这份实测:

  • 分段送模型 :LLM_MAX_CHARS=24000,超长文档自动切分,各段候选人合并去重;
  • 限制输出 token :推理型模型不限制输出时会长时间"思考",表现为偶发 60--90s 超时并降级;设了 LLM_MAX_TOKENS 后同一批样例稳定在 25s 内。若供应商端点抖动仍可能降级,这是预期行为而非功能错误。

5.6 链路里两个真实缺陷(架构层面的教训)

① Word 图形坐标被当成姓名。

原实现用正则逐层剥 XML 标签,但 <wp:posOffset>321059</wp:posOffset> 里的数字是元素内容,剥完留在正文里:

复制代码
321059-10481080个人简历
468287120574000-6350076200个人信息

模型把首行当姓名,段落标题被数字污染,项目经历直接归零。

修复不是打补丁,而是换抽象:放弃剥标签,改成词法扫描 + 白名单采信 ,只认 w:t / a:t 文本节点,结构靠 w:p、w:tc、w:tr 重建换行与分隔;同时跳过 mc:Fallback(文本框的兼容副本,不跳会整段重复)。

② birthYear 恒等于 1940。

Number('') === 0,而 Number.isFinite(0) 为真,于是空字符串被 clamp 到下限 1940。这个 bug 不抛错、不告警,只是安静写入错误值。

修复是在转换前显式判空:

ts 复制代码
const raw = ...;
if (raw === '') return null;      // ← 关键一行

教训很具体:clamp 类函数的测试必须覆盖 ''、' '、'abc'、0、负数、超大值。

5.7 表格导入的状态机、幂等与安全

文档导入之外,CSV / XLSX 表格导入是另一条成熧行链路,几个控制点:

任务状态机 :pending → parsing → importing → success / partial_success / failed。有成功行也有失败行是 partial_success,不算失败;后台执行若进程异常中断,run() 的 catch 会把状态置为 failed 并写入一行说明原因的错误记录------保证前端不会永远停在 importing。执行中的任务拒绝并发 retry。

幂等 :导入键是 sha256(filename + mode + mapping + content),24 小时窗口内同键直接复用原任务(响应 reused=true)。重复提交同一文件返回同一任务 id,不产生重复档案;新建返回 201,复用返回 200。

行级校验 :数值字段有明确区间(年限 0--60、出生年 1940--2010、到岗天数 0--365),枚举做中英文映射,技能按 名称:等级 解析后必须能在词表命中,未命中的行单独失败并提示"未知技能「Rust」"。最终统一过 PersonCreateSchema.safeParse,保证入库数据与 M02 契约完全一致。同一技能重复出现取最高等级。

安全 :文件名拒绝含 /、\、..(防路径穿越);以 = + - @ 开头的单元格在错误回显与模板中加前导 '(防 CSV 公式注入)------注意这只影响回显,不改业务解析值。分批写入每批 100 行,下游少报成功数时按批内剩余行补齐,保证 successRows + failedRows === totalRows 恒成立。

上限:5000 行 / 10 MB,超限在入口拒绝而不是跑到一半挂掉。


6. 特色二:人才智能推荐------召回、过滤、融合、重排

6.1 单一召回的必然盲区

只用 会漏掉什么
关键词 / 技能倒排 技能名不同但能力等价的人(SAP Fiori ↔ UI5 开发)
向量语义 硬技能精确命中、等级达标这类确定性证据
行为协同 冷启动人群;且与"能力是否匹配"无关
规则 长尾候选人

所以做四路并行召回,再融合。

6.2 四路召回

通道 权重 输入 回答的问题
vector 0.35 JD 文本向量 整体语义像不像
skill 0.30 必备/加分技能集合 硬技能覆盖多少
relation 0.15 同项目 / 同公司 / 同校 有没有可信的连接
rule 0.20 城市 + 岗位族 + 求职状态 这个人现在能不能触达

rule 这一路常被忽视,但它决定了推荐的可执行性:能力再匹配,人不在目标城市、且不看机会,HR 也没法推进。

向量通道(A 路)的实现细节:M04 的 ANN 返回的是 personId 列表,recommend 拿它作为 personIds 白名单调 M06 回表补全快照,并按 ANN 的顺序保序------否则融合时的名次就是错的。

6.3 融合之后先过五条硬过滤

融合去重后的候选池(≤200 人)先过硬过滤,再进精算。五条规则:

规则 判定 说明
必备技能缺失 mustHave 技能的候选人等级 < 要求等级 岗位侧声明
年限不足 person.yearsExp < job.yearsMin ---
学历不足 person.educationLevel < job.educationMin ---
到岗超限 person.availabilityDays > 岗位上限(默认 90 天) ---
已在流程中 反馈表中该 (jobId, personId) 已有 invite / interview / offer / hire 避免重复推荐

注意反馈的两条语义:view / reject 不触发过滤 ------看过、拒绝过不代表流程中;reject 只作为 M05 的惩罚项输入。过滤发生在打分之前,被过滤的人不消耗精算成本。

6.4 为什么用 RRF 而不是加权求和

四路返回的分数尺度根本不可比:余弦相似度是 0--1,技能覆盖度是百分比,关系扩散是跳数,规则召回是布尔。

硬做归一化会引入新的偏差(比如把"同公司 +1 跳"压到 0.3,靠人为定系数)。RRF 只用名次,天然免疫尺度问题:

python 复制代码
for rank, person_id in enumerate(person_ids, start=1):
    scores[person_id] = scores.get(person_id, 0.0) + weight / (k + rank)

k = 60,四路权重 0.35 / 0.30 / 0.15 / 0.20,候选上限 200。

两个实现细节决定了它能不能被测试:

  • 通道顺序固定:浮点累加不满足结合律,遍历顺序一变末位就差 1e-12,测试会随机失败;
  • 排序键带 tie-break :(-round(score, 12), person_id),同分按 id 升序,分页才不会重复或漏人。

6.5 重排:Top20 不能是 20 个同质候选人

分数最高的一批人往往来自同一家公司、同一职级。直接取 Top20,HR 会看到"20 个同背景的人",池子越用越窄。

MMR(最大边际相关性)在每次选择时同时看相关性与多样性:

复制代码
select = argmax[ λ·relevance(c) − (1−λ)·max_similarity(c, selected) ]

λ = 0.75,偏向相关性但保留多样性压力。再加三条约束:

约束 值 作用
同公司上限 3 防止单一雇主刷屏
同职级占比 ≤ 60% 保证层级结构多元
探索位 2 个,分数 ≥ 最高值 50% 给低曝光候选人机会,避免马太效应

约束的松紧是有层次的:公司配额是硬约束,职级分布是软约束 。MMR 贪心过程中超限的候选跳过但不丢弃,只有候选池实在凑不齐时才逐级放宽(先放宽职级、再放宽公司),并置 quotaRelaxed=true 让调用方知道结果是在放宽条件下产出的。探索位同理:不足额时空缺,不硬凑。

探索位是产品判断而非算法最优:纯取最优会让新人永远不出现,短期分数略降,长期保证池子不板结。

6.6 缓存、幂等与版本

  • 推荐结果缓存 600 秒,缓存键包含岗位快照哈希------岗位要求一变立即失效,不用等 TTL 到期;
  • 幂等键 sha256(jobId + job 快照 + algoVersion + 召回参数):同键不产生新结果记录,matchId 不变;
  • revision 只在输入哈希变化时 +1,refresh=true 强制重算不升版本------避免"重算一次就换代"导致前端的对比视图失效;
  • 结果带 recallStats(四路各召回多少人)、recallFailed、degraded 标记,降级可观测。

6.7 一个显式取舍:岗位主数据没有归属模块

契约缺口如实记录:数据所有权矩阵里没有 job 表,也没有岗位 CRUD 服务。当前方案是请求体内联 job 快照 ,或 PUT /jobs/{jobId} 注册到 recommend 的本地投影;岗位模块落地后替换为 REST 拉取,接口不变。宁可显式记录取舍,也不假装问题不存在。

6.8 结果不是分数,是证据

推荐结果里每条都带:rrfScore、命中通道来源、命中必备技能、缺失技能、经验差距、上下文加分原因。反馈写入后可按版本失效。


7. 特色三:相似性搜索------一个入口,三种问法

7.1 三种模式对应三种真实场景

模式 入参 HR 的真实问题
person personId "这个人不错,还有没有类似的?"(竞品挖人、替补)
jd 岗位描述文本 / jobId "这个岗位,谁最像?"(没有现成候选人名单时)
skills 技能列表 + 等级 "会 ABAP 5 级 + Fiori 的人有哪些?"(定向找人)

三种入参共用 discriminatedUnion 契约保证互斥,也共用 search 进程内的同一套打分函数(见下)。

7.2 打分:加权 Jaccard + 城市/行业微调

/search/similar 的排序打分在 search 进程内完成,一次请求不需要跨服务往返。实现(search-svc/src/domain/similarity.ts):

技能权重:等级主导,年限只做轻微调节。

ts 复制代码
skillWeight = level + Math.min(years ?? 0, 10) / 50

年限最多贡献 +0.2,避免"资历碾压技能匹配"------写 10 年 Java 但等级自评 2 的人,不应该比等级 5 年限 1 年的人排得靠前。

加权 Jaccard :交集按 min 权重、并集按 max 权重累加,结果稳定在 0,1;同名技能去重保留最高权重(简历里常见重复条目,先归并再算,否则分母被污染)。

综合分:

复制代码
score = 0.85 × skillSim + 0.10 × 同城 + 0.05 × 同行业

关键在按可用维度归一化 :目标没填城市时,分母只有 0.85,技能相同同城的候选人仍能拿满分------缺失维度不奖不罚 ,而不是把"没比城市"当成隐性扣分。目标技能为空(JD 没解析出技能)时 score=0 且 degraded=true,不伪造结果。

确定性 :分数倒序、同分按 personId 升序,sharedSkillIds 返回技能交集供前端解释"为什么相似"。

7.3 筛选语义:先判隐私,再算别的

/search/people 的筛选维度遵循"维度间 AND、维度内 OR"(技能与分类例外,是 AND):

条件 语义
keyword 名称/职位/公司/行业/自述模糊包含(小写归一)
city[] / educationLevel[] / jobStatus[] OR
skills[{skillId, minLevel}] AND,且校验候选人等级 ≥ minLevel
categoryIds[] AND
yearsExp{min,max} 闭区间
maxPrivacyLevel 先于其它条件判定,超限档案直接不进入流程
personIds[] / excludePersonIds[] 白名单召回 / 排除

maxPrivacyLevel 先判这条值得展开:如果隐私过滤放在最后做,攻击者可以通过"调整其他条件观察结果数变化"来推断高隐私档案的存在。先判隐私,越权探测连侧信道都没有。

其他两个边界:检索用 POST 而非 GET(条件体含数组且较大);page × pageSize > 10000 直接拒绝(3002),深分页在内存索引上代价不可控,不如显式不支持。

7.4 相似 ≠ 可见,降级 ≠ 报错

/search/similar 的职责边界很清晰:进程内打分回答"像不像",筛选与隐私回答"能不能看到"。

降级语义同样明确:上游宕机时保留旧快照 继续服务,响应标 degraded=true,HTTP 仍是 200------数据可能旧一拍,但服务不塌;完全没有数据时才返回 503。JD 模式依赖 taxonomy 解析技能,taxonomy 不可用时返回空列表 + degraded=true + 200,由前端提示重试。

这个划分让两件事可以独立演进:换相似度算法不影响权限策略,改权限策略不影响打分。


8. 特色四:岗位需求 × 简历,算出"最佳人选"

8.1 四分量加权,而不是一个黑盒分

复合分由五个部分构成:

复制代码
score = 0.40·semantic + 0.35·skill + 0.15·experience + 0.10·context − 0.05·penalty
分量 含义 为什么是这个权重
semantic 0.40 整体语义接近度 能力可迁移性主要靠语义,权重最高
skill 0.35 技能加权 Jaccard 硬门槛,但不应压过整体判断
experience 0.15 职级 / 岗位族匹配 职级是稳定信号,年限易被人为拉长
context 0.10 城市、薪资、到岗 影响可触达性,不是能力
penalty 0.05 硬性冲突扣分 如明确不匹配的行业或地点

权重可通过 GET /weights/default 查看、POST /calibrate 校准,不是写死在代码里的魔数。

几个分量内部的具体规则:

  • experience 用职级而非年限做主项 :职级差是稳定的序数信号,年限只作职级缺失时的退化依据(level ≈ yearsExp/3 + 1,clamp 到 1...5)。
  • 缺失字段走中性 0.5,而不是 0:技能为空、城市未知、薪资缺失,都返回 0.5。"没填"不等于"很差"。这条规则有个已知交互并如实记录:一份完全空白的技能档案会拿到中性 0.5,可能高于"只覆盖 1/3 必备技能"的 0.33------所以空白档案靠 M07 的必备技能硬过滤束住,而不是把中性值改成 0;若未来发现空白档案靠中性分上位,应该在 recommend 侧加完整度阈值拦截。
  • 上下文三分 :0.5·城市匹配 + 0.3·薪资区间交并比 + 0.2·到岗可用性(15 天内满分,15--90 天线性衰减,缺失取 0.5)。
  • 浮点确定性 :所有分项 round(x, 6),同输入字面相等。

8.2 权重校验:不做静默归一

四项权重之和必须等于 1.0(惩罚项独立扣减),不满足直接返回 1001。

不做静默归一是有意的:如果配置错了权重(比如加起来 1.1),静默除回去会让配置错误不可见------分数看着正常,实际每一项都被系统性放大了。让错误大声失败,好过让错误安静漂移。

8.3 校准:让分数可比,且诚实标注"未训练"

原始加权和在 0--1 之间但分布不均,直接用会出现"全是 0.7 分"的观感问题。所以过一层 logistic 校准:k=6.0, x0=0.55,边界稳定(0 分 → 0.036,满分 → 0.938)。

这样做的意义是同一套标准下跨岗位可比:0.8 分在 ABAP 岗位和前端岗位含义一致,HR 才能横向比较不同岗位的候选人质量。

必须说明的是:理想的校准是 Isotonic 回归,但它需要标注数据,当前没有。所以现在是单调 logistic 占位曲线 ,/weights/default 里显式标注 calibrationKind,避免被误认为"已经用真实通过率训练过"。契约预留了 calibration.kind = "isotonic" + points[],有标注后替换实现、接口不变。

8.4 解释层:把分数翻译成可验证的事实

explain 不重新算分,只做翻译:

命中 4/5 项必备技能,缺 SAP Fiori;同城;5 年同岗位经验;近 3 个月活跃。

对比传统系统的"匹配度 87%"------HR 能判断"缺 Fiori 我能接受",但没法判断"87 分算高吗"。这是可解释性的实际价值,不是概念。

实现上有三条守则:

  • 证据链分层取 :项目 → 经历 → 技能 → 摘要,按信息量从高到低,够 5 条即停;空层跳过,不产生占位项。每条证据带 ref(如 projects.2、skills.5)指回快照路径。
  • 差距项量化:统一"要求 X,实际 Y"句式,按严重度分级(必备技能未达、年限差 > 2 年、薪资缺口 > 20% 为 medium),M07 传入的差距与 explain 重算的合并去重,同键取高严重度。
  • 事实校验兜底 (/explain/validate):摘要中的数字 token 必须能在事实集里找到(允许 ±0.5 误差与四舍五入),找不到即 unknown_number 违规。若启用 LLM 润色摘要,温度 0,输出再过这道校验,不过就丢弃 LLM 结果、回落模板并记 llmFallback=true------幻觉可以被机器拦住,而不是靠提示词祈祷。

8.5 公平性:有些字段根本不存在

性别、年龄、婚育字段在 M05 的输入模型里不存在 ------不是"不参与计算",而是结构上传不进来。测试里有一条属性存在性断言:模型字段集合中不含这些属性。相似搜索的索引同样只收能力字段,birthYear 不进入索引。

此外有一条公平性测试:移除 gender / birthYear 后推荐顺序不变。

8.6 反馈闭环

POST /matches/:id/feedback 记录采纳/忽略及原因。当前阶段反馈仅入库用于后续调权(reject 作为 M05 惩罚输入 0.5),尚未做在线学习------这是刻意克制:没有足够样本时的自动调权,比不调更危险。


9. 可测试性:模块自治的验收方式

9.1 测试分布就是架构的镜像

模块 用例数 说明
M05 similarity 85 纯函数,边界最多(等级、缺失、重复技能)
M07 recommend 78 融合、过滤、约束、分页稳定性
M08 explain 73 证据生成与自洽校验
M06 search 50 筛选、隐私、分面
M09 import 39 8 种格式(含表格)、降级、两段式
M02 profile 36 乐观锁、批量、快照
M01 taxonomy 31 别名归一、表外技能
M00 contracts 29 Schema 边界与错误码
M03 identity 25 鉴权、掩码、审计
M04 vector 13 索引一致性与重建幂等
M10 bff 12 聚合与降级
合计 471 49 个测试文件(TS 26 个 222 用例 + Python 23 个 249 用例)

(TS 侧 222 个用例为本机实测复核;Python 侧 249 = 78 + 85 + 73 + 13。)

算法服务测试最多,因为它们是纯函数 ------没有网络、没有状态、没有时间依赖,测试成本极低。这反过来验证了架构划分:该纯的地方纯了。

测试类型上,两个算法服务各有三类特殊用例:

  • 性质测试 (M05):对称性 score(a,b) == score(b,a)、值域 0,1、技能覆盖增加时分数不降;
  • 黄金测试(M05/M07):固定输入 → 固定输出快照,M05 为 30 组打分对,M07 为固定候选池下 20 条结果的完整顺序;
  • 公平性测试(M05/M07):模型无性别/年龄字段(属性存在性断言)+ 移除敏感字段后顺序不变。

9.2 三层验收

层 工具 回答什么
单元 / 契约 Vitest + pytest 逻辑对不对、契约有没有漂移
接口 E2E scripts/e2e-api.mjs 跨服务编排通不通
浏览器 E2E scripts/e2e-ui.mjs 用户能不能完成操作

接口 E2E 共 82 个断言 ,覆盖:10 个服务健康检查与 /meta、人才列表的筛选/分页/隐私过滤、乐观锁 409 与重复删除 404、三种相似搜索与参数校验、推荐分数区间/降序/四维拆解、反馈闭环(邀约后该人退出推荐)、导入的幂等复用/部分成功/失败重试、XLSX 导入。

浏览器 E2E 共 34 个用例 ,覆盖:工作台、档案增删改、三种相似搜索、推荐工作台重算与四维拆解、候选人对比、邀约反馈、CSV 导入与失败明细、404,并断言零 console error、无 5xx。

浏览器 E2E 用 playwright-core 驱动本机 Chromium(不下载浏览器),创建的数据统一以 UI-E2E-${RUN} 开头并在结束时软删除,避免测试垃圾留在人才库里。降级也被纳入验收:停掉 explain 后刷新推荐页,结果仍在、partial=true、页面出现告警条。

bash 复制代码
npm test                      # Node 222 用例
npm run typecheck             # 7 个 workspace
npm run snapshot:check        # 契约漂移
node scripts/e2e-ui.mjs       # 真实浏览器验收

10. 能力对比再总结

能力 传统招聘网站 本系统 实现位置
简历入库 模板 / 人工录入 八种格式自动解析 + 确定性兜底 M09
数据结构 原文 + 标签 技能等级、经历、项目、可计算快照 M02
关键词找人 包含匹配 语义 + 技能 + 关系 + 规则四路召回 M06/M07
相似推荐 行为协同 以人/以 JD/以技能三种能力相似 M05/M06
岗位匹配 关键词重合 四分量加权 + 校准 + 惩罚项 M05/M07
推荐解释 一个百分比 命中/缺失技能、经验差距、可触达性 M08
模块可替换 单体 契约驱动,每模块可独立启动与测试 M00--M11

一句话概括差异:传统系统在"文本"层面匹配,本系统在"能力"层面匹配,并且让每一步都可验证。


11. 边界与演进

当前明确的限制(写进 README,避免被误当生产系统):

限制 影响 演进方向
数据全内存 重启回种子态、无法横向扩展 接 PostgreSQL,按服务分库;事件改走消息队列
identity 未接入 BFF 无登录态、无数据范围控制 前置鉴权中间件 + 字段掩码落地
岗位主数据无归属模块 job 快照内联/本地投影 岗位模块落地后改 REST 拉取,接口不变
向量默认哈希嵌入 语义质量依赖真实模型接入 EMBEDDING_URL 接模型,接口已隔离
隐私上限由调用方传入 调用方可自行放宽 M03 权限上下文下发该上限
推荐缓存 600 秒 改档案后结果滞后 订阅 person.changed 主动失效
反馈未在线学习 权重需人工校准 样本足够后做离线训练 + 灰度
观测性仅日志 无链路追踪 OpenTelemetry + 请求 ID 贯通

如果重做一次会改的三件事:

  1. LLM 输出用 JSON Schema 强约束,而不是靠提示词"禁止编造"------提示词降低概率,schema 拒绝结果;
  2. .env 加载统一收口到脚本层,避免某条启动路径绕过配置导致静默降级;
  3. 给 degraded 加前端常驻角标------降级必须可见,否则运维不看接口发现不了。

12. 附录

12.1 端口与模块

模块 进程 端口 语言
M00 contracts --- TS
M01 taxonomy-svc 7101 TS / Fastify
M02 profile-svc 7102 TS / Fastify
M03 identity-svc 7103 TS / Fastify
M04 vector-svc 7104 Python / FastAPI
M05 similarity-svc 7105 Python / FastAPI
M06 search-svc 7106 TS / Fastify
M07 recommend-svc 7107 Python / FastAPI
M08 explain-svc 7108 Python / FastAPI
M09 import-svc 7109 TS / Fastify
M10 bff 7100 TS / Fastify
M11 web 5173 React + Vite

12.2 关键参数

参数 值 位置
复合分权重 0.40 / 0.35 / 0.15 / 0.10 − 0.05 similarity-svc/app/weights.py
技能权重 must_have 1.0 / plus 0.6 / soft 0.3 同上
校准 logistic k=6.0, x0=0.55(占位,非训练) 同上
RRF k 60 recommend-svc/app/config.py
召回权重 0.35 / 0.30 / 0.15 / 0.20 同上
召回超时 300 ms 同上
候选上限 / TopN 200 / 20 同上
MMR λ 0.75 同上
同公司 / 同职级 3 / 60%(公司硬约束,职级软约束) 同上
探索位 2(≥ 最高分 50%,不足则空缺) 同上
精算批量 score/batch ≤ 2000 对,1000 对 < 50ms similarity-svc
推荐缓存 600 s(键含岗位快照哈希) recommend-svc/app/config.py
相似搜索综合分 0.85 技能 + 0.10 城市 + 0.05 行业(按可用维度归一) search-svc/src/domain/similarity.ts
深分页上限 page × pageSize ≤ 10000 search-svc
解释批量 /explain ≤ 30 条、/explain/batch ≤ 50 条 explain-svc
单文档文本上限 200,000 字符 import-svc
批量导入 5000 行 / 10 MB,分批 100 行 apps/web / import-svc
导入幂等窗口 24 h(sha256 键) import-svc
LLM 分段 / 输出 24,000 字符 / 2048 token import-svc 环境变量
BFF 分类缓存 60 s bff/src/service.ts

12.3 命令速查

bash 复制代码
npm install && npm run dev:all     # 全量启动(11 进程)
npm run dev:health                 # 状态表:监听 up + 健康 ok
npm run dev:list                   # 各服务真实命令与端口
npm test                           # Node 222 用例
npm run typecheck                  # 7 个 workspace
npm run snapshot:check             # 契约漂移检查
node scripts/e2e-api.mjs           # 接口编排(82 断言)
node scripts/e2e-ui.mjs            # 浏览器验收(34 用例)

12.4 相关文档


文中所有端口、参数、用例数与代码片段均取自仓库当前实现,未做美化。

相关推荐
热心网友俣先生1 小时前
2026年MathorCup大数据竞赛-B题数据集分享
数学建模·1024程序员节
老王IT1 小时前
Rust基础语法介绍
1024程序员节
larance1 小时前
[机器学习40讲]32 - 从有限到无限:高斯过程
1024程序员节
泡海椒1 小时前
JQuick-SQL NoSQL 数据接入:先标准化文档记录,再统一 SQL 查询
1024程序员节
小贺儿开发1 小时前
《长征 · 征途》Unity 三维互动沙盘
科技·3d·unity·1024程序员节·地形·互动·沙盘
让代码飞19261 小时前
DSH 数据目录从 C 盘迁到 其他 盘完整教程
1024程序员节
benchmark_cc1 小时前
量化开发中,什么时候应该停止手动下载股票数据?
python·数据分析·pandas·量化交易·1024程序员节·股票数据·quantdash
passionSnail1 小时前
DeepSeek V4.1 Flash 高效落地实战指南
1024程序员节
泡茶喝茶写代码2 小时前
A股量化策略回测实战:从行情数据到绩效评估(第 15 篇):新高新低策略
python·1024程序员节·股票数据api·股票数据api接口·股票api数据接口·股票量化数据api·股票量化数据接口