深入浅出 RAG:揭秘大模型“开卷考试”背后的技术
1. 前言
如果你关注过 2023 年以来的 AI 发展,一定对大语言模型(LLM)的“幻觉”问题不陌生——它们时常一本正经地胡说八道,或者在面对超出训练数据时间范围的问题时束手无策。这就好比让一个天才学生参加闭卷考试,他知识再渊博,也总有记不清或不知道的领域。
-
LLM的困境:知识滞后、知识缺失、幻觉、不可追溯。
-
幻觉的成因:训练知识存在偏差、LLM训练泛化、LLM没有真正学习到深层次的含义、LLM缺乏某些领域的相关知识。
那有没有办法让大模型从“闭卷考试”变成“开卷考试”呢?
答案是肯定的。检索增强生成(Retrieval-Augmented Generation,简称 RAG) 正是为此而生。它就像给大模型配了一个专属的图书馆管理员,每次回答前先帮模型翻查相关资料,再让模型结合这些资料给出有理有据的回答。如今,RAG 已成为企业落地大模型应用最主流、最实用的技术方案之一。
本文将带你从零开始,彻底搞懂 RAG 的核心原理,重点拆解其工作流程中的第一个阶段——数据准备阶段的每一个关键步骤。
2. 什么是RAG
RAG 全称 Retrieval-Augmented Generation,直译为“检索增强生成”。它由 Meta 公司在 2020 年的论文 Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks 中首次提出。简单来说,RAG 是一种将信息检索系统与大语言模型相结合的技术框架。基本思想为:将传统的生成式大模型和实时信息检索技术相结合,为大模型补充来自外部的相关数据和上下文,来帮助大模型生成更加准确可靠的内容。这使得大模型在生成内容时可以依赖实时与个性化的数据和知识,而非仅仅依赖训练知识。
它的核心思想可以概括为一句话:先检索,后生成。
当用户提出一个问题时,RAG 系统不会直接把问题丢给大模型,而是先从一个外部知识库中检索出与问题最相关的若干文档片段,再将这些片段和原始问题一起作为提示词(Prompt)输入给大模型,让模型基于这些参考资料生成最终回答。
这样做有三个明显的好处:
-
事实准确性大幅提升:模型不再依赖记忆中可能出错或过时的参数化知识,而是基于检索到的具体文本作答。
-
回答可溯源:你可以明确指出回答依据了哪份文档,这在金融、医疗、法律等强监管领域至关重要。
-
知识更新灵活:当知识需要更新时,只需要替换或增删外部知识库的内容,无需重新训练或微调模型,成本极低。
3. RAG的两个阶段
从流程上,我们可以将 RAG 清晰地划分为两个阶段:
| 阶段 | 名称 | 主要任务 |
|---|---|---|
| 第一阶段 | 索引阶段 | 将外部知识库中的文档进行加载、切片、向量化,并构建向量索引,存储到向量数据库中。 |
| 第二阶段 | 查询与生成阶段 | 将用户问题向量化,在向量库中检索最相似的文档片段,将检索结果与问题组装成提示词,交由大模型生成最终答案。 |
简单打个比方:第一阶段是在建图书馆,给每本书编目上架;第二阶段是读者来借书时,馆员快速找到相关书籍,并摘抄重点交给读者。
当前大部分技术文章和开源框架(如 LangChain、LlamaIndex)的优化重点都集中在第一阶段,因为数据准备的质量直接决定了第二阶段检索效果的上限——正所谓“垃圾进,垃圾出”。下面我们就深入第一阶段,拆解每一个环节。
4. RAG的第一个阶段:索引阶段
索引阶段的目标是把非结构化的原始文档转化为可供高效检索的向量索引。整个流程包含四个紧密相连的步骤:数据加载 → 数据切片 → 向量化 → 向量存储。下面我们逐一剖析。
4.1 数据加载(Data Loading)
数据加载是整个 RAG 流程的“水源”。这一步要做的事情很简单:把各种来源、各种格式的原始数据读取进来,统一转化为纯文本内容。
现实世界中,企业知识库的格式五花八门:
-
结构化数据:数据库表、Excel 表格、JSON 文件
-
半结构化数据:PDF、Word、Markdown 文件
-
非结构化数据:纯文本、HTML 网页、邮件
-
多模态数据:图片中的文字(需要 OCR)、音视频的转录文本
技术要点:
-
针对 PDF,需要处理扫描件(OCR 识别)和文本型 PDF(直接提取)。常用工具有 PyPDF2、pdfplumber、pymupdf。
-
针对 Word/Excel,可使用 python-docx、openpyxl。
-
针对网页,可使用 BeautifulSoup 进行 HTML 解析。
-
针对数据库,通过 SQL 查询获取结构化记录后,需要转换为自然语言描述文本(例如将一行用户记录转为“用户张三,年龄 30 岁,职业是工程师”)。
** 代码例子**:
- 加载md
可以使用 Unstructured 文档加载器来加载 Markdown 文件。Unstructured.io 对 Markdown 的解析流程大致为:
- 按照 Markdown 语法结构进行切分:标题、列表等会被切分成单独的 element
- 对同一标题下的正文,再按段落进行切分:不同段落(通过空行或 \n 区分)会被拆成多个 element。
# uv add markdown langchain-community "unstructured[md]"
from langchain_community.document_loaders import UnstructuredMarkdownLoader
loader = UnstructuredMarkdownLoader (file_path="./demo.md",
mode = "elements",
encoding = 'utf-8')
documents = loader.load()
print(documents)
for i, doc in enumerate(documents):
print(f"=== Element {i} ===")
print(doc.page_content)
print("metadata:", doc.metadata)
print("============\n")
- 加载docx
使用 Unstructured.io 的 Loader 解析 .docx 时,目前通常只会按照换行对文件进行切分成多个element,不能可靠地区分“正文段落”和“各级标题”,因此会丢失语义化的标题层级信息
对于标题层级信息敏感的.docx文件,上面的方式会丢失标题层级信息,此时可通过MinerU进行处理。
# uv add langchain-community "unstructured[docx]"
from langchain_community.document_loaders import UnstructuredWordDocumentLoader
loader = UnstructuredWordDocumentLoader (file_path="./demo.docx",
mode = "elements",
encoding = 'utf-8')
documents = loader.load()
print(documents)
for i, doc in enumerate(documents):
print(f"=== Element {i} ===")
print(doc.page_content)
print("metadata:", doc.metadata)
print("============\n")
- 加载PDF
MinerU 是一款将 PDF 转化为机器可读格式(如 Markdown、JSON)的工具,便于后续按任意结构进行抽取和处理。
MinerU2.5 采用两阶段解析策略:先在下采样图像上做高效全局布局分析,再在原始分辨率裁剪图像上对文本、公式、表格等进行细粒度识别。
MinerU两阶段流程:
-
- 粗细粒:先看整页图像–》再缩成一张小图–》识别版面布局–》输出每个块的类型+位置框
- 细粒度:根据位置框剪裁各块–》文本块–》表格块–》公式块–》结构化输出:页面分块+每块类型/位置/内容
# 1. 创建解析任务
import os
import requests
from dotenv import load_dotenv
load_dotenv()
token = os.getenv("MINERU_API_KEY")
url = "https://mineru.net/api/v4/extract/task"
# 组装头信息
header = {
"Content-Type": "application/json",
"Authorization": f"Bearer {token}"
}
# 组装提交数据
data = {
"url": "https://cdn-mineru.openxlab.org.cn/demo/example.pdf",
"model_version": "vlm" # 视觉模型(vlm) 其实可以指定pipeline版本(mineru本地模型)
}
res = requests.post(url,headers=header,json=data)
print(res.status_code)
print(res.json())
print(res.json()["data"])
import os
import requests
from dotenv import load_dotenv
load_dotenv()
token = os.getenv("MINERU_API_KEY")
task_id = '2e94ce46-2dfb-4e4c-b2e7-b8b8e5078f3f'
url = f"https://mineru.net/api/v4/extract/task/{task_id}"
header = {
"Content-Type": "application/json",
"Authorization": f"Bearer {token}"
}
res = requests.get(url, headers=header)
print(res.status_code)
print(res.json())
print(res.json()["data"])
⚠️ 注意:数据加载阶段要特别关注格式噪声的清洗,比如 PDF 中的页眉页脚、多余换行符、特殊 Unicode 字符等。这些噪声若不清理,会直接影响后续切片和向量化的质量。
4.2 数据切片(Chunking)
数据切片是 RAG 中最关键也最容易被忽视的环节。它的任务很简单:将加载好的长文档,切分成若干个较小的文本片段(Chunk)。
为什么要切片?原因有三:
-
大模型上下文窗口限制:虽然当前模型窗口已扩展到百万级别,但检索时仍然需要将最相关的 Top-K 个片段拼入 Prompt,单个片段过长会浪费宝贵的上下文空间。
-
向量检索的精度问题:如果整个文档作为一个向量,那么检索时只能找到文档级别的相关度,无法定位到具体段落。切片越细,检索粒度越精准。
-
Embedding 模型的能力边界:大多数 Embedding 模型对输入长度有上限(通常为 512 tokens 或 8192 tokens 不等),超长文本会被截断或平均池化,丢失语义信息。
切片策略是门学问,常见的方案包括:
-
固定大小切片:按字符数或 Token 数强行切分(如每 512 个字符一块)。简单粗暴,但可能切断句子或段落,破坏语义连贯性。
-
基于分隔符切片:按句号、换行符、章节标题等自然边界切分。语义完整性更好,但长度可能参差不齐。
-
递归切片(Recursive Chunking):LangChain 推荐的策略。先尝试按大分隔符(如双换行)切,如果切片仍然过长,则递归地按小分隔符(如单换行、句号)继续切。既保证语义完整,又控制长度。
-
语义切片(Semantic Chunking):利用模型计算句子之间的语义相似度,在相似度发生突变的边界处切分。这种方法最智能,但计算开销较大。
-
重叠切片(Overlapping Chunking):让相邻切片之间有部分内容重叠(如重叠 10%)。这能有效避免边界信息被切断导致的关键信息丢失。
** 代码示例**:
from langchain_community.document_loaders import UnstructuredWordDocumentLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
# 加载文档
docs_list = UnstructuredWordDocumentLoader(file_path="./demo.docx",
mode="single").load()
# 切分为文本块
chunks = RecursiveCharacterTextSplitter(
separators=["\n\n", "\n", "。", "!", "?", "……", ",", ""], # 分隔符优先级列表
chunk_size=400, # 每个块的最大长度
chunk_overlap=50, # 相邻块重叠长度
length_function=len, # 长度计算函数
).split_documents(docs_list)
docs_list = chunks.split_documents(docs_list)
print(docs_list)
# 统计块数量
print(f"切分得到 {len(chunks)} 个文本块\n")
# 可选:查看前几个块内容和元数据
for i, chunk in enumerate(chunks[:5]): # 只看前 5 个
print(f"=== Chunk {i} ===")
print(chunk.page_content)
print("metadata:", chunk.metadata)
print("============\n")
📌 经验法则:切片大小的选择没有“银弹”,需要根据你的文档特点(如技术文档偏长、客服对话偏短)和 Embedding 模型的最佳长度进行实验。通常建议从 256-512 tokens 开始尝试,并结合检索效果调优。
4.3 向量化(Embedding)
切片完成后,我们需要将每一个文本片段转化为稠密向量(Dense Vector)——也就是一串固定长度的浮点数数组。这个过程就叫做向量化(Embedding),其核心组件是一个 Embedding 模型。
- 稠密向量:主要用于实现文本相似度的匹配
- 稀疏向量:主要用于关键字匹配
为什么要把文本变成向量?因为计算机处理不了自然语言,但可以高效计算向量之间的数学距离。通过 Embedding 模型,语义相似的文本会被映射到向量空间中相近的位置,这样就可以通过计算向量相似度(如余弦相似度)来实现语义检索,而非仅仅依赖关键词匹配。
技术选型要点:
-
开源模型:如 BGE(BAAI General Embedding)、GTE(General Text Embeddings)、E5、text2vec 等。其中 BGE 系列在中文场景表现优异,且支持多语言。
-
商业 API 模型:如 OpenAI 的 text-embedding-3-small/large、Cohere Embed、Google Cloud Vertex AI 等。使用方便,但数据需上传至第三方,存在隐私合规风险。
-
维度选择:向量维度越高,表达能力越强,但存储成本和检索耗时也越大。常见维度从 384 到 3072 不等,需要平衡精度与性能。
** 代码示例**
# uv add sentence-transformers langchain_huggingface
def embedding_demo():
from langchain_huggingface import HuggingFaceEmbeddings
embed_model = HuggingFaceEmbeddings(
model_name=r'D:\bge\bge-m3'
)
# 单文本嵌入
query = "你好,世界"
query_result = embed_model.embed_query(query)
print(len(query_result)) # 1024
print(query_result[0:10])
# 多文本嵌入
docs = ["你好,世界", "你好,世界"]
res = embed_model.embed_documents(docs)
print(type(res)) # <class 'list'>
if __name__ == '__main__':
embedding_demo()
from FlagEmbedding import BGEM3FlagModel
model = BGEM3FlagModel(model_name_or_path=r"D:\bge\bge-m3")
text = "标量字段通常用来存储一些元数据,并可以在搜索时通过元数据进行过滤"
res = model.encode(
[text],
return_sparse=True,
return_dense=True
)
print("=== 原始文本 ===")
print(text)
print("=" * 50)
# 1. 稀疏向量(关键词及其权重,原始 id 形式)
print("=== 稀疏向量(id → 权重)===")
lexical_weights = res["lexical_weights"][0] # batch 中第一条
# 只看前若干个,避免太长
for i, (token_id, weight) in enumerate(list(lexical_weights.items())[:20]):
print(f"[{i:02d}] id={token_id:<5} weight={weight:.4f}")
print("...(仅展示前 20 个)")
print("=" * 50)
# 2. 把稀疏向量的 id 转成可读 token
print("=== 稀疏向量(token → 权重)===")
sparse_tokens = model.convert_id_to_token(lexical_weights)
for i, (token, weight) in enumerate(list(sparse_tokens.items())[:20]):
print(f"[{i:02d}] token='{token}' weight={weight:.4f}")
# 3. 稠密向量(1024 维)
dense_vec = res["dense_vecs"][0]
print("=== 稠密向量信息 ===")
print(f"维度: {len(dense_vec)}")
print("前 10 维示例:")
print([round(x, 4) for x in dense_vec[:10]])
💡 重要提示:向量化这一步有“一次嵌入,终身使用”的特点。同一个知识库只需做一次离线向量化,后续查询阶段只是用同一个模型把用户问题转为向量,然后在库中检索。因此 Embedding 模型一旦选定,索引阶段和查询阶段必须使用完全相同的模型,否则向量空间不对齐,检索结果将毫无意义。
4.4 向量存储(Vector Storage)
向量化完成后,我们得到了海量的高维向量数据,还需要一个专门为向量检索优化的数据库来存储和管理它们。这就是向量数据库(Vector Database)。
向量数据库的核心能力包括:
-
高效的相似性检索:在大规模向量集合中快速找到与查询向量最相似的 Top-K 个向量。这依赖近似最近邻(ANN,Approximate Nearest Neighbor)算法,如 HNSW、IVF、PQ 等。
-
元数据存储与过滤:每个向量除了本身的浮点数数组外,通常还附带元数据(如原始文档名称、切片序号、时间戳、作者等),支持按条件过滤后再检索。
-
持久化与高可用:支持数据落盘、备份、主从同步等企业级特性。
向量数据库的核心价值在于解决了传统数据库面对高维数据时的两个致命短板:
-
查询逻辑的转变(相似度检索):突破了传统关系型数据库只能找“绝对相等”或“标量范围”的限制,向量数据库支持在高维度空间中寻找“最相似”的特征。
-
海量数据的检索速度(高维索引):相比于传统数据库在计算相似度时无奈的“全表扫描”,向量数 据库通过构建 ANN(近似最近邻)、HNSW 等专用的高维向量索引算法,巧妙地避免了全局遍 历,在亿级数据中依然能实现毫秒级的快速返回。
5. 总结
我们通过一张图来回顾 RAG 第一阶段的核心流程:
原始文档 → [数据加载] → 纯文本
→ [数据切片] → 文本块 (Chunks)
→ [向量化] → 稠密向量 (Embeddings)
→ [向量存储] → 向量数据库索引
如果说大模型是 RAG 的“大脑”,那么第一阶段构建的向量知识库就是 RAG 的“长期记忆”。这个记忆的质量——包括文档覆盖度、切片合理性、Embedding 模型的语义表征能力、向量数据库的检索效率——直接决定了整个 RAG 系统回答的准确性、相关性和可靠性。
在实际工程中,第一阶段的优化是一个持续迭代的过程:
-
调整切片策略,增加重叠或调整块大小,可能让检索召回率明显提升;
-
更换更强的 Embedding 模型(如从 BGE-small 升级到 BGE-large),可能让语义匹配更加精准;
-
在向量检索之上叠加关键词检索(即混合检索 HyDE),可以兼顾精准匹配和语义泛化。
RAG 并非银弹,但它无疑是当前大模型落地最务实、最高效的路径之一。掌握了第一阶段的数据准备工程,你已经走完了 RAG 最核心、最关键的 60%。后续的查询与生成阶段,本质上就是在这些高质量的索引之上做高效的检索和聪明的提示构造。
下一篇文章中,我们将继续深入 RAG 的第二阶段——查询与生成,探讨如何优化检索策略(如 Multi-Query、Rerank、HyDE),以及如何设计 Prompt 让大模型更好地利用检索到的信息。敬请期待!
如果你对 RAG 工程落地感兴趣,欢迎在评论区交流你的实践经验或遇到的问题。如果觉得本文对你有帮助,也请点赞、转发支持~
更多推荐




所有评论(0)