☰
同一职位发在 3 个平台 = 1 条记录:freehire 归一化与稳定键去重设计详解
2026/10/22 7:33:22 网站建设 项目流程

同一职位发在 3 个平台 = 1 条记录:freehire 归一化与稳定键去重设计详解

【免费下载链接】freehirefreehire — the open-source search engine for job seekers项目地址: https://gitcode.com/GitHub_Trending/fr/freehire

freehire 是一个开源的职位搜索引擎,它把来自数百个招聘平台的职位归一化为统一结构,并用稳定键去重机制让同一个职位无论被发到多少个平台、多少个城市,最终都收敛为搜索结果中的1 条记录。本文将拆解它背后的三层去重设计:唯一键、内容指纹与模糊匹配,以及为什么"标记"而不是"删除"是关键抉择。

为什么"同一职位发 3 次"是个问题

如果你用过任何招聘网站,都会遇到这样的场景:同一份"Senior Backend Engineer",公司官网发一份、ATS 平台发一份、聚合站再转手发三份。对搜索引擎来说,这意味着:

  • 搜索被灌水:用户搜一次,同一份工作出现 5 次;
  • 统计失真:热门岗位和冷门岗位无法区分;
  • 索引膨胀:重复文档占用宝贵的检索资源。

freehire 面对的是真实数据规模——数千万条职位记录。它的解法不是简单的 URL 比对,而是一套分层的归一化 + 去重管线,整体流程为:

抓取(fetch)→ 归一化(normalize)→ 去重(dedup)→ 写入(upsert)

第一层:归一化——把 N 种格式变成 1 种结构

不同 ATS 平台(Workday、Greenhouse、Lever、Ashby……)返回的字段名、格式、单位五花八门。freehire 为每个平台实现了独立的适配器(adapter),位于internal/ingest/sources/,所有适配器都实现同一个Source接口,把平台原始数据转换进同一套职位 schema。

归一化完成后,写入路径只认一种"语言"。这一步的意义在于:去重逻辑不需要关心职位来自哪个平台,只处理统一结构即可。相关约定见 internal/ingest/pipeline/AGENTS.md。

第二层:稳定键去重——UNIQUE(source, external_id)

归一化解决"长得一样",但还要解决"是同一个"。最可靠的身份标识是来源 + 外部 ID:

  • source:这个职位来自哪个平台;
  • external_id:该平台给这条职位分配的唯一编号。

数据库上为jobs表建了UNIQUE (source, external_id)唯一约束,所有写入都走ON CONFLICT的 upsert。这带来一个极其重要的性质:重复爬取是免费的。同一个职位爬 100 次,数据库里永远只有一行,重复请求只是刷新last_seen_at时间戳。

这个唯一键就是所谓的"稳定键"(stable key)——它不随时间、URL 变化或页面刷新而改变,是整个去重体系的基石。

第三层:role_fingerprint——识别"换了马甲"的同一岗位

稳定键只能识别"同一来源的同一职位",但解决不了更隐蔽的情况:

  • 同一岗位发到不同平台,external_id 完全不同;
  • 同一公司在30 个城市各发一份同样的职位;
  • 职位撤下后用新 ID重新发布(repost)。

为此,freehire 引入了role_fingerprint(角色指纹),定义见 migrations/0003_role_fingerprint.sql:

指纹包含指纹刻意排除
公司标识posted_at(发布日期)
归一化后的职位名称职位 URL
归一化后的职位描述public_slug

排除易变字段是精髓:只要公司+职位名+描述实质相同,无论 ID、日期、链接怎么变,都会落到同一个指纹上。所有共享(company_slug, role_fingerprint)的职位构成一个"角色集群",集群中min(id)的那条行被选为规范记录(canonical),其余行打上duplicate_of标记指向它。这一设计见 migrations/0012_job_duplicate_of.sql。

注意这里没有删除任何行。原因很实际:repost 计数、批量发布信号等数据恰恰依赖"行数"本身;而列表页和搜索索引会排除带标记的行,详情接口仍可访问被标记的职位。折叠发生在读取时,而非物理删除。

标记分治:三个去重通道,各管一列

早期三个去重通道(角色指纹、聚合站压制、模糊匹配)共用duplicate_of一列,会互相"擦掉"对方的标记,导致每天数百万行无谓的重复写入,且两个通道之间存在长达一小时的"重复窗口"。

重构方案见 migrations/0114_jobs_duplicate_marker_owner_columns.sql 与 migrations/0115_jobs_derive_duplicate_of.sql:

  1. 每个通道独占一列:duplicate_of_aggregator、duplicate_of_role、duplicate_of_fuzzy,一个通道只能清除自己设的标记;
  2. duplicate_of改为触发器派生:
NEW.duplicate_of := COALESCE( NEW.duplicate_of_aggregator, NEW.duplicate_of_role, NEW.duplicate_of_fuzzy );

优先级顺序为聚合站 → 角色 → 模糊,它精确复现了线上"哪个通道赢下争议行"的现状,避免重构偷偷改变行为。其中模糊去重通道按描述文本的词重叠度(阈值 0.9)聚类,实现见 cmd/reindex/fuzzy.go。

写入时的同步去重:不让重复"先上车"

批量重算每几小时跑一次,但爬虫是持续写入的——两条时间线之间,同一岗位的多份副本会短暂地都以"规范记录"的身份出现在搜索里。

internal/job/jobdedup/包在写入事务内同步回答"这个岗位是否已有更老的规范记录",规则见 internal/job/jobdedup/jobdedup.go。两条设计约束值得留意:

  • 只认更老的规范记录:批量重算选min(id),同步通道若把新行指向上"更新"的兄弟行,下次重算就会把它反转,等于自我打架;
  • 失败即放行:查询出错时该行照常以无标记写入——去重是优化,绝不能成为职位丢失的条件。

被同步标记的行不入搜索索引、不入 LLM 富化队列,从源头省掉了下游成本。

用户视角:1 条记录如何呈现

最终效果就是你在 freehire 界面上看到的:左侧按地区、工作方式、专业领域、职级筛选,右侧是去重后的职位卡片——同一公司同一岗位不会出现多张卡片:

还有一个容易被忽略的细节:当用户从某个聚合站复制一条职位链接过来查询时,系统会通过normalize_job_url归一化 URL(剥离协议、www.、追踪参数、锚点、尾部斜杠)再匹配,见 migrations/0042_jobs_normalized_url_idx.sql。即使用户站在那条职位是"重复副本"的页面上,系统也会回答它对应的那条规范记录——"我知道这个职位",而不是因为去重偏好它的孪生体就答"没有"。

小结

freehire 的"3 平台 1 条记录"并非一技之长,而是一套分层设计:

层级机制解决的问题
归一化适配器 + 统一 schemaN 种平台格式 → 1 种结构
稳定键UNIQUE(source, external_id)重复爬取幂等
角色指纹role_fingerprint+min(id)规范记录跨平台、跨城市、repost 折叠
标记分治三列 + 触发器派生多通道互不踩踏
同步去重写事务内CanonicalForRole缩短重复可见窗口

加上"标记不删除"的读取时折叠哲学,这套设计在保持数据完整性的同时,把数千万行目录收敛为一份干净的搜索结果。对想自建搜索系统、内容聚合或任何"多源数据去重"项目的读者来说,这五个决策点都极具参考价值。

【免费下载链接】freehirefreehire — the open-source search engine for job seekers项目地址: https://gitcode.com/GitHub_Trending/fr/freehire

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询