跳转至

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