讲检索大部分人脑子里冒出来的第一个东西是“关键词匹配”。我把搜索从倒排索引做到向量召回踩了不少坑今天专门把稠密检索技术掰开揉碎讲一遍。这篇不只讲“模型怎么把句子变成向量”还会讲为什么我们需要从字面匹配走向语义匹配以及在真实项目里搭一套稠密检索系统要过的坎。适合做搜索、做问答、做RAG外部知识库增强生成的同学甚至可以当成一篇入门的工程复盘来看。我自己的体会是稠密检索技术真正解决的不是“找到含有同样词的文档”而是“理解你真正想问什么”。它让检索从“字面精确”走向“语义相关”这也是为什么它会成为当前智能搜索和个人知识库系统的标配。下面我把整个技术链路拆开来说。1. 为什么要做稠密检索从字面匹配到语义匹配1.1 稀疏检索卡在哪词项匹配的三个痛点先回到传统稀疏检索。以经典的BM25算法为例它的核心思想是一个词在文档中出现次数越多同时在整个语料里出现次数越少那这个词对当前文档的权重就越高。这套词频-逆文档频率的打分逻辑在很短文本、规范语料的检索里表现稳健但它有一个天然假设——文档和查询必须共享相同的词项。这个假设在真实场景里经常不成立。常见的问题有几个第一个是同义替换。用户搜“怎么保养汽车轮胎”文档里写的是“调整胎压、检查磨损”里面没有一个字是“轮胎”或“保养”但内容明明就是用户想要的。BM25只能干瞪眼。第二个是一词多义。“苹果”到底是水果还是手机“Java”是岛名还是编程语言传统模型只能从词面上看没法区分上下文。第三个是口语化表达和书面表达之间的鸿沟。用户问“这附近哪儿能吃饭”系统里存的是“餐饮商户推荐”字面完全不重叠。我早期做搜索项目时为了缓解这种词汇鸿沟最粗暴的办法是人工维护同义词表。把“保养”映射到“维护”“调整”再扩展“轮胎”到“胎压”“轮毂”。每个行业都要配一套词典维护成本极高而且词典永远跟不上用户口癖的更新速度。这种基于词项扩展的思路本质上还是在“用词去碰词”。1.2 稠密检索的破局思路把文本扔进向量空间稠密检索换了一条完全不同的路不再比较词项是否重合而是用神经网络把查询和文档分别编码成一个有限维度的稠密向量再计算两个向量之间的距离用距离代表语义相关性。这里有两个关键词值得反复琢磨。“稠密”是相对“稀疏”来说的。传统向量表示是一个高维稀疏向量维度等于词表大小大部分位置是0。而稠密向量通常是128维到1024维的浮点数组每一个维度都有数值整个向量被“压”成了一段语义签名。训练好的模型会把“如何保养轮胎”和“调整胎压检查磨损”编码成彼此靠近的向量哪怕字面没有一个词重合。这套思路带来的影响不只是搜索。它把文本、图片、视频都映射到了同一个向量空间里所以可以做跨模态检索——用一段文字描述去找一张图片用一段镜头描述去找视频片段。这也是后来多模态模型的一个基础能力。从影响范围看稠密检索已经从学术研究变成了搜索引擎、智能客服、推荐系统、企业知识库里的通用零件。2. 稠密检索的系统架构四个核心模块拆解2.1 编码器双塔与交叉谁负责召回谁负责精排稠密检索系统的第一个核心模块是编码器。工程上通常提“双塔模型”一个塔编码查询query另一个塔编码文档document两个塔输出各自向量后在线阶段只要算一次相似度就能完成召回。双塔的最大好处是文档向量可以离线算好、提前建索引在线查询时只需要实时编码一条query延迟能压到几十毫秒以内适合海量文档的召回阶段。但它也有代价。两个塔各自独立编码缺少查询与文档之间的交互精度有上限。所以很多团队会用双塔做召回召回前几十个候选再用一个更精细的“交叉编码器”模型做精排。交叉编码器把查询和文档拼成一个句子对放进同一个编码器能充分建模两者之间的词级交互精度显著更高但没法对全量文档在线计算只能处理候选集很小的情况。我习惯把双塔当成粗筛漏斗把交叉编码器当成最后一公里的精判官。编码器结构本身通常采用预训练Transformer编码器。常见的做法是把预训练模型作为底座再用检索语料微调。输出向量要做一个L2归一化归一化之后余弦相似度和内积就是等价的后续索引和检索只需要算内积省掉一次除法工程效率更高。2.2 训练信号怎么让向量成为“语义相似度”的标尺有了模型还需要“训练信号”来告诉模型什么是相关的、什么是不相关的。稠密检索模型最常用的训练方式是对比学习每个训练样本包含一个查询、一个正样本相关的文档和一些负样本不相关的文档。训练目标就是让查询向量与正样本向量的相似度尽量高与负样本向量的相似度尽量低。这里边度最高的环节是负样本选择。随机负样本太容易模型很快就学会“只要不是同一个文档分数都压低”但学不到细微的区分能力。实际工程里我更推荐“难负样本”——从稠密召回结果里找那些分数很高、但实际上并不相关或者相关性偏弱的文档。这类负样本能让模型被迫去区分表面相似、本质不同的内容。负样本数量也不是越大越好常见做法是batch内部互相当负样本也就是一个batch里的所有其他正样本都作为当前查询的负样本这相当于免费扩充了负样本池。损失函数一般用交叉熵或对比损失系列里面有个重要的超参数叫温度系数。温度越小对分数差距越敏感训练出的向量分布更尖锐温度太大模型容易对正负样本区分不足。我自己常用的做法是先选一个中等温度比如0.05在小规模验证集上看召回率如果发现正样本和负样本分数太接近就继续把温度调低。2.3 索引与召回百万级向量里怎么做近似最近邻搜索当文档向量离线编码完成系统就面临一个工程问题一个百万甚至亿级的稠密向量集合线上如何快速找到与query向量最接近的top-k个最直接的办法是暴力扫描也就是全量计算余弦相似度。一百万条向量时单次查询要做一百万次浮点运算在GPU上勉强能撑住但到了千万、亿级就完全不可行。所以工程上要用近似最近邻搜索ANN来压延迟。常用的ANN家族有基于图的算法如HNSW、基于倒排聚类的算法如IVF、基于量化的算法如PQ。HNSW的核心思路是构建一张多层图搜索时像走高速公路一样快速接近目标区域。它很适合需要高召回率和可接受内存消耗的场景。IVF则是先把向量聚类成多个簇查询时只搜索最近的几个簇速度更快但召回率受簇数影响。工程上经常把IVF和PQ叠加用量化把向量压缩到很小但压缩幅度太大会损失精度。索引参数里有两个关键值一个是建图时的邻居数MM越大图连接越密召回越高但内存和延迟也上升另一个是查询时的搜索宽度efSearchefSearch越大候选越多召回越高但延迟上升。没有万能参数我一般先按文档规模估计一个初始值再用自己的真实数据跑一个召回率与延迟的权衡曲线再定值。2.4 评估体系别只看一个点击指标评估稠密检索效果线上看用户点击、转化率固然重要但离线阶段就要建立一套评估体系否则每次模型改动都是玄学。最朴素的评估是构建一批人工标注的(query, 相关文档)集合然后计算两个指标一个是Top-k召回率也就是人工标注的相关文档是否出现在模型召回的top-k集合里另一个是平均排序质量MRR看第一条相关文档到底排在第几位。如果做的是问答场景还可以看NDCG因为检索结果本身就是有序的NDCG会对排序位置敏感。我自己的经验是任何指标都要按“查询类型”分开看。长尾口语化查询改进了多少专有名词查询是变好还是变坏了千万不要只盯着平均值。项目里最容易出现的现象是平均分数涨了但某个核心业务查询反而排到了第二页这种回归往往是混合问题。3. 实操从零搭一套稠密检索基线系统3.1 数据准备切分文档与构造训练集实操第一步不是改模型而是准备数据。我以最典型的企业知识库问答场景为例有一批文档比如操作手册、FAQ、产品说明用户会输入自然语言问题系统要找到最相关的文档片段。拿到原始文档后不能把整篇文档丢给编码器。大多数预训练编码器都有长度上限而且一篇三千字的文档做向量平均关键信息会被稀释。需要先把文档切成chunk。切分策略我踩过几次坑按固定字符切简单但容易把一句话拦腰截断按段落和标题层级切更好地保留语义边界但每个文档块长度差别很大。稳妥方案是“段落优先 二级标题作为上下文 固定长度回看”保证每个chunk在200到300个token之间。如果用了上下文拼接注意控制重叠长度避免同一句话出现在太多chunk里导致检索结果重复。如果要用业务数据微调模型还需要构造训练集。最省力的方式是“伪标签”先用现成的通用检索模型跑一遍把用户真实query召回的文档当作候选再由人工或规则给这些候选打相关和不相关标签。正样本不够的时候可以借助业务知识图谱做同义改写但核心思路永远是让模型看到业务领域里真实出现的query和文档表达而不是通用语料里的表达。3.2 向量化与索引构建从文档到可检索的向量库编码这一步我建议先找一个训练好的文本向量编码模型直接试用不要一上来就从头训练。模型输出通常是768维向量。把每个chunk输入编码器得到的结果除了数值向量还要记录这个向量对应的文档ID、chunk序号和原始文本这部分元数据在后续展示和调试时很重要。下面是一段示意代码重点展示接口流程具体实现可以根据你选用的开源组件替换。# 示意代码文档向量化与索引构建 # encode_text 代表你使用的编码模型接口返回归一化向量 def build_document_index(documents): # documents: list of dict, 每个文档包含 id, text, meta vectors [] ids [] for doc in documents: vec encode_text(doc[text]) # 输出 768 维的 numpy 数组 vec vec / np.linalg.norm(vec) # L2 归一化 vectors.append(vec) ids.append(doc[id]) # VectorIndex 是对开源向量索引的抽象封装 index VectorIndex(dimension768, metriccosine) index.add(vectors, ids) # 批量插入 return index构建索引前要估算内存。一个简单公式N条向量d为维度float32存一个数字占4字节那么原始向量占N × d × 4字节。一百万条768维向量大约需要3GB内存。如果再加HNSW这类图索引额外要准备1到2倍内存。这一点在申请机器资源时特别重要很容易被低估。索引构建完成后要持久化到磁盘我一般会把索引文件、文档元数据、配置参数一起打包保存方便后续版本回溯。3.3 在线检索从query到答案的完整链路在线检索的流程可以分成三步query向量化、近似最近邻搜索、结果后处理。# 示意代码在线检索 def online_search(query, index, top_k10): q_vec encode_text(query) q_vec q_vec / np.linalg.norm(q_vec) # 返回候选 doc id 列表和相似度分数 ids, scores index.search(q_vec, top_k) results [] for doc_id, score in zip(ids, scores): # 过滤低分结果 if score 0.75: continue doc doc_store.get(doc_id) results.append({doc: doc, score: score}) # 业务后处理对候选结果去重、排序 results deduplicate_by_chunk(results) results sorted(results, keylambda x: x[score], reverseTrue) return results后处理里容易被忽略的是分数阈值。离线模型在训练集上分数分布和线上真实数据往往不一致所以阈值不能只看一次测试。我见过很多团队用0.8做硬截断结果真实长尾query都低于阈值召回率掉一半。正确做法是先收集线上真实query的分数分布再拿少量badcase标定一个相对保守的阈值同时保留低分结果并标记低置信度让后续重排去判断。4. 常见问题与排查技巧实录4.1 为什么稠密检索效果还不如BM25这是几乎所有团队第一次上手都会遇到的现象换上模型之后中英文通用测试集上召回率涨了但一到业务数据就明显不如BM25。我复盘过几次问题往往出在训练数据上。模型没有见过业务里的特殊缩写、产品型号、黑话它对这些词的语义编码是混乱的。比如搜“A32升降机故障”预训练模型可能把“A32”编码成一个普通token完全意识不到这是一个产品型号。这时的第一反应不是换更大模型而是先做领域微调。造一批包含产品型号、业务术语的query-文档样本哪怕只有几千条都能大幅提升效果。还有一种兜底方案是混合检索把BM25召回结果和稠密检索召回结果做合并用RRF或线性加权重排这样能保证词法匹配强项不被丢。4.2 索引实时更新与向量模型版本管理线上系统永远会遇到新文档入库和旧文档删除。稠密检索对实时更新有两个天然瓶颈。第一是编码耗时不低新文档入库要经过一次模型推理和传统倒排索引秒级更新相比稠密索引的链路要慢一个量级。第二是向量索引删除困难有些索引结构尤其是HNSW不支持高效删除需要标记删除后异步合并。我的经验是不要把“实时”和“离线重建”对立起来。常规做法是把新增文档保持一个小的增量索引查询时同时搜索旧主索引和增量索引定时把增量合并进主索引。同时要做向量模型版本管理。模型升级后旧模型产出的向量和新模型产出的向量不在同一个语义空间里不能混着放进一个索引。我会建一个“模型版本号索引版本号”的双重标记上线后新文档用新模型旧索引整体快照暂存直到全部重建完成再切换。4.3 召回结果不稳定的隐藏原因长文本切块和温度系数有一个问题经常被我周围同事忽略同一个query换一天检索结果变了。排查到最后发现不是模型变了而是文档切块的时候原始文档更新了chunk边界跟着变化导致最后整合结果时某些doc片段被拆成不同范围。问题根源是“内容哈希没做”。我会给每个原始文档计算一个内容哈希文档内容变了就触发重新切块和重新编码其他情况一律沿用旧向量。另一个隐藏原因是温度系数影响。微调时温度过高模型对所有样本输出相似度都保持在很窄的区间里线上看起来就是“感觉都在0.7附近排序像随机”。调低温度后正样本分数会明显顶上去负样本被压低排序变得锐利。温度系数和损失函数是配套的换损失函数时候要一起重新调参。5. 应用场景扩展RAG、跨模态与混合召回5.1 RAG系统稠密检索决定答案的上限大模型落地最常见的姿势是RAG把外部知识库检索结果塞进提示词让模型基于资料作答。RAG的效果上限基本由检索质量决定检索不到关键信息后面的生成模型再强也只能凭幻觉乱编。所以我在设计RAG系统时会把检索部分分成“快速召回 精确重排”两段。快速召回用稠密检索解决语义扩展同时保留一个基于关键词的召回作为兜底最终用重排模型从两类候选里筛出最相关的三五条。召回阶段如果只依赖某一个算法后段优化的空间会非常小。5.2 跨模态与更多业务场景把一切变成向量稠密检索的“向量化”特性让它天然适合跨模态。我在处理一个视频检索需求时就是先把视频关键帧通过视觉编码器转成向量把文本描述用文本编码器转成向量然后放在同一个索引里检索。所以当你把文档检索吃透后会发现这一套流程完全能迁移到图片检索、音视频检索、甚至是结构化知识图谱检索。这也是稠密检索技术影响范围最大的地方它统一了不同模态之间的“相似”问题。5.3 工程落地后的一个实用建议如果让我给一个刚准备上马稠密检索的团队提一点建议那就是“基线优先混合兜底”。先用现成编码模型跑通全链路不要第一天就去训练一个复杂的双塔模型。全链路包括数据准备、向量索引、在线服务、效果评估。链路通了你才会发现真正的瓶颈到底在模型还是在数据或者工程架构。一个能跑起来的“慢但正确”的基线远比一个“快但哪都连不上”的新模型重要。最后分享一个我在实际项目里的体会。很多团队以为稠密检索模型的语义泛化能力可以包打天下结果遇到品牌词、型号、精确数字就翻车。后来我们把密集检索和关键词检索路径做成了一个“双通道”召回遇到高精度专名时词法通道占优遇到口语化同义表达时向量通道占优。这给了我一个印象很深的结论检索系统的本质不是比拼单一算法而是组合策略的艺术。所有新模型都值得尝试但上线之前永远先用一个badcase集合做回归验证。
阅读完成 · 觉得有帮助?