ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Embedding语义检索实战:从归一化到混合召回

Embedding语义检索实战:从归一化到混合召回

第一次拿两段文本算相似度,屏幕上跳出 0.83,我心想这事成了。第二天换了一批数据,0.83 出现在两段毫不相干的文字之间,回头一查,向量压根没做归一化,余弦相似度被我当成点积在用。Embedding 这个方向就是这么个东西:接口简单到一行encode()就能出结果,但真正决定效果好坏的全在接口之外——切分粒度、归一化、模型选型、索引更新策略,任何一环偷懒,最后都会以"分数看着挺高但召回全是垃圾"的形式还给你。

这篇内容面向的是想把语义向量真正用起来的人:不管你是刚接触这个概念,想知道向量到底在表示什么;还是已经跑通了 Demo,正准备往生产环境搬,被内存、延迟、召回率折腾过一轮。我会从坐标系的直觉讲起,把几条技术路线的取舍讲清楚,然后给一份能直接跑的最小检索实现,再把我在实际项目里踩过的四个典型故障连同排查链路完整还原一遍,最后聊规模上去之后的量化、混合检索和评估方法。文中代码以 numpy 为核心,不绑定任何特定框架,你把手上的编码模型换进去就能用。

1. 把 Embedding 当成"给语义标坐标"就通了

1.1 one-hot 的困境:词表有多大,浪费就有多大

最早的文本数值化是计数式的。假设词表有十万个词,那每个词就是一个十万维的向量,只有自己那一位置 1,其余全是 0。这个表示法的问题不在于维度高,而在于任意两个词的内积恒为 0。"手机"和"电话"在语义上几乎是邻居,在这套表示里却是完全正交的两个方向,模型拿不到任何"它们相近"的信号。

更麻烦的是稀疏性带来的统计问题。十万维里只有一维有效,剩下九万九千九百九十九维全是零,任何基于距离、聚类、相似度的方法在这种表示上都会失效。你要是想训练一个分类器,参数量的绝大部分都花在了永远为零的维度上。这也是为什么早期的文本任务大量依赖人工特征工程——模型自己没法从这种表示里学到词与词之间的关系。

Embedding 要解决的核心问题就是:找一个低维稠密的表示,让语义相近的对象在空间里的位置也相近。维度从十万降到几百,每一维都有实数值,不再是"是或否",而是"在这个语义方向上偏多少"。这一降一稠,才让后面的距离计算、聚类、检索成为可能。

1.2 分布式假设:一个词的意思藏在它的邻居里

支撑整套方法的假设只有一句话:意思相近的词,出现的上下文也相近。这个假设朴素得有点过分,但它极其能打。你没法给"咖啡"下一个精确定义,但你可以观察它周围常出现什么——"喝""杯""苦""早晨""提神",把这些上下文特征拼起来,"咖啡"的位置就大致确定了。

基于这个思路,早期的训练方法把语料切成一个个窗口,用中心词预测上下文,或者反过来用上下文预测中心词。训练完成后,模型中间那层权重矩阵的每一行,就成了一个词的向量。有意思的地方在于,这种训练目标本身跟"语义相似"没有任何直接关系,它只是在做预测任务,但副产品——那些权重——恰好编码了语义关系。这也是很多人第一次接触时觉得反直觉的点:我们想要的是向量,但训练目标却是"猜邻居"。

理解这一点很关键,因为它解释了一个后续会反复出现的现象:向量的质量取决于训练语料的分布。你的业务语料如果是医疗问诊记录,用通用语料训出来的向量在"复查""随访""指征"这些词上就会表现平平,因为它在训练时见到的上下文跟你的场景差太远。这也是后面讲选型时,"领域匹配度"要排在"模型参数量"前面的原因。

1.3 维度到底在表示什么,以及为什么不是越高越好

很多人默认维度越高效果越好,这个直觉在 Embedding 上只对了一半。维度本质上是可用来区分语义的自由度数量。维度太低,比如 32 维,你能表达"情感正负""正式与非正式"这些粗粒度差异,但分不清"退款"和"退货"这种细微区别,因为它们会挤在同一个方向上。维度提上去,空间变大,能容纳的区分度变多。

但维度的收益是有天花板的,而且代价是实打实的。一个 768 维的 float32 向量,单条占 768 × 4 = 3072 字节,也就是 3KB。十万条文本就是 300MB,一百万条就是 3GB,一千万条直接 30GB。这还只是原始向量,没算索引结构本身的开销。当维度继续往上走到 1536 甚至更高时,内存和检索延迟都会成比例上升,而检索精度的提升往往只有一两个百分点。

我在实际项目里见过的一种典型浪费是:向量维度选了 1024,但只做关键词级别的粗召回,业务上根本不需要那么细的区分度。这种情况降到 256 维,内存直接砍到四分之一,端到端延迟明显下降,而业务指标几乎没动。还有一种情况是反过来——用 128 维的向量去区分上千个高度相似的商品标题,模型空间不够用,大量不同商品被压到几乎同一个位置,召回怎么做都是错的。

维度的选择应该由"最细需要区分到什么程度"决定,而不是由"哪个模型参数多"决定。先跑一批真实数据,看同一类别的向量距离分布和不同类别的向量距离分布有没有明显分离,再决定要不要加维度。

补一个容易忽略的点:Embedding 的处理对象早就不限于词了。句子、段落、整篇文档可以编码,图片可以编码,用户的行为序列、商品的属性组合、代码片段、音频片段同样可以。只要你需要"判断两个东西像不像",把两者编码到同一个空间就是通用解法。所以这套方法真正的价值不在文本领域,而在于它提供了一种跨模态的相似度计算范式——图片和文字摆在同一个空间里,可以直接算距离。

2. 四条技术路线的分野与选型依据

2.1 静态词向量:局部窗口预测与全局共现统计

静态词向量这条路线的代表是两类方法。一类是基于局部窗口的预测模型,用中心词猜上下文或反过来,训练快、实现简单;另一类是基于全局共现矩阵的分解方法,先统计整个语料里词与词共现的频率,再对这个稀疏矩阵做降维分解。两者的共同点是每个词最终只对应一个固定向量。

这两类方法内部的细节差异很值得一说。窗口预测模型里,用中心词预测上下文(Skip-gram 思路)通常比反过来(CBOW 思路)对低频词更友好,因为每个低频词出现一次就能贡献多个训练样本;而 CBOW 对高频词的平滑效果更好、训练更快。窗口大小也是个隐含的超参数:窗口小,学到的偏向句法相似("喝"和"饮"接近);窗口大,学到的偏向主题相似("咖啡"和"早餐"接近)。这个差异在做同义词替换和做文档聚类时表现完全不同。

共现矩阵分解路线的优势在于它直接利用了全局统计量,对语料规模不太敏感,在中小语料上往往表现稳定。但两类方法都有致命短板:一个词只有一个向量。"苹果"在"吃苹果"和"苹果公司"里必须是同一个坐标,模型只能学到这两类用法的某种加权平均结果,往下游一传,歧义就跟着传下去了。

静态向量的实用价值并没有完全消失。在轻量场景——比如对延迟极其敏感、模型必须跑在资源受限设备上、或者只需要粗粒度的话题聚类——它仍然是性价比很高的选项。我处理过一个日志归类的需求,用几百维的静态向量加简单聚类,准确率够用,单机每秒能处理上万条,换用大模型反而没必要。

2.2 上下文相关向量:同一个词为什么会有两个坐标

要解决一词多义,就必须让向量依赖上下文。思路是先用大规模语料预训练一个编码器,让它学会"根据周围的词动态调整当前词的表示",然后再把编码器输出的隐藏状态拿来做下游任务。这样一来,"苹果"在两种语境里的坐标就自然分开了。

这条路线带来的不只是消歧。因为编码器在预训练阶段见过海量文本,它隐含地学到了句法结构、指代关系、甚至一定程度的推理能力,所以同一个编码器输出的向量,在语义相似度、文本分类、实体识别等多个任务上都能直接用。семантically 相近的整句,其句向量也会靠近,这为后来的句向量检索打下了基础。

代价是推理成本跳了一个数量级。静态向量查表就出结果,上下文编码器每条文本都要完整前向一次,序列长度直接决定计算量。一条 512 token 的文本,在普通 CPU 上跑几十毫秒到几百毫秒很正常。如果你的场景是百万级文档建索引,这个成本必须提前算清楚——一次性建库可能还能接受,但如果文档会频繁更新,就得上批处理队列和增量策略了。

还有一个实践中的坑:上下文编码器输出的是每个 token 的向量,不是整句的向量。早期很多人在 token 向量上直接取平均或者取第一个 token 来代表整句,效果忽好忽坏。原因是这些编码器在预训练时的目标不是"让整句有一个好的表示",平均池化会稀释掉关键信息,取首 token 又过度依赖那一个位置。这也是后来专门针对句向量优化的训练方法出现的原因。

2.3 双塔句向量:检索场景为什么不用交叉编码器

做语义检索时,有两种结构可选:双塔和交叉编码器。

交叉编码器把查询和文档拼在一起送进模型,让模型在内部做完整的注意力交互,输出一个相关性分数。它的精度通常更高,因为查询和文档的每个词都能互相"看见"。但它的致命问题是无法预计算——每来一个查询,都要跟库里的每一篇文档重新算一遍。十万篇文档就是十万次前向推理,延迟直接爆炸。

双塔结构则是查询走一个编码器、文档走另一个编码器(实践中常常共享参数),各自独立产出向量,相关性用向量内积或余弦衡量。文档侧的向量可以离线算好存起来,查询侧只需要算一次,然后做向量检索。这是唯一能撑起大规模检索的结构,代价是两侧在编码阶段完全隔离,没有细粒度的词级交互,精度上会让一点给交叉编码器。

所以工业界的标准做法是两段式:双塔做粗召回,从百万级里捞出几十到几百条候选;交叉编码器做精排,对这几百条做精细打分。粗召回保证覆盖率,精排保证准确率,两者各司其职。这个组合我在后面第 5 章会给出具体实现。

双塔的训练方式也值得说一句。目标是让语义相关的查询-文档对在空间里靠近,不相关的推远。常用的做法是构造正负样本对做对比学习,一个关键细节是负样本的难度:全是随机负样本,模型学不到细粒度区分;混入一定比例的"难负样本"(表面相似但实际不相关),效果提升很明显。这个思路在做领域微调时同样适用。

2.4 选型对照:维度、语言、吞吐与硬件门槛

把几条路线的关键指标摆在一起,选型时会清晰很多。下表是我在实际项目中反复参考的一个对照,数值是常见区间,不是绝对标准:

路线典型维度是否区分一词多义单条文本编码开销硬件门槛适合的场景
静态词向量100 - 300否微秒级(查表)纯 CPU 可跑日志归类、粗粒度聚类、资源受限环境
上下文编码器(token 级)512 - 1024是十至百毫秒建议 GPU 批处理序列标注、需要词级表示的抽取任务
双塔句向量256 - 1024是十至百毫秒GPU 批处理更划算语义检索、去重、聚类、推荐召回
交叉编码器不产出向量是每条候选一次前向必须 GPU精排阶段,候选集在百级以内

选型时我一般按这个顺序问自己四个问题:第一,我要处理的是词级还是句/段落级?词级选前两条,句级选后两条。第二,数据量是十万还是千万?千万级必须考虑向量存储成本和索引结构。第三,更新频率高不高?高频更新要避开重编码成本高的路线。第四,有没有 GPU?没有的话,双塔模型的批量编码得排好队慢慢跑,别指望实时。

还有一个经常被忽略的维度:多语言支持。如果业务里有中英混杂的内容,选一个只在单一语种上训练过的模型,跨语言检索基本没法用——中文查询检索不到英文文档,因为两个语种的向量根本不在同一个空间里。多语言模型的训练语料覆盖多种语言,能把不同语言的等价表达映射到相近位置,这是单语模型做不到的。

3. 一个能跑起来的语义检索 Demo,从清洗到召回

3.1 环境与依赖:版本冲突比算法难缠

先把话说在前面:这类项目里,环境问题造成的排查时间经常超过算法本身。核心依赖其实只有两个——数值计算库和一个能产出句向量的编码模型。数值计算用 numpy 足够,编码部分你可以接任意开源的句向量模型,本文代码刻意做了抽象,你只需要实现一个encode_texts(list_of_str) -> np.ndarray函数,返回形状为[N, dim]的二维数组。

环境上有几个高频坑我列一下。第一,Python 版本和科学计算库的版本要匹配,某些新版本 Python 上预编译包还没跟上,pip 会尝试从源码编译,一编译就是半小时起步。第二,GPU 相关的运行库版本要和驱动对齐,版本不匹配时表现为"能 import 但一跑就报设备错误",而不是安装失败,很容易误判。第三,编码模型的运行库和数值库之间可能存在依赖版本冲突,装完 A 之后 B 的某个函数签名变了。

我的做法是给每个项目建独立虚拟环境,并在依赖文件里锁死版本号,包括间接依赖。这不是洁癖,是因为这类项目往往要跑很久,半年后回来复现时,依赖漂移会让你怀疑人生。锁版本的成本是几分钟,不锁的成本可能是几天。

python -m venv .venv source .venv/bin/activate pip install "numpy==1.26.4" # 编码模型的运行库按你选用的模型文档安装,并在此处锁版本 pip freeze > requirements.txt

3.2 文本清洗与切分:颗粒度直接决定召回上限

向量检索的上限在切分那一步就定了。这个判断我越来越确信:如果切分把语义切断了,后面用什么模型都救不回来。因为模型只能编码它看到的内容,看不到的东西它没法表示。

清洗要做的事情不复杂,但每一项都影响结果。全角半角统一,否则"ABC"和"ABC"会被当成不同 token;连续空白和换行压成一个空格,避免无意义字符占满序列长度;HTML 标签和多余的转义字符去掉,这些内容对语义没有贡献,却会实实在在地挤占 token 预算;不可见字符(零宽空格之类)要专门处理,它们在肉眼看来不存在,但会打断分词。

切分策略上,按固定字数硬切是最省事也最糟糕的做法。中文一句话动辄二三十字,你按 200 字硬切,很容易把一句话从中间劈开,"这个方案不支持批量导入,因为"和"数据量太大会超时"被分到两个块里,两个块单独看都不完整,向量自然也就偏了。

我的常规做法是优先按语义边界切,再做长度兜底:

import re def split_by_sentence(text, max_chars=300, overlap=50): # 先按中英文句末标点切句 sentences = re.split(r'(?<=[。!?!?;;\n])', text) sentences = [s.strip() for s in sentences if s and s.strip()] chunks, buf = [], "" for s in sentences: if len(buf) + len(s) <= max_chars: buf += s else: if buf: chunks.append(buf) # 单句超长时按硬长度切,并保留 overlap while len(s) > max_chars: chunks.append(s[:max_chars]) s = s[max_chars - overlap:] buf = s if buf: chunks.append(buf) return chunks

overlap这个参数值得单独解释。它的作用是让相邻块之间保留一段重叠内容,防止关键信息正好落在切割线上被切掉。代价是存储量增加,overlap 为 50 而块长 300 时,存储量大约多出 17%。这个开销我觉得完全值得,因为被切掉的那句话往往就是用户查询命中的那句话。

3.3 编码、归一化与批量推理

编码这一步看起来只是一行调用,实际有两个点必须做对。

第一个是批处理。逐条编码在 GPU 上效率极低,因为每次前向的固定开销远大于计算本身。我一般按 32 到 128 的批大小处理,具体值取决于序列长度和显存。长文本批次要调小,短文本可以调大。

第二个是归一化。这一步不做,后面所有相似度计算都会失去意义,我会在第 4 章详细讲这个坑有多隐蔽。这里先把代码写好:

import numpy as np def normalize(mat, eps=1e-12): """按行做 L2 归一化,输入 [N, dim],输出同形状""" norm = np.linalg.norm(mat, axis=1, keepdims=True) # 加 eps 防止全零向量导致除零 return mat / np.maximum(norm, eps) def build_index(texts, encode_texts, batch_size=64): all_vecs = [] for i in range(0, len(texts), batch_size): batch = texts[i:i + batch_size] vecs = encode_texts(batch) all_vecs.append(vecs) mat = np.vstack(all_vecs).astype(np.float32) mat = normalize(mat) # 归一化后如果出现全零行,说明原始向量异常,需要排查 zero_rows = np.where(np.linalg.norm(mat, axis=1) < 1e-6)[0] if len(zero_rows) > 0: print(f"警告:{len(zero_rows)} 条向量接近零向量,索引位置 {zero_rows[:10]}") return mat

那段零向量检查不是我多此一举。在实际数据里,空字符串、纯符号、纯表情、全被清洗掉的文本,都会产出接近零的向量。这种向量跟任何查询的相似度都是 0 或接近 0,本身无害,但如果你后续做了量化或者降维,零向量会成为异常点,影响归一化参数的估计。提前发现比事后排查省事得多。

3.4 相似度计算与 Top-K 召回的正确写法

归一化之后,余弦相似度就等价于点积,直接用矩阵乘法即可。这里有个性能细节:不要在 Python 里写循环逐条算,numpy 的矩阵乘法会走底层优化过的实现,速度差几十倍很正常。

def search(query_vec, doc_mat, k=10): """query_vec: [dim] 已归一化; doc_mat: [N, dim] 已归一化""" scores = doc_mat @ query_vec # [N] k = min(k, len(scores)) # argpartition 是 O(N),比全排序 O(N log N) 快 idx = np.argpartition(-scores, k - 1)[:k] idx = idx[np.argsort(-scores[idx])] # 只对这 k 个排序 return idx, scores[idx]

argpartition这个用法值得记住。当你只需要前 10 个结果而库里有 100 万条时,全排序要排 100 万个元素,而argpartition只需要把数据分成"前 k 大"和"其余"两部分,复杂度是线性的。在这个场景下,实测下来能快三五倍。

如果要批量查询,直接把矩阵乘起来:

def batch_search(query_mat, doc_mat, k=10): """query_mat: [M, dim], doc_mat: [N, dim],返回 [M, k] 的索引和分数""" score_mat = query_mat @ doc_mat.T # [M, N] out_idx, out_scores = [], [] for i in range(score_mat.shape[0]): row = score_mat[i] idx = np.argpartition(-row, k - 1)[:k] idx = idx[np.argsort(-row[idx])] out_idx.append(idx) out_scores.append(row[idx]) return np.array(out_idx), np.array(out_scores)

3.5 把逐条循环换成矩阵乘法

上一节的批量版本里还有一层循环,如果 M 很大,这层循环会成为瓶颈。彻底的向量化写法是利用argpartition的 axis 参数:

def batch_search_vectorized(query_mat, doc_mat, k=10): score_mat = query_mat @ doc_mat.T # [M, N] # 沿最后一维取 top-k 的索引,一次算完所有查询 part = np.argpartition(-score_mat, k - 1, axis=1)[:, :k] # 取出这些位置的分数,用于排序 part_scores = np.take_along_axis(score_mat, part, axis=1) order = np.argsort(-part_scores, axis=1) final_idx = np.take_along_axis(part, order, axis=1) final_scores = np.take_along_axis(part_scores, order, axis=1) return final_idx, final_scores

向量化版本在 M 为几千、N 为几万时,相比循环版本有非常明显的提升。不过要注意内存:score_mat的形状是[M, N],M=10000、N=100000 时就是 10 亿个 float32,约 4GB,直接撑爆。所以批量查询时要控制单批的查询数量,把 M 限制在几百以内,分多批跑。

这个内存估算习惯我建议养成。做向量检索时,脑子里要随时有一个账本:N 条向量占多少、打分矩阵占多少、索引结构占多少。很多"卡死"不是算法问题,是这个账本没算清楚。

4. 四个高频故障的完整排查链路

4.1 显存爆了:从 batch 到长尾文本逐层定位

现象:编码脚本跑到一半报显存不足,之前用同样的批大小跑得好好的。

我一般是这么排的。第一步,确认是不是 batch 突然变大了。最隐蔽的情况是数据里混进了超长文本,序列长度从平均 100 暴涨到 8000,注意力计算的显存开销大致随序列长度平方增长,一条超长文本就能吃掉整个批次的预算。排查方法是跑之前先统计一遍长度分布,把 P99 和最大值打出来。

lengths = [len(t) for t in texts] lengths.sort() n = len(lengths) print("P50:", lengths[n // 2]) print("P95:", lengths[int(n * 0.95)]) print("P99:", lengths[int(n * 0.99)]) print("Max:", lengths[-1])

如果 P99 和 Max 差了一个数量级,说明有长尾,必须做长度过滤或者截断。

第二步,检查推理时有没有关闭梯度。这是新手最常踩的坑,编码任务根本不需要反向传播,但如果不显式声明,框架默认会构建计算图,显存占用可能是正常情况的三到五倍。这个错误的表现是"第一批就爆",非常好识别。

第三步,检查是不是缓存没释放。循环里如果不断累积中间结果的引用,显存会缓慢上涨,最后在某个随机位置爆掉。表现是"每次都爆在不同位置",且跑得越久越危险。处理办法是每批处理完显式清空中间变量,并把结果直接转成 numpy 存下来。

第四步,如果以上都对,考虑把批次按长度分组。把相近长度的文本放一个批次,可以避免"一条长文本拖累整个批次都要 pad 到最长"的浪费,实际吞吐能提升不少。

4.2 分数看着合理,排序却乱:归一化与距离度量混用

这个故障我在开头提过,它值得展开讲,因为它的表现太有欺骗性了。

现象:相似度分数都在 0.7 到 0.95 之间,看起来很合理,但 Top-10 结果里明显有无关内容,而真正相关的那条排在二十名开外。

根因通常有三类。第一类,向量没归一化,却用点积当余弦。点积同时受方向夹角和模长影响,一个模长特别大的向量,跟任何查询的点积都会偏大,于是它永远排前面。很多模型输出的原始向量模长差异很大,不做归一化直接点积,等于给某些文档偷偷加了权重。

第二类,索引侧归一化了,查询侧忘了。这个更隐蔽,因为大部分情况下影响不大,只在边界上出问题,所以容易被当成"模型的锅"。

第三类,距离度量和排序方向搞反了。欧氏距离是越小越近,余弦相似度是越大越近,如果索引库配置的是欧氏距离而你按降序取结果,取到的就是最远的那些。

验证方法很直接:拿一个已知答案的查询,手工算一下归一化前后的分数,对比排序是否变化。如果变化很大,基本就是归一化的问题。修复也很简单——索引和查询走同一个归一化函数,最好把这个函数封成唯一入口,不给绕过的机会。

# 反例:查询没有归一化 raw_query = encode_texts(["怎么退款"])[0] scores = doc_mat @ raw_query # doc_mat 已归一化 # 正例 q = normalize(encode_texts(["怎么退款"]))[0] scores = doc_mat @ q

4.3 中文长文本被切碎后语义断裂

现象:文档明明写了"退货政策不适用于定制商品",但查询"定制商品能退吗"召回不到这个块。

排查链路是这样的。先看召回结果里有没有这条文档。没有的话,看它的向量跟查询向量的相似度是多少。如果分数极低,说明向量本身就不对,问题出在编码阶段。然后去看这条文档被切成了几块、每块具体是什么内容。我遇到过的真实情况是:一句关键的话被从中间切开,"退货政策不适用于"在上一块,"定制商品"在下一块,两个块单独看都表达不了完整意思,查询自然匹配不上。

如果确认是切分问题,处理办法有三层。第一层是调整切分粒度,把块长调大或者改成按标题层级切,保证一句话不被劈开。第二层是加 overlap,让关键句至少完整出现在一个块里。第三层是块内补上下文,在每个块前面加上所属的章节标题或文档主题,这样即使块本身不完整,加上上下文后语义也基本完整了。

第三层这个做法很多人不知道,但效果很实在。比如把块内容从"不适用于定制商品"改成"【退货政策】不适用于定制商品",向量立刻就能跟"能不能退"这类查询对上了。代价是每个块多了几十个字符,编码成本略微上升。

还有一种情况是查询本身太短。"退货"两个字的信息量极低,任何包含"退货"的块都能匹配到,排序就靠运气。这时候可以做查询扩展——把用户的短查询先过一遍改写,补全成完整问句再编码。这个做法在检索系统里很常见,效果比调模型参数来得直接。

4.4 增量更新之后,新旧向量不在同一个空间

现象:索引建好之后跑了一段时间,陆续加了几批新文档,结果新文档几乎从不被召回。

根因通常是编码模型换了。可能是升级了版本,可能是换了不同的模型,也可能是同一个模型但预处理逻辑改了(比如加了新的清洗规则、改了截断长度)。向量空间是由模型定义的,两个不同模型产出的向量摆在一起算相似度,在数学上没有任何意义——它们就像两套独立的坐标系,原点、轴向、尺度都可能不同。

排查方法:抽十组语义相近的文本,分别用"建索引时的模型"和"当前模型"编码,看两组向量的距离分布是否一致。如果不一致,就是模型漂移了。

处理办法只有一个:全量重建索引。增量更新之前必须先确认模型版本一致,把模型版本号当成索引元数据写进去,重建时校验。这个约束听起来笨重,但比"混着用导致效果莫名变差"好得多。如果重建成本太高,退而求其次的做法是建两套索引并行查询,结果合并,但这只是过渡方案,长期来看还是要统一。

我的习惯是:把模型标识、维度、归一化方式、切分参数这四项写进索引的配置里,任何一项变化都触发"必须重建"的告警。这个检查花不了多少代码,但能避免很多"我什么都没改,效果怎么变差了"的困惑。

5. 向量数量上去之后:存储、成本与召回的逐级优化

5.1 量化与降维:内存从 GB 级压到百 MB 级

数据量到了一定规模,float32 存储会变得很奢侈。一百万条 768 维向量是 3GB,一千万条就是 30GB,光放内存里就够呛,更别提还要做检索。

第一个手段是量化。把 float32 压成 int8,内存直接降到四分之一。原理是给每一维(或每一段)算一个缩放因子,把浮点范围映射到 -127 到 127 的整数区间。检索时把查询向量也用同样的因子量化,做整数点积,最后再乘回缩放因子还原分数。

def quantize_int8(mat): """按列计算缩放因子,把 float32 压成 int8""" scale = np.abs(mat).max(axis=0, keepdims=True) / 127.0 scale = np.maximum(scale, 1e-12) q = np.round(mat / scale).clip(-127, 127).astype(np.int8) return q, scale.astype(np.float32) def dequantize_int8(q, scale): return q.astype(np.float32) * scale

这个做法在实测里精度损失很小,召回率通常只掉一两个百分点,而内存降到四分之一、检索速度提升明显。原因是向量检索对绝对数值精度不敏感,只要相对排序不大变,Top-K 结果就基本一致。

第二个手段是降维。用主成分分析把 768 维压到 256 维,保留方差最大的那些方向。numpy 里用奇异值分解实现很直接:

def fit_pca(X, dim): mean = X.mean(axis=0, keepdims=True) Xc = X - mean U, S, Vt = np.linalg.svd(Xc, full_matrices=False) # Vt 的前 dim 行就是主方向 return mean, Vt[:dim].T def apply_pca(X, mean, W): return (X - mean) @ W

这里有个顺序问题要注意:先降维还是先量化。我的建议是先降维再量化,因为降维之后维度少了,量化的误差也会被压缩,整体精度损失更可控。反过来先量化再降维,降维过程会放大整数化的舍入误差。

还有一个组合技:降维后重新做一次归一化。因为降维会改变向量的模长,如果不重新归一化,之前算好的距离关系就全变了。这一步很容易忘。

5.2 混合检索:向量召回之外要留一条关键词兜底

纯向量检索有个固有短板:它对精确匹配不敏感。用户搜一个具体的错误码、一个订单号、一个产品型号,向量模型很可能把它编码成一个泛化的"数字类词汇",召回来一堆语义相近但完全无关的内容。因为模型在训练时见到的这类精确字符串很少,学不到它们之间的区分度。

解决办法是混合检索:向量召回和关键词召回并行跑,然后融合结果。关键词召回用经典的词频统计方法就行,它在精确匹配上的表现至今没有对手——用户搜什么词,包含这个词的文档一定排前面。

融合方式我常用的是倒数排名融合:

def rrf_fuse(rank_lists, k=60): """rank_lists: 多个检索通道返回的 id 列表,按相关性降序""" scores = {} for ranks in rank_lists: for pos, doc_id in enumerate(ranks): scores[doc_id] = scores.get(doc_id, 0.0) + 1.0 / (k + pos + 1) return sorted(scores.items(), key=lambda x: -x[1])

这个公式的好处是只看排名不看分数,所以你不需要操心"向量相似度 0.8 和关键词得分 12.5 怎么加权"这种没有标准答案的问题。k 取 60 是文献里的常见默认值,作用是压平头部差距,避免某一路的第一名过度主导。实际调的时候,k 小一点更看重头名,k 大一点更均衡。

两路各自召回多少?我的经验是各取 50 到 100,融合后取前 20,再交给下一阶段。取太少融合空间不够,取太多后面精排压力大。

5.3 重排序补位:先粗后精的两段式结构

混合检索解决的是"能不能捞到",重排序解决的是"捞到之后排得对不对"。双塔模型由于两侧隔离,对查询和文档的细粒度关系把握不够,经常出现"相关但不最相关"的文档排在前面。

重排序用一个交叉编码器,把查询和每条候选拼在一起送进模型,输出一个精细的相关性分数。因为候选集只有几十到几百条,即使每条都要做一次完整前向,总延迟也还在可接受范围。

两段式的参数设置有个经验比例:粗召回取 50 到 200,精排后返回 5 到 10。粗召回太少,正确答案可能压根没进来,后面的精排再准也没用;粗召回太多,精排延迟会顶不住。这个比例要按你的延迟预算来定,如果延迟要求严苛,也可以省略精排,直接优化粗召回的质量。

5.4 别凭感觉判断效果:一套最小可用的评估方法

优化到这里,最危险的一件事就是凭感觉判断好坏。你调了切分参数、换了模型、加了量化,然后随手搜几个查询,觉得"好像好了一点"。这种判断方式极不可靠,因为你会不自觉地挑那些能验证自己想法的例子。

最小可用的评估方法需要一个标注集。不需要很大,两三百条就够,格式是"查询 + 应该被召回的正确文档 id"。标注集要覆盖你的真实查询分布:有短查询、有长查询、有精确匹配型、有语义泛化型。

有了标注集,就可以算指标了。最直观的是 Recall@K,看正确答案有没有出现在前 K 条里:

def recall_at_k(retrieved_ids, relevant_ids, k=10): hit = len(set(retrieved_ids[:k]) & set(relevant_ids)) return hit / max(len(relevant_ids), 1) def evaluate(search_fn, eval_set, k=10): scores = [] for query, relevant in eval_set: retrieved, _ = search_fn(query, k=k) scores.append(recall_at_k(retrieved, relevant, k)) return sum(scores) / len(scores)

除了召回率,我还会看 MRR(平均倒数排名),它更关注"第一个正确答案排在第几",对排序质量更敏感。两个指标一起看,能区分"没召回"和"召回了但排太后"这两种完全不同的失败模式,对应的优化手段也不一样。

评估集要固定住,不要一边优化一边改评估集。我见过的情况是:调参调不动了,就换一批更简单的查询,指标立刻好看了。这种做法除了自我安慰没有任何价值。

6. 只有真正跑过一轮才会在意的几个细节

6.1 模型版本换了,索引必须重建

这个前面提过一次,但值得单拎出来说,因为它的代价最大。向量空间是模型定义的,换模型等于换坐标系。哪怕只是同一个模型的微调版本,向量分布也可能发生偏移。

我的做法是把模型指纹(版本号、维度、归一化方式、预处理规则)作为元数据跟索引绑在一起,查询前先校验,不一致就拒绝服务并告警。这个检查写起来就几行,但能挡住一整类"效果莫名变差"的问题。有个团队的做法更彻底——把向量库和模型打包成一个不可分割的部署单元,要换一起换,从流程上杜绝了混用。

6.2 查询和文档的写法不对称,分数会系统性偏低

很多句向量模型是对称训练的,查询和文档用同一种方式编码。但检索场景本质上是不对称的:查询通常是短问句,文档是长段落。这种长度和形式的差异会让相似度分数系统性偏低,而且偏得很均匀,导致排序区分度不够。

处理办法是给查询加一个统一的前缀或模板,比如把"退货怎么办"改写成"查询意图:用户想知道退货流程"。这样编码出来的向量跟文档侧的分布更接近。有些模型原生支持这种"查询指令"机制,用的时候要严格按照模型说明加上对应的前缀,漏了会导致效果明显下降。

这个细节我在一个项目里吃过亏:换了新模型之后没有加查询前缀,召回率掉了七八个点,一度以为是模型变差了,回头翻文档才发现是用法不对。

6.3 空文本、超短文本与重复文本的边界处理

线上环境永远会给你送来意料之外的输入。空字符串、纯空格、纯符号、一两个字的查询、整段复制的重复内容,这些都要在编码前处理掉。

空文本会产生零向量,零向量跟任何查询的相似度都是 0,如果按分数排序它永远垫底,本身不会造成错误,但会浪费索引空间。我的做法是在入索引前过滤掉长度小于某个阈值的文本,同时记录下来,避免数据静默丢失。

纯符号和纯表情的文本,模型通常编码成一堆噪声,相似度结果没有意义。这类内容要么过滤,要么走别的处理路径。

重复文本是个更隐蔽的问题。同一篇文档被切成多个高度相似的块,检索时这多个块会一起挤进 Top-10,把其他相关内容挤出去。解决办法是在块级别做去重,相似度超过阈值的块只保留一个,或者用最大边际相关性做结果多样化处理,让返回的列表覆盖更多不同的信息点。

6.4 一点个人体会

做了一段时间下来,我最大的感受是:向量模型本身的效果提升,远不如数据预处理的改进带来的收益大。换个更大的模型,召回率可能涨两三个点;把切分改对、把归一化补齐、把混合检索加上,涨十几个点是常有的事。所以真正的功夫在数据这一侧,而不是在模型选型表里反复横跳。

另一件事是关于评估。我现在的习惯是,任何优化动手之前,先把当前版本的指标跑一遍存下来。改完之后再跑一遍,两个数字摆在一起比。没有这个基线,所有的"感觉好了"都不可靠——人的判断太容易被个别案例带偏,尤其是你自己刚改动的那部分。

最后一个提醒是延迟。语义检索链路的延迟是累加的:查询编码、向量检索、关键词检索、结果融合、精排,每一段都要算进去。我见过反复优化模型精度、最后上线发现延迟超标的案例,回头一算,光是精排那一段就吃掉了大半预算。动手之前先把延迟预算按段分配好,比事后砍功能容易得多。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进