嵌入模型选错的代价不会当场报错,而是在两周后表现为"检索就是不准",然后有人开始怀疑分块、怀疑重排、怀疑向量库。真正该先回答的是三个问题:要几维、要不要稀疏输出、查询和文档是不是对称的。这篇按这三点把常见中文模型摆到同一张表上,再往下走查询改造与评测。
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 | 中文知识增强 | 中文长尾实体、百科类语料 |
三条经验:
- BGE-M3 的价值在"一份权重出三种分数",省掉单独维护 BM25 索引的对齐工作;代价是 dense 部分不一定比专用模型强。
- M3E-base 的 768 维是显存档位优势,不是精度优势;在 GB 级索引上,768 与 1024 的存储差约 25%。
- 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. 自测
- 500 万条 1024 维 fp32 向量大约占多少存储?加上 HNSW 图结构后为什么不能只按向量本体算?
- 入库归一化、查询忘了归一化,余弦相似度还能算出来吗?排序会怎样?
- BGE 中文系列在对称任务上加查询前缀,会发生什么?
- Multi-Query 的
N从 3 加到 10,收益通常出现在哪里、什么时候饱和? - 为什么"ERR_4021"这类查询需要单独留一路 BM25Okapi?分词器在这条链路上起什么作用?
- 评测集只有 30 条时,用它比较两个嵌入模型的结论可靠吗?为什么?