学习笔记-初探RAG
一、简介
RAG 是 Retrieval-Augmented Generation,中文通常叫 检索增强生成。
RAG 可以表示为:
$$\text{Answer}=\operatorname{LLM}\left(\text{Query},\operatorname{Retrieve}(\text{Query},\mathcal{D})\right)$$
其中:Query为用户问题,D为外部文档集合,Retrieve为检索器
RAG 将知识分成两类:
- 参数化知识(Parametric Memory):存储在模型参数中的知识;
- 非参数化知识(Non-parametric Memory):存储在文档、数据库、向量库、网页或知识图谱中的知识。
传统大语言模型主要依赖参数化知识:$$P_\theta(y\mid x)$$
RAG 在生成时额外引入外部文档 z:$$P(y\mid x)=\sum_{z\in\mathcal{Z}}P_\eta(z\mid x)P_\theta(y\mid x,z)$$
其中:x为输入问题,z为检索到的文档,y为生成答案
$$P_\eta(z\mid x)$$检索器认为文档 z 与问题 x 相关的概率;
$$P_\theta(y\mid x,z$$:生成器根据问题和文档生成答案的概率。
二、流程
1.文档加载
设文档集合为:$$\mathcal{D}={D_1,D_2,\ldots,D_N}$$
每个文档通常包含正文和元数据:$$D_i=(\text{content}_i,\text{metadata}_i)$$
2.文档清洗
常见操作包括:
- 删除空白和重复内容;
- 去掉网页导航栏、页眉、页脚;
- 保留标题结构;
- 识别代码块、表格和公式;
- 添加来源、时间和权限信息。
清洗很重要。原始数据很乱时,后面的检索效果通常也不会好。
3.文档切分
文档太长,不能直接全部交给模型,所以需要切成小块:$$D_i\rightarrow{c_{i,1},c_{i,2},\ldots,c_{i,m}}$$
常用参数:
- chunk_size:每块长度;
- chunk_overlap:相邻块的重叠长度。
假设文档长度为 T,块长度为 L,重叠长度为 O,块数大约为:$$M\approx\left\lceil\frac{T-L}{L-O}\right\rceil+1$$
块太小,信息容易被切断;块太大,又会混入很多无关内容。
4,文档向量化
Embedding 模型把文本转换为向量:$$\mathbf{v}_i=f(c_i)\in\mathbb{R}^d$$
语义接近的文本,向量通常也比较接近。
5.建立索引
保存文档块、向量和元数据:$$\mathcal{I}={(\mathbf{v}_i,c_i,\text{metadata}_i)}$$
6.查询和检索
用户问题 q 也会被转换为向量:$$\mathbf{q}=f(q)$$
然后计算它和文档向量的相似度,取分数最高的 k 个文档:$$\operatorname{TopK}(q)=\underset{c_i}{\operatorname{arg,topk}};s(\mathbf{q},\mathbf{v}_i)$$
7.重排序
第一次检索主要追求速度,结果不一定非常准确。
因此可以再使用 Reranker 对候选文档重新打分:$$r_i=g(q,c_i)$$
典型流程为:
$$\text{快速召回 Top-20}\rightarrow\text{重排序后取 Top-5}$$
8.生成答案
将检索结果和问题一起交给大模型:$$P=\text{Instruction}\Vert\text{Context}\Vert q$$
最终生成:$$P_\theta(y\mid q,C)=\prod_{t=1}^{T}P_\theta(y_t\mid y_{<t},q,C)$$
三、检索的数学基础
1.TF-IDF
TF-IDF 用于衡量一个词在某篇文档中的重要程度。
词频:$$\operatorname{TF}(t,D)=\frac{f(t,D)}{|D|}$$
逆文档频率:$$\operatorname{IDF}(t)=\log\frac{N+1}{\operatorname{df}(t)+1}+1$$
最终:$$\operatorname{TFIDF}(t,D)=\operatorname{TF}(t,D)\cdot\operatorname{IDF}(t)$$
TF-IDF 适合关键词检索,但不擅长理解同义词。
2.BM25
BM25 是实际搜索系统中很常用的稀疏检索算法:$$\operatorname{BM25}(Q,D) =\sum_{t\in Q}\operatorname{IDF}(t) \frac{f(t,D)(k_1+1)} {f(t,D)+k_1\left(1-b+b\frac{|D|}{\operatorname{avgdl}}\right)}$$
其中:
f(t,D):词 t 在文档中的出现次数;
|D|:文档长度;
avgdl:平均文档长度;
k_1:控制词频影响;
b:控制文档长度影响。
3.余弦相似度
向量检索最常见的相似度是余弦相似度:
$$\operatorname{cos}(\mathbf{a},\mathbf{b}) =\frac{\mathbf{a}\cdot\mathbf{b}} {|\mathbf{a}|2|\mathbf{b}|2}=\frac{\sum{i=1}^{d}a_ib_i} {\sqrt{\sum{i=1}^{d}a_i^2}\sqrt{\sum_{i=1}^{d}b_i^2}}$$
值越接近 1,通常说明两个文本越相似。
如果向量已经归一化:$$\hat{\mathbf{a}}=\frac{\mathbf{a}}{|\mathbf{a}|_2}$$
则内积和余弦相似度相同:$$\hat{\mathbf{a}}^{\mathsf T}\hat{\mathbf{b}} =\operatorname{cos}(\mathbf{a},\mathbf{b})$$
4.双编码器
密集检索通常分别编码问题和文档:$$\mathbf{q}=f_q(q),\qquad\mathbf{d}=f_d(d)$$
相关性为:$$s(q,d)=\mathbf{q}^{\mathsf T}\mathbf{d}$$
文档向量可以提前算好,所以检索速度很快。
5.对比学习
训练检索模型时,希望问题和正确文档接近,和错误文档远离:
$$\mathcal{L} =-\log \frac{\exp(s(q,d^+)/\tau)} {\exp(s(q,d^+)/\tau)+\sum_j\exp(s(q,d_j^-)/\tau)}$$
其中:
- $$d^+$$正确文档;
- $$d_j^-$$错误文档;
- $$\tau$$温度参数。
6.RAG 概率模型
RAG 的基本思想可以表示为:$$P(y\mid x)=\sum_{z\in\mathcal{Z}} P_\eta(z\mid x)P_\theta(y\mid x,z)$$
其中:
- $$P_\eta(z\mid x)$$检索器认为文档 z 相关的概率;
- $$P_\theta(y\mid x,z)$$生成器根据文档生成答案的概率。
四、常见检索方法
1.稀疏检索
常见算法:
- TF-IDF;
- BM25;
- 倒排索引。
优点:精确匹配强,适合编号、函数名和关键词。
缺点:不太理解同义词和自然语言改写。
2.密集检索
将查询和文档转换为向量,再做语义匹配。
优点:能找到“意思相近但关键词不同”的内容。
缺点:可能忽略精确编号和特殊符号。
3.混合检索
实际项目中,BM25 和向量检索经常一起使用:$$s_{\text{hybrid}} =\alpha s_{\text{dense}}+(1-\alpha)s_{\text{sparse}}$$
也可以使用 RRF 融合排名:$$\operatorname{RRF}(d) =\sum_{r\in\mathcal{R}}\frac{1}{K+\operatorname{rank}_r(d)}$$
对于安全和代码场景,混合检索通常比单独使用向量检索更可靠。
4.多查询检索
把一个问题改写为多个问题:$$q\rightarrow{q_1,q_2,\ldots,q_m}$$
多个查询分别检索,再合并结果,可以提高召回率
5.父子文档检索
- 小块用于检索
- 大块用于提供上下文
设子块 c_{i,j} 属于父文档 P_i:
$c_{i,j}\subset P_i$
命中小块后,返回更完整的父文档:$$\operatorname{Return}(c_{i,j})=P_i$$
这种方式比较适合教材、论文和源码文件。
五、向量索引
1.Flat
直接将查询向量与所有文档向量比较:$$O(Nd)$$
优点是结果精确,缺点是数据量大时速度慢。
适合:
- 小型知识库;
- 学习和测试;
- 作为其他索引的效果基准
2.HNSW
HNSW 使用多层近邻图进行搜索。
常见参数:
- M:每个节点连接多少邻居;
- efConstruction:建库时搜索范围;
- efSearch:查询时搜索范围。
efSearch 越大,结果通常越准,但查询也越慢。
3.IVF
IVF 先把向量聚成多个桶:$$a(\mathbf{v}) =\underset{j}{\operatorname{argmin}} |\mathbf{v}-\boldsymbol{\mu}_j|_2$$
查询时只搜索最接近的几个桶,而不是搜索全部向量。
4.PQ
PQ 将一个长向量拆成多个子向量,再分别压缩:$$\mathbf{v}=[\mathbf{v}^{(1)},\mathbf{v}^{(2)},\ldots,\mathbf{v}^{(m)}]$$
优点是节省内存,缺点是会损失一些精度。