讲检索,大部分人脑子里冒出来的第一个东西是“关键词匹配”。我把搜索从倒排索引做到向量召回,踩了不少坑,今天专门把稠密检索技术掰开揉碎讲一遍。这篇不只讲“模型怎么把句子变成向量”,还会讲为什么我们需要从字面匹配走向语义匹配,以及在真实项目里搭一套稠密检索系统要过的坎。适合做搜索、做问答、做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叠加,用量化把向量压缩到很小,但压缩幅度太大会损失精度。
索引参数里有两个关键值:一个是建图时的邻居数M,M越大图连接越密,召回越高但内存和延迟也上升;另一个是查询时的搜索宽度efSearch,efSearch越大候选越多,召回越高但延迟上升。没有万能参数,我一般先按文档规模估计一个初始值,再用自己的真实数据跑一个召回率与延迟的权衡曲线,再定值。
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(dimension=768, metric="cosine") 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_k=10): 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, key=lambda x: x["score"], reverse=True) 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集合做回归验证。