📖 本章学习目标
- 使用"假设 → 实验 → 评估 → 迭代"的方法论系统调优 RAG 系统
- 分阶段比较切分、Top-K、阈值、Embedding 和检索策略,识别参数之间的交互影响
- 通过 RAGAS 指标量化每次调优的收益
- 识别并聚焦当前 RAG 系统最大的性能瓶颈
在《第12章:RAG 系统评估与指标体系》中,我们已经建立了评估体系,接触了 Hit Rate@K、MRR、NDCG@K,也知道可以使用 Context Precision、Context Recall、Faithfulness 和 Answer Relevancy 分别观察检索、上下文和答案质量。
但指标只是测量结果,不会自动告诉我们 chunk_size 该取多少、是否值得更换 Embedding,或者增大 Top-K 会不会把更多噪声送进模型。没有评估的调优是盲目的,没有调优的评估也是无用的,两者必须结合。
本章要做的是把测量结果变成实验闭环。这里讨论标准 RAG 管道的离线效果调优。第9章已经讲过 BM25、混合检索、RRF、Multi-Query 和 MMR,第10章已经讲过重排序、阈值过滤和上下文压缩,第11章已经讲过 Prompt 与上下文组装。这里只把这些能力当作实验变量,不重复它们的实现原理。另外,文章会记录调用次数、上下文规模和离线耗时,但不展开线上缓存、并发、吞吐、流式输出或 SLO。这些内容放在第16章和第17章讨论。
一、调优方法论
系统化调优并非简单的参数试错,而是一个严谨的工程闭环。如流程图所示,整个过程由 问题诊断、假设提出、实验设计、执行评估以及参数固化五个核心步骤构成。这是一个持续迭代的循环,每一轮实验的目标都是让系统在特定指标上获得可量化的提升。
graph LR
A[问题诊断<br/>哪个环节最弱] --> B[提出假设<br/>改什么可能有效]
B --> C[设计实验<br/>如何公平对比]
C --> D[执行 + 评估<br/>指标变化]
D --> E{效果满意}
E -->|否| A
E -->|是| F[固化新参数配置]
1. 问题诊断:精准定位系统瓶颈
在着手修改任何配置之前,必须利用《第12章:RAG 系统评估与指标体系》建立的评估体系对系统进行全量扫描。通过量化数据,我们可以将复杂的系统表现归纳为以下三类典型瓶颈,从而避免盲目优化。
场景一:检索召回率低下(Hit Rate < 60%)
当系统频繁出现无法找到相关文档的情况时,说明检索模块未能有效捕获相关信息。这通常源于切分策略不当导致语义破碎,或是 Embedding 模型对领域术语的理解存在偏差。此时的优化重心应完全聚焦于检索环节,包括调整 Chunk Size、引入混合检索或优化元数据过滤。
场景二:高召回率下的低忠实度(Faithfulness < 0.8)
如果系统能够召回相关文档,但生成的答案却包含大量幻觉或与原文相悖,说明问题出在生成与后处理阶段。这往往是因为 Prompt 指令对上下文的约束力不足,或是检索结果中混杂了过多噪声,干扰了大模型的判断。优化方向应转向 Prompt 工程的精细化打磨,以及引入重排序(Rerank)机制以净化上下文。
场景三:高忠实度下的低相关性(Answer Relevancy < 0.7)
这是一种比较隐蔽的失效模式:答案虽然严格忠实于检索结果,但却答非所问,无法解决用户的实际诉求。这通常是因为用户的原始查询表述模糊,导致检索方向偏离。此时的优化重点应放在查询理解上,通过 Multi-Query 或查询改写技术,提升检索结果与用户意图的匹配度。
为了辅助这一诊断过程,我们可以编写自动化工具来快速定位短板。以下代码展示了如何通过计算核心指标,自动输出当前的系统瓶颈:
async function diagnoseBottleneck(evalResults: EvalResult[]) {
const hitRate = calculateHitRate(evalResults, 5);
const faithfulness = await calculateFaithfulness(evalResults);
const answerRelevancy = await calculateAnswerRelevancy(evalResults);
console.log(`Hit Rate @5: ${(hitRate * 100).toFixed(1)}%`);
console.log(`Faithfulness: ${faithfulness.toFixed(3)}`);
console.log(`Answer Relevancy: ${answerRelevancy.toFixed(3)}`);
if (hitRate < 0.6) {
console.log("瓶颈:检索召回率低,优先优化检索环节");
} else if (faithfulness < 0.8) {
console.log("瓶颈:忠实度低,优先优化 Prompt 和后处理");
} else if (answerRelevancy < 0.7) {
console.log("瓶颈:答案相关性低,优先优化查询和答案生成");
} else {
console.log("系统表现良好,可以继续微调");
}
}
通过这种数据驱动的诊断方式,我们可以将模糊的系统效果问题转化为具体的工程任务,从而确保每一次调优都能直击要害。
2. 实验设计:控制变量法
在调优过程中,如果同时调整 chunk_size、Top-K、Embedding模型等多个参数,将难以判断具体是哪个参数的变动导致了指标的变化。因此,在实验设计时我们可以采用分阶段控制变量的方法,建议分为单变量粗筛和小规模交互实验两个阶段。其中单变量实验适合第一轮粗筛,它能快速排除明显不合适的范围,这一阶段必须严格遵循控制变量法,每一次实验仅修改一个参数,其余配置保持与基线一致;而交互实验阶段则可以候选两个维度进行实验。
(1)单变量粗筛
// 采用控制变量法,每次仅调整一个参数
baseline: chunk_size=500, topK=5
experiment1: chunk_size=800, topK=5 // 仅调整 chunk_size
experiment2: chunk_size=500, topK=10 // 仅调整 topK
(2)小规模交互实验
chunk_size × chunk_overlap
candidateK × contextK
Embedding 模型 × 阈值校准策略
交互实验不必一开始覆盖所有组合。先从业务上有理由的组合入手,并在日志中注明组合数量和选择依据。
二、Chunk 策略调优
1. 核心参数定义
在第5章已和第6章的学习中我们已经知道,Chunk 策略直接影响文档切分的粒度,进而决定检索精度和生成质量。该策略包含两个核心参数。
- chunk_size(切分大小):决定单个片段的长度。过大导致语义稀释、噪声增多;过小则破坏语义连贯性,Embedding 无法准确表征上下文。
- chunk_overlap(重叠大小):决定相邻片段之间的重叠程度。用于保留跨片段的边界信息,避免关键内容被切断。
这两个参数需要协同调优,不能孤立看待。按照控制变量法两阶段的方式,可以先设计一个分别改变chunk_size和chunk_overlap的实验,通过快速筛选不合理的配置后再设计交互实验。
2. 实验设计
以下代码展示了如何系统性地测试不同 chunk_size 和 chunk_overlap 的组合配置,并记录完整的评估指标。需要注意的是每次改变 size 或 overlap 后,都要重新切分并重建索引。
import { RecursiveCharacterTextSplitter } from "langchain/text_splitter";
import { Chroma } from "@langchain/community/vectorstores/chroma";
interface ChunkExperimentResult {
chunkSize: number;
chunkOverlap: number;
mrr: number;
hitRate: number;
faithfulness: number;
answerRelevancy: number;
avgChunkLength: number;
totalChunks: number;
}
async function experimentChunkStrategy(
docs: Document[],
sizeOptions: number[],
overlapOptions: number[],
evalDataset: EvalSample[]
): Promise<ChunkExperimentResult[]> {
const results: ChunkExperimentResult[] = [];
// 分别遍历不同的size和overlap进行组合
for (const size of sizeOptions) {
for (const overlap of overlapOptions) {
// 跳过不合理的组合:overlap 不能大于等于 chunk_size
if (overlap >= size) continue;
console.log(`\n=== 测试 chunk_size=${size}, overlap=${overlap} ===`);
// 1. 切分文档
const splitter = new RecursiveCharacterTextSplitter({
chunkSize: size,
chunkOverlap: overlap,
});
const chunks = await splitter.splitDocuments(docs);
const avgLength = chunks.reduce((sum, c) => sum + c.pageContent.length, 0) / chunks.length;
console.log(` 切分为 ${chunks.length} 个片段,平均长度 ${avgLength.toFixed(0)} 字符`);
// 2. 建立索引
const collectionName = `exp_chunk_${size}_${overlap}`;
const vectorStore = await Chroma.fromDocuments(
chunks,
embeddings,
{ collectionName }
);
// 3. 在评估集上运行评估,runEvaluation是封装好的评估方法(参考上一章节)
const retriever = vectorStore.asRetriever({ k: 5 });
const metrics = await runEvaluation(evalDataset, retriever);
const result: ChunkExperimentResult = {
chunkSize: size,
chunkOverlap: overlap,
mrr: metrics.mrr,
hitRate: metrics.hitRate,
faithfulness: metrics.faithfulness,
answerRelevancy: metrics.answerRelevancy,
avgChunkLength: avgLength,
totalChunks: chunks.length,
};
results.push(result);
console.log(` MRR: ${metrics.mrr.toFixed(3)}`);
console.log(` Hit Rate: ${(metrics.hitRate * 100).toFixed(1)}%`);
console.log(` Faithfulness: ${metrics.faithfulness.toFixed(3)}`);
console.log(` Answer Relevancy: ${metrics.answerRelevancy.toFixed(3)}`);
// 清理临时 Collection
await deleteCollection(collectionName);
}
}
return results;
}
// 运行实验
const chunkSizes = [200, 500, 800, 1500]; // 不同的size
const overlaps = [0, 50, 100, 200]; // 不同overlap
const results = await experimentChunkStrategy(allDocs, chunkSizes, overlaps, evalDataset);
// 找出最佳配置
const bestByMRR = results.reduce((best, r) => r.mrr > best.mrr ? r : best, results[0]);
console.log(`\n最佳配置 (by MRR): chunk_size=${bestByMRR.chunkSize}, overlap=${bestByMRR.chunkOverlap}`);
3. 实验结果示例
执行后,你可以得到类似以下的实验结果。
=== 测试 chunk_size=200, overlap=0 ===
切分为 2500 个片段,平均长度 200 字符
MRR: 0.390, Hit Rate: 62.0%, Faithfulness: 0.83, Answer Relevancy: 0.70
=== 测试 chunk_size=200, overlap=50 ===
切分为 2800 个片段,平均长度 200 字符
MRR: 0.420, Hit Rate: 65.0%, Faithfulness: 0.85, Answer Relevancy: 0.72
=== 测试 chunk_size=500, overlap=0 ===
切分为 1000 个片段,平均长度 500 字符
MRR: 0.540, Hit Rate: 74.0%, Faithfulness: 0.89, Answer Relevancy: 0.78
=== 测试 chunk_size=500, overlap=50 ===
切分为 1100 个片段,平均长度 500 字符
MRR: 0.580, Hit Rate: 78.0%, Faithfulness: 0.91, Answer Relevancy: 0.81
=== 测试 chunk_size=500, overlap=100 ===
切分为 1200 个片段,平均长度 500 字符
MRR: 0.575, Hit Rate: 77.0%, Faithfulness: 0.90, Answer Relevancy: 0.80
=== 测试 chunk_size=800, overlap=100 ===
切分为 625 个片段,平均长度 800 字符
MRR: 0.550, Hit Rate: 75.0%, Faithfulness: 0.88, Answer Relevancy: 0.79
=== 测试 chunk_size=1500, overlap=200 ===
切分为 333 个片段,平均长度 1500 字符
MRR: 0.380, Hit Rate: 58.0%, Faithfulness: 0.82, Answer Relevancy: 0.70
最佳配置 (by MRR): chunk_size=500, overlap=50
MRR: 0.580, Hit Rate: 78.0%, Faithfulness: 0.91, Answer Relevancy: 0.81
切分为 1100 个片段,平均长度 500 字符
4. 结果分析
一般来讲,从实验结果中,我们可以看到chunk_size 的影响呈现明显的倒 U 型曲线,存在一个最佳平衡点,各项指标均达到峰值;当 chunk_size 过小时,片段语义不完整导致检索精度偏低;当 chunk_size 过大时,语义稀释和噪声增多使指标逐步下滑。
而chunk_overlap 的作用相对温和:适度的重叠能有效保留边界信息,显著提升 MRR;但继续增加重叠比例后指标基本持平,而片段总数增加导致存储和检索成本上升,收益明显递减。综合来看,chunk_size 的 10%-20% 作为 overlap 是当前数据集下的最优选择。
三、Top-K 与相似度阈值的联合调参
1. 核心参数定义
Top-K 和相似度阈值共同决定了有多少检索结果进入 Prompt,两者同样需要联合调参。
- Top-K:控制召回数量。K 太小可能遗漏相关信息,K 太大则引入噪声并增加 Token 消耗。
- 相似度阈值:过滤低相关度结果。阈值太低保留过多噪声,阈值太高则过滤掉相关文档。
这两个参数存在互补关系,高 K 值配合高阈值可以在保证召回率的同时控制噪声;低 K 值配合低阈值则可能导致召回不全。
2. 实验设计
以下代码展示了如何联合测试 Top-K 和相似度阈值的组合配置:
interface TopKThresholdResult {
k: number;
threshold: number;
mrr: number;
faithfulness: number;
avgTokens: number;
avgResults: number;
}
async function experimentTopKAndThreshold(
vectorStore: Chroma,
kValues: number[],
thresholds: number[],
evalDataset: EvalSample[]
): Promise<TopKThresholdResult[]> {
const results: TopKThresholdResult[] = [];
for (const k of kValues) {
for (const threshold of thresholds) {
console.log(`\n=== 测试 Top-K=${k}, 阈值=${threshold.toFixed(2)} ===`);
// 1. 自定义过滤逻辑
const filteredRetriever = {
invoke: async (query: string) => {
const allResults = await vectorStore.similaritySearchWithScore(query, k);
const filtered = allResults
.filter(([_, score]) => score >= threshold)
.map(([doc]) => doc);
return filtered;
},
};
const metrics = await runEvaluation(evalDataset, filteredRetriever);
const result: TopKThresholdResult = {
k,
threshold,
mrr: metrics.mrr,
faithfulness: metrics.faithfulness,
avgTokens: metrics.avgTokens,
avgResults: metrics.avgResults,
};
results.push(result);
console.log(` MRR: ${metrics.mrr.toFixed(3)}`);
console.log(` Faithfulness: ${metrics.faithfulness.toFixed(3)}`);
console.log(` Avg Tokens: ${metrics.avgTokens}`);
console.log(` Avg Results: ${metrics.avgResults.toFixed(1)}`);
}
}
return results;
}
// 2. 运行实验
const kValues = [3, 5, 8, 10, 15];
const thresholds = [0.5, 0.6, 0.7, 0.75, 0.8, 0.85];
const results = await experimentTopKAndThreshold(vectorStore, kValues, thresholds, evalDataset);
// 找出最佳配置(综合 MRR 和 Faithfulness)
const bestConfig = results.reduce((best, r) => {
const score = r.mrr * 0.6 + r.faithfulness * 0.4; // 加权评分
const bestScore = best.mrr * 0.6 + best.faithfulness * 0.4;
return score > bestScore ? r : best;
}, results[0]);
console.log(`\n最佳配置: Top-K=${bestConfig.k}, 阈值=${bestConfig.threshold.toFixed(2)}`);
console.log(` MRR: ${bestConfig.mrr.toFixed(3)}, Faithfulness: ${bestConfig.faithfulness.toFixed(3)}`);
3. 典型实验结果
=== 测试 Top-K=5, 阈值=0.50 ===
MRR: 0.60, Faithfulness: 0.88, Avg Tokens: 1800, Avg Results: 4.8
=== 测试 Top-K=5, 阈值=0.70 ===
MRR: 0.62, Faithfulness: 0.89, Avg Tokens: 1500, Avg Results: 3.5
=== 测试 Top-K=5, 阈值=0.75 ===
MRR: 0.63, Faithfulness: 0.90, Avg Tokens: 1200, Avg Results: 2.8
=== 测试 Top-K=5, 阈值=0.85 ===
MRR: 0.58, Faithfulness: 0.92, Avg Tokens: 800, Avg Results: 1.5
=== 测试 Top-K=10, 阈值=0.50 ===
MRR: 0.63, Faithfulness: 0.84, Avg Tokens: 2800, Avg Results: 8.2
=== 测试 Top-K=10, 阈值=0.70 ===
MRR: 0.65, Faithfulness: 0.86, Avg Tokens: 2200, Avg Results: 5.5
=== 测试 Top-K=10, 阈值=0.75 ===
MRR: 0.66, Faithfulness: 0.87, Avg Tokens: 1800, Avg Results: 4.0
=== 测试 Top-K=10, 阈值=0.85 ===
MRR: 0.60, Faithfulness: 0.90, Avg Tokens: 1200, Avg Results: 2.2
=== 测试 Top-K=15, 阈值=0.70 ===
MRR: 0.65, Faithfulness: 0.83, Avg Tokens: 3500, Avg Results: 9.0
---
所有组合实验完成,共测试 30 组配置
最佳配置: Top-K=10, 阈值=0.75
MRR: 0.66, Faithfulness: 0.87
4. 结果分析
Top-K 和相似度阈值呈现明显的互补关系。单独增加 K 值能提升召回率但会引入噪声,单独提高阈值能提升忠实度但可能召回不全;两者配合使用时,较高的 K 值配合适中的阈值可以在保证召回率的同时有效控制噪声。从实验数据来看,Top-K=10 配合阈值=0.75 是当前数据集下的最优组合,MRR 达到峰值且 Faithfulness 保持在较高水平;当 K 值继续增加到 15 时,MRR 不再提升而 Token 消耗显著增加,收益明显递减;当阈值超过 0.85 时,虽然 Faithfulness 继续提升,但 MRR 因召回不全而下降。
四、Embedding 模型选型对比
Embedding 模型直接决定检索质量的上限,不同模型在同一数据集上的表现有时是有很大差异的。2026 年的模型生态已经高度成熟,选型逻辑从看榜单分数转向了按场景分层。
| 模型 | 维度 | 部署方式 | 中文能力 | 最大长度 | 成本/1M tokens |
|---|---|---|---|---|---|
| Qwen3-Embedding-8B | 7168 (可调) | 开源自部署 | 极强 | 32K | 免费(硬件成本另算) |
| BGE-M3 | 1024 | 开源自部署 | 强 | 8192 | 免费(硬件成本另算) |
| text-embedding-3-large | 3072 (可调) | OpenAI API | 中 | 8191 | $0.13 |
| text-embedding-3-small | 1536 | OpenAI API | 中 | 8191 | $0.02 |
| Gemini Embedding 2 | 3072 (可调) | Google API | 强 | 8192 | $0.15 |
| Cohere embed-v4 | 1024 | Cohere API | 强 | 128K | $0.10 |
| GLM-Embedding-3 | 256-2048 (可调) | 智谱 API | 极强 | 8192 | 约 $0.05 |
| Qwen3-Embedding-0.6B | 1024 | 开源自部署 | 极强 | 32K | 免费(CPU 可跑) |
- 开源模型崛起:Qwen3-Embedding-8B 在 2026 年 MTEB v2 多语言榜单上以 70.58 分登顶,超越所有闭源 API;BGE-M3 凭借"稠密+稀疏+多向量"混合检索机制,成为中文 RAG 生产环境的标杆。
- 多模态成为标配:Gemini Embedding 2、Qwen3-VL-Embedding 等模型原生支持文本、图像、音视频的统一语义空间,跨模态检索能力大幅领先。
- 维度压缩技术成熟:MRL(Matryoshka Representation Learning)让向量可以优雅降维,3072 维压缩到 256 维时质量衰减可控,存储成本直接砍半。
- 长文本支持普及:Qwen3-Embedding 支持 32K 上下文,Cohere embed-v4 甚至达到 128K,长文档检索不再需要复杂的分块策略。
以上数据由AI输出,数据可能与实际有出入。
2. 实验设计
以下代码展示了如何系统性地对比不同 Embedding 模型的检索效果,同时记录索引耗时和存储成本:
interface EmbeddingExperiment {
name: string;
model: string;
dimensions: number;
costPerMillion: number;
isLocal: boolean;
}
const experimentalSetups: EmbeddingExperiment[] = [
{ name: "qwen3-8b", model: "qwen3-embedding-8b", dimensions: 1024, costPerMillion: 0, isLocal: true },
{ name: "bge-m3", model: "bge-m3", dimensions: 1024, costPerMillion: 0, isLocal: true },
{ name: "openai-large", model: "text-embedding-3-large", dimensions: 1024, costPerMillion: 0.13, isLocal: false },
{ name: "openai-small", model: "text-embedding-3-small", dimensions: 1536, costPerMillion: 0.02, isLocal: false },
{ name: "qwen3-0.6b", model: "qwen3-embedding-0.6b", dimensions: 1024, costPerMillion: 0, isLocal: true },
];
async function experimentEmbeddingModels(
docs: Document[],
setups: EmbeddingExperiment[],
evalDataset: EvalSample[]
): Promise<any[]> {
const results = [];
for (const setup of setups) {
console.log(`\n=== 测试 ${setup.name} ===`);
// 创建 Embedding 实例(本地模型或 API)
const embeddings = createEmbeddings(setup.model, setup.dimensions);
// 重建索引(耗时操作,本地模型需考虑 GPU 推理时间)
const startTime = Date.now();
const vectorStore = await Chroma.fromDocuments(
docs,
embeddings,
{ collectionName: `exp_${setup.name}` }
);
const indexingTime = (Date.now() - startTime) / 1000;
const indexSize = await getIndexSize(`exp_${setup.name}`);
console.log(` 索引耗时: ${indexingTime.toFixed(1)}s`);
console.log(` 索引大小: ${(indexSize / 1024 / 1024).toFixed(1)}MB`);
// 运行评估
const retriever = vectorStore.asRetriever({ k: 5 });
const metrics = await runEvaluation(evalDataset, retriever);
results.push({
...setup,
mrr: metrics.mrr,
hitRate: metrics.hitRate,
faithfulness: metrics.faithfulness,
indexingTime,
indexSize,
});
console.log(` MRR: ${metrics.mrr.toFixed(3)}`);
console.log(` Hit Rate: ${(metrics.hitRate * 100).toFixed(1)}%`);
console.log(` Faithfulness: ${metrics.faithfulness.toFixed(3)}`);
// 清理
await deleteCollection(`exp_${setup.name}`);
}
return results;
}
// 运行实验
const results = await experimentEmbeddingModels(allDocs, experimentalSetups, evalDataset);
// 按 MRR 排序
const sorted = results.sort((a, b) => b.mrr - a.mrr);
console.log("\n=== 模型排名 (by MRR) ===");
sorted.forEach((r, i) => {
console.log(`${i + 1}. ${r.name}: MRR=${r.mrr.toFixed(3)}`);
});
3. 选型决策建议
如前面所说,选型逻辑应该按不同场景进行分层,你可以参考下列的选型建议。
中文 RAG 生产环境:首选 BGE-M3 或 Qwen3-Embedding-8B。BGE-M3 的混合向量机制(稠密+稀疏+多向量)在中文检索上表现稳定,社区成熟、踩坑少;Qwen3-Embedding-8B 在 MTEB 多语言榜单上分数更高,但需要 A10 级别显卡部署。
快速验证 / 缺乏本地算力:OpenAI text-embedding-3-large 生态最成熟,开箱即用;如果预算有限,text-embedding-3-small 性价比极高,$0.02/百万 token 的价格适合大规模测试。
资源受限 / 边缘设备:Qwen3-Embedding-0.6B 仅需 1-2GB 显存甚至 CPU 即可运行,推理延迟约 186ms,适合轻量级本地部署。
长文档场景:Cohere embed-v4 支持 128K 上下文,Qwen3-Embedding 支持 32K,远超传统模型的 8K 限制。如果知识库包含大量法律合同、学术论文等长文档,这类模型能显著减少分块带来的语义损失。
多模态检索:如果知识库包含图表、扫描件或音视频,直接用 Qwen3-VL-Embedding(开源)或 Gemini Embedding 2(API),不要试图用纯文本模型硬扛。
重要提醒
更换 Embedding 模型需要重新索引整个知识库,不同模型的向量空间互不兼容。将索引时间记入实验成本。比如模型精度提升 5% 但索引需要 3 小时,这个权衡需要结合你的知识库更新频率来评估。
另外,MTEB 榜单分数仅供参考,真正的检验标准是:在你的私有数据集上,哪个模型让下游任务(如问答准确率)提升最明显。建议准备 20-30 个真实业务查询做 POC 测试,Recall@5 低于 80% 不建议上线。
五、检索策略的联合优化
1. 混合检索的效果
在《第9章:高级检索策略》中,我们实现了混合检索。现在用实验量化它的效果。
async function experimentRetrievalStrategies(
docs: Document[],
evalDataset: EvalSample[]
): Promise<any[]> {
const strategies = [
{ name: "纯向量检索", type: "dense" },
{ name: "混合检索 (RRF)", type: "hybrid" },
{ name: "混合检索 + MMR", type: "hybrid_mmr" },
{ name: "Multi-Query", type: "multi_query" },
];
const results = [];
for (const strategy of strategies) {
console.log(`\n=== 测试 ${strategy.name} ===`);
const retriever = createRetriever(strategy.type, docs);
const metrics = await runEvaluation(evalDataset, retriever);
results.push({ strategy: strategy.name, ...metrics });
console.log(` MRR: ${metrics.mrr.toFixed(3)}`);
console.log(` Hit Rate: ${(metrics.hitRate * 100).toFixed(1)}%`);
}
return results;
}
// 运行实验
const results = await experimentRetrievalStrategies(allDocs, evalDataset);
示例结果:
纯向量检索: MRR=0.58, Hit Rate=75%
混合检索 (RRF): MRR=0.68, Hit Rate=85%
混合检索 + MMR: MRR=0.70, Hit Rate=87%
Multi-Query: MRR=0.72, Hit Rate=90%
结果分析:
混合检索带来显著提升,这是因为关键词检索能捕获向量检索遗漏的精确匹配,RRF 融合策略能有效平衡两者的排序。MMR 进一步提升多样性,但收益相对温和,主要作用是减少结果同质化。Multi-Query 效果最好,通过 LLM 改写查询生成多个变体,显著提升召回率,但延迟也最高(3-4 倍),适合对响应时间不敏感的场景。Multi-Query + MMR 的组合在 MRR 上仅有边际提升(+1%),但延迟进一步增加,性价比不高。
推荐配置
- 通用场景:从混合检索(RRF)开始,这是性价比最高的选择。
- 高召回率需求:如果业务对召回率要求极高(如法律、医疗场景),加入 Multi-Query,但需要接受 3-4 倍的延迟增加。
- 结果同质化问题:如果检索结果过于相似,加入 MMR(λ=0.5-0.7),能有效提升多样性而不过度牺牲相关性。
- 延迟敏感场景:如果延迟是首要考虑因素,纯向量检索 + 重排序(见下一章)可能是更好的选择,延迟最低且通过重排序能弥补部分精度损失。
检索策略的选择需要结合业务场景的延迟容忍度。Multi-Query 虽然效果最好,但 450ms 的平均延迟在实时对话场景中可能不可接受。建议在实验阶段同时记录延迟指标,不要只看 MRR。
六、调优效果的量化表达
1. 调优手段对比
RAG 系统的调优不是哪个手段最强就用哪个,而是按性价比排序,从低成本的开始。以下表格汇总了常见调优手段的典型收益和成本:
| 调优手段 | 典型 MRR 提升 | 典型 Faithfulness 提升 | 成本增加 | 实施难度 | 推荐优先级 |
|---|---|---|---|---|---|
| 调整 chunk_size | +5%~15% | +3%~10% | 无 | 低 | ⭐⭐⭐⭐⭐ |
| 调整 Top-K | +3%~8% | +2%~5% | 略增 Token | 低 | ⭐⭐⭐⭐⭐ |
| 相似度阈值过滤 | +2%~5% | +5%~10% | 无 | 低 | ⭐⭐⭐⭐ |
| 引入混合检索 | +10%~25% | +5%~15% | 双索引维护 | 中 | ⭐⭐⭐⭐ |
| 升级 Embedding 模型 | +5%~20% | +5%~10% | API 成本/计算 | 中 | ⭐⭐⭐ |
| 引入 Rerank | +5%~15% | +10%~20% | API 成本 | 中 | ⭐⭐⭐ |
| Multi-Query | +8%~15% | +3%~8% | LLM 调用 | 中 | ⭐⭐ |
| MMR | +2%~5% | +2%~5% | 计算复杂度 | 低 | ⭐⭐ |
- 零成本调优被严重低估:调整
chunk_size和Top-K不需要任何额外基础设施,但 MRR 提升可达 15%,Faithfulness 提升可达 10%。很多团队一上来就换 Embedding 模型,结果发现chunk_size没调好,换了模型也没用。 - Faithfulness 提升最显著的是 Rerank:Rerank 通过交叉编码器重新打分,能有效过滤掉看似相关但实际不匹配的文档,Faithfulness 提升可达 20%。
- Multi-Query 的边际收益递减:虽然 MRR 提升明显,但 LLM 调用成本较高,且延迟增加 3-4 倍,适合对召回率要求极高的场景。
2. 推荐调优路径
按性价比从高到低排序:
(1)第一阶段:零成本快速收益: 调整 chunk_size、调整 Top-K、加入相似度阈值过滤(阈值从 0.7 开始测试)
(2)第二阶段:中等成本大收益:引入 BM25 混合检索(RRF 融合)、引入 Rerank(Top-K=20 召回 → Rerank → Top-5 返回)
(3)第三阶段:高成本精细调优:升级 Embedding 模型(需要重新索引)、Multi-Query(适合召回率要求极高的场景)
不要上来就升级 Embedding 模型。前两个阶段的调优往往能带来 20%-30% 的 MRR 提升,而升级 Embedding 的收益通常只有 5%-20%,且需要重新索引整个知识库。先把零成本的调优做到位,再考虑高成本手段。
3. 实验记录与追溯
调优是一个迭代过程,建议用结构化方式记录每次实验的结果,避免重复踩坑.
interface ExperimentLog {
date: string;
change: string; // 做了什么改动
beforeMetrics: Metrics; // 改动前的指标
afterMetrics: Metrics; // 改动后的指标
improvement: { // 提升幅度
mrr: number;
faithfulness: number;
hitRate: number;
};
conclusion: string; // 结论(保留/回退/继续测试)
}
const experimentLogs: ExperimentLog[] = [];
// 每次实验后记录
experimentLogs.push({
date: "2026-05-27",
change: "chunk_size 从 500 改为 800",
beforeMetrics: { mrr: 0.58, faithfulness: 0.91, hitRate: 0.75 },
afterMetrics: { mrr: 0.55, faithfulness: 0.88, hitRate: 0.72 },
improvement: { mrr: -0.03, faithfulness: -0.03, hitRate: -0.03 },
conclusion: "chunk_size=800 效果不如 500,回退",
});
experimentLogs.push({
date: "2026-05-28",
change: "引入 Rerank (bge-reranker-large),Top-K=20 → 5",
beforeMetrics: { mrr: 0.58, faithfulness: 0.91, hitRate: 0.75 },
afterMetrics: { mrr: 0.65, faithfulness: 0.96, hitRate: 0.78 },
improvement: { mrr: +0.07, faithfulness: +0.05, hitRate: +0.03 },
conclusion: "Rerank 显著提升 Faithfulness,保留",
});
这样做的价值在于:
- 避免重复踩坑:三个月后你忘了为什么 chunk_size=800 不行,翻看日志一目了然
- 量化调优收益:每次改动都有明确的指标变化,方便向团队或上级汇报
- 建立调优基线:随着实验积累,你会知道哪些手段在你的数据集上有效,哪些无效
建议用 Git 管理实验日志,或者用简单的 CSV/Notion 表格记录。每次调优前拍一张基线快照,调优后对比指标变化,这样能清晰看到每一步的收益。
FAQ
Q1:调优需要多少实验轮次?
通常 5-10 轮就能找到 80% 的最优配置。
- 第一轮:粗扫(chunk_size: 200/500/1000,Top-K: 3/5/10)
- 第二轮:细调(基于最佳结果上下 100 的范围)
- 第三轮:验证(多次运行取均值避免随机波动)
Q2:不同评估集的"最优参数"会不同吗?
会的。一个评估集上最优的 chunk_size 在另一个评估集上可能不是最优。这就是为什么评估集需要覆盖知识库的不同主题——避免对特定主题过拟合。
Q3:调优过程中指标波动怎么办?
小幅度波动(< 5%)是正常的,可能是:
- LLM API 的随机性
- 评估集的样本偏差
- 系统负载波动
建议:
- 多次运行取平均
- 关注趋势而非单点
- 只有显著变化(> 10%)才认为是有效的
Q4:什么时候应该停止调优?
当满足以下条件时,可以停止:
- 指标达到业务目标(如 MRR > 0.7, Faithfulness > 0.9)
- 进一步调优的收益递减(如 MRR 提升 < 2%)
- 成本超过预算(如 API 费用过高)
记住:完美是优秀的敌人。80% 的最优解通常只需要 20% 的努力。
练习
练习 1:运行 chunk_size 对比实验
对 3 种 chunk_size(300/500/800)运行完整实验,记录每种配置下的 MRR 和 Faithfulness。输出最佳配置。
验证标准:
- 有完整的实验数据
- 明确的推荐配置
- 能够解释为什么这个配置最优
练习 2:记录调优前后对比
用《第7章:基础篇实战:文档问答机器人》构建的机器人作为基线,应用本章的调优方法(至少调整 chunk_size 和 Top-K),用 RAGAS 记录调优前后的指标变化。
验证标准:
- 至少一个指标有可量化的提升(> 5%)
- 能够解释提升的原因
- 记录实验过程和结论
练习 3:对比不同 Embedding 模型
对比 text-embedding-3-small 和 text-embedding-3-large 在你的数据集上的表现。考虑成本和精度的权衡。
验证标准:
- 量化两个模型的 MRR 和 Faithfulness 差异
- 计算成本差异
- 给出推荐及理由
练习 4:建立实验追踪系统
创建一个简单的实验追踪系统,记录每次调优的参数、指标和结论。
验证标准:
- 能够保存和查询历史实验
- 能够对比不同实验的结果
- 能够生成调优报告
📚 延伸阅读
- LangSmith — LangChain 官方的 LLM 应用调试和实验平台,可以自动追踪实验结果
- MLflow — 开源机器学习实验追踪平台,可用于记录 RAG 参数实验
- The 80/20 Guide to RAG Optimization — Pinecone 的 RAG 优化实践指南,包含更多调优技巧
- Weights & Biases — 另一个流行的实验追踪平台,支持可视化和协作