向量、余弦相似度与统计:从箭头读懂检索分数
正在读取学习记录…
地图上的地点可以用“向东走多少、向北走多少”表示。一份资料也可以用一组数字表示,程序再比较这些数字,找出与问题接近的资料。这组有顺序的数字就是向量。
等你会读分数和排序,再看一个更接近 Agent 检索的场景:用户问“如何撤销预订”,系统却把“如何创建预订”排在前面。两份资料讨论同一个业务,并不代表都能回答问题;后半篇会把“相似”与“答对”分开检查。
0. 先把资料表示成数字
Agent 为了回答企业知识、产品文档或历史工单,常会先把查询变成向量,再从许多文档向量中找邻近候选。向量负责把内容变成可比较的数字,相似度负责排序,检索规则决定留下哪些候选;随后仍需要权限过滤、版本判断、重排和答案核验。
地图用两个坐标标出位置,程序也能用一组数字描述资料。我们先约定两个坐标各表示什么,再看两根箭头的方向,接着计算相似度。后半篇才检查:选出来的资料是否真正回答了问题。

1. 向量:把两步路记成两个数字
从地图的起点出发,向右走 3 格,再向上走 4 格,可以记成 [3, 4]。第一个数字表示左右方向,第二个数字表示上下方向。[4, 3] 则是向右 4 格、向上 3 格,走到的地方不同,所以数字顺序很重要。
这两个数字叫坐标,每个位置叫一个维度。两个数字构成二维向量,三个数字构成三维向量;“d 维”就是有 d 个数字。负数表示相反方向,例如 [-1, 0] 是向左 1 格。
还可以从起点画一根箭头,箭头终点是 (3, 4)。这样,同一个向量有两种看法:一组数字,或一根有方向和长度的箭头。坐标描述怎么走,箭头长度描述直线走多远。
向量长度怎么求?刚才的水平距离是 3,竖直距离是 4,直线箭头是直角三角形的斜边。勾股定理说,斜边的平方等于两条直角边的平方之和:
二维向量长度: length([x, y]) = √(x² + y²)
length([3, 4]) = √(3² + 4²) = √25 = 5
length 是“长度”;² 表示这个数乘自己,例如 3² = 3×3;√ 是平方根,√25 = 5,因为 5×5 = 25。Python 里平方写作 **2,平方根可以用 math.sqrt()。
描述资料时,我们也可以先约定第一维是“预订”出现的次数,第二维是“退款”出现的次数。一份资料中两词分别出现 2 次、1 次,就记成 [2, 1]。这里只是借地图建立直觉:两列各代表什么,要在比较前约定一致。
真实资料常用 Embedding(把文字转换成向量的方法)生成模型向量,坐标由模型学出,各维通常没有单独的业务名字。像两份地图采用不同坐标系,不同模型生成的向量也各有自己的表示空间。保存向量时关联模型版本与文本处理方式;查询和资料使用同一套表示,比较才有一致依据。
接下来用手工坐标练习计算;后面的 Embedding 与 Reranker 会讲文字怎样通过模型变成向量。
2. 余弦:两根箭头朝得有多一致
两根箭头从同一个起点出发,它们之间张开的角叫夹角,常用希腊字母 θ(读作 theta)表示。两根都朝右,夹角是 0°;一根朝右、一根朝上,夹角是 90°;朝相反方向,夹角是 180°。
余弦的英文是 cosine,常缩写成 cos。对于直角三角形里的锐角 θ,余弦等于“贴着这个角的直角边长度 ÷ 斜边长度”,即邻边除以斜边。右图中,斜边长 1、沿水平的邻边长 0.8,因此 cos θ = 0.8 ÷ 1 = 0.8。
余弦把角度转换成一个数字:同向为 1,垂直为 0,反向为 -1。夹角从 0° 逐渐增大到 180°,余弦逐渐从 1 降到 -1。因此,用余弦比较两个向量时,分数越大,方向越接近。这就叫余弦相似度。
计算时可以直接使用向量的坐标,不必先量出角度。先做点积:同一位置的数字相乘,再把结果加起来。dot 就是代码里“点积”的名称。二维公式为:
点积: dot(a, b) = a₁×b₁ + a₂×b₂
余弦相似度: cosine(a, b) = dot(a, b) ÷ (length(a)×length(b))
这里 a₁ 是向量 a 的第一个数字,a₂ 是第二个数字。更多维时,继续把每个位置的乘积加起来;长度也相应使用所有坐标的平方和。
查询用 q 表示,候选用 b 表示。令 q = [1, 0]、b = [0.8, 0.6],逐步代入:
| 步骤 | 把坐标代入 | 结果 |
|---|---|---|
| 点积 | 1×0.8 + 0×0.6 | 0.8 |
| q 的长度 | √(1² + 0²) | 1 |
| b 的长度 | √(0.8² + 0.6²) = √(0.64 + 0.36) | 1 |
| 余弦相似度 | 0.8 ÷ (1×1) | 0.8 |
为什么要除以长度?把 b 的两个数字都扩大 10 倍,箭头变长 10 倍,方向没变。点积也变大 10 倍,但分母中的 b 的长度同样变大 10 倍,一除便抵消了。因此,余弦衡量的是朝向,而不是谁的箭头更长。scikit-learn 的公式定义
| 候选 | 与 [1,0] 的点积 | 余弦 | 解释 |
|---|---|---|---|
| [2,0] | 2 | 1 | 同向,长度不同 |
| [0.8,0.6] | 0.8 | 0.8 | 夹角小于直角 |
| [0,1] | 0 | 0 | 垂直 |
| [-1,0] | -1 | -1 | 反向 |
余弦范围为 -1 到1,但模型的实际分数分布不一定均匀覆盖它。0.8不是“80%概率相关”,更不是“80%概率事实正确”。它只是特定表示和度量下的几何分数。
零向量长度为0,没有方向,直接代入会除零。这里明确拒绝它;库也可能按自身约定处理零行,调用前要核对,而不是默认所有实现行为相同。
3. 欧氏距离:从一个终点走到另一个终点有多远
余弦比较从原点出发的两根箭头;欧氏距离比较这两根箭头的终点之间有多远,也就是地图上两点之间的直线距离。英文 distance 就是“距离”。
从点 a 到点 b,先算横着差几格,再算竖着差几格,又得到一个直角三角形。仍用勾股定理:
二维欧氏距离: distance(a, b) = √((a₁−b₁)² + (a₂−b₂)²)
现在把查询 q 放在 (1, 0)。候选 A 在 (10, 0),候选 B 在 (0.8, 0.6)。先只看位置:A 与查询相隔 9 格;B 横着只差 0.2 格、竖着差 0.6 格,所以 B 离查询更近。
把距离算出来:
| 候选 | 距离的计算 | 欧氏距离 | 余弦的计算 | 余弦相似度 |
|---|---|---|---|---|
| A = [10, 0] | √((10−1)² + (0−0)²) = √81 | 9 | 10 ÷ (1×10) | 1 |
| B = [0.8, 0.6] | √((0.8−1)² + (0.6−0)²) = √0.4 | 约 0.632 | 0.8 ÷ (1×1) | 0.8 |
于是两个答案都说得通:B 的终点距离小,A 的箭头方向更一致。选择哪一个,要先决定自己想比较什么。像选住得近的同事和选出门方向相同的同事,问的是两个问题。
代码和接口里常见这三个英文名称:
| 名称 | 中文 | 读分数的方法 |
|---|---|---|
distance | 距离 | 越小,终点越近;0 表示重合 |
cosine | 余弦;这里的函数计算余弦相似度 | 越接近 1,方向越一致 |
similarity | 相似度,是一类指标的统称 | 通常越大越相似;cosine similarity 是其中一种 |
把箭头缩成一样长,再比较
将每个坐标除以原来的长度,箭头就变成长度 1,方向保持不变。这个操作叫长度归一化。例如 [10, 0] 除以长度 10,变成 [1, 0];[3, 4] 除以长度 5,变成 [0.6, 0.8]。
两根箭头都归一化后,终点都落在同一个单位圆上。这时方向越接近,终点也越接近。二者的关系是:
两个单位长度向量: distance(a, b)² = 2 − 2×cosine(a, b)
例如余弦为 1 时,距离平方为 0;余弦为 0.8 时,距离平方为 0.4,距离约 0.632。这就是为什么有些检索系统先做归一化,再按距离搜索,也能得到和余弦相同的排序。
4. 运行真实计算,检查输入边界
将 vectors.py 和 test_vectors.py 按下方代码逐个保存到同一目录:
保存为 vectors.py:
"""用手工坐标计算余弦相似度和检索指标。"""
import math
def cosine(a, b):
if not a or len(a) != len(b):
raise ValueError('expected matching non-empty dimensions')
# 分别把两根箭头缩成长度 1,保留方向。
normalized = []
for vector in (a, b):
if any(type(x) not in (int, float) or not math.isfinite(x) for x in vector):
raise ValueError('expected finite numeric coordinates')
# 先缩小坐标,减轻极大或极小数字带来的计算误差。
scale = max(abs(x) for x in vector)
if scale == 0:
raise ValueError('zero vector has no direction')
scaled = [x / scale for x in vector]
length = math.hypot(*scaled)
normalized.append([x / length for x in scaled])
# 单位长度向量的点积就是余弦相似度。
value = math.fsum(x*y for x, y in zip(*normalized))
return max(-1.0, min(1.0, value))
def metrics(scores, labels, threshold):
if len(scores) != len(labels):
raise ValueError('one label per score required')
if any(type(label) is not bool for label in labels):
raise ValueError('labels must be boolean')
if not math.isfinite(threshold) or any(not math.isfinite(s) for s in scores):
raise ValueError('scores and threshold must be finite')
# 达到阈值的候选被选中;再对照人工相关性标签统计。
selected = [s >= threshold for s in scores]
tp = sum(p and y for p, y in zip(selected, labels))
fp = sum(p and not y for p, y in zip(selected, labels))
fn = sum(not p and y for p, y in zip(selected, labels))
# 精确率看选中的资料,召回率看所有相关资料;分母为 0 时返回 None。
precision = tp / (tp+fp) if tp+fp else None
recall = tp / (tp+fn) if tp+fn else None
return tp, fp, fn, precision, recall
if __name__ == '__main__':
query = [1, 0]
candidates = [('A', [2, 0]), ('B', [0.8, 0.6]), ('C', [0, 1]), ('D', [-1, 0])]
for name, vector in candidates:
print(f'{name}: cosine={cosine(query, vector):.3f}')
scores, labels = [0.9, 0.85, 0.8, 0.2], [True, False, True, False]
for threshold in [0.8, 0.9, 1.0]:
print(f'threshold={threshold}: TP,FP,FN,precision,recall={metrics(scores, labels, threshold)}')完整代码:test_vectors.py
# 核对向量、余弦相似度与统计的正常结果和错误分支;断言失败时会显示对应的测试。
import unittest
from vectors import cosine, metrics
class VectorTest(unittest.TestCase):
def test_direction_and_scale(self):
self.assertAlmostEqual(cosine([1, 0], [2, 0]), 1)
self.assertAlmostEqual(cosine([1, 0], [0, 1]), 0)
self.assertAlmostEqual(cosine([1, 0], [-1, 0]), -1)
self.assertAlmostEqual(cosine([1, 0], [0.8, 0.6]), 0.8)
def test_invalid_vectors(self):
for a, b in [([], []), ([0, 0], [1, 0]), ([1], [1, 2]),
([float('nan')], [1]), ([float('inf')], [1]),
([True], [1]), (['1'], [1])]:
with self.subTest(a=a, b=b), self.assertRaises(ValueError):
cosine(a, b)
def test_extreme_finite_scale(self):
self.assertAlmostEqual(cosine([1e300, 0], [1e-300, 0]), 1)
self.assertAlmostEqual(cosine([1e300, 1e300], [1, 1]), 1)
def test_metrics_are_counts_not_score_averages(self):
scores = [0.9, 0.85, 0.8, 0.2]
labels = [True, False, True, False]
self.assertEqual(metrics(scores, labels, 0.8), (2, 1, 0, 2/3, 1))
self.assertEqual(metrics(scores, labels, 0.9), (1, 0, 1, 1, 0.5))
def test_undefined_denominators(self):
self.assertEqual(metrics([0.5], [True], 1), (0, 0, 1, None, 0))
self.assertEqual(metrics([0.5], [False], 0), (0, 1, 0, 0, None))
if __name__ == '__main__':
unittest.main(verbosity=2)Windows 初学者可以新建文件夹后,在资源管理器地址栏输入 powershell 回车。先检查当前目录和文件:
macOS:先在 Python 篇的学习文件夹中打开终端,执行 source .venv/bin/activate。再输入 cd ,把当前例子的文件夹拖入终端并回车,运行下面的命令。
pwd
ls
python --version列表里必须同时有 vectors.py 和 test_vectors.py。若浏览器把文件保存为 vectors.py.txt,在资源管理器里打开“显示文件扩展名”并改正;Python 只会按真实文件名导入。
python -B -X utf8 vectors.py
python -B -X utf8 -m unittest -v test_vectors下面先看四个方向的分数,后面的阈值小节会解释其余输出:
A: cosine=1.000
B: cosine=0.800
C: cosine=0.000
D: cosine=-1.000进一步读源码:怎样避免极大或极小数字造成计算错误
实现先校验相同的非空维度、有限数值和非零长度。随后先除以最大绝对坐标,再求范数、归一化。这个步骤用于减轻直接平方极大数导致的溢出、极小数导致的下溢:
scale = max(abs(x) for x in vector)
if scale == 0:
raise ValueError('zero vector has no direction')
scaled = [x / scale for x in vector]
length = math.hypot(*scaled)
unit = [x / length for x in scaled]math.hypot 计算欧氏范数,math.fsum 用于最后点积求和。Python math 文档 最终限制分数到 [-1,1] 是防止小幅舍入越界,不是容忍非法输入。
跟着源码走一遍
Python 列表 [1, 0] 表示两个有顺序的坐标。len(a) != len(b) 用来拒绝维数不同的向量;zip(a, b) 才能把同一位置的坐标配对。for x, y in zip(a, b) 不是“搜索相似项”,只是依次得到 (a[0], b[0])、(a[1], b[1])。
源码先找尺度 scale。例如 Python 中的 [10**200, 10**200],** 表示乘方;直接平方可能超出浮点数范围。先同时除以 10**200 得到 [1,1],方向保持不变,再求长度更稳定。零向量没有方向,函数会抛出 ValueError。第一次阅读时,先掌握“对应坐标相乘、加起来、除以长度”;这里的缩放是让同一个公式更可靠地运行。
在已经得到的两个单位长度向量上,源码计算一次点积:
# normalized 中依次放着两个单位长度向量。
# zip 把相同位置的坐标配成一对;乘起来再求和。
value = math.fsum(x * y for x, y in zip(*normalized))这与前面手算的“同位置相乘,再相加”相同。返回前的范围限制用于处理浮点运算在边界产生的微小舍入误差。
测试先检查正常的方向关系,再故意输入零向量、长度不等的列表和特殊数字。NaN 是“不是有效数字”,无穷大也不是一个可拿来计算夹角的有限坐标;这两种输入应被拒绝。True 在 Python 中虽然能参加运算,但它原本表示“是/否”,本例也把它拒绝为坐标。最后再观察很大或很小的数,确认计算过程没有悄悄失真。
5. 需要标签,才能谈阈值好不好
评判相关性时,先请人或独立规则为资料标注“相关/不相关”,再与分数选出的结果比较。这样标签像参考答案,分数像系统提交的答卷。
用下面四条固定数据计算指标,其中乙的分数高,却被标注为不相关:
| 候选 | 分数 | 设定标签 |
|---|---|---|
| 甲 | 0.90 | 相关 |
| 乙 | 0.85 | 不相关 |
| 丙 | 0.80 | 相关 |
| 丁 | 0.20 | 不相关 |
规则为 score >= threshold。阈值0.8选中甲乙丙,其中两个相关;阈值0.9只选中甲,漏掉丙。结果更少不等于全面更好。
想象挑出和报销有关的书:精确率问“挑出的书里,有几本真有用”,召回率问“所有有用的书里,找到了几本”。阈值0.8时挑出甲乙丙,甲丙有用,所以精确率是2/3;有用的两份都找到了,所以召回率是2/2。
下面用三个缩写简化这笔账:TP 是选中且相关,FP 是选中但不相关,FN 是漏掉的相关资料。这个例子中 TP=2、FP=1、FN=0,因此公式为:
precision = TP / (TP + FP)
recall = TP / (TP + FN)它们是计数的比例,不是平均相似度。指标定义和零分母约定
把四个缩写放回“检索动作”就更直观:
| 分类 | 系统是否选中 | 人工标签是否相关 | 在 Agent 中意味着什么 |
|---|---|---|---|
| TP | 是 | 是 | 把有用资料交给后续回答 |
| FP | 是 | 否 | 无关资料占上下文,甚至误导答案 |
| FN | 否 | 是 | 漏掉本可支持回答的资料 |
| TN | 否 | 否 | 正确排除;通常数量很大 |
精确率关心送进上下文的候选是否干净,召回率关心有用证据是否漏掉。两者没有一个永远优先:客服知识库可能更怕错误条款进入回答,故障排查可能更怕漏掉关键日志。指标选择要回到失败成本。
6. 把阈值代价跑出来
脚本后半段输出:
threshold=0.8: TP,FP,FN,precision,recall=(2, 1, 0, 0.6666666666666666, 1.0)
threshold=0.9: TP,FP,FN,precision,recall=(1, 0, 1, 1.0, 0.5)
threshold=1.0: TP,FP,FN,precision,recall=(0, 0, 2, None, 0.0)0.8时精确率2/3、召回率2/2;0.9时分别是1/1、1/2。1.0时一个结果都没选,精确率分母为零,所以返回 None,而不是100%。报表可以采用其他约定,但要明示,且展示空结果比例。
固定候选分数时,提高阈值不会增加选中数量,但精确率不保证单调提高,取决于移除了哪些候选。Top-k 则是数量上限,不是质量标准:即使全都不相关,单独 Top-3 也可能返回三篇。可以先过滤阈值,再取最多 k 个,允许零结果。
打开 vectors.py,在文件末尾找到 scores, labels = ... 那一行。将标签列表由 [True, False, True, False] 改成 [False, True, True, False]:甲现在不相关,乙和丙相关,四个分数保持原样。保存后运行 python -X utf8 vectors.py。
阈值0.8选中前三项,仍是2个相关、1个不相关;阈值0.9只剩甲,精确率和召回率都变成0。提高阈值移除了谁,比阈值数字本身更重要。观察后把标签恢复,再运行测试。
7. 在真实查询上选择阈值
为业务选择阈值时,准备常见、模糊、无答案、版本冲突和长尾查询;按查询划分调参集与独立测试集。先选阈值,再用没有参与选择的数据检验,而不是反复看同一测试集调到满意。
记录查询数量、类别比例、模型版本、分块方式、索引度量和阈值。多个查询汇总时,先合并 TP/FP/FN 再算,和每查询算完再平均,权重不同;要说明采用哪种方式。只有两个相关样本时,错过一个就会让召回率掉50个百分点,这不足以说明线上效果稳定。
即使相关,文档也可能过时、错误或无权限。相似度负责排序,版本和权限筛选决定可用内容,来源核验帮助确认回答。三步合起来才得到适合当前任务的资料。
8. 练习与实践作品
课后练习展开习题收起习题
正在读取本地记录…
不调用模型,不执行代码。简答、代码和场景题仅保存答案并展示自查标准,不生成评分、徽章或认证。输入内容不会自动保存,请点击提交;不要填写个人信息或密钥。
选择题 · 余弦相似度看什么?
查询q=[1,0],候选a=[2,0]、b=[0,1]。余弦相似度的正确结果是什么?
对应知识点:L0-vectors
简答题 · 同维度就能比较吗?
文档向量来自模型A,查询向量来自模型B,两者都是768维,余弦函数也没有报错。为什么仍不能据此信任排名?请说明数值校验和表示一致性的区别。
对应知识点:L0-vectors
代码题 · 不把空结果报成100%
给定分数[0.9,0.85,0.8,0.2]、相关标签[True,False,True,False],编写函数计算score>=threshold时的TP/FP/FN、精确率和召回率。验证阈值0.8、0.9、1.0,明确零分母规则。
对应知识点:L0-vectors
场景题 · 提高阈值一定更好吗?
业务把阈值从0.8提高到0.9,演示中的精确率提高,但用户投诉漏掉关键资料。你如何解释,并设计一次避免调参数据泄漏的评测?
对应知识点:L0-vectors
在同目录新建 try_cosine.py,保存下面的完整例子并运行 python -X utf8 try_cosine.py:
from vectors import cosine
print(cosine([1, 0], [2, 0]))
print(cosine([1, 0], [20, 0]))
print(cosine([1, 0], [0, 1]))结果依次为 1.0、1.0、0.0。把第二个候选改得更长,方向仍然相同;转到垂直方向,相似度才改变。这就把公式、箭头和实际计算连在一起了。
接着在 try_cosine.py 末尾追加一个完整排序例子:
candidates = [("B", [2, 0]), ("A", [20, 0]), ("C", [0, 1])]
ranked = sorted(candidates, key=lambda item: (-cosine([1, 0], item[1]), item[0]))
print([name for name, vector in ranked])输出 ['A', 'B', 'C']。sorted 排序,key 指定比较规则:先按负分数把高分排前面,同分再比较 ID。调换 candidates 中 A、B 的位置再运行,仍是 A 在前。ID 规则让并列结果稳定;A 和 B 的语义分数仍然相同。
9. 总结与开源阅读
把“相近”和“正确”分开记:余弦分数衡量两个向量的方向相近程度,不是答案正确概率。旧版政策即使与问题很相近,也可能不能支持当前答案。
在原来四条数据中,阈值从 0.8 提到 0.9,精确率从 2/3 升到1,召回率却从1降到1/2。改变标签后,同样的提高阈值会得到另一种结果。公式和指标可对照 scikit-learn 余弦定义 与 指标定义。
开源阅读先看 scikit-learn cosine_similarity 的输入矩阵、归一化和零行行为,再看精确率/召回率接口的平均方式与零分母参数。
下一篇:Transformer 与概率生成直觉。后面的 Embedding 与 Reranker 篇会把向量比较接到候选召回和重排流程。
参考资料
- scikit-learn 余弦相似度:矩阵输入和归一化定义。
- scikit-learn 精确率、召回率与 F 分数:平均方式和零分母设置。
- Python
math:hypot、fsum和有限数值判断的实现依据。
描述会发送给本站维护者,用于修订文章,保存90天。请只填写文章问题,省略个人信息、密钥和内部资料。