在任何一次语义检索功能上线之前,我都会先问自己一个问题:这套系统到底是靠字面匹配,还是靠意思匹配。区别很大。几年前我在一个内部知识库项目里,用关键词检索硬撑了两个月:用户搜“怎么退钱”,文档里明明写着“申请退款流程”,却一条都没召回;用户搜“发票抬头填错了”,只有标题里带“发票”两个字的文档才能出来。换成向量检索之后,同一批问题,召回率从三成多直接拉到八成以上。那一刻我才算真正理解 Embedding(嵌入)在做什么——它把“意思”变成了一串可以计算距离的浮点数,让机器第一次能用“像不像”来比较两段文字,而不是只能数“有几个字一样”。
这篇内容想把嵌入这件事从零讲清楚,包括它在数学上的最小必要概念、向量是怎么算出来的、怎么选模型、怎么定阈值、怎么放进真实项目,以及我在实际落地时踩过的那些坑。适合三类人看:刚接触大模型想搞懂基础概念的、准备搭 RAG 或者语义搜索但不知道从哪下手的、以及已经能用起来但效果不稳定想找原因的。全程不玩玄学,能上代码的地方给代码,能算账的地方算给你看。
1. 一句话变成一个向量:嵌入到底解决了什么问题
1.1 从词表编号到连续空间里的坐标
要理解嵌入,先得知道没有嵌入的世界长什么样。最朴素的做法是给词表里的每个词编个号,“退款”是第 1024 号,“退钱”是第 8865 号。这种编号叫离散表示,问题非常直接:1024 和 8865 之间没有任何数学关系,计算机只知道它们不相等,不知道它们意思一样。你没法做减法,没法算距离,更没法说“这两个词八成像”。
另一种老办法叫独热编码,词表多大向量就多长,每个词对应一个位置是 1,其余全是 0。五万词的词表就是五万维向量,里面 49999 个零。这种向量有两个致命缺陷:一是存储和计算都浪费得离谱;二是任意两个不同词的向量夹角永远是 90 度,距离永远相等——在独热编码的世界里,“猫”和“狗”的距离,跟“猫”和“量子力学”的距离一模一样。
嵌入做的事情就是把这个稀疏的、正交的空间,换成一个稠密的、低维的连续空间。每个词、每句话、每段文档,都变成空间里的一个点,坐标是一串小数,比如 768 个或 1024 个浮点数。这个空间是“训练”出来的,不是人手工设计的,所以它的每一维不对应任何人类能说清楚的含义——你不要指望第 37 维代表“情感色彩”,第 512 维代表“是否正式”。真正有意义的不是单维,而是整体位置:语义相近的文本,坐标就靠得近。
提醒一句:很多入门材料会说“每一维代表一个特征”,这在早期的可解释性研究里勉强成立,在现在的句向量模型里基本不成立。别在这上面浪费时间,把注意力放在“距离”上就好。
1.2 嵌入在大模型里的三个位置
“大模型入门”这个语境下,嵌入其实出现在三个完全不同的地方,很多人会把它们混为一谈。
第一个位置在模型内部,是输入层。任何一个大语言模型的第一层,本质上都是一张巨大的查找表:词表里每个 token 对应一个向量,输入一句话,就是把这几个 token 查出来拼成一个矩阵,送进后面的注意力层。这一层的参数在大模型总参数里占比不高,但它决定了模型对“词”的最初始理解。这部分嵌入是随模型一起训练出来的,你通常不会单独去用。
第二个位置是句向量,也就是把一整句话、一整段话压成一个向量。这是绝大多数人说的“Embedding”时真正指的东西,也是做检索、聚类、去重、分类时用的东西。它需要一个专门的编码器,把变长的文本序列聚合成一个定长的向量。
第三个位置是外部知识索引,是 RAG 场景里的基础设施。你的文档被切成块,每块算一个向量存进向量库;用户提问时,问题也算一个向量,去库里找最近的几块,塞进大模型的上下文。这条链路里,嵌入模型的质量直接决定了召回质量,而召回质量又是后面一切的天花板——召不回来的东西,再强的生成模型也编不出来。
搞清楚这三个位置很重要,因为它们的选型标准完全不同。模型内部的嵌入你不用管;句向量要看你自己的语料和任务;索引层还要额外考虑维度成本、更新频率和检索延迟。
1.3 必须先建立的直觉:距离近不等于意思相等
这一条是我见过最多人误解的地方。嵌入空间里的“近”,度量的是用法和上下文的相似,不是逻辑等价。
“今天天气不错”和“今儿天气挺好”,向量距离会非常小,这符合预期。但“我喜欢猫”和“我不喜欢猫”,在很多句向量模型里距离也相当近——因为它们共享了绝大部分词汇和句法结构,只是否定词不同。早期的静态词向量更夸张,“好”和“坏”在空间里经常挨得很近,因为它们出现的上下文高度重叠(“这个产品很___”)。
这带来两个实操结论。第一,如果你做的是情感分类、意图识别这类对极性敏感的任务,纯靠向量相似度是不够的,需要在下游加分类器,或者用专门为这类任务训练过的模型。第二,做检索时不要指望向量能理解“排除”“除了”“不要”这类否定约束,这些交给关键词过滤或者让生成模型去处理更靠谱。
还有一个相关的坑:多义词。 “苹果发布了新手机”和“苹果今天很甜”,如果模型是上下文相关的(现在的句向量模型基本都是),这两句的向量会分得很开,这是好事。但如果你用的是按词查表的老式词向量,一个词只有一个向量,多义词就会糊成一团。这也是为什么现在做句向量都用基于 Transformer 的编码器,而不是查表。
2. 向量是算出来的:编码器、池化与训练信号
2.1 从 token 向量到句向量,池化这一步决定成败
一句话进模型,出来的是 N 个 token 向量(N 是 token 数量),但你要的是一个向量。中间这一步叫池化,是把矩阵压成向量的过程,常见的有三种做法。
第一种是取第一个特殊 token 的输出,很多编码器在句首会加一个占位 token,训练时让它承载整句信息。这种做法在早期模型里很常见,缺点是如果模型不是专门为句向量训练的,这个位置的信息未必最全。
第二种是均值池化,把所有 token 向量按维度求平均。这是目前最常用的做法,稳健、实现简单。但这里有个非常容易出错的细节:必须排除 padding。因为一批文本长度不一,框架会自动用 0 补齐到统一长度,如果你直接对整批做平均,那些补齐位置的向量会把结果往 0 拉偏。长度差异越大,偏差越大。正确的做法是用 attention mask 加权,只对真实 token 求平均。
第三种是取最后几个 token 或者加权池化,用得相对少一些,某些模型会在文档里明确说明推荐哪种方式。选哪种不要自己猜,看模型说明。
我见过一个很典型的翻车案例:一个团队把池化写成了简单平均,没有处理 mask,短文本(10 个 token)和长文本(500 个 token)如果放在同一批里,短文本的向量会被大量 padding 稀释,导致短 query 和长文档的相似度整体偏低,阈值怎么调都不对。改成 mask 加权平均之后,同一批数据的召回立刻正常了。这类问题不看代码根本发现不了,因为结果不会报错,只是“效果不太好”。
import numpy as np def mean_pooling(token_vectors, attention_mask): # token_vectors: [batch, seq_len, dim] # attention_mask: [batch, seq_len],真实 token 为 1,padding 为 0 mask = attention_mask[..., None].astype(np.float32) # [batch, seq_len, 1] summed = (token_vectors * mask).sum(axis=1) # [batch, dim] counts = np.clip(mask.sum(axis=1), 1e-8, None) # 防除零 return summed / counts2.2 两种训练信号,解释了为什么“生成模型的第一层”不好用
嵌入向量是怎么“学”出来的?这决定了你能不能拿现成的东西凑合用。
第一种训练信号来自语言模型本身,也就是“预测下一个词”。模型为了把下一个词预测准,被迫学会把语义相近的词映射到相近的位置,嵌入是这个过程的一个副产物。这种训练方式得到的向量,对“词”的表示不错,但对“整句”的表示往往不行——因为训练目标从来没要求过“整句的向量要能代表整句的意思”。
第二种是对比学习,这也是现在专门的句向量模型的主流做法。思路很朴素:构造正样本对(意思相同或相近的两段文本)和负样本对(不相关的两段文本),训练目标是让正样本的向量靠近、负样本的向量推远。经典的实现是用同一个批次里的其他样本当负样本,一批 64 条就是 63 个负例,成本很低。这里面有个温度系数控制“推多远算远”的力度,调得太小模型会对难负样本过度敏感,太大则区分度不够。
对比学习的训练数据来源决定了模型的偏好。有些模型主要用问答对训练,那么它在“短问题匹配长答案”这个场景下表现特别好,这叫非对称检索;有些模型主要用同义句对训练,那么它更适合判断两句话是不是一个意思,这叫对称任务。这两类任务的向量分布是不一样的,用错了模型,效果可能还不如关键词。这也是下一节选型时要重点看的东西。
顺带说一个常见误解:有人觉得“大模型这么强,我直接拿它的隐藏层输出当向量不就行了”。实测下来通常不行,原因就是上面说的——生成模型的训练目标里没有“句向量要好”这一项,它的隐藏状态更适合用来生成下一个 token,不适合用来做距离比较。偶尔能用,但不稳定,而且维度往往很大,成本不划算。
2.3 维度的账要这么算:存储、精度与效果的三方权衡
维度是选型时最容易被拍脑袋决定、又最容易后悔的参数。先说结论:主流句向量模型集中在 384、768、1024 这几个档位,少数到 1536 甚至更高。不是越高越好。
先算存储。假设你要索引 100 万条文本块,每块一个向量:
| 维度 | float32 单向量 | 100 万条总占用 | float16 总占用 | 量化到 int8 总占用 |
|---|---|---|---|---|
| 384 | 1.5 KB | 约 1.43 GB | 约 0.72 GB | 约 0.36 GB |
| 768 | 3.0 KB | 约 2.86 GB | 约 1.43 GB | 约 0.72 GB |
| 1024 | 4.0 KB | 约 3.81 GB | 约 1.91 GB | 约 0.95 GB |
| 1536 | 6.0 KB | 约 5.72 GB | 约 2.86 GB | 约 1.43 GB |
单条向量的算法是维度 × 4 字节,1024 维就是 4096 字节,100 万条乘起来约 3.81 GB。这只是原始向量,向量库还要存索引结构,HNSW 这类图索引通常会额外占用 30% 到 100% 不等。所以别看着“才 4 GB”就放心,实际部署时内存要按两到三倍预留。
再算效果。维度提升带来的收益是递减的。从 384 提到 768,效果提升通常明显;从 768 提到 1024,提升就很有限了,除非你的任务特别难(比如跨语言、专业领域)。而检索延迟和内存是线性增长的。我的经验是:中文通用场景 768 维是甜点区,专业领域或者多语言可以上 1024,再往上就要有明确的评测数据支撑,不然就是花钱买心理安慰。
还有个常被忽略的点:有些模型支持降维。训练时用了特殊的损失函数,让前若干维就能承载主要信息,你可以直接截断到 256 维用,效果损失很小。如果你的场景对内存极度敏感,这类模型值得优先考虑。但截断不是随便截的,得模型本身支持才行,自己手工截断的向量和原始向量不在一个空间里,会出问题。
3. 选型与跑通:把一段中文稳定地变成向量
3.1 选模型先回答三个问题:语言、长度、对称性
模型选型不是看排行榜第一名就完事。榜单上的分数是在特定数据集上算的,换个领域可能完全反过来。我一般按三个问题筛。
第一个问题:语言。如果你的语料以中文为主,别直接上纯英文模型,哪怕它榜单分很高。中文的分词、句式、成语表达,纯英文模型处理得很勉强。中英混合的场景要选多语言模型,但要注意多语言模型在单语任务上往往不如同规模的单语模型,这是容量被分摊的代价。
第二个问题:最大输入长度。这是最容易被忽略、又最容易造成“效果莫名其妙差”的因素。相当多的句向量模型最大只支持 512 个 token,中文大概 300 到 500 字。超过就截断,而且很多框架是静默截断,不报错不警告,你的后半段内容根本没进模型。如果你的文档块普遍在 800 字以上,用 512 的模型等于只索引了一半内容。要么换支持长输入的模型,要么把块切小。
第三个问题:对称还是非对称。判断方法很简单:你要比较的两段文本,长度和形态是否接近?判断两句话是不是同义、给相似问题聚类,这是对称任务。用一个短问题去匹配一段长文档,这是非对称任务。有些模型对这两类任务分别提供了不同的编码方式(比如给查询和文档加不同的前缀),用的时候要按说明来,混用会掉点。
我一般会准备一个小评测集,把候选的三到五个模型都跑一遍,看 recall@10,而不是只信榜单。评测集的构造方法在第 4 节细说。
3.2 一个最小可运行的闭环
下面这段是最小闭环的逻辑示意。不同框架的函数名和参数名会有差异,但流程是一样的四步:加载编码器、批量编码、归一化、算相似度。
import numpy as np # 第一步:加载本地向量模型(具体加载方式看你用的推理框架) encoder = load_encoder(model_dir="/path/to/embedding-model", device="cpu") texts = [ "申请退款的具体流程是什么", "我想把钱退回来,需要怎么操作", "如何修改发票抬头信息", "服务器宕机了怎么排查", ] # 第二步:批量编码,注意 batch_size、max_length、normalize 三个参数 vectors = encoder.encode( texts, batch_size=32, max_length=512, normalize=True, # 输出即单位向量,后续相似度直接用点积 ) vectors = np.asarray(vectors, dtype=np.float32) print(vectors.shape) # (4, dim) # 第三步:归一化后,余弦相似度等价于点积 sim = vectors @ vectors.T print(np.round(sim, 3)) # 第四步:取指定文本的 top-k 邻居 def top_k(query_idx, vectors, k=3): scores = vectors @ vectors[query_idx] order = np.argsort(-scores) return [(i, float(scores[i])) for i in order if i != query_idx][:k] print(top_k(0, vectors))跑通之后你会发现,第 0 句和第 1 句的相似度明显高于跟第 2、3 句的相似度,尽管它们字面上重合的词并不多(“退款”和“退回来”只有一个字相同)。这就是嵌入和关键词检索最本质的区别。
注意:
normalize=True一定要开。很多相似度算出来数值很奇怪、阈值怎么调都不对的问题,源头就是没归一化,向量的模长在干扰结果。归一化之后,余弦相似度落在 -1 到 1 之间,点积就等于余弦,后面的计算全部简化。
3.3 批量、缓存、归一化:上线前必须补的三刀
Demo 跑通和上线之间,隔着三件必须做的事。
第一件是批量大小。单条编码在 CPU 上大概几十毫秒到上百毫秒,一百万条就是十几个小时。批量编码能把这个时间压下来一大截,因为 GPU 和 CPU 的并行度被用起来了。但批量不是越大越好,太大容易爆显存,而且在长度差异很大的批次里,padding 会浪费大量算力。我的做法是先按长度排序,再按长度分组做批次,同批次内长度接近,padding 浪费最小。这个优化在长文本占比高的时候效果非常明显。
第二件是缓存。同一个文本不要算两次。索引重建的时候,绝大多数文本块是没变的,只有少数新增和修改。用一个内容哈希做 key,把向量存起来,重建时只算增量。这个改动看起来朴素,实际能把重建时间从几小时压到几分钟。哈希用文本内容的摘要就行,注意要把模型版本也拼进 key 里,不然换模型之后会拿到旧向量,这是第 6 节要讲的坑。
第三件是归一化。前面说了要开,这里补充一个细节:如果你的向量库或者检索库要求你自己做归一化,记得在写入前做,而不是查询时做。两边都归一化才对等,只归一化一边,相似度就是错的。而且归一化后的向量模长恒为 1,可以用 float16 存而不损失精度,存储直接减半。
4. 相似度不是概率:分数怎么算、阈值怎么定
4.1 余弦、点积、欧氏距离,什么时候用哪个
三种度量在归一化之后是有等价关系的,但没归一化的时候差别很大。
| 度量方式 | 计算方式 | 对模长敏感 | 取值范围 | 适用场景 |
|---|---|---|---|---|
| 余弦相似度 | 点积除以模长乘积 | 否 | -1 到 1 | 文本语义比较的默认选择 |
| 点积 | 对应维相乘求和 | 是 | 无上界 | 向量已归一化时,等价余弦且更快 |
| 欧氏距离 | 差的平方和开根号 | 是 | 0 到正无穷 | 向量已归一化时,与余弦单调对应 |
文本场景我默认用余弦,原因是文本长度差异大,长文本的向量模长往往更大,用点积会让长文本天然占优,短查询匹配长文档时容易被压制。归一化之后两者等价,那就直接用点积,省掉除法的开销,向量库内部也都是这么做的。
欧氏距离在归一化之后和余弦是单调对应的(距离越小余弦越大),所以选哪个不影响排序,只影响你看到的数值。如果你的向量库只支持欧氏距离,不用担心,归一化之后结果一致。
有一个例外情况要注意:某些模型在训练时用的就是点积损失,没有做归一化,那么它的向量模长本身携带信息(比如文本的“重要性”或者“置信度”),这时候强行归一化反而会丢信息。这种情况很少,但如果你的模型文档明确说了不要归一化,就按文档来。
4.2 为什么 0.8 分不能当成 80% 把握
这是我最想强调的一点。余弦相似度是一个相对排序指标,不是概率。0.8 不代表“八成相关”,0.6 也不代表“六成相关”。
原因有三个。第一,不同模型的分数分布完全不同。有的模型把所有文本都映射到一个很窄的锥形区域里,任意两句话的相似度都在 0.5 以上,那 0.6 就是“很不相关”;有的模型分布很散,0.4 就已经是强相关了。第二,同一个模型在不同语料上的分布也不同。你的语料如果全是同一个领域的文档,彼此天然相似,整体分数会偏高。第三,查询和文档的长度差异会系统性影响分数,短查询对长文档的分数普遍偏低。
所以正确做法是:排序用分数,判定用阈值,而阈值必须在自己的数据上校准。不要抄别人的 0.7、0.75,那些数字放到你的数据上大概率是错的。
工程上我一般用“双保险”:先取 top-k(比如 10 条),再用一个绝对值阈值过滤掉明显不相关的。阈值不求精确,只求把噪声挡在门外。而且这个阈值要定期复查,因为语料在变、模型可能换,分布会漂移。
4.3 用三十条样本把阈值钉死
校准阈值不需要大数据集,三十到五十条查询就够了,关键是每条都要人工标出“哪些文档是真正相关的”。
具体做法:准备 40 条真实用户查询,对每条准备 20 到 30 个候选文档(从你的库里随机抽加上人工挑几个难的),人工标注相关性(相关/不相关二分类就行)。然后用你的模型算出所有查询-文档对的相似度,按分数从高到低排序,逐个分数点试:如果以这个分数为阈值,精确率和召回率分别是多少。
import numpy as np def eval_threshold(scores, labels, thresholds): # scores: 相似度数组;labels: 1 表示相关,0 表示不相关 rows = [] for t in thresholds: pred = (scores >= t).astype(int) tp = int(((pred == 1) & (labels == 1)).sum()) fp = int(((pred == 1) & (labels == 0)).sum()) fn = int(((pred == 0) & (labels == 1)).sum()) precision = tp / (tp + fp) if tp + fp else 0.0 recall = tp / (tp + fn) if tp + fn else 0.0 rows.append((round(float(t), 2), round(precision, 3), round(recall, 3))) return rows thresholds = np.arange(0.30, 0.95, 0.05) for row in eval_threshold(np.array(scores), np.array(labels), thresholds): print(f"阈值={row[0]} 精确率={row[1]} 召回率={row[2]}")输出会告诉你一个很实际的结论:阈值调高,精确率上去、召回率下来;调低则相反。选哪个取决于你的业务容忍度。召回不够的代价是“用户找不到”,精确不够的代价是“用户看到一堆无关内容”或者“生成模型被噪声带偏”。做 RAG 的时候我一般偏向多召回一些,把 top-k 放大到 20 甚至 30,然后靠后面的重排模型收敛,而不是在召回阶段就把阈值卡死。
校准还有一个副产品:你会看到那些“模型认为很像但实际不相关”的难例,这些样本是后续优化(换模型、微调、加关键词召回)最宝贵的输入。
5. 四个真实用法:检索、去重、聚类、分类
5.1 语义检索与知识库召回:切块策略比模型更影响效果
做知识库问答的时候,很多人把精力全放在选模型上,实际上切块策略对最终效果的影响往往比模型选型更大。
原因很直接:向量是块级别的,如果你的块切得不好,一块里混了三四个不相关的主题,这个块的向量就是一个“平均脸”,跟谁都不像。反过来,块切得太碎,一句话一块,上下文丢了,向量也没意义。
我的经验参数:中文文档按 300 到 500 字切块,块之间保留 50 到 100 字重叠。重叠的作用是防止关键信息正好卡在切割边界上被劈成两半。切的时候优先按语义边界切(段落、标题、列表项),实在不行再按固定长度硬切。有标题结构的文档,把标题拼进块内容里再编码,效果提升很明显——标题往往就是这个块的主题句。
另一个实用技巧是父子块:小块用于检索(精度高),命中之后返回它所属的大块给生成模型(上下文足)。这样既解决了“块小了检索准但上下文不够”,又解决了“块大了检索糊”的矛盾。实现上就是给每个小块记一个父块 ID,召回后按父块去重。
再就是混合检索。纯向量检索对专有名词、型号、编号这类东西不敏感——“X2000”和“X3000”在向量空间里可能非常接近,但对用户来说是两个完全不同的产品。这种场景加一路关键词检索(BM25 之类),两路结果融合,能把这类漏召回补上。融合最简单的做法是倒数排序融合:按名次而不是按分数加权,避免两路分数不可比的问题。
5.2 工单去重:先归一化,再谈阈值
客服工单去重是我做过的最容易见效的嵌入应用。逻辑很简单:新工单进来,算向量,去历史工单里找最相似的几条,超过阈值就提示“可能与这些重复”。
这里最关键的参数不是模型,是阈值和“看几条”。我一般这样定:先取 top-5,然后看第一名和第二名的分数差。如果第一名明显高出一截(比如高 0.1 以上),那大概率是真重复;如果前几名分数都差不多,那往往是“同一类问题但不是同一个问题”,交给人工判断更稳妥。这个“分数差”信号比单纯的绝对阈值可靠得多,因为它利用了相对关系。
还有一个细节:工单里往往有一大段是模板化的客套话(“您好,这里是客服中心…”),这些内容对所有工单都一样,会把向量整体拉近。处理办法是去重前先做文本清洗,把模板段落、签名档、自动回复去掉,只留问题描述部分。这个清洗动作带来的效果提升,比换一个更强的模型还大。
阈值方面,去重场景我倾向保守:宁可漏判也别误判,因为误判的体验很差(用户会觉得系统在敷衍)。所以阈值取高一些,并且只在前几名分数差明显时才自动标记。
5.3 小样本分类:嵌入加一个线性层往往就够了
如果你有几百条标注数据,想做个文本分类(意图识别、情感极性、工单分类),不必上来就微调大模型。用现成的句向量模型把文本转成向量,然后在向量上训一个逻辑回归或者浅层网络,往往就能拿到不错的效果,而且训练成本几乎为零,几分钟就能迭代一轮。
原因是嵌入模型已经把语义信息提取出来了,你要做的只是在这个空间里画几条分界线。数据量小的时候,这种方案比微调更稳——微调几百条数据很容易过拟合,反而不如固定特征加简单分类器。
什么时候该升级到微调?两个信号:一是分类边界在语义上很微妙,比如“咨询”和“投诉”的区分往往取决于语气而不是内容,向量层面区分度不够;二是你有上万条标注,简单分类器的天花板到了。这时候可以用对比学习的方式微调编码器,把同类样本拉近、异类推远,效果会有明显提升。但要注意,微调之后的向量空间变了,之前建好的索引全部要重建,这个成本要提前算进去。
顺便说一个省事的技巧:分类任务里,如果某一类样本特别少(比如只有十几条),可以用“类中心”的思路——把这一类的样本向量平均一下当作类的代表向量,新样本跟类中心比距离。这比训练分类器更不容易过拟合,冷启动阶段特别好用。
6. 踩坑记录:五个我真实踩过的坑
6.1 长文档截断,结论永远召不回来
这个坑我印象最深。当时做一个产品手册的问答,手册里每个章节三四千字,我图省事直接整章编码存进去。上线之后发现,用户问章节末尾才提到的参数,永远召不回来。查了半天,最后发现模型最大输入是 512 个 token,整章内容只编码了前面的四分之一,后面的全被静默截断了。
更麻烦的是这个问题很难自查,因为不报错、不警告,向量照常生成,只是内容不全。我后来的做法是:在编码前先统计 token 长度分布,把超过模型上限的文本单独拎出来看比例。如果超过 5%,就必须处理。处理方式要么换长输入模型,要么老老实实切块。切块虽然麻烦,但在检索场景里通常比长输入模型效果更好,因为块小了之后每个向量的语义更聚焦。
一个自查方法:随便抽十条长文本,把它们的向量和“文本末尾那句关键句”的向量算相似度。如果相似度低得离谱,基本就是被截断了。
6.2 换了模型,向量库必须重建
有一回我们觉得检索效果不够好,换了个更强的模型。维度一样,都是 768,代码里只改了一行模型路径,索引也没重建,直接跑——结果检索结果乱七八糟,甚至出现了完全不相关的文档排第一。
原因很简单:不同模型训练出来的向量空间完全不同,即使维度一样,第 37 维在两个空间里代表的含义也毫无关系。旧向量和新查询向量做点积,等于把两份不同语言的词典硬对着翻译,结果毫无意义。
正确的做法是:模型版本必须和向量库绑定。我在向量库里加了一个元数据字段记录模型版本和模型指纹,查询时校验,不匹配就拒绝服务并提示重建。重建的成本可以通过缓存降低——如果只是换模型,所有向量都要重算,这部分省不掉,所以换模型前一定要先在小评测集上验证清楚真的更好,别频繁折腾。
6.3 行业黑话不敏感,别急着怪模型
通用句向量模型在专业领域经常表现不佳,因为训练数据里这些术语出现得少,模型没学过它们之间的关系。我做过一个工业设备的问答,设备型号、故障代码满天飞,通用模型把这些代码全都映射到了空间里同一片模糊区域,导致“E102 报警”和“E205 报警”的向量几乎一样。
解决办法有三个层次,成本从低到高。最便宜的是混合检索:把型号、代码这类强标识符用关键词检索单独处理,向量检索负责语义部分,两路结果融合。这个方法立竿见影,我一般先上这个。第二个层次是在编码前做术语展开,把缩写、别名、口语说法统一成规范说法,或者在文本里补一句解释,让模型有更多语义线索。第三个层次才是微调编码器,用领域内的相似文本对做对比学习。微调效果最好,但需要构造训练数据,周期以周计,不要一上来就走这条路。
6.4 索引参数与过滤条件的组合陷阱
向量库不是把向量丢进去就能用。以图索引为例,有几个参数直接影响召回率和速度:控制每个节点连边数量的参数决定了索引的连通性,建索引时的搜索深度参数影响索引质量,查询时的搜索深度参数影响查询时的召回和延迟。
这些参数的调法不复杂,但有一个陷阱特别隐蔽:带过滤条件的查询容易召回不足。比如你只想在某个分类下检索,向量库会先按条件过滤出候选集,再在候选集里做近邻搜索。如果候选集很小,而索引的连通性又是在全量数据上建的,过滤之后图可能被切断,导致明明有相关文档却搜不到。表现为“去掉过滤条件就能搜到,加上就搜不到”。
应对办法:要么把过滤条件做成“后过滤”(先搜索更大的 top-k 再过滤,牺牲一点精度换召回),要么把分类信息也编码进向量让同类聚在一起,要么干脆按分类分库。选哪种取决于你的过滤维度和数据分布,但一定要专门测一下带过滤的场景,别只测无过滤的。
6.5 成本与延迟:什么时候该离线算,什么时候该实时算
最后一个坑是性能和成本的账没算清楚。
原则很简单:能离线算的都离线算。文档库、商品库、历史工单,这些都是相对静态的,向量应该提前算好存起来,查询时只算用户那一句。实时编码的只有查询侧,一条文本几十毫秒,完全可接受。如果连查询侧都要编好几次(比如多路查询改写),那就要考虑加缓存,把高频查询的向量缓存起来。
批量重建的时候有几个能显著提速的点:按长度分桶减少 padding 浪费、用更大的批量吃满硬件、关掉不需要的梯度计算、用半精度推理。这几个加起来,吞吐能翻好几倍。另外,重建不要全量重来,用内容哈希做增量,只算变化的块,这个前面说过,效果非常明显。
延迟方面,如果你的检索链路里还有重排模型,要注意重排往往是延迟大头,不是向量检索。向量检索在百万级数据上通常是个位数毫秒,真正慢的是后面的模型推理。所以优化顺序应该是先看重排,再看编码,最后才看向量索引。
我个人在实际项目里的体会是,嵌入这套东西的技术门槛不高,难的是工程细节:切块怎么切、阈值怎么定、缓存怎么做、版本怎么管。模型选型在整个效果里大概只占三成,剩下七成都在这些不起眼的地方。所以如果你刚开始做,别在选模型上纠结太久,先用一个够用的模型把全链路跑通,然后用真实查询去校准阈值和切块策略,效果提升会比换模型快得多。