RAG 系统搭建实战

从文档清洗、切块、向量检索、重排序到回答生成与评测闭环,完整搭建一个可落地的 RAG 系统。

难度

进阶

阅读时长

约 125 分钟

更新日期

2026/03/24

主题

RAG / 检索增强 / 向量检索 / 重排序

先修知识

LLM 推理服务搭建基础 Prompt 设计常识JSON 与 Python 基础

学习目标

读完这篇教程后,你应该能:

  1. 说清楚一个 RAG 系统从数据进入到答案返回的完整链路。
  2. 理解切块、召回、重排序、上下文组装分别影响什么指标。
  3. 用一套最小代码把“知识库问答”原型真正跑起来。
  4. 判断 RAG 问题到底出在检索、提示词、上下文利用,还是模型本身。

如果你已经读过 RAG 综述,这篇文章会把论文里的方法图谱落到可执行的工程步骤上。

为什么很多应用先做 RAG,而不是先微调

当团队第一次想把大模型接进业务时,最常见的诉求并不是“让模型变成一个更强的通才”,而是:

  • 让它知道企业内部文档
  • 让它回答基于最新知识的问题
  • 让它尽量减少瞎编
  • 让内容更新不必每次都重训模型

这正是 RAG 最适合介入的场景。它的核心思想不是把知识“塞进参数”,而是让模型在回答前先检索外部知识,再基于这些证据生成答案。

这会带来几个非常现实的好处:

  • 知识更新可以通过更新索引完成,不一定要重新训练。
  • 回答可以附带来源,便于审计和人工复核。
  • 模型参数不动,更适合快速迭代。

但 RAG 也不是银弹。它解决的是“让模型拿到更相关外部信息”的问题,不直接解决推理能力不足、工具调用失败或业务流程编排等其他问题。

先建立一张总览图:RAG 系统到底由哪些环节构成

一个最小可用的 RAG 系统,通常可以拆成 7 个步骤:

  1. 文档采集与清洗
  2. 文档切块与元数据设计
  3. 文本向量化
  4. 索引构建与召回
  5. 重排序与上下文组装
  6. 提示词模板与回答生成
  7. 离线评测与线上回流

如果你把这七步都建成清晰的模块,后续做性能和质量优化时就会容易很多。因为 RAG 系统里“答不好”的原因往往不止一个,而是不同环节叠加出来的。

先看一张分层图,会更容易把“离线建库”和“在线问答”区分开:

RAG 系统分层流程图 上层为离线知识库构建,包括文档采集、清洗标准化、切块元数据和向量索引;下层为在线问答,包括用户问题、查询改写、召回、重排序、上下文组装和生成回答,召回阶段依赖上层索引。
  <text x="48" y="64" font-size="22" font-weight="700">离线知识库构建</text>
  <text x="48" y="226" font-size="22" font-weight="700">在线问答执行</text>

  <g>
    <rect x="46" y="84" width="170" height="58" rx="16" fill="#e8f1ff" stroke="#98b7e1" />
    <text x="131" y="118" text-anchor="middle" font-size="19" font-weight="700">文档采集</text>
  </g>
  <g>
    <rect x="238" y="84" width="170" height="58" rx="16" fill="#eef6e8" stroke="#a8c48e" />
    <text x="323" y="112" text-anchor="middle" font-size="19" font-weight="700">清洗标准化</text>
    <text x="323" y="133" text-anchor="middle" font-size="13" fill="#4b5563">去噪 / 去重 / 标题层级</text>
  </g>
  <g>
    <rect x="430" y="84" width="170" height="58" rx="16" fill="#fff4dc" stroke="#e2c36f" />
    <text x="515" y="112" text-anchor="middle" font-size="19" font-weight="700">切块与元数据</text>
    <text x="515" y="133" text-anchor="middle" font-size="13" fill="#4b5563">chunk / overlap / source</text>
  </g>
  <g>
    <rect x="622" y="84" width="312" height="58" rx="16" fill="#f4ebff" stroke="#c7afe8" />
    <text x="778" y="112" text-anchor="middle" font-size="19" font-weight="700">向量化与索引构建</text>
    <text x="778" y="133" text-anchor="middle" font-size="13" fill="#4b5563">embedding model + vector index</text>
  </g>

  <line x1="216" y1="113" x2="238" y2="113" stroke="#5b6b7f" stroke-width="3" marker-end="url(#rag-arrow)" />
  <line x1="408" y1="113" x2="430" y2="113" stroke="#5b6b7f" stroke-width="3" marker-end="url(#rag-arrow)" />
  <line x1="600" y1="113" x2="622" y2="113" stroke="#5b6b7f" stroke-width="3" marker-end="url(#rag-arrow)" />

  <g>
    <rect x="36" y="256" width="130" height="64" rx="16" fill="#e8f1ff" stroke="#98b7e1" />
    <text x="101" y="293" text-anchor="middle" font-size="18" font-weight="700">用户问题</text>
  </g>
  <g>
    <rect x="180" y="256" width="130" height="64" rx="16" fill="#fef3c7" stroke="#e2c36f" />
    <text x="245" y="285" text-anchor="middle" font-size="18" font-weight="700">Query 改写</text>
    <text x="245" y="306" text-anchor="middle" font-size="12" fill="#4b5563">可选,但常有效</text>
  </g>
  <g>
    <rect x="324" y="256" width="130" height="64" rx="16" fill="#e0f2e5" stroke="#8bc09b" />
    <text x="389" y="293" text-anchor="middle" font-size="18" font-weight="700">召回</text>
  </g>
  <g>
    <rect x="468" y="256" width="130" height="64" rx="16" fill="#fce7ef" stroke="#e2a8bd" />
    <text x="533" y="293" text-anchor="middle" font-size="18" font-weight="700">重排序</text>
  </g>
  <g>
    <rect x="612" y="256" width="150" height="64" rx="16" fill="#ece8ff" stroke="#b5abef" />
    <text x="687" y="285" text-anchor="middle" font-size="18" font-weight="700">上下文组装</text>
    <text x="687" y="306" text-anchor="middle" font-size="12" fill="#4b5563">去重 / 排序 / 截断</text>
  </g>
  <g>
    <rect x="776" y="256" width="168" height="64" rx="16" fill="#dff4f0" stroke="#8dc7bd" />
    <text x="860" y="285" text-anchor="middle" font-size="18" font-weight="700">生成回答</text>
    <text x="860" y="306" text-anchor="middle" font-size="12" fill="#4b5563">引用证据 / 输出答案</text>
  </g>

  <line x1="166" y1="288" x2="180" y2="288" stroke="#5b6b7f" stroke-width="3" marker-end="url(#rag-arrow)" />
  <line x1="310" y1="288" x2="324" y2="288" stroke="#5b6b7f" stroke-width="3" marker-end="url(#rag-arrow)" />
  <line x1="454" y1="288" x2="468" y2="288" stroke="#5b6b7f" stroke-width="3" marker-end="url(#rag-arrow)" />
  <line x1="598" y1="288" x2="612" y2="288" stroke="#5b6b7f" stroke-width="3" marker-end="url(#rag-arrow)" />
  <line x1="762" y1="288" x2="776" y2="288" stroke="#5b6b7f" stroke-width="3" marker-end="url(#rag-arrow)" />

  <line x1="778" y1="142" x2="389" y2="256" stroke="#5b6b7f" stroke-width="3" marker-end="url(#rag-arrow)" />
  <text x="670" y="214" font-size="14" fill="#5b6b7f">索引提供候选片段</text>
</g>
RAG 的关键不是“把文档丢进向量库”,而是把离线建库和在线问答拆成可独立优化的模块。

第一步:文档不是直接扔进向量库就行

很多 RAG 原型失败,是因为第一步就太粗糙。真实文档通常包含:

  • 页眉页脚
  • 导航菜单
  • 重复声明
  • 失真的表格与图片占位
  • 无意义的版权信息

如果这些噪声直接进入索引,就会带来两个问题:

  1. 高相似度检索结果里混入低价值片段。
  2. 模型拿到的上下文表面上很多,真正可用信息却很少。

因此,文档进入知识库前至少要做:

  • 去模板噪声
  • 去重
  • 标准化标题层级
  • 保留来源、章节、时间等元数据

RAG 的第一层质量控制,不是在召回阶段,而是在数据入口。

第二步:切块策略会直接决定召回上限

切块是 RAG 系统里最容易被低估的一步。过大的 chunk 会把不相关信息绑在一起,过小的 chunk 又会让语义断裂。

一个实用的判断框架是:

  • 事实密集型文档:chunk 可以稍小,减少混入无关内容。
  • 流程说明型文档:chunk 要尽量保持步骤完整。
  • 长论文或规范文档:优先按标题层级切,再做长度约束。

实际设计时,你通常要同时决定:

  • chunk_size
  • chunk_overlap
  • 是否保留标题上下文
  • 是否按段落、标题、表格做结构化切分

经验上,最稳定的方式不是“固定按字数硬切”,而是“先尽量保持语义边界,再做长度约束”。

第三步:元数据设计比很多人想的更重要

RAG 常被理解成“文本向量检索”,但实际高质量系统几乎都会依赖元数据过滤。常见元数据包括:

  • 文档来源
  • 文档类型
  • 更新时间
  • 业务部门
  • 权限范围
  • 标题路径
  • chunk 所属章节

元数据的作用不只是给前端展示来源,它还会直接影响检索策略:

  • 先按权限过滤,再做召回
  • 先按时间窗口过滤,再检索最新资料
  • 先限定文档类型,再在小范围内做 dense retrieval

如果没有元数据,很多检索错误你几乎无从修正。

第四步:向量数据库怎么选

第一次做 RAG 时,不必把精力全部花在数据库选型上,但你至少要知道不同方案的侧重点:

本地原型型

例如 FAISS 一类方案,优点是简单、快、无外部依赖,特别适合先验证召回逻辑。

业务系统嵌入型

例如 pgvector 这类直接融入已有数据库生态的方案,适合中小规模系统、简化运维。

独立向量服务型

例如专门的向量数据库或检索服务,更适合高并发、多集合、多租户或复杂过滤需求。

真正的选择标准通常不是“谁最流行”,而是:

  • 数据规模有多大
  • 是否需要权限控制
  • 是否已有现成数据库体系
  • 团队能接受多复杂的运维成本

第五步:召回策略不是只有 dense retrieval

很多人第一次做 RAG,会直接把问题编码成向量,然后做相似度搜索。这当然可以作为起点,但它并不总是最优。

常见召回策略大致有四类:

1. 稠密向量召回

适合语义匹配、问法改写、同义表达等场景。

2. 关键词召回

适合术语、编号、命令、产品名、法规条款等高度词面敏感的查询。

3. 混合召回

把关键词召回和向量召回结合,通常会更稳,尤其适合企业知识库这类查询分布复杂的场景。

4. 查询改写 / 多路召回

先让模型改写问题、补充同义词或拆成多个子查询,再做召回。这种方式对复杂问题尤其有效,但也会增加延迟和系统复杂度。

RAG 的召回层最值得记住的一句话是:

不同问题的最佳召回方式不一样,统一一种检索很容易把系统做窄。

第六步:重排序决定“取回来”和“排前面”的差距

召回拿回来的 top-k 文档,并不等于最终最适合进入模型上下文的 top-k 文档。因为召回阶段往往更偏高覆盖率,而不是最终排序精度。

这时重排序器的价值就出来了。它通常会对“问题 + 候选 chunk”做更细的相关性判断,把真正最相关的片段排到前面。

重排序能解决的典型问题包括:

  • dense retrieval 把语义相近但不回答问题的片段排得太前
  • 多个 chunk 都和问题有关,但关键证据被埋在后面
  • 检索结果分散在多个近似文档中,缺少核心答案块

对 RAG 系统来说,重排序往往是比“换一个更大模型”更划算的质量提升点。

第七步:上下文组装不是“把 top-k 全塞进去”

很多 RAG 初版系统都有一个习惯动作:检索出前 5 或前 10 个 chunk,然后直接拼接给模型。这样做的问题是:

  • 上下文可能过长
  • 多个 chunk 内容重复
  • 关键证据被长篇无关信息稀释

这和 Lost in the Middle 讨论的现象高度相关。长上下文不等于高利用率,证据排布方式会显著影响模型是否真的用到它。

更合理的上下文组装通常会考虑:

  • 去重与去相似
  • 按文档或章节聚合
  • 把最关键证据放前面
  • 限制总 token 数
  • 为每段内容附上来源标签

RAG 系统做得好不好,常常取决于“给模型看什么、按什么顺序看”,而不是只看检索分数。

一个最小 RAG 原型代码

下面给一份足够说明流程的最小 Python 风格示例。它不追求工业级完备,但能帮助你把“切块 -> 向量化 -> 检索 -> 组装 prompt”串起来。

import faiss
import numpy as np
from sentence_transformers import SentenceTransformer

chunks = [
    {"id": "doc1#0", "text": "FlashAttention 通过分块和在线 softmax 降低 attention 的 IO 开销。", "source": "paper-a"},
    {"id": "doc2#0", "text": "PagedAttention 通过分页式管理 KV Cache 减少显存碎片。", "source": "paper-b"},
    {"id": "doc3#0", "text": "RAG 系统通常包含召回、重排序和上下文组装三个关键环节。", "source": "internal-note"},
]

embedder = SentenceTransformer("BAAI/bge-small-zh-v1.5")
embeddings = embedder.encode(
    [item["text"] for item in chunks],
    normalize_embeddings=True,
).astype("float32")

index = faiss.IndexFlatIP(embeddings.shape[1])
index.add(embeddings)

def retrieve(query, top_k=3):
    q = embedder.encode([query], normalize_embeddings=True).astype("float32")
    scores, ids = index.search(q, top_k)
    return [chunks[i] for i in ids[0]]

query = "什么是 RAG 里的重排序,它解决什么问题?"
results = retrieve(query, top_k=2)

context = "\n\n".join(
    f"[来源: {item['source']}]\n{item['text']}"
    for item in results
)

prompt = f"""请基于给定资料回答问题。

资料:
{context}

问题:{query}

要求:
1. 只基于资料回答
2. 若资料不足,请明确说明
3. 输出时保留来源说明
"""

这段代码的重点不是具体模型名,而是你已经能看见完整主线:

  1. 准备 chunk
  2. 建索引
  3. 检索
  4. 拼上下文
  5. 再交给生成模型

生成阶段的提示词应该怎么写

RAG 的提示词通常比普通问答更强调边界和证据。一个更稳的模板,通常要明确写出:

  • 只能依据给定材料回答
  • 材料不足时要承认不足
  • 尽量引用来源
  • 对多来源冲突要说明差异,而不是擅自融合

你可以把它理解成:RAG prompt 的目标不是“让模型更会说”,而是“让模型更少乱补”。

如果你准备进一步系统化这部分提示词设计,可以继续看 Prompt Engineering 系统指南

如何评测一个 RAG 系统

RAG 评测不能只看最终答案质量,因为错误可能来自不同层。一个更完整的评测框架通常至少包含:

检索层指标

  • recall@k
  • hit rate
  • top-k 是否包含正确证据

排序层指标

  • 重排序后关键 chunk 是否更靠前
  • 无关 chunk 是否被压后

生成层指标

  • 回答是否正确
  • 是否引用了真实证据
  • 是否在证据不足时诚实降级

系统层指标

  • 召回延迟
  • 总响应时间
  • 成本
  • 长文档场景下的稳定性

如果你把这些问题全混成“用户感觉好不好”,你很难知道优化应该落在哪一层。

一个实用的排障顺序

当 RAG 效果不佳时,建议按下面顺序排查:

  1. 检索结果里有没有正确证据。
  2. 如果有,正确证据是否排得足够靠前。
  3. 如果也有,组装后的上下文是否被噪声稀释。
  4. 如果仍然不行,提示词是否明确要求基于证据回答。
  5. 最后才问:是不是模型本身不够强。

这个顺序很重要,因为很多团队会直接把问题归咎于“模型不够大”,而实际上召回层就已经错了。

常见误区

1. 把 RAG 当作“万能补脑”

如果问题需要复杂规划、工具操作或多步决策,单纯检索文档并不能解决全部问题。

2. 只做向量召回,不做元数据过滤

企业场景里,权限、时间和文档类型往往同样关键。

3. 只追求 top-k 越大越好

检索更多文档不一定更准,反而可能让模型更难抓住关键证据。

4. 没有独立评测集

没有固定问题集和失败样本库,你很难判断一次调整到底是真的改进,还是只是偶然。

练习与思考题

  1. 为什么说高质量切块策略本质上是在给召回上限打地基?
  2. 在什么场景下,混合检索会比单纯向量检索更稳?
  3. 为什么“正确证据在 top-k 里”并不等于“最终回答就一定正确”?
  4. 如果系统经常把相关但不直接回答问题的 chunk 排到前面,你会优先优化哪一层?

延伸阅读

配套模拟器

先看原理,再到模拟器里调参验证,学习效果更稳定。