RAG 从入门到调优:分块、重排序与评估的工程实践指南
一篇不讲空话、只讲实操的 RAG 技术笔记
一、RAG 是什么?先讲个故事
去年我接手了一个项目:给某律所做一个法律文档问答系统。律师们的要求很直接------上传一份 200 页的合同,问任何条款相关问题,系统要给出准确答案,并且必须标注出处。
当时团队里有人提议:"要不咱们微调一个法律大模型?"
我算了一笔账:收集清洗高质量法律语料至少 3 个月,租用 8 卡 A100 训练 2 周,光算力成本就奔着 50 万去了,还不算人工标注和调参的时间。而律师的合同模板每季度都在更新,微调模型根本追不上。
最后我们用了 RAG(Retrieval-Augmented Generation),2 周上线,成本不到 5 万。
RAG 的本质就是开卷考试:不让 LLM 死记硬背所有知识,而是给它配一本随时可以翻的参考书。用户提问 → 从参考书里找到相关段落 → LLM 基于这些段落组织答案。
这套流程有三个核心环节:存得对 (分块)、找得准 (检索 + 重排)、评得好(评估)。下面逐一拆解,每个环节都给出具体参数和代码。
二、分块(Chunking):检索质量的第一道关
业内有个共识:RAG 的效果 70% 靠检索,30% 靠生成。而检索质量的基础就是分块。
分块没做好的后果我亲身经历过:有一次把 300 页的技术手册按 512 字符固定切分,结果用户问"内存溢出怎么处理",检索回来的块里只有一个片段提到"内存",上下文完全断裂,LLM 只能胡编------垃圾进,垃圾出。
2.1 五种分块策略对比
| 策略 | 实现方式 | 适用场景 | 注意事项 |
|---|---|---|---|
| 固定分块 | 按固定 token/字符切,带重叠 | 日志、纯文本、快速验证 | 重叠长度一般设 10%-20% |
| 递归分块 | 按段落→句子层级递归切 | 有结构的文档(论文、报告) | LangChain 的 RecursiveCharacterTextSplitter 就是典型实现 |
| 语义分块 | 句子嵌入相似度变化处切分 | 法律文书、科研论文 | 阈值需调试,一般 0.3-0.5 |
| 结构化分块 | 按标题、表格、列表边界切 | HTML、Markdown、维基 | 需配合文档解析器 |
| 延迟分块 | 先存整段,查询时动态切 | 大型报告、长文档 | 需要长上下文模型支持 |
2.2 我们的选择与参数
法律文档的特点是层级结构清晰 (编→章→节→条→款),所以我们采用了结构化分块 + 递归分块的组合策略:
python
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain.document_loaders import PyPDFLoader
# 1. 先按章节结构提取(用文档解析库获取标题层级)
# 2. 对每个章节内用递归分块
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=800, # 每块 800 token(约 600 中文字符)
chunk_overlap=200, # 重叠 200 token,保证上下文连贯
separators=["\n\n", "\n", "。", "!", "?", ";", ",", " ", ""],
length_function=len,
)
chunks = text_splitter.split_documents(documents)
为什么选 800/200? 我们做了对比实验:
| chunk_size | overlap | 召回率(Top-10) | 答案完整度 |
|---|---|---|---|
| 256 | 64 | 62% | 差,信息碎片化 |
| 512 | 128 | 78% | 中等 |
| 800 | 200 | 89% | 好 |
| 1200 | 300 | 91% | 好但浪费 token,响应慢 |
800 是性价比拐点,再大收益递减。
踩坑提醒 :中文场景别直接用英文 tokenizer 按空格切,中文"字"和"词"的边界不同。建议用 jieba 分词后再计算长度,或者直接用 tiktoken 的 cl100k_base 编码器统一处理。
2.3 语义分块实战(高精度场景)
如果预算充足、对精度要求极高(比如医疗、法律),可以上语义分块:
python
import numpy as np
from sentence_transformers import SentenceTransformer
model = SentenceTransformer('BAAI/bge-small-zh-v1.5')
def semantic_chunking(text, threshold=0.4):
sentences = text.split('。') # 简化示例
embeddings = model.encode(sentences)
chunks = []
current_chunk = [sentences[0]]
for i in range(1, len(sentences)):
sim = np.dot(embeddings[i], embeddings[i-1]) / (
np.linalg.norm(embeddings[i]) * np.linalg.norm(embeddings[i-1])
)
if sim < threshold:
chunks.append('。'.join(current_chunk))
current_chunk = [sentences[i]]
else:
current_chunk.append(sentences[i])
return chunks
这个 threshold 需要根据你的文档做网格搜索,一般在 0.3~0.5 之间。低阈值切得更细,高阈值保留更多上下文。
三、检索与重排序(Rerank):从"找得快"到"找得准"
3.1 双塔检索(Bi-Encoder):快速海选
向量检索本质是把 Query 和 Document 分别编码成向量,然后算余弦相似度。它的优势是快------文档向量可以提前算好存起来,Query 来了只需要算一次编码,然后在向量索引里做 ANN(近似最近邻)搜索。
python
from sentence_transformers import SentenceTransformer
import chromadb
# 初始化嵌入模型
embed_model = SentenceTransformer('BAAI/bge-small-zh-v1.5')
# 存入向量库(以 ChromaDB 为例)
collection = client.create_collection("legal_docs")
collection.add(
embeddings=embed_model.encode(chunk_texts).tolist(),
documents=chunk_texts,
ids=[f"chunk_{i}" for i in range(len(chunk_texts))]
)
# 检索:先粗筛 Top-50
query_vec = embed_model.encode("合同中违约金条款的上限是多少?")
results = collection.query(query_embeddings=[query_vec], n_results=50)
这里的关键是 Top-K 的选择 。我们之所以先召回 50 条,是为了保证召回率------宁可多捞一些不相关的,也不能漏掉真正相关的。因为双塔模型的向量压缩会丢失细粒度语义信息,Top-10 里可能漏掉真正相关的那篇文档。
向量索引的 ANN 算法(HNSW/IVF/PQ)负责在百万级数据里毫秒级完成这个粗筛,原理不再展开,只需记住:索引参数影响的是速度/内存/精度的三角平衡。
3.2 精排(Cross-Encoder):为什么必须加这一步?
双塔检索的问题是:Query 和 Document 在编码时互相看不到对方。所有语义信息被压缩进一个 768 维的向量里,会丢失很多细节。
举个例子,用户问:"Python 大文件内存溢出怎么处理?"
双塔召回的 Top-10 里可能混进:
- "Java 大文件处理最佳实践"(主题相似但语言不对)
- "Python 内存管理机制详解"(语言对但没说大文件)
- "Python 大文件逐行读取"(这才是真正相关的)
双塔区分不出这些细微差别,因为它在编码 "Python 内存管理" 和 "Python 大文件逐行读取" 时的向量差异不够大。
这时候需要 Cross-Encoder 重排序 :把 Query 和每篇 Document 拼在一起 送进模型,让模型做全注意力交互,输出一个相关性分数。
python
from FlagEmbedding import FlagReranker
# BGE 官方重排模型,中文效果很好
reranker = FlagReranker('BAAI/bge-reranker-large')
def rerank(query, documents, top_n=5):
pairs = [[query, doc] for doc in documents]
scores = reranker.compute_score(pairs) # 返回相关性分数
# 按分数降序排列
sorted_pairs = sorted(zip(documents, scores), key=lambda x: x[1], reverse=True)
return sorted_pairs[:top_n]
Cross-Encoder 慢在哪?每一对 (Query, Document) 都要完整过一遍 Transformer,50 条候选就要做 50 次推理。所以我们只在 50 条候选上做精排,最后取 Top-5 喂给 LLM------这就是 "粗筛 + 精排"的级联架构。
3.3 工程调优:候选集大小选多少?
| 候选集大小 | Rerank 耗时 | 最终答案准确率 | 说明 |
|---|---|---|---|
| 10 | 0.3s | 72% | 可能漏召回,巧妇难为无米之炊 |
| 50 | 0.8s | 89% | 推荐,性价比最高 |
| 100 | 1.5s | 91% | 收益递减,延迟翻倍 |
| 200 | 3.0s | 92% | 延迟不可接受 |
建议:用离线评估集画一条"候选集大小 vs 召回率"曲线,找到拐点------我们的是 50。
3.4 混合检索(Hybrid Search):补上关键词匹配的短板
纯向量检索有个弱点:对专有名词、编号、缩写不敏感。比如用户搜"第 27 条",向量检索可能把它泛化成"法律条款"的语义,召不回精确的那一条。
解法是混合检索:同时做向量检索 + BM25 关键词检索,然后用 RRF(Reciprocal Rank Fusion)合并结果。
python
from rank_bm25 import BM25Okapi
def hybrid_search(query, vector_results, bm25_results, k=60):
"""
RRF 融合算法:score = sum(1 / (k + rank))
"""
scores = {}
for rank, doc in enumerate(vector_results, 1):
scores[doc.id] = scores.get(doc.id, 0) + 1 / (k + rank)
for rank, doc in enumerate(bm25_results, 1):
scores[doc.id] = scores.get(doc.id, 0) + 1 / (k + rank)
return sorted(scores.items(), key=lambda x: x[1], reverse=True)[:10]
实际效果:混合检索在处理"条款编号"、"产品型号"、"日期"这类查询时,召回率比纯向量检索高出 15-20%。
四、评估(Evaluation):没有度量就没有优化
很多团队搭完 RAG 就上线,结果 Bad Case 层出不穷。RAG 必须做离线评估,否则你根本不知道改哪里有效。
4.1 RAGAS 评估框架
我们使用 RAGAS(RAG Assessment),它用 LLM 自动计算三个核心指标:
| 指标 | 含义 | 计算公式(简化) |
|---|---|---|
| Context Relevancy | 检索到的上下文有多少是相关的? | 相关句子数 / 总句子数,用 LLM 判定每句是否相关 |
| Faithfulness | 答案是否严格基于上下文? | (总陈述数 - 与上下文矛盾的陈述数) / 总陈述数,用 NLI 模型或 LLM 判定 |
| Answer Relevancy | 答案有没有跑题? | 生成答案和标准答案的语义相似度 |
python
from ragas import evaluate
from ragas.metrics import faithfulness, answer_relevancy, context_relevancy
dataset = {
"question": ["合同中违约金条款的上限是多少?"],
"answer": ["根据合同第 12.3 条,违约金上限为合同总金额的 20%..."],
"contexts": [["合同第 12.3 条规定:违约金不超过合同总金额的 20%..."]],
"ground_truth": ["违约金上限为合同总金额的 20%"]
}
result = evaluate(dataset, metrics=[
faithfulness, answer_relevancy, context_relevancy
])
print(result)
我们的基线数据(优化前 vs 优化后):
| 指标 | 固定分块(512) | 结构化+递归(800) | 加 Rerank 后 |
|---|---|---|---|
| Context Relevancy | 0.52 | 0.71 | 0.83 |
| Faithfulness | 0.61 | 0.78 | 0.91 |
| Answer Relevancy | 0.68 | 0.79 | 0.87 |
分块优化 + Rerank 让三项指标都提升了 20% 以上。
4.2 人工评估:别迷信自动指标
RAGAS 指标不能完全替代人工。我们建立了 Bad Case 追踪表,每周抽样 50 个问答做人工标注:
- ✅ 完全正确
- ⚠️ 部分正确(信息不全或轻微错误)
- ❌ 错误(幻觉或答非所问)
一个容易忽略但致命的 Bad Case:RAG 返回了正确信息,但 LLM 没用到。这种情况 Faithfulness 会暴露出来(答案与上下文不一致),但根源往往是 Prompt 写得不好,而不是检索问题。
五、进阶玩法:什么时候需要多跳检索?
基础 RAG 只能回答"单跳"问题------检索一次就能拿到答案。但有些问题需要多步推理:
"甲公司 2023 年最大的供应商是哪家?这家供应商的注册地在哪?"
这个问题需要两步:
- 先找到"甲公司 2023 年最大供应商是谁"
- 再找"这个供应商的注册地"
解法是 Agentic RAG:让 LLM 做任务规划,自动拆解成多步检索。
python
# 伪代码示例
def agentic_rag(query):
steps = llm.plan(query) # LLM 拆解成子任务
for step in steps:
if step.type == "retrieve":
context = retrieve(step.query)
elif step.type == "reason":
context = llm.reason(step.context)
return llm.generate(steps, context)
这种模式成本更高(多次 LLM 调用),但对于复杂问答场景,效果提升显著。
六、总结:一张 RAG 调优路线图
┌─────────────────────────────────────────────────────┐
│ RAG 调优优先级 │
├─────────────────────────────────────────────────────┤
│ 1. 分块策略 → 先跑通,再优化 chunk_size │
│ 2. 嵌入模型 → bge-small 起步,换 large 提精度 │
│ 3. 检索数量 → Top-50 → Rerank → Top-5 │
│ 4. 混合检索 → 加 BM25,对付专有名词 │
│ 5. 评估闭环 → RAGAS + 人工抽检,持续迭代 │
│ 6. 高级玩法 → 多跳 / Agentic(按需) │
└─────────────────────────────────────────────────────┘
三个最容易踩的坑:
- 分块切断了语义:在句子中间、段落中间切断,导致检索到的块信息不完整 → 用递归分块,优先在段落边界切。
- 候选集太小:只召回 Top-10,真正相关的没进候选集 → Rerank 也救不了 → 至少召回 Top-50。
- 忽略评估:凭感觉优化,改了也不知道效果 → 必须建立 RAGAS 基线,每次改动跑一遍评估。
最后说一句:RAG 没有银弹。文档类型不同、查询分布不同、延迟预算不同,最佳配置都不一样。关键是把评估跑起来,让数据告诉你下一步优化哪里。
有问题欢迎留言交流。如果对某个环节(比如分块调参、Rerank 模型选型、评估数据集构建)想深入了解,我可以再单独展开写一篇。
参考资料:
- LangChain 官方文档 - RAG 教程
- RAGAS 论文:https://arxiv.org/abs/2309.15217
- BGE 模型系列:https://github.com/FlagOpen/FlagEmbedding
- Anthropic - Contextual Retrieval 指南