RAG 落地实践:那些文档里不会写的坑
把 RAG 从 demo 做到生产,真正的难点几乎都不在向量检索本身,而在数据、切分与评估。
几乎每个做 AI 应用的团队都会经历这样一个阶段:用一个下午跑通 RAG 的 demo,然后花三个月把它做成能上线的产品。
这篇文章记录我在实践中踩过的坑,以及最后沉淀下来的一些判断。
Demo 与生产的距离
Demo 的隐含假设是:知识库是干净的、问题是清晰的、答案是存在的。
而生产环境的真实情况通常是:
- 文档格式混乱,PDF 里有双栏、表格、页眉页脚
- 同一件事在五个地方有五种说法,且互相矛盾
- 用户问的问题根本不在知识库里
- 有些问题需要跨多个文档推理才能回答
每一条都会让”检索准确率”这个指标失去意义。
切分策略比模型选择更重要
我见过太多团队在纠结用哪个 Embedding 模型,却把 chunk_size 设成 512 就再也没动过。
实际上,切分决定了检索的基本盘。一份被拦腰截断的文档,换什么模型都救不回来。
几个我验证过有效的做法:
- 按语义边界切,而不是按字符数切。优先在标题、段落、列表项之间断开。
- 保留上下文头。给每个 chunk 加上它所属的章节标题,检索时会带来明显提升。
- 重叠是必要的。10%–15% 的重叠能显著降低”答案正好卡在边界上”的概率。
- 表格单独处理。不要指望通用切分器能正确理解表格。
def split_by_heading(text: str, max_tokens: int = 700) -> list[dict]:
"""按标题切分,并把标题作为上下文头附加到每个片段。"""
chunks, current, heading = [], [], None
for line in text.splitlines():
if line.startswith('#'):
if current:
chunks.append({"heading": heading, "text": "\n".join(current)})
current = []
heading = line.lstrip('# ').strip()
current.append(line)
if current:
chunks.append({"heading": heading, "text": "\n".join(current)})
return chunks
检索之后还有一道关
很多人把 RAG 理解成”检索 → 塞进 Prompt → 生成”,但中间其实少了一步:筛选。
召回 20 个片段,不代表这 20 个都有用。把它们全部塞进去,除了浪费 token,还会引入噪声干扰模型判断。
我的做法是两段式:
- 粗排:向量检索召回 top-20,追求召回率
- 精排:用 cross-encoder 或 LLM 打分,选出 top-3~5,追求准确率
好的 RAG 系统不是”检索得更多”,而是”检索得更准”。
评估:最难也最容易被跳过的一环
没有评估,你所有的优化都是玄学。
我建议从第一天就建立一个小而具体的评估集:
| 类型 | 数量 | 作用 |
|---|---|---|
| 事实型问题 | 30 | 验证基础检索能力 |
| 多跳问题 | 15 | 验证跨文档推理 |
| 无答案问题 | 15 | 验证系统会不会胡编 |
| 边界问题 | 10 | 验证异常输入的处理 |
“无答案问题”这一列尤其重要。一个不会说”我不知道”的 RAG 系统,比一个承认自己不知道的系统危险得多。
我的结论
如果只能记住一句话,那就是:
RAG 的质量上限由数据质量决定,而不是由模型决定。
把 80% 的精力花在数据清洗、切分和评估上,剩下的 20% 再考虑换模型。这个比例听起来很反直觉,但实践下来几乎总是对的。