Dense、Sparse、RRF、ColBERT 到底各自干什么?一次把 RAG 检索链路捋清楚
从语义召回、关键词召回到 RRF 融合与 ColBERT 精排,一次讲清 RAG 两阶段检索链路中各组件的职责。
Dense、Sparse、RRF、ColBERT 到底各自干什么?一次把 RAG 检索链路捋清楚
在做 RAG 知识库时,我之前对检索链路的理解其实比较模糊。
大概知道流程是:
文件上传 ↓切 Chunk ↓Embedding ↓写入向量数据库 ↓用户提问 ↓向量检索后来又陆续加入了:
Dense RetrievalSparse RetrievalRRFColBERT于是链路变成:
┌─ Dense ──┐用户 Query ─────┤ ├─ RRF ─ ColBERT ─ LLM └─ Sparse ─┘看上去就是不断往检索后面加东西。
但真正到了面试或者需要解释架构的时候,一个很现实的问题马上就出来了:
Dense、Sparse、RRF、ColBERT 到底分别解决什么问题?
尤其容易产生几个误区:
Dense 是不是就是给 Chunk 做 Embedding?
Sparse 是不是一种针对专有名词的“稀疏语义向量”?
RRF 已经排过序了,为什么后面还需要 ColBERT?
ColBERT 和普通 Embedding 到底有什么区别?
把这些问题真正拆开以后,我才发现,这几个组件其实不是重复工作。
它们分别在解决:
语义召回、精确词项召回、多路结果融合,以及细粒度相关性判断。
先从一个真实的问题开始
假设企业知识库里有一份设备手册。
其中某段内容写的是:
XJ-320 设备出现 E104 故障码时,通常表示通信模块初始化失败。
请检查网络模块连接状态以及固件版本。用户问:
XJ-320 报错 E104 是什么问题?对于人来说,这个问题非常简单。
但对于检索系统来说,这里面其实同时存在两种完全不同的信息。
第一类是:
XJ-320E104这是非常明确的设备型号、错误码和专有实体。
第二类是:
报错是什么问题它表达的是一种自然语言语义:
错误原因故障原因为什么失败如果只使用一种检索方式,很难同时把这两类信号都利用好。
这就是 Dense + Sparse 的意义。
Dense Retrieval:重点不是“做了 Embedding”,而是解决语义相似
我之前回答 Dense Retrieval 时,第一反应是:
文件上传以后切成 Chunk,然后用 Embedding 模型生成向量并建立索引。
这句话没有错。
但它回答的是:
Dense 索引是怎么建立的?
而没有回答:
为什么需要 Dense Retrieval?
Dense 真正重要的能力是:
找到用词不同,但意思相近的内容。
例如知识库写:
设备无法建立网络连接用户却问:
为什么设备连不上网?文本表面并不完全一样:
连不上网和:
无法建立网络连接但语义非常接近。
Dense Embedding 会把 Query 和 Document/Chunk 编码成稠密向量,然后通过向量相似度寻找语义上接近的内容。
可以粗略理解成:
“设备连不上网” ↓ Dense Vector ↓[0.18, -0.43, 0.72, ...]
“无法建立网络连接” ↓ Dense Vector ↓[0.20, -0.40, 0.69, ...]虽然文字不同,但向量空间中的距离可能很近。
所以 Dense Retrieval 更擅长解决:
同义表达自然语言改写描述性问题概念相似上下文语义这也是为什么用户不需要按照文档里的原话提问。
Dense 的问题:所有信息被压进一个向量
Dense 很强,但它也有一个非常直观的限制。
假设一个 Chunk 是:
XJ-320 设备在固件 3.2.1 中出现 E104 时,表示通信模块初始化失败。升级至 3.2.2 后可解决部分网络模块兼容问题。这段话里面其实有很多信息:
XJ-3203.2.1E104通信模块初始化失败3.2.2网络模块兼容问题普通的 single-vector Dense Retrieval 通常会把整段 Chunk 压缩成一个向量表示:
一整个 Chunk ↓一个 Vector这是非常高效的。
但代价是:
很多细粒度的信息都被压缩进一个整体表示。
自然语言语义通常没有问题。
可是:
XJ-320E104v3.2.1API_V2MySQL 1062这种非常具体的字符串、型号、错误码、版本号,就不一定应该完全依赖“整体语义”。
这时候 Sparse Retrieval 就有意义了。
Sparse Retrieval:更关注词项到底有没有出现
我之前容易把 Sparse 理解成:
一种针对专有名词的具体语义向量。
这个说法并不准确。
更好的理解是:
Sparse Retrieval 更强调词项、关键词和实体匹配。
例如用户问:
XJ-320 E104文档 A:
XJ-320 E104 通信模块初始化失败文档 B:
XJ-500 E108 网络连接异常Dense 可能认为两段文本在“设备、故障、网络、异常”等语义上都有一定关系。
但 Sparse 会非常明确地看到:
XJ-320E104在文档 A 中精确出现。
因此 Sparse 特别适合:
设备型号错误码接口名称函数名产品名称版本号人名数据库错误码专业缩写例如:
E104MySQL 1062HTTP 429v1.8.7GetDeviceInfoXJ-320这些字符串很多时候不是“语义差不多”就可以。
用户问:
E104真正需要的是:
E104而不是:
E105E108网络异常设备错误Dense 和 Sparse 不是互相替代
把两者放到一起以后,就比较容易理解了。
假设用户问:
XJ-320 为什么一直连不上服务器并提示 E104?Dense 擅长的是:
为什么连不上服务器?可以匹配:
网络连接失败无法连接服务端通信模块异常连接建立失败即使原文没有出现“连不上服务器”这几个字,也可能被召回。
Sparse 擅长的是:
XJ-320E104希望精确找到设备型号和错误码。
所以可以把它们理解成:
Dense:“这段内容整体上是不是在说同一件事?”
Sparse:“这里面这些关键字和实体是不是对得上?”Dense 保证:
不要因为用户换了一种说法就找不到。
Sparse 保证:
不要因为语义相似,就把型号和错误码搞错。
所以混合检索的意义,是:
用两种不同的信号提高 Recall。
两边都检索完了,然后怎么办?
假设现在:
Dense TopK = 20Sparse TopK = 20Dense 得到:
1. Chunk A2. Chunk B3. Chunk C4. Chunk DSparse 得到:
1. Chunk C2. Chunk A3. Chunk E4. Chunk F接下来必须解决一个问题:
到底听谁的?
最简单的想法可能是:
最终分数 = Dense Score + Sparse Score但 Dense 和 Sparse 的原始 Score 往往不是一个尺度。
Dense 可能输出:
0.820.760.71Sparse 可能输出:
13.78.45.2直接相加没有稳定意义。
于是就需要 RRF。
RRF:我不管你具体多少分,我看你排第几
RRF 全称:
Reciprocal Rank Fusion它的核心思想很直观:
不要强行比较不同检索器的原始 Score,而是比较它们给出的排名。
例如:
Dense:
1. Chunk A2. Chunk B3. Chunk CSparse:
1. Chunk C2. Chunk A3. Chunk DChunk A:
Dense 第 1Sparse 第 2Chunk C:
Dense 第 3Sparse 第 1两者在融合后都会获得比较高的排名。
RRF 可以粗略表示成:
RRF(document) = Σ 1 / (k + rank)这里真正重要的不是背公式,而是理解:
Dense Score = 0.82Sparse Score = 15.7不需要互相比较。
RRF 看的是:
Dense 排第几Sparse 排第几所以它很适合:
Dense + Sparse这种来自不同检索系统的结果融合。
那 RRF 排完以后,为什么还要 ColBERT?
假设:
Dense \ → RRF → Top 20 /SparseRRF 已经得到 Top 20 了。
为什么还要:
ColBERT再排一次?
因为:
RRF 解决的是“如何融合多个召回器”,它并没有真正重新理解 Query 和每个 Chunk 的细粒度对应关系。
RRF 只知道:
Chunk A:Dense 第 1Sparse 第 2但它不会进一步分析:
用户 Query 里的 E104到底和 Chunk 里的哪个 Token 对应?
“连不上服务器”到底有没有对应到“通信模块初始化失败”?这就进入了 Rerank 阶段。
ColBERT 和普通 Dense 最大的区别是什么?
如果只记一个区别,可以记:
普通 Dense:
Query 整体 ↕Chunk 整体而 ColBERT 更接近:
Query Token ↕Document TokenColBERT 的核心是 late interaction:
Query 和 Document 可以先独立编码,同时保留 token 粒度的表示,再做细粒度相关性计算。
举个例子
Query:
XJ-320 报错 E104 是什么问题?普通 Dense 更接近:
整个 Query ↓一个 VectorChunk:
XJ-320 出现 E104 时表示通信模块初始化失败也是:
整个 Chunk ↓一个 Vector然后比较:
Query Vector ↔ Chunk Vector但 ColBERT 会保留更细的多向量表示。
可以粗略想象成:
Query:
XJ-320 -> vector Q1报错 -> vector Q2E104 -> vector Q3什么问题 -> vector Q4Document:
XJ-320 -> vector D1出现 -> vector D2E104 -> vector D3通信模块 -> vector D4初始化失败 -> vector D5然后针对 Query 中的不同 token,去 Document 中寻找最匹配的表示。
例如:
XJ-320 ↓XJ-320
E104 ↓E104
报错 ↓初始化失败 / 故障相关表示所以 ColBERT 能比单向量 Dense 做更细粒度的相关性判断。
MaxSim 又是什么?
ColBERT 经常提到:
MaxSim最直观的理解就是:
Query 中的每个 Token,都去 Document 的 Token 中找到和自己最相似的那个。
例如:
Query Token:E104去比较:
XJ-320设备出现E104通信模块初始化失败发现:
E104 ↔ E104相似度最高。
再看:
Query Token:报错可能发现:
报错 ↔ 初始化失败或者和某个故障语义 Token 有较高相似度。
最后把 Query 各 Token 的这些匹配信号聚合起来,形成 Query 和这个 Chunk 的相关性得分。
因此 ColBERT 不只是问:
这两段话整体像不像?
而是在问:
用户问题里的每一个重要部分,在这段文档里分别有没有好的对应?
那为什么不直接所有文档都跑 ColBERT?
既然 ColBERT 更精细,一个自然的问题就是:
为什么不直接把 Dense 和 Sparse 都去掉,让 ColBERT 搜整个知识库?
工程上最重要的原因之一就是:
成本。
Single-vector Dense Retrieval 非常适合大规模 ANN 检索。
例如:
100 万 Chunk每个 Chunk 一个向量,可以很快找到:
Top 20Top 50Top 100而 ColBERT 保存的是更细粒度的 multi-vector 表示,计算和存储都更贵。
所以工程上一个自然策略是:
第一阶段:便宜、快速、尽量别漏
第二阶段:候选已经很少了,再做昂贵但精细的判断也就是:
Dense ───┐ ├── RRF ── Top 20 ── ColBERT ── Top 5Sparse ──┘第一阶段要 Recall,第二阶段要 Precision
这时候整条链路可以用两个词理解。
第一阶段:
DenseSparseRRF主要目标是:
Recall
正确答案可以暂时排第 8、第 15,但最好不要直接漏掉。
Dense 负责语义召回。
Sparse 负责关键词、实体精确召回。
RRF 把两边结果稳定融合。
然后进入第二阶段:
ColBERT目标变成:
Precision
现在候选已经只有二三十条。
不再考虑:
100 万 Chunk 里去哪找?而是考虑:
这 20 个里面谁才是真正最相关?于是通过 ColBERT 的细粒度 late interaction,把:
Top 20重新排序成:
Top 5再交给 LLM。
为什么 RRF 第一名不一定是 ColBERT 第一名?
例如 RRF 排名:
1. Chunk A2. Chunk B3. Chunk CChunk A 可能:
Dense 第 1Sparse 第 2因此 RRF 很高。
但 ColBERT 真正比较以后可能发现:
用户问:XJ-320 E104 网络问题
Chunk A:XJ-320 设备安装说明
Chunk C:XJ-320 E104 通信模块初始化失败虽然 Chunk A 在 Dense 和 Sparse 两边都表现不错,但 Chunk C 在细粒度 token 对齐上明显更加精确。
于是重新排序:
1. Chunk C2. Chunk A3. Chunk B这就是:
Retrieval和:
Rerank的区别。
文件入库时,这些东西又分别存了什么?
假设用户上传:
device-manual.pdf先经过:
解析 ↓清洗 ↓Chunk得到:
Chunk 1Chunk 2Chunk 3...对于 Dense:
Chunk ↓Embedding Model ↓Dense Vector ↓Vector DB对于 Sparse:
Chunk ↓Sparse Representation / Term Signal ↓Sparse Index如果系统使用 ColBERT Multi-vector:
Chunk ↓ColBERT Encoder ↓多个 Token-level Vector ↓Multi-vector Index / Rerank Representation三者本质上是在为同一份 Chunk 建立不同角度的检索表示。
不是保存三份业务文档,而是为一份知识内容建立多种检索表示。
一条完整的查询链路
用户:
“XJ-320 报错 E104 是什么问题?”首先做 Dense Retrieval:
语义:设备故障网络异常错误原因拿到:
Dense Top 20同时做 Sparse Retrieval:
XJ-320E104拿到:
Sparse Top 20然后:
Dense Top 20 \ → RRF /Sparse Top 20得到:
Fusion Top 20接下来:
Query +Top 20 Chunks ↓ColBERT MaxSim / Late Interaction ↓重新排序得到:
Top 5最后才进入:
Prompt Context ↓ LLM ↓ Answer为什么这套链路不能只理解成“向量数据库搜索”?
RAG 的检索并不是简单的:
Embedding ↓Vector DB ↓TopK ↓LLM真正进入生产环境以后,用户的问题可能同时包含:
自然语言语义专有名词产品型号错误码版本号API 名称业务实体所以一条更完整的检索链路其实是在不断回答不同的问题:
Dense:意思像不像?
Sparse:关键词和实体对不对?
RRF:两种检索结果怎么融合?
ColBERT:候选里面谁和 Query 的细粒度对应最好?
LLM:基于最终证据怎么回答?这样看以后,它们之间就不再容易混淆了。
最后,用一个例子记住四个组件
假设用户问:
XJ-320 报错 E104 是什么问题?Dense
核心是:
语义相似。
Sparse
核心是:
关键词和实体精确匹配。
RRF
核心是:
多路召回结果融合。
ColBERT
核心是:
候选文档精排。
最终可以记成:
Dense:找“意思差不多”的。
Sparse:找“字词必须对得上”的。
RRF:把两拨人合并排队。
ColBERT:把进决赛的人重新仔细面一遍。这时候:
Dense + Sparse + RRF + ColBERT就不再是一串为了显得复杂而堆起来的技术名词。
它实际上是一条非常清晰的两阶段检索逻辑:
第一阶段:尽量不要漏。
第二阶段:尽量不要选错。而这也是理解 RAG 检索链路最简单的一种方式。
References
- Khattab, O. & Zaharia, M. ColBERT: Efficient and Effective Passage Search via Contextualized Late Interaction over BERT.
- Santhanam, K. et al. ColBERTv2: Effective and Efficient Retrieval via Lightweight Late Interaction.
- Cormack, G. V., Clarke, C. L. A. & Buettcher, S. Reciprocal Rank Fusion Outperforms Condorcet and Individual Rank Learning Methods.