2026/11/2 7:51:58

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 numpy1.26.4 # 编码模型的运行库按你选用的模型文档安装并在此处锁版本 pip freeze requirements.txt3.2 文本清洗与切分颗粒度直接决定召回上限向量检索的上限在切分那一步就定了。这个判断我越来越确信如果切分把语义切断了后面用什么模型都救不回来。因为模型只能编码它看到的内容看不到的东西它没法表示。清洗要做的事情不复杂但每一项都影响结果。全角半角统一否则和ABC会被当成不同 token连续空白和换行压成一个空格避免无意义字符占满序列长度HTML 标签和多余的转义字符去掉这些内容对语义没有贡献却会实实在在地挤占 token 预算不可见字符零宽空格之类要专门处理它们在肉眼看来不存在但会打断分词。切分策略上按固定字数硬切是最省事也最糟糕的做法。中文一句话动辄二三十字你按 200 字硬切很容易把一句话从中间劈开这个方案不支持批量导入因为和数据量太大会超时被分到两个块里两个块单独看都不完整向量自然也就偏了。我的常规做法是优先按语义边界切再做长度兜底import re def split_by_sentence(text, max_chars300, overlap50): # 先按中英文句末标点切句 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 chunksoverlap这个参数值得单独解释。它的作用是让相邻块之间保留一段重叠内容防止关键信息正好落在切割线上被切掉。代价是存储量增加overlap 为 50 而块长 300 时存储量大约多出 17%。这个开销我觉得完全值得因为被切掉的那句话往往就是用户查询命中的那句话。3.3 编码、归一化与批量推理编码这一步看起来只是一行调用实际有两个点必须做对。第一个是批处理。逐条编码在 GPU 上效率极低因为每次前向的固定开销远大于计算本身。我一般按 32 到 128 的批大小处理具体值取决于序列长度和显存。长文本批次要调小短文本可以调大。第二个是归一化。这一步不做后面所有相似度计算都会失去意义我会在第 4 章详细讲这个坑有多隐蔽。这里先把代码写好import numpy as np def normalize(mat, eps1e-12): 按行做 L2 归一化输入 [N, dim]输出同形状 norm np.linalg.norm(mat, axis1, keepdimsTrue) # 加 eps 防止全零向量导致除零 return mat / np.maximum(norm, eps) def build_index(texts, encode_texts, batch_size64): 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, axis1) 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, k10): 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, k10): 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, k10): score_mat query_mat doc_mat.T # [M, N] # 沿最后一维取 top-k 的索引一次算完所有查询 part np.argpartition(-score_mat, k - 1, axis1)[:, :k] # 取出这些位置的分数用于排序 part_scores np.take_along_axis(score_mat, part, axis1) order np.argsort(-part_scores, axis1) final_idx np.take_along_axis(part, order, axis1) final_scores np.take_along_axis(part_scores, order, axis1) return final_idx, final_scores向量化版本在 M 为几千、N 为几万时相比循环版本有非常明显的提升。不过要注意内存score_mat的形状是[M, N]M10000、N100000 时就是 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 q4.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(axis0, keepdimsTrue) / 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(axis0, keepdimsTrue) Xc X - mean U, S, Vt np.linalg.svd(Xc, full_matricesFalse) # Vt 的前 dim 行就是主方向 return mean, Vt[:dim].T def apply_pca(X, mean, W): return (X - mean) W这里有个顺序问题要注意先降维还是先量化。我的建议是先降维再量化因为降维之后维度少了量化的误差也会被压缩整体精度损失更可控。反过来先量化再降维降维过程会放大整数化的舍入误差。还有一个组合技降维后重新做一次归一化。因为降维会改变向量的模长如果不重新归一化之前算好的距离关系就全变了。这一步很容易忘。5.2 混合检索向量召回之外要留一条关键词兜底纯向量检索有个固有短板它对精确匹配不敏感。用户搜一个具体的错误码、一个订单号、一个产品型号向量模型很可能把它编码成一个泛化的数字类词汇召回来一堆语义相近但完全无关的内容。因为模型在训练时见到的这类精确字符串很少学不到它们之间的区分度。解决办法是混合检索向量召回和关键词召回并行跑然后融合结果。关键词召回用经典的词频统计方法就行它在精确匹配上的表现至今没有对手——用户搜什么词包含这个词的文档一定排前面。融合方式我常用的是倒数排名融合def rrf_fuse(rank_lists, k60): 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(), keylambda x: -x[1])这个公式的好处是只看排名不看分数所以你不需要操心向量相似度 0.8 和关键词得分 12.5 怎么加权这种没有标准答案的问题。k 取 60 是文献里的常见默认值作用是压平头部差距避免某一路的第一名过度主导。实际调的时候k 小一点更看重头名k 大一点更均衡。两路各自召回多少我的经验是各取 50 到 100融合后取前 20再交给下一阶段。取太少融合空间不够取太多后面精排压力大。5.3 重排序补位先粗后精的两段式结构混合检索解决的是能不能捞到重排序解决的是捞到之后排得对不对。双塔模型由于两侧隔离对查询和文档的细粒度关系把握不够经常出现相关但不最相关的文档排在前面。重排序用一个交叉编码器把查询和每条候选拼在一起送进模型输出一个精细的相关性分数。因为候选集只有几十到几百条即使每条都要做一次完整前向总延迟也还在可接受范围。两段式的参数设置有个经验比例粗召回取 50 到 200精排后返回 5 到 10。粗召回太少正确答案可能压根没进来后面的精排再准也没用粗召回太多精排延迟会顶不住。这个比例要按你的延迟预算来定如果延迟要求严苛也可以省略精排直接优化粗召回的质量。5.4 别凭感觉判断效果一套最小可用的评估方法优化到这里最危险的一件事就是凭感觉判断好坏。你调了切分参数、换了模型、加了量化然后随手搜几个查询觉得好像好了一点。这种判断方式极不可靠因为你会不自觉地挑那些能验证自己想法的例子。最小可用的评估方法需要一个标注集。不需要很大两三百条就够格式是查询 应该被召回的正确文档 id。标注集要覆盖你的真实查询分布有短查询、有长查询、有精确匹配型、有语义泛化型。有了标注集就可以算指标了。最直观的是 RecallK看正确答案有没有出现在前 K 条里def recall_at_k(retrieved_ids, relevant_ids, k10): hit len(set(retrieved_ids[:k]) set(relevant_ids)) return hit / max(len(relevant_ids), 1) def evaluate(search_fn, eval_set, k10): scores [] for query, relevant in eval_set: retrieved, _ search_fn(query, kk) 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 一点个人体会做了一段时间下来我最大的感受是向量模型本身的效果提升远不如数据预处理的改进带来的收益大。换个更大的模型召回率可能涨两三个点把切分改对、把归一化补齐、把混合检索加上涨十几个点是常有的事。所以真正的功夫在数据这一侧而不是在模型选型表里反复横跳。另一件事是关于评估。我现在的习惯是任何优化动手之前先把当前版本的指标跑一遍存下来。改完之后再跑一遍两个数字摆在一起比。没有这个基线所有的感觉好了都不可靠——人的判断太容易被个别案例带偏尤其是你自己刚改动的那部分。最后一个提醒是延迟。语义检索链路的延迟是累加的查询编码、向量检索、关键词检索、结果融合、精排每一段都要算进去。我见过反复优化模型精度、最后上线发现延迟超标的案例回头一算光是精排那一段就吃掉了大半预算。动手之前先把延迟预算按段分配好比事后砍功能容易得多。