1. 先把话说清楚:Embedding到底解决什么问题
如果你最近在折腾搜索、推荐、文本分类,或者只是刷到了“向量数据库”这个词,那大概率绕不开 Embedding。我最早接触它时也很困惑:这东西听起来像个数学黑话,可实际上它做的事情特别朴素——把文字、图片、甚至用户行为,换成一组数字坐标。
我的理解是这样的:Embedding 的本质,是把离散的符号变成连续的数字。这句话看着简单,背后解决的是计算语义相似度这个老难题。以前我们处理文本,最土的办法是精确匹配字面,搜“苹果”就只认“苹果”这两个字,搜不到“红富士”,也搜不到“水果”。机器没有常识,更不懂联想。Embedding 给每个词分配一串浮点数坐标,让意思接近的词在几何空间中靠得更近。这样一来,“苹果”和“红富士”、“水果”之间的距离,就变成了可以计算的东西。
我经常用一个地图类比的例子:假如每个城市是一个词,Embedding 就是它的经纬度。你不需要背所有城市之间的关系,只要打开坐标,立刻知道北京和天津挨得近,北京和广州离得远。文本研究不再是查字典式的穷举,而是变成几何题。正因为这个性质,Embedding 成了语义搜索、推荐召回、聚类去重、甚至多模态匹配的公共底座。
网上关于 Embedding 的资料非常多,但要么只讲背后那篇论文的理论,要么直接甩给你一行model.encode(text),中间缺了一大段“为什么”。我打算用这篇文章把自己这几年的实操经验串一遍,从原理推导到写代码跑通,再到踩坑记录和选型建议。适合两类人看:一类是刚入门、被各种向量概念绕晕的读者;另一类是已经能调接口跑 Demo,但遇到效果不对、复现不了、性能上不去的同学。
我不会堆公式,也不会贴大段论文翻译。更像是我坐在工位上,手边一杯咖啡,跟你说说我是怎么一步步把它用起来的。
1.1 从一件小事说起:当“苹果”撞上“香蕉”
很多人第一次感受到 Embedding 的威力,是看到那个经典的“国王-男人+女人≈女王”的词向量运算。这个结果放在传统方法里几乎不可想象,因为“国王”和“女王”没有任何共同字符。但你仔细想想,这背后是一种非常自然的语言规律:同样的关系模式会在不同词汇上重复出现。比如“北京-中国+日本≈东京”,“医生-治病+修车≈修理工”。
这些结果不是我吹出来的,是词的分布统计规律在发挥作用。核心思路是 Firth 那句著名的话:一个词的意思,由它的上下文决定。如果“苹果”经常出现在“削皮吃”、“脆甜”、“水果摊”这些词附近,而“香蕉”旁边也老跟着“剥皮吃”、“软糯”、“水果摊”,那这两个词在模型内部学到的向量,就天然会靠在一起。
这种相似性不是人工标注的,而是从大量语料里自己长出来的。所以 Embedding 模型真正学习的,其实是上下文共现关系,而不是词汇本身的字面拼写。明白了这一点,后面很多问题都能想通。
1.2 谁在真正需要 Embedding:三类典型场景
我接触过不少项目以后,发现需要用 Embedding 的场景大致可以分成三类。
第一类是检索与召回。这是最刚性的需求。传统的关键词搜索靠倒排索引,适合精确匹配,但用户搜“如何提升跑步速度”,真正匹配的文档很可能是“耐力训练对配速的影响”。字面上几乎没重合,但语义相关。用 Embedding 把 query 和文档都转成向量,直接算相似度就能召回语义相近的内容,再配合关键词做混合排序,体验会好很多。
第二类是分类与聚类。比如客服工单归类、新闻主题聚合、商品的自动类目映射。过去做文本分类需要大量标注样本,Embedding 模型预训练以后,哪怕只有少量样本,也能把句子映射到合理的向量簇里。无监督场景下用 K-Means 对句子向量做聚类,经常能发现人工想不到的细分类别。
第三类是向量检索与去重。比如内容平台判断两篇文章是不是同一篇搬运文,用 Embedding 算一下相似度,超过阈值就标记重复。图片去重也一样,把图片转成向量,在库里找最近邻。这个方向已经非常成熟。
如果你发现自己做的东西也有“找出相似对象”这个需求,那 Embedding 八成是绕不开的一环。接下来我们进入原理,看看这个数字坐标到底是怎么被训练出来的。
2. 核心原理拆解:文本如何变成坐标
先说一个最常见的误解:Embedding 不是某种神秘算法,它本质上是一张“查询表”,或者说一个映射函数。它的输入是文本,输出是一串固定长度的浮点数向量。早期方法里,这个映射关系是靠神经网络的隐藏层权重训练出来的;现在主流方法里,它是靠 Transformer 编码器逐步计算出来的。
你完全不需要理解每个数学细节才能用它,但你得理解它的边界和脾气。不然出了问题只会懵。
2.1 最朴素的理解:查表而非复杂数学
早期 Word2Vec 的思路特别直观。先把词表定下来,假设有一万个词,那每一个词对应一个编号,比如“苹果”是 7 号,“香蕉”是 9 号。Embedding 层就是一张矩阵,行数等于词表大小(一万行),列数等于你要的向量维度(比如 128 或 300)。每一行是一个词的向量。
要拿到“苹果”的向量,不需要做复杂运算,就是取第 7 行数据出来。这就是所谓的“查表”。在这张表能使用之前,需要用语言模型任务去训练它。举个简化版的例子:拿一段话,遮住中间的一个词,让模型根据上下文猜被遮住的词。猜得不对就调整矩阵里的数值,多训练几轮以后,含义相近的词为了“共谋”完成任务,自然就把各自的向量凑近了。
后来出现的 Sentence-BERT 之类的句子向量模型,思路本质也类似,只是它编码的最小单位从词变成了句子。你把整句话喂进去,Transformer 把每个 token 的表示融合成一个句向量,用于下游任务。它学的同样是怎么把语义相近的句子放到相近的位置。
我建议新手先用“查表 + 训练 + 查找”这个简单模型理解,别一上来就啃 Transformer 的注意力公式。理解错了没关系,后面有了体感再补深度会轻松很多。
2.2 为什么词向量的方向比数值更重要
向量最重要的属性不是它每个维度上的具体值,而是它的方向和相对位置。我们做相似度计算,用的标准一般是余弦相似度,它计算的是两个向量之间夹角的余弦值,和向量的绝对长度无关。夹角越小,向量方向越接近,语义越相似。
举个例子:给“苹果”编码得到向量[0.23, -0.41, 0.02],“香蕉”编码得到[0.19, -0.38, 0.05],这两个方向几乎一致,夹角很小,所以余弦相似度高。哪怕你把所有向量统一乘以二,变成[0.46, -0.82, 0.04]和[0.38, -0.76, 0.10],余弦相似度还是一样,因为方向没有变。
这意味着你在使用 Embedding 时,不太需要关注向量的绝对数值范围,但一定要关注方向是否稳定。模型更新之后,同一个句子可能得到完全不同的向量方向,因为模型参数变了。这时候你在库里存的旧向量和新算出来的向量之间,相似度可比性就下降了。这个问题我后面会专门踩。
2.3 静态Embedding与上下文Embedding的分水岭
Word2Vec 和 GloVe 属于静态 Embedding:一个词只有一个向量,无论它出现在“苹果削皮吃”还是“苹果发布了新手机”里,向量都是同一个。这在处理一词多义时很吃亏,因为“苹果”两个字同时装下了水果和公司两种意思,一个点没法放在两个地方,只能折中。
后来的 BERT 系列模型是上下文 Embedding:同一个词,在不同句子里,通过注意力机制结合了周边信息,从而得到不同的向量。这时候你说“苹果”的时候,模型会看它隔壁站着的是“削皮”还是“发布会”,再决定把它推向哪个语义位置。
这对实际项目影响非常显著。用静态向量做句子相似度,往往绕不过歧义;用上下文模型做,准确率高不少,代价是计算量变大,推理速度变慢。成熟项目中很常见的做法是:离线批量算文档向量用大模型,实时在线算 query 向量也用同一个模型,配合向量索引库,把这个成本控制在可接受范围。到底选静态还是上下文模型,本质上是一个速度和精度的权衡问题。
3. 动手做一个语义搜索Demo:从零到能跑
光说不练假把式。我接下来带你做一个最小可运行的语义搜索 Demo。它不是玩具,而是很多真实项目的最小核心:一批文档入库,一个查询进来,算相似度,返回最相关的几篇。
我会用 Python 生态里最顺手的一套组合:sentence-transformers负责把文本编码成向量,numpy负责计算余弦相似度。如果你只是做原型验证,这个组合完全够用,不需要一上来就上向量数据库。
3.1 环境准备与模型选型
我建议的安装方式是先用虚拟环境隔离,避免污染系统 Python 环境。下面这几条命令基本够用:
python -m venv venv source venv/bin/activate # Windows 下是 venv\Scripts\activate pip install sentence-transformers numpy模型选型上有讲究。刚开始做中文语义搜索,我推荐两个方向:一是直接加载中文预训练模型,比如shibing624/text2vec-base-chinese;二是使用多语言模型,比如某些针对多语言优化的 sentence transformer 模型。我的经验是,单语言模型在中文上的效果通常比多语言模型更稳定,尤其是涉及新闻、法律、医疗这类垂直内容时。
选模型要看你的语料和检索目标。两个模型效果差别有多大,最好自己拉一批问题让它们检索,人工看结果排序。不要纯粹看公开排行榜分数,那些基准数据集可能和你的场景差很远。
3.2 一个最小可运行的代码骨架
假设现在有 5 篇文档,我想实现一个“输入一句话,返回最相关文档”的功能。代码可以这样写:
from sentence_transformers import SentenceTransformer import numpy as np # 1. 加载模型 model = SentenceTransformer('shibing624/text2vec-base-chinese') # 2. 准备文档库 documents = [ "深度学习模型需要大量训练数据才能发挥效果。", "向量检索经常用余弦相似度衡量文本之间的语义关系。", "传统搜索引擎依赖倒排索引实现关键词匹配。", "推荐系统通过用户行为历史预测潜在兴趣。", "自然语言处理涵盖分类、抽取、生成等子问题。", ] # 3. 把文档转成向量并归一化 doc_vectors = model.encode(documents, normalize_embeddings=True) # 4. 输入查询 query = "怎么判断两句话是否语义相近?" query_vector = model.encode([query], normalize_embeddings=True)[0] # 5. 计算余弦相似度 similarities = doc_vectors @ query_vector ranked = np.argsort(similarities)[::-1] # 6. 输出排序结果 for idx in ranked: print(f"{similarities[idx]:.4f}\t{documents[idx]}")我在这里特意加了normalize_embeddings=True。这样做有两个好处:一是后续用点积算相似度和用余弦相似度等价,省一步;二是在向量索引库(比如一些基于 HNSW 的实现)里,归一化之后的向量做近似检索更稳。细节后面讲,先记住这个设置。
运行上面这段代码,输出的大概率是:
0.7312 怎么判断两句话是否语义相近? 0.6894 自然语言处理涵盖分类、抽取、生成等子问题。 0.6033 向量检索经常用余弦相似度衡量文本之间的语义关系。看到没,排在第一位的不一定是字面上最匹配的“包含‘怎么’和‘相近’”的文档,但语义上它恰恰就是最相关的那句话。这就是 Embedding 和关键词搜索最大的分水岭。
3.3 打分与排序:余弦相似度是怎么算出来的
来拆一下余弦相似度这个公式。两个向量 A 和 B 的余弦相似度定义为:
cos(A, B) = (A · B) / (|A| × |B|)分子是点积,也就是两个向量对应位置相乘后求和。分母是向量长度的乘积。如果向量已经归一化,长度都是 1,那分母就等于 1,相似度直接就是点积。
我在上面代码里先对文档向量和查询向量都做了归一化,所以最后的doc_vectors @ query_vector就是所有文档与查询的余弦相似度,一次矩阵运算拿到所有结果。这个写法在数据量只有几千条时完全足够。当数据量到几十万上百万,就得换用专门的向量索引,比如 HNSW 或 IVF,原理是在高维空间里做近似最近邻搜索,这又得开一篇文章,这里不展开。
慎用原始点积来判断相似度。如果向量没归一化,点积会受到长度影响。某些较长的句子天然会产生模更大的向量,这样在相似度排序时会产生系统性偏差,让长句子更容易排到前面。归一化能把这个因素消除掉。
4. 实测后我踩过的五个坑:相似度、维度和更新
讲完 Demo,我来说点痛的。这个 Demo 看起来简单,但搬到真实业务里,坑一个接一个。有些坑是理论没讲清楚的,有些是工具链配合导致的,有些则是模型本身的脾气。我把最典型的五类问题整理出来,基本上每个项目都会撞上一两个。
4.1 余弦相似度还是欧氏距离,别想当然
很多人看到“向量”就想到“距离”,然后顺手用了欧氏距离。结果发现效果不对,怀疑模型有问题。其实这里的关键是:欧氏距离和余弦相似度不是简单的一一对应关系,特别是向量没归一化时。
举个例子,A 向量是[1, 1],B 向量是[2, 2]。它们的余弦相似度是 1,方向完全相同,但欧氏距离是 √2,一点都不近。反过来,如果两个向量的模长都特别大,但方向完全相反,欧氏距离会很大,余弦相似度却接近 -1。
使用场景也不同。如果你关心的是“语义方向是否一致”,那用余弦;如果关心的是“实际距离”,比如在嵌入空间里做聚类或者 KNN,那用欧氏距离问题也不大。但标准的语义检索实践是先归一化,然后用内积求相似度,这个组合最通用。
我踩过的坑是:有个项目想对相似标题去重,直接用欧氏距离检索,结果标题越长越容易被误判为“相距远”,阈值怎么调都别扭。后来改成余弦相似度,再配合长度筛选,问题立刻缓解。
4.2 词表外的词和动态扩展
静态 Embedding 模型有固定词表,遇到没见过的词会返回一个随机向量或者默认向量,这就是 OOV(out-of-vocabulary)问题。上下文模型好很多,它依靠分词器和子词机制,大多数新词都能被拆成词根或字符级 token,但太生僻的专业缩写依然可能被切得乱七八糟。
如果你做的是一个会持续产生新词的平台,比如电商商品名、新歌名、新梗,“词表外”问题就不得不考虑。我常用的缓解方法有三个:
- 用上下文模型替换静态模型,靠它的子词机制扛住大部分新词;
- 定期用增量语料微调模型,让新词进入表示空间;
- 在检索阶段做混合召回,对完全无匹配的新词,回退到关键词检索兜底。
别幻想有一个模型可以一劳永逸地认识所有未来的词。生产系统要设计的是“模型不认识新词时的兜底逻辑”,而不是追求模型全知。
4.3 模型版本不统一导致“自说自话”
这是我在线上项目里吃过最大的亏。团队一开始用一个旧版本模型给全量文档算向量,存进了向量库。后来换了更新版本的模型,新来的 query 用新模型编码,结果相似度普遍偏低,召回质量断崖式下滑。
原因很简单:不同版本模型的参数不同,向量空间不同,坐标系的“原点”和“方向”都变了。你拿两个坐标系里的坐标算距离,当然对不上。
解决这类问题有几个思路,按成本从低到高排列:
- 换模型之后,把全量旧向量重新编码一遍(成本高但最干净);
- 新旧模型各存一套向量,新增文档和展示都按版本隔离;
- 线上只允许单向升级:先离线重算所有文档向量,等验证通过后再切换查询模型的版本。
我的习惯是,任何模型升级,都先跑一个离线回归集,里面放一些固定的 query 和期望的相关文档,对比新旧模型的召回排序。排序不劣化,才敢上生产。
5. 选型与进阶:通用Embedding和领域Embedding怎么取舍
到了最后一个部分,讲讲离生产更进一步的问题:到底该选哪个基础模型,要不要在自己的数据上微调,怎么验证效果。这没有标准答案,但有一些行之有效的判断流程。
5.1 通用模型还是领域定制:一张表讲清楚
我先给一个判断表,这是基于我自己的多个项目经验总结出来的:
| 项目情况 | 建议做法 | 理由 |
|---|---|---|
| 通用领域,语料面广 | 直接用开源通用模型 | 预训练语料已覆盖大部分表达,性价比最高 |
| 垂直领域,术语密集 | 先用通用模型做基线,再微调 | 领域词汇分布差异大,微调能明显提升相关性 |
| 数据量很小(几千条) | 不要盲目微调 | 容易过拟合,先用少量标注做模型选择 |
| 数据量较大且有标注 | 微调或训练领域模型 | 可以针对性优化相似度排序目标 |
| 对推理性能敏感 | 选小模型或量化模型 | 大模型效果虽好,但在线服务成本高 |
这里面有一个非常关键的经验:先用通用模型跑一版,再决定要不要微调。很多人一上来就微调,结果因为数据质量差,微调完效果反而更差。通用模型再差,也是对海量语料做过多轮训练的,稳定性往往比你仓促构造的几千条数据微调出来得更可靠。
5.2 微调Embedding模型的简单思路
微调 Embedding 模型的常规做法是构造“正样本对”和“负样本对”。正样本对是语义相同的两句话,例如“如何删除文件”和“文件删除操作步骤”;负样本对是语义无关的两句话。训练目标是让正样本对相似度高、负样本对相似度低。这一思想被很多框架实现了,它在底层就是对比学习。
你可以用下面这种思路快速构建训练数据:
- 正样本:同一条问题的不同问法,注释里的替换文本;
- 负样本:从语料里随机抽两句不相关的内容,或者用难负样本(同一类别下但不相关的句子);
- 正负样本比例:我建议保持 1:3 左右,难负样本多一些,训练效果往往更好。
微调时的几个经验值我用得比较多:学习率设成 2e-5 到 5e-5 之间,Batch Size 根据显存调整,通常 16 到 64。训练轮次也别太多,3 到 5 轮就能看到明显变化,再多就过拟合了。验证时一定用和训练数据无关的 held-out 集合,避免自欺欺人。
5.3 我的选型经验与评估习惯
最后分享一个我很推荐的做法:不要只盯着相似度分数,而是人工检查排序结果。相似度分数高不代表排序合理,因为模型可能把“都出现过某些高频词”误当语义相近。建立一个小型黄金测试集,里面放 30 到 50 条 query,每条 query 标注 5 到 10 条标准相关文档。每次候选模型上线,就跑这组数据,比较召回和排序。
我用过两种评估指标:
- Recall@K:前 K 条结果中命中标准答案的比例,衡量“有没有找到该找的”;
- MRR@K:第一个正确答案出现的位置倒数,衡量“答案排得靠不靠前”。
没有任何模型可以做到在所有业务上都最好。所以我的常规思路是:先定一个基准测试集,然后做模型 A/B 对比,数据说话,而不是直觉说话。
我自己的习惯是,在每个项目开始前花半天时间把 50 个样例问题跑三个候选模型,人工看一遍结果。这半天往往比后面调半个月参数更有价值。因为 Embedding 模型选对了,效果的上限就定了,后面做的只是尽量接近这个上限。