RAG 系统设计清单:不要只把文档塞进向量库

0阅读13分钟

一次让我重新审视 RAG 的线上故障

去年年底,我负责的一个内部知识库项目上线了。技术栈很"标准":LangChain + OpenAI Embedding + Pinecone,文档按 512 token 固定长度切块,用户提问后做 top-5 向量检索,拼进 prompt 交给 GPT-4 回答。

Demo 演示那天,一切顺利。直到正式上线第二周,客服团队反馈:用户问「A 产品的退款政策是什么」,系统抓回来的是 B 产品的保修条款。更离谱的是,有人问「你们支持对公转账吗」,返回了一段公司内部 hr 手册里关于工资发放的段落。

问题不在模型。模型拿到错误的材料,当然会给出错误的回答。真正的问题出在切块把「退款」和「保修」压进了相似的向量空间,而向量检索对「对公转账」这种精确业务术语几乎无感——它找的是语义距离最近的段落,而不是用户真正需要的段落。

这次事故让我意识到一件事:大多数团队搭建 RAG 的方式,本质上是在做一个「看起来很像检索」的余弦相似度搜索,然后指望 LLM 把它变成一个「真正的检索系统」。中间差的东西,比多数教程愿意承认的多得多。

这篇文章就是一份从实战中沉淀出来的设计清单。不是教你搭一个能跑的 Demo,而是帮你判断:你的 RAG 系统到底缺了什么。

RAG 到底在解决什么问题

Retrieval-Augmented Generation 这个概念最早由 Meta 的 Lewis 等人在 2020 年提出,核心思路并不复杂:先从外部知识源检索相关文档,再把检索结果作为上下文注入 LLM 的 prompt,让模型基于真实材料回答问题,而不是纯粹依赖参数记忆。

这个思路之所以在 2023 年之后爆发,是因为它同时缓解了 LLM 的几个核心缺陷:

  • 幻觉问题:模型不再需要凭空编造,而是有据可依。
  • 知识时效:不用每次更新知识就重新微调,换文档就行。
  • 可溯源性:回答可以附带引用来源,用户能回看原文。
  • 领域适配:不需要微调模型,只需要建好领域索引。

但 RAG 的本质是一个信息检索系统,上面套了一层生成模型。检索质量决定了生成的天花板——你给模型看什么,它就只能在那个范围内回答。如果检索环节出了问题,再强的生成模型也只是在错误材料上做漂亮的文字重组。

Kapa.ai 的 CTO 在一次技术分享中说过一句很实在的话:「大多数团队能跑到 70% 的效果,然后永远卡住上不了生产。」问题通常不在生成端,而在检索端——数据没清洗好、切块太粗暴、没有混合检索、缺少重排。

数据流的七个环节

一个生产级 RAG 系统可以按数据流拆成七个环节。每个环节都有各自的坑,后面的检查清单会逐项展开。

流程图画布 · 115%
Mermaid 流程图加载中...

下面逐个拆解。

1. 数据摄取:垃圾进,垃圾出

这一步最容易被跳过。很多团队直接从 S3 拉文件、扔进解析器就完事了。但文档质量参差不齐:PDF 里夹着扫描图片、Word 表格被拆成散乱文本、HTML 残留一堆导航栏内容。

我的经验是,摄取阶段至少要做好三件事:

  • 来源追踪:每篇文档记录原始 URL、上传时间、所有者、权限等级。
  • 格式清洗:用 Unstructured.io 或 LlamaParse 做结构化提取,别自己写正则。
  • 元数据提取:标题、章节层级、文档类型、更新时间——这些后面要做过滤条件。

2. 切分:决定检索质量的关键环节

切块策略对检索质量的影响,远比多数人想象的大。Firecrawl 在 2026 年初做了一个覆盖 7 种切分策略的对比测试,发现不同方法之间的召回率差距最高可达 9 个百分点。

下表是目前主流的切分策略对比:

切分策略原理优势劣势适用场景
固定长度切分按固定 token/字符数切割,支持滑动窗口重叠实现简单,处理速度快会切断句子和语义边界原型验证、内容均匀的场景
递归字符切分按段落 → 行 → 空格 → 字符的优先级逐级切分保留文档结构,召回率稳定在 88-89%需要理解内容结构80% 的通用文本场景的默认选择
句子级切分用 NLP 检测句子边界,按句分组不会切断句子,适合对话内容块大小不均匀聊天日志、客服对话、Q&A 内容
页级切分以文档页为单位保留页面布局和表格完整性仅适用于分页文档PDF、PPT、财务报表
语义切分计算相邻句子的 embedding 相似度,在阈值处切割召回率比递归切分高 2-3 个百分点计算成本高,每篇文档需要数百次 embedding 调用对准确率要求高、预算充足的场景
LLM 辅助切分用 LLM 分析文档结构,识别逻辑切分点能处理非标准文档结构成本高、延迟大高价值文档、实验性项目
延迟切分 (Late Chunking)先对整篇文档做 transformer 编码,再按边界切分并平均池化每个块的 embedding 携带全文上下文依赖长上下文模型,目前仅 Jina 有生产级实现法律合同、学术论文等跨段落引用多的文档

Chroma 的研究团队用 text-embedding-3-large 做的基准测试显示,递归字符切分在 400-token 块大小下能达到 88-89% 的召回率,是性价比最高的默认选项。语义切分虽然理论上限更高,但一篇 1 万字的文档仅切分阶段就需要 200-300 次 embedding API 调用,成本不容忽视。

下面对比两种切分策略的代码实现:

# 固定长度切分 — 简单但粗暴
def fixed_size_chunk(text: str, chunk_size: int = 512, overlap: int = 50):
    chunks = []
    start = 0
    while start < len(text):
        end = start + chunk_size
        chunks.append(text[start:end])
        start = end - overlap
    return chunks
# 问题:可能在句子中间切断,丢失语义完整性
# 递归字符切分 — 推荐的默认策略
from langchain_text_splitters import RecursiveCharacterTextSplitter
 
splitter = RecursiveCharacterTextSplitter(
    chunk_size=512,
    chunk_overlap=50,
    separators=["\n\n", "\n", "。", "!", "?", " ", ""],
)
chunks = splitter.split_text(text)
# 优先在段落、句子边界切分,保留结构
# 语义切分 — 精度更高,成本也更高
from langchain_experimental.text_splitter import SemanticChunker
from langchain_openai import OpenAIEmbeddings
 
embeddings = OpenAIEmbeddings()
semantic_splitter = SemanticChunker(
    embeddings,
    breakpoint_threshold_type="percentile",
    breakpoint_threshold_amount=95,
)
chunks = semantic_splitter.split_text(text)
# 在语义变化点切分,但每句都需要一次 embedding 调用

一个容易忽略的优化是给每个 chunk 加上上下文前缀。Orkes 的工程博客建议在 embedding 之前,给每段文本前面拼上 50-100 token 的上下文描述(比如所属文档标题、章节名),这能显著提升检索准确率。原理很直观:一段孤立文本的 embedding 可能跟用户问题距离很远,但如果前面加了「本文档是关于 A 产品退款政策的说明」,检索命中率会高很多。

3. 索引:不要只建向量索引

向量索引是 RAG 的标配,但只用向量索引是一个常见错误。向量检索擅长模糊语义匹配,但对精确术语(产品编号、专有名词、错误代码)几乎无效。

一个可靠的索引方案应该同时维护三种索引:

  • 向量索引:用于语义相似度检索,处理用户模糊意图。
  • 关键词索引:BM25 或 TF-IDF,用于精确匹配产品名、编号、术语。
  • 结构化字段:文档类型、时间范围、权限等级、来源可信度——这些用来做前置过滤。

实际检索时,先走结构化字段过滤(权限、时间、范围),再并行跑向量检索和关键词检索,最后用 Reciprocal Rank Fusion(RRF)合并结果。这就是所谓的混合检索(Hybrid Search)。

# 纯向量检索 — 语义好,但精确匹配差
def vector_only_search(query: str, top_k: int = 10):
    query_embedding = embed(query)
    return vector_db.similarity_search(query_embedding, k=top_k)
# 混合检索 — 向量 + BM25 + RRF 融合
def hybrid_search(query: str, top_k: int = 10, filters: dict = None):
    # 结构化过滤:权限、时间范围
    pre_filtered = vector_db.filter(filters)
 
    # 并行两路召回
    vector_results = pre_filtered.similarity_search(embed(query), k=top_k * 2)
    bm25_results = bm25_index.search(query, k=top_k * 2)
 
    # Reciprocal Rank Fusion 合并
    fused = reciprocal_rank_fusion(vector_results, bm25_results, k=60)
    return fused[:top_k]

4. 检索:从用户问题到正确的材料

用户提问和文档表述之间经常存在 gap。用户说「怎么退款」,文档写的是「退款流程说明」或「Return Policy」。这种表述差异会导致向量检索遗漏相关材料。

Galileo AI 的架构文章提到了一个实用的解决方案:查询变换(Query Transformation)。在真正检索之前,先用 LLM 对用户问题做改写或拆解:

# 查询改写 — 把用户问题扩展为多个检索视角
def transform_query(user_query: str) -> list[str]:
    prompt = f"""请将以下用户问题改写为 3 个不同角度的检索查询,
    用于从知识库中检索相关文档:
 
    用户问题:{user_query}
 
    输出 3 个改写后的查询,每行一个:"""
 
    response = llm.invoke(prompt)
    return response.text.strip().split("\n")
 
# 多查询并行检索 + 合并
queries = transform_query("怎么退款")
# => ["退款流程说明", "商品退货操作步骤", "Return Policy and Refund Process"]
results = merge_unique([search(q) for q in queries])

不过要注意,多查询扩展不是免费的。Galileo 的建议是先用单查询做 baseline,确认瓶颈确实在召回率上,再引入多查询。否则你只是在花更多的钱和延迟,换来了一个并不更好的结果。

5. 重排:检索之后的精排

检索阶段为了不漏掉候选材料,通常会返回比最终需要更多的结果(比如 top-20)。重排(Reranking)的任务是从这 20 个候选中挑出最相关的 top-5。

重排方式原理延迟效果适用场景
不重排直接使用检索分数排序最低一般原型验证
Cross-encoder 重排用 cross-encoder 模型对 query-doc 对打分中等大多数生产场景
LLM 重排用 LLM 判断每篇文档与问题的相关性很好高价值场景
规则重排按元数据(时效、来源可信度)调整排序取决于规则质量有明确业务规则的场景

Cohere Rerank 3.5 是目前用得最多的 cross-encoder 重排模型。Orkes 的实践中,默认先检索 top-10,重排后取 top-5 送入 LLM。这个 10→5 的比例在生产中被验证为一个不错的平衡点。

6. 生成:让模型基于材料回答

生成环节的核心原则是「 grounded generation」——模型必须基于检索到的材料回答,而不是自由发挥。Prompt 中需要明确约束:

generation_prompt = f"""基于以下参考材料回答用户问题。
 
规则:
1. 只使用参考材料中的信息回答
2. 如果材料不足以回答问题,明确说明「根据现有资料无法回答」
3. 回答中引用具体来源编号,如 [1]、[2]
4. 不要编造材料中没有的内容
 
参考材料:
{formatted_context}
 
用户问题:{user_query}"""

这个 prompt 模板看起来简单,但在实际使用中,模型仍然可能在材料不足时「脑补」答案。应对方式有两种:一是降低模型的 temperature(推荐 0.0-0.2),二是在输出后做一层自动校验——检查回答中提到的关键实体是否确实出现在参考材料中。

7. 反馈闭环:被忽视的最后一公里

大多数 RAG 教程到「生成回答」就结束了。但在生产系统中,你需要知道用户是否真的采纳了这个答案。

反馈信号至少包括:

  • 采纳:用户没有继续追问,直接结束了对话。
  • 追问:用户换了个说法再问一遍——说明第一次的回答没有满足需求。
  • 纠错:用户明确标记了「回答有误」——这是最有价值的负反馈。
  • 引用点击:用户是否点击查看了引用来源——说明回答引起了兴趣但不完全信任。

这些信号需要写入 trace,跟 query、召回片段、重排分数、最终上下文和模型输出一起,构成完整的可观测链路。没有这些,RAG 系统的问题几乎不可能定位。

三个真实场景的设计取舍

理论讲完了,来看几个具体场景下 RAG 的设计差异。

场景一:企业内部知识库

这是最常见的 RAG 应用。文档来源包括 Confluence、飞书文档、内部 wiki、技术文档。特点是文档质量参差不齐、更新频率不一、权限要求严格。

这个场景下,最重要的不是检索算法有多先进,而是数据治理。我在前面提到的那次线上故障,根因是 hr 手册和技术文档被扔进了同一个向量空间,没有任何权限隔离。后来我们加了三层过滤:文档类型标签、部门权限等级、有效期校验,问题才解决。

另一个教训是不要一上来就把所有文档都索引。Kapa.ai 的建议很务实:先挑少量「主要文档」覆盖 80% 的高频问题,跑通流程后再逐步加入「次要文档」处理长尾问题。

场景二:电商客服问答

用户会问「XX 型号支持防水吗」「这个包能装 15 寸笔记本吗」这类精确的产品属性问题。向量检索对这类问题的命中率很差——因为「防水」和「IP68 等级」在语义空间中可能距离很远,但用户问的是同一件事。

这个场景下,纯向量检索不够用。你需要:

  • 对产品属性做结构化索引(品牌、型号、参数),作为 metadata filter。
  • 关键词检索(BM25)处理型号名和专有术语。
  • 混合检索 + 重排,确保精确匹配和语义匹配都能覆盖到。

场景三:法律合同审查

律师需要检索合同中的特定条款,比如「竞业限制」「违约金计算方式」。法律文档的特点是篇幅长、条款之间有大量交叉引用。第 12 条提到的「前述约定」可能指向第 3 条的定义。

这个场景下,标准切块策略会出问题——因为切块切断了交叉引用。Jina AI 提出的 Late Chunking 在这里有用武之地:先对整篇合同做 transformer 编码,让每个 token 的 embedding 都携带全文上下文信息,然后再按边界切分。在他们的基准测试中,Late Chunking 在 NFCorpus 数据集上的 nDCG@10 指标比朴素切分高了 6.5 个百分点。

不过 Late Chunking 目前的生产实现选择有限,Jina 的 jina-embeddings-v3 是主要选项,上下文窗口限制在 8192 token(大约 10 页)。超长合同需要先做分段处理。

架构选型:你不需要最复杂的那个

2026 年的 RAG 生态里有很多架构模式可选。Data Engineer Things 博客在一篇文章中提出了一个很好的决策框架:你的架构选择应该取决于「回答错误的代价」,而不是「技术有多新」。

架构模式复杂度适用场景回答错误代价
Naive RAG(单次检索 + 生成)内部文档问答、容错率高的场景
Advanced RAG(查询变换 + 混合检索 + 重排)客服系统、产品知识库
GraphRAG(知识图谱 + 多跳推理)需要跨实体推理的复杂问答
Agentic RAG(自主控制循环 + 工具调用)很高需要动态决策的复杂工作流很高
Self-RAG(反思 token + 自适应检索)需要模型自判断是否需要检索的场景中-高

Naive RAG 能覆盖 70% 的场景。如果你不确定该用什么架构,从 Naive RAG 开始,用真实用户反馈来驱动迭代。过早引入复杂架构只会增加维护成本和调试难度。

Galileo AI 总结的五阶段生产架构是一个好的中间方案:查询变换 → 并行多路检索 → 混合融合 → Cross-encoder 重排 → Grounded 生成。它在复杂度和效果之间取了一个不错的平衡。

评估:不要只看「回答质量」

RAG 系统的评估需要把检索和生成拆开看。Galileo AI 提出了五个核心指标:

指标含义关注点
Context Relevance检索到的材料与问题的相关度检索质量
Context Recall回答所需的信息是否都被检索到了召回完整性
Faithfulness回答是否忠于检索到的材料幻觉控制
Chunk Attribution能否追踪到具体哪个 chunk 影响了回答可溯源性
Completeness回答是否覆盖了问题的所有方面生成完整性

评估工具方面,Ragas 和 DeepEval 是常用的开源选项,但它们依赖 LLM-as-Judge 的方式,评分一致性不稳定。Kapa.ai 的建议是构建自己的评估框架:确定性检查 + 人工标注的 ground truth + 定期人工抽检。如果追求更快的迭代,ARES 框架用合成数据微调一个小模型做评估器,是一个折中方案。

设计检查清单

这是我每次设计或审查 RAG 系统时会过一遍的清单。不需要全部满足,但每一项都值得想一想。

数据质量

  • 每篇文档是否有明确的来源、所有者、更新时间和权限等级?
  • 是否做了格式清洗和结构化提取,而不是直接扔原始文件?
  • 是否优先索引了少量高质量的核心文档,而不是一上来就全量索引?
  • 过期文档是否有自动失效或降权机制?

切分策略

  • 切分是否保留了文档的标题层级、表格和上下文关系?
  • 是否给每个 chunk 加了上下文前缀(所属文档标题、章节信息)?
  • 是否评估过不同切分策略在你自己数据上的召回率差异,而不是默认用 512 token 固定长度?
  • 对于有交叉引用的文档,是否考虑了 Late Chunking 或其他保留上下文的策略?

检索与重排

  • 是否同时维护了向量索引和关键词索引,而不是只做向量检索?
  • 是否用结构化字段(权限、时间、文档类型)做了前置过滤?
  • 是否引入了重排环节(cross-encoder 或规则重排),而不是直接把检索结果送入生成?
  • 对于表述模糊的用户查询,是否考虑了查询变换或多查询扩展?

生成与反馈

  • Prompt 是否明确要求模型「基于材料回答,材料不足时明确说明」?
  • 回答是否附带了来源引用,用户可以回看原文?
  • 是否记录了完整的 trace(query、召回片段、重排分数、上下文、模型输出)?
  • 是否收集了用户反馈信号(采纳、追问、纠错、引用点击)并用于迭代优化?

写在最后

RAG 系统的核心不是向量库,不是 embedding 模型,也不是 prompt 模板。它是一个完整的信息检索系统,只不过最后一步从传统的「返回结果列表」变成了「让 LLM 基于检索结果生成回答」。

如果你把精力都花在调 prompt 上,而忽略了数据清洗、切分策略、混合检索和反馈闭环,那你只是在装饰天花板,而地基是歪的。

从 Naive RAG 开始,用真实用户反馈驱动迭代。每个环节的优化都应该有数据支撑——你知道哪里出了问题,才去修哪里。

参考资料

评论 0

0 / 1000