AgentDepthAgent 工程学习指南
输入概念或问题,检索正文和待编写目录。
第一次学习?从 L0 总览开始 →

向量、余弦相似度与统计:从箭头读懂检索分数

我的学习 →

正在读取学习记录…

地图上的地点可以用“向东走多少、向北走多少”表示。一份资料也可以用一组数字表示,程序再比较这些数字,找出与问题接近的资料。这组有顺序的数字就是向量。

等你会读分数和排序,再看一个更接近 Agent 检索的场景:用户问“如何撤销预订”,系统却把“如何创建预订”排在前面。两份资料讨论同一个业务,并不代表都能回答问题;后半篇会把“相似”与“答对”分开检查。

0. 先把资料表示成数字

Agent 为了回答企业知识、产品文档或历史工单,常会先把查询变成向量,再从许多文档向量中找邻近候选。向量负责把内容变成可比较的数字,相似度负责排序,检索规则决定留下哪些候选;随后仍需要权限过滤、版本判断、重排和答案核验。

地图用两个坐标标出位置,程序也能用一组数字描述资料。我们先约定两个坐标各表示什么,再看两根箭头的方向,接着计算相似度。后半篇才检查:选出来的资料是否真正回答了问题。

研究者从资料柜找出主题相近的卡片,复核者再判断哪一本资料真正回答问题的静态插画
图1:先找到相近资料,再核对它是否回答了问题。

1. 向量:把两步路记成两个数字

从地图的起点出发,向右走 3 格,再向上走 4 格,可以记成 [3, 4]。第一个数字表示左右方向,第二个数字表示上下方向。[4, 3] 则是向右 4 格、向上 3 格,走到的地方不同,所以数字顺序很重要。

这两个数字叫坐标,每个位置叫一个维度。两个数字构成二维向量,三个数字构成三维向量;“d 维”就是有 d 个数字。负数表示相反方向,例如 [-1, 0] 是向左 1 格。

还可以从起点画一根箭头,箭头终点是 (3, 4)。这样,同一个向量有两种看法:一组数字,或一根有方向和长度的箭头。坐标描述怎么走,箭头长度描述直线走多远。

从原点向右3格向上4格到达3和4,直线箭头长度为5;右侧用两根箭头标出夹角与直角三角形
图:左边读坐标和长度,右边读夹角。先看箭头,再看公式里的数字。

向量长度怎么求?刚才的水平距离是 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.60.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]21同向,长度不同
[0.8,0.6]0.80.8夹角小于直角
[0,1]00垂直
[-1,0]-1-1反向
按同一坐标比例绘制的四个候选:A与查询同向且更长,B偏向右上,C垂直,D反向;余弦为1、0.8、0和负1
图2:虚线是单位圆;A 在圆外,仍与查询向量同向。

余弦范围为 -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 离查询更近。

左图按同一比例标出原点、查询1和0、候选A10和0、候选B0.8和0.6,比较终点距离;右图放大原点附近,看查询与A同向而B偏向右上
图:问“哪个终点离查询近”,选 B;问“哪根箭头和查询同向”,选 A。右图是原点附近的放大图。

把距离算出来:

候选距离的计算欧氏距离余弦的计算余弦相似度
A = [10, 0]√((10−1)² + (0−0)²) = √81910 ÷ (1×10)1
B = [0.8, 0.6]√((0.8−1)² + (0.6−0)²) = √0.4约 0.6320.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:

Python
"""用手工坐标计算余弦相似度和检索指标。"""
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
Python
# 核对向量、余弦相似度与统计的正常结果和错误分支;断言失败时会显示对应的测试。
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 ,把当前例子的文件夹拖入终端并回车,运行下面的命令。

Bash
pwd
ls
python --version

列表里必须同时有 vectors.py 和 test_vectors.py。若浏览器把文件保存为 vectors.py.txt,在资源管理器里打开“显示文件扩展名”并改正;Python 只会按真实文件名导入。

Bash
python -B -X utf8 vectors.py
python -B -X utf8 -m unittest -v test_vectors

下面先看四个方向的分数,后面的阈值小节会解释其余输出:

TXT
A: cosine=1.000
B: cosine=0.800
C: cosine=0.000
D: cosine=-1.000
进一步读源码:怎样避免极大或极小数字造成计算错误

实现先校验相同的非空维度、有限数值和非零长度。随后先除以最大绝对坐标,再求范数、归一化。这个步骤用于减轻直接平方极大数导致的溢出、极小数导致的下溢:

Python
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。第一次阅读时,先掌握“对应坐标相乘、加起来、除以长度”;这里的缩放是让同一个公式更可靠地运行。

在已经得到的两个单位长度向量上,源码计算一次点积:

Python
# 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,因此公式为:

TXT
precision = TP / (TP + FP)
recall = TP / (TP + FN)

它们是计数的比例,不是平均相似度。指标定义和零分母约定

把四个缩写放回“检索动作”就更直观:

分类系统是否选中人工标签是否相关在 Agent 中意味着什么
TP是是把有用资料交给后续回答
FP是否无关资料占上下文,甚至误导答案
FN否是漏掉本可支持回答的资料
TN否否正确排除;通常数量很大

精确率关心送进上下文的候选是否干净,召回率关心有用证据是否漏掉。两者没有一个永远优先:客服知识库可能更怕错误条款进入回答,故障排查可能更怕漏掉关键日志。指标选择要回到失败成本。

6. 把阈值代价跑出来

脚本后半段输出:

TXT
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. 练习与实践作品

课后练习展开习题收起习题

4 道练习 · 客观题规则评分 · 其余题目待批改

正在读取本地记录…

不调用模型,不执行代码。简答、代码和场景题仅保存答案并展示自查标准,不生成评分、徽章或认证。输入内容不会自动保存,请点击提交;不要填写个人信息或密钥。

练习 1 / 4 · v2.0.0

选择题 · 余弦相似度看什么?

查询q=[1,0],候选a=[2,0]、b=[0,1]。余弦相似度的正确结果是什么?

对应知识点:L0-vectors

选择一个答案
练习 2 / 4 · v2.0.0

简答题 · 同维度就能比较吗?

文档向量来自模型A,查询向量来自模型B,两者都是768维,余弦函数也没有报错。为什么仍不能据此信任排名?请说明数值校验和表示一致性的区别。

对应知识点:L0-vectors

练习 3 / 4 · v2.0.0

代码题 · 不把空结果报成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

练习 4 / 4 · v2.0.0

场景题 · 提高阈值一定更好吗?

业务把阈值从0.8提高到0.9,演示中的精确率提高,但用户投诉漏掉关键资料。你如何解释,并设计一次避免调参数据泄漏的评测?

对应知识点:L0-vectors

在同目录新建 try_cosine.py,保存下面的完整例子并运行 python -X utf8 try_cosine.py:

Python
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 末尾追加一个完整排序例子:

Python
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 篇会把向量比较接到候选召回和重排流程。

参考资料

图片查看器
100%

文章配图 · 按 Esc 关闭