2740 字
约 9 分钟
15
S13-X09 · 向量检索与混合检索

用户搜"怎么取消自动续费",文档写的是"关闭订阅扣款"——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. 自测

  1. 为什么"取消订阅"这类 query 用纯 BM25 会零召回,而向量能中?
  2. 索引用 dot_product 时客户端漏了归一化,线上表现会偏向什么样的文档?
  3. num_candidates 设得比 k 还小会发生什么?
  4. RRF 相对分数加权,最大的工程优势是什么?
  5. 分块重叠过大,会以什么形式污染检索结果?
  6. 换了 embedding 模型但没重建索引,为什么"看起来还能用"却效果变差?
S13-X09 · 向量检索与混合检索
https://www.clxhxhhr.top/posts/5848/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。