起因
我手头有一个岗位匹配的检索场景。老系统一套 ES 7.6.2,岗位和简历都靠 BM25 匹配。问题很具体:拿一份「中高层餐饮管理岗」的简历去搜,排第一的是一个完全不搭的一线服务岗,第二「餐饮总监」,第五「餐饮部资深员工」。词都对上了,岗位不太对。
听说 8/9 的原生向量搜索能治这个,但我不想为了一个功能去买白金 license。于是拿两个真实集群做了一组对照:老的 7.6.2、新的 9.5.5,两边都是 basic 免费版;把同一批 920 条真实职位 JD 灌进两边,用一条真实简历当 query,跑 BM25 / 纯向量 / RRF 三路。
下面每个数字都是现场请求打出来的,不是文档抄的。先说清楚:这是内网 demo,920 条文档、单节点、没压 QPS,生产我还没这么干过。
环境和索引
| 项 | 老集群 | 新集群 |
|---|---|---|
| 版本 | 7.6.2(Lucene 8.4.0,2020-03 构建) | 9.5.5(Lucene 10.5.1,2026-09 构建) |
| License | basic / active | basic / active |
| 插件 | analysis-ik 7.6.2、analysis-pinyin 7.6.2 | analysis-ik 9.5.5、analysis-phonetic 9.5.5 |
注意最后一个格子:老集群有拼音插件,新集群没有。新集群那个 analysis-phonetic 是英文音系(soundex/metaphone),对中文没用。这个坑后面说。
索引 schema(两侧字段一致,只有向量那段写法不同):
|
|
7.6 上这行向量只能降级成 {"type":"dense_vector","dims":1024}。原因下面有 400 的原文。
数据:从一个有 3.9 万条 JD 的生产索引里抽了 920 条带正文的真实职位(job_name + 正文 截 1200 字)。老索引里大量职位正文是空的,按接口翻页能拿到且有正文的就这些;920 条够跑对照,但你要拿它当全量结论,我心里没底。同一批文档、同一个 _id,两边各灌一遍。向量用本地的 bge-large-zh-v1.5,1024 维。
BM25 基线
|
|
top5:
| # | 岗位 | 公司 | score |
|---|---|---|---|
| 1 | 一线服务岗 A | 公司 A | 409.30 |
| 2 | 餐饮总监 | 公司 B | 390.92 |
| 3 | 驻外岗 B | 公司 C | 373.57 |
| 4 | 厨房管理 | 公司 D | 310.68 |
| 5 | 餐饮部资深员工 | 公司 E | 303.49 |
看得出来毛病在哪:简历里「管理」「餐饮」「厨房」这些词出现频率高,BM25 就按词频给分,完全不看你原来是经理还是员工。那个一线服务岗能排第一,大概是因为它 JD 里餐饮类词密度特别高。你说它推得错吗?也不算——都是餐饮口。但一个中高层管理岗,第一眼看到一线服务岗和驻外岗,会觉得这系统不懂他。
顺手做了个对照:同一条 DSL 打到 7.6,top5 完全一样,分数是 409.24 / 391.05 / 373.52 / 310.65 / 303.45,小数点后三位以内吻合。两边的 _analyze 我也比了,同一段中文切出来的 token 一个不差。这条让我安心:升级不会把 BM25 排序搞乱。
但延迟差得离谱:9.5 上 wall clock 中位数 37ms,7.6 上 367ms(各跑 7 次)。这不是版本的锅——老集群是共享测试环境,几百个索引、monitoring 天天在写。同一批数据、同样分词、分数几乎相同,说明差的是机器负载,不是版本。别拿这种数字当升级理由。
纯向量
模型没花钱:bge-large-zh-v1.5,1.21GB,CPU 直接跑(这台机器没 GPU)。加载 5.9 秒,跑起来常驻内存约 1.5GB。
离线编码 920 条 JD 花了 174 秒,平均 189 ms/条(batch 16,文本最长 1200 字)。这个数在离线任务里能接受:一万条大概半小时,按线性外推十万条要 5 小时出头(外推,没实测)。
但查询侧就不舒服了:单条简历编码 745ms(5 次中位数)。用户点一下「找工作」等 0.75 秒才发第一个请求,这在生产是不能接受的。要么上 GPU,要么把简历向量和 JD 向量都预计算缓存——我现在这个 demo 是每次现编,纯实验。
9.5 的 knn:
|
|
top5:
| # | 岗位 | 公司 | knn _score |
|---|---|---|---|
| 1 | 综合管理岗 | 公司 F | 0.8868 |
| 2 | 常务副总经理 | 公司 G | 0.8751 |
| 3 | 餐饮总监 | 公司 B | 0.8743 |
| 4 | 西餐厅经理(某国际连锁酒店) | 公司 H | 0.8729 |
| 5 | 总经理 | 公司 I | 0.8706 |
先提醒一句:这一列不是余弦本身。ES 对 cosine 的 knn 分数做了 (1+cos)/2 归一化,0.8868 对应的真实余弦是 0.7736——这个数下面用 script_score 验证过,两个集群一致。我第一次看也以为是两个不同的相似度,差点写错。
味道变了。BM25 给的是「餐饮部资深员工」「厨房管理」这类执行岗,向量给的是「常务副总经理」「总经理」「餐饮总监」这类管理岗——它抓住了「管理岗」这个职级语义,而不是只认词。
两个榜单的 top5 只重叠 1 条(餐饮总监)。
k 取 10、num_candidates 取 200,是照"宁可多探一点"设的。num_candidates 越大召回越准,但 920 条的小库里 200 已经占了全量两成,再调大基本没意义。这个参数在生产要不要往上抬,取决于库有多大、P95 能忍多少,我没做敏感性实验,待补。
向量也不是万能的。knn 第一名是个"综合管理岗"(0.8868),一个中高层管理岗的简历被推成这种泛岗,很明显 bge 对这种垂直细分岗位的理解不够,只在"酒店/管理"这个粗粒度上靠得住。所以我的判断是:向量解决"职级和岗位族",解决不了"细分职能"。
老集群想干同样的事,只有一条路:把向量存进 dense_vector,用 script_score 暴力算。我试了下 8.x 的 HNSW 写法,当场被拒:
能跑的写法长这样(必须先 filter 压候选,否则全库扫):
|
|
有意思的是结果对得上:7.6 的 script_score top5 顺序是 综合管理岗 / 常务副总经理 / 餐饮总监 / 西餐厅经理 / 总经理,分数 1.7736 / 1.7501 / 1.7487 / 1.7459 / 1.7412,减 1.0 就是余弦 0.7736 / 0.7501 / 0.7487 / 0.7459 / 0.7412。
更巧的是我把同一条 script_score 也打到 9.5,五个分数一字不差。说明两代集群算余弦的逻辑没变,也正好印证了上面那个 (1+0.7736)/2 = 0.8868。
所以这三条路的关系是:920 条这个量级,HNSW、7.6 的暴力、9.5 的暴力,排出来的顺序完全一样,只是 HNSW 的分数换了个刻度。精度上没有损失。至于速度,见成本账那节,结论可能和你预期相反。
RRF 混合
两路 top5 只重叠 1 条,说明它们各自漏掉的东西,正好是对方能补的。这时候该上混合检索。
RRF 的公式很简单:
只看名次,不看原始分。这一步很关键——BM25 的分数能到 400 多,knn 是 0~1 的余弦,直接相加就是拿两种货币算总价。k=60 是 Cormack 那篇论文里的经验值,作用是压平"第一名碾压第二名"的效应:第 1 名贡献 1/61≈0.0164,第 20 名 1/80≈0.0125,差距不大,所以两路都命中的文档会明显占优。
先试服务端原生 retriever.rrf(9.x 的新东西):
|
|
返回 403:
注意是 403 不是 400:语法它认得,就是 basic 不让用。这条路断了。
于是回到应用层自己算。两路各取 top20,用 _id 聚合累加,Go 里就是一个 map:
|
|
跑出来的 top10:
| # | 岗位 | BM25 名次 | knn 名次 | RRF |
|---|---|---|---|---|
| 1 | 餐饮总监 | #2 | #3 | 0.03200 |
| 2 | 西餐厅经理(某国际连锁酒店) | #9 | #4 | 0.03012 |
| 3 | 财务总监 | #8 | #9 | 0.02920 |
| 4 | 一线服务岗 A | #1 | — | 0.01639 |
| 5 | 综合管理岗 | — | #1 | 0.01639 |
| 6 | 常务副总经理 | — | #2 | 0.01613 |
| 7 | 驻外岗 B | #3 | — | 0.01587 |
| 8 | 厨房管理 | #4 | — | 0.01562 |
| 9 | 餐饮部资深员工 | #5 | — | 0.01538 |
| 10 | 总经理 | — | #5 | 0.01538 |
这张表信息量很大:
- 餐饮总监两路都进前三(BM25 #2 + knn #3),0.03200 稳居第一。这是 RRF 最舒服的情况。
- 西餐厅经理是典型的"BM25 漏、向量捞回":BM25 排到第 9,靠 knn #4 拉到总第 2。单看 BM25 它差点掉出前十。
- 财务总监同理,BM25 #8 + knn #9 合成第 3。虽然"财务总监"跟中高层餐饮管理岗职能不搭,但它是"高层管理"这个语义簇里的,比那个一线服务岗合理。
- 一线服务岗 A BM25 第 1,但向量完全没召回,掉到第 4。这一路的分被"降权"了,我觉得对。
- 综合管理岗 / 常务副总经理 / 总经理 三条只在 knn 里,BM25 top20 都找不到,分别落在第 5、6、10。位置靠后,不喧宾夺主,但也没被丢掉。
三个 knn top5 的岗位在 BM25 top20 里完全不存在——这就是单靠关键词会漏掉的那部分。
我对 RRF 的评价是:它不一定让 top1 更准,但能让整个榜单更稳。任何一路抽风(分词炸了、向量模型换了),另一路还能兜住,不至于整页崩掉。这是工程价值,不是效果价值。
结论
basic license 够不够? 向量这条链路够:dense_vector + HNSW 图索引 + 原生 knn,全部 200。不够的是融合那一层:原生 rrf retriever 直接 403,只能自己写。所以"免费版能不能上混合检索"的答案是:能,但融合代码归你。
升级的破坏性变更,我现场撞了四个:
include_type_name=true:9.5 返回400 unrecognized parameter: [include_type_name]。7.x 时代的脚本如果还带这个参数,直接挂。- 带类型的 mapping
{"mappings":{"_doc":{"properties":{...}}}}:9.5 返回400 The mapping definition cannot be nested under a type [_doc]。老 mapping 模板要改写成无_doc包裹。 - 反向的兼容问题:8.x 的 HNSW 写法在 7.6 上是 400(
unsupported parameters [similarity][index][index_options])。这意味着升级这一步本身不可逆,回滚要把向量字段的 mapping 也退回去。 - 最容易被忽略的:拼音插件没了。7.6 上
POST /_analyze {"analyzer":"pinyin","text":"店长"}返回 200,切出dian / dc / ...;9.5 上同一个请求返回400 failed to find global analyzer [pinyin]。如果业务里有人靠"dz"搜"店长",升级当天就会有人来问"搜索坏了"。这是本次唯一真丢的能力。
什么场景值得升? 我的判断:需要语义召回、并且数据上了十万级、或者需要 knn 里带业务过滤(比如只搜同城在招岗位),那升。什么场景继续 7.6? 只做关键词搜索、依赖拼音、数据量几万条以内——那 7.6 完全够。脚本 + 客户端 RRF 这条路我实测是通的,结果和 9.5 的 knn 完全一致,只是代码难看点。
成本账
都是这台机器(无 GPU)上实测的:
| 项 | 数值 |
|---|---|
| 模型体积 | 1.21 GB(bge-large-zh-v1.5,1024 维) |
| 加载时间 | 5.9 s |
| 常驻内存 | 约 1.5 GB(RSS,编码一次后稳定) |
| 离线编码 | 189 ms/条(920 条共 174 s,batch 16) |
| 单条查询编码 | 745 ms(5 次中位数)——生产不能这么用 |
| 索引大小(920 条 1024 维) | 9.5 HNSW:4.06 MB;7.6 只存向量:8.18 MB |
| bulk 灌数(920 条) | 9.5:3.5 s;7.6:26.4 s |
| BM25 查询 | 9.5:37 ms(ES took 23–34);7.6:367 ms(took 247–430,环境负载高) |
| HNSW knn 查询 | 22.5 ms(ES took 8–15) |
| script_score 暴力(同为 920 条) | 9.5 上 16.0 ms(took 6–8);7.6 上 488 ms(took 400–412) |
最后两行是我最想让你们看的。同一个 9.5 集群、同一批数据:HNSW 22.5ms,暴力扫 16.0ms。这个量级下,HNSW 没赢,反而慢了一点。 建图是有开销的,920 条文档还不够它回本。老集群那个 488ms 更不能拿来当"暴力算法很慢"的证据——它的 BM25 都要 367ms,整台机器就是慢。
所以"要不要为了向量升版本",跟"要不要为了性能升版本"是两件事。HNSW 的 O(logN) 要在大数据量、高维、没有强过滤的条件下才拉得开。我这个 demo 给不出那个结论——QPS 没压,大数据量没灌,这两项待补。
索引大小那栏倒是稳的:同一批 920 条文档,9.5 用 HNSW 只占 4.06MB,7.6 要 8.18MB。按条算更直观——一条 1024 维 float32 向量就是 4KB,920 条光向量约 3.7MB,HNSW 索引那 4.06MB 基本就是这个量级,也就是说这个索引里绝大部分体积都是向量,文本本身很小。7.6 多出来的那 4MB,我猜是它为了给 script_score 算余弦,把向量额外存进了 doc_values,相当于存了两份;9.5 的 HNSW 不吃 doc_values。这是观察加猜测,没深挖。
一句话收尾:向量搜索在 basic 版是真的可用,RRF 是真的要自己写,升级是真的会掉拼音,性能是真的还没到吹的时候。
(脚本和每一路的原始返回我留着,包括 403/400 的完整报文,没往这儿贴。)