Ohhnews

分类导航

$ cd ..
foojay原文

每个职位一个还是两个嵌入?在Spring Boot招聘门户中评测语义搜索

#语义搜索#文本嵌入#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 时,我把这三种都当成标题,并保留匹配下面这个模式的标题下面的内容:

$ java
private static final Pattern REQUIREMENT_HEADING = Pattern.compile(
        "(?i)\\b(requirements?|qualifications?|skills?|must[- ]haves?|nice[- ]to[- ]haves?"
        + "|what you('ll)? (need|bring)|you (have|bring|are)|who you are|experience"
        + "|tech(nology)? stack|preferred)\\b");

如果一则职位没有这样的标题,就退回到所有项目符号;如果也没有项目符号,就退回到整段描述。这样,对于有描述的职位,它永远不会返回空文本。

我为第二个向量给出的推理是这样的。当有人搜 "Kafka" 或 "keep cloud services reliable" 时,他们描述的是自己能做什么。而像 "Our synthetic team is hiring to deliver reliable software with product partners" 这样的句子,是公司在说它自己。所以要求应该更接近人们实际输入的内容。反正,理论上是这样。

有一个后来带来回报的小细节:每个存储的向量旁边都有一个 SHA-256 哈希,覆盖策略、模型、维度和确切文本。如果其中任何一项变了,向量就过期了,需要重新嵌入。如果什么都没变,重新跑回填不会做任何事,也不会有任何成本。

同时用两者搜索

搜索是在 SQL 里做精确余弦扫描,只针对已经通过用户筛选条件(地点、职位类型、经验)的职位:

$ query
SELECT job_post_id, strategy, source_hash,
       1 - VEC_COSINE_DISTANCE(embedding, CAST(:vector AS VECTOR)) AS similarity
FROM ai_job_embedding
WHERE job_post_id IN (:ids) AND model = :model AND dimensions = :dims
  AND strategy IN ('job_full_v1', 'job_req_v1')

你可能想知道为什么没有向量索引。TiDB 只在没有 WHERE 子句的裸 ORDER BY distance LIMIT k 查询里才会用索引,而我的查询总是带 WHERE。只有几百个职位时,精确扫描既正确又足够快,所以我就不再纠结了。

这个查询每个职位最多返回两行,每种策略一行。把它们变成一个分数,就是一段普通 Java 里的小循环:

$ java
for (var match : vectors.matches(byId.keySet(), vector, properties.model(), properties.dimensions())) {
    JobPostActivity job = byId.get(match.id());
    if (job == null || !Double.isFinite(match.similarity()) || match.similarity() < minimumSimilarity) continue;
    EmbeddingStrategy strategy = EmbeddingStrategy.fromId(match.strategy());
    if (!properties.strategiesFor(EmbeddingTarget.JOB).contains(strategy)) continue;
    String text = EmbeddingTexts.job(strategy, job, properties.maxChars());
    String hash = SourceHash.of(strategy, properties.model(), properties.dimensions(), text);
    if (hash.equals(match.hash())) scores.merge(match.id(), match.similarity(), Math::max);
}

这里发生了两件事。哈希检查意味着,只有当向量是基于职位当前文本构建时,它才算数。如果招聘者编辑了职位,而回填还没跟上,旧向量就会被直接忽略,而不是悄悄按它以前说的内容给职位排名。而 scores.merge(..., Math::max) 用一行就完成了整个“两个向量”的决策:无论是全文还是要求匹配,职位都算匹配,取其中更高的分数。

混合模式在此基础上叠加,放在控制器里。每个同时也匹配旧关键词搜索的职位,都会得到一个固定的 +1:

$ java
keywords.forEach(j -> {
    combined.put(j.getJobPostId(), j);
    hybrid.put(j.getJobPostId(), scores.getOrDefault(j.getJobPostId(), 0.0) + 1.0);
});

余弦相似度不可能超过 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 下:

模式策略Precision@5Recall@10MRR@10
仅关键词---0.3700.1680.450
仅语义job_full_v10.7800.4880.913
仅语义job_req_v10.8250.5150.913
仅语义两者取最大0.8150.5100.925
混合job_full_v10.8300.5190.925
混合job_req_v10.8250.5150.913
混合两者取最大0.8300.5190.925

第一个结果很无聊:相比关键词,语义搜索的精确率翻了一倍多。这没什么好惊讶的。有趣的是那些关键词搜索完全答不了的查询。"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.732
小动物兽医外科医生一个云工程职位0.739

你会以为海洋生物学家和数据分析职位应该接近零分。它们却得了 0.744。

我几乎没怎么想就设了 0.55。它看起来像个安全数字。看到海洋生物学家以 0.744 落到数据分析职位上,那一刻我意识到,我其实并不理解这些分数意味着什么。

用 gemini-embedding-001 时,所有形状都像职位发布的文本会彼此靠得很近,即使它们毫无关系。

我扫了一遍阈值,想看看在哪里这三条终于返回空结果。第一个起作用的阈值是 0.75,只比它们的最高分高 0.006。而且代价很高:仅语义技能查询的召回率大幅下降。"Kafka" 的 recall@10 从 0.556 掉到了 0.056。

所以我把生产环境留在了 0.55。那三条无答案查询是我自己选的,然后我又用它们来挑阈值,这根本不能证明什么。我下一步需要的是一个全新的留出集,包含多得多无答案查询,并且在阈值固定之后再评估。这已经在我清单上了。## 开始之前我会告诉自己的事

  1. 衡量你嵌入的内容。 在纯语义搜索中,仅仅改变我嵌入的文本,就让 precision@5 提升了 4.5 个百分点,而完全没有触碰模型。
  2. 嵌入那个听起来像搜索查询的部分。 如果只能存储文档的一个视图,就选最接近人们搜索方式的那一个。对于职位来说,那就是职位要求,而不是营销文案。
  3. 混合搜索会掩盖很多东西。 关键词加分平滑了大多数策略之间的差异。对用户来说很好,但当你想证明第二个向量有必要时,就没那么好了。
  4. 相似度分数不是概率。 “海洋生物学家”和一份数据职位之间得到 0.74,在这里完全正常。要用那些本应失败的查询来设定阈值。
  5. 让评估成本变低,否则你就不会运行它。 磁盘缓存,加上在一次检索过程中给所有内容打分,让重新运行只需要几分钟,而且没有 API 成本。这就是为什么我每次改动后都运行它,而不是只跑一次。

对我来说,最大的收获并不是一个数字。而是这一切都不需要 Python。Spring Boot、一个 SQL 数据库和几百行 Java 就足够构建并衡量它。如果你手头有一个较老的 Java 应用,并且怀疑 AI 功能是否遥不可及,我希望这能说明它们并非如此。

如果你想深入了解,评估框架、数据集和完整结果(包括阈值扫描)都在仓库的 eval 文件夹中。如果你找到了更好的方法来判断该嵌入什么,我很想听听。

[LOADING...]

作者:

Somil Garg

Somil Garg 是一名 Java 后端开发者,使用 Spring AI 在 JVM 上构建 AI 功能。