起因

我手头有一个岗位匹配的检索场景。老系统一套 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(两侧字段一致,只有向量那段写法不同):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
PUT /jd_demo
{
  "settings": { "number_of_shards": 1, "number_of_replicas": 0 },
  "mappings": { "properties": {
    "jd_id":     { "type": "keyword" },
    "job_name":  { "type": "text", "analyzer": "ik_max_word" },
    "company":   { "type": "text", "analyzer": "ik_max_word" },
    "jd_text":   { "type": "text", "analyzer": "ik_max_word" },
    "jd_vector": { "type": "dense_vector", "dims": 1024, "index": true,
                   "similarity": "cosine",
                   "index_options": { "type": "hnsw", "m": 16, "ef_construction": 100 } }
  } }
}

7.6 上这行向量只能降级成 {"type":"dense_vector","dims":1024}。原因下面有 400 的原文。

数据:从一个有 3.9 万条 JD 的生产索引里抽了 920 条带正文的真实职位(job_name + 正文 截 1200 字)。老索引里大量职位正文是空的,按接口翻页能拿到且有正文的就这些;920 条够跑对照,但你要拿它当全量结论,我心里没底。同一批文档、同一个 _id,两边各灌一遍。向量用本地的 bge-large-zh-v1.5,1024 维。

BM25 基线

1
2
3
4
5
6
7
POST /jd_demo/_search
{
  "size": 10,
  "query": { "bool": { "must": [ { "multi_match": {
      "query": "<简历全文>", "type": "best_fields",
      "fields": ["job_name^3", "jd_text"] } } ] } }
}

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:

1
2
3
4
5
6
POST /jd_demo/_search
{
  "size": 10,
  "knn": { "field": "jd_vector", "query_vector": [0.0132, -0.0298, ...],
           "k": 10, "num_candidates": 200 }
}

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 写法,当场被拒:

P M U a T p [ p s / i i j n m d g i _ l d d a e e r m f i o i t n y i t : i 4 o c 0 n o 0 s f i o n r e ] [ j [ d i _ n v d e e c x t o : r ] t r h u a e s ] u [ n i s n u d p e p x o _ r o t p e t d i o p n a s r a : m e { t h e n r s s w : , m : 1 6 , e f _ c o n s t r u c t i o n : 1 0 0 } ]

能跑的写法长这样(必须先 filter 压候选,否则全库扫):

1
2
3
4
5
6
7
8
POST /jd_demo/_search
{
  "size": 10,
  "query": { "script_score": {
    "query": { "match_all": {} },
    "script": { "source": "cosineSimilarity(params.q, 'jd_vector') + 1.0",
                "params": { "q": [0.0132, -0.0298, ...] } } } }
}

有意思的是结果对得上: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 的公式很简单:

R R F ( d ) = Σ 1 / ( k + r a n k _ i ( d ) ) k = 6 0

只看名次,不看原始分。这一步很关键——BM25 的分数能到 400 多,knn 是 0~1 的余弦,直接相加就是拿两种货币算总价。k=60 是 Cormack 那篇论文里的经验值,作用是压平"第一名碾压第二名"的效应:第 1 名贡献 1/61≈0.0164,第 20 名 1/80≈0.0125,差距不大,所以两路都命中的文档会明显占优。

先试服务端原生 retriever.rrf(9.x 的新东西):

1
2
3
4
5
6
7
8
9
POST /jd_demo/_search
{
  "retriever": { "rrf": {
    "retrievers": [
      { "standard": { "query": { "multi_match": { "query": "<简历>", "fields": ["job_name^3","jd_text"] } } } },
      { "knn": { "field": "jd_vector", "query_vector": [...], "k": 10, "num_candidates": 200 } }
    ],
    "rank_window_size": 20, "rank_constant": 60 } }
}

返回 403:

s [ e R c e u c r i i p t r y o _ c e a x l c e R p a t n i k o n F : u s c i u o r n r e ( n R t R F l ) i ] c e n s e i s n o n - c o m p l i a n t f o r

注意是 403 不是 400:语法它认得,就是 basic 不让用。这条路断了。

于是回到应用层自己算。两路各取 top20,用 _id 聚合累加,Go 里就是一个 map:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
type cand struct{ score float64; bm25, knn int; doc map[string]any }
fused := map[string]*cand{}
for rank, h := range bm25Hits {          // rank 从 1 开始
    c := get(fused, h.ID, h.Source)
    c.bm25 = rank
    c.score += 1.0 / (60 + float64(rank))
}
for rank, h := range knnHits {
    c := get(fused, h.ID, h.Source)
    c.knn = rank
    c.score += 1.0 / (60 + float64(rank))
}
// 按 score 降序取 Top10

跑出来的 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,只能自己写。所以"免费版能不能上混合检索"的答案是:能,但融合代码归你。

升级的破坏性变更,我现场撞了四个:

  1. include_type_name=true:9.5 返回 400 unrecognized parameter: [include_type_name]。7.x 时代的脚本如果还带这个参数,直接挂。
  2. 带类型的 mapping {"mappings":{"_doc":{"properties":{...}}}}:9.5 返回 400 The mapping definition cannot be nested under a type [_doc]。老 mapping 模板要改写成无 _doc 包裹。
  3. 反向的兼容问题:8.x 的 HNSW 写法在 7.6 上是 400(unsupported parameters [similarity][index][index_options])。这意味着升级这一步本身不可逆,回滚要把向量字段的 mapping 也退回去。
  4. 最容易被忽略的:拼音插件没了。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 的完整报文,没往这儿贴。)