主题
11.3 机器怎么读懂语义?—— 向量嵌入与多模态
痛点场景:你把文档切好块了,但计算机根本不认识“退款”“退货”“售后”这些词——它只认 0 和 1。你问“退款多久到账?”,如果机器只能做字面匹配,它永远无法把这个问题和“退货后资金原路返还时效”这段资料联系起来。嵌入(Embedding)就是给文字装上“语义坐标”,让机器从“认字”升级到“懂意”。
痛点:字面匹配 vs 语义匹配
| 用户问题 | 字面匹配能命中吗 | 语义匹配能命中吗 |
|---|---|---|
| “退款多久到账?” | ❌ 库里的资料写的是“退货后资金原路返还” | ✅ 语义相近,命中 |
| “食堂几点开饭?” | ❌ 库里的资料写的是“餐厅用餐时段” | ✅ 命中 |
| “怎么连不上网?” | ⚠️ 可能命中“网络故障”但不精准 | ✅ 更精准命中 |
一句话:字面匹配看“字长得像不像”,语义匹配看“意思挨得近不近”。Embedding 解决的就是后者。
思路:把文字变成高维坐标
1. 相亲角比喻:多维打分雷达图
想象相亲角有一张打分表,维度是「身高、年薪、幽默感、颜值」:
- 张三:
[180, 50, 85, 90] - 李四:
[178, 48, 80, 88] - 王五:
[150, 10, 20, 30]
计算机不需要“认识”张三李四,只要算坐标距离,就能立刻判断“张三和李四是一路人,王五差得远”。Embedding 就是把这句话映射到几百上千维的坐标空间,语义相近的句子坐标就靠近。
2. 三种相似度度量的“适用边界”
| 度量方式 | 公式直觉 | 生活比喻 | 适用场景 |
|---|---|---|---|
| 余弦相似度 (Cosine) | 看两向量夹角,值域 [-1,1] | 不管个子高矮,方向一致就算同路 | 文本语义匹配(工业界默认) |
| 欧氏距离 (L2) | 看空间直线距离 | 用尺子量地图上的物理距离 | 图像、聚类 |
| 点积 (Dot) | 方向和长度一起看 | 综合投影 | 向量已归一化时的极速计算 |
💡 实践提示:绝大多数向量库对文本场景默认用 Cosine;如果你已把向量做过 L2 归一化,用点积(Dot)计算等价且更快。
代码实现一:Embedding 计算、三种度量与“手写最近邻检索”
下面的代码演示的不只是“算个相似度”,而是把“Embedding → 度量 → 最近邻检索”整条链路自己实现一遍,帮你真正看清向量检索的本质。
python
import numpy as np
from langchain_openai import OpenAIEmbeddings
# 1. 初始化嵌入模型(也可换成智谱/BGE/GTE 等任意 OpenAI 兼容端点)
embed = OpenAIEmbeddings(model="text-embedding-3-small")
# 2. 构造知识库句子与用户问题
docs = [
"退货成功后,退款将在 48 小时内原路返回至支付账户",
"年度体检由公司在每年 6 月统一组织,员工可自行预约",
"猎鹰九号火箭于今日凌晨成功完成回收",
]
query = "我申请退货,钱多久能回来?"
# 3. 批量向量化(embed_documents 会走批量,比逐个调用更快更省)
doc_vectors = np.array(embed.embed_documents(docs))
q_vector = np.array(embed.embed_query(query))
# 4. 三种度量自己实现一遍
def cosine(v1, v2):
return np.dot(v1, v2) / (np.linalg.norm(v1) * np.linalg.norm(v2))
def euclidean(v1, v2):
return np.linalg.norm(v1 - v2)
def dot(v1, v2):
return np.dot(v1, v2)
print("=== 三种度量对比(对第 0 篇文档)===")
print(f"余弦相似度 : {cosine(q_vector, doc_vectors[0]):.4f} (越大越近)")
print(f"欧氏距离 : {euclidean(q_vector, doc_vectors[0]):.4f} (越小越近)")
print(f"点积 : {dot(q_vector, doc_vectors[0]):.4f} (越大越近)")
# 5. 手写“最近邻检索”:对每篇文档算余弦,取 Top-K
scores = [cosine(q_vector, v) for v in doc_vectors]
top_idx = sorted(range(len(scores)), key=lambda i: scores[i], reverse=True)[:2]
print("\n=== 最近邻 Top-2 ===")
for i in top_idx:
print(f"[{scores[i]:.4f}] {docs[i]}")这段代码揭示的本质:任何“向量检索”底层都在干这件事 —— 把查询转成向量,和库里的向量比距离,取最近的 K 个。所谓“向量数据库”,只是把这个过程加速并做成了可以落盘、过滤、并发的服务(11.4 详述)。
代码实现二:Matryoshka 降维(省钱利器)
很多商业嵌入模型(如 OpenAI text-embedding-3)支持 Matryoshka 表征学习(MRL):允许你把 3072 维向量直接“掐头”截短到 512 维、256 维,精度损失很小,但存储和检索成本大幅下降。
python
from langchain_openai import OpenAIEmbeddings
# 老办法:完整 3072 维(text-embedding-3-large),占内存大
emb_full = OpenAIEmbeddings(model="text-embedding-3-large")
v_full = emb_full.embed_query("如何申请年假")
print(f"完整向量维度: {len(v_full)}")
# 降维办法:只取前 256 维(MRL 特性,需模型支持)
v_small = v_full[:256]
print(f"截断后维度: {len(v_small)} 内存降为 1/12")
# 工程建议:先在完整维度上离线评测,找到“精度可接受的最小维度”再定配置⚠️ 注意:并非所有模型都支持“直接截断维度”,只有训练时采用 MRL 的模型才支持。使用前务必查阅模型说明,或先做小规模离线评测。
多模态嵌入:图文混杂场景怎么办?
企业文档里除了纯文本,还有大量架构图、报表截图、UI 设计稿、扫描合同。传统做法是“先 OCR 再文本嵌入”,但 OCR 会丢失排版、颜色、箭头指向等空间信息。
- CLIP(OpenAI):把图片和文字放进同一个语义空间,输入“红色跑车”的图片与文字都能互相检索;
- ColPali(基于 PaliGemma):把 PDF 页面以图像形式输入视觉模型,生成基于图像补丁的多向量表征,从而绕过 OCR。用户输入“查看 2025 年各季度净利润柱状图”时,可以直接命中对应页面截图。
主流 Embedding 模型怎么选?(MTEB / C-MTEB)
权威基准是 MTEB(英文) 与 C-MTEB(中文)。选型时参考这张速查表:
| 模型 | 特点 | 适合场景 |
|---|---|---|
| BGE-M3(智源) | Dense + Sparse 三合一、8192 长上下文、100+ 语言 | 中文私有化部署首选 |
| GTE-Qwen2(阿里) | 基于 Qwen2 底座,在中文检索基准上表现较强 | 中文强语义理解 |
| text-embedding-3(OpenAI) | MRL 可降维、生态完善 | 海外/多云场景、快速接入 |
| Jina Embeddings v3 | 支持 LoRA 任务适配 | 需要按领域微调适配 |
排行榜只能海选,真实语料才是面试
公共排行榜像高考成绩:能筛掉明显不合格者,却不能保证候选人适合你公司的岗位。模型可能在百科问答上很强,却分不清你们内部的相似产品型号;也可能支持上百种语言,但中英混问时把数字和否定词处理错。
Embedding 选型至少做四组检查:
| 检查 | 为什么 | 最低验证方式 |
|---|---|---|
| 训练时要求的前缀/指令 | 有些模型要求 Query 和 Document 使用不同前缀 | 严格按模型卡编码,再与“不加前缀”对照 |
| 最大长度与截断位置 | 关键条款可能在文末被截掉 | 构造长文,把答案分别放开头、中间、末尾 |
| 归一化与距离度量 | 模型、库、度量配错会让排序悄悄变化 | 明确是否 L2 归一化,核对 Cosine/Dot 配置 |
| 领域与语言 | 公共语料不包含内部缩写、错误码和制度 | 用真实问题测 Recall@K、MRR、nDCG |
换模型不是“改一行配置”
不同 Embedding 模型产生的是不同坐标系。把查询换成新模型、文档仍保留旧向量,就像拿上海地铁图上的坐标去纽约找站点,数值能算,结果没有意义。正确升级流程是:
- 新建独立索引版本,全量重算文档向量;
- 用同一评测集对比旧索引和新索引;
- 同时记录索引大小、写入时间、查询 P95 与单次成本;
- 通过门禁后切换别名,保留旧索引供回滚;
- 让缓存键包含 Embedding 与索引版本。
降维要测近邻保持率
“维度从 3072 降到 256,体积变成十二分之一”只说明省空间,不说明还找得准。应把完整维度的精确 Top-K 或人工相关标注作为参照,逐档测试 1536、1024、512、256 维的 Recall@K 与延迟曲线。配套脚本的 evaluate_dimension_recall() 就是这个最小门禁。
⚠️ MTEB 是持续更新的排行榜,正文不应长期写“某模型霸榜”。更稳妥的写法是说明模型能力、发布日期与评测日期,再让读者复查当前榜单。
更换嵌入模型意味着重建索引
向量只在同一模型、同一处理方式下具有可比性。更换嵌入模型、向量维度或文本预处理后,旧文档向量与新查询向量不能直接混用,应建立新索引并完成回归测试后再切换。
模型选择可先用业务语料构造近义表达、型号数字和中英混合问题,比较 Recall@k。排行榜适合缩小候选范围,最终仍要看自己的文档语言、部署条件、维度成本和许可证要求。
进阶:检索器也能“进修” —— 领域 Embedding 微调
痛点场景:MTEB 排行榜只能帮你“海选”,通用模型未必懂你们行业的黑话。用户问“开票抬头怎么填”,你的库里写的是“发票购买方名称”;用户说“走个签”,文档里写的是“发起会签流程”。通用模型觉得这俩八竿子打不着,召回自然失败。
思路:与其继续换更大的通用模型,不如用自己的语料把模型“进修”一遍。做法是构造 3k–10k 条 (query, 正例, 难负例) 三元组:
| 元素 | 含义 | 怎么来 |
|---|---|---|
| query | 用户的真实问法 | 线上日志、客服记录、内部提问 |
| 正例(正样本) | 真正含答案的块 | 人工标注,或从点击/引用反推 |
| 难负例(难负样本) | 被向量检索捞上来、却不含答案的块 | 用当前模型跑一遍,专挑“像但不对”的 |
然后用对比学习微调:拉近 query 与正例、推远 query 与难负例。其中“难负例”是关键 —— 随机负例太容易区分,模型学不到细微差别。
收益与代价:
- 常见收益大于“换一个更大的通用模型”,因为它学的是你语料的分布;
- 还能和本文前面讲的 MRL 降维叠加:先微调出好向量,再截断降维省成本;
- 代价是要标注/挖掘数据、要重跑训练,属于一次性投入。
⚠️ 红线:微调后向量空间已经变了,必须重建整个索引(呼应本文“更换嵌入模型意味着重建索引”一节),并跑一遍回归集确认没有整体退化 —— 别为了几个黑话把原有能力练废了。
| 路线 | 成本 | 适用场景 |
|---|---|---|
| 换模型 | 低(改配置 + 重建索引) | 通用能力不足、语料无明显行业特性 |
| 微调模型 | 高(标注 + 训练 + 重建索引) | 行业黑话多、query 分布稳定、语料量大 |
| 只做重排 | 中(加一个重排模型) | 召回基本 OK,只是排序不理想 |
下面是 15 行左右的微调骨架,用 sentence-transformers 的损失函数:
python
from sentence_transformers import SentenceTransformer, losses
from torch.utils.data import DataLoader
model = SentenceTransformer("BAAI/bge-base-zh-v1.5") # 中文检索常用底座
# 训练样本:每条是 (query, 正例, 难负例)
train_samples = [
("开票抬头怎么填", "发票购买方名称需填写公司全称", "发票开具时间不得超过当月"),
# ... 数百到数千条,来自真实日志 + 人工标注
]
train_dataloader = DataLoader(train_samples, shuffle=True, batch_size=16)
train_loss = losses.TripletLoss(model) # 也可换 MultipleNegativesRankingLoss
model.fit(
train_objectives=[(train_dataloader, train_loss)],
epochs=1, # 领域适配通常 1~3 个 epoch 就够
warmup_steps=100,
)
model.save("bge-domain-ft") # 微调后:全量重算索引 + 跑回归集边界:微调是“把通用模型调教成懂你行业的人”,不是“万能提分药”。如果召回本来就差在排序而非语义,优先上重排模型即可,不必一上来就动训练。参考:sentence-transformers 官方仓库、FlagEmbedding(BGE 系列)。