每个职位一个还是两个嵌入?在Spring Boot招聘门户中评测语义搜索
这个求职门户起初只是一个练习项目。我大学毕业后做了它,主要是为了能在简历上写点什么。最近我又把它捡了起来。当时我自己也在找工作、想跳槽,突然觉得应该认真把它做起来,说不定哪天还能上线。
最让我在意的是搜索。结果就是不够好,于是我开始研究怎么把它做得更好。
还有另一个原因。我用 Java 很久了,也很喜欢它。但一提到 AI,大家似乎都会去用 Python。我觉得未必非得这样。而且外面还有大量老的、遗留的 Java 系统,不可能简单用另一种语言重写。如果它们要获得 AI 功能,这些功能就得能在 Java 里工作。所以我决定自己试试,只用 Spring Boot,看能做到什么程度。
问题出在这里。搜索框以前用的是 SQL LIKE。只要你输入的和招聘者写的一模一样,它就能用。搜 "Java developer",你会得到所有包含 "Java developer" 的职位。搜 "SDE working on Java services",你什么也得不到,而数据库里明明全是 Java 后端职位。
嵌入向量显然是解决办法,所以我加上了它们。但我没想到,自己很快就卡在一个教程不太花时间讲的问题上:到底应该嵌入什么文本?
一则职位发布不是单一的东西。它有标题、一段公司宣传、一份职责列表,中间某处还有一份他们真正想从你这里得到什么的列表。我决定不了,于是两个都做:每个职位两个向量。然后我搭了一个小型评估,想看看这是聪明,还是只是把工作量翻倍。
简短版:对于我实际发布的搜索模式来说,它几乎没什么影响。更长的版本,以及一个真正让我意外的数字,见下文。
配置如下,万一你在意:Java 17 上的 Spring Boot 3.5,数据库用 TiDB(它兼容 MySQL,并且有原生 VECTOR 类型),Google 的 gemini-embedding-001,768 维。所有代码都在 GitHub 上。
描述同一份工作的两种方式
两种策略都会从职位实体构建一小段带标签的文本。我觉得标签本身不太重要。重要的是每次都是相同字段、相同顺序。
job_full_v1 取所有内容:标题、雇佣类型、工作模式、经验范围,以及整段描述作为纯文本。
job_req_v1 保留标题和经验范围,然后只保留描述中看起来像要求的部分。找这些部分比我想的烦人,因为没人用同样的方式格式化职位描述。有些人用真正的 h2 标签。大多数人只是把一行加粗,或者以冒号结尾,用的还是各自不同的富文本编辑器。所以用 jsoup 时,我把这三种都当成标题,并保留匹配下面这个模式的标题下面的内容:
如果一则职位没有这样的标题,就退回到所有项目符号;如果也没有项目符号,就退回到整段描述。这样,对于有描述的职位,它永远不会返回空文本。
我为第二个向量给出的推理是这样的。当有人搜 "Kafka" 或 "keep cloud services reliable" 时,他们描述的是自己能做什么。而像 "Our synthetic team is hiring to deliver reliable software with product partners" 这样的句子,是公司在说它自己。所以要求应该更接近人们实际输入的内容。反正,理论上是这样。
有一个后来带来回报的小细节:每个存储的向量旁边都有一个 SHA-256 哈希,覆盖策略、模型、维度和确切文本。如果其中任何一项变了,向量就过期了,需要重新嵌入。如果什么都没变,重新跑回填不会做任何事,也不会有任何成本。
同时用两者搜索
搜索是在 SQL 里做精确余弦扫描,只针对已经通过用户筛选条件(地点、职位类型、经验)的职位:
你可能想知道为什么没有向量索引。TiDB 只在没有 WHERE 子句的裸 ORDER BY distance LIMIT k 查询里才会用索引,而我的查询总是带 WHERE。只有几百个职位时,精确扫描既正确又足够快,所以我就不再纠结了。
这个查询每个职位最多返回两行,每种策略一行。把它们变成一个分数,就是一段普通 Java 里的小循环:
这里发生了两件事。哈希检查意味着,只有当向量是基于职位当前文本构建时,它才算数。如果招聘者编辑了职位,而回填还没跟上,旧向量就会被直接忽略,而不是悄悄按它以前说的内容给职位排名。而 scores.merge(..., Math::max) 用一行就完成了整个“两个向量”的决策:无论是全文还是要求匹配,职位都算匹配,取其中更高的分数。
混合模式在此基础上叠加,放在控制器里。每个同时也匹配旧关键词搜索的职位,都会得到一个固定的 +1:
余弦相似度不可能超过 1,所以实际上这个加成会把每个关键词命中都排在纯语义结果之上。这很粗糙,但意味着输入精确职位名称的人,永远不会看到它被一个“差不多”的匹配压下去。
说实话,“取两者的最大值”是一个猜测。它听起来合理,所以我就用了。测试这个猜测,就是本文剩余部分要讲的内容。
构建一个能衡量它的东西
很容易自己跑几个搜索,然后觉得看起来不错。问题是,你最后多半只会看到自己本来就相信的东西。所以我改为写了一个小型评估工具。它是一个单独的 Spring Boot 入口点,有自己的 profile,并且除非数据库名包含 eval,否则拒绝启动。我不想让它有任何碰到真实数据的机会。
它跑的数据如下:
- 288 个合成职位,横跨 12 个角色族(Java 后端、React、数据工程、SRE、安全、产品管理等),由固定种子生成。每个公司名都以
Synthetic开头,所以很容易辨认。 - 40 条我手写的查询,分为七组:精确职位名称、同义词("SDE working on Java services")、裸技能("Kafka"、"OAuth")、角色加地点、带经验筛选的查询、完整句子("I want to keep cloud services reliable and respond to incidents"),以及三条应该什么都返回不了的查询,比如 "marine biologist studying coral reefs"。
- 分级标签:0 表示不相关,1 表示相关,2 表示高度相关。
对于每条查询,它会报告 precision@5(前五条里有多少条相关)、recall@10(所有相关职位中有多少进入了前十)、MRR@10(第一条好结果排得有多高),以及那些无答案查询是否真的返回了空结果。
两个决定让它便宜到我可以一直跑。第一,每个嵌入都缓存在磁盘上,以它的 SHA-256 为键。下面这些数字背后的那次运行没有调用任何 Gemini API:全部 80 次嵌入查询都是缓存命中。第二,每条查询只做一次检索,所有模式、策略和阈值都从那一份结果集在内存里评分。当我想加入按策略比较时,只需要改评分循环。
结果如何
在默认相似度阈值 0.55 下:
第一个结果很无聊:相比关键词,语义搜索的精确率翻了一倍多。这没什么好惊讶的。有趣的是那些关键词搜索完全答不了的查询。"SDE working on Java services"、"site reliability and production operations" 和那句事故响应的句子,关键词结果都是零。混合搜索在每一条上都把三个高度相关的职位放到了最前面。
第二个结果才是我真正好奇的。在这次评估里,仅要求向量单独就打赢了完整职位发布。在仅语义搜索中,job_req_v1 的 precision@5 比 job_full_v1 高 4.5 个百分点。两者取最大得到了最好的 MRR,但精确率比只用要求略低。我猜,公司的那些宣传文字会把向量拉向“泛泛的软件工作”,偏离人们搜索的具体技能。
第三个结果有点抵消了第二个。在混合模式下,差异几乎消失了。有了关键词加成后,完整描述向量单独使用和“两者取最大”得分完全一样。关键词命中已经把显而易见的结果钉在顶部,而向量主要只是对长尾排序,而两种策略在长尾上反正也一致。
所以在这个评估里,对于我实际发布的搜索模式,第二个向量几乎没给我带来什么。不过我还没删掉它,接下来两节会解释为什么。
我不能从中断言什么
写这篇文章时,我必须小心这些数字到底说明了什么。
- 标签有一部分是估计的。前 341 个判断是我手工标注的。其余的是第一轮从未看到的结果所需要的,我用根据自己标注校准过的规则做了估计。所以要把这个比较当成探索性的。它能提示哪种策略更好,但不是基准测试;在生产环境信任它之前,我还想要更多人工复核和一个新的留出集。
- 职位是合成的。真实职位发布更长、更乱,而且充满套话。如果说有什么影响,那可能反而会更有利于仅要求策略,比我的干净样本更有利。
- 四十条查询区分不出 0.825 和 0.830。仅语义的差距值得注意。混合模式的差距是噪声。
- 我在同一份数据上调了阈值,又在这里报告它。这就引出了让我意外的部分。
0.55 并不是真正的截断点
还记得那三条应该什么都不返回的查询吗?它们全都返回了职位。它们的最佳匹配有多接近:
你会以为海洋生物学家和数据分析职位应该接近零分。它们却得了 0.744。
我几乎没怎么想就设了 0.55。它看起来像个安全数字。看到海洋生物学家以 0.744 落到数据分析职位上,那一刻我意识到,我其实并不理解这些分数意味着什么。
用 gemini-embedding-001 时,所有形状都像职位发布的文本会彼此靠得很近,即使它们毫无关系。
我扫了一遍阈值,想看看在哪里这三条终于返回空结果。第一个起作用的阈值是 0.75,只比它们的最高分高 0.006。而且代价很高:仅语义技能查询的召回率大幅下降。"Kafka" 的 recall@10 从 0.556 掉到了 0.056。
所以我把生产环境留在了 0.55。那三条无答案查询是我自己选的,然后我又用它们来挑阈值,这根本不能证明什么。我下一步需要的是一个全新的留出集,包含多得多无答案查询,并且在阈值固定之后再评估。这已经在我清单上了。## 开始之前我会告诉自己的事
- 衡量你嵌入的内容。 在纯语义搜索中,仅仅改变我嵌入的文本,就让 precision@5 提升了 4.5 个百分点,而完全没有触碰模型。
- 嵌入那个听起来像搜索查询的部分。 如果只能存储文档的一个视图,就选最接近人们搜索方式的那一个。对于职位来说,那就是职位要求,而不是营销文案。
- 混合搜索会掩盖很多东西。 关键词加分平滑了大多数策略之间的差异。对用户来说很好,但当你想证明第二个向量有必要时,就没那么好了。
- 相似度分数不是概率。 “海洋生物学家”和一份数据职位之间得到 0.74,在这里完全正常。要用那些本应失败的查询来设定阈值。
- 让评估成本变低,否则你就不会运行它。 磁盘缓存,加上在一次检索过程中给所有内容打分,让重新运行只需要几分钟,而且没有 API 成本。这就是为什么我每次改动后都运行它,而不是只跑一次。
对我来说,最大的收获并不是一个数字。而是这一切都不需要 Python。Spring Boot、一个 SQL 数据库和几百行 Java 就足够构建并衡量它。如果你手头有一个较老的 Java 应用,并且怀疑 AI 功能是否遥不可及,我希望这能说明它们并非如此。
如果你想深入了解,评估框架、数据集和完整结果(包括阈值扫描)都在仓库的 eval 文件夹中。如果你找到了更好的方法来判断该嵌入什么,我很想听听。
[LOADING...]
作者:
Somil Garg
Somil Garg 是一名 Java 后端开发者,使用 Spring AI 在 JVM 上构建 AI 功能。