文章指出「每公司一行」schema 会丢失内部晋升记录,建议用「每角色一行」+共享雇主字段设计,可保留完整信息且避免歧义。
一份简历没有固定的模式、没有权威依据、也没有校验位。它有的只是一组一致的结构,这些结构会破坏那种简单的"雇主—职位—开始—结束"式记录,而所有这些问题都可以在写 prompt 之前就设计掉,而不是事后打补丁。
最容易想到的记录方式是一条记录对应一家公司——但在"每公司一条"和"每职位一条"之间的选择,其实是"多实体文档模式设计"中的通用问题。这种选择在一个最常见的简历模式上必败:内部晋升,写成一个区块,雇主名只出现一次,下面嵌套两三个职位,各自带有独立的时间段:
Borealis Logistics Apr 2022 - Present
Senior Platform Engineer Feb 2024 - Present
Platform Engineer Apr 2022 - Feb 2024
如果用公司级模式,你必须选一个职位名和一个时间段,无论选哪个都丢弃了真实信息。如果用职位级模式,这就是两条记录共享一个雇主值,不需要丢弃任何东西。外层的时间范围由此变成可推导的——它就是各职位的并集——而不是模型需要调和的第四个东西。
同样的选择还能免费处理其他三种情况。一个承包商通过一家中介在四个客户那里工作,就是四条职位记录,中介放在单独字段里。一个人在从事日常工作的同时兼任顾问,就是时间段重叠的两条职位记录,这是合法的,不是错误。复聘——相隔多年在同一公司的两段任职——是两条职位记录,而不是一个吞掉中间时间空白的单一时间段。
最后这种情况的意义比看起来更大。复聘的公司级记录会覆盖整个时间段,包括当事人在别处的那些年份,任何基于此计算在职时长或空档期的下游逻辑都会出错,而且这种错误不会被任何人发现。具体可参见从简历日期范围提取就业空档期,看看这种计算究竟需要什么。
三个独立的问题被混为一谈成了"解析日期",每个都需要在模式设计时单独做决定。
精度在同一份文档内就有差异。2019 年 3 月、03/2019、2019、2019 年春都会出现,有时就在同一份简历的相邻行上。把它们全部存成完整的 ISO 日期,迫使模型编造一个具体日期,而一旦进入数据库,这个编造的日期就和一个真实的日期无法区分。要以原始精度存储数值,同时存储精度本身,这样任何使用方都知道 2019-03-01 是代表 3 月 1 日,还是只表示 3 月。
结束日期往往是开放的。Present、Current、Now、to date,以及破折号后什么都没有,都表示同一件事。不要让模型用今天的日期来替代:那样的话这条记录的语义会根据提取时间悄然变化,两年前写的简历提取后会显示当事人仍然在职。把它建模为一个显式的空结束,加一个布尔标志,如果确实需要闭区间来做计算,就在提取日期处关闭,并记录那个日期。
数字日期按地区有歧义。04/07/2021 在美式格式的简历里是 4 月,在世界大多数地方是 7 月,token 本身无法告诉你答案。唯一可靠的消歧手段是同一份文档内的上下文:如果简历上任何日期的第一个组成部分大于 12,那么整个文档就是"日在前",其余日期都可以依此处理。如果没有任何日期能消歧,就记录这个歧义而不是猜测——起始日期三个月的小错误,恰好是会悄悄破坏在职时长计算的那种错误量级。
const ROLE_SCHEMA = {
type: "object",
additionalProperties: false,
required: ["employer", "title", "start", "end", "raw"],
properties: {
employer: { type: "string" },
employer_via: { type: ["string", "null"] }, // agency or umbrella company
title: { type: "string" },
employment_type: {
enum: ["full_time", "part_time", "contract", "internship",
"freelance", "volunteer", "unknown"],
},
location: { type: ["string", "null"] },
start: {
type: "object",
additionalProperties: false,
required: ["value", "precision"],
properties: {
value: { type: "string" }, // "2022-04" or "2022"
precision: { enum: ["day", "month", "year"] },
},
},
end: {
type: ["object", "null"], // null means still in role
additionalProperties: false,
required: ["value", "precision"],
properties: {
value: { type: "string" },
precision: { enum: ["day", "month", "year"] },
},
},
is_current: { type: "boolean" },
date_order_ambiguous: { type: "boolean" },
raw: { type: "string" }, // the block as it appeared
},
};
employer_via 这个字段之所以必要,是因为没有它就无法代表性用工场景:简历上写的是"Ravensworth Consulting(客户:Northgate Bank)",而单一雇主字段必然要丢失其中之一。raw 这个字段在所有这类场景中都有必要——原因是一样的——它是当 prompt 或模型发生变化时用来做 diff 的依据,而这就是提取模型版本审计追踪的意义所在;也是审核人员不需要重新打开 PDF 就能阅读的内容。
两条指令就能完成大部分工作,而且两条都是关于"禁止主动帮忙"而不是"请求帮忙"。
提取每一条独立的职位。一条职位是指在同一个雇主下、
同一个连续时间段内的唯一一个职位名称。
- 如果一个雇主区块下列出了多个各有时间段的头衔,
每条头衔输出一条职位。不要为雇主区块本身输出一条职位。
- 按原文复制日期。不要把月份转换成具体某一天,
不要推断缺失的月份,也不要把 "Present" 替换成日期。
如果结束日期缺失或开放,将 end 设为 null,
并将 is_current 设为 true。
- 如果一个数字日期既可能是日在前也可能是月在前,
且文档中没有其他日期可以消歧,
将 date_order_ambiguous 设为 true。
- raw 必须是原文区块的完整文本,包括换行符。
不转换精度的指令是最容易被遗漏的一条,也是对输出影响最大的一条。没有这条指令,模型每次遇到"2019 年 3 月"都会返回 2019-03-01,而这个编造的日期就变成了系统中的一个既定事实。这是提取类 prompt 的一般特性,而非简历特有的问题——提取类 prompt 中有详细讨论。
简历在大多数隐私法规下属于个人数据,发送给第三方模型的简历等于数据离开了你的基础设施。这是否合法取决于你的处理依据以及你与提供商的合同,而非提取过程本身;相关义务见 GDPR 分处理商清单。本文不构成法律建议。
简历没有外部权威可以验证,但提取结果可以通过五种方式做内部校验,而且五种都很廉价:
每条职位的 start 必然早于 end。时间范围颠倒几乎总是模型把右对齐日期布局中的两列互换了一下,这是最常见的结构性错误。
没有任何日期在未来,除了标记为当前职位的 end(本来也应该是 null)。未来的 start 日期通常意味着两位年份被扩展到了错误的世纪。
每个雇主最多只有一条标记为 current 的职位,除非简历中确实显示了同时任职。同时存在于同一雇主的两个 current 职位,意味着一个晋升区块被错误地拆分开了。
每条 raw 值都是源文本经空白符规范化后的子串。这是对非结构化文档可用的最强校验:如果找不到 raw 区块在输入中的位置,说明是模型自己编造的,那么从中解析出的所有内容都值得怀疑。
各职位时间范围的并集不超过从最早教育日期起的合理工作年限。这能捕捉到世纪错误,虽然只能抓到这一种,但它只花费一次比较。
子串校验值得优先实现。它把"模型在编造内容"从人工审核时才发现的问题,变成了管道在每条记录上都能检测到的异常,而且不需要 ground truth、不需要标签、不需要第二次模型调用。
简历解析是一种按文档选择模型而非按任务选择模型的工作负载:纯文本简历需要一个小型廉价模型,两栏设计的 PDF 需要视觉能力强的模型,而且不同提供商之间的每页价格差异很大。按文档通过单一 API 路由,意味着批处理驱动选择的是层级而非供应商,而按请求的成本归因可以在事后告诉你 корпус 中实际花费最多的那十分之一是哪些文档。
从简历日期范围提取就业空档期
从 CV 逐个提取教育经历
从简历提取技能与资质