做RAG和语义搜索这些年,几乎每个项目都在跟embedding打交道。但说句实在话,真正把“embedding计算过程”从头到尾讲清楚的人不多,多数教程一上来就调库、跑模型、算相似度,至于中间那几步到底发生了什么、为什么要这样做,很多人是迷糊的。这篇文章我就把embedding从输入到输出的完整计算链路拆开揉碎,结合这几年实际项目中踩过的坑,把核心原理、模型选型、微调方式和排查技巧一次讲透,希望能帮刚入门的朋友少走弯路,也能给有经验的同行提供一些参考。
1. 先从根上理解:Embedding到底在算什么
1.1 从One-Hot到稠密向量
要说清楚embedding,必须先回到它要解决的问题。在深度学习普及之前,自然语言处理面对的最大难题是:计算机不认识文字,只认识数字。早期最朴素的做法是One-Hot编码,比如词表里有“苹果”“香蕉”“手机”三个词,就把每个词表示成[1,0,0]、[0,1,0]、[0,0,1]这样的向量。这种方式简单粗暴,但有两个致命问题:
第一是维度灾难。真实场景的词表动辄几十万,每个词都是一个几十万维的向量,存储和计算成本都高得离谱。第二是语义鸿沟。[1,0,0]和[0,1,0]之间没有任何几何关系,模型无法从向量中感知到“苹果”和“香蕉”都是水果,而“手机”和它们无关。
Embedding的核心思路,就是把这种超高维、离散、无语义信息的稀疏表示,压缩成一个低维、连续、携带语义信息的稠密向量。说白了,就是用训练数据让模型学会把语义相近的词,映射到向量空间中距离更近的位置。这个“映射过程”,就是embedding的计算过程。
从数学上看,embedding本质上是一个从离散符号空间到稠密向量空间的函数。这个函数可以是一个简单的矩阵乘法(查表),也可以是一个复杂的神经网络编码器(比如BERT),具体怎么做,取决于你用的是哪一代的embedding技术。
1.2 计算过程的全貌:查表还是训练?
很多人以为embedding就是拿一个训练好的模型,把文本扔进去,出来的向量就是结果。这个理解没错,但忽略了中间的关键细节。一次完整的embedding计算,实际上包含三个环节:
第一个环节是输入处理。文本要先经过分词器(Tokenizer)切成token序列,每个token对应词表中的唯一ID。第二个环节是向量映射。把token ID序列输入模型,得到一个或多个向量。如果是Word2Vec这类静态模型,直接用查表方式取向量;如果是BERT这类上下文模型,还要经过多层Transformer编码,每个token都会得到一个上下文相关的向量。第三个环节是向量融合。一个句子通常包含多个token,需要把多个token向量合并成一个句子向量,这一步叫Pooling(池化)。
这三个环节里最容易出问题的就是第三个。我发现很多新人直接用模型的last_hidden_state第一个token的向量当句子向量,或者直接对所有token向量求平均,结果相似度计算总是不对劲。后面我会详细说Pooling策略怎么选,这里先记住一个结论:embedding计算不是“模型输出一个向量”这么简单,而是一整套pipeline,每一环都影响最终效果。
2. Embedding模型家族:不同场景怎么选
2.1 经典静态Embedding:Word2Vec、GloVe、FastText
先说静态embedding,代表是Word2Vec、GloVe和FastText。这类模型的特点是:每个词对应一个固定的向量,无论上下文怎么变,“苹果”的向量永远是同一个。Word2Vec有两种训练方式:CBOW用上下文预测中心词,Skip-gram用中心词预测上下文。训练完成后,模型中间层的权重矩阵就是词向量表。
Word2Vec能学到语义关系,比如经典的“国王-男人+女人=女王”,这是因为它训练时利用了词的共现统计信息。但静态embedding的短板也很明显:一词多义问题无法解决。“苹果”指水果还是公司,这两个含义共享一个向量,语义上必然存在冲突。GloVe在共现矩阵上做矩阵分解,理论上能更好利用全局统计信息,但多义词问题依然存在。FastText的核心改进是把词拆成字符n-gram,对未登录词(OOV)更友好,因为它可以通过子词组合生成词向量。
在今天的RAG和人设问答场景里,静态embedding已经很少作为主力方案了,但它作为理解embedding原理的入门材料非常合适。而且在某些垂直领域,如果数据量小、计算资源有限,用FastText训一个自家词表,效果可能比大模型还好用。
2.2 上下文感知Embedding:BERT及其变体
2018年BERT出现后,embedding领域发生了质变。BERT对每个token生成的向量都是上下文相关的。“苹果”在“苹果很好吃”和“苹果发布了新手机”两个句子里,得到的向量完全不同。这种动态语义表示能力,让文本相似度计算、语义搜索的精度大幅提升。
BERT类模型的embedding计算过程和Word2Vec有本质区别。Word2Vec是查表,BERT是编码。输入token经过Embedding层后,还要经过12层(base版)或24层(large版)Transformer编码器,每层都在做自注意力计算,层层堆叠后,最后一层的输出向量就是每个token的上下文表示。这也带来一个问题:计算开销比静态embedding大好几个数量级。
实际使用中,用BERT类模型做embedding,通常不会直接用最后一层原始输出,而是结合不同层的信息。比如有研究说倒数第二层或中间层的输出在某些任务上效果更好,还有一些方案是把最后四层加权求和再池化。这就是为什么会在很多开源embedding模型的说明文档里看到“layer_strategy”或者“pooling_method”这类参数——它们决定了怎么从模型的内部状态中提取最终的句子向量。
2.3 专为检索优化的Embedding模型:BGE、M3E、OpenAI、Qwen3-VL
从2023年开始,社区出现了一大批专为检索和RAG优化的embedding模型,比如智源的BGE系列、M3E(Moka Massive Multilingual Embedding)、OpenAI的text-embedding-3-small/large,以及Qwen系列的多模态embedding模型。
这些模型和通用BERT类模型的关键区别在于训练目标。通用模型训练目标是语言建模(预测mask词),而检索优化模型训练目标是让相似文本对的向量距离更近、不相似文本对的向量距离更远。这种训练方式叫对比学习(Contrastive Learning),一般用InfoNCE损失或者Triplet Loss。训练数据是海量的“查询-文档”对和“正例-负例”对。
选型的时候我有一个习惯:先看目标语言和领域。中文场景优先考虑中文预训练模型,比如BGE-large-zh-v1.5、M3E-large,英文或跨语言场景再考虑OpenAI或者多语言模型。再看维度需求,像OpenAI的text-embedding-3-small支持动态调整维度(从3072降到更低),BGE系列也有不同量级可选。最后要考虑的是部署成本,大规模检索场景中,向量维度直接决定存储和计算开销,这一点容易被人忽视。
Qwen3-VL是比较新的多模态embedding方向,支持图片和文本的统一向量表示,在图文检索、多模态RAG里很有应用前景。如果项目涉及图搜文、文搜图,可以关注它的论文和开源实现,核心突破在于用统一的特征空间来对齐视觉和语言的语义。
2.4 图Embedding:SDNE原理简述
除了文本,embedding也广泛用于图结构数据,比如社交网络、知识图谱、分子结构。图embedding的目标是把节点映射成低维向量,同时保持图上的结构相似性和语义相似性。思路和文本embedding一脉相承,但实现方式差异很大。
SDNE(Structural Deep Network Embedding)是图embedding中的一个经典模型,它的核心思想是用深度自编码器同时优化一阶相似度和二阶相似度。一阶相似度指两个节点直接相连,向量应该接近;二阶相似度指两个节点共享很多共同邻居,向量也应该接近。SDNE用邻接矩阵作为输入,自编码器中间层的输出就是节点的embedding向量。同时它还借鉴了拉普拉斯特征映射的思路,对直接相连的节点加一个惩罚项,让它们在低维空间更靠近。
对比其他图embedding方法,Node2Vec基于随机游走生成节点序列,再用Word2Vec训练向量,本质上还是静态embedding的路子;GraphSAGE则引入了邻居采样和图卷积,支持对未见过节点作推理,更适合动态图。SDNE的优点是对网络局部结构刻画得细,缺点是需要对整个邻接矩阵做矩阵运算,扩展性上不如采样类方法。如果项目是知识图谱推理或推荐系统图建模,可以按数据规模在图Embedding方法里做取舍。
3. Embedding计算过程实操拆解:从Tokenizer到向量输出
3.1 Tokenizer到底在做什么
大部分embedding模型跑起来,第一个步骤就是Tokenizer。以BERT为例,Tokenizer会先把“我喜欢吃苹果”这句话拆成[我, 喜欢, 吃, 苹果]。注意,这不是简单按字切词。中文的tokenizer一般基于词表做匹配,英文则用BPE(Byte Pair Encoding)算法把单词拆成子词。
Tokenizer完成后,每个token会对应一个数字ID,这是模型能处理的输入格式。计算过程的第一个关键参数就在这里:max_length。设得太短会截断长文本,丢失关键信息;设得太长会浪费计算资源,而且可能引入噪声。我在项目中常用的做法是,先统计语料长度分布,选择覆盖80%~90%文本的长度作为阈值,避免一刀切。
Tokenizer还会产生attention_mask,标记哪些位置是真实token、哪些是padding补全的部分。后续在Pooling阶段必须用注意力掩码过滤掉padding位置,否则会把无效的[PAD]向量混入句子向量,拉偏语义表示。这是一个很隐蔽但影响很大的坑。
3.2 Embedding层与编码器的分工
Token化的ID序列进入模型后,先经过Embedding层转换为向量。这个Embedding层本质是一个查表矩阵,矩阵的行数是词表大小,列数就是模型的hidden size,基础BERT一般是768,large版本是1024。每个token ID在矩阵中找到对应行,得到初始向量。
但这一步得到的向量还不携带上下文信息。它只是把“苹果”映射成模型学习到的静态表示,后续的Transformer编码层才是真正处理和计算上下文的环节。自注意力机制会让每个token的向量不断吸收其他token的信息,比如“苹果”在“苹果好吃”里会更多地融入“好吃”的信息,在“苹果手机”里会更多融入“手机”的信息。层数越深,这个上下文融合越充分。
实操中有个很常见的困惑:既然最后一层包含最丰富的上下文信息,为什么不直接用最后一层的输出?原因在于,检索任务需要的句子语义表示,不一定完全等价于语言建模任务的最终表示。有些信息(比如语法细节)对生成任务重要,但对相似度计算反而是干扰。所以很多embedding模型采用了“平均倒数几层”的方式,提取更平缓、更通用的语义特征。具体选哪几层、怎么加权,建议直接参考模型发布方给出的推荐配置。
3.3 从Token向量到句子向量:Pooling策略全解析
这一步是整个计算过程中最容易被忽视的环节,也是导致效果“莫名其妙变差”的头号元凶。BERT输出的是每个token的向量,维度是[seq_len, hidden_size],但一个句子在语义检索中需要一个固定维度的向量,所以必须做池化。
常见的池化方式有三种:CLS向量、平均池化和最大池化。CLS向量直接取序列第一个[CLS]token的输出作为句子向量,BERT预训练时这个位置聚合了整句信息,但实际效果并不总是最好。平均池化是对所有非padding token的向量求平均,语义更平滑,在句子相似度任务上通常表现稳定。最大池化取每个维度上的最大值,能保留最显著的特征,但受噪声词影响大,实际用得不那么多。
还有一种方式叫加权池化,根据词的权重(比如TF-IDF权重)对token向量加权平均。这种方式在长文档检索中效果好一些,因为可以弱化停用词和无关词的干扰。选择了哪种池化方式,决定了检索效果的上限。特别是遇到“语义相似但词面不相似”的情况,池化策略不同,结果差别会很明显。
我的建议是:不要只看模型文档里的默认值,最好在你自己领域的标注数据集上做一次小规模消融实验,实测不同的池化方式,从中选出效果最好的。这个步骤花不了多少时间,但对最终RAG效果的影响远大于换一个更大参数的模型。
3.4 相似度计算的细节:归一化与度量方式
Embedding计算得到的向量,最终通常要用来计算相似度。常见的度量方式有欧氏距离、余弦相似度和点积。大多数检索系统默认用余弦相似度,因为对向量长度不敏感,只关心方向,这与语义相似度的直觉比较一致。
但很多人没有注意到:在计算余弦相似度之前,是否对embedding向量做L2归一化,会直接影响点积和余弦相似度的等价性。如果两个向量都已经做了L2归一化,那么点积、余弦、欧氏距离三种度量方式在排序效果上是完全等价的。这也是为什么很多向量数据库(如Milvus、Faiss)在写入时要求开启归一化——归一化之后可以直接用内积代替余弦,计算更快。
实操中还有一个细节容易被忽略:不同模型产出的向量,最好不要直接混合计算相似度。因为不同模型的向量空间语义分布不对齐,一个模型里的“猫”可能正好落在另一个模型里的“狗”附近,强行比较没有意义。所以在多源异构数据场景,要么统一模型,要么做向量空间对齐(比如训练一个映射层把两个空间的向量映射到同一空间),否则检索结果会非常离谱。
4. 模型微调与部署:LoRA微调Embedding模型的实战经验
4.1 什么情况下需要微调Embedding模型
预训练embedding模型在通用领域表现不错,但一落到垂直领域就有点“水土不服”。比如法律条文、医疗报告、金融公告这类文本,术语密集、表达方式和通用语料差异很大,直接用通用模型做检索,经常会召回一些看似相关实则无关的内容,或者错过关键文档。
这时候就要考虑微调了。但微调embedding模型和微调LLM的路径不太一样:LLM微调用交叉熵损失学文本生成,embedding微调需要用对比学习损失学向量空间结构。一个很经典的做法是,用领域内的“查询-相关文档-不相关文档”三元组,微调预训练embedding模型,让查询向量和正例更近、和负例更远。
判断是否需要微调,可以先做一个快速测试:抽几十个典型查询,用现成模型跑一遍检索,看看召回的文档是否命中了你预期的关键信息。如果明显偏离,或者排名不分先后,那基本可以判定模型没有理解领域语义,微调是值得投入的。
4.2 LoRA微调Embedding模型中如何操作
LoRA(Low-Rank Adaptation)是一种参数高效微调技术。它的核心思想是冻结预训练模型的原始参数,在模型的线性层旁边引入两个低秩矩阵,微调时只更新这两个低秩矩阵。这样训练参数量可以压缩到全量微调的1%甚至更低,显存占用和训练时间都大幅下降。
用LoRA微调embedding模型时,有几个关键点值得特别注意。第一,要明确在哪些层注入LoRA。文本embedding模型中,Self-Attention层的Q、K、V、O矩阵是最常见的注入点,因为它们对语义表示影响最大。第二,LoRA的秩(rank)直接影响表达能力,一般从8到64之间选择,过大容易过拟合训练集,过小可能学不到领域特征。第三,训练目标函数最好用InfoNCE或对比损失,搭配in-batch负样本采样策略。
我的一个实操心得是:微调数据的质量远比数量重要。几百条精心标注的查询-正例-负例三元组,往往比几千条自动挖掘的噪声数据更有用。负例的选取很关键——最好用难负例,也就是那些表面相似但实际不相关的文档,这样模型才能真正学到细粒度语义差异。如果负例太容易区分,模型学不到什么有效信息。
LoRA微调完成后,还有一个容易踩坑的点:推断时要把LoRA权重合并回原始模型,或者用专门的支持LoRA的推理框架。合并权重后得到的模型大小和原始模型一样,部署和常规推理没有区别。如果推理框架不支持LoRA,就只能在导出模型时预先合并,否则加载权重会出现维度不匹配或结果异常。
4.3 部署与推理的常见坑
Embedding模型部署看起来简单,就是加载模型、输入文本、输出向量,但实际生产环境中有不少细节会影响稳定性。第一个坑是并发和的显存控制。Embedding模型虽然比LLM小,但多个请求并发时,如果batch设置过大,显存还是会被打满,导致OOM。一般建议推理时动态batch,根据输入长度实时计算显存占用,避免一次性把所有请求都灌进去。
第二个坑是向量维度和向量数据库的匹配。假设模型输出768维,但向量数据库集合创建成了1024维,写入时会直接报错或静默截断。这种问题一旦上线会造成数据错乱,排查起来还特别费劲,因为很多数据库的错误提示并不直观。建议在写入前先跑一个维度校验脚本。
第三个坑是长文本的处理策略。前面提到tokenizer有max_length限制,但真实业务里用户粘贴一篇几千字的文档很常见。如果直接截断,标题和开头的信息会保留,但中间和结尾的关键内容可能被切掉。更合理的做法是分段embedding,把长文本切成多个chunk分别向量化,再用均值或加权方式得到文档级向量。分段大小、重叠率如何设置,直接关系到RAG的检索精度,我一般从256到512个token之间做实验,找到当前数据集的最优配置。
5. 常见问题与排查技巧实录
5.1 指标诡异?先查数据再查模型
做embedding项目遇到最多的困惑,就是“模型没问题,指标就是上不去”。我见过很多次,团队花大量时间调模型结构、换更大的底座,结果问题出在数据上。
有一个典型案例:某次做电商商品搜索,查询是“蓝牙耳机”,但商品标题里有大量“蓝牙音箱”“蓝牙适配器”。模型检索出来的结果看着好像都很相关,但用户要的其实是“耳机”,不是“音箱”。这种场景靠通用模型解决不了,必须微调或调整训练数据,让模型学会区分品类边界。
另一个高频问题是:同一句话,稍微改几个词,向量相似度就暴跌。比如“如何办理签证”和“办理签证需要什么材料”,语义很接近,但因为词面差异较大,相似度可能只有0.7甚至更低。解决方案有多方面:尝试不同的pooling策略、换更大的模型、或者用文本增强的方式构造更多语义等价的训练样本。总之,指标出问题先回数据,别急着换模型。
5.2 维度选择的权衡
Embedding维度是另一个容易被忽视但影响深远的参数。高维度能捕获更多语义细节,但也会带来更高的存储成本和计算开销。一个1000万条数据的集合,从768维换成3072维,存储会膨胀近4倍,而且高维向量在ANN(近似最近邻)检索中容易出现维度灾难,检索效率下降。
实践中我的建议是:先用中等维度(768或1024)跑通pipeline,验证业务效果,再考虑是否需要用大模型和更高维度去精调。如果数据量级在百万以内,768维完全够用。如果追求极致效果且资源充足,可以考虑更高维模型,但务必评估向量数据库的索引参数是否需要调整。很多数据库在高维场景下,HNSW的M参数和efConstruction参数需要重新调优,否则召回率会有明显下降。
5.3 向量检索效果差?可能是文本预处理的问题
很多embedding检索效果差,根因根本不是模型,而是数据没有做基本的清洗。比如网页爬下来的文本充满HTML标签、乱码、超长空白,这些噪声会让embedding模型的注意力被错误吸引。我建议在embedding之前,先做一套标准的文本清洗流程:去HTML标签、去空白符、统一大小写、处理特殊字符。这样有时什么都不用改,效果就能提升不少。
还有一个容易被忽略的问题:语言一致性。用户查询是中文,但语料库里有大量中英混杂文本,模型可能把中文查询和英文文档在某些维度上错误对齐。解决方案有两类,要么把语料统一翻译成一种语言,要么使用专门的多语言embedding模型,并且在query侧和文档侧保持相同的预处理流程。
5.4 Embedding模型更新后,旧向量怎么办
生产环境中,模型升级是必然的。但新模型产出的向量空间和旧模型不一致,如果直接混用,检索质量会受影响。比较常见的做法是:双写兼容期。新模型上线时,正对线上流量逐步迁移,先将新模型和老模型并行运行,离线对一批核心查询做AB对比,确认效果稳定后,再用离线任务把全量旧向量重新算一遍,并切换索引。这个成本比较高,但可靠。
如果切换成本实在太高,也可以考虑用映射网络对齐两个模型的空间,把旧向量映射到新空间里。但说实话,映射方式毕竟有精度损失,除非实在没法全量重算,否则不建议作为首选方案。
后记
做embedding项目这几年,我最大的体会是:不要迷信“换更大的模型就有效果”。很多问题的瓶颈在数据质量、池化策略、预处理流程这些看起来不起眼的细节上。把基础原理吃透,再把pipeline的每一环都调校到位,往往比盲目堆参数更有效。另外,每次上线新模型或调整策略,一定记得保存当时的评测样本和评测结果,方便后期对比回归。最后再分享一个实用小技巧:如果手头没有标注数据,可以用大语言模型批量生成“查询-正例-难负例”训练样本,再人工抽检清洗,成本低且能快速起步。希望这篇文章能给做RAG、语义搜索、向量检索的朋友一些实在的帮助。