
前阵子调一个RAG问答系统用户问“苹果公司创始人是谁”召回第一屏里全是“苹果的营养价值”。当时第一反应是embedding模型选错了换了好几个模型都不见好。后来把向量拉出来逐条看才意识到问题不在模型而在每个向量的magnitude——也就是向量模长。这个常被一句“学过线代”就带过去的概念正在悄悄决定你的检索排序、召回质量甚至整个RAG系统的成败。这篇文章不是要再讲一遍“向量是什么”而是想借magnitude这个关键词把我自己在向量检索和RAG项目里踩过的坑、验证过的结论、能直接抄的代码一次讲透。如果你正在做embedding召回、相似度检索或者准备搭一套新的RAG pipeline这篇文章应该能帮你少走不少弯路。内容会涉及数学推导但都会配上最直白的解释和可运行的代码不会让你看着公式发呆。1. magnitude到底是什么先给“向量长度”一个直觉1.1 向量的“长度”不是可有可无的装饰在二维平面上向量[3, 4]的长度是5这个初中就该会算√(3²4²)。到了高维空间比如现在常用embedding动辄768维、1024维我们没法再靠肉眼判断“谁更长”但计算方式完全一样——对每个维度的数值取平方全部加起来再开根号。这就是L2范数也就是magnitude最常用的定义。在实际代码里用NumPy一行就能算出来import numpy as np vec np.array([3.0, 4.0]) print(np.linalg.norm(vec)) # 5.0这里有个特别容易被忽略的点当你拿到一个embedding向量你看到的是一堆浮点数可能几百上千个。这些数字本身没有意义有意义的是两个属性——方向与长度。方向决定了“这个向量在语义空间里指向哪里”长度决定了“它距离原点有多远”。很多检索项目里大家只关心方向用余弦相似度去比较也有不少项目直接上内积或欧氏距离这时候长度就成了一个隐形的调节器会让排序结果发生你根本预料不到的变化。1.2 两种相似度公式里magnitude藏在哪先看三组最常见的相似度计算方法我直接列公式余弦相似度cos(A, B) (A·B) / (‖A‖ × ‖B‖)点积内积A·B Σ(Ai × Bi)欧氏距离‖A - B‖ √(Σ(Ai - Bi)²)注意看余弦相似度的分母上明晃晃地写着两个向量的magnitude。它的逻辑是先把两个向量的长度都“约掉”只比方向。点积没有任何归一化操作长度直接参与乘积所以向量越长点积天然越大。欧氏距离算的是两个向量之间的“差向量”的长度如果两个向量本身的长度都偏大哪怕方向差得不多欧氏距离也可能非常大。这里的关键结论是你选择哪种度量方式本质就是在决定你有多在乎magnitude。用余弦就是明确告诉系统“我不管向量多长只关心方向”。用点积就是让长度和方向一起参与打分。用欧氏距离就是在高维空间里看“直线距离”而这个距离又会被两个向量的长度同时影响。1.3 到底什么时候该用点积、什么时候该用余弦我自己的判断标准很简单先看你的embedding向量模长是不是恒定。如果模型输出本来就是归一化的也就是每个向量的模长都等于1或非常接近1那么点积和余弦算出来完全一样用哪个都无所谓。如果模长不恒定而且你的文本长短差异明显那点积就很容易偏向长向量这时候最好归一化或者直接用余弦。度量方式是否受magnitude影响适用场景注意事项点积/内积是且影响很大模型输出已归一化或刻意保留长度信息不归一化时排序容易被长向量带偏余弦相似度否长度会被约掉通用检索、FAQ匹配、语义相似度在向量数据库中通常能直接选欧氏距离是长度同时影响两边聚类、离群点检测、已经归一化的向量双归一化后与余弦单调一致但分数含义不同这段话值得反复读几遍。因为很多人在搭建检索系统的时候只是“选了一个相似度计算方式”却根本没想过这个选择背后到底是在利用magnitude还是在忽略它。2. embedding的magnitude为什么会“偷走”检索效果方向与长度解耦2.1 一个二维例子把问题说得明明白白文字解释再多不如直接看一个数值例子。假设我们现在有三条数据文档A的向量是 A [1, 0]文档B的向量是 B [2, 2]查询Q的向量是 Q [0.9, 0.3]先算一下各向量模长‖A‖ 1‖B‖ √(44) ≈ 2.828。如果直接用点积排序A·Q 1×0.9 0×0.3 0.9B·Q 2×0.9 2×0.3 1.8 0.6 2.4这个结果里文档B明显排在A前面。但如果改用余弦相似度也就是先把A和B都归一化A归一化后还是 [1, 0]和Q的余弦就是0.9B归一化后是 [0.707, 0.707]和Q的余弦是0.707×0.9 0.707×0.3 ≈ 0.848排序反转了A现在排在B前面。为什么会这样因为B虽然在方向上和Q差得更远但它长度更长在点积里直接把分数“撑”大了。归一化一上来长度被约掉真实的语义方向关系才浮出水面。这个例子虽然只有二维但机制和在768维空间里的情况完全一样。2.2 真实场景里什么因素会让向量模长差异巨大你可能会觉得上面这个例子是不是故意构造出来的实际场景中向量模长的差异确实存在而且在某些情况下相当明显。第一个因素是文本长度。很多开源的embedding模型比如常用的bge系列、e5系列内部都会对token向量做池化。池化方式如果是mean pooling理论上平均值会抵消一部分长度影响但由于Transformer层里还有残差连接、LayerNorm这样的结构最终输出向量的模长在不同长度文本之间并不恒定。我在实际项目中见过短文本模长0.7左右长文本模长能到1.5甚至更高。第二个因素是文本里的重复信息。比如一条文档反复出现某个领域的高频词这些词的embedding方向高度一致池化之后会让整个向量往某个方向“拉长”模长也会变大。第三个因素是你自己拼出来的向量。很多系统会做多模态或多特征拼接比如把标题的embedding和正文的embedding直接concat起来这时候新向量的维度翻倍模长天然比单独任何一部分都大。如果不做归一化就进检索索引长拼接向量会占据极大优势。2.3 归一化究竟做了什么一个值得记住的数学等价归一化的操作极其简单每个向量除以它自己的模长也就是 A_normalized A / ‖A‖。做完之后向量的方向不变但长度变成1。这个操作在相似度计算中有一个非常漂亮的等价关系cos(A, B) A_normalized · B_normalized也就是说把两个向量都归一化之后再做点积结果就等于余弦相似度。很多向量数据库之所以推荐“归一化内积”的组合原因就在这里。还有一个扩展结论值得记如果两个向量都已经归一化那么欧氏距离和余弦相似度之间也有明确的单调对应关系‖A - B‖² 2 - 2×cos(A, B)这意味着在双归一化前提下你用欧氏距离和用余弦相似度排序结果是一样的。但注意“双归一化”这四个字只有一边归一化这个关系完全不成立排序也会和真正的余弦相似度产生偏差。很多项目出问题恰恰就是只归一化了入库向量查询向量没归一化或者反过来。3. 实测同一个向量库里归一化前后Top-K排序相差多少3.1 实验设计这一节讲的是我自己在本地复现的一个小实验场景很简单值得大家照着跑一遍。我用了大约200条中文FAQ语料包含咨询服务、产品使用、售后问题等几个类别文本长度从十几个字的短句到两三百字的段落不等。embedding模型用的是BAAI/bge-small-zh-v1.5维度是512维。实验分三组对比原始向量 点积不归一化原始向量 欧氏距离不归一化归一化向量 点积等价于余弦每组都对同样的查询做Top-5召回然后比较排序是否有漂移。核心代码框架如下from sentence_transformers import SentenceTransformer import numpy as np model SentenceTransformer(BAAI/bge-small-zh-v1.5) docs [如何申请发票, 发票申请流程是什么, ...] # 你的语料 queries [怎么开发票, ...] # 不归一化 emb_docs model.encode(docs) emb_queries model.encode(queries) # 归一化 emb_docs_norm emb_docs / np.linalg.norm(emb_docs, axis1, keepdimsTrue) emb_queries_norm emb_queries / np.linalg.norm(emb_queries, axis1, keepdimsTrue) # 对每个query计算点积排序 scores emb_docs_norm emb_queries_norm.T top5 np.argsort(-scores, axis0)[:5]3.2 我看到的典型结果实验跑完之后第一感觉是“果然如此”。并不是每个查询的排序都会天翻地覆但只要有明显的长短文本混排漂移就出现了。这里给一个示意性质的对比不是精确到每一条的数据但现象非常典型查询未归一化Top-3归一化Top-3家里断网了怎么办长文“网络故障排查手册完整版”短句“断网怎么处理”发票什么时间能到长文“发票与税务问题全流程说明”短句“发票多久到账”怎么退款长文“售后政策详解与退款流程说明”短句“退款入口在哪”可以看到未归一化时长文本因为模长偏大在点积里占了明显便宜归一化之后真正在语义方向上更接近查询的短句回到了前面。这个现象在FAQ这种长短混合的场景里非常常见。更有意思的是欧氏距离那一组。未归一化时欧氏距离的结果甚至更离谱因为长文本向量模长大和高模长文档之间的距离被“拉开”导致很多短查询觉得长文档“很远”。归一化之后再算欧氏距离排序基本和余弦一致符合那个数学等价关系。3.3 结论与判断标准基于这个实验我总结出几条判断标准可以直接套用在自己的项目里如果语料里文本长度差异很大建议强制归一化否则点积和欧氏距离都会偏向长文本。如果向量来自不同模型或者不同分块策略归一化几乎是必须的因为你根本不知道两个模型的模长分布是否一致。如果语料长度均匀模长分布也比较集中那归一化前后排序差异不会太大可以酌情省略。如果你已经选了向量数据库的余弦距离那实际上已经约掉了magnitude不需要再额外归一化。4. 代码实操算magnitude、做归一化到底该怎么写才稳4.1 用NumPy检查向量的模长分布我常跟同事说接入一个新的embedding模型第一件事不是看效果而是看模长分布。只要一行代码就能快速了解这个模型输出向量的“脾气”norms np.linalg.norm(embeddings, axis1) print(min:, norms.min()) print(max:, norms.max()) print(mean:, norms.mean()) print(std:, norms.std())如果min和max差距很大比如0.5到1.8那这个模型输出明显不是归一化的后续检索逻辑就必须考虑magnitude的影响。如果所有模长都在0.9999到1.0001之间恭喜你模型内部已经做了归一化直接用点积和用余弦结果一样。4.2 两种归一化的正确姿势手动归一化的标准写法是这样的norms np.linalg.norm(embeddings, axis1, keepdimsTrue) embeddings_norm embeddings / (norms 1e-8)加一个1e-8的小量是为了防止某个zero向量除零报错。虽然embedding几乎不可能是全零向量但在批量处理时加这个保护是个好习惯。如果你用的是sentence-transformers更简单直接在encode的时候打开开关embeddings model.encode(docs, normalize_embeddingsTrue)这个参数会在内部帮你完成归一化而且用的就是上面那个公式。在FAISS里进索引之前也一定要想清楚。IndexFlatIP是内积索引如果向量没归一化排序就会受magnitude影响IndexFlatL2是欧氏距离索引如果向量没归一化排序同样会偏。很多人直接把原始向量塞进IndexFlatIP然后指望它“等同于余弦”这是不对的。正确做法是先归一化再塞进IndexFlatIP这时候内积才等于余弦。import faiss dim embeddings_norm.shape[1] index faiss.IndexFlatIP(dim) index.add(embeddings_norm.astype(float32))4.3 第一个容易踩的坑入库和查询必须用同一套处理这个坑我在项目里见过太多次。有人在离线管道里把文档向量归一化后入库在线查询却忘了对query向量做同样的归一化。这时候算出来的分数等于在一个已归一化空间里混进了一个未归一化的查询排序结果完全乱套。更隐蔽的是这个如果向量数据库新建索引的时候勾选了“normalize”数据库会自动在入库时做归一化。但有些数据库的查询接口并不会自动归一化查询向量需要你手动传进去。每一环都要对一遍最好在代码里写好预处理函数入库和查询都调用同一个函数从根上杜绝不一致。4.4 第二个坑范数不是只有L2很多人一提归一化默认就是除以L2范数。但embedding体系里其实还有不同的范数概念。如果你用的是稠密向量L2是绝对主流没问题。但如果你做的是稀疏向量检索或者跟BM25这类词频统计方法混用有些库内部用的是L1归一化或者max归一化处理逻辑完全不一样。把稠密向量的L2归一化逻辑直接套到稀疏向量上轻则效果变差重则计算出来的相似度毫无意义。碰到这类场景先读文档搞清楚底层用的到底是哪种范数再动手。5. 由magnitude引出的三个高频深坑池化、混合检索与向量库配置5.1 均值池化会改变向量模长长文本不一定更长很多人有个错误直觉文档越长embedding向量就越长。实际做mean pooling的时候最后一步是把所有token向量平均理论上平均之后模长不应当随长度单调增长。但前面提到过Transformer内部的残差连接和LayerNorm会让不同长度文本的向量分布产生差异实际输出的模长波动仍然存在。更需要注意的是分块策略。很多RAG系统会把长文档切成固定长度的chunk每个chunk单独embedding。如果切块大小不统一有的块300字有的块50字那么不同块的模长分布可能也不一致。检索的时候模长偏大的chunk在点积里就是占便宜。这也是为什么分块之后最好重新检查一遍向量模长分布而不是只检查文本长度。5.2 混合检索里向量分数和BM25分数必须对齐量纲现在做RAG很多人会采用混合检索把向量召回和BM25关键词召回的分数加权融合。这时候magnitude的坑会绕一个弯出现向量相似度分数如果不做归一化它的分布范围可能很宽特别是在向量模长不统一的情况下比如有的query点积最高分是25有的query最高分只有0.3。直接把这两个分数和BM25分数做线性加权整个权重就废掉了。常规做法是先对向量分数做min-max归一化或者用z-score标准化把分数范围压到一个可控区间里再和BM25分数融合。这一步看起来和向量magnitude没有直接关系但本质还是在处理“量纲对齐”问题——一个来自向量长度的隐性量纲。5.3 向量数据库的cosine选项和“手动归一化内积”并不完全等价很多向量数据库提供了cosine距离看起来一选就万事大吉。但不同库的实现方式其实有差异。有的库内部是先把两个向量都归一化再算内积然后返回1减去这个内积作为“距离”所以值越小越相似。有的库为了性能可能在索引压缩或量化过程中对向量做了近似处理归一化的精度会受影响。更关键的是如果你在建索引时选择了内积IP那数据库不会替你约掉magnitude排序就会受到向量模长影响。有的库在add数据的时候提供“自动归一化”开关但查询向量是否也走同样的归一化需要自己确认。我的建议是无论用哪个数据库都在代码里明确地手动归一化不依赖数据库的默认行为这样最容易排查问题。5.4 一个我一直在用的小技巧同一份向量保留两份现在很多项目为了省存储只保留一份向量。我个人的习惯是正式检索用的向量索引存归一化之后的版本同时把原始向量另存一份用于分析、聚类或者可视化。原因也很简单。归一化会抹掉magnitude信息这在检索时很有效但在做聚类的时候模长其实含有额外信息比如高置信度领域特征或更复杂的表达。保留两份向量检索和探索两不误。而且这个成本没有想象中高现在向量存储本来就有冗余备份需求多一份向量索引在磁盘上也就是多一个embedding文件的事。回到开头那个RAG问答系统的问题。最后我做的改动非常简单检查了所有文档向量的模长分布发现长短差异确实明显然后在离线管道和在线查询里统一加了normalize_embeddingsTrue同时把检索度量从点积显式改成了余弦。改完再跑一遍测试集召回质量肉眼可见地稳定了那些“苹果营养价值”乱入的case基本消失。整个排查过程里我最大的体会就是别把magnitude当成一个躺在教科书里的数学名词就放过去。很多检索效果问题不是模型不够好不是chunk切得不够细而是向量进入索引之前那一行归一化代码没写对。建议你也在项目里加一行检查打印一下所有embedding模长的min和max如果差距明显就知道下一步该怎么做了。