很早以前在招聘网站上找工作,简历是一栏一栏填出来的。过了许多年,网站有了"简历模板导入",每次仍要花很多时间:严格按照字段格式,把内容逐项输入到对应的位置,等于换了个界面把简历再抄一遍。当时就想:简历里明明已经写了这些信息,为什么不能一键导入、自动识别关键信息?
这个疑问一直没有像样的答案,根源在于传统平台只在文本 层面处理简历------读出来的是自由文本,没有统一的技能、没有可比的年限,匹配只能退化为关键词包含。这个项目就是冲着这个问题来的:先把任意格式的简历变成结构化的能力档案,再让岗位需求与能力之间做可计算、可解释的匹配。导入只是入口,可组装的架构和四个匹配能力才是主体。
这是一份工程实录,不是方案构想:10 个后端服务 + 1 个 React 前端 + 1 个纯类型契约包,Node 与 Python 混栈,当前阶段没有数据库、没有消息队列,所有业务数据都在进程内存里。本文讲四件事:模块按什么边界拆 、模块之间靠什么对齐 、简历解析 / 人才推荐 / 相似搜索 / 岗位匹配四个能力各自怎么实现 、471 个单元测试和两级端到端验收如何兜底。文中所有端口、参数、代码与测试数字均取自仓库当前实现,已知限制一并列出。
先看运行页面
- 首页

- 相似性搜索

- 推荐工作台

0. 先讲清楚:它和传统招聘网站不是同一类东西
传统招聘网站(以及大多数 HR SaaS 的人才库)本质是一个带筛选的列表页:
- 求职者按平台模板填表单,或上传 PDF 后靠人工补录;
- HR 用关键词 + 下拉筛选找简历,命中的是"文本里有没有出现这个词";
- 推荐是"买过这个岗位的人还看了"这类行为协同;
- 结果是一个排序列表,说不出为什么排第一。
这套系统换了一条路径:先把人变成结构化的能力档案,再让岗位需求与能力之间做可计算的匹配。
| 维度 | 传统招聘网站 | 本系统 |
|---|---|---|
| 建档入口 | 平台模板 / 固定格式简历 | 任意格式文档:docx、PDF、HTML、Markdown、XML、TXT、CSV、XLSX |
| 数据形态 | 简历原文 + 少量标签 | 结构化档案:技能带等级与年限、逐段工作与项目经历、可计算快照 |
| 找人方式 | 关键词包含匹配 | 语义 + 技能 + 关系 + 规则 四路召回后融合 |
| 相似推荐 | 行为协同(看过的人) | 能力相似:以人搜人 / 以 JD 搜人 / 以技能搜人 |
| 匹配依据 | 关键词重合度 | 四分量加权:语义、技能等级、经验、上下文,含惩罚项 |
| 输出 | 一个分数或一个排名 | 分数 + 证据链:命中哪些必备技能、缺什么、差距多少 |
| 数据所有权 | 单体应用内共享读写 | 每个字段只有一个服务可写,其余只读投影 |
下面四个能力是这套系统的立足点,后文逐个展开实现:
- 不限格式导入------HR 手里的简历是什么格式就能传什么,系统自己负责解析建档;
- 人才智能推荐------给一个岗位,召回一批人,并且排得有理有据;
- 相似性搜索------给一个人、一段 JD 或一组技能,找出能力结构相近的人;
- 岗位 × 简历匹配------把"岗位要求"和"人的能力"放在同一个尺度上算分,选出最佳人选。
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 里有三个设计决策值得说:
maxPrivacyLevel放在筛选条件里,而不是调用方参数里------隐私是检索语义的一部分。越权不是"过滤结果",而是"根本不进候选集"。personIds白名单:向量召回只知道 id,需要 search 回表补全快照。没有这个字段,BFF 就得自己查 N 次档案。- 所有数组都有上限 (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 节。
M06 search --- 检索与相似搜索
- 契约 :
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 组装后才出现的能力
单模块测试通过不代表组装正确。真正只有拼起来才成立的能力有三个:
- 导入 → 建档 → 可被检索 :import 解析完写 profile,profile 发
person.changed,search 与 vector 刷新投影。链路断在事件这一步时,表现是"导入成功但搜不到人"。 - 推荐 → 解释 → 展示:recommend 只给 id 和分数,explain 补证据,BFF 再回表补快照。三者缺一,HR 看到的就是裸分数。
- 相似搜索 → 分面筛选:相似度给"像不像",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 贯通 |
如果重做一次会改的三件事:
- LLM 输出用 JSON Schema 强约束,而不是靠提示词"禁止编造"------提示词降低概率,schema 拒绝结果;
.env加载统一收口到脚本层,避免某条启动路径绕过配置导致静默降级;- 给
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 相关文档
文中所有端口、参数、用例数与代码片段均取自仓库当前实现,未做美化。