RAG:检索增强生成
旅游助手的训练数据截止到某个日期。用户问"杭州茶叶博物馆这周开门吗",模型只能猜。它没有实时数据,也没有你的私有文档。
RAG 的思路很直:先从外部文档库检索相关片段,把片段塞进 prompt,让模型基于检索到的内容回答。
这一页讲最基础的 RAG 流程:加载文档 → 切块 → 嵌入 → 存入向量库 → 检索 → 生成。不涉及循环、不涉及 Agent。后面的 Retrieval Loop 和 Agentic RAG 是在这个基础上加控制流。
一句话
RAG 把"模型凭记忆回答"变成"先查资料再回答",让回答有外部依据。
它修什么失败
| 问题 | 表面看起来 | 实际风险 |
|---|---|---|
| 模型凭记忆回答 | 像是知道的 | 训练数据过期或从未包含这些信息 |
| 没有引用来源 | 回答自信 | 无法追溯依据 |
| 私有知识不在模型里 | 回答泛泛 | 公司文档、用户偏好查不到 |
它引入什么复杂度
| 谁 | 负责什么 |
|---|---|
| 文档加载器 | 读取原始文档 |
| 切块器(chunker) | 把文档切成适合检索的片段 |
| 嵌入模型 / 索引 | 把片段变成可检索的表示 |
| 检索器 | 根据 query 返回 top-k 片段 |
| 生成模型 | 基于检索到的片段回答 |
| Python | 编排整个流程 |
和其他模式的关系
flowchart LR
RAG["RAG(一次检索)"] -->|"一次不够"| RL["Retrieval Loop"]
RL -->|"需要多种工具"| ARAG["Agentic RAG"]
RAG -.->|"长报告"| STM["STORM"]
RAG -.->|"事实验证"| COVE["CoVe"]
- Retrieval Loop:RAG 只查一次;如果 query 不好或证据不够,需要 Retrieval Loop 的重查循环。
- Agentic RAG:检索只是 Agent 的一个工具,Agent 还能做其他动作。
- STORM:要写长文章,按章节分别检索和写作。
- CoVe:检索到的内容不一定对,事实断言仍需验证。
完整实现:旅游知识库 RAG
"""rag_travel.py — 基础 RAG:加载 → 切块 → 内存向量库 → 检索 → 生成"""
from __future__ import annotations
import math
from dataclasses import dataclass, field
# ── 极简运行时 ──────────────────────────────────────────
@dataclass(frozen=True)
class Message:
role: str
content: str
class MockLLM:
def __init__(self, responses: list[str]):
self._responses = list(responses)
self._idx = 0
def complete(self, messages: list[Message]) -> str:
if self._idx >= len(self._responses):
raise RuntimeError("MockLLM 回复用完了")
out = self._responses[self._idx]
self._idx += 1
return out
# ── 文档和切块 ──────────────────────────────────────────
@dataclass(frozen=True)
class Document:
doc_id: str
text: str
@dataclass(frozen=True)
class Chunk:
doc_id: str
chunk_id: str
text: str
def load_documents() -> list[Document]:
"""模拟加载旅游知识库。"""
return [
Document(
doc_id="tea_museum",
text=(
"中国茶叶博物馆位于杭州龙井路 88 号,"
"开放时间 9:00-17:00(16:30 停止入馆),"
"每周一闭馆(法定节假日除外),"
"免费参观,凭身份证入馆。"
"馆内设有茶史厅、茶萃厅、茶事厅、茶缘厅。"
),
),
Document(
doc_id="west_lake",
text=(
"西湖位于杭州市中心,全天开放,免费。"
"主要景点包括断桥残雪、苏堤春晓、三潭印月。"
"环湖一圈约 10 公里,步行约 3 小时。"
"游船票价 55 元/人(三潭印月上岛)。"
),
),
Document(
doc_id="hefang_street",
text=(
"河坊街是杭州的历史文化街区,位于吴山脚下。"
"以小吃和手工艺品闻名,全天开放。"
"推荐:定胜糕、龙须糖、吴山烤禽。"
"从西湖步行约 15 分钟可达。"
),
),
]
def chunk_documents(docs: list[Document], max_chars: int = 120) -> list[Chunk]:
"""简单切块:按句号分割,合并不超过 max_chars 的连续句子。"""
chunks: list[Chunk] = []
for doc in docs:
sentences = [s.strip() for s in doc.text.split("。") if s.strip()]
buf = ""
chunk_idx = 0
for sent in sentences:
candidate = buf + sent + "。" if buf else sent + "。"
if len(candidate) > max_chars and buf:
chunks.append(Chunk(
doc_id=doc.doc_id,
chunk_id=f"{doc.doc_id}_{chunk_idx}",
text=buf,
))
chunk_idx += 1
buf = sent + "。"
else:
buf = candidate
if buf:
chunks.append(Chunk(
doc_id=doc.doc_id,
chunk_id=f"{doc.doc_id}_{chunk_idx}",
text=buf,
))
return chunks
# ── 内存向量库(用词频代替嵌入,演示用) ──────────────
def _tokenize(text: str) -> list[str]:
"""极简分词:按字符切,小写,只保留字母数字和中文。"""
tokens: list[str] = []
buf = ""
for ch in text:
if ch.isalnum() or '\u4e00' <= ch <= '\u9fff':
buf += ch.lower()
else:
if buf:
tokens.append(buf)
buf = ""
if buf:
tokens.append(buf)
return tokens
@dataclass
class VectorStore:
"""用 TF 评分的内存向量库。生产环境替换为真正的嵌入 + 向量数据库。"""
chunks: list[Chunk] = field(default_factory=list)
def add(self, chunks: list[Chunk]) -> None:
self.chunks.extend(chunks)
def search(self, query: str, k: int = 3) -> list[tuple[Chunk, float]]:
query_tokens = _tokenize(query)
if not query_tokens:
return []
scored: list[tuple[Chunk, float]] = []
for chunk in self.chunks:
chunk_tokens = _tokenize(chunk.text)
score = sum(1 for qt in query_tokens for ct in chunk_tokens if qt == ct)
if score > 0:
scored.append((chunk, float(score)))
scored.sort(key=lambda x: x[1], reverse=True)
return scored[:k]
# ── RAG 主流程 ──────────────────────────────────────────
def rag_answer(
model: MockLLM,
question: str,
store: VectorStore,
k: int = 3,
) -> str:
"""
基础 RAG:
1. 用 question 检索 top-k 片段
2. 把片段塞进 prompt
3. 模型基于片段回答
"""
# 检索
results = store.search(question, k=k)
print(f"[检索] query='{question}', 命中 {len(results)} 条:")
context_parts: list[str] = []
for chunk, score in results:
print(f" [{chunk.chunk_id}] score={score:.0f} | {chunk.text[:60]}...")
context_parts.append(f"[{chunk.chunk_id}] {chunk.text}")
context = "\n\n".join(context_parts)
# 生成
answer = model.complete([
Message(
role="system",
content="根据提供的参考资料回答问题。引用来源用 [chunk_id]。如果资料不足以回答,说明不确定。",
),
Message(
role="user",
content=f"问题:{question}\n\n参考资料:\n{context}",
),
])
return answer
# ── 运行 ────────────────────────────────────────────────
def main() -> None:
# 1. 加载文档
docs = load_documents()
print(f"[加载] {len(docs)} 份文档")
# 2. 切块
chunks = chunk_documents(docs, max_chars=120)
print(f"[切块] {len(chunks)} 个片段")
# 3. 存入向量库
store = VectorStore()
store.add(chunks)
# 4. 检索 + 生成
model = MockLLM([
(
"中国茶叶博物馆免费参观,凭身份证入馆,开放时间 9:00-17:00,"
"每周一闭馆。[tea_museum_0]"
),
])
question = "茶叶博物馆要门票吗?几点开门?"
answer = rag_answer(model, question, store, k=3)
print(f"\n回答:\n{answer}")
if __name__ == "__main__":
main()
运行日志
[加载] 3 份文档
[切块] 5 个片段
[检索] query='茶叶博物馆要门票吗?几点开门?', 命中 3 条:
[tea_museum_0] score=3 | 中国茶叶博物馆位于杭州龙井路 88 号,开放时间 9:00-17:00(16:30 停止入馆),每周一闭馆...
[tea_museum_1] score=1 | 馆内设有茶史厅、茶萃厅、茶事厅、茶缘厅。...
[hefang_street_0] score=1 | 河坊街是杭州的历史文化街区,位于吴山脚下。以小吃和手工艺品闻名,全天开放。...
回答:
中国茶叶博物馆免费参观,凭身份证入馆,开放时间 9:00-17:00,每周一闭馆。[tea_museum_0]
调试:trace 里看什么
| 要看的 | 怎么看 | 说明什么 |
|---|---|---|
| 检索命中数 | len(results) |
0 命中说明 query 或索引有问题 |
| top-1 相关性 | 人工读 top-1 片段 | 如果 top-1 不相关,query 需要改写 |
| 是否引用了来源 | 回答里有 [chunk_id] 吗 |
没有引用说明 prompt 模板有问题 |
| 回答是否超出检索内容 | 回答包含检索片段里没有的信息 | 说明模型在用训练数据补充,可能幻觉 |
| 切块质量 | 读几个 chunk | 切太碎丢语义,切太大浪费 context |
工程笔记
成本公式
索引构建 = n_docs × 切块 + n_chunks × 嵌入
每次查询 = 1 × 嵌入(query) + top-k 检索 + 1 × 生成
瓶颈通常在生成(模型调用),不在检索。
RAG 流程拆解
| 步骤 | 做什么 | 常见选项 |
|---|---|---|
| 文档加载 | 读原始数据 | 文件、数据库、API |
| 切块 | 拆成检索单位 | 按句子、段落、固定 token 数、语义边界 |
| 嵌入 | 转成向量 | OpenAI embeddings、本地模型、TF-IDF |
| 存储 | 写入可检索的索引 | 内存列表、FAISS、Pinecone、pgvector |
| 检索 | 按 query 返回 top-k | 余弦相似度、BM25、混合 |
| 生成 | 基于 context 回答 | 带引用的 prompt 模板 |
常见坑
| 坑 | 现象 | 修法 |
|---|---|---|
| query 太宽泛 | 检索结果不相关 | query 改写、HyDE |
| 切块太碎 | 丢失上下文 | 加重叠窗口或按段落切 |
| 切块太大 | 浪费 context window | 按语义边界切,控制 token 数 |
| 检索注入 | 文档内容指示模型忽略规则 | 把检索内容当不可信输入 |
| 没有引用 | 不知道答案来自哪里 | prompt 要求引用 chunk_id |
| context 窗口溢出 | 塞太多片段 | 控制 top-k 或 summarize |
什么时候用
- 回答需要外部知识。
- 你有文档库可以索引。
- 一次检索通常够用。
什么时候别用
- 问题需要多轮检索;那是 Retrieval Loop。
- 检索只是多种工具之一;那是 Agentic RAG。
- 回答不需要外部知识。
读完以后
RAG 是检索增强的基线。
如果一次检索不够,读 Retrieval Loop。 如果检索要跟其他工具混用,读 Agentic RAG。 如果事实断言需要逐条核实,读 CoVe。