【Agent技术】 RAG 学习与优化:从“能跑”到“好用”的一套渐进式实践(附开源代码仓库)

0 阅读16分钟

我把 RAG 从基础实现一路优化到更稳定的检索效果。文章会讲清楚每个优化动作为什么做、在什么场景有效、以及在真实项目里要注意哪些取舍。

一、先补齐背景:什么是 RAG?为什么很多业务都绕不开它?

如果你之前没系统了解过 RAG,可以先用 3 分钟把概念对齐。

(1)RAG 是什么

RAG(Retrieval-Augmented Generation,检索增强生成)可以理解为:

  • 先查资料(检索):从你的知识库/文档库里找出与问题最相关的片段
  • 再让模型写答案(生成):把这些片段作为上下文交给大模型,让它在“证据”基础上作答

它的核心价值是:让模型回答时更像“开卷考试”,而不是只靠参数记忆。

(2)RAG 解决什么问题(以及不解决什么)

  • 适合
    • 知识更新频繁:政策、产品说明、FAQ、SOP、运维手册
    • 需要引用依据:希望答案可追溯到文档
    • 长尾问题多:无法靠固定意图/模板覆盖
  • 不太适合(或要谨慎)
    • 问题需要强推理但资料缺失:再好的检索也找不到不存在的证据
    • 权限隔离/合规很严格但 metadata 做得差:容易误召回
    • 数据噪声大且没有评估闭环:会出现“看似有理但不可信”的回答

(3)RAG vs 微调 vs 传统搜索:怎么选

  • RAG
    • 优点:知识可更新、可追溯、成本可控
    • 缺点:效果高度依赖检索链路(分块、索引、召回、融合、重排)
  • 微调(Fine-tune)
    • 优点:对固定风格/固定任务可能更稳、更快
    • 缺点:更新成本高、难追溯、容易“记错”且难纠正
  • 传统搜索(关键词检索)
    • 优点:精确匹配强、可解释性好
    • 缺点:语义泛化弱,用户问法变化大时体验差

本文主要聚焦:RAG 里最容易出问题、也最值得优化的“检索链路”在这里插入图片描述

二、这篇分享会按什么主线展开

  • 先把最小闭环跑通:分块 → 向量化 → 检索 →(生成)
  • 再定义“好不好”:相关性(relevance)与完整性(coverage/recall)
  • 最后按三条链路逐个加固
    • 数据源侧:智能分块、QA 增强、知识图谱、元数据
    • 输入侧:扩展 + 融合、抽象改写
    • 检索侧:混合检索(关键词 + 向量)

三、一张图看清:RAG 的最小闭环

基础 RAG 可以抽象成 4 步:

  1. 分块(chunking):把文档切成可检索的粒度
  2. 向量化(embedding):把 chunk 变成向量
  3. 检索(retrieval):根据用户问题在向量库里找相似 chunk
  4. 生成(generation):把检索到的 chunk 作为上下文喂给 LLM

下面开始进入主线:围绕分块、输入改写、检索融合、元数据与图谱等关键环节,逐个解释为什么它们会影响 RAG 的稳定性。

(1)架构图(最小闭环)

flowchart LR
  U[User Query] --> Q[Query Processing]
  Q --> R[Retriever]
  R --> V[(Vector Store / Faiss)]
  V --> C[TopK Chunks]
  C --> G[LLM Generate]
  G --> A[Answer]

  D[Documents] --> S[Splitter / Chunking]
  S --> E[Embeddings]
  E --> V

四、先把指标说清楚:RAG 好坏怎么看?

先给出一个我认为非常关键的判断框架:

  • 相关性(检索准确性):TopK 里是不是“问的那个点”的答案
  • 召回率(完整性):需要的关键证据是否被找全(尤其是多条件、多步骤问题)

实际做下来,一个很常见的现象是:RAG “看起来能回答”,但线上体验不稳定;根因往往就落在这两个指标的拉扯上。

我在复盘时通常会这样判断:

  • 相关性差,经常是 chunk 被切碎、query 表达与语料表达不一致、噪声文档太多
  • 召回不全,经常是 检索路径单一、query 太短/太具体、缺少多路召回与融合

这就是为什么本文的优化会沿着三条链路展开:

  • 数据源侧(让“能被检索到的东西”更像知识)
  • 输入侧(让“问法”更容易命中语料)
  • 检索侧(让召回路径更丰富、更鲁棒)

在这里插入图片描述

五、一个小工程技巧:为什么这些示例可以“纯本地”跑通

为了让你在没有外部 Embedding API Key 的情况下也能把检索链路跑通,我用了一个“教学用 embedding”:把文本 token 通过哈希映射到固定维度向量。

下面是一个最小可读版本(不要直接用于生产):

// 纯本地可运行的 Embeddings(教学演示用,不用于生产)
export class LocalHashEmbeddings {
  constructor(dim = 128) {
    this.dim = dim;
  }

  async embedDocuments(texts) {
    return texts.map((text) => this.embedText(text));
  }

  async embedQuery(text) {
    return this.embedText(text);
  }

  embedText(text) {
    const vec = new Array(this.dim).fill(0);
    const tokens = tokenize(text);

    for (const token of tokens) {
      const h = stableHash(token);
      const idx = Math.abs(h) % this.dim;
      vec[idx] += 1;
    }

    const norm = Math.sqrt(vec.reduce((sum, v) => sum + v * v, 0)) || 1;
    return vec.map((v) => v / norm);
  }
}

export function tokenize(text) {
  const normalized = String(text).toLowerCase();
  // 同时提取:连续英文/数字词 + 单个中文字符(让中文查询也能稳定命中)
  return (normalized.match(/[a-z0-9]+|[\u4e00-\u9fa5]/g) || []).filter(Boolean);
}

export function stableHash(str) {
  let hash = 0;
  for (let i = 0; i < str.length; i += 1) {
    hash = (hash * 31 + str.charCodeAt(i)) | 0;
  }
  return hash;
}

(1)LocalHashEmbeddings 的价值(仅用于演示,不要直接上生产)

它不是生产级 embedding,但它解决了做分享/学习时最常见的阻塞点:

  • 不依赖外部 API(没有 key、没有限流、没有费用)
  • 让你可以专注在 RAG 流程与优化方法,而不是被环境问题卡住

核心思路:

  • tokenize(text):用正则把英文词与中文单字拆出来
  • stableHash(token):把 token 哈希到固定维度(如 128)
  • 归一化向量:让相似度计算稳定

这会让“包含相同 token 的文本”更容易相似,从而支撑 demo 的检索对比。

六、数据源侧优化(一):智能分块 Smart Chunking(段落优先 + 句子兜底)

我做智能分块时遵循一个简单原则:优先尊重文本的自然结构(标题/段落/句子),最后才做“硬边界约束”

(1)为什么固定长度切片常常“看似能用但效果不稳”?

固定长度 + overlap 的切法(滑动窗口)在工程上简单,但有一个结构性问题:

  • 不尊重语义边界
  • 一段规则被切成两半,检索只捞到上半段/下半段,就会:
    • 相关性降低(命中的 chunk 不完整)
    • 召回率降低(必须命中两个 chunk 才够用,但 TopK 往往不够)

(2)这套示例的实现:三段式分块流程

intelligentChunkByParagraphAndSentence 里,流程很清晰:

  1. normalizeText:清洗文本,保留段落结构(双换行)
  2. 先按段落切:split(/\n{2,}/)
  3. 段落太长再按句子切:splitIntoSentences + mergeSentencesBySize
  4. 最后用 LangChain 的 RecursiveCharacterTextSplitter 做“最终边界约束”

下面是核心代码的最小可读版本(省略了依赖导入与运行入口):

function normalizeText(rawText) {
  return rawText
    .replace(/\r\n/g, "\n")
    .replace(/[ \t]+/g, " ")
    .replace(/\n{3,}/g, "\n\n")
    .trim();
}

function splitIntoSentences(paragraph) {
  return paragraph
    .split(/(?<=[。!?.!?])\s+/)
    .map((s) => s.trim())
    .filter(Boolean);
}

function mergeSentencesBySize(sentences, maxSize) {
  const merged = [];
  let current = "";

  for (const sentence of sentences) {
    const candidate = current ? `${current} ${sentence}` : sentence;
    if (candidate.length <= maxSize) {
      current = candidate;
      continue;
    }
    if (current) merged.push(current);
    current = sentence;
  }
  if (current) merged.push(current);
  return merged;
}

export async function intelligentChunkByParagraphAndSentence({
  text,
  chunkSize = 300,
  chunkOverlap = 60,
  source = "demo-source",
}) {
  const cleaned = normalizeText(text);

  const paragraphs = cleaned
    .split(/\n{2,}/)
    .map((p) => p.trim())
    .filter(Boolean);

  const semanticUnits = [];
  for (const paragraph of paragraphs) {
    if (paragraph.length <= chunkSize) {
      semanticUnits.push(paragraph);
      continue;
    }
    const sentences = splitIntoSentences(paragraph);
    semanticUnits.push(...mergeSentencesBySize(sentences, chunkSize));
  }

  // 这里通常会把 semanticUnits 包装成 Document,并写入 metadata
  // 然后交给 RecursiveCharacterTextSplitter 做最终 chunkSize/overlap 控制
  return { semanticUnits, chunkSize, chunkOverlap, source };
}
flowchart TD
  A[Raw Text] --> B[normalizeText]
  B --> C[Split by Paragraph]
  C --> D{paragraph length <= chunkSize?}
  D -- yes --> E[semantic unit]
  D -- no --> F[Split by Sentence]
  F --> G[mergeSentencesBySize]
  G --> E
  E --> H[Document + metadata]
  H --> I[RecursiveCharacterTextSplitter]
  I --> J[Final Chunks]

(3)关键工程点:separators 的顺序决定“语义优先级”

你在 splitter 里配置了:

separators: ["\n\n", "\n", "。", "!", "?", ". ", "! ", "? ", " ", ""]

这背后的原则是:

  • 从强语义边界到弱语义边界逐级回退
  • 只有在更强边界无法满足 chunkSize 时,才退到更弱边界

(4)metadata 的意义:让后续优化“有抓手”

你在语义单元阶段就写入了:

  • source
  • semanticUnitIndex
  • chunkStrategy: "paragraph-sentence"

真实项目里,建议至少要能追溯:

  • 文档 ID / 标题 / 章节
  • chunk 在文档中的位置
  • chunk 生成策略版本(以后你换策略要能回溯)

七、数据源侧优化(二):文档 → 问答对(QA Pair)增强

“QA 增强”的直觉很朴素:

  • 用户更常用问句表达需求
  • 而知识库更常是陈述句/规则条款

所以我会把 chunk 转成若干“可能的问法”,让向量库里同时存在“陈述表达”和“问句表达”。

(1)为什么“QA 增强”能提升检索相关性?

很多语料是“陈述式”的:

  • 文档写:退款通常在3个工作日内原路返回
  • 用户问:退货一般多久到账?

问法与语料表达不一致时,纯向量检索可能没那么稳(尤其是 embedding 较弱、或语料短小)。

QA 增强做的是:

  • 把 chunk 包装成更像“用户会问的问题”
  • 让向量库里出现更多“问句表达”

(2)这份示例的做法

  • 先用 RecursiveCharacterTextSplitter 切 chunk
  • generateQaPairsFromChunks 用规则生成问句:
    • 关于${title},核心规则是什么?
    • ${title}有什么注意事项?
    • 如何理解:${title}?
  • 建两份索引对比:
    • A:仅原始 chunk
    • B:原始 chunk + synthetic QA

关键逻辑其实就一段:从 chunk 生成 QA 文本,并把它当成“可检索文档”一并入库。

function generateQaPairsFromChunks(chunks) {
  const qaDocs = [];

  for (const chunk of chunks) {
    const title = chunk.metadata?.title || "未命名文档";
    const answer = chunk.pageContent;

    const questions = [
      `关于${title},核心规则是什么?`,
      `${title}有什么注意事项?`,
      `如何理解:${title}?`,
    ];

    for (const q of questions) {
      qaDocs.push({
        pageContent: `问题:${q}\n答案:${answer}`,
        metadata: {
          ...chunk.metadata,
          docType: "synthetic_qa",
          syntheticQuestion: q,
        },
      });
    }
  }

  return qaDocs;
}
flowchart LR
  D[Docs] --> S[Splitter]
  S --> C[Chunks]
  C -->|index| V1[(Base Vector Store)]
  C --> Q[Generate QA Docs]
  Q -->|index| V2[(Enhanced Vector Store)]

  U[Query] --> V1
  U --> V2

(3)生产注意事项(很重要)

  • 合成 QA 一定会引入噪声:
    • 问句过泛,会把检索“拉向泛化 chunk”
    • 问句过多,会稀释向量空间、增大索引
  • 工程建议:
    • 用高质量 LLM 生成 + 人工抽检
    • 对 QA 样本打 docType: synthetic_qa,生成时可加权或筛选

八、数据源侧优化(三):知识图谱 RAG(Graph + Vector)

当问题更像“关系查询”(适用/不适用/时效/条件)时,我会考虑把规则抽成三元组(实体-关系-实体/属性),用“图谱事实”补强上下文结构化程度。

(1)什么时候“图谱”比纯向量更稳?

当问题是 关系型/规则型,例如:

  • 适用条件:退款政策适用哪些商品?
  • 不适用条件:哪些不支持无理由退货?
  • 时效:到账时效是多少?

这种问题的答案往往是结构化事实(实体-关系-实体/属性)。向量检索能找到相关段落,但不一定能稳定、完整地组织成结构化答案。

(2)这份示例做了什么

  • 手写一个教学三元组集合 TRIPLES
  • buildGraphIndex 把实体/关系词映射到三元组列表
  • graphRetrievetokenize(query) 做命中召回
  • triplesToDocuments 把三元组变成可用于生成的“事实文本”
  • 与向量召回融合:fused = [...graphDocs, ...vectorHits]

下面是一个教学版的最小实现(用 token 命中来做图召回,重点是流程):

const TRIPLES = [
  ["退款政策", "适用", "7天无理由"],
  ["退款政策", "不适用", "生鲜商品"],
  ["退款政策", "不适用", "定制商品"],
  ["退款政策", "到账时效", "3个工作日"],
  ["VIP会员", "享受", "95折"],
  ["VIP会员", "享受", "快速退款通道"],
];

function buildGraphIndex(triples) {
  const index = new Map();
  const add = (key, triple) => {
    if (!index.has(key)) index.set(key, []);
    index.get(key).push(triple);
  };
  triples.forEach((triple) => {
    const [s, p, o] = triple;
    add(s, triple);
    add(p, triple);
    add(o, triple);
  });
  return index;
}

function graphRetrieve(query, graphIndex, tokenize) {
  const hits = [];
  const qTokens = tokenize(query);

  for (const [key, triples] of graphIndex.entries()) {
    if (qTokens.some((t) => key.toLowerCase().includes(t) || t.includes(key.toLowerCase()))) {
      hits.push(...triples);
    }
  }

  const dedup = new Map();
  hits.forEach((triple) => dedup.set(triple.join("|"), triple));
  return [...dedup.values()];
}

function triplesToContext(triples) {
  return triples.map((t, i) => `图谱事实${i + 1}${t[0]} - ${t[1]} - ${t[2]}`).join("\n");
}
flowchart TD
  U[User Query] --> T[tokenize]
  T --> GR[Graph Retrieve]
  GR --> GD[Graph Facts as Docs]

  U --> VR[Vector Retrieve]
  VR --> VD[Vector Docs]

  GD --> F[Fuse Context]
  VD --> F
  F --> G[Generate Answer]

(3)生产实践建议

  • 图谱不一定要“重型图数据库”,可以先从:
    • 规则表、配置表
    • FAQ 的结构化字段
    • 商品/订单/政策的结构化数据
  • 图谱上下文与向量上下文要分区(提示词里标注来源),便于模型引用与去冲突

九、输入侧优化(一):Query Expansion + RRF 融合

输入侧优化里,我最常先做的是 query expansion:把一个问题扩展成多种问法/角度,然后多路检索,再把结果融合。

(1)为什么要扩展 query?

用户问题往往只是一种表达方式,而语料可能用另一种表达:

  • 用户:会员退款有什么规则?
  • 语料:VIP会员享受快速退款通道

Query expansion 的目标:

  • 生成多个“等价/近义”的子查询
  • 多路检索提高覆盖

(2)RRF(Reciprocal Rank Fusion)是什么?

RRF 是一种非常常见的“多路排序融合”方法:

  • 每个检索器输出一个排序列表
  • 最终分数是各路排名的倒数之和:
    • score += 1 / (k + rank)

下面是一个可直接复用的 RRF 融合器(按 id 去重融合多路排序结果):

// rankedLists: Array<Array<{id: string, text: string}>>
export function rrfFuse(rankedLists, k = 60) {
  const scoreMap = new Map();

  rankedLists.forEach((list) => {
    list.forEach((item, rankIndex) => {
      const add = 1 / (k + rankIndex + 1);
      scoreMap.set(item.id, (scoreMap.get(item.id) || 0) + add);
    });
  });

  const merged = [];
  for (const [id, score] of scoreMap.entries()) {
    const found = rankedLists.flat().find((x) => x.id === id);
    if (found) merged.push({ ...found, score });
  }

  return merged.sort((a, b) => b.score - a.score);
}

工程上可以用现成的 Ensemble/RRF 实现(很多框架都内置),也可以像上面一样自己写一个轻量融合器:关键是多路召回 + 融合这个范式。

flowchart LR
  U[User Query] --> E[Expand to Sub-Queries]
  E --> R1[Retriever q1]
  E --> R2[Retriever q2]
  E --> R3[Retriever q3]
  R1 --> L1[Ranked List 1]
  R2 --> L2[Ranked List 2]
  R3 --> L3[Ranked List 3]
  L1 --> RRF[RRF Fuse]
  L2 --> RRF
  L3 --> RRF
  RRF --> TopK[Final TopK Docs]

(3)生产实践建议

  • Expansion 最好由 LLM 生成,但要控制:
    • 子查询数量(常见 3-8)
    • 子查询多样性(同义改写 + 角度扩展)
    • 子查询安全(避免引导越权/注入)
  • RRF 的优势:
    • 对分数标定不敏感(只看 rank)
    • 多路融合稳定,工程上“很抗脏”

十、输入侧优化(二):具体问题 → 抽象查询(Abstraction Rewrite)

另一个很“便宜但有效”的手段是:把过于具体、带噪声的问法做抽象化,让它更容易命中“规则型文档”。

(1)这个优化解决什么问题?

当用户问得极其具体时:

  • 带订单号:ORD003
  • 带时间状语:昨天
  • 带口语:能不能、怎么

这些词对“规则类文档”往往是噪声,反而会干扰召回。

(2) demo 实现

abstractQuery(query) 用规则把具体信息替换成抽象槽位:

  • ORD\d+订单
  • 昨天/今天/...时间
  • 能不能/怎么/如何规则
  • 并且如果包含“退”,追加 退款规则 退货条件

最小实现如下(规则只是示例,生产中更常用 LLM 做抽象改写):

function abstractQuery(query) {
  const rules = [
    { pattern: /(ORD\d+|订单\s*\d+)/gi, replace: "订单" },
    { pattern: /(昨天|今天|明天|这周|下周|本月|下月)/g, replace: "时间" },
    { pattern: /(能不能|可不可以|怎么|如何)/g, replace: "规则" },
  ];

  let rewritten = query;
  rules.forEach((r) => {
    rewritten = rewritten.replace(r.pattern, r.replace);
  });

  if (rewritten.includes("退")) {
    rewritten = `${rewritten} 退款规则 退货条件`;
  }

  return rewritten.replace(/\s+/g, " ").trim();
}

生产中这一步通常由 LLM 来做(更强的语义抽象);规则版示例的价值在于你可以直观看到“去掉具体噪声词后,检索更容易命中规则”。

十一、检索侧优化:混合检索(关键词 + 向量)

检索侧我很推荐尽早把“单路向量检索”升级成“混合检索”:关键词负责精确,向量负责语义。

(1)为什么混合检索很常用?

向量检索擅长语义相似,但对:

  • 数字、型号、专有名词
  • 精确短语匹配

可能不够稳。

关键词检索(BM25/TF-IDF/甚至简单词频)则刚好相反:

  • 精确匹配强
  • 语义泛化弱

所以混合检索通常能获得更平衡的效果。

(2)你的 demo 做法:加权融合

你实现了:

  • keywordRetrieve:对每个 doc 计算 keywordScore(query, doc.pageContent)
  • vectorRetrieve:向量召回并用 rank 近似映射分数 1/(idx+1)
  • hybridFuse:归一化后加权
    • final = alpha * keyword_norm + beta * vector_norm
    • demo 默认 alpha=0.2, beta=0.8

下面给出一个最小的融合实现(关键词分数 + 向量分数分别归一化,再线性加权):

function hybridFuse(keywordList, vectorList, alpha = 0.2, beta = 0.8) {
  const map = new Map();

  const maxKeyword = Math.max(...keywordList.map((x) => x.keyword), 1e-9);
  const maxVector = Math.max(...vectorList.map((x) => x.vector), 1e-9);

  keywordList.forEach((item) => {
    if (!map.has(item.id)) {
      map.set(item.id, { id: item.id, title: item.title, text: item.text, keyword: 0, vector: 0 });
    }
    map.get(item.id).keyword = item.keyword / maxKeyword;
  });

  vectorList.forEach((item) => {
    if (!map.has(item.id)) {
      map.set(item.id, { id: item.id, title: item.title, text: item.text, keyword: 0, vector: 0 });
    }
    map.get(item.id).vector = item.vector / maxVector;
  });

  const fused = [];
  for (const value of map.values()) {
    const score = alpha * value.keyword + beta * value.vector;
    fused.push({ ...value, score });
  }

  return fused.sort((a, b) => b.score - a.score);
}
flowchart LR
  U[Query] --> KW[Keyword Retrieve]
  U --> VE[Vector Retrieve]
  KW --> KWL[Keyword Ranked List]
  VE --> VEL[Vector Ranked List]
  KWL --> F[Normalize + Weighted Fuse]
  VEL --> F
  F --> TopK[Final TopK]

(3)生产实践建议

  • 关键词检索建议用成熟实现(BM25 / Elastic / OpenSearch)
  • 融合策略从易到难:
    • 线性加权(你 demo 的方式)
    • RRF(更“分数无关”)
    • Learning-to-Rank(需要标注数据)

十二、数据源侧优化(四):元数据(Metadata)过滤与加权

当语料混杂(官方/社区、版本新旧、适用人群不同)时,metadata 往往是“让 RAG 稳起来”的分水岭:先过滤掉不该参与竞争的候选,再检索/重排。

(1)为什么 metadata 能显著提升稳定性?

在真实业务里,语料往往混杂:

  • 官方政策 vs 社区帖子
  • 新版本 vs 老版本
  • VIP 专属 vs 全量用户

如果你让检索在“全量语料”里自由竞争,噪声来源很容易在某些 query 上跑到前面。

(2)你的 demo:过滤后再建索引

  • buildDocsWithMetadata() 构造 docs,包含:
    • topicaudiencechannelupdatedAt
  • filterByMetadata 做过滤:
    • 只看 topic=refund
    • 只看官方 channel in [official]
    • 只看 updatedAfter >= 2025-01-01
  • 过滤后再 FaissStore.fromDocuments(filteredDocs, embeddings)

最小可读实现如下:

function filterByMetadata(docs, { topic, channels, updatedAfter }) {
  return docs.filter((doc) => {
    const byTopic = topic ? doc.metadata.topic === topic : true;
    const byChannel = channels?.length ? channels.includes(doc.metadata.channel) : true;
    const byTime = updatedAfter ? new Date(doc.metadata.updatedAt) >= new Date(updatedAfter) : true;
    return byTopic && byChannel && byTime;
  });
}

// 用法示例:只看官方渠道 + 2025年后的文档 + 退款主题
const filteredDocs = filterByMetadata(allDocs, {
  topic: "refund",
  channels: ["official"],
  updatedAfter: "2025-01-01",
});

这是非常典型的工程做法:先缩小候选集合,再做昂贵/复杂的向量检索与重排。

(3)生产实践建议

  • 优先做“硬过滤”:
    • 可信来源、时间版本、租户隔离、权限控制
  • 再做“软加权”:
    • updatedAt 新的文档加分
    • official 加分

十三、把这些优化串起来:我会怎么把它落到真实项目里

如果把上面的优化当成“技能点”,真实项目里更需要的是一个可执行的落地顺序。这里给出一个我比较推荐的优先级(从易到难、从低成本到高收益):

  1. 智能分块 + metadata(最推荐先做)
  2. 混合检索(关键词 + 向量)
  3. 输入改写(抽象 + 扩展)
  4. 多路融合(RRF / Ensemble)
  5. 数据增强(QA Pair)
  6. 知识图谱(规则/关系型问题效果很明显)

这套顺序背后的原则是:

  • 先减少“切碎/误召回/噪声”(分块与 metadata 往往是最高性价比)
  • 再扩展召回路径(混合检索、扩展查询、融合)
  • 最后上“结构化知识与数据增强”(QA、图谱会带来更大系统复杂度)
flowchart TD
  L0[Baseline: fixed chunk + vector search] --> L1[Smart Chunking]
  L1 --> L2[Metadata filter/boost]
  L2 --> L3[Hybrid Search]
  L3 --> L4[Query Rewrite: abstract]
  L4 --> L5[Query Expansion + RRF]
  L5 --> L6[QA Pair augmentation]
  L6 --> L7[Knowledge Graph RAG]

十四、小结

这套渐进式优化的价值在于:

  • 每个优化点都能单独跑通、可对比、能复盘动机
  • 用本地 embedding 避开外部依赖,把注意力放在“RAG 检索链路怎么优化”上
  • 覆盖了 RAG 最常见的三大类优化:
    • 数据源侧(chunk/metadata/graph/QA)
    • 输入侧(rewrite/expansion)
    • 检索侧(hybrid/fusion)

如果接下来要把这套 demo 往“生产版”推进,通常最先补的不是更多技巧,而是把“效果上限”和“可控性”补齐:

  • 真实 embedding + reranker(提升相关性上限)
  • metadata 的权限/租户隔离与版本控制(避免误召回)
  • 评估集与自动化指标(让优化从“体感”变成“可量化”)

十五、开源代码

最后这里放一下适合AI Agent开发学习的开源代码库: github.com/mingle98/AI…