RAG 是什么?
点击下方按钮,AI 将为您生成文章摘要
简介
LLM 正成为各类业务的底层能力,但在实际落地中,会发现 LLM 有一些硬伤:知识固化,模型训练完成后无法获取训练数据之外的新信息; 幻觉问题,模型容易生成看似合理但缺乏事实依据的错误内容;缺乏私有知识,不了解内部文档。
检索增强生成(RAG:Retrieval-Augmented Generation)正是解决这些痛点的关键技术。它并非对 LLM 进行模型架构级的修改,而是在不改动模型参数的前提下, 为 LLM 外接一个 “专属知识库”—— 回答问题前,RAG 会先从这个外部知识库中检索相关的参考资料,再让 LLM 基于这些真实资料生成答案,最终实现 “准确、及时、安全” 的输出效果。
| 单词 | 含义 | 通俗解释 |
|---|---|---|
| Retrieve | 检索 | 用户提问后,系统先去知识库中找到最相关的几段文字 |
| Augment | 增强 | 把找到的文字塞进给模型的「提示词」(Prompt)里,作为背景知识 |
| Generate | 生成 | 模型结合用户问题和检索到的资料,生成最终回答 |
开卷考试
想象一个你或许并不陌生的场景:某门课你平时没怎么下功夫,课本几乎还是新的,可偏偏考试允许开卷。你很清楚,要在考场上从头翻书找每道题的答案,时间根本来不及。 于是聪明的你提前做一些准备:把课本按章节和知识点拆开,在书页侧面贴上彩色标签,注明“词法分析”、“抽象语法树”等; 又在目录和每章开头写下关键考点对应的页码。等到考试时,你扫一眼题目就能判断出它考哪个知识点,然后顺着标签和页码快速翻到那几页,把相关内容摘出来,组织成自己的答案。
这套做法的核心在于:你并没有把整本书背下来,它也没有让你变聪明,而是建立了一套快速定位信息的机制,让知识随用随取。RAG 的运作逻辑和这个开卷考试过程很相像。
那本贴满标签的课本,就是外部知识库,里面全是跟业务相关的文档。
根据题目分析考点、按标签定位页码的过程,对应检索器的工作。
翻到知识点后,把内容整理成一段有逻辑的回答,就是生成器——大语言模型本身在做的事。
所以,RAG 并没有改变模型的“大脑”,它只是给了模型一套高效翻书的系统。这比把整本课本塞进脑子里省力得多,也更灵活——课本内容有更新,只要重新贴一轮标签就好,不用把整本书重新背一遍。
RAG 作用
我问一个裸的通用大模型:“我们公司年假怎么休?”它大概会给你一段关于劳动法通用规定的回答,还会发散思维告诉你年假没修完应该怎么办。 但如果我用 RAG 把公司制度文档先喂给它,再问同样的问题,它就会直接回复:“根据公司规定,员工没有年假。”。/(ㄒoㄒ)/~~
具体来说,没有 RAG 会有这么几个痛点:
知识截止 大模型的知识停留在训练数据截止的那一天。此后世界上发生的任何事,无论多重大,它都一概不知。
幻觉严重,尤其容易“迷失在中间” 这是最头疼的问题。幻觉并不是模型故意说谎,而是它的生成机制导致的。大模型在预测下一个词时,本质上是根据已有的上下文计算概率分布。 当它没有足够的外部信息约束时,就会倾向于生成“听起来合理”但实际不实的内容。更有趣的是,研究者发现模型对长文本的注意力分布是不均匀的:它会清晰地记住开头和结尾的信息, 但中间部分往往被忽略,这就是所谓的 lost in the middle 现象。如果你把一堆文档一股脑丢进上下文窗口,模型很可能对中间部分“视而不见”,自己脑补出一些东西。 在企业场景里,这样的假答案可能比直接说“不知道”危害更大,因为它说得太像真的了。
不懂私域内容 通用模型没有见过你公司的产品手册、规章制度、项目文档。这些信息从没进入过它的训练集,自然回答不了。
答案无法溯源 它给你一段回复,你看不到依据在哪里,只能凭感觉判断可信度。
更新成本高 想让模型学会新知识,传统方式是微调。微调需要准备高质量数据集、消耗大量算力,每一次更新都是一笔不小的投入。
再来看引入 RAG 之后的变化:
知识可以准实时更新。往向量库里追加新文档,模型不用重新训练,答案立刻就跟着变了。
幻觉被显著抑制。你在提示里明确要求“只根据提供的资料回答”,模型的行为就被限制在了给定的素材范围内。即使资料很长,你也通过检索只把最相关的几个片段送进去, 避开了 lost in the middle 的陷阱——模型的注意力可以集中在少量高相关度的文本上,而不是散落在一大篇无关内容中。
把产品文档、历史工单或政策文件喂进去,它就能答得头头是道。
答案可查证。你可以顺手把引用的原文片段展示出来,用户一看就知道没胡说。
实施成本低。维护一个向量数据库比微调模型便宜太多了,更新也快。
RAG 如何起作用的?
整个 RAG 系统可以分成两大阶段:离线准备(离线索引) 和 在线问答(在线查询)。
阶段一:离线索引
你有各种格式的文档:PDF、Word、网页、数据库记录…… 要让它能被模型检索,需要经过四个步骤:加载、切片、向量化、存储。
1. 加载文档(读取各种格式)
Spring AI 提供了 DocumentReader 接口来统一处理不同来源。比如读取一个 PDF 文件,可以用 PagePdfDocumentReader;读取网页,可以用 CrawlerDocumentReader。它们都输出 Document 对象。
// 从PDF文件加载文档
import org.springframework.ai.reader.pdf.PagePdfDocumentReader;
import org.springframework.ai.document.Document;
import java.util.List;
PagePdfDocumentReader reader = new PagePdfDocumentReader("classpath:company_policy.pdf");
List<Document> documents = reader.get();这里得到的 documents 列表,每个元素代表 PDF 的一页,内容已经提取为纯文本。
2. 切片(Chunking)
不能把一整本手册直接扔给模型,因为大模型每次能处理的文字量有限,而且如果段落太长、内容太杂,检索时就很难精确命中相关部分。切片就是把大文档切成小段,每一段称为一个 chunk。
切片听上去简单,其实有不少讲究:
- 切多大? 通常按 token 数来定,比如每个 chunk 含 500 个 token。太小了信息不完整,太大了检索不精准。
- 要不要重叠? 必须要有。为了让一句话不刚好被切在两段的边界上导致意思断裂,切的时候相邻 chunk 会有一部分重叠,比如 overlap 设为 50 个 token。
- 怎么切? 最简单的就是按固定长度硬切,但这样可能在句子中间切断。更好的做法是找自然边界——句号、换行符来切,这称为递归文本分割。
- Spring AI 的
TokenTextSplitter会按字符、句子等层级尝试切分,尽量保持语义完整。
import org.springframework.ai.document.Document;
import org.springframework.ai.transformer.splitter.TokenTextSplitter;
import java.util.List;
// 假设已经有一个大的 Document 对象
Document largeDocument = new Document("很长的文本内容...");
// Builder 方式:链式设置参数,未显式设置的会使用类定义的默认值
TokenTextSplitter splitter = TokenTextSplitter.builder()
.chunkSize(200) // 目标 token 数
.chunkOverlap(30) // 相邻块重叠的 token 数
.minChunkSizeChars(10) // 每块最小字符数
.maxNumChunks(10000) // 最大分块数
.keepSeparator(true) // 是否保留分隔符
.build();
List<Document> chunks = splitter.split(List.of(largeDocument));举例来说,原文是:“员工每年享有10天带薪年假。工作满1年后方可申请。年假可累计至下一年度。” 如果切成两个 chunk 且没有重叠,第一个 chunk 可能是“员工每年享有10天带薪年假。工作满”,第二个是“1年后方可申请。年假可累计至下一年度。” 这显然把意思切碎了。加了重叠之后,第二个 chunk 可能变成“工作满1年后方可申请。年假可累计至下一年度。” 这样每个 chunk 都有完整句子。
3. 向量化(Embedding)
文本本身没法直接做数学运算,得把它变成一串数字,这串数字就是向量,它能“表示”这段文字的意思。 做这个转换的东西叫嵌入模型,比如 OpenAI 的 text-embedding-3-small。它会读入一段文本,输出一个固定长度的向量。
那么嵌入模型到底怎么把文字变成向量的呢?你可以把它想象成一个受过特殊训练的黑盒子。它内部是一个神经网络,这个网络已经在海量文本上做过训练,训练的目标不是生成文字, 而是让语义相近的句子,输出的向量在空间里的距离也近。距离通常用余弦相似度来衡量,越接近 1 代表越相似。
例如,“年假怎么休”和“带薪年假政策”,尽管字面不同,但被嵌入模型映射成向量后,两个向量的夹角会非常小,余弦相似度很高。 而“年假怎么休”和“午餐吃什么”的向量就离得很远。这就是语义搜索的数学基础。
在 Spring AI 中,我们不需要自己操心向量化,因为 VectorStore 的 add() 方法会帮你搞定。但如果你想单独调用嵌入模型,也很简单:
import org.springframework.ai.embedding.EmbeddingModel;
import org.springframework.ai.embedding.EmbeddingRequest;
import java.util.List;
// embeddingModel 会自动注入
List<float[]> embeddings = embeddingModel.embed(List.of("员工每年享有10天带薪年假", "年假怎么休"));
// 现在你可以计算两个向量的余弦相似度,或者把它们存入向量库每一个 float[] 就是一串数字,也就是对应文本的向量。你不需要完全理解向量的每一个维度代表什么,只需要记住:意思越近的文本,它们的向量越相似。
4. 存入向量数据库
得到了每个 chunk 的向量和原文,就可以成对地存进向量数据库了。向量数据库专门为向量设计了高效的索引, 比如 HNSW(分层可导航小世界图),它能在数百万级数据中极快地找到最近似的向量,而不需要遍历所有数据。
常用的向量数据库有 Chroma(轻量)、Milvus(高并发)、PgVector(PostgreSQL 插件)等。存入时一般保存三样东西:向量、原文、元数据(如来源文件名、页码)。
import org.springframework.ai.vectorstore.VectorStore;
// vectorStore 已配置好
vectorStore.add(chunks);add() 内部会:遍历每个 chunk → 调用嵌入模型生成向量 → 将 <向量, 文本, 元数据> 持久化到向量库。到此,知识库就建好了。
阶段二:在线查询问答
当用户提问时,系统会实时跑通下面几步:
问题向量化
用同一个嵌入模型,把用户的问题变成一个向量。相似度检索
拿这个向量去向量数据库里,用余弦相似度找出最接近的 Top-K 个向量,并返回它们对应的 chunk 原文。K 通常是 3~5。同时可以设一个相似度阈值,低于 0.7 的结果直接丢掉,免得噪声干扰。import org.springframework.ai.vectorstore.SearchRequest; import org.springframework.ai.vectorstore.VectorStore; // 检索最相关的3个片段,相似度低于0.7的不要 SearchRequest request = SearchRequest.defaults() .withTopK(3) .withSimilarityThreshold(0.7); List<Document> relevantDocs = vectorStore.similaritySearch(question, request);组装增强提示
把检索到的几个 chunk 原文,连同用户的问题,拼接到一个预先设计好的提示模板里。调用大模型生成
把拼接好的整个提示发给大模型。模型会“阅读”参考资料,然后生成答案。返回结果
把答案返回给用户,通常也会附上引用的 chunk,方便核实。
在 Spring AI 里,上面这些步骤被 QuestionAnswerAdvisor 很好地封装了起来。
用 Spring AI 搭一个 RAG 问答应用
TODO: 用 Spring AI 搭一个 RAG 问答应用