相似度 0.86,这两段文字其实毫不相干
给知识库检索定了一条规则:余弦相似度超过 0.7 才算相关。换了一个嵌入模型之后,几乎所有的文档对都超过了 0.7,连毫不相干的也有 0.86。另一处,接口要求返回 10 条结果,加上「只看本租户」的过滤之后,经常一条也没有。再有,把精确检索换成向量索引之后,同一个查询的前 10 条里有两条换了。
向量检索的接口很简单:给一个向量,返回最相似的 k 个。围绕它的几个数字却各有各的口径:相似度的数值代表什么,索引返回的是不是真正最近的 k 个,过滤条件加在检索之前还是之后,以及「最近」和「相关」是不是一回事。
本文用合成的向量把这四个口径分别量出来。数据是固定种子生成的:64 维,200 个主题,20000 篇文档,每篇是「主题方向加随机噪声」,不涉及任何真实的模型。它说明的是机制与方向;具体数值换一个模型或语料就会不同。实验见文末。
一、用哪种距离,结果一样吗
常见的三种度量是余弦相似度、内积和欧氏距离。它们是否给出相同的排序,取决于向量有没有归一化成单位长度。
| 向量 | 余弦与内积的前 10 条重合 | 余弦与欧氏距离的前 10 条重合 |
|---|---|---|
| 归一化之后 | 100% | 100% |
| 不归一化(长度在 0.5 到 3 之间) | 32% | 20% |
向量都是单位长度时,三种度量在数学上给出同一个排序,选哪个只影响计算成本。不归一化时它们量的是不同的东西:
- 内积偏向长的向量。实验里全部文档的平均长度是 1.76,内积取回的前 10 条平均长度 2.72。
- 欧氏距离偏向和查询一样长的向量。查询是单位长度,取回的前 10 条平均长度只有 0.65。
- 余弦只看方向。
由此得到两条规则。第一,模型的文档里说明了应该用哪种度量、输出是否已经归一化,按它来;拿不准时自己归一化再用内积,结果与余弦一致。第二,写入索引时用的度量和查询时用的度量必须是同一个。建索引时选了欧氏距离、查询时按余弦理解分数,不会报错,只是排序不是想要的那个。
二、相似度的数值不是相关的概率
余弦相似度的取值范围是 -1 到 1,很容易把它读成「有多相关」,进而定一个阈值。但同一个数值在不同条件下的含义相差很大。
维度。 随机生成的、毫无关系的向量对:
| 维度 | 余弦的第 95 百分位 | 超过 0.3 的比例 |
|---|---|---|
| 8 | 0.59 | 21.64% |
| 64 | 0.20 | 0.75% |
| 768 | 0.06 | 接近 0 |
8 维时,无关的向量对有五分之一超过 0.3;768 维时 0.3 已经是极强的信号。维度越高,随机向量越接近互相垂直。
向量的整体分布。 上面说的是均匀分布的随机向量,真实的嵌入向量往往不是这样:所有向量都偏向空间里的某一个方向,于是任意两个向量之间都有不低的相似度。实验里用「给所有向量加上同一个方向」来模拟这种情况:
| 向量 | 同主题文档对 | 不同主题文档对 | 阈值 0.7 放过的不同主题对 |
|---|---|---|---|
| 原始 | 0.41 | -0.02 | 0 |
| 加上共同方向 | 0.92 | 0.86 | 100% |
| 减去全体均值后 | 0.41 | -0.03 | 0 |
文档之间的主题关系一点没变,只是整体平移了一下,0.86 就成了「不相干」对应的数值。阈值 0.7 从一个都不放过变成全部放过。
这解释了开头的第一个现象:阈值是和模型绑定的。换模型、换模型版本、换归一化或预处理方式,都要在自己的数据上重新确定。确定的办法是拿一批标注好相关与否的文档对,看两类的分数各自落在哪里;实验里第 1 名的结果,与查询同主题时余弦的中位数是 0.61,不同主题时是 0.52,两类的分数是重叠的,没有哪个阈值能把它们干净地分开。
整体平移对排序的影响小一些,但不是没有:加上共同方向前后,前 10 条结果重合 82%。
三、索引返回的不一定是最近的
逐个比较全部向量得到的是精确结果,文档多了之后太慢。向量索引为了快,只检查一部分候选,返回的是近似的最近邻。实验里实现了最简单的一种:先把文档聚成 100 个桶,查询时只在离查询最近的几个桶里找。
| 查几个桶 | 扫描的文档比例 | 前 10 条的召回 | 10 条全部找对的查询 |
|---|---|---|---|
| 1 | 1.1% | 0.856 | 66% |
| 2 | 2.1% | 0.893 | 70% |
| 5 | 5.2% | 0.929 | 76% |
| 10 | 10.2% | 0.957 | 80% |
| 20 | 20.1% | 0.982 | 88% |
| 100(全部) | 100% | 1.000 | 100% |
这里的召回,指的是「精确结果的前 10 条里,索引找回了几条」。只查一个桶时要比较的文档只有全量的百分之一,代价是三分之一的查询至少漏掉一条真正的近邻:那条文档被分到了旁边的桶里。
各种索引(分桶的、基于图的)都有这样一个在速度和召回之间调节的参数,而且默认值通常偏向速度。这意味着:
- 从精确检索换成索引,或者调整索引参数、重建索引,结果会变,这是预期行为。
- 索引的召回要在自己的数据上测:抽一批查询,同时跑精确检索和索引检索,比较前 k 条的重合。这个测量不需要人工标注,成本很低,应该是上线前的固定步骤。
- 文档数量不大(几万到几十万)时,精确检索往往已经够快,不建索引就没有这个问题。
四、过滤条件加在哪一步
实际的查询几乎都带条件:只看本租户的、只看有权限的、只看最近一个月的。条件加在向量检索之后还是之前,结果不一样。实验里每篇文档随机属于 20 个租户之一,查询要求返回本租户最相似的 10 条:
| 做法 | 平均返回 | 一条都没有的查询 | 凑够 10 条的查询 |
|---|---|---|---|
| 先取前 10 条,再按租户过滤 | 0.6 条 | 53% | 0 |
| 先取前 50 条,再过滤 | 2.4 条 | 10% | 0 |
| 先取前 200 条,再过滤 | 8.7 条 | 0 | 57% |
| 先按租户筛出候选,再检索 | 10 条 | 0 | 100% |
先检索后过滤时,前 10 条里属于本租户的平均只有二十分之一。多取一些再过滤能缓解,但要多取多少取决于过滤条件有多严,而这因查询而异:大租户够了,小租户仍然不够。
把四种做法的返回条数和零结果比例画在一起:
更重要的是权限类的条件。把它放在检索之后,意味着别的租户的文档先进入了候选,再靠后面的代码剔除;任何一处遗漏(缓存了过滤前的结果、把候选交给了下游的重排或生成步骤)都是越权。权限条件应当在检索之前生效:按租户分开建索引,或者使用支持「带条件检索」的索引并确认它是先过滤的。
五、最近的不一定相关
前面三节讨论的都是检索系统有没有忠实地返回「最近的 k 个」。最后一个口径是:最近的 k 个,是不是用户要的。
实验里把「与查询同主题」当作相关。用精确检索,没有任何近似:
- 前 10 条里同主题的占 97%;
- 前 10 条全部同主题的查询占 82%,其余 18% 的查询至少混进了一条别的主题的文档。
这些混进来的文档确实离查询更近,向量空间里的「近」和主题上的「相关」本来就不是同一件事。合成数据里这个差距来自噪声;真实场景里它来自模型的训练目标、文档的切分方式、查询与文档的表述差异。
所以评估要分两层,用两套指标:
- 索引相对精确检索的召回(第三节):检索系统的工程指标,不需要标注。
- 检索结果相对人工标注的相关性:模型和数据处理的效果指标,需要一批「查询—相关文档」的标注。
第一个指标是 1.0 也不说明第二个指标好。把检索结果交给生成模型时,下游拿到的是「最近的」,其中混着不相关的,要有相应的处理:重排、让模型在证据不足时拒答、保留出处以便核对。对模型输出做独立校验的另一个例子,见 模型写的 SQL 能跑通,离放它去查库还差几道检查。
六、上线前核对
- 度量与归一化:模型文档要求的度量、索引配置的度量、应用里解释分数的方式三者一致。
- 阈值:在当前模型和自己的数据上用标注样本确定;换模型后重新确定。
- 索引召回:对照精确检索测量,记录所用的参数;重建索引或调参后重测。
- 过滤:权限类条件在检索之前生效;其余条件确认返回条数不足时的行为。
- 相关性:有一套标注数据,能回答「前 k 条里有多少是相关的」。
- 版本:向量是由哪个模型的哪个版本生成的,和文档一起存下来。两个模型的向量不能放在同一个索引里比较。
检索效果怎么评估、评估分数在什么情况下不可比,见新检索系统的评估分数只有旧系统的四分之一。
配套实验
- codesphere-labs/ai/vector-search-pitfalls:numpy 2.5.3 上用固定种子的合成向量测量:三种度量在归一化前后的排序重合、不同维度与分布下的余弦取值、分桶近似索引的召回、先检索后过滤的返回条数、精确最近邻与主题的一致程度(验证记录)
参考资料
- FAISS Wiki:MetricType and distances(内积、欧氏距离与余弦的关系)
- FAISS Wiki:Faster search(IVF 与 nprobe)
- pgvector 文档(距离运算符、近似索引的召回参数、带过滤条件的查询)
- Ethayarajh:How Contextual are Contextualized Word Representations?(EMNLP 2019)(嵌入向量集中在一个狭窄的锥内)
- Mu、Viswanath:All-but-the-Top(ICLR 2018)(减去均值等后处理)
- Malkov、Yashunin:Efficient and robust approximate nearest neighbor search using HNSW