深入浅出RAG——第13章:RAG 系统调优实战

1 阅读13分钟

📖 本章学习目标

  • 使用"假设 → 实验 → 评估 → 迭代"的方法论系统调优 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-8B7168 (可调)开源自部署极强32K免费(硬件成本另算)
BGE-M31024开源自部署强8192免费(硬件成本另算)
text-embedding-3-large3072 (可调)OpenAI API中8191$0.13
text-embedding-3-small1536OpenAI API中8191$0.02
Gemini Embedding 23072 (可调)Google API强8192$0.15
Cohere embed-v41024Cohere API强128K$0.10
GLM-Embedding-3256-2048 (可调)智谱 API极强8192约 $0.05
Qwen3-Embedding-0.6B1024开源自部署极强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 — 另一个流行的实验追踪平台,支持可视化和协作