用户搜"怎么取消自动续费",文档写的是"关闭订阅扣款"——BM25 一个词都不重合,召回为零;向量检索能把这条排上来。反过来搜工单号 ERR_4021,向量常把"报错处理"这类泛化文档排在真命中前面,BM25 一次就对上。失效面几乎不重叠,这才是混合检索的理由。
按"边界 → 链路 → 度量 → 字段 → 融合 → 验证 → 分块 → 坑"走。
1. 向量检索解决什么,天然不擅长什么
| 查询形态 | 例子 | BM25 | 向量 |
|---|---|---|---|
| 精确编号 | ERR_4021、SO20261109001 |
直接命中 | 编号被映射成无意义方向,常漏 |
| 同义改写 | "退订" vs "取消订阅" | 无共同词即零召回 | 强项,语义近即近 |
| 跨语言 | 中文 query 找英文原文 | 需翻译或双语字段 | 多语模型可直接对齐 |
| 长尾专有名词 | 内部项目代号、新人名 | 词典/倒排里有就准 | 训练语料没见过,方向近似随机 |
| 否定与排除 | "不含 Spring 的方案" | 词袋分不清否定作用域 | 同样弱,向量没有逻辑运算 |
分工一句话:BM25 管"字面必须出现",向量管"字面没出现但意思对"。两者都弱的场景(否定、数值比较、布尔组合)靠结构化字段过滤,不靠调权重。
2. 向量化流程与每一步的失真来源
原文 → 分块(chunk) → 逐块 embedding → 写入 dense_vector → 查询串 embedding → 近邻检索 → 回表取原文
| | | | | |
切点失真 域不匹配 归一化/维度 短长句分布差 候选数不足
| 步骤 | 动作 | 失真来源 | 可控点 |
|---|---|---|---|
| 分块 | 按长度/段落切 | 切断句子,指代丢失 | 重叠窗口、按层级切 |
| 向量化 | 调 embedding 模型 | 模型训练域与业务语料不匹配(法律/医疗/内部黑话) | 选域内模型或做微调 |
| 存储 | 写入 dense_vector |
归一化方式与查询侧不一致 | 归一化只在一处做 |
| 查询向量 | 同一模型编码 query | query 是短句、文档是长段,分布不对齐 | 非对称模型、加 query 前缀 |
| 近邻检索 | HNSW 近似搜索 | num_candidates 太小漏真近邻 |
调候选数、加 filter |
normalize_embeddings=True 与索引侧 similarity 必须成对决定:用 cosine 时 ES 写入即归一化,客户端多做一次无碍;用 dot_product 则必须归一化,否则写入报 dot_product vectors must have unit length。
3. 三种度量与归一化的关系
similarity |
计算方式 | 是否需客户端归一化 | 排序含义 | 注意 |
|---|---|---|---|---|
cosine |
余弦 | 不需要(ES 内部归一化) | 越大越近,上限 1 | 最稳的默认选择 |
dot_product |
内积 | 必须单位向量 | 越大越近,无上限 | 未归一化直接报错 |
max_inner_product |
内积,允许负值 | 建议归一化 | 越大越近 | 需配合向量幅度的业务含义 |
l2_norm |
欧氏距离 | 不需要 | 越小越近 | 排序方向与其它三种相反 |
单位向量下 cos = a·b,且 L2² = ‖a‖²+‖b‖²−2(a·b) 与内积严格单调对应,所以三者排序一致;一旦其中一侧没归一化,三个度量就会给出不同答案。
典型翻车:离线用 sklearn 按 cosine 评测,线上用 dot_product 却没归一化 → 模长大的长文档整体靠前,看着像"向量不准",实为度量不一致。
4. dense_vector 字段与 HNSW 参数
PUT /kb_chunk
{
"settings": { "number_of_shards": 2, "number_of_replicas": 1 },
"mappings": {
"properties": {
"doc_id": { "type": "keyword" },
"chunk_no": { "type": "integer" },
"title": { "type": "text", "analyzer": "ik_max_word", "search_analyzer": "ik_smart" },
"content": { "type": "text", "analyzer": "ik_max_word", "search_analyzer": "ik_smart" },
"vec": {
"type": "dense_vector",
"dims": 1024,
"index": true,
"similarity": "cosine",
"index_options": { "type": "hnsw", "m": 16, "ef_construction": 100 }
}
}
}
}
GET /kb_chunk/_search
{
"size": 10,
"knn": {
"field": "vec",
"query_vector": [0.013, -0.204, 0.771],
"k": 10,
"num_candidates": 200,
"filter": { "term": { "doc_id": "D-1024" } }
},
"_source": ["doc_id", "chunk_no", "content"]
}
| 参数 | 位置 | 调大 | 调小 | 代价 |
|---|---|---|---|---|
dims |
mapping | 表达力上限高 | 省内存 | 必须与模型输出严格相等,改只能重建 |
m |
建图 | 图更密,召回稳 | 省内存 | 每向量连接数,内存线性涨 |
ef_construction |
建图 | 建图更准 | 建得快 | 只影响建索引耗时 |
num_candidates |
查询 | 召回更高 | 更快 | 每请求扫描的候选集,影响延迟 |
k |
查询 | 返回更多 | 快 | 与 size 分开控制 |
num_candidates 须 ≥ k。近似检索先按图找候选再算分,池太小则真近邻不进池,这类漏召回日志看不出,只能靠评测集发现。
维度上限随小版本变化,建索引前按当前 8.x 版本核对:超上限时只能 index: false(存不建图),查询侧改 script_score 全量算分,代价是线性扫描。
5. 三种融合方式
5.1 倒数排名融合(RRF)
POST /kb_chunk/_search
{
"retriever": {
"rrf": {
"retrievers": [
{ "standard": { "query": { "bool": { "should": [
{ "match": { "title": "取消订阅" } },
{ "match": { "content": "取消订阅" } } ] } } } },
{ "knn": { "field": "vec", "query_vector": [0.013, -0.204, 0.771],
"k": 50, "num_candidates": 200 } }
],
"rank_constant": 60,
"rank_window_size": 50
}
},
"size": 10
}
公式 RRF(d) = Σ_i 1 / (rank_constant + rank_i(d)),两路排名各贡献一份倒数分。
RRF 只吃排名不吃分数,因此免疫两路分数量纲不同的问题——这是它最大的价值。rrf retriever 从 8.8 起提供,参数名与默认值按版本核对。
5.2 分数加权
GET /kb_chunk/_search
{
"size": 10,
"query": {
"script_score": {
"query": { "match": { "content": "取消订阅" } },
"script": {
"source": "double bm = _score; double v = cosineSimilarity(params.qv, 'vec') + 1.0; return bm * params.w1 + v * params.w2;",
"params": { "qv": [0.013, -0.204, 0.771], "w1": 0.3, "w2": 0.7 }
}
}
}
}
三个硬约束:BM25 分数无上界且跨查询不可比,必须先归一化(saturation 或除最大值);cosineSimilarity 脚本会遍历全部匹配文档,规模大时延迟不可控;权重是唯一变量,改权重必须固定分块与模型。
5.3 先过滤后重排
BM25 取 top 200 → 对这 200 条用向量重排(或反之)。可控、可解释,但第一段漏掉的文档永远进不了第二段,召回上限被第一段锁死。
| 融合方式 | 参数敏感性 | 优点 | 失效场景 |
|---|---|---|---|
| RRF | 低(只对 rank_constant 中度敏感) |
免标定、稳 | 两路质量差极大时会把噪声路抬上来 |
| 分数加权 | 高(权重 + 归一化方式) | 可精细调 | 换查询类型就失衡 |
| 两段重排 | 中(第一段窗口大小) | 可解释、省算力 | 第一段漏召回无法补救 |
6. 怎么验证两者真的互补
分别看两路各自的命中,别靠感觉:
# A. 纯字面:精确编号
curl -s "localhost:9200/kb_chunk/_search?pretty" -H 'Content-Type: application/json' -d '
{"query":{"match":{"content":"ERR_4021"}},"size":5,"_source":["doc_id","chunk_no"]}'
# B. 纯向量:语义改写
curl -s "localhost:9200/kb_chunk/_search?pretty" -H 'Content-Type: application/json' -d '
{"knn":{"field":"vec","query_vector":[0.013,-0.204,0.771],"k":5,"num_candidates":100},
"_source":["doc_id","chunk_no"]}'
| 观察项 | 判定 | 处置 |
|---|---|---|
| 编号类 query 两路都差 | 分词把编号切碎,或编号落在了非 keyword 字段 | 编号单独建 keyword 字段并走 term 查询 |
| 语义类 query BM25 全空、向量全中 | 正常互补 | 融合,别删 BM25 |
7. 分块策略与它对召回的影响
| 策略 | 切法 | 对召回的正面 | 对召回的负面 |
|---|---|---|---|
| 按段落 | 空行/换行边界 | 语义完整,指代不丢 | 段落长短悬殊,长段仍超窗 |
| 固定长度 + 重叠 | 每 N token,重叠 M | 不会在句中断死 | 重复内容抬高同文档多 chunk 命中 |
| 按语义边界 | 标题层级/句子相似度断点 | 最贴合主题 | 实现复杂,块数不稳定 |
| 父子块 | 子块检索、父块回表 | 召回细、上下文足 | 需两套存储与回表逻辑 |
父块回表的做法:子块存向量并带 parent_no,命中后按 doc_id 回取父块;同文档多 chunk 用 collapse 去重,再 inner_hits 补齐上下文。
分块是最被低估的变量:换模型要重建索引,换分块同样要;分块改错时权重怎么调都救不回来。
8. 排错
| 现象 | 原因 | 处理 |
|---|---|---|
| 换了 embedding 模型但结果没变好 | 没重建索引,旧向量还在 | 新建索引 → 重灌 → 别名切换,别原地改 mapping |
写入报 must have unit length |
dot_product 未归一化 |
客户端 L2 归一化,或改用 cosine |
| 写入报维度不符 | dims 与模型输出不等 |
以模型卡为准;改 dims 只能重建 |
| 近似检索结果和暴力算分不一致 | num_candidates 太小 |
调大候选数后重测,确认是漏召回而非模型问题 |
加了 filter 后召回骤降 |
过滤后候选池太小,或 filter 字段类型错 | 用 keyword 过滤;候选数按过滤后规模给 |
| 权重调来调去没起色 | 分块质量差 / 两路都漏 | 先看失败样本落在哪一路,再动参数 |
9. 边界
| 场景 | 更适合 | 理由 |
|---|---|---|
| 全是编号、错误码、SKU | 纯 BM25 + keyword 字段 | 向量对离散符号无优势 |
| 多语种知识库 | 多语向量模型 | 省掉翻译链路 |
| 超大规模(亿级 chunk) | 向量库/专用 ANN 服务 | ES 的图索引与堆内存要精细规划 |
10. 自测
- 为什么"取消订阅"这类 query 用纯 BM25 会零召回,而向量能中?
- 索引用
dot_product时客户端漏了归一化,线上表现会偏向什么样的文档? num_candidates设得比k还小会发生什么?- RRF 相对分数加权,最大的工程优势是什么?
- 分块重叠过大,会以什么形式污染检索结果?
- 换了 embedding 模型但没重建索引,为什么"看起来还能用"却效果变差?