主题
Embedding 与向量检索剖析
《RAG 检索增强工程》讲了怎么搭检索管线。这一篇往下挖一层:"语义相似"是怎么变成一次矩阵运算的,向量库凭什么在亿级文档里毫秒返回,以及为什么这套机制天然带着几个必须绕开的坑。
1. 语义如何变成向量
Embedding 模型把一段文本映射成固定长度的向量(如 768 或 1536 维),核心性质:语义相近的文本,向量在空间中相近。
它通常是一个 Transformer 编码器(《注意力机制剖析》的结构),用对比学习训练:让"问题与其正确答案"的向量靠近,"问题与随机文本"的向量推远。大规模配对语料(问答对、标题正文)训练后,模型学会把语义结构编码进几何结构。
相似度用余弦值衡量:
text
cos(a, b) = a·b / (|a| × |b|) # 1 = 方向相同,0 = 正交,-1 = 相反2. 机制级的三个坑
理解了"几何结构承载语义",就能推出这类系统的固有缺陷——它们不是实现 bug,是机制边界:
- 相似不等于相关。余弦相似度衡量的是文本分布意义上的接近。"如何退货"和"如何退货款"高度相似;"苹果手机"和"苹果公司"在向量空间也可能很近。没有业务上下文过滤的纯向量检索,会系统性返回"话题相邻但答非所问"的结果。
- 训练分布决定能力边界。对代码、表格、罕见领域文本,通用 embedding 模型的向量质量明显下降——训练语料里这类配对少。选模型先看它的目标场景。
- 整段一个向量是有损压缩。把 500 字压成 1536 个数,细节必然丢失。这就是《RAG 检索增强工程》强调切分策略的数学原因:向量粒度决定了语义保真度上限。
3. 检索:从 O(N) 到毫秒
3.1 暴力检索与 ANN
文档量小(几千条)时,直接算 query 与全部向量的余弦再排序即可。但百万级以上不可行,于是用 ANN(近似最近邻)索引:牺牲一点点召回率,换取几个数量级的速度。
主流方案 **HNSW(分层可导航小世界图)**的直觉:
text
把所有向量连成一张"近邻图",建多个层级:
顶层:节点稀疏,边跨度大 —— 类似高速公路,快速接近目标区域
底层:节点稠密,边跨度小 —— 类似街道,精确找到近邻
查询:从顶层贪心游走,逐层下沉,最后在底层做局部精修这解释了向量库宣传的两个参数:ef(搜索时的候选队列长度,越大越准越慢)与 M(每节点连接数,越大图越密、内存越多)。调参就是在"召回率-延迟-内存"三角里选点。
3.2 工程含义
- 建索引时间与内存:HNSW 图要驻留内存,亿级向量是实打实的成本;
- 增删的代价:图结构对删除不友好,频繁更新的库要考虑分区或定期重建;
- 量化压缩:PQ 等方法把向量压到几个字节每维,内存换精度——又一个三角权衡。
4. 混合检索:两套互补的失败模式
向量检索(语义)与 BM25(关键词)各有盲区:
text
向量检索:抓住语义,漏掉精确符号 —— 搜 "ERR_0x80070057" 可能一无所获
BM25: 抓住字面,漏掉同义改写 —— 搜 "退款" 找不到写着 "退货返现" 的文档混合检索两路并行、用 **RRF(倒数排名融合)**合并结果,让对方的盲区互相兜底。这正是《RAG 检索增强工程》把它列为标配的机制原因:两个错误模式不重叠的系统,融合后召回质量必然高于任何单路。
5. 重排:Bi-Encoder 与 Cross-Encoder
为什么检索之后还要重排(rerank)?因为两阶段用了两种结构:
text
Bi-Encoder(召回阶段):query 和文档各自独立编码,靠向量夹角比相似
→ 优点:文档向量可离线预计算,检索快
→ 代价:两个文本从未"见过面",交互细节全丢
Cross-Encoder(重排阶段):query 和文档拼在一起过一个模型,直接输出相关分
→ 优点:token 级交互能捕捉细粒度匹配,精度高得多
→ 代价:每对都要现算,不可能扫全库所以标准管线是"Bi-Encoder 粗召回 top-50 → Cross-Encoder 精排 top-5":快与准各用其长。这与你熟悉的缓存分级是同一种架构智慧——贵而准的操作永远放在小候选集上。
6. 一个最小可运行的向量检索
不依赖任何向量库,用纯 Python 体会本质(生产请用专业向量库)。让它在本地跑起来,先做三件准备:
- 安装依赖:
pip install openai; - 按《模型、API 与客户端》配置环境变量
OPENAI_BASE_URL与OPENAI_API_KEY(令牌形如sk-xxxxxxxx,不要写进代码); - 确认你的接入平台提供 embedding 模型,把下面
EMBEDDING_MODEL换成它的名字。
先存 retrieval_demo.py——embedding 函数来自 embedding 模型的 API:
python
import os
from openai import OpenAI
client = OpenAI(
base_url=os.environ["OPENAI_BASE_URL"],
api_key=os.environ["OPENAI_API_KEY"],
)
EMBEDDING_MODEL = "你的 embedding 模型名" # 换成平台实际提供的模型
def embedding(text: str) -> list[float]:
resp = client.embeddings.create(model=EMBEDDING_MODEL, input=text)
return resp.data[0].embedding再追加检索逻辑:
python
import math
def cos_sim(a, b):
dot = sum(x * y for x, y in zip(a, b))
norm = math.sqrt(sum(x * x for x in a)) * math.sqrt(sum(y * y for y in b))
return dot / norm
docs = ["年假可按 1.5 天/月 折算", "报销需在 30 天内提交", "试用期不享年终奖"]
doc_vecs = [embedding(d) for d in docs]
query_vec = embedding("入职满一年能休多少天假")
ranked = sorted(
zip(docs, doc_vecs),
key=lambda x: cos_sim(query_vec, x[1]),
reverse=True,
)
print(ranked[0][0])运行 python retrieval_demo.py,正常情况会命中"年假可按 1.5 天/月 折算"——注意查询和这条文档几乎没有字面重叠,按关键词匹配是找不到的,命中的是语义。
十几行就是所有向量库的核心逻辑;库解决的是第 3 节的规模问题,模型解决的是第 1 节的质量问题——你的选型时间应该花在切分策略与 embedding 模型上,而不是向量库的参数调优上。
7. 概念对照表
| 应用层的说法 | 机制层的对应 |
|---|---|
| "语义搜索" | embedding 空间中的近邻查询 |
| "检索不准" | 相似≠相关、切分有损、分布外文本 |
| "向量库很快" | HNSW 多层图贪心游走,近似换速度 |
| "要混合检索" | 语义与字面两套失败模式互补 |
| "要重排" | Bi-Encoder 保速度,Cross-Encoder 保精度 |
下一步:检索只是 RAG 的前半段,检索结果如何被生成环节忠实使用(以及为什么引用对了答案还会错),见《RAG 检索增强工程》与《LLM 推理全链路剖析》。
本文为工程向剖析:省略了对比学习的损失函数细节、量化编码原理与分布式索引。生产选型时以向量库与 embedding 模型的官方基准为准。