RAG是什么
文章目录
P.S. 推荐一个大神的教程给想要了解或者学习人工智能知识的读者,这个教程里内容讲解通俗易懂且风趣幽默,对我帮助很大。我想与大家分享这个宝藏教程,请点击下方链接查看, 传送门https://blog.csdn.net/qq_74013365
前言
各位好。今天来聊 RAG。
先交代一下我为什么要讲这个。去年老板让我给客服机器人接知识库,我说没问题,调接口嘛,谁不会。然后我打开第一篇资料,看到"稠密嵌入"四个字,当场想把接口调回去。
但钱难挣。我硬着头皮把整条链路啃完了,顺手写了几个配套的小项目,今天这篇就是我的踩坑记录。尽量说人话,你听完能跟同事吹半小时那种。
1. RAG 是啥:给大模型开外挂
1.1 先说清楚它治什么病
大模型本质上是个"考完试就失忆"的学霸。它的训练数据有截止日期,截止之后的世界它一概不知道。
你问它"昨天谁塌房了",它能当场给你编一套完整的塌房流程,从起因到公关稿,比狗仔还专业,全是现挂。
RAG 就是来治这个病的。说穿了就是给模型配个图书馆,让它现场翻书,翻到什么答什么,答不出来就老实说"资料不足"。比某些同事靠谱多了。
1.2 两个例子感受一下
**例一:维基百科。**用户问"量子纠缠是什么,最新实验进展如何"。模型训练数据里可能只有"量子纠缠是种量子力学现象"这种正确的废话。RAG 的流程是这样:
// 1. 用户提问
const query = "量子纠缠是什么?最新的实验进展有哪些?";
// 2. 检索:从知识库中找到最相关的片段
const results = await retriever.search(query, { topK: 3 });
// results = [
// "量子纠缠是一种量子力学现象,两个粒子的量子态相互关联...",
// "2022年诺贝尔物理学奖授予量子纠缠实验验证的三位科学家...",
// "贝尔不等式实验证明了量子纠缠的非局域性..."
// ]
// 3. 生成:将检索结果作为上下文,让 LLM 生成答案
const answer = await llm.generate({
system: "根据以下参考资料回答用户问题。如果资料不足,明确说明。",
context: results, // ← 检索到的知识片段注入上下文
question: query,
});
**例二:公司知识库。**用户问"我买的东西想退款,流程是啥"。模型不可能知道你司的退款政策,除非你把它写进训练数据——但那样的话你司法务大概已经找过你了。检索器去知识库捞两条相关文档,生成器照着念,搞定。
两个例子套路完全一致:检索相关片段 → 注入上下文 → 让 LLM 基于上下文生成。就这么三步,跟煎饼果子一样,换什么馅都这流程。
1.3 为什么不直接微调
每次讲 RAG 都有人问:有新知识,为什么不微调模型?三个原因:
**第一,贵。**微调一次的成本,够你吃一年外卖,还得是顿顿加鸡腿那种。RAG 呢?改个文档,完事。
**第二,黑盒。**微调完的知识在模型脑子里,你既不知道它记没记住,也不知道它记没记歪,还没法溯源。RAG 的答案能指给你看:就这段文档,支撑了这句话。可审计性拉满。
**第三,慢。**知识更新比需求变更还频繁。今天政策改一个字,明天就要生效,你总不能每周微调一次模型吧。那频率比你改需求还高,谁受得了。
一句话总结:微调是给猫报班学握手,RAG 是给猫贴小抄。小抄便宜、能撕、随时换新,猫还不记仇。
2. 文档分块:切西瓜的艺术
2.1 为什么要切
知识库里的文档动不动几万字。嵌入模型一口吞不下——不是嘴小,是输入长度有上限。而且一整篇压成一个向量,等于把所有主题搅成一锅粥,向量自己都不知道自己在表达啥。
另外,检索的目标是把"相关的那部分"喂给模型。你切大块,喂进去一半是废话,窗口被浪费,注意力被稀释。就像你只想吃个鸡腿,外卖给你送一整只鸡,你还得含着泪啃完。
2.2 三种切法,各有性格
**固定大小切分:**按 token 数一刀切,比如 512 一个,相邻块留点重叠,防止关键句正好卡在刀口上。简单、可预测,但完全不看文档结构。段落、代码、表格,一视同仁,拦腰截断。你的代码块写到一半被切开,关键字没了,比 bug 还 bug。
**递归/结构感知切分:**按标题、段落、句子这些自然边界递归切。先按大边界切,块还是超长就降级到小边界。Markdown、HTML 这种带结构的文档是它的主场。这是生产环境的默认选项,属于"不聪明但绝不犯错"的那种同事。
**语义切分:**算相邻句子的嵌入相似度,在相似度"断崖"处下刀。就像分手,话说到"我们还是做朋友吧",就该切了。质量最高,代价是要额外算嵌入,费钱。毕竟感情破裂的鉴定费不便宜。
2.3 块大小和重叠的权衡
块太小,单块信息不完整。“该公司收入增长了 3%”——脱离上下文,鬼知道是哪家公司哪个季度,你问模型它也只能猜。块太大,一个块里好几个主题,嵌入被稀释,检索精度下降。
常见起点:每块 256-1024 token,重叠 10%-20%,然后实测调优。别问为什么是这个数,跑一遍数据比啥都强。
顺便埋个坑:分块有个固有缺陷——块切出去就跟上下文失联了。"该公司"指谁,这段话出自哪份报告,全留在块外面。这个问题下篇才解决,本篇先记一笔。
3. 稠密嵌入:把文字变成坐标
3.1 什么是嵌入
计算机不认识"苹果"。你给它看这两个字,它看到的是两个字节。嵌入就是把这些字变成一串数字,比如 [0.2, -0.5, 0.8, …],并且保证语义相近的文本,数字串也相近。
前端同学可以把嵌入类比成 RGB。颜色本来是人眼的感受,但表示成 #FF5733 这种数值后,计算机就能算"两种颜色有多接近"。嵌入对文本做的是同一件事——语义相近的文本,就像色相相近的颜色,数值也靠近。
这些数字所在的空间叫向量空间,可以想象成一张高维地图,每个词都是其中一个点,语义近的住得近,就像北京和上海在地图上的距离反映地理远近。
有个经典例子:“国王” - “男性” + “女性” ≈ “女王”。我第一次看到这个公式直接震惊了:向量空间里居然藏着性别议题。程序员第一次发现,原来数学也讲政治正确。
3.2 余弦相似度:看方向,不看身高
怎么判断两个向量像不像?最常用的是余弦相似度——算两个向量夹角的余弦值,越接近 1 越像。
为什么看夹角不看距离?因为我们关心方向一不一致,不关心长度。两篇内容相同但长度不同的文档,方向一致,就该判同类。就像判断两个人三观合不合,看的是聊不聊得来,不是看谁个子高。
想亲手验证的话,给个可跳过的手算示例。3 维空间里:
- A = “如何养猫” → (0.9, 0.5, 0.1)
- B = “猫咪饲养指南” → (0.8, 0.6, 0.1)
- C = “股票投资策略” → (0.1, 0.1, 0.9)
代进公式一算:A 和 B 的余弦值约 0.99,几乎重合;A 和 C 只有约 0.25。你看,养猫的和养猫的住一栋楼,炒股的被赶到郊区去了。
3.3 从 Word2Vec 到上下文感知
早期的 Word2Vec,一个词一个固定向量。"bank"在"river bank"和"investment bank"里是同一个向量——它分不清河岸和银行,就像我分不清女朋友的口红色号。
现代模型(BERT、BGE-M3)靠自注意力机制,算每个词的时候会参考整句话。同一个"苹果",在"苹果公司发布新产品"和"买了两斤苹果"里,向量完全不同。实现了从"词汇级"到"语境级"的飞跃。
另外 BGE-M3 还支持多语言和长文本,BERT 的输入上限是 512 个 token,稍微长点的文档它就噎住了。512 个 token 大概也就够读完这篇的开头,你感受一下。
3.4 ANN 索引:在海量向量里捞针
知识库上百万条文档,每次查询都全量算一遍相似度?那是每次都在大润发里找一根针,找完还要回来结账。
近似最近邻(ANN)算法的思路跟数据库索引一样:牺牲一点点精确,换数量级的速度。
拿 HNSW 举例:它把向量组织成一张多层图。顶层稀疏,只有少数"长程连接",相当于高速公路;底层稠密,包含全部节点,相当于小区里的羊肠小道。查询从顶层开始,先上高速直奔目标区域,再逐层下来精确定位,复杂度能到 O(log N) 量级。新增向量直接插进图里,不用推倒重建。
实际选型就一句话:数据不怎么变,选 ANNOY;要实时上新,选 HNSW。选错了,下篇讲知识库治理的时候你会知道什么叫运维成本酸爽。
3.5 动手实现:可切换后端的向量检索
我的 dense-embedding 项目干的事很简单:同一套接口,切换 ANNOY 和 HNSW 两个后端,让你亲眼看看表里每一行的差异。核心结构如下:
// dense-embedding:统一接口 + 可切换后端
interface VectorBackend {
build(vectors: Array<{ id: string; vec: number[] }>): void;
insert(id: string, vec: number[]): void;
search(query: number[], topK: number): Array<{ id: string; score: number }>;
}
// 通过配置切换两种索引结构,调用方无感知
function createBackend(name: "annoy" | "hnsw", dims: number): VectorBackend {
switch (name) {
case "annoy":
return new AnnoyBackend(dims); // 基于树:构建快、内存低,不支持增量插入
case "hnsw":
return new HnswBackend(dims); // 基于图:支持实时插入,内存开销更高
}
}
const index = createBackend("hnsw", 768);
// 写入:文档分块 → 嵌入 → 入库
for (const chunk of chunks) {
index.insert(chunk.id, await embed(chunk.text));
}
// 查询:同一接口下对比两种后端的召回质量与延迟
console.time("search");
const hits = index.search(await embed(userQuery), 10);
console.timeEnd("search");
代码的精髓是接口隔离:把"向量检索"抽象成 build、insert、search 三个动作,后端像换数据库驱动一样替换。配套的基准脚本会分别测构建耗时、内存占用、增量插入支持和 top-k 召回重合度。跑一遍你会发现,下表不是抄来的,是能亲手复现的。
| 特性 | ANNOY(基于树) | HNSW(基于图) |
|---|---|---|
| 构建速度 | 快 | 较慢 |
| 内存占用 | 低 | 较高 |
| 增量更新 | 不支持(需完全重建) | 支持(但长期增量插入后建议定期重建) |
| 查询精度 | 较高 | 极高 |
| 适用场景 | 数据不常变的静态数据集 | 需要实时索引新信息的动态场景 |
4. 稀疏嵌入:关键词党的尊严
4.1 词袋模型:只看有没有,不管谁追谁
稀疏嵌入的老祖宗是词袋模型:把文本当成一袋子词,只关心哪些词出现了、出现几次,完全忽略顺序。
所以"猫追狗"和"狗追猫",在词袋模型眼里一模一样。这是程序员的浪漫:我不在乎谁追谁,我只在乎这袋子里有没有猫和狗。
4.2 从 TF-IDF 到 BM25
假设知识库有 100 篇技术文章,你搜"模型蒸馏"。"模型"在 60 篇里都出现,烂大街;"蒸馏"只出现在 3 篇里,稀有。好的检索算法应该给"蒸馏"更高权重——就像微博热搜:"某顶流塌房"人人都在刷,没信息量;"某糊咖领证"才是真新闻。
TF-IDF 就是基于这个直觉:词在文档里出现越多(TF 高),同时包含它的文档越少(IDF 高),这词就越重要。但 TF-IDF 有两个毛病:不看文档长度,长文档天然词频高占便宜;词频线性增长,出现 10 次的重要性真的是 5 次的 2 倍吗?我看像 1.5 倍。
BM25 用两个参数修正:
k1 控制词频"饱和度"。一篇文章提"蒸馏"20 次和 10 次,相关程度真的不差一倍。k1 让词频贡献随次数增长趋于平缓,避免长文档靠堆砌词频作弊。
b 控制文档长度归一化,让长短文档公平竞争。
k1 和 b 就是两个调参旋钮,像防抖函数的等待时长:不改变逻辑,只改变手感。默认值 k1=1.5、b=0.75,绝大多数场景直接用,相当于麻辣烫默认微辣,别动它。
4.3 动手实现:50 行的 BM25
我的 sparse-embedding 项目从零实现了一遍 BM25,核心价值不在性能,而在完全透明——日志把每一步计算都摊开给你看。整个搜索引擎就两个动作:建索引、打分。
// sparse-embedding:BM25 搜索引擎核心
const K1 = 1.5, B = 0.75;
interface Posting { docId: string; tf: number }
class BM25Index {
private inverted = new Map<string, Posting[]>(); // 倒排索引:词 → 命中文档列表
private docLen = new Map<string, number>();
private avgdl = 0; // 平均文档长度
private N = 0; // 文档总数
addDocument(docId: string, text: string) {
const terms = tokenize(text); // 分词,并去除"的""了"等停用词
const tf = new Map<string, number>();
for (const t of terms) tf.set(t, (tf.get(t) ?? 0) + 1);
for (const [term, freq] of tf) {
const postings = this.inverted.get(term) ?? [];
postings.push({ docId, tf: freq });
this.inverted.set(term, postings);
}
this.docLen.set(docId, terms.length);
this.N++;
this.avgdl = [...this.docLen.values()].reduce((a, b) => a + b, 0) / this.N;
}
private idf(df: number) {
// 词越稀有(df 越小),IDF 越高
return Math.log((this.N - df + 0.5) / (df + 0.5));
}
search(query: string, topK = 10) {
const scores = new Map<string, number>();
for (const term of tokenize(query)) {
const postings = this.inverted.get(term) ?? [];
const idf = this.idf(postings.length); // df = 命中文档数
for (const { docId, tf } of postings) {
const dl = this.docLen.get(docId)!;
// k1 让词频贡献饱和,b 做长度归一化
const norm = (tf * (K1 + 1)) / (tf + K1 * (1 - B + (B * dl) / this.avgdl));
scores.set(docId, (scores.get(docId) ?? 0) + idf * norm);
}
}
return [...scores.entries()].sort((a, b) => b[1] - a[1]).slice(0, topK);
}
}
倒排索引,用 JavaScript 的眼光看,本质就是一张 Map<关键词, 文档ID[]>。这玩意就是书后面的术语索引页:你查"TCP",它告诉你第 45、112、203 页提到过。比搜索引擎靠谱,因为至少它不给你推荐莆田医院。
BM25 没有黑箱,每个分数都能用手指头指着算出来。把示例语料喂进去,输出的排序跟日志里逐行手算的结果一模一样。这就是"可复算"的魅力。
4.4 学习型稀疏检索
这年头连稀疏检索都开始卷学习了。SPLADE 和 BGE-M3 的稀疏分支用神经网络给每个词打分,甚至能给原文没出现、但语义相关的词补上非零权重——术语叫"词项扩展"。稀疏和稠密两条路线,在这里偷偷握手言和了。谁再说稀疏检索只会数数,我跟谁急。
5. 混合检索:两个眼睛,一个鼻子
5.1 谁都有盲区
稠密检索懂语义但漏关键词:搜"HTTP-403",它可能给你返回一堆"服务器报错了"的泛泛之谈,仿佛你的报错是什么人生感悟。稀疏检索精确匹配但读不懂同义词:搜"kitty",死活找不到只写了"cat"的文档。
一个是近视眼,一个是脸盲。单独出门,都容易认错人。
5.2 三阶段流水线
混合检索的思路很简单:两个引擎都跑,结果合并。难点在于两路得分根本不可比——稠密的余弦相似度在 0 到 1 之间,稀疏的 BM25 得分可能是 0 到几十。你没法直接把 0.87 和 8.4 加一块,那比把苹果和手机放同一个购物车还离谱。
**第一阶段:并行检索。**稠密、稀疏两路同时召回,各拉一批候选。
**第二阶段:结果融合。**把两路结果合成一个统一候选池。常用两种:
- 加权归一化:各路得分分别归一化后加权求和。保留了原始得分信息,但两路尺度对齐本身就不好调,调起来比调前任脾气还累。
- RRF(倒数排名融合):完全抛开得分,只看排名。每个文档的综合得分是它在各路结果中排名的平滑倒数之和:得分 = Σ 1/(k + rank),k 通常取 60。
RRF 就像评选时不让评委打分,只让评委按名次投票,防止某个手松的评委给阿猫阿狗都打高分把结果带偏。缺点是它把原始得分里的信息全扔了,扔得比断舍离还彻底。
**第三阶段:神经重排序。**注意,重排序不是来"补救"融合的——无论融合用哪种方法,它都值得加。它用跨编码器对候选做深度交互匹配,精度比检索阶段的双编码器高一个档次。
一句话理清分工:融合负责"选出候选池",重排序负责"在池子里精排"。没有前者,后者连该给谁打分都不知道,巧妇难为无米之炊。
5.3 双编码器 vs 跨编码器
打个比方:双编码器是 HR 扫简历,跨编码器是面试官跟你唠两小时。
**双编码器:**查询和文档各自编码成向量,文档向量离线算好缓存,查询时只做一次向量比对。极快,但只能做大规模初筛,抓不到深层匹配。
**跨编码器:**把查询和候选文档拼成一段完整文字,塞给模型逐词比对,输出一个综合相关度得分。慢得多,但准得多。常用的 bge-reranker-v2-m3 就是这种架构。
面试官为什么比 HR 准?因为他能看到 HR 看不到的细节——比如你简历上写着"精通",实际是"听说过"。
5.4 怎么度量检索质量
调这么长的流水线,手里得有尺子。三个核心指标,都在带标注答案的测试集上算:
| 指标 | 直觉解释 | 相亲版 |
|---|---|---|
| recall@k | 正确答案出现在前 k 个结果里的查询比例,回答"该找的找到了吗" | 有没有认识人 |
| MRR | 第一个相关文档排名的倒数再取平均,排第 1 得 1 分,排第 10 只得 0.1 分,回答"找到得够不够靠前" | 第一个介绍的是不是对的人 |
| nDCG | 综合考虑所有相关文档的排名与相关程度,越靠后折扣越大,回答"整个列表质量如何" | 整个备选池的质量如何 |
recall@k 是最贴近 RAG 需求的指标:只要相关文档进了上下文,模型就有机会用上它。剩下的都是锦上添花。
另外,看到"检索失败率"这种词先别慌。它通常就是 1 − recall@20,指正确信息没出现在 top-20 里的查询比例。先弄清它对应哪个指标、k 取多少,再谈比较,不然就是拿苹果跟手机比续航。
5.5 动手实现:三阶段混合检索
我的 retrieval-pipeline 项目把三个阶段翻译成了代码,主流程长这样:
// retrieval-pipeline:三阶段混合检索
async function hybridSearch(query: string, topK = 10) {
// 第一阶段:稠密、稀疏两路并行召回
const [denseHits, sparseHits] = await Promise.all([
vectorIndex.search(await embed(query), 50), // 语义召回
bm25.search(query, 50), // 关键词召回
]);
// 第二阶段:RRF 融合——只看排名,不看原始得分
const rrfScore = new Map<string, number>();
const K = 60; // 平滑常数,压低头部名次的得分差距
for (const hits of [denseHits, sparseHits]) {
hits.forEach((hit, rank) => {
rrfScore.set(hit.id, (rrfScore.get(hit.id) ?? 0) + 1 / (K + rank + 1));
});
}
const candidates = [...rrfScore.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, 50)
.map(([id]) => docs.get(id)!);
// 第三阶段:跨编码器对候选逐一精排
const reranked = await reranker.score(query, candidates);
return reranked.slice(0, topK);
}
三个阶段的职责在代码里一目了然:并行召回解决"找得全",RRF 融合解决"两路得分不可比",重排序解决"排得准"。
想亲眼看看重排序的价值?改一行代码,把 reranker.score 换成直接返回 candidates——召回率纹丝不动,但头部精度肉眼可见地往下掉。那一刻你会明白:重排序不是锦上添花,是雪中送炭。
6. 多模态:让知识库学会看图
到目前为止,我们检索的都是纯文本。但现实世界的知识不只在文字里——图表、PDF 版式、语音,全是知识。多模态信息提取就是整条管道的最前端,它决定了非文本内容以什么形态进知识库。
架构上有三条路,核心取舍就一句话:保真度和成本,你选哪个。
6.1 原生多模态:开挂的眼睛
原生多模态的核心,是把不同类型的数据都映射到同一个高维语义空间。以图像为例,Qwen-VL、LLaVA 这类模型都集成了基于 Vision Transformer(ViT)的视觉编码器。
ViT 说穿了就是把图切成固定大小的小方块,当"视觉单词"交给 Transformer 处理。前端看到这里应该会心一笑:这不就是 CSS Grid 吗?把一张图划分成网格,每个格子当一个 token 参与计算。
自注意力机制对文本和图像 token 一视同仁,模型直接"看到"PDF 的页面布局、图表和文字,理解图文之间的空间关系。版式复杂、信息密度高的文档,它是绝对的王者。
6.2 提取为文本:省钱省到家
两阶段:先用 OCR、语音转录把非文本内容转成纯文本,再喂给语言模型。任何多模态任务都能变成纯文本任务,兼容所有模型,提取结果还能缓存复用。
代价:版式、图表、图像信息,全在提取过程中丢了。就像把一幅画讲成文字给盲人朋友听,讲得再声情并茂,画本身也没了。
6.3 工具化:先摘要,按需深入
折中方案:先给 Agent 一份轻量文本摘要,同时给它配 analyze_image、analyze_pdf 这类工具,确有需要时再调用深入分析。
这就像女朋友查岗:先回个"在忙",真要细问才发定位。token 花在刀刃上,低成本处理大部分查询,高成本深度分析按需触发。
6.4 动手实现:一个文件,三种活法
我的 multimodal-agent 项目把三种模式放在一个框架里,对同一份输入走三条处理路径:
// multimodal-agent:同一份文件,三种处理模式
type Mode = "native" | "extract" | "tools";
async function answer(file: UploadedFile, question: string, mode: Mode) {
switch (mode) {
case "native":
// 原生多模态:文件直接进入模型,图文联合理解
return llm.generate({
input: [await file.toBase64(), question],
});
case "extract": {
// 提取为文本:先 OCR/转录,再走纯文本链路
const text = await extractText(file); // 版式与图像信息在此丢失
return llm.generate({ input: [text, question] });
}
case "tools": {
// 工具化:先给轻量摘要,模型按需调用深入分析工具
const summary = await extractSummary(file);
return agent.run({
input: [summary, question],
tools: [analyzeImage, analyzePdf], // 确有需要时才付出高成本
});
}
}
}
三种模式的分野就在信息进入模型之前:native 什么都不丢但最贵,extract 最便宜但丢掉全部视觉信息,tools 用轻量摘要做第一道过滤、把高成本分析变成可选项。
把同一份 PDF 报告分别跑三遍,对比回答质量和 token 账单,"保真度与成本的平衡"就从一句口号变成了两行能比较的数字。实践出真知,账单出真理。
7. 结语:知识进来了,但还是碎片
这篇走完了知识获取管道的全部工序:分块划定检索单元,稠密嵌入捕捉语义,稀疏嵌入做关键词匹配,融合汇成候选池,重排序做最终精排,多模态把感知范围扩展到图表和版式。
如果只能记住一个结论,那就是:没有哪种单一检索策略在所有场景下都可靠。稠密检索懂语义但漏关键词,稀疏检索精确但不认识同义词,重排序用跨编码器补齐两者的短板。组合起来,才是生产级 RAG 的正确姿势。
但还有一个更根本的问题没回答:这些文本块本身该怎么组织?简单切块会丢失知识的内在结构和跨文档关联——就像你试图靠翻字典的随机词条来理解一部小说,翻到"的"字的概率,比翻到"主角"大多了。
这个问题,下篇用 RAPTOR 和 GraphRAG 来回答。坑我先埋这儿,下篇再填。填坑这种事,就跟写需求文档一样,迟早要还的。
P.S. 推荐一个大神的教程给想要了解或者学习人工智能知识的读者,这个教程里内容讲解通俗易懂且风趣幽默,对我帮助很大。我想与大家分享这个宝藏教程,请点击下方链接查看,传送门https://blog.csdn.net/qq_74013365
DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。
更多推荐



所有评论(0)