RAG 工程落地全指南:分块策略、重排序调参与评估体系搭建

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 分词后再计算长度,或者直接用 tiktokencl100k_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 年最大的供应商是哪家?这家供应商的注册地在哪?"

这个问题需要两步:

  1. 先找到"甲公司 2023 年最大供应商是谁"
  2. 再找"这个供应商的注册地"

解法是 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(按需)            │
└─────────────────────────────────────────────────────┘

三个最容易踩的坑

  1. 分块切断了语义:在句子中间、段落中间切断,导致检索到的块信息不完整 → 用递归分块,优先在段落边界切。
  2. 候选集太小:只召回 Top-10,真正相关的没进候选集 → Rerank 也救不了 → 至少召回 Top-50。
  3. 忽略评估:凭感觉优化,改了也不知道效果 → 必须建立 RAGAS 基线,每次改动跑一遍评估。

最后说一句:RAG 没有银弹。文档类型不同、查询分布不同、延迟预算不同,最佳配置都不一样。关键是把评估跑起来,让数据告诉你下一步优化哪里。

有问题欢迎留言交流。如果对某个环节(比如分块调参、Rerank 模型选型、评估数据集构建)想深入了解,我可以再单独展开写一篇。


参考资料: