导读:RAG(检索增强生成)听起来高大上,但拆开来看就是六个步骤的流水线——数据加载、文档分块、Embedding向量化、向量存储、相似度检索、增强生成。这篇把每个环节的原理、踩坑点和代码全摊开讲,帮你从"能跑"到"好用"。
一、先搞清楚:RAG到底在解决什么问题?
大模型有两个硬伤:幻觉(一本正经胡说八道)和知识过时(训练数据截止到去年)。RAG的思路很简单——不让模型自己瞎编,而是先去知识库里"查资料",找到相关内容后再让模型基于这些内容回答。
打个比方:大模型像一个没带课本的考生,RAG就是让他开卷考试——先翻书找答案,再组织语言作答。答案有据可查,幻觉大幅降低。
整个流程长这样:
用户提问 → 问题向量化 → 向量数据库检索 → 取回相关文档片段 → 拼成Prompt → 大模型生成回答
但在这之前,还有一条离线构建链路:
原始文档 → 数据加载 → 文档分块 → Embedding向量化 → 存入向量数据库
两条链路合在一起,就是完整的RAG流程。下面逐个拆解。
二、Step 1:数据加载——别小看这一步
2.1 做什么
把各种格式的文档(PDF、Word、TXT、HTML、Markdown、Excel)统一转成纯文本,方便后续处理。
2.2 常用工具
| 工具 | 特点 | 适用场景 |
|---|---|---|
LangChain DocumentLoaders | 支持50+格式,生态最全 | 通用场景 |
LlamaIndex SimpleDirectoryReader | 自动识别格式,一行代码 | 快速原型 |
Unstructured.io | PDF/HTML解析能力强,保留结构 | 复杂文档 |
PyPDF2/pdfplumber | 纯Python,轻量 | 简单PDF |
python-docx | Word文档专用 | .docx文件 |
2.3 踩坑实录
⚠️ PDF扫描件是大坑:很多公司的制度文件是扫描PDF,直接提取出来全是乱码。必须先做OCR(推荐PaddleOCR或Tesseract),再做版面分析,不然文本顺序全乱。
⚠️ 表格别直接丢:我之前做财务RAG,把Excel直接转纯文本,数字全挤在一起,模型根本看不懂。后来转成Markdown表格再分块,效果直接翻倍。
# LangChain 加载文档示例
from langchain.document_loaders import PyPDFLoader, TextLoader, UnstructuredExcelLoader
# PDF
loader = PyPDFLoader("knowledge_base.pdf")
docs = loader.load()
# TXT
loader = TextLoader("faq.txt", encoding="utf-8")
docs = loader.load()
# 每个doc有 page_content(文本)和 metadata(页码/来源等)
print(f"加载了 {len(docs)} 个文档片段")
print(f"第一段内容: {docs[0].page_content[:200]}")
print(f"元数据: {docs[0].metadata}")
三、Step 2:文档分块——RAG效果的天花板
分块是整个RAG流程里最容易被忽视但影响最大的一环。分得好,检索精准;分得烂,模型拿到碎片化内容直接开始编。
3.1 为什么不能不分块?
直接把整篇文档塞给模型?不行,原因有两个:
- Token限制:一篇文档可能几万字,远超模型的上下文窗口
- 检索精度:整篇文档的向量是所有内容的"平均值",查出来的结果往往不精准
3.2 六种分块策略对比
| 策略 | 原理 | 优点 | 缺点 | 推荐度 |
|---|---|---|---|---|
| 固定长度分块 | 按字数/Token数切,设重叠 | 最简单,速度快 | 可能从句子中间切断 | ⭐⭐ 原型用 |
| 递归字符分割 | 按段落→换行→句号→逗号依次切 | 保持语义完整,LangChain默认 | 不识别文档结构 | ⭐⭐⭐⭐ 通用首选 |
| 文档结构感知 | 按标题/章节/代码块切 | 保留文档结构,表格不拆散 | 需要额外解析器 | ⭐⭐⭐⭐⭐ 结构化文档 |
| 语义分块 | Embedding检测语义边界,变化大时切 | 边界最精准 | 慢,需要额外Embedding计算 | ⭐⭐⭐⭐ 叙述性文档 |
| 父子层级分块 | 小块检索,返回大块上下文 | 兼顾精度和上下文 | 存储翻倍 | ⭐⭐⭐⭐⭐ 生产推荐 |
| Agent分块 | 用LLM判断分块边界 | 质量最高 | 10-50倍成本,巨慢 | ⭐⭐⭐ 高价值文档 |
3.3 代码实战
from langchain.text_splitter import RecursiveCharacterTextSplitter
# 递归字符分割(推荐默认方案)
splitter = RecursiveCharacterTextSplitter(
chunk_size=512, # 每块最大字符数
chunk_overlap=64, # 块之间重叠字符数,防止语义断裂
separators=["\n\n", "\n", "。", "!", "?", ".", " ", ""]
)
chunks = splitter.split_documents(docs)
print(f"分块数量: {len(chunks)}")
print(f"第一块长度: {len(chunks[0].page_content)} 字符")
💡 分块大小怎么选? 没有银弹,但有几个经验值:
- 128-256 Token:适合FAQ、短问答,检索精度高但上下文少
- 512 Token:通用推荐起点,大多数场景够用
- 1024+ Token:适合长文档、需要丰富上下文的场景
⚠️ 踩坑提醒:chunk_overlap千万别设成0!我之前省这个参数,结果一个完整的API说明被切成两半,前半块在检索结果里但后半块不在,模型只能"半猜半编"。设64-128的重叠能大幅缓解这个问题。
3.4 生产推荐:父子层级分块
这是目前业界公认最实用的生产方案:
from langchain.text_splitter import RecursiveCharacterTextSplitter
# 父块(大块,用于给模型提供上下文)
parent_splitter = RecursiveCharacterTextSplitter(chunk_size=1024, chunk_overlap=64)
# 子块(小块,用于精准检索)
child_splitter = RecursiveCharacterTextSplitter(chunk_size=256, chunk_overlap=32)
parent_chunks = parent_splitter.split_documents(docs)
child_chunks = child_splitter.split_documents(docs)
# 子块存入向量数据库用于检索
# 检索到子块后,返回对应的父块给模型
四、Step 3:Embedding向量化——把文本变成数字
4.1 做什么
把每个文本块通过Embedding模型转成一个高维向量(比如768维),语义相近的文本在向量空间里距离也近。
4.2 模型选型
| 模型 | 维度 | 中文支持 | 特点 |
|---|---|---|---|
BAAI/bge-large-zh-v1.5 | 1024 | ✅ 原生中文 | 中文场景首选,MTEB榜单靠前 |
BAAI/bge-m3 | 1024 | ✅ 多语言 | 支持Dense+Sparse+Multi-vector |
text-embedding-3-small | 1536 | ✅ | OpenAI,API调用方便 |
m3e-base | 768 | ✅ 原生中文 | 轻量,本地部署快 |
💡 选型建议:中文场景直接上BGE系列,开源免费效果还好。需要多语言或和OpenAI生态打通就用text-embedding-3。
from sentence_transformers import SentenceTransformer
# 加载模型(首次运行会自动下载)
model = SentenceTransformer('BAAI/bge-large-zh-v1.5')
# 批量编码
texts = [chunk.page_content for chunk in chunks]
embeddings = model.encode(texts, show_progress_bar=True, batch_size=32)
print(f"向量形状: {embeddings.shape}") # (N, 1024)
⚠️ 踩坑提醒:查询向量和文档向量必须用同一个模型生成!** 我之前用BGE存的文档,查询时手滑用了OpenAI的Embedding,结果相似度全乱套,检索出来的东西驴唇不对马嘴。
五、Step 4:向量存储——给向量安个家
5.1 做什么
把文本块和对应的向量一起存入向量数据库,建立索引,方便后续快速检索。
5.2 存储方案选择
| 方案 | 类型 | 适用场景 |
|---|---|---|
| FAISS | 算法库 | 本地原型、算法实验 |
| LanceDB | 嵌入式数据库 | 快速Demo、中小规模 |
| Milvus | 分布式数据库 | 生产环境、大规模数据 |
| Chroma | 轻量数据库 | 本地开发、Jupyter Notebook |
| Elasticsearch | 搜索引擎 | 需要混合搜索(关键词+向量) |
5.3 代码实战(以LanceDB为例)
import lancedb
import numpy as np
# 连接本地数据库
db = lancedb.connect("./rag_vector_db")
# 准备数据:向量 + 元数据
data = []
for i, (chunk, embedding) in enumerate(zip(chunks, embeddings)):
data.append({
"id": i,
"vector": embedding.tolist(),
"text": chunk.page_content,
"source": chunk.metadata.get("source", "unknown"),
"page": chunk.metadata.get("page", 0)
})
# 创建表并建索引
table = db.create_table("knowledge_base", data=data, mode="overwrite")
table.create_index(metric="cosine", num_partitions=16)
print(f"存储完成,共 {len(table)} 条记录")
💡 元数据很重要:存向量时一定要带上来源、页码、章节等元数据。检索时可以过滤(比如只搜某个文档),生成时可以标注引用来源,用户一看就知道答案出自哪里。
六、Step 5:检索——找到最相关的知识片段
6.1 做什么
用户提问 → 问题向量化 → 在向量数据库中搜索最相似的Top-K个文本块。
6.2 检索策略
| 策略 | 说明 | 适用场景 |
|---|---|---|
| 纯向量检索 | 只用Embedding相似度 | 通用场景 |
| 混合检索 | 向量 + 关键词(BM25) | 需要精确匹配(人名/编号) |
| 重排序(Rerank) | 先粗筛Top-50,再用Cross-Encoder精排 | 生产环境推荐 |
6.3 代码实战
# 1. 用户问题向量化
query = "如何配置Nginx反向代理?"
query_vector = model.encode([query])[0].tolist()
# 2. 向量检索
results = table.search(query_vector) \
.limit(5) \
.to_pandas()
# 3. 查看检索结果
for _, row in results.iterrows():
print(f"[相似度: {1 - row['_distance']:.4f}] {row['text'][:100]}...")
print(f" 来源: {row['source']} 第{row['page']}页\n")
6.4 高级:混合检索 + Rerank
# 混合检索:向量 + 关键词过滤
results = table.search(query_vector) \
.where("source = 'nginx_docs'") \
.limit(20) \
.to_pandas()
# Rerank:用Cross-Encoder精排
from sentence_transformers import CrossEncoder
reranker = CrossEncoder('BAAI/bge-reranker-base')
pairs = [(query, row['text']) for _, row in results.iterrows()]
scores = reranker.predict(pairs)
# 按重排序分数排序,取Top-5
ranked_indices = np.argsort(scores)[::-1][:5]
final_results = [results.iloc[i] for i in ranked_indices]
⚠️ 踩坑提醒:Top-K不是越大越好!我之前设Top-20,结果塞进Prompt的内容太多,模型反而被无关信息干扰开始胡说。一般Top-3到Top-5最合适,配合Rerank效果更好。
七、Step 6:增强生成——让模型基于检索内容回答
7.1 做什么
把检索到的文本块和用户问题拼成一个Prompt,喂给大模型生成最终回答。
7.2 Prompt模板设计
prompt_template = """你是一个专业的知识问答助手。请根据以下检索到的参考资料回答用户问题。
要求:
1. 只基于提供的参考资料回答,不要编造信息
2. 如果参考资料不足以回答问题,请明确说明
3. 回答时标注信息来源
【参考资料】
{context}
【用户问题】
{question}
【回答】
"""
7.3 完整生成代码
from openai import OpenAI
# 构建上下文
context_parts = []
for _, row in final_results.iterrows():
context_parts.append(f"[来源: {row['source']}] {row['text']}")
context = "\n\n".join(context_parts)
# 填充Prompt
prompt = prompt_template.format(context=context, question=query)
# 调用大模型
client = OpenAI(api_key="your-api-key", base_url="your-base-url")
response = client.chat.completions.create(
model="qwen-plus", # 或 gpt-4o、deepseek-chat 等
messages=[
{"role": "system", "content": "你是一个专业的知识问答助手。"},
{"role": "user", "content": prompt}
],
temperature=0.1, # 低温度,减少幻觉
max_tokens=1024
)
answer = response.choices[0].message.content
print(answer)
💡 Temperature设多少? RAG场景建议0-0.3。我们是让模型基于已有内容回答,不需要"创意"。Temperature太高容易偏离检索内容开始自由发挥。我一般设0.1,既不太死板又不会乱编。
八、完整链路串联
把上面六步串起来,就是一个完整的RAG系统:
def rag_query(question, model, table, reranker=None):
"""完整RAG查询链路"""
# 1. 问题向量化
query_vector = model.encode([question])[0].tolist()
# 2. 向量检索
results = table.search(query_vector).limit(10).to_pandas()
# 3. Rerank(可选)
if reranker:
pairs = [(question, row['text']) for _, row in results.iterrows()]
scores = reranker.predict(pairs)
ranked = np.argsort(scores)[::-1][:5]
results = [results.iloc[i] for i in ranked]
# 4. 构建Prompt
context = "\n\n".join(
f"[{row['source']}] {row['text']}" for _, row in results.iterrows()
)
prompt = prompt_template.format(context=context, question=question)
# 5. 大模型生成
response = client.chat.completions.create(
model="qwen-plus",
messages=[
{"role": "system", "content": "你是专业的知识问答助手。"},
{"role": "user", "content": prompt}
],
temperature=0.1
)
return response.choices[0].message.content
# 使用
answer = rag_query("如何配置Nginx反向代理?", model, table, reranker)
print(answer)
九、RAG优化清单
从"能跑"到"好用",通常需要优化这些点:
- 数据清洗:去重、去噪、统一术语("LLM"和"大语言模型"标准化)
- 分块调优:从512 Token开始,根据检索效果调整大小和重叠
- 元数据标注:来源、时间、分类、权限,过滤和溯源全靠它
- 混合检索:向量+关键词,解决精确匹配问题(人名/编号/错误码)
- Rerank精排:粗筛Top-50→Cross-Encoder精排→Top-5,召回率+精度双提升
- 查询改写:用户问题口语化/模糊,先用LLM改写成标准查询再检索
- 父子分块:小块检索精准,大块生成有上下文
- 缓存策略:相同/相似问题直接返回缓存结果,省Token省延迟
- 评估体系:用RAGAS或自建评测集,定期测检索准确率和回答质量
最后说一句大实话:RAG效果不好,80%的问题出在数据质量和分块策略上,而不是模型不够强。与其花时间换更贵的大模型,不如把文档清洗干净、分块调好,性价比高得多