Dense、Sparse、RRF、ColBERT 到底各自干什么?一次把 RAG 检索链路捋清楚

jessy jessy #rag#retrieval#dense-retrieval#sparse-retrieval#colbert#ai-agent

从语义召回、关键词召回到 RRF 融合与 ColBERT 精排,一次讲清 RAG 两阶段检索链路中各组件的职责。

Dense、Sparse、RRF、ColBERT 到底各自干什么?一次把 RAG 检索链路捋清楚

Dense、Sparse、RRF、ColBERT 到底各自干什么?一次把 RAG 检索链路捋清楚

在做 RAG 知识库时,我之前对检索链路的理解其实比较模糊。

大概知道流程是:

文件上传
切 Chunk
Embedding
写入向量数据库
用户提问
向量检索

后来又陆续加入了:

Dense Retrieval
Sparse Retrieval
RRF
ColBERT

于是链路变成:

┌─ 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-320
E104

这是非常明确的设备型号、错误码和专有实体。

第二类是:

报错是什么问题

它表达的是一种自然语言语义:

错误原因
故障原因
为什么失败

如果只使用一种检索方式,很难同时把这两类信号都利用好。

这就是 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-320
3.2.1
E104
通信模块
初始化失败
3.2.2
网络模块
兼容问题

普通的 single-vector Dense Retrieval 通常会把整段 Chunk 压缩成一个向量表示:

一整个 Chunk
一个 Vector

这是非常高效的。

但代价是:

很多细粒度的信息都被压缩进一个整体表示。

自然语言语义通常没有问题。

可是:

XJ-320
E104
v3.2.1
API_V2
MySQL 1062

这种非常具体的字符串、型号、错误码、版本号,就不一定应该完全依赖“整体语义”。

这时候 Sparse Retrieval 就有意义了。


Sparse Retrieval:更关注词项到底有没有出现

我之前容易把 Sparse 理解成:

一种针对专有名词的具体语义向量。

这个说法并不准确。

更好的理解是:

Sparse Retrieval 更强调词项、关键词和实体匹配。

例如用户问:

XJ-320 E104

文档 A:

XJ-320 E104 通信模块初始化失败

文档 B:

XJ-500 E108 网络连接异常

Dense 可能认为两段文本在“设备、故障、网络、异常”等语义上都有一定关系。

但 Sparse 会非常明确地看到:

XJ-320
E104

在文档 A 中精确出现。

因此 Sparse 特别适合:

设备型号
错误码
接口名称
函数名
产品名称
版本号
人名
数据库错误码
专业缩写

例如:

E104
MySQL 1062
HTTP 429
v1.8.7
GetDeviceInfo
XJ-320

这些字符串很多时候不是“语义差不多”就可以。

用户问:

E104

真正需要的是:

E104

而不是:

E105
E108
网络异常
设备错误

Dense 和 Sparse 不是互相替代

把两者放到一起以后,就比较容易理解了。

假设用户问:

XJ-320 为什么一直连不上服务器并提示 E104?

Dense 擅长的是:

为什么连不上服务器?

可以匹配:

网络连接失败
无法连接服务端
通信模块异常
连接建立失败

即使原文没有出现“连不上服务器”这几个字,也可能被召回。

Sparse 擅长的是:

XJ-320
E104

希望精确找到设备型号和错误码。

所以可以把它们理解成:

Dense:
“这段内容整体上是不是在说同一件事?”
Sparse:
“这里面这些关键字和实体是不是对得上?”

Dense 保证:

不要因为用户换了一种说法就找不到。

Sparse 保证:

不要因为语义相似,就把型号和错误码搞错。

所以混合检索的意义,是:

用两种不同的信号提高 Recall。


两边都检索完了,然后怎么办?

假设现在:

Dense TopK = 20
Sparse TopK = 20

Dense 得到:

1. Chunk A
2. Chunk B
3. Chunk C
4. Chunk D

Sparse 得到:

1. Chunk C
2. Chunk A
3. Chunk E
4. Chunk F

接下来必须解决一个问题:

到底听谁的?

最简单的想法可能是:

最终分数 = Dense Score + Sparse Score

但 Dense 和 Sparse 的原始 Score 往往不是一个尺度。

Dense 可能输出:

0.82
0.76
0.71

Sparse 可能输出:

13.7
8.4
5.2

直接相加没有稳定意义。

于是就需要 RRF。


RRF:我不管你具体多少分,我看你排第几

RRF 全称:

Reciprocal Rank Fusion

它的核心思想很直观:

不要强行比较不同检索器的原始 Score,而是比较它们给出的排名。

例如:

Dense:
1. Chunk A
2. Chunk B
3. Chunk C

Sparse:

1. Chunk C
2. Chunk A
3. Chunk D

Chunk A:

Dense 第 1
Sparse 第 2

Chunk C:

Dense 第 3
Sparse 第 1

两者在融合后都会获得比较高的排名。

RRF 可以粗略表示成:

RRF(document) = Σ 1 / (k + rank)

这里真正重要的不是背公式,而是理解:

Dense Score = 0.82
Sparse Score = 15.7

不需要互相比较。

RRF 看的是:

Dense 排第几
Sparse 排第几

所以它很适合:

Dense + Sparse

这种来自不同检索系统的结果融合。


那 RRF 排完以后,为什么还要 ColBERT?

假设:

Dense
\
→ RRF → Top 20
/
Sparse

RRF 已经得到 Top 20 了。

为什么还要:

ColBERT

再排一次?

因为:

RRF 解决的是“如何融合多个召回器”,它并没有真正重新理解 Query 和每个 Chunk 的细粒度对应关系。

RRF 只知道:

Chunk A:
Dense 第 1
Sparse 第 2

但它不会进一步分析:

用户 Query 里的 E104
到底和 Chunk 里的哪个 Token 对应?
“连不上服务器”
到底有没有对应到“通信模块初始化失败”?

这就进入了 Rerank 阶段。


ColBERT 和普通 Dense 最大的区别是什么?

如果只记一个区别,可以记:

普通 Dense:
Query 整体
Chunk 整体

而 ColBERT 更接近:

Query Token
Document Token

ColBERT 的核心是 late interaction:

Query 和 Document 可以先独立编码,同时保留 token 粒度的表示,再做细粒度相关性计算。


举个例子

Query:

XJ-320 报错 E104 是什么问题?

普通 Dense 更接近:

整个 Query
一个 Vector

Chunk:

XJ-320 出现 E104 时表示通信模块初始化失败

也是:

整个 Chunk
一个 Vector

然后比较:

Query Vector ↔ Chunk Vector

但 ColBERT 会保留更细的多向量表示。

可以粗略想象成:

Query:
XJ-320 -> vector Q1
报错 -> vector Q2
E104 -> vector Q3
什么问题 -> vector Q4

Document:

XJ-320 -> vector D1
出现 -> vector D2
E104 -> 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 20
Top 50
Top 100

而 ColBERT 保存的是更细粒度的 multi-vector 表示,计算和存储都更贵。

所以工程上一个自然策略是:

第一阶段:
便宜、快速、尽量别漏
第二阶段:
候选已经很少了,再做昂贵但精细的判断

也就是:

Dense ───┐
├── RRF ── Top 20 ── ColBERT ── Top 5
Sparse ──┘

第一阶段要 Recall,第二阶段要 Precision

这时候整条链路可以用两个词理解。

第一阶段:

Dense
Sparse
RRF

主要目标是:

Recall

正确答案可以暂时排第 8、第 15,但最好不要直接漏掉。

Dense 负责语义召回。

Sparse 负责关键词、实体精确召回。

RRF 把两边结果稳定融合。

然后进入第二阶段:

ColBERT

目标变成:

Precision

现在候选已经只有二三十条。

不再考虑:

100 万 Chunk 里去哪找?

而是考虑:

这 20 个里面谁才是真正最相关?

于是通过 ColBERT 的细粒度 late interaction,把:

Top 20

重新排序成:

Top 5

再交给 LLM。


为什么 RRF 第一名不一定是 ColBERT 第一名?

例如 RRF 排名:

1. Chunk A
2. Chunk B
3. Chunk C

Chunk A 可能:

Dense 第 1
Sparse 第 2

因此 RRF 很高。

但 ColBERT 真正比较以后可能发现:

用户问:
XJ-320 E104 网络问题
Chunk A:
XJ-320 设备安装说明
Chunk C:
XJ-320 E104 通信模块初始化失败

虽然 Chunk A 在 Dense 和 Sparse 两边都表现不错,但 Chunk C 在细粒度 token 对齐上明显更加精确。

于是重新排序:

1. Chunk C
2. Chunk A
3. Chunk B

这就是:

Retrieval

和:

Rerank

的区别。


文件入库时,这些东西又分别存了什么?

假设用户上传:

device-manual.pdf

先经过:

解析
清洗
Chunk

得到:

Chunk 1
Chunk 2
Chunk 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-320
E104

拿到:

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.