开始前,会这些就够了
- 会计算点积与向量长度
- 知道近邻排序中距离越小越靠前
- 先读 用向量和相似度建立 Embedding 直觉:按该文准备相关概念。
- 先读 向量检索:选择距离、建立索引并检查召回:按该文准备相关概念。
三个方向依次相差 60°。如果用 1−cosθ 当距离,从第一个方向到第三个是 1.5,经过中间方向的两段相加却只有 1。它被叫作“余弦距离”,但没有满足通常距离应有的三角不等式。
这个名字不会阻止它用于检索排序;真正需要检查的是,你是否把只对数学度量成立的界限直接套在了它上面。本文构造一次会误删正确候选的剪枝,再用角距离和弦距离说明排序与度量性质的区别。
把例子带到自己的电脑上
下载完整练习包
需要 Python 3.10 或更新版本,无第三方依赖;制作时实测 Windows / Python 3.14.0。使用标准库,不需要安装第三方包。下载 ZIP 后选择“全部提取”,进入能看到 README.md 的文件夹。
下载 ZIP · 5 个文件包里有什么
- cosine_metric.py:完整可运行示例
- README.md:运行说明与独立练习
- observed.stdout.txt:本次实际输出
- verification.json:源码和产物验证记录
- SHA256SUMS.txt:内容摘要
在这个文件夹打开 PowerShell,逐行运行
python --version
python -B cosine_metric.py解压到独立练习文件夹,在含 cosine_metric.py 的目录打开终端运行。已运行人工二维方向的三角反例、排序、剪枝与浮点边界,并用生成角度独立核验;未运行 Embedding、ANN 索引或真实召回性能测试。
找不到命令或文件时,先按 README 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。
直接走的“距离”,能比绕一点还长吗?
A、B、C 都是长度为 1 的二维向量,A 固定在 0°。比较 A 到 C 与 A 到 B 再到 C 的两段之和;图中连线画的是弦,数值按所选公式计算。
橙色实线连接 A 与 C,蓝色虚线连接 A、B、C。方向相同时,点会重合。
直接:d(A,C) = 1.5000。
两段:d(A,B) + d(B,C) = 0.5000 + 0.5000 = 1.0000。
这组三点违反三角不等式。直接值比两段之和还大,所以不能把 1 − cos 直接代入依赖三角不等式的剪枝证明。
只讨论单位向量;原向量归一化后,同方向的不同长度会合并。零向量没有定义好的方向。比较使用 10⁻¹⁰ 容差处理浮点误差,不把所有近似近邻算法都归为依赖三角不等式。
先说明比较的是原向量,还是它的方向
对非零向量 x、y,余弦相似度为 c=(x·y)/(||x||||y||),余弦不相似度为 d_cos=1−c。先令 u=x/||x||、v=y/||y||,就有 c=u·v。归一化把点放到单位球面上,保留方向,去掉长度。
数学上的度量要求非负、对称、只有相同对象距离为零,并满足 d(a,c)≤d(a,b)+d(b,c)。[1,0] 与 [7,0] 是不同原向量,余弦不相似度却为 0;若讨论原始坐标点,这已经不满足“不同对象不能距离为零”。
本文把正比例的非零向量看成同一个方向,或直接使用归一化后的单位向量。这样的分组叫方向等价类:[1,0] 与 [7,0] 归为同一类,[-1,0] 是相反方向,不能合并。即使明确了这个对象范围,1−cos 仍会违反三角不等式。零向量没有方向,必须另行拒绝或制定业务规则。
三个二维方向就能推翻三角不等式
取 A=(1,0),B=(cos60°,sin60°),C=(cos120°,sin120°)。A 与 B、B 与 C 的夹角都是 60°,所以两段余弦不相似度各为 1−0.5=0.5;A 与 C 的夹角是 120°,直接距离为 1−(−0.5)=1.5。
三角不等式要求直接距离不超过两段之和;这里却有 1.5>0.5+0.5=1。只要一个反例就足以否定“它在所有输入上都满足”的结论,无需随机试遍所有向量。
这不是因为向量没有归一化,也不是高维空间造成的现象。三个向量已经是二维单位向量。浮点程序会有末位误差,但 1.5 与 1 的差距远大于舍入误差,结论不依赖比较两个几乎相等的数。
角距离和弦距离改变数值,却保留同一查询的排序
角距离定义为 d_angle=acos(c),单位是弧度,取值从 0 到 π。弦距离是两个单位向量之间的直线长度 d_chord=||u−v||=sqrt(2−2c)。后一个等式由 ||u−v||²=||u||²+||v||²−2u·v=2−2c 得到。它只适用于已归一化向量。
在这个三点例子中,角距离的直接值为 2.094395,两段相加也是 2.094395;弦距离的直接值为 1.732051,两段相加为 2。弦距离继承欧氏距离的三角不等式;角距离对应单位球面上的最短弧长,绕经第三点不会比最短路径更短。
c 从 −1 增加到 1 时,1−c、acos(c) 和 sqrt(2−2c) 都严格减小。因此对同一查询、同一批非零向量,精确数学下三者近邻次序一致,原有并列也保留。程序用 [12°,73°,38°,145°,−20°] 得到同一个顺序 [12,−20,38,73,145]。实际浮点下极近的方向可能舍入成并列。
排序一致不表示阈值数字可以原样复用。例如余弦不相似度半径 r 对应角半径 acos(1−r) 和弦半径 sqrt(2r),其中 0≤r≤2。若只换函数不换阈值,筛选出的候选范围就会改变。
错误的下界会把半径内候选剪掉
对真正的度量,可由三角不等式得到 d(q,x)≥|d(q,p)−d(x,p)|。这里 p 是预先选好的参照点,q 是查询,x 是候选。如果右侧下界已经大于搜索半径,就不必再算完整距离。这一推理必须以度量性质成立为前提。
现在取查询 q=120°、参照点 p=0°、候选 x=60°。余弦不相似度给出 d(q,p)=1.5、d(x,p)=0.5。若错误地使用上述下界,就会认为 d(q,x)至少是 1;但实际 d(q,x)=0.5。搜索半径设为 0.75 时,错误下界会把一个本应保留的候选删掉。
程序同时把半径转换为角半径与弦半径,再检查各自的合法下界,二者都保留这个候选。这个实验说明的是一种具体剪枝证明的适用条件,不是宣称所有近似近邻算法都要求三角不等式,也不是对 HNSW 或某个数据库实现的故障报告。
浮点夹取能修舍入越界,不能恢复丢掉的角度
计算中 u·v 可能因为舍入略大于 1 或略小于 −1,而 acos 的定义域只有 [−1,1]。示例允许 1e−12 范围内的微小越界,然后夹回端点;明显越界、NaN 或无穷值直接拒绝。这个容差是本文的实现约定,不是所有库统一采用的标准。
归一化时先除以坐标最大绝对值,再计算长度,可以避免有限大坐标在平方和阶段直接溢出。程序也拒绝空向量、不同维度和零向量。它没有用“给零向量加 ε”的方式凭空赋予方向。
还要注意几乎平行的方向。[1,0] 与 [1,10⁻¹²] 的点积在本例浮点计算中会舍入到 1,因此 acos 返回 0。直接用归一化坐标差计算弦长仍得到约 10⁻¹²。夹取解决不了已经丢失的差异,所以代码用 math.dist(u,v) 算弦长,并把这个限制打印出来。
如果任务需要区分极小夹角,应另选并验证适合该精度范围的角度算法。这里不承诺 acos 在任意精度要求下都可靠;256 对普通角度的参考核验,也不能覆盖所有接近平行或反平行的病态输入。
运行脚本,检查反例、排序和独立角度参考
保存为 cosine_metric.py,执行 python -B cosine_metric.py。它只使用 Python 标准库,已在 Python 3.14.0 运行,语法按 Python 3.10 检查;运行后写出 report.json。前三行分别展示三种量的直接距离与经 60° 的两段和。
参考核验不是再调用一次同一个距离函数。程序保存生成二维向量时的原角度差,用 atan2(sinΔ,cosΔ) 得到参考夹角,再用 2sin(θ/2) 得到参考弦长。256 对固定种子角度分别检查夹角、弦长和平方弦长关系;此外还检查错误剪枝、缩放输入和非法值。
本次通过 792 项检查。数量主要来自逐对参考比较,不等于 792 种真实检索场景。report.json 保存最大参考误差、候选顺序和剪枝数字;本例没有调用 Embedding 模型、建立向量索引或测量真实召回率。
展开收起代码 · 小实验
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
"""Cosine dissimilarity, angular distance and chord distance.
Python 3.10+; standard library only. No embedding model or ANN benchmark.
"""
import json
import math
import platform
import random
from pathlib import Path
def unit(vector):
if not vector or not all(math.isfinite(v) for v in vector):
raise ValueError("vector must be nonempty and finite")
scale = max(abs(v) for v in vector)
if scale == 0:
raise ValueError("zero vector has no direction")
scaled = [v / scale for v in vector]
norm = math.hypot(*scaled)
return [v / norm for v in scaled]
def clip_cosine(value):
if not math.isfinite(value) or value < -1.0 - 1e-12 or value > 1.0 + 1e-12:
raise ValueError("cosine outside the roundoff tolerance")
return max(-1.0, min(1.0, value))
def distances(a, b):
if len(a) != len(b):
raise ValueError("vector dimensions must match")
u, v = unit(a), unit(b)
cosine = clip_cosine(math.fsum(x * y for x, y in zip(u, v)))
# The direct coordinate difference retains more detail for nearby directions.
return 1.0 - cosine, math.acos(cosine), math.dist(u, v)
def direction(degrees):
angle = math.radians(degrees)
return [math.cos(angle), math.sin(angle)]
def angle_reference(degrees_a, degrees_b):
"""Independent angular reference using original generating angles, not dot products."""
delta = math.radians(degrees_a - degrees_b)
return abs(math.atan2(math.sin(delta), math.cos(delta)))
def main():
checks = []
def check(name, condition):
if not condition:
raise AssertionError(name)
checks.append(name)
a, b, c = [direction(d) for d in [0, 60, 120]]
ab, bc, ac = distances(a, b), distances(b, c), distances(a, c)
check("cosine triangle inequality counterexample", ac[0] > ab[0] + bc[0] + 0.1)
check("angular triangle equality", math.isclose(ac[1], ab[1] + bc[1], abs_tol=1e-12))
check("chord triangle inequality", ac[2] <= ab[2] + bc[2] + 1e-12)
check("cosine distance 0 to 120", math.isclose(ac[0], 1.5, abs_tol=1e-12))
check("chord distance 0 to 120", math.isclose(ac[2], math.sqrt(3), abs_tol=1e-12))
rng = random.Random(20261018)
max_angle_error = max_chord_error = 0.0
for i in range(256):
da, db = rng.uniform(-180, 180), rng.uniform(-180, 180)
measured = distances(direction(da), direction(db))
theta = angle_reference(da, db)
chord_ref = 2.0 * math.sin(theta / 2.0)
max_angle_error = max(max_angle_error, abs(measured[1] - theta))
max_chord_error = max(max_chord_error, abs(measured[2] - chord_ref))
check("angle reference " + str(i), math.isclose(measured[1], theta, rel_tol=0, abs_tol=1e-11))
check("chord reference " + str(i), math.isclose(measured[2], chord_ref, rel_tol=0, abs_tol=1e-12))
check("squared chord identity " + str(i), math.isclose(measured[2] ** 2, 2 * measured[0], abs_tol=1e-12))
degrees = [12, 73, 38, 145, -20]
values = {d: distances(direction(0), direction(d)) for d in degrees}
rankings = [[d for d in sorted(degrees, key=lambda d: values[d][j])] for j in range(3)]
check("same nearest-neighbor ordering", rankings[0] == rankings[1] == rankings[2])
check("direction equivalence", distances([1, 0], [7, 0]) == (0.0, 0.0, 0.0))
check("opposite directions are distinct", math.isclose(distances([1, 0], [-1, 0])[1], math.pi))
check("large finite input can be normalized", math.isclose(math.hypot(*unit([1e308] * 4)), 1.0))
check("small finite input can be normalized", unit([1e-300, 0]) == [1.0, 0.0])
check("small upper overshoot is clipped", clip_cosine(1 + 2e-16) == 1.0)
check("small lower overshoot is clipped", clip_cosine(-1 - 2e-16) == -1.0)
# Query=120 degrees, pivot=0, candidate=60. A false metric assumption loses a valid result.
radius = 0.75
false_lower_bound = abs(ac[0] - ab[0])
actual_distance = bc[0]
false_prune = false_lower_bound > radius and actual_distance <= radius
check("unsafe cosine pruning drops an in-radius point", false_prune)
transformed_radii = [math.acos(1 - radius), math.sqrt(2 * radius)]
metric_lower_bounds = [abs(ac[j] - ab[j]) for j in [1, 2]]
check("angular lower bound keeps point", metric_lower_bounds[0] <= transformed_radii[0])
check("chord lower bound keeps point", metric_lower_bounds[1] <= transformed_radii[1])
invalid_vectors = [([], []), ([0, 0], [1, 0]), ([math.inf, 0], [1, 0]),
([math.nan, 0], [1, 0]), ([1], [1, 0])]
for i, (left, right) in enumerate(invalid_vectors):
try:
distances(left, right)
except ValueError:
check("reject invalid vector " + str(i), True)
else:
check("reject invalid vector " + str(i), False)
for value in [1.01, -1.01, math.nan]:
try:
clip_cosine(value)
except ValueError:
check("reject invalid cosine " + str(value), True)
else:
check("reject invalid cosine " + str(value), False)
near = distances([1, 0], [1, 1e-12])
check("direct chord retains a tiny separation", near[2] > 0)
report = {
"python_version": platform.python_version(), "checks": len(checks), "passed": checks,
"names": ["cosine_dissimilarity", "angular_radians", "chord"],
"d_0_60": ab, "d_60_120": bc, "d_0_120": ac, "rankings_degrees": rankings,
"reference_pairs": 256, "max_angular_reference_error": max_angle_error,
"max_chord_reference_error": max_chord_error,
"pruning": {"cosine_radius": radius, "assumed_lower_bound": false_lower_bound,
"actual_distance": actual_distance, "false_prune": false_prune,
"metric_lower_bounds": metric_lower_bounds, "transformed_radii": transformed_radii},
"near_parallel_distances": near,
"scope": "Synthetic directions only; no embedding model, index, or ANN performance benchmark."
}
Path("report.json").write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
for j, name in enumerate(report["names"]):
print(f"{name}: d(0,120)={ac[j]:.6f} via_60={ab[j] + bc[j]:.6f}")
print("same_ranking=" + str(rankings[0]))
print(f"unsafe_pruning: lower_bound={false_lower_bound:.6f} radius={radius:.6f} actual={actual_distance:.6f}")
print(f"near_parallel: angular={near[1]:.3e} direct_chord={near[2]:.3e}")
print(f"reference_pairs=256 checks={len(checks)} passed; report.json written")
if __name__ == "__main__":
main()
怎样运行:在独立练习文件夹中执行 python -B cosine_metric.py。2026-10-18 已在 Windows、Python 3.14.0 运行此份源码并通过自带检查。已运行人工二维方向的三角反例、排序、剪枝与浮点边界,并用生成角度独立核验;未运行 Embedding、ANN 索引或真实召回性能测试。
运行后对照结果
cosine_dissimilarity: d(0,120)=1.500000 via_60=1.000000
angular_radians: d(0,120)=2.094395 via_60=2.094395
chord: d(0,120)=1.732051 via_60=2.000000
same_ranking=[12, -20, 38, 73, 145]
unsafe_pruning: lower_bound=1.000000 radius=0.750000 actual=0.500000
near_parallel: angular=0.000e+00 direct_chord=1.000e-12
reference_pairs=256 checks=792 passed; report.json written选择比较规则时,把证明和实现分开验收
如果只做全部候选的精确排序,可以在明确归一化条件后比较这三个量。若要做半径过滤,必须转换阈值;若要根据某个下界跳过候选,就要检查下界所用的数学条件,以及当前实现是否在数值上保持所需精度。
公式正确、浮点实现可靠和真实检索有效是三项不同的证据。本篇给出三点反例、独立数值参考与输入检查;它没有替代真实模型编码配置、授权过滤、近似索引召回或延迟评测。前置的向量检索文章继续负责这些系统层问题。
下方练习让你改变角度和半径,再比较全量扫描与基于下界的筛选。先保留全量结果作为可核对参考,不提前给出改动后的答案,也不要用扩大半径掩盖不合法的下界。
记住这些要点
- 1−cos 可以用于排序,但在单位向量上仍不满足三角不等式。
- 角距离和单位向量弦距离是不同数值尺度,精确数学下保留近邻排序。
- 依赖三角不等式的剪枝必须使用满足前提的量。
- 夹取只修微小越界,不能恢复浮点舍入丢失的角度。
别把例子用过头
- 只因 API 名字含 distance 就假定它是数学度量。
- 把单位向量弦距离的公式用于未归一化的位置坐标。
- 换距离函数后继续使用原半径数字。
- 把一个剪枝反例扩大成所有 ANN 实现都错误的结论。
自己试一小步
把三个方向改成 0°、45°、90°,并设计一次完整的半径检索核验。
- 分别手算三种距离的直接值与两段之和。
- 选定查询、参照点、候选和余弦半径,先用全量扫描保存正确候选集合。
- 比较错误的余弦下界与合法的角/弦下界,转换对应半径。
- 加入一组同方向但长度不同的向量,以及一组几乎平行的向量,记录并列与精度现象。
做到这些,就算完成
- 明确对象是非零向量的方向,零向量有可测试的拒绝规则。
- 所有合法剪枝结果与同一距离、同一半径下的全量扫描一致。
- 能指出一个不合法推理的具体前提,不只展示不一致的输出。
- 近似相等比较写出容差;不把浮点测试当作全域证明。
查原始资料
想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。
- SciPy:scipy.spatial.distance.cosine (新标签页)↗
余弦距离 1−归一化点积的定义
- scikit-learn:Pairwise metrics, Affinities and Kernels (新标签页)↗
度量的四个条件、归一化点积与单位球面解释
- van Dongen 与 Enright:Metric distances derived from cosine similarity (新标签页)↗
角距离、弦距离、1−cos 的三角不等式问题与排序关系