ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

freehire 德语 IT 职位标题分类别名设计:融合复合词、词边界匹配与检索可见性

freehire 德语 IT 职位标题分类别名设计:融合复合词、词边界匹配与检索可见性

【免费下载链接】freehire

freehire — the open-source search engine for job seekers

项目地址:https://gitcode.com/GitHub_Trending/fr/freehire
点击查看免费下载

本文以 add-german-it-classify-aliases 设计文档 为主线,拆解 freehire 如何在确定性的标题→职业类别(jobs.category)词典中补齐德语 IT 职位标题的「融合复合词」(fused compound)别名。读完后你将掌握:词边界匹配为何天生看不见Systemadministrator这类德语拼写、这一缺口如何经由search.CategoryUnresolved与is_tech三态派生把约 400 条真实职位悄悄排除出搜索索引,以及「裸别名 vs 限定别名 vs 排序」三种设计取舍的判定标准与验证方法。

一、问题背景:当词典对德语复合词完全无词可说

分类器的行为在 internal/dict/classify/dictionaries.go 与 internal/dict/classify/classify.go 中被明确约束:它是一份人工策展词典,而不是模型,只解析已知别名,解析不出来就输出空值,绝不猜测。类别解析的入口是matchCategory:

func matchCategory(lower string) string { lower = ptGenderSuffix.Replace(lower) if c := matchOrdered(lower, categoryTable); c != categoryNone { return c } return "" }

而matchOrdered只是按优先级顺序遍历[]aliasEntry表,返回第一个整词命中的别名所对应的规范值:

func matchOrdered(title string, table []aliasEntry) string { for _, e := range table { if wordmatch.Contains(title, e.alias, wordmatch.UnicodeBoundary) { return e.canonical } } return "" }

这里的「整词」由 internal/dict/wordmatch/wordmatch.go 的UnicodeBoundary定义:命中区间的左右两侧都必须是字符串边界,或者不是 Unicode 字母/数字的字符。

func UnicodeBoundary(s string, start, end int) bool { if start > 0 { if r, _ := utf8.DecodeLastRuneInString(s[:start]); isWordRune(r) { return false } } if end < len(s) { if r, _ := utf8.DecodeRuneInString(s[end:]); isWordRune(r) { return false } } return true }

这带来两个直接结论,也正是设计文档 Context 一节的核心论断:

  1. 连字符和空格本身是合法的边界字符,所以一条裸的复合词别名已经能覆盖该词的IT-前缀或IT前缀拼写,无需另立条目。俄语разработчик/программист与既有德语entwickler簇都建立在这一特性上。
  2. 如果复合词本身的拼接方式不同,词边界匹配无能为力。softwareentwickler、software-entwickler、software entwickler对strings.Index来说是三个不同子串,因此必须写成三条别名。

德语的麻烦在于第 2 类:它把标题中的角色词直接粘成一个不间断的词(Systemadministrator、Fachinformatiker),词典里既然没有融合形式的别名,匹配器就永远看不见——这与「连字符俄语标题」「空格英文标题」被正常解析的情形形成鲜明对照。

设计文档还记录了一个关键的事实来源:受影响标题压倒性地来自单一来源arbeitsagentur(德国联邦就业局),这与该来源的性质完全吻合,是对本地数据库核查后得出的结论,而非推测。

二、下游后果:不是「少一个标签」,而是「从搜索里消失」

这份设计之所以值得单独成篇,是因为类别字段不只是展示用的元数据。设计文档在 Goals 中强调:新增别名让标题解析出category,这既是CategoryUnresolved读取的对象,也是is_tech派生的输入(tech-classification 的「已识别技术类别 → true」规则)——无需单独修改tech.go。

检出逻辑在 internal/search/search/document.go:

func CategoryUnresolved(j db.Job) bool { if j.Category != "" || j.Source == job.EmployerSource { return false } if j.IsTech.Valid && j.IsTech.Bool { return false } if roletype.Derive(j.Title) != "" || classify.NamesManager(j.Title) { return false } var e enrich.Enrichment if len(j.Enrichment) > 0 { _ = json.Unmarshal(j.Enrichment, &e) } return e.Category == "" || e.Category == "other" }

也就是说,只要category为空、is_tech未被确认为真、角色类型与管理岗标记都未命中,职位就会被判定为「类别未解析」。而在 cmd/embed / 搜索索引构建路径中,这类职位不会进入 Meilisearch。这就是提案文档 proposal.md 中「Why」一节所描述的现象:在本地开发目录库做一次核查,仅arbeitsagentur一家就有约 400 条开放职位——普通的 IT 系统管理员、技术员、测试员标题——因此原因被静默排除在搜索之外。

而is_tech的派生是三态的(true / false / nil),见 internal/job/jobderive/jobderive.go:

func deriveIsTech(category, title string, sourceHint bool) *bool { if sourceHint || classify.IsTech(title) { t := true return &t } nonTech := slices.Contains(vocab.NonTechCategories, category) || classify.IsNonTech(title) if slices.Contains(vocab.TechCategories, category) && !nonTech { t := true return &t } if nonTech { f := false return &f } return nil }

一旦类别解析为devops、network_engineering、qa、software_engineering这类vocab.TechCategories成员,is_tech就会被推为true,CategoryUnresolved随即返回 false。这解释了设计文档「Rejected 方案」之外的另一个判断:不必触碰只做is_tech判定的tech.go,因为类别已能翻转三态。

三、目标与非目标

设计文档把边界划得很清楚,这一节完整继承其表述。

目标:

  • 补齐缺失的德语融合复合词别名,使受影响标题解析出category——这既是CategoryUnresolved的读取对象,也是is_tech派生的输入,无需单独改is_tech/tech.go。
  • 每条新别名都必须有本地数据库中真实标题作为证据(见 proposal.md),不得靠猜,与仓库AGENTS.md中「Dictionaries: ... never guess, emit nothing for unknowns」的原则一致。

非目标:

  • 不把Fachinformatiker拆分到所有可能的限定方向(Daten- und Prozessanalyse、Digitale Vernetzung、支持类岗位等)。只有两个占主导且无歧义的赛道(Systemintegration / Anwendungsentwicklung)获得专属类别,其余措辞一律落回裸别名的devops默认值。这是为搜索可见性牺牲一部分类别精度的取舍——同一个取舍,it-title-coverage规范自己在把有歧义的通用 IT 标题「IT Specialist」「IT Technician」归入support时已经做过。
  • 不回溯重分类既有行。词典条目只影响之后的摄取(ingest)以及下一次backfill-derive运行;跑这个 backfill 并执行随后的make reindex是部署期的运维动作(见 proposal.md 的 Impact),不属于本次变更的任务。
  • 不修改internal/dict/classify/tech.go(独立的纯is_tech检测器)——没必要,因为已解析的类别通过既有三态派生规则就能把is_tech翻成 true。

四、五项设计决策与取舍依据

4.1 六个无歧义词使用裸别名

systemadministrator、netzwerkadministrator、datenbankadministrator、netzwerktechniker、softwaretester、anwendungsentwickler六个词采用裸别名。理由是被本地库中抽样到的该词全部标题都确属 IT 工作,不像「Systems Engineer」或「Systemtechniker」存在跨领域同名,因此裸别名是安全的,并且与文件中其他无歧义的德语/俄语裸 token 惯例一致。

被否决的替代方案是:为了与Systemtechniker/Systemelektroniker对称,给它们统一加IT/system前缀。否决理由是——这会静默排除大量不带任何前缀的真实抽样标题(例如裸的Datenbankadministrator (m/w/d)),而真实数据并未显示存在这种风险。

4.2Systemtechniker/Systemelektroniker只接受限定形式

数据库样本中包含非 IT 用法:「Systemtechniker Elektrotechnik」「Systemtechniker Sicherheitstechnik」。所以只有IT限定的写法(连字符形式与空格形式各算一条独立别名)才会被解析,裸词保持未解析。这与it-title-coverage中「Systems Engineer」家族对非 IT 同名词的盲化处理一脉相承。

4.3Fachinformatiker:限定短语声明在裸回退之前

遵循文件已有的「更具体的别名在前」排序规则(与1С/аналитик 1С的先例相同)。限定短语只在限定词紧邻该词时命中(中间没有für、/in、m/w/d等打断);限定词不相邻的标题会落回裸别名,归类为devops(样本中数量占主导的赛道)。

4.4SPS-Programmierer被有意排除

SPS-Programmierer早已是一处有记录的、刻意的排除项(工业/PLC 编程,与「CNC Programmer」同理)。给它加别名会与既有的、有证据支撑的意图相冲突,因此本次变更不碰它。

4.5 在dictionaries.go中的放置位置

新块直接放在俄语裸 token 簇(программист/разработчик)之后、「The Russian engineering family」小节之前。理由正是那个簇自己的注释所给出的:裸 token 能覆盖所有连字符/空格前缀拼写,而这套推理同样适用于德语融合复合词。因此新块紧邻它最接近的教义先例,而不是散落在文件更早处的英文administrator/technician/tester条目旁。同时用一小段注释记录边界匹配的理据(裸 vs 限定),以匹配该文件其他位置已有的文档密度。

五、落地实现:别名表与测试的双向印证

最终落地的词典块位于 internal/dict/classify/dictionaries.go,紧接俄语裸 token 簇之后:

// German administration/technician/tester/developer fused compounds. German // joins a title's role words into one unbroken word with no separator, so // none of these can be reached by the spaced English alias they otherwise // match — same doctrine as the "разработчик" bare tokens above, except a // German compound has no internal separator at all, so the alias must be // the fused form itself rather than relying on a hyphen/space boundary // inside it. A hyphen or space BEFORE the compound is still a boundary, // so each bare alias below already reaches an "IT-"/"IT "-prefixed title // without a separate entry. {"systemadministrator", "devops"}, {"netzwerkadministrator", "network_engineering"}, {"datenbankadministrator", "devops"}, {"netzwerktechniker", "network_engineering"}, {"softwaretester", "qa"}, {"anwendungsentwickler", "software_engineering"},

随后是限定形式块,注释点名了造成歧义的非 IT 同名用法,并说明「连字符形式与空格形式对匹配器是两个不同字符串,因此各需一条」:

{"it systemtechniker", "devops"}, {"it-systemtechniker", "devops"}, {"it systemelektroniker", "devops"}, {"it-systemelektroniker", "devops"},

最后是Fachinformatiker家族,两条限定短语成对声明在裸回退之前:

{"fachinformatiker systemintegration", "devops"}, {"fachinformatiker für systemintegration", "devops"}, {"fachinformatiker anwendungsentwicklung", "software_engineering"}, {"fachinformatiker für anwendungsentwicklung", "software_engineering"}, {"fachinformatiker", "devops"},

这里有一个容易忽略的细节:aliasEntry的规范值必须落在受控词表内,因为类别集与is_tech派生共享同一套枚举。参见 internal/dict/vocab/vocab.go:CategoryValues、TechCategories、NonTechCategories与残差{"other"}必须精确划分全集(由 internal/dict/vocab/vocab_test.go 断言)。本次新增别名映射到的devops、network_engineering、qa、software_engineering都是既有类别,所以没有引入新类别,也就不需要动词表与生成的契约。

回归覆盖写在 internal/dict/classify/classify_test.go,每条用例都带一句「为什么」:

标题期望类别设计意图
Systemadministrator (m/w/d)devops裸融合形式
IT-Systemadministrator (m/w/d)devops连字符是词边界,裸别名即可覆盖
IT Systemadministrator (m/w/d)devops空格同样是词边界
Netzwerkadministrator (m/w/d)network_engineering与network administrator对齐
Datenbankadministrator (m/w/d)devops与database administrator对齐
Netzwerktechniker (m/w/d)/IT-Netzwerktechniker (m/w/d)network_engineering裸 + 前缀两种拼写
Softwaretester (m/w/d)qa裸tester看不见融合形式
Anwendungsentwickler (m/w/d)/Inhouse Anwendungsentwickler (m/w/d)software_engineering加入既有entwickler簇
IT Systemtechniker/IT-Systemtechniker/IT Systemelektroniker/IT-Systemelektronikerdevops仅限定形式
Systemtechniker (m/w/d) Elektrotechnik无电气/控制,非 IT
Systemtechniker Sicherheitstechnik (m/w/d)无实体安防系统,非 IT
Fachinformatiker (m/w/d)devops裸回退,从不指非 IT 岗位
Fachinformatiker Systemintegration (m/w/d)/... für Systemintegration ...devops限定短语(含für变体)
Fachinformatiker Anwendungsentwicklung (m/w/d)/... für Anwendungsentwicklung ...software_engineering开发赛道
SPS-Programmierer (m/w/d)无工业/PLC,与 CNC Programmer 同理

注意Systemtechniker/Systemelektroniker的「裸词返回空」用例不仅仅是不写条目那么简单:由于matchOrdered是按序首个命中即返回,只要表中任何位置存在一个更宽泛的别名可以命中,裸词就会「漏」成某个类别。这也是为什么「盲化 / 不声明」必须与「排序」联合考虑——设计文档在 Placement 一节特意把新块放在俄语簇旁,正是为了避免与文件更早的英文administrator/technician/tester条目产生优先级纠缠。

六、风险与权衡

设计文档列了三条风险,每条都给出了缓解手段,值得作为后续同类扩展的模板:

  • [风险] 某个德语标题以本设计未抽样的语义使用Fachinformatiker/Systemadministrator等词,可能被错误打标。缓解:每一条新裸别名在被接受为「无歧义」之前,都对照了本地库中该词的全量去重标题列表(而不只是 Top-N 抽样);两个真正暴露歧义的词被限制为仅限定形式,而不是裸别名。

  • [风险]Fachinformatiker的裸回退会把一些 Anwendungsentwicklung 赛道职位归为devops,因为限定词不与词相邻(例如Fachinformatiker (m/w/d) für Anwendungsentwicklung——für打断了双词短语匹配)。 接受的取舍:职位仍然被解析、仍可被搜索到(这才是本次变更的真实目标),只是类别比理想情况更粗;这一点在非目标中明确记录,而不是靠枚举一堆「包含 für 的短语变体」去追。

  • [风险] 变更前已摄取的存量开放职位携带过期的空category。不是本次变更的修复范围;已作为必需的后续运维步骤记录在 proposal.md 的 Impact 中。

七、迁移与运行:没有 schema 变更,只有一次运维补齐

设计文档的 Migration Plan 只有两句话,但每一句都关键:

没有 schema 或数据迁移。部署就是一次常规代码发布;随后的cmd/backfill-derive+make reindex运维轮次(记录在 proposal.md,任务跟踪在本次变更之外)才是触达既有行的手段。

具体到命令层面,仓库提供了现成的入口:

# 1) 重算所有由 jobderive.Derive 从原始字段纯函数推导出的列 go run ./cmd/backfill-derive # 2) 全量重建 Meilisearch 索引 make reindex

make reindex的目标定义在 Makefile,实际执行go run ./cmd/reindex。backfill-derive的并发与范围由环境变量控制,读取位置见 cmd/backfill-derive/main.go:

环境变量作用备注(源自源码注释)
BACKFILL_CONCURRENCY每行派生的 worker 池大小默认 1;设为 6 曾使生产环境退化,建议维持 2–3
BACKFILL_DERIVE_MAX本次最多处理的行数默认 0,表示不设上限
BACKFILL_DERIVE_FROM_ID从指定 id 起继续(断点续跑)默认 0;中断后可从日志给出的续跑点继续
BACKFILL_DERIVE_CLOSED_WITHIN_DAYS限制只处理最近 N 天内关闭的职位默认 0

backfill-derive的语义值得强调两点:它是幂等的(每列都是原始字段的纯函数,第二次运行不会改写任何东西),并且它不会触碰public_slug(那是插入时铸造一次的已发布 URL,重算会让所有指向该职位的链接 404)。此外它对work_mode采取保留策略,但对 seniority / category / skills 等字段是覆盖式重算——这正是「传播词典变更」所需要的。

一个补充视角来自 internal/dict/classify/AGENTS.md:开放职位会在下一次抓取时自我修正,因为job.New每次摄取都跑jobderive.Derive,而is_tech是RefreshUnchangedJob的匹配键之一;backfill-derive真正不可替代的价值在于那些不会再被抓取的行——首当其冲是已关闭职位,它们不出现在任何列表里。文档还记录了该命令在生产上的量级(约 15 小时,并发需压低)以及is_tech不在content_hash中、因此单纯增量推送无法覆盖这些行的事实。

八、验证方式与本次变更的交付清单

按 tasks.md,工作被拆成五组,并全部勾选完成:

  1. 裸别名:先写失败测试(管理员/技术员/测试员/开发者四类),再加入六条裸别名,最后跑go test ./internal/dict/classify/...确认新用例通过且无回归。
  2. 限定形式的 Systemtechniker / Systemelektroniker:先加四条限定拼写的失败测试,再加一条回归测试断言Systemtechniker (m/w/d) Elektrotechnik与Systemtechniker Sicherheitstechnik (m/w/d)不解析出类别,然后才写词典条目。
  3. Fachinformatiker 家族:裸词 →devops,Systemintegration 两种写法 →devops,Anwendungsentwicklung 两种写法 →software_engineering;限定短语对声明在裸回退之前。
  4. 非目标回归护栏:断言SPS-Programmierer (m/w/d)仍然不解析出类别。
  5. 收尾:gofmt -l .、go vet ./...、go test ./...全绿(唯一例外是一个既有的、与本 diff 无关的失败:cmd/billing-sync.TestTheStoreProviderAloneKeepsTheWorkerRunning,在未修改的检出上同样失败);并完成一轮针对整个 diff 的评审,评审者对照真实的wordmatch/classify代码验证了边界匹配与排序主张,未发现功能性问题。

可复现的最小验证命令:

go test ./internal/dict/classify/...

九、把这份设计抽象成可复用的检查清单

当你要为另一种语言补同类别名时,这份文档实际上给出了一套可执行的判定流程:

  1. 先确认该语言是否把角色词融合成一个不可分词的复合词。若是(德语Systemadministrator),必须写出融合形式本身;若否(俄语把技术名放前面并用连字符,Java-разработчик),则裸 token 已能覆盖所有拼写。
  2. 不要为「前缀」重复造条目。连字符与空格本身是词边界,裸复合词别名自动覆盖IT-/IT前缀形式——测试里那两行IT-Systemadministrator/IT Systemadministrator就是在钉住这个行为。
  3. 连字符形式与空格形式是两条不同别名。it-systemtechniker与it systemtechniker对strings.Index是不同子串,必须各写一条。
  4. 用真实数据判定「裸」还是「仅限定」。样本中出现跨领域同名(Systemtechniker Elektrotechnik、Systemtechniker Sicherheitstechnik)就退到限定形式;样本全为 IT 才能用裸别名。判定必须针对全量去重标题,而不是 Top-N。
  5. 限定短语必须排在其裸回退之前,但要对「限定词不相邻」的标题接受一个更粗的类别——这是可见性与精度之间的显式取舍,应写进非目标而不是靠枚举变体去追。
  6. 明确写下不做什么。SPS-Programmierer这类既有排除项必须有用例守住,否则下一次「顺手补全」就会把它扫进软件类别。
  7. 记住词典变更不会自动触达存量行:go run ./cmd/backfill-derive加make reindex才是让旧职位重新可被搜索的最后一步。

围绕这几个环节,设计文档给出的不是一次性的补丁说明,而是一套关于「确定性词典如何处理形态学差异」的可迁移方法论:匹配语义由wordmatch.UnicodeBoundary决定,优先级由表顺序编码,歧义由「盲化(不声明)」表达,而所有判断都必须有真实标题作为证据。

【免费下载链接】freehire

freehire — the open-source search engine for job seekers

项目地址:https://gitcode.com/GitHub_Trending/fr/freehire
点击查看免费下载
上一篇:3dsconv终极指南:5分钟学会3DS游戏格式转换的完整教程
下一篇:Excel/WPS 自定义函数查 A 股行情:3 步装好 STOCK_DATA JS 宏, 本地量化数据即查即用

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

返回列表