RAG 是 Retrieval-Augmented Generation 的缩写,2020 年由 Meta 提出,如今已经是给大模型接私域知识最主流的做法。这篇从最朴素的实现开始,一步步演化到工业界常用的形态。
RAG 是一种在生成前先做检索、把检索到的相关片段拼进提示词、再让模型生成回复的方法。
它解决的问题是:模型的参数里只固化了训练截止日之前的公开知识,但真实业务里你要问的是公司内部文档、昨天的日志、客户的资料。这些内容模型不可能知道,光靠 prompt 全塞进去又会超上下文、贵、慢。
RAG 的做法是:把海量知识存到外部索引里,每次提问只捞出最相关的几段拼进 prompt,让模型基于这几段回答。
三段结构
一个 RAG 系统跑起来分三步:
- 检索(Retrieval):把用户问题变成一个查询,从索引里捞出相关的若干片段。
- 增强(Augmentation):把这些片段拼进 prompt,形成新的上下文。
- 生成(Generation):模型基于这个上下文生成回复。
看起来平淡,但每一步都有工程细节可以磨。
最朴素的版本
先看一个能跑但很粗糙的实现。假设你有一堆技术文档想让 LLM 能答:
def naive_rag(question: str, docs: list[str]) -> str:
context = "\n\n".join(docs)
prompt = f"根据以下资料回答问题:\n\n{context}\n\n问题:{question}"
return llm.chat(prompt)这版有两个问题:
- docs 全塞进去,超上下文只是时间问题。
- 没有相关性判断,无关内容也一起进 prompt,模型会被带偏。
加上向量检索
第一次真正的 RAG 长这样:
def basic_rag(question: str, index) -> str:
query_vec = embed(question)
chunks = index.search(query_vec, top_k=5)
context = "\n\n".join(c.text for c in chunks)
prompt = f"根据以下资料回答问题:\n\n{context}\n\n问题:{question}"
return llm.chat(prompt)绿色的两行是关键变化:把问题转成向量,从向量索引里捞出语义最接近的 5 段。
要让 index.search 能用,事先要做一遍索引构建:
def build_index(docs: list[str], index):
for doc in docs:
for chunk in split(doc, size=500, overlap=50):
vec = embed(chunk)
index.add(vec, chunk)高亮那行就是 chunk 的核心:把长文档切成 500 字左右、有 50 字重叠的小段。切法直接决定检索质量。
为什么要 chunk
如果不切,直接把整篇文档作为一个向量:
- 一个向量要表达一整篇文档的全部语义,信息被压平。
- 检索命中时返回整篇文档,token 消耗爆炸。
如果切得太细(比如每句话一个 chunk):
- 上下文被砍碎,检索到的句子脱离原语境。
- 索引膨胀,查询变慢。
工程上 500~1000 字是常见起点,配合 10%~20% 的 overlap 防止关键句被切在边界上。文档结构清晰时按标题切效果更好,非结构化文本才用固定长度。
检索的两条路
向量检索不是唯一选项。实际系统里往往两种一起用:
| 方法 | 擅长 | 短板 |
|---|---|---|
| 向量检索(Embedding) | 语义相近但用词不同的匹配 | 精确关键词、专有名词 |
| 关键词检索(BM25) | 精确匹配、专有名词、代码符号 | 同义改写、语义泛化 |
一个纯向量的系统查“K8s 的 pod 重启策略”,可能捞回一堆讲“容器编排”的段落但漏掉直接讲 pod 的那篇。反过来纯 BM25 又会漏掉说“kubernetes 中 pod 的 restart policy”的段落。
工业界的做法是混合检索:两种各捞一批,合并后统一打分。
加上 Rerank
再进一步,是在检索之后加一层重排:
def rag_with_rerank(question: str, index) -> str:
query_vec = embed(question)
candidates = index.search(query_vec, top_k=30)
scored = reranker.score(question, [c.text for c in candidates])
chunks = [c for c, _ in sorted(zip(candidates, scored), key=lambda x: -x[1])[:5]]
context = "\n\n".join(c.text for c in chunks)
prompt = f"根据以下资料回答问题:\n\n{context}\n\n问题:{question}"
return llm.chat(prompt)Reranker 是一个专门的小模型(通常是 cross-encoder),它把问题和每个候选段落一起喂进去,输出一个精确的相关性分数。它比向量检索准,但慢,只适合处理少量候选。
标准套路是:向量检索先粗筛 30 个,rerank 精排出前 5 个。这个组合能显著提升命中质量。
常见的坑
chunk 切在句子中间。用固定长度切时,容易把一句话从中间劈开。解决办法是切分时对齐到句号、换行等分隔符,长度作为上限而不是硬边界。
embedding 模型和领域不匹配。用通用 embedding 处理专业术语(法律、医疗、代码)时相似度会失真。要么用领域 embedding,要么在关键词检索上补足。
问题和文档表达差异大。用户问“密码忘了怎么办”,文档里写的是“账户恢复流程”。这种时候可以先让 LLM 把用户问题改写或扩写成几种变体,分别检索再合并(HyDE、Query Rewriting)。
只看召回,不看生成。检索命中不等于回答正确。模型可能没看懂片段,可能被无关片段带偏,可能片段之间矛盾。评估 RAG 要同时看检索指标(recall、MRR)和生成指标(答对没答对、有没有幻觉)。
忘了权限。多租户系统里 RAG 一定要在检索层就做隔离,否则 A 用户可能问出 B 用户的文档。这是 RAG 系统最常见的安全事故来源。
什么时候用 RAG
合适的场景:
- 知识量大、频繁更新,塞不下上下文,也不适合微调。
- 需要引用出处、需要审计。RAG 天然能返回“这段回答来自哪个文档”。
- 私域数据不能进训练集,只能挂外部索引。
不太合适的场景:
- 问题不需要外部知识,直接问模型更快。
- 知识极少(一两页 PDF),塞进 prompt 更简单可靠。
- 问题需要跨文档推理和综合。RAG 擅长找相关片段,不擅长把 10 个片段拼成一个新结论。这种场景考虑 Agent 或 GraphRAG。
和其他方案的边界
| 方案 | 适用场景 |
|---|---|
| Long Context | 知识量能塞进上下文,且愿意为长 prompt 付费 |
| Fine-tuning | 想改变模型的风格、格式、思维路径 |
| RAG | 想让模型使用外部、可更新、需要引用的知识 |
| Agent + 工具 | 知识存在结构化系统里(数据库、API),需要主动查询 |
它们不互斥,实际系统里经常同时存在。
一些工程经验
- 先用最朴素的版本跑起来。向量库 + top-k,不加 rerank、不加 hybrid,先看命中率能到哪。看到具体错例再决定加什么。
- 给模型看到检索片段的机会,也给模型说“没找到”的机会。prompt 里明确写“如果资料里没有相关信息就说不知道”,能大幅降低幻觉。
- 保留来源。每个 chunk 存好文档 id 和位置,回答时能标注来源。用户信任度和调试效率都会提升。
- 评估不能只靠人眼。搭一个小的评测集,问题 + 期望答案 + 期望命中的文档,每次改动都跑一遍,防止改一处坏三处。
- 别过早追新技术。GraphRAG、多跳检索、agentic retrieval 在特定场景确实有效,但基础没打牢时上这些只会让问题更难定位。
写在最后
RAG 的技术门槛不高,做出一个能跑的原型只要几十行代码。但把它做准、快、稳、可维护,是另一件事。真正拉开差距的是数据处理(怎么切、怎么清洗)、检索策略(混合、rerank、改写)、以及评估体系。
模型每一代都在变强,RAG 的角色也在调整。上下文变长后有人喊“RAG 死了”,但只要私域数据、审计需求、成本约束存在,RAG 就还有位置。它不是终极方案,只是一个务实的中间态。
