RAG 入门:从能回答到答得有依据
RAG 不是“给模型塞一堆文档”的同义词,而是一套围绕检索、上下文拼装和回答约束展开的系统设计。
01.RAG 真正解决的不是“模型不够聪明”
很多团队第一次做知识库问答时,会先尝试两种办法:
- •把大段文档直接贴进 prompt
- •指望模型“自己知道公司内部知识”
这两条路很快都会碰到天花板。
原因通常不是模型不够强,而是系统没有解决三个基础问题:
- •文档太长,没法每次都完整塞进上下文
- •知识会更新,模型参数里不可能实时同步
- •即使模型会回答,也未必能指出答案来自哪里
RAG 的价值就在这里。它不是让模型“学会更多”,而是让模型在回答前,先从外部知识源里拿到与当前问题最相关的上下文。
所以更准确地说,RAG 解决的是“基于私有或最新知识进行有依据回答”的问题。
02.一个最小 RAG 系统长什么样
不管你选什么框架,最小 RAG 系统都绕不开下面四层:
1. 内容层
内容层决定你到底拿什么给模型用。常见来源包括:
- •Markdown 文档
- •产品说明书
- •FAQ
- •工单记录
- •内部知识库页面
这一步最容易被低估。内容源本身如果结构混乱、重复严重、术语不统一,后面再怎么调模型都只是救火。
2. 索引层
索引层负责把原始内容变成“可检索”的形式,通常包括:
- •切块
- •提取元数据
- •生成 embedding
- •存储到向量库或混合检索系统
索引不是一次性工作。只要内容持续更新,索引就必须有同步机制。
3. 检索层
检索层的职责不是“找最多内容”,而是“找最相关且可用的内容”。
这里常见的动作有:
- •向量召回
- •关键词召回
- •元数据过滤
- •重排
如果检索质量差,后面的模型只能在错误上下文上“认真胡说”。
4. 生成层
模型的工作不是重新发明知识,而是:
- •阅读检索到的片段
- •组织答案
- •在信息不足时明确说明
- •必要时附上引用
这一步要靠 prompt 约束回答范围,不能把所有正确性都寄托在模型自觉上。
03.切块为什么比很多人想象中更重要
RAG 入门最常见的做法,是把文档按固定字数切成块。这个方法可以工作,但效果经常一般,因为它没有考虑内容边界。
比如一篇故障排查文档里,可能包含:
- •背景
- •配置项说明
- •错误码解释
- •恢复步骤
如果简单按 500 字切块,就很容易把“错误码定义”切在上一块,把“恢复步骤”切在下一块。用户问的是“出现 502 怎么恢复”,结果召回时只拿到一半信息。
更靠谱的原则是:
- •优先按自然结构切块,例如标题、段落、列表、代码块
- •让每个 chunk 尽量表达一个完整语义单元
- •给 chunk 保留来源、标题、标签等元数据
下面是一个更贴近实际的切块骨架:
from dataclasses import dataclass
@dataclass
class Chunk:
id: str
title: str
content: str
source: str
tags: list[str]
def split_by_sections(markdown: str, source: str) -> list[Chunk]:
sections = []
current_title = "未命名小节"
current_lines: list[str] = []
index = 0
for line in markdown.splitlines():
if line.startswith("## "):
if current_lines:
sections.append(
Chunk(
id=f"{source}-{index}",
title=current_title,
content="\n".join(current_lines).strip(),
source=source,
tags=[],
)
)
index += 1
current_lines = []
current_title = line.removeprefix("## ").strip()
continue
current_lines.append(line)
if current_lines:
sections.append(
Chunk(
id=f"{source}-{index}",
title=current_title,
content="\n".join(current_lines).strip(),
source=source,
tags=[],
)
)
return [chunk for chunk in sections if chunk.content]这个例子不复杂,但它传达了一个重要原则:切块首先是内容建模问题,其次才是向量化问题。
04.一个适合个人项目的最小实现
如果你只是做个人站点搜索、知识问答助手或内部文档答疑,完全没必要一开始就上复杂架构。下面这个组合已经足够起步:
- •内容源:Markdown 或数据库正文
- •切块:按标题和段落切块
- •embedding:统一模型生成向量
- •检索:向量召回 + 元数据过滤
- •生成:要求模型只基于命中片段回答
示意代码如下:
from openai import OpenAI
client = OpenAI()
def embed_texts(texts: list[str]) -> list[list[float]]:
response = client.embeddings.create(
model="text-embedding-3-small",
input=texts,
)
return [item.embedding for item in response.data]
def build_prompt(question: str, contexts: list[str]) -> str:
context_block = "\n\n---\n\n".join(contexts)
return f"""
你是文档问答助手,只能依据提供的上下文回答。
如果上下文不足,请明确说“当前资料不足以回答这个问题”。
上下文:
{context_block}
问题:{question}
"""
def answer_question(question: str, retrieve) -> str:
retrieved_docs = retrieve(question, top_k=4)
contexts = [doc["content"] for doc in retrieved_docs]
response = client.responses.create(
model="gpt-4.1-mini",
input=build_prompt(question, contexts),
)
return response.output_text真正关键的不是这几十行代码,而是 retrieve() 做得怎么样。一个实际可用的 retrieve(),通常至少要支持:
- •过滤内容范围,例如产品线、语言、版本号
- •去掉明显重复的 chunk
- •把命中结果按相关性重新排序
- •把来源信息一起返回,便于回答时引用
05.RAG 常见失败,不一定是模型的问题
失败一:召回到了“相关词”,没召回到“相关答案”
用户问“如何回滚发布失败的任务”,召回结果却全是“发布系统介绍”。这通常说明:
- •切块太粗
- •元数据不够
- •只做向量召回,没有做关键词补充
对这类问题,往往要先修检索,而不是先换更贵的模型。
失败二:上下文太多,模型抓不到重点
很多人觉得“多给一点文档总没坏处”,结果恰恰相反。相关内容和无关内容混在一起,会让回答变散,甚至忽略关键条件。
一个实用原则是:上下文要追求“刚好够用”,而不是“越多越安全”。
失败三:知识本身质量不高
如果源文档里版本混杂、规则冲突、标题乱写,RAG 只能把这些问题更稳定地暴露出来。
所以做 RAG 之前,值得先问一句:现在的问题到底是“模型不会答”,还是“文档本来就不好用”。
06.什么时候该做 RAG,什么时候先别做
适合做 RAG:
- •有明确的私有知识源
- •回答必须基于最新资料
- •需要可追溯出处
- •问题相对聚焦,能通过检索缩小范围
先别做 RAG:
- •内容量很小,直接整理成 FAQ 更简单
- •用户问题高度开放,检索很难界定上下文
- •资料本身质量差,尚未完成清洗
- •还没想清楚怎么评估回答是否更好
如果只是几篇静态文档,先把信息架构整理清楚,比急着上向量库更划算。
07.做 RAG 时至少要盯住的三个指标
RAG 不是“能跑起来”就结束。最起码要观察:
- •命中率:检索结果里是否真的包含答案
- •回答可用率:用户是否能直接拿答案做事
- •引用准确率:引用的来源是否和答案一致
这三项里,第一项是根。检索没命中,后面再怎么调 prompt 都只是补救。
08.总结
RAG 的核心不是“向量数据库”这几个字,而是三件事:
- •让知识可被稳定检索
- •让模型只基于相关上下文回答
- •让整个系统能被持续更新和评估
如果你从内容结构、切块策略和检索质量开始搭,而不是一上来就迷信模型参数,RAG 项目通常会走得更稳,也更接近真实工程落地。