在基于检索增强生成(RAG, Retrieval-Augmented Generation)的系统里,L2 distancecosine similarity 是最常用的两个相似度度量。表面上看,它们都在比较向量之间的接近程度;但在不同的归一化策略、嵌入模型训练目标、数据库实现与索引结构之下,它们的行为差异会直接影响召回的排序、长文档的偏置、在线性能以及最终大模型回答的质量。下面沿着几何直觉、数学关系、模型与数据库的实践、以及真实案例来完整拆解这两个函数在 RAG 场景中的使用方式与取舍,并给出可直接运行的完整 Python 源代码做对比实验。


1. 几何直觉:长度 vs. 角度

L2 distance(欧氏距离)衡量的是两点之间的直线距离,等价于在高维空间里对两个向量做毕达哥拉斯距离计算。它对向量的长度方向都敏感:同一方向但长度更大的向量,会被判定为更远。cosine similarity(余弦相似度)则只看方向,忽略长度:两个共线向量,不论一个是否是另一个的多倍,余弦相似度都为 1。直观地说,如果你的嵌入向量长度容易受文本长度、词频或聚合方式影响,cosine similarity 更鲁棒;如果向量的模长携带了重要信号(例如概率幅或强度),L2 distance 会保留这类差异。关于余弦相似度的经典定义、取值范围与对向量模长的不敏感性,可参考百科与教材性资料。(Wikipedia)


2. 数学关系:归一化之后的等价排序

将两个向量 xy 归一化到单位球面(||x|| = ||y|| = 1)后,二者的欧氏距离与余弦相似度存在直接变换关系:

∥ x − y ∥ 2 = 2 ( 1 − cos ⁡ θ ) \|x-y\|^2 = 2(1-\cos\theta) xy2=2(1cosθ)

这意味着在逐向量做 L2 归一化的前提下,用 L2 distance 做最近邻排序,与用 cosine similarity 做最近邻排序得到完全一致的排序结果(单调变换不改变排序)。工程上由此衍生出一个极其重要的经验:如果嵌入在落库前统一做了 L2 归一化,那么你选择 L2 distance 还是 cosine similarity,对检索的排名没有本质差异。该等价关系在教材与库文档中都有明确描述。(Wikipedia, Scikit-learn)

许多主流嵌入在发布时就已经单位范数归一化。例如 OpenAI 官方说明:其文本嵌入默认长度为 1,因此使用 cosine similarity 与欧氏距离会得到相同排序;并且在这种设置下,cosine similarity 还能退化为简单的内积计算,从而略快一些。(OpenAI Help Center)


3. 模型与库的实现差异:从训练目标到检索索引

并非所有嵌入都针对同一度量训练。许多对比学习类的句向量模型(如 Sentence Transformers 的大量模型)直接用余弦或内积为目标进行优化,这类向量更关心夹角而非模长;在此语境下,cosine similarity 或对单位向量使用的 dot 度量往往更契合。官方文档也指出:若模型末端包含 Normalize 层,计算相似度时直接用 dotcosine 更快,因为不需要再归一化一次,且结果等价。(SentenceTransformers)

在相似度搜索库层面,FAISSMilvuspgvector 等都提供对 L2cosine(以及 inner product)的原生支持:

  • FAISSMETRIC_L2 实际上报告的是平方欧氏距离(省去开方以提速),排序依然与欧氏距离一致。(GitHub)
  • Milvus 明确支持 L2IPCOSINE 等多种度量,便于针对模型特性选择合适的度量与索引。(Milvus)
  • pgvectorPostgreSQL 中提供 <->(L2 距离)、<=>(cosine 距离)与 l2_distancecosine_distance 等函数,并对 HNSWIVFFlat 提供按度量类型区分的操作类与索引。(GitHub)

这些实现细节意味着:当你迁移或替换底层向量引擎时,除了检查嵌入的训练目标和归一化策略外,也要核对索引类型与度量是否匹配,避免由于度量不一致导致召回退化。Milvus 社区文档也强调了度量与嵌入训练目标错配会带来次优结果。(Milvus)


4. 什么时候用 L2 distance,什么时候用 cosine similarity

更偏向 cosine similarity 的情况

  • 嵌入已经做了 L2 归一化,且模型以余弦或内积为训练目标。这是绝大多数文本检索、问答 RAG 的常态。OpenAI 官方 FAQ 的建议也是采用余弦相似度。(OpenAI Help Center)
  • 文本长度变化很大(例如一段问句与一段长文),你希望忽略模长带来的幅度偏置,只看语义方向一致性。这在检索 FAQ、政策条款、工单知识库时尤为常见。(Wikipedia)

更偏向 L2 distance 的情况

  • 嵌入模长本身携带了意义(如置信度、强度或某些非归一化的统计特征),你希望将这种幅度差异计入相似度度量。
  • 底层库或索引在 L2 上有成熟的加速路径或更稳定的召回性能,且你的向量未做归一化(或不方便归一化)。在 FAISS 的纯 L2 pipeline 中,平方距离排序与欧氏距离排序等价,且实现上更高效。(GitHub)

数据库层面的选型
PostgreSQL + pgvector 里,如果你使用 <-> 做排序,就在用 L2 distance;使用 <=> 就在用 cosine distance。同时可以对不同度量各建一类索引,例如 vector_l2_opsvector_cosine_ops。这使得你可以在同一张表按不同度量进行 A/B 试验。(GitHub)


5. 真实世界的差异:长文档偏置与单位化消解

设想一个企业知识库的 RAG:

  • 文档 A:退货时效 的短规则说明,50 字。
  • 文档 B:售后政策总则,包含退货、换货、保修等多个章节,共 5000 字。

如果将段落嵌入直接平均或求和落库,不做 L2 归一化,文档 B 的嵌入模长通常更大。在 L2 distance 下,查询 如何办理退货 这类短句与文档 B 的距离可能会因为模长不匹配而偏大,导致 B 里与退货高度相关的小节没有进入前列;而在 cosine similarity 下,由于只看方向,B 中与退货相关的段落即便整体模长更大,也更容易被召回靠前。对这一点,cosine similarity 的定义与性质给出了理论支撑。(Wikipedia)

当你把所有嵌入统一做 L2 归一化后,上述偏置会显著消解,且 L2 distancecosine similarity 的排名将一致。这个工程实践与数学关系来自多处权威资料与库文档的一致结论。(Scikit-learn, OpenAI Help Center)


6. 与向量数据库的衔接:SQL、索引与检索片段

pgvector 为例,下面的 SQL 片段展示了两种度量的使用方式与函数:

-- 启用扩展
CREATE EXTENSION IF NOT EXISTS vector;

-- 建表,假设嵌入维度为 384
CREATE TABLE IF NOT EXISTS docs (
  id BIGSERIAL PRIMARY KEY,
  title TEXT,
  body TEXT,
  embedding VECTOR(384)
);

-- L2 距离的 HNSW 索引
CREATE INDEX IF NOT EXISTS idx_docs_l2
  ON docs USING hnsw (embedding vector_l2_ops);

-- cosine 距离的 HNSW 索引
CREATE INDEX IF NOT EXISTS idx_docs_cos
  ON docs USING hnsw (embedding vector_cosine_ops);

-- 用 L2 距离做 KNN
SELECT id, title, embedding <-> $1 AS dist
FROM docs
ORDER BY embedding <-> $1
LIMIT 5;

-- 用 cosine 距离做 KNN,并把距离转为相似度
SELECT id, title, 1 - (embedding <=> $1) AS cos_sim
FROM docs
ORDER BY embedding <=> $1
LIMIT 5;

-- 直接调用函数形式
SELECT l2_distance(embedding, $1) AS d2,
       1 - cosine_distance(embedding, $1) AS cos_sim
FROM docs
ORDER BY d2
LIMIT 5;

以上操作符与函数、索引族的定义可在 pgvector 官方文档中查到。(GitHub)


7. 可运行的 Python 实验:一眼看懂两种度量的差异

下面给出一个完整、可直接运行的 Python 脚本。它只依赖标准库,通过可重复的哈希-随机手段把文本映射到低维嵌入向量,用 L2 distancecosine similarity 分别做最近邻检索,并对比在未归一化归一化两种策略下的排名差异。脚本同时模拟一个简化版的 RAG:取 Top-k 片段拼接给 LLM(这里仅打印片段,不调用外部模型)。

说明:为了满足你在本地“一键可跑”的诉求,下面代码不依赖 numpy,运算完全用纯 Python 实现;在真实项目中建议用 numpy/Faiss/Milvus/pgvector 获得更高性能与更大规模能力。FAISSL2 上报告平方距离从而省掉开方,这样的实现细节在实际库里很常见。(GitHub)

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import math
import random
import re
from typing import List, Tuple

# -----------------------------
# 1) 简单可重复的文本嵌入器(纯 Python,无第三方依赖)
#    方法:按词切分;每个词通过 hash 设定随机种子,生成固定维度向量;句向量为词向量求和
# -----------------------------

DIM = 32  # 低维一点便于阅读
TOKEN_RE = re.compile(r'\w+', re.UNICODE)

def tokenise(text: str) -> List[str]:
    # 极简英文/数字 token;中文可按字或引入更完善的分词器,这里为演示从简
    return [t.lower() for t in TOKEN_RE.findall(text)]

def seed_for_token(tok: str) -> int:
    # 稳定的伪随机种子
    return abs(hash(tok)) % (2**32)

def rand_unit_vector(seed: int, dim: int = DIM) -> List[float]:
    rnd = random.Random(seed)
    v = [rnd.uniform(-1.0, 1.0) for _ in range(dim)]
    # 单位化每个词向量,使词的权重主要体现在出现次数上
    n = math.sqrt(sum(x*x for x in v)) or 1.0
    return [x / n for x in v]

def embed(text: str, dim: int = DIM, normalize_sentence: bool = False) -> List[float]:
    toks = tokenise(text)
    if not toks:
        return [0.0] * dim
    vec = [0.0] * dim
    for tok in toks:
        sv = rand_unit_vector(seed_for_token(tok), dim)
        for i in range(dim):
            vec[i] += sv[i]
    if normalize_sentence:
        n = math.sqrt(sum(x*x for x in vec)) or 1.0
        vec = [x / n for x in vec]
    return vec

# -----------------------------
# 2) 相似度与距离
# -----------------------------

def dot(a: List[float], b: List[float]) -> float:
    return sum(x*y for x, y in zip(a, b))

def l2_distance(a: List[float], b: List[float]) -> float:
    return math.sqrt(sum((x-y)**2 for x, y in zip(a, b)))

def cosine_similarity(a: List[float], b: List[float]) -> float:
    na = math.sqrt(sum(x*x for x in a)) or 1.0
    nb = math.sqrt(sum(y*y for y in b)) or 1.0
    return dot(a, b) / (na * nb)

# -----------------------------
# 3) 简化的向量检索
# -----------------------------

def knn(query_vec: List[float],
        docs: List[Tuple[str, str, List[float]]],
        k: int = 3,
        metric: str = 'cosine') -> List[Tuple[str, str, float]]:
    scored = []
    for doc_id, title, vec in docs:
        if metric == 'cosine':
            s = cosine_similarity(query_vec, vec)  # 越大越相似
            scored.append((doc_id, title, s))
        elif metric == 'l2':
            d = l2_distance(query_vec, vec)        # 越小越相似
            scored.append((doc_id, title, -d))     # 为了统一按降序排分值,取负号
        else:
            raise ValueError('unknown metric')
    scored.sort(key=lambda x: x[2], reverse=True)
    return scored[:k]

# -----------------------------
# 4) 数据集:近义主题 + 长文档偏置演示
# -----------------------------

CORPUS = [
    ('A', '退货时效', '顾客可在 7 天内无理由退货。生鲜类与个性化定制商品不适用。'),
    ('B', '售后政策总则', '本政策覆盖退货、换货、维修与保修条款,共分十章。第一章说明退货时效与流程;第二章说明退款路径;其余章节与退货无关。'),
    ('C', '物流异常处理', '包裹延误、丢件时的处理办法与客服流程。'),
    ('D', '价格保护规则', '若在 30 天内同款商品降价,可申请价保退差。'),
    ('E', '退款到账时间', '退款通常在 3 到 5 个工作日原路退回,节假日顺延。'),
]

def build_index(normalize_sentence: bool = False):
    index = []
    for doc_id, title, text in CORPUS:
        vec = embed(text, normalize_sentence=normalize_sentence)
        index.append((doc_id, title, vec))
    return index

def demo(query: str):
    print('='*70)
    print('Query:', query)

    # 未归一化索引
    idx_raw = build_index(normalize_sentence=False)
    q_raw = embed(query, normalize_sentence=False)

    # 归一化索引
    idx_norm = build_index(normalize_sentence=True)
    q_norm = embed(query, normalize_sentence=True)

    # 未归一化下的结果
    top_cos_raw = knn(q_raw, idx_raw, k=3, metric='cosine')
    top_l2_raw  = knn(q_raw, idx_raw, k=3, metric='l2')

    # 归一化下的结果(理论上两者排名应一致或高度一致)
    top_cos_n = knn(q_norm, idx_norm, k=3, metric='cosine')
    top_l2_n  = knn(q_norm, idx_norm, k=3, metric='l2')

    def pretty(tag, items):
        print(f'\n[{tag}]')
        for doc_id, title, score in items:
            print(f'  {doc_id} - {title}  score={score:.4f}')

    pretty('Raw-Cosine', top_cos_raw)
    pretty('Raw-L2', top_l2_raw)
    pretty('Norm-Cosine', top_cos_n)
    pretty('Norm-L2', top_l2_n)

if __name__ == '__main__':
    demo('如何办理退货')
    demo('退款多久能到帐')

如何阅读输出

  • Raw-CosineRaw-L2 展示未做句向量归一化时,两种度量的排名差异。由于文档 B 较长,聚合后模长更大,在 L2 下可能被距离偏置影响,排名下滑。
  • Norm-CosineNorm-L2 列展示在句向量统一 L2 归一化后,两个度量的前几名高度一致或完全一致,验证了理论上的等价排序结论。对应的理论与官方实践建议见文档。(Wikipedia, Scikit-learn, OpenAI Help Center)

8. 面向 RAG 的工程策略:可落地的清单

关于嵌入与预处理

  • 若使用 OpenAISentence Transformers 等常见模型,建议在落库前做 L2 归一化;这让你可在检索时自由切换 L2cosine 而不改变排序,亦可在不同数据库/索引之间切换而不影响语义表现。(OpenAI Help Center, SentenceTransformers)
  • 若模型训练目标是内积或余弦,且推理阶段已做 Normalize,可以在检索里用 dot(对单位向量等价于 cosine),减少归一化的开销。(SentenceTransformers)

关于索引与引擎

  • FAISS 中使用 IndexFlatL2 或基于 L2 的 IVF/HNSW 索引时,记住其报告的是平方欧氏距离,排序不受影响。(GitHub)
  • Milvus 中选择 L2IPCOSINE 时,尽量与模型训练目标保持一致,避免错配。(Milvus)
  • pgvector 中,<-> 对应 L2<=> 对应 cosine,并可对不同度量分建索引(vector_l2_opsvector_cosine_ops);函数层面亦提供 l2_distancecosine_distance,便于可读的 SQL。(GitHub)

关于长文档与切片

  • 对超长文档进行分段并各自嵌入,是缓解 L2 对模长敏感性的根本做法;同时结合归一化,可进一步中和篇幅带来的偏置。
  • RAG 的重排阶段可以引入交叉编码器(cross-encoder)或基于多特征的重排器,把 L2/cosine 的候选 Top-k 再做语义层面的细致判别;Sentence Transformers 文档提供了这类重排器的使用接口说明。(SentenceTransformers)

9. 案例分析:企业客服知识库的两阶段检索

设定一个真实业务:电商客服需要回答退货/退款/价保相关问题,知识库里既有简明 FAQ,也有包含多章大纲的政策总则与合规条文。

构建步骤

  • 文档切片:每个段落 200-500 字,避免把无关章节混到同一个向量里。
  • 嵌入生成:选用以余弦/内积为目标训练的句嵌入模型,输出后做单位化
  • 向量库:在 pgvector 中保存 embedding 字段,并同时建立 vector_l2_opsvector_cosine_ops 两类索引,便于 A/B 试验与回退。(GitHub)
  • 召回阶段:采用 cosine 排序(或对单位向量使用内积 dot),Top-k=10。
  • 重排阶段:使用 cross-encoder 进行细粒度语义匹配,并加入额外特征(段落标题、更新时间、来源可信度)。Sentence Transformers 生态提供易用的交叉编码器接口。(SentenceTransformers)

故障与定位

  • 若发现长文档段落在 L2 下召回靠后,检查是否忘记对段落向量做归一化;验证方法是切换到 cosine 并观察排名变化是否更符合语义预期。
  • 若不同引擎之间切换导致效果大幅波动,核查是否度量与索引错配(比如数据是单位向量,却在某引擎里用了非单位化的内积/欧氏距离)。Milvus 的知识库提醒了度量与训练目标错配的风险。(Milvus)

10. 常见问答

问:为什么我用 FAISSL2 距离,打印出来像是没开方的数?
答:FAISS 报告的是平方欧氏距离,为了性能没有做开方;不用担心,排序不受影响。(GitHub)

问:我的嵌入已经是单位向量了,用 cosineL2 有区别吗?
答:在排名上没有区别,两者是单调变换关系;可以按实现便利性与性能选择。例如直接用内积实现的 cosine 会更快。(Wikipedia, Scikit-learn, OpenAI Help Center)

问:pgvector 里面 L2DISTANCECOSINE_SIMILARITY 应该怎么写?
答:在 pgvector 的 SQL 里,函数名是 l2_distance(vec1, vec2)cosine_distance(vec1, vec2);操作符为 <->(L2 距离)和 <=>(cosine 距离)。如果你要得到相似度,通常用 1 - cosine_distance(...)。(GitHub)


11. 关键要点小结

  • cosine similarity 看角度、忽略模长;L2 distance 同时看角度与模长。(Wikipedia)
  • 归一化到单位向量后,两者排序等价 ∥ x − y ∥ 2 = 2 ( 1 − cos ⁡ θ ) \|x-y\|^2 = 2(1-\cos\theta) xy2=2(1cosθ)。(Wikipedia, Scikit-learn)
  • 许多文本嵌入默认归一化,官方推荐用余弦/内积做检索。(OpenAI Help Center)
  • 工程层面,FAISSMilvuspgvector 都对两种度量提供原生支持与相应索引;在 pgvector<->L2<=>cosine。(GitHub, Milvus)

参考与延伸阅读

  • scikit-learn 对余弦与单位化后的等价关系说明。(Scikit-learn)
  • 余弦相似度的百科条目与与欧氏距离的关系推导。(Wikipedia)
  • OpenAI 对嵌入的归一化与度量选择建议。(OpenAI Help Center)
  • FAISS 的度量类型与平方 L2 说明。(GitHub)
  • Milvus 的度量类型与选择提示。(Milvus)
  • pgvector 的操作符、函数与索引族。(GitHub)

标题:RAG 实战中的 L2 distancecosine similarity:从理论等价到数据库落地与可运行实验

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐