语义检索需要同时解决两件事:把内容表示成合适的向量,以及在给定延迟和资源预算内找到候选结果。前者主要由模型和数据决定,后者涉及索引与检索配置。下面分开评估这两部分,再看它们在同一条链路中怎样影响召回。
前置阅读:建议先读 ViT 与 CLIP(双塔思想)、Milvus + Neo4j 实战、RAG 进阶。
Embedding 模型:向量空间的语义质量从哪来
演进三十年,三步看懂
- Word2Vec/GloVe(2013-2015):词向量时代。「国王 − 男人 + 女人 ≈ 女王」的算术惊艳了所有人,但静态向量解决不了多义词(「苹果」在句子和水果里是一个向量)。
- BERT 句向量(2018-):上下文相关了,但直接拿 BERT 的 [CLS] 或平均池化做检索效果很差——训练目标不是为相似度设计的,向量空间是「塌缩」的(各向异性)。
- 对比学习句向量(2021-):SimCSE、SBERT、BGE、E5 用对比学习专门训练「语义相似靠近、不相似远离」的向量空间——这才是现代语义检索的起点。今天的 BGE-M3、E5-multilingual 一句话:检索专用的双塔编码器。
选型看 MTEB,但要会看
MTEB(Massive Text Embedding Benchmark)是 embedding 模型的权威榜单,读法三点:
- 看你任务的子项分:检索(Retrieval)、重排、聚类、分类是分开评的——总分高不代表检索强。
- 看语言:C-MTEB 是中文榜。英文榜首模型中文可能拉胯,中文任务认准 BGE 系、multilingual-e5 系。
- 看尺寸-效果比:0.5B 的 bge-m3 经常够打 7B 模型——线上延迟是钱,别为用不上的 2 个点付 10 倍推理成本。
我的 2026 默认选择:中文/多语言 → bge-m3(多粒度、稠密+稀疏一体);英文为主 → e5-large 或 bge-en;代码 → 专门代码 embedding。
领域微调:什么时候必须做
通用 embedding 在垂直领域(医疗、法律、工业术语)会「词都认识但语义错位」。微调信号:Recall@K 在评估集上明显低于通用文本,且 badcase 集中在术语理解。微调方法是对比学习,几百到几千对「查询-正文档」就够(自监督篇的 InfoNCE 同款):
from sentence_transformers import SentenceTransformer, InputExample, losses
from torch.utils.data import DataLoader
model = SentenceTransformer("BAAI/bge-m3")
examples = [InputExample(texts=[q, pos]) for q, pos in pairs] # 正样本对
loader = DataLoader(examples, batch_size=32, shuffle=True)
loss = losses.MultipleNegativesRankingLoss(model) # batch 内其他样本当负例
model.fit(train_objectives=[(loader, loss)], epochs=3, warmup_steps=100)
model.save("bge-m3-mydomain")
关键技巧:硬负样本(和查询字面相似但语义无关的文档)——只拿随机负样本,模型学不到细粒度区分。挖掘法:用基础模型检索 top-K 但不命中的当硬负样本。
向量数据库:工程性能从哪来
索引类型:召回率与速度的权衡
| 索引 | 原理 | 特点 |
|---|---|---|
| FLAT | 暴力全扫 | 100% 召回,百万级以上不可用 |
| IVF_FLAT/PQ | 先聚类分桶,只搜近的几个桶 | 快,nprobe 调召回率 |
| HNSW | 分层小世界图,图上游走找近邻 | 当前默认:召回 95%+ 且快,内存占用大 |
| DiskANN | 磁盘友好的图索引 | 亿级数据、内存有限 |
经验法则:千万级以下一律 HNSW,调参就两个:M(图的连接度,1664,影响内存和召回)和 ef_search(搜索时探索的候选数,调召回率-延迟)。PQ 压缩再省内存但掉 25 个点召回,内存紧张才开。
三大方案对比
| 维度 | Milvus | Qdrant | pgvector |
|---|---|---|---|
| 定位 | 专业向量数据库 | 专业向量库(Rust,轻) | PostgreSQL 插件 |
| 规模 | 亿级+,分布式 | 千万级单机舒适 | 百万级以内 |
| 标量过滤 | 强(分区+表达式) | 强(payload 索引) | 借 PG 全家桶(JOIN/事务) |
| 运维 | 重(依赖 etcd/MinIO) | 轻(单二进制) | 零新增(已有 PG 的话) |
| 适用 | 大规模生产 | 中小规模、快速落地 | 小规模、想少养组件 |
选型决策树:已有 PostgreSQL 且向量 <100 万 → pgvector 零新增组件;中小规模独立服务 → Qdrant;亿级或多租户 → Milvus。我在 Milvus + Neo4j 篇里走的是 Milvus 路线,今天回头看,那个数据量用 Qdrant 会更轻——组件复杂度是永久的运维税。
标量过滤:被低估的刚需
真实检索很少是纯向量:「在 2024 年后的文档里语义搜索」「只搜该用户有权限的」。向量 + 标量混合过滤的实现质量是数据库的分水岭:先过滤后检索(pre-filter)可能让图索引退化,先检索后过滤(post-filter)可能过滤完没结果。Qdrant/Milvus 的「filtered ANN」是各自的工程卖点,选型时拿你的过滤比例实测——过滤掉 99% 数据的查询是各家性能的照妖镜。
一个完整的最小检索栈
# bge-m3 + Qdrant 的最小可跑栈
from sentence_transformers import SentenceTransformer
from qdrant_client import QdrantClient, models
model = SentenceTransformer("BAAI/bge-m3")
client = QdrantClient(":memory:") # 测试用内存模式
client.create_collection("docs",
vectors_config=models.VectorParams(size=1024, distance=models.Distance.COSINE))
vecs = model.encode(documents, normalize_embeddings=True)
client.upsert("docs", points=[
models.PointStruct(id=i, vector=v.tolist(), payload={"text": d, "year": 2024})
for i, (v, d) in enumerate(zip(vecs, documents))])
q = model.encode([query], normalize_embeddings=True)[0]
hits = client.query_points("docs", query=q.tolist(), limit=5,
query_filter=models.Filter(must=[
models.FieldCondition(key="year", match=models.MatchValue(value=2024))]))
踩坑排查
| 现象 | 原因 | 解法 |
|---|---|---|
| 相似文本检索不到 | 没归一化 + 距离度量错配 | cosine 距离要 normalize_embeddings=True |
| 中文效果差 | 用了英文优化的 embedding | 换 bge-m3 / multilingual-e5 |
| 检索慢 | ef_search 过高或用了 FLAT | 换 HNSW,ef 从 64 起调 |
| 加过滤条件后结果为空 | post-filter 把召回滤没了 | 增大 limit 或用数据库原生 filtered ANN |
| 内存爆了 | HNSW 的 M 过大 + 未量化 | 降 M 到 16;开 PQ/SQ 量化 |
| 微调后通用场景退化 | 过拟合领域负样本 | 混 20% 通用数据一起训 |
练习
- 在 MTEB 中文榜上挑 3 个模型,在你的 50 条领域查询上对比 Recall@5——榜单排名和你实测一致吗?
- 用 Qdrant 内存模式建 1 万条文档的索引,扫 ef_search ∈ {16, 64, 256},画「召回率-延迟」曲线。
- 构造 100 对领域数据微调 bge-m3,对比微调前后在领域评估集上的表现(别忘了留测试集)。
- 设计实验:同一批数据分别用 pgvector 和 Qdrant 索引,对比「无过滤」和「过滤 95%」两种查询的延迟。
面试常问
Q:HNSW 的原理和调参? 分层图:上层稀疏高速(大跳转)、下层稠密精确。搜索从顶层入口点贪心走到最近邻,逐层下降到底层精搜。M(每节点最大连接数)影响图密度——大 M 高召回高内存;ef_construction 影响建图质量;ef_search 影响查询召回(可调在线权衡)。经验:M=16~32、ef_construction=200、ef_search 按 SLA 调。
Q:向量检索为什么用余弦相似度?和归一化什么关系? embedding 的语义主要在方向不在模长(模长受词频等影响)。余弦只比方向。向量 L2 归一化后,余弦相似度 = 内积,欧氏距离也单调等价——所以归一化后选 IP(内积)距离既快又对。忘了归一化又用 IP 是最常见的静默 bug。
Q:双塔 embedding 和 BERT 直接取 [CLS] 的区别? BERT 预训练目标(MLM+NSP)不为相似度设计,[CLS] 向量空间各向异性——所有句向量挤在一个窄锥里,余弦相似度都 0.9+ 无法区分。对比学习微调(SimCSE/BGE 路线)把空间「展开」,语义相似的靠近不相似的拉远。同一个 BERT 底座,效果天壤之别——目标函数决定表示几何。
Q:亿级向量怎么规划架构? 分片(按 ID 或业务键 sharding 到多节点)+ 副本(QPS 扩展)+ 索引选型(DiskANN/IVF_PQ 省内存)+ 分层存储(热数据内存、冷数据磁盘)。Milvus 的云原生架构(查询节点/索引节点分离)就是为这个场景设计。另外问自己:真的需要亿级在线吗?多数场景「按租户/时间分区 + 冷热分离」就把规模降回千万级。
Q:稀疏向量(BM25/SPLADE)和稠密向量的关系? 稠密向量(bge 等)捕获语义,稀疏向量(BM25 天然、SPLADE 学到的词权重)捕获精确词匹配。bge-m3 一体化输出两者(multi-vector 还有 ColBERT 式的晚交互)。工程上混合检索(RAG 进阶篇)通常两路并行 + RRF 融合,是比单纯稠密更稳的方案。
相关阅读
- Milvus + Neo4j 搭建 RAG 知识库——Milvus 实战路线
- RAG 进阶:Chunking、Rerank 与评估——检索链路的完整调优
- 自监督学习入门——对比学习的理论根
- ViT 与 CLIP:多模态基石——双塔对齐的原型
- NoSQL 选型实战——向量库在存储版图中的位置
Embedding 决定你的系统「懂不懂」,向量数据库决定它「快不快」。前者看评估集说话,后者看 QPS 说话——两个都别凭感觉选。
💬 评论
这套方案在哪一步卡住了?也欢迎补充你的实现方式或踩坑经验。