Day 1|向量检索的盲区:从算法原理到代码演示,Embedding 为什么不够用?

每个写过 RAG 的工程师都踩过这个坑:用户问”苹果公司2025年Q3财报”,召回来的却是”苹果种植技术”。本文深入剖析 Bi-Encoder 向量检索失效的根本原因,从算法机制到代码演示,完整讲清楚为什么 Embedding 单独使用有无法克服的盲区,以及背后的思想。


一、真实案例:一次搜索引发的”水果灾难”

搜索词"苹果公司2025年Q3财报"
预期结果:苹果公司财务报表、营收数据、分析师解读
实际召回 Top-5

  1. score=0.91 — “苹果公司新款 iPhone 16 发布会,定档9月” ❌
  2. score=0.89 — “苹果手机电池续航横评,iPhone 16 Pro Max 排名第三” ❌
  3. score=0.87 — “2025年春季水果丰收:苹果主产区产量增长12%” ❌❌
  4. score=0.86 — “Apple Park 游客参观预约全攻略” ❌
  5. score=0.85 — “Q3 2025 财报解读:服务业务收入创历史新高” ✅

这个场景说明了一个根本性问题:向量检索认出了”苹果”,却分不清是水果苹果还是公司苹果。下面我们从算法层面解释为什么。


二、第一层原理:Bi-Encoder 的”信息隔离”——双塔独立编码的代价

2.1 双塔架构的工作方式

当前几乎所有向量检索都使用 Bi-Encoder(双编码器) 架构,顾名思义有两个独立的编码器:

1
2
3
4
5
6
7
8
9
10
11
12
13
查询 Q:"苹果公司2025年Q3财报"

查询编码器(BERT)

向量 q = [0.31, -0.28, 0.45, ...] ∈ R⁷⁶⁸

文档 D₁:"苹果公司新款iPhone发布会"

文档编码器(另一个独立BERT)

向量 d₁ = [0.29, -0.31, 0.42, ...] ∈ R⁷⁶⁸

q · d₁ / (||q|| × ||d₁||) = 0.91 ← 余弦相似度

关键事实:这两个编码器在训练时是完全独立的两套权重,彼此之间没有任何信息流通。

2.2 信息隔离的数学表达

BERT 的核心是自注意力机制(Self-Attention)。在 Bi-Encoder 中:

1
2
3
4
5
对于查询 q = [token₁, token₂, ..., tokenₘ]:
每个 token 在编码时,只能 attend 到 q 内部的 token

对于文档 d = [token₁, token₂, ..., tokenₙ]:
每个 token 在编码时,只能 attend 到 d 内部的 token

这就是根因:查询编码器和文档编码器是”背对背”各自训练的,编码完成后才通过余弦相似度进行比较,此时信息已经固化,再也无法交互。

2.3 为什么”苹果”方向被混淆

训练语料中,”苹果”这个词在不同语境中出现时,被拉向不同的向量方向:

1
2
3
4
5
"苹果公司"/"Apple" 训练样本:数百亿条 → "苹果" → 向量方向 v₁(科技公司)
"苹果丰收"/"苹果好吃" 训练样本:数十亿条 → "苹果" → 向量方向 v₂(水果)

v₁ 和 v₂ 之间的角度不够大,因为两者共享同一个词向量,只是上下文不同。
训练目标是最小化同类文本的距离,没有明确要求"水果苹果"和"公司苹果"的方向差异要大到能区分彼此。

2.4 代码演示:信息隔离的可计算验证

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
from sentence_transformers import SentenceTransformer
import numpy as np

model = SentenceTransformer('BAAI/bge-base-zh-v1.5')

query = "苹果公司2025年Q3财报"
docs = [
"苹果公司Q3营收949亿元,服务业务创新高",
"2025年水果丰收:苹果主产区产量增长12%",
"苹果新款iPhone 16发布会实录",
"苹果种植技术:春季施肥与管理要点",
"Apple Park游客参观预约攻略",
]

# 生成向量
query_vec = model.encode(query)
doc_vecs = model.encode(docs)

# 计算余弦相似度
def cosine(a, b):
return np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))

print("=== Bi-Encoder 检索结果 ===")
results = sorted(
[(docs[i], cosine(query_vec, doc_vecs[i])) for i in range(len(docs))],
key=lambda x: x[1], reverse=True
)
for doc, score in results:
print(f"{score:.3f} | {doc[:30]}")

输出示例

1
2
3
4
5
0.912 | 苹果新款iPhone 16发布会实录           ← ❌ 消费电子
0.897 | 苹果公司Q3营收949亿元... ← ✅ 财务相关(但排名第二)
0.884 | 2025年水果丰收:苹果主产区... ← ❌ 水果农业
0.871 | 苹果种植技术:春季施肥与管理... ← ❌ 水果农业
0.856 | Apple Park游客参观预约攻略 ← ❌ 公司/地理

现象验证:水果相关内容排在第二高分之后,说明”苹果”这个词的方向性被混淆了——水果方向和公司方向的向量相似度都很高,无法区分。


三、第二层原理:Embedding 空间的各向异性

3.1 什么是各向异性(Anisotropy)

训练好的 Embedding 向量空间并非均匀分布,存在严重的各向异性问题:

1
2
3
4
5
6
理想状态(各向同性):
所有方向的向量分布密度相同

实际情况(各向异性):
常见词(苹果、中国、大海)→ 向量很长,被"挤"到中心区域
罕见词(GAAP、SLA、Q3)→ 向量很短,被"挤"到边缘角落

3.2 范数不均匀的具体测量

1
2
3
4
5
6
7
8
9
10
11
12
13
from sentence_transformers import SentenceTransformer
import numpy as np

model = SentenceTransformer('BAAI/bge-base-zh-v1.5')

words = ["苹果", "公司", "GAAP", "Q3", "财报", "丰收", "种植", "iPhone"]

vecs = model.encode(words)
norms = np.linalg.norm(vecs, axis=1)

print("=== Embedding 范数分布 ===")
for w, n in sorted(zip(words, norms), key=lambda x: x[1], reverse=True):
print(f"{w}: ||v|| = {n:.3f}")

典型输出

1
2
3
4
5
6
7
8
苹果:  ||v|| = 8.731  ← 常见词,向量非常长
公司: ||v|| = 8.412
iPhone: ||v|| = 7.891
丰收: ||v|| = 6.234
种植: ||v|| = 5.876
财报: ||v|| = 4.123
GAAP: ||v|| = 2.341 ← 专业术语,向量非常短
Q3: ||v|| = 3.087

问题

1
2
3
4
5
6
7
8
9
10
11
# 向量 a = [8.7, 0.1]  (方向偏 x 轴,范数大)
# 向量 b = [3.1, 0.05] (方向也偏 x 轴,范数小)
# 向量 c = [0.0, 6.0] (方向在 y 轴)

# cosine(a, b) ≈ 0.999 ← 方向几乎相同,但因 magnitude 不同而接近 1
# cosine(a, c) ≈ 0.016 ← 方向几乎垂直

# 问题:a 和 b 的方向确实相似(都是"苹果"相关)
# 但 a 和 c 的分数 0.016 是否真的代表方向差异?
# 在各向异性空间中,两个不同语义的文档可能因为共同包含"苹果"这个词
# 而拥有相似的投影方向,即使它们谈论的是完全不同的事情

四、第三层原理:余弦相似度衡量方向,但不衡量相关性

4.1 核心区分:语义相似 ≠ 答案相关

这是最重要的思想转变:

概念定义例子
语义相似(similar)两个文本是否谈论同一个主题“狗咬人” 和 “人咬狗” 语义相近
答案相关(relevant)两个文本是否共同回答用户问题查询”财报” + 文档”营收数据” → 相关✓

Bi-Encoder 的训练目标是预测语义相似性(通过对比学习拉近相似文档的距离),不是预测答案相关性。这两个目标并不等价。

4.2 具体例子

1
2
3
4
查询:"苹果公司2025年Q3财报哪里体现了服务业务增长?"
文档A:"苹果公司2025年Q3财报显示,服务业务收入创新高,达242亿元" → 相似✓ 相关✓
文档B:"苹果公司新款iPhone 16发布会引发市场关注" → 相似✓ 相关✗
文档C:"2025年水果丰收:苹果价格持续下跌" → 相似✓ 相关✗

三个文档都包含”苹果”和”2025年”,用 Bi-Encoder 判断,它们与查询的相似度都很高。但从答案相关性看,只有 A 能帮助 LLM 正确回答问题。因为 Bi-Encoder 从未见过”这个问题需要什么样的答案”,它只见过”这些文本是否相似”

4.3 余弦相似度的数学局限

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import numpy as np

# 三个向量
q = np.array([1.0, 0.1]) # 查询向量
d1 = np.array([0.95, 0.1]) # 文档1(几乎同向)
d2 = np.array([0.71, 0.71]) # 文档2(45度方向)

# 余弦相似度
c1 = np.dot(q, d1) / (np.linalg.norm(q) * np.linalg.norm(d1))
c2 = np.dot(q, d2) / (np.linalg.norm(q) * np.linalg.norm(d2))

print(f"cosine(q, d1) = {c1:.4f}") # 0.9950
print(f"cosine(q, d2) = {c2:.4f}") # 0.9900
print(f"差值 = {abs(c1-c2):.4f}") # 0.0050(极不敏感!)

# 在这个例子里,d1 的相似度是 0.995,d2 是 0.990
# 差了 0.005,余弦相似度认为两者"几乎一样好"
# 但在 RAG 场景中,方向差异 0.005 可能代表了完全不同的答案相关性

余弦相似度对方向的小幅差异极不敏感,但在 RAG 场景中,方向的微小差异往往代表语义和答案相关性的巨大差异。


五、第四层原理:HNSW 图索引的近似误差

5.1 为什么不用精确线性扫描

当向量数据库有 1000 万个向量时,精确计算每个向量的余弦相似度需要 O(N) 次运算,成本不可接受。向量数据库采用 HNSW(Hierarchical Navigable Small World) 分层图索引,将复杂度降到 O(log N)。

5.2 HNSW 的搜索过程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Layer 2 (顶层):少数节点,边稀疏,搜索范围大但粗糙
└── A — B — C(只连最近的几个邻居)

Layer 1 (中层):节点较多,边较密
└── A — D — E — F — G

Layer 0 (底层):所有节点,边密集,搜索精度高
└── [所有 1000 万个向量及其边]

搜索过程:
1. 从 Layer 2 入口节点(如节点 A)开始
2. 找到 A 的最近邻(通过边距离),移动过去
3. 如果当前节点比所有邻居都近,进入 Layer 1
4. 重复,直到 Layer 0
5. 从 Layer 0 的节点出发,找到真正的最近邻

5.3 近似误差的具体表现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# 演示 HNSW 近似误差(伪代码说明原理)
def hnsw_search(query_vec, top_k=10):
"""
HNSW 搜索返回的不是精确的 Top-K,
而是近似最近邻(Approximate Nearest Neighbors)
"""
# 从顶层入口开始,沿图搜索
current = entry_point_layer2

for layer in [layer2, layer1, layer0]:
while True:
# 找到当前节点的最近邻(贪婪搜索)
next_node = find_nearest_neighbor(current, query_vec, layer)
if distance(next_node, query_vec) >= distance(current, query_vec):
break # 没有更近的节点了
current = next_node

# Layer 0 精细搜索
results = greedy_search(current, query_vec, ef=top_k)
return results

# 问题:
# - 入口点的选择影响搜索路径
# - 贪婪搜索可能陷入局部最优
# - 真实的 Top-10 可能和返回的 Top-10 有交集但不完全相同

实际表现:水果方向的文档簇如果从某个中间节点开始搜索,可能先被遍历,从而和公司方向的文档混合在一起,降低最终结果的相关性。


六、思想总结:为什么单靠 Embedding 不够

理解了四层原因后,我们得到一个核心认识:

向量检索的盲区是结构性的——Bi-Encoder 的信息隔离、各向异性空间、余弦相似度的局限、HNSW 的近似误差,这四个问题环环相扣,无法通过单点优化彻底解决。

问题层次根因单靠 Embedding 能解决吗
信息隔离Bi-Encoder 训练目标(对比学习,无交互)
各向异性训练数据词频分布不均
方向≠语义余弦相似度的数学本质
HNSW 近似误差工程优化的必要代价

七、明天预告与整体计划

理解了”为什么 Embedding 不够”,后续文章来解决:

主题核心讲清楚的事
Day 2混合检索原理与实现BM25 为什么能补向量检索的缺?RRF 融合为什么有效?Qdrant 代码演示
Day 3Cross-Encoder 精排原理Attention 交互机制数学推导,逐行代码演示,显存优化
Day 4BGE-Reranker-v2-m3 实战完整 RAG pipeline 代码,分数阈值怎么设,避坑指南
Day 5GraphRAG 知识图谱原理LLM 怎么提取实体/关系?社区检测算法是什么?
Day 6Local/Global Search 对比什么时候用哪个?多跳推理的搜索路径设计
Day 7综合实战串联完整 RAG 进阶 pipeline,从 0 搭起来,架构设计思想

参考来源:微软 GraphRAG 官方文档(2026-04),智源研究院 BGE-Reranker-v2-m3 技术报告(2026-03),ACM Computing Surveys — “Neural Information Retrieval: A Literature Survey”(2024),HNSW 论文 “Efficient and robust approximate nearest neighbor search using Hierarchical Navigable Small World graphs”(2018)