2758 字
约 9 分钟
13
S20-X16 · 中文嵌入模型选型:BGE-M3、M3E-base 与 ERNIE-Embedding 的取舍

嵌入模型选错的代价不会当场报错,而是在两周后表现为"检索就是不准",然后有人开始怀疑分块、怀疑重排、怀疑向量库。真正该先回答的是三个问题:要几维、要不要稀疏输出、查询和文档是不是对称的。这篇按这三点把常见中文模型摆到同一张表上,再往下走查询改造与评测。


1. 选型先定四件事

问题 选项 选错的后果
语言范围 纯中文 / 中英混 / 多语 中英混场景用纯中文模型,英文查询召回骤降
输入长度上限 512 / 1024 / 8192 token 超长截断,尾部条款静默丢失
输出类型 只有 dense / dense+sparse / 多向量 想要混合检索却只有 dense,得另搭一路 BM25
部署约束 GPU 显存、批大小、延迟预算 维度大 + 长输入,单卡吞吐掉一个量级

先填这张表再谈模型名,顺序反了就会陷入"换模型 → 没用 → 再换"的循环。


2. 四条谱系的定位

模型 维度(按发布配置核对) 输出 训练侧重 适合
BGE-M3 1024 dense + sparse + multi-vector 多语言、长文、三路统一 一份模型撑混合检索
BGE-large-zh-v1.5 1024 dense 中文语义匹配、指令前缀 纯中文语义召回主力
M3E-base 768 dense 中文通用、体积小 边缘部署、成本敏感
ERNIE-Embedding 按版本而异 dense 中文知识增强 中文长尾实体、百科类语料

三条经验:

  1. BGE-M3 的价值在"一份权重出三种分数",省掉单独维护 BM25 索引的对齐工作;代价是 dense 部分不一定比专用模型强。
  2. M3E-base 的 768 维是显存档位优势,不是精度优势;在 GB 级索引上,768 与 1024 的存储差约 25%。
  3. ERNIE-Embedding 类模型对实体名友好,但版本差异大,必须按具体版本核对维度与最大长度,别拿别人的表直接套。

3. 维度与存储账

docs = 5_000_000          # 条数
for dim in (768, 1024):
    for dtype, size in (("fp32", 4), ("fp16", 2), ("int8", 1)):
        gb = docs * dim * size / 2**30
        print(f"dim={dim:<5} {dtype:<5} {gb:7.1f} GiB")
规模 dim=768 fp32 dim=1024 fp32 dim=1024 int8
100 万条 约 2.9 GiB 约 3.8 GiB 约 1.0 GiB
500 万条 约 14 GiB 约 19 GiB 约 4.8 GiB
2000 万条 约 57 GiB 约 76 GiB 约 19 GiB

上表只是向量本体,HNSW 图结构还要额外占(典型量级为向量的 1.2–1.5 倍,随 M 与 ef_construction 变)。所以"选个大维度更稳"这句话在小机器上往往直接触发磁盘水位告警。

3.1 维度不是越大越好,而是要和训练口径匹配

把 1024 维模型用 PCA 降到 256,检索质量通常只掉一点点;反过来,把 768 维模型"补零"到 1024,除了浪费显存没有任何收益。降维要配一次召回评测,别凭感觉。


4. 归一化与相似度口径

import numpy as np
def embed_norm(v):
    v = np.asarray(v, dtype=np.float32)
    n = np.linalg.norm(v, axis=-1, keepdims=True) + 1e-12
    return v / n

# 归一化之后,内积 == 余弦,可以直接用向量库的点积索引
sim = float(embed_norm(q) @ embed_norm(d).T)
度量 是否要求归一化 向量库索引 常见错配
余弦 是(或由库内部归一) cosine / 归一后 dot 存了归一化又用 l2
内积 否,但尺度影响排序 ip / dot 未归一化导致长文本占优
L2 否 l2 与训练损失不匹配

最隐蔽的一种错配:入库时归一化、查询时忘了归一化。余弦相似度仍然算得出来,只是排序全乱,表现为"明明同义句却排不进前 10"。


5. 指令前缀与非对称检索

BGE 系列在中文检索上依赖查询前缀(如 为这个句子生成表示以用于检索相关文章:)。前缀不是玄学,它是训练时 query 侧与 passage 侧的分布差。

Q_PREFIX = "为这个句子生成表示以用于检索相关文章:"

def encode_query(text, model):
    return model.encode(Q_PREFIX + text, normalize_embeddings=True)

def encode_passage(text, model):
    return model.encode(text, normalize_embeddings=True)   # 文档侧不加前缀
场景 查询侧前缀 文档侧前缀 说明
非对称检索(问句找文档) 加 不加 BGE 中文系列的默认用法
对称任务(相似句判定) 不加 不加 加了前缀反而掉点
短查询 → 长条款 加 不加 配合 512 截断要单独验证
跨语言检索 按模型卡核对 按模型卡核对 多语模型的前缀策略不同

前缀用错的表现是"整体掉几个点到十几个点",不会报错。验证方法很土但有效:取 20 条查询,分别带前缀和不带前缀各跑一遍,看 top-5 命中数。


6. Multi-Query 与 Self-Query:查询侧的两把改刀

Multi-Query 解决"用户措辞只覆盖了其中一种说法",Self-Query 解决"语义之外还有结构化条件"。

# Multi-Query:让模型生成 N 个等价问法,分别检索后做 RRF 融合
def multi_query(q, llm, n=3):
    prompt = f"把下面的问题改写成 {n} 个语义等价、措辞不同的检索查询,每行一个:\n{q}"
    return [q] + [l.strip() for l in llm(prompt).splitlines() if l.strip()]

def rrf(rank_lists, k=60):
    score = {}
    for ranks in rank_lists:
        for i, doc_id in enumerate(ranks, start=1):
            score[doc_id] = score.get(doc_id, 0.0) + 1.0 / (k + i)
    return sorted(score.items(), key=lambda x: -x[1])
# Self-Query:先从自然语言里抽出过滤条件,再带过滤做向量检索
import json

def self_query(q, llm):
    raw = llm(f'抽取过滤条件,输出 JSON:{{"filter": {{}}, "query": ""}}\n问题:{q}')
    payload = json.loads(raw)
    return payload["query"], payload["filter"]      # 如 {"year": {"gte": 2024}}
手段 改的是 成本 收益出现在
Multi-Query 召回面(改写多条) N 倍检索 + 1 次生成 口语化查询、术语别名多
Self-Query 精确性(结构化过滤) 1 次生成 + 解析失败重试 有时间/类别/权限维度
HyDE(假设文档) 查询形态(改成文档样) 1 次生成 短问句对长文档
查询归一化 噪声(错别字、全半角) 可忽略 用户输入脏

Multi-Query 的 N 不是越大越好:N > 5 后新增改写大多是同义重复,融合后排序几乎不动,白付 token。


7. BM25Okapi 仍然要留一路

BM25Okapi 用的是词频饱和 + 文档长度归一,对编号、型号、人名、错误码这类字面强信号极其有效,而这恰好是 dense 检索的弱项。

from rank_bm25 import BM25Okapi
import jieba

corpus_tokens = [list(jieba.cut(d)) for d in docs]
bm25 = BM25Okapi(corpus_tokens, k1=1.5, b=0.75)
top = bm25.get_top_n(list(jieba.cut("ERR_4021 超时"), corpus_tokens, n=10)
参数 含义 调高的效果 常用区间
k1 词频饱和速度 词频影响更持久 1.2–2.0
b 长度归一强度 长文档惩罚更重 0.5–0.8

分词器选错会静默毁掉这一路:用默认分词切"ERR_4021",下划线会被切开,字面匹配能力直接消失。中文场景要么固定词典加自定义词,要么对编号类 token 做保留规则。


8. 召回评测的小闭环

指标 回答什么 计算方式 达标线(按业务定)
Recall@k 该找到的找到没有 命中数 / 标注数 k=20 时通常要求 > 0.85
MRR 第一条正确结果排多前 1/rank 均值 问答类看这个
nDCG@k 排序整体质量 折损累计增益 多结果页看这个
覆盖失败率 完全无相关结果比例 零命中查询占比 高于 5% 就要查分块

评测集建设的顺序是:先 50 条真实查询 → 再扩到 200 条。50 条能发现方向性错误,200 条才能比较两个模型;少于这个量,跑出来的差异基本是噪声。


9. 排错:六种"检索不准"的真实原因

现象 可能原因 定位动作
同义句召不回 查询侧漏了指令前缀 带/不带前缀各跑 20 条对比
编号类查询全 Miss 只有 dense 一路,或分词切碎编号 加 BM25Okapi 一路,检查分词
长文档尾部内容召不回 超过模型最大长度被截断 打印 token 数分布,改分块
换了模型分数整体变低 相似度口径与归一化不一致 检查入库/查询是否同口径
前 10 条全是同一段 分块重叠过大 看 chunk 的 offset 是否重叠
中英混合查询效果差 模型语言覆盖不足 换多语模型或双语双索引

10. 边界:换模型解决不了的问题

问题 真正的解法 为什么换模型无效
库里根本没有这条内容 补数据源 表示再好也召不回不存在的内容
分块切断了语义 改分块与重叠 输入已经是残缺语义
权限过滤没生效 在检索层加 filter 相似度与权限无关
排序对了但答案错 改生成与引用约束 属生成侧问题
延迟超预算 降维、量化、缩 k 是系统账不是模型账

11. 自测

  1. 500 万条 1024 维 fp32 向量大约占多少存储?加上 HNSW 图结构后为什么不能只按向量本体算?
  2. 入库归一化、查询忘了归一化,余弦相似度还能算出来吗?排序会怎样?
  3. BGE 中文系列在对称任务上加查询前缀,会发生什么?
  4. Multi-Query 的 N 从 3 加到 10,收益通常出现在哪里、什么时候饱和?
  5. 为什么"ERR_4021"这类查询需要单独留一路 BM25Okapi?分词器在这条链路上起什么作用?
  6. 评测集只有 30 条时,用它比较两个嵌入模型的结论可靠吗?为什么?
S20-X16 · 中文嵌入模型选型:BGE-M3、M3E-base 与 ERNIE-Embedding 的取舍
https://www.clxhxhhr.top/posts/6010/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。